3 Commits
Author SHA1 Message Date
threenebula23 2191545629 feat(v3): TCP connection, named-file requests, 3-tier send games, rebalanced shop, Golos UI and pixel icons
CI / tests (push) Successful in 37s
2026-10-26 02:04:47 +03:00
threenebula23 9cb983a9b5 merge: Nikita v2 baseline (shop, polyomino grid, sorting table, 11 minigames) 2026-10-25 23:14:40 +03:00
Nikita 39846421bc feat(game): PEAK HOUR v2 - shop, polyomino grid, sorting table, 11 minigames
- 8 lots / 3 offers / rerolls / locks; start 50 USD, SLA 60/60, 1 slot
- 5x4..8x6 polyomino grid, reservations, 4x6 sorting table, save v2 + v1 migration
- 7 send minigames + 4 attack minigames without timers, dense B4 selection
- attacks as minigames, shield, SLA regen/defeat, 20 attacks per run
- balance simulator 240 seeds: all hours within +/-20%, PASS
- UI v2, old attack cards/connection strip removed
- tests: 26/26 PASS, docs v2.0
2026-10-25 21:38:18 +03:00
130 changed files with 29956 additions and 8195 deletions

No files matched your search

+125 -45
View File
@@ -1,4 +1,4 @@
# AI_REPORT — PEAK HOUR 97
# AI_REPORT — PEAK HOUR 97 (v3.0)
## 1. Инструменты и модели
@@ -13,66 +13,146 @@ test-writer, explorer, feis-regulator, oracle):
| **MiMo-V2.6-Flash** | аудио: SFX, ambient, фон |
| дефолтные модели субагентов | поиск, тесты, ревью |
MR-ревью — агент **oracle** (`opencode-go/deepseek-v4.1-flash`): dry-run
MR-ревью — агент **oracle** (opencode-go/deepseek-v4.1-flash): dry-run
74–90 с, комментарий в MR, без мержа.
## 2. Где применялся ИИ
## 2. Что изменилось в v3.0
- **Код:** `GameController`, `HourDirector`, `AttackDirector`, `Shop`,
`SaveManager`, UI, мини-игры.
- **UI v2:** Kaleidos, маскот **БАЙТ**, туториал.
- **Ambient:** 4 WAV (`audio/amb`, PSU/fan/room/seek), генератор
`generate_ambient.py`, autoload после первого жеста, mute/пауза/громкость.
- **VHS:** процедурный `vhs_overlay.gd` (`RefCounted`) в `MainMenu._draw`;
скан-линии/зерно/виньетка, без shader/screen texture, ввод не блокирует,
текст резкий; только меню, чекбокс в настройках, HEADLESS без рендера.
- **Баланс/мета:** SLA, 10 апгрейдов, таймеры, канал, мета 3/5/9.
- **Аудит:** calm-фон, Esc итогов, уничтожение, тосты/fullscreen.
- **Тесты/доки/звук:** unit/fuzz/интеграция/полный забег; `docs/`;
18 SFX + `hum.wav` на Python.
- **Запросы:** 1–4 **конкретных именованных файла** вместо анонимных слотов;
имена на карточке 2×2, прикрепление по точному `file_id`, «ПОТЕРЯН» для
пропавшего файла, жёсткая резервация. Новые `request_mix` H1–H9 и
`request_cap`; H1-затравка и анти-голодание.
- **TCP:** возвращена полоска соединения (3 попадания, 440/340/280 px/с,
формула зоны, промах −4 SLA / 8 с, Esc 0 SLA / 4 с). TCP платит ×1.15,
UDP — ×0.85 и передаёт ×0.8.
- **Мини-игры:** 6 отправки + 4 атаки; игра с соединением проводов удалена;
у отправки 3 ступени (старые T2–T4), у атак 4. Выбор — по сумме
коэффициентов и таблице `rank_games` на 27 достижимых сумм.
- **Магазин:** 8 лотов, дерево **8 085 $**, SLA до **220**, ускорение ×0.90,
щит с 70 % награды, папки до 9×6, новая папка 180 $.
- **UI:** Golos Text для тела текста, Press Start 2P только для логотипа и
цифр; шкала 11/13/17/26; пиксельные иконки `art/icons/`; карточка запроса
400×128 с сеткой файлов и прогнозом; пустое состояние отправки; «СЛОТЫ».
- **Туториал:** 5 шагов с событийными триггерами, пульсирующие рамки,
«ДАЛЕЕ», сохранение `tutorial_done`, повтор из главного меню.
- **Документация:** GDD/DECISIONS/README/ITCH_PAGE/UI_REFRESH/ASSETS
переписаны под фактические числа v3; старые решения помечены, а не удалены.
## 3. Приём: «враждебный критик»
## 3. Где применялся ИИ
- **Код:** HourDirector, FileRegistry, Economy, Shop, AttackDirector,
SaveManager, UI, соединение TCP и мини-игры.
- **UI v3:** Golos Text, пиксельные иконки, карточка запроса, туториал с
рамками, VHS главного меню.
- **Ambient:** 4 WAV (audio/amb), генератор generate_ambient.py, autoload
после первого жеста, mute/пауза/громкость.
- **Баланс:** цели дохода, дерево магазина 8 085 $, симулятор на 240 сидах.
- **Аудит:** B4 (дыры в выборе мини-игры) закрыт ещё в v2; в v3 выбор
перестроен на 27 сумм и 3 ступени. M8 (масштаб денег атак), M16
(soft-lock «нет денег/нет места») сохранены.
- **Тесты/доки/звук:** unit/fuzz/интеграция/полный забег; docs/;
18 SFX + hum.wav на Python.
## 4. Приём: «враждебный критик»
Промпт просил быть враждебным критиком: «найди блокеры, из-за которых
прототип нельзя собрать; не хвали». Нашлось **6 блокеров**. Один из них:
выбор мини-игры по самому сложному типу ломал смешанные запросы. Заменили на
средний коэффициент:
прототип нельзя собрать; не хвали». Главный дефект v2 — **B4**: выбор
мини-игры по `S−1` давал дыры и недостижимые строки. В v3 выбор перестроен:
```
difficulty = sum(coeff(required)) / n
coeff = {text: 1.0, sound: 2.0, photo: 3.0, video: 4.0}
```
ACHIEVABLE_SUMS = [1..22, 24, 25, 26, 28, 32] # 27 сумм для 1–4 файлов
rank = index of S in ACHIEVABLE_SUMS
tier = T1 при rank 0–7, T2 при 8–13, T3 при 14–26
id = RANK_GAMES[rank] # фиксированная таблица
Это изменило §8.4 и §10 GDD и решение D17; пороги `<1.5 / <2.5 / <3.5`
зафиксированы в `data/balance.json` и проверяются в `test_minigame.gd`.
Все 6 игр достижимы в каждой из 3 ступеней; S=32 → video/T3. Дополнительно
закрыты: H1-голодание запросов (анти-голодание + seed-файл), потерянные
required-файлы (подстановка того же типа), неоднозначность TCP-протокола
(блокировка после соединения). M16 (бесплатное уничтожение неприкреплённого
файла) сохранён, но файл, закреплённый за запросом, удалить нельзя.
## 4. Что сделано без ИИ
## 5. Что сделано без ИИ
Идея, логлайн, исходная спецификация, ключевые решения (1997 год, 9 часов,
4 атаки, экономика, тон 90-х) и финальная приёмка — человеком, автором
проекта. ИИ не придумывал игру: он помогал формулировать, писать код,
считать баланс и искать противоречия.
## 5. Ассеты и лицензии
## 6. Ассеты и лицензии
- **Handjet** и **Press Start 2P** — SIL OFL 1.1, тексты в
`art/fonts/OFL-Handjet.txt` и `OFL-PressStart2P.txt`.
- Pixelify Sans в теме не используется (нет глифов `О`/`П`); файл и OFL
оставлены в `art/fonts/`.
- UI, иконки и `icon.svg` — команда проекта, MIT.
- SFX, ambient и `hum.wav` — скрипты проекта, MIT/CC0.
- Код — MIT (`LICENSE`); OFL-шрифты совместимы с MIT при сохранении
лицензий. Подробности — в `ASSETS.md`.
- **Golos Text** — SIL OFL 1.1 (`art/fonts/OFL-GolosText.txt`), основной
шрифт UI с v3.
- **Press Start 2P** — SIL OFL 1.1 (`art/fonts/OFL-PressStart2P.txt`), только
логотип и цифры.
- **Handjet** — SIL OFL 1.1, с v3 в теме не используется и помечен как
legacy.
- Pixelify Sans в теме не используется (нет глифов О/П); файл и OFL
оставлены в art/fonts/.
- Пиксельные иконки `art/icons/*.png` сгенерированы `generate_icons.py`
(Pillow), команда проекта, **MIT**; исходный тайл-лист был недоступен,
поэтому набор перегенерирован с нуля.
- UI, маскот **БАЙТ** и icon.svg — команда проекта, MIT.
- SFX, ambient и hum.wav — скрипты проекта, MIT/CC0.
- Код — MIT (LICENSE); OFL-шрифты совместимы с MIT при сохранении лицензий.
Подробности — в ASSETS.md.
## 6. Проверки и ограничения
## 7. Проверки v3.0
- **22/22 PASS** (21 тест + parse). Core 543, hour 133, shop 116, minigame
51, slots 34, audio 37; fuzz 3249, integration 231, attacks 173,
controller 143.
- `test_full_playthrough` — 294 на сидах `1/42/777`: 3/3 victory, 9/9,
SLA 200; main 350, toast 14, fullscreen 31, ui 135, ambient 74.
- Web-экспорт — exit 0; Chromium **1920×1080**: меню → «Новый забег» →
подготовка, JS-ошибок нет.
- **Симулятор баланса:** `python tests/sim_balance.py`, 240 сидов, 9 часов,
отчёт `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.
Ограничения: туториал — маскот и подсказки; web-save — IndexedDB; плейтест,
громкость и масштаб — за человеком; remote push ещё не выполнен.
### Финальная проверка 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` выплаты фиксируется на старте отправки, пока таймер был заморожен
мини-игрой; быстрый игрок видит ≈1.0.
- Лимит папок 6, новая папка 180 $.
- Бесплатное уничтожение любого неприкреплённого файла в подготовке
(review M16); файл, закреплённый за запросом, не удаляется.
- Деньги атак масштабируются по часу `max(0.75, hour/3)` (review M8).
- H2 теоретически может выдать T2-запрос из трёх фото (S12); на практике
кривая H2 остаётся почти полностью T1.
- Расход магазина 57,9 % — внутри целевой полосы; это не «полный выкуп».
## 9. Ограничения
Туториал автоматически запускается только в первом часу первого забега;
повтор — из главного меню. Web-save — IndexedDB; плейтест, громкость и
масштаб — за человеком; remote push ещё не выполнен. Мета-ключ `search`
остаётся в данных, но UI-поиск не подключён. Скриншоты в `docs/screenshots/`
стоит пересобрать под v3 перед публикацией страницы.
+67 -25
View File
@@ -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.
## Чего в проекте нет
+127 -67
View File
@@ -2,56 +2,70 @@
> Час пик. Одна смена. Ни одного свободного места.
Ночной диспетчер узла файлового обмена, 1997 год. Принимайте пакеты,
раскладывайте файлы, собирайте запросы клиентов, держите канал и SLA — в
интерфейсе, вдохновлённом Kaleidos: тёплая бумага, тонкие контуры и
бирюзовый акцент.
1997 год. Ночной диспетчер узла файлового обмена: принимайте пакеты,
раскладывайте файлы-полимино по папкам, собирайте контейнеры запросов,
проходите мини-игры и держите SLA. UI — «Kaleidos»: тёплая бумага, тонкие
контуры и бирюзовый акцент.
**Жанр:** менеджмент / тайм-менеджмент, retro UI.
**Платформа:** Godot **4.7.2** → **Web (HTML5, без threads)**, RU-only.
**Окно:** 1920×1080, логический viewport 1280×720, fractional scale.
**Забег:** 9 игровых часов по 5 минут (≈45 минут) + подготовка между часами.
## Статус v1.2
## Статус v3.0
Играбельный веб-прототип полного цикла: **меню → подготовка → час → итоги
часа → … → итоги забега**. Девять часов проходят на сидах `1`, `42`, `777`
без потери часов; SLA после магазина — 200.
часа → … → итоги забега**. Актуальная спецификация — [GDD v3.0](docs/GDD.md),
решения — [DECISIONS](docs/DECISIONS.md) (актуальны D45–D52, D01–D44 — история).
Реализовано:
- 4 типа файлов (text/sound/photo/video), буфер приёма, папки и секции.
- **10 апгрейдов** магазина, включая `SLA +24`; магазин открывается с часа 2
и скрыт во время часа.
- TCP-слоты и полоска соединения: три попадания; промах закрывает панель
без автоповтора.
- 4 атаки, но **час 1 проходит без атак**; junk/ddos — с часа 2,
phishing — с 3, ransomware — с 4.
- **SLA 128 / 200** на старте; потеря пакета: text 2 / sound 4 / photo 8 /
video 16.
- Мета-награды **постепенно по часам**: поиск после 3-го часа, щит после
5-го, `complete_run` после 9-го; итоги показывают «ОТКРЫТО: …», прогресс
живёт между забегами. `minigame_advanced` — резерв под будущий контент.
- Поиск по имени в текущей папке после открытия; заголовок «N ИЗ M ОБЪЕКТОВ».
- Автообновление папки/дерева, уникальные имена папок (`Имя`, `Имя 2`),
удаление пустых папок, attached-галочки, скролл приёма до 8 пакетов.
- «Уничтожить содержимое» папки удаляет файлы с подтверждением и −5 SLA;
пустая папка удаляется без штрафа.
- «Спокойный фон» — плоская бумага без dither; Esc на итогах забега ведёт
в главное меню.
- UI v2 «Kaleidos», маскот **БАЙТ** и короткий туториал первого часа.
- Ambient машинного зала: 4 WAV, автозапуск после первого жеста, mute/пауза/
громкость; генератор `audio/generate_ambient.py`.
- Процедурный VHS только в главном меню (настройки → «ЭКРАН»): скан-линии,
зерно, виньетка и полоса; рисуется кодом, не блокирует ввод, текст резкий.
Игровой UI остаётся чистым, без CRT на весь интерфейс.
- Handjet + Press Start 2P, 18 SFX + фоновый `hum`.
- **9 часов**, старт **50 $**, **SLA 60/60** (кап +40 за уровень `lot_sla`
до **220**), 1 слот (макс. 5), канал 10 (макс. 46), буфер 6 (макс. 12),
таймер запроса 30 с (макс. 65), таймер пакета 20 с.
- **4 типа файлов-полимино:** текст 1, звук 2, фото 3, видео 4 клетки;
поворот `R`/ПКМ во время drag; сетка папки **5×4 → 6×4 → 7×5 → 8×5 →
8×6 → 9×6** (до 54 клеток). Новая папка — **180 $**, всего до 6.
- **Стол сортировки 4×6** в подготовке; файлы со стола нельзя прикрепить,
на старте часа они автоматически возвращаются в папки (если места нет —
час не начинается).
- **Запросы на 1–4 конкретных именованных файла:** имена видны на карточке
сеткой 2×2, прикрепление проверяется по точному `file_id`, потерянный файл
помечается «ПОТЕРЯН», обязательные файлы жёстко зарезервированы. H1
стартует с seed-файла и сразу с запроса на него; анти-голодание не даёт
панели запросов простаивать.
- **8 лотов магазина**; ровно 3 предложения, рероллы 40/80/120 (макс. 3 за
подготовку), замок отдельного предложения. Магазин — с H2 и только в
подготовке. Полное дерево — **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 ступени; атака — это
мини-игра, которая ставит час на паузу. Щит «Заземление» гасит атаку
автоматически (**70 %** награды, без штрафа).
- **Пошаговый туториал v3:** 5 шагов (пакет → прикрепление → TCP-соединение →
мини-игра → SLA/атаки), пульсирующие рамки у цели и кнопка «ДАЛЕЕ».
Прохождение хранится в `Game.stats["tutorial_done"]`; кнопка «ОБУЧЕНИЕ» в
главном меню запускает повтор.
- **Save VERSION 2**: позиции только в `folder.files`/`table`, метаданные в
`files`; v1-сейвы мигрируют без потери файлов и без уменьшения сеток;
офферы/рероллы/замки, стол и `tutorial_done` сохраняются.
- Мета-награды после 3/5/9 часов, маскот **БАЙТ**, UI v3 «Kaleidos», ambient
машинного зала, VHS только в главном меню.
Ограничения:
- Туториал — маскот и несколько подсказок, без пошагового сценария.
- Сохранение в Web идёт через IndexedDB (`user://save.json`); проверяйте
продолжение после перезагрузки вкладки.
- Средний расход магазина в симуляторе — **57,9 %** дерева 8 085 $; это
внутри целевой полосы 55–65 %, то есть не «полный выкуп».
- Remote push ещё не выполнен; сборка для itch.io готовится локально.
## Управление
@@ -60,20 +74,26 @@
| --- | --- |
| Выбор, меню, перетаскивание | ЛКМ |
| Контекстное меню | ПКМ |
| Пакет из «Приёма» → папка; файл → карточка запроса | drag&drop |
| Поиск по имени в текущей папке | после открытия «Поиска» |
| Мини-игра соединения: три попадания | ЛКМ или `Space` |
| Мини-игра подготовки: отправить | `Enter` или кнопка |
| Поворот фигуры во время drag | `R` или ПКМ |
| Пакет из «Приёма» → папка | drag&drop |
| Файл из папки → карточка запроса | drag&drop |
| Файл ↔ стол сортировки | drag&drop |
| Соединение TCP | кнопка `СОЕДИНИТЬСЯ`, 3 попадания в зелёную зону |
| Отправка запроса | кнопка `ОТПРАВИТЬ` |
| TCP/UDP | переключатель на карточке запроса до соединения |
| Мини-игра: действие | ЛКМ / `Space` / стрелки (по игре) |
| `dots`: линия | нажать на «С» и вести курсор по клеткам |
| Пропустить мини-игру | `ПРОПУСТИТЬ`/`СДАТЬСЯ` + второй клик |
| Результат мини-игры | `ПРОДОЛЖИТЬ` или `Esc` |
| Магазин: закрыть | `ЗАКРЫТЬ` или `Esc` |
| Пауза | `Esc` |
| Панель соединения: отмена (слот свободен, запрос остаётся) | `Esc` |
## Запуск
Нужен Godot 4.7.2. В этом окружении:
Нужен Godot **4.7.2**. Команды выполняются из корня репозитория; ниже
`GODOT` — путь к бинарю Godot.
```bash
GODOT=/home/eq/techlb/tools/godot/Godot_v4.7.2-stable_linux.x86_64
# один раз после клона: импорт шрифтов и темы
"$GODOT" --headless --path . --import
@@ -84,6 +104,13 @@ GODOT=/home/eq/techlb/tools/godot/Godot_v4.7.2-stable_linux.x86_64
"$GODOT" -e --path .
```
В Windows удобно задать путь один раз:
```powershell
$env:GODOT_BIN = "D:\Apps\godot\Godot_v4.7.2-stable_win64_console.exe"
& $env:GODOT_BIN --path .
```
## Сборка Web
```bash
@@ -97,20 +124,34 @@ mkdir -p build/web
просмотр — только по HTTP:
```bash
python3 -m http.server 8000 --directory build/web
python -m http.server 8000 --directory build/web
# http://localhost:8000
```
Нужны export templates 4.7.2 (`web_nothreads_release.zip`).
Нужны export templates 4.7.2 (`web_nothreads_release.zip`). Каталог шаблонов
должен называться `4.7.2.stable`.
## Тесты
Полный прогон (21 набор + parse-check):
Актуальный список — **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
powershell -ExecutionPolicy Bypass -File scripts/run_all_tests.ps1
```
```bash
# Linux / macOS
bash scripts/run_all_tests.sh
```
Windows-раннер сам ищет Godot (сначала `GODOT_BIN`, затем стандартный путь,
затем `godot4`/`godot` из `PATH`); путь можно передать явно:
`-GodotBin <path>`.
Точечно:
```bash
@@ -120,32 +161,49 @@ bash scripts/run_all_tests.sh
"$GODOT" --headless --path . --script scripts/ui/ui_smoke.gd
```
Фактический итог: **22/22 PASS** (21 набор + parse-check); ключевые
счётчики — `test_main_prep` 92/0, `ui_smoke` 135/0, `test_ambient` 74/0.
Подробности — в
[AI_REPORT](AI_REPORT.md).
Баланс (240 сидов, отчёт `tests/SIM_BALANCE_REPORT.md`):
```bash
python tests/sim_balance.py
```
Отчёт `tests/SIM_BALANCE_REPORT.md` (240 сидов, 9 часов, `q=0.85`, TCP): все
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 пакета в папку и файла в чип
запроса, открытие модалки соединения.
## Структура
```
peak-hour/
Game-JAM/
├─ project.godot # 1920×1080 / 1280×720, GL Compatibility
├─ export_presets.cfg # Web (no threads)
├─ scenes/ # Main + dev-превью
├─ scenes/ # Main + UI/мини-игры
├─ scripts/
│ ├─ main.gd # UI, ввод, модалки, drag&drop, туториал
│ ├─ game_controller.gd # цикл menu → prep → hour → results
│ ├─ run_all_tests.sh # полный headless-прогон
│ ├─ run_all_tests.ps1/.sh # полный headless-прогон
│ ├─ autoload/ # Game, Data, Audio, Rng, Ambient
│ ├─ core/ # HourDirector, FileRegistry, SlotManager, Economy
│ ├─ entities/ # файлы, пакеты, запросы, папки, слоты
│ ├─ systems/ # атаки, магазин, сохранения
│ ├─ ui/ # Theme90 v2, модалки, маскот, VHS-оверлей
│ └─ minigames/ # ConnectionGame, MiniGame
│ ├─ entities/ # файлы, папки, секции, пакеты, запросы, слоты
│ ├─ systems/ # Shop, AttackDirector, SaveManager
│ ├─ ui/ # Theme90, FileGrid, SortingTable, ShopModal, VHS
│ └─ 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
├─ tests/ # headless-наборы
├─ docs/ # GDD, DECISIONS, UI_REFRESH, screenshots
├─ shaders/ # vhs.gdshader (только главное меню)
├─ tests/ # headless-наборы, sim_balance.py
├─ docs/ # GDD, DECISIONS, UI_REFRESH, скриншоты
└─ build/ # артефакты экспорта (gitignored)
```
@@ -153,23 +211,25 @@ peak-hour/
- Godot **4.7.2** (GL Compatibility).
- Для Web: export templates 4.7.2, `web_nothreads_release.zip`.
- Python 3 — только для пересборки SFX.
- Python 3 — для баланса и пересборки SFX/ambient.
- Никаких секретов и внешних сервисов.
## Документация
- [GDD](docs/GDD.md) — правила и числа v1.2.
- [DECISIONS](docs/DECISIONS.md) — решения D01–D25.
- [GDD](docs/GDD.md) — правила и числа v3.0.
- [DECISIONS](docs/DECISIONS.md) — решения D01–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 и матрица тестов (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).
Binary file not shown.
+36
View File
@@ -0,0 +1,36 @@
[remap]
importer="font_data_dynamic"
type="FontFile"
uid="uid://cwms0uuyam0fk"
path="res://.godot/imported/GolosText-VF.ttf-af72b844d15035464cce46e3ecf9f3ea.fontdata"
[deps]
source_file="res://art/fonts/GolosText-VF.ttf"
dest_files=["res://.godot/imported/GolosText-VF.ttf-af72b844d15035464cce46e3ecf9f3ea.fontdata"]
[params]
Rendering=null
antialiasing=1
generate_mipmaps=false
disable_embedded_bitmaps=true
multichannel_signed_distance_field=false
msdf_pixel_range=8
msdf_size=48
allow_system_fallback=true
force_autohinter=false
modulate_color_glyphs=false
hinting=3
subpixel_positioning=4
keep_rounding_remainders=true
oversampling=0.0
Fallbacks=null
fallbacks=[]
Compress=null
compress=true
preload=[]
language_support={}
script_support={}
opentype_features={}
+93
View File
@@ -0,0 +1,93 @@
Copyright 2019 The Golos Text Project Authors (https://github.com/googlefonts/golos-text)
This Font Software is licensed under the SIL Open Font License, Version 1.1.
This license is copied below, and is also available with a FAQ at:
https://scripts.sil.org/OFL
-----------------------------------------------------------
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
-----------------------------------------------------------
PREAMBLE
The goals of the Open Font License (OFL) are to stimulate worldwide
development of collaborative font projects, to support the font creation
efforts of academic and linguistic communities, and to provide a free and
open framework in which fonts may be shared and improved in partnership
with others.
The OFL allows the licensed fonts to be used, studied, modified and
redistributed freely as long as they are not sold by themselves. The
fonts, including any derivative works, can be bundled, embedded,
redistributed and/or sold with any software provided that any reserved
names are not used by derivative works. The fonts and derivatives,
however, cannot be released under any other type of license. The
requirement for fonts to remain under this license does not apply
to any document created using the fonts or their derivatives.
DEFINITIONS
"Font Software" refers to the set of files released by the Copyright
Holder(s) under this license and clearly marked as such. This may
include source files, build scripts and documentation.
"Reserved Font Name" refers to any names specified as such after the
copyright statement(s).
"Original Version" refers to the collection of Font Software components as
distributed by the Copyright Holder(s).
"Modified Version" refers to any derivative made by adding to, deleting,
or substituting -- in part or in whole -- any of the components of the
Original Version, by changing formats or by porting the Font Software to a
new environment.
"Author" refers to any designer, engineer, programmer, technical
writer or other person who contributed to the Font Software.
PERMISSION & CONDITIONS
Permission is hereby granted, free of charge, to any person obtaining
a copy of the Font Software, to use, study, copy, merge, embed, modify,
redistribute, and sell modified and unmodified copies of the Font
Software, subject to the following conditions:
1) Neither the Font Software nor any of its individual components,
in Original or Modified Versions, may be sold by itself.
2) Original or Modified Versions of the Font Software may be bundled,
redistributed and/or sold with any software, provided that each copy
contains the above copyright notice and this license. These can be
included either as stand-alone text files, human-readable headers or
in the appropriate machine-readable metadata fields within text or
binary files as long as those fields can be easily viewed by the user.
3) No Modified Version of the Font Software may use the Reserved Font
Name(s) unless explicit written permission is granted by the corresponding
Copyright Holder. This restriction only applies to the primary font name as
presented to the users.
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
Software shall not be used to promote, endorse or advertise any
Modified Version, except to acknowledge the contribution(s) of the
Copyright Holder(s) and the Author(s) or with their explicit written
permission.
5) The Font Software, modified or unmodified, in part or in whole,
must be distributed entirely under this license, and must not be
distributed under any other license. The requirement for fonts to
remain under this license does not apply to any document created
using the Font Software.
TERMINATION
This license becomes null and void if any of the above conditions are
not met.
DISCLAIMER
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
OTHER DEALINGS IN THE FONT SOFTWARE.
+125 -127
View File
@@ -1,17 +1,15 @@
[gd_resource type="Theme" format=3]
[ext_resource type="FontFile" path="res://art/fonts/Handjet-VF.ttf" id="1_hjthv"]
[ext_resource type="FontFile" path="res://art/fonts/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")
Binary file not shown.

After

Width:  |  Height:  |  Size: 294 B

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

After

Width:  |  Height:  |  Size: 245 B

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

After

Width:  |  Height:  |  Size: 213 B

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

After

Width:  |  Height:  |  Size: 239 B

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

After

Width:  |  Height:  |  Size: 190 B

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

After

Width:  |  Height:  |  Size: 370 B

+40
View File
@@ -0,0 +1,40 @@
[remap]
importer="texture"
type="CompressedTexture2D"
uid="uid://dj6scq4olnonj"
path="res://.godot/imported/packet.png-2a847fa0ce4ca153b815c85ae5cddeab.ctex"
metadata={
"vram_texture": false
}
[deps]
source_file="res://art/icons/packet.png"
dest_files=["res://.godot/imported/packet.png-2a847fa0ce4ca153b815c85ae5cddeab.ctex"]
[params]
compress/mode=0
compress/high_quality=false
compress/lossy_quality=0.7
compress/uastc_level=0
compress/rdo_quality_loss=0.0
compress/hdr_compression=1
compress/normal_map=0
compress/channel_pack=0
mipmaps/generate=false
mipmaps/limit=-1
roughness/mode=0
roughness/src_normal=""
process/channel_remap/red=0
process/channel_remap/green=1
process/channel_remap/blue=2
process/channel_remap/alpha=3
process/fix_alpha_border=true
process/premult_alpha=false
process/normal_map_invert_y=false
process/hdr_as_srgb=false
process/hdr_clamp_exposure=false
process/size_limit=0
detect_3d/compress_to=1
+606 -206
View File
@@ -1,123 +1,626 @@
{
"_meta": {
"file": "balance.json",
"version": "1.1",
"rules": "docs/GDD.md (компактная сводка) + docs/DECISIONS.md (D01–D17)",
"note": "null = параметр не задан в постановке, уточнить до кода (см. _todo). Никаких выдуманных чисел.",
"_todo": [
"attacks.junk.spawn_probability — не задан в спецификации (явно null)",
"income_target_by_hour: часы 4,6,7,8 — интерполяция, уточнить",
"difficulty.h2 packet_timer/request_timer: формула даёт 26.325/50.15 (19.5×1.35 и 29.5×1.7 — у запросов отдельный grace)"
]
"version": "2.0",
"rules": "PEAK_HOUR_V2_SHOP_FILES_MINIGAMES.md §2, §3, §6, §8 + Appendix A",
"note": "v2: 8 lot families, 3 offers, rerolls, dense send-minigame selection. Legacy sections (difficulty/spawn/progression/timers/connections/channel) kept for the not-yet-rewritten HourDirector. New code reads constants/hours/grid/shop_rules/shop/minigames/attacks/payout/sla."
},
"constants": {
"hour_seconds": 300,
"run_hours": 9,
"sla_start": 128,
"sla_max": 200,
"money_start": 150,
"buffer_base": 4,
"buffer_per_upgrade": 2,
"buffer_max": 8,
"slots_start": 2,
"slots_max": 4,
"max_sections": 6,
"files_per_folder": 16,
"money_start": 50,
"sla_start": 60,
"sla_base": 60,
"sla_per_level": 40,
"sla_hard_cap": 220,
"sla_regen_per_hour": 15,
"slots_start": 1,
"slots_max": 5,
"channel_capacity": 10,
"channel_per_level": 6,
"channel_capacity_max": 46,
"buffer_base": 6,
"buffer_per_level": 1,
"buffer_max": 12,
"request_timer": 30,
"request_timer_per_level": 7,
"request_timer_max": 65,
"packet_timer": 20,
"folder_cols": 5,
"folder_rows": 4,
"folders_start": 2,
"folders_max": 6,
"folder_new_cost": 180,
"max_sections": 1,
"section_start": "C",
"folders_per_section": 12,
"files_per_folder": 48,
"send_window_visible_cards": 4,
"send_scroll_card_height_px": 56,
"required_name_char_limit": 12,
"file_cooldown_after_send_sec": 20,
"required_per_request": { "min": 1, "max": 3 }
"file_cooldown_after_send_sec": 0,
"consume_files_on_send": true,
"required_per_request": { "min": 1, "max": 4 },
"spawn_window_ratio": 0.75,
"spawn_jitter": 0.2,
"buffer_per_upgrade": 1
},
"economy": {
"start_stats": {
"money": 50,
"sla": 60,
"sla_cap": 60,
"slots": 1,
"channel_capacity": 10,
"buffer": 6,
"request_timer": 30,
"packet_timer": 20,
"folder_grid": [5, 4],
"money_mult": 1.0,
"speed_mult": 1.0
},
"formulas": {
"max_sla": "clamp(60 + 40 * sla_level, 60, 220)",
"sla_regen": "sla = min(sla_cap, sla + 15) в начале каждого часа",
"slots": "1 + slot_level (max 5)",
"channel_capacity": "10 + 6 * channel_level",
"buffer": "min(12, 6 + channel_level)",
"request_timer": "min(65, 30 + 7 * timer_level)",
"packet_timer": "20 (не покупается)",
"speed": "pow(0.90, speed_level)",
"money_mult": "1 + 0.05 * money_level",
"folder_dims": "[[5,4],[6,4],[7,5],[8,5],[8,6],[9,6]][folder_level]",
"transfer_time": "base_time_sec * pow(0.90, speed_level) * max(1.0, load / capacity)",
"protocol_time": "UDP ×0.8, TCP ×1.0 (только отправка)"
}
},
"types": {
"text": {
"name_ru": "Текст",
"ext": ".txt",
"icon": "▤",
"recv_time": 1.0,
"send_time": 1.0,
"recv_fee": 2,
"base_pay": 20,
"weight": 6,
"minigame_coeff": 1.0,
"sla_loss": 2
"ext": "txt",
"icon": "text",
"size_kb": 64,
"cells": 1,
"base_time_sec": 3,
"base_pay": 18,
"recv_fee": 1,
"weight": 1,
"minigame_coeff": 1,
"sla_loss": 2,
"shapes": ["O1"]
},
"sound": {
"name_ru": "Звук",
"ext": ".wav",
"icon": "♪",
"recv_time": 1.5,
"send_time": 2.0,
"recv_fee": 3,
"base_pay": 35,
"weight": 10,
"minigame_coeff": 2.0,
"sla_loss": 4
"ext": "mp3",
"icon": "sound",
"size_kb": 3072,
"cells": 2,
"base_time_sec": 5,
"base_pay": 30,
"recv_fee": 2,
"weight": 2,
"minigame_coeff": 2,
"sla_loss": 4,
"shapes": ["I2H", "I2V"]
},
"photo": {
"name_ru": "Фото",
"ext": ".jpg",
"icon": "▣",
"recv_time": 2.0,
"send_time": 3.0,
"recv_fee": 5,
"base_pay": 55,
"weight": 14,
"minigame_coeff": 3.0,
"sla_loss": 8
"ext": "jpg",
"icon": "photo",
"size_kb": 5120,
"cells": 3,
"base_time_sec": 8,
"base_pay": 44,
"recv_fee": 4,
"weight": 4,
"minigame_coeff": 4,
"sla_loss": 8,
"shapes": ["I3H", "I3V", "L3a", "L3b", "L3c", "L3d"]
},
"video": {
"name_ru": "Видео",
"ext": ".mp4",
"icon": "▶",
"recv_time": 3.0,
"send_time": 5.0,
"recv_fee": 8,
"base_pay": 90,
"weight": 22,
"minigame_coeff": 4.0,
"sla_loss": 16
"ext": "mp4",
"icon": "video",
"size_kb": 20480,
"cells": 4,
"base_time_sec": 12,
"base_pay": 69,
"recv_fee": 6,
"weight": 8,
"minigame_coeff": 8,
"sla_loss": 16,
"shapes": ["I4H", "I4V", "O4", "T4a", "T4b", "T4c", "T4d", "L4a", "L4b", "L4c", "L4d", "J4a", "J4b", "J4c", "J4d", "S4", "Z4"]
}
},
"hours": [
{
"hour": 1,
"files": 7,
"request_cap": 6,
"active_requests": 2,
"attacks": 0,
"income_target": 150,
"recv_interval": 12,
"send_interval": 12,
"type_weights": { "text": 0.7, "sound": 0.3, "photo": 0.0, "video": 0.0 },
"request_mix": { "1": 0.76, "2": 0.24, "3": 0.0, "4": 0.0 }
},
{
"hour": 2,
"files": 8,
"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.63, "2": 0.32, "3": 0.05, "4": 0.0 }
},
{
"hour": 3,
"files": 12,
"request_cap": 5,
"active_requests": 2,
"attacks": 1,
"income_target": 380,
"recv_interval": 10,
"send_interval": 18,
"type_weights": { "text": 0.4, "sound": 0.3, "photo": 0.22, "video": 0.08 },
"request_mix": { "1": 0.53, "2": 0.37, "3": 0.10, "4": 0.0 }
},
{
"hour": 4,
"files": 16,
"request_cap": 6,
"active_requests": 3,
"attacks": 2,
"income_target": 560,
"recv_interval": 9,
"send_interval": 17,
"type_weights": { "text": 0.3, "sound": 0.25, "photo": 0.28, "video": 0.17 },
"request_mix": { "1": 0.50, "2": 0.36, "3": 0.12, "4": 0.02 }
},
{
"hour": 5,
"files": 20,
"request_cap": 6,
"active_requests": 3,
"attacks": 2,
"income_target": 760,
"recv_interval": 8,
"send_interval": 15,
"type_weights": { "text": 0.22, "sound": 0.2, "photo": 0.32, "video": 0.26 },
"request_mix": { "1": 0.42, "2": 0.38, "3": 0.16, "4": 0.04 }
},
{
"hour": 6,
"files": 20,
"request_cap": 7,
"active_requests": 3,
"attacks": 3,
"income_target": 980,
"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.40, "2": 0.38, "3": 0.17, "4": 0.05 }
},
{
"hour": 7,
"files": 20,
"request_cap": 7,
"active_requests": 4,
"attacks": 3,
"income_target": 1200,
"recv_interval": 7,
"send_interval": 13,
"type_weights": { "text": 0.1, "sound": 0.12, "photo": 0.35, "video": 0.43 },
"request_mix": { "1": 0.32, "2": 0.40, "3": 0.21, "4": 0.07 }
},
{
"hour": 8,
"files": 20,
"request_cap": 7,
"active_requests": 4,
"attacks": 4,
"income_target": 1450,
"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.30, "2": 0.39, "3": 0.22, "4": 0.09 }
},
{
"hour": 9,
"files": 20,
"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.27, "2": 0.38, "3": 0.23, "4": 0.12 }
}
],
"payout": {
"speed_base": 0.6,
"speed_bonus": 0.4,
"quality_base": 0.5,
"quality_bonus": 0.5,
"multi_step": 0.15,
"protocol_mult": { "TCP": 1.15, "UDP": 0.85 },
"udp_time_mult": 0.8,
"streak_step": 0.02,
"streak_cap": 10,
"money_mult_step": 0.05,
"formula": "payout = int(round(float(base_sum) * speed * quality * multi * protocol * streak * money_mult))",
"speed_formula": "speed = 0.6 + 0.4 * clamp(time_left / timer_total, 0, 1)",
"quality_formula": "quality = 0.5 + 0.5 * q",
"multi_formula": "multi = 1 + 0.15 * (n - 1)",
"streak_formula": "streak = 1 + 0.02 * min(10, streak)",
"money_mult_formula": "money_mult = 1 + 0.05 * money_level",
"note": "Только float-арифметика: никаких int/int и никакого ^ для степени."
},
"sla": {
"start": 60,
"regen_per_hour": 15,
"defeat_at_zero": true,
"timeout_fine_base": 10,
"timeout_fine_per_hour": 2,
"timeout_fine_formula": "-(10 + 2*hour) SLA",
"wrong_attach_fine": -2,
"lost_file_fine": -5,
"buffer_overflow_fine": -5,
"reject_money": -5,
"reject_sla": -5,
"folder_destroyed_fine": -5,
"sla_lose": "SLA <= 0 во время часа = поражение забега"
},
"grid": {
"gap": 6,
"cell_min": 56,
"cell_max": 104,
"view_w": 558,
"view_h": 470,
"folder_cols": 5,
"folder_rows": 4,
"folder_dims": [[5, 4], [6, 4], [7, 5], [8, 5], [8, 6], [9, 6]],
"table_cols": 4,
"table_rows": 6,
"cell_formula": "cell = clamp(floor(min((view_w - gap*(cols-1))/cols, (view_h - gap*(rows-1))/rows)), 56, 104)",
"shapes": {
"O1": { "cells": [[0, 0]], "w": 1, "h": 1 },
"I2H": { "cells": [[0, 0], [1, 0]], "w": 2, "h": 1 },
"I2V": { "cells": [[0, 0], [0, 1]], "w": 1, "h": 2 },
"I3H": { "cells": [[0, 0], [1, 0], [2, 0]], "w": 3, "h": 1 },
"I3V": { "cells": [[0, 0], [0, 1], [0, 2]], "w": 1, "h": 3 },
"L3a": { "cells": [[0, 0], [0, 1], [1, 1]], "w": 2, "h": 2 },
"L3b": { "cells": [[0, 0], [1, 0], [0, 1]], "w": 2, "h": 2 },
"L3c": { "cells": [[1, 0], [0, 1], [1, 1]], "w": 2, "h": 2 },
"L3d": { "cells": [[0, 0], [1, 0], [1, 1]], "w": 2, "h": 2 },
"I4H": { "cells": [[0, 0], [1, 0], [2, 0], [3, 0]], "w": 4, "h": 1 },
"I4V": { "cells": [[0, 0], [0, 1], [0, 2], [0, 3]], "w": 1, "h": 4 },
"O4": { "cells": [[0, 0], [1, 0], [0, 1], [1, 1]], "w": 2, "h": 2 },
"T4a": { "cells": [[0, 0], [1, 0], [2, 0], [1, 1]], "w": 3, "h": 2 },
"T4b": { "cells": [[0, 1], [1, 1], [2, 1], [1, 0]], "w": 3, "h": 2 },
"T4c": { "cells": [[0, 0], [0, 1], [1, 1], [0, 2]], "w": 2, "h": 3 },
"T4d": { "cells": [[1, 0], [1, 1], [0, 1], [1, 2]], "w": 2, "h": 3 },
"L4a": { "cells": [[0, 0], [0, 1], [0, 2], [1, 2]], "w": 2, "h": 3 },
"L4b": { "cells": [[0, 0], [1, 0], [2, 0], [0, 1]], "w": 3, "h": 2 },
"L4c": { "cells": [[0, 0], [1, 0], [1, 1], [1, 2]], "w": 2, "h": 3 },
"L4d": { "cells": [[2, 0], [0, 1], [1, 1], [2, 1]], "w": 3, "h": 2 },
"J4a": { "cells": [[1, 0], [1, 1], [1, 2], [0, 2]], "w": 2, "h": 3 },
"J4b": { "cells": [[0, 0], [1, 0], [2, 0], [2, 1]], "w": 3, "h": 2 },
"J4c": { "cells": [[0, 0], [0, 1], [1, 1], [2, 1]], "w": 3, "h": 2 },
"J4d": { "cells": [[0, 0], [0, 1], [0, 2], [1, 0]], "w": 2, "h": 3 },
"S4": { "cells": [[1, 0], [2, 0], [0, 1], [1, 1]], "w": 3, "h": 2 },
"Z4": { "cells": [[0, 0], [1, 0], [1, 1], [2, 1]], "w": 3, "h": 2 }
},
"type_shapes": {
"text": ["O1"],
"sound": ["I2H", "I2V"],
"photo": ["I3H", "I3V", "L3a", "L3b", "L3c", "L3d"],
"video": ["I4H", "I4V", "O4", "T4a", "T4b", "T4c", "T4d", "L4a", "L4b", "L4c", "L4d", "J4a", "J4b", "J4c", "J4d", "S4", "Z4"]
}
},
"shop_rules": {
"offers": 3,
"unlock_hour": 2,
"reroll_costs": [40, 80, 120],
"max_rerolls": 3,
"last_offer_weight": 0.5,
"pity_hours": 2,
"pity_weight": 1.5,
"note": "Веса берутся из shop[].weight_by_hour (9 значений, индекс = hour-1). Фазы H2-3/H4-6/H7-9 — таблица §3.1 — источник правды."
},
"shop": [
{
"id": "lot_slots",
"name_ru": "Порты коммутатора",
"desc_ru": "Ещё один порт: приём и отправка могут идти параллельно.",
"effect": "connection_slots",
"value": 1,
"max_level": 4,
"prices": [90, 200, 400, 700],
"level_names": ["Второй порт", "Третий порт", "Четвёртый порт", "Магистральный порт"],
"weight_by_hour": [1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 0.8, 0.8, 0.8]
},
{
"id": "lot_speed",
"name_ru": "Оптоволокно",
"desc_ru": "Скорость приёма и отправки +10% за уровень (умножается).",
"effect": "transfer_speed",
"value": 0.10,
"max_level": 6,
"prices": [55, 75, 100, 135, 180, 245],
"level_names": ["Витая пара", "Экранированная пара", "Оптоволокно", "Гигабитная линия", "Магистраль", "Тёмное волокно"],
"weight_by_hour": [1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 0.8, 0.8, 0.8]
},
{
"id": "lot_sla",
"name_ru": "Резервный ИБП",
"desc_ru": "Держит узел живым: кап SLA +40 и мгновенное восстановление +40.",
"effect": "sla_cap",
"value": 40,
"max_level": 4,
"prices": [80, 160, 270, 430],
"level_names": ["Резервный ИБП", "Горячий резерв", "Двойное питание", "Автономный контур"],
"weight_by_hour": [0.8, 0.8, 0.8, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0]
},
{
"id": "lot_shield",
"name_ru": "Заземление",
"desc_ru": "Автоматически гасит атаку: 70% награды, без штрафа.",
"effect": "shield_charge",
"value": 1,
"max_level": 3,
"prices": [100, 220, 410],
"level_names": ["Заземление", "Громоотвод", "Защитный контур"],
"weight_by_hour": [0.3, 0.3, 0.3, 0.8, 0.8, 0.8, 1.0, 1.0, 1.0]
},
{
"id": "lot_money",
"name_ru": "Корпоративный контракт",
"desc_ru": "Оплата за контейнеры +5% за уровень.",
"effect": "money_mult",
"value": 0.05,
"max_level": 8,
"prices": [70, 95, 130, 170, 220, 285, 365, 465],
"level_names": ["Тарифный план", "Корпоративный контракт", "SLA-пакет", "Госзаказ", "Экспортный контракт", "Франшиза", "Монополия", "Золотая жила"],
"weight_by_hour": [0.6, 0.6, 0.6, 1.0, 1.0, 1.0, 1.2, 1.2, 1.2]
},
{
"id": "lot_folder",
"name_ru": "Складской стеллаж",
"desc_ru": "Больше места под фигуры: 20 → 24 клетки на первом уровне (до 54).",
"effect": "folder_cells",
"value": 1,
"max_level": 5,
"prices": [50, 100, 180, 290, 440],
"level_names": ["Стеллаж", "Секция стеллажа", "Складская линия", "Автоматизированный склад", "Ангар"],
"weight_by_hour": [1.0, 1.0, 1.0, 0.9, 0.9, 0.9, 0.8, 0.8, 0.8]
},
{
"id": "lot_channel",
"name_ru": "Пропускная способность",
"desc_ru": "Канал +6 и буфер приёма +1 за уровень.",
"effect": "channel_capacity",
"value": 6,
"buffer_value": 1,
"max_level": 6,
"prices": [45, 70, 95, 130, 175, 230],
"level_names": ["Канал 56k", "ISDN", "ADSL", "Выделенная линия", "Оптика", "Магистраль"],
"weight_by_hour": [1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 0.9, 0.9, 0.9]
},
{
"id": "lot_timer",
"name_ru": "Регламент обработки",
"desc_ru": "Больше времени на поиск файлов и прикрепление: 30 → 37 с (до 65).",
"effect": "request_timer",
"value": 7,
"max_level": 5,
"prices": [45, 75, 115, 165, 230],
"level_names": ["Регламент", "Продление сессии", "Гибкий график", "Приоритетная обработка", "Выделенный менеджер"],
"weight_by_hour": [1.0, 1.0, 1.0, 0.9, 0.9, 0.9, 0.7, 0.7, 0.7]
}
],
"minigames": {
"selection": {
"order": ["dots", "sound", "text", "video", "photo", "maze"],
"achievable_sums": [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 24, 25, 26, 28, 32],
"max_index": 26,
"tier_max_rank": [7, 13],
"rank_games": ["text", "sound", "dots", "photo", "maze", "dots", "photo", "video", "dots", "text", "video", "maze", "photo", "sound", "video", "text", "maze", "sound", "video", "sound", "photo", "dots", "dots", "maze", "maze", "photo", "video"],
"formula": "S=sum(coeff по 1-4 файлам); rank=index of S in achievable_sums (или ближайшей меньшей); tier=1 при rank<=7, 2 при 8<=rank<=13, иначе 3; id=rank_games[rank]; S=0 → dots/T1",
"note": "6 игр отправки (wires удалена), 3 ступени, ровно 27 достижимых сумм коэффициентов для 1-4 файлов. Таблица rank_games задаёт игру по rank напрямую (без modulo)."
},
"games": {
"dots": {
"name_ru": "Одна линия",
"kind": "send",
"tiers": [
{ "grid": [4, 3], "obstacles": 2, "undo_penalty": 0.04 },
{ "grid": [4, 4], "obstacles": 4, "undo_penalty": 0.04 },
{ "grid": [5, 4], "obstacles": 6, "undo_penalty": 0.03 }
]
},
"sound": {
"name_ru": "Уровни",
"kind": "send",
"tiers": [
{ "channels": 4, "tolerance": 0.11, "drift": 0.0, "drift_hz": 0.0 },
{ "channels": 5, "tolerance": 0.08, "drift": 0.02, "drift_hz": 0.15 },
{ "channels": 6, "tolerance": 0.06, "drift": 0.03, "drift_hz": 0.25 }
]
},
"text": {
"name_ru": "Чужое слово",
"kind": "send",
"tiers": [
{ "words": 15, "aliens": 2, "lines": 2, "alien_kind": "foreign_language+typos" },
{ "words": 19, "aliens": 3, "lines": 3, "alien_kind": "foreign_language+typos+semantic" },
{ "words": 24, "aliens": 4, "lines": 3, "alien_kind": "foreign_language+typos+semantic+archaic" }
]
},
"video": {
"name_ru": "Раскадровка",
"kind": "send",
"tiers": [
{ "frames": 4, "slots": 4, "decoys": 0, "mirror": false },
{ "frames": 5, "slots": 4, "decoys": 1, "mirror": false },
{ "frames": 6, "slots": 5, "decoys": 2, "mirror": true }
]
},
"photo": {
"name_ru": "Кадр",
"kind": "send",
"tiers": [
{ "speed": 140, "frame_w": 180, "frame_h": 120, "shots": 4, "retarget_min": 0.6, "retarget_max": 1.0 },
{ "speed": 200, "frame_w": 150, "frame_h": 100, "shots": 5, "retarget_min": 0.5, "retarget_max": 0.8 },
{ "speed": 260, "frame_w": 120, "frame_h": 80, "shots": 6, "retarget_min": 0.35, "retarget_max": 0.65 }
]
},
"maze": {
"name_ru": "Лабиринт",
"kind": "send",
"tiers": [
{ "cols": 11, "rows": 4, "width": 440, "height": 160, "speed": 470, "collision_penalty": 0.05, "dead_end_penalty": 0.02 },
{ "cols": 13, "rows": 4, "width": 494, "height": 152, "speed": 520, "collision_penalty": 0.04, "dead_end_penalty": 0.02 },
{ "cols": 15, "rows": 5, "width": 520, "height": 180, "speed": 560, "collision_penalty": 0.04, "dead_end_penalty": 0.02 }
]
},
"junk": {
"name_ru": "Фильтр",
"kind": "attack",
"tiers": [
{ "cards": 9, "junk": 2, "hints": 1, "threshold": 0.6 },
{ "cards": 10, "junk": 3, "hints": 2, "threshold": 0.65 },
{ "cards": 11, "junk": 4, "hints": 2, "threshold": 0.7 },
{ "cards": 12, "junk": 5, "hints": 3, "threshold": 0.75 }
]
},
"ddos": {
"name_ru": "Щит",
"kind": "attack",
"tiers": [
{ "packets": 8, "speed": 110, "shield_width": 260, "interval": 0.55, "threshold": 0.6 },
{ "packets": 10, "speed": 140, "shield_width": 220, "interval": 0.45, "threshold": 0.65 },
{ "packets": 12, "speed": 170, "shield_width": 180, "interval": 0.35, "threshold": 0.7 },
{ "packets": 14, "speed": 200, "shield_width": 150, "interval": 0.28, "threshold": 0.75 }
]
},
"phishing": {
"name_ru": "Паспорт",
"kind": "attack",
"tiers": [
{ "clients": 3, "checks": ["name"], "threshold": 0.75 },
{ "clients": 4, "checks": ["name", "department"], "threshold": 0.75 },
{ "clients": 5, "checks": ["name", "department", "id"], "threshold": 0.8 },
{ "clients": 6, "checks": ["name", "department", "id", "photo", "expiry"], "threshold": 0.8 }
]
},
"ransomware": {
"name_ru": "Карантин",
"kind": "attack",
"tiers": [
{ "cards": 12, "exe": 1, "threshold": 0.5, "masking": "extension_visible" },
{ "cards": 16, "exe": 2, "threshold": 0.55, "masking": "extension_small" },
{ "cards": 20, "exe": 2, "threshold": 0.6, "masking": "document_icon" },
{ "cards": 24, "exe": 3, "threshold": 0.65, "masking": "similar_names" }
]
}
}
},
"attacks": {
"schedule": [0, 1, 1, 2, 2, 3, 3, 4, 4],
"tier_formula": "clamp(1 + floor((hour - 2) / 2.0), 1, 4)",
"spawn_window": { "start_sec": 60, "end_sec": 240 },
"shield_reward_scale": 0.7,
"money_scale": 1.0,
"kinds": {
"junk": {
"name_ru": "Мусорный пакет",
"minigame": "junk",
"weight_by_hour": [0.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0],
"tiers": [
{ "reward_money": 5, "reward_sla": 1, "fail_money": -10, "fail_sla": -5, "threshold": 0.6 },
{ "reward_money": 10, "reward_sla": 2, "fail_money": -15, "fail_sla": -7, "threshold": 0.65 },
{ "reward_money": 15, "reward_sla": 3, "fail_money": -20, "fail_sla": -9, "threshold": 0.7 },
{ "reward_money": 20, "reward_sla": 4, "fail_money": -25, "fail_sla": -12, "threshold": 0.75 }
]
},
"ddos": {
"name_ru": "DDoS",
"minigame": "ddos",
"weight_by_hour": [0.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0],
"tiers": [
{ "reward_money": 30, "reward_sla": 2, "fail_money": -10, "fail_sla": -15, "channel_debuff": 0.4, "debuff_sec": 15, "threshold": 0.6 },
{ "reward_money": 40, "reward_sla": 3, "fail_money": -15, "fail_sla": -20, "channel_debuff": 0.4, "debuff_sec": 15, "threshold": 0.65 },
{ "reward_money": 50, "reward_sla": 4, "fail_money": -20, "fail_sla": -25, "channel_debuff": 0.4, "debuff_sec": 15, "threshold": 0.7 },
{ "reward_money": 60, "reward_sla": 5, "fail_money": -25, "fail_sla": -30, "channel_debuff": 0.4, "debuff_sec": 15, "threshold": 0.75 }
]
},
"phishing": {
"name_ru": "Фишинг",
"minigame": "phishing",
"weight_by_hour": [0.0, 0.0, 0.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0],
"tiers": [
{ "reward_money": 20, "reward_sla": 2, "fail_money": -40, "fail_sla": -10, "file_lock_sec": 60, "send_penalty_sec": 5, "threshold": 0.75 },
{ "reward_money": 30, "reward_sla": 3, "fail_money": -50, "fail_sla": -12, "file_lock_sec": 60, "send_penalty_sec": 5, "threshold": 0.75 },
{ "reward_money": 40, "reward_sla": 4, "fail_money": -60, "fail_sla": -15, "file_lock_sec": 60, "send_penalty_sec": 5, "threshold": 0.8 },
{ "reward_money": 50, "reward_sla": 5, "fail_money": -70, "fail_sla": -18, "file_lock_sec": 60, "send_penalty_sec": 5, "threshold": 0.8 }
]
},
"ransomware": {
"name_ru": "Шифровальщик",
"minigame": "ransomware",
"weight_by_hour": [0.0, 0.0, 0.0, 0.0, 1.0, 1.0, 1.0, 1.0, 1.0],
"tiers": [
{ "reward_money": 25, "reward_sla": 2, "fail_money": -30, "fail_sla": -10, "folder_block_sec": 15, "threshold": 0.5 },
{ "reward_money": 35, "reward_sla": 3, "fail_money": -40, "fail_sla": -12, "folder_block_sec": 15, "threshold": 0.55 },
{ "reward_money": 45, "reward_sla": 4, "fail_money": -50, "fail_sla": -15, "folder_block_sec": 15, "threshold": 0.6 },
{ "reward_money": 55, "reward_sla": 5, "fail_money": -60, "fail_sla": -18, "folder_block_sec": 15, "threshold": 0.65 }
]
}
}
},
"difficulty": [
{ "hour": 1, "recv_interval": 12, "send_interval": 22, "max_requests": 2, "buffer": 4, "packet_timer": 30, "request_timer": 60, "attacks": 0 },
{ "hour": 2, "recv_interval": 11, "send_interval": 20, "max_requests": 2, "buffer": 4, "packet_timer": 26.325, "request_timer": 50.15, "attacks": 1 },
{ "hour": 3, "recv_interval": 10, "send_interval": 18, "max_requests": 3, "buffer": 5, "packet_timer": 22.8, "request_timer": 40.6, "attacks": 1 },
{ "hour": 4, "recv_interval": 9, "send_interval": 17, "max_requests": 3, "buffer": 5, "packet_timer": 18.5, "request_timer": 28.5, "attacks": 2 },
{ "hour": 5, "recv_interval": 8, "send_interval": 15, "max_requests": 4, "buffer": 6, "packet_timer": 18, "request_timer": 28, "attacks": 2 },
{ "hour": 6, "recv_interval": 7.5, "send_interval": 14, "max_requests": 4, "buffer": 6, "packet_timer": 17.5, "request_timer": 27.5, "attacks": 3 },
{ "hour": 7, "recv_interval": 7, "send_interval": 13, "max_requests": 4, "buffer": 6, "packet_timer": 17, "request_timer": 27, "attacks": 3 },
{ "hour": 8, "recv_interval": 6.5, "send_interval": 12, "max_requests": 4, "buffer": 7, "packet_timer": 16.5, "request_timer": 26.5, "attacks": 4 },
{ "hour": 9, "recv_interval": 6, "send_interval": 12, "max_requests": 4, "buffer": 7, "packet_timer": 16, "request_timer": 26, "attacks": 4 }
{ "hour": 1, "recv_interval": 12, "send_interval": 22, "max_requests": 2, "buffer": 4, "packet_timer": 20, "request_timer": 30, "attacks": 0 },
{ "hour": 2, "recv_interval": 11, "send_interval": 20, "max_requests": 5, "buffer": 6, "packet_timer": 20, "request_timer": 30, "attacks": 1 },
{ "hour": 3, "recv_interval": 10, "send_interval": 18, "max_requests": 7, "buffer": 6, "packet_timer": 20, "request_timer": 30, "attacks": 1 },
{ "hour": 4, "recv_interval": 9, "send_interval": 17, "max_requests": 9, "buffer": 6, "packet_timer": 20, "request_timer": 30, "attacks": 2 },
{ "hour": 5, "recv_interval": 8, "send_interval": 15, "max_requests": 10, "buffer": 6, "packet_timer": 20, "request_timer": 30, "attacks": 2 },
{ "hour": 6, "recv_interval": 7.5, "send_interval": 14, "max_requests": 11, "buffer": 6, "packet_timer": 20, "request_timer": 30, "attacks": 3 },
{ "hour": 7, "recv_interval": 7, "send_interval": 13, "max_requests": 11, "buffer": 6, "packet_timer": 20, "request_timer": 30, "attacks": 3 },
{ "hour": 8, "recv_interval": 6.5, "send_interval": 12, "max_requests": 12, "buffer": 6, "packet_timer": 20, "request_timer": 30, "attacks": 4 },
{ "hour": 9, "recv_interval": 6, "send_interval": 12, "max_requests": 12, "buffer": 6, "packet_timer": 20, "request_timer": 30, "attacks": 4 }
],
"difficulty_rules": {
"table_end_hour": 8,
"after_end_hour": "значения часа 8; +5% к скорости спавна каждые 2 часа",
"spawn_speedup_after_end": 0.05,
"table_end_hour": 9,
"after_end_hour": "значения часа 9",
"spawn_speedup_after_end": 0.0,
"spawn_speedup_period_hours": 2,
"type_availability": "час 1 — text; час 2 — text и sound; час 3 — +photo; час 4+ — все 4 (D23); атаки — с часа 2 (attack_junk)",
"timer_grace": "packet_timer: grace h1 1.5, h2 1.35, h3 1.2, h4+ 1.0 (Economy.TIMER_GRACE). request_timer: отдельный grace h1 2.0, h2 1.7, h3 1.4, h4+ 1.0 (Economy.REQUEST_TIMER_GRACE). Формула: (base − 0.5*(h−1))*grace + 4*level. h2: packet 19.5*1.35=26.325, request 29.5*1.7=50.15."
"type_availability": "веса типов — hours[].type_weights (единственный источник правды); H1 только text/sound, photo с H2, video с H3",
"timer_grace": "v2: таймеры не масштабируются по часу (packet 20, request 30+7*level, cap 65)"
},
"progression": {
"unlocks": {
"shop": {"run_hour": 2, "label": "Магазин"},
"search": {"meta": "survive_hour_3", "label": "Поиск"},
"shield": {"meta": "survive_hour_5", "label": "Щит"},
"minigame_text": {"run_hour": 1},
"minigame_sound": {"run_hour": 2},
"minigame_photo": {"run_hour": 3},
"minigame_video": {"run_hour": 4},
"minigame_advanced": {"meta": "complete_run", "label": "Продвинутые мини-игры"},
"attack_junk": {"run_hour": 2},
"attack_ddos": {"run_hour": 2},
"attack_phishing": {"run_hour": 3},
"attack_ransomware": {"run_hour": 4}
"shop": { "run_hour": 2, "label": "Магазин" },
"search": { "meta": "survive_hour_3", "label": "Поиск" },
"shield": { "meta": "survive_hour_5", "label": "Щит" },
"minigame_text": { "run_hour": 1 },
"minigame_sound": { "run_hour": 2 },
"minigame_photo": { "run_hour": 3 },
"minigame_video": { "run_hour": 4 },
"attack_junk": { "run_hour": 2 },
"attack_ddos": { "run_hour": 2 },
"attack_phishing": { "run_hour": 3 },
"attack_ransomware": { "run_hour": 4 }
},
"meta_rewards": {
"survive_hour_3": {"min_hours": 3, "unlocks": ["search"]},
"survive_hour_5": {"min_hours": 5, "unlocks": ["shield"]},
"complete_run": {"min_hours": 9, "unlocks": ["minigame_advanced"]}
"survive_hour_3": { "min_hours": 3, "unlocks": ["search"] },
"survive_hour_5": { "min_hours": 5, "unlocks": ["shield"] },
"complete_run": { "min_hours": 9, "unlocks": ["minigame_advanced"] }
}
},
"timers": {
@@ -127,34 +630,20 @@
"minigame_timeout": null,
"delete_long_press_sec": 0.5
},
"sla": {
"recv_bonus": 0.5,
"send_bonus": 1.0,
"reflect_bonus": 1.0,
"timeout_fine_base": 10,
"timeout_fine_per_hour": 2,
"timeout_fine_formula": "-(10 + 2*hour) — таймаут и подобные штрафы (D12)",
"wrong_attach_fine": -2,
"lost_file_fine": -5,
"buffer_overflow_fine": -5,
"sla_lose": "SLA <= 0"
},
"payout": {
"base_pay": "см. types.*.base_pay (значения в types)",
"recv_fee": "см. types.*.recv_fee (значения в types)",
"speed_formula": "speed = 0.6 + 0.4*(time_left / timer_total)",
"quality_formula": "quality = 0.5 + 0.5*mini_quality",
"multi_formula": "multi = 1 + 0.1*(n - 1)",
"protocol_mult": { "TCP": 1.15, "UDP": 1.0 },
"streak_formula": "streak = 1 + 0.02*min(10, streak)",
"income_target_by_hour": {
"hours": [1, 2, 3, 5, 9],
"approx": [500, 800, 1200, 1800, 2200],
"note": "ожидаемо ±10%; часы 4,6,7,8 — интерполяция, уточнить"
}
"spawn": {
"type_pool_by_hour": { "1": ["text", "sound"], "2": ["text", "sound", "photo"], "3+": ["text", "sound", "photo", "video"] },
"malicious_ratio": 0.0,
"required_count_by_hour": {
"1": { "1": 0.7, "2": 0.3, "3": 0.0 },
"2": { "1": 0.6, "2": 0.35, "3": 0.05 },
"3+": { "1": 0.5, "2": 0.4, "3": 0.1 }
},
"required_size_distribution": { "1": 0.5, "2": 0.4, "3": 0.1 },
"required_count_note": "legacy; v2 читает hours[].request_mix",
"attack_schedule": "attacks.schedule (0/1/1/2/2/3/3/4/4), окно 60–240 с часа"
},
"connections": {
"W_formula": "W = clamp(0.30 - 0.005*(hour-1) - 0.005*successes + 0.05*zone_upgrade_level, 0.18, 0.36)",
"W_formula": "legacy (полоска соединения удалена в v2)",
"W_base": 0.3,
"W_hour_coef": -0.005,
"W_success_coef": -0.005,
@@ -169,103 +658,14 @@
"CHANNEL_BG_PER_HOUR": 0,
"SLOT_LOAD": 0,
"BG_note": "фоновый шум и занятые слоты НЕ грузят канал; load = только активные передачи (loading/sending)",
"CHANNEL_CAPACITY": 80,
"capacity_per_upgrade": 25,
"capacity_upgrade_max_level": 4,
"formula": "load = sum(weight(active_transfers)); factor = max(1.0, load / CHANNEL_CAPACITY); actual_time = base_time * factor. idle load = 0"
},
"attacks": {
"junk": {
"name_ru": "Мусорный пакет",
"spawn_probability": null,
"duration_sec": 15,
"tell": "серая иконка ?, размер 0 Б, нет таймера",
"effect": "авто-занос через duration_sec, занимает место в приёме; уничтожить можно как malicious в любой фазе (D02)",
"reward_money": 5,
"reward_sla": 1,
"fail_money": -10,
"fail_sla": -5,
"filter_disabled_sec": 10
},
"ddos": {
"name_ru": "DDoS",
"cards_min": 8,
"cards_max": 12,
"spawn_window_sec": 2.0,
"shield_hold_sec": 1.5,
"tell": "8–12 идентичных карточек без типа и таймера",
"effect": "волна карточек; отбивается удержанием щита",
"reward_money": 30,
"reward_sla": 2,
"fail_sla": -15,
"channel_debuff": 0.4,
"channel_debuff_sec": 15,
"packets_lost": 2,
"recv_disabled_sec": 5
},
"phishing": {
"name_ru": "Фишинг",
"reward_money": 20,
"reward_sla": 2,
"fail_money": -40,
"fail_sla": -10,
"file_lock_sec": 60,
"send_disabled_sec": 5,
"timeout_money": -20,
"timeout_sla": -5,
"timeout_lock_sec": 30,
"tell": "client_id формата id_XXXX с неверным символом (например id_7F3К — кириллическая К), серый аватар",
"effect": "фальшивый запрос/пакет"
},
"ransomware": {
"name_ru": "Шифровальщик",
"reward_money": 25,
"reward_sla": 2,
"folder_block_delay_sec": 3,
"auto_unlock_sec": 30,
"auto_unlock_money": -30,
"auto_unlock_sla": -10,
"tell": "расширение .exe, красный квадрат 4×4",
"effect": "файл становится locked (lock_until), кликабелен для уничтожения (D08)"
}
},
"shop": [
{ "id": "shop_slot3", "name_ru": "Третий слот", "effect": "connection_slots", "value": 1, "price": 250, "max_level": 1 },
{ "id": "shop_slot4", "name_ru": "Четвёртый слот", "effect": "connection_slots", "value": 1, "price": 450, "max_level": 1 },
{ "id": "shop_buffer", "name_ru": "Буфер +2", "effect": "buffer_slots", "value": 2, "price": 200, "max_level": 2 },
{ "id": "shop_timer", "name_ru": "Таймер пакетов +4 с", "effect": "packet_timer", "value": 4, "unit": "сек (аддитивно)", "price": 350, "max_level": 3 },
{ "id": "shop_zone", "name_ru": "Зона +5%", "effect": "zone_width", "value": 0.05, "unit": "доля W (аддитивно в формуле D04)", "price": 300, "max_level": 3 },
{ "id": "shop_channel", "name_ru": "Канал +25", "effect": "channel_capacity", "value": 25, "price": 250, "max_level": 4 },
{ "id": "shop_section", "name_ru": "Новая секция", "effect": "sections", "value": 1, "price": 300, "max_level": 5 },
{ "id": "shop_search", "name_ru": "Поиск", "effect": "search_highlight", "value": 1, "price": 250, "max_level": 1 },
{ "id": "shop_shield", "name_ru": "Щит", "effect": "shield", "value": 1, "price": 400, "max_level": 1 },
{ "id": "shop_sla", "name_ru": "SLA +24", "effect": "sla_add", "value": 24, "price": 300, "max_level": 3 }
],
"shop_reserve": {
"items": ["scanner", "autosort"],
"note": "резерв — в прототип НЕ входят (D13); позиций в shop НЕТ"
},
"spawn": {
"type_pool_by_hour": {
"1": ["text"],
"2": ["text", "sound"],
"3+": ["text", "sound", "photo", "video"]
},
"malicious_ratio": 0.0,
"required_count_by_hour": {
"1": { "1": 1.0 },
"2": { "1": 0.75, "2": 0.25 },
"3+": { "1": 0.6, "2": 0.3, "3": 0.1 }
},
"required_size_distribution": { "1": 0.6, "2": 0.3, "3": 0.1 },
"required_count_note": "required_count_by_hour — распределение по часу; required_size_distribution — fallback/час 3+",
"attack_schedule": "с часа 2, далее по difficulty.attacks, окно 60–240 с часа, не во время мини-игры"
"CHANNEL_CAPACITY": 10,
"capacity_per_upgrade": 6,
"capacity_upgrade_max_level": 6,
"formula": "load = sum(weight(active_transfers)); factor = max(1.0, load / capacity); actual_time = base_time_sec * pow(0.90, speed_level) * factor"
},
"minigame_selection": {
"coeff": { "text": 1.0, "sound": 2.0, "photo": 3.0, "video": 4.0 },
"difficulty_formula": "sum(coeff(required)) / n",
"thresholds": { "text": "< 1.5", "sound": "< 2.5", "photo": "< 3.5", "else": "video" },
"call": "MiniGame.start(type, difficulty)",
"coeff": { "text": 1, "sound": 2, "photo": 4, "video": 8 },
"formula": "см. minigames.selection.formula (27 достижимых сумм для 1-4 файлов, 3 ступени, rank_games)",
"quality_stub": 1.0
}
}
+73 -1
View File
@@ -72,6 +72,10 @@
"retry_no_effect": "Качество прежнее — улучшить нельзя",
"delete_prep_only": "Обычные файлы удаляются только в подготовке",
"money_short": "Не хватает средств",
"folder_need_money": "НУЖНО {cost} $",
"folder_limit": "ДОСТИГНУТ ЛИМИТ ПАПОК",
"delete_file_confirm": "Удалить файл?",
"delete_file_detail": "Файл «{name}» будет уничтожен без штрафа SLA.",
"save_restored": "Прогресс смена восстановлен"
},
"shop": {
@@ -87,7 +91,75 @@
"shop_channel": "Ёмкость канала +25",
"shop_section": "Новая секция хранения",
"shop_search": "Поиск нужных файлов",
"shop_shield": "Щит (отражения атак)"
"shop_shield": "Щит (отражения атак)",
"title_offers": "МАГАЗИН · 3 ПРЕДЛОЖЕНИЯ",
"subtitle": "Новые предложения после каждого часа",
"reroll": "ОБНОВИТЬ АССОРТИМЕНТ · %d $",
"reroll_limit": "ЛИМИТ ОБНОВЛЕНИЙ",
"level": "УРОВЕНЬ %d/%d",
"now": "СЕЙЧАС",
"after": "ПОСЛЕ",
"not_enough": "НЕ ХВАТАЕТ %d $",
"maxed": "МАКСИМУМ",
"all_bought": "ВСЁ КУПЛЕНО",
"lock": "ЗАКРЕПИТЬ",
"unlock": "ОТКРЕПИТЬ",
"lot_slots": "Порты коммутатора",
"lot_speed": "Оптоволокно",
"lot_sla": "Резервный ИБП",
"lot_shield": "Заземление",
"lot_money": "Корпоративный контракт",
"lot_folder": "Складской стеллаж",
"lot_channel": "Пропускная способность",
"lot_timer": "Регламент обработки"
},
"sort": {
"title": "СТОЛ СОРТИРОВКИ",
"hint": "Перетащите файлы, чтобы разложить по папкам",
"full": "СТОЛ ЗАПОЛНЕН",
"return_blocked": "На столе остались файлы, места нет",
"empty": "Здесь можно разложить файлы после часа пика"
},
"grid": {
"cells": "%d / %d клеток",
"free": "свободно %d клеток",
"busy": "занято %d из %d · свободно %d",
"empty_folder": "ПУСТО · перетащите файл или пакет",
"pack": "УПАКОВАТЬ"
},
"packet": {
"shape": "ЗАЙМЁТ %d КЛЕТОК",
"no_space": "НЕТ МЕСТА В ПАПКЕ"
},
"request": {
"minigame": "МИНИ-ИГРА: %s · T%d",
"difficulty": "СЛОЖНОСТЬ: %d",
"waiting_slot": "ОЖИДАНИЕ СЛОТА"
},
"minigame": {
"quality": "КАЧЕСТВО %d%%",
"surrender": "СДАТЬСЯ",
"skip": "ПРОПУСТИТЬ",
"no_timer": "БЕЗ ТАЙМЕРА",
"continue": "ПРОДОЛЖИТЬ",
"clean": "ЧИСТО",
"normal": "НОРМАЛЬНО",
"weak": "СЛАБО",
"dots": "Одна линия",
"sound": "Уровни",
"text": "Чужое слово",
"video": "Раскадровка",
"photo": "Кадр",
"maze": "Лабиринт",
"junk": "Фильтр",
"ddos": "Щит",
"phishing": "Паспорт",
"ransomware": "Карантин"
},
"hour": {
"next_offers": "3 НОВЫХ ЛОТА В МАГАЗИНЕ",
"sla_regen": "SLA восстановлен: +%d",
"all_slots_busy": "ВСЕ КАНАЛЫ ЗАНЯТЫ"
},
"attack": {
"junk": {
+469 -11
View File
@@ -1,15 +1,34 @@
# DECISIONS — решения v1.2
# DECISIONS — решения v3.0
> Каждое решение: ID, что решили, что меняется в GDD. Дедлайн
> 26.10.2026 23:59 (Minsk). Источник правил — `docs/GDD.md`.
> **v1.2 (25.10.2026):** D01–D17 сохранены; добавлены D18–D25. Если D19–D24
> меняют числа старых решений, это указано в примечании к D12/D13/D16.
> 26.10.2026 23:59 (Minsk). Источник правил — `docs/GDD.md` (v3.0).
>
> **v3.0 (26.10.2026):** актуальны решения **D45–D52**. Решения **D01–D44**
> сохранены как история; конфликтующие с v3.0 помечены пометкой
> «**Статус v3.0: история**» или «**заменено**» с указанием замены. Ничего не
> удалено.
>
> **Сдвиг ID:** диапазон D26–D28 занят решениями v1.2 (аудит, VHS, ambient),
> D29–D44 — решения v2.0, поэтому восемь новых решений v3.0 идут **D45–D52**.
>
> **Где смотреть актуальные числа:** 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 — карточка запроса
**Решение:** в карточке запроса показывать имя требуемого файла
(до 12 символов) и его папку, а не только иконку типа. `required` —
конкретные id файлов, не фильтры по типу.
**GDD:** §7 (нужные файлы — по имени+папке, привязка по id).
**Статус v2.0: устарело.** В v2 `required` — это 1–3 слота контейнера, файлы
выбирает игрок; конкретные id не зашиты (D32, GDD §7).
## D02 — расход/удаление файлов и склад
**Решение:** файлы **не расходуются** при отправке, но после успешной
@@ -19,6 +38,10 @@
поражением, но пройденные 30+ часов засчитываются; в итоговом
отчёте это указывается явно.
**GDD:** §3, §5, §16.
**Статус v2.0: устарело.** Файлы **расходуются** при успешной отправке
(cooldown 0); при отмене/таймауте сохраняются (D33). «Конец забега по
складу» не используется: поражение только по SLA (D40, GDD §16). Удаление
неприкреплённого файла в подготовке — без штрафа (D44).
## D03 — заморозка таймеров модалками
**Решение:** пока открыта модалка мини-игры или панель соединения —
@@ -26,6 +49,9 @@
активного запроса продолжает идти; при полном буфере новые пакеты
не спавнятся.
**GDD:** §6, §8, §8.4.
**Статус v2.0: частично устарело.** Мини-игра отправки не ставит час на
паузу: она морозит спавн/загрузку, спавн запросов и таймер своего запроса;
другие таймеры идут. Мини-игра атаки ставит на паузу весь час (D34/D38).
## D04 — полоска соединения
**Решение:** `W = clamp(0.30 − 0.005×(hour−1) − 0.005×successes
@@ -33,6 +59,11 @@
440/340/280 px/с; окна попадания ~130–200 мс; апгрейд зоны применяется
внутри формулы, cap 0.36.
**GDD:** §11.
**Статус v2.0: устарело.** Полоска соединения удалена. TCP/UDP — переключатель
оплаты/скорости: TCP ×1.15 к выплате, UDP ×0.8 ко времени (D33, GDD §11).
**Статус v3.0: формула снова актуальна.** TCP-соединение вернулось (D45):
та же формула зоны, скорости 440/340/280, 3 попадания; промах −4 SLA / 8 с,
Esc — 0 SLA / 4 с. UDP соединения не требует.
## D05 — один источник истины
**Решение:** буфер = `4 + 2×buffer_upgrades` (max 8); зона — только
@@ -40,18 +71,30 @@
(`balance.json → difficulty`); в окне «Отправка» максимум 4 видимых
карточки; при 5-м запросе — скролл и высота карточек 56 px.
**GDD:** §6, §7, §11.
**Статус v2.0: устарело в числах.** Буфер = 6 + уровень `lot_channel`, max 12;
`request_cap` берётся из `hours[]` (2/5/5/6/6/7/7/7/8), а не из legacy
`difficulty`. Правило «4 карточки, дальше скролл, высота 56 px» остаётся
(D29, GDD §6–7).
## D06 — файл в нескольких запросах
**Решение:** файл может входить в `required` нескольких запросов, но
`attached` — только в одном; второй запрос показывает файл серым.
При `failed` файла все запросы, где он `required`, отменяются со штрафом.
**GDD:** §7.
**Статус v2.0: частично устарело.** Файл по-прежнему прикрепляется только к
одному запросу; но `required` больше не содержит конкретных id, поэтому
правило «все запросы, где он `required`» не применяется. При `locked`/
`failed`/`malicious` файл просто открепляется (D32).
## D07 — атаки вне буфера и единая таблица штрафов
**Решение:** атаки не занимают буфер и не вытесняются ими; отдельный
слой карточек атак. Единая таблица урона: мусор −5 SLA, DDoS −15,
фишинг −10, шифровальщик −10 (вместо глобального −15).
**GDD:** §12.
**Статус v2.0: устарело.** Карточек/оверлеев атак больше нет: каждая атака —
мини-игра, которая ставит час на паузу. Урон/награда — по
`attacks.kinds.*.tiers`, плюс эффекты DDoS/фишинга/шифровальщика
(D38/D39, GDD §12.1).
## D08 — видимые теллы
**Решение:** у пакета показывать расширение файла; у шифровальщика —
@@ -59,6 +102,8 @@
символом (например `id_7F3К`, кириллическая К) и серый аватар.
`locked`/malicious файл остаётся кликабельным для уничтожения.
**GDD:** §20.
**Статус v2.0: устарело.** Зловредных пакетов/фишинговых запросов и их
теллов нет; атаки вынесены в отдельные мини-игры (D38, GDD §12.1, §20).
## D09 — состояния-флаги
**Решение:** состояния — флаги, а не один enum:
@@ -68,12 +113,23 @@
Таблица переходов при истечении таймера (загрузка/соединение/мини-игра)
— в `docs/IMPLEMENTATION.md`.
**GDD:** §17.
**Статус v2.0: обновлено.** Добавлены `table` и `sending`; `connected` —
legacy и всегда `true` (полоски нет). Резервации приёма живут в папке, а не
в запросе (D31/D32, GDD §17).
**Статус v3.0: обновлено.** `connected` снова рабочий флаг TCP; добавлены
`connecting`, `connect_attempts`, `connect_cooldown_until`, `required_by`
(D45/D46, GDD §17).
## D10 — поведение Esc
**Решение:** в час — пауза; в мини-игре — no-op; в панели соединения —
отмена (слот освобождается, запрос остаётся); закрытие модалок — только
кнопкой или таймаутом.
**GDD:** §18.
**Статус v2.0: частично устарело.** Панели соединения нет. В мини-игре
отправки `Esc` во время игры — no-op; в мини-игре атаки — двухшаговое
подтверждение `СДАТЬСЯ`; на экране результата — `ПРОДОЛЖИТЬ` (D34, GDD §18).
**Статус v3.0: обновлено.** TCP-панель вернулась: `Esc` отменяет соединение
без штрафа SLA, с кулдауном 4 с (D45, GDD §11.1, §18).
## D11 — устойчивость к лагам
**Решение:** `delta = min(delta, 0.1)`; авто-пауза при
@@ -89,6 +145,10 @@
значения, но +5 % к скорости спавна каждые 2 часа.
**GDD:** §12, §13, §23.
**v1.2:** старт SLA — 128/200; штраф за потерю пакета — по типу (D19).
**Статус v2.0: устарело.** Доход — цели 65/220/380/560/760/980/1200/1450/
1700; старт SLA 60/60; пассивных +0.5/+1 нет, есть +15 в начале часа и
награды атак. «Полный выкуп к H8–H9» не подтверждён (56,7–57,9 % дерева в v3).
Штраф `−(10+2×hour)` сохранён (D29/D40/D50, GDD §12–13).
## D13 — магазин
**Решение:** 9 позиций: 3-й слот 250, 4-й слот 450, буфер +2 — 200×2,
@@ -97,9 +157,12 @@
резерв (вырезаны из прототипа).
**GDD:** §14.
**v1.2:** добавлена 10-я позиция «SLA +24» (300, max 3); открытия — D23.
**Статус v2.0: устарело.** 8 лотов, 3 оффера, рероллы и замки; зоны/секции/
поиска в магазине нет. Полное дерево — **8 085 $** в v3 (D50); прежние
числа v2 — история.
## D14 — фон
**Решение:** фон — статичный градиент + слабый шум 320×180,
**Решение:** шейдер фона — статичный градиент + слабый шум 320×180,
без полноэкранного fbm; CRT-эффект убрать.
**GDD:** §21.
**v1.2:** фон — тёплый бумажный дизеринг/панели, см. D18.
@@ -115,11 +178,16 @@ 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.
**GDD:** §23.
**v1.2:** календарь открытий — D23; в часе 1 атак нет.
**Статус v2.0: исторический.** Актуальная структура — `main.gd` +
`game_controller.gd` + `core/` + `minigames/`; порядок работ и проверки —
D43, GDD §23.
## D17 — quality/retry
**Решение:** заглушка качества возвращает 1.0; кнопка «Переделать»
@@ -130,6 +198,9 @@ Press Start 2P остаётся только для крупных заголо
`difficulty = sum(coeff)/n`, пороги `<1.5 text, <2.5 sound,
<3.5 photo, иначе video`; `MiniGame.start(type, difficulty)`.
**GDD:** §4, §8.4, §10.
**Статус v2.0: устарело.** Заглушки качества и кнопки «Переделать» нет:
`q` даёт мини-игра. Выбор игры — по плотному списку достижимых сумм и
коэффициентам 1/2/4/8 (D34/D35, GDD §8.4, §10).
## D18 — UI v2 «Kaleidos»
@@ -149,6 +220,9 @@ Press Start 2P остаётся только для крупных заголо
10-я позиция **«SLA +24»** — 300, max 3 уровня. Остальные 9 позиций D13
сохраняются; итого 10.
**GDD:** §4, §12, §14.
**Статус v2.0: устарело.** Старт SLA 60/60; лота «SLA +24» нет, кап в v3
растёт на +40 за уровень `lot_sla` до 220. Потеря пакета по типу сохранена
(D29/D40/D50, GDD §12).
## D20 — таймеры и grace
@@ -159,6 +233,8 @@ h2 26.325/39.825, h3 22.8/34.8, h4 18.5/28.5, h5 18/28, далее
плавно до h9 16/26. Апгрейд «Таймер пакетов +4 с» даёт чистые +4 с
поверх grace.
**GDD:** §8.
**Статус v2.0: устарело.** Grace и per-hour scaling отменены: пакет 20 с,
запрос 30 + 5×уровень (max 55) (D29, GDD §8).
## D21 — канал v1.2
@@ -167,6 +243,8 @@ h2 26.325/39.825, h3 22.8/34.8, h4 18.5/28.5, h5 18/28, далее
слоты не грузят; idle = 0 %. `capacity = 80 + 25×channel_level`,
`factor = max(1.0, load / capacity)`, `actual_time = base_time × factor`.
**GDD:** §9.
**Статус v2.0: устарело в числах.** `capacity = 10 + 6×level` (10…46);
формула нагрузки и idle=0 сохранены (D29, GDD §9).
## D22 — TCP-соединение
@@ -175,6 +253,8 @@ h2 26.325/39.825, h3 22.8/34.8, h4 18.5/28.5, h5 18/28, далее
после успешного прохождения; при промахе слот освобождается, запрос
остаётся. `Esc` — отмена с тем же результатом.
**GDD:** §11, §17.
**Статус v2.0: устарело.** Полоски/панели соединения нет; `connected` всегда
`true`. TCP/UDP — переключатель выплаты/времени (D33, GDD §11).
## D23 — открытия и мета
@@ -188,6 +268,11 @@ h2 26.325/39.825, h3 22.8/34.8, h4 18.5/28.5, h5 18/28, далее
(`difficulty.attacks = 0,1,1,2,2,3,3,4,4`). `minigame_advanced` — резерв
под будущие мини-игры, контента пока нет.
**GDD:** §3, §10, §15, §23.
**Статус v2.0: обновлено.** Магазин с H2 и мета 3/5/9 сохранены. Типы H1
text/sound, photo с H2, video с H3. Атаки: junk/ddos с H2, phishing с H4,
ransomware с H5 (по `weight_by_hour`); `minigame_advanced` по-прежнему
резерв (D29/D38, GDD §3, §14). Мета-ключ `search` остаётся в данных, но
UI-поиск в v2 не подключён — как готовая фича не документируется.
## D24 — UX и читаемость
@@ -202,6 +287,8 @@ h2 26.325/39.825, h3 22.8/34.8, h4 18.5/28.5, h5 18/28, далее
Маскот **БАЙТ** ведёт короткий туториал первого часа. Esc на итогах забега
ведёт в главное меню (не soft-lock).
**GDD:** §18, §19, §21.
**Статус v2.0: частично.** Поле поиска не подключено (см. D23), остальное —
имена, уничтожение папки, магазин без крестика, calm-фон — в силе.
## D25 — проверки и MR-ревью
@@ -211,6 +298,9 @@ OpenCode-ревью агентом `oracle` на модели
`opencode-go/deepseek-v4.1-flash`: dry-run 74–90 с, комментарий с
Blockers/High/Medium/Test gaps/Verdict; бот не мержит.
**GDD:** §23, §25.
**Статус v2.0: устарело в числах.** Актуальный прогон — Windows/Linux
раннеры, **25 тестовых наборов + parse-check (26/26 PASS)**; баланс —
`python tests/sim_balance.py` (D43, GDD §23).
## D26 — фиксы аудита v1.2
@@ -227,15 +317,17 @@ Blockers/High/Medium/Test gaps/Verdict; бот не мержит.
на слот запускает соединение, тост покупки с названием, честный статус
сейва, fallback `SLA +24`.
- `minigame_advanced` — зарезервированный unlock, контента пока нет.
**Статус v2.0: частично устарело.** Пункты про TCP-прогресс, drag на слот и
fallback `SLA +24` неактуальны: соединения и лота SLA+24 нет. Остальное
(calm-фон, Esc, уничтожение папки, мета, H1 без атак) сохранено.
## D27 — VHS главного меню
**Решение:** VHS-слой только на главном меню: процедурный
`scripts/ui/vhs_overlay.gd` (`extends RefCounted`), рисуется в
`MainMenu._draw` до текста. Скан-линии, слабая хроматика, зерно, виньетка и
полоса; без shader/screen texture, ввод не блокирует, текст резкий.
Галочка «VHS-эффект» — в настройках, группа «ЭКРАН»; HEADLESS не рендерит.
Карточка/кнопки меню под фильтром, модалки — нет; это не CRT на весь UI.
**Решение:** VHS-слой только на главном меню: `shaders/vhs.gdshader` +
`scripts/ui/vhs_overlay.gd`, screen texture 1920, скан-линии 2 физ. px,
слабая хроматика, зерно, виньетка и полоса. Галочка «VHS-эффект» — в
настройках, группа «ЭКРАН»; HEADLESS не рендерит. Карточка/кнопки меню под
фильтром, модалки — нет; это не CRT на весь UI.
**GDD:** §21, §25.
## D28 — ambient machine room
@@ -245,3 +337,369 @@ Blockers/High/Medium/Test gaps/Verdict; бот не мержит.
`scripts/autoload/ambient.gd`. Стартует после первого жеста (web autoplay),
поддерживает mute, паузу и громкость музыки. `test_ambient` — 74/0.
**GDD:** §22, §25.
---
# Решения v2.0 (D29–D44)
## D29 — стартовые статы и формулы
**Решение:** забег начинается с **50 $** и **SLA 60/60**. Кап SLA —
`60 + 40×sla_level`, жёсткий максимум **220**. Слоты — `1 + slot_level`
(1…5), канал — `10 + 6×channel_level` (10…46, он же даёт буфер
`6 + уровень`, 6…12), таймер запроса — `30 + 7×timer_level` (30…65),
таймер пакета — фиксированные **20 с**. Сетка папки —
`[5×4, 6×4, 7×5, 8×5, 8×6, 9×6]`. На старте 2 папки, максимум 6, новая —
**180 $**. В начале часа SLA +15 и заряды щита восстанавливаются.
**GDD:** §4, §6, §8, §9, §12, §14.
**Заменяет:** числа D05, D12, D19, D20, D21.
**Статус v3.0: обновлено.** Числа приведены к v3 (D50): SLA-кап 220,
таймер +7 с до 65, сетки 5×4 → 9×6, новая папка 180 $.
## D30 — сетки папок и файлы-полимино
**Решение:** каждая папка — клеточная сетка; файл — полимино: текст 1,
звук 2, фото 3, видео 4 клетки. Формы — `grid.shapes` в `data/balance.json`.
Поворот — `R` или ПКМ во время drag (0–3). Точное размещение проверяет
пересечения и границы; auto-fit идёт first-fit по строкам/столбцам. Подпись
рисуется в **верхней левой занятой** клетке, а не в пустом углу bbox
(S/Z-формы). Перемещать `attached`/`locked`/`failed`/`malicious` нельзя.
**GDD:** §5, §20.
**Заменяет:** плоские «слоты файлов» v1.
## D31 — приём, резервации и стол сортировки
**Решение:** приём резервирует клетки фигуры в папке на время загрузки;
отмена/таймаут освобождает резервацию. Полный буфер замораживает спавн, но
не сжигает бюджет часа. Стол сортировки — отдельная сетка **4×6**, доступен
в подготовке. Файл на столе нельзя прикрепить к запросу. На старте часа
стол автоматически возвращает файлы в папки; если хотя бы один не влез —
час не начинается, игрок освобождает место. Позиции на столе входят в сейв.
**GDD:** §3, §5–7, §15.
**Обновляет:** D03, D05.
## D32 — запросы и прикрепление
**Решение:** запрос требует до трёх файлов (слоты контейнера), конкретные id не
зашиты — игрок сам выбирает файлы. Файл прикреплён только к одному запросу;
со стола не прикрепляется. При `locked`/`failed`/`malicious` файл
открепляется, контейнер нужно собрать снова. Таймер запроса замирает в его
мини-игре, в очереди out-слота и во время передачи; остальные таймеры идут.
`ОТКЛОНИТЬ` — −5 $/−5 SLA.
**GDD:** §7, §8, §17.
**Заменяет:** D01, D06; обновляет D09.
**Статус v3.0: заменено D46.** Запрос снова требует конкретные `file_id`,
но их **1–4**; имена видны на карточке, есть «ПОТЕРЯН» и жёсткая резервация.
## D33 — отправка, расход файлов и TCP/UDP
**Решение:** отправка — только по явной кнопке `ОТПРАВИТЬ`; прикрепление и
закрытие мини-игры автостарт не запускают. TCP/UDP переключаются до старта:
TCP — выплата ×1.15, время ×1.0; UDP — время ×0.8, выплата **×0.85**. После
мини-игры запрос занимает out-слот или встаёт в очередь «ОЖИДАНИЕ СЛОТА»
(таймер заморожен), пока слот не освободится. При успешной отправке файлы
**расходуются**; при отмене/таймауте/конце часа сохраняются. Полоска
соединения в v2 была удалена; в v3 TCP-соединение вернулось (D45), а
`connected` снова рабочий флаг.
**GDD:** §7, §11, §17.
**Заменяет:** D02 (расход), D04, D22.
**Статус v3.0: заменено D45.** TCP снова требует полоску соединения; UDP
шлёт сразу. Выплата UDP — **×0.85** (не ×1.0), TCP — ×1.15, время UDP ×0.8.
## D34 — мини-игры: состав и контракт
**Решение:** 11 игр — семь отправки (включая удалённую в v3 игру с проводами)
и 4 атаки (`junk`, `ddos`, `phishing`, `ransomware`). Общий
контракт: без таймеров, конечная цель, локальный seeded RNG, `q ∈ 0…1`,
экран результата `q%`/вердикт и `ПРОДОЛЖИТЬ`; `finished` эмитится только
после `ПРОДОЛЖИТЬ` в модальном режиме (награда не применяется раньше).
`СДАТЬСЯ` (атаки) / `ПРОПУСТИТЬ` (отправки) с подтверждением даёт `q=0`.
Формулы `q` — GDD §10.
`Esc` во время игры отправки — no-op, в игре атаки — двухшаговое `СДАТЬСЯ`;
на результате — продолжить.
**GDD:** §10, §18.
**Заменяет:** D17 (заглушка q), D08 (теллы).
**Примечание:** строка `minigame.surrender` (`СДАТЬСЯ`) используется
мини-играми атак; у мини-игр отправки кнопка — `ПРОПУСТИТЬ`.
**Статус v3.0: заменено D47/D48.** Сейчас **10** игр: 6 отправки + 4 атаки;
игры с соединением проводов нет; у отправки 3 ступени. Общий контракт
(без таймеров, seeded RNG, отложенный `finished`) сохранён и дополнен шапкой
«название + цель», полосой прогресса и двухкликовым отказом (GDD §10).
## D35 — выбор мини-игры отправки (B4)
**Решение:** коэффициенты `text 1 / sound 2 / photo 4 / video 8`;
`S = sum(coeff)` по трём файлам максимум. `rank` — позиция S в плотном списке
`[1…14,16,17,18,20,24]` (или ближайшая меньшая), `tier = clamp(1 +
floor(rank×4/19), 1, 4)`, `id = order[rank % 7]`, где order — семь игр v2
(включая удалённую в v3 игру с проводами). Суммы 15/19/21/22/23 недостижимы,
поэтому 28-элементный массив не перечисляется. Все 7 игр и 4 ступени были
достижимы; `S=24 → video/T4` (старый пример S−1 отменён).
**GDD:** §8.4, §10.
**Заменяет:** D17 (средний коэффициент/пороги).
**Статус v3.0: история.** Актуальная формула — **27 достижимых сумм для 1–4
файлов**, 3 ступени и фиксированная таблица `rank_games` (D49, GDD §8.4).
Старые «19 сумм / 4 ступени / rank % 7» больше не действуют.
## D36 — магазин: 8 лотов и 3 оффера
**Решение:** 8 лотов (`lot_slots`, `lot_speed`, `lot_sla`, `lot_shield`,
`lot_money`, `lot_folder`, `lot_channel`, `lot_timer`), ровно **3**
предложения в подготовке. Магазин — только в подготовке с H2, в H1 и во
время часа недоступен. Максимальные лоты не предлагаются. Сумма всех
уровней v2 — историческая; в v3 дерево стоит **8 085 $** (D50). Крестика нет:
`ЗАКРЫТЬ`/`Esc`.
**GDD:** §3, §14.
**Заменяет:** D13, D19 (10 позиций).
**Статус v3.0: история в числах.** 8 лотов и 3 оффера сохранены, но цены,
эффекты и сумма дерева заменены D50 (**8 085 $**).
## D37 — рероллы, замки, веса и сохранение офферов
**Решение:** реролл стоит **40/80/120 $**, максимум **3** за подготовку;
замок отдельного предложения сохраняется при реролле. Вес =
`weight_by_hour[hour−1] × 0.5` (был в прошлой выдаче) `× 1.5` (pity, ≥2
часов без предложения). По возможности в выдаче есть хотя бы один
доступный лот. Офферы, рероллы, `last_seen` и замки сохраняются в сейве и
восстанавливаются при загрузке; счётчик/замки сбрасываются на новой
подготовке.
**GDD:** §14, §15.
**Обновляет:** D23 (открытия), D24 (магазин без крестика).
## D38 — атаки v2
**Решение:** 20 атак за забег: `[0,1,1,2,2,3,3,4,4]` для H1–H9, время —
случайное в окне **60–240 с**. Виды: `junk`, `ddos`, `phishing`,
`ransomware`; доступность по `weight_by_hour` (junk/ddos с H2, phishing с
H4, ransomware с H5). Ступень: H2–3 T1, H4–5 T2, H6–7 T3, H8–9 T4
(`clamp(1+floor((hour−2)/2),1,4)`). Каждая атака — мини-игра, которая
ставит на паузу **весь час**; карточек/пакетов/запросов атак нет.
**GDD:** §3, §10, §12.1.
**Заменяет:** D07, D08, D19 (часть).
## D39 — щит, награды и последствия провала
**Решение:** заряды щита = уровень `lot_shield` (макс. 3), восстанавливаются
в начале часа. Заряд тратится **до** открытия мини-игры: атака считается
отражённой, награда **50 %**, штрафа нет. Деньги наград/штрафов
масштабируются по часу: `max(0.75, hour/3) × attacks.money_scale`. Провал:
DDoS — канал −40 % (factor ×1.4) на 15 с; фишинг — случайный файл
заблокирован на 60 с и отправка недоступна 5 с; ransomware — случайная
папка заблокирована на 15 с. SLA/деньги — из `attacks.kinds.*.tiers`.
**GDD:** §9, §12, §12.1.
**Заменяет:** D07 (таблица урона).
**Статус v3.0: история в числах.** Щит теперь даёт **70 %** награды (D50);
остальные эффекты провала сохранены.
## D40 — SLA, реген и поражение
**Решение:** старт 60/60; в начале часа +15 (не выше капа), кап рос на
+35 за `lot_sla` до исторического максимума v2 (в v3 — +40 до 220, D50).
Потеря пакета — 2/4/8/16 по типу; таймаут запроса
и провал запросов в конце часа — `−(10+2×hour)`; отклонение — −5/−5;
уничтожение папки — −5 SLA. Пассивных +0.5/+1 нет. **SLA ≤ 0 во время часа
= поражение**; «конца забега по складу» нет.
**GDD:** §12, §16.
**Заменяет:** D12 (SLA-прирост), D19 (старт), D02 (склад).
**Статус v3.0: история в числах.** Кап SLA — **220** (+40 за уровень), не 200;
см. D50.
## D41 — save v2 и миграция
**Решение:** `SaveManager.VERSION = 2`, поддерживаются 1 и 2. Позиции
хранятся **только** в `folder.files[]` и `table[]`; в `files{}` — метаданные.
Сейв включает деньги/SLA/час/фазу/streak/stats/upgrades/мету/RNG/реестр/
офферы/рероллы/замки/`last_seen`/заряды щита/час SLA-регена. Запись
атомарная (`save.tmp` → rename), в Web — `force_fs_sync()`. v1 мигрирует
без уменьшения сеток и без потери файлов; лишние файлы уходят на стол или в
`registry`.
**GDD:** §15, §17.
**Заменяет:** старую схему слотов v1.
## D42 — баланс v2
**Решение:** цели дохода H1–H9 — **150/220/380/560/760/980/1200/1450/
1700 $**. `request_cap`, параметры симулятора и расход магазина в v2 были
зафиксированы отдельно; в v3 они заменены (D50, GDD §13). В прогоне v2 все
часы укладывались в ±20 %, но расход магазина оставался ниже целевой полосы
55–65 % — это было зафиксировано честно, а не задекларировано как «полный
выкуп».
**GDD:** §13, §14.
**Статус v3.0: история.** В отчёте v3 — **7 354 $**, **56,7 %** дерева
**8 085 $**, **32/240** поражений; после H1-правки свежий прогон — **7 533 $**,
**57,9 %**, **28/240**; см. D50 и `tests/SIM_BALANCE_REPORT.md`.
## D43 — проверки, запуск и Web
**Решение:** единый прогон:
`powershell -ExecutionPolicy Bypass -File scripts/run_all_tests.ps1`
(Windows) или `bash scripts/run_all_tests.sh` (Linux/macOS). Наборы —
**25 тестовых наборов + parse-check (26/26 PASS)**; все проходят.
Баланс — `python tests/sim_balance.py` (exit 0, отчёт
`tests/SIM_BALANCE_REPORT.md`). Windows-раннер сам ищет Godot и позволяет
переопределить путь через `GODOT_BIN` или `-GodotBin`. Web-пресет
`export_presets.cfg` существует: `thread_support=false`, экспорт в
`build/web/index.html`, просмотр только по HTTP.
**GDD:** §23, §24.
**Заменяет:** D25 (числа/платформы).
## D44 — известные отклонения и упрощения
**Решение:** фиксируем осознанно:
- `ПРОПУСТИТЬ` → `q=0` → `quality = 0.5`, то есть половина quality-множителя,
а не нулевая выплата;
- `speed` выплаты использует `time_left/timer_total` на **старте отправки**,
пока таймер был заморожен мини-игрой; быстрый игрок видит ≈1.0;
- лимит папок **6**, новая папка **100 $** (review H5);
- любой не прикреплённый файл можно уничтожить в подготовке бесплатно —
защита от soft-lock «нет денег/нет места» (review M16);
- удалённая в v3 игра с проводами: T4 имел `box 34`, `pitch 40`, верх `56`,
чтобы низ был ≤320 (review M11); в v3 неактуально;
- деньги атак масштабируются по часу `max(0.75, hour/3)` (review M8).
**GDD:** §26.
**Не является обещанием:** это не баги «на потом», а принятые рамки
джем-прототипа; менять только вместе с балансом и тестами.
**Статус v3.0: история.** Пункт про геометрию игры с проводами неактуален:
игра удалена (D47). Лимит папок теперь 6 при цене **180 $**, магазин усилен
(D50). Остальные упрощения (q=0 у пропуска, snapshot `speed`, M16, M8)
сохранены; актуальный список — GDD §26.
---
# Решения v3.0 (D45–D52)
## 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.
+593 -199
View File
@@ -1,252 +1,646 @@
# PEAK HOUR — GDD (компактная сводка v1.2)
# PEAK HOUR — GDD (v3.0)
> **Статус:** компактная сводка правил и чисел из постановки задачи +
> решений `DECISIONS.md` (ссылки **Dxx**). Версия **v1.2** (25.10.2026)
> добавляет решения D18–D25 и не отменяет D01–D17, если ниже не сказано иное.
> Godot 4.7.2 → HTML5 (Web, no threads), RU-only, окно **1920×1080**,
> **Статус:** актуальная спецификация **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.
## v3.0 — что изменилось
- **Запросы — конкретные файлы.** Запрос требует **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. Обзор и платформа
Менеджер приёма/отправки файлов: дежурный узла обрабатывает пакеты,
запросы клиентов, канал и атаки в течение одной смены. Ограничения:
окно 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).
Менеджер приёма/отправки файлов: дежурный узла раскладывает пакеты по
папкам-сеткам, собирает контейнеры запросов, проходит мини-игры и отражает
атаки за одну смену.
Ограничения: окно 1920×1080, логический viewport 1280×720 (`canvas_items`,
`keep`, `fractional`), nearest, RU-only, Web no-threads (без `Thread` и
блокирующего IO). Основной шрифт — **Golos Text** (OFL 1.1, полная
кириллица), Press Start 2P — только логотип и числовые акценты.
UI v3 «Kaleidos»: тёплая бумага, 1 px контуры, мягкие тени и бирюзовый
акцент (D18, D51). Игровой UI без CRT; VHS-слой — только в главном меню
(D27).
## 3. Структура смены
Забег конечный, **9 игровых часов**: `Prep (подготовка) → Hour → итоги
часа`; после 9-го — **победа** и итоги забега. Досрочное поражение —
SLA ≤ 0 (сразу итоги забега). «Конец забега по складу» — страховочный
механизм (см. §16), тоже итоги забега. Магазин открывается с часа 2
(в подготовке после часа 1) и скрыт во время часа (D23).
Забег конечный, **9 игровых часов по 300 с**:
```
menu → prep(H1) → hour(H1) → hour result → prep(H2) → … → hour(H9)
→ hour result → run result
```
- **Подготовка:** таймеров нет. Доступны папки, стол сортировки 4×6, новая
папка (**180 $**, максимум 6) и магазин (только с H2).
- **Час:** 300 с; спавн файлов идёт в первые **75 %** часа (225 с), атаки —
в окно **60–240 с**. В конце часа активные запросы проваливаются по
таймауту (штраф `−(10 + 2×hour)`), файлы при этом сохраняются; пакеты
снимаются без дополнительного штрафа; открытая мини-игра отправки
принудительно закрывается без отправки.
- **Старт часа:** SLA **+15** (не выше капа), заряды щита восстанавливаются,
файлы со стола автоматически возвращаются в папки. Если хотя бы одному
файлу не хватило места, час **не начинается** — игрок освобождает место.
- **H1-затравка:** в первой папке создаётся один seed-файл `text`, сразу же
появляется запрос ровно на него, первый пакет приходит в `t = 0`. Пока
запросов нет и есть свободный файл, анти-голодание создаёт запрос, не ждя
полный `send_interval`.
- **Победа:** отработаны все 9 часов. **Поражение:** SLA ≤ 0 во время часа.
Отдельного «конца забега по складу» нет.
## 4. Файлы и типы
4 типа файлов: текст (`text`), звук (`sound`), фото (`photo`), видео
(`video`). Каждый с полями `name_ru, ext, icon, recv_time, send_time,
recv_fee, base_pay, weight, minigame_coeff, sla_loss` — значения в
`data/balance.json`.
Перед отправкой запроса качество определяется заглушкой `quality = 1.0`;
кнопка «Переделать» не даёт бонусов (D17). «Самый сложный тип» заменён
средним коэффициентом (см. выбор мини-игры, п. 8.4/10). Потеря пакета
бьёт по SLA по типу: **text 2, sound 4, photo 8, video 16** (D19).
## 5. Папки и секции
Файлы лежат в папках (список папок по умолчанию — `names_ru.json`),
папки сгруппированы в секции; секции докупаются в магазине (D13).
Обычные файлы удаляются только в подготовке (долгое удержание/ПКМ);
зловредные — в любой фазе (D02). «Уничтожить содержимое» папки реально
удаляет файлы после подтверждения и снимает 5 SLA; пустая папка — без штрафа
(D26).
4 типа файлов. Каждый файл — полимино; число клеток равно `cells`:
| Тип | Клетки | Формы | `base_time_sec` | `base_pay` | `recv_fee` | `weight` | `sla_loss` |
| --- | ---: | ---: | ---: | ---: | ---: | ---: | ---: |
| `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` — штраф за потерю пакета.
- Пакет в приёме показывает тип/расширение и фигуру; зловредных пакетов и
«файлов-ловушек» нет — атаки вынесены в мини-игры (§12.1).
- Качество отправки даёт мини-игра (§10).
## 5. Папки, сетка и секции
- Одна секция **C**. На старте **2 папки** (`Разное`, `Входящие`), игрок может
создать новую за **180 $**, всего до **6** папок. Имена уникальны внутри
секции (`Имя`, `Имя 2`).
- Каждая папка — клеточная сетка. Размер общий для всех папок и растёт лотом
`lot_folder`:
| Уровень | 0 | 1 | 2 | 3 | 4 | 5 |
| --- | --- | --- | --- | --- | --- | --- |
| Сетка | 5×4 | 6×4 | 7×5 | 8×5 | 8×6 | 9×6 |
| Клеток | 20 | 24 | 35 | 40 | 48 | 54 |
- Файл занимает несколько клеток по форме; пересечения запрещены. При
перемещении доступен точный drop или auto-fit (first-fit по строкам, затем
столбцам; текущий поворот, затем 0–3).
- Поворот фигуры во время drag — **`R`** или **ПКМ** (циклически 0→1→2→3).
- Подпись файла рисуется в **верхней левой занятой** клетке фигуры (важно для
S/Z-образных форм, где пустой угол bbox не является клеткой файла).
- Файл в `attached`/`locked`/`failed`/`malicious` нельзя перемещать.
- «Уничтожить содержимое» папки удаляет все её файлы после подтверждения и
снимает **5 SLA**; пустая папка — без штрафа. Обычный не прикреплённый файл
можно уничтожить в подготовке без штрафа (review M16), но файл, закреплённый
за запросом, удалить нельзя.
- Загрузка сейва не уменьшает сетку: при нехватке места папка растёт до
ближайшего размера из таблицы, иначе файл уходит на стол или в `registry`
без потери.
## 6. Буфер приёма
Буфер = **4 + 2×уровня апгрейда буфера**, максимум **8** (D05).
Пакет тянут по таймеру в папку; таймеры пакетов в буфере идут только
когда нет модалок (D03).
- Буфер = **6 + уровень `lot_channel`**, максимум **12**.
- Полный буфер **замораживает спавн**, но не сжигает бюджет файлов часа:
файл появится позже, когда место освободится.
- Бюджет файлов — `hours[].files` (4…20) плюс перенос недоспанного бюджета с
прошлого часа, но не больше **20** файлов за час. Интервал спавна —
`225 с / бюджет` с джиттером ±20 %.
- Пакет ждёт в буфере **20 с** (фиксировано, не покупается). После переноса в
папку начинается загрузка: `base_time_sec × 0.90^speed_level × channel_factor`.
Пока идёт загрузка, таймер пакета не тикает.
- Папка резервирует клетки фигуры **во время приёма**; отмена/таймаут
освобождает резервацию. Это исключает двойную выдачу места.
## 7. Запросы
Карточка запроса показывает **имя требуемого файла (до 12 симв.) и
папку**, а не только иконку типа; `required` — конкретные id (D01).
Файл может числиться в `required` нескольких запросов, но `attached` —
только одном; второй запрос видит его серым; при `failed` файла все
запросы, где он `required`, отменяются со штрафом (D06).
В окне «Отправка» максимум 4 видимых карточки; 5-й и далее — скролл,
карточки высотой 56 px (D05).
## 8. Meter/фазы и таймеры
Все фазы (приём, отправка, соединение, мини-игра, атаки) — на едином
`Clock`/`HourDirector.tick(delta)`; пауза флагами (см. IMPLEMENTATION.md).
Полный буфер останавливает спавн приёма; открытые модалки/панели —
останавливают спавн приёма и таймеры пакетов, но таймер активного
запроса продолжается (D03). `delta = min(delta, 0.1)`, авто-пауза при
потере фокуса окна, макс FPS 60 (D11).
- Запрос требует **1–4 конкретных файла**. Число файлов разыгрывается по
`hours[].request_mix`; при создании запрос выбирает реальные файлы из пула
(свободные, не прикреплённые, не затребованные другим запросом) и хранит их
`file_id` в `required`.
- На карточке показаны имена обязательных файлов (сетка 2×2). Прикрепление
принимается только для файла из `required` этого запроса; чужой файл
отклоняется с подсказкой «Нужен файл: …».
- **Жёсткая резервация:** обязательный файл нельзя уничтожить, унести на стол
или прикрепить к другому запросу. Пока запрос жив, файл закреплён за ним
(`required_by`).
- Если обязательный файл исчез (`failed`/`malicious`/удалён), карточка пишет
**«ПОТЕРЯН»**. Директор пытается подставить свободный файл того же типа; если
замены нет, запрос остаётся с дыркой — его можно отклонить, но отправить
нельзя.
- Файл может быть прикреплён только к одному запросу. Прикрепить файл со
стола нельзя — сначала верните его в папку.
- Таймер запроса: **30 + 7×timer_level**, максимум **65 с**. Он замирает,
пока открыта мини-игра этого запроса, пока идёт соединение TCP, пока запрос
ждёт out-слот и пока идёт передача. Время часа при этом продолжается.
- `ОТКЛОНИТЬ` — штраф **−5 $ и −5 SLA**, запрос удаляется, файлы остаются.
- Максимум запросов и одновременно активных запросов — из `hours[]`
(`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 с», «Все запросы часа выданы»
или «Нет свободных файлов для запроса».
Таймеры пакетов/запросов с grace первых часов: `h1 1.5`, `h2 1.35`,
`h3 1.2`, `h4+ 1.0`; формула `(base − 0.5×(h−1))×grace + 4×timer_level`
(D20). Итоговые значения:
## 8. Таймеры и фазы
| Час | Пакет, с | Запрос, с |
| Таймер | Значение | Примечание |
| --- | --- | --- |
| 1 | 30 | 45 |
| 2 | 26.325 | 39.825 |
| 3 | 22.8 | 34.8 |
| 4 | 18.5 | 28.5 |
| 5–9 | 18 → 16 | 28 → 26 |
| Пакет | 20 с | фиксировано; загрузка считается отдельно |
| Запрос | 30 + 7×уровень, макс. 65 с | без per-hour grace |
| Час | 300 с | 9 часов на забег |
| Мини-игра | нет | конечная цель, не время |
| Соединение TCP | нет | таймер запроса заморожен |
| Атака | 60–240 с от начала часа | окно спавна |
- `delta = min(delta, 0.1)`, `Engine.max_fps = 60`, авто-пауза при потере
фокуса окна (D11).
- **Мини-игра отправки** замораживает спавн пакетов, загрузку, спавн запросов
и таймер своего запроса; час и таймеры других запросов продолжают идти.
- **Мини-игра соединения TCP** тоже замораживает таймер своего запроса.
- **Мини-игра атаки** ставит на паузу весь час (все таймеры), пауза
выставляется до показа окна.
- Модалки подготовки (магазин, пауза, настройки) не тикают час, потому что
час в этой фазе не идёт.
### 8.4. Мини-игры запросов: выбор
Коэффициенты типов: `text 1`, `sound 2`, `photo 4`, `video 8`.
`S = sum(coeff)` по **1–4** фактически прикреплённым (или обязательным)
файлам. Достижимо ровно **27 сумм**:
### 8.4. Мини-игры запросов (правило выбора)
```
coeff = {text:1.0, sound:2.0, photo:3.0, video:4.0}
difficulty = sum(coeff(required)) / n # средний коэффициент
пороги: <1.5 → text, <2.5 → sound, <3.5 → photo, иначе → video
MiniGame.start(type, difficulty)
ACHIEVABLE_SUMS = [1..22, 24, 25, 26, 28, 32]
rank = индекс S в списке (или ближайшей меньшей достижимой)
tier = T1 при rank 0–7, T2 при 8–13, T3 при 14–26
id = RANK_GAMES[rank]
```
`RANK_GAMES` — фиксированная таблица на 27 позиций (`scripts/core/economy.gd`),
без modulo:
| 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. Канал
Один общий ресурс для отправок. Фон и занятые слоты канал **не грузят**:
`idle load = 0`, в нагрузку входят только активные передачи (`loading`/
`sending`). Формула (D21):
Один общий ресурс для приёма и отправки. Фон и занятые слоты канал **не
грузят**: `idle load = 0`, в нагрузку входят только активные передачи
(`packet.loading` и `request.sending`).
```
load = Σ weight(active_transfers)
factor = max(1.0, load / CHANNEL_CAPACITY)
CHANNEL_CAPACITY = 80 + 25×channel_level
actual_time = base_time × factor
load = Σ weight(active_transfers)
capacity = 10 + 6 × channel_level # 10 … 46
factor = max(1.0, load / capacity)
time = base_time_sec × 0.90^speed_level × factor
```
Апгрейд «Канал +25» (магазин, ×4, D13/D21) увеличивает capacity.
Полные параметры — `data/balance.json → channel`.
- `lot_channel` даёт **+6 канала и +1 буфера** за уровень, максимум 6.
- TCP не меняет время; UDP — `×0.8` (см. §11).
- Провал DDoS временно умножает `factor` на **1.4** (канал −40 %) на 15 с.
## 10. Мини-игры (детали)
Мини-игра — модалка; тип определяется правилом п. 8.4 (коэффициенты
text 1.0 / sound 2.0 / photo 3.0 / video 4.0, средняя `difficulty`,
пороги <1.5/<2.5/<3.5/иначе). `MiniGame.start(type, difficulty)`.
Типы: text — h1, sound — h2, photo/video — h3+; продвинутые мини-игры —
мета `complete_run`, контента пока нет (D23). `Esc` в мини-игре — no-op (D10).
## 10. Мини-игры
## 11. Полоска соединения
`W = clamp(0.30 − 0.005×(hour−1) − 0.005×successes + 0.05×zone_upgrade_level, 0.18, 0.36)`;
скорости палочек 440/340/280 px/с; окна попадания ~130–200 мс;
апгрейд зоны учтён в формуле с кап 0.36 (D04). `W` — единственный
источник правды по зоне; других формул зоны нет (D05).
`Esc` в панели соединения — отмена: слот освобождается, запрос остаётся (D10).
Промах закрывает полоску: автоповтора нет, нужно снова нажать
«Соединиться». `connected` выставляется только после успешного прохождения
полоски (D22).
Всего **10** игр: **6 отправки** и **4 атаки**. Общие правила:
- **Нет таймеров** и поражения по времени; игра заканчивается действием
игрока или кнопкой отказа.
- Локальный seeded RNG: сид выдаёт `HourDirector`/`AttackDirector`, состояние
не зависит от глобального `randi()`.
- Шапка мини-игры — две строки: название и цель. В шапке же полоса прогресса;
цель дополнительно продублирована строкой с акцентной чертой. Отказ —
`ПРОПУСТИТЬ` (отправка) или `СДАТЬСЯ` (атака) — требует **двух кликов**:
первый взводит кнопку, второй подтверждает.
- Экран результата: название игры, крупный `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` |
| `sound` Уровни | send | выставить каналы в целевые значения | среднее `1 − abs(v−target)/(2×tol)` |
| `text` Чужое слово | send | найти все чужие слова | `(found − false_positive) / aliens` |
| `video` Раскадровка | send | разложить кадры по слотам | `(correct − 0.5×wrong − decoys) / slots` |
| `photo` Кадр | send | сделать все снимки | `captures / shots` |
| `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` |
`dots` управляется **нажатием и протяжкой**: линия растёт непрерывно, пока
курсор идёт по соседним свободным клеткам. Быстрое движение сэмплируется
шагами ~полклетки, поэтому курсор не «перескакивает» клетку. Недопустимое
движение по чужой/занятой клетке **молча игнорируется** и не штрафует;
ошибкой считается только осознанный клик. `R` сбрасывает линию.
Подсказки атак находятся **внутри окна** игры (в нижней полосе), поэтому не
обрезаются границей модалки и не уходят за экран.
## 11. Протоколы и явная отправка
TCP и UDP различаются экономикой, временем и наличием соединения.
- Переключатель TCP/UDP доступен до старта отправки; во время соединения,
мини-игры, очереди или передачи менять протокол нельзя. После успешного
соединения TCP протокол **заблокирован**.
- **TCP:** сначала мини-игра соединения, затем мини-игра отправки.
Выплата `×1.15`, время передачи `×1.0`.
- **UDP:** соединения нет, мини-игра отправки открывается сразу.
Время передачи `×0.8`, выплата `×0.85`.
- Отправка начинается только по кнопке **`СОЕДИНИТЬСЯ`** (TCP без связи) или
**`ОТПРАВИТЬ`**. Прикрепление файлов автостарт не запускает. Успешное
соединение сразу открывает мини-игру отправки, если контейнер собран.
- После мини-игры запрос занимает свободный **out-слот**; если слота нет —
встаёт в очередь `ОЖИДАНИЕ СЛОТА` (таймер запроса заморожен) и стартует,
когда слот освободится.
- Слоты общие для приёма и отправки: `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, 0.85 для UDP
streak = 1 + 0.02 × min(10, streak)
money_mult = 1 + 0.05 × money_level
```
`recv_fee` начисляется при приёме и не умножается на эти коэффициенты.
`base_sum` — сумма `base_pay` прикреплённых файлов. При успешной отправке
файлы **расходуются**; при отмене/таймауте/конце часа сохраняются.
## 12. SLA и штрафы
Старт забега: **SLA 128 / 200** (D19). Прирост: приём +0.5, отправка +1,
отражённая атака +1 (D12). Потеря пакета — по типу: **text 2, sound 4,
photo 8, video 16** (D19). Отдельная таблица штрафов атак (D07): мусор −5,
DDoS −15, фишинг −10, шифровальщик −10. Таймаут и подобные штрафы
масштабируются: −(10 + 2×hour) (D12).
## 13. Экономика
Ожидаемый доход/час ≈ 500 / 800 / 1200 / 1800 / 2200 для часов
1 / 2 / 3 / 5 / 9 (D12). Цены магазина подняты так, что полный выкуп
реалистичен к часу 8–9. Таблица сложности останавливается на часе 8;
дальше те же значения +5 % к скорости спавна каждые 2 часа (D12).
- Старт забега: **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.
- **SLA ≤ 0 во время часа = поражение забега.** Других способов проиграть нет.
## 12.1. Атаки
- Расписание на забег: `[0,1,1,2,2,3,3,4,4]` для H1–H9, всего **20** атак;
время — случайное в окне **60–240 с** от начала часа.
- Виды: `junk` (мусор), `ddos`, `phishing`, `ransomware`. Доступность по
`weight_by_hour`: junk/ddos с H2, phishing с H4, ransomware с H5.
- Ступень: H2–3 → T1, H4–5 → T2, H6–7 → T3, H8–9 → T4
(`clamp(1 + floor((hour−2)/2), 1, 4)`). У атак **4 ступени**.
- Каждая атака — мини-игра (§10); на время неё **весь час** на паузе.
Карточек, пакетов, запросов и файлов атака не создаёт.
- **Щит «Заземление»:** заряды = уровень `lot_shield` (макс. 3),
восстанавливаются в начале часа. Заряд тратится автоматически **до**
открытия мини-игры: атака считается отражённой, награда **70 %**, штрафа
нет.
- Успех/провал дают деньги и SLA из `attacks.kinds.*.tiers`; порог качества
(`threshold`) свой у каждого вида/ступени. Деньги масштабируются:
`max(0.75, hour/3) × attacks.money_scale`.
- Провал даёт эффект:
- `ddos` — канал −40 % (`factor ×1.4`) на **15 с**;
- `phishing` — случайный файл заблокирован на **60 с**, отправка
недоступна **5 с**;
- `ransomware` — случайная папка заблокирована на **15 с**;
- `junk` — только деньги/SLA.
- Кнопка `СДАТЬСЯ` (как и `Esc` с подтверждением) в мини-игре атаки даёт
`q=0`; если порог не пройден, атака считается пропущенной со всеми эффектами.
## 13. Экономика и баланс
Стартовый капитал — **50 $**. Цели дохода по часам (`hours[].income_target`):
| Час | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 |
| --- | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: |
| Доход, $ | 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_REPORT.md`):
- Средний итог — **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. Магазин
**10 позиций** (D13 + D19): 3-й слот (250), 4-й слот (450), буфер +2
(200, ×2), таймер пакетов +4 с (350, ×3), зона +5 % (300, ×3), канал +25
(250, ×4), секция (300, ×5), поиск (250), щит (400), **SLA +24 (300, ×3)**.
Сканер и автосортировка — резерв (в прототип не входят). Открытия (D23):
магазин — с часа 2; поиск — мета `survive_hour_3`; щит — мета
`survive_hour_5`; в час магазин скрыт. Крестика у магазина нет: закрытие —
«Закрыть» или `Esc`.
Магазин открывается **только в подготовке** и только **с H2** (после H1).
Во время часа он скрыт. Крестика нет: закрытие — `ЗАКРЫТЬ` или `Esc`.
- Ровно **3 предложения** за подготовку; максимальные лоты не предлагаются.
- Реролл: **40 / 80 / 120 $**, максимум **3** за подготовку.
- Замок отдельного предложения сохраняется при реролле; замки и счётчик
рероллов сбрасываются на следующей подготовке.
- Вес лота: `weight_by_hour[hour−1] × 0.5` (если был в прошлых предложениях)
`× 1.5` (pity, если не предлагался ≥2 часов). Если возможно, в выдаче
есть хотя бы один доступный по деньгам лот.
- Офферы, рероллы, `last_seen` и замки сохраняются в сейве.
| Лот | Эффект | Уровни | Цены |
| --- | --- | ---: | --- |
| `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 |
Сумма всех уровней — **8 085 $**. Новая папка стоит **180 $** (максимум 6).
Сетки папок: `5×4 → 6×4 → 7×5 → 8×5 → 8×6 → 9×6`.
## 15. Save
Сохранение между часами (`SaveManager`, без секретов, локально). В Web —
IndexedDB (`user://save.json`). Мета-открытия (`meta_unlocks`) сохраняются
между забегами: `survive_hour_3` → поиск, `survive_hour_5` → щит,
`complete_run` → продвинутые мини-игры (D23). Награды выдаются постепенно:
после 3-го, 5-го и 9-го часа; `Game.run_meta_awards` копит открытия забега,
итоги показывают «ОТКРЫТО: …».
- `SaveManager`, `user://save.json`, **VERSION 2**; поддерживаются версии
**1 и 2**. Запись атомарная: `save.tmp` → rename, затем проверка чтением;
в Web вызывается `force_fs_sync()` (IndexedDB).
- **Единый источник позиций:** `folder.files[]` (id, shape_id, origin,
rotation) и `table[]` (file_id, origin, rotation). В `files{}` хранятся
только метаданные (тип, имя, location, attached_request, lock/cooldown,
`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` — файлы не теряются.
## 16. Окончание забега
Победа — отработаны все 9 часов, затем итоги забега. Поражение —
SLA ≤ 0 (смена не отработана), экран итогов. Переполнение склада за
9 часов недостижимо; механизм «конец забега по складу» остаётся как
страховка (не основной конец): поражение, но пройденные 30+ часов
засчитываются — отметка в итоговом отчёте (D02).
- **Победа:** отработаны 9 часов, затем итоги забега.
- **Поражение:** SLA ≤ 0 во время часа.
- «Конец забега по складу» из v1 не используется: склад не является
отдельным условием поражения, а переполнение решается сеткой/столом.
- На итогах показываются часы, заработок, SLA, счёт и `ОТКРЫТО: …` для
мета-наград. `Esc` ведёт в главное меню.
## 17. Состояния
Флаги, а не enum (D09):
- `FileItem.location` ∈ registry/folder/attached/locked/malicious/failed,
`attached_request`, `malicious_kind`, `lock_until`;
- `Request.slot_reserved`, `Request.connected`, `Request.minigame_active`.
Таблица переходов по таймерам (потеря таймера в приёме/соединении/мини-игре)
— `docs/IMPLEMENTATION.md`.
## 18. Esc и паузирование (D10)
Час — пауза; мини-игра — no-op; панель соединения — отмена (слот
свободен, запрос остаётся); закрытие модалок — кнопкой/таймаутом. Магазин
закрывается «Закрыть» или `Esc`, без крестика (D24). Esc на итогах забега
ведёт в главное меню (D26).
Флаги, а не один enum:
- `FileItem.location` ∈ `registry` / `folder` / `table` / `attached` /
`locked` / `malicious` / `failed`;
- `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`, `connected`, `connecting`,
`connect_attempts`, `connect_cooldown_until`; `required_count`/`queued` —
метаданные. `connected` снова рабочий флаг TCP-соединения (D45);
- у пакета: `waiting` / `loading`; у папки — `reserved` (резервации приёма).
## 18. Esc и паузирование
- В час — пауза часа.
- В мини-игре отправки во время игры — 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.*`, `shop.*`, `attack.*` (+ итаковые/панные/подсказки).
Нет оскорблений, русский язык, никакого мата.
## 20. Tells и читаемость
Видимые теллы (D08): пакет показывает **расширение**; шифровальщик —
красный квадрат 4×4; фишинг — client_id в формате `id_XXXX` с неверным
символом (например `id_7F3К`, кириллическая К) и серым аватаром.
`locked`/malicious файл остаётся кликабельным (уничтожение).
Строки игрока — из `data/strings_ru.json`, ключи `btn.*`, `msg.*`, `ui.*`,
`shop.*`, `attack.*`, `minigame.*`. Русский язык, без оскорблений и мата.
Fallback-строки в коде совпадают по смыслу с JSON.
## 20. Читаемость
- Пакет показывает тип, расширение, фигуру и пиксельную иконку; при 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). (D15)
- Press Start 2P — ТОЛЬКО крупные заголовки/логотип (слишком широкий:
17 символов не влезают в карточку 132 px).
- Pixelify Sans — **удалён** из темы (неполная кириллица: нет `О`, `П`).
- Допустимые кегли: **10 / 12 / 16 / 24** (Handjet узкий, 10 разрешён).
- UI v2 «Kaleidos»: тёплая бумага `#E9E6DD`, 1 px контуры, мягкие тени
`#00000018`, скругления 3–4 px, бирюзовый акцент `#2F7F8F`; меньше
декоративных подписей. «Спокойный фон» — плоская `PAPER` без dither
(D18, D24, D26).
- Маскот **БАЙТ** ведёт короткий туториал первого часа (D24).
- VHS-эффект **только на главном меню**: процедурный слой
`scripts/ui/vhs_overlay.gd` (`extends RefCounted`), рисуется в
`MainMenu._draw` до текста; скан-линии 2 физ. px, слабая хроматика, зерно,
виньетка, полоса. Без shader/screen texture, ввод не блокирует, текст
резкий. Чекбокс «VHS-эффект» в настройках, HEADLESS не рендерит.
Игровой UI без CRT; это не полноэкранный CRT (D14, D27).
- Фон: тёплый бумажный дизеринг и светлые панели, без полноэкранного fbm;
CRT убрать (D14, D18).
- Основной шрифт — **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 не рендерит.
## 22. Аудио
18 SFX (интерфейс, приём/отправка, соединение, мини-игры, атаки, экономика,
итоги) + фоновый `hum.wav` (loop-friendly, 3 с); регулируемая громкость.
Ambient машинного зала — 4 WAV (`audio/amb`, PSU/fan/room/seek), генератор
`audio/generate_ambient.py`, autoload `Ambient` стартует после первого жеста
(web autoplay), поддерживает mute/паузу/громкость музыки (D28).
## 23. Порядок разработки
P0 (D16, уточнено D23): данные/реестр → слоты → приём/отправка → UI.
Типы: text h1, sound h2, photo/video h3+. **Час 1 — без атак**; junk/ddos —
h2, phishing — h3, ransomware — h4 (`difficulty.attacks = 0,1,1,2,2,3,3,4,4`).
Магазин — h2. Дальше: UI v2 «Kaleidos», прогрессивная мета, маскот-туториал,
полировка, Web-сборка.
18 SFX (интерфейс, приём/отправка, мини-игры, атаки, экономика, итоги) +
фоновый `hum.wav`. Ambient машинного зала — 4 WAV в `audio/amb`
(PSU/fan/room/seek), генератор `audio/generate_ambient.py`; autoload `Ambient`
стартует после первого жеста (web autoplay), поддерживает mute/паузу/громкость.
## 23. Порядок разработки и структура
Актуальный порядок: данные/реестр → сетки и фигуры → приём/отправка →
мини-игры → магазин/экономика → атаки → UI/полировка → Web.
```
scripts/
├─ 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, 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 типа файлов, 4 атаки, **10 позиций магазина**,
правило выбора мини-игры и формула зоны исчерпываются через data,
RU-тексты, save/load и мета-открытия, HTML5-сборка работает на itch.io.
## 25. v1.2 — delta (D18–D28)
Полный забег из 9 часов; 4 типа файлов-полимино; **6 мини-игр отправки**
(3 ступени) и **4 атаки** (4 ступени); 8 лотов магазина с 3 офферами,
рероллами и замками; явная отправка с TCP-соединением и UDP; запросы на
1–4 именованных файла; save v2 с миграцией v1; RU-тексты; пошаговый туториал;
HTML5-сборка работает на itch.io.
- **D18 — UI v2 «Kaleidos»:** тёплая бумага, 1 px контуры, мягкие тени,
скругления, бирюзовый акцент; окно 1920×1080, логический viewport
1280×720, fractional scale; меньше декоративного шума.
- **D19 — SLA/экономика:** старт 128/200; потеря пакета `text 2 / sound 4 /
photo 8 / video 16`; в магазине 10-я позиция `SLA +24` (300, ×3).
- **D20 — таймеры:** grace `h1 1.5 / h2 1.35 / h3 1.2 / h4+ 1.0`;
`(base − 0.5×(h−1))×grace + 4×timer_level`; итоговая таблица — §8.
- **D21 — канал:** `load = Σ weight` только активных передач; idle 0 %;
`capacity = 80 + 25×level`; фон и слоты не грузят.
- **D22 — TCP:** промах закрывает полоску, автоповтора нет; повторное
«Соединиться»; `connected` только после успеха.
- **D23 — открытия/мета:** магазин с часа 2; поиск/щит/`complete_run` —
после 3/5/9 часов; `run_meta_awards` + «ОТКРЫТО» в итогах; типы text h1,
sound h2, photo/video h3+; **час 1 без атак**, junk/ddos h2, phishing h3,
ransomware h4; `minigame_advanced` — резерв без контента.
- **D24 — UX:** уникальные имена папок, удаление пустых папок,
автообновление, attached-галочки, магазин после часа без крестика,
настройки на главном экране, маскот **БАЙТ**, спокойный фон = плоская
бумага, реальное уничтожение содержимого (−5 SLA), Esc на итогах → меню.
- **D25 — проверки/ревью:** `scripts/run_all_tests.sh` — 21 набор +
parse-check, **22/22 PASS**; MR-ревью OpenCode `oracle`
(`opencode-go/deepseek-v4.1-flash`, 74–90 с на dry-run).
- **D26 — фиксы аудита:** calm-фон без серого `#C0C0C0`, Esc на итогах →
меню без soft-lock, реальное уничтожение папки, прогрессивная мета,
час 1 без атак, честный резерв `minigame_advanced`.
- **D27 — VHS главного меню:** процедурный `vhs_overlay.gd`
(`extends RefCounted`) в `MainMenu._draw`; скан-линии, хроматика, зерно,
виньетка, полоса. Без shader/screen texture, ввод не блокирует, текст
резкий; только меню, чекбокс, HEADLESS без рендера. Не CRT на весь UI.
- **D28 — ambient machine room:** 4 WAV в `audio/amb`, генератор
`generate_ambient.py`, autoload после первого жеста, mute/пауза/громкость;
тест `test_ambient` — 74/0.
## 25. Решения v3.0
Кратко; полные формулировки — `docs/DECISIONS.md`:
- **D45** возврат TCP-соединения; **D46** запросы на конкретные файлы (1–4);
**D47** удаление игры с проводами; **D48** сдвиг ступеней отправки;
**D49** коэффициенты и таблица выбора; **D50** усиление магазина;
**D51** Golos Text и пиксельные иконки; **D52** перестройка туториала.
- Решения v2 (D29–D44) сохранены как история; их числа заменены в v3 и
помечены в DECISIONS.
## 26. Известные отклонения v3.0
- `ПРОПУСТИТЬ`/`СДАТЬСЯ` дают `q = 0`; `quality = 0.5 + 0.5×0 = 0.5`, то есть
половину quality-множителя, а не нулевую выплату. Это осознанно.
- `speed` в выплате использует `time_left / timer_total`, зафиксированное
**на старте отправки**, пока таймер запроса был заморожен мини-игрой.
Быстрый игрок видит `speed ≈ 1.0`; это принятое упрощение.
- Лимит папок — **6**, новая папка — **180 $**.
- Любой не прикреплённый файл можно уничтожить в подготовке бесплатно —
защита от soft-lock «нет денег и нет места» (review M16). Файл, закреплённый
за запросом, не удаляется.
- Денежные награды/штрафы атак масштабируются по часу:
`max(0.75, hour/3) × attacks.money_scale` (review M8).
- H2 теоретически может выдать T2-запрос из трёх фото (S12); на практике это
редкий случай, и кривая сложности H2 остаётся почти полностью T1.
+31
View File
@@ -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. Автолоады
| Автолоад | Файл | Ответственность |
+71 -47
View File
@@ -10,38 +10,58 @@
## Короткое описание
1997 год. Ночная смена в узле файлового обмена. Принимайте пакеты,
отправляйте файлы клиентам, держите SLA — и не дайте атакам положить канал.
раскладывайте файлы по папкам, отправляйте контейнеры клиентам, держите
SLA — и не дайте атакам положить канал.
## Описание
Вы — дежурный оператор узла 7. В 03:42 на столе мигает приём: пакеты
приходят быстрее, чем вы успеваете разложить их по папкам. Клиенты присылают
запросы на конкретные файлы — имя и папку видно в карточке, так что искать
приходится по всему диску. Перетащите пакет в папку, найдите нужный файл,
прикрепите его к запросу, установите соединение и отправьте. В окне
соединения три бегунка — три попадания по очереди, и канал ваш. Промах
закрывает панель: придётся нажать «Соединиться» снова.
приходят быстрее, чем вы успеваете разложить их по папкам. Каждый файл —
фигура из клеток: текст занимает одну, видео — четыре. Фигуру можно
повернуть и плотно уложить в сетку папки; на столе сортировки всегда есть
место, чтобы разобраться с завалом.
Смена длится девять игровых часов по пять минут. На старте **SLA 128/200**,
а каждый потерянный пакет бьёт по типу файла: текст 2, звук 4, фото 8,
видео 16. Первый час проходит без атак; дальше в узел лезут мусор, DDoS,
фишинг и шифровальщик — их нужно вовремя заметить и уничтожить. Магазин
открывается после первого часа: **10 улучшений** от слотов и буфера до зоны,
канала, щита и запаса SLA. Поиск и щит открываются мета-прогрессом после
3-го и 5-го часа, продвинутые мини-игры — после 9-го (контент пока
в разработке). Дожить до конца смены — победа; уронить SLA — поражение.
Клиенты присылают запросы на 1–4 конкретных файла — их имена видны на
карточке, и прикрепить нужно именно этот файл. Соберите контейнер, выберите
протокол — TCP платит больше, UDP быстрее — и нажмите «СОЕДИНИТЬСЯ» или
«ОТПРАВИТЬ». TCP сначала требует провести три палочки через зелёную зону;
UDP отправляет сразу. Отправка не абстрактная: перед ней открывается
мини-игра. Всего их десять: шесть для отправки и четыре для атак, у отправки
три ступени, у атак четыре. В них нет таймеров — только конечная цель и
качество выполнения, от которого зависит выплата. Результат показывает
проценты, вердикт и детали, а награда применяется после «ПРОДОЛЖИТЬ».
Смена длится девять игровых часов по пять минут. На старте **SLA 60/60**,
кап растёт до 220; в начале часа восстанавливается 15 SLA. Потерянный пакет
бьёт по типу файла: текст 2, звук 4, фото 8, видео 16. Первый час проходит
без атак; дальше в узел лезут мусор, DDoS, фишинг и шифровальщик. Каждая
атака — это отдельная мини-игра, которая ставит час на паузу: отбейте её, и
получите деньги и SLA; провалите — получите штраф и временный дебафф.
«Заземление» гасит атаку автоматически, но приносит 70 % награды.
Магазин открывается после первого часа: **8 улучшений** — порты, оптика,
ИБП, заземление, контракты, стеллажи, канал и регламент. Каждую подготовку
предлагаются три лота; ассортимент можно обновить за 40/80/120 $ или
закрепить нужное предложение. Полное дерево стоит 8 085 $ — выкупить всё
за один забег не получится, придётся выбирать.
Дожить до конца смены — победа; уронить SLA — поражение. Сохранение
работает между часами, включая офферы магазина и стол сортировки.
Интерфейс вдохновлён Kaleidos: тёплая бумага, тонкие 1 px контуры, мягкие
тени и бирюзовый акцент. В первом часе рядом подсказывает маскот **БАЙТ**.
В главном меню — лёгкий процедурный VHS-эффект (скан-линии, зерно, виньетка),
который отключается в настройках; в игре UI остаётся чистым. Фон — ambient
машинного зала, включается после первого жеста и регулируется вместе с музыкой.
UI рисуется кодом, без внешних текстур; Handjet держит плотный русский
текст, Press Start 2P — логотип и цифры. Звуки синтезированы скриптом
проекта. Собрано на Godot 4.7.2 для Web без threads:
игра запускается в браузере без COOP/COEP. Это играбельный джем-прототип
полного цикла: меню, подготовка, девять часов, итоги, магазин, пауза,
настройки, атаки, сохранение и мета-открытия.
тени и бирюзовый акцент. В первом часу БАЙТ ведёт пошаговый туториал:
пакет → прикрепление → TCP-соединение → мини-игра → SLA и атаки; шаг можно
пропустить кнопкой «ДАЛЕЕ», а повторить обучение — кнопкой «ОБУЧЕНИЕ» в
меню. В главном меню — лёгкий VHS-эффект, который отключается в настройках;
в игре UI остаётся чистым. Фон — ambient машинного зала, включается после
первого жеста и регулируется вместе с музыкой.
Тело интерфейса набрано Golos Text, Press Start 2P — только логотип и цифры;
иконки файлов — пиксельные, из `art/icons/`. Звуки синтезированы скриптом
проекта. Собрано на Godot 4.7.2 для Web без threads: игра запускается в
браузере без COOP/COEP. Это играбельный джем-прототип полного цикла: меню,
подготовка, девять часов, итоги, магазин, пауза, настройки, атаки,
сохранение и мета-открытия.
## Управление
@@ -49,13 +69,17 @@ UI рисуется кодом, без внешних текстур; Handjet д
| --- | --- |
| Выбор, меню, перетаскивание | ЛКМ |
| Контекстное меню | ПКМ |
| Поворот фигуры во время drag | R или ПКМ |
| Пакет из «Приёма» → папка | drag&drop |
| Файл из папки → карточка запроса | drag&drop |
| Поиск по имени в текущей папке | после открытия «Поиска» |
| Мини-игра соединения: три попадания | ЛКМ или `Space` |
| Мини-игра подготовки: отправить | `Enter` |
| Пауза | `Esc` |
| Панель соединения: отмена | `Esc` |
| Файл ↔ стол сортировки | drag&drop |
| Отправка контейнера | кнопка «СОЕДИНИТЬСЯ» (TCP) или «ОТПРАВИТЬ» |
| TCP / UDP | переключатель на карточке запроса |
| Мини-игра: действие | ЛКМ / Space / стрелки |
| `dots`: линия | нажать на «С» и вести курсор по клеткам |
| Пропустить мини-игру | «ПРОПУСТИТЬ»/«СДАТЬСЯ» + второй клик |
| Результат мини-игры | «ПРОДОЛЖИТЬ» или Esc |
| Пауза | Esc |
## Сделано на FEIS Game Jam
@@ -70,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).
## Ссылки
@@ -89,19 +113,19 @@ FEIS Game Jam, 2026. Команда — 4 человека. Godot 4.7.2, GDScrip
![Подготовка](screenshots/prep.png)
2. **Подготовка.** «Подготовка: склад и папки, SLA 128/200, магазин откроется после первого часа».
2. **Подготовка.** «Подготовка: сетка папок, стол сортировки 4×6, SLA 60/60; магазин откроется после первого часа».
![Час пик](screenshots/hour.png)
3. **Час пик.** «Час пик: 3 пакета в приёме и 2 запроса с таймерами/протоколами/слотами».
3. **Час пик.** «Час пик: пакеты-фигуры в приёме и запросы с таймерами, протоколами и слотами».
![Атаки](screenshots/attacks.png)
4. **Атаки.** «DDoS-баннер в приёме и фишинговый запрос с ???».
4. **Атаки.** «Мини-игра атаки: час на паузе, качество и вердикт после действия».
![Магазин](screenshots/shop.png)
5. **Магазин.** «Магазин после часа: доступные улучшения, SLA +24, без крестика».
5. **Магазин.** «Магазин после часа: три предложения, реролл и замок, без крестика».
![Настройки](screenshots/settings.png)
@@ -109,20 +133,20 @@ FEIS Game Jam, 2026. Команда — 4 человека. Godot 4.7.2, GDScrip
## Теги
`godot` `game jam` `management` `simulation` `retro` `90s` `pixel art`
`singleplayer` `short` `web`
godot game jam management simulation retro 90s pixel art singleplayer short web
---
## Перед публикацией (служебное, в текст страницы не вставлять)
- [ ] Пересобрать `build/web` из текущего кода и загрузить в itch.io:
**HTML5**, 1920×1080, без fullscreen-lock.
- [ ] Загрузить 6 кадров из `docs/screenshots/`: 3 обязательных — `main_menu.png`,
`hour.png`, `attacks.png`; дополнительно — `prep.png`, `shop.png`,
`settings.png`; `cover.png` (630×500) — обложка страницы.
- [ ] Сверить ссылку на репозиторий и ветку (`main`); при другой ветке
заменить `main` на фактическую.
- [ ] Пересобрать build/web из текущего кода и загрузить в itch.io:
HTML5, 1920×1080, без fullscreen-lock.
- [ ] Пересобрать скриншоты под v3 (запросы с именами файлов, TCP-полоска,
иконки, сетка 9×6, 3 оффера) и загрузить 6 кадров из docs/screenshots/:
обязательные — main_menu.png, hour.png, attacks.png; дополнительно —
prep.png, shop.png, settings.png; cover.png (630×500) — обложка страницы.
- [ ] Сверить ссылку на репозиторий и ветку (main); при другой ветке
заменить main на фактическую.
- [ ] Прогнать полный забег вручную на itch.io, проверить сохранение после
перезагрузки вкладки.
- [ ] Отправить страницу и сборку в джем.
+4
View File
@@ -1,5 +1,9 @@
# TEST_PLAN — матрица тестов PEAK HOUR
> **Исторический документ v1.** Актуальная матрица и запуск —
> `scripts/run_all_tests.sh` + README §Тесты; числа v3 — `data/balance.json`,
> GDD v3.
> Числовые данные — только `data/balance.json` и `docs/DECISIONS.md`.
> Юнит-Runner — headless-прогон `tests/run_tests.gd`
> (`godot --headless --path . --script tests/run_tests.gd`).
+84 -48
View File
@@ -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-фон только здесь |
| `vhs_overlay.gd` | процедурный CRT/VHS-слой меню (RefCounted): скан-линии, зерно, виньетка, полоса |
| `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,54 +57,84 @@ 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-эффект главного меню
Тонкий CRT/VHS-слой есть **только на главном меню** (`scripts/ui/vhs_overlay.gd`).
Это `RefCounted`, а не Node/Control: меню само рисует слой в `MainMenu._draw()`.
Тонкий CRT/VHS-слой есть **только на главном меню** (`shaders/vhs.gdshader` +
`scripts/ui/vhs_overlay.gd`). Полноэкранный `ColorRect` с canvas_item-шейдером
читает готовый кадр через `hint_screen_texture`.
Порядок отрисовки:
Ключевое — **native-res**: эффект считается в физических пикселях окна
(`FRAGCOORD`/`SCREEN_PIXEL_SIZE`). При 1920×1080 скан-линии идут ровно каждые
2 px, без блоков и banding; логика UI при этом остаётся 1280×720
(`stretch/mode = canvas_items`), но VHS не растягивает 1280-текстуру.
1. бумага;
2. VHS по фону — скан-линии, зерно, виньетка, полоса;
3. карточка, правая панель и декоративные фигуры;
4. мягкий VHS-проход по карточке и панели (0.72 силы, без виньетки);
5. текст и кнопки.
- screen texture сэмплится с линейной фильтрацией 1:1. Identity-проверка
при 1920×1080: diff в области текста = 0, то есть картинка не мылится;
- хроматика ≤0.0003 UV (~0.6 px у края кадра), зелёный канал не сдвигается —
буквы не двоятся;
- полоса меняет только яркость и не двигает изображение, поэтому текст
под ней не «плывёт».
Поэтому слой не перекрывает меню, не перехватывает мышь и не может затереть
кадр фоном подготовки. Кнопки — дети `MainMenu`, они рисуются поверх.
Screen texture (`hint_screen_texture`) не используется: интерфейс не сэмплится
и остаётся резким.
Параметры (`VhsOverlay`):
Параметры (`VhsOverlay` / шейдер):
| Параметр | Значение | Смысл |
| --- | --- | --- |
| `intensity` | **0.42** (макс. 0.55) | общая сила слоя |
| `SCANLINE_STEP` | 2 лог. px | шаг скан-линий, 1 px затемнения |
| `SCANLINE_STRENGTH` | 0.07 | затемнение линий |
| проход по карточке | ×0.72 | мягче фона, текст рисуется поверх |
| зерно | тайл 128×128, 2 Гц | амплитуда ≤0.05, без мерцания |
| виньетка | 0.13 | затемнение к краям кадра |
| полоса | ~18 с на проход | мягкая тёмная, со светлой кромкой и дрожанием ±4 px |
| `intensity` | **0.40** (макс. 0.55) | общая сила слоя |
| `scanline_step` | 2 физ. px | тонкие ровные скан-линии |
| `scanline_strength` | 0.06 | затемнение ≈2.4% при intensity 0.40 |
| `aberration` | 0.0003 UV | мягкая хроматика, без двоения |
| `grain` | 0.025, 2 Гц | слабое зерно, без мерцания |
| `vignette` | 0.11 | затемнение к краям кадра |
| `band` | 0.04, ~18 с на проход | редкая полоса, только яркость |
Скан-линии рисуются в канве меню (логические 1280×720) и масштабируются
вместе с UI: на 1920×1080 шаг ≈3 физических px. Это осознанный компромисс —
эффект не читает экранную текстуру, поэтому не бывает мыла и затирания UI.
Слой — последний ребёнок `MainMenu`, `mouse_filter = IGNORE`,
`process_mode = ALWAYS`. Модалки (настройки, подтверждение) — отдельные
соседи в `modal_root`, рисуются поверх и **не** фильтруются.
В HEADLESS текстуры не создаются и слой не рисуется — тесты не тормозят.
В HEADLESS материал не создаётся, слой скрыт — тесты не тормозят.
**Как отключить:**
@@ -116,5 +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).
Binary file not shown.

Before

Width:  |  Height:  |  Size: 69 KiB

After

Width:  |  Height:  |  Size: 188 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 52 KiB

After

Width:  |  Height:  |  Size: 1.2 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 88 KiB

After

Width:  |  Height:  |  Size: 231 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 82 KiB

After

Width:  |  Height:  |  Size: 1.4 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 133 KiB

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

Before

Width:  |  Height:  |  Size: 62 KiB

After

Width:  |  Height:  |  Size: 106 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 49 KiB

After

Width:  |  Height:  |  Size: 1.2 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 81 KiB

After

Width:  |  Height:  |  Size: 142 KiB

+1 -1
View File
@@ -8,7 +8,7 @@ config_version=5
config/name="PEAK HOUR 97"
config/description="Час пик. Одна смена. Ни одного свободного места."
config/version="0.1.0"
config/version="2.0.0"
run/main_scene="res://scenes/Main.tscn"
run/max_fps=60
config/features=PackedStringArray("4.7", "GL Compatibility")
+368 -67
View File
@@ -1,88 +1,148 @@
extends Node
## Data — доступ к JSON-данным из res://data (autoload).
##
## Файлы данных сейчас заготовки, поэтому отсутствующие ключи заменяются
## безопасными дефолтами с push_warning; игра не должна падать из-за данных.
## v2: новые секции balance.json (constants/hours/grid/shop_rules/shop/
## minigames/attacks/payout/sla). Отсутствующие ключи заменяются безопасными
## дефолтами с push_warning; игра не должна падать из-за данных.
##
## Legacy-API (tr, type_def, difficulty_for, shop_item, available_shop,
## folder_name) сохранён для ещё не переписанных систем.
const DATA_DIR := "res://data"
const DEFAULT_TYPES := {
"text": {
"name_ru": "Текст", "ext": "txt", "icon": "text",
"recv_time": 2.0, "send_time": 1.5, "recv_fee": 1,
"base_pay": 80, "weight": 8, "minigame_coeff": 1.0, "size_kb": 64,
"sla_loss": 2,
"size_kb": 64,
"cells": 1, "base_time_sec": 3, "recv_fee": 1,
"base_pay": 18, "weight": 1, "minigame_coeff": 1, "sla_loss": 2,
"shapes": ["O1"],
},
"sound": {
"name_ru": "Звук", "ext": "mp3", "icon": "sound",
"recv_time": 3.5, "send_time": 2.5, "recv_fee": 2,
"base_pay": 140, "weight": 12, "minigame_coeff": 2.0, "size_kb": 3072,
"sla_loss": 4,
"size_kb": 3072,
"cells": 2, "base_time_sec": 5, "recv_fee": 2,
"base_pay": 30, "weight": 2, "minigame_coeff": 2, "sla_loss": 4,
"shapes": ["I2H", "I2V"],
},
"photo": {
"name_ru": "Фото", "ext": "jpg", "icon": "photo",
"recv_time": 5.0, "send_time": 3.5, "recv_fee": 3,
"base_pay": 220, "weight": 18, "minigame_coeff": 3.0, "size_kb": 5120,
"sla_loss": 8,
"size_kb": 5120,
"cells": 3, "base_time_sec": 8, "recv_fee": 4,
"base_pay": 44, "weight": 4, "minigame_coeff": 4, "sla_loss": 8,
"shapes": ["I3H", "I3V", "L3a", "L3b", "L3c", "L3d"],
},
"video": {
"name_ru": "Видео", "ext": "mp4", "icon": "video",
"recv_time": 8.0, "send_time": 6.0, "recv_fee": 4,
"base_pay": 380, "weight": 22, "minigame_coeff": 4.0, "size_kb": 20480,
"sla_loss": 16,
"size_kb": 20480,
"cells": 4, "base_time_sec": 12, "recv_fee": 6,
"base_pay": 69, "weight": 8, "minigame_coeff": 8, "sla_loss": 16,
"shapes": ["I4H", "I4V", "O4", "T4a", "T4b", "T4c", "T4d",
"L4a", "L4b", "L4c", "L4d", "J4a", "J4b", "J4c", "J4d", "S4", "Z4"],
},
}
## Таблица сложности — до часа 8 (D12); дальше используется последняя строка.
## Таблица сложности v1 — оставлена для legacy difficulty_for().
const DEFAULT_DIFFICULTY := [
{"hour": 1, "max_requests": 2, "spawn_sec": 7.0},
{"hour": 2, "max_requests": 3, "spawn_sec": 6.5},
{"hour": 3, "max_requests": 3, "spawn_sec": 6.0},
{"hour": 4, "max_requests": 4, "spawn_sec": 5.5},
{"hour": 5, "max_requests": 4, "spawn_sec": 5.0},
{"hour": 6, "max_requests": 5, "spawn_sec": 4.5},
{"hour": 7, "max_requests": 5, "spawn_sec": 4.2},
{"hour": 8, "max_requests": 6, "spawn_sec": 4.0},
{"hour": 2, "max_requests": 5, "spawn_sec": 6.5},
{"hour": 3, "max_requests": 7, "spawn_sec": 6.0},
{"hour": 4, "max_requests": 9, "spawn_sec": 5.5},
{"hour": 5, "max_requests": 10, "spawn_sec": 5.0},
{"hour": 6, "max_requests": 11, "spawn_sec": 4.5},
{"hour": 7, "max_requests": 11, "spawn_sec": 4.2},
{"hour": 8, "max_requests": 12, "spawn_sec": 4.0},
]
const DEFAULT_SHOP := [
{"id": "slot_3", "name": "3-й слот", "cost": 250, "max_level": 1},
{"id": "slot_4", "name": "4-й слот", "cost": 450, "max_level": 1},
{"id": "buffer", "name": "Буфер +2", "cost": 200, "max_level": 2},
{"id": "packet_timer", "name": "Таймер пакетов +4 с", "cost": 350, "max_level": 3},
{"id": "zone", "name": "Зона +5 %", "cost": 300, "max_level": 3},
{"id": "channel", "name": "Канал +25", "cost": 250, "max_level": 4},
{"id": "section", "name": "Секция", "cost": 300, "max_level": 5},
{"id": "search", "name": "Поиск", "cost": 250, "max_level": 1},
{"id": "shield", "name": "Щит", "cost": 400, "max_level": 1},
{"id": "shop_sla", "name": "SLA +24", "effect": "sla_add", "value": 24, "cost": 300, "max_level": 3},
const DEFAULT_SEND_ORDER := ["dots", "sound", "text", "video", "photo", "maze"]
const DEFAULT_ACHIEVABLE_SUMS := [
1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20,
21, 22, 24, 25, 26, 28, 32,
]
## Прогрессия/открытия (дублирует data/balance.json → progression).
const DEFAULT_PROGRESSION := {
"unlocks": {
"shop": {"run_hour": 2, "label": "Магазин"},
"search": {"meta": "survive_hour_3", "label": "Поиск"},
"shield": {"meta": "survive_hour_5", "label": "Щит"},
"minigame_text": {"run_hour": 1},
"minigame_sound": {"run_hour": 2},
"minigame_photo": {"run_hour": 3},
"minigame_video": {"run_hour": 4},
"minigame_advanced": {"meta": "complete_run", "label": "Продвинутые мини-игры"},
"attack_junk": {"run_hour": 2},
"attack_ddos": {"run_hour": 2},
"attack_phishing": {"run_hour": 3},
"attack_ransomware": {"run_hour": 4},
},
"meta_rewards": {
"survive_hour_3": {"min_hours": 3, "unlocks": ["search"]},
"survive_hour_5": {"min_hours": 5, "unlocks": ["shield"]},
"complete_run": {"min_hours": 9, "unlocks": ["minigame_advanced"]},
},
const DEFAULT_HOUR_FILES := [4, 8, 12, 16, 20, 20, 20, 20, 20]
const DEFAULT_REQUEST_CAP := [2, 5, 7, 9, 10, 11, 11, 12, 12]
const DEFAULT_ACTIVE_REQUESTS := [1, 2, 2, 3, 3, 3, 4, 4, 4]
const DEFAULT_ATTACKS := [0, 1, 1, 2, 2, 3, 3, 4, 4]
const DEFAULT_INCOME_TARGET := [90, 220, 380, 560, 760, 980, 1200, 1450, 1700]
const DEFAULT_RECV_INTERVAL := [12, 11, 10, 9, 8, 7.5, 7, 6.5, 6]
const DEFAULT_SEND_INTERVAL := [22, 20, 18, 17, 15, 14, 13, 12, 12]
## Порядок: text, sound, photo, video (§2.5).
const DEFAULT_TYPE_WEIGHTS := [
[0.70, 0.30, 0.00, 0.00],
[0.55, 0.30, 0.15, 0.00],
[0.40, 0.30, 0.22, 0.08],
[0.30, 0.25, 0.28, 0.17],
[0.22, 0.20, 0.32, 0.26],
[0.15, 0.15, 0.35, 0.35],
[0.10, 0.12, 0.35, 0.43],
[0.06, 0.08, 0.32, 0.54],
[0.04, 0.04, 0.30, 0.62],
]
## Доли числа файлов в запросе: 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",
"lot_money", "lot_folder", "lot_channel", "lot_timer",
]
const DEFAULT_LOT_NAMES := [
"Порты коммутатора", "Оптоволокно", "Резервный ИБП", "Заземление",
"Корпоративный контракт", "Складской стеллаж", "Пропускная способность",
"Регламент обработки",
]
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.10, 40, 1, 0.05, 1, 6, 7]
const DEFAULT_LOT_MAX := [4, 6, 4, 3, 8, 5, 6, 5]
const DEFAULT_LOT_PRICES := [
[90, 200, 400, 700],
[55, 75, 100, 135, 180, 245],
[80, 160, 270, 430],
[100, 220, 410],
[70, 95, 130, 170, 220, 285, 365, 465],
[50, 100, 180, 290, 440],
[45, 70, 95, 130, 175, 230],
[45, 75, 115, 165, 230],
]
const DEFAULT_LOT_WEIGHTS := [
[1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 0.8, 0.8, 0.8],
[1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 0.8, 0.8, 0.8],
[0.8, 0.8, 0.8, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0],
[0.3, 0.3, 0.3, 0.8, 0.8, 0.8, 1.0, 1.0, 1.0],
[0.6, 0.6, 0.6, 1.0, 1.0, 1.0, 1.2, 1.2, 1.2],
[1.0, 1.0, 1.0, 0.9, 0.9, 0.9, 0.8, 0.8, 0.8],
[1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 0.9, 0.9, 0.9],
[1.0, 1.0, 1.0, 0.9, 0.9, 0.9, 0.7, 0.7, 0.7],
]
const DEFAULT_SHOP_RULES := {
"offers": 3,
"unlock_hour": 2,
"reroll_costs": [40, 80, 120],
"max_rerolls": 3,
"last_offer_weight": 0.5,
"pity_hours": 2,
"pity_weight": 1.5,
}
const DEFAULT_GRID_DIMS := [[5, 4], [6, 4], [7, 5], [8, 5], [8, 6], [9, 6]]
var balance: Dictionary = {}
var constants: Dictionary = {}
var economy: Dictionary = {}
var types: Dictionary = {}
var hours: Array = []
var grid: Dictionary = {}
var shop_lots: Array = []
var rules: Dictionary = {}
var minigames: Dictionary = {}
var attacks: Dictionary = {}
var payout_rules: Dictionary = {}
var sla_rules: Dictionary = {}
var difficulty: Array = []
var shop: Array = []
var names: Dictionary = {}
@@ -100,9 +160,27 @@ func _ready() -> void:
func load_all() -> void:
_cache.clear()
balance = load_json("balance.json")
constants = _as_dict(balance.get("constants", null))
economy = _as_dict(balance.get("economy", null))
types = _merge_types(balance.get("types", null))
hours = _load_hours(balance.get("hours", null))
grid = _as_dict(balance.get("grid", null))
if grid.is_empty():
push_warning("Data: balance.json → grid отсутствует, используются дефолты")
grid = {"folder_cols": 5, "folder_rows": 4, "gap": 6,
"cell_min": 56, "cell_max": 104, "table_cols": 4, "table_rows": 6,
"folder_dims": DEFAULT_GRID_DIMS.duplicate(true), "shapes": {}}
shop_lots = _load_shop_lots(balance.get("shop", null))
shop = shop_lots
rules = _load_rules(balance.get("shop_rules", null))
minigames = _as_dict(balance.get("minigames", null))
if minigames.is_empty():
push_warning("Data: balance.json → minigames отсутствует")
minigames = {"games": {}, "selection": {}}
attacks = _as_dict(balance.get("attacks", null))
payout_rules = _as_dict(balance.get("payout", null))
sla_rules = _as_dict(balance.get("sla", null))
difficulty = _load_difficulty(balance.get("difficulty", null))
shop = _load_shop(balance.get("shop", null))
names = _load_names(load_json("names_ru.json"))
strings = load_json("strings_ru.json")
var progression = balance.get("progression", null)
@@ -182,7 +260,7 @@ func type_def(type: String) -> Dictionary:
return DEFAULT_TYPES["text"].duplicate(true)
## Строка таблицы сложности; для hour > max — последняя строка.
## Строка таблицы сложности; для hour > max — последняя строка (legacy).
func difficulty_for(hour: int) -> Dictionary:
var h := maxi(1, int(hour))
if difficulty.is_empty():
@@ -204,6 +282,140 @@ func difficulty_for(hour: int) -> Dictionary:
return last.duplicate(true)
# --- v2: часы --------------------------------------------------------------
## Строка бюджета часа (files/request_cap/active_requests/attacks/
## income_target/type_weights/request_mix). hour > 9 → последняя строка.
func hour_def(hour: int) -> Dictionary:
var h := maxi(1, int(hour))
if hours.is_empty():
push_warning("Data: balance.json → hours отсутствует, дефолт часа %d" % h)
return _default_hour_row(h)
var prev: Dictionary = hours[0]
for row in hours:
if not (row is Dictionary):
continue
var row_hour := int(row.get("hour", 0))
if row_hour == h:
return row.duplicate(true)
if row_hour < h:
prev = row
elif row_hour > h:
return prev.duplicate(true)
return (hours[hours.size() - 1] as Dictionary).duplicate(true)
func hour_count() -> int:
return maxi(1, hours.size())
# --- v2: магазин -----------------------------------------------------------
func lot_defs() -> Array:
return shop_lots.duplicate(true)
func lot_def(id: String) -> Dictionary:
for lot in shop_lots:
if lot is Dictionary and str(lot.get("id", "")) == str(id):
return lot.duplicate(true)
push_warning("Data: нет лота '%s'" % str(id))
return {}
func shop_rules() -> Dictionary:
return rules.duplicate(true)
# --- v2: мини-игры ---------------------------------------------------------
func minigame_def(id: String) -> Dictionary:
var games = minigames.get("games", null)
if games is Dictionary:
var def = (games as Dictionary).get(str(id), null)
if def is Dictionary:
return (def as Dictionary).duplicate(true)
push_warning("Data: нет мини-игры '%s'" % str(id))
return {}
func minigame_selection() -> Dictionary:
var out: Dictionary = {}
var raw = minigames.get("selection", null)
if raw is Dictionary:
out = (raw as Dictionary).duplicate(true)
if not (out.get("order", null) is Array) or (out.get("order", null) as Array).is_empty():
out["order"] = DEFAULT_SEND_ORDER.duplicate()
if not (out.get("achievable_sums", null) is Array) or (out.get("achievable_sums", null) as Array).is_empty():
out["achievable_sums"] = DEFAULT_ACHIEVABLE_SUMS.duplicate()
out["max_index"] = int(out.get("max_index", 26))
var coeff: Dictionary = {}
for key in ["text", "sound", "photo", "video"]:
coeff[key] = int(type_def(key).get("minigame_coeff", DEFAULT_TYPES[key].get("minigame_coeff", 1)))
out["coeff"] = coeff
return out
## Legacy-ступень для индекса (idx / 6 + 1), три ступени отправки.
func minigame_tier(index: int) -> int:
return clampi(1 + int(floor(float(maxi(0, index)) / 6.0)), 1, 3)
# --- v2: атаки -------------------------------------------------------------
func attack_def(id: String) -> Dictionary:
var kinds = attacks.get("kinds", null)
if kinds is Dictionary:
var def = (kinds as Dictionary).get(str(id), null)
if def is Dictionary:
return (def as Dictionary).duplicate(true)
push_warning("Data: нет атаки '%s'" % str(id))
return {}
func attack_tier(hour: int) -> int:
return clampi(1 + int(floor(float(maxi(1, int(hour)) - 2) / 2.0)), 1, 4)
func attack_schedule() -> Array:
var raw = attacks.get("schedule", null)
if raw is Array:
return (raw as Array).duplicate()
return DEFAULT_ATTACKS.duplicate()
# --- v2: сетка -------------------------------------------------------------
func grid_rules() -> Dictionary:
return grid.duplicate(true)
func shape_def(id: String) -> Dictionary:
var shapes = grid.get("shapes", null)
if shapes is Dictionary:
var def = (shapes as Dictionary).get(str(id), null)
if def is Dictionary:
return (def as Dictionary).duplicate(true)
push_warning("Data: нет фигуры '%s'" % str(id))
return {}
func shapes() -> Dictionary:
var raw = grid.get("shapes", null)
if raw is Dictionary:
return (raw as Dictionary).duplicate(true)
return {}
func folder_dims_table() -> Array:
var raw = grid.get("folder_dims", null)
if raw is Array and not (raw as Array).is_empty():
return (raw as Array).duplicate(true)
return DEFAULT_GRID_DIMS.duplicate(true)
# --- legacy shop API -------------------------------------------------------
func shop_item(id: String) -> Dictionary:
for item in shop:
if item is Dictionary and str(item.get("id", "")) == str(id):
@@ -213,7 +425,6 @@ func shop_item(id: String) -> Dictionary:
## Ключ открытия для позиции магазина. "" — позиция доступна всегда.
## shop_sla продаётся вне прогрессии; поиск/щит открываются мета-наградами.
func shop_unlock_key(id: String) -> String:
match str(id):
"shop_sla":
@@ -228,7 +439,10 @@ func shop_unlock_key(id: String) -> String:
## Открыта ли позиция магазина по текущему состоянию Game (run_hour/meta).
func shop_item_unlocked(item: Dictionary) -> bool:
var key := shop_unlock_key(str(item.get("id", "")))
var id := str(item.get("id", ""))
if id.begins_with("lot_"):
return true
var key := shop_unlock_key(id)
if key.is_empty():
return true
var game = _game_node()
@@ -237,7 +451,7 @@ func shop_item_unlocked(item: Dictionary) -> bool:
return true
## Каталог магазина без закрытых позиций (shop_sla виден всегда).
## Каталог магазина без закрытых позиций.
func available_shop() -> Array:
var out: Array = []
for item in shop:
@@ -261,6 +475,13 @@ func folder_name() -> String:
return str(values[randi_range(0, values.size() - 1)])
## Значение из constants с fallback (для других систем).
func const_value(key: String, fallback_value = null):
if constants.has(key):
return constants[key]
return fallback_value
# --- служебное -------------------------------------------------------------
func _game_node():
@@ -270,13 +491,31 @@ func _game_node():
return null
func _as_dict(raw) -> Dictionary:
if raw is Dictionary:
return (raw as Dictionary).duplicate(true)
return {}
func _load_unlocks(progression) -> Dictionary:
if progression is Dictionary:
var raw = progression.get("unlocks", null)
if raw is Dictionary and not raw.is_empty():
return raw.duplicate(true)
push_warning("Data: balance.json → progression.unlocks отсутствует, используются дефолты")
return DEFAULT_PROGRESSION["unlocks"].duplicate(true)
return {
"shop": {"run_hour": 2, "label": "Магазин"},
"search": {"meta": "survive_hour_3", "label": "Поиск"},
"shield": {"meta": "survive_hour_5", "label": "Щит"},
"minigame_text": {"run_hour": 1},
"minigame_sound": {"run_hour": 2},
"minigame_photo": {"run_hour": 3},
"minigame_video": {"run_hour": 4},
"attack_junk": {"run_hour": 2},
"attack_ddos": {"run_hour": 2},
"attack_phishing": {"run_hour": 3},
"attack_ransomware": {"run_hour": 4},
}
func _load_meta_rewards(progression) -> Dictionary:
@@ -285,7 +524,12 @@ func _load_meta_rewards(progression) -> Dictionary:
if raw is Dictionary and not raw.is_empty():
return raw.duplicate(true)
push_warning("Data: balance.json → progression.meta_rewards отсутствует, используются дефолты")
return DEFAULT_PROGRESSION["meta_rewards"].duplicate(true)
return {
"survive_hour_3": {"min_hours": 3, "unlocks": ["search"]},
"survive_hour_5": {"min_hours": 5, "unlocks": ["shield"]},
"complete_run": {"min_hours": 9, "unlocks": ["minigame_advanced"]},
}
func _merge_types(raw) -> Dictionary:
var out: Dictionary = DEFAULT_TYPES.duplicate(true)
@@ -319,22 +563,79 @@ func _load_difficulty(raw) -> Array:
return DEFAULT_DIFFICULTY.duplicate(true)
func _load_shop(raw) -> Array:
if raw is Array and not raw.is_empty():
func _load_hours(raw) -> Array:
if raw is Array and not (raw as Array).is_empty():
var out: Array = []
for row in raw:
if row is Dictionary:
out.append(row.duplicate(true))
if not out.is_empty():
out.sort_custom(func(a, b): return int(a.get("hour", 0)) < int(b.get("hour", 0)))
return out
push_warning("Data: balance.json → hours отсутствует, используются дефолты")
var fallback: Array = []
for i in range(9):
fallback.append(_default_hour_row(i + 1))
return fallback
func _default_hour_row(hour: int) -> Dictionary:
var idx := clampi(hour - 1, 0, 8)
var type_keys := ["text", "sound", "photo", "video"]
var weights: Dictionary = {}
for i in range(type_keys.size()):
weights[type_keys[i]] = DEFAULT_TYPE_WEIGHTS[idx][i]
return {
"hour": maxi(1, hour),
"files": DEFAULT_HOUR_FILES[idx],
"request_cap": DEFAULT_REQUEST_CAP[idx],
"active_requests": DEFAULT_ACTIVE_REQUESTS[idx],
"attacks": DEFAULT_ATTACKS[idx],
"income_target": DEFAULT_INCOME_TARGET[idx],
"recv_interval": DEFAULT_RECV_INTERVAL[idx],
"send_interval": DEFAULT_SEND_INTERVAL[idx],
"type_weights": weights,
"request_mix": DEFAULT_REQUEST_MIX.duplicate(),
}
func _load_shop_lots(raw) -> Array:
if raw is Array and not (raw as Array).is_empty():
var out: Array = []
for item in raw:
if item is Dictionary and not str(item.get("id", "")).is_empty():
out.append(item.duplicate(true))
if not out.is_empty():
return out
push_warning("Data: balance.json → shop отсутствует, используется дефолт")
return DEFAULT_SHOP.duplicate(true)
push_warning("Data: balance.json → shop отсутствует, используются 8 дефолтных лотов")
var fallback: Array = []
for i in range(DEFAULT_LOT_IDS.size()):
fallback.append({
"id": DEFAULT_LOT_IDS[i],
"name_ru": DEFAULT_LOT_NAMES[i],
"desc_ru": "",
"effect": DEFAULT_LOT_EFFECTS[i],
"value": DEFAULT_LOT_VALUES[i],
"max_level": DEFAULT_LOT_MAX[i],
"prices": DEFAULT_LOT_PRICES[i].duplicate(),
"level_names": [],
"weight_by_hour": DEFAULT_LOT_WEIGHTS[i].duplicate(),
})
return fallback
func _load_rules(raw) -> Dictionary:
var out: Dictionary = DEFAULT_SHOP_RULES.duplicate(true)
if raw is Dictionary and not raw.is_empty():
for key in raw.keys():
out[str(key)] = raw[key]
return out
push_warning("Data: balance.json → shop_rules отсутствует, используются дефолты")
return out
func _load_names(raw) -> Dictionary:
if raw is Dictionary and not raw.is_empty():
# Сохраняем все ключи файла (adjectives/nouns/organizations/folders/...),
# а не только жёстко заданный набор.
return raw.duplicate(true)
push_warning("Data: names_ru.json пуст, используются пустые пулы имён")
return {
+358 -29
View File
@@ -1,6 +1,10 @@
extends Node
## Game — верхнеуровневое состояние забега (autoload).
##
## v2-экономика: старт 50 $, SLA 60/60, 1 слот, сетка 5×4, канал 10,
## буфер 6, таймер запроса 30 с. Уровни лотов хранятся в Game.upgrades
## одновременно под id лота и под агрегированным ключом эффекта.
##
## Не знает о сценах и UI: только данные, переходы фаз и сигналы.
## Фазы: menu → prep → hour → ... → results.
@@ -11,26 +15,41 @@ signal phase_changed(p)
signal run_ended(reason)
signal toast(text)
const START_MONEY := 150
const START_SLA := 128.0
const START_MONEY := 50
const START_SLA := 60.0
const HOURS_PER_RUN := 9
const MAX_SLA := 200.0
## Жёсткий кап SLA (main.gd показывает его в статус-баре). Рабочий кап —
## sla_cap(): 60 + 40 * sla_level, не выше MAX_SLA.
const MAX_SLA := 220.0
const REGISTRY_SCRIPT := preload("res://scripts/core/file_registry.gd")
const SAVE_SCRIPT := preload("res://scripts/systems/save_system.gd")
const SHOP_SCRIPT := preload("res://scripts/systems/shop.gd")
## Резерв, если Data ещё не готов (id → товар).
## effect → агрегированный ключ уровня в Game.upgrades.
const LOT_EFFECT_KEYS := {
"connection_slots": "slot_level",
"transfer_speed": "speed_level",
"sla_cap": "sla_level",
"shield_charge": "shield_level",
"money_mult": "money_level",
"folder_cells": "folder_level",
"channel_capacity": "channel_level",
"request_timer": "timer_level",
}
const FALLBACK_LOT_DIMS := [[5, 4], [6, 4], [7, 5], [8, 5], [8, 6], [9, 6]]
## Резерв, если Data ещё не готов (id → лот минимально необходимых полей).
const FALLBACK_SHOP := {
"shop_slot3": {"id": "shop_slot3", "name_ru": "Третий слот", "price": 250, "max_level": 1},
"shop_slot4": {"id": "shop_slot4", "name_ru": "Четвёртый слот", "price": 450, "max_level": 1},
"shop_buffer": {"id": "shop_buffer", "name_ru": "Буфер +2", "price": 200, "max_level": 2},
"shop_timer": {"id": "shop_timer", "name_ru": "Таймер пакетов +4 с", "price": 350, "max_level": 3},
"shop_zone": {"id": "shop_zone", "name_ru": "Зона +5%", "price": 300, "max_level": 3},
"shop_channel": {"id": "shop_channel", "name_ru": "Канал +25", "price": 250, "max_level": 4},
"shop_section": {"id": "shop_section", "name_ru": "Новая секция", "price": 300, "max_level": 5},
"shop_search": {"id": "shop_search", "name_ru": "Поиск", "price": 250, "max_level": 1},
"shop_shield": {"id": "shop_shield", "name_ru": "Щит", "price": 400, "max_level": 1},
"shop_sla": {"id": "shop_sla", "name_ru": "SLA +24", "price": 300, "max_level": 3},
"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
@@ -44,6 +63,19 @@ var upgrades: Dictionary = {}
var meta_unlocks: Dictionary = {}
## Награды, выданные в текущем забеге (для итогового экрана); чистится новым забегом.
var run_meta_awards: Array[String] = []
## v2-магазин: 3 id, счётчик рероллов, час генерации, pity-история и замки.
var shop_offers: Array[String] = []
var shop_rerolls: int = 0
var shop_offer_hour: int = 0
var shop_last_seen: Dictionary = {}
var shop_locked: Array[String] = []
## Заряды «Заземления»: восстанавливаются в начале часа.
var shield_charges: int = 0
## Час, за который SLA-реген уже выдан (идемпотентность вызова).
var _sla_regen_hour: int = 0
## FileRegistry; без type hint, чтобы работать без прогретого class cache.
var registry = null
var rng_seed: int = 0
@@ -78,6 +110,8 @@ func start_hour() -> bool:
return false
if registry == null:
registry = _make_registry()
regen_sla_for_hour()
regen_shield_for_hour()
phase = &"hour"
phase_changed.emit(phase)
toast.emit(_tr("msg.hour_start", "Час %d" % hour))
@@ -99,6 +133,9 @@ func end_hour() -> bool:
var finished := hour
hour += 1
phase = &"prep"
# Рероллы живут одну подготовку.
shop_rerolls = 0
shop_locked.clear()
hour_changed.emit(hour)
phase_changed.emit(phase)
toast.emit(_tr("msg.hour_end", "Час %d завершён" % finished))
@@ -128,7 +165,7 @@ func add_money(delta: int, reason: String = "") -> void:
func add_sla(delta: float, reason: String = "") -> void:
sla = clampf(sla + float(delta), 0.0, MAX_SLA)
sla = clampf(sla + float(delta), 0.0, sla_cap())
if not reason.is_empty():
stats["last_reason"] = reason
sla_changed.emit(sla)
@@ -136,10 +173,145 @@ func add_sla(delta: float, reason: String = "") -> void:
_finish_run("sla")
## +15 SLA в начале часа (не выше динамического капа). Возвращает новый SLA.
## Идемпотентно в пределах одного часа: повторный вызов не добавляет SLA.
func regen_sla_for_hour() -> float:
if _sla_regen_hour == hour:
return sla
_sla_regen_hour = hour
var regen := _const("sla_regen_per_hour", 15.0)
sla = clampf(sla + regen, 0.0, sla_cap())
sla_changed.emit(sla)
return sla
## Заряды щита восстанавливаются до максимума в начале часа.
func regen_shield_for_hour() -> int:
shield_charges = shield_max()
return shield_charges
func add_stat(key: String, delta: int = 1) -> void:
stats[key] = int(stats.get(key, 0)) + int(delta)
# --- v2: уровни лотов и производные статы ----------------------------------
## Уровень по id лота (lot_*) или по эффекту/агрегату
## (connection_slots / slot_level / ...).
func level(effect_or_lot: String) -> int:
var key := str(effect_or_lot)
if key.is_empty():
return 0
if key.begins_with("lot_"):
if upgrades.has(key):
return int(upgrades[key])
var def := _lot_def(key)
var effect := str(def.get("effect", ""))
var agg := str(LOT_EFFECT_KEYS.get(effect, ""))
if not agg.is_empty():
return int(upgrades.get(agg, 0))
return 0
if upgrades.has(key):
return int(upgrades[key])
if LOT_EFFECT_KEYS.has(key):
return int(upgrades.get(str(LOT_EFFECT_KEYS[key]), 0))
return 0
## Записать уровень лота в оба ключа и сразу применить мгновенные эффекты
## (SLA-кап/щит). Остальные статы читаются геттерами динамически.
func set_lot_level(id: String, new_level: int) -> int:
var def := _lot_def(id)
if def.is_empty():
return 0
if not (upgrades is Dictionary):
upgrades = {}
var max_level := maxi(1, int(def.get("max_level", 1)))
var old_level := level(id)
var lvl := clampi(int(new_level), 0, max_level)
upgrades[id] = lvl
var effect := str(def.get("effect", ""))
var agg := str(LOT_EFFECT_KEYS.get(effect, ""))
if not agg.is_empty():
upgrades[agg] = lvl
if effect == "sla_cap" and lvl > old_level:
# Кап уже вырос (sla_cap() читает новый уровень), добавляем +40 сразу.
var gain := _lot_value(def, 40.0) * float(lvl - old_level)
sla = clampf(sla + gain, 0.0, sla_cap())
sla_changed.emit(sla)
elif effect == "shield_charge" and lvl > old_level:
shield_charges = mini(shield_max(), shield_charges + (lvl - old_level))
if effect == "folder_cells" and lvl != old_level and registry != null \
and registry.has_method("resize_folders"):
# Лот растит уже существующие папки, а не только будущие.
registry.resize_folders(folder_dims())
return lvl
func sla_cap() -> float:
var base := _const("sla_base", START_SLA)
var step := _lot_effect_value("lot_sla", "value", _const("sla_per_level", 40.0))
var hard := _const("sla_hard_cap", MAX_SLA)
return clampf(base + step * float(level("sla_level")), base, hard)
func money_mult() -> float:
var step := _lot_effect_value("lot_money", "value", _const("money_mult_step", 0.05))
return 1.0 + step * float(level("money_level"))
## pow(0.90, speed_level) — множитель времени передачи (10% за уровень).
func speed_mult() -> float:
var cut := clampf(_lot_effect_value("lot_speed", "value", 0.10), 0.01, 0.9)
return pow(1.0 - cut, float(maxi(0, level("speed_level"))))
func folder_dims() -> Vector2i:
var table := _folder_dims_table()
if table.is_empty():
table = FALLBACK_LOT_DIMS
var idx := clampi(level("folder_level"), 0, table.size() - 1)
var pair = table[idx]
if pair is Array and (pair as Array).size() >= 2:
return Vector2i(int(pair[0]), int(pair[1]))
return Vector2i(5, 4)
func connection_slots() -> int:
var base := int(_const("slots_start", 1.0))
var cap := int(_const("slots_max", 5.0))
return clampi(base + level("slot_level"), base, cap)
func channel_capacity() -> float:
var base := _const("channel_capacity", 10.0)
var step := _lot_effect_value("lot_channel", "value", _const("channel_per_level", 6.0))
return base + step * float(level("channel_level"))
func buffer_limit() -> int:
var base := int(_const("buffer_base", 6.0))
var step := int(_lot_effect_value("lot_channel", "buffer_value", _const("buffer_per_level", 1.0)))
var cap := int(_const("buffer_max", 12.0))
return clampi(base + step * level("channel_level"), base, cap)
func request_timer() -> float:
var base := _const("request_timer", 30.0)
var step := _lot_effect_value("lot_timer", "value", _const("request_timer_per_level", 7.0))
var cap := _const("request_timer_max", 65.0)
return clampf(base + step * float(level("timer_level")), base, cap)
func packet_timer() -> float:
return _const("packet_timer", 20.0)
func shield_max() -> int:
return maxi(0, level("shield_level"))
# --- прогрессия/открытия ---------------------------------------------------
## Открыта ли фича по data/balance.json → progression.unlocks.
@@ -190,23 +362,23 @@ func award_meta_for_run(hours: int) -> Array[String]:
return awarded
# --- магазин (заглушка эффектов) ------------------------------------------
# --- магазин (legacy-фасад) ------------------------------------------------
## Покупка проверяет деньги и max_level, но не применяет эффект (P1).
## Покупка по id лота (legacy-путь; UI v2 использует Shop.buy).
func buy_upgrade(id: String) -> bool:
var item := _shop_item(id)
if item.is_empty():
return false
var level := int(upgrades.get(id, 0))
var lvl := level(id)
var max_level := int(item.get("max_level", 1))
if max_level > 0 and level >= max_level:
if max_level > 0 and lvl >= max_level:
return false
var cost := _item_cost(item, level)
var cost := _item_cost(item, lvl)
if money < cost:
toast.emit(_tr("msg.money_short", "Недостаточно денег"))
return false
money -= cost
upgrades[id] = level + 1
set_lot_level(id, lvl + 1)
money_changed.emit(money)
toast.emit(_tr("msg.upgrade_bought", "Куплено: %s") % _item_name(item, id))
return true
@@ -234,6 +406,13 @@ func save_game() -> bool:
"rng_seed": rng_seed,
"rng_state": rng_state,
"registry": registry.to_dict(),
"shop_offers": shop_offers.duplicate(),
"shop_rerolls": shop_rerolls,
"shop_offer_hour": shop_offer_hour,
"shop_last_seen": shop_last_seen.duplicate(true),
"shop_locked": shop_locked.duplicate(),
"shield_charges": shield_charges,
"sla_regen_hour": _sla_regen_hour,
}
return SAVE_SCRIPT.save(payload)
@@ -250,14 +429,31 @@ func load_game() -> bool:
if not reg.from_dict(raw_registry):
push_warning("Game: не удалось восстановить registry")
return false
# runtime-резервы под запросы не переживают загрузку: сами запросы не
# сохраняются, поэтому file.required_by/attached_request и location
# "attached" после load указывали бы на несуществующий запрос. Такой файл
# нельзя ни переместить, ни удалить — он навсегда занимает клетку.
for file in reg.files.values():
file.required_by = ""
file.attached_request = ""
if String(file.location) == "attached":
file.location = &"folder" if not String(file.folder_id).is_empty() else &"registry"
# _repair_after_load уже разложил файл по папкам/клеткам. Если папки
# (или её bookkeeping) нет, файл должен быть видимым в реестре, а не
# числиться в несуществующем месте.
if String(file.location) == "folder":
var folder = reg.folders.get(String(file.folder_id), null)
if folder == null or not folder.file_ids.has(file.id):
file.folder_id = ""
file.location = &"registry"
registry = reg
upgrades = _dict_of(data.get("upgrades", {}))
money = maxi(0, int(data.get("money", START_MONEY)))
sla = clampf(float(data.get("sla", START_SLA)), 0.0, MAX_SLA)
hour = clampi(int(data.get("hour", 1)), 1, HOURS_PER_RUN)
phase = StringName(str(data.get("phase", "menu")))
streak = maxi(0, int(data.get("streak", 0)))
upgrades = _dict_of(data.get("upgrades", {}))
sla = clampf(float(data.get("sla", START_SLA)), 0.0, sla_cap())
meta_unlocks = _dict_of(data.get("meta", {}))
run_meta_awards = _string_array_of(data.get("run_meta_awards", []))
stats = _default_stats()
@@ -266,6 +462,10 @@ func load_game() -> bool:
for key in raw_stats.keys():
stats[key] = raw_stats[key]
rng_seed = int(data.get("rng_seed", 0))
shield_charges = clampi(int(data.get("shield_charges", 0)), 0, shield_max())
_sla_regen_hour = int(data.get("sla_regen_hour", 0))
_restore_shop_state(data)
var rng = _rng_node()
if rng != null and rng.has_method("restore_state"):
@@ -287,6 +487,13 @@ func _reset_fields(keep_meta: bool = true) -> void:
streak = 0
stats = _default_stats()
upgrades = {}
shop_offers.clear()
shop_rerolls = 0
shop_offer_hour = 0
shop_last_seen.clear()
shop_locked.clear()
shield_charges = 0
_sla_regen_hour = 0
if not keep_meta:
meta_unlocks = {}
registry = _make_registry()
@@ -322,24 +529,138 @@ func _default_stats() -> Dictionary:
}
## Восстановить офферы/рероллы/last_seen/замки; выкинуть неизвестные и
## максимальные лоты; при необходимости догенерировать до 3.
func _restore_shop_state(data: Dictionary) -> void:
shop_offers.clear()
for raw_id in _string_array_of(data.get("shop_offers", [])):
var id := str(raw_id)
if not id.begins_with("lot_"):
continue
var def := _lot_def(id)
if def.is_empty():
continue
if level(id) >= maxi(1, int(def.get("max_level", 1))):
continue
if shop_offers.has(id):
continue
shop_offers.append(id)
shop_rerolls = clampi(int(data.get("shop_rerolls", 0)), 0, _max_rerolls())
shop_offer_hour = int(data.get("shop_offer_hour", 0))
shop_last_seen = _int_dict_of(data.get("shop_last_seen", {}))
shop_locked.clear()
for raw_id in _string_array_of(data.get("shop_locked", [])):
var id := str(raw_id)
if shop_offers.has(id) and not shop_locked.has(id):
shop_locked.append(id)
if hour <= 1:
return
var target := int(_shop_rules().get("offers", 3))
if shop_offers.size() >= target and shop_offer_hour > 0:
return
var lots := _lot_defs()
if lots.is_empty():
return
var selected := SHOP_SCRIPT.select_offers(
hour, lots, upgrades, money, [], shop_last_seen,
shop_offers.duplicate(), target)
shop_offers.clear()
for id in selected:
shop_offers.append(str(id))
if shop_offer_hour <= 0:
shop_offer_hour = hour
for id in shop_offers:
shop_last_seen[str(id)] = hour
func _lot_def(id: String) -> Dictionary:
var data = _data_node()
if data != null and data.has_method("lot_def"):
var def = data.lot_def(id)
if def is Dictionary and not (def as Dictionary).is_empty():
return def
var fallback = FALLBACK_SHOP.get(str(id), null)
if fallback is Dictionary:
return (fallback as Dictionary).duplicate(true)
return {}
func _lot_defs() -> Array:
var data = _data_node()
if data != null and data.has_method("lot_defs"):
var lots = data.lot_defs()
if lots is Array and not (lots as Array).is_empty():
return lots
var out: Array = []
for id in FALLBACK_SHOP.keys():
out.append((FALLBACK_SHOP[id] as Dictionary).duplicate(true))
return out
func _shop_rules() -> Dictionary:
var data = _data_node()
if data != null and data.has_method("shop_rules"):
var rules = data.shop_rules()
if rules is Dictionary and not (rules as Dictionary).is_empty():
return rules
return {"offers": 3, "reroll_costs": [40, 80, 120], "max_rerolls": 3}
func _max_rerolls() -> int:
return maxi(0, int(_shop_rules().get("max_rerolls", 3)))
func _folder_dims_table() -> Array:
var data = _data_node()
if data != null and data.has_method("folder_dims_table"):
var table = data.folder_dims_table()
if table is Array and not (table as Array).is_empty():
return table
return FALLBACK_LOT_DIMS.duplicate(true)
func _const(key: String, fallback_value: float) -> float:
var data = _data_node()
if data != null and data.get("constants") is Dictionary:
var consts: Dictionary = data.constants
if consts.has(key):
return float(consts[key])
return float(fallback_value)
func _lot_value(def: Dictionary, fallback_value: float) -> float:
if def.has("value"):
return float(def.get("value"))
return float(fallback_value)
## Значение поля лота (source of truth для эффекта), fallback — константа.
func _lot_effect_value(id: String, field: String, fallback_value: float) -> float:
var def := _lot_def(id)
if def.has(field):
return float(def.get(field))
return float(fallback_value)
func _shop_item(id: String) -> Dictionary:
var data = _data_node()
if data != null and data.has_method("shop_item"):
var item = data.shop_item(id)
if item is Dictionary and not item.is_empty():
if item is Dictionary and not (item as Dictionary).is_empty():
return item
var fallback = FALLBACK_SHOP.get(id, null)
if fallback is Dictionary:
return fallback.duplicate(true)
return (fallback as Dictionary).duplicate(true)
return {}
func _item_cost(item: Dictionary, level: int) -> int:
func _item_cost(item: Dictionary, lvl: int) -> int:
var prices = item.get("prices", null)
if prices is Array and not prices.is_empty():
if prices is Array and not (prices as Array).is_empty():
var arr: Array = prices
if level < arr.size():
return maxi(0, int(arr[level]))
if lvl < arr.size():
return maxi(0, int(arr[lvl]))
return maxi(0, int(arr[arr.size() - 1]))
if item.has("price"):
return maxi(0, int(item.get("price", 0)))
@@ -387,6 +708,14 @@ func _dict_of(value) -> Dictionary:
return {}
func _int_dict_of(value) -> Dictionary:
var out: Dictionary = {}
if value is Dictionary:
for key in value.keys():
out[str(key)] = int(value[key])
return out
func _string_array_of(value) -> Array[String]:
var out: Array[String] = []
if value is Array:
+164 -55
View File
@@ -1,48 +1,71 @@
class_name Economy
extends RefCounted
## Чистая экономика и тайминги (D12). Все методы статические, без состояния
## Чистая экономика и тайминги v2. Все методы статические, без состояния
## и без обращений к сценам — удобно тестировать headless.
##
## ВАЖНО: только float-арифметика в формулах выплаты (никаких int/int) и
## только pow() для степеней (никакого ^).
## Фон/слоты НЕ входят в load (уточнение п.11): канал грузят только
## активные передачи. Константы оставлены нулевыми для совместимости.
## Канал: load = Σ weight(активных передач). Фон и занятые слоты не грузят.
const CHANNEL_BG_BASE := 0.0
const CHANNEL_BG_PER_HOUR := 0.0
const SLOT_LOAD := 0.0
const CHANNEL_CAPACITY_BASE := 80.0
const CHANNEL_CAPACITY_STEP := 25.0
const CHANNEL_CAPACITY_BASE := 10.0
const CHANNEL_CAPACITY_STEP := 6.0
const PACKET_TIMER_BASE := 20.0
const REQUEST_TIMER_BASE := 30.0
const TIMER_PER_HOUR := 0.5
const TIMER_UPGRADE_STEP := 4.0
const PACKET_TIMER_MIN := 12.0
const REQUEST_TIMER_MIN := 20.0
const TIMER_MAX := 999.0
const REQUEST_TIMER_STEP := 7.0
const REQUEST_TIMER_MAX := 65.0
## Grace-множитель первых часов (п.10): больше времени на старте.
## h1 1.5, h2 1.35, h3 1.2, h4+ 1.0 — для таймера пакетов.
const TIMER_GRACE := {1: 1.5, 2: 1.35, 3: 1.2}
const BUFFER_BASE := 6
const BUFFER_PER_LEVEL := 1
const BUFFER_MAX := 12
## Отдельный grace для таймера запросов: в начале игроку нужно больше времени,
## чтобы собрать файлы. h1 2.0, h2 1.7, h3 1.4, h4+ 1.0.
## h1: 30×2.0 = 60, h2: 29.5×1.7 = 50.15, h3: 29×1.4 = 40.6.
const REQUEST_TIMER_GRACE := {1: 2.0, 2: 1.7, 3: 1.4}
## Множитель времени передачи за уровень «Оптоволокна» (10% за уровень).
const SPEED_STEP := 0.90
## Коэффициенты мини-игр: text 1, sound 2, photo 4, video 8.
const MINIGAME_COEFF := {
"text": 1.0,
"sound": 2.0,
"photo": 3.0,
"video": 4.0,
"text": 1,
"sound": 2,
"photo": 4,
"video": 8,
}
## Игры отправки (wires удалена). Порядок совпадает с data.gd/minigame_factory.
const SEND_ORDER := ["dots", "sound", "text", "video", "photo", "maze"]
## Игра по rank (позиция суммы в ACHIEVABLE_SUMS). Прямая таблица, без modulo.
const RANK_GAMES := [
"text", "sound", "dots", "photo", "maze",
"dots", "photo", "video", "dots", "text",
"video", "maze", "photo", "sound", "video",
"text", "maze", "sound", "video", "sound",
"photo", "dots", "dots", "maze", "maze",
"photo", "video",
]
## Достижимые суммы коэффициентов для 1–4 файлов: 27 значений.
const ACHIEVABLE_SUMS := [
1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14,
15, 16, 17, 18, 19, 20, 21, 22, 24, 25, 26, 28, 32,
]
## rank 0..7 → T1, 8..13 → T2, 14..26 → T3.
const TIER_MAX_RANK := [7, 13]
const PAYOUT_SPEED_BASE := 0.6
const PAYOUT_SPEED_BONUS := 0.4
const PAYOUT_QUALITY_BASE := 0.5
const PAYOUT_QUALITY_BONUS := 0.5
const PAYOUT_MULTI_STEP := 0.1
const PAYOUT_MULTI_STEP := 0.15
const PAYOUT_TCP_MULT := 1.15
const PAYOUT_UDP_MULT := 0.85
const PAYOUT_UDP_TIME_MULT := 0.8
const PAYOUT_STREAK_STEP := 0.02
const PAYOUT_STREAK_CAP := 10
const PAYOUT_MONEY_STEP := 0.05
# --- канал -----------------------------------------------------------------
@@ -58,7 +81,7 @@ static func channel_load(_hour: int, _used_slots: int, active_weights: Array, _u
static func channel_capacity(upgrades: Dictionary = {}) -> float:
var level := _level(upgrades, ["channel", "channel_level", "channel_lvl", "shop_channel", "channel_capacity"])
var level := _level(upgrades, ["channel", "channel_level", "channel_lvl", "lot_channel", "channel_capacity"])
return CHANNEL_CAPACITY_BASE + CHANNEL_CAPACITY_STEP * float(level)
@@ -75,54 +98,82 @@ static func channel_factor(load: float, capacity_or_upgrades = null) -> float:
return maxf(1.0, maxf(0.0, load) / capacity)
## Базовое время приёма/отправки умножается на фактор канала.
static func recv_time(type_def: Dictionary, factor: float, _upgrades: Dictionary = {}) -> float:
return maxf(0.0, float(type_def.get("recv_time", 0.0))) * maxf(0.0, factor)
static func buffer_limit(upgrades: Dictionary = {}) -> int:
var level := _level(upgrades, ["channel", "channel_level", "buffer", "buffer_level", "lot_channel"])
return clampi(BUFFER_BASE + BUFFER_PER_LEVEL * level, BUFFER_BASE, BUFFER_MAX)
static func send_time(type_def: Dictionary, factor: float, _upgrades: Dictionary = {}) -> float:
return maxf(0.0, float(type_def.get("send_time", 0.0))) * maxf(0.0, factor)
## pow(0.90, speed_level): 0 → 1.0, 6 → ≈0.531.
static func speed_mult(speed_level: int) -> float:
return pow(SPEED_STEP, float(maxi(0, int(speed_level))))
## Базовое время типа: v2-поле base_time_sec с fallback на legacy.
static func type_base_time(type_def: Dictionary) -> float:
if type_def.has("base_time_sec"):
return maxf(0.0, float(type_def.get("base_time_sec", 0.0)))
if type_def.has("recv_time"):
return maxf(0.0, float(type_def.get("recv_time", 0.0)))
return 0.0
## Время приёма: base_time_sec × pow(0.90, speed_level) × channel_factor.
## У приёма нет протокольного множителя.
static func recv_transfer_time(type_def: Dictionary, channel_factor_value: float = 1.0, speed_level: int = 0) -> float:
return type_base_time(type_def) * speed_mult(speed_level) * maxf(0.0, channel_factor_value)
## Время отправки: то же + UDP ×0.8 (TCP ×1.0).
static func send_transfer_time(type_def: Dictionary, channel_factor_value: float = 1.0, speed_level: int = 0, protocol = "tcp") -> float:
var mult := 1.0
if str(protocol).to_lower() == "udp":
mult = PAYOUT_UDP_TIME_MULT
return type_base_time(type_def) * speed_mult(speed_level) * maxf(0.0, channel_factor_value) * mult
## Legacy-обёртки: factor — channel_factor, upgrades даёт speed_level.
static func recv_time(type_def: Dictionary, factor: float, upgrades: Dictionary = {}) -> float:
var level := _level(upgrades, ["speed", "speed_level", "transfer_speed", "lot_speed"])
return recv_transfer_time(type_def, factor, level)
static func send_time(type_def: Dictionary, factor: float, upgrades: Dictionary = {}) -> float:
var level := _level(upgrades, ["speed", "speed_level", "transfer_speed", "lot_speed"])
return send_transfer_time(type_def, factor, level, "tcp")
# --- таймеры ---------------------------------------------------------------
## Формула (п.10): (base − 0.5*(h−1)) * grace(h) + 4*level.
## Grace умножает только базовую часть; апгрейд таймера даёт чистые +4 с.
static func packet_timer(hour: int, upgrades: Dictionary = {}) -> float:
var h := maxi(1, hour)
var level := _level(upgrades, ["timer", "timer_level", "packet_timer", "packet_timer_level", "shop_timer"])
var base := PACKET_TIMER_BASE - TIMER_PER_HOUR * float(h - 1)
var value := base * timer_grace(h) + TIMER_UPGRADE_STEP * float(level)
return clampf(value, PACKET_TIMER_MIN, TIMER_MAX)
## v2: таймер пакета фиксирован — 20 с, не покупается и не зависит от часа.
static func packet_timer(_hour: int = 1, _upgrades: Dictionary = {}) -> float:
return PACKET_TIMER_BASE
static func request_timer(hour: int, upgrades: Dictionary = {}) -> float:
var h := maxi(1, hour)
var level := _level(upgrades, ["timer", "timer_level", "request_timer", "request_timer_level", "shop_timer"])
var base := REQUEST_TIMER_BASE - TIMER_PER_HOUR * float(h - 1)
var value := base * request_timer_grace(h) + TIMER_UPGRADE_STEP * float(level)
return clampf(value, REQUEST_TIMER_MIN, TIMER_MAX)
## v2: 30 + 7 × timer_level, не выше 65 с.
static func request_timer(_hour: int = 1, upgrades: Dictionary = {}) -> float:
var level := _level(upgrades, ["timer", "timer_level", "request_timer", "request_timer_level", "lot_timer"])
var value := REQUEST_TIMER_BASE + REQUEST_TIMER_STEP * float(level)
return clampf(value, REQUEST_TIMER_BASE, REQUEST_TIMER_MAX)
## Grace-множитель таймеров по часу: h1 1.5, h2 1.35, h3 1.2, h4+ 1.0.
static func timer_grace(hour: int) -> float:
return float(TIMER_GRACE.get(maxi(1, hour), 1.0))
## Legacy-хуки: в v2 grace не используется.
static func timer_grace(_hour: int) -> float:
return 1.0
## Grace-множитель таймера запросов: h1 2.0, h2 1.7, h3 1.4, h4+ 1.0.
static func request_timer_grace(hour: int) -> float:
return float(REQUEST_TIMER_GRACE.get(maxi(1, hour), 1.0))
static func request_timer_grace(_hour: int) -> float:
return 1.0
# --- мини-игры -------------------------------------------------------------
## Средний коэффициент сложности. Пустой/неизвестный тип трактуем как text.
## Средний коэффициент сложности (legacy-эвристика).
static func minigame_difficulty(types: Array) -> float:
if types.is_empty():
return 1.0
var total := 0.0
for type in types:
total += float(MINIGAME_COEFF.get(str(type), 1.0))
total += float(MINIGAME_COEFF.get(str(type), 1))
return total / float(types.size())
@@ -137,8 +188,59 @@ static func minigame_type(types: Array) -> String:
return "video"
## Выбор мини-игры отправки по сумме коэффициентов (1–4 файла).
##
## rank — позиция суммы S в списке ACHIEVABLE_SUMS (для недостижимой S —
## позиция ближайшей меньшей достижимой). tier: rank 0..7 → T1, 8..13 → T2,
## 14..26 → T3. Игра берётся напрямую из RANK_GAMES[rank], без modulo.
##
## S = 0 (нет файлов) → dots/T1.
static func send_minigame_for(types: Array) -> Dictionary:
var total := 0
for type in types:
total += int(MINIGAME_COEFF.get(str(type).to_lower(), 1))
return selection_for_sum(total)
## Публичный алиас выбора по готовой сумме (для MiniGameFactory/тестов).
static func selection_for_sum(total: int) -> Dictionary:
var sum := maxi(0, int(total))
if sum <= 0:
return {"id": "dots", "tier": 1, "index": 0, "sum": 0}
var count := ACHIEVABLE_SUMS.size()
var rank := 0
for i in range(count):
if int(ACHIEVABLE_SUMS[i]) <= sum:
rank = i
else:
break
var tier := 1
if rank > int(TIER_MAX_RANK[1]):
tier = 3
elif rank > int(TIER_MAX_RANK[0]):
tier = 2
return {
"id": str(RANK_GAMES[clampi(rank, 0, RANK_GAMES.size() - 1)]),
"tier": tier,
"index": rank,
"sum": sum,
}
## Список достижимых сумм коэффициентов (копия, чтобы нельзя было испортить const).
static func achievable_sums() -> Array:
return ACHIEVABLE_SUMS.duplicate()
## Ступень атаки: H2-3 → T1, H4-5 → T2, H6-7 → T3, H8-9 → T4.
static func attack_tier(hour: int) -> int:
return clampi(1 + int(floor(float(maxi(1, int(hour)) - 2) / 2.0)), 1, 4)
# --- выплата ---------------------------------------------------------------
## payout = round(base_sum × speed × quality × multi × protocol × streak ×
## money_mult). TCP ×1.15, UDP ×0.85; money_level из upgrades.
static func payout(
base_sum: float,
n: int,
@@ -147,22 +249,29 @@ static func payout(
mini_quality: float,
protocol,
streak: int,
_upgrades: Dictionary = {}
upgrades: Dictionary = {}
) -> int:
if n <= 0:
return 0
var time_ratio := 1.0
if timer_total > 0.0:
time_ratio = clampf(time_left / timer_total, 0.0, 1.0)
time_ratio = clampf(float(time_left) / float(timer_total), 0.0, 1.0)
var speed := PAYOUT_SPEED_BASE + PAYOUT_SPEED_BONUS * time_ratio
var quality := PAYOUT_QUALITY_BASE + PAYOUT_QUALITY_BONUS * clampf(mini_quality, 0.0, 1.0)
var quality := PAYOUT_QUALITY_BASE + PAYOUT_QUALITY_BONUS * clampf(float(mini_quality), 0.0, 1.0)
var multi := 1.0 + PAYOUT_MULTI_STEP * float(n - 1)
var protocol_mult := PAYOUT_TCP_MULT if str(protocol).to_lower() == "tcp" else 1.0
var protocol_mult := PAYOUT_TCP_MULT if str(protocol).to_lower() == "tcp" else PAYOUT_UDP_MULT
var streak_mult := 1.0 + PAYOUT_STREAK_STEP * float(clampi(streak, 0, PAYOUT_STREAK_CAP))
var value := maxf(0.0, base_sum) * speed * quality * multi * protocol_mult * streak_mult
var money_level := _level(upgrades, ["money", "money_level", "money_mult", "lot_money"])
var money_mult := 1.0 + PAYOUT_MONEY_STEP * float(money_level)
var value := maxf(0.0, float(base_sum)) * speed * quality * multi * protocol_mult * streak_mult * money_mult
return int(round(value))
static func money_mult(upgrades: Dictionary = {}) -> float:
var level := _level(upgrades, ["money", "money_level", "money_mult", "lot_money"])
return 1.0 + PAYOUT_MONEY_STEP * float(level)
## Итоговый счёт: часы × 1000 + заработанные деньги + SLA × 10.
static func score(hours: int, money_earned_total: int, sla: float) -> int:
var safe_hours := maxi(0, hours)
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
+6 -6
View File
@@ -1,6 +1,6 @@
class_name SlotManager
extends RefCounted
## Слоты соединений. Всего 2 + slot_level, но не больше 4 (D13/D16).
## Слоты соединений v2. Всего 1 + slot_level, но не больше 5.
##
## Слоты общие для приёма и отправки; направление хранится для UI и канала.
## manager не тикает сам: прогресс обновляет HourDirector.
@@ -8,8 +8,8 @@ extends RefCounted
signal changed
const SLOT_SCRIPT := preload("res://scripts/entities/slot.gd")
const BASE_SLOTS := 2
const MAX_SLOTS := 4
const BASE_SLOTS := 1
const MAX_SLOTS := 5
var slots: Array = []
var _fallback_level: int = 0
@@ -192,11 +192,11 @@ func _slot_level() -> int:
if game == null:
return _fallback_level
var upgrades: Dictionary = game.upgrades if game.upgrades is Dictionary else {}
# Сначала агрегированный уровень, который пишет Shop.
for key in ["slot_level", "connection_slots", "slots"]:
# v2: агрегированный уровень, который пишет Shop/Game (lot_slots → slot_level).
for key in ["slot_level", "connection_slots", "lot_slots"]:
if upgrades.has(key):
return int(upgrades[key])
# Совместимость с покупками по id (Game.buy_upgrade старого образца).
# Совместимость с v1-покупками по id (если такие остались в старом сейве).
var level := 0
for key in ["shop_slot3", "shop_slot4"]:
level += int(upgrades.get(key, 0))
+60 -3
View File
@@ -6,6 +6,10 @@ extends RefCounted
## `attached_request` — к какому запросу прикреплён, `lock_until` /
## `cooldown_until` — таймеры (unix-время).
##
## v2: файл — полимино на сетке папки. `shape_id` + `rotation` задают форму,
## `origin` — верхняя левая клетка bounding box, `shape_cells` — производное
## поле (пересчитывается из shape_id/rotation, в save не пишется).
##
## ВАЖНО: проект запускается без прогретого global class cache, поэтому
## между своими скриптами используем `preload`, а не имя класса. Внутри
## этого файла допустим `new()` (не `FileItem.new()`).
@@ -13,6 +17,7 @@ extends RefCounted
const LOCATIONS: Array[String] = [
"registry", # получен, ещё не разложен по папке
"folder", # лежит в папке
"table", # лежит на столе сортировки (не в папке, нельзя attach)
"attached", # прикреплён к запросу (остаётся в папке)
"locked", # заблокирован атакой/шифровальщиком
"malicious", # вредоносный, можно уничтожить
@@ -26,12 +31,23 @@ var type: String = "text"
var folder_id: String = ""
var location: StringName = &"folder"
var attached_request: String = ""
## Runtime-резерв: id запроса, для которого файл обязателен (не сохраняется).
var required_by: String = ""
var malicious_kind: String = ""
var lock_until: float = 0.0
var cooldown_until: float = 0.0
var size_kb: int = 0
var created_at: float = 0.0
## Форма на сетке (v2).
var shape_id: String = "O1"
## Производное от shape_id/rotation; в save не входит.
var shape_cells: Array = []
## Верхняя левая клетка bounding box в сетке папки/стола.
var origin: Vector2i = Vector2i.ZERO
## 0..3, по часовой стрелке.
var rotation: int = 0
## Куда файл вернётся после снятия lock (служебное поле, в save пишется).
var _location_before_lock: StringName = &"folder"
@@ -76,8 +92,21 @@ func can_attach(now: float = -1.0) -> bool:
return is_available(now)
func to_dict() -> Dictionary:
return {
func is_on_table() -> bool:
return location == &"table"
func cell_count() -> int:
if not shape_cells.is_empty():
return shape_cells.size()
return 1
## Позиция не входит в метаданные save v2 (единый источник — folder.files /
## table), поэтому по умолчанию include_placement=true нужен только для
## прямого roundtrip FileItem.
func to_dict(include_placement: bool = true) -> Dictionary:
var out := {
"id": id,
"name": name,
"ext": ext,
@@ -91,7 +120,12 @@ func to_dict() -> Dictionary:
"size_kb": size_kb,
"created_at": created_at,
"location_before_lock": String(_location_before_lock),
"shape_id": shape_id,
}
if include_placement:
out["origin"] = [origin.x, origin.y]
out["rotation"] = rotation
return out
static func from_dict(d: Dictionary):
@@ -112,12 +146,35 @@ static func from_dict(d: Dictionary):
f.size_kb = int(d.get("size_kb", 0))
f.created_at = float(d.get("created_at", 0.0))
var prev := StringName(str(d.get("location_before_lock", "folder")))
if prev != &"registry":
if prev != &"registry" and prev != &"folder" and prev != &"table":
prev = &"folder"
f._location_before_lock = prev
f.shape_id = str(d.get("shape_id", "O1"))
if f.shape_id.is_empty():
f.shape_id = "O1"
f.rotation = posmod(int(d.get("rotation", 0)), 4)
f.origin = _vec_from(d.get("origin", [0, 0]))
var raw_cells = d.get("shape_cells", [])
if raw_cells is Array:
for cell in raw_cells:
f.shape_cells.append(_vec_from(cell))
# Инварианты: attached без запроса и failed/malicious не бывают прикреплены.
if f.location == &"attached" and f.attached_request.is_empty():
f.location = &"folder" if not f.folder_id.is_empty() else &"registry"
if f.location == &"failed" or f.location == &"malicious":
f.attached_request = ""
if f.location == &"table":
f.attached_request = ""
return f
static func _vec_from(value) -> Vector2i:
if value is Vector2i:
return value
if value is Vector2:
return Vector2i(int(value.x), int(value.y))
if value is Array and value.size() >= 2:
return Vector2i(int(value[0]), int(value[1]))
if value is Dictionary:
return Vector2i(int(value.get("x", 0)), int(value.get("y", 0)))
return Vector2i.ZERO
+81 -8
View File
@@ -1,26 +1,86 @@
class_name Folder
extends RefCounted
## Папка секции. Хранит только id файлов; сами файлы — в FileRegistry.
## Папка секции. Хранит id файлов; сами файлы — в FileRegistry.
##
## `reserved` — счётчик слотов, зарезервированных под ещё не завершённые
## загрузки. В save не пишется: после перезапуска незавершённых загрузок нет.
## v2: папка — клеточная сетка `cols` x `rows`. `cells` — служебная карта
## Vector2i -> owner_id (id файла или id пакета для резервации). В save
## `cells` не пишется: позиции восстанавливаются из placements в FileRegistry.
##
## `reserved` — резервации пакетов: packet_id -> {shape_id, origin, rotation}.
## Клетки резервации тоже лежат в `cells`, поэтому cell_occupied() возвращает
## true и для файлов, и для резерваций.
var id: String = ""
var section: String = ""
var name: String = ""
var cols: int = 5
var rows: int = 4
var file_ids: Array[String] = []
## Служебное: зарезервированные слоты (в файл сохранения не попадает).
var reserved: int = 0
## Служебное: резервации приёма (в save пишутся отдельным списком).
var reserved: Dictionary = {}
## Служебное: занятость клеток (не сохраняется).
var cells: Dictionary = {}
## Legacy-совместимость со старым reserve_slot()/release_slot() (v1 API).
var legacy_slots: int = 0
func count() -> int:
return file_ids.size()
## Свободно ли место с учётом резерваций.
func total_cells() -> int:
return maxi(0, cols) * maxi(0, rows)
func cell_occupied(c: int, r: int) -> bool:
return cells.has(Vector2i(c, r))
func owner_at(c: int, r: int) -> String:
return str(cells.get(Vector2i(c, r), ""))
func is_reserved_cell(c: int, r: int) -> bool:
var owner := owner_at(c, r)
return not owner.is_empty() and reserved.has(owner)
func has_free_cell() -> bool:
return cells.size() < total_cells()
func occupied_cell_count() -> int:
return cells.size()
func free_cell_count() -> int:
return maxi(0, total_cells() - cells.size())
func reserved_count() -> int:
return reserved.size()
## Legacy: раньше ёмкость измерялась слотами (16 файлов).
func has_free_slot(capacity: int = 16) -> bool:
return count() + reserved < capacity
return count() + reserved.size() + legacy_slots < capacity
## Служебный API для FileRegistry: занять клетку владельцем.
func set_cell(cell: Vector2i, owner_id: String) -> void:
cells[cell] = owner_id
## Служебный API: освободить клетки, принадлежащие owner_id.
func clear_owner(owner_id: String) -> void:
for cell in cells.keys():
if str(cells[cell]) == owner_id:
cells.erase(cell)
func clear_cells() -> void:
cells.clear()
func to_dict() -> Dictionary:
@@ -28,6 +88,8 @@ func to_dict() -> Dictionary:
"id": id,
"section": section,
"name": name,
"cols": cols,
"rows": rows,
"file_ids": file_ids.duplicate(),
}
@@ -37,9 +99,20 @@ static func from_dict(d: Dictionary):
f.id = str(d.get("id", ""))
f.section = str(d.get("section", ""))
f.name = str(d.get("name", ""))
f.cols = maxi(0, int(d.get("cols", 0)))
f.rows = maxi(0, int(d.get("rows", 0)))
var raw = d.get("file_ids", [])
if raw is Array:
for value in raw:
f.file_ids.append(str(value))
f.reserved = 0
# v2: placements могут лежать в files — id подтянет FileRegistry.
var placements = d.get("files", [])
if placements is Array:
for entry in placements:
if entry is Dictionary:
var fid := str(entry.get("id", ""))
if not fid.is_empty() and not f.file_ids.has(fid):
f.file_ids.append(fid)
f.reserved = {}
f.cells = {}
return f
+20
View File
@@ -28,6 +28,12 @@ var is_attack: bool = false
var attack_kind: String = ""
var size_kb: int = 0
## Форма, которую пакет займёт в папке. Выбирается при спавне, чтобы показать
## превью ещё до приёма. `shape_cells` — производное (не сохраняется).
var shape_id: String = "O1"
var rotation: int = 0
var shape_cells: Array = []
## Служебные поля (не входят в минимальный контракт, но нужны директору).
var lost_left: float = 0.0
var loading_duration: float = 0.0
@@ -92,6 +98,8 @@ func to_dict() -> Dictionary:
"loading_duration": loading_duration,
"order": order,
"attack_id": attack_id,
"shape_id": shape_id,
"rotation": rotation,
}
@@ -115,6 +123,18 @@ static func from_dict(d: Dictionary):
p.loading_duration = float(d.get("loading_duration", 0.0))
p.order = int(d.get("order", 0))
p.attack_id = str(d.get("attack_id", ""))
p.shape_id = str(d.get("shape_id", "O1"))
if p.shape_id.is_empty():
p.shape_id = "O1"
p.rotation = posmod(int(d.get("rotation", 0)), 4)
p.shape_cells = []
var raw_cells = d.get("shape_cells", [])
if raw_cells is Array:
for cell in raw_cells:
if cell is Vector2i:
p.shape_cells.append(cell)
elif cell is Array and cell.size() >= 2:
p.shape_cells.append(Vector2i(int(cell[0]), int(cell[1])))
return p
+24
View File
@@ -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
@@ -32,6 +38,12 @@ var required_types: Array[String] = []
var send_duration: float = 0.0
var send_base_sum: float = 0.0
## v2: выбор мини-игры отправки. Сумма коэффициентов типов, выбранная игра и
## её ступень. Заполняются HourDirector/Economy, в save опциональны.
var send_sum: int = 0
var minigame_id: String = ""
var minigame_tier: int = 0
## Готов ли запрос к отправке: все required прикреплены, TCP подключён.
func is_ready() -> bool:
@@ -85,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,
@@ -95,6 +110,9 @@ func to_dict() -> Dictionary:
"required_types": required_types.duplicate(),
"send_duration": send_duration,
"send_base_sum": send_base_sum,
"send_sum": send_sum,
"minigame_id": minigame_id,
"minigame_tier": minigame_tier,
}
@@ -111,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)
@@ -121,6 +142,9 @@ static func from_dict(d: Dictionary):
r.required_types = _string_array(d.get("required_types", []))
r.send_duration = float(d.get("send_duration", 0.0))
r.send_base_sum = float(d.get("send_base_sum", 0.0))
r.send_sum = int(d.get("send_sum", 0))
r.minigame_id = str(d.get("minigame_id", ""))
r.minigame_tier = int(d.get("minigame_tier", 0))
return r
+75 -15
View File
@@ -21,6 +21,9 @@ signal shop_changed
signal toast(text: String)
signal show_connection(game)
signal show_minigame(game)
signal minigame_closed(req_id: String)
signal show_attack_minigame(game, kind: String)
signal attack_resolved(kind: String, success: bool, tier: int)
## Дополнительно: проброс изменения слотов (удобно для UI во время часа).
signal slot_changed
@@ -35,6 +38,7 @@ const HOURS_PER_RUN := 9
const FOLDER_CAPACITY := 16
const HOUR_DIRECTOR_SCRIPT := preload("res://scripts/core/hour_director.gd")
const CONNECTION_GAME_SCRIPT := preload("res://scripts/minigames/connection_game.gd")
const SHOP_SCRIPT := preload("res://scripts/systems/shop.gd")
const SAVE_SCRIPT := preload("res://scripts/systems/save_system.gd")
const ECONOMY := preload("res://scripts/core/economy.gd")
@@ -104,6 +108,12 @@ func _enter_prep() -> void:
modal_open = false
if game != null:
game.phase = PHASE_PREP
# Магазин: 3 лота после каждого часа, начиная с подготовки перед H2.
# Во время часа офферы не генерируются и магазин не открывается.
if int(game.hour) > 1 and int(game.shop_offer_hour) != int(game.hour):
if shop != null:
shop.refresh(int(game.hour), int(game.money))
shop_changed.emit()
# Автосейв на входе в подготовку: прогресс не теряется между часами.
game.save_game()
phase_changed.emit(phase)
@@ -119,11 +129,19 @@ func start_hour() -> bool:
# Час начинается только из подготовки (menu допустим, если Game уже в prep).
if phase != PHASE_PREP and not (phase == PHASE_MENU and game.phase == PHASE_PREP):
return false
# M4/M18: стол сортировки возвращает файлы до старта часа. Если места нет —
# час не начинается (никакого soft-lock/потери файлов).
if game.registry != null and game.registry.has_method("auto_return_table"):
if not bool(game.registry.auto_return_table()):
var message := "На столе остались файлы, места нет"
toast.emit(message)
return false
if not bool(game.start_hour()):
return false
hour_director.start_hour(int(game.hour))
hour_director.set_paused(false)
hour_director.set_modal_open(false)
hour_director.set_attack_paused(false)
phase = PHASE_HOUR
paused = false
modal_open = false
@@ -225,13 +243,14 @@ func prep_data() -> Dictionary:
for file in registry.get_folder_files(str(folder.id)):
folder_files.append(_file_entry(file))
files_map[str(folder.id)] = folder_files
capacity_total += FOLDER_CAPACITY
free_slots += maxi(0, FOLDER_CAPACITY - folder.count() - int(folder.reserved))
capacity_total += folder.total_cells()
free_slots += folder.free_cell_count()
folders_data.append({
"id": str(folder.id),
"name": str(folder.name),
"count": folder.count(),
"capacity": FOLDER_CAPACITY,
"count": folder.occupied_cell_count(),
"capacity": folder.total_cells(),
"files": folder.count(),
})
sections_data.append({
"id": str(section.id),
@@ -288,9 +307,11 @@ func hour_data() -> Dictionary:
for id in hour_director.active_requests.keys():
var request = hour_director.active_requests[id]
var req_entry: Dictionary = request.to_dict()
req_entry["ready"] = request.is_ready()
req_entry["ready"] = bool(hour_director.is_request_ready(str(id)))
req_entry["time_ratio"] = request.time_ratio()
req_entry["missing"] = request.missing_count()
req_entry["required_count"] = int(hour_director.request_required_count(str(id)))
req_entry["missing"] = maxi(0, int(req_entry["required_count"]) - request.attached.size())
req_entry["queued"] = bool(hour_director.is_request_queued(str(id)))
requests.append(req_entry)
var used := 0
@@ -323,20 +344,28 @@ func hour_data() -> Dictionary:
return {"packets": packets, "requests": requests, "slots": slots_data, "status": status}
## Каталог магазина с текущими уровнями/ценами — для модалки магазина.
## 3 текущих предложения магазина (v2). Пустой массив в H1/во время часа.
func shop_data() -> Array:
_bind()
var out: Array = []
if shop == null:
if shop == null or game == null:
return out
for item in shop.items():
var id := str(item.get("id", ""))
if int(game.hour) <= 1 and game.shop_offers.is_empty():
return out
for raw_id in shop.offers():
var id := str(raw_id)
var item: Dictionary = shop.get_item(id)
if item.is_empty():
continue
var item_name := str(item.get("name_ru", ""))
if item_name.is_empty():
item_name = str(item.get("name", id))
out.append({
"id": id,
"name": item_name,
"desc": str(item.get("desc_ru", "")),
"effect": str(item.get("effect", "")),
"value": float(item.get("value", 0.0)),
"price": int(shop.price(id)),
"level": int(shop.level(id)),
"max_level": int(shop.max_level(id)),
@@ -371,16 +400,41 @@ func _on_hour_toast(text: String) -> void:
toast.emit(text)
func _on_show_connection(game_node) -> void:
modal_open = true
show_connection.emit(game_node)
## HourDirector эмитит трио (request_id, hour, successes): ConnectionGame
## создаёт контроллер, а Main только монтирует готовую ноду в modal_root.
func _on_show_connection(request_id: String, hour: int, successes: int) -> void:
var zone_level := 0
if game != null and game.has_method("level"):
# Бонус зоны соединения привязан к уровню лота «Регламент обработки».
zone_level = maxi(0, int(game.level("timer_level")))
var node = CONNECTION_GAME_SCRIPT.new()
node.start(int(hour), int(successes), zone_level, str(request_id))
# Через set_modal_open(), иначе HourDirector не узнаёт о модалке и час
# продолжает спавнить пакеты/запросы под ней.
set_modal_open(true)
show_connection.emit(node)
func _on_show_minigame(game_node) -> void:
modal_open = true
set_modal_open(true)
show_minigame.emit(game_node)
func _on_minigame_closed(req_id: String) -> void:
set_modal_open(false)
minigame_closed.emit(req_id)
func _on_show_attack_minigame(game_node, kind: String) -> void:
set_modal_open(true)
show_attack_minigame.emit(game_node, kind)
func _on_attack_resolved(kind: String, success: bool, tier: int) -> void:
set_modal_open(false)
attack_resolved.emit(kind, success, tier)
func _on_slot_changed() -> void:
slot_changed.emit()
@@ -431,6 +485,9 @@ func _bind() -> void:
hour_director.toast.connect(_on_hour_toast)
hour_director.show_connection.connect(_on_show_connection)
hour_director.show_minigame.connect(_on_show_minigame)
hour_director.minigame_closed.connect(_on_minigame_closed)
hour_director.show_attack_minigame.connect(_on_show_attack_minigame)
hour_director.attack_resolved.connect(_on_attack_resolved)
hour_director.slot_changed.connect(_on_slot_changed)
if game != null and not game.run_ended.is_connected(_on_game_run_ended):
game.run_ended.connect(_on_game_run_ended)
@@ -440,7 +497,10 @@ func _bind() -> void:
func _reset_director_state() -> void:
if hour_director == null:
return
hour_director.start_hour(1)
hour_director.reset_carry()
# Подготовка перед H1: seed-файл создаём, но активный запрос/резервации
# не оставляем — иначе игрок не видит запрос, а файл уже занят.
hour_director.reset_for_prep()
hour_director.running = false
hour_director.paused = false
hour_director.set_modal_open(false)
+1513 -666
View File
File diff suppressed because it is too large. Load diff
+386
View File
@@ -0,0 +1,386 @@
class_name DdosGame
extends MiniGame
## Атака «Щит» (ddos). Конечная волна красных пакетов падает сверху.
## Щит следует за мышью по X (кнопку держать не нужно) и двигается стрелками.
##
## Движение только dt-based: p.y += speed * delta, без покадрового damping.
## Раунд заканчивается сам, когда каждый пакет волны отбит или пробил низ.
## Качество: q = blocked / total_packets (все пакеты волны входят в total).
const GAME_ID := "ddos"
const TITLE_RU := "ЩИТ"
## Поле заканчивается на y=294, чтобы подсказка (y=298..311) не легла на доску.
const FIELD := Rect2(18.0, 58.0, 604.0, 236.0)
const PACKET_W := 18.0
const PACKET_H := 20.0
const SHIELD_H := 16.0
const SHIELD_Y := 270.0
const FIELD_PAD := 10.0
const SHIELD_PAD := 8.0
const MIN_SPACING := 12.0
const TIERS := {
1: {"packets": 8, "speed": 110.0, "shield_width": 260.0, "interval": 0.55, "threshold": 0.60},
2: {"packets": 10, "speed": 140.0, "shield_width": 220.0, "interval": 0.45, "threshold": 0.65},
3: {"packets": 12, "speed": 170.0, "shield_width": 180.0, "interval": 0.35, "threshold": 0.70},
4: {"packets": 14, "speed": 200.0, "shield_width": 150.0, "interval": 0.28, "threshold": 0.75},
}
var _wave: Dictionary = {}
var _packets: Array = []
var _events: Array = []
var _spawned: int = 0
var _blocked: int = 0
var _breached: int = 0
var _elapsed: float = 0.0
var _shield_center: float = 320.0
var _flash: float = 0.0
var _round_done: bool = false
func _own_game_id() -> String:
return GAME_ID
func title() -> String:
return TITLE_RU
func quality() -> float:
if _finished:
return quality_value
return ddos_quality(_blocked, int(_wave.get("total", 0)))
func _setup() -> void:
_wave = generate_wave(tier, seed_value)
_packets.clear()
_events.clear()
_spawned = 0
_blocked = 0
_breached = 0
_elapsed = 0.0
_shield_center = float(_wave.get("start_shield_x", 320.0))
_flash = 0.0
_round_done = false
# --- параметры -------------------------------------------------------------
static func tier_params(p_tier: int) -> Dictionary:
var t := clampi(p_tier, 1, 4)
var base: Dictionary = (TIERS.get(t, TIERS[1]) as Dictionary).duplicate(true)
var row := MiniGame.data_minigame_tier(GAME_ID, t)
if row.is_empty():
return base
for key in ["packets"]:
if row.has(key):
base[key] = int(row[key])
for key in ["speed", "shield_width", "interval", "threshold"]:
if row.has(key):
base[key] = float(row[key])
return base
# --- генерация -------------------------------------------------------------
static func generate_wave(p_tier: int, seed: int) -> Dictionary:
var t := clampi(p_tier, 1, 4)
var p := tier_params(t)
var total := clampi(int(p.get("packets", 8)), 4, 24)
var base_speed := maxf(40.0, float(p.get("speed", 110.0)))
var shield_width := clampf(float(p.get("shield_width", 260.0)), 80.0, FIELD.size.x)
var interval := maxf(0.10, float(p.get("interval", 0.55)))
var rng := RandomNumberGenerator.new()
rng.seed = seed
var left := FIELD.position.x + FIELD_PAD
var right := FIELD.end.x - FIELD_PAD - PACKET_W
var band := (right - left) / float(total)
var bands: Array = []
for i in range(total):
bands.append(i)
_shuffle(bands, rng)
var packets: Array = []
var spawn_at := 0.35
for i in range(total):
var band_index := int(bands[i])
var band_left := left + band * float(band_index)
var inner := maxf(1.0, band - 12.0 - PACKET_W)
var x := band_left + 6.0 + rng.randf() * inner
x = clampf(x, left, right)
var speed := base_speed * rng.randf_range(0.90, 1.10)
if i > 0:
spawn_at += interval * rng.randf_range(0.85, 1.15)
packets.append({
"id": i,
"x": x,
"speed": speed,
"spawn_at": spawn_at,
})
var min_center := FIELD.position.x + shield_width * 0.5 + SHIELD_PAD
var max_center := FIELD.end.x - shield_width * 0.5 - SHIELD_PAD
var start_shield := rng.randf_range(min_center, max_center)
return {
"tier": t,
"packets": packets,
"total": total,
"speed": base_speed,
"shield_width": shield_width,
"interval": interval,
"threshold": float(p.get("threshold", 0.6)),
"start_shield_x": start_shield,
"field": FIELD,
"shield_y": SHIELD_Y,
"packet_w": PACKET_W,
"packet_h": PACKET_H,
}
static func validate_wave(data: Dictionary) -> bool:
var packets: Array = data.get("packets", [])
var total := int(data.get("total", 0))
if total <= 0 or packets.size() != total:
return false
var shield_width := float(data.get("shield_width", 0.0))
if shield_width <= 0.0 or shield_width > FIELD.size.x:
return false
var base_speed := float(data.get("speed", 0.0))
var last_spawn := -1.0
var xs: Array = []
for packet in packets:
var p: Dictionary = packet
var x := float(p.get("x", -1000.0))
var speed := float(p.get("speed", -1.0))
var spawn_at := float(p.get("spawn_at", -1.0))
if is_nan(x) or is_inf(x) or is_nan(speed) or is_inf(speed):
return false
if is_nan(spawn_at) or is_inf(spawn_at) or spawn_at < 0.0 or spawn_at > 30.0:
return false
if x < FIELD.position.x + FIELD_PAD - 0.5 or x > FIELD.end.x - FIELD_PAD - PACKET_W + 0.5:
return false
if speed < base_speed * 0.89 or speed > base_speed * 1.11:
return false
if spawn_at <= last_spawn:
return false
last_spawn = spawn_at
xs.append(x)
# Щит такой ширины может физически перекрыть пакет.
var center := x + PACKET_W * 0.5
var min_center := FIELD.position.x + shield_width * 0.5 + SHIELD_PAD
var max_center := FIELD.end.x - shield_width * 0.5 - SHIELD_PAD
var reachable := clampf(center, min_center, max_center)
if absf(reachable - center) > shield_width * 0.5 - PACKET_W * 0.5 + 0.5:
return false
xs.sort()
for i in range(1, xs.size()):
if float(xs[i]) - float(xs[i - 1]) < MIN_SPACING - 0.01:
return false
return true
static func ddos_quality(blocked: int, total_packets: int) -> float:
if total_packets <= 0:
return 0.0
return clampf(float(blocked) / float(total_packets), 0.0, 1.0)
# --- игра ------------------------------------------------------------------
func _shield_rect() -> Rect2:
var shield_width := float(_wave.get("shield_width", 260.0))
var min_center := FIELD.position.x + shield_width * 0.5 + SHIELD_PAD
var max_center := FIELD.end.x - shield_width * 0.5 - SHIELD_PAD
_shield_center = clampf(_shield_center, min_center, max_center)
return Rect2(_shield_center - shield_width * 0.5, SHIELD_Y, shield_width, SHIELD_H)
func _spawn_packet(packet: Dictionary) -> void:
_packets.append({
"id": int(packet.get("id", 0)),
"x": float(packet.get("x", 0.0)),
"y": FIELD.position.y - PACKET_H,
"speed": float(packet.get("speed", 100.0)),
"resolved": false,
})
func _add_event(pos: Vector2, text: String, color: Color) -> void:
_events.append({"pos": pos, "text": text, "color": color, "ttl": 0.7})
func _process(delta: float) -> void:
if _finished or _round_done:
return
_elapsed += delta
if _flash > 0.0:
_flash = maxf(0.0, _flash - delta)
var wave_packets: Array = _wave.get("packets", [])
while _spawned < wave_packets.size() \
and _elapsed >= float((wave_packets[_spawned] as Dictionary).get("spawn_at", 0.0)):
_spawn_packet(wave_packets[_spawned])
_spawned += 1
var shield := _shield_rect()
var active: Array = []
for entry in _packets:
var p: Dictionary = entry
var prev_bottom := float(p["y"]) + PACKET_H
p["y"] = float(p["y"]) + float(p["speed"]) * delta
var bottom := float(p["y"]) + PACKET_H
var px := float(p["x"])
if not bool(p["resolved"]) and prev_bottom < SHIELD_Y and bottom >= SHIELD_Y:
if px + PACKET_W >= shield.position.x and px <= shield.end.x:
p["resolved"] = true
_blocked += 1
_add_event(Vector2(px + PACKET_W * 0.5, SHIELD_Y - 10.0), "ОТБИТО",
Theme90.SUCCESS)
_play_sfx(&"stamp")
if not bool(p["resolved"]) and bottom >= FIELD.end.y:
p["resolved"] = true
_breached += 1
_flash = 0.22
_add_event(Vector2(px + PACKET_W * 0.5, FIELD.end.y - 12.0), "ПРОБОЙ",
Theme90.ERROR)
_play_sfx(&"bad")
if not bool(p["resolved"]):
active.append(p)
_packets = active
for event in _events:
(event as Dictionary)["ttl"] = float((event as Dictionary)["ttl"]) - delta
(event as Dictionary)["pos"] = (event as Dictionary)["pos"] + Vector2(0.0, -14.0 * delta)
_events = _events.filter(func(e): return float(e["ttl"]) > 0.0)
if _spawned >= wave_packets.size() and _packets.is_empty():
_round_done = true
var total := int(_wave.get("total", wave_packets.size()))
var q := ddos_quality(_blocked, total)
_result_detail = _with_context("ОТБИТО %d ИЗ %d · ПРОБОЙ %d" % [
_blocked, total, _breached])
_finish(q)
queue_redraw()
func _with_context(text: String) -> String:
var parts: Array = []
var reward := str(context.get("reward_text", ""))
var penalty := str(context.get("penalty_text", ""))
if not reward.is_empty():
parts.append(reward)
if not penalty.is_empty():
parts.append(penalty)
parts.append(text)
return " · ".join(parts)
func _on_pointer_moved(local: Vector2, _event: InputEvent) -> void:
if _finished or _round_done:
return
_shield_center = local.x
_queue_shield_redraw()
func _on_pointer_pressed(local: Vector2, event: InputEvent) -> bool:
if _finished or _round_done:
return false
_on_pointer_moved(local, event)
return true
func _on_key_pressed(keycode: int, event: InputEvent) -> bool:
if _finished or _round_done:
return false
var step := 8.0 if event is InputEventKey and (event as InputEventKey).shift_pressed else 26.0
match keycode:
KEY_LEFT, KEY_A:
_shield_center -= step
_queue_shield_redraw()
return true
KEY_RIGHT, KEY_D:
_shield_center += step
_queue_shield_redraw()
return true
return false
func _queue_shield_redraw() -> void:
_shield_rect()
queue_redraw()
# --- отрисовка -------------------------------------------------------------
func _draw_game() -> void:
var total := int(_wave.get("total", 0))
draw_rect(FIELD, Color(0.075, 0.110, 0.120, 1.0))
draw_rect(FIELD, Color(1, 1, 1, 0.10), false, 1.0)
var grid := Color(1, 1, 1, 0.055)
var gx := FIELD.position.x + 28.0
while gx < FIELD.end.x:
draw_line(Vector2(gx, FIELD.position.y), Vector2(gx, FIELD.end.y), grid, 1.0)
gx += 32.0
var gy := FIELD.position.y + 26.0
while gy < FIELD.end.y:
draw_line(Vector2(FIELD.position.x, gy), Vector2(FIELD.end.x, gy), grid, 1.0)
gy += 26.0
_draw_label("ОТБИТО %d / %d" % [_blocked, total], Vector2(24.0, 44.0), 12,
Theme90.INK, 220.0)
_draw_label("ПРОБОЙ %d" % _breached, Vector2(420.0, 44.0), 12,
Theme90.ERROR if _breached > 0 else Theme90.SHADE, 200.0,
HORIZONTAL_ALIGNMENT_RIGHT)
if _flash > 0.0:
draw_rect(FIELD, Color(Theme90.ERROR.r, Theme90.ERROR.g, Theme90.ERROR.b,
0.10 + 0.18 * (_flash / 0.22)))
for entry in _packets:
var p: Dictionary = entry
var rect := Rect2(float(p["x"]), float(p["y"]), PACKET_W, PACKET_H)
draw_style_box(Theme90.box(Theme90.ERROR, Theme90.ERROR.darkened(0.25), 2, 1,
Theme90.SHADOW), rect)
Theme90.text_vcenter(self, Theme90.ui(), rect, "!", 12, Theme90.WHITE,
-1, HORIZONTAL_ALIGNMENT_CENTER)
draw_line(Vector2(rect.position.x + 4.0, rect.position.y + 4.0),
Vector2(rect.end.x - 4.0, rect.end.y - 4.0), Color(1, 1, 1, 0.32), 1.0)
var shield := _shield_rect()
draw_style_box(Theme90.box(Theme90.ACCENT, Theme90.NAVY_DEEP, 3, 1, Theme90.SHADOW), shield)
Theme90.draw_hatch(self, shield.grow(-2.0), Color(1, 1, 1, 0.14), 7.0)
Theme90.text_vcenter(self, Theme90.ui(), shield, "ЩИТ", 10, Theme90.WHITE,
-1, HORIZONTAL_ALIGNMENT_CENTER)
for event in _events:
var e: Dictionary = event
var alpha := clampf(float(e["ttl"]) / 0.55, 0.0, 1.0)
var pos: Vector2 = e["pos"]
var col: Color = e["color"]
Theme90.text(self, Theme90.ui(), pos - Vector2(24.0, 0.0), str(e["text"]), 10,
Color(col.r, col.g, col.b, alpha), 48.0, HORIZONTAL_ALIGNMENT_CENTER)
func _draw_footer_hint() -> void:
# Подсказка живёт в свободной полосе между полем (y=294) и футером (y=320).
_draw_label("ЩИТ СЛЕДУЕТ ЗА МЫШЬЮ ПО X · СТРЕЛКИ ← → ТОЖЕ", Vector2(18.0, 298.0),
10, Theme90.SHADE, 604.0)
# --- служебное -------------------------------------------------------------
static func _shuffle(list: Array, rng: RandomNumberGenerator) -> void:
for i in range(list.size() - 1, 0, -1):
var j := rng.randi_range(0, i)
var tmp = list[i]
list[i] = list[j]
list[j] = tmp
+1
View File
@@ -0,0 +1 @@
uid://djv0odpjkyh8p
+649
View File
@@ -0,0 +1,649 @@
class_name JunkGame
extends MiniGame
## Атака «Фильтр» (junk). Статическая сетка карточек: игрок помечает мусор
## в карантин, повторный клик снимает пометку, ПРОВЕРИТЬ фиксирует выбор.
##
## Мусор выглядит почти как обычный файл: аномалия спрятана в метаданных
## (размер, расширение/тип, дубль имени, дата, CRC, непечатаемый символ).
## Нормальные карточки полностью согласованы. Точное число мусора не
## показывается — только «ОТМЕЧЕНО: N».
##
## Здесь нет таймера и нет поражения по времени. Качество:
## q = clamp((removed_junk - false_positive) / total_junk, 0, 1).
const GAME_ID := "junk"
const TITLE_RU := "ФИЛЬТР"
const BAD_CHAR := "▯"
const COLS := 3
const CARD_W := 190.0
const CARD_GAP := 8.0
const GRID_TOP := 58.0
const GRID_BOTTOM := 294.0
const VERIFY_RECT := Rect2(466.0, 320.0, 156.0, 28.0)
const TIERS := {
1: {"cards": 9, "junk": 2, "hints": 1, "threshold": 0.60},
2: {"cards": 10, "junk": 3, "hints": 2, "threshold": 0.65},
3: {"cards": 11, "junk": 4, "hints": 2, "threshold": 0.70},
4: {"cards": 12, "junk": 5, "hints": 3, "threshold": 0.75},
}
const TYPE_DEFS := [
{"kind": "text", "label": "ТЕКСТ", "exts": ["TXT", "DOC", "RTF"]},
{"kind": "sound", "label": "ЗВУК", "exts": ["WAV", "MP3"]},
{"kind": "photo", "label": "ФОТО", "exts": ["JPG", "PNG", "BMP"]},
{"kind": "video", "label": "ВИДЕО", "exts": ["AVI", "MP4"]},
]
const NAMES := [
"отчёт", "смета", "письмо", "схема", "прайс", "акт", "реестр", "план",
"курс", "заявка", "договор", "опись", "факс", "сводка", "накладная",
"ведомость", "протокол", "фото", "звук", "клип",
]
const ANOMALY_DEFS := [
{"id": "zero_size", "family": "size"},
{"id": "absurd_size", "family": "size"},
{"id": "wrong_ext", "family": "ext"},
{"id": "duplicate_name", "family": "name"},
{"id": "bad_date", "family": "date"},
{"id": "missing_crc", "family": "crc"},
{"id": "bad_char", "family": "name"},
]
const ANOMALY_HINTS := {
"zero_size": "размер 0 Б — это мусор",
"absurd_size": "абсурдный размер файла",
"wrong_ext": "расширение не сходится с типом",
"duplicate_name": "два файла с одинаковым именем",
"invalid_date": "несуществующая дата",
"future_date": "дата из будущего",
"missing_crc": "нет контрольной суммы",
"bad_char": "в имени непечатаемый символ",
}
const WRONG_EXTS := {
"text": ["WAV", "JPG", "AVI", "EXE"],
"sound": ["TXT", "JPG", "AVI", "EXE"],
"photo": ["TXT", "WAV", "AVI", "EXE"],
"video": ["TXT", "WAV", "JPG", "EXE"],
}
var _data: Dictionary = {}
var _cards: Array = []
var _marked: Dictionary = {}
var _submitted: bool = false
var _hover: int = -1
func _own_game_id() -> String:
return GAME_ID
func title() -> String:
return TITLE_RU
func quality() -> float:
if _finished:
return quality_value
var counts := count_marks(_cards, _marked)
return junk_quality(int(counts["removed_junk"]), int(counts["false_positive"]),
int(_data.get("total_junk", 0)))
func _setup() -> void:
_data = generate_junk(tier, seed_value)
_cards = _data.get("cards", [])
_marked.clear()
_submitted = false
_hover = -1
# --- параметры -------------------------------------------------------------
static func tier_params(p_tier: int) -> Dictionary:
var t := clampi(p_tier, 1, 4)
var base: Dictionary = (TIERS.get(t, TIERS[1]) as Dictionary).duplicate(true)
var row := MiniGame.data_minigame_tier(GAME_ID, t)
if row.is_empty():
return base
for key in ["cards", "junk", "hints"]:
if row.has(key):
base[key] = int(row[key])
if row.has("threshold"):
base["threshold"] = float(row["threshold"])
return base
# --- генерация -------------------------------------------------------------
static func generate_junk(p_tier: int, seed: int) -> Dictionary:
var t := clampi(p_tier, 1, 4)
var p := tier_params(t)
var total := clampi(int(p.get("cards", 9)), 4, 24)
var junk_count := clampi(int(p.get("junk", 2)), 1, total - 1)
var hint_count := clampi(int(p.get("hints", 1)), 0, 3)
var rng := RandomNumberGenerator.new()
rng.seed = seed
var names: Array = NAMES.duplicate()
_shuffle(names, rng)
var cards: Array = []
for i in range(total):
var td: Dictionary = TYPE_DEFS[rng.randi_range(0, TYPE_DEFS.size() - 1)]
var exts: Array = td["exts"]
var ext := str(exts[rng.randi_range(0, exts.size() - 1)])
var raw_name := str(names[i % names.size()])
if i >= names.size():
raw_name = "%s_%d" % [raw_name, i]
var size_kb := rng.randi_range(2, 480)
cards.append({
"id": i,
"name": raw_name,
"kind": str(td["kind"]),
"type_label": str(td["label"]),
"ext": ext,
"size_kb": size_kb,
"size_text": "%d КБ" % size_kb,
"date": _random_date(rng),
"crc": "%04X" % rng.randi_range(0, 65535),
"junk": false,
"anomalies": [],
})
var order: Array = []
for i in range(total):
order.append(i)
_shuffle(order, rng)
var junk_indices: Array = order.slice(0, junk_count)
var normal_indices: Array = []
for i in range(total):
if not junk_indices.has(i):
normal_indices.append(i)
for pos in range(junk_indices.size()):
var card: Dictionary = cards[int(junk_indices[pos])]
card["junk"] = true
var anomaly_ids := _pick_anomalies(t, pos, rng)
var applied: Array = []
for anomaly_id in anomaly_ids:
var result := _apply_anomaly(card, str(anomaly_id), rng, cards, normal_indices)
if not str(result).is_empty():
applied.append(str(result))
if applied.is_empty():
card["size_kb"] = 0
card["size_text"] = "0 Б"
applied.append("zero_size")
card["anomalies"] = applied
var hints := _collect_hints(cards, hint_count)
return {
"tier": t,
"cards": cards,
"total_cards": total,
"total_junk": junk_count,
"hint_count": hint_count,
"hints": hints,
"threshold": float(p.get("threshold", 0.6)),
}
static func _pick_anomalies(p_tier: int, junk_pos: int, rng: RandomNumberGenerator) -> Array:
var pool: Array = ANOMALY_DEFS.duplicate(true)
_shuffle(pool, rng)
var picked: Array = []
var families: Dictionary = {}
if p_tier == 1 and junk_pos == 0:
picked.append("zero_size")
families["size"] = true
elif p_tier <= 2 and junk_pos == 0 and rng.randf() < 0.5:
picked.append("zero_size")
families["size"] = true
for entry in pool:
if picked.size() >= 2:
break
var family := str((entry as Dictionary)["family"])
var anomaly_id := str((entry as Dictionary)["id"])
if families.has(family):
continue
picked.append(anomaly_id)
families[family] = true
if picked.is_empty():
picked.append("zero_size")
return picked
static func _apply_anomaly(card: Dictionary, anomaly_id: String, rng: RandomNumberGenerator,
cards: Array, normal_indices: Array) -> String:
match anomaly_id:
"zero_size":
card["size_kb"] = 0
card["size_text"] = "0 Б"
return "zero_size"
"absurd_size":
card["size_kb"] = 999999
card["size_text"] = "999 999 КБ"
return "absurd_size"
"wrong_ext":
var kind_name := str(card.get("kind", "text"))
var options: Array = WRONG_EXTS.get(kind_name, ["EXE"])
var alt := str(options[rng.randi_range(0, options.size() - 1)])
if _ext_matches_kind(kind_name, alt):
alt = "EXE"
card["ext"] = alt
return "wrong_ext"
"duplicate_name":
if normal_indices.is_empty() or str(card.get("name", "")).contains(BAD_CHAR):
return ""
var victim: Dictionary = cards[int(normal_indices[rng.randi_range(0,
normal_indices.size() - 1)])]
card["name"] = str(victim.get("name", ""))
return "duplicate_name"
"bad_date":
if rng.randf() < 0.5:
card["date"] = "31.02.97"
return "invalid_date"
card["date"] = "12.06.31"
return "future_date"
"missing_crc":
card["crc"] = "—"
return "missing_crc"
"bad_char":
var name := str(card.get("name", ""))
if name.length() >= 2:
card["name"] = name.substr(0, 1) + BAD_CHAR + name.substr(2)
else:
card["name"] = BAD_CHAR + name
return "bad_char"
return ""
static func _collect_hints(cards: Array, limit: int) -> Array:
if limit <= 0:
return []
var order := ["zero_size", "absurd_size", "wrong_ext", "duplicate_name",
"invalid_date", "future_date", "missing_crc", "bad_char"]
var seen: Dictionary = {}
for key in order:
for card in cards:
if not bool((card as Dictionary).get("junk", false)):
continue
var anomalies: Array = (card as Dictionary).get("anomalies", [])
if anomalies.has(key):
seen[key] = true
var hints: Array = []
for key in order:
if seen.has(key) and hints.size() < limit:
hints.append(str(ANOMALY_HINTS.get(key, "")))
return hints
# --- проверка --------------------------------------------------------------
static func _ext_matches_kind(kind: String, ext: String) -> bool:
var e := ext.strip_edges().to_upper()
for td in TYPE_DEFS:
if str((td as Dictionary)["kind"]) != kind:
continue
var exts: Array = (td as Dictionary)["exts"]
for allowed in exts:
if e == str(allowed):
return true
return false
static func _valid_date(value: String) -> bool:
var parts := value.split(".")
if parts.size() != 3:
return false
if not parts[0].is_valid_int() or not parts[1].is_valid_int() \
or not parts[2].is_valid_int():
return false
var day := int(parts[0])
var month := int(parts[1])
var year := int(parts[2])
if month < 1 or month > 12:
return false
if day < 1 or day > _days_in_month(month):
return false
return year >= 0 and year <= 99
static func _future_date(value: String) -> bool:
var parts := value.split(".")
if parts.size() != 3 or not parts[2].is_valid_int():
return false
# Игра идёт в 1996–1997; двухзначные 00–89 читаются как 2000-е.
var year := int(parts[2])
return year > 97 or year < 90
static func _days_in_month(month: int) -> int:
match month:
2:
return 28
4, 6, 9, 11:
return 30
return 31
static func _random_date(rng: RandomNumberGenerator) -> String:
var day := rng.randi_range(1, 28)
var month := rng.randi_range(1, 12)
var year := 96 + rng.randi_range(0, 1)
return "%02d.%02d.%02d" % [day, month, year]
## Все видимые аномалии карточки. Дубль имени — отношение между карточками,
## поэтому передаётся counts имён.
static func detected_anomalies(card: Dictionary, name_counts: Dictionary) -> Array:
var out: Array = []
var size_kb := int(card.get("size_kb", 0))
if size_kb == 0:
out.append("zero_size")
elif size_kb >= 999999:
out.append("absurd_size")
if not _ext_matches_kind(str(card.get("kind", "")), str(card.get("ext", ""))):
out.append("wrong_ext")
if str(card.get("crc", "")) == "—":
out.append("missing_crc")
var date := str(card.get("date", ""))
if not _valid_date(date):
out.append("invalid_date")
elif _future_date(date):
out.append("future_date")
if str(card.get("name", "")).contains(BAD_CHAR):
out.append("bad_char")
if int(name_counts.get(str(card.get("name", "")), 0)) > 1:
out.append("duplicate_name")
return out
static func validate_junk(data: Dictionary) -> bool:
var cards: Array = data.get("cards", [])
var p := tier_params(int(data.get("tier", 1)))
if cards.size() != int(data.get("total_cards", -1)) or cards.is_empty():
return false
if int(data.get("total_junk", -1)) != int(p.get("junk", -1)):
return false
var name_counts: Dictionary = {}
for card in cards:
var name := str((card as Dictionary).get("name", ""))
name_counts[name] = int(name_counts.get(name, 0)) + 1
var junk_seen := 0
for card in cards:
var c: Dictionary = card
var detected := detected_anomalies(c, name_counts)
if bool(c.get("junk", false)):
junk_seen += 1
if detected.is_empty():
return false
# Дубль имени сам по себе неоднозначен: у мусорной карточки должен
# быть ещё хотя бы один независимый признак.
var independent := 0
for anomaly in detected:
if str(anomaly) != "duplicate_name":
independent += 1
if independent == 0:
return false
else:
if int(c.get("size_kb", 0)) <= 0 or int(c.get("size_kb", 0)) > 999998:
return false
if not _ext_matches_kind(str(c.get("kind", "")), str(c.get("ext", ""))):
return false
if str(c.get("crc", "")) == "—" or str(c.get("crc", "")).length() != 4:
return false
if not _valid_date(str(c.get("date", ""))) or _future_date(str(c.get("date", ""))):
return false
if str(c.get("name", "")).contains(BAD_CHAR):
return false
if junk_seen != int(data.get("total_junk", -1)):
return false
return true
static func junk_quality(removed_junk: int, false_positive: int, total_junk: int) -> float:
if total_junk <= 0:
return 0.0
return clampf((float(removed_junk) - float(false_positive)) / float(total_junk),
0.0, 1.0)
static func count_marks(cards: Array, marked: Dictionary) -> Dictionary:
var removed := 0
var false_positive := 0
var missed := 0
for card in cards:
var c: Dictionary = card
var is_marked := bool(marked.get(int(c.get("id", -1)), false))
if bool(c.get("junk", false)):
if is_marked:
removed += 1
else:
missed += 1
elif is_marked:
false_positive += 1
return {
"removed_junk": removed,
"false_positive": false_positive,
"missed_junk": missed,
"marked": int(marked.size()),
}
# --- ввод ------------------------------------------------------------------
func _card_index_at(local: Vector2) -> int:
for i in range(_cards.size()):
if _card_rect(i).has_point(local):
return i
return -1
func _toggle(index: int) -> void:
if index < 0 or index >= _cards.size():
return
var id := int((_cards[index] as Dictionary).get("id", index))
if _marked.has(id):
_marked.erase(id)
else:
_marked[id] = true
_play_sfx(&"tick")
queue_redraw()
func _submit() -> void:
if _submitted or _finished:
return
var counts := count_marks(_cards, _marked)
var q := junk_quality(int(counts["removed_junk"]), int(counts["false_positive"]),
int(_data.get("total_junk", 0)))
_submitted = true
_result_detail = _with_context("СНЯТО %d ИЗ %d · ЛОЖНЫХ %d · ПРОПУЩЕНО %d" % [
int(counts["removed_junk"]), int(_data.get("total_junk", 0)),
int(counts["false_positive"]), int(counts["missed_junk"])])
_finish(q)
func _with_context(text: String) -> String:
var parts: Array = []
var reward := str(context.get("reward_text", ""))
var penalty := str(context.get("penalty_text", ""))
if not reward.is_empty():
parts.append(reward)
if not penalty.is_empty():
parts.append(penalty)
parts.append(text)
return " · ".join(parts)
func _on_pointer_pressed(local: Vector2, _event: InputEvent) -> bool:
if _submitted or _finished:
return false
if VERIFY_RECT.has_point(local):
_submit()
return true
var index := _card_index_at(local)
if index >= 0:
_toggle(index)
return true
return false
func _on_pointer_moved(local: Vector2, _event: InputEvent) -> void:
if _submitted or _finished:
return
var next := _card_index_at(local)
if next != _hover:
_hover = next
queue_redraw()
func _on_key_pressed(keycode: int, _event: InputEvent) -> bool:
if _submitted or _finished:
return false
if keycode in [KEY_ENTER, KEY_KP_ENTER, KEY_SPACE]:
_submit()
return true
return false
# --- отрисовка -------------------------------------------------------------
func _card_rect(index: int) -> Rect2:
var rows := maxi(1, int(ceil(float(_cards.size()) / float(COLS))))
var gap := CARD_GAP
var avail_h := GRID_BOTTOM - GRID_TOP
var card_h := minf(70.0, (avail_h - gap * float(rows - 1)) / float(rows))
var total_w := CARD_W * float(COLS) + gap * float(COLS - 1)
var x0 := 18.0 + (604.0 - total_w) * 0.5
var col := index % COLS
var row := index / COLS
return Rect2(x0 + float(col) * (CARD_W + gap), GRID_TOP + float(row) * (card_h + gap),
CARD_W, card_h)
func _draw_game() -> void:
var marked_count := _marked.size()
_draw_label("ОТМЕЧЕНО: %d" % marked_count, Vector2(24.0, 44.0), 12,
Theme90.INK, 180.0)
for i in range(_cards.size()):
_draw_card(i)
_draw_action_button(VERIFY_RECT, "ПРОВЕРИТЬ", not _submitted, true)
func _draw_card(index: int) -> void:
var card: Dictionary = _cards[index]
var rect := _card_rect(index)
var marked := _marked.has(int(card.get("id", -1)))
var junk := bool(card.get("junk", false))
var fill := Theme90.PAPER_HI
var border := Theme90.BORDER
var border_w := 1
var status := ""
if _submitted:
if junk and marked:
fill = Theme90.SUCCESS.lightened(0.78)
border = Theme90.SUCCESS
border_w = 2
status = "СНЯТ"
elif junk and not marked:
fill = Theme90.WARN.lightened(0.78)
border = Theme90.WARN
border_w = 2
status = "ПРОПУЩЕН"
elif not junk and marked:
fill = Theme90.ERROR.lightened(0.80)
border = Theme90.ERROR
border_w = 2
status = "ЛОЖНО"
elif not junk:
border = Theme90.BORDER_SOFT
elif marked:
fill = Theme90.ERROR.lightened(0.90)
border = Theme90.ERROR
border_w = 2
status = "В КАРАНТИН"
elif index == _hover:
border = Theme90.ACCENT
border_w = 2
draw_style_box(Theme90.box(fill, border, 3, border_w), rect)
var kind := str(card.get("kind", "text"))
var ext := str(card.get("ext", ""))
var cat := Theme90.ext_category(ext)
var icon_col := Theme90.ext_color(cat)
if cat.is_empty():
match kind:
"sound":
icon_col = Theme90.WARN
"photo":
icon_col = Theme90.SUCCESS
"video":
icon_col = Theme90.ERROR
_:
icon_col = Theme90.ACCENT
Theme90.draw_doc_icon(self, Rect2(rect.position + Vector2(7.0, 7.0), Vector2(28.0, 28.0)),
kind, icon_col, ext)
var tall := rect.size.y >= 64.0
var name_y := rect.position.y + (8.0 if tall else 5.0)
var meta_y := rect.position.y + (27.0 if tall else 21.0)
var sub_y := rect.position.y + (44.0 if tall else 36.0)
var text_x := rect.position.x + 42.0
var text_w := rect.size.x - 50.0
var name := Theme90.fit(Theme90.ui(), str(card.get("name", "")), 12, text_w)
Theme90.text(self, Theme90.ui(), Vector2(text_x, name_y), name, 12, Theme90.INK, text_w)
Theme90.text(self, Theme90.ui(), Vector2(text_x, meta_y),
"%s · %s" % [ext, str(card.get("size_text", ""))], 10, Theme90.DARK, text_w)
Theme90.text(self, Theme90.ui(), Vector2(text_x, sub_y),
"%s · CRC %s" % [str(card.get("date", "")), str(card.get("crc", ""))],
10, Theme90.SHADE, text_w)
var badge := status
if badge.is_empty():
badge = str(card.get("type_label", ""))
var badge_col := Theme90.SHADE
if _submitted and status == "СНЯТ":
badge_col = Theme90.SUCCESS
elif _submitted and status == "ЛОЖНО":
badge_col = Theme90.ERROR
elif status == "ПРОПУЩЕН":
badge_col = Theme90.WARN
elif status == "В КАРАНТИН":
badge_col = Theme90.ERROR
Theme90.text(self, Theme90.ui(), Vector2(rect.position.x + 112.0, name_y),
Theme90.fit(Theme90.ui(), badge, 10, rect.size.x - 118.0), 10, badge_col,
rect.size.x - 118.0, HORIZONTAL_ALIGNMENT_RIGHT)
func _draw_footer_hint() -> void:
var hints: Array = _data.get("hints", [])
var text := "ПОДСКАЗКИ: " + " · ".join(hints) if not hints.is_empty() \
else "СНИМАЙТЕ ТОЛЬКО МУСОР"
# Полная подсказка — в полосе между сеткой (y=294) и футером (y=320).
_draw_label(Theme90.fit(Theme90.ui(), text, 10, 604.0),
Vector2(18.0, 298.0), 10, Theme90.SHADE, 604.0)
# --- служебное -------------------------------------------------------------
static func _shuffle(list: Array, rng: RandomNumberGenerator) -> void:
for i in range(list.size() - 1, 0, -1):
var j := rng.randi_range(0, i)
var tmp = list[i]
list[i] = list[j]
list[j] = tmp
+1
View File
@@ -0,0 +1 @@
uid://dmrrnmmspoyhy
+609
View File
@@ -0,0 +1,609 @@
class_name PhishingGame
extends MiniGame
## Атака «Паспорт» (phishing). Очередь клиентов: две «бумаги» — заявка и
## пропуск. Игрок сверяет поля и жмёт ВЫДАТЬ или ОТКАТАТЬ.
##
## У каждого клиента ровно один правильный ответ. Подделка отличается ровно
## одним проверяемым полем (имя / отдел / ID / срок действия), причём ID
## использует кириллическую похожую букву. Отмена решения невозможна,
## таймера нет; после решения — короткая печать 0.12 с.
##
## Качество: q = correct / total_clients.
const GAME_ID := "phishing"
const TITLE_RU := "ПАСПОРТ"
const ISSUE_RECT := Rect2(142.0, 262.0, 166.0, 30.0)
const DENY_RECT := Rect2(332.0, 262.0, 166.0, 30.0)
const FILE_RECT := Rect2(24.0, 62.0, 286.0, 196.0)
const PASS_RECT := Rect2(330.0, 62.0, 286.0, 196.0)
const STAMP_TTL := 0.12
const TIERS := {
1: {"clients": 3, "checks": ["name"], "threshold": 0.75},
2: {"clients": 4, "checks": ["name", "department"], "threshold": 0.75},
3: {"clients": 5, "checks": ["name", "department", "id"], "threshold": 0.80},
4: {"clients": 6, "checks": ["name", "department", "id", "photo", "expiry"],
"threshold": 0.80},
}
const PEOPLE := [
{"surname": "Иванов", "first": "Пётр", "female": false},
{"surname": "Смирнова", "first": "Анна", "female": true},
{"surname": "Кузнецов", "first": "Илья", "female": false},
{"surname": "Попова", "first": "Мария", "female": true},
{"surname": "Соколов", "first": "Артём", "female": false},
{"surname": "Лебедева", "first": "Ольга", "female": true},
{"surname": "Новиков", "first": "Денис", "female": false},
{"surname": "Морозова", "first": "Вера", "female": true},
{"surname": "Волков", "first": "Сергей", "female": false},
{"surname": "Зайцева", "first": "Нина", "female": true},
{"surname": "Павлов", "first": "Максим", "female": false},
{"surname": "Орлова", "first": "Елена", "female": true},
{"surname": "Фёдоров", "first": "Олег", "female": false},
{"surname": "Егорова", "first": "Ирина", "female": true},
{"surname": "Белов", "first": "Антон", "female": false},
{"surname": "Гусева", "first": "Дарья", "female": true},
{"surname": "Тихонов", "first": "Роман", "female": false},
{"surname": "Киселёва", "first": "Света", "female": true},
{"surname": "Борисов", "first": "Кирилл", "female": false},
{"surname": "Андреева", "first": "Таня", "female": true},
]
const DEPARTMENTS := [
"БУХГАЛТЕРИЯ", "ОТДЕЛ КАДРОВ", "ЛОГИСТИКА", "ПРОДАЖИ", "ИТ-ОТДЕЛ",
"ЮРОТДЕЛ", "СКЛАД", "ДИСПЕТЧЕРСКАЯ", "АРХИВ", "СНАБЖЕНИЕ",
"КЛИЕНТСКИЙ ОТДЕЛ", "ТРАНСПОРТ",
]
## ASCII → кириллический двойник. ID всегда содержит буквы из ключей.
const LOOKALIKE := {
"A": "А", "B": "В", "C": "С", "E": "Е", "H": "Н", "K": "К",
"M": "М", "O": "О", "P": "Р", "T": "Т", "X": "Х", "Y": "У",
}
const ID_LETTERS := "ABEKMHOPCTX"
const CHECK_LABELS := {
"name": "ИМЯ",
"dept": "ОТДЕЛ",
"id": "ID",
"expiry": "СРОК",
"photo": "ФОТО",
}
var _clients: Array = []
var _index: int = 0
var _correct: int = 0
var _wrong: int = 0
var _stamp_ttl: float = 0.0
var _last_ok: bool = false
var _last_choice: String = ""
var _hover: String = ""
func _own_game_id() -> String:
return GAME_ID
func title() -> String:
return TITLE_RU
func quality() -> float:
if _finished:
return quality_value
return phishing_quality(_correct, _clients.size())
func _setup() -> void:
_clients = generate_clients(tier, seed_value)
_index = 0
_correct = 0
_wrong = 0
_stamp_ttl = 0.0
_last_ok = false
_last_choice = ""
_hover = ""
mouse_default_cursor_shape = Control.CURSOR_ARROW
# --- параметры -------------------------------------------------------------
static func tier_params(p_tier: int) -> Dictionary:
var t := clampi(p_tier, 1, 4)
var base: Dictionary = (TIERS.get(t, TIERS[1]) as Dictionary).duplicate(true)
var row := MiniGame.data_minigame_tier(GAME_ID, t)
if row.is_empty():
base["checks"] = _normalized_checks(base.get("checks", []))
return base
if row.has("clients"):
base["clients"] = int(row["clients"])
if row.has("checks"):
base["checks"] = _normalized_checks(row["checks"])
else:
base["checks"] = _normalized_checks(base.get("checks", []))
if row.has("threshold"):
base["threshold"] = float(row["threshold"])
return base
static func _normalized_checks(raw) -> Array:
var out: Array = []
if raw is Array:
for entry in raw:
match str(entry).to_lower():
"name":
if not out.has("name"):
out.append("name")
"department", "dept":
if not out.has("dept"):
out.append("dept")
"id":
if not out.has("id"):
out.append("id")
"expiry", "expires", "date":
if not out.has("expiry"):
out.append("expiry")
"photo", "avatar":
if not out.has("photo"):
out.append("photo")
if out.is_empty():
out = ["name"]
return out
# --- генерация -------------------------------------------------------------
static func generate_clients(p_tier: int, seed: int) -> Array:
var t := clampi(p_tier, 1, 4)
var p := tier_params(t)
var total := clampi(int(p.get("clients", 3)), 2, 12)
var clients: Array = []
for i in range(total):
clients.append(generate_client(t, seed, i, -1))
var valid_count := 0
var fake_count := 0
for client in clients:
if bool((client as Dictionary).get("valid", true)):
valid_count += 1
else:
fake_count += 1
if fake_count == 0 and total >= 2:
clients[0] = generate_client(t, seed, 0, 1)
elif valid_count == 0 and total >= 2:
clients[0] = generate_client(t, seed, 0, 0)
return clients
## force_fake: -1 — случайно, 0 — честный, 1 — подделка.
static func generate_client(p_tier: int, seed: int, index: int = 0,
force_fake: int = -1) -> Dictionary:
var t := clampi(p_tier, 1, 4)
var p := tier_params(t)
var checks: Array = p.get("checks", ["name"])
var rng := RandomNumberGenerator.new()
rng.seed = seed + index * 104729 + 17
var person: Dictionary = PEOPLE[rng.randi_range(0, PEOPLE.size() - 1)]
var dept := str(DEPARTMENTS[rng.randi_range(0, DEPARTMENTS.size() - 1)])
var id_value := _make_id(rng)
var expiry := _random_expiry(rng)
var photo := {
"jacket": rng.randi_range(0, 3),
"hair": rng.randi_range(0, 3),
"skin": rng.randi_range(0, 2),
"female": bool(person.get("female", false)),
}
var full_name := "%s %s" % [str(person.get("surname", "")), str(person.get("first", ""))]
var file_data := {
"name": full_name,
"dept": dept,
"id": id_value,
"expiry": expiry,
"photo": photo.duplicate(true),
}
var pass_data := {
"name": full_name,
"dept": dept,
"id": id_value,
"expiry": expiry,
"photo": photo.duplicate(true),
}
var make_fake := false
if force_fake == 1:
make_fake = true
elif force_fake == 0:
make_fake = false
else:
make_fake = rng.randf() < 0.5
var mismatch := ""
if make_fake:
var kinds: Array = []
for check in checks:
if check in ["name", "dept", "id", "expiry"]:
kinds.append(str(check))
if kinds.is_empty():
kinds = ["name"]
var kind := str(kinds[rng.randi_range(0, kinds.size() - 1)])
_apply_fake(pass_data, kind, rng)
mismatch = kind
return {
"id": index,
"file": file_data,
"pass": pass_data,
"mismatch": mismatch,
"valid": mismatch.is_empty(),
"expected": "deny" if not mismatch.is_empty() else "issue",
"checks": checks.duplicate(),
}
static func _apply_fake(pass_data: Dictionary, kind: String, rng: RandomNumberGenerator) -> void:
match kind:
"name":
var full := str(pass_data.get("name", ""))
var space := full.find(" ")
if space > 0:
pass_data["name"] = _surname_variant(full.substr(0, space)) + full.substr(space)
else:
pass_data["name"] = _surname_variant(full)
"dept":
var current := str(pass_data.get("dept", ""))
var current_index := DEPARTMENTS.find(current)
if current_index < 0:
current_index = 0
var offset := rng.randi_range(1, DEPARTMENTS.size() - 1)
pass_data["dept"] = str(DEPARTMENTS[(current_index + offset) % DEPARTMENTS.size()])
"id":
pass_data["id"] = _lookalike_id(str(pass_data.get("id", "")))
"expiry":
pass_data["expiry"] = _shift_expiry(str(pass_data.get("expiry", "")))
static func _surname_variant(surname: String) -> String:
if surname.ends_with("ов") or surname.ends_with("ев") or surname.ends_with("ин"):
return surname + "а"
if surname.ends_with("а"):
return surname.substr(0, surname.length() - 1)
return surname + "а"
static func _make_id(rng: RandomNumberGenerator) -> String:
var out := "id_"
out += ID_LETTERS[rng.randi_range(0, ID_LETTERS.length() - 1)]
out += str(rng.randi_range(0, 9))
out += str(rng.randi_range(0, 9))
out += ID_LETTERS[rng.randi_range(0, ID_LETTERS.length() - 1)]
return out
static func _lookalike_id(id_value: String) -> String:
for i in range(id_value.length()):
var ch := id_value.substr(i, 1)
if LOOKALIKE.has(ch):
return id_value.substr(0, i) + str(LOOKALIKE[ch]) + id_value.substr(i + 1)
return id_value + "К"
static func _random_expiry(rng: RandomNumberGenerator) -> String:
var day := rng.randi_range(1, 28)
var month := rng.randi_range(1, 12)
var year := 97 + rng.randi_range(0, 1)
return "%02d.%02d.%02d" % [day, month, year]
static func _shift_expiry(value: String) -> String:
var parts := value.split(".")
if parts.size() != 3:
return "01.01.98"
var day := clampi(int(parts[0]), 1, 28)
var month := clampi(int(parts[1]), 1, 12)
var year := int(parts[2])
if year >= 98:
year = 97
else:
year += 1
return "%02d.%02d.%02d" % [day, month, year]
# --- проверка --------------------------------------------------------------
static func _same_photo(a: Dictionary, b: Dictionary) -> bool:
return int(a.get("jacket", -1)) == int(b.get("jacket", -2)) \
and int(a.get("hair", -1)) == int(b.get("hair", -2)) \
and int(a.get("skin", -1)) == int(b.get("skin", -2)) \
and bool(a.get("female", false)) == bool(b.get("female", true))
static func client_diff_fields(client: Dictionary) -> Array:
var out: Array = []
var file_data: Dictionary = client.get("file", {})
var pass_data: Dictionary = client.get("pass", {})
for field in ["name", "dept", "id", "expiry"]:
if str(file_data.get(field, "")) != str(pass_data.get(field, "")):
out.append(field)
if not _same_photo(file_data.get("photo", {}), pass_data.get("photo", {})):
out.append("photo")
return out
static func validate_client(client: Dictionary, p_tier: int) -> bool:
var checks: Array = tier_params(p_tier).get("checks", ["name"])
var diffs := client_diff_fields(client)
if diffs.size() > 1:
return false
var valid_now := diffs.is_empty()
if valid_now != bool(client.get("valid", false)):
return false
if str(client.get("expected", "")) != ("issue" if valid_now else "deny"):
return false
if not valid_now:
if not checks.has(str(diffs[0])):
return false
if str(client.get("mismatch", "")) != str(diffs[0]):
return false
else:
if not str(client.get("mismatch", "")).is_empty():
return false
return true
static func validate_clients(clients: Array, p_tier: int) -> bool:
if clients.is_empty():
return false
for client in clients:
if not validate_client(client, p_tier):
return false
return true
static func phishing_quality(correct: int, total_clients: int) -> float:
if total_clients <= 0:
return 0.0
return clampf(float(correct) / float(total_clients), 0.0, 1.0)
# --- игра ------------------------------------------------------------------
func _current_client() -> Dictionary:
if _index < 0 or _index >= _clients.size():
return {}
return _clients[_index]
func _decide(choice: String) -> void:
if _finished or _stamp_ttl > 0.0:
return
var client := _current_client()
if client.is_empty():
return
var expected := "issue" if bool(client.get("valid", false)) else "deny"
var ok := choice == expected
if ok:
_correct += 1
else:
_wrong += 1
_last_ok = ok
_last_choice = choice
_stamp_ttl = STAMP_TTL
_hover = ""
mouse_default_cursor_shape = Control.CURSOR_ARROW
_play_sfx(&"ok" if ok else &"bad")
queue_redraw()
func _advance() -> void:
_index += 1
_last_ok = false
_last_choice = ""
if _index >= _clients.size():
var q := phishing_quality(_correct, _clients.size())
_result_detail = _with_context("ВЕРНО %d ИЗ %d · ОШИБОК %d" % [
_correct, _clients.size(), _wrong])
_finish(q)
else:
queue_redraw()
func _with_context(text: String) -> String:
var parts: Array = []
var reward := str(context.get("reward_text", ""))
var penalty := str(context.get("penalty_text", ""))
if not reward.is_empty():
parts.append(reward)
if not penalty.is_empty():
parts.append(penalty)
parts.append(text)
return " · ".join(parts)
func _process(delta: float) -> void:
if _finished:
return
if _stamp_ttl <= 0.0:
return
_stamp_ttl = maxf(0.0, _stamp_ttl - delta)
if _stamp_ttl <= 0.0:
_advance()
queue_redraw()
func _on_pointer_pressed(local: Vector2, _event: InputEvent) -> bool:
if _finished or _stamp_ttl > 0.0:
return false
if ISSUE_RECT.has_point(local):
_decide("issue")
return true
if DENY_RECT.has_point(local):
_decide("deny")
return true
return false
func _on_pointer_moved(local: Vector2, _event: InputEvent) -> void:
if _finished:
return
var next := ""
if _stamp_ttl <= 0.0:
if ISSUE_RECT.has_point(local):
next = "issue"
elif DENY_RECT.has_point(local):
next = "deny"
if next != _hover:
_hover = next
mouse_default_cursor_shape = Control.CURSOR_POINTING_HAND if not next.is_empty() \
else Control.CURSOR_ARROW
queue_redraw()
func _on_key_pressed(keycode: int, _event: InputEvent) -> bool:
if _finished or _stamp_ttl > 0.0:
return false
match keycode:
KEY_1, KEY_A:
_decide("issue")
return true
KEY_2, KEY_D:
_decide("deny")
return true
return false
# --- отрисовка -------------------------------------------------------------
func _draw_game() -> void:
var total := _clients.size()
var shown := mini(_index + 1, total)
_draw_label("КЛИЕНТ %d / %d" % [shown, total], Vector2(24.0, 44.0), 12,
Theme90.INK, 220.0)
_draw_label("ВЕРНО %d · ОШИБОК %d" % [_correct, _wrong], Vector2(380.0, 44.0),
12, Theme90.ERROR if _wrong > 0 else Theme90.SHADE, 230.0,
HORIZONTAL_ALIGNMENT_RIGHT)
var client := _current_client()
if client.is_empty():
_draw_label("ОЧЕРЕДЬ ОБРАБОТАНА", Vector2(24.0, 140.0), 24, Theme90.NAVY_DEEP, 604.0,
HORIZONTAL_ALIGNMENT_CENTER)
return
_draw_file(FILE_RECT, client)
_draw_pass(PASS_RECT, client)
_draw_action_button(ISSUE_RECT, "ВЫДАТЬ", true, true)
_draw_action_button(DENY_RECT, "ОТКАТАТЬ", true, false)
if _hover == "issue":
draw_rect(ISSUE_RECT.grow(2.0), Theme90.ACCENT, false, 2.0)
elif _hover == "deny":
draw_rect(DENY_RECT.grow(2.0), Theme90.ACCENT, false, 2.0)
if _stamp_ttl > 0.0:
_draw_stamp()
func _draw_file(rect: Rect2, client: Dictionary) -> void:
draw_style_box(Theme90.box(Theme90.PAPER_HI, Theme90.BORDER, 4, 1, Theme90.SHADOW), rect)
_draw_paper_header(rect, "ЗАЯВКА")
var data: Dictionary = client.get("file", {})
var x := rect.position.x + 14.0
var w := rect.size.x - 90.0
_draw_avatar(Rect2(rect.end.x - 68.0, rect.position.y + 30.0, 54.0, 66.0),
data.get("photo", {}))
Theme90.text(self, Theme90.ui(), Vector2(x, rect.position.y + 30.0), "ВЛАДЕЛЕЦ",
10, Theme90.NAVY_DEEP, w)
Theme90.text(self, Theme90.ui(), Vector2(x, rect.position.y + 42.0),
Theme90.fit(Theme90.ui(), str(data.get("name", "")), 16, w), 16, Theme90.INK, w)
Theme90.text(self, Theme90.ui(), Vector2(x, rect.position.y + 72.0), "ОТДЕЛ",
10, Theme90.NAVY_DEEP, w)
Theme90.text(self, Theme90.ui(), Vector2(x, rect.position.y + 84.0),
Theme90.fit(Theme90.ui(), str(data.get("dept", "")), 12, w), 12, Theme90.DARK, w)
Theme90.text(self, Theme90.ui(), Vector2(x, rect.position.y + 110.0), "ID",
10, Theme90.NAVY_DEEP, w)
Theme90.text(self, Theme90.ui(), Vector2(x, rect.position.y + 122.0),
str(data.get("id", "")), 12, Theme90.DARK, w)
Theme90.text(self, Theme90.ui(), Vector2(x, rect.position.y + 148.0), "ДЕЙСТВУЕТ ДО",
10, Theme90.NAVY_DEEP, w)
Theme90.text(self, Theme90.ui(), Vector2(x, rect.position.y + 160.0),
str(data.get("expiry", "")), 12, Theme90.DARK, w)
func _draw_pass(rect: Rect2, client: Dictionary) -> void:
draw_style_box(Theme90.box(Theme90.PAPER_HI, Theme90.BORDER, 4, 1, Theme90.SHADOW), rect)
_draw_paper_header(rect, "ПРОПУСК")
var data: Dictionary = client.get("pass", {})
_draw_avatar(Rect2(rect.position.x + 14.0, rect.position.y + 32.0, 58.0, 72.0),
data.get("photo", {}))
var x := rect.position.x + 84.0
var w := rect.size.x - 98.0
Theme90.text(self, Theme90.ui(), Vector2(x, rect.position.y + 36.0),
Theme90.fit(Theme90.ui(), str(data.get("name", "")), 16, w), 16, Theme90.INK, w)
Theme90.text(self, Theme90.ui(), Vector2(x, rect.position.y + 68.0),
Theme90.fit(Theme90.ui(), str(data.get("dept", "")), 12, w), 12, Theme90.DARK, w)
Theme90.text(self, Theme90.ui(), Vector2(x, rect.position.y + 94.0),
str(data.get("id", "")), 12, Theme90.DARK, w)
Theme90.text(self, Theme90.ui(), Vector2(x, rect.position.y + 120.0),
"СРОК " + str(data.get("expiry", "")), 12, Theme90.DARK, w)
var checks: Array = client.get("checks", [])
var check_text := "ПРОВЕРКА: "
var labels: Array = []
for check in checks:
labels.append(str(CHECK_LABELS.get(str(check), str(check))))
check_text += " · ".join(labels)
Theme90.text(self, Theme90.ui(), Vector2(x, rect.position.y + 150.0),
Theme90.fit(Theme90.ui(), check_text, 10, w), 10, Theme90.SHADE, w)
func _draw_paper_header(rect: Rect2, text: String) -> void:
var header := Rect2(rect.position, Vector2(rect.size.x, 20.0))
draw_style_box(Theme90.top_box(Theme90.NAVY, 4), header)
draw_rect(Rect2(header.position.x, header.end.y - 1.0, header.size.x, 1.0),
Theme90.NAVY_DEEP)
Theme90.text(self, Theme90.ui(), Vector2(header.position.x + 10.0,
header.position.y + 5.0), text, 10, Theme90.WHITE, header.size.x - 20.0)
func _draw_avatar(rect: Rect2, photo: Dictionary) -> void:
draw_style_box(Theme90.box(Theme90.ACCENT_PALE, Theme90.BORDER, 2, 1), rect)
var jackets := [Theme90.NAVY, Theme90.ACCENT, Color(0.43, 0.48, 0.37), Color(0.48, 0.41, 0.36)]
var hairs := [Color(0.23, 0.21, 0.18), Color(0.35, 0.29, 0.23), Color(0.42, 0.38, 0.35),
Color(0.18, 0.23, 0.27)]
var skins := [Color(0.85, 0.76, 0.65), Color(0.79, 0.68, 0.56), Color(0.88, 0.80, 0.71)]
var jacket: Color = jackets[clampi(int(photo.get("jacket", 0)), 0, jackets.size() - 1)]
var hair: Color = hairs[clampi(int(photo.get("hair", 0)), 0, hairs.size() - 1)]
var skin: Color = skins[clampi(int(photo.get("skin", 0)), 0, skins.size() - 1)]
var cx := rect.get_center().x
var bottom := rect.end.y - 3.0
draw_colored_polygon(PackedVector2Array([
Vector2(cx - 22.0, bottom), Vector2(cx - 14.0, bottom - 24.0),
Vector2(cx + 14.0, bottom - 24.0), Vector2(cx + 22.0, bottom)]), jacket)
draw_rect(Rect2(cx - 4.0, bottom - 38.0, 8.0, 14.0), skin)
draw_circle(Vector2(cx, bottom - 44.0), 11.0, skin)
draw_circle(Vector2(cx, bottom - 46.0), 11.0, hair)
draw_rect(Rect2(cx - 10.0, bottom - 46.0, 20.0, 8.0), skin)
if bool(photo.get("female", false)):
draw_rect(Rect2(cx - 12.0, bottom - 42.0, 4.0, 12.0), hair)
draw_rect(Rect2(cx + 8.0, bottom - 42.0, 4.0, 12.0), hair)
func _draw_stamp() -> void:
var label := "ВЕРНО" if _last_ok else "ПРОМАХ"
var color := Theme90.SUCCESS if _last_ok else Theme90.ERROR
var rect := Rect2(250.0, 148.0, 140.0, 36.0)
draw_style_box(Theme90.box(Theme90.PAPER_HI, color, 4, 2, Theme90.SHADOW), rect)
Theme90.text_vcenter(self, Theme90.ui(), rect, label, 16, color, -1,
HORIZONTAL_ALIGNMENT_CENTER)
func _draw_footer_hint() -> void:
if _finished:
return
var checks: Array = tier_params(tier).get("checks", ["name"])
var labels: Array = []
for check in checks:
labels.append(str(CHECK_LABELS.get(str(check), str(check))))
# Подсказка — в полосе между кнопками (y=292) и футером (y=320).
_draw_label(Theme90.fit(Theme90.ui(), "СВЕРЬТЕ: " + " · ".join(labels), 10, 604.0),
Vector2(18.0, 298.0), 10, Theme90.SHADE, 604.0)
+1
View File
@@ -0,0 +1 @@
uid://lj8d55h1pplr
+530
View File
@@ -0,0 +1,530 @@
class_name RansomwareGame
extends MiniGame
## Атака «Карантин» (ransomware). Сетка файлов, среди которых спрятаны
## исполняемые (.exe/.scr/.bat) с документными именами.
##
## Клик — «ИЗОЛИРОВАТЬ», повторный клик снимает пометку, ПРОВЕРИТЬ фиксирует.
## На T3/T4 расширение скрыто до наведения на карточку; на T4 есть ловушки,
## в имени которых есть «exe», но расширение doc/xls.
##
## Качество: q = clamp((found_exe - 0.5 * wrong_clicks) / total_exe, 0, 1).
const GAME_ID := "ransomware"
const TITLE_RU := "КАРАНТИН"
const VERIFY_RECT := Rect2(466.0, 320.0, 156.0, 28.0)
const GRID_TOP := 58.0
const GRID_BOTTOM := 294.0
const GRID_GAP := 8.0
const TIERS := {
1: {"cards": 12, "exe": 1, "threshold": 0.50, "masking": "extension_visible"},
2: {"cards": 16, "exe": 2, "threshold": 0.55, "masking": "extension_small"},
3: {"cards": 20, "exe": 2, "threshold": 0.60, "masking": "document_icon"},
4: {"cards": 24, "exe": 3, "threshold": 0.65, "masking": "similar_names"},
}
const EXEC_EXTS := ["EXE", "SCR", "BAT"]
const CLEAN_EXTS := ["DOC", "XLS", "TXT", "JPG", "BMP", "ZIP", "WAV", "AVI", "PDF"]
const BASE_NAMES := [
"отчёт", "смета", "письмо", "фото", "схема", "архив", "звук", "клип",
"опись", "прайс", "счёт", "курс", "факс", "лог", "план", "акт", "заявка",
"реестр", "накладная", "договор", "таблица", "список", "карта", "резерв",
"сводка", "ведомость", "протокол", "этикетка",
]
var _data: Dictionary = {}
var _cards: Array = []
var _marked: Dictionary = {}
var _submitted: bool = false
var _hover: int = -1
func _own_game_id() -> String:
return GAME_ID
func title() -> String:
return TITLE_RU
func quality() -> float:
if _finished:
return quality_value
var counts := count_marks(_cards, _marked)
return ransomware_quality(int(counts["found_exe"]), int(counts["wrong_clicks"]),
int(_data.get("total_exe", 0)))
func _setup() -> void:
_data = generate_cards(tier, seed_value)
_cards = _data.get("cards", [])
_marked.clear()
_submitted = false
_hover = -1
# --- параметры -------------------------------------------------------------
static func tier_params(p_tier: int) -> Dictionary:
var t := clampi(p_tier, 1, 4)
var base: Dictionary = (TIERS.get(t, TIERS[1]) as Dictionary).duplicate(true)
var row := MiniGame.data_minigame_tier(GAME_ID, t)
if row.is_empty():
return base
for key in ["cards", "exe"]:
if row.has(key):
base[key] = int(row[key])
if row.has("threshold"):
base["threshold"] = float(row["threshold"])
if row.has("masking"):
base["masking"] = str(row["masking"])
return base
static func masking_for(p_tier: int) -> String:
return str(tier_params(p_tier).get("masking", "extension_visible"))
# --- генерация -------------------------------------------------------------
static func generate_cards(p_tier: int, seed: int) -> Dictionary:
var t := clampi(p_tier, 1, 4)
var p := tier_params(t)
var total := clampi(int(p.get("cards", 12)), 8, 30)
var exe_count := clampi(int(p.get("exe", 1)), 1, total - 2)
var trap_count := 2 if t == 4 else 0
if total - exe_count - trap_count < 2:
trap_count = 0
var rng := RandomNumberGenerator.new()
rng.seed = seed
var names: Array = BASE_NAMES.duplicate()
_shuffle(names, rng)
var cursor := 0
var execs: Array = []
for i in range(exe_count):
var ext := str(EXEC_EXTS[rng.randi_range(0, EXEC_EXTS.size() - 1)])
execs.append({
"name": str(names[cursor % names.size()]),
"ext": ext,
"size_kb": rng.randi_range(18, 46),
"date": _random_date(rng),
"executable": true,
"target": false,
"trap": false,
})
cursor += 1
var traps: Array = []
for i in range(trap_count):
var base_name := str(names[cursor % names.size()])
cursor += 1
var trap_name := ("exe_" + base_name) if i % 2 == 0 else (base_name + "_exe")
traps.append({
"name": trap_name,
"ext": "DOC" if i % 2 == 0 else "XLS",
"size_kb": rng.randi_range(2, 480),
"date": _random_date(rng),
"executable": false,
"target": false,
"trap": true,
})
var clean_count := total - execs.size() - traps.size()
var cleans: Array = []
for i in range(clean_count):
var ext := str(CLEAN_EXTS[rng.randi_range(0, CLEAN_EXTS.size() - 1)])
cleans.append({
"name": str(names[cursor % names.size()]),
"ext": ext,
"size_kb": rng.randi_range(2, 480),
"date": _random_date(rng),
"executable": false,
"target": false,
"trap": false,
})
cursor += 1
# Ровно одна «настоящая» цель; остальные исполняемые — тоже угрозы,
# но флаг target не дублируется. Ловушки целью не бывают никогда.
var target_index := rng.randi_range(0, execs.size() - 1)
(execs[target_index] as Dictionary)["target"] = true
# Хотя бы один чистый файл совпадает по размеру с исполняемым, чтобы
# размер сам по себе не выдавал цель.
if not cleans.is_empty():
(cleans[0] as Dictionary)["size_kb"] = int((execs[0] as Dictionary)["size_kb"])
var descriptors: Array = []
descriptors.append_array(execs)
descriptors.append_array(traps)
descriptors.append_array(cleans)
_shuffle(descriptors, rng)
var cards: Array = []
for i in range(descriptors.size()):
var d: Dictionary = descriptors[i]
var size_kb := int(d.get("size_kb", 2))
cards.append({
"id": i,
"name": str(d.get("name", "")),
"ext": str(d.get("ext", "")),
"size_kb": size_kb,
"size_text": "%d КБ" % size_kb,
"date": str(d.get("date", "01.01.97")),
"executable": bool(d.get("executable", false)),
"target": bool(d.get("target", false)),
"trap": bool(d.get("trap", false)),
})
return {
"tier": t,
"cards": cards,
"total_cards": total,
"total_exe": exe_count,
"trap_count": trap_count,
"threshold": float(p.get("threshold", 0.5)),
"masking": str(p.get("masking", "extension_visible")),
}
static func _random_date(rng: RandomNumberGenerator) -> String:
var day := rng.randi_range(1, 28)
var month := rng.randi_range(1, 12)
var year := 96 + rng.randi_range(0, 1)
return "%02d.%02d.%02d" % [day, month, year]
# --- проверка --------------------------------------------------------------
static func validate_cards(data: Dictionary) -> bool:
var t := clampi(int(data.get("tier", 1)), 1, 4)
var p := tier_params(t)
var cards: Array = data.get("cards", [])
var total := int(data.get("total_cards", -1))
if cards.size() != total or total != int(p.get("cards", -1)):
return false
if int(data.get("total_exe", -1)) != int(p.get("exe", -1)):
return false
var exec_count := 0
var target_count := 0
var trap_count := 0
var names: Dictionary = {}
for card in cards:
var c: Dictionary = card
var name_key := str(c.get("name", ""))
if names.has(name_key):
return false
names[name_key] = true
var ext := str(c.get("ext", "")).to_upper()
if bool(c.get("trap", false)):
trap_count += 1
if bool(c.get("executable", false)) or bool(c.get("target", false)):
return false
if t != 4:
return false
if not name_key.to_lower().contains("exe"):
return false
if ext != "DOC" and ext != "XLS":
return false
if bool(c.get("executable", false)):
exec_count += 1
if not EXEC_EXTS.has(ext):
return false
if bool(c.get("trap", false)):
return false
else:
if EXEC_EXTS.has(ext):
return false
if not bool(c.get("trap", false)) and name_key.to_lower().contains("exe"):
return false
if bool(c.get("target", false)):
target_count += 1
if not bool(c.get("executable", false)):
return false
if exec_count < 1 or exec_count != int(p.get("exe", 0)):
return false
if target_count > 1:
return false
if t <= 2 and target_count != 1:
return false
if trap_count != int(data.get("trap_count", 0)):
return false
if t != 4 and trap_count != 0:
return false
return true
static func ransomware_quality(found_exe: int, wrong_clicks: int, total_exe: int) -> float:
if total_exe <= 0:
return 0.0
return clampf((float(found_exe) - 0.5 * float(wrong_clicks)) / float(total_exe),
0.0, 1.0)
static func count_marks(cards: Array, marked: Dictionary) -> Dictionary:
var found := 0
var wrong := 0
var missed := 0
for card in cards:
var c: Dictionary = card
var is_marked := bool(marked.get(int(c.get("id", -1)), false))
if bool(c.get("executable", false)):
if is_marked:
found += 1
else:
missed += 1
elif is_marked:
wrong += 1
return {"found_exe": found, "wrong_clicks": wrong, "missed_exe": missed,
"marked": int(marked.size())}
# --- игра ------------------------------------------------------------------
func _card_rects() -> Array:
var n := _cards.size()
if n <= 0:
return []
var cols := 4
if n > 20:
cols = 6
elif n > 16:
cols = 5
var rows := int(ceil(float(n) / float(cols)))
var card_w := (604.0 - GRID_GAP * float(cols - 1)) / float(cols)
var avail_h := GRID_BOTTOM - GRID_TOP
var card_h := minf(64.0, (avail_h - GRID_GAP * float(rows - 1)) / float(rows))
var rects: Array = []
for i in range(n):
var col := i % cols
var row := i / cols
rects.append(Rect2(18.0 + float(col) * (card_w + GRID_GAP),
GRID_TOP + float(row) * (card_h + GRID_GAP), card_w, card_h))
return rects
func _card_index_at(local: Vector2) -> int:
var rects := _card_rects()
for i in range(rects.size()):
if (rects[i] as Rect2).has_point(local):
return i
return -1
func _ext_visible(card: Dictionary, index: int) -> bool:
var masking := str(_data.get("masking", "extension_visible"))
if masking == "extension_visible" or masking == "extension_small":
return true
return index == _hover
func _toggle(index: int) -> void:
if index < 0 or index >= _cards.size():
return
var id := int((_cards[index] as Dictionary).get("id", index))
if _marked.has(id):
_marked.erase(id)
else:
_marked[id] = true
_play_sfx(&"tick")
queue_redraw()
func _submit() -> void:
if _submitted or _finished:
return
var counts := count_marks(_cards, _marked)
var q := ransomware_quality(int(counts["found_exe"]), int(counts["wrong_clicks"]),
int(_data.get("total_exe", 0)))
_submitted = true
_result_detail = _with_context("НАЙДЕНО %d ИЗ %d · ЛОЖНЫХ %d · ПРОПУЩЕНО %d" % [
int(counts["found_exe"]), int(_data.get("total_exe", 0)),
int(counts["wrong_clicks"]), int(counts["missed_exe"])])
_finish(q)
func _with_context(text: String) -> String:
var parts: Array = []
var reward := str(context.get("reward_text", ""))
var penalty := str(context.get("penalty_text", ""))
if not reward.is_empty():
parts.append(reward)
if not penalty.is_empty():
parts.append(penalty)
parts.append(text)
return " · ".join(parts)
func _on_pointer_pressed(local: Vector2, _event: InputEvent) -> bool:
if _submitted or _finished:
return false
if VERIFY_RECT.has_point(local):
_submit()
return true
var index := _card_index_at(local)
if index >= 0:
_toggle(index)
return true
return false
func _on_pointer_moved(local: Vector2, _event: InputEvent) -> void:
if _submitted or _finished:
return
var next := _card_index_at(local)
if next != _hover:
_hover = next
queue_redraw()
func _on_key_pressed(keycode: int, _event: InputEvent) -> bool:
if _submitted or _finished:
return false
if keycode in [KEY_ENTER, KEY_KP_ENTER, KEY_SPACE]:
_submit()
return true
return false
# --- отрисовка -------------------------------------------------------------
func _draw_game() -> void:
_draw_label("ОТМЕЧЕНО: %d" % _marked.size(), Vector2(24.0, 44.0), 12,
Theme90.INK, 220.0)
var masking := str(_data.get("masking", "extension_visible"))
var mode_text := "РАСШИРЕНИЕ ВИДНО"
if masking == "document_icon":
mode_text = "НАВЕДИТЕ НА ФАЙЛ: РАСШИРЕНИЕ В ТУЛТИПЕ"
elif masking == "similar_names":
mode_text = "ОСТОРОЖНО: ИМЕНА С «EXE» МОГУТ БЫТЬ ЛОВУШКОЙ"
elif masking == "extension_small":
mode_text = "РАСШИРЕНИЕ МЕЛКИМ ШРИФТОМ"
_draw_label(mode_text, Vector2(230.0, 44.0), 10, Theme90.SHADE, 382.0,
HORIZONTAL_ALIGNMENT_RIGHT)
var rects := _card_rects()
for i in range(_cards.size()):
_draw_card(i, rects[i] if i < rects.size() else Rect2())
_draw_action_button(VERIFY_RECT, "ПРОВЕРИТЬ", not _submitted, true)
func _draw_card(index: int, rect: Rect2) -> void:
if rect.size.x <= 0.0:
return
var card: Dictionary = _cards[index]
var marked := _marked.has(int(card.get("id", -1)))
var executable := bool(card.get("executable", false))
var trap := bool(card.get("trap", false))
var fill := Theme90.PAPER_HI
var border := Theme90.BORDER
var border_w := 1
var status := ""
if _submitted:
if executable and marked:
fill = Theme90.SUCCESS.lightened(0.78)
border = Theme90.SUCCESS
border_w = 2
status = "ИЗОЛИРОВАН"
elif executable and not marked:
fill = Theme90.WARN.lightened(0.78)
border = Theme90.WARN
border_w = 2
status = "ПРОПУЩЕН"
elif not executable and marked:
fill = Theme90.ERROR.lightened(0.80)
border = Theme90.ERROR
border_w = 2
status = "ЛОЖНО"
elif trap:
border = Theme90.BORDER_SOFT
elif marked:
fill = Theme90.ERROR.lightened(0.90)
border = Theme90.ERROR
border_w = 2
status = "ИЗОЛИРОВАТЬ"
elif index == _hover:
border = Theme90.ACCENT
border_w = 2
draw_style_box(Theme90.box(fill, border, 3, border_w), rect)
var ext := str(card.get("ext", ""))
var show_ext := _ext_visible(card, index)
var icon_ext := ext if show_ext else ""
var icon_kind := "text"
if show_ext:
icon_kind = _kind_for_ext(ext)
var icon_col := Theme90.ext_color(Theme90.ext_category(ext))
if not show_ext:
icon_col = Theme90.NAVY
var icon_size := 24.0 if rect.size.x >= 120.0 else 20.0
Theme90.draw_doc_icon(self, Rect2(rect.position + Vector2(6.0, 7.0),
Vector2(icon_size, icon_size)), icon_kind, icon_col, icon_ext)
var name_size := 12 if rect.size.x >= 130.0 else 10
var text_x := rect.position.x + icon_size + 12.0
var text_w := rect.size.x - icon_size - 18.0
Theme90.text(self, Theme90.ui(), Vector2(text_x, rect.position.y + 6.0),
Theme90.fit(Theme90.ui(), str(card.get("name", "")), name_size, text_w),
name_size, Theme90.INK, text_w)
var meta := str(card.get("size_text", ""))
if show_ext:
meta = "%s · %s" % [ext, meta]
Theme90.text(self, Theme90.ui(), Vector2(text_x, rect.position.y + 23.0),
Theme90.fit(Theme90.ui(), meta, 10, text_w), 10, Theme90.DARK, text_w)
var bottom_text := str(card.get("date", ""))
var bottom_col := Theme90.SHADE
if not status.is_empty():
bottom_text = status
bottom_col = Theme90.ERROR
if _submitted and status == "ИЗОЛИРОВАН":
bottom_col = Theme90.SUCCESS
elif status == "ПРОПУЩЕН":
bottom_col = Theme90.WARN
Theme90.text(self, Theme90.ui(), Vector2(rect.position.x + 6.0,
rect.position.y + rect.size.y - 14.0),
Theme90.fit(Theme90.ui(), bottom_text, 10, rect.size.x - 12.0), 10,
bottom_col, rect.size.x - 12.0)
func _kind_for_ext(ext: String) -> String:
match Theme90.ext_category(ext):
"audio":
return "sound"
"image":
return "photo"
"video":
return "video"
return "text"
func _draw_footer_hint() -> void:
var masking := str(_data.get("masking", "extension_visible"))
var text := "ИЩИТЕ ИСПОЛНЯЕМЫЕ ФАЙЛЫ · КЛИК — ИЗОЛИРОВАТЬ"
if masking == "document_icon":
text = "ИКОНКА ДОКУМЕНТА НИЧЕГО НЕ ЗНАЧИТ · НАВЕДИТЕ НА ФАЙЛ"
elif masking == "similar_names":
text = "«EXE» В ИМЕНИ — ЕЩЁ НЕ ПРИГОВОР · ПРОВЕРЬТЕ РАСШИРЕНИЕ"
# Подсказка — в полосе между сеткой (y=294) и футером (y=320).
_draw_label(text, Vector2(18.0, 298.0), 10, Theme90.SHADE, 604.0)
# --- служебное -------------------------------------------------------------
static func _shuffle(list: Array, rng: RandomNumberGenerator) -> void:
for i in range(list.size() - 1, 0, -1):
var j := rng.randi_range(0, i)
var tmp = list[i]
list[i] = list[j]
list[j] = tmp
@@ -0,0 +1 @@
uid://bvm5eshivri3g
+65 -7
View File
@@ -20,6 +20,9 @@ const STICK_H := 18.0
const STICK_SPEEDS: Array[float] = [440.0, 340.0, 280.0]
const FLASH_SEC := 0.8
const CLICKS_REQUIRED := 3
## Окно гасит клик, который открыл панель: кнопка «Соединиться» находится
## вне модалки, но тот же/следующий event мог долететь уже до неё.
const INPUT_GRACE_SEC := 0.35
var hour: int = 1
var successes: int = 0
@@ -36,6 +39,8 @@ var missed: bool = false
var game_over: bool = false
var _emitted: bool = false
var _input_grace: float = 0.0
var _armed: bool = false
func _init() -> void:
@@ -49,7 +54,16 @@ func _init() -> void:
func _ready() -> void:
# start() мог быть вызван до add_child: окно монтируется уже после клика,
# поэтому отсчёт grace начинаем заново в момент появления в дереве.
if running:
_input_grace = INPUT_GRACE_SEC
_armed = false
_center_on_screen()
# Main ставит модалку через PRESET_CENTER уже после add_child, а в Godot
# 4.7.2 этот пресет обнуляет offsets (окно уезжает в правый нижний угол).
# Повторяем центрирование в конце кадра, когда родитель уже modal_root.
call_deferred("_center_on_screen")
get_viewport().size_changed.connect(_center_on_screen)
@@ -64,6 +78,8 @@ func start(p_hour: int, p_successes: int, p_zone_level: int, p_request_id: Strin
running = true
missed = false
flash_left = 0.0
_input_grace = INPUT_GRACE_SEC
_armed = false
_reset_attempt()
visible = true
set_process(true)
@@ -118,6 +134,12 @@ func _process(delta: float) -> void:
return
# Вспышка идёт по реальному delta, движение палочек — с D11-капом 0.1.
var raw_dt := maxf(delta, 0.0)
# Grace тикает по реальному времени до игровой логики: клик, открывший
# панель, не должен успеть стать попыткой соединения.
if _input_grace > 0.0:
_input_grace = maxf(0.0, _input_grace - raw_dt)
if _input_grace <= 0.0:
_armed = true
if flash_left > 0.0:
flash_left = maxf(0.0, flash_left - raw_dt)
if flash_left <= 0.0:
@@ -140,19 +162,47 @@ func _process(delta: float) -> void:
func _input(event: InputEvent) -> void:
if not running or game_over:
return
# Esc доступен всегда (в том числе в grace): отмена не должна штрафовать.
if event is InputEventKey and event.pressed and not event.echo:
if event.keycode == KEY_ESCAPE:
_mark_input_handled()
_finish(false)
return
if event.keycode == KEY_SPACE:
if event.keycode == KEY_SPACE or event.keycode == KEY_ENTER \
or event.keycode == KEY_KP_ENTER:
if _input_grace > 0.0:
# Тот же клик/нажатие, что открыло модалку, не считаем попыткой.
_mark_input_handled()
return
if not _armed:
return
_mark_input_handled()
_attempt_click()
return
if event is InputEventMouseButton:
if event.button_index == MOUSE_BUTTON_LEFT and event.pressed:
if event.button_index != MOUSE_BUTTON_LEFT:
return
if _input_grace > 0.0:
# И press, и release в grace гасим: событие могло прийти от кнопки
# «СОЕДИНИТЬСЯ», которая находится вне окна модалки.
_mark_input_handled()
return
if event.pressed:
# Только свежее нажатие: отпускание кнопки ниже взводит _armed.
if not _armed:
return
# Клики вне окна 640×240 (карточка запроса, меню, статус-бар за
# модалкой) не должны считаться попыткой соединения: иначе промах
# закрывает панель, штрафует SLA и вешает кулдаун.
var local: Vector2 = event.position - global_position
if not Rect2(Vector2.ZERO, size).has_point(local):
return
_mark_input_handled()
_attempt_click()
else:
# Первое отпускание после grace тоже разрешает попытки (защита от
# зажатой кнопки/double-click).
_armed = true
func _attempt_click() -> void:
@@ -266,11 +316,19 @@ func _draw() -> void:
func _center_on_screen() -> void:
var viewport := get_viewport()
if viewport == null:
return
var screen := get_viewport_rect().size
position = ((screen - WINDOW_SIZE) * 0.5).floor()
# get_viewport_rect() — физический размер окна (1920×1080), а UI-канва
# логическая 1280×720. Центрируем по реальному родителю (modal_root),
# иначе модалка уезжает в правый нижний квадрант. Fallback — вьюпорт.
var area := Vector2.ZERO
if is_inside_tree() and get_parent() is Control:
area = (get_parent() as Control).size
if area.x <= 0.0 or area.y <= 0.0:
# Вне дерева (нода создана до add_child) вьюпорта ещё нет —
# центрируем позже в _ready()/повторном start().
if not is_inside_tree():
return
area = get_viewport_rect().size
position = ((area - size) * 0.5).floor()
position.x = maxf(0.0, position.x)
position.y = maxf(0.0, position.y)
+537 -137
View File
@@ -1,35 +1,83 @@
class_name MiniGame
extends Control
## Заглушка мини-игры запроса (D17).
## Базовая мини-игра v2: без таймера, локальный сид, quality 0..1.
##
## Окно 640×360, заголовок по типу файла, полоса заполняется за 2 с,
## кнопки «Отправить» / «Переделать». quality всегда 1.0; «Переделать»
## лишь перезапускает полосу и не меняет качество. Esc — no-op (D10).
## Рисуется кодом, без UI-компонентов.
## Окно 640×360. Общая шапка, кнопка «ПРОПУСТИТЬ» с двухкликовым
## подтверждением и экран результата. Конкретные игры наследуются от этого
## класса и переопределяют _setup(), _draw_game(), обработчики ввода.
##
## Важно: здесь нет узлов-таймеров и нет поражения по времени. Игра
## завершается только действием игрока (конечная цель) или пропуском.
signal finished(quality: float)
const WINDOW_SIZE := Vector2(640.0, 360.0)
const DURATION := 2.0
const QUALITY_STUB := 1.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)
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_RECT := Rect2(150.0, 282.0, 150.0, 34.0)
const REDO_RECT := Rect2(340.0, 282.0, 150.0, 34.0)
const BAR_RECT := Rect2(100.0, 210.0, 440.0, 22.0)
const TYPE_NAMES := {
"text": "ТЕКСТ",
"sound": "ЗВУК",
"photo": "ФОТО",
"video": "ВИДЕО",
const SEND_IDS: Array[String] = [
"dots", "sound", "text", "video", "photo", "maze",
]
const ATTACK_IDS: Array[String] = [
"junk", "ddos", "phishing", "ransomware",
]
const GAME_TITLES := {
"dots": "ОДНА ЛИНИЯ",
"sound": "УРОВНИ",
"text": "ЧУЖОЕ СЛОВО",
"video": "РАСКАДРОВКА",
"photo": "КАДР",
"maze": "ЛАБИРИНТ",
"junk": "ФИЛЬТР",
"ddos": "ЩИТ",
"phishing": "ПАСПОРТ",
"ransomware": "КАРАНТИН",
}
const LEGACY_TYPES := {
"text": "text",
"sound": "sound",
"photo": "photo",
"video": "video",
# Старые id «проводов» после удаления игры ведут на ближайшую по смыслу.
"connection": "dots",
"connect": "dots",
}
## Короткие цели для игр атак (файлы атак не переопределяют _goal_text).
## Игры отправки возвращают свою строку сами.
const ATTACK_GOALS := {
"junk": "СНИМИТЕ МУСОРНЫЕ ФАЙЛЫ · НОРМАЛЬНЫЕ НЕ ТРОГАЙТЕ",
"ddos": "ДВИГАЙТЕ ЩИТ И ОТРАЖАЙТЕ ПАКЕТЫ",
"phishing": "СВЕРЬТЕ ДАННЫЕ КЛИЕНТА С ЭТАЛОНОМ",
"ransomware": "ИЗОЛИРУЙТЕ ФАЙЛЫ С ОПАСНЫМ РАСШИРЕНИЕМ",
}
var game_type: String = "text"
var difficulty: float = 1.0
var progress: float = 0.0
## Общие поля контракта.
var game_id: String = ""
var tier: int = 1
var rng := RandomNumberGenerator.new()
var context: Dictionary = {}
var quality_value: float = 0.0
var running: bool = false
var difficulty: float = 1.0
var seed_value: int = 0
## true для мини-игр атак: кнопка отказа — СДАТЬСЯ, Esc взводит/подтверждает
## сдачу (§5.1/§5.5). Мини-игры отправки оставляют ПРОПУСТИТЬ и Esc — no-op.
var is_attack: bool = false
var _emitted: bool = false
var _finished: bool = false
var _skip_armed: bool = false
var _result_detail: String = ""
## D2 (§5.5): если true, _finish() только показывает экран результата и НЕ
## эмитит finished. Сигнал уходит по клику «ПРОДОЛЖИТЬ» (Main включает режим
## для модальных мини-игр). unit-тесты без Main работают с default=false.
var defer_finish_signal: bool = false
var _pending_finish: bool = false
func _init() -> void:
@@ -37,154 +85,339 @@ func _init() -> void:
size = WINDOW_SIZE
custom_minimum_size = WINDOW_SIZE
mouse_filter = Control.MOUSE_FILTER_STOP
focus_mode = Control.FOCUS_CLICK
visible = false
set_process(false)
set_process(true)
set_process_input(true)
func _ready() -> void:
_center_on_screen()
get_viewport().size_changed.connect(_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)
func start(type: String, p_difficulty: float = 1.0) -> void:
game_type = str(type)
if not TYPE_NAMES.has(game_type):
game_type = "text"
## Совместимая сигнатура со старым main/hour_director:
## start(type_or_id, difficulty, seed, context).
func start(type_or_id: String, p_difficulty: float = 1.0, p_seed: int = 0,
p_context: Dictionary = {}) -> void:
var requested := str(type_or_id).to_lower()
var resolved := resolve_game_id(requested)
if resolved.is_empty():
resolved = _own_game_id()
if resolved.is_empty():
resolved = requested if not requested.is_empty() else "dots"
game_id = resolved
difficulty = maxf(0.0, float(p_difficulty))
_emitted = false
# Старый вызов передавал 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, max_tier)
context = p_context.duplicate(true)
seed_value = p_seed
if seed_value == 0:
seed_value = int(Time.get_ticks_usec()) ^ int(hash("%s:%d:%d" % [game_id, tier, Time.get_ticks_msec()]))
rng = RandomNumberGenerator.new()
rng.seed = seed_value
_emitent_reset()
_setup()
running = true
progress = 0.0
visible = true
_skip_armed = false
_result_detail = ""
set_process(true)
set_process_input(true)
_center_on_screen()
queue_redraw()
## Сброс состояния при повторном start() на том же узле.
func _emitent_reset() -> void:
_finished = false
quality_value = 0.0
_skip_armed = false
_result_detail = ""
_pending_finish = false
## Переопределяется наследником: генерация поля/состояния.
func _setup() -> void:
pass
## База не анимируется; наследники (sound/photo/maze) переопределяют.
func _process(_delta: float) -> void:
pass
## Идентификатор игры самого класса (нужен для прямого new().start()).
func _own_game_id() -> String:
return ""
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 "СЛОЖНОСТЬ %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_STUB
return quality_value
## Правило выбора типа по среднему коэффициенту (GDD §8.4 / D17).
static func select_type(types: Array) -> String:
var total := 0.0
if types.is_empty():
return "text"
for type in types:
total += _coeff(str(type))
var difficulty := total / float(types.size())
if difficulty < 1.5:
return "text"
if difficulty < 2.5:
return "sound"
if difficulty < 3.5:
return "photo"
return "video"
func is_finished() -> bool:
return _finished
static func type_label(type: String) -> String:
return str(TYPE_NAMES.get(str(type), "ТЕКСТ"))
func verdict_for(q: float) -> String:
if q >= 0.75:
return "ЧИСТО"
if q >= 0.45:
return "НОРМАЛЬНО"
return "СЛАБО"
static func _coeff(type: String) -> float:
match type:
"sound":
return 2.0
"photo":
return 3.0
"video":
return 4.0
return 1.0
func result_text() -> String:
return "КАЧЕСТВО %d%% · %s" % [roundi(quality_value * 100.0),
verdict_for(quality_value)]
func _process(delta: float) -> void:
if not running:
## Завершение игры. Эмитит ровно один раз, клампит q в 0..1.
func _finish(q: float) -> void:
if _finished:
return
if progress < DURATION:
progress = minf(DURATION, progress + minf(maxf(delta, 0.0), 0.1))
queue_redraw()
func _input(event: InputEvent) -> void:
if not running:
return
if event is InputEventKey and event.pressed and not event.echo:
if event.keycode == KEY_ESCAPE:
# D10: Esc в мини-игре — no-op, но событие не отдаём паузе часа.
_mark_input_handled()
return
if event.keycode == KEY_ENTER or event.keycode == KEY_KP_ENTER:
_mark_input_handled()
_send()
return
if event is InputEventMouseButton and event.button_index == MOUSE_BUTTON_LEFT and event.pressed:
var local: Vector2 = event.position - global_position
if SEND_RECT.has_point(local) and progress >= DURATION:
_mark_input_handled()
_send()
elif REDO_RECT.has_point(local):
_mark_input_handled()
_restart_bar()
func _send() -> void:
if _emitted:
return
_emitted = true
quality_value = clampf(float(q), 0.0, 1.0)
_finished = true
running = false
set_process(false)
set_process_input(false)
_play_sfx(&"minigame_win")
finished.emit(QUALITY_STUB)
queue_free()
func _restart_bar() -> void:
# D17: «Переделать» перезапускает заглушку, quality не меняется.
progress = 0.0
_skip_armed = false
if _result_detail.is_empty():
_result_detail = _default_result_detail()
_play_sfx(&"minigame_win" if quality_value >= 0.45 else &"minigame_fail")
if defer_finish_signal:
# Экран результата уже нарисован; сигнал ждёт «ПРОДОЛЖИТЬ».
_pending_finish = true
else:
finished.emit(quality_value)
queue_redraw()
## Эмитит отложенный finished ровно один раз (если _finish уже случился).
func _emit_finished_once() -> void:
if not _pending_finish:
return
_pending_finish = false
finished.emit(quality_value)
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", ""))
if not reward.is_empty():
parts.append(reward)
var penalty := str(context.get("penalty_text", ""))
if not penalty.is_empty():
parts.append(penalty)
return " · ".join(parts)
# --- ввод ------------------------------------------------------------------
func _input(event: InputEvent) -> void:
if event is InputEventKey:
if not event.pressed or event.echo:
return
if _finished:
if event.keycode in [KEY_ENTER, KEY_KP_ENTER, KEY_SPACE, KEY_ESCAPE]:
_mark_input_handled()
_continue_clicked()
return
if event.keycode == KEY_ESCAPE:
# В мини-играх отправки Esc — no-op (D10), но пауза часа его не ловит.
# В мини-играх атак Esc — двухшаговое подтверждение сдачи.
_mark_input_handled()
if is_attack:
_on_skip_clicked()
return
if _on_key_pressed(event.keycode, event):
_mark_input_handled()
return
if event is InputEventMouseButton and event.button_index == MOUSE_BUTTON_LEFT:
var local: Vector2 = event.position - global_position
if _finished:
if event.pressed and _result_continue_rect().has_point(local):
_mark_input_handled()
_continue_clicked()
return
if event.pressed:
if SKIP_RECT.has_point(local):
_mark_input_handled()
_on_skip_clicked()
return
if _on_pointer_pressed(local, event):
_mark_input_handled()
else:
_on_pointer_released(local, event)
return
if event is InputEventMouseMotion:
if _finished:
return
var local: Vector2 = event.position - global_position
_on_pointer_moved(local, event)
func _on_pointer_pressed(_local: Vector2, _event: InputEvent) -> bool:
return false
func _on_pointer_released(_local: Vector2, _event: InputEvent) -> void:
pass
func _on_pointer_moved(_local: Vector2, _event: InputEvent) -> void:
pass
func _on_key_pressed(_keycode: int, _event: InputEvent) -> bool:
return false
func _on_skip_clicked() -> void:
if not _skip_armed:
_skip_armed = true
_play_sfx(&"tick")
queue_redraw()
return
_finish(0.0)
func _continue_clicked() -> void:
# Модальный режим: сначала отдаём результат директору, затем освобождаем ноду.
_emit_finished_once()
queue_free()
func _mark_input_handled() -> void:
var viewport := get_viewport()
if viewport != null:
viewport.set_input_as_handled()
# --- отрисовка -------------------------------------------------------------
func _draw() -> void:
var font := Theme90.ui()
var full := Rect2(Vector2.ZERO, WINDOW_SIZE)
var full := Rect2(Vector2.ZERO, size)
Theme90.draw_bevel(self, full, false, Theme90.BEVEL, Theme90.PAPER)
# Светлый титлбар с teal-акцентом.
var tr := Rect2(1.0, 1.0, WINDOW_SIZE.x - 2.0, 28.0)
var tr := Rect2(1.0, 1.0, size.x - 2.0, HEADER_H - 2.0)
draw_style_box(Theme90.top_box(Theme90.PAPER_HI, 4), tr)
draw_rect(Rect2(tr.position.x + 1.0, tr.end.y, tr.size.x - 2.0, 1.0),
Theme90.HAIRLINE)
draw_circle(Vector2(tr.position.x + 14.0, tr.position.y + 14.0), 3.0, Theme90.ACCENT)
var title := "МИНИ-ИГРА: %s" % type_label(game_type)
Theme90.text(self, font, Vector2(tr.position.x + 25.0, tr.position.y + 7.0),
title, 14, Theme90.NAVY, tr.size.x - 40.0)
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 + 3.0),
title(), 18, Theme90.NAVY_DEEP, tr.size.x - 400.0)
Theme90.text(self, font, Vector2(40.0, 64.0), "ЗАГЛУШКА",
20, Theme90.INK)
Theme90.text(self, font, Vector2(40.0, 94.0),
"Сложность: %.2f · quality = %.1f" % [difficulty, QUALITY_STUB],
12, Theme90.SHADE)
Theme90.text(self, font, Vector2(40.0, 118.0),
"Подготовьте файл к отправке. Esc не закрывает окно.",
12, Theme90.DARK)
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(), 11, Theme90.NAVY,
-1, HORIZONTAL_ALIGNMENT_CENTER)
# Полоса 2 с: тонкая дорожка + мягкая заливка.
Theme90.text(self, font, Vector2(BAR_RECT.position.x, BAR_RECT.position.y - 20.0),
"ОБРАБОТКА %.1f / %.1f с" % [progress, DURATION], 11, Theme90.SHADE)
Theme90.draw_sunken(self, BAR_RECT)
var ratio := clampf(progress / DURATION, 0.0, 1.0)
Theme90.draw_meter(self, BAR_RECT.grow(-1.0), ratio, Theme90.SUCCESS,
Theme90.PAPER_LO)
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)
# Компактные кнопки в стиле v2.
_draw_button(SEND_RECT, "Отправить", ratio >= 1.0, true)
_draw_button(REDO_RECT, "Переделать", true, false)
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()
_draw_footer_hint()
if _finished:
_draw_result_overlay()
func _draw_button(rect: Rect2, label: String, enabled: bool, primary: bool = false) -> void:
var font := Theme90.ui()
## Переопределяется наследником. Рисовать в CONTENT_RECT.
func _draw_game() -> void:
pass
## Необязательная подпись над футером/в футере.
func _draw_footer_hint() -> void:
pass
func _draw_skip_button() -> void:
var face := Theme90.WARN if _skip_armed else Theme90.PAPER_HI
var fg := Theme90.WHITE if _skip_armed else Theme90.DARK
var border := Theme90.WARN.darkened(0.18) if _skip_armed else Theme90.BORDER
draw_style_box(Theme90.box(face, border, 3, 1,
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 "ЕЩЁ КЛИК — ПРОПУСК"
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, size_px: int = 12) -> void:
var face := Theme90.PAPER_HI
var fg := Theme90.INK
var border := Theme90.BORDER
@@ -194,30 +427,188 @@ func _draw_button(rect: Rect2, label: String, enabled: bool, primary: bool = fal
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, font, rect, label, 12, fg, -1,
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, 11, Theme90.DARK,
-1, HORIZONTAL_ALIGNMENT_CENTER)
func _draw_modal_panel(rect: Rect2, fill: Color = Theme90.PAPER_HI) -> void:
draw_style_box(Theme90.box(fill, Theme90.BORDER, 4, 1, Theme90.SHADOW), rect)
func _draw_label(text: String, pos: Vector2, size_px: int = 12,
color: Color = Theme90.INK, width: float = -1.0,
align: int = HORIZONTAL_ALIGNMENT_LEFT) -> void:
Theme90.text(self, Theme90.ui(), pos, text, size_px, color, width, align)
func _draw_centered(text: String, rect: Rect2, size_px: int = 12,
color: Color = Theme90.INK) -> void:
Theme90.text_vcenter(self, Theme90.ui(), rect, text, size_px, color,
-1, HORIZONTAL_ALIGNMENT_CENTER)
func _draw_bar(rect: Rect2, ratio: float, color: Color) -> void:
Theme90.draw_sunken(self, rect)
Theme90.draw_meter(self, rect.grow(-1.0), clampf(ratio, 0.0, 1.0), color,
Theme90.PAPER_LO)
func _result_continue_rect() -> Rect2:
var panel := _result_panel_rect()
return Rect2(panel.position.x + (panel.size.x - CONTINUE_RECT.size.x) * 0.5,
panel.position.y + panel.size.y - CONTINUE_RECT.size.y - 16.0,
CONTINUE_RECT.size.x, CONTINUE_RECT.size.y)
func _result_panel_rect() -> Rect2:
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 + 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 + 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)
_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 + 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, 13)
# --- служебное -------------------------------------------------------------
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:
var loop := Engine.get_main_loop()
if not (loop is SceneTree):
return {}
var root := (loop as SceneTree).root
var data = root.get_node_or_null("Data")
if data == null:
return {}
if data.has_method("minigame_def"):
var value = data.call("minigame_def", id)
if value is Dictionary:
return (value as Dictionary).duplicate(true)
var balance = data.get("balance")
if balance is Dictionary:
var minigames = (balance as Dictionary).get("minigames", null)
if minigames is Dictionary:
var games = (minigames as Dictionary).get("games", null)
if games is Dictionary and (games as Dictionary).get(id) is Dictionary:
return ((games as Dictionary).get(id) as Dictionary).duplicate(true)
return {}
## Строка 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 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
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 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():
# Плоская схема: параметры прямо в def (без tiers).
for key in def.keys():
if str(key) != "tiers":
row[key] = def[key]
return row
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)
func _mark_input_handled() -> void:
var viewport := get_viewport()
if viewport != null:
viewport.set_input_as_handled()
func _play_sfx(id: StringName) -> void:
var audio = _autoload("Audio")
if audio != null and audio.has_method("play_sfx"):
@@ -229,3 +620,12 @@ func _autoload(name: String):
if loop is SceneTree:
return (loop as SceneTree).root.get_node_or_null(name)
return null
## Удобный static-хелпер для игр, которым нужен общий вердикт.
static func verdict(q: float) -> String:
if q >= 0.75:
return "ЧИСТО"
if q >= 0.45:
return "НОРМАЛЬНО"
return "СЛАБО"
+136
View File
@@ -0,0 +1,136 @@
class_name MiniGameFactory
extends RefCounted
## Фабрика мини-игр v2 и выбор игры для отправки.
##
## create_send() загружает res://scripts/minigames/send/<id>.gd.
## create_attack() — заготовка: возвращает null, если скрипта атаки ещё нет.
## selection_for_sum()/send_for_types() делегируют Economy (единственный
## источник правды: 6 игр, 3 ступени, 27 достижимых сумм).
const SEND_IDS: Array[String] = [
"dots", "sound", "text", "video", "photo", "maze",
]
const SEND_TIERS := 3
const ATTACK_IDS: Array[String] = [
"junk", "ddos", "phishing", "ransomware",
]
const COEFF := {
"text": 1,
"sound": 2,
"photo": 4,
"video": 8,
}
const SEND_DIR := "res://scripts/minigames/send"
const ATTACK_DIR := "res://scripts/minigames/attack"
const ECONOMY_PATH := "res://scripts/core/economy.gd"
const ECONOMY_SCRIPT := preload("res://scripts/core/economy.gd")
static func create_send(game_id: String, tier: int, seed: int,
context: Dictionary = {}) -> MiniGame:
var id := str(game_id).strip_edges().to_lower()
if not SEND_IDS.has(id):
id = MiniGame.resolve_game_id(id)
if not SEND_IDS.has(id):
return null
var path := "%s/%s.gd" % [SEND_DIR, id]
if not ResourceLoader.exists(path):
return null
var script = load(path)
if script == null:
return null
var node = script.new()
if node == null or not (node is MiniGame):
if node != null:
node.free()
return null
node.start(id, clampi(tier, 1, SEND_TIERS), seed, context)
return node
## Атаки добавляются отдельным агентом. Пока скрипта нет — null.
static func create_attack(kind: String, tier: int, seed: int,
context: Dictionary = {}):
var id := str(kind).strip_edges().to_lower()
if id.is_empty():
return null
var path := "%s/%s.gd" % [ATTACK_DIR, id]
if not ResourceLoader.exists(path):
return null
var script = load(path)
if script == null:
return null
var node = script.new()
if node == null or not (node is MiniGame):
if node != null:
node.free()
return null
node.start(id, clampi(tier, 1, 4), seed, context)
return node
static func is_send_id(game_id: String) -> bool:
return SEND_IDS.has(str(game_id).strip_edges().to_lower())
static func send_ids() -> Array[String]:
return SEND_IDS.duplicate()
static func tier_for_hour(hour: int) -> int:
return clampi(1 + int(floor(float(maxi(1, hour) - 2) / 2.0)), 1, SEND_TIERS)
## Канонический выбор: полностью делегируется Economy.selection_for_sum()
## (rank в achievable_sums, таблица rank_games, 3 ступени). Здесь только
## страховка: не пропускаем наружу неизвестную игру и ступень вне 1..3.
static func selection_for_sum(total: int) -> Dictionary:
var sum := maxi(total, 0)
var result: Dictionary = ECONOMY_SCRIPT.selection_for_sum(sum)
var id := str(result.get("id", "")).strip_edges().to_lower()
if not SEND_IDS.has(id):
id = SEND_IDS[0]
result["id"] = id
result["tier"] = clampi(int(result.get("tier", 1)), 1, SEND_TIERS)
result["sum"] = sum
return result
static func send_for_types(types: Array) -> Dictionary:
var delegated := _economy_selection(types)
if not delegated.is_empty():
if not delegated.has("id") and delegated.has("game"):
delegated["id"] = str(delegated["game"])
var id := str(delegated.get("id", "")).strip_edges().to_lower()
# Economy может работать по старым таблицам: «wires» уже нет, а
# ступень могла остаться четвёртой. Не пропускаем такое наружу.
if SEND_IDS.has(id):
delegated["id"] = id
delegated["tier"] = clampi(int(delegated.get("tier", 1)), 1, SEND_TIERS)
return delegated
var sum := 0
for type in types:
sum += int(COEFF.get(str(type).to_lower(), 1))
return selection_for_sum(sum)
static func _economy_selection(types: Array) -> Dictionary:
if not ResourceLoader.exists(ECONOMY_PATH):
return {}
var script = load(ECONOMY_PATH)
if script == null:
return {}
var has_method := false
for method in script.get_script_method_list():
if str(method.get("name", "")) == "send_minigame_for":
has_method = true
break
if not has_method:
return {}
var instance = script.new()
if instance == null:
return {}
var result = instance.call("send_minigame_for", types)
if result is Dictionary:
return (result as Dictionary).duplicate(true)
return {}
@@ -0,0 +1 @@
uid://brr3sl85uqcxa
+540
View File
@@ -0,0 +1,540 @@
extends MiniGame
## «Одна линия» (dots). Гамильтонов путь по свободным клеткам.
##
## FIX B3: сначала выбираются свободные клетки и препятствия, затем ищется
## путь только по свободным клеткам. Если случайная раскладка нерешаема —
## новый локальный RNG и ограниченное число попыток; последний рубеж — змейка.
const TIERS := {
1: {"cols": 4, "rows": 3, "obstacles": 2, "undo_penalty": 0.04},
2: {"cols": 4, "rows": 4, "obstacles": 4, "undo_penalty": 0.04},
3: {"cols": 5, "rows": 4, "obstacles": 6, "undo_penalty": 0.03},
}
const MAX_BOARD_ATTEMPTS := 16
const MAX_START_TRIES := 8
const DFS_GUARD := 120000
const RESET_RECT := Rect2(466.0, 320.0, 156.0, 28.0)
var board: Dictionary = {}
var _path: Array = []
var _undos: int = 0
var _invalid: int = 0
var _dragging: bool = false
var _cell_rects: Array[Rect2] = []
var _cell_ids: Array = []
var _stride: float = 60.0
var _port_rect: Rect2 = Rect2()
func _own_game_id() -> String:
return "dots"
func _setup() -> void:
board = generate_tier(tier, seed_value)
_path.clear()
_undos = 0
_invalid = 0
_dragging = false
_build_geometry()
func _goal_text() -> String:
return "ВЕДИТЕ ЛИНИЮ ОТ «С» ПО ВСЕМ КЛЕТКАМ · R — СБРОС"
func _progress_ratio() -> float:
var free_count := int(board.get("free_count", 0))
if free_count <= 0:
return 0.0
return clampf(float(_path.size()) / float(free_count), 0.0, 1.0)
func _result_stats() -> String:
return "ЛИНИЯ %d/%d · ОТКАТОВ %d · ОШИБОК %d" % [_path.size(),
int(board.get("free_count", 0)), _undos, _invalid]
# --- параметры -------------------------------------------------------------
static func tier_params(p_tier: int) -> Dictionary:
var base: Dictionary = (TIERS.get(clampi(p_tier, 1, 3), TIERS[1]) as Dictionary).duplicate(true)
var row := MiniGame.data_minigame_tier("dots", p_tier)
if row.is_empty():
return base
var grid = row.get("grid", null)
if grid is Array and (grid as Array).size() >= 2:
base["cols"] = int((grid as Array)[0])
base["rows"] = int((grid as Array)[1])
for key in ["cols", "rows", "obstacles"]:
if row.has(key):
base[key] = int(row[key])
if row.has("undo_penalty"):
base["undo_penalty"] = float(row["undo_penalty"])
return base
# --- генерация/валидация ---------------------------------------------------
static func generate_tier(p_tier: int, seed: int) -> Dictionary:
var p := tier_params(p_tier)
return generate(int(p["cols"]), int(p["rows"]), int(p["obstacles"]), seed)
## Чистая генерация. Возвращает разложение по int-id (row * cols + col).
static func generate(cols: int, rows: int, obstacles: int, seed: int) -> Dictionary:
var c := maxi(2, cols)
var r := maxi(2, rows)
var cells := c * r
var obs := clampi(obstacles, 0, cells - 4)
var free_count := cells - obs
var ids: Array = []
for i in range(cells):
ids.append(i)
var fallback_used := false
for attempt in range(MAX_BOARD_ATTEMPTS):
var rng := RandomNumberGenerator.new()
rng.seed = seed + 7919 * (attempt + 1)
var shuffled := ids.duplicate()
_shuffle(shuffled, rng)
var blocked: Array = shuffled.slice(0, obs)
var free: Array = shuffled.slice(obs)
if free.size() != free_count:
continue
if not _free_connected(free, c, r):
continue
if not _parity_ok(free, c):
continue
var starts := free.duplicate()
_shuffle(starts, rng)
var tries := mini(MAX_START_TRIES, starts.size())
for si in range(tries):
var start := int(starts[si])
var visited := {start: true}
var path: Array = [start]
var guard: Array = [0]
if _ham_dfs(start, free, c, r, visited, path, free_count, guard, rng):
if _path_valid(path, free, c, r):
return _make_board(c, r, free, blocked, path, attempt + 1, false)
# Случайный, но гарантированно валидный откат: полный гамильтонов путь
# по всей сетке, затем первые free_count клеток — свободные, хвост — стены.
for fb_attempt in range(4):
var rng3 := RandomNumberGenerator.new()
rng3.seed = seed + 15485863 + fb_attempt * 101
var all_ids: Array = []
for i in range(cells):
all_ids.append(i)
var starts3 := all_ids.duplicate()
_shuffle(starts3, rng3)
var start3 := int(starts3[0])
var visited3 := {start3: true}
var full_path: Array = [start3]
var guard3: Array = [0]
if _ham_dfs(start3, all_ids, c, r, visited3, full_path, cells, guard3, rng3):
var free3: Array = full_path.slice(0, free_count)
var blocked3: Array = full_path.slice(free_count, cells)
return _make_board(c, r, free3, blocked3, free3.duplicate(),
MAX_BOARD_ATTEMPTS + fb_attempt + 1, true)
# Последний рубеж: змейка по всей сетке; свободные — её префикс.
fallback_used = true
var rng2 := RandomNumberGenerator.new()
rng2.seed = seed + 104729
var snake := _snake_ids(c, r)
var free2: Array = snake.slice(0, free_count)
var blocked2: Array = snake.slice(free_count, snake.size())
_shuffle(blocked2, rng2)
return _make_board(c, r, free2, blocked2, free2.duplicate(), MAX_BOARD_ATTEMPTS,
fallback_used)
static func _make_board(c: int, r: int, free: Array, blocked: Array, path: Array,
attempts: int, fallback_used: bool) -> Dictionary:
var path_cells: Array = []
for id in path:
path_cells.append(Vector2i(int(id) % c, int(id) / c))
var sorted_blocked := blocked.duplicate()
sorted_blocked.sort()
var sorted_free := free.duplicate()
sorted_free.sort()
return {
"cols": c,
"rows": r,
"obstacles": sorted_blocked.size(),
"free_count": sorted_free.size(),
"blocked": sorted_blocked,
"free": sorted_free,
"path": path.duplicate(),
"path_cells": path_cells,
"start": int(path[0]),
"finish": int(path[path.size() - 1]),
"attempts": attempts,
"fallback": fallback_used,
}
static func _free_connected(free: Array, c: int, r: int) -> bool:
if free.is_empty():
return false
var free_map := {}
for id in free:
free_map[int(id)] = true
var seen := {int(free[0]): true}
var queue: Array = [int(free[0])]
while not queue.is_empty():
var id := int(queue.pop_front())
for n in _neighbors(id, c, r):
if free_map.has(n) and not seen.has(n):
seen[n] = true
queue.append(n)
return seen.size() == free.size()
static func _parity_ok(free: Array, c: int) -> bool:
var even := 0
var odd := 0
for id in free:
var x := int(id) % c
var y := int(id) / c
if (x + y) % 2 == 0:
even += 1
else:
odd += 1
return absi(even - odd) <= 1
static func _ham_dfs(current: int, free: Array, c: int, r: int, visited: Dictionary,
path: Array, total: int, guard: Array, rng: RandomNumberGenerator) -> bool:
guard[0] = int(guard[0]) + 1
if int(guard[0]) > DFS_GUARD:
return false
if path.size() == total:
return true
var free_map := {}
for id in free:
free_map[int(id)] = true
var near: Array = []
for n in _neighbors(current, c, r):
if free_map.has(n) and not visited.has(n):
near.append(n)
_shuffle(near, rng)
for n in near:
visited[n] = true
path.append(n)
if _ham_dfs(int(n), free, c, r, visited, path, total, guard, rng):
return true
path.pop_back()
visited.erase(n)
return false
static func _path_valid(path: Array, free: Array, c: int, r: int) -> bool:
if path.size() != free.size() or path.is_empty():
return false
var free_map := {}
for id in free:
free_map[int(id)] = true
var seen := {}
for i in range(path.size()):
var id := int(path[i])
if not free_map.has(id) or seen.has(id):
return false
seen[id] = true
if i > 0 and not _adjacent(int(path[i - 1]), id, c):
return false
return true
static func validate_board(board_data: Dictionary) -> bool:
if not board_data.has("cols") or not board_data.has("rows"):
return false
var c := int(board_data["cols"])
var r := int(board_data["rows"])
var free: Array = board_data.get("free", [])
var blocked: Array = board_data.get("blocked", [])
var path: Array = board_data.get("path", [])
if free.size() + blocked.size() != c * r:
return false
if int(board_data.get("obstacles", blocked.size())) != blocked.size():
return false
if int(board_data.get("free_count", free.size())) != free.size():
return false
if not _path_valid(path, free, c, r):
return false
if int(board_data.get("start", -1)) != int(path[0]):
return false
if int(board_data.get("finish", -1)) != int(path[path.size() - 1]):
return false
return true
static func _neighbors(id: int, c: int, r: int) -> Array:
var x := id % c
var y := id / c
var out: Array = []
if y > 0:
out.append(id - c)
if x < c - 1:
out.append(id + 1)
if y < r - 1:
out.append(id + c)
if x > 0:
out.append(id - 1)
return out
static func _adjacent(a: int, b: int, c: int) -> bool:
var ax := a % c
var ay := a / c
var bx := b % c
var by := b / c
return absi(ax - bx) + absi(ay - by) == 1
static func _shuffle(list: Array, p_rng: RandomNumberGenerator) -> void:
for i in range(list.size() - 1, 0, -1):
var j := p_rng.randi_range(0, i)
var tmp = list[i]
list[i] = list[j]
list[j] = tmp
static func _snake_ids(c: int, r: int) -> Array:
var out: Array = []
for y in range(r):
for i in range(c):
var x := i if y % 2 == 0 else c - 1 - i
out.append(y * c + x)
return out
static func quality_for(p_undos: int, p_invalid: int, p_tier: int) -> float:
var p := tier_params(p_tier)
var penalty := float(p.get("undo_penalty", 0.04))
return clampf(1.0 - penalty * float(maxi(0, p_undos))
- 0.1 * float(maxi(0, p_invalid)), 0.0, 1.0)
# --- геометрия/игра --------------------------------------------------------
func _build_geometry() -> void:
var cols := int(board.get("cols", 3))
var rows := int(board.get("rows", 3))
var area := Rect2(20.0, 54.0, 600.0, 256.0)
_stride = minf(area.size.x / float(cols), area.size.y / float(rows))
_stride = clampf(floorf(_stride), 46.0, 78.0)
var total_w := _stride * float(cols)
var total_h := _stride * float(rows)
_port_rect = Rect2(area.position.x + (area.size.x - total_w) * 0.5,
area.position.y + (area.size.y - total_h) * 0.5, total_w, total_h)
_cell_rects.clear()
_cell_ids.clear()
for y in range(rows):
for x in range(cols):
var pad := 5.0
_cell_rects.append(Rect2(
Vector2(_port_rect.position.x + x * _stride,
_port_rect.position.y + y * _stride) + Vector2(pad, pad),
Vector2(_stride - pad * 2.0, _stride - pad * 2.0)))
_cell_ids.append(y * cols + x)
func _cell_at(local: Vector2) -> int:
for i in range(_cell_rects.size()):
if _cell_rects[i].grow(4.0).has_point(local):
return int(_cell_ids[i])
return -1
func _blocked_map() -> Dictionary:
var out := {}
for id in board.get("blocked", []):
out[int(id)] = true
return out
func _free_map() -> Dictionary:
var out := {}
for id in board.get("free", []):
out[int(id)] = true
return out
func _on_pointer_pressed(local: Vector2, _event: InputEvent) -> bool:
if RESET_RECT.has_point(local):
_reset_line()
return true
var id := _cell_at(local)
if id < 0:
return false
_dragging = false
if _path.is_empty():
if id == int(board.get("start", -1)):
_path = [id]
_dragging = true
_play_sfx(&"tick")
else:
_invalid += 1
queue_redraw()
return true
var last := int(_path[_path.size() - 1])
if _path.size() >= 2 and id == int(_path[_path.size() - 2]):
_pop_path()
_dragging = true
return true
if id == last:
_dragging = true
return true
if _try_append_cell(id):
_dragging = true
else:
# Ошибкой считается только осознанный клик, не движение курсора.
_invalid += 1
queue_redraw()
return true
## Общая точка роста пути для клика и drag: молча игнорирует недопустимые
## клетки, чтобы быстрый проход мышью не портил качество.
func _try_append_cell(id: int) -> bool:
if id < 0 or _path.is_empty() or _finished:
return false
var last := int(_path[_path.size() - 1])
if id == last:
return false
var free := _free_map()
if not free.has(id) or _path.has(id):
return false
var cols := int(board.get("cols", 3))
if not _adjacent(last, id, cols):
return false
if id == int(board.get("finish", -1)) \
and _path.size() < int(board.get("free_count", 0)) - 1:
# Финиш можно только последним: иначе линия не покроет все порты.
return false
_path.append(id)
_play_sfx(&"tick")
if _path.size() == int(board.get("free_count", 0)) \
and id == int(board.get("finish", -1)):
_finish(quality_for(_undos, _invalid, tier))
queue_redraw()
return true
func _pop_path() -> void:
if _path.size() < 2:
return
_path.pop_back()
_undos += 1
_play_sfx(&"tick")
queue_redraw()
func _handle_motion_cell(id: int) -> void:
if id < 0 or _path.is_empty() or _finished:
return
var last := int(_path[_path.size() - 1])
if id == last:
return
if _path.size() >= 2 and id == int(_path[_path.size() - 2]):
_pop_path()
return
_try_append_cell(id)
func _on_pointer_moved(local: Vector2, _event: InputEvent) -> void:
if not _dragging or _finished or _path.is_empty():
return
# Быстрый drag может проскочить целую клетку между двумя motion-событиями.
# Поэтому идём от центра последней клетки к курсору шагами ~полклетки и
# скармливаем каждую точку той же логике добавления.
var start := _cell_center(int(_path[_path.size() - 1]))
var dist := start.distance_to(local)
var step := maxf(6.0, _stride * 0.5)
var steps := maxi(1, int(ceil(dist / step)))
for i in range(1, steps + 1):
var point := start.lerp(local, float(i) / float(steps))
_handle_motion_cell(_cell_at(point))
if _finished:
return
_handle_motion_cell(_cell_at(local))
func _on_pointer_released(_local: Vector2, _event: InputEvent) -> void:
_dragging = false
func _on_key_pressed(keycode: int, _event: InputEvent) -> bool:
if keycode == KEY_R:
_reset_line()
return true
return false
func _reset_line() -> void:
if not _path.is_empty():
_undos += 1
_path.clear()
_dragging = false
_play_sfx(&"tick")
queue_redraw()
func _draw_game() -> void:
var font := Theme90.ui()
var cols := int(board.get("cols", 3))
var blocked := _blocked_map()
var free := _free_map()
var path_set := {}
for id in _path:
path_set[int(id)] = true
_draw_label("ЛИНИЯ %d / %d" % [_path.size(), int(board.get("free_count", 0))],
Vector2(24.0, 46.0), 10, Theme90.NAVY, 220.0)
_draw_label("ПРЕПЯТСТВИЙ %d · ОТКАТОВ %d · ОШИБОК %d" % [
int(board.get("obstacles", 0)), _undos, _invalid],
Vector2(300.0, 46.0), 10, Theme90.DARK, 300.0, HORIZONTAL_ALIGNMENT_RIGHT)
draw_style_box(Theme90.box(Theme90.PAPER_LO, Theme90.BORDER_SOFT, 4, 1),
Rect2(_port_rect.position - Vector2(8, 8), _port_rect.size + Vector2(16, 16)))
for i in range(_cell_ids.size()):
var id := int(_cell_ids[i])
var rect := _cell_rects[i]
if blocked.has(id):
draw_style_box(Theme90.fill_box(Theme90.DARK.darkened(0.08), 3), rect)
Theme90.draw_hatch(self, rect.grow(-2.0), Color(1, 1, 1, 0.10), 5.0)
continue
var fill := Theme90.PAPER_HI
if path_set.has(id):
fill = Theme90.ACCENT_PALE
draw_style_box(Theme90.box(fill, Theme90.BORDER, 3, 1), rect)
var center := rect.get_center()
draw_circle(center, minf(rect.size.x, rect.size.y) * 0.22, Theme90.ACCENT_LIGHT)
draw_circle(center, minf(rect.size.x, rect.size.y) * 0.13, Theme90.ACCENT)
if id == int(board.get("start", -1)):
Theme90.text_vcenter(self, font, rect, "С", 16, Theme90.WHITE,
-1, HORIZONTAL_ALIGNMENT_CENTER)
elif id == int(board.get("finish", -1)):
draw_circle(center, minf(rect.size.x, rect.size.y) * 0.30, Theme90.SUCCESS)
Theme90.text_vcenter(self, font, rect, "Ф", 16, Theme90.WHITE,
-1, HORIZONTAL_ALIGNMENT_CENTER)
for i in range(1, _path.size()):
var a := _cell_center(int(_path[i - 1]))
var b := _cell_center(int(_path[i]))
draw_line(a, b, Theme90.ACCENT, 4.0, true)
if i == _path.size() - 1:
draw_circle(b, 4.0, Theme90.NAVY_DEEP)
_draw_secondary_button(RESET_RECT, "СБРОС (R)")
func _cell_center(id: int) -> Vector2:
for i in range(_cell_ids.size()):
if int(_cell_ids[i]) == id:
return _cell_rects[i].get_center()
return _port_rect.get_center()
+1
View File
@@ -0,0 +1 @@
uid://co766vx8eagfo
+457
View File
@@ -0,0 +1,457 @@
extends MiniGame
## «Лабиринт» (maze). Recursive backtracker + BFS-валидация.
## Пакет следует за мышью с максимальной скоростью, скользит по осям.
## Качество: 1 - 0.04*collisions - 0.02*dead_ends (без нижнего порога 0.4).
const TIERS := {
1: {"cols": 11, "rows": 4, "field_w": 440.0, "field_h": 160.0,
"corridor": 24.0, "speed": 470.0},
2: {"cols": 13, "rows": 4, "field_w": 494.0, "field_h": 152.0,
"corridor": 20.0, "speed": 520.0},
3: {"cols": 15, "rows": 5, "field_w": 520.0, "field_h": 180.0,
"corridor": 18.0, "speed": 560.0},
}
const HITBOX := 9.0
const PACKET := 12.0
const LOOP_CHANCE := 0.12
const COLLISION_COOLDOWN := 0.25
var maze: Dictionary = {}
var params: Dictionary = {}
var _walls: Array[Rect2] = []
var _field: Rect2 = Rect2()
var _pitch: float = 20.0
var _corridor: float = 20.0
var _solution: Array = []
var _sol_index: Dictionary = {}
var _dead_ends: Dictionary = {}
var _packet: Vector2 = Vector2.ZERO
var _target: Vector2 = Vector2.ZERO
var _entrance: Vector2 = Vector2.ZERO
var _exit: Vector2 = Vector2.ZERO
var _collisions: int = 0
var _dead_end_hits: int = 0
var _visited_dead: Dictionary = {}
var _collision_cd: float = 0.0
var _max_sol: int = 0
var _exited: bool = false
func _own_game_id() -> String:
return "maze"
func _setup() -> void:
params = tier_params(tier)
maze = generate_tier(tier, seed_value)
_pitch = float(params["field_w"]) / float(maze["cols"])
_corridor = float(params["corridor"])
_field = Rect2((640.0 - float(params["field_w"])) * 0.5,
70.0 + (180.0 - float(params["field_h"])) * 0.5,
float(params["field_w"]), float(params["field_h"]))
_walls = build_walls(maze["cells"], int(maze["cols"]), int(maze["rows"]),
int(maze["ent_row"]), int(maze["exit_row"]), _field, _corridor)
_solution = maze.get("solution", [])
_sol_index.clear()
for i in range(_solution.size()):
_sol_index[int(_solution[i])] = i
_entrance = Vector2(_field.position.x + _pitch * 0.5,
_field.position.y + (float(maze["ent_row"]) + 0.5) * _pitch)
_exit = Vector2(_field.end.x - _pitch * 0.5,
_field.position.y + (float(maze["exit_row"]) + 0.5) * _pitch)
_packet = _entrance
_target = _entrance
_collisions = 0
_dead_end_hits = 0
_visited_dead.clear()
_collision_cd = 0.0
_max_sol = 0
_exited = false
_dead_ends = find_dead_ends(maze["cells"], int(maze["cols"]), int(maze["rows"]),
int(maze["ent_row"]), int(maze["exit_row"]))
func _goal_text() -> String:
return "ПРОВЕДИТЕ ПАКЕТ ОТ ВХОДА ДО ЗЕЛЁНОГО ВЫХОДА"
func _progress_ratio() -> float:
if _solution.is_empty():
return 0.0
return clampf((float(_max_sol) + 1.0) / float(_solution.size()), 0.0, 1.0)
func _result_stats() -> String:
return "СТОЛКНОВЕНИЙ %d · ТУПИКОВ %d" % [_collisions, _dead_end_hits]
# --- параметры/генерация ---------------------------------------------------
static func tier_params(p_tier: int) -> Dictionary:
var base: Dictionary = (TIERS.get(clampi(p_tier, 1, 3), TIERS[1]) as Dictionary).duplicate(true)
var row := MiniGame.data_minigame_tier("maze", p_tier)
if row.is_empty():
return base
for key in ["cols", "rows"]:
if row.has(key):
base[key] = int(row[key])
if row.has("width"):
base["field_w"] = float(row["width"])
if row.has("height"):
base["field_h"] = float(row["height"])
for key in ["field_w", "field_h", "corridor", "speed"]:
if row.has(key):
base[key] = float(row[key])
if row.has("speed"):
base["speed"] = float(row["speed"])
return base
static func generate_tier(p_tier: int, seed: int) -> Dictionary:
var p := tier_params(p_tier)
var temp := RandomNumberGenerator.new()
temp.seed = seed
var ent_row := temp.randi_range(0, int(p["rows"]) - 1)
var exit_row := temp.randi_range(0, int(p["rows"]) - 1)
return generate(int(p["cols"]), int(p["rows"]), seed, ent_row, exit_row)
static func generate(cols: int, rows: int, seed: int, ent_row: int = -1,
exit_row: int = -1) -> Dictionary:
var c := maxi(3, cols)
var r := maxi(2, rows)
var rng := RandomNumberGenerator.new()
rng.seed = seed
var ent := clampi(ent_row if ent_row >= 0 else rng.randi_range(0, r - 1), 0, r - 1)
var ex := clampi(exit_row if exit_row >= 0 else rng.randi_range(0, r - 1), 0, r - 1)
var cells: Array = []
for y in range(r):
var row: Array = []
for x in range(c):
row.append({"n": true, "e": true, "s": true, "w": true, "v": false})
cells.append(row)
var stack: Array = [Vector2i(0, ent)]
cells[ent][0]["v"] = true
var visited := 1
while visited < c * r:
var top: Vector2i = stack[stack.size() - 1]
var near: Array = []
if top.y > 0 and not bool(cells[top.y - 1][top.x]["v"]):
near.append([top.x, top.y - 1, "n", "s"])
if top.x < c - 1 and not bool(cells[top.y][top.x + 1]["v"]):
near.append([top.x + 1, top.y, "e", "w"])
if top.y < r - 1 and not bool(cells[top.y + 1][top.x]["v"]):
near.append([top.x, top.y + 1, "s", "n"])
if top.x > 0 and not bool(cells[top.y][top.x - 1]["v"]):
near.append([top.x - 1, top.y, "w", "e"])
if near.is_empty():
stack.pop_back()
continue
var pick: Array = near[rng.randi_range(0, near.size() - 1)]
cells[top.y][top.x][str(pick[2])] = false
cells[int(pick[1])][int(pick[0])][str(pick[3])] = false
cells[int(pick[1])][int(pick[0])]["v"] = true
visited += 1
stack.append(Vector2i(int(pick[0]), int(pick[1])))
# Петли: аккуратно убираем часть внутренних стен (10–20%).
for y in range(r):
for x in range(c):
if x < c - 1 and bool(cells[y][x]["e"]) and rng.randf() < LOOP_CHANCE:
cells[y][x]["e"] = false
cells[y][x + 1]["w"] = false
if y < r - 1 and bool(cells[y][x]["s"]) and rng.randf() < LOOP_CHANCE:
cells[y][x]["s"] = false
cells[y + 1][x]["n"] = false
# Вход/выход.
cells[ent][0]["w"] = false
cells[ex][c - 1]["e"] = false
var solution := bfs(cells, c, r, ent * c + 0, ex * c + (c - 1))
return {
"cols": c,
"rows": r,
"seed": seed,
"ent_row": ent,
"exit_row": ex,
"cells": cells,
"solution": solution,
"solvable": not solution.is_empty(),
}
static func neighbors_open(cells: Array, x: int, y: int, cols: int, rows: int) -> Array:
var out: Array = []
if x < 0 or y < 0 or x >= cols or y >= rows:
return out
var cell: Dictionary = cells[y][x]
if y > 0 and not bool(cell["n"]):
out.append(Vector2i(x, y - 1))
if x < cols - 1 and not bool(cell["e"]):
out.append(Vector2i(x + 1, y))
if y < rows - 1 and not bool(cell["s"]):
out.append(Vector2i(x, y + 1))
if x > 0 and not bool(cell["w"]):
out.append(Vector2i(x - 1, y))
return out
static func bfs(cells: Array, cols: int, rows: int, start_id: int, goal_id: int) -> Array:
var sx := start_id % cols
var sy := start_id / cols
var gx := goal_id % cols
var gy := goal_id / cols
if sx < 0 or sy < 0 or sx >= cols or sy >= rows:
return []
if gx < 0 or gy < 0 or gx >= cols or gy >= rows:
return []
var prev := {start_id: -1}
var queue: Array = [start_id]
while not queue.is_empty():
var current := int(queue.pop_front())
if current == goal_id:
var path: Array = []
var node := current
while node >= 0:
path.append(node)
node = int(prev.get(node, -1))
path.reverse()
return path
var cx := current % cols
var cy := current / cols
for n in neighbors_open(cells, cx, cy, cols, rows):
var nid: int = int(n.y) * cols + int(n.x)
if prev.has(nid):
continue
prev[nid] = current
queue.append(nid)
return []
static func find_dead_ends(cells: Array, cols: int, rows: int, ent_row: int,
exit_row: int) -> Dictionary:
var out := {}
for y in range(rows):
for x in range(cols):
var degree := neighbors_open(cells, x, y, cols, rows).size()
if degree == 1 and not (x == 0 and y == ent_row) \
and not (x == cols - 1 and y == exit_row):
out[y * cols + x] = true
return out
## Все рёбра лабиринта: closed=true/false. Ключи:
## v:bx:by — вертикальная стена слева от клетки (bx,by), 0<=bx<=cols;
## h:bx:by — горизонтальная стена над клеткой (bx,by), 0<=by<=rows.
static func wall_edges(cells: Array, cols: int, rows: int, ent_row: int,
exit_row: int) -> Dictionary:
var out := {}
for by in range(rows):
for bx in range(cols + 1):
var closed := true
if bx == 0:
closed = by != ent_row
elif bx == cols:
closed = by != exit_row
else:
closed = bool(cells[by][bx - 1]["e"])
out["v:%d:%d" % [bx, by]] = closed
for by in range(rows + 1):
for bx in range(cols):
var closed := true
if by == 0:
closed = true
elif by == rows:
closed = true
else:
closed = bool(cells[by - 1][bx]["s"])
out["h:%d:%d" % [bx, by]] = closed
return out
static func build_walls(cells: Array, cols: int, rows: int, ent_row: int,
exit_row: int, field: Rect2, corridor: float) -> Array[Rect2]:
var pitch := field.size.x / float(cols)
var wall := pitch - corridor
var out: Array[Rect2] = []
var edges := wall_edges(cells, cols, rows, ent_row, exit_row)
for by in range(rows):
for bx in range(cols + 1):
if not bool(edges["v:%d:%d" % [bx, by]]):
continue
out.append(Rect2(field.position.x + float(bx) * pitch - wall * 0.5,
field.position.y + float(by) * pitch - wall * 0.5,
wall, pitch + wall))
for by in range(rows + 1):
for bx in range(cols):
if not bool(edges["h:%d:%d" % [bx, by]]):
continue
out.append(Rect2(field.position.x + float(bx) * pitch - wall * 0.5,
field.position.y + float(by) * pitch - wall * 0.5,
pitch + wall, wall))
return out
static func hit_wall(center: Vector2, walls: Array, half: float) -> bool:
var box := Rect2(center - Vector2(half, half), Vector2(half, half) * 2.0)
for wall in walls:
if box.intersects(wall as Rect2):
return true
return false
## Движение с раздельным скольжением по осям. Возвращает новую позицию и
## флаг столкновения. Чистая функция — удобна для headless-теста.
static func move_packet(pos: Vector2, target: Vector2, speed: float, dt: float,
walls: Array, field: Rect2, half: float) -> Dictionary:
var delta := target - pos
var dist := delta.length()
var max_move := maxf(0.0, speed) * maxf(0.0, dt)
if dist > max_move and dist > 0.001:
delta = delta / dist * max_move
var blocked := false
if delta.length_squared() <= 0.000001:
return {"pos": pos, "blocked": false}
var steps := maxi(1, int(ceil(maxf(absf(delta.x), absf(delta.y)) / 3.0)))
var sx := delta.x / float(steps)
var sy := delta.y / float(steps)
for i in range(steps):
var nx := pos.x + sx
if absf(sx) > 0.0001:
if not hit_wall(Vector2(nx, pos.y), walls, half):
pos.x = nx
else:
blocked = true
var ny := pos.y + sy
if absf(sy) > 0.0001:
if not hit_wall(Vector2(pos.x, ny), walls, half):
pos.y = ny
else:
blocked = true
pos.x = clampf(pos.x, field.position.x + half, field.end.x - half)
pos.y = clampf(pos.y, field.position.y + half, field.end.y - half)
return {"pos": pos, "blocked": blocked}
static func quality_for(collisions: int, dead_ends: int) -> float:
return clampf(1.0 - 0.04 * float(maxi(0, collisions))
- 0.02 * float(maxi(0, dead_ends)), 0.0, 1.0)
# --- игра ------------------------------------------------------------------
func _process(delta: float) -> void:
if not running or _exited:
return
var half := HITBOX * 0.5
var result := move_packet(_packet, _target, float(params.get("speed", 420.0)),
minf(maxf(delta, 0.0), 0.1), _walls, _field, half)
_packet = result["pos"]
_collision_cd = maxf(0.0, _collision_cd - delta)
if bool(result["blocked"]) and _collision_cd <= 0.0:
_collisions += 1
_collision_cd = COLLISION_COOLDOWN
var id := _cell_under(_packet)
if id >= 0:
if _sol_index.has(id) and int(_sol_index[id]) > _max_sol:
_max_sol = int(_sol_index[id])
if _dead_ends.has(id) and not _visited_dead.has(id):
_visited_dead[id] = true
_dead_end_hits += 1
# Выход справа, в строке выхода.
if _packet.x >= _field.end.x - half - 1.5 \
and absf(_packet.y - _exit.y) <= _corridor * 0.5:
_exited = true
_play_sfx(&"minigame_win")
_finish(quality_for(_collisions, _dead_end_hits))
return
queue_redraw()
func _cell_under(pos: Vector2) -> int:
var gx := int(floor((pos.x - _field.position.x) / _pitch))
var gy := int(floor((pos.y - _field.position.y) / _pitch))
var cols := int(maze.get("cols", 1))
var rows := int(maze.get("rows", 1))
if gx < 0 or gy < 0 or gx >= cols or gy >= rows:
return -1
return gy * cols + gx
func _clamp_target(local: Vector2) -> Vector2:
var half := HITBOX * 0.5
return Vector2(
clampf(local.x, _field.position.x + half, _field.end.x - half),
clampf(local.y, _field.position.y + half, _field.end.y - half))
func _on_pointer_pressed(local: Vector2, _event: InputEvent) -> bool:
if local.y < FOOTER_Y and _field.grow(20.0).has_point(local):
_target = _clamp_target(local)
return true
return false
func _on_pointer_moved(local: Vector2, _event: InputEvent) -> void:
if local.y >= FOOTER_Y:
return
if _field.grow(20.0).has_point(local):
_target = _clamp_target(local)
func _on_key_pressed(keycode: int, _event: InputEvent) -> bool:
var step := 12.0
match keycode:
KEY_LEFT:
_target.x -= step
KEY_RIGHT:
_target.x += step
KEY_UP:
_target.y -= step
KEY_DOWN:
_target.y += step
KEY_HOME:
_target = _entrance
_:
return false
_target = _clamp_target(_target)
return true
func _draw_game() -> void:
var font := Theme90.ui()
_draw_label("МАРШРУТ · ВЫХОД СПРАВА", Vector2(24.0, 46.0), 10,
Theme90.DARK, 280.0)
_draw_label("СТОЛКНОВЕНИЙ %d · ТУПИКОВ %d" % [_collisions, _dead_end_hits],
Vector2(320.0, 46.0), 10, Theme90.DARK, 280.0,
HORIZONTAL_ALIGNMENT_RIGHT)
draw_style_box(Theme90.box(Theme90.PAPER_HI, Theme90.BORDER, 4, 1),
_field.grow(10.0))
# Лёгкая шахматная подложка.
var pitch := _pitch
for y in range(int(maze.get("rows", 1))):
for x in range(int(maze.get("cols", 1))):
if (x + y) % 2 == 0:
draw_rect(Rect2(_field.position + Vector2(float(x) * pitch,
float(y) * pitch), Vector2(pitch, pitch)),
Color(0.18, 0.30, 0.32, 0.035))
for wall in _walls:
draw_rect(wall, Theme90.NAVY_DEEP)
# Вход/выход.
draw_rect(Rect2(_entrance - Vector2(3, 4), Vector2(6, 8)), Theme90.ACCENT)
draw_circle(_exit, maxf(5.0, _corridor * 0.32), Theme90.SUCCESS)
# Пакет и цель.
draw_circle(_target, 2.0, Theme90.ACCENT.lightened(0.25))
draw_style_box(Theme90.box(Theme90.ACCENT, Theme90.NAVY_DEEP, 3, 1,
Theme90.SHADOW), Rect2(_packet - Vector2(PACKET, PACKET) * 0.5,
Vector2(PACKET, PACKET)))
draw_circle(_packet + Vector2(0, -2), 2.0, Theme90.WHITE)
var progress := 0.0
if not _solution.is_empty():
progress = clampf((float(_max_sol) + 1.0) / float(_solution.size()), 0.0, 1.0)
_draw_label("ПРОГРЕСС %d%%" % roundi(progress * 100.0),
Vector2(_field.position.x, _field.end.y + 12.0), 10, Theme90.DARK,
_field.size.x)
+1
View File
@@ -0,0 +1 @@
uid://b21ebusrqnwn
+368
View File
@@ -0,0 +1,368 @@
extends MiniGame
## «Кадр» (photo). Бюст клиента всегда движется случайно; игрок ведёт рамку
## и делает N снимков. FIX H4: движение считается по фиксированному
## логическому шагу (SIM_DT), внутри которого используется dt-вероятность
## смены цели 1-exp(-rate*dt), retarget_timer -= dt и current_dir = dir.
## Поэтому сид даёт одинаковую траекторию на 30 и 144 fps.
const SCAN := Rect2(30.0, 44.0, 580.0, 268.0)
const BUST := Vector2(36.0, 48.0)
const INSET := 4.0
const SIM_DT := 1.0 / 120.0
const TWITCH_RATE := 1.25
const TIERS := {
1: {"speed": 140.0, "frame_w": 180.0, "frame_h": 120.0, "shots": 4,
"switch_min": 0.6, "switch_max": 1.0, "turn_rate": 260.0},
2: {"speed": 200.0, "frame_w": 150.0, "frame_h": 100.0, "shots": 5,
"switch_min": 0.5, "switch_max": 0.8, "turn_rate": 300.0},
3: {"speed": 260.0, "frame_w": 120.0, "frame_h": 80.0, "shots": 6,
"switch_min": 0.35, "switch_max": 0.65, "turn_rate": 340.0},
}
var state: Dictionary = {}
var params: Dictionary = {}
var _frame_pos: Vector2 = Vector2.ZERO
var _shots_taken: int = 0
var _captures: int = 0
var _acc: float = 0.0
var _flash: float = 0.0
var _last_capture_ok: bool = false
func _own_game_id() -> String:
return "photo"
func _setup() -> void:
params = tier_params(tier)
state = _make_state(rng, params)
_frame_pos = _clamp_frame(Vector2(SCAN.get_center().x, SCAN.position.y),
params)
_shots_taken = 0
_captures = 0
_acc = 0.0
_flash = 0.0
_last_capture_ok = false
func _goal_text() -> String:
return "НАВЕДИТЕ РАМКУ НА КЛИЕНТА · ЛКМ / ПРОБЕЛ — СНИМОК"
func _progress_ratio() -> float:
var shots := int(params.get("shots", 1))
if shots <= 0:
return 0.0
return clampf(float(_shots_taken) / float(shots), 0.0, 1.0)
func _result_stats() -> String:
return "УДАЧНЫХ %d ИЗ %d" % [_captures, int(params.get("shots", 0))]
# --- параметры/чистая математика ------------------------------------------
static func tier_params(p_tier: int) -> Dictionary:
var base: Dictionary = (TIERS.get(clampi(p_tier, 1, 3), TIERS[1]) as Dictionary).duplicate(true)
var row := MiniGame.data_minigame_tier("photo", p_tier)
if row.is_empty():
return base
for key in ["speed", "frame_w", "frame_h", "switch_min", "switch_max",
"turn_rate"]:
if row.has(key):
base[key] = float(row[key])
for alias in [["retarget_min", "switch_min"], ["retarget_max", "switch_max"]]:
if row.has(alias[0]):
base[alias[1]] = float(row[alias[0]])
if row.has("shots"):
base["shots"] = int(row["shots"])
return base
static func generate(p_tier: int, seed: int) -> Dictionary:
var rng := RandomNumberGenerator.new()
rng.seed = seed
return _make_state(rng, tier_params(p_tier))
static func _make_state(p_rng: RandomNumberGenerator, p: Dictionary) -> Dictionary:
var speed := float(p.get("speed", 90.0)) * p_rng.randf_range(0.9, 1.1)
var pos := _random_point(p_rng)
var target := _random_point(p_rng)
var dir := Vector2.RIGHT.rotated(p_rng.randf_range(0.0, TAU))
var timer := p_rng.randf_range(float(p.get("switch_min", 0.8)),
float(p.get("switch_max", 1.2)))
return {
"pos": pos,
"target": target,
"current_dir": dir,
"retarget_timer": timer,
"speed": speed,
"params": p,
}
static func _random_point(p_rng: RandomNumberGenerator) -> Vector2:
var half := BUST * 0.5
return Vector2(
p_rng.randf_range(SCAN.position.x + half.x + 2.0,
SCAN.end.x - half.x - 2.0),
p_rng.randf_range(SCAN.position.y + half.y + 2.0,
SCAN.end.y - half.y - 2.0))
static func _clamp_bust(pos: Vector2) -> Vector2:
var half := BUST * 0.5
return Vector2(
clampf(pos.x, SCAN.position.x + half.x, SCAN.end.x - half.x),
clampf(pos.y, SCAN.position.y + half.y, SCAN.end.y - half.y))
## H4: один логический шаг. dt здесь всегда SIM_DT, поэтому вероятность
## смены цели масштабируется временем, а не кадром.
static func step_motion(p_state: Dictionary, p_rng: RandomNumberGenerator,
dt: float) -> void:
if dt <= 0.0:
return
var p: Dictionary = p_state.get("params", {})
var pos: Vector2 = p_state.get("pos", SCAN.get_center())
var target: Vector2 = p_state.get("target", SCAN.get_center())
var current_dir: Vector2 = p_state.get("current_dir", Vector2.RIGHT)
var speed := float(p_state.get("speed", float(p.get("speed", 90.0))))
var turn_rate := deg_to_rad(float(p.get("turn_rate", 240.0)))
var to_target := target - pos
var desired := current_dir
if to_target.length_squared() > 0.001:
desired = to_target.normalized()
var max_turn := turn_rate * dt
var angle := current_dir.angle_to(desired)
current_dir = current_dir.rotated(clampf(angle, -max_turn, max_turn))
# H4: явно фиксируем текущее направление и двигаемся по нему.
var dir := current_dir
pos += dir * speed * dt
pos = _clamp_bust(pos)
# Редкая резкая смена цели, вероятность зависит от dt.
if p_rng.randf() < 1.0 - exp(-TWITCH_RATE * dt):
target = _random_point(p_rng)
p_state["retarget_timer"] = p_rng.randf_range(
float(p.get("switch_min", 0.8)), float(p.get("switch_max", 1.2)))
var timer := float(p_state.get("retarget_timer", 1.0)) - dt
if timer <= 0.0 or pos.distance_to(target) < 24.0:
target = _random_point(p_rng)
timer = p_rng.randf_range(float(p.get("switch_min", 0.8)),
float(p.get("switch_max", 1.2)))
p_state["pos"] = pos
p_state["target"] = target
p_state["current_dir"] = current_dir
p_state["retarget_timer"] = timer
## Детерминированная симуляция на фиксированном шаге. fps влияет только на
## группировку вызовов, не на последовательность шагов/позиций.
static func simulate(seed: int, p_tier: int, duration: float, fps: int) -> Dictionary:
var rng := RandomNumberGenerator.new()
rng.seed = seed
var p := tier_params(p_tier)
var s := _make_state(rng, p)
var total_steps := int(floor(maxf(0.0, duration) / SIM_DT))
var positions: Array[Vector2] = []
var frame_dt := 1.0 / float(maxi(1, fps))
var acc := 0.0
var steps := 0
var guard := 0
while steps < total_steps and guard < total_steps * 4 + 16:
guard += 1
acc += frame_dt
while acc >= SIM_DT and steps < total_steps:
step_motion(s, rng, SIM_DT)
positions.append(s["pos"])
steps += 1
acc -= SIM_DT
return {
"positions": positions,
"final": s["pos"],
"steps": steps,
}
static func capture_success(bust_pos: Vector2, frame_pos: Vector2,
frame_size: Vector2) -> bool:
var b := Rect2(bust_pos - BUST * 0.5, BUST)
var f := Rect2(frame_pos, frame_size)
return b.position.x >= f.position.x and b.position.y >= f.position.y \
and b.end.x <= f.end.x and b.end.y <= f.end.y
static func quality_for(captures: int, shots: int) -> float:
if shots <= 0:
return 0.0
return clampf(float(maxi(0, captures)) / float(shots), 0.0, 1.0)
static func _clamp_frame(mouse: Vector2, p: Dictionary) -> Vector2:
var fw := float(p.get("frame_w", 220.0))
var fh := float(p.get("frame_h", 150.0))
var x := clampf(mouse.x - fw * 0.5,
SCAN.position.x + INSET, SCAN.end.x - INSET - fw)
var y := clampf(mouse.y - fh * 0.5,
SCAN.position.y + INSET, SCAN.end.y - INSET - fh)
return Vector2(x, y)
func _frame_size() -> Vector2:
return Vector2(float(params.get("frame_w", 220.0)),
float(params.get("frame_h", 150.0)))
func _frame_rect() -> Rect2:
return Rect2(_frame_pos, _frame_size())
# --- игра ------------------------------------------------------------------
func _process(delta: float) -> void:
if not running or _finished:
return
var mouse := _frame_pos + _frame_size() * 0.5
if is_inside_tree():
mouse = get_local_mouse_position()
if SCAN.grow(2.0).has_point(mouse):
_frame_pos = _clamp_frame(mouse, params)
_acc += minf(maxf(delta, 0.0), 0.1)
var guard := 0
while _acc >= SIM_DT and guard < 32:
guard += 1
step_motion(state, rng, SIM_DT)
_acc -= SIM_DT
if _flash > 0.0:
_flash = maxf(0.0, _flash - delta)
queue_redraw()
func _capture() -> void:
if _finished:
return
var shots := int(params.get("shots", 3))
if _shots_taken >= shots:
return
_shots_taken += 1
var ok := capture_success(state["pos"], _frame_pos, _frame_size())
_last_capture_ok = ok
if ok:
_captures += 1
_flash = 0.22
_play_sfx(&"minigame_win" if ok else &"minigame_fail")
if _shots_taken >= shots:
_finish(quality_for(_captures, shots))
queue_redraw()
func _on_pointer_pressed(local: Vector2, _event: InputEvent) -> bool:
if local.y >= FOOTER_Y or not SCAN.grow(6.0).has_point(local):
return false
_frame_pos = _clamp_frame(local, params)
_capture()
return true
func _on_pointer_moved(local: Vector2, _event: InputEvent) -> void:
if local.y >= FOOTER_Y:
return
_frame_pos = _clamp_frame(local, params)
queue_redraw()
func _on_key_pressed(keycode: int, _event: InputEvent) -> bool:
if keycode == KEY_SPACE or keycode == KEY_ENTER:
_capture()
return true
var step := 6.0
var center := _frame_pos + _frame_size() * 0.5
if keycode == KEY_LEFT:
center.x -= step
elif keycode == KEY_RIGHT:
center.x += step
elif keycode == KEY_UP:
center.y -= step
elif keycode == KEY_DOWN:
center.y += step
else:
return false
_frame_pos = _clamp_frame(center, params)
queue_redraw()
return true
# --- отрисовка -------------------------------------------------------------
func _draw_game() -> void:
var font := Theme90.ui()
var shots := int(params.get("shots", 3))
var inside := capture_success(state["pos"], _frame_pos, _frame_size())
draw_style_box(Theme90.box(Theme90.DARK, Color(0, 0, 0, 0), 4, 1), SCAN)
# Мягкий фон скана: окно/стол, на игру не влияет.
draw_rect(Rect2(SCAN.position + Vector2(18, 16), Vector2(178, 118)),
Color(0.86, 0.84, 0.76, 0.12))
draw_line(SCAN.position + Vector2(18, 134), SCAN.position + Vector2(196, 134),
Color(1, 1, 1, 0.16), 2.0)
draw_rect(Rect2(SCAN.end - Vector2(170, 102), Vector2(120, 72)),
Color(0.65, 0.58, 0.45, 0.12))
# Статус рисуется поверх тёмного скана, иначе его закрывает фон.
_draw_label("СКАН КЛИЕНТА · КАДР %d / %d" % [_shots_taken, shots],
Vector2(40.0, 47.0), 10, Theme90.PAPER_HI, 280.0)
_draw_label("УДАЧНЫХ %d · ОСТАЛОСЬ %d" % [_captures, shots - _shots_taken],
Vector2(320.0, 47.0), 10, Theme90.ACCENT_LIGHT, 270.0,
HORIZONTAL_ALIGNMENT_RIGHT)
var bust_pos: Vector2 = state["pos"]
_draw_bust(bust_pos)
var frame := _frame_rect()
var fcol := Theme90.SUCCESS if inside else Theme90.ACCENT_LIGHT
Theme90.draw_dashed_box(self, frame, fcol, 5.0, 2.0)
# Уголки рамки.
var corner := 12.0
for p in [frame.position, Vector2(frame.end.x, frame.position.y),
Vector2(frame.position.x, frame.end.y), frame.end]:
draw_circle(p, 2.2, fcol)
draw_line(frame.position + Vector2(0, corner),
frame.position + Vector2(0, 0), fcol, 2.0)
draw_line(frame.position + Vector2(0, 0),
frame.position + Vector2(corner, 0), fcol, 2.0)
draw_line(Vector2(frame.end.x, frame.position.y),
Vector2(frame.end.x, frame.position.y + corner), fcol, 2.0)
draw_line(Vector2(frame.end.x - corner, frame.position.y),
Vector2(frame.end.x, frame.position.y), fcol, 2.0)
if _flash > 0.0:
var a := _flash / 0.22
draw_rect(SCAN, Color(1, 1, 1, 0.22 * a))
var text := "В РАМКЕ" if _last_capture_ok else "МИМО"
_draw_label(text, Vector2(SCAN.get_center().x - 60.0, SCAN.position.y + 12.0),
16, Theme90.SUCCESS if _last_capture_ok else Theme90.ERROR, 120.0,
HORIZONTAL_ALIGNMENT_CENTER)
_draw_label("рамка %d×%d" % [roundi(_frame_size().x), roundi(_frame_size().y)],
Vector2(466.0, 344.0), 10, Theme90.DARK, 156.0,
HORIZONTAL_ALIGNMENT_RIGHT)
func _draw_bust(pos: Vector2) -> void:
var b := Rect2(pos - BUST * 0.5, BUST)
draw_circle(Vector2(pos.x, b.end.y - 4.0), 12.0, Color(0, 0, 0, 0.16))
# Плечи.
draw_colored_polygon(PackedVector2Array([
Vector2(pos.x - 15.0, b.end.y - 2.0),
Vector2(pos.x - 9.0, b.position.y + 26.0),
Vector2(pos.x + 9.0, b.position.y + 26.0),
Vector2(pos.x + 15.0, b.end.y - 2.0)]), Theme90.NAVY)
# Голова.
draw_circle(Vector2(pos.x, b.position.y + 14.0), 9.0, Theme90.NAVY)
# Шея.
draw_rect(Rect2(Vector2(pos.x - 3.0, b.position.y + 22.0), Vector2(6, 8)),
Theme90.NAVY_DEEP)
# Лицо/блик — чтобы бюст читался.
draw_circle(Vector2(pos.x + 3.0, b.position.y + 12.0), 1.5, Theme90.PAPER_HI)
+1
View File
@@ -0,0 +1 @@
uid://cld1besfvk2ow
+330
View File
@@ -0,0 +1,330 @@
extends MiniGame
## «Уровни» (sound). Вертикальные фейдеры, зелёные целевые зоны.
## T1–T3: 4/5/6 каналов, допуск 11/8/6%, на T2/T3 — синусоидальный дрейф.
## Таймера нет; качество фиксируется кнопкой «ЗАПИСАТЬ».
const TIERS := {
1: {"count": 4, "tolerance": 0.11, "drift_amp": 0.0, "drift_hz": 0.0},
2: {"count": 5, "tolerance": 0.08, "drift_amp": 0.02, "drift_hz": 0.15},
3: {"count": 6, "tolerance": 0.06, "drift_amp": 0.03, "drift_hz": 0.25},
}
const LABELS := [
["НЧ", "60"], ["НСЧ", "250"], ["СЧ", "1К"], ["ВСЧ", "4К"], ["ВЧ", "12К"],
["УВЧ", "20К"],
]
const TRACK_TOP := 92.0
const TRACK_H := 154.0
const TRACK_W := 30.0
var state: Dictionary = {}
var _values: Array[float] = []
var _targets: Array[float] = []
var _phases: Array[float] = []
var _time: float = 0.0
var _dragging: int = -1
var _active: int = 0
func _own_game_id() -> String:
return "sound"
func _setup() -> void:
state = generate(tier, seed_value)
_values.clear()
_targets.clear()
_phases.clear()
for v in state.get("starts", []):
_values.append(float(v))
for v in state.get("targets", []):
_targets.append(float(v))
for p in state.get("phases", []):
_phases.append(float(p))
_time = 0.0
_dragging = -1
_active = 0
func _goal_text() -> String:
return "СОВМЕСТИТЕ БЕГУНКИ С ЗЕЛЁНЫМИ ПОЛОСАМИ · ENTER — ЗАПИСАТЬ"
func _result_stats() -> String:
var in_zone := 0
for i in range(_values.size()):
var tol := float(state.get("tolerance", 0.11))
if absf(_values[i] - _current_target(i)) <= tol:
in_zone += 1
return "В ЗОНЕ %d/%d · ДОПУСК ±%d%%" % [in_zone, _values.size(),
roundi(float(state.get("tolerance", 0.11)) * 100.0)]
# --- параметры/чистая логика ----------------------------------------------
static func tier_params(p_tier: int) -> Dictionary:
var base: Dictionary = (TIERS.get(clampi(p_tier, 1, 3), TIERS[1]) as Dictionary).duplicate(true)
var row := MiniGame.data_minigame_tier("sound", p_tier)
if row.is_empty():
return base
if row.has("channels"):
base["count"] = int(row["channels"])
if row.has("count"):
base["count"] = int(row["count"])
if row.has("tolerance"):
base["tolerance"] = float(row["tolerance"])
if row.has("drift"):
base["drift_amp"] = float(row["drift"])
if row.has("drift_amp"):
base["drift_amp"] = float(row["drift_amp"])
if row.has("drift_hz"):
base["drift_hz"] = float(row["drift_hz"])
return base
static func generate(p_tier: int, seed: int) -> Dictionary:
var p := tier_params(p_tier)
var count := clampi(int(p.get("count", 3)), 2, 6)
var tol := clampf(float(p.get("tolerance", 0.16)), 0.02, 0.30)
var amp := maxf(0.0, float(p.get("drift_amp", 0.0)))
var hz := maxf(0.0, float(p.get("drift_hz", 0.0)))
var rng := RandomNumberGenerator.new()
rng.seed = seed
var targets: Array[float] = []
var min_gap := tol * 2.0
var lo := tol
var hi := 1.0 - tol
var attempts := 0
while targets.size() < count and attempts < 600:
attempts += 1
var candidate := rng.randf_range(lo, hi)
var ok := true
for t in targets:
if absf(candidate - float(t)) < min_gap - 0.0001:
ok = false
break
if ok:
targets.append(candidate)
# Резерв: равномерная сетка, если rejection не сошёлся.
if targets.size() < count:
targets.clear()
var span := maxf(0.0, hi - lo)
var step := span / float(maxi(1, count - 1))
for i in range(count):
targets.append(clampf(lo + step * float(i), 0.02, 0.98))
targets.sort()
var starts: Array[float] = []
var phases: Array[float] = []
for i in range(count):
var target := float(targets[i])
var sign := -1.0 if rng.randf() < 0.5 else 1.0
var offset := rng.randf_range(tol * 1.10, tol * 2.8)
var start := clampf(target + sign * offset, 0.02, 0.98)
if absf(start - target) <= tol * 1.02:
start = clampf(target - sign * offset, 0.02, 0.98)
if absf(start - target) <= tol:
start = clampf(target + tol * 2.0, 0.02, 0.98)
if absf(start - target) <= tol:
start = clampf(target - tol * 2.0, 0.02, 0.98)
if absf(start - target) <= tol:
start = 0.02 if target >= 0.5 else 0.98
starts.append(start)
phases.append(rng.randf_range(0.0, TAU))
return {
"tier": clampi(p_tier, 1, 3),
"count": count,
"tolerance": tol,
"drift_amp": amp,
"drift_hz": hz,
"targets": targets,
"starts": starts,
"phases": phases,
}
static func target_at(target: float, amp: float, hz: float, phase: float,
time: float) -> float:
if amp <= 0.0 or hz <= 0.0:
return target
return clampf(target + amp * sin(TAU * hz * time + phase), 0.0, 1.0)
static func channel_quality(value: float, target: float, tol: float) -> float:
if tol <= 0.0:
return 1.0 if is_equal_approx(value, target) else 0.0
return clampf(1.0 - absf(value - target) / (2.0 * tol), 0.0, 1.0)
static func quality_from(values: Array, targets: Array, tol: float,
amp: float = 0.0, hz: float = 0.0, phases: Array = [], time: float = 0.0) -> float:
var n := mini(values.size(), targets.size())
if n <= 0:
return 0.0
var total := 0.0
for i in range(n):
var target := float(targets[i])
if amp > 0.0 and hz > 0.0:
var phase := float(phases[i]) if i < phases.size() else 0.0
target = target_at(target, amp, hz, phase, time)
total += channel_quality(float(values[i]), target, tol)
return clampf(total / float(n), 0.0, 1.0)
func _current_target(i: int) -> float:
if i < 0 or i >= _targets.size():
return 0.5
return target_at(_targets[i], float(state.get("drift_amp", 0.0)),
float(state.get("drift_hz", 0.0)), _phases[i] if i < _phases.size() else 0.0,
_time)
func _quality_now() -> float:
var targets: Array = []
for i in range(_targets.size()):
targets.append(_current_target(i))
return quality_from(_values, targets, float(state.get("tolerance", 0.16)))
func _process(delta: float) -> void:
if not running:
return
if float(state.get("drift_amp", 0.0)) > 0.0:
_time += maxf(0.0, delta)
queue_redraw()
# --- геометрия -------------------------------------------------------------
func _track_rect(i: int) -> Rect2:
var count := _values.size()
var spacing := minf(104.0, 480.0 / float(maxi(1, count)))
var center_x := 320.0
var x := center_x + (float(i) - float(count - 1) * 0.5) * spacing - TRACK_W * 0.5
return Rect2(x, TRACK_TOP, TRACK_W, TRACK_H)
func _value_to_y(rect: Rect2, value: float) -> float:
var pad := 7.0
return rect.position.y + pad + (1.0 - clampf(value, 0.0, 1.0)) * (rect.size.y - pad * 2.0)
func _y_to_value(rect: Rect2, y: float) -> float:
var pad := 7.0
var inner := rect.size.y - pad * 2.0
return clampf(1.0 - (y - rect.position.y - pad) / inner, 0.0, 1.0)
func _fader_at(local: Vector2) -> int:
for i in range(_values.size()):
var rect := _track_rect(i)
if local.x >= rect.position.x - 24.0 and local.x <= rect.end.x + 24.0 \
and local.y >= rect.position.y - 20.0 and local.y <= rect.end.y + 18.0:
return i
return -1
# --- ввод ------------------------------------------------------------------
func _on_pointer_pressed(local: Vector2, _event: InputEvent) -> bool:
if ACTION_RECT.has_point(local):
_finish(_quality_now())
return true
var idx := _fader_at(local)
if idx < 0:
return false
_dragging = idx
_active = idx
_values[idx] = _y_to_value(_track_rect(idx), local.y)
_play_sfx(&"tick")
queue_redraw()
return true
func _on_pointer_moved(local: Vector2, _event: InputEvent) -> void:
if _dragging < 0:
return
_values[_dragging] = _y_to_value(_track_rect(_dragging), local.y)
queue_redraw()
func _on_pointer_released(_local: Vector2, _event: InputEvent) -> void:
_dragging = -1
func _on_key_pressed(keycode: int, event: InputEvent) -> bool:
if keycode == KEY_ENTER or keycode == KEY_KP_ENTER:
_finish(_quality_now())
return true
if keycode == KEY_LEFT or keycode == KEY_A:
_active = posmod(_active - 1, _values.size())
queue_redraw()
return true
if keycode == KEY_RIGHT or keycode == KEY_D:
_active = (_active + 1) % _values.size()
queue_redraw()
return true
if keycode in [KEY_UP, KEY_DOWN, KEY_W, KEY_S]:
var key_event := event as InputEventKey
var step := 0.005 if key_event != null and key_event.shift_pressed else 0.02
var sign := 1.0 if keycode in [KEY_UP, KEY_W] else -1.0
_values[_active] = clampf(_values[_active] + sign * step, 0.0, 1.0)
queue_redraw()
return true
return false
# --- отрисовка -------------------------------------------------------------
func _draw_game() -> void:
var tol := float(state.get("tolerance", 0.16))
var in_zone := 0
for i in range(_values.size()):
if absf(_values[i] - _current_target(i)) <= tol:
in_zone += 1
_draw_label("МИКШЕР · КАНАЛ 97", Vector2(24.0, 46.0), 10, Theme90.ACCENT, 240.0)
_draw_label("В ЗОНЕ %d / %d · ДОПУСК ±%d%%" % [in_zone, _values.size(),
roundi(tol * 100.0)], Vector2(280.0, 46.0), 10,
Theme90.SUCCESS if in_zone == _values.size() else Theme90.DARK, 320.0,
HORIZONTAL_ALIGNMENT_RIGHT)
var panel := Rect2(18.0, 58.0, 604.0, 250.0)
draw_style_box(Theme90.box(Theme90.PAPER_HI, Theme90.BORDER, 4, 1), panel)
# Общая шкала 100/50/0.
var first := _track_rect(0)
for v in [1.0, 0.5, 0.0]:
var y := _value_to_y(first, v)
Theme90.text(self, Theme90.ui(), Vector2(first.position.x - 44.0, y - 6.0),
"%d" % roundi(v * 100.0), 10, Theme90.DARK, 34.0,
HORIZONTAL_ALIGNMENT_RIGHT)
for i in range(_values.size()):
var rect := _track_rect(i)
var target := _current_target(i)
var active := i == _active
draw_style_box(Theme90.box(Theme90.PAPER_LO, Theme90.BORDER_SOFT, 3, 1), rect)
var y_top := _value_to_y(rect, minf(1.0, target + tol))
var y_bottom := _value_to_y(rect, maxf(0.0, target - tol))
draw_rect(Rect2(rect.position.x + 2.0, y_top, rect.size.x - 4.0,
maxf(2.0, y_bottom - y_top)), Color(Theme90.SUCCESS.r, Theme90.SUCCESS.g,
Theme90.SUCCESS.b, 0.30))
draw_line(Vector2(rect.position.x - 4.0, _value_to_y(rect, target)),
Vector2(rect.end.x + 4.0, _value_to_y(rect, target)), Theme90.SUCCESS, 2.0)
var y := _value_to_y(rect, _values[i])
draw_rect(Rect2(rect.position.x + 4.0, y, rect.size.x - 8.0,
rect.end.y - y - 7.0), Color(Theme90.ACCENT.r, Theme90.ACCENT.g,
Theme90.ACCENT.b, 0.24))
draw_style_box(Theme90.box(Theme90.WHITE, Theme90.ACCENT, 3, 1,
Theme90.SHADOW), Rect2(rect.position.x - 7.0, y - 6.0, rect.size.x + 14.0, 12.0))
if active:
Theme90.draw_dashed_box(self, rect.grow(3.0), Theme90.ACCENT, 3.0, 1.0)
var label: Array = LABELS[i % LABELS.size()]
Theme90.text(self, Theme90.ui(), Vector2(rect.position.x - 10.0,
rect.end.y + 8.0), str(label[0]), 12, Theme90.DARK, rect.size.x + 20.0,
HORIZONTAL_ALIGNMENT_CENTER)
Theme90.text(self, Theme90.ui(), Vector2(rect.position.x - 10.0,
rect.end.y + 23.0), "%s · %d%%" % [str(label[1]), roundi(_values[i] * 100.0)],
10, Theme90.DARK, rect.size.x + 20.0, HORIZONTAL_ALIGNMENT_CENTER)
_draw_action_button(ACTION_RECT, "ЗАПИСАТЬ", true, true)
+1
View File
@@ -0,0 +1 @@
uid://besue7m8ig8k6
+447
View File
@@ -0,0 +1,447 @@
extends MiniGame
## «Чужое слово» (text). Письмо с нормальными и чужими словами.
## Клик помечает/снимает слово; «ОТПРАВИТЬ» фиксирует.
## q = clamp((found - false_positive) / aliens, 0, 1), ложное слово
## учитывается не более одного раза.
const TIERS := {
1: {"words": 15, "aliens": 2, "lines": 2, "types": ["foreign", "deformed"]},
2: {"words": 19, "aliens": 3, "lines": 3, "types": ["foreign", "deformed", "semantic"]},
3: {"words": 24, "aliens": 4, "lines": 3, "types": ["foreign", "deformed", "semantic", "archaic"]},
}
const NORMAL := [
"смета", "отчёт", "курсор", "канал", "папка", "сервер", "файл", "запрос",
"договор", "смена", "пакет", "клиент", "письмо", "проверка", "срочно",
"подтвердить", "согласовано", "принято", "оплата", "счёт", "архив",
"копия", "печать", "модем", "линия", "обмен", "срок", "заявка",
"подпись", "курьер", "терминал", "диспетчер", "доставка", "опись",
"накладная", "пломба", "сверка", "реестр", "график", "смена", "склад",
"погрузка", "маршрут", "квитанция", "сектор", "порт", "кабель", "узел",
"сбой", "ремонт", "дежурный", "инструкция", "согласование", "скан",
]
const ALIEN_POOLS := {
"foreign": [
"deadline", "please", "update", "attachment", "urgent", "email",
"file", "message", "report", "invoice", "delivery", "storage",
],
"deformed": [
"подтверDить", "согласоваNо", "клиеNт", "проверXа", "докумеNт",
"срочNо", "пакеD", "адреZ", "сектоR", "накладNая",
],
"semantic": [
"СУХОЙ-МОДЕМ", "КВАДРАТНЫЙ-ЧАЙ", "ЖИДКИЙ-ФАКС", "ГЛУХОЙ-ЗВОНОК",
"СОЛЁНЫЙ-КАБЕЛЬ", "ЦИФРОВОЙ-СНЕГ", "МОКРАЯ-ПОЧТА", "ХРУСТЯЩИЙ-ДИСК",
],
"archaic": [
"ТЕЛЕГРАФ", "ПЕРФОЛЕНТА", "ТЕЛЕКС", "ГРАММОФОН", "АРИФМОМЕТР",
"ПАТЕФОН", "КЕРОСИНКА", "БОБИНА", "ТАБУЛЯТОР", "ДИСКЕТА",
],
}
const ACTION_LABEL := "ОТПРАВИТЬ"
var generated: Dictionary = {}
var _words: Array = []
var _marked: Dictionary = {}
var _layout: Array = []
var _hover: int = -1
var _found: int = 0
func _own_game_id() -> String:
return "text"
func _setup() -> void:
generated = generate(tier, seed_value)
_words = []
for w in generated.get("words", []):
_words.append((w as Dictionary).duplicate(true))
_marked.clear()
_found = 0
_hover = -1
_build_layout()
func _goal_text() -> String:
return "НАЙДИТЕ ЧУЖИЕ СЛОВА И НАЖМИТЕ «ОТПРАВИТЬ»"
func _progress_ratio() -> float:
var aliens := int(generated.get("aliens", 0))
if aliens <= 0:
return 0.0
return clampf(float(_found) / float(aliens), 0.0, 1.0)
func _result_stats() -> String:
return "НАЙДЕНО %d/%d · ЛОЖНЫХ %d" % [_found,
int(generated.get("aliens", 0)), _false_positive_count()]
# --- параметры/чистая логика ----------------------------------------------
static func tier_params(p_tier: int) -> Dictionary:
var base: Dictionary = (TIERS.get(clampi(p_tier, 1, 3), TIERS[1]) as Dictionary).duplicate(true)
var row := MiniGame.data_minigame_tier("text", p_tier)
if row.is_empty():
return base
for key in ["words", "aliens", "lines"]:
if row.has(key):
base[key] = int(row[key])
return base
static func generate(p_tier: int, seed: int) -> Dictionary:
var p := tier_params(p_tier)
var total := clampi(int(p.get("words", 11)), 6, 30)
var aliens := clampi(int(p.get("aliens", 1)), 1, mini(6, total - 2))
var lines := clampi(int(p.get("lines", 2)), 1, 4)
var allowed: Array = p.get("types", ["foreign"])
if allowed.is_empty():
allowed = ["foreign"]
var rng := RandomNumberGenerator.new()
rng.seed = seed
var normal_pool := NORMAL.duplicate()
_shuffle(normal_pool, rng)
var chosen: Array = []
var used := {}
for word in normal_pool:
var key := str(word).to_lower()
if used.has(key):
continue
used[key] = true
chosen.append(str(word))
if chosen.size() >= total - aliens:
break
while chosen.size() < total - aliens:
var filler := "пакет%d" % (chosen.size() + 1)
if not used.has(filler):
used[filler] = true
chosen.append(filler)
var alien_words: Array = []
var types_used: Array = []
var type_cycle: Array = allowed.duplicate()
_shuffle(type_cycle, rng)
for i in range(aliens):
var type := str(type_cycle[i % type_cycle.size()])
var pool: Array = ALIEN_POOLS.get(type, ALIEN_POOLS["foreign"])
var candidates := pool.duplicate()
_shuffle(candidates, rng)
var picked := ""
for candidate in candidates:
var candidate_key := str(candidate).to_lower()
if not used.has(candidate_key):
picked = str(candidate)
break
if picked.is_empty():
# Пул исчерпан — берём любую другую разрешённую категорию.
for alt in allowed:
var alt_pool: Array = ALIEN_POOLS.get(str(alt), [])
for candidate in alt_pool:
var candidate_key := str(candidate).to_lower()
if not used.has(candidate_key):
picked = str(candidate)
type = str(alt)
break
if not picked.is_empty():
break
if picked.is_empty():
picked = "чужое%d" % (i + 1)
used[picked.to_lower()] = true
alien_words.append({"text": picked, "type": type})
if not types_used.has(type):
types_used.append(type)
# Строки: сумма ровно total, все строки непустые.
var rows: Array = _split_counts(total, lines, rng)
var words: Array = []
for word in chosen:
words.append({"text": word, "alien": false, "type": "", "row": 0})
for alien in alien_words:
words.append({"text": str(alien["text"]), "alien": true,
"type": str(alien["type"]), "row": 0})
_shuffle(words, rng)
var row_index := 0
var in_row := 0
for i in range(words.size()):
if row_index >= rows.size():
row_index = rows.size() - 1
words[i]["row"] = row_index
in_row += 1
if in_row >= int(rows[row_index]) and row_index < rows.size() - 1:
row_index += 1
in_row = 0
var alien_count := 0
for w in words:
if bool(w["alien"]):
alien_count += 1
return {
"tier": clampi(p_tier, 1, 3),
"words": words,
"count": words.size(),
"aliens": alien_count,
"rows": rows,
"types": allowed,
}
static func _split_counts(total: int, lines: int, rng: RandomNumberGenerator) -> Array:
var out: Array = []
var base := total / lines
var rest := total % lines
for i in range(lines):
out.append(base + (1 if i < rest else 0))
if lines > 1 and rng.randf() < 0.5 and int(out[0]) > 1 and int(out[lines - 1]) > 1:
out[0] = int(out[0]) + 1
out[lines - 1] = int(out[lines - 1]) - 1
return out
static func validate_generation(data: Dictionary, p_tier: int) -> bool:
var p := tier_params(p_tier)
var words: Array = data.get("words", [])
var rows: Array = data.get("rows", [])
if words.size() != int(p.get("words", words.size())):
return false
var expected_aliens := int(p.get("aliens", 1))
var aliens := 0
var seen := {}
var row_sum := 0
for r in rows:
row_sum += int(r)
if row_sum != words.size() or rows.size() != int(p.get("lines", rows.size())):
return false
for w in words:
var text := str(w.get("text", "")).strip_edges()
if text.is_empty():
return false
var key := text.to_lower()
if seen.has(key):
return false
seen[key] = true
if bool(w.get("alien", false)):
aliens += 1
if not _is_known_alien(text):
return false
else:
if _is_known_alien(text):
return false
return aliens == expected_aliens
static func _is_known_alien(word: String) -> bool:
var key := word.strip_edges().to_lower()
for pool in ALIEN_POOLS.values():
for candidate in (pool as Array):
if str(candidate).strip_edges().to_lower() == key:
return true
return false
static func quality_for(found: int, false_positive: int, aliens: int) -> float:
if aliens <= 0:
return 1.0
return clampf((float(found) - float(false_positive)) / float(aliens), 0.0, 1.0)
static func _shuffle(list: Array, p_rng: RandomNumberGenerator) -> void:
for i in range(list.size() - 1, 0, -1):
var j := p_rng.randi_range(0, i)
var tmp = list[i]
list[i] = list[j]
list[j] = tmp
# --- раскладка/игра --------------------------------------------------------
func _build_layout() -> void:
_layout.clear()
var font := Theme90.ui()
if _words.is_empty():
return
var panel := Rect2(28.0, 62.0, 584.0, 232.0)
var inner_w := panel.size.x - 40.0
var rows_count := int(generated.get("rows", []).size())
if rows_count <= 0:
rows_count = 2
var counts: Array = generated.get("rows", [])
var size_px := 10
for candidate in [16, 12, 10]:
var fits := true
for r in range(counts.size()):
var width := 0.0
for w in _row_words(r, counts):
width += font.get_string_size(str(w["text"]), HORIZONTAL_ALIGNMENT_LEFT,
-1, int(candidate)).x + 10.0
width += 7.0 * float(maxi(0, _row_words(r, counts).size() - 1))
if width > inner_w:
fits = false
break
if fits:
size_px = int(candidate)
break
var line_h := float(size_px) + 13.0
# Первая строка ниже заголовка записки, а не поверх него.
var header_h := 24.0
var top := panel.position.y + header_h + 2.0
var avail := panel.size.y - header_h - 10.0
var gap := 10.0
if rows_count > 1:
gap = clampf((avail - line_h * float(rows_count)) / float(rows_count - 1),
10.0, 46.0)
var block_h := line_h * float(rows_count) + gap * float(maxi(0, rows_count - 1))
var y := top + maxf(0.0, (avail - block_h) * 0.5)
for r in range(counts.size()):
var row_list := _row_words(r, counts)
var widths: Array = []
var total_w := 0.0
for w in row_list:
var ww := font.get_string_size(str(w["text"]), HORIZONTAL_ALIGNMENT_LEFT,
-1, size_px).x + 10.0
widths.append(ww)
total_w += ww
total_w += 7.0 * float(maxi(0, row_list.size() - 1))
var x := panel.position.x + 20.0 + maxf(0.0, (inner_w - total_w) * 0.5)
var row_layout: Array = []
for i in range(row_list.size()):
var ww := float(widths[i])
row_layout.append({
"idx": int(row_list[i]["idx"]),
"rect": Rect2(x, y, ww, line_h),
})
x += ww + 7.0
_layout.append(row_layout)
y += line_h + gap
_layout.append(size_px)
func _row_words(r: int, counts: Array) -> Array:
var out: Array = []
for i in range(_words.size()):
if int(_words[i].get("row", 0)) == r:
var copy: Dictionary = (_words[i] as Dictionary).duplicate(true)
copy["idx"] = i
out.append(copy)
return out
func _word_index_at(local: Vector2) -> int:
for row in _layout:
if not (row is Array):
continue
for item in (row as Array):
var hit: Rect2 = (item as Dictionary)["rect"]
if hit.grow(3.0).has_point(local):
return int((item as Dictionary)["idx"])
return -1
func _false_positive_count() -> int:
var n := 0
for idx in _marked.keys():
var i := int(idx)
if i >= 0 and i < _words.size() and not bool(_words[i].get("alien", false)):
n += 1
return n
func _recount() -> void:
_found = 0
for idx in _marked.keys():
var i := int(idx)
if i >= 0 and i < _words.size() and bool(_words[i].get("alien", false)):
_found += 1
func _on_pointer_pressed(local: Vector2, _event: InputEvent) -> bool:
if ACTION_RECT.has_point(local):
var fp := _false_positive_count()
_finish(quality_for(_found, fp, int(generated.get("aliens", 1))))
return true
var idx := _word_index_at(local)
if idx < 0:
return false
if _marked.has(idx):
_marked.erase(idx)
else:
_marked[idx] = true
_recount()
_play_sfx(&"tick")
queue_redraw()
return true
func _on_pointer_moved(local: Vector2, _event: InputEvent) -> void:
var idx := _word_index_at(local)
if idx != _hover:
_hover = idx
queue_redraw()
func _on_key_pressed(keycode: int, _event: InputEvent) -> bool:
if keycode == KEY_ENTER or keycode == KEY_KP_ENTER:
var fp := _false_positive_count()
_finish(quality_for(_found, fp, int(generated.get("aliens", 1))))
return true
return false
func _draw_game() -> void:
var font := Theme90.ui()
var fp := _false_positive_count()
_draw_label("ПИСЬМО КЛИЕНТА · ЗАДАНИЕ 97", Vector2(24.0, 46.0), 10,
Theme90.NAVY, 260.0)
_draw_label("НАЙДЕНО %d / %d · ЛОЖНЫХ %d" % [_found,
int(generated.get("aliens", 1)), fp], Vector2(300.0, 46.0), 10,
Theme90.SUCCESS if _found >= int(generated.get("aliens", 1)) else Theme90.DARK,
300.0, HORIZONTAL_ALIGNMENT_RIGHT)
var panel := Rect2(28.0, 62.0, 584.0, 232.0)
draw_style_box(Theme90.box(Theme90.PAPER_HI, Theme90.BORDER, 4, 1,
Theme90.SHADOW), panel)
draw_rect(Rect2(panel.position.x + 18.0, panel.position.y + 23.0,
panel.size.x - 36.0, 1.0), Theme90.HAIRLINE)
_draw_label("СЛУЖЕБНАЯ ЗАПИСКА · НАЙДИТЕ ЧУЖИЕ СЛОВА", Vector2(panel.position.x + 20.0,
panel.position.y + 7.0), 10, Theme90.DARK, panel.size.x - 40.0)
var size_px := 12
if not _layout.is_empty() and typeof(_layout[_layout.size() - 1]) == TYPE_INT:
size_px = int(_layout[_layout.size() - 1])
for row in _layout:
if not (row is Array):
continue
for item in (row as Array):
var d: Dictionary = item
var idx := int(d["idx"])
var rect: Rect2 = d["rect"]
var word: String = str(_words[idx].get("text", ""))
var marked := _marked.has(idx)
if marked and _hover == idx:
draw_style_box(Theme90.box(Theme90.ACCENT_PALE, Theme90.ACCENT, 2, 1),
rect)
elif marked:
var fcol := Theme90.ERROR if not bool(_words[idx].get("alien", false)) \
else Theme90.SUCCESS
draw_style_box(Theme90.box(Color(fcol.r, fcol.g, fcol.b, 0.13),
fcol, 2, 1), rect)
elif _hover == idx:
draw_style_box(Theme90.box(Theme90.PAPER_LO, Theme90.BORDER_SOFT, 2, 1),
rect)
var color := Theme90.INK
if marked:
color = Theme90.ERROR if not bool(_words[idx].get("alien", false)) else Theme90.SUCCESS
Theme90.text_vcenter(self, font, rect, word, size_px, color,
rect.size.x, HORIZONTAL_ALIGNMENT_CENTER)
if marked and bool(_words[idx].get("alien", false)):
draw_line(Vector2(rect.position.x + 2.0, rect.get_center().y + 5.0),
Vector2(rect.end.x - 2.0, rect.get_center().y + 5.0),
Theme90.SUCCESS, 1.5)
_draw_label("КЛИК ПО СЛОВУ — ПОМЕТИТЬ · ПОВТОРНЫЙ КЛИК — СНЯТЬ",
Vector2(24.0, 300.0), 10, Theme90.DARK, 400.0)
_draw_action_button(ACTION_RECT, ACTION_LABEL, true, true)
+1
View File
@@ -0,0 +1 @@
uid://cn5qitwtcdc72
+440
View File
@@ -0,0 +1,440 @@
extends MiniGame
## «Раскадровка» (video). Разложить кадры по слотам.
##
## T1 4/4 (таймкод), T2 5 карточек/4 слота (1 скрытый брак),
## T3 6 карточек/5 слотов (1 брак + 1 зеркальный кадр).
## Таймкоды случайны для каждой раздачи, брак дублирует ровно один реальный
## таймкод и отличается силуэтом.
const TIERS := {
1: {"cards": 4, "slots": 4, "decoy": false, "mirror": false, "hint": "timecode"},
2: {"cards": 5, "slots": 4, "decoy": true, "mirror": false, "hint": "ghost"},
3: {"cards": 6, "slots": 5, "decoy": true, "mirror": true, "hint": "timecode"},
}
const SHAPES := ["circle", "triangle", "square", "diamond", "cross"]
const COLORS := ["accent", "warn", "success", "error", "navy"]
const COLOR_HEX := {
"accent": Color(0.184314, 0.498039, 0.560784),
"warn": Color(0.772549, 0.541176, 0.180392),
"success": Color(0.247059, 0.560784, 0.368627),
"error": Color(0.760784, 0.356863, 0.290196),
"navy": Color(0.172549, 0.360784, 0.407843),
}
var generated: Dictionary = {}
var _cards: Array = []
var _selected: int = -1
var _card_slot: Array[int] = []
var _slot_rects: Array[Rect2] = []
var _tray_rects: Array = []
func _own_game_id() -> String:
return "video"
func _setup() -> void:
generated = generate(tier, seed_value)
_cards = []
for card in generated.get("cards", []):
_cards.append((card as Dictionary).duplicate(true))
_card_slot.clear()
for i in range(_cards.size()):
_card_slot.append(-1)
_selected = -1
_build_slot_rects()
func _goal_text() -> String:
return "РАЗЛОЖИТЕ КАДРЫ ПО СЛОТАМ · КАДР → СЛОТ · ОТПРАВИТЬ"
func _progress_ratio() -> float:
var slots := int(generated.get("slots", 0))
if slots <= 0:
return 0.0
return clampf(float(_occupied_slots()) / float(slots), 0.0, 1.0)
func _result_stats() -> String:
var c := _counts()
return "ВЕРНО %d ИЗ %d · ОШИБОК %d · БРАК %d" % [int(c["correct"]),
int(generated.get("slots", 0)), int(c["wrong"]), int(c["decoys"])]
# --- параметры/чистая логика ----------------------------------------------
static func tier_params(p_tier: int) -> Dictionary:
var base: Dictionary = (TIERS.get(clampi(p_tier, 1, 3), TIERS[1]) as Dictionary).duplicate(true)
var row := MiniGame.data_minigame_tier("video", p_tier)
if row.is_empty():
return base
if row.has("frames"):
base["cards"] = int(row["frames"])
if row.has("cards"):
base["cards"] = int(row["cards"])
if row.has("slots"):
base["slots"] = int(row["slots"])
if row.has("decoy"):
base["decoy"] = bool(row["decoy"])
if row.has("decoys"):
base["decoy"] = int(row["decoys"]) > 0
if row.has("mirror"):
base["mirror"] = bool(row["mirror"])
return base
static func generate(p_tier: int, seed: int) -> Dictionary:
var p := tier_params(p_tier)
var slots := clampi(int(p.get("slots", 3)), 2, 5)
var cards_total := clampi(int(p.get("cards", slots)), slots, 7)
var want_decoy := bool(p.get("decoy", false)) and cards_total > slots
var want_mirror := bool(p.get("mirror", false))
var rng := RandomNumberGenerator.new()
rng.seed = seed
var shape_pool := SHAPES.duplicate()
var color_pool := COLORS.duplicate()
_shuffle(shape_pool, rng)
_shuffle(color_pool, rng)
# Таймкоды: случайная стартовая секунда и случайные интервалы; слот i
# получает i-й по возрастанию таймкод, поэтому порядок не фиксирован.
var seconds := rng.randi_range(0, 18)
var timecodes: Array[String] = []
for i in range(slots):
timecodes.append("00:%02d" % seconds)
seconds += rng.randi_range(2, 7)
var slot_shapes: Array = []
var cards: Array = []
var mirror_index := -1
if want_mirror:
mirror_index = rng.randi_range(0, slots - 1)
for i in range(slots):
var shape := str(shape_pool[i % shape_pool.size()])
var color := str(color_pool[i % color_pool.size()])
var mirrored := i == mirror_index
if mirrored and shape == "circle":
# Зеркало заметно только на несимметричной фигуре.
shape = "triangle"
slot_shapes.append({"shape": shape, "color": color, "timecode": timecodes[i]})
cards.append({
"id": cards.size(),
"timecode": timecodes[i],
"shape": shape,
"color": color,
"correct_slot": i,
"decoy": false,
"mirrored": mirrored,
})
if want_decoy:
var source_slot := rng.randi_range(0, slots - 1)
var source: Dictionary = cards[source_slot]
var decoy_shape := str(source["shape"])
var candidates: Array = []
for s in SHAPES:
if str(s) != decoy_shape:
candidates.append(str(s))
decoy_shape = str(candidates[rng.randi_range(0, candidates.size() - 1)])
cards.append({
"id": cards.size(),
"timecode": str(source["timecode"]),
"shape": decoy_shape,
"color": str(source["color"]),
"correct_slot": -1,
"decoy": true,
"mirrored": false,
})
# В лотке карточки перемешаны; брак не первый и не последний.
var tray := cards.duplicate(true)
_shuffle(tray, rng)
if want_decoy and tray.size() > 2:
var decoy_idx := -1
for i in range(tray.size()):
if bool((tray[i] as Dictionary).get("decoy", false)):
decoy_idx = i
break
if decoy_idx == 0 or decoy_idx == tray.size() - 1:
var safe_idx := rng.randi_range(1, tray.size() - 2)
var tmp = tray[decoy_idx]
tray[decoy_idx] = tray[safe_idx]
tray[safe_idx] = tmp
return {
"tier": clampi(p_tier, 1, 3),
"slots": slots,
"cards": tray,
"card_count": tray.size(),
"slot_timecodes": timecodes,
"slot_shapes": slot_shapes,
"hint": str(p.get("hint", "timecode")),
"has_decoy": want_decoy,
"has_mirror": want_mirror,
}
static func quality_from(slots: int, correct: int, wrong: int, decoys_in_slot: int) -> float:
if slots <= 0:
return 0.0
return clampf((float(correct) - 0.5 * float(maxi(0, wrong))
- float(maxi(0, decoys_in_slot))) / float(slots), 0.0, 1.0)
static func _shuffle(list: Array, p_rng: RandomNumberGenerator) -> void:
for i in range(list.size() - 1, 0, -1):
var j := p_rng.randi_range(0, i)
var tmp = list[i]
list[i] = list[j]
list[j] = tmp
static func decoy_info(data: Dictionary) -> Dictionary:
var cards: Array = data.get("cards", [])
var counts := {}
for card in cards:
var tc := str(card.get("timecode", ""))
counts[tc] = int(counts.get(tc, 0)) + 1
for card in cards:
if not bool(card.get("decoy", false)):
continue
var tc := str(card.get("timecode", ""))
var duplicate_real := -1
for other in cards:
if other == card or bool(other.get("decoy", false)):
continue
if str(other.get("timecode", "")) == tc:
duplicate_real = int(other.get("id", -1))
break
return {
"decoy_timecode": tc,
"duplicate_real": duplicate_real,
"decoy_shape": str(card.get("shape", "")),
"duplicate_count": int(counts.get(tc, 0)),
}
return {"decoy_timecode": "", "duplicate_real": -1, "decoy_shape": "",
"duplicate_count": 0}
# --- раскладка/игра --------------------------------------------------------
func _build_slot_rects() -> void:
_slot_rects.clear()
var slots := int(generated.get("slots", 3))
var area := Rect2(40.0, 62.0, 560.0, 128.0)
var gap := 10.0
var w := (area.size.x - gap * float(maxi(0, slots - 1))) / float(maxi(1, slots))
for i in range(slots):
_slot_rects.append(Rect2(area.position.x + float(i) * (w + gap),
area.position.y, w, area.size.y))
func _recompute_tray() -> void:
_tray_rects.clear()
var visible: Array = []
for i in range(_cards.size()):
if _card_slot[i] < 0:
visible.append(i)
var card_w := 84.0
var card_h := 96.0
var gap := 8.0
var total := float(visible.size()) * card_w + float(maxi(0, visible.size() - 1)) * gap
var x := 320.0 - total * 0.5
for idx in visible:
_tray_rects.append({"card": int(idx), "rect": Rect2(x, 224.0, card_w, card_h)})
x += card_w + gap
func _slot_at(local: Vector2) -> int:
for i in range(_slot_rects.size()):
if _slot_rects[i].has_point(local):
return i
return -1
func _card_at(local: Vector2) -> int:
_recompute_tray()
for entry in _tray_rects:
if (entry["rect"] as Rect2).has_point(local):
return int(entry["card"])
return -1
func _counts() -> Dictionary:
var correct := 0
var wrong := 0
var decoys := 0
for i in range(_cards.size()):
var slot := _card_slot[i]
if slot < 0:
continue
var card: Dictionary = _cards[i]
if bool(card.get("decoy", false)):
decoys += 1
elif slot == int(card.get("correct_slot", -2)):
correct += 1
else:
wrong += 1
return {"correct": correct, "wrong": wrong, "decoys": decoys}
func _quality_now() -> float:
var c := _counts()
return quality_from(int(generated.get("slots", 3)), int(c["correct"]),
int(c["wrong"]), int(c["decoys"]))
func _on_pointer_pressed(local: Vector2, _event: InputEvent) -> bool:
if ACTION_RECT.has_point(local):
_finish(_quality_now())
return true
var si := _slot_at(local)
if si >= 0:
# Клик по занятому слоту возвращает кадр в лоток.
for i in range(_cards.size()):
if _card_slot[i] == si:
_card_slot[i] = -1
_selected = -1
_play_sfx(&"tick")
queue_redraw()
return true
if _selected >= 0:
_card_slot[_selected] = si
_selected = -1
_play_sfx(&"tick")
queue_redraw()
return true
var ci := _card_at(local)
if ci >= 0:
_selected = -1 if _selected == ci else ci
_play_sfx(&"tick")
queue_redraw()
return true
return false
func _on_key_pressed(keycode: int, _event: InputEvent) -> bool:
if keycode == KEY_ENTER or keycode == KEY_KP_ENTER:
_finish(_quality_now())
return true
return false
func _draw_game() -> void:
var font := Theme90.ui()
var slots := int(generated.get("slots", 3))
var hint := str(generated.get("hint", "timecode"))
var c := _counts()
_draw_label("РАСКАДРОВКА · СЛОЙ 97", Vector2(24.0, 46.0), 10,
Theme90.ACCENT, 260.0)
_draw_label("РАЗМЕЩЕНО %d / %d · ОШИБОК %d · БРАК %d" % [
_occupied_slots(), slots, int(c["wrong"]), int(c["decoys"])],
Vector2(300.0, 46.0), 10, Theme90.DARK, 300.0,
HORIZONTAL_ALIGNMENT_RIGHT)
var slot_shapes: Array = generated.get("slot_shapes", [])
for i in range(slots):
var rect := _slot_rects[i]
draw_style_box(Theme90.box(Theme90.PAPER_HI, Theme90.BORDER, 3, 1), rect)
draw_rect(Rect2(rect.position.x + 1.0, rect.position.y + 20.0,
rect.size.x - 2.0, 1.0), Theme90.HAIRLINE)
_draw_label("СЛОТ %02d" % (i + 1), Vector2(rect.position.x + 8.0,
rect.position.y + 5.0), 10, Theme90.ACCENT, rect.size.x - 16.0)
var occupant := -1
for ci in range(_cards.size()):
if _card_slot[ci] == i:
occupant = ci
break
if occupant >= 0:
_draw_card(_cards[occupant], Rect2(rect.position + Vector2(8, 28),
rect.size - Vector2(16, 36)), true, hint)
_draw_label("КЛИК — ВЕРНУТЬ", Vector2(rect.position.x, rect.end.y - 14.0),
10, Theme90.DARK, rect.size.x, HORIZONTAL_ALIGNMENT_CENTER)
continue
Theme90.draw_dashed_box(self, Rect2(rect.position + Vector2(8, 28),
rect.size - Vector2(16, 36)), Theme90.BORDER_SOFT, 3.0, 1.0)
if i < slot_shapes.size():
var ghost: Dictionary = slot_shapes[i]
var gshape := str(ghost.get("shape", "circle"))
var gcol: Color = COLOR_HEX.get(str(ghost.get("color", "accent")),
Theme90.NAVY)
if hint == "shape" or hint == "ghost":
_draw_frame_shape(Vector2(rect.get_center().x, rect.position.y + 72.0),
34.0, gshape, Color(gcol.r, gcol.g, gcol.b, 0.50), false)
if hint != "shape":
_draw_centered(str(ghost.get("timecode", "")),
Rect2(rect.position.x, rect.end.y - 34.0, rect.size.x, 24.0),
16 if hint == "ghost" else 24, Theme90.NAVY)
else:
_draw_centered(str(ghost.get("timecode", "")),
Rect2(rect.position.x, rect.end.y - 30.0, rect.size.x, 20.0),
10, Theme90.DARK)
_recompute_tray()
for entry in _tray_rects:
var card: Dictionary = _cards[int(entry["card"])]
var rect: Rect2 = entry["rect"]
if int(entry["card"]) == _selected:
rect.position.y -= 4.0
_draw_card(card, rect, false, hint)
if int(entry["card"]) == _selected:
draw_rect(rect.grow(3.0), Theme90.NAVY_DEEP, false, 2.0)
_draw_action_button(ACTION_RECT, "ОТПРАВИТЬ", true, true)
func _empty_slots() -> int:
var n := 0
for i in range(_cards.size()):
if _card_slot[i] < 0:
n += 1
return n
func _occupied_slots() -> int:
var n := 0
for i in range(_cards.size()):
if _card_slot[i] >= 0:
n += 1
return mini(n, int(generated.get("slots", 3)))
func _draw_card(card: Dictionary, rect: Rect2, in_slot: bool, hint: String) -> void:
var col: Color = COLOR_HEX.get(str(card.get("color", "accent")), Theme90.ACCENT)
draw_style_box(Theme90.box(col, Theme90.BORDER, 3, 1), rect)
var center := Vector2(rect.get_center().x, rect.position.y + rect.size.y * 0.46)
_draw_frame_shape(center, minf(rect.size.y * 0.34, 38.0),
str(card.get("shape", "circle")), Color(1, 1, 1, 0.90),
bool(card.get("mirrored", false)))
var label := str(card.get("timecode", ""))
if hint == "shape" and not in_slot:
label = "КАДР"
_draw_centered(label, Rect2(rect.position.x, rect.end.y - 25.0, rect.size.x, 20.0),
12 if rect.size.x >= 90.0 else 10, Theme90.WHITE)
func _draw_frame_shape(center: Vector2, s: float, shape: String, color: Color,
mirrored: bool) -> void:
var flip := -1.0 if mirrored else 1.0
match shape:
"circle":
draw_circle(center, s * 0.52, color)
"triangle":
draw_colored_polygon(PackedVector2Array([
center + Vector2(0, -s * 0.62) * Vector2(flip, 1),
center + Vector2(s * 0.58, s * 0.40) * Vector2(flip, 1),
center + Vector2(-s * 0.58, s * 0.40) * Vector2(flip, 1)]), color)
"square":
draw_rect(Rect2(center - Vector2(s, s) * 0.46, Vector2(s, s) * 0.92), color)
"diamond":
draw_colored_polygon(PackedVector2Array([
center + Vector2(0, -s * 0.64) * Vector2(flip, 1),
center + Vector2(s * 0.64, 0) * Vector2(flip, 1),
center + Vector2(0, s * 0.64) * Vector2(flip, 1),
center + Vector2(-s * 0.64, 0) * Vector2(flip, 1)]), color)
_:
draw_rect(Rect2(center - Vector2(s * 0.15, s * 0.60),
Vector2(s * 0.30, s * 1.20)), color)
draw_rect(Rect2(center - Vector2(s * 0.60, s * 0.15),
Vector2(s * 1.20, s * 0.30)), color)
+1
View File
@@ -0,0 +1 @@
uid://cdhe6b2d04ehx
+235
View File
@@ -0,0 +1,235 @@
# run_all_tests.ps1 - headless test runner for PEAK HOUR 97 v2 (Windows).
#
# Usage:
# powershell -ExecutionPolicy Bypass -File scripts/run_all_tests.ps1
# $env:GODOT_BIN = "D:\path\Godot_v4.7.2-stable_win64_console.exe"
# powershell -ExecutionPolicy Bypass -File scripts/run_all_tests.ps1
#
# Each test is a separate Godot process with a per-test timeout. Missing test
# files are skipped (SKIP), not failed. APPDATA is redirected to a temp folder
# for the run so tests never touch the real user save. The final parse check
# runs `--headless --path <root> --quit`.
[CmdletBinding()]
param(
[string]$GodotBin = $env:GODOT_BIN,
[int]$TimeoutSec = 180
)
$ErrorActionPreference = "Stop"
$Root = (Resolve-Path (Join-Path $PSScriptRoot "..")).Path
Set-Location $Root
if ([string]::IsNullOrWhiteSpace($GodotBin)) {
$GodotBin = "D:\Apps\godot\Godot_v4.7.2-stable_win64_console.exe"
}
if (-not (Test-Path -LiteralPath $GodotBin -PathType Leaf)) {
$fallback = Get-Command godot4 -ErrorAction SilentlyContinue
if (-not $fallback) { $fallback = Get-Command godot -ErrorAction SilentlyContinue }
if ($fallback) { $GodotBin = $fallback.Source }
}
if (-not (Test-Path -LiteralPath $GodotBin -PathType Leaf)) {
Write-Host "FAIL: Godot binary not found: $GodotBin" -ForegroundColor Red
Write-Host "Set GODOT_BIN or pass -GodotBin <path>."
exit 1
}
$Tests = @(
"res://tests/run_tests.gd",
"res://tests/test_hour.gd",
"res://tests/test_slots.gd",
"res://tests/test_shop.gd",
"res://tests/test_minigame.gd",
"res://tests/test_send_games.gd",
"res://tests/test_attack_games.gd",
"res://tests/test_file_grid.gd",
"res://tests/test_shop_modal.gd",
"res://tests/test_audio.gd",
"res://tests/test_ambient.gd",
"res://tests/test_fuzz.gd",
"res://tests/test_integration.gd",
"res://tests/test_attacks.gd",
"res://tests/test_controller.gd",
"res://tests/test_main_prep.gd",
"res://tests/test_main_hour.gd",
"res://tests/test_main_interactions.gd",
"res://tests/test_main_attacks.gd",
"res://tests/test_main_pause.gd",
"res://tests/test_main_fullscreen.gd",
"res://tests/test_main_polish.gd",
"res://tests/test_toast.gd",
"res://tests/test_full_playthrough.gd",
"res://scripts/ui/ui_smoke.gd"
)
$ErrorRe = 'SCRIPT ERROR|Parse Error|USER ERROR|Failed to load|Cannot open file|FAIL|SMOKE FAILED'
$LeakRe = 'ObjectDB instances leaked|leaked at exit'
function Quote-Arg([string]$Value) {
return '"' + $Value.Replace('"', '\"') + '"'
}
function Invoke-Godot([string]$Arguments, [int]$Timeout) {
$psi = New-Object System.Diagnostics.ProcessStartInfo
$psi.FileName = $GodotBin
$psi.Arguments = $Arguments
$psi.WorkingDirectory = $Root
$psi.UseShellExecute = $false
$psi.RedirectStandardOutput = $true
$psi.RedirectStandardError = $true
$psi.CreateNoWindow = $true
$process = New-Object System.Diagnostics.Process
$process.StartInfo = $psi
$null = $process.Start()
$outTask = $process.StandardOutput.ReadToEndAsync()
$errTask = $process.StandardError.ReadToEndAsync()
$exited = $process.WaitForExit($Timeout * 1000)
if (-not $exited) {
try { $process.Kill() } catch { }
try { $process.WaitForExit(5000) | Out-Null } catch { }
}
$stdout = ""
$stderr = ""
try { $stdout = $outTask.Result } catch { }
try { $stderr = $errTask.Result } catch { }
$code = -1
if ($exited) { $code = $process.ExitCode }
return @{
ExitCode = $code
TimedOut = (-not $exited)
Output = ($stdout + "`n" + $stderr)
}
}
function Write-Tail([string]$Text, [int]$Count = 40) {
$lines = $Text -split "`r?`n"
if ($lines.Count -gt $Count) { $lines = $lines[($lines.Count - $Count)..($lines.Count - 1)] }
Write-Host "----- output (last $Count lines) -----"
foreach ($line in $lines) { Write-Host $line }
Write-Host "-------------------------------------"
}
function Test-Log([string]$Text, [string]$Pattern) {
# Case-sensitive on purpose: test summaries print "fails=0", which must not
# be mistaken for the uppercase FAIL marker.
return [bool]([regex]::IsMatch($Text, $Pattern))
}
$Tmp = Join-Path ([System.IO.Path]::GetTempPath()) ("peak-hour-tests-" + [guid]::NewGuid().ToString("N"))
$LogDir = Join-Path $Tmp "logs"
$OldAppData = $env:APPDATA
New-Item -ItemType Directory -Path $Tmp -Force | Out-Null
New-Item -ItemType Directory -Path $LogDir -Force | Out-Null
$env:APPDATA = Join-Path $Tmp "appdata"
New-Item -ItemType Directory -Path $env:APPDATA -Force | Out-Null
$Passed = 0
$Failed = 0
$Skipped = @()
$FailedNames = @()
Write-Host "PEAK HOUR 97 - headless tests (v2, Windows)"
Write-Host "Godot: $GodotBin"
Write-Host "APPDATA (temp): $env:APPDATA"
Write-Host ""
try {
foreach ($script in $Tests) {
$localPath = Join-Path $Root ($script -replace '^res://', '' -replace '/', '\')
$name = [System.IO.Path]::GetFileNameWithoutExtension($script)
if (-not (Test-Path -LiteralPath $localPath -PathType Leaf)) {
$Skipped += $name
Write-Host "SKIP $name (no file)"
continue
}
Write-Host ""
Write-Host ">>> $script"
$godotArgs = "--headless --path $(Quote-Arg $Root) --script $(Quote-Arg $script)"
$result = Invoke-Godot $godotArgs $TimeoutSec
$logPath = Join-Path $LogDir ($name + ".log")
Set-Content -LiteralPath $logPath -Value $result.Output -Encoding UTF8
if ($result.TimedOut) {
Write-Host "FAIL $name (timeout ${TimeoutSec}s)"
Write-Tail $result.Output
$Failed++
$FailedNames += $name
continue
}
if ($result.ExitCode -eq 0) {
if (Test-Log $result.Output $ErrorRe) {
Write-Host "FAIL $name (exit 0, but errors in log)"
Write-Tail $result.Output
$Failed++
$FailedNames += $name
} else {
Write-Host "PASS $name"
$Passed++
}
continue
}
if (Test-Log $result.Output $ErrorRe) {
Write-Host "FAIL $name (exit $($result.ExitCode))"
Write-Tail $result.Output
$Failed++
$FailedNames += $name
continue
}
if ((Test-Log $result.Output $LeakRe) -and -not (Test-Log $result.Output 'ERROR:')) {
Write-Host "PASS $name (exit $($result.ExitCode), harmless ObjectDB leak warning)"
$Passed++
continue
}
Write-Host "FAIL $name (exit $($result.ExitCode))"
Write-Tail $result.Output
$Failed++
$FailedNames += $name
}
Write-Host ""
Write-Host ">>> parse check: --headless --path . --quit"
$parseArgs = "--headless --path $(Quote-Arg $Root) --quit"
$parse = Invoke-Godot $parseArgs $TimeoutSec
Set-Content -LiteralPath (Join-Path $LogDir "parse_check.log") -Value $parse.Output -Encoding UTF8
$parseOk = $false
if (-not $parse.TimedOut) {
if ($parse.ExitCode -eq 0) {
$parseOk = $true
} elseif ((Test-Log $parse.Output $LeakRe) -and -not (Test-Log $parse.Output 'ERROR:')) {
$parseOk = $true
}
}
if ($parseOk) {
Write-Host "PASS parse-check"
$Passed++
} else {
Write-Host "FAIL parse-check (exit $($parse.ExitCode))"
Write-Tail $parse.Output
$Failed++
$FailedNames += "parse-check"
}
}
finally {
$env:APPDATA = $OldAppData
try {
Remove-Item -LiteralPath $Tmp -Recurse -Force -ErrorAction SilentlyContinue
} catch { }
}
Write-Host ""
Write-Host "================================"
Write-Host "Total: PASS=$Passed FAIL=$Failed SKIP=$($Skipped.Count)"
if ($Skipped.Count -gt 0) {
Write-Host "Skipped: $($Skipped -join ', ')"
}
if ($Failed -ne 0) {
Write-Host "Failed: $($FailedNames -join ', ')" -ForegroundColor Red
exit 1
}
Write-Host "All tests passed." -ForegroundColor Green
exit 0
+39 -16
View File
@@ -1,18 +1,18 @@
#!/usr/bin/env bash
# run_all_tests.sh — прогон всех headless-тестов PEAK HOUR 97.
# run_all_tests.sh — прогон всех headless-тестов PEAK HOUR 97 (v2).
#
# Запуск:
# bash scripts/run_all_tests.sh
# GODOT_BIN=/path/to/godot bash scripts/run_all_tests.sh
# GODOT_BIN="/d/Apps/godot/Godot_v4.7.2-stable_win64_console.exe" bash scripts/run_all_tests.sh
#
# GODOT_BIN имеет приоритет. Если он не задан и Linux-путь по умолчанию
# отсутствует, скрипт пробует `godot4`/`godot` из PATH.
#
# Каждый тест — отдельный процесс Godot с таймаутом 180 с. XDG_DATA_HOME
# выносится во временный каталог, чтобы тесты не трогали пользовательские
# данные и не мешали параллельным прогонам. В конце — проверка парсинга
# проекта (`--quit`).
#
# Известный безвредный шум Godot при выходе (ObjectDB instances leaked /
# leaked at exit) не считается провалом: тест, завершившийся с таким
# предупреждением и без реальных ошибок, помечается PASS.
# выносится во временный каталог. В конце — проверка парсинга проекта.
# Отсутствующие файлы тестов пропускаются (SKIP), а не считаются FAIL:
# волны разработки иногда удаляют модули.
set -euo pipefail
ROOT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/.." && pwd)"
@@ -21,8 +21,17 @@ cd "$ROOT_DIR"
GODOT_BIN="${GODOT_BIN:-/home/eq/techlb/tools/godot/Godot_v4.7.2-stable_linux.x86_64}"
TEST_TIMEOUT="${TEST_TIMEOUT:-180}"
if [[ ! -x "$GODOT_BIN" ]]; then
for candidate in godot4 godot; do
if command -v "$candidate" >/dev/null 2>&1; then
GODOT_BIN="$(command -v "$candidate")"
break
fi
done
fi
if [[ ! -x "$GODOT_BIN" ]]; then
echo "FAIL: Godot binary is not executable: $GODOT_BIN" >&2
echo "Set GODOT_BIN=... explicitly." >&2
exit 1
fi
@@ -33,18 +42,22 @@ mkdir -p "$XDG_DATA_HOME" "$LOG_DIR"
export XDG_DATA_HOME
cleanup() {
# Осторожная уборка без rm -rf: только внутри созданного mktemp-каталога.
find "$TMP_BASE" -mindepth 1 -delete 2>/dev/null || true
rmdir "$TMP_BASE" 2>/dev/null || true
}
trap cleanup EXIT
# Aggregator + all standalone v2 suites. New wave-D1 suites are included.
TESTS=(
"res://tests/run_tests.gd"
"res://tests/test_hour.gd"
"res://tests/test_slots.gd"
"res://tests/test_shop.gd"
"res://tests/test_minigame.gd"
"res://tests/test_send_games.gd"
"res://tests/test_attack_games.gd"
"res://tests/test_file_grid.gd"
"res://tests/test_shop_modal.gd"
"res://tests/test_audio.gd"
"res://tests/test_ambient.gd"
"res://tests/test_fuzz.gd"
@@ -63,7 +76,17 @@ TESTS=(
"res://scripts/ui/ui_smoke.gd"
)
# Какие строки означают настоящую ошибку (а не безвредный leak-шум).
EXISTING_TESTS=()
SKIPPED_NAMES=()
for t in "${TESTS[@]}"; do
local_path="${ROOT_DIR}/${t#res://}"
if [[ -f "$local_path" ]]; then
EXISTING_TESTS+=("$t")
else
SKIPPED_NAMES+=("$(basename "$t" .gd)")
fi
done
ERROR_RE='SCRIPT ERROR|Parse Error|USER ERROR|Failed to load|Cannot open file|FAIL|SMOKE FAILED'
LEAK_RE='ObjectDB instances leaked|leaked at exit'
@@ -102,7 +125,6 @@ run_one() {
fi
if [[ "$rc" -eq 0 ]]; then
# Godot может вернуть 0, но напечатать SCRIPT ERROR/Parse Error в лог.
if grep -qE "$ERROR_RE" "$log"; then
echo "FAIL $name (exit 0, но в логе есть ошибки)"
print_log_tail "$log"
@@ -115,7 +137,6 @@ run_one() {
return 0
fi
# Ненулевой код: отличаем реальную ошибку от известного leak-шума.
if grep -qE "$ERROR_RE" "$log"; then
echo "FAIL $name (exit $rc, ${elapsed}s)"
print_log_tail "$log"
@@ -136,11 +157,14 @@ run_one() {
FAILED_NAMES+=("$name")
}
echo "PEAK HOUR 97 — headless tests"
echo "PEAK HOUR 97 — headless tests (v2)"
echo "Godot: $GODOT_BIN"
echo "XDG_DATA_HOME: $XDG_DATA_HOME"
if [[ ${#SKIPPED_NAMES[@]} -gt 0 ]]; then
echo "SKIP (нет файлов): ${SKIPPED_NAMES[*]}"
fi
for t in "${TESTS[@]}"; do
for t in "${EXISTING_TESTS[@]}"; do
run_one "$t"
done
@@ -165,11 +189,10 @@ fi
echo ""
echo "================================"
echo "Итог: PASS=$PASSED FAIL=$FAILED"
echo "Итог: PASS=$PASSED FAIL=$FAILED SKIP=${#SKIPPED_NAMES[@]}"
if [[ "$FAILED" -ne 0 ]]; then
echo "Провалены: ${FAILED_NAMES[*]}"
echo "Логи: $LOG_DIR"
# Сохраняем логи провалов рядом с прогоном для разбора в CI.
if [[ -n "${CI:-}" ]]; then
mkdir -p "$ROOT_DIR/build/test-logs"
for n in "${FAILED_NAMES[@]}"; do
File diff suppressed because it is too large. Load diff
+4 -2
View File
@@ -8,7 +8,9 @@ extends RefCounted
const SAVE_PATH := "user://save.json"
const TMP_PATH := "user://save.tmp"
const VERSION := 1
## v2: клеточные сетки/фигуры. v1 читается для миграции (Game/FileRegistry).
const VERSION := 2
const SUPPORTED_VERSIONS: Array[int] = [1, 2]
static func save(data: Dictionary) -> bool:
@@ -70,7 +72,7 @@ static func _read_save() -> Dictionary:
return {}
var data: Dictionary = parsed
var version := int(data.get("version", -1))
if version != VERSION:
if not SUPPORTED_VERSIONS.has(version):
push_warning("SaveManager: версия save %d не поддерживается (ожидается %d)" % [version, VERSION])
return {}
return data
+470 -134
View File
@@ -1,119 +1,103 @@
class_name Shop
extends RefCounted
## Магазин: 10 позиций из data/balance.json → shop (D13 + SLA +24, п.13).
## Магазин v2: 8 семейств лотов, ровно 3 предложения, реролл 40/80/120
## (максимум 3 за подготовку) и замок отдельного предложения.
##
## Уровни покупок хранятся в Game.upgrades. Для совместимости с ядром
## дублируются агрегированные ключи эффектов: slot_level, buffer_level,
## timer_level, zone_level, channel_level, sections_unlocked,
## search_unlocked, shield_unlocked.
## Уровни покупок хранятся в Game.upgrades под id лота и под агрегированным
## ключом эффекта (slot_level, speed_level, ...). Эффекты применяет Game
## (set_lot_level + динамические геттеры), магазин только списывает деньги и
## повышает уровень.
signal offers_changed
signal purchased(id: String)
const EFFECT_TARGETS := {
"connection_slots": "slot_level",
"buffer_slots": "buffer_level",
"packet_timer": "timer_level",
"zone_width": "zone_level",
"transfer_speed": "speed_level",
"sla_cap": "sla_level",
"shield_charge": "shield_level",
"money_mult": "money_level",
"folder_cells": "folder_level",
"channel_capacity": "channel_level",
"sections": "sections_unlocked",
"search_highlight": "search_unlocked",
"shield": "shield_unlocked",
"sla_add": "sla_level",
"request_timer": "timer_level",
}
const DEFAULT_RULES := {
"offers": 3,
"reroll_costs": [40, 80, 120],
"max_rerolls": 3,
"last_offer_weight": 0.5,
"pity_hours": 2,
"pity_weight": 1.5,
}
## Резерв, если data/balance.json недоступен. Совпадает с §3.2/3.3.
const FALLBACK_ITEMS := [
{"id": "shop_slot3", "name_ru": "Третий слот", "effect": "connection_slots", "value": 1, "price": 250, "max_level": 1},
{"id": "shop_slot4", "name_ru": "Четвёртый слот", "effect": "connection_slots", "value": 1, "price": 450, "max_level": 1},
{"id": "shop_buffer", "name_ru": "Буфер +2", "effect": "buffer_slots", "value": 2, "price": 200, "max_level": 2},
{"id": "shop_timer", "name_ru": "Таймер пакетов +4 с", "effect": "packet_timer", "value": 4, "price": 350, "max_level": 3},
{"id": "shop_zone", "name_ru": "Зона +5%", "effect": "zone_width", "value": 0.05, "price": 300, "max_level": 3},
{"id": "shop_channel", "name_ru": "Канал +25", "effect": "channel_capacity", "value": 25, "price": 250, "max_level": 4},
{"id": "shop_section", "name_ru": "Новая секция", "effect": "sections", "value": 1, "price": 300, "max_level": 5},
{"id": "shop_search", "name_ru": "Поиск", "effect": "search_highlight", "value": 1, "price": 250, "max_level": 1},
{"id": "shop_shield", "name_ru": "Щит", "effect": "shield", "value": 1, "price": 400, "max_level": 1},
{"id": "shop_sla", "name_ru": "SLA +24", "effect": "sla_add", "value": 24, "price": 300, "max_level": 3},
{"id": "lot_slots", "name_ru": "Порты коммутатора", "effect": "connection_slots", "value": 1,
"max_level": 4, "prices": [90, 200, 400, 700],
"weight_by_hour": [1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 0.8, 0.8, 0.8]},
{"id": "lot_speed", "name_ru": "Оптоволокно", "effect": "transfer_speed", "value": 0.10,
"max_level": 6, "prices": [55, 75, 100, 135, 180, 245],
"weight_by_hour": [1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 0.8, 0.8, 0.8]},
{"id": "lot_sla", "name_ru": "Резервный ИБП", "effect": "sla_cap", "value": 40,
"max_level": 4, "prices": [80, 160, 270, 430],
"weight_by_hour": [0.8, 0.8, 0.8, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0]},
{"id": "lot_shield", "name_ru": "Заземление", "effect": "shield_charge", "value": 1,
"max_level": 3, "prices": [100, 220, 410],
"weight_by_hour": [0.3, 0.3, 0.3, 0.8, 0.8, 0.8, 1.0, 1.0, 1.0]},
{"id": "lot_money", "name_ru": "Корпоративный контракт", "effect": "money_mult", "value": 0.05,
"max_level": 8, "prices": [70, 95, 130, 170, 220, 285, 365, 465],
"weight_by_hour": [0.6, 0.6, 0.6, 1.0, 1.0, 1.0, 1.2, 1.2, 1.2]},
{"id": "lot_folder", "name_ru": "Складской стеллаж", "effect": "folder_cells", "value": 1,
"max_level": 5, "prices": [50, 100, 180, 290, 440],
"weight_by_hour": [1.0, 1.0, 1.0, 0.9, 0.9, 0.9, 0.8, 0.8, 0.8]},
{"id": "lot_channel", "name_ru": "Пропускная способность", "effect": "channel_capacity", "value": 6,
"buffer_value": 1, "max_level": 6, "prices": [45, 70, 95, 130, 175, 230],
"weight_by_hour": [1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 0.9, 0.9, 0.9]},
{"id": "lot_timer", "name_ru": "Регламент обработки", "effect": "request_timer", "value": 7,
"max_level": 5, "prices": [45, 75, 115, 165, 230],
"weight_by_hour": [1.0, 1.0, 1.0, 0.9, 0.9, 0.9, 0.7, 0.7, 0.7]},
]
var _items: Array = []
func _init() -> void:
refresh()
_load_items()
func refresh() -> void:
_items = []
var data = _data_node()
var raw = null
if data != null and data.get("balance") is Dictionary:
raw = data.balance.get("shop", null)
if raw is Array and not raw.is_empty():
for item in raw:
if item is Dictionary and not str(item.get("id", "")).is_empty():
_items.append(item.duplicate(true))
if _items.is_empty():
for item in FALLBACK_ITEMS:
_items.append(item.duplicate(true))
# --- каталог ---------------------------------------------------------------
func items() -> Array:
return _items.duplicate(true)
## Каталог без закрытых прогрессией позиций. shop_sla доступен всегда.
func available_items() -> Array:
var out: Array = []
for item in _items:
if item is Dictionary and item_unlocked(item):
out.append(item.duplicate(true))
return out
## Открыта ли позиция: shop_sla всегда; shop_search/shield — по мета-награде;
## остальные — по открытию магазина (progression.unlocks.shop).
func item_unlocked(item: Dictionary) -> bool:
var key := _unlock_key(str(item.get("id", "")))
if key.is_empty():
return true
var game = _game()
if game != null and game.has_method("feature_unlocked"):
return bool(game.feature_unlocked(key))
return true
func item_count() -> int:
return _items.size()
func get_item(id: String) -> Dictionary:
for item in _items:
if str(item.get("id", "")) == str(id):
if item is Dictionary and str(item.get("id", "")) == str(id):
return item.duplicate(true)
return {}
## Позиции каталога без гейта прогрессии (v2: все лоты доступны с H2,
## видимость магазина решает фаза/час).
func available_items() -> Array:
return _items.duplicate(true)
func item_unlocked(_item: Dictionary) -> bool:
return true
func level(id: String) -> int:
var game = _game()
if game == null:
return 0
var upgrades: Dictionary = game.upgrades if game.upgrades is Dictionary else {}
if upgrades.has(str(id)):
return int(upgrades[str(id)])
var item := get_item(id)
if item.is_empty():
return 0
var effect := str(item.get("effect", ""))
var target := str(EFFECT_TARGETS.get(effect, ""))
var aggregate := 0
if not target.is_empty():
aggregate = int(upgrades.get(target, 0))
# Для двух слотовых товаров агрегат slot_level раскладывается по порядку
# с учётом уже купленного второго товара.
if effect == "connection_slots":
var other_id := "shop_slot4" if id == "shop_slot3" else "shop_slot3"
var other_level := int(upgrades.get(other_id, 0))
return clampi(aggregate - other_level, 0, 1)
return clampi(aggregate, 0, max_level(id))
if game != null and game.has_method("level"):
return int(game.level(id))
return level_for(get_item(id), _levels(game))
func max_level(id: String) -> int:
@@ -123,64 +107,54 @@ func max_level(id: String) -> int:
return maxi(1, int(item.get("max_level", 1)))
## Цена следующего уровня. -1, если лот максимален (никогда не индексируем
## prices[max_level]).
func price(id: String) -> int:
var item := get_item(id)
if item.is_empty():
return 0
var current := level(id)
var prices = item.get("prices", null)
if prices is Array and not (prices as Array).is_empty():
var arr: Array = prices
return maxi(0, int(arr[mini(current, arr.size() - 1)]))
if item.has("price"):
return maxi(0, int(item.get("price", 0)))
return maxi(0, int(item.get("cost", 0)))
return price_for(item, level(id))
func can_buy(id: String) -> bool:
var item := get_item(id)
if item.is_empty():
return false
if not item_unlocked(item):
return false
if level(id) >= max_level(id):
return false
var game = _game()
if game == null:
return false
return int(game.money) >= price(id)
if not offers().has(str(id)):
return false
var item := get_item(id)
if item.is_empty():
return false
if level(id) >= max_level(id):
return false
var cost := price(id)
if cost < 0:
return false
return int(game.money) >= cost
func buy(id: String) -> bool:
if not can_buy(id):
return false
var item := get_item(id)
var game = _game()
if game == null:
return false
var cost := price(id)
if cost < 0:
return false
game.money = maxi(0, int(game.money) - cost)
if game.has_signal("money_changed"):
game.money_changed.emit(game.money)
if not (game.upgrades is Dictionary):
game.upgrades = {}
var current := level(id)
game.upgrades[id] = current + 1
var effect := str(item.get("effect", ""))
var target := str(EFFECT_TARGETS.get(effect, ""))
if not target.is_empty():
game.upgrades[target] = int(game.upgrades.get(target, 0)) + 1
if effect == "sections":
_unlock_section(game)
elif effect == "sla_add":
# SLA +24 за уровень; Game.add_sla сам клампит до MAX_SLA.
var value := float(item.get("value", 24))
if game.has_method("add_sla"):
game.add_sla(value, "shop")
var new_level := level(id) + 1
if game.has_method("set_lot_level"):
game.set_lot_level(id, new_level)
else:
_apply_level_fallback(game, id, new_level)
_play_sfx(&"money_down")
purchased.emit(id)
offers_changed.emit()
if game.has_signal("toast"):
game.toast.emit("Куплено: %s" % str(item.get("name_ru", id)))
game.toast.emit("Куплено: %s" % str(get_item(id).get("name_ru", id)))
return true
@@ -189,37 +163,327 @@ func effect_level(effect: String) -> int:
var game = _game()
if game == null:
return 0
var target := str(EFFECT_TARGETS.get(effect, ""))
var target := str(EFFECT_TARGETS.get(str(effect), ""))
if target.is_empty():
return 0
return int(game.upgrades.get(target, 0))
return int(_levels(game).get(target, 0))
# --- v2: предложения/реролл -----------------------------------------------
func offers() -> Array[String]:
var out: Array[String] = []
var game = _game()
if game == null:
return out
for id in game.shop_offers:
out.append(str(id))
return out
## Сгенерировать 3 предложения на подготовку. Идемпотентно: повторный вызов
## для того же часа ничего не меняет, пока force = true.
func refresh(hour: int = 0, money: int = -1, force: bool = false) -> void:
_load_items()
if hour <= 0:
return
var game = _game()
if game == null:
return
var h := maxi(1, int(hour))
if not force and int(game.shop_offer_hour) == h and not game.shop_offers.is_empty():
offers_changed.emit()
return
var m := int(money) if money >= 0 else int(game.money)
var previous: Array = []
for id in game.shop_offers:
previous.append(str(id))
game.shop_offer_hour = h
game.shop_rerolls = 0
game.shop_locked.clear()
var selected := select_offers(h, _items, _levels(game), m, previous,
_seen(game), [], _offer_count())
_write_offers(game, selected, h)
offers_changed.emit()
func reroll_cost() -> int:
var game = _game()
if game == null:
return -1
var max_rerolls := int(_rules().get("max_rerolls", 3))
if int(game.shop_rerolls) >= max_rerolls:
return -1
if _non_maxed_count(_levels(game)) <= 0:
return -1
return _cost_for(int(game.shop_rerolls))
func can_reroll() -> bool:
var game = _game()
if game == null:
return false
if game.shop_offers.is_empty():
return false
var cost := reroll_cost()
if cost < 0:
return false
return int(game.money) >= cost
## Реролл сохраняет закреплённые предложения. Стоимость 40/80/120.
func reroll() -> bool:
if not can_reroll():
return false
var game = _game()
if game == null:
return false
var cost := reroll_cost()
if cost < 0:
return false
game.money = maxi(0, int(game.money) - cost)
if game.has_signal("money_changed"):
game.money_changed.emit(game.money)
game.shop_rerolls = int(game.shop_rerolls) + 1
var h := maxi(1, int(game.hour))
var previous: Array = []
for id in game.shop_offers:
previous.append(str(id))
var locked: Array = []
for id in game.shop_locked:
locked.append(str(id))
var selected := select_offers(h, _items, _levels(game), int(game.money), previous,
_seen(game), locked, _offer_count())
game.shop_offer_hour = h
_write_offers(game, selected, h)
offers_changed.emit()
return true
## Переключить замок предложения. Возвращает новое состояние (true = locked).
func toggle_lock(index: int) -> bool:
var game = _game()
if game == null:
return false
var ofs := offers()
if index < 0 or index >= ofs.size():
return false
var id := ofs[index]
if game.shop_locked.has(id):
game.shop_locked.erase(id)
else:
game.shop_locked.append(id)
offers_changed.emit()
return game.shop_locked.has(id)
func is_locked(index: int) -> bool:
var game = _game()
if game == null:
return false
var ofs := offers()
if index < 0 or index >= ofs.size():
return false
return game.shop_locked.has(ofs[index])
func locked_ids() -> Array[String]:
var out: Array[String] = []
var game = _game()
if game == null:
return out
for id in game.shop_locked:
out.append(str(id))
return out
# --- v2: генерация (статические, тестируемые) ------------------------------
## Вес лота: база по часу × 0.5 за прошлое предложение × 1.5 за pity.
static func lot_weight(lot: Dictionary, hour: int, last_offers: Array, last_seen: Dictionary) -> float:
var id := str(lot.get("id", ""))
var w := 1.0
var arr = lot.get("weight_by_hour", null)
if arr is Array and not (arr as Array).is_empty():
var table: Array = arr
w = float(table[clampi(int(hour) - 1, 0, table.size() - 1)])
var rules: Dictionary = DEFAULT_RULES
if last_offers.has(id):
w *= float(rules.get("last_offer_weight", 0.5))
if last_seen is Dictionary and last_seen.has(id):
var since := int(hour) - int(last_seen[id])
if since >= int(rules.get("pity_hours", 2)):
w *= float(rules.get("pity_weight", 1.5))
return maxf(0.0, w)
## Цена следующего уровня лота. -1 при максимальном уровне.
static func price_for(lot: Dictionary, lvl: int) -> int:
if lot.is_empty():
return 0
var max_level := maxi(1, int(lot.get("max_level", 1)))
if lvl >= max_level:
return -1
var prices = lot.get("prices", null)
if prices is Array and not (prices as Array).is_empty():
var arr: Array = prices
return maxi(0, int(arr[clampi(lvl, 0, arr.size() - 1)]))
if lot.has("price"):
return maxi(0, int(lot.get("price", 0)))
return maxi(0, int(lot.get("cost", 0)))
## Уровень лота из словаря upgrades: id лота → агрегат эффекта → 0.
static func level_for(lot: Dictionary, levels: Dictionary) -> int:
if lot.is_empty():
return 0
var id := str(lot.get("id", ""))
var max_level := maxi(1, int(lot.get("max_level", 1)))
if levels.has(id):
return clampi(int(levels[id]), 0, max_level)
var effect := str(lot.get("effect", ""))
var agg := str(EFFECT_TARGETS.get(effect, ""))
if not agg.is_empty() and levels.has(agg):
return clampi(int(levels[agg]), 0, max_level)
return 0
## Выбрать до count различных не-максимальных лотов.
## must_keep — закреплённые id (сохраняются в выдаче).
static func select_offers(
hour: int,
lots: Array,
levels: Dictionary,
money: int,
last_offers: Array,
last_seen: Dictionary,
must_keep: Array = [],
count: int = 3,
rand_source = null
) -> Array[String]:
var h := maxi(1, int(hour))
var lot_map: Dictionary = {}
for lot in lots:
if lot is Dictionary:
var id := str(lot.get("id", ""))
if not id.is_empty():
lot_map[id] = lot
var result: Array[String] = []
for raw_id in must_keep:
var kept := str(raw_id)
if result.has(kept) or not lot_map.has(kept):
continue
var kept_lot: Dictionary = lot_map[kept]
if level_for(kept_lot, levels) >= maxi(1, int(kept_lot.get("max_level", 1))):
continue
result.append(kept)
var pool: Array = []
for lot in lots:
if not (lot is Dictionary):
continue
var id := str(lot.get("id", ""))
if id.is_empty() or result.has(id):
continue
if level_for(lot, levels) >= maxi(1, int(lot.get("max_level", 1))):
continue
pool.append(lot)
var target := mini(maxi(0, int(count)), result.size() + pool.size())
while result.size() < target and not pool.is_empty():
var picked_index := _pick_weighted(pool, h, last_offers, last_seen, rand_source)
var picked: Dictionary = pool[picked_index]
result.append(str(picked.get("id", "")))
pool.remove_at(picked_index)
# Хотя бы одно доступное по деньгам предложение (если такое существует).
if money >= 0 and not _any_affordable(result, lot_map, levels, money):
_try_swap_affordable(result, lots, lot_map, levels, money, must_keep)
return result
# --- служебное -------------------------------------------------------------
## Ключ открытия позиции. Данные — из Data, иначе локальная копия правила.
func _unlock_key(id: String) -> String:
func _load_items() -> void:
_items = []
var data = _data_node()
if data != null and data.has_method("shop_unlock_key"):
return str(data.shop_unlock_key(id))
match id:
"shop_sla":
return ""
"shop_search":
return "search"
"shop_shield":
return "shield"
_:
return "shop"
if data != null and data.has_method("lot_defs"):
var raw = data.lot_defs()
if raw is Array and not (raw as Array).is_empty():
for item in raw:
if item is Dictionary and not str(item.get("id", "")).is_empty():
_items.append((item as Dictionary).duplicate(true))
if _items.is_empty():
for item in FALLBACK_ITEMS:
_items.append((item as Dictionary).duplicate(true))
func _unlock_section(game) -> void:
if game.registry == null or not game.registry.has_method("create_section"):
return
for letter in ["D", "E", "F", "G", "H"]:
if not game.registry.sections.has(letter):
game.registry.create_section(letter)
return
func _write_offers(game, ids: Array, hour: int) -> void:
game.shop_offers.clear()
for id in ids:
game.shop_offers.append(str(id))
if not (game.shop_last_seen is Dictionary):
game.shop_last_seen = {}
for id in game.shop_offers:
game.shop_last_seen[str(id)] = int(hour)
func _apply_level_fallback(game, id: String, new_level: int) -> void:
if not (game.upgrades is Dictionary):
game.upgrades = {}
game.upgrades[id] = new_level
var effect := str(get_item(id).get("effect", ""))
var target := str(EFFECT_TARGETS.get(effect, ""))
if not target.is_empty():
game.upgrades[target] = new_level
func _offer_count() -> int:
return maxi(1, int(_rules().get("offers", 3)))
func _cost_for(reroll_index: int) -> int:
var costs = _rules().get("reroll_costs", [40, 80, 120])
if costs is Array and not (costs as Array).is_empty():
var arr: Array = costs
return maxi(0, int(arr[clampi(int(reroll_index), 0, arr.size() - 1)]))
return 40
func _non_maxed_count(levels: Dictionary) -> int:
var count := 0
for lot in _items:
if not (lot is Dictionary):
continue
if level_for(lot, levels) < maxi(1, int(lot.get("max_level", 1))):
count += 1
return count
func _rules() -> Dictionary:
var data = _data_node()
var out: Dictionary = DEFAULT_RULES.duplicate(true)
if data != null and data.has_method("shop_rules"):
var raw = data.shop_rules()
if raw is Dictionary and not (raw as Dictionary).is_empty():
for key in raw.keys():
out[str(key)] = raw[key]
return out
func _seen(game) -> Dictionary:
if game == null or not (game.shop_last_seen is Dictionary):
return {}
return game.shop_last_seen
func _levels(game) -> Dictionary:
if game == null:
return {}
if game.upgrades is Dictionary:
return game.upgrades
return {}
func _game():
@@ -243,3 +507,75 @@ func _play_sfx(id: StringName) -> void:
var audio = (loop as SceneTree).root.get_node_or_null("Audio")
if audio != null and audio.has_method("play_sfx"):
audio.play_sfx(str(id))
# --- статические хелперы выбора -------------------------------------------
static func _pick_weighted(pool: Array, hour: int, last_offers: Array, last_seen: Dictionary, rand_source) -> int:
var weights: Array = []
var total := 0.0
for lot in pool:
var w := lot_weight(lot, hour, last_offers, last_seen)
weights.append(w)
total += w
if total <= 0.0:
return int(float(_randf(rand_source)) * float(pool.size())) % pool.size()
var roll := _randf(rand_source) * total
var acc := 0.0
for i in range(weights.size()):
acc += float(weights[i])
if roll < acc:
return i
return pool.size() - 1
static func _any_affordable(ids: Array, lot_map: Dictionary, levels: Dictionary, money: int) -> bool:
for raw_id in ids:
var id := str(raw_id)
if not lot_map.has(id):
continue
var p := price_for(lot_map[id], level_for(lot_map[id], levels))
if p >= 0 and p <= money:
return true
return false
static func _try_swap_affordable(ids: Array, lots: Array, lot_map: Dictionary, levels: Dictionary, money: int, must_keep: Array) -> void:
var candidate := ""
var best := 2147483647
for lot in lots:
if not (lot is Dictionary):
continue
var id := str(lot.get("id", ""))
if id.is_empty() or ids.has(id):
continue
var lvl := level_for(lot, levels)
if lvl >= maxi(1, int(lot.get("max_level", 1))):
continue
var p := price_for(lot, lvl)
if p >= 0 and p <= money and p < best:
best = p
candidate = id
if candidate.is_empty():
return
# Заменяем самое дорогое незакреплённое предложение.
var swap_index := -1
var worst := -1
for i in range(ids.size()):
var id := str(ids[i])
if must_keep.has(id):
continue
if not lot_map.has(id):
continue
var p := price_for(lot_map[id], level_for(lot_map[id], levels))
if p > worst:
worst = p
swap_index = i
if swap_index >= 0:
ids[swap_index] = candidate
static func _randf(rand_source) -> float:
if rand_source != null and rand_source.has_method("randf"):
return clampf(float(rand_source.randf()), 0.0, 0.999999)
return clampf(randf(), 0.0, 0.999999)
+536 -39
View File
@@ -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
+200
View File
@@ -0,0 +1,200 @@
class_name DragController
extends Node
## Центральный контроллер перетаскивания: держит одно превью (FileShape) и
## его режим (shape | square), двигает превью за мышью и отсчитывает 250 мс
## наведения на папку БЕЗ Timer-узлов.
##
## Владелец — Main. Компоненты находят его через группу "drag_controller"
## или через свойство drag_controller.
##
## ВАЖНО: при отсутствии превью контроллер не должен падать — все вызовы
## проверяют is_instance_valid.
signal hover_open(folder_id: String)
signal drag_ended
## Пользователь повернул фигуру (R / ПКМ). Слушают все сетки, чтобы ghost
## обновлялся даже у той, которая не получила событие ввода.
signal rotation_changed(rotation: int)
const HOVER_DELAY := 0.25
const PREVIEW_OFFSET := Vector2(9, 9)
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
func _ready() -> void:
if not is_in_group("drag_controller"):
add_to_group("drag_controller")
set_process(false)
func begin(preview_control: Control, p_mode: String = "shape",
p_initial_rotation: int = 0) -> void:
end()
_preview = preview_control
_mode = _normalize(p_mode)
# Пакет создаёт превью со своим поворотом, но зовёт begin() без третьего
# аргумента. Берём поворот из превью, чтобы drop не сбрасывал его в 0.
var seeded := posmod(int(p_initial_rotation), 4)
if seeded == 0 and is_instance_valid(preview_control):
var preview_rotation = preview_control.get("shape_rotation")
if preview_rotation != null:
seeded = posmod(int(preview_rotation), 4)
_rotation = seeded
_rotate_frame = -1
if is_instance_valid(_preview):
_preview.mouse_filter = Control.MOUSE_FILTER_IGNORE
_preview.top_level = true
_preview.z_index = 200
if _preview.get_parent() == null:
add_child(_preview)
if _preview.has_method("set_mode"):
_preview.set_mode(_mode)
else:
_preview.set("mode", _mode)
_preview.visible = true
_sync_preview_rotation()
_preview.queue_redraw()
_place()
set_process(true)
func set_mode(p_mode: String) -> void:
var next := _normalize(p_mode)
_mode = next
if is_instance_valid(_preview):
if _preview.has_method("set_mode"):
_preview.set_mode(next)
else:
_preview.set("mode", next)
_preview.queue_redraw()
func end() -> void:
var had := is_instance_valid(_preview) or not _hover_id.is_empty()
if is_instance_valid(_preview):
_preview.visible = false
var parent := _preview.get_parent()
if parent != null:
parent.remove_child(_preview)
_preview.queue_free()
_preview = null
_rotation = 0
_rotate_frame = -1
reset_hover()
if had:
drag_ended.emit()
set_process(false)
func preview() -> Control:
return _preview if is_instance_valid(_preview) else null
func mode() -> String:
return _mode
# --- поворот ---------------------------------------------------------------
## Текущий поворот фигуры (0..3). Авторитетное состояние на время drag.
func rotation() -> int:
return _rotation
func set_rotation(value: int) -> void:
var next := posmod(int(value), 4)
if next == _rotation:
return
_rotation = next
_sync_preview_rotation()
rotation_changed.emit(_rotation)
## +90° по часовой. Повторный вызов в том же кадре игнорируется: R/ПКМ
## приходят и источнику, и цели drop, а повернуть нужно один раз.
func rotate_cw() -> int:
var frame := Engine.get_process_frames()
if _rotate_frame == frame:
return _rotation
_rotate_frame = frame
set_rotation(_rotation + 1)
return _rotation
## Есть ли активное превью — то есть drag действительно идёт.
func is_active() -> bool:
return is_instance_valid(_preview)
func _sync_preview_rotation() -> void:
if not is_instance_valid(_preview):
return
if _preview.has_method("setup"):
_preview.setup(str(_preview.get("file_type")), str(_preview.get("shape_id")),
_rotation, str(_preview.get("file_name")), str(_preview.get("ext")),
float(_preview.get("cell_size")))
if _preview.has_method("set_mode"):
_preview.set_mode(_mode)
else:
_preview.set("shape_rotation", _rotation)
_preview.queue_redraw()
# setup() меняет custom_minimum_size — превью должно остаться под курсором.
_place()
func hover_folder(folder_id: String) -> void:
if folder_id.is_empty():
reset_hover()
return
if folder_id == _hover_id:
# повторный вход в ту же папку не сбрасывает и не дублирует таймер
return
_hover_id = folder_id
_hover_left = HOVER_DELAY
_hover_fired = false
set_process(true)
func reset_hover() -> void:
_hover_id = ""
_hover_left = 0.0
_hover_fired = false
if not is_instance_valid(_preview):
set_process(false)
func hover_id() -> String:
return _hover_id
func _process(delta: float) -> void:
if not _hover_id.is_empty() and not _hover_fired:
_hover_left -= delta
if _hover_left <= 0.0:
_hover_fired = true
hover_open.emit(_hover_id)
if is_instance_valid(_preview) and _preview.visible:
_place()
if not is_instance_valid(_preview) and (_hover_id.is_empty() or _hover_fired):
set_process(false)
func _place() -> void:
if not is_instance_valid(_preview):
return
var vp := get_viewport()
if vp == null:
return
_preview.position = vp.get_mouse_position() - _preview.custom_minimum_size * 0.5 \
+ PREVIEW_OFFSET
func _normalize(value: String) -> String:
return "square" if value == "square" else "shape"
+1
View File
@@ -0,0 +1 @@
uid://cybnog1pa4ni4
+987 -257
View File
File diff suppressed because it is too large. Load diff
+557
View File
@@ -0,0 +1,557 @@
class_name FileShape
extends Control
## Одна фигура-полимино: превью drag, карточка пакета, стол сортировки.
##
## Рисуется без дочерних узлов: заливка занятых клеток, ОДИН внешний контур
## 2 px (внутренние границы не рисуются), подпись в верхней левой ЗАНЯТОЙ
## клетке. Тот же painter (`paint`) использует FileGrid — так превью и сетка
## выглядят одинаково.
##
## Режимы:
## shape — полимино по shape_id/rotation (для сетки и стола);
## square — одна клетка 1×1 (для карточки запроса: файл = квадрат).
##
## ВАЖНО: global class cache при `--script` может быть пустым, поэтому
## FileRegistry подключаем через preload.
const FR = preload("res://scripts/core/file_registry.gd")
const GAP := 0.0
var file_type: String = "text"
var shape_id: String = "O1"
var shape_rotation: int = 0
var file_name: String = ""
var ext: String = ""
var cell_size: float = 48.0
var mode: String = "shape"
var state: String = "stored"
var selected: bool = false
var hovered: bool = false
var show_label: bool = true
var alpha: float = 1.0
## Непрозрачная замена контура/заливки (ghost, ошибки). Alpha 0 — обычные.
var outline_override: Color = Color(0, 0, 0, 0)
var fill_override: Color = Color(0, 0, 0, 0)
func _init() -> void:
mouse_filter = Control.MOUSE_FILTER_IGNORE
_sync_size()
func setup(p_type: String, p_shape_id: String, p_rotation: int, p_name: String,
p_ext: String, p_cell: float) -> void:
file_type = p_type
shape_id = p_shape_id if not p_shape_id.is_empty() else "O1"
shape_rotation = posmod(int(p_rotation), 4)
file_name = p_name
ext = p_ext
cell_size = maxf(8.0, p_cell)
_sync_size()
func set_mode(value: String) -> void:
var next := "square" if value == "square" else "shape"
if mode == next:
return
mode = next
_sync_size()
queue_redraw()
func get_mode() -> String:
return mode
func set_state(value: String) -> void:
state = value
queue_redraw()
func set_selected(value: bool) -> void:
selected = value
queue_redraw()
func resolved_cells() -> Array:
if mode == "square":
return [Vector2i.ZERO]
return FR.shape_cells(shape_id, shape_rotation)
func _sync_size() -> void:
var cells := resolved_cells()
var b := FR.shape_bounds(cells)
var w := maxf(1.0, float(b.x) * cell_size)
var h := maxf(1.0, float(b.y) * cell_size)
custom_minimum_size = Vector2(w, h)
size = custom_minimum_size
func _draw() -> void:
var cells := resolved_cells()
paint(self, Vector2.ZERO, cells, cell_size, {
"type": file_type,
"name": file_name,
"ext": ext,
"state": state,
"selected": selected,
"hovered": hovered,
"alpha": alpha,
"show_label": show_label,
"gap": GAP,
"square": mode == "square",
"outline": outline_override,
"fill": fill_override,
})
# --- палитра ---------------------------------------------------------------
static func type_color(file_type: String) -> Color:
match file_type:
"sound":
return Theme90.WARN
"photo":
return Theme90.SUCCESS
"video":
return Theme90.ERROR
_:
return Theme90.ACCENT
static func ext_color(file_type: String, ext_value: String) -> Color:
var cat := Theme90.ext_category(ext_value)
if not cat.is_empty():
return Theme90.ext_color(cat)
return type_color(file_type)
static func _with_alpha(c: Color, a: float) -> Color:
return Color(c.r, c.g, c.b, c.a * a)
# --- геометрия -------------------------------------------------------------
## Рёбра внешнего контура в координатах клеток. Каждое ребро — словарь
## {"from": Vector2i-клетка, "dir": Vector2i-направление наружу}.
## Внутренние границы (сосед занят) не попадают в список.
static func outer_edges(cells: Array) -> Array:
var out: Array = []
var occupied := {}
for c in cells:
occupied[Vector2i(c)] = true
var dirs: Array[Vector2i] = [
Vector2i(1, 0), Vector2i(0, 1), Vector2i(-1, 0), Vector2i(0, -1),
]
for c in cells:
var p := Vector2i(c)
for d in dirs:
if not occupied.has(p + d):
out.append({"from": p, "dir": d})
return out
static func _cell_vec(value) -> Vector2:
if value is Vector2:
return value
var s := float(value)
return Vector2(s, s)
## Прямоугольник клетки в пикселях. gap расширяет заливку на половину зазора,
## чтобы соседние клетки одной фигуры сливались в цельное полимино.
## cell_px — float (квадрат) или Vector2 (прямоугольная клетка стола).
static func cell_pixel_rect(pos: Vector2, cell: Vector2i, cell_px, gap: float) -> Rect2:
var cs := _cell_vec(cell_px)
var step := cs + Vector2(gap, gap)
var p := pos + Vector2(float(cell.x) * step.x, float(cell.y) * step.y)
if gap > 0.0:
var e := gap * 0.5
return Rect2(p - Vector2(e, e), cs + Vector2(gap, gap))
return Rect2(p, cs)
## Отрезок внешнего ребра в пикселях.
static func edge_segment(pos: Vector2, from_cell: Vector2i, dir: Vector2i,
cell_px, gap: float) -> PackedVector2Array:
var cs := _cell_vec(cell_px)
var step := cs + Vector2(gap, gap)
var p := pos + Vector2(float(from_cell.x) * step.x, float(from_cell.y) * step.y)
var e := gap * 0.5 if gap > 0.0 else 0.0
var l := p.x - e
var t := p.y - e
var r := p.x + cs.x + e
var b := p.y + cs.y + e
if dir == Vector2i(1, 0):
return PackedVector2Array([Vector2(r, t), Vector2(r, b)])
if dir == Vector2i(-1, 0):
return PackedVector2Array([Vector2(l, t), Vector2(l, b)])
if dir == Vector2i(0, 1):
return PackedVector2Array([Vector2(l, b), Vector2(r, b)])
return PackedVector2Array([Vector2(l, t), Vector2(r, t)])
# --- painter ---------------------------------------------------------------
## Рисует фигуру. opts: type, name, ext, state, selected, hovered, alpha,
## show_label, gap, square, outline, fill.
static func paint(ci: CanvasItem, pos: Vector2, cells: Array, cell_px,
opts: Dictionary = {}) -> void:
var cs := _cell_vec(cell_px)
if cells.is_empty() or cs.x <= 0.0 or cs.y <= 0.0:
return
var file_type := str(opts.get("type", "text"))
var name := str(opts.get("name", ""))
var ext_value := str(opts.get("ext", ""))
var state := str(opts.get("state", "stored"))
var selected := bool(opts.get("selected", false))
var hovered := bool(opts.get("hovered", false))
var alpha := clampf(float(opts.get("alpha", 1.0)), 0.0, 1.0)
if alpha <= 0.01:
return
var show_label := bool(opts.get("show_label", true))
var gap := float(opts.get("gap", 0.0))
var square := bool(opts.get("square", false))
var outline_override: Color = opts.get("outline", Color(0, 0, 0, 0))
var fill_override: Color = opts.get("fill", Color(0, 0, 0, 0))
var base := type_color(file_type)
var locked := state == "locked"
var bad := state == "malicious" or state == "failed"
if locked:
base = base.lerp(Theme90.SHADE, 0.55)
var fill := fill_override if fill_override.a > 0.0 else _with_alpha(base, 0.22)
if hovered and fill_override.a <= 0.0:
fill = _with_alpha(base, 0.30)
fill.a *= alpha
var outline := outline_override
if outline.a <= 0.0:
if selected:
outline = Theme90.ACCENT
elif bad:
outline = Theme90.ERROR
elif locked:
outline = Theme90.SHADE
else:
outline = base
outline.a *= alpha
var outline_w := 2.6 if selected else 2.0
# 1) заливка занятых клеток (без внутренних швов)
for c in cells:
var cell := Vector2i(c)
var r := cell_pixel_rect(pos, cell, cell_px, gap)
ci.draw_rect(r, fill)
if hovered and fill_override.a <= 0.0:
ci.draw_rect(r, Color(1, 1, 1, 0.09 * alpha))
# 2) один внешний контур
for edge in outer_edges(cells):
var seg := edge_segment(pos, Vector2i(edge["from"]), Vector2i(edge["dir"]),
cell_px, gap)
ci.draw_line(seg[0], seg[1], outline, outline_w, false)
# 3) тонкая красная рамка вокруг bbox для malicious/failed
if bad:
var b := FR.shape_bounds(cells)
var step := cs + Vector2(gap, gap)
var frame := Rect2(pos - Vector2(2, 2),
Vector2(float(b.x) * step.x - gap + 4.0, float(b.y) * step.y - gap + 4.0))
ci.draw_rect(frame, _with_alpha(Theme90.ERROR, 0.55 * alpha), false, 1.0)
if not show_label:
return
var lbl := FR.label_cell(cells)
if square:
_paint_square_label(ci, pos, lbl, cs, gap, base, file_type, name, alpha, locked)
else:
_paint_cell_label(ci, pos, lbl, cs, gap, base, file_type, name, ext_value,
alpha, locked, state)
## Кегль подписи файла по размеру клетки: 12 / 14 / 16 px.
## Используется и в сетке, и в карточке запроса (square), чтобы имена
## не были микроскопическими на больших клетках.
## Это БАЗОВЫЙ кегль: если имя не влезает в отведённые строки, painter
## уменьшает его на 1 px (не ниже 11) прежде чем обрезать многоточием.
static func label_font_size(cell_px: float) -> int:
if cell_px > 96.0:
return 16
if cell_px >= 72.0:
return 14
return 12
## Сколько строк имени даёт клетка: 3 при >= 88, 2 при 72–87, 1 ниже 72.
## Больше строк — длинные имена читаются целиком, а не «запас_е…».
static func label_max_lines(cell_px: float) -> int:
if cell_px >= 88.0:
return 3
if cell_px >= 72.0:
return 2
return 1
## Перенос по словам с РАЗНОЙ шириной строк: первая уже (в ней иконка),
## последующие занимают всю клетку за вычетом полей.
static func _wrap_name(f: Font, text: String, size: int, first_w: float,
rest_w: float) -> PackedStringArray:
var out := PackedStringArray()
if text.is_empty():
return out
var cur := ""
for raw in text.split(" ", false):
var word := str(raw)
var width := first_w if out.is_empty() else rest_w
var cand := word if cur.is_empty() else cur + " " + word
if f.get_string_size(cand, HORIZONTAL_ALIGNMENT_LEFT, -1, size).x <= width:
cur = cand
else:
if not cur.is_empty():
out.append(cur)
cur = word
if not cur.is_empty():
out.append(cur)
return out
## Имя целиком помещается в max_lines при данном кегле (без многоточия)?
static func _name_fits(f: Font, text: String, size: int, first_w: float,
rest_w: float, max_lines: int) -> bool:
var lines := _wrap_name(f, text, size, first_w, rest_w)
if lines.size() > max_lines:
return false
for i in lines.size():
var width := first_w if i == 0 else rest_w
if f.get_string_size(lines[i], HORIZONTAL_ALIGNMENT_LEFT, -1, size).x > width:
return false
return true
## Самый крупный кегль от базового, при котором имя ещё влезает целиком.
## Ниже 11 px не опускаемся: лучше многоточие, чем нечитаемая строка.
static func name_font_size(cell_px: float, text: String, first_w: float,
rest_w: float, max_lines: int) -> int:
return _max_fit_size(Theme90.ui(), text, cell_px, first_w, rest_w, max_lines)
static func _max_fit_size(f: Font, text: String, cell_px: float, first_w: float,
rest_w: float, max_lines: int) -> int:
var base := label_font_size(cell_px)
var lo := mini(11, base)
for size in range(base, lo - 1, -1):
if _name_fits(f, text, size, first_w, rest_w, max_lines):
return size
return lo
## Готовые строки для отрисовки. Если имя не влезает — последняя строка
## получает хвост и многоточие; отдельные слишком длинные слова режутся
## по ширине своей строки.
static func _build_lines(f: Font, text: String, size: int, first_w: float,
rest_w: float, max_lines: int) -> PackedStringArray:
var lines := _wrap_name(f, text, size, first_w, rest_w)
if lines.size() > max_lines:
var kept := PackedStringArray()
for i in max_lines:
kept.append(lines[i])
kept[max_lines - 1] = Theme90.fit(f,
kept[max_lines - 1] + " " + lines[max_lines], size,
first_w if max_lines == 1 else rest_w)
lines = kept
for i in lines.size():
var lw := first_w if i == 0 else rest_w
if f.get_string_size(lines[i], HORIZONTAL_ALIGNMENT_LEFT, -1, size).x > lw:
lines[i] = Theme90.fit(f, lines[i], size, lw)
return lines
## Сколько символов имени реально видно (без многоточий и пробелов).
static func _visible_chars(lines: PackedStringArray) -> int:
var total := 0
for line in lines:
total += str(line).replace("…", "").replace(" ", "").length()
return total
## Раскладка подписи. Обычно иконка стоит в верхнем левом углу, а первая
## строка начинается справа от неё (icon_inline = true).
## Если первое слово в такую строку не помещается даже после сжатия кегля,
## текст переносится ПОД иконку и все строки получают полную ширину:
## иначе длинное слово теряло бы начало («запас_енер…» вместо имени целиком).
## Возвращает {"size": int, "lines": PackedStringArray, "icon_inline": bool}.
static func _label_layout(f: Font, name: String, cell_px: float, first_w: float,
rest_w: float, max_lines: int) -> Dictionary:
var inline_size := name_font_size(cell_px, name, first_w, rest_w, max_lines)
if _name_fits(f, name, inline_size, first_w, rest_w, max_lines):
return {"size": inline_size, "icon_inline": true,
"lines": _build_lines(f, name, inline_size, first_w, rest_w, max_lines)}
var below_size := _max_fit_size(f, name, cell_px, rest_w, rest_w, max_lines)
if _name_fits(f, name, below_size, rest_w, rest_w, max_lines):
return {"size": below_size, "icon_inline": false,
"lines": _build_lines(f, name, below_size, rest_w, rest_w, max_lines)}
# Не влезает ни так, ни так — оставляем раскладку, где видно больше имени.
var inline_lines := _build_lines(f, name, inline_size, first_w, rest_w, max_lines)
var below_lines := _build_lines(f, name, below_size, rest_w, rest_w, max_lines)
var below_better := below_size > inline_size \
or (below_size == inline_size
and _visible_chars(below_lines) > _visible_chars(inline_lines))
if below_better:
return {"size": below_size, "icon_inline": false, "lines": below_lines}
return {"size": inline_size, "icon_inline": true, "lines": inline_lines}
static func _paint_cell_label(ci: CanvasItem, pos: Vector2, lbl: Vector2i,
cs: Vector2, gap: float, base: Color, file_type: String, name: String,
ext_value: String, alpha: float, locked: bool, state: String) -> void:
var step := cs + Vector2(gap, gap)
var lr := Rect2(pos + Vector2(float(lbl.x) * step.x, float(lbl.y) * step.y), cs)
if lr.size.x < 16.0 or lr.size.y < 16.0:
return
var cell_min := minf(cs.x, cs.y)
var f := Theme90.ui()
var ink := Theme90.SHADE if locked else Theme90.INK
var shade := Theme90.SHADE
# Поля: строки после первой идут во всю ширину клетки (cell − 10 px).
const PAD := 5.0
var base_size := label_font_size(cell_min)
var ext_size := maxi(9, base_size - 3)
var max_lines := label_max_lines(cell_min)
var icon_size := clampf(cs.x * 0.16, 10.0, 16.0)
var ty := lr.position.y + 3.0
# Иконка — в верхнем левом углу. Первая строка идёт справа от неё,
# остальные — от левого поля, во всю ширину; если имя не влезает рядом
# с иконкой, весь текст уходит под неё (см. _label_layout).
var text_x := lr.position.x + PAD
var first_x := text_x + icon_size + 2.0
var badge_room := 10.0 if state == "attached" or state == "malicious" \
or state == "failed" else 0.0
var first_w := maxf(8.0, lr.end.x - first_x - PAD - badge_room)
var rest_w := maxf(8.0, lr.size.x - PAD * 2.0)
var layout := _label_layout(f, name, cell_min, first_w, rest_w, max_lines)
var name_size: int = int(layout["size"])
var lines: PackedStringArray = layout["lines"]
var icon_inline: bool = bool(layout["icon_inline"])
var line_h := float(name_size) + 1.0
# Строка с иконкой занимает не меньше самой иконки — иначе вторая строка
# наехала бы на неё.
var first_h := maxf(line_h, icon_size + 2.0)
var text_top := ty if icon_inline else ty + first_h
var icon := Rect2(Vector2(text_x, ty + maxf(0.0, (line_h - icon_size) * 0.5))
.round(), Vector2(icon_size, icon_size))
if not Theme90.draw_file_icon(ci, icon, file_type, _with_alpha(Color.WHITE, alpha)):
draw_type_icon(ci, icon, file_type, base, alpha)
var last_bottom := ty
for i in lines.size():
var lx := text_x
var lw := rest_w
var ly := text_top + float(i) * line_h
if icon_inline:
if i == 0:
lx = first_x
lw = first_w
ly = ty
else:
ly = ty + first_h + float(i - 1) * line_h
Theme90.text(ci, f, Vector2(lx, ly), lines[i], name_size,
_with_alpha(ink, alpha), lw)
last_bottom = ly + line_h
if not ext_value.is_empty():
var ey := (last_bottom if not lines.is_empty() else ty) + 1.0
# Расширение — отдельной мелкой строкой, если в клетке осталось место.
if ey + float(ext_size) + 2.0 <= lr.end.y:
Theme90.text(ci, f, Vector2(text_x, ey),
Theme90.fit(f, ext_value, ext_size, rest_w),
ext_size, _with_alpha(shade, alpha), rest_w)
# состояния в верхнем правом углу label-клетки
var badge := Vector2(lr.end.x - 8.0, lr.position.y + 8.0)
if state == "attached":
Theme90.draw_check_badge(ci, badge, 5.5, _with_alpha(Theme90.SUCCESS, alpha),
_with_alpha(Theme90.WHITE, alpha))
elif state == "malicious" or state == "failed":
ci.draw_circle(badge, 5.5, _with_alpha(Theme90.ERROR, alpha))
Theme90.text_vcenter(ci, f, Rect2(badge.x - 5.0, badge.y - 6.0, 10.0, 12.0),
"!", 10, _with_alpha(Theme90.WHITE, alpha), 10.0, HORIZONTAL_ALIGNMENT_CENTER)
elif locked:
Theme90.draw_lock_icon(ci, Vector2(lr.end.x - 12.0, lr.end.y - 11.0),
_with_alpha(Theme90.SHADE, alpha))
static func _paint_square_label(ci: CanvasItem, pos: Vector2, lbl: Vector2i,
cs: Vector2, gap: float, base: Color, file_type: String, name: String,
alpha: float, locked: bool) -> void:
var step := cs + Vector2(gap, gap)
var lr := Rect2(pos + Vector2(float(lbl.x) * step.x, float(lbl.y) * step.y), cs)
var f := Theme90.ui()
var cell_min := minf(cs.x, cs.y)
var name_size := label_font_size(cell_min)
var has_name := cell_min >= 40.0 and not name.is_empty()
var icon_size := clampf(cell_min * 0.30, 14.0, 18.0)
var name_y := lr.end.y - float(name_size) - 4.0
var icon := Rect2(lr.get_center().x - icon_size * 0.5,
lr.get_center().y - icon_size * 0.5, icon_size, icon_size)
if has_name:
# Иконка слева от имени, выровнена по строке подписи.
icon = Rect2(lr.position.x + 3.0,
name_y + (float(name_size) - icon_size) * 0.5,
icon_size, icon_size)
if not Theme90.draw_file_icon(ci, icon, file_type, _with_alpha(Color.WHITE, alpha)):
draw_type_icon(ci, icon, file_type, base, alpha)
if has_name:
var col := Theme90.SHADE if locked else Theme90.INK
var nx := icon.end.x + 2.0
Theme90.text(ci, f, Vector2(nx, name_y),
Theme90.fit(f, name, name_size, lr.end.x - nx - 3.0), name_size,
_with_alpha(col, alpha), lr.end.x - nx - 3.0)
## 8×8 иконка типа: строки (text), волна (sound), горы (photo), play (video).
static func draw_type_icon(ci: CanvasItem, rect: Rect2, file_type: String,
color: Color, alpha: float = 1.0) -> void:
var s := minf(rect.size.x, rect.size.y)
if s <= 0.0:
return
var r := Rect2(rect.position.round(), Vector2(s, s))
var tint := _with_alpha(color, 0.18 * alpha)
ci.draw_style_box(Theme90.fill_box(tint, 2), r)
var c := _with_alpha(color, alpha)
var x := r.position.x
var y := r.position.y
match file_type:
"sound":
var pts := PackedVector2Array([
Vector2(x + 1.0, y + s * 0.55),
Vector2(x + s * 0.30, y + s * 0.25),
Vector2(x + s * 0.52, y + s * 0.75),
Vector2(x + s * 0.74, y + s * 0.38),
Vector2(x + s - 1.0, y + s * 0.55),
])
ci.draw_polyline(pts, c, 1.0, false)
"photo":
var pts := PackedVector2Array([
Vector2(x + 1.0, y + s - 2.0),
Vector2(x + s * 0.34, y + s * 0.30),
Vector2(x + s * 0.56, y + s * 0.62),
Vector2(x + s * 0.72, y + s * 0.44),
Vector2(x + s - 1.0, y + s - 2.0),
])
ci.draw_polyline(pts, c, 1.0, false)
ci.draw_circle(Vector2(x + s * 0.72, y + s * 0.26), 1.2, c)
"video":
ci.draw_colored_polygon(PackedVector2Array([
Vector2(x + s * 0.30, y + s * 0.18),
Vector2(x + s * 0.30, y + s * 0.82),
Vector2(x + s * 0.82, y + s * 0.50),
]), c)
_:
for i in 3:
var ly := y + s * (0.30 + float(i) * 0.20)
ci.draw_line(Vector2(x + s * 0.22, ly),
Vector2(x + s * (0.78 - float(i) * 0.10), ly), c, 1.0, false)
+1
View File
@@ -0,0 +1 @@
uid://cywkgtu02b8pk
+55 -3
View File
@@ -7,19 +7,33 @@ extends Control
signal folder_selected(folder_id: String)
signal expansion_toggled(folder_id: String)
signal file_dropped(folder_id: String, payload: Dictionary)
signal packet_dropped(folder_id: String, payload: Dictionary)
signal context_menu_requested(folder_id: String, global_pos: Vector2)
## Папка раскрыта/открыта наведением во время drag (250 мс).
signal hover_open_requested(folder_id: String)
const ROW_H := 24.0
const SECTION_H := 22.0
var folder_id := ""
var folder_name := ""
## v2: count — занятые КЛЕТКИ, capacity — всего клеток.
var count := 0
var capacity := 16
## Число файлов (для тултипа «N файлов · X из Y клеток»).
var files_count := 0
var free_text := ""
var depth := 0
var is_section := false
var expandable := true
var hover_open := false:
set(v):
if hover_open == v:
return
hover_open = v
queue_redraw()
if v and not is_section:
hover_open_requested.emit(folder_id)
var expanded := true:
set(v):
expanded = v
@@ -76,6 +90,10 @@ func _draw() -> void:
elif drop_active:
draw_style_box(Theme90.box(Theme90.ACCENT_PALE, Theme90.ACCENT, 3, 1),
Rect2(2, 1, size.x - 4, size.y - 2))
elif hover_open:
draw_style_box(Theme90.box(Theme90.ACCENT_PALE, Theme90.ACCENT_LIGHT, 3, 1),
Rect2(2, 1, size.x - 4, size.y - 2))
draw_rect(Rect2(2, 5, 2, size.y - 10), Theme90.ACCENT_LIGHT)
elif hovered:
draw_rect(Rect2(2, 1, size.x - 4, size.y - 2), Color(1, 1, 1, 0.45))
@@ -123,7 +141,7 @@ func _draw_section(r: Rect2, f: Font) -> void:
Theme90.draw_drive_icon(self, Vector2(sx + 16, 6), Theme90.NAVY)
var fg := Theme90.NAVY if selected else Theme90.INK
Theme90.text_vcenter(self, f, Rect2(sx + 32, 0, size.x - 80, size.y),
Theme90.fit(f, folder_name, 11, size.x - 110), 11, fg)
Theme90.fit(f, folder_name, 12, size.x - 110), 12, fg)
if not free_text.is_empty():
var tw := f.get_string_size(free_text, HORIZONTAL_ALIGNMENT_LEFT, -1, 10).x
Theme90.text(self, f, Vector2(size.x - tw - 8, 6), free_text, 10, Theme90.SHADE)
@@ -155,16 +173,50 @@ func _can_drop_data(_at_position: Vector2, data: Variant) -> bool:
if k != "file" and k != "packet":
return false
drop_active = true
if not is_section:
_request_hover_open()
return true
func _drop_data(_at_position: Vector2, data: Variant) -> void:
drop_active = false
if is_section:
hover_open = false
_reset_hover_timer()
if is_section or not (data is Dictionary):
return
file_dropped.emit(folder_id, data)
if str(data.get("kind", "")) == "packet":
packet_dropped.emit(folder_id, data)
else:
file_dropped.emit(folder_id, data)
func _notification(what: int) -> void:
if what == NOTIFICATION_DRAG_END:
drop_active = false
hover_open = false
_reset_hover_timer()
elif what == NOTIFICATION_MOUSE_EXIT:
if drop_active or hover_open:
drop_active = false
hover_open = false
_reset_hover_timer()
func set_hover(on: bool) -> void:
hover_open = on
func _request_hover_open() -> void:
if not is_inside_tree():
return
var dc := get_tree().get_first_node_in_group("drag_controller")
if dc != null and dc.has_method("hover_folder"):
dc.call("hover_folder", folder_id)
func _reset_hover_timer() -> void:
if not is_inside_tree():
return
var dc := get_tree().get_first_node_in_group("drag_controller")
if dc != null and dc.has_method("reset_hover"):
dc.call("reset_hover")
+28 -33
View File
@@ -9,6 +9,7 @@ extends Control
signal new_run
signal continue_run
signal tutorial_requested
signal settings
signal about
@@ -20,20 +21,21 @@ const VHS_OVERLAY := preload("res://scripts/ui/vhs_overlay.gd")
const WIN_RECT := Rect2(120, 80, 1040, 560)
## Стартовая сводка v2: 9 часов, 50 $, SLA 60, 1 слот.
const START_STATS_TEXT := "9 часов · $50 на старте · 60 SLA · 1 слот"
var has_save := false
## Тонкий VHS-слой поверх меню. Игра и модалки не фильтруются.
var vhs = null
var _btn_continue: Button90
var _btn_new: Button90
var _btn_tutorial: Button90
var _btn_settings: Button90
var _btn_about: Button90
static var _card_style: StyleBoxFlat = null
## Накопитель для перерисовки VHS на 12 Гц.
var _vhs_clock := 0.0
func _ready() -> void:
process_mode = Node.PROCESS_MODE_ALWAYS
mouse_filter = Control.MOUSE_FILTER_STOP
@@ -64,18 +66,6 @@ func is_vhs_enabled() -> bool:
return vhs != null and vhs.enabled
## Медленная анимация: полоса и зерно. Перерисовываем меню на 12 Гц —
## чаще для такого эффекта не нужно, а текст остаётся тем же.
func _process(delta: float) -> void:
if vhs == null or not vhs.enabled or not vhs.is_available() or not visible:
return
vhs.advance(delta)
_vhs_clock += delta
if _vhs_clock >= 1.0 / 12.0:
_vhs_clock = 0.0
queue_redraw()
func _build() -> void:
_btn_continue = _make_button("Продолжить")
_btn_continue.disabled = not has_save
@@ -85,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
@@ -94,11 +90,19 @@ func _build() -> void:
_btn_about.pressed.connect(func() -> void: about.emit())
## VHS-слой — процедурный помощник (RefCounted, без Node/Control).
## Меню рисует его само в _draw(), поэтому ничто не перекрывает меню
## и не перехватывает мышь. Модалки — отдельные соседи в modal_root.
## VHS-слой — полноэкранный ColorRect поверх меню. Он фильтрует карточку,
## текст и кнопки, но остаётся внутри MainMenu; модалки — отдельные соседи
## в modal_root, они рисуются поверх и не фильтруются.
func _build_vhs() -> void:
vhs = VHS_OVERLAY.new()
vhs.name = "VhsOverlay"
add_child(vhs)
vhs.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT)
## Строка стартовой сводки (вынесена для тестов; рисуется в _draw_hero_text).
func start_stats_text() -> String:
return START_STATS_TEXT
func _tr(key: String, fallback: String) -> String:
@@ -129,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
@@ -150,22 +155,12 @@ func _draw() -> void:
Color(Theme90.ACCENT_LIGHT.r, Theme90.ACCENT_LIGHT.g, Theme90.ACCENT_LIGHT.b, 0.10))
draw_circle(Vector2(full.size.x * 0.06, full.size.y * 0.96), 300.0,
Color(0.96, 0.80, 0.72, 0.09))
# 2. VHS на бумаге.
if vhs != null:
vhs.draw_layer(self, full)
# 2. Карточка, панель, декоративные фигуры и текст. VHS — отдельный
# full-rect слой-ребёнок, он фильтрует весь кадр меню в native-res.
var w := _window()
var panel := _panel_rect(w)
# 3. Поверхности: карточка, панель, декоративные фигуры.
_draw_card(w)
_draw_actions_surface(w)
_draw_hero_shapes(w)
# 4. Мягкий VHS-проход по карточке и панели — ДО текста, поэтому буквы
# и кнопки остаются резкими.
if vhs != null:
vhs.draw_layer(self, w, true)
vhs.draw_layer(self, panel, true)
# 5. Текст поверх эффекта.
_draw_hero_text(w)
_draw_actions_text(w)
@@ -242,7 +237,7 @@ func _draw_hero_text(w: Rect2) -> void:
var strip := Rect2(x, w.position.y + 412, 512, 28)
Theme90.text_vcenter(self, f, Rect2(strip.position.x + 26, strip.position.y,
strip.size.x - 36, strip.size.y),
"9 часов · $150 на старте · 128 SLA · 4 типа файлов", 11, Theme90.DARK)
start_stats_text(), 11, Theme90.DARK)
# нижняя строка — только один тихий кредит
Theme90.text(self, f, Vector2(x, w.position.y + w.size.y - 28),
+156 -172
View File
@@ -1,16 +1,20 @@
class_name PacketCard
extends Control
## Карточка входящего пакета 400×44.
## Иконка, имя+ext, бейдж TCP/UDP, таймер (зелёный/жёлтый/красный + мигание <5 %),
## секунды и прогресс загрузки. TCP-пакет в ожидании соединяется кликом по
## карточке; перетаскивание в папку по-прежнему работает.
## Перетаскивается как {"kind": "packet", "id": ...}.
## Карточка входящего пакета 400×52 (v2).
##
## Показывает мини-превью ФИГУРЫ, которую пакет займёт в папке, и текст
## «ЗАЙМЁТ N КЛЕТОК». Карточка перетаскивается в сетку папки или на строку
## папки: payload {"kind": "packet", "id", "shape_id", "rotation", …}.
## Соединений/атак больше нет — карточка только источник drag&drop.
signal connect_pressed(id: String)
signal connect_pressed(id: String) # legacy: соединение удалено, не эмитится
const CARD := Vector2(400, 44)
## Допустимое смещение мыши, при котором нажатие считается кликом, а не drag.
const CLICK_SLOP := 6.0
const CARD := Vector2(400, 52)
const FR = preload("res://scripts/core/file_registry.gd")
const FILE_SHAPE = preload("res://scripts/ui/file_shape.gd")
const DRAG_CONTROLLER = preload("res://scripts/ui/drag_controller.gd")
const PREVIEW_CELL := 6.0
const PREVIEW_GAP := 1.0
var id := ""
var file_name := ""
@@ -18,15 +22,12 @@ var ext := ""
var type := "text"
var protocol := "TCP"
var time_left := 20.0
var time_total := 40.0
var time_total := 20.0
var progress := 0.0
var size_kb := 0
var state := "normal"
## Карточка-угроза (мусор/DDoS/шифровальщик): без кнопки соединения,
## перетаскивается в щит-карантин.
var is_attack := false
var attack_kind := ""
var attack_id := ""
var state := "waiting"
var shape_id := "O1"
var shape_rotation := 0
var hovered := false:
set(v):
@@ -35,9 +36,6 @@ var hovered := false:
var _blink := 0.0
var _blink_on := true
var _pressed := false
var _press_pos := Vector2.ZERO
var _drag_started := false
func _ready() -> void:
@@ -45,61 +43,35 @@ func _ready() -> void:
custom_minimum_size = CARD
if size == Vector2.ZERO:
size = CARD
mouse_default_cursor_shape = Control.CURSOR_POINTING_HAND
mouse_default_cursor_shape = Control.CURSOR_DRAG
mouse_entered.connect(func() -> void: hovered = true)
mouse_exited.connect(func() -> void: hovered = false)
set_process(true)
_update_tooltip()
## Совместимая сигнатура: первые 9 аргументов как в v1, затем состояние и
## форма пакета. Атаки удалены — их аргументов здесь нет.
func setup(p_id: String, p_name: String, p_ext: String, p_type: String, p_proto: String,
p_left: float, p_total: float, p_progress: float = 0.0, p_size_kb: int = 0,
p_is_attack: bool = false, p_attack_kind: String = "", p_attack_id: String = "",
p_state: String = "waiting") -> void:
p_state: String = "waiting", p_shape_id: String = "O1",
p_rotation: int = 0) -> void:
id = p_id
file_name = p_name
ext = p_ext
type = p_type
protocol = p_proto
protocol = str(p_proto).to_upper()
time_left = p_left
time_total = p_total
progress = p_progress
progress = clampf(p_progress, 0.0, 1.0)
size_kb = p_size_kb
is_attack = p_is_attack
attack_kind = p_attack_kind
attack_id = p_attack_id
state = p_state
_update_interaction()
shape_id = p_shape_id if not p_shape_id.is_empty() else "O1"
shape_rotation = posmod(int(p_rotation), 4)
_update_tooltip()
queue_redraw()
## Клик по карточке соединяет только готовый TCP-пакет.
func _clickable() -> bool:
return not is_attack and protocol == "TCP" and state == "waiting"
func _update_interaction() -> void:
mouse_default_cursor_shape = Control.CURSOR_POINTING_HAND if _clickable() \
else Control.CURSOR_ARROW
if is_attack:
tooltip_text = "УГРОЗА: %s\nПеретащите в ЩИТ (карантин)" % _display_name()
elif protocol == "TCP":
tooltip_text = "%s\n%d КБ · TCP · Клик — соединиться; перетащите в папку" % [
_display_name(), size_kb]
else:
tooltip_text = "%s\n%d КБ · UDP · перетащите в папку" % [_display_name(), size_kb]
## Имя для показа: атака-шифровальщик приходит уже с расширением (.exe),
## поэтому не добавляем тип повторно.
func _display_name() -> String:
if is_attack and file_name.contains("."):
# шифровальщик приходит как «счёт.exe» — расширение уже в имени
return file_name
if ext.is_empty():
return file_name
return "%s.%s" % [file_name, ext]
func set_time(left: float, total: float) -> void:
time_left = left
time_total = total
@@ -121,6 +93,37 @@ func timer_color() -> Color:
return Theme90.SUCCESS
## Клетки фигуры этого пакета (для тестов/подсказки).
func cells() -> Array:
return FR.shape_cells(shape_id, shape_rotation)
func cells_count() -> int:
return cells().size()
## «ЗАЙМЁТ N КЛЕТОК» — сколько места пакет займёт в папке.
func cells_text() -> String:
var n := cells_count()
var word := "КЛЕТОК"
if n % 10 == 1 and n % 100 != 11:
word = "КЛЕТКУ"
elif n % 10 >= 2 and n % 10 <= 4 and (n % 100 < 12 or n % 100 > 14):
word = "КЛЕТКИ"
return "ЗАЙМЁТ %d %s" % [n, word]
func _display_name() -> String:
if ext.is_empty():
return file_name
return "%s.%s" % [file_name, ext]
func _update_tooltip() -> void:
# Полное имя одной строкой (без переноса): ОС-подсказка не обрезает его.
tooltip_text = "%s · %s · %s" % [_display_name(), FileCard.type_title(type), protocol]
func _process(delta: float) -> void:
# мигание только у «горящих» пакетов (<5 %)
if ratio() < 0.05:
@@ -141,149 +144,130 @@ func _draw() -> void:
draw_style_box(Theme90.box(face, border, 4, 1,
Color(0, 0, 0, 0.11) if hovered else Theme90.SHADOW), r)
if is_attack:
_draw_attack_icon(Rect2(6, 12, 20, 20))
else:
var tc := FileCard.type_color(type)
Theme90.draw_doc_icon(self, Rect2(6, 12, 20, 20), type, tc)
Theme90.text(self, f, Vector2(32, 2), _display_name(), 12,
Theme90.ERROR if is_attack else Theme90.INK, 186)
_draw_shape_preview(Rect2(4, 10, 30, 30))
# бейдж протокола / тип угрозы
var badge := Rect2(222, 4, 28, 13)
# Иконка типа 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)
draw_style_box(Theme90.well_box(Theme90.WHITE), badge)
if is_attack:
var mark := "???"
var mark_color := Theme90.SHADE
match attack_kind:
"ddos":
mark = "???"
mark_color = Theme90.ERROR
"ransomware":
mark = "EXE"
mark_color = Theme90.ERROR
"junk":
mark = "??"
mark_color = Theme90.SHADE
_:
mark = "!"
mark_color = Theme90.WARN
Theme90.text_vcenter(self, f, badge, mark, 10, mark_color, -1, HORIZONTAL_ALIGNMENT_CENTER)
else:
Theme90.text_vcenter(self, f, badge, protocol, 10,
Theme90.NAVY if protocol == "TCP" else Theme90.WARN, -1, HORIZONTAL_ALIGNMENT_CENTER)
Theme90.text_vcenter(self, f, badge, protocol, 10,
Theme90.NAVY if protocol == "TCP" else Theme90.WARN, -1,
HORIZONTAL_ALIGNMENT_CENTER)
# таймер
var tr := Rect2(32, 19, 160, 5)
var tr := Rect2(40, 20, 140, 5)
var col := timer_color()
if not _blink_on:
col = Theme90.ERROR.lerp(Theme90.FACE, 0.45)
Theme90.draw_meter(self, tr, ratio(), col, Theme90.PAPER_LO)
var w := Theme90.num_line(self, Vector2(186, 17), str(int(ceilf(time_left))), 8,
timer_color(), " с", 10)
Theme90.text(self, f, Vector2(186 + w + 3, 19), "·", 10, Theme90.SHADE)
Theme90.num_line(self, Vector2(186 + w + 8, 17), str(size_kb), 8,
Theme90.SHADE, " КБ", 10)
# секунды + размер
var w := Theme90.num_line(self, Vector2(197, 17), str(int(ceilf(time_left))), 8, timer_color(), " с", 10)
Theme90.text(self, f, Vector2(197 + w + 3, 19), "·", 10, Theme90.SHADE)
Theme90.num_line(self, Vector2(197 + w + 8, 17), str(size_kb), 8, Theme90.SHADE, " КБ", 10)
# состояние + сколько клеток займёт
Theme90.text(self, f, Vector2(40, 31), _state_caption(), 10, _state_color(), 160)
Theme90.text(self, f, Vector2(204, 31), cells_text(), 10, Theme90.DARK, 150)
# состояние
var status := "В ОЧЕРЕДИ"
var status_color := Theme90.DARK
if is_attack:
status = "УГРОЗА · В ЩИТ"
status_color = Theme90.ERROR
if attack_kind == "junk":
status = "МУСОР · В ЩИТ"
status_color = Theme90.SHADE
elif attack_kind == "ransomware":
status = "ВИРУС · В ЩИТ"
elif attack_kind == "ddos":
status = "DDoS · ДЕРЖИТЕ ЩИТ"
elif progress > 0.0:
status = "ПЕРЕДАЧА ИДЁТ"
status_color = Theme90.ACCENT
elif protocol != "TCP":
status = "ОДНОСТОРОННЯЯ ПЕРЕДАЧА"
else:
status = "ГОТОВ К СОЕДИНЕНИЮ"
Theme90.text(self, f, Vector2(32, 29), status, 10, status_color, 220)
# прогресс загрузки (справа под кнопкой); у угроз — метка щита
if is_attack:
Theme90.text(self, f, Vector2(300, 29), "ЩИТ", 10, Theme90.ERROR, 76)
return
var pr := Rect2(300, 27, 76, 4)
# прогресс загрузки справа
var pr := Rect2(310, 42, 80, 4)
Theme90.draw_meter(self, pr, progress, Theme90.ACCENT, Theme90.PAPER_LO)
Theme90.num_line(self, Vector2(380, 21), str(int(progress * 100.0)), 8, Theme90.DARK, "%", 8)
Theme90.num_line(self, Vector2(348, 31), str(int(progress * 100.0)), 8,
Theme90.DARK, "%", 8)
func _draw_attack_icon(r: Rect2) -> void:
var f := Theme90.ui()
var soft := Color(Theme90.ERROR.r, Theme90.ERROR.g, Theme90.ERROR.b, 0.12)
match attack_kind:
"junk":
draw_style_box(Theme90.box(Theme90.PAPER_LO, Theme90.BORDER_SOFT, 4, 1), r)
Theme90.text(self, f, Vector2(r.position.x, r.position.y + 2), "?", 14,
Theme90.SHADE, r.size.x, HORIZONTAL_ALIGNMENT_CENTER)
"ddos":
draw_style_box(Theme90.box(soft, Color(0, 0, 0, 0), 4, 0), r)
for i in 3:
draw_rect(Rect2(r.position.x + 4, r.position.y + 5 + i * 4, r.size.x - 8, 1.5),
Theme90.ERROR)
"ransomware":
draw_style_box(Theme90.box(soft, Color(0, 0, 0, 0), 4, 0), r)
draw_rect(Rect2(r.position.x + 4, r.position.y + 4, 12, 12), Theme90.ERROR, false, 1.0)
draw_rect(Rect2(r.position.x + 9, r.position.y + 9, 4, 4), Theme90.ERROR)
_:
draw_style_box(Theme90.box(soft, Color(0, 0, 0, 0), 4, 0), r)
Theme90.text(self, f, Vector2(r.position.x, r.position.y + 2), "!", 14,
Theme90.ERROR, r.size.x, HORIZONTAL_ALIGNMENT_CENTER)
func _state_caption() -> String:
if state == "loading" or progress > 0.0:
return "ЗАГРУЗКА В ПАПКУ"
if protocol != "TCP":
return "UDP · ЖДЁТ ПАПКУ"
return "ЖДЁТ ПАПКУ"
func _gui_input(event: InputEvent) -> void:
if event is InputEventMouseButton and event.button_index == MOUSE_BUTTON_LEFT:
if event.pressed:
_pressed = true
_press_pos = event.position
_drag_started = false
return
if not _pressed:
return
_pressed = false
# Drag начинается через _get_drag_data; отпускание после него — не клик.
if _drag_started or event.position.distance_to(_press_pos) > CLICK_SLOP:
_drag_started = false
return
if _clickable():
connect_pressed.emit(id)
accept_event()
elif event is InputEventMouseMotion and _pressed:
if event.position.distance_to(_press_pos) > CLICK_SLOP:
_drag_started = true
func _state_color() -> Color:
if state == "loading" or progress > 0.0:
return Theme90.ACCENT
return Theme90.DARK
func _notification(what: int) -> void:
if what == NOTIFICATION_DRAG_END:
_pressed = false
_drag_started = false
func _draw_shape_preview(rect: Rect2) -> void:
# Лёгкая подложка 4×4, чтобы фигура читалась как «место в папке».
var grid_w := 4.0 * PREVIEW_CELL + 3.0 * PREVIEW_GAP
var origin := rect.position + Vector2(
roundf((rect.size.x - grid_w) * 0.5),
roundf((rect.size.y - grid_w) * 0.5))
for r in 4:
for c in 4:
var cell := Rect2(origin + Vector2(
float(c) * (PREVIEW_CELL + PREVIEW_GAP),
float(r) * (PREVIEW_CELL + PREVIEW_GAP)),
Vector2(PREVIEW_CELL, PREVIEW_CELL))
draw_rect(cell, Color(0, 0, 0, 0.045), true)
var cs := cells()
if cs.is_empty():
return
var bounds := FR.shape_bounds(cs)
var shape_w := float(bounds.x) * (PREVIEW_CELL + PREVIEW_GAP) - PREVIEW_GAP
var shape_h := float(bounds.y) * (PREVIEW_CELL + PREVIEW_GAP) - PREVIEW_GAP
var pos := origin + Vector2(
roundf((grid_w - shape_w) * 0.5),
roundf((grid_w - shape_h) * 0.5))
FILE_SHAPE.paint(self, pos, cs, PREVIEW_CELL, {
"type": type,
"alpha": 0.95,
"show_label": false,
"gap": PREVIEW_GAP,
})
func _get_drag_data(_at_position: Vector2) -> Variant:
_drag_started = true
var ghost := Theme90.DragGhost.new(_display_name(),
("УГРОЗА · В ЩИТ" if is_attack else "%d КБ · %s" % [size_kb, protocol]), "packet")
ghost.size = Vector2(168, 30)
set_drag_preview(ghost)
return {
var payload := {
"kind": "packet",
"id": id,
"name": file_name,
"protocol": protocol,
"type": type,
"is_attack": is_attack,
"attack_kind": attack_kind,
"attack_id": attack_id,
"ext": ext,
"size_kb": size_kb,
"shape_id": shape_id,
# Внутреннее поле — shape_rotation (Control.rotation занят), но ключ
# payload остаётся "rotation": это контракт FileGrid/TreePanel/HourDirector.
"rotation": shape_rotation,
"from": "packet",
}
var dc := _drag_controller()
if dc != null:
var preview := FILE_SHAPE.new()
preview.setup(type, shape_id, shape_rotation, file_name, ext, 36.0)
preview.set_mode("shape")
dc.begin(preview, "shape")
else:
# Без контроллера остаётся нативный ghost с фигурой-плашкой.
var ghost := Theme90.DragGhost.new(_display_name(), cells_text().to_lower(), "packet")
ghost.size = Vector2(176, 30)
set_drag_preview(ghost)
return payload
func _drag_controller() -> Node:
if not is_inside_tree():
return null
return get_tree().get_first_node_in_group("drag_controller")
func _can_drop_data(_at_position: Vector2, _data: Variant) -> bool:
return false
func _notification(what: int) -> void:
if what == NOTIFICATION_DRAG_END:
var dc := _drag_controller()
if dc != null and dc.has_method("end"):
dc.call("end")
+676 -173
View File
@@ -1,43 +1,82 @@
class_name RequestCard
extends Control
## Карточка запроса клиента 400×64. Светлая карточка, три слота файлов.
## TCP-запрос в ожидании соединяется кликом по самой карточке; X отклоняет,
## двойной клик по файлу отсоединяет его. attached-файл помечается зелёной
## галочкой, пустой слот — пунктиром и мигающим «ожидается».
## Карточка запроса клиента 400×128 (v3).
##
## Сетка 2×2 обязательных файлов (конкретные REQUIRED-id из main), счётчик
## «ПРИКРЕПЛЕНО X/Y», прогноз мини-игры (Economy.send_minigame_for по типам
## обязательных файлов) и явные кнопки СОЕДИНИТЬСЯ/ОТПРАВИТЬ, ОТКРЕПИТЬ,
## ОТКЛОНИТЬ. Наведение на чип показывает полное имя файла и папку, где он
## лежит, в подсказке и строке внутри карточки. Атак/фишинга в карточке нет.
signal connect_pressed(id: String)
signal send_pressed(id: String)
signal protocol_toggle_requested(id: String)
signal reject_pressed(id: String)
signal file_dropped(request_id: String, slot_index: int, payload: Dictionary)
signal detach_pressed(request_id: String, slot_index: int)
## Legacy: соединение удалено. Сигнал оставлен, чтобы старые подписи не падали.
signal connect_pressed(id: String)
const CARD := Vector2(400, 64)
const SLOT_X := 40.0
const SLOT_STEP := 72.0
const SLOT_Y := 36.0
const SLOT_W := 68.0
const SLOT_H := 20.0
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_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 := "После успешной отправки файлы списываются."
## Имена игр — только запасной вариант, если Data.minigame_def недоступен.
const FALLBACK_NAMES := {
"dots": "ОДНА ЛИНИЯ",
"sound": "УРОВНИ",
"text": "ЧУЖОЕ СЛОВО",
"video": "РАСКАДРОВКА",
"photo": "КАДР",
"maze": "ЛАБИРИНТ",
}
var id := ""
var client_name := ""
var client_id := ""
var protocol := "TCP"
var slots: Array = []
var slot_count := 3
var required_count := 0
var time_left := 30.0
var time_total := 60.0
var time_total := 30.0
var progress := 0.0
var avatar_muted := false
var queued := false
var sending := false
var minigame_active := false
## TCP-соединение: идёт мини-игра соединения / соединение уже установлено.
var connecting := false
var connected := false
## Запрос-угроза (фишинг): приглушённый аватар и бейдж «???».
var is_attack := false
var attack_kind := ""
var attack_id := ""
## Готовность контейнера (HourDirector.is_request_ready): файлы собраны.
var send_ready := false
var send_sum := 0
var minigame_id := ""
var minigame_tier := 0
var hovered := false:
set(v):
hovered = v
queue_redraw()
var _reject: Button90
var _send_btn: Button90
var _detach_btn: Button90
var _reject_btn: Button90
var _blink := 0.0
var _blink_on := true
var _hover_slot := -1
@@ -46,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
@@ -53,53 +93,107 @@ func _ready() -> void:
mouse_entered.connect(func() -> void: hovered = true)
mouse_exited.connect(func() -> void:
hovered = false
_hover_slot = -1)
_set_hover_slot(-1))
_reject = Button90.new()
_reject.name = "RejectButton"
_reject.icon = Button90.Icon.X
_reject.position = Vector2(386, 38)
_reject.size = Vector2(14, 14)
_reject.tooltip_text = "Отклонить запрос"
add_child(_reject)
_reject.pressed.connect(func() -> void: reject_pressed.emit(id))
_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, 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, 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:
var b := Button90.new()
b.caption = caption
b.caption_size = 10
b.accent = accent
b.position = pos
b.size = sz
b.mouse_entered.connect(func() -> void: _set_hover_slot(-1))
add_child(b)
return b
## Совместимые первые 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_is_attack: bool = false, p_attack_kind: String = "", p_attack_id: String = "",
p_avatar_muted: bool = false) -> void:
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,
p_connecting: bool = false, p_connected: bool = false) -> void:
id = p_id
client_name = p_client
client_id = p_client_id
protocol = p_proto
slots = p_slots
slot_count = maxi(p_slots.size(), 1)
protocol = str(p_proto).to_upper()
if protocol != "UDP":
protocol = "TCP"
slots = p_slots.duplicate(true) if p_slots is Array else []
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 = p_progress
is_attack = p_is_attack
attack_kind = p_attack_kind
attack_id = p_attack_id
avatar_muted = p_avatar_muted
_update_interaction()
if is_attack and attack_kind == "phishing":
tooltip_text = "ПОДОЗРИТЕЛЬНЫЙ ЗАПРОС · %s" % client_id
else:
tooltip_text = "%s · %s" % [client_name, client_id]
custom_minimum_size = Vector2(CARD.x, 0)
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)
# Слот наведения переживает ежеминутные (покадровые) обновления карточки:
# иначе полоса с именем/папкой успевала показаться лишь на один кадр.
if _hover_slot >= required_count:
_hover_slot = -1
_refresh_tooltip()
custom_minimum_size = CARD
if size == Vector2.ZERO:
size = CARD
_update_buttons()
queue_redraw()
## Клик по карточке (не по слоту и не по X) соединяет TCP-запрос.
func _clickable() -> bool:
return not is_attack and protocol == "TCP" and not connected
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()
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 or connecting
if _reject_btn != null:
_reject_btn.disabled = sending
func _update_interaction() -> void:
mouse_default_cursor_shape = Control.CURSOR_POINTING_HAND if _clickable() \
else Control.CURSOR_ARROW
## Текст главной кнопки: TCP без соединения сначала соединяется.
func _send_caption() -> String:
if connecting:
return "СОЕДИНЕНИЕ…"
if protocol == "TCP" and not connected:
return "СОЕДИНИТЬСЯ"
return "ОТПРАВИТЬ"
func ratio() -> float:
@@ -108,6 +202,114 @@ func ratio() -> float:
return clampf(time_left / time_total, 0.0, 1.0)
func attached_count() -> int:
var n := 0
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 connecting or sending or queued or minigame_active:
return false
return attached_count() >= required_count
func send_disabled() -> bool:
return _send_btn != null and _send_btn.disabled
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).
## Считается по типам ОБЯЗАТЕЛЬНЫХ файлов, поэтому видно ещё до прикрепления.
## Если main передал send_sum/minigame_id, они приоритетнее локального расчёта.
func prediction() -> Dictionary:
var types: Array = []
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, 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, 3)
if send_sum > 0:
sum = send_sum
return {
"sum": sum,
"index": int(pick.get("index", 0)),
"id": game,
"name": _game_name(game),
"tier": tier,
}
func minigame_text() -> String:
var p := prediction()
if int(p.get("sum", 0)) <= 0 and minigame_id.is_empty():
return "МИНИ-ИГРА: —"
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))
func _game_name(game_id: String) -> String:
var data := get_node_or_null("/root/Data")
if data != null and data.has_method("minigame_def"):
var def: Dictionary = data.call("minigame_def", game_id)
var name_ru := str(def.get("name_ru", ""))
if not name_ru.is_empty():
return name_ru.to_upper()
return str(FALLBACK_NAMES.get(game_id, game_id.to_upper()))
func _process(delta: float) -> void:
_blink += delta
var on := int(_blink * 2.0) % 2 == 0
@@ -116,14 +318,134 @@ func _process(delta: float) -> void:
queue_redraw()
# --- слоты -----------------------------------------------------------------
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 + i * SLOT_STEP, SLOT_Y, SLOT_W, SLOT_H)
return Rect2(SLOT_X + float(i % 2) * SLOT_STEP_X,
SLOT_Y + floorf(float(i) / 2.0) * SLOT_STEP_Y, SLOT_W, SLOT_H)
func _icon_rect(i: int) -> Rect2:
return Rect2(SLOT_X + i * SLOT_STEP + 1, SLOT_Y + 1, 18, 18)
## Кегль имени в чипе: 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)
var f := Theme90.ui()
@@ -132,62 +454,27 @@ func _draw() -> void:
draw_style_box(Theme90.box(bg, border, 4, 1,
Color(0, 0, 0, 0.11) if hovered else Theme90.SHADOW), r)
_draw_avatar(Rect2(8, 8, 24, 24))
_draw_avatar(Rect2(8, 6, 20, 20))
Theme90.text(self, f, Vector2(40, 4), Theme90.fit(f, client_name, 12, 146), 12,
Theme90.INK, 146)
Theme90.text(self, f, Vector2(40, 19), Theme90.fit(f, client_id, 10, 148), 10,
Theme90.SHADE, 148)
Theme90.text(self, f, Vector2(32, 4), Theme90.fit(f, client_name, 12, 140), 12,
Theme90.INK, 140)
Theme90.text(self, f, Vector2(32, 18), Theme90.fit(f, client_id, 9, 140), 9,
Theme90.SHADE, 140)
# протокол — маленький цветной чип
var chip := Rect2(190, 7, 34, 13)
if is_attack and attack_kind == "phishing":
draw_style_box(Theme90.box(Color(Theme90.ERROR.r, Theme90.ERROR.g, Theme90.ERROR.b, 0.12),
Color(0, 0, 0, 0), 3, 0), chip)
Theme90.text_vcenter(self, f, chip, "???", 10, Theme90.ERROR, -1,
HORIZONTAL_ALIGNMENT_CENTER)
else:
var pcol := Theme90.NAVY if protocol == "TCP" else Theme90.WARN
draw_style_box(Theme90.box(Color(pcol.r, pcol.g, pcol.b, 0.12),
Color(0, 0, 0, 0), 3, 0), chip)
Theme90.text_vcenter(self, f, chip, protocol, 10, pcol, -1,
HORIZONTAL_ALIGNMENT_CENTER)
_draw_protocol_chip(f)
_draw_timer(f)
for i in slot_count:
var count := required_count if required_count > 0 else 1
for i in count:
_draw_slot(i, f)
# таймер / прогресс отправки
var tr := Rect2(300, 4, 92, 12)
var label := "ОТПРАВКА" if progress > 0.0 else "ОСТАЛОСЬ"
var value := "%d%%" % int(roundf(progress * 100.0)) if progress > 0.0 \
else "%d с" % int(ceilf(time_left))
Theme90.text(self, f, Vector2(tr.position.x, tr.position.y), label, 10, Theme90.SHADE)
var vw := f.get_string_size(value, HORIZONTAL_ALIGNMENT_LEFT, -1, 10).x
Theme90.text(self, f, Vector2(tr.end.x - vw, tr.position.y), value, 10,
_time_color())
var bar := Rect2(300, 20, 92, 4)
var fill := progress if progress > 0.0 else ratio()
var bcol := Theme90.ACCENT if progress > 0.0 else _time_color()
Theme90.draw_meter(self, bar, fill, bcol)
# маленькая подсказка-статус в правом нижнем углу (не кнопка)
var hint := ""
var hint_color := Theme90.ACCENT
if is_attack:
hint = ""
elif progress > 0.0:
hint = "ОТПРАВКА…"
elif protocol != "TCP":
hint = "UDP · АВТО"
hint_color = Theme90.WARN
elif connected:
hint = "ПОДКЛЮЧЁН"
hint_color = Theme90.SUCCESS
else:
hint = "КЛИК — СОЕДИНИТЬСЯ"
if not hint.is_empty():
Theme90.text_vcenter(self, f, Rect2(288, 42, 88, 16), hint, 8,
hint_color, -1, HORIZONTAL_ALIGNMENT_RIGHT)
# Полное имя файла и папка — поверх правой части шапки, только при наведении на чип.
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),
@@ -197,7 +484,157 @@ func _draw() -> void:
Theme90.ACCENT, 4.0, 1.2)
func _draw_protocol_chip(f: Font) -> void:
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)
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 connecting:
label = "СОСТОЯНИЕ"
value = "СВЯЗЬ…"
col = Theme90.WARN
elif sending:
label = "ОТПРАВКА"
value = "%d%%" % int(roundf(progress * 100.0))
col = Theme90.ACCENT
elif queued:
label = "СЛОТ"
value = "ОЖИДАНИЕ"
col = Theme90.WARN
elif minigame_active:
label = "СОСТОЯНИЕ"
value = "МИНИ-ИГРА"
col = Theme90.ACCENT
Theme90.text(self, f, Vector2(232, 4), label, 9, Theme90.SHADE)
var vw := f.get_string_size(value, HORIZONTAL_ALIGNMENT_LEFT, -1, 10).x
Theme90.text(self, f, Vector2(392 - vw, 3), value, 10, col)
var fill := progress if sending else ratio()
Theme90.draw_meter(self, Rect2(232, 19, 152, 4), fill,
Theme90.ACCENT if sending else col)
func _draw_counter(f: Font) -> void:
var have := attached_count()
var cap := maxi(required_count, 0)
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(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:
hint = "ПЕРЕДАЧА ИДЁТ"
hcol = Theme90.ACCENT
elif minigame_active:
hint = "ИДЁТ МИНИ-ИГРА"
hcol = Theme90.ACCENT
elif _lost_count() > 0:
hint = "ПОТЕРЯН ФАЙЛ"
hcol = Theme90.ERROR
elif _blink_on and attached_count() < required_count:
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 or connecting:
return Theme90.WARN
if ratio() < 0.2:
return Theme90.ERROR
if ratio() < 0.5:
@@ -206,20 +643,16 @@ func _time_color() -> Color:
func _draw_avatar(r: Rect2) -> void:
var bg := Theme90.PAPER_LO if avatar_muted else Theme90.ACCENT
var fg := Theme90.SHADE if avatar_muted else Theme90.WHITE
var bg := Theme90.ACCENT
draw_style_box(Theme90.box(bg, Color(0, 0, 0, 0), 5, 0), r)
var f := Theme90.ui()
Theme90.text(self, f, Vector2(r.position.x, r.position.y + 5), _monogram(), 14, fg,
r.size.x, HORIZONTAL_ALIGNMENT_CENTER)
draw_circle(Vector2(r.end.x - 4, r.end.y - 4), 3.0, Theme90.PAPER_HI)
draw_circle(Vector2(r.end.x - 4, r.end.y - 4), 2.0,
Theme90.SHADE if avatar_muted else Theme90.SUCCESS)
Theme90.text(self, f, Vector2(r.position.x, r.position.y + 3), _monogram(), 12,
Theme90.WHITE, r.size.x, HORIZONTAL_ALIGNMENT_CENTER)
draw_circle(Vector2(r.end.x - 3, r.end.y - 3), 2.5, Theme90.PAPER_HI)
draw_circle(Vector2(r.end.x - 3, r.end.y - 3), 1.6, Theme90.SUCCESS)
func _monogram() -> String:
if is_attack:
return "?"
var parts := client_name.replace("«", " ").replace("»", " ").split(" ", false)
if parts.is_empty():
return "?"
@@ -231,78 +664,149 @@ func _monogram() -> String:
func _draw_slot(i: int, f: Font) -> void:
var cell := _slot_rect(i)
var ir := _icon_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 + 4), "+", 12,
Theme90.SHADE)
if _blink_on:
Theme90.text(self, f, Vector2(cell.position.x + 15, cell.position.y + 5),
"ожидается", 10, Theme90.WARN)
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.5), 3, 1),
cell)
Theme90.draw_doc_icon(self, ir, t, col)
var nm := "%s.%s" % [str(d.get("name", "")), str(d.get("ext", ""))]
Theme90.text(self, f, Vector2(cell.position.x + 22, cell.position.y + 5),
Theme90.fit(f, nm, 10, SLOT_W - 24.0), 10, Theme90.INK, SLOT_W - 24.0)
if bool(d.get("attached", false)):
Theme90.draw_check_badge(self, Vector2(cell.position.x + 17, cell.position.y + 2),
4.5)
# подсказка «отсоединить» на наведении
if _hover_slot == i or drop:
var xr := Rect2(cell.end.x - 12, cell.position.y + 1, 11, 11)
draw_circle(xr.get_center(), 5.5, Color(Theme90.ERROR.r, Theme90.ERROR.g,
Theme90.ERROR.b, 0.92))
Theme90.draw_x(self, xr, Theme90.WHITE, 1.0)
if drop:
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 _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()
elif event is InputEventMouseButton and event.pressed:
if event.button_index == MOUSE_BUTTON_LEFT:
var i := _slot_at(event.position)
if i >= 0:
# Слоты (и X отсоединения) не должны запускать соединение.
if event.double_click and i < slots.size() and slots[i] != null:
detach_pressed.emit(id, i)
accept_event()
return
_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):
if not connected and not connecting:
protocol_toggle_requested.emit(id)
accept_event()
if _clickable():
connect_pressed.emit(id)
func _slot_at(pos: Vector2) -> int:
for i in slot_count:
if _slot_rect(i).has_point(pos):
return i
return -1
return
var i := _slot_at(event.position)
if i >= 0:
if event.double_click and _slot_attached(i):
detach_pressed.emit(id, i)
accept_event()
return
accept_event()
func _can_drop_data(at_position: Vector2, data: Variant) -> bool:
if not (data is Dictionary):
return false
if data.get("kind", "") != "file":
if str(data.get("kind", "")) != "file":
return false
# Файл со стола сортировки в запрос прикреплять нельзя (§4.7).
if str(data.get("from", "")) == "table":
return false
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")
queue_redraw()
return true
@@ -316,11 +820,10 @@ func _drop_data(at_position: Vector2, data: Variant) -> void:
file_dropped.emit(id, i, data)
func _first_empty() -> int:
for i in slot_count:
if i >= slots.size() or slots[i] == null:
return i
return slot_count - 1
func _drag_controller() -> Node:
if not is_inside_tree():
return null
return get_tree().get_first_node_in_group("drag_controller")
func _notification(what: int) -> void:
+707 -315
View File
File diff suppressed because it is too large. Load diff
+640
View File
@@ -0,0 +1,640 @@
class_name SortingTable
extends Control
## Стол сортировки: сетка 4×6 (24 клетки) в правой колонке подготовки.
## Читает Game.registry.table_files(); drag folder→table, table→folder,
## table→table. Файл на столе нельзя прикрепить к запросу (это проверяет
## RequestCard), а переполненный стол отклоняет drop тостом «СТОЛ ЗАПОЛНЕН».
##
## Рисуется тем же painter'ом FileShape, что и FileGrid; дочерних узлов на
## файл нет.
signal file_dropped(payload: Dictionary)
signal table_changed
signal drop_rejected(reason: String)
signal file_selected(id: String)
signal selection_changed(id: String)
signal context_requested(id: String)
const FR = preload("res://scripts/core/file_registry.gd")
const FG = preload("res://scripts/ui/file_grid.gd")
const FILE_SHAPE = preload("res://scripts/ui/file_shape.gd")
const DRAG_CONTROLLER = preload("res://scripts/ui/drag_controller.gd")
const GAP := 6.0
const CELL_MIN := 56.0
const CELL_MAX := 110.0
const DEFAULT_COLS := 4
const DEFAULT_ROWS := 6
var drag_controller: Node = null
var _registry = null
var _files: Array = []
var _by_id: Dictionary = {}
var _cells_owner: Dictionary = {}
var _cols: int = DEFAULT_COLS
var _rows: int = DEFAULT_ROWS
var _cell: Vector2 = Vector2(CELL_MAX, CELL_MAX)
var _grid_origin: Vector2 = Vector2.ZERO
var _selected: String = ""
var _hover_id: String = ""
var _legacy_files: Array = []
var _drop_active: bool = false
var _drag_key: String = ""
var _drag_payload: Dictionary = {}
var _drag_rotation: int = 0
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:
mouse_filter = Control.MOUSE_FILTER_STOP
clip_contents = true
mouse_default_cursor_shape = Control.CURSOR_ARROW
mouse_exited.connect(_on_mouse_exited)
resized.connect(_on_resized)
_bind_registry()
refresh()
# --- API -------------------------------------------------------------------
## data == bool → видимость; data == Array → legacy-снимок; иначе refresh().
func set_visible_data(data = null) -> void:
if data is bool:
visible = bool(data)
return
if data is Array:
var arr: Array = data
_legacy_files = arr.duplicate(true)
refresh()
func refresh() -> void:
_bind_registry()
_files.clear()
_by_id.clear()
_cells_owner.clear()
_cols = DEFAULT_COLS
_rows = DEFAULT_ROWS
if _registry != null:
_cols = maxi(1, int(_registry.table_cols))
_rows = maxi(1, int(_registry.table_rows))
for file in _registry.table_files():
_files.append(file)
_by_id[str(file.id)] = file
var cells := FR.shape_cells(str(file.shape_id), int(file.rotation))
for cell in cells:
_cells_owner[Vector2i(file.origin) + Vector2i(cell)] = str(file.id)
_files.sort_custom(func(a, b) -> bool:
return str(a.name).naturalnocasecmp_to(str(b.name)) < 0)
if not _selected.is_empty() and not _by_id.has(_selected):
_selected = ""
selection_changed.emit("")
_recalc_layout()
queue_redraw()
func occupied_cells() -> int:
return _cells_owner.size()
func total_cells() -> int:
return _cols * _rows
func file_count() -> int:
return _files.size()
func footer_text() -> String:
var total := _cols * _rows
return "%d %s" % [total, FG.plural_cells(total)]
func grid_metrics() -> Dictionary:
return {
"cell": _cell,
"cols": _cols,
"rows": _rows,
"origin": _grid_origin,
"gap": GAP,
}
## Статическая математика стола (для тестов/интеграции): 416×548 → 99×86.
static func cell_size(view: Vector2, cols: int, rows: int, gap: float = 6.0,
lo: float = CELL_MIN, hi: float = CELL_MAX) -> Vector2:
return FG.cell_size_pair(view, cols, rows, gap, lo, hi)
func select_file(id: String) -> void:
if id == _selected:
return
_selected = id if _by_id.has(id) else ""
selection_changed.emit(_selected)
queue_redraw()
# --- registry --------------------------------------------------------------
func _bind_registry() -> void:
if not is_inside_tree():
return
var game := get_tree().root.get_node_or_null("Game")
if game == null:
return
var next = game.get("registry")
if next == null:
return
if next != _registry:
_registry = next
if _registry.has_signal("table_changed") \
and not _registry.is_connected("table_changed", Callable(self, "_on_registry_table")):
_registry.connect("table_changed", Callable(self, "_on_registry_table"))
func _on_registry_table() -> void:
refresh()
# --- layout / hit test -----------------------------------------------------
func _on_resized() -> void:
_recalc_layout()
queue_redraw()
func _recalc_layout() -> void:
_cell = FG.cell_size_pair(size, _cols, _rows, GAP, CELL_MIN, CELL_MAX)
var gs := FG.grid_size_pair(_cols, _rows, _cell, GAP)
_grid_origin = Vector2(
maxf(1.0, roundf((size.x - gs.x) * 0.5)),
maxf(1.0, roundf((size.y - gs.y) * 0.5)))
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:
return ""
return str(_cells_owner.get(c, ""))
func _get_cursor_shape(at_position: Vector2) -> int:
if _drag_active:
return Control.CURSOR_DRAG
return Control.CURSOR_POINTING_HAND if not _file_at(at_position).is_empty() \
else Control.CURSOR_ARROW
func _on_mouse_exited() -> void:
if not _hover_id.is_empty():
_hover_id = ""
tooltip_text = ""
queue_redraw()
func _update_hover(pos: Vector2) -> void:
var id := _file_at(pos)
if id == _hover_id:
return
_hover_id = id
queue_redraw()
if id.is_empty():
tooltip_text = ""
else:
var file = _by_id.get(id)
if file != null:
tooltip_text = "%s.%s · на столе" % [str(file.name), str(file.ext)]
# --- ввод ------------------------------------------------------------------
func _gui_input(event: InputEvent) -> void:
if event is InputEventMouseMotion:
_update_hover(event.position)
elif event is InputEventMouseButton and event.pressed:
if event.button_index == MOUSE_BUTTON_LEFT:
var id := _file_at(event.position)
if id.is_empty():
select_file("")
else:
_selected = id
file_selected.emit(id)
selection_changed.emit(id)
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:
var vp := get_viewport()
if vp != null and vp.is_input_handled():
return
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()
vp.set_input_as_handled()
func _rotate_drag() -> void:
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)
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:
var id := _file_at(at_position)
if id.is_empty():
return null
var file = _by_id.get(id)
if file == null:
return null
_drag_active = true
_drag_rotation = posmod(int(file.rotation), 4)
var payload := FG.shape_payload_for(str(file.id), str(file.name), str(file.type),
str(file.ext), str(file.shape_id), int(file.rotation),
Vector2i(file.origin), "table")
_drag_payload = payload
mouse_default_cursor_shape = Control.CURSOR_DRAG
var dc := _drag_controller()
if dc != null:
var preview := FILE_SHAPE.new()
preview.setup(str(file.type), str(file.shape_id), int(file.rotation),
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", 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
var dc := get_tree().get_first_node_in_group("drag_controller")
if dc == null:
dc = DRAG_CONTROLLER.new()
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):
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 -----------------------------------------------------------
func _can_drop_data(at_position: Vector2, data: Variant) -> bool:
if not (data is Dictionary):
return false
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
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)
if dc != null:
dc.set_mode("shape")
return true
func _refresh_ghost(pos: Vector2) -> void:
var payload := _drag_payload
if payload.is_empty():
_ghost_cells.clear()
return
var shape_id := str(payload.get("shape_id", "O1"))
if shape_id.is_empty():
shape_id = "O1"
var target := FG.nearest_cell_rect(pos, _grid_origin, _cell, GAP, _cols, _rows)
var fit := _resolve_placement(payload, target, _drag_rotation)
_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()
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 := str(payload.get("id", ""))
if target.x < 0 or target.y < 0:
var auto := _find_fit(shape_id, preferred, ignore)
if not auto.is_empty():
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(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,
"auto": rot != preferred}
return {
"ok": false,
"origin": FG.anchor_origin(target, shape_id, preferred),
"rotation": preferred,
"auto": false,
}
func _find_fit(shape_id: String, preferred: int, ignore_id: String) -> Dictionary:
for i in 4:
var rot := posmod(preferred + i, 4)
for r in _rows:
for c in _cols:
var origin := Vector2i(c, r)
if _can_place(shape_id, origin, rot, ignore_id):
return {"origin": origin, "rotation": rot}
return {}
func _can_place(shape_id: String, origin: Vector2i, rotation: int,
ignore_id: String) -> bool:
if _registry != null and _registry.has_method("can_place_table"):
return bool(_registry.can_place_table(shape_id, origin, rotation, ignore_id))
for cell in FR.shape_cells(shape_id, rotation):
var c := origin.x + int(cell.x)
var r := origin.y + int(cell.y)
if c < 0 or r < 0 or c >= _cols or r >= _rows:
return false
if not str(_cells_owner.get(Vector2i(c, r), "")).is_empty():
return false
return true
func _drop_data(at_position: Vector2, data: Variant) -> void:
if not (data is Dictionary):
_end_drop()
return
var payload: Dictionary = data
if str(payload.get("kind", "")) != "file":
_end_drop()
return
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()
return
var file_id := str(payload.get("id", ""))
var ok := false
if _registry != null and not file_id.is_empty() \
and _registry.has_method("move_file_to_table"):
ok = bool(_registry.move_file_to_table(file_id, Vector2i(fit["origin"]),
int(fit["rotation"])))
if not ok:
_reject("СТОЛ ЗАПОЛНЕН")
_end_drop()
return
var out := payload.duplicate(true)
out["origin"] = Vector2i(fit["origin"])
out["rotation"] = int(fit["rotation"])
out["from"] = "table"
file_dropped.emit(out)
table_changed.emit()
refresh()
_end_drop()
func _reject(text: String) -> void:
drop_rejected.emit(text)
_notify(text)
func _notify(text: String) -> void:
var node: Node = self
while node != null:
if node.has_method("_toast"):
node.call("_toast", text, "warn")
return
node = node.get_parent()
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()
func _notification(what: int) -> void:
if what == NOTIFICATION_DRAG_END:
var was_dragging := _drag_active
_drag_active = false
_end_drop()
var dc := _existing_drag_controller()
if dc != null:
dc.reset_hover()
if was_dragging:
dc.end()
# --- отрисовка -------------------------------------------------------------
func _draw() -> void:
if size.x < 2.0 or size.y < 2.0:
return
var f := Theme90.ui()
draw_style_box(Theme90.box(Theme90.PAPER_HI, Theme90.BORDER_SOFT, 3, 1),
Rect2(Vector2.ZERO, size))
_draw_grid_lines()
for file in _files:
_draw_file(file)
_draw_ghost(f)
if _files.is_empty():
_draw_empty(f)
_draw_footer(f)
func _draw_grid_lines() -> void:
var gs := FG.grid_size_pair(_cols, _rows, _cell, GAP)
if gs.x <= 0.0 or gs.y <= 0.0:
return
for r in _rows:
for c in _cols:
if (c + r) % 2 == 1:
draw_rect(_cell_rect(c, r), Color(0, 0, 0, 0.016))
var line := Color(0, 0, 0, 0.10)
var step := _cell + Vector2(GAP, GAP)
for c in range(1, _cols):
var x := _grid_origin.x + float(c) * step.x - GAP * 0.5
draw_line(Vector2(x, _grid_origin.y), Vector2(x, _grid_origin.y + gs.y),
line, 1.0, false)
for r in range(1, _rows):
var y := _grid_origin.y + float(r) * step.y - GAP * 0.5
draw_line(Vector2(_grid_origin.x, y), Vector2(_grid_origin.x + gs.x, y),
line, 1.0, false)
draw_rect(Rect2(_grid_origin, gs), line, false, 1.0)
func _draw_file(file) -> void:
var cells := FR.shape_cells(str(file.shape_id), int(file.rotation))
FILE_SHAPE.paint(self, _shape_pos(Vector2i(file.origin)), cells, _cell, {
"type": str(file.type),
"name": str(file.name),
"ext": str(file.ext),
"state": FG.file_state(file),
"selected": str(file.id) == _selected,
"hovered": str(file.id) == _hover_id,
"alpha": 1.0,
"show_label": true,
"gap": GAP,
})
func _draw_ghost(f: Font) -> void:
if not _drop_active or _ghost_cells.is_empty():
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, _shape_pos(_ghost_origin), _ghost_cells, _cell, {
"type": "text",
"alpha": 0.9,
"show_label": false,
"gap": GAP,
"fill": fill,
"outline": color,
})
var b := FR.shape_bounds(_ghost_cells)
var step := _cell + Vector2(GAP, GAP)
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)
Theme90.text_vcenter(self, f, pill, hint, 10, Theme90.NAVY, -1,
HORIZONTAL_ALIGNMENT_CENTER)
func _draw_empty(f: Font) -> void:
var gs := FG.grid_size_pair(_cols, _rows, _cell, GAP)
if gs.x <= 0.0 or gs.y <= 0.0:
return
var box := Rect2(_grid_origin, gs).grow(-10.0)
Theme90.draw_dashed_box(self, box, Color(Theme90.SHADE.r, Theme90.SHADE.g,
Theme90.SHADE.b, 0.40), 5.0, 1.0)
var lines := Theme90.wrap(f, "Здесь можно разложить файлы после часа пика",
12, minf(box.size.x - 24.0, 280.0), 2)
var total_h := float(lines.size()) * 15.0
var y := box.get_center().y - total_h * 0.5
for i in lines.size():
var w := f.get_string_size(lines[i], HORIZONTAL_ALIGNMENT_LEFT, -1, 12).x
Theme90.text(self, f, Vector2(box.get_center().x - w * 0.5, y + float(i) * 15.0),
lines[i], 12, Theme90.SHADE)
func _draw_footer(f: Font) -> void:
draw_rect(Rect2(0, size.y - 16.0, size.x, 16.0), Color(Theme90.PAPER_HI.r,
Theme90.PAPER_HI.g, Theme90.PAPER_HI.b, 0.94))
draw_line(Vector2(3, size.y - 16.0), Vector2(size.x - 3, size.y - 16.0),
Theme90.HAIRLINE, 1.0)
Theme90.text(self, f, Vector2(4, size.y - 12.0), footer_text(), 10,
Theme90.SHADE, size.x - 8.0)
+1
View File
@@ -0,0 +1 @@
uid://d1a87l837swyv
+80 -35
View File
@@ -10,6 +10,7 @@ extends Control
var segments: Array[Dictionary] = []
var _floats: Array[Control] = []
var _buffer_hint: Control = null
var _channel_hint: Control = null
const PAD := 14.0
@@ -17,36 +18,54 @@ const PAD := 14.0
func _ready() -> void:
mouse_filter = Control.MOUSE_FILTER_STOP
segments = [
{"key": "tcp", "label": "TCP", "value": "2 / 3", "w": 132.0, "fill": 0.667},
{"key": "channel", "label": "КАНАЛ", "value": "45%", "w": 222.0, "fill": 0.45},
{"key": "sla", "label": "SLA", "value": "128 / 200", "w": 188.0, "fill": 0.64},
{"key": "buffer", "label": "МЕСТО В ПРИЁМЕ", "value": "3 / 4", "w": 158.0, "fill": 0.75},
{"key": "money", "label": "СЧЁТ", "value": "$ 1 240", "w": 176.0},
{"key": "hour", "label": "СМЕНА", "value": "ЧАС 3 03:42", "w": 356.0, "fill": 0.463},
{"key": "tcp", "label": "TCP", "value": "0 / 1", "w": 132.0, "fill": 0.0},
{"key": "channel", "label": "КАНАЛ", "value": "0/10", "w": 222.0, "fill": 0.0,
"danger": false},
{"key": "sla", "label": "SLA", "value": "60 / 60", "w": 188.0, "fill": 1.0},
{"key": "buffer", "label": "МЕСТО В ПРИЁМЕ", "value": "0 / 6", "w": 158.0, "fill": 0.0},
{"key": "money", "label": "СЧЁТ", "value": "$ 50", "w": 176.0},
{"key": "hour", "label": "СМЕНА", "value": "ЧАС 1 05:00", "w": 356.0, "fill": 0.0},
]
_buffer_hint = Control.new()
_buffer_hint.name = "BufferHint"
_buffer_hint.mouse_filter = Control.MOUSE_FILTER_PASS
_buffer_hint.mouse_default_cursor_shape = Control.CURSOR_HELP
_buffer_hint.focus_mode = Control.FOCUS_NONE
add_child(_buffer_hint)
_buffer_hint = _make_hint("BufferHint", "buffer")
_channel_hint = _make_hint("ChannelHint", "channel")
resized.connect(_layout_hint)
call_deferred("_layout_hint")
## Подсказка «что такое буфер» с текущим значением сегмента.
func _buffer_tooltip() -> String:
var raw := ""
func _make_hint(node_name: String, key: String) -> Control:
var hint := Control.new()
hint.name = node_name
hint.mouse_filter = Control.MOUSE_FILTER_PASS
hint.mouse_default_cursor_shape = Control.CURSOR_HELP
hint.focus_mode = Control.FOCUS_NONE
add_child(hint)
hint.tooltip_text = _tooltip_for(key)
return hint
func _metric_value(key: String) -> String:
for s in segments:
if str(s["key"]) == "buffer":
raw = str(s["value"])
break
if str(s["key"]) == key:
return str(s["value"])
return ""
func _metric_pair(key: String) -> Vector2i:
var raw := _metric_value(key)
var parts := raw.split("/")
var used := 0
var cap := 0
if parts.size() == 2:
used = parts[0].strip_edges().to_int()
cap = parts[1].strip_edges().to_int()
return Vector2i(used, cap)
## Подсказка «что такое буфер» с текущим значением сегмента.
func _buffer_tooltip() -> String:
var pair := _metric_pair("buffer")
var used := pair.x
var cap := pair.y
var out := "Буфер (место в приёме) — сколько пакетов можно держать одновременно\n"
out += "Сейчас: %d / %d" % [used, cap]
if cap > 0:
@@ -56,24 +75,42 @@ func _buffer_tooltip() -> String:
return out
## Прозрачная зона над сегментом буфера: только подсказка, без отрисовки.
## Подсказка канала (M19): почему передачи замедлились.
func _channel_tooltip() -> String:
var pair := _metric_pair("channel")
var out := "Канал — суммарный вес активных приёма и отправки.\n"
out += "Сейчас: %d / %d" % [pair.x, pair.y]
if pair.y > 0 and pair.x > pair.y:
out += "\nКанал перегружен: передачи идут медленнее, пока нагрузка выше нормы."
elif pair.y > 0 and pair.x == pair.y:
out += "\nКанал загружен полностью: следующая передача начнёт замедлять остальные."
else:
out += "\nЧем больше файлов одновременно, тем медленнее каждая передача."
return out
func _tooltip_for(key: String) -> String:
if key == "channel":
return _channel_tooltip()
return _buffer_tooltip()
## Прозрачные зоны над сегментами: только подсказки, без отрисовки.
func _layout_hint() -> void:
if _buffer_hint == null:
return
var idx := -1
for i in segments.size():
if str(segments[i]["key"]) == "buffer":
idx = i
break
if idx < 0:
return
var rects := _segment_rects()
if idx >= rects.size():
return
var r: Rect2 = rects[idx]
_buffer_hint.position = Vector2(r.position.x, 0.0)
_buffer_hint.size = Vector2(r.size.x, size.y)
_buffer_hint.tooltip_text = _buffer_tooltip()
for i in segments.size():
var key := str(segments[i]["key"])
var hint: Control = null
if key == "buffer":
hint = _buffer_hint
elif key == "channel":
hint = _channel_hint
if hint == null or i >= rects.size():
continue
var r: Rect2 = rects[i]
hint.position = Vector2(r.position.x, 0.0)
hint.size = Vector2(r.size.x, size.y)
hint.tooltip_text = _tooltip_for(key)
func _segment_rects() -> Array[Rect2]:
@@ -138,6 +175,8 @@ func _is_numeric(s: String) -> bool:
func _gauge_color(s: Dictionary) -> Color:
if bool(s.get("danger", false)):
return Theme90.ERROR
match str(s["key"]):
"channel":
var fill := float(s.get("fill", 0.0))
@@ -162,6 +201,8 @@ func _gauge_color(s: Dictionary) -> Color:
func _value_color(s: Dictionary) -> Color:
if bool(s.get("danger", false)):
return Theme90.ERROR
match str(s["key"]):
"channel":
var fill := float(s.get("fill", 0.0))
@@ -185,14 +226,18 @@ func _value_color(s: Dictionary) -> Color:
return Theme90.INK
func set_metric(key: String, value: String, fill: float = -1.0) -> void:
func set_metric(key: String, value: String, fill: float = -1.0,
danger: bool = false) -> void:
for s in segments:
if str(s["key"]) == key:
s["value"] = value
if fill >= 0.0:
s["fill"] = fill
s["danger"] = danger
if key == "buffer" and _buffer_hint != null:
_buffer_hint.tooltip_text = _buffer_tooltip()
elif key == "channel" and _channel_hint != null:
_channel_hint.tooltip_text = _channel_tooltip()
queue_redraw()
return
+75 -15
View File
@@ -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:
+21 -8
View File
@@ -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:
+181 -20
View File
@@ -5,8 +5,10 @@ extends Control
signal folder_selected(folder_id: String)
signal file_dropped(folder_id: String, payload: Dictionary)
signal packet_dropped(folder_id: String, payload: Dictionary)
signal context_menu_requested(folder_id: String, global_pos: Vector2)
signal new_folder_requested
signal hover_open_requested(folder_id: String)
var _view: Control
var _content: Control
@@ -14,6 +16,7 @@ var _bar: ScrollBar90
var _new_btn: Button90
var _rows: Array[Dictionary] = []
var _selected := ""
var _drag: Node = null
func _ready() -> void:
@@ -44,6 +47,8 @@ func _ready() -> void:
resized.connect(_relayout)
_relayout()
call_deferred("_bind_drag_controller")
set_process(true)
func _view_rect() -> Rect2:
@@ -79,7 +84,8 @@ func _content_height() -> float:
return maxf(y + 2.0, 0.0)
## sections: [{id, name, free, folders: [{id, name, count, capacity, locked}]}]
## sections: [{id, name, free|free_cells, folders: [
## {id, name, count|occupied_cells, capacity|total_cells, files, locked}]}]
func set_data(sections: Array) -> void:
for c in _content.get_children():
c.queue_free()
@@ -88,7 +94,7 @@ func set_data(sections: Array) -> void:
var sid: String = str(sec.get("id", ""))
var srow := FolderRow.new()
srow.setup(sid, str(sec.get("name", "")), 0, true)
srow.free_text = str(sec.get("free", ""))
srow.free_text = _section_free_text(sec)
srow.custom_minimum_size = Vector2(0, FolderRow.SECTION_H)
srow.expanded = true
srow.expansion_toggled.connect(_on_toggle)
@@ -100,22 +106,93 @@ func set_data(sections: Array) -> void:
var frow := FolderRow.new()
var fid: String = str(fol.get("id", ""))
frow.setup(fid, str(fol.get("name", "")), 1, false)
frow.count = int(fol.get("count", 0))
frow.capacity = int(fol.get("capacity", 16))
frow.tooltip_text = "Папка заполнена" if frow.count >= frow.capacity \
else "%d из %d объектов" % [frow.count, frow.capacity]
frow.count = _folder_occupied(fol)
frow.capacity = _folder_total(fol)
frow.files_count = _folder_files(fol)
frow.tooltip_text = _folder_tooltip(frow.files_count, frow.count, frow.capacity)
frow.locked = bool(fol.get("locked", false))
frow.custom_minimum_size = Vector2(0, FolderRow.ROW_H)
frow.folder_selected.connect(_on_folder_clicked)
frow.expansion_toggled.connect(_on_toggle)
frow.file_dropped.connect(_on_drop)
frow.packet_dropped.connect(_on_packet_drop)
frow.context_menu_requested.connect(_on_context)
frow.hover_open_requested.connect(_on_row_hover_open)
_content.add_child(frow)
_rows.append({"row": frow, "section": sid, "visible": true})
_apply_layout()
_relayout()
if not _selected.is_empty():
select_folder(_selected)
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:
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 ""
func _folder_occupied(fol: Dictionary) -> int:
return _pick_int(fol, ["occupied_cells", "occupied", "count_cells", "count"], 0)
func _folder_total(fol: Dictionary) -> int:
var total := _pick_int(fol, ["total_cells", "cells_total", "capacity"], 16)
return total if total > 0 else 16
func _folder_files(fol: Dictionary) -> int:
return _pick_int(fol, ["files", "file_count", "count"], 0)
func _pick_int(fol: Dictionary, keys: Array, fallback: int) -> int:
for key in keys:
var value = fol.get(key, null)
if value is int or value is float:
return int(value)
return fallback
func _folder_tooltip(files: int, occupied: int, total: int) -> String:
return "%d %s · %d из %d клеток" % [files, _plural_files(files), occupied, total]
func _apply_layout() -> void:
@@ -167,8 +244,34 @@ func update_count(folder_id: String, count: int) -> void:
for e in _rows:
var row: FolderRow = e["row"]
if not row.is_section and row.folder_id == folder_id:
row.count = count
row.files_count = count
row.tooltip_text = _folder_tooltip(row.files_count, row.count, row.capacity)
row.queue_redraw()
break
func update_stats(folder_id: String, files: int, occupied: int, total: int) -> void:
for e in _rows:
var row: FolderRow = e["row"]
if not row.is_section and row.folder_id == folder_id:
row.files_count = files
row.count = occupied
row.capacity = total if total > 0 else row.capacity
row.tooltip_text = _folder_tooltip(row.files_count, row.count, row.capacity)
row.queue_redraw()
queue_redraw()
break
func footer_text() -> String:
var folders := 0
var files := 0
for e in _rows:
var row: FolderRow = e["row"]
if not row.is_section:
folders += 1
files += row.files_count
return "%d %s · %d %s" % [folders, _plural_folders(folders), files, _plural_files(files)]
func _on_toggle(folder_id: String) -> void:
@@ -185,10 +288,79 @@ func _on_drop(folder_id: String, payload: Dictionary) -> void:
file_dropped.emit(folder_id, payload)
func _on_packet_drop(folder_id: String, payload: Dictionary) -> void:
packet_dropped.emit(folder_id, payload)
func _on_context(folder_id: String, global_pos: Vector2) -> void:
context_menu_requested.emit(folder_id, global_pos)
## DragController: 250 мс над папкой истекли — раскрываем секцию и визуал.
func _bind_drag_controller() -> void:
if not is_inside_tree():
return
var dc := get_tree().get_first_node_in_group("drag_controller")
if dc == null or _drag == dc:
return
_drag = dc
if dc.has_signal("hover_open") \
and not dc.is_connected("hover_open", Callable(self, "_on_hover_open")):
dc.connect("hover_open", Callable(self, "_on_hover_open"))
func _process(_delta: float) -> void:
if _drag != null and is_instance_valid(_drag):
set_process(false)
return
_drag = null
_bind_drag_controller()
if _drag != null:
set_process(false)
func _on_hover_open(folder_id: String) -> void:
var row := _row_for(folder_id)
if row == null:
return
row.set_hover(true)
## FolderRow.hover_open_requested: открываем папку без повторного set_hover.
func _on_row_hover_open(folder_id: String) -> void:
if _selected != folder_id:
select_folder(folder_id)
_expand_section_of(folder_id)
folder_selected.emit(folder_id)
hover_open_requested.emit(folder_id)
func _row_for(folder_id: String) -> FolderRow:
for e in _rows:
var row: FolderRow = e["row"]
if not row.is_section and row.folder_id == folder_id:
return row
return null
func _expand_section_of(folder_id: String) -> void:
var target_section := ""
for e in _rows:
var row: FolderRow = e["row"]
if not row.is_section and row.folder_id == folder_id:
target_section = str(e.get("section", ""))
break
if target_section.is_empty():
return
for e in _rows:
var row: FolderRow = e["row"]
if row.is_section and row.folder_id == target_section and not row.expanded:
row.expanded = true
_apply_layout()
_relayout()
return
func _gui_input(event: InputEvent) -> void:
if event is InputEventMouseButton and event.pressed:
if event.button_index == MOUSE_BUTTON_WHEEL_UP:
@@ -205,17 +377,6 @@ func _draw() -> void:
var f := Theme90.ui()
draw_style_box(Theme90.box(Theme90.PAPER_HI, Theme90.BORDER_SOFT, 3, 1), vr)
# подвал: одна спокойная строка вместо двух «счётчиков»
var folders := 0
var objs := 0
for e in _rows:
var row: FolderRow = e["row"]
if not row.is_section:
folders += 1
objs += row.count
# подвал: папки · файлы · занятые клетки
Theme90.text(self, f, Vector2(fr.position.x + 6, fr.position.y + 1),
"%d папок · %d объектов" % [folders, objs], 10, Theme90.SHADE, fr.size.x - 120)
var free := "8,5 МБ"
var fw := f.get_string_size(free, HORIZONTAL_ALIGNMENT_LEFT, -1, 10).x
Theme90.text(self, f, Vector2(fr.end.x - fw - 6, fr.position.y + 1), free, 10,
Theme90.SHADE)
footer_text(), 10, Theme90.SHADE, fr.size.x - 6.0)
+44 -17
View File
@@ -4,8 +4,8 @@ extends SceneTree
## godot --headless --path . --script scripts/ui/ui_smoke.gd
##
## Сценарий: меню → start_new_run() (подготовка) → _on_start_hour() (час).
## Проверяет геометрию каркаса, наполнение дерева/сетки/панелей,
## содержимое статус-бара и появление 4 TCP-слотов.
## Проверяет геометрию каркаса 1280×720, наполнение дерева/сетки/панелей,
## сетку папки 5×4, стол сортировки 4×6, 5 ячеек слотов и статус-бар.
const W := 1280.0
const H := 720.0
@@ -108,9 +108,17 @@ func _check_layout() -> void:
_check(not (_nodes[order[i]] as Rect2).intersects(_nodes[order[j]]),
"%s и %s не перекрываются" % [order[i], order[j]])
var grid_w := 4.0 * 132.0 + 3.0 * 8.0
var grid: FileGrid = _main.get("grid")
var view_w: float = (_nodes["FileGrid"].size.x - 18.0) if _nodes.has("FileGrid") else 0.0
_check(grid_w <= view_w, "4×132+3×8=%.0f ≤ ширины поля %.0f" % [grid_w, view_w])
if grid != null:
var gm: Dictionary = grid.grid_metrics()
var cell := float(gm.get("cell", 0.0))
var gap := float(gm.get("gap", 6.0))
var grid_w := 5.0 * cell + 4.0 * gap
_check(int(gm.get("cols", 0)) == 5 and int(gm.get("rows", 0)) == 4,
"сетка папки 5×4 (сейчас %s×%s)" % [gm.get("cols", 0), gm.get("rows", 0)])
_check(grid_w <= view_w + 0.5,
"5×cell+4×gap=%.0f ≤ ширины поля %.0f" % [grid_w, view_w])
func _check_menu() -> void:
@@ -176,6 +184,19 @@ func _check_prep() -> void:
print("сетка: %d файлов" % cards.size())
_check(cards.is_empty(), "в сетке 0 файлов на старте подготовки (сейчас %d)" % cards.size())
_check((_main.get("grid") as FileGrid).folder_name.length() > 0, "в сетке выбрана папка")
var grid_stats: Dictionary = (_main.get("grid") as FileGrid).stats()
_check(int(grid_stats.get("total", 0)) == 20, "сетка 5×4 = 20 клеток")
var table = _main.get("sorting_table")
_check(table != null and bool(table.visible), "в подготовке виден стол сортировки")
if table != null:
_check(int(table.total_cells()) == 24, "стол сортировки 4×6 = 24 клетки")
var tm: Dictionary = table.grid_metrics()
_check(int(tm.get("cols", 0)) == 4 and int(tm.get("rows", 0)) == 6,
"стол 4×6 (сейчас %s×%s)" % [tm.get("cols", 0), tm.get("rows", 0)])
var recv_hidden: WindowFrame = _main.get_node("ReceiveWindow")
var send_hidden: WindowFrame = _main.get_node("SendWindow")
_check(not recv_hidden.visible and not send_hidden.visible,
"ПРИЁМ/ОТПРАВКА скрыты в подготовке")
var files_win: WindowFrame = _main.get("files_window")
_check(files_win.title.contains((_main.get("grid") as FileGrid).folder_name),
"заголовок ФАЙЛЫ содержит имя папки (%s)" % files_win.title)
@@ -200,8 +221,8 @@ func _check_prep() -> void:
str(sb.get_metric("buffer").get("value", "")),
str(sb.get_metric("channel").get("value", "")),
str(sb.get_metric("hour").get("value", ""))])
_check(money.contains("$ 150"), "статус: счёт «$ 150» (сейчас «%s»)" % money)
_check(sla.contains("128 / 200"), "статус: SLA «128 / 200» (сейчас «%s»)" % sla)
_check(money.contains("$ 50"), "статус: счёт «$ 50» (сейчас «%s»)" % money)
_check(sla.contains("60 / 60"), "статус: SLA «60 / 60» (сейчас «%s»)" % sla)
var shop_btn = _main.get("_shop_btn")
_check(shop_btn != null and not bool(shop_btn.visible),
"в первом приготовлении кнопка МАГАЗИН скрыта")
@@ -209,8 +230,8 @@ func _check_prep() -> void:
"статус: TCP «0 / N»")
_check(str(sb.get_metric("buffer").get("value", "")).begins_with("0 /"),
"статус: буфер «0 / N»")
_check(str(sb.get_metric("channel").get("value", "")).ends_with("%"),
"статус: канал в процентах")
_check(str(sb.get_metric("channel").get("value", "")).contains("/"),
"статус: канал вида load/capacity")
# --------------------------------------------------------------------------
@@ -224,12 +245,12 @@ func _check_hour() -> void:
var cells: Array = _main.get("slot_cells")
print("слоты: %d" % cells.size())
_check(cells.size() == 4, "slot_cells = 4 (сейчас %d)" % cells.size())
_check(cells.size() == 5, "slot_cells = 5 (сейчас %d)" % cells.size())
var strip: Panel90 = _main.get_node("SlotsStrip")
var nodes := _find_all(strip, "SlotCell")
_check(nodes.size() == 4, "в полосе 4 SlotCell (сейчас %d)" % nodes.size())
_check(nodes.size() == 5, "в полосе 5 SlotCell (сейчас %d)" % nodes.size())
var strip_rect := Rect2(strip.global_position, strip.size)
for i in mini(cells.size(), 4):
for i in mini(cells.size(), 5):
var cell: SlotCell = cells[i]
var r := Rect2(cell.global_position, cell.size)
_check(r.size == Vector2(236, 44), "слот %d: 236×44 (сейчас %.0f×%.0f)" % [i, r.size.x, r.size.y])
@@ -249,15 +270,21 @@ func _check_hour() -> void:
_check(tcp.begins_with("0 /"), "статус[час]: TCP вида «0 / N» (сейчас «%s»)" % tcp)
_check(hour.contains("ЧАС"), "статус[час]: подпись «ЧАС N» (сейчас «%s»)" % hour)
_check(money.contains("$"), "статус[час]: счёт в долларах")
_check(sla.contains("/ 200"), "статус[час]: SLA из 200")
_check(sla.contains("/ 60"), "статус[час]: SLA из 60")
var shop_btn = _main.get("_shop_btn")
_check(shop_btn != null and not bool(shop_btn.visible),
"во время часа кнопка МАГАЗИН скрыта")
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 НАЧАЛСЯ» показан")
@@ -298,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")
# --------------------------------------------------------------------------
+57 -132
View File
@@ -1,154 +1,79 @@
class_name VhsOverlay
extends RefCounted
## Процедурный VHS-слой главного меню. НЕ создаёт Node/Control и не читает
## screen texture.
extends ColorRect
## Нативный VHS/CRT-слой главного меню.
##
## Меню рисует его прямо в MainMenu._draw() в два прохода:
## бумага → VHS фона → карточка/панель/фигуры → VHS по ним → текст.
## Поэтому слой не перекрывает меню, не перехватывает мышь и не может
## затереть кадр фоном подготовки. Кнопки — дети MainMenu, они поверх.
## Полноэкранный ColorRect с canvas_item-шейдером: читает готовый кадр через
## hint_screen_texture и добавляет скан-линии (2 физических px), очень слабое
## зерно, хроматику ≤0.0003 UV, виньетку и редкую полосу. Считается в
## разрешении фактического окна (1920×1080), а не в логических 1280×720,
## поэтому линии ровные, без блоков и banding; текст остаётся резким.
##
## Состояние и параметры здесь; рисование вызывает меню:
## vhs.draw_layer(self, full_rect)
## vhs.advance(delta) + queue_redraw() при анимации
## Слой — ребёнок MainMenu и последний в дереве: он фильтрует меню (включая
## кнопки), но соседние модалки в modal_root рисуются поверх и не фильтруются.
##
## В HEADLESS текстуры не создаются и draw_layer() ничего не рисует.
## В HEADLESS материал не создаётся и слой скрыт — тесты не тормозят.
## Скан-линии: шаг 2 логических px (при 1920×1080 — 3 физ. px), 1 px затемнения.
const SCANLINE_STEP := 2.0
const SCANLINE_STRENGTH := 0.07
const GRAIN_SIZE := 128
const VIGNETTE_W := 320
const VIGNETTE_H := 180
const SHADER_PATH := "res://shaders/vhs.gdshader"
const DARK := Color(0.035, 0.045, 0.05)
const LIGHT := Color(0.99, 0.99, 0.96)
## Включён ли эффект. Отключается кодом: menu.set_vhs_enabled(false)
## или напрямую overlay.set_enabled(false).
var enabled := true:
set(value):
enabled = value
_refresh()
## Включён ли эффект. Отключается кодом: menu.set_vhs_enabled(false).
var enabled := true
## Общая сила слоя, 0.0..0.55.
var intensity := 0.42
## Внутреннее время анимации (полоса и зерно).
var time := 0.0
## Общая сила слоя, 0.0..0.55. Выше 0.55 текст начинает терять контраст.
var intensity := 0.40:
set(value):
intensity = clampf(value, 0.0, 0.55)
if _mat != null:
_mat.set_shader_parameter("intensity", intensity)
var _grain: ImageTexture = null
var _vignette: ImageTexture = null
var _band: ImageTexture = null
var _ready_ok := false
var _mat: ShaderMaterial = null
func _init() -> void:
# В headless не тратим время на генерацию текстур и анимацию.
_ready_ok = DisplayServer.get_name() != "headless"
if not _ready_ok:
return
_build_grain()
_build_vignette()
_build_band()
func _ready() -> void:
process_mode = Node.PROCESS_MODE_ALWAYS
mouse_filter = Control.MOUSE_FILTER_IGNORE
focus_mode = Control.FOCUS_NONE
color = Color(1, 1, 1, 1)
set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT)
_build_material()
## Публичный переключатель: true — эффект виден, false — слой полностью скрыт.
func set_enabled(value: bool) -> void:
enabled = value
func is_available() -> bool:
return _ready_ok
return _mat != null
## Продвинуть время анимации. Вызывается меню из _process.
func advance(delta: float) -> void:
if enabled and _ready_ok:
time += delta
## Нарисовать VHS в rect. Canvas — обычно сам MainMenu.
##
## over_content=false — проход по бумажному фону: скан-линии, зерно,
## виньетка и полоса. over_content=true — мягкий проход по карточке и панели
## ДО отрисовки текста: чуть слабее, без виньетки, но с той же полосой.
func draw_layer(canvas: CanvasItem, rect: Rect2, over_content: bool = false) -> void:
if not enabled or not _ready_ok:
func _build_material() -> void:
# В headless экранный шейдер не нужен и только замедляет тесты.
if DisplayServer.get_name() == "headless":
visible = false
set_process(false)
return
var k := 0.72 if over_content else 1.0
# 1. Скан-линии: 1 px затемнения каждые 2 px.
var scan := Color(DARK.r, DARK.g, DARK.b, SCANLINE_STRENGTH * intensity * k)
var y := rect.position.y
while y < rect.end.y:
canvas.draw_rect(Rect2(rect.position.x, y, rect.size.x, 1.0), scan)
y += SCANLINE_STEP
# 2. Зерно: тайл 128×128, обновляется 2 раза в секунду.
var step := int(floor(time * 2.0))
var off := Vector2(float((step * 37) % GRAIN_SIZE), float((step * 53) % GRAIN_SIZE))
canvas.draw_texture_rect(_grain,
Rect2(rect.position - off, rect.size + off), true,
Color(1, 1, 1, intensity * k))
# 3. Виньетка — только у краёв кадра, не на карточке.
if not over_content:
canvas.draw_texture_rect(_vignette, rect, false, Color(1, 1, 1, intensity))
# 4. Редкая полоса прокрутки: медленно идёт сверху вниз с лёгким дрожанием.
# Позиция считается по логической высоте кадра (720), поэтому полоса
# совпадает на бумаге и на карточке; rect только обрезает её.
var band_h := 96.0
var span := 720.0 + band_h * 2.0
var band_top := fposmod(time * 0.055, 1.0) * span - band_h
var top := maxf(band_top, rect.position.y)
var bottom := minf(band_top + band_h, rect.end.y)
if bottom > top:
var v0 := (top - band_top) / band_h
var v1 := (bottom - band_top) / band_h
var src := Rect2(0.0, v0 * _band.get_height(), _band.get_width(),
(v1 - v0) * _band.get_height())
var jitter := sin(time * 0.7) * 4.0
canvas.draw_texture_rect_region(_band,
Rect2(rect.position.x + jitter, top, rect.size.x, bottom - top),
src, Color(1, 1, 1, intensity * k))
var shader := load(SHADER_PATH) as Shader
if shader == null:
push_warning("VHS: не удалось загрузить %s" % SHADER_PATH)
visible = false
return
_mat = ShaderMaterial.new()
_mat.shader = shader
_mat.set_shader_parameter("intensity", intensity)
material = _mat
_refresh()
func _build_grain() -> void:
var img := Image.create(GRAIN_SIZE, GRAIN_SIZE, false, Image.FORMAT_RGBA8)
for yy in GRAIN_SIZE:
for xx in GRAIN_SIZE:
var n := float((xx * 73 + yy * 151 + 31) % 97) / 97.0
var c := DARK if n < 0.5 else LIGHT
var a := absf(n - 0.5) * 2.0 * 0.05
img.set_pixel(xx, yy, Color(c.r, c.g, c.b, a))
_grain = ImageTexture.create_from_image(img)
func _build_vignette() -> void:
var img := Image.create(VIGNETTE_W, VIGNETTE_H, false, Image.FORMAT_RGBA8)
for yy in VIGNETTE_H:
for xx in VIGNETTE_W:
var q := Vector2(float(xx) / VIGNETTE_W - 0.5,
float(yy) / VIGNETTE_H - 0.5)
q.x *= 16.0 / 9.0
var v := smoothstep(0.34, 0.94, q.length())
img.set_pixel(xx, yy, Color(DARK.r, DARK.g, DARK.b, v * 0.13))
_vignette = ImageTexture.create_from_image(img)
func _build_band() -> void:
const BW := 128
const BH := 32
var img := Image.create(BW, BH, false, Image.FORMAT_RGBA8)
for yy in BH:
var cy := absf(float(yy) / (BH - 1) - 0.5) * 2.0
var band_a := (1.0 - cy * cy) * 0.12
for xx in BW:
# слабые вертикальные штрихи — при дрожании полоса «плывёт»
var streak := 0.85 + 0.15 * float((xx * 29) % 17) / 17.0
if yy == 0:
# светлая ведущая кромка — «трекинг» кассеты
img.set_pixel(xx, yy, Color(LIGHT.r, LIGHT.g, LIGHT.b,
0.45 * streak))
elif yy == BH - 1:
img.set_pixel(xx, yy, Color(LIGHT.r, LIGHT.g, LIGHT.b,
0.28 * streak))
else:
img.set_pixel(xx, yy, Color(DARK.r, DARK.g, DARK.b,
band_a * streak))
_band = ImageTexture.create_from_image(img)
func _refresh() -> void:
# Сеттер может сработать до _ready — тогда состоянием займётся _ready.
if not is_node_ready():
return
var on: bool = enabled and _mat != null
visible = on
set_process(on)
# TIME в canvas_item шейдере обновляется движком автоматически, пока
# слой видим; ручных uniform-обновлений не нужно.
+82
View File
@@ -0,0 +1,82 @@
shader_type canvas_item;
render_mode unshaded;
// Нативный VHS/CRT-слой главного меню.
//
// Считается в ФИЗИЧЕСКИХ пикселях окна: скан-линии идут по FRAGCOORD,
// поэтому при 1920×1080 шаг ровно 2 px, без блоков и banding. Экранная
// текстура читается с линейной фильтрацией 1:1 — буквы не размываются.
// Хроматическая аберрация минимальна (0.0003 UV ≈ 0.6 px на краю кадра)
// и не сдвигает зелёный канал, поэтому форма букв остаётся резкой.
uniform sampler2D screen_texture : hint_screen_texture, repeat_disable, filter_linear;
uniform float intensity : hint_range(0.0, 0.55) = 0.40;
// Скан-линии: 2 физических px, затемнение до 6%.
uniform float scanline_strength : hint_range(0.0, 0.12) = 0.06;
uniform float scanline_step : hint_range(2.0, 3.0) = 2.0;
// Хроматика: очень мягкая, чтобы не двоить текст.
uniform float aberration : hint_range(0.0, 0.0006) = 0.0003;
// Зерно: ±0.025, обновление 2 Гц.
uniform float grain : hint_range(0.0, 0.04) = 0.025;
// Виньетка по краям кадра.
uniform float vignette : hint_range(0.0, 0.18) = 0.11;
// Медленная полоса прокрутки: только яркость, без сдвига картинки.
uniform float band_strength : hint_range(0.0, 0.05) = 0.04;
uniform float band_speed : hint_range(0.01, 0.3) = 0.055;
const vec3 BAND_TINT = vec3(0.85, 1.0, 1.0);
float hash21(vec2 p) {
p = fract(p * vec2(123.34, 456.21));
p += dot(p, p + 45.32);
return fract(p.x * p.y);
}
void fragment() {
vec2 res = 1.0 / max(SCREEN_PIXEL_SIZE, vec2(1e-6));
vec2 uv = SCREEN_UV;
// Хроматика: R/B расходятся к краям, G сэмплится ровно по UV.
float edge = 0.35 + abs(uv.x - 0.5) * 1.3;
vec2 shift = vec2(aberration * edge, 0.0);
vec3 col;
col.r = texture(screen_texture, uv + shift).r;
col.g = texture(screen_texture, uv).g;
col.b = texture(screen_texture, uv - shift).b;
// Скан-линии по физическим строкам. FRAGCOORD — центр пикселя (n+0.5),
// поэтому фазу берём от floor(y)+1, иначе cos((n+0.5)·PI) == 0.
float row = floor(FRAGCOORD.y);
float line = 0.5 + 0.5 * cos((row + 1.0) * (6.2831853 / scanline_step));
col *= 1.0 - scanline_strength * line;
// Зерно: пиксельный шум, обновляется 2 раза в секунду.
float gt = floor(TIME * 2.0);
float n = hash21(floor(FRAGCOORD.xy) + vec2(gt * 31.7, gt * 17.3));
col += (n - 0.5) * grain;
// Виньетка с поправкой на соотношение сторон.
vec2 q = uv - 0.5;
q.x *= res.x / max(res.y, 1.0);
col *= 1.0 - vignette * smoothstep(0.35, 0.95, length(q));
// Полоса: медленно идёт сверху вниз. Меняет только яркость и не двигает
// картинку, поэтому текст под ней не «плывёт».
float band_y = fract(TIME * band_speed + 0.15);
float d1 = abs(fract(uv.y - band_y + 0.5) - 0.5);
float d2 = abs(fract(uv.y - band_y - 0.5 + 0.5) - 0.5);
float band = exp(-pow(d1 / 0.045, 2.0))
+ 0.45 * exp(-pow(d2 / 0.035, 2.0));
col += band * band_strength * BAND_TINT;
vec3 base = texture(screen_texture, uv).rgb;
COLOR = vec4(mix(base, col, intensity), 1.0);
}
+1
View File
@@ -0,0 +1 @@
uid://kqjc8uchsx0s
Loaded 100 of 130 files, more files were not shown because too many files have changed in this diff. Show more