Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f0ba5fd2b9 | ||
|
|
a2e7456362 | ||
|
|
417a352823 | ||
|
|
6cb2639055 | ||
|
|
44510e2926 | ||
|
|
ed1ecb7d1c | ||
|
|
2191545629 | ||
|
|
9cb983a9b5 | ||
|
|
39846421bc |
No files matched your search
@@ -1,4 +1,4 @@
|
||||
# AI_REPORT — PEAK HOUR 97
|
||||
# AI_REPORT — PEAK HOUR 97 (v3.0)
|
||||
|
||||
## 1. Инструменты и модели
|
||||
|
||||
@@ -13,66 +13,218 @@ test-writer, explorer, feis-regulator, oracle):
|
||||
| **MiMo-V2.6-Flash** | аудио: SFX, ambient, фон |
|
||||
| дефолтные модели субагентов | поиск, тесты, ревью |
|
||||
|
||||
MR-ревью — агент **oracle** (`opencode-go/deepseek-v4.1-flash`): dry-run
|
||||
MR-ревью — агент **oracle** (opencode-go/deepseek-v4.1-flash): dry-run
|
||||
74–90 с, комментарий в MR, без мержа.
|
||||
|
||||
## 2. Где применялся ИИ
|
||||
## 2. Что изменилось в v3.0
|
||||
|
||||
- **Код:** `GameController`, `HourDirector`, `AttackDirector`, `Shop`,
|
||||
`SaveManager`, UI, мини-игры.
|
||||
- **UI v2:** Kaleidos, маскот **БАЙТ**, туториал.
|
||||
- **Ambient:** 4 WAV (`audio/amb`, PSU/fan/room/seek), генератор
|
||||
`generate_ambient.py`, autoload после первого жеста, mute/пауза/громкость.
|
||||
- **VHS:** процедурный `vhs_overlay.gd` (`RefCounted`) в `MainMenu._draw`;
|
||||
скан-линии/зерно/виньетка, без shader/screen texture, ввод не блокирует,
|
||||
текст резкий; только меню, чекбокс в настройках, HEADLESS без рендера.
|
||||
- **Баланс/мета:** SLA, 10 апгрейдов, таймеры, канал, мета 3/5/9.
|
||||
- **Аудит:** calm-фон, Esc итогов, уничтожение, тосты/fullscreen.
|
||||
- **Тесты/доки/звук:** unit/fuzz/интеграция/полный забег; `docs/`;
|
||||
18 SFX + `hum.wav` на Python.
|
||||
- **Запросы:** 1–4 **конкретных именованных файла** вместо анонимных слотов;
|
||||
имена на карточке 2×2, прикрепление по точному `file_id`, «ПОТЕРЯН» для
|
||||
пропавшего файла, жёсткая резервация. Новые `request_mix` H1–H9 и
|
||||
`request_cap`; H1-затравка и анти-голодание.
|
||||
- **TCP:** возвращена полоска соединения (3 попадания, 440/340/280 px/с,
|
||||
формула зоны, промах −4 SLA / 8 с, Esc 0 SLA / 4 с). TCP платит ×1.15,
|
||||
UDP — ×0.85 и передаёт ×0.8.
|
||||
- **Мини-игры:** 6 отправки + 4 атаки; игра с соединением проводов удалена;
|
||||
у отправки 3 ступени (старые T2–T4), у атак 4. Выбор — по сумме
|
||||
коэффициентов и таблице `rank_games` на 27 достижимых сумм.
|
||||
- **Магазин:** 8 лотов, дерево **8 085 $**, SLA до **220**, ускорение ×0.90,
|
||||
щит с 70 % награды, папки до 9×6, новая папка 180 $ (в v3.1 — 9 лотов и
|
||||
8 825 $, см. §10).
|
||||
- **UI:** Golos Text для тела текста, Press Start 2P только для логотипа и
|
||||
цифр; шкала 11/13/17/26; пиксельные иконки `art/icons/`; карточка запроса
|
||||
400×128 с сеткой файлов и прогнозом; пустое состояние отправки; «СЛОТЫ».
|
||||
- **Туториал:** 5 шагов с событийными триггерами, пульсирующие рамки,
|
||||
«ДАЛЕЕ», сохранение `tutorial_done`, повтор из главного меню.
|
||||
- **Документация:** GDD/DECISIONS/README/ITCH_PAGE/UI_REFRESH/ASSETS
|
||||
переписаны под фактические числа v3; старые решения помечены, а не удалены.
|
||||
|
||||
## 3. Приём: «враждебный критик»
|
||||
## 3. Где применялся ИИ
|
||||
|
||||
- **Код:** HourDirector, FileRegistry, Economy, Shop, AttackDirector,
|
||||
SaveManager, UI, соединение TCP и мини-игры.
|
||||
- **UI v3:** Golos Text, пиксельные иконки, карточка запроса, туториал с
|
||||
рамками, VHS главного меню.
|
||||
- **Ambient:** 4 WAV (audio/amb), генератор generate_ambient.py, autoload
|
||||
после первого жеста, mute/пауза/громкость.
|
||||
- **Баланс:** цели дохода, дерево магазина 8 085 $ (в v3.1 — 8 825 $),
|
||||
симулятор на 240 сидах.
|
||||
- **Аудит:** B4 (дыры в выборе мини-игры) закрыт ещё в v2; в v3 выбор
|
||||
перестроен на 27 сумм и 3 ступени. M8 (масштаб денег атак), M16
|
||||
(soft-lock «нет денег/нет места») сохранены.
|
||||
- **Тесты/доки/звук:** unit/fuzz/интеграция/полный забег; docs/;
|
||||
18 SFX + hum.wav на Python.
|
||||
|
||||
## 4. Приём: «враждебный критик»
|
||||
|
||||
Промпт просил быть враждебным критиком: «найди блокеры, из-за которых
|
||||
прототип нельзя собрать; не хвали». Нашлось **6 блокеров**. Один из них:
|
||||
выбор мини-игры по самому сложному типу ломал смешанные запросы. Заменили на
|
||||
средний коэффициент:
|
||||
прототип нельзя собрать; не хвали». Главный дефект v2 — **B4**: выбор
|
||||
мини-игры по `S−1` давал дыры и недостижимые строки. В v3 выбор перестроен:
|
||||
|
||||
```
|
||||
difficulty = sum(coeff(required)) / n
|
||||
coeff = {text: 1.0, sound: 2.0, photo: 3.0, video: 4.0}
|
||||
```
|
||||
ACHIEVABLE_SUMS = [1..22, 24, 25, 26, 28, 32] # 27 сумм для 1–4 файлов
|
||||
rank = index of S in ACHIEVABLE_SUMS
|
||||
tier = T1 при rank 0–7, T2 при 8–13, T3 при 14–26
|
||||
id = RANK_GAMES[rank] # фиксированная таблица
|
||||
|
||||
Это изменило §8.4 и §10 GDD и решение D17; пороги `<1.5 / <2.5 / <3.5`
|
||||
зафиксированы в `data/balance.json` и проверяются в `test_minigame.gd`.
|
||||
Все 6 игр достижимы в каждой из 3 ступеней; S=32 → video/T3. Дополнительно
|
||||
закрыты: H1-голодание запросов (анти-голодание + seed-файл), потерянные
|
||||
required-файлы (подстановка того же типа), неоднозначность TCP-протокола
|
||||
(блокировка после соединения). M16 (бесплатное уничтожение неприкреплённого
|
||||
файла) сохранён, но файл, закреплённый за запросом, удалить нельзя.
|
||||
|
||||
## 4. Что сделано без ИИ
|
||||
## 5. Что сделано без ИИ
|
||||
|
||||
Идея, логлайн, исходная спецификация, ключевые решения (1997 год, 9 часов,
|
||||
4 атаки, экономика, тон 90-х) и финальная приёмка — человеком, автором
|
||||
проекта. ИИ не придумывал игру: он помогал формулировать, писать код,
|
||||
считать баланс и искать противоречия.
|
||||
|
||||
## 5. Ассеты и лицензии
|
||||
## 6. Ассеты и лицензии
|
||||
|
||||
- **Handjet** и **Press Start 2P** — SIL OFL 1.1, тексты в
|
||||
`art/fonts/OFL-Handjet.txt` и `OFL-PressStart2P.txt`.
|
||||
- Pixelify Sans в теме не используется (нет глифов `О`/`П`); файл и OFL
|
||||
оставлены в `art/fonts/`.
|
||||
- UI, иконки и `icon.svg` — команда проекта, MIT.
|
||||
- SFX, ambient и `hum.wav` — скрипты проекта, MIT/CC0.
|
||||
- Код — MIT (`LICENSE`); OFL-шрифты совместимы с MIT при сохранении
|
||||
лицензий. Подробности — в `ASSETS.md`.
|
||||
- **Golos Text** — SIL OFL 1.1 (`art/fonts/OFL-GolosText.txt`), основной
|
||||
шрифт UI с v3.
|
||||
- **Press Start 2P** — SIL OFL 1.1 (`art/fonts/OFL-PressStart2P.txt`), только
|
||||
логотип и цифры.
|
||||
- **Handjet** — SIL OFL 1.1, с v3 в теме не используется и помечен как
|
||||
legacy.
|
||||
- Pixelify Sans в теме не используется (нет глифов О/П); файл и OFL
|
||||
оставлены в art/fonts/.
|
||||
- Пиксельные иконки `art/icons/*.png` сгенерированы `generate_icons.py`
|
||||
(Pillow), команда проекта, **MIT**; исходный тайл-лист был недоступен,
|
||||
поэтому набор перегенерирован с нуля.
|
||||
- UI, маскот **БАЙТ** и icon.svg — команда проекта, MIT.
|
||||
- SFX, ambient и hum.wav — скрипты проекта, MIT/CC0.
|
||||
- Код — MIT (LICENSE); OFL-шрифты совместимы с MIT при сохранении лицензий.
|
||||
Подробности — в ASSETS.md.
|
||||
|
||||
## 6. Проверки и ограничения
|
||||
## 7. Проверки v3.1
|
||||
|
||||
- **22/22 PASS** (21 тест + parse). Core 543, hour 133, shop 116, minigame
|
||||
51, slots 34, audio 37; fuzz 3249, integration 231, attacks 173,
|
||||
controller 143.
|
||||
- `test_full_playthrough` — 294 на сидах `1/42/777`: 3/3 victory, 9/9,
|
||||
SLA 200; main 350, toast 14, fullscreen 31, ui 135, ambient 74.
|
||||
- Web-экспорт — exit 0; Chromium **1920×1080**: меню → «Новый забег» →
|
||||
подготовка, JS-ошибок нет.
|
||||
- **Симулятор баланса:** `python tests/sim_balance.py`, 240 сидов, 9 часов,
|
||||
`dt=0.25`, `q=0.85`, TCP. Отчёт `tests/SIM_BALANCE_REPORT.md`: средний итог
|
||||
**7 205 $** в полосе **6 982–8 500 $**, расход магазина **3 964 $**
|
||||
(**44,9 %** дерева **8 825 $**), **0 поражений из 240**, SLA в конце H9 —
|
||||
**64,9**, запросов выполнено — **62,0**. Все 9 часов в ±20 %, exit code **0**,
|
||||
вердикт `PASS`. H2, H4 и H5 идут близко к −20 % (H2 −16,4 %, H4 −15,3 %,
|
||||
H5 −15,1 %), H1 — +14,1 % при цели **100 $**.
|
||||
- **Тесты:** актуальный список — **полный набор + parse-check** в
|
||||
`scripts/run_all_tests.sh` / `.ps1` (см. также `tests/`; счётчик наборов не
|
||||
хардкодится). `test_economy` и `test_save` входят в runner; core-наборы
|
||||
обновлены и зелёные; UI-наборы (`test_main_*`, `ui_smoke`) прогоняются
|
||||
отдельно.
|
||||
- **Web-пресет** `export_presets.cfg` существует: `thread_support=false`,
|
||||
экспорт в `build/web/index.html`, просмотр только по HTTP.
|
||||
|
||||
Ограничения: туториал — маскот и подсказки; web-save — IndexedDB; плейтест,
|
||||
громкость и масштаб — за человеком; remote push ещё не выполнен.
|
||||
### Финальная проверка v3
|
||||
|
||||
> Замер предыдущей волны. После изменений баланса, каталога файлов и UI
|
||||
> core-наборы перепрогнаны (зелёные), UI-наборы и E2E/веб-смоук требуют
|
||||
> повторного прогона.
|
||||
|
||||
- **Headless:** полный набор + parse-check; список и статус — в
|
||||
`scripts/run_all_tests.sh` / `.ps1`. Число наборов здесь не фиксируется.
|
||||
- **Баланс:** симулятор — `PASS`, 240 сидов, все 9 часов в ±20 %, средний
|
||||
итог **7 205 $**, магазин — **3 964 $ (44,9 %)** дерева **8 825 $**,
|
||||
поражений **0/240** (`tests/SIM_BALANCE_REPORT.md`).
|
||||
- **E2E на реальной сцене Main (1920×1080):** замер предыдущей волны,
|
||||
**190/190**; после изменений темпа и «ЖАРЫ» требует повторного прогона:
|
||||
меню → подготовка → час, приём пакета, прикрепление конкретного файла,
|
||||
модалка TCP и промах/кулдаун, мини-игра отправки → завершение (файлы
|
||||
**вернулись в папку**, деньги начислены), UDP-обход, конец часа при
|
||||
открытом соединении, H1-голодание, очистка ghost-привязок при save/load,
|
||||
защита ввода в модалках, шаг туториала «отправка»; повторный «НОВЫЙ ЗАБЕГ»
|
||||
тоже получает seed-файл в подготовке.
|
||||
- **Web (Godot 4.7.2, Web/no-threads; Chromium + SwiftShader):** сборка
|
||||
загружается, WebGL2 OK, 0 ошибок/предупреждений в консоли; меню →
|
||||
«Новый забег» → «НАЧАТЬ ЧАС» работает; drag пакета в папку и drag нужного
|
||||
файла в чип запроса работают; модалка соединения открывается (палочка
|
||||
промахивается сама, если не кликнуть за ~1,1 с, — так задумано).
|
||||
- **Полоска TCP** — рефлекторная мини-игра: 3 попадания, зона 30 % на H1;
|
||||
уход палочки за край = автопромах; промах = −4 SLA и кулдаун 8 с
|
||||
(`Esc` = 4 с, без штрафа).
|
||||
|
||||
## 8. Известные отклонения
|
||||
|
||||
- `ПРОПУСТИТЬ` даёт `q=0`; `quality = 0.5`, то есть половина
|
||||
quality-множителя, а не нулевая выплата. Осознанно.
|
||||
- `speed` выплаты фиксируется на старте отправки, пока таймер был заморожен
|
||||
мини-игрой; быстрый игрок видит ≈1.0.
|
||||
- Лимит папок 6, новая папка 180 $.
|
||||
- Бесплатное уничтожение любого неприкреплённого файла в подготовке
|
||||
(review M16); файл, закреплённый за запросом, не удаляется.
|
||||
- Деньги атак масштабируются по часу `max(0.75, hour/3)` (review M8).
|
||||
- H2 теоретически может выдать T2-запрос из трёх фото (S12); на практике
|
||||
кривая H2 остаётся почти полностью T1.
|
||||
- Расход магазина **44,9 %** — ниже проектной полосы 55–65 %; это известное
|
||||
отклонение, не влияющее на exit code.
|
||||
|
||||
## 9. Ограничения
|
||||
|
||||
Туториал автоматически запускается только в первом часу первого забега;
|
||||
повтор — из главного меню. Web-save — IndexedDB; плейтест, громкость и
|
||||
масштаб — за человеком; remote push ещё не выполнен. Мета-ключ `search`
|
||||
остаётся в данных, но UI-поиск не подключён. Скриншоты в `docs/screenshots/`
|
||||
стоит пересобрать под v3 перед публикацией страницы.
|
||||
|
||||
## 10. Изменение v3.1: TCP-модель, UDP-лот и шрифты
|
||||
|
||||
Короткая запись о текущей волне — без переписывания истории выше.
|
||||
|
||||
- **TCP-соединение — отдельная сущность `TcpConnection`.** Оно живёт в
|
||||
`HourDirector.active_connections`, занимает out-слот и хранит только клиента,
|
||||
IP и `request_id`. До соединения файлы в запрос не прикрепляются (drag
|
||||
отклоняется, backend режет). Кнопка `ПОДКЛЮЧИТЬСЯ` справа снизу запускает
|
||||
полоску; успех больше не открывает мини-игру отправки.
|
||||
- **Слот после отправки.** Успешная TCP-отправка удаляет запрос, но файлы
|
||||
**не расходует**: они возвращаются в папку и доступны новым запросам. Слот
|
||||
остаётся занятым (idle) до ручного `ОТКЛЮЧИТЬ`. Отключение
|
||||
мгновенное; во время передачи оно прерывает её без выплаты и без расхода
|
||||
файлов. `reject`/`timeout`/конец часа освобождают слоты. Слоты общие,
|
||||
`1 + slot_level` (макс. 5); TCP может занять единственный слот и
|
||||
заблокировать приём — это осознанно.
|
||||
- **UDP-риск.** Новый лот `lot_udp` («UDP-повторитель», 4 уровня, цены
|
||||
60/120/210/350) поднимает шанс с **40 %** до **80 %** шагом **+10 %**.
|
||||
Ролл — в конце передачи: успех даёт обычную выплату UDP ×0.85; провал —
|
||||
**−3 SLA**, без денег, файлы остаются, запрос жив, повтор через **4 с**,
|
||||
слот освобождается, streak сбрасывается. Всего лотов **9**, дерево
|
||||
**8 825 $**.
|
||||
- **Шрифты.** Тело — **Kechusilka Regular** (нормализован под Golos,
|
||||
x-height 0.530 em) с fallback **Golos Text**; крупный текст — **DisplayOTF**
|
||||
(wordmark 46, «ПОБЕДА/ПОРАЖЕНИЕ» 34, титлбар 14, «РЕЗУЛЬТАТ» 18); цифры и
|
||||
счётчики — **Press Start 2P**. Шкала 11/13/17/26.
|
||||
- **Документация:** обновлены `docs/GDD.md`, `docs/DECISIONS.md` (D53–D61),
|
||||
`docs/UI_REFRESH.md`, `docs/IMPLEMENTATION.md`, `docs/TEST_PLAN.md`,
|
||||
`docs/ITCH_PAGE.md` и `README.md`. `ASSETS.md` не менялся — шрифтовые
|
||||
лицензии уже зафиксированы там.
|
||||
- **Проверки:** полный набор + parse-check; core-наборы обновлены и зелёные;
|
||||
UI-наборы (`test_main_*`, `ui_smoke`) ещё идут. `tests/SIM_BALANCE_REPORT.md`
|
||||
отражает текущий `data/balance.json`: **7 205 $**, магазин **44,9 %**,
|
||||
**0/240** поражений, exit **0**.
|
||||
|
||||
## 11. v3.1: темп, каталог файлов и «ЖАРА»
|
||||
|
||||
Текущая волна; история выше не переписана.
|
||||
|
||||
- **Длительность:** часы **120 / 150 / 180 / 210 / 240 / 270 / 300 / 330 /
|
||||
360 с** (`120 + 30×(h−1)`), забег **36 минут** + подготовка. H1
|
||||
`income_target` — **100 $**. Окно спавна — **75 %** часа.
|
||||
- **Файлы:** успешная отправка не удаляет файл; он остаётся в папке и снова
|
||||
доступен пулу запросов. Общего лимита на забег нет; `hours[].files`
|
||||
(**6,6,7,8,9,9,10,10,10**) — скорость, не жёсткий cap. Интервал —
|
||||
`spawn_window / files`, переноса между часами нет (carry = 0); папки
|
||||
докупаются, к финалу ~**5–6**.
|
||||
- **Запросы:** `request_cap` удалён. Темп — `send_interval`
|
||||
(**20,24,18,24,24,24,24,24,24**), лимит — только `active_requests` +
|
||||
cutoff последних **10 с**. Анти-голодание — только первый запрос часа;
|
||||
требования выбираются из файлов в папках и не связаны с приходом.
|
||||
- **Атаки:** `start = clamp(0.25×duration, 20, 60)`,
|
||||
`end = min(0.85×duration, duration−20)`; H2 **37,5–127,5 с**,
|
||||
H9 **60–306 с**.
|
||||
- **Визуал:** `TemperatureGauge` вместо 03:42 в меню-баре, `StressOverlay`
|
||||
(heat-модель, alpha-only шейдер без screen texture), пульс SLA, тряска
|
||||
панелей ≤2 px, `reduce_motion`, `UiMotion`, Lane B (drag/карточки/слоты),
|
||||
headless-safe.
|
||||
- **Баланс:** `tests/SIM_BALANCE_REPORT.md` — **7 205 $**, полоса
|
||||
**6 982–8 500 $**, магазин **3 964 $ (44,9 %)**, **0/240** поражений,
|
||||
exit **0**; H2, H4 и H5 близко к −20 % (H2 −16,4 %, H4 −15,3 %, H5 −15,1 %).
|
||||
- **Тесты:** полный набор + parse-check; `test_economy` и `test_save` в
|
||||
runner; точный счёт не фиксируется.
|
||||
@@ -5,23 +5,123 @@
|
||||
|
||||
## Шрифты
|
||||
|
||||
### Handjet — основной UI-шрифт
|
||||
### Golos Text — fallback для редких символов (v3)
|
||||
|
||||
| Поле | Значение |
|
||||
| --- | --- |
|
||||
| Файл | `art/fonts/GolosText-VF.ttf` (variable; локальное имя для `GolosText[wght].ttf`) |
|
||||
| Оси | `wght` 400–900; в теме — `wght 600` |
|
||||
| Автор / проект | The Golos Text Project Authors — https://github.com/googlefonts/golos-text |
|
||||
| Источник | https://github.com/google/fonts/tree/main/ofl/golostext |
|
||||
| Лицензия | SIL Open Font License 1.1 — `art/fonts/OFL-GolosText.txt` |
|
||||
| SHA-256 | `17bb58fb69aec2dfb047a2ebf52534023e9b688c97a6b7ac795b0a72912c2063` |
|
||||
| Кириллица | полная (О/П/Ё/№), гуманистический рисунок, хорошо читается в 11–17 px |
|
||||
| Использование | fallback для Kechusilka и DisplayOTF (`Theme90.FONT_FALLBACK`, `Font.fallbacks`): `№ – … · × © ± ≈ −` и стрелки. Прямо в разметке не используется |
|
||||
|
||||
Типографическая шкала: **11 / 13 / 17 / 26 px** (`FS_MICRO`, `FS_BODY`,
|
||||
`FS_HEAD`, `FS_LOGO`); значения проверяет `ui_smoke.gd`, поэтому менять их
|
||||
можно только вместе с проверкой. Крупнее 26 px размер передаётся на месте
|
||||
вызова (24/28/34/46). Роли: тело текста — Kechusilka, крупный текст —
|
||||
DisplayOTF (`display()`), цифры и счётчики — Press Start 2P (`logo()`),
|
||||
страховка для редких символов — Golos Text.
|
||||
|
||||
### Kechusilka — основной шрифт тела (нормализованный)
|
||||
|
||||
| Поле | Значение |
|
||||
| --- | --- |
|
||||
| Файл | `art/fonts/Kechusilka-Regular.otf` — нормализованный (UPM 2048, 173 глифа) |
|
||||
| Оригинал | `art/fonts/source/Kechusilka-Regular-original.otf` — только для сверки, к игре не подключён |
|
||||
| Источник | архив `kechusilka-regular.zip` (загрузка пользователя), внутри `kechusilka-Regular_1.otf` и два PNG-превью |
|
||||
| Версия | Version 1.000; 16.09.2026; FontCreator 15 |
|
||||
| SHA-256 (нормализованный) | `e296eca059c05886da25808170accf6004619850dcac14200046199be04e10c8` |
|
||||
| SHA-256 (оригинал) | `3f666b9b04dcbbfcfe3a31256d99e807553c9dd2efa3f360cc14422599917d36` |
|
||||
| Лицензия | **подтверждена владельцем проекта** (license confirmed by project owner). Файла лицензии в архиве нет, строк copyright/license в шрифте тоже нет — поэтому текст лицензии здесь не приводится и её название не угадывается |
|
||||
| Кириллица | полная: все 66 букв (включая Ё/ё), все цифры |
|
||||
| Нет глифов | `№` U+2116, `–` U+2013, `…` U+2026, `·` `×` `← → ↑ ↓` `≤ ≥` `§` `°` `€` `•` `©` `±` `≈` `−` — закрываются fallback-шрифтом |
|
||||
| Fallback | `GolosText-VF.ttf` через `Font.fallbacks` в `Theme90.ui()` |
|
||||
| Использование | `FONT_UI`, `Theme90.ui()`; `default_font` темы `art/fonts/ui_theme.tres` |
|
||||
|
||||
**Нормализация.** У исходника x-height `0.312 em` против `0.530 em` у Golos
|
||||
Text: в одном кегле текст был бы на 40 % мельче, и шкала 11/13/17/26 поехала
|
||||
бы во всех карточках. Шрифт пересобран скриптом
|
||||
`art/fonts/normalize_kechusilka.py`:
|
||||
|
||||
- коэффициент `scale = 0.530 × 2048 / 640 = 1.696`;
|
||||
- масштабируются контуры (`TransformPen`), `hmtx` (advance + lsb),
|
||||
`hhea`/`OS/2`/`head` и 150 значений `GPOS` — иначе кернинг стал бы на 40 %
|
||||
слабее;
|
||||
- результат: x-height `0.5298 em`, cap `0.7422 em`, линия `15.16 px` против
|
||||
`15.60 px` у Golos при 13 px; ширина русских строк отличается от Golos
|
||||
в пределах ±6 %, поэтому вёрстку пересчитывать не пришлось;
|
||||
- хинтов в исходнике нет (0 stem-операторов), потерь при пересборке нет;
|
||||
- сборка воспроизводима: повторный запуск даёт тот же файл и тот же SHA-256.
|
||||
|
||||
Оригинал не удалён — он лежит в `art/fonts/source/` и в теме не используется:
|
||||
это точка сверки, если понадобится поменять коэффициент.
|
||||
|
||||
**Вес штриха.** Kechusilka светлее прежнего Golos Text `wght 600`: толщина
|
||||
стойки `0.10 em` против `0.145 em`, то есть 1.35 px против 1.88 px при 13 px
|
||||
(замер по растеризации `l`/`I`). На 11–13 px это читается, но на 9 px стойка
|
||||
уходит в 0.94 px и текст «сереет». Поэтому подписи мельче 11 px лучше поднять
|
||||
до `FS_MICRO`. Если после первого запуска в Godot захочется веса ближе к
|
||||
прежнему, это делается одной строкой — `FontVariation.variation_embolden` в
|
||||
`Theme90.ui()`; значение подбирать на месте, начиная с 0.1, потому что
|
||||
масштаб эффекта зависит от версии TextServer.
|
||||
|
||||
### Kechusilka: чем закрыты отсутствующие символы
|
||||
|
||||
В шрифте нет 19 символов, которые встречаются в тексте проекта. Цепочка
|
||||
`Kechusilka → Golos Text` закрывает все, кроме трёх: `Σ` U+03A3, `↔` U+2194
|
||||
(только комментарии и тесты, на экран не попадают) и `▯` U+25AF — его нет ни в
|
||||
одном шрифте проекта, включая прежний Golos, поэтому он и раньше рисовался
|
||||
встроенным резервом Godot. Регрессии нет.
|
||||
|
||||
Проверено разбором `cmap` всех шрифтов проекта и всех строковых литералов
|
||||
`.gd`/`.json`/`.tscn`, плюс рендер-симуляция подстановки: на 11–13 px стык
|
||||
шрифтов не виден, потому что у Kechusilka и Golos совпадает x-height.
|
||||
|
||||
|
||||
### DisplayOTF — крупный текст и логотип
|
||||
|
||||
| Поле | Значение |
|
||||
| --- | --- |
|
||||
| Файл | `art/fonts/DisplayOTF.otf` (статический, вариаций нет) |
|
||||
| Автор | Denis A Serikov, 2003–2006 — `art/fonts/COPYRIGHT-DisplayOTF.txt` |
|
||||
| Источник | архив `displayotf.zip` (загрузка пользователя; каталог `www.otlab.ru`), публичной ссылки нет |
|
||||
| Лицензия | Freeware — текст соглашения `art/fonts/LICENSE-DisplayOTF.txt` (в архиве `license_131.txt` в CP1251, перекодирован в UTF-8 без правок текста) |
|
||||
| SHA-256 | `c6578605ddc6992008044a1c7266cdd46dcf846a5d5f5dffbd391023e6ba0552` |
|
||||
| Кириллица | полная: все 66 букв (включая Ё/ё), цифры, `№ % « » — – … ! ?` |
|
||||
| Кернинг | таблиц `GPOS`/`kern` нет; есть `liga`, `salt`, `aalt` |
|
||||
| Цифры | пропорциональные (`1` = 395/1000 em, остальные 553/1000) |
|
||||
| Embedding | `fsType 0` — встраивание в игру разрешено |
|
||||
| Использование | крупный текст: `theme90.gd → display()`; wordmark `PEAK HOUR 97`, «ПОБЕДА»/«ПОРАЖЕНИЕ», «РЕЗУЛЬТАТ», заголовки окон, вариация темы `LogoLabel` |
|
||||
| Не использовать | тело текста и живые счётчики: мельче 16 px насечки сливаются, а пропорциональные цифры «дрожат» при смене значения |
|
||||
|
||||
Шрифт держит ту же «машинную» интонацию, что Press Start 2P, но с более
|
||||
узким и характерным рисунком, поэтому занимает роль крупного текста, а Press
|
||||
Start 2P остаётся на цифрах с моноширинной сеткой.
|
||||
|
||||
Условия лицензии, которые нужно соблюдать: файл распространяется «как есть» —
|
||||
не переконвертировать в TTF, не сабсетить и не менять состав знаков без
|
||||
разрешения автора; продавать шрифт отдельно нельзя.
|
||||
|
||||
### Handjet — legacy, в теме не используется
|
||||
|
||||
| Поле | Значение |
|
||||
| --- | --- |
|
||||
| Файл | `art/fonts/Handjet-VF.ttf` (variable; локальное имя для `Handjet[ELGR,ELSH,wght].ttf`) |
|
||||
| Оси | `wght` 100–900, `ELGR` 1–2, `ELSH` 0–16; в теме — `wght 700, ELGR 2, ELSH 2` |
|
||||
| Оси | `wght` 100–900, `ELGR` 1–2, `ELSH` 0–16 |
|
||||
| Автор / проект | The Handjet Project Authors / Rosetta Type — https://github.com/google/fonts/tree/main/ofl/handjet |
|
||||
| Прямая ссылка | https://raw.githubusercontent.com/google/fonts/main/ofl/handjet/Handjet%5BELGR%2CELSH%2Cwght%5D.ttf |
|
||||
| Лицензия | SIL Open Font License 1.1 — `art/fonts/OFL-Handjet.txt` |
|
||||
| SHA-256 | `9262749e8bb0b73ebcae0e20428689c3c59576eebeb6c4e1020300d2d41bdf4d` |
|
||||
| Кириллица | полная (проверено по cmap: А–Я, а–я, Ё/ё, цифры; 1339 кодпоинтов) |
|
||||
| Использование | `default_font` темы `art/fonts/ui_theme.tres`; все UI-тексты |
|
||||
| Кириллица | полная (1339 кодпоинтов) |
|
||||
| Использование | **нигде**: после перехода на Golos Text (D51) файл оставлен как исторический артефакт |
|
||||
|
||||
Handjet узкий (средняя ширина ≈0.36 em), поэтому в карточки 132 px влезают
|
||||
имена файлов. Допустимые кегли: **10 / 12 / 16 / 24**.
|
||||
TTF, `.import` и OFL можно удалить без влияния на игру; тема на Handjet не
|
||||
ссылается. Узкий рисунок Handjet больше не нужен: Golos Text держит плотный
|
||||
русский текст в карточках 400 px.
|
||||
|
||||
### Press Start 2P — логотип и крупные цифры
|
||||
### Press Start 2P — цифры и числовые акценты
|
||||
|
||||
| Поле | Значение |
|
||||
| --- | --- |
|
||||
@@ -32,10 +132,12 @@ Handjet узкий (средняя ширина ≈0.36 em), поэтому в
|
||||
| Лицензия | SIL Open Font License 1.1 — `art/fonts/OFL-PressStart2P.txt` |
|
||||
| SHA-256 | `034c77f1f05ec89421e4a63f0e3a4ca1ecf852cc6d2bf611f126f275728e017d` |
|
||||
| Кириллица | полная (656 кодпоинтов, включая Ё/ё и служебные `:/-`) |
|
||||
| Использование | только логотип, заголовки и числовые строки (`theme90.gd → logo()`) |
|
||||
| Использование | `theme90.gd → logo()`: цифры и счётчики (`num_line()`), часы, номера слотов, знак «?». Крупные слова и заголовки — уже на `display()` |
|
||||
|
||||
Шрифт слишком широкий для карточек (17 символов не влезают в 132 px), поэтому
|
||||
основным текстом он не используется.
|
||||
Шрифт слишком широкий для тела текста и карточек, поэтому основным текстом
|
||||
он не используется (D51). Цифры у него ровно 1 em, поэтому счётчики и часы
|
||||
не меняют ширину при смене значения — это главная причина держать его на
|
||||
числах, а крупный текст отдавать DisplayOTF.
|
||||
|
||||
### Pixelify Sans — в теме не используется
|
||||
|
||||
@@ -54,22 +156,58 @@ Handjet узкий (средняя ширина ≈0.36 em), поэтому в
|
||||
|
||||
### Совместимость лицензий
|
||||
|
||||
Все три шрифта — **SIL OFL 1.1**. OFL разрешает встраивать шрифты в игру
|
||||
(включая коммерческую), но требует сохранять текст лицензии и не продавать
|
||||
шрифт отдельно. Для производных шрифтов действует Reserved Font Name
|
||||
(у Press Start 2P — «Press Start 2P»). Это совместимо с MIT-лицензией кода:
|
||||
Golos Text, Handjet, Press Start 2P и Pixelify Sans — **SIL OFL 1.1**.
|
||||
OFL разрешает встраивать шрифты в игру (включая коммерческую), но требует
|
||||
сохранять текст лицензии и не продавать шрифт отдельно. Для производных
|
||||
шрифтов действует Reserved Font Name. Это совместимо с MIT-лицензией кода:
|
||||
MIT не накладывает ограничений на шрифты, а OFL-тексты лежат рядом с файлами.
|
||||
|
||||
DisplayOTF — **freeware**, не OFL. Играть и встраивать в игру можно, текст
|
||||
соглашения лежит рядом (`LICENSE-DisplayOTF.txt`); продавать сам шрифт
|
||||
отдельно и менять состав знаков нельзя. Это не мешает MIT-коду игры, но при
|
||||
смене лицензии проекта или платной сборке условия нужно перечитать.
|
||||
|
||||
Kechusilka — по шрифту лицензия **подтверждена владельцем проекта** (license
|
||||
confirmed by project owner). Текста лицензии в архиве и в файле шрифта нет,
|
||||
поэтому её название здесь не указано и условия не пересказываются: если
|
||||
проект будет публиковаться или продаваться, текст нужно запросить у
|
||||
владельца. Нормализация (масштаб контуров) — техническое изменение файла,
|
||||
о котором владелец уведомлён.
|
||||
|
||||
## Графика
|
||||
|
||||
| Файл | Автор | Лицензия | Примечание |
|
||||
| --- | --- | --- | --- |
|
||||
| UI, иконки, фаски | команда проекта | MIT | рисуются кодом в `scripts/ui/theme90.gd`; внешних текстур нет |
|
||||
| `art/icons/*.png` | команда проекта | MIT | 32×32 RGBA, пиксельные иконки файлов/папки/пакета; сгенерированы `art/icons/generate_icons.py` |
|
||||
| UI, панели, маскот | команда проекта | MIT | рисуются кодом в `scripts/ui/theme90.gd`; внешних UI-текстур нет |
|
||||
| `icon.svg` | команда проекта | MIT | иконка проекта, формы без текста (ThorVG) |
|
||||
| `docs/screenshots/*.png` | команда проекта | MIT | кадры игры 1280×720, сняты из работающей сборки |
|
||||
|
||||
`art/icons/` и `art/ui/` пусты (`.gitkeep`): спрайтов, тайлсетов и сторонней
|
||||
графики в проекте нет.
|
||||
### Пиксельные иконки файлов
|
||||
|
||||
`art/icons/` содержит `file_text.png`, `file_sound.png`, `file_photo.png`,
|
||||
`file_video.png`, `folder.png` и `packet.png` (все 32×32 RGBA). Иконки
|
||||
показываются в сетке папки, drag-preview, чипах запроса и карточках пакетов;
|
||||
в `Theme90.draw_file_icon()` они рисуются с `nearest`, поэтому остаются
|
||||
резкими в 16/24/32 px.
|
||||
|
||||
- Генератор: `art/icons/generate_icons.py` (Python 3 + Pillow), палитра
|
||||
Kaleidos, контур 1 px `#2C2A26`, без сглаживания.
|
||||
- Лицензия: **MIT**, сгенерировано в команде проекта в стиле пиксельного
|
||||
тайл-листа; сторонних спрайтов нет.
|
||||
- Исходный тайл-лист/лист иконок был недоступен, поэтому набор **не вырезан**
|
||||
из готового файла, а **перегенерирован** скриптом с нуля. Это позволяет
|
||||
воспроизвести все PNG одной командой и не зависит от потерянного исходника.
|
||||
|
||||
Пересборка:
|
||||
|
||||
```bash
|
||||
cd peak-hour
|
||||
python3 art/icons/generate_icons.py
|
||||
# превью: python3 art/icons/generate_icons.py --preview /tmp/opencode/icons_preview.png
|
||||
```
|
||||
|
||||
`art/ui/` по-прежнему пуст (`.gitkeep`): отдельных UI-спрайтов нет.
|
||||
|
||||
## Аудио
|
||||
|
||||
@@ -108,21 +246,33 @@ python3 audio/generate_sfx.py
|
||||
|
||||
```bash
|
||||
cd peak-hour/art/fonts
|
||||
curl -L -o Handjet-VF.ttf \
|
||||
"https://raw.githubusercontent.com/google/fonts/main/ofl/handjet/Handjet%5BELGR%2CELSH%2Cwght%5D.ttf"
|
||||
# Golos Text: скачайте GolosText[wght].ttf из google/fonts/ofl/golostext
|
||||
# и сохраните локально как GolosText-VF.ttf
|
||||
curl -L -o PressStart2P-Regular.ttf \
|
||||
"https://raw.githubusercontent.com/google/fonts/main/ofl/pressstart2p/PressStart2P-Regular.ttf"
|
||||
sha256sum Handjet-VF.ttf PressStart2P-Regular.ttf
|
||||
sha256sum GolosText-VF.ttf PressStart2P-Regular.ttf
|
||||
# Kechusilka пересобирается из оригинала (нужен fontTools):
|
||||
python3 normalize_kechusilka.py
|
||||
```
|
||||
|
||||
Ожидаемые хеши — в таблицах выше. Тексты OFL уже лежат в `art/fonts/`; при
|
||||
обновлении шрифта обновите и их. Pixelify Sans скачивается по ссылке из его
|
||||
раздела, но для сборки не требуется.
|
||||
обновлении шрифта обновите и их. Handjet и Pixelify Sans для сборки не
|
||||
требуются.
|
||||
|
||||
DisplayOTF уже лежит в `art/fonts/` вместе с лицензией — публичной ссылки на
|
||||
него нет, файл нужно сохранять как есть (не переконвертировать в TTF и не
|
||||
сабсетить).
|
||||
|
||||
Kechusilka тоже в репозитории: `Kechusilka-Regular.otf` — результат
|
||||
нормализации, оригинал лежит в `art/fonts/source/`. Правьте не результат, а
|
||||
скрипт: он пересобирает файл из оригинала и даёт тот же SHA-256. Если
|
||||
пересобрали — обновите хеш в таблице выше.
|
||||
|
||||
### Графика
|
||||
|
||||
Генерировать нечего: UI и иконки рисуются кодом, `icon.svg` — обычный SVG.
|
||||
После клонирования достаточно импортировать проект в Godot 4.7.2.
|
||||
Иконки пересобираются `python3 art/icons/generate_icons.py` (нужен Pillow).
|
||||
`icon.svg` — обычный SVG. После клонирования достаточно импортировать проект
|
||||
в Godot 4.7.2.
|
||||
|
||||
## Чего в проекте нет
|
||||
|
||||
|
||||
@@ -2,56 +2,102 @@
|
||||
|
||||
> Час пик. Одна смена. Ни одного свободного места.
|
||||
|
||||
Ночной диспетчер узла файлового обмена, 1997 год. Принимайте пакеты,
|
||||
раскладывайте файлы, собирайте запросы клиентов, держите канал и SLA — в
|
||||
интерфейсе, вдохновлённом Kaleidos: тёплая бумага, тонкие контуры и
|
||||
бирюзовый акцент.
|
||||
1997 год. Ночной диспетчер узла файлового обмена: принимайте пакеты,
|
||||
раскладывайте файлы-полимино по папкам, собирайте контейнеры запросов,
|
||||
проходите мини-игры и держите SLA. UI — «Kaleidos»: тёплая бумага, тонкие
|
||||
контуры и бирюзовый акцент.
|
||||
|
||||
**Жанр:** менеджмент / тайм-менеджмент, retro UI.
|
||||
**Платформа:** Godot **4.7.2** → **Web (HTML5, без threads)**, RU-only.
|
||||
**Окно:** 1920×1080, логический viewport 1280×720, fractional scale.
|
||||
**Забег:** 9 игровых часов по 5 минут (≈45 минут) + подготовка между часами.
|
||||
**Забег:** 9 игровых часов — **120 / 150 / 180 / 210 / 240 / 270 / 300 / 330 /
|
||||
360 с** (`120 + 30×(h−1)`, всего **36 минут**) + подготовка между часами.
|
||||
|
||||
## Статус v1.2
|
||||
## Статус v3.0
|
||||
|
||||
Играбельный веб-прототип полного цикла: **меню → подготовка → час → итоги
|
||||
часа → … → итоги забега**. Девять часов проходят на сидах `1`, `42`, `777`
|
||||
без потери часов; SLA после магазина — 200.
|
||||
часа → … → итоги забега**. Актуальная спецификация — [GDD v3.0](docs/GDD.md),
|
||||
решения — [DECISIONS](docs/DECISIONS.md) (актуальны D45–D61, D01–D44 — история).
|
||||
|
||||
Реализовано:
|
||||
|
||||
- 4 типа файлов (text/sound/photo/video), буфер приёма, папки и секции.
|
||||
- **10 апгрейдов** магазина, включая `SLA +24`; магазин открывается с часа 2
|
||||
и скрыт во время часа.
|
||||
- TCP-слоты и полоска соединения: три попадания; промах закрывает панель
|
||||
без автоповтора.
|
||||
- 4 атаки, но **час 1 проходит без атак**; junk/ddos — с часа 2,
|
||||
phishing — с 3, ransomware — с 4.
|
||||
- **SLA 128 / 200** на старте; потеря пакета: text 2 / sound 4 / photo 8 /
|
||||
video 16.
|
||||
- Мета-награды **постепенно по часам**: поиск после 3-го часа, щит после
|
||||
5-го, `complete_run` после 9-го; итоги показывают «ОТКРЫТО: …», прогресс
|
||||
живёт между забегами. `minigame_advanced` — резерв под будущий контент.
|
||||
- Поиск по имени в текущей папке после открытия; заголовок «N ИЗ M ОБЪЕКТОВ».
|
||||
- Автообновление папки/дерева, уникальные имена папок (`Имя`, `Имя 2`),
|
||||
удаление пустых папок, attached-галочки, скролл приёма до 8 пакетов.
|
||||
- «Уничтожить содержимое» папки удаляет файлы с подтверждением и −5 SLA;
|
||||
пустая папка удаляется без штрафа.
|
||||
- «Спокойный фон» — плоская бумага без dither; Esc на итогах забега ведёт
|
||||
в главное меню.
|
||||
- UI v2 «Kaleidos», маскот **БАЙТ** и короткий туториал первого часа.
|
||||
- Ambient машинного зала: 4 WAV, автозапуск после первого жеста, mute/пауза/
|
||||
громкость; генератор `audio/generate_ambient.py`.
|
||||
- Процедурный VHS только в главном меню (настройки → «ЭКРАН»): скан-линии,
|
||||
зерно, виньетка и полоса; рисуется кодом, не блокирует ввод, текст резкий.
|
||||
Игровой UI остаётся чистым, без CRT на весь интерфейс.
|
||||
- Handjet + Press Start 2P, 18 SFX + фоновый `hum`.
|
||||
- **9 часов**, длительность **120 → 360 с** (+30 с за час), старт **50 $**,
|
||||
**SLA 60/60** (кап +40 за уровень `lot_sla` до **220**), 1 слот (макс. 5),
|
||||
канал 10 (макс. 46), буфер 6 (макс. 12), таймер запроса 30 с (макс. 65),
|
||||
таймер пакета 20 с.
|
||||
- **4 типа файлов-полимино:** текст 1, звук 2, фото 3, видео 4 клетки;
|
||||
поворот `R`/ПКМ во время drag; сетка папки **5×4 → 6×4 → 7×5 → 8×5 →
|
||||
8×6 → 9×6** (до 54 клеток). Новая папка — **180 $**, всего до 6.
|
||||
- **Стол сортировки 4×6** в подготовке; файлы со стола нельзя прикрепить,
|
||||
на старте часа они автоматически возвращаются в папки (если места нет —
|
||||
час не начинается).
|
||||
- **Запросы на 1–4 конкретных именованных файла:** имена видны на карточке
|
||||
сеткой 2×2, прикрепление проверяется по точному `file_id`, потерянный файл
|
||||
помечается «ПОТЕРЯН», обязательные файлы жёстко зарезервированы. H1
|
||||
стартует с seed-файла и сразу с запроса на него. Отдельного `request_cap`
|
||||
нет: одновременно живут не больше `active_requests` запросов, новые не
|
||||
появляются в последние **10 с** часа; анти-голодание срабатывает только для
|
||||
первого запроса часа, дальше темп задаёт `send_interval`.
|
||||
- **Файлы — каталог, а не расходник:** успешная отправка только снимает
|
||||
привязку к запросу, файл остаётся в папке и может быть затребован снова.
|
||||
Приход задаётся только скоростью: окно спавна **75 %** часа, интервал —
|
||||
`spawn_window / hours[].files` с джиттером ±20 %, без переноса и общего
|
||||
лимита файлов (carry = 0).
|
||||
- **9 лотов магазина**; ровно 3 предложения, рероллы 40/80/120 (макс. 3 за
|
||||
подготовку), замок отдельного предложения. Магазин — с H2 и только в
|
||||
подготовке. Полное дерево — **8 825 $** (было 8 085 $ с 8 лотами), новый
|
||||
лот `lot_udp` — «UDP-повторитель».
|
||||
- **6 мини-игр отправки** (`dots`, `sound`, `text`, `video`, `photo`, `maze`;
|
||||
**3 ступени**) и **4 мини-игры атак** (`junk`, `ddos`, `phishing`,
|
||||
`ransomware`; 4 ступени); без таймеров, с шапкой «название + цель», полосой
|
||||
прогресса и экраном результата: имя игры, `q%`, полоса качества, вердикт,
|
||||
детали и `ПРОДОЛЖИТЬ`/`Esc`.
|
||||
- **TCP как отдельное соединение:** до `ПОДКЛЮЧИТЬСЯ` файлы в запрос не
|
||||
прикрепляются. Успех (3 попадания) занимает out-слот, в слоте — имя клиента
|
||||
и случайный IP; мини-игра отправки сама не открывается. `ОТПРАВИТЬ`
|
||||
запускает её, только когда прикреплены все файлы. После успешной отправки
|
||||
запрос исчезает, файлы возвращаются в папку и остаются доступны новым
|
||||
запросам, выплата начисляется, но TCP-слот остаётся занятым (idle) до
|
||||
ручного `ОТКЛЮЧИТЬ` на слоте. TCP платит **×1.15**.
|
||||
- **UDP без соединения:** время передачи **×0.8**, выплата **×0.85**, но
|
||||
успех — ролл в конце передачи: база **40 %**, +**10 %** за уровень
|
||||
`lot_udp`, максимум **80 %** (цены 60/120/210/350). Провал: **−3 SLA**, без
|
||||
выплаты и денежного штрафа, файлы не расходуются, запрос остаётся, повтор
|
||||
через **4 с**, слот освобождается. Файлы не расходуются ни при успешной
|
||||
отправке, ни при отмене/таймауте/отключении.
|
||||
- **Слоты общие для приёма и отправки:** `1 + slot_level`, максимум 5.
|
||||
TCP-соединение может занять единственный слот и заблокировать приём — это
|
||||
осознанное требование. Открепление файла — двойной клик по чипу.
|
||||
- **Шрифты:** тело — **Kechusilka Regular** (нормализован под Golos, fallback
|
||||
Golos Text), крупный текст — **DisplayOTF**, цифры/счётчики — **Press Start
|
||||
2P**; шкала 11/13/17/26.
|
||||
- **«ЖАРА»:** в меню-баре вместо часов 03:42 — `TemperatureGauge`
|
||||
(216×18, колба, деления, °C); поверх игры — `StressOverlay` с heat-моделью
|
||||
и alpha-only шейдером без чтения экрана. Пульс SLA/CRIT, тряска панелей
|
||||
≤2 px, «Снизить эффекты» (`reduce_motion`) отключает движение; в headless
|
||||
слой скрыт. Анимации карточек, слотов и drag — через `UiMotion` (Lane B).
|
||||
- **20 атак за забег** (`0,1,1,2,2,3,3,4,4`), 4 вида, 4 ступени; атака — это
|
||||
мини-игра, которая ставит час на паузу. Щит «Заземление» гасит атаку
|
||||
автоматически (**70 %** награды, без штрафа).
|
||||
- **Пошаговый туториал v3:** 5 шагов (пакет → прикрепление → TCP-соединение →
|
||||
мини-игра → SLA/атаки), пульсирующие рамки у цели и кнопка «ДАЛЕЕ».
|
||||
Прохождение хранится в `Game.stats["tutorial_done"]`; кнопка «ОБУЧЕНИЕ» в
|
||||
главном меню запускает повтор.
|
||||
- **Save VERSION 2**: позиции только в `folder.files`/`table`, метаданные в
|
||||
`files`; v1-сейвы мигрируют без потери файлов и без уменьшения сеток;
|
||||
офферы/рероллы/замки, стол и `tutorial_done` сохраняются.
|
||||
- Мета-награды после 3/5/9 часов, маскот **БАЙТ**, UI v3 «Kaleidos», ambient
|
||||
машинного зала, VHS только в главном меню.
|
||||
|
||||
Ограничения:
|
||||
|
||||
- Туториал — маскот и несколько подсказок, без пошагового сценария.
|
||||
- Сохранение в Web идёт через IndexedDB (`user://save.json`); проверяйте
|
||||
продолжение после перезагрузки вкладки.
|
||||
- Средний расход магазина в симуляторе — **44,9 %** дерева **8 825 $**;
|
||||
итог **7 205 $** в полосе **6 982–8 500 $**, поражений **0/240**.
|
||||
H2, H4 и H5 идут близко к нижней границе −20 % (−16,4 %, −15,3 %, −15,1 %),
|
||||
поэтому дальше ужесточать темп нельзя. Подробности —
|
||||
`tests/SIM_BALANCE_REPORT.md`.
|
||||
- Remote push ещё не выполнен; сборка для itch.io готовится локально.
|
||||
|
||||
## Управление
|
||||
@@ -60,20 +106,29 @@
|
||||
| --- | --- |
|
||||
| Выбор, меню, перетаскивание | ЛКМ |
|
||||
| Контекстное меню | ПКМ |
|
||||
| Пакет из «Приёма» → папка; файл → карточка запроса | drag&drop |
|
||||
| Поиск по имени в текущей папке | после открытия «Поиска» |
|
||||
| Мини-игра соединения: три попадания | ЛКМ или `Space` |
|
||||
| Мини-игра подготовки: отправить | `Enter` или кнопка |
|
||||
| Поворот фигуры во время drag | `R` или ПКМ |
|
||||
| Пакет из «Приёма» → папка | drag&drop |
|
||||
| Файл из папки → карточка запроса | drag&drop (TCP — только после `ПОДКЛЮЧИТЬСЯ`) |
|
||||
| Файл ↔ стол сортировки | drag&drop |
|
||||
| TCP-соединение | кнопка `ПОДКЛЮЧИТЬСЯ` справа снизу, 3 попадания в зелёную зону |
|
||||
| Отправка запроса | кнопка `ОТПРАВИТЬ` слева снизу |
|
||||
| Отклонить запрос | кнопка `ОТКЛОНИТЬ` слева снизу |
|
||||
| Закрыть TCP-соединение | `ОТКЛЮЧИТЬ` на слоте (мгновенно) |
|
||||
| Открепить файл от запроса | двойной клик по чипу |
|
||||
| TCP/UDP | переключатель на карточке запроса до соединения |
|
||||
| Мини-игра: действие | ЛКМ / `Space` / стрелки (по игре) |
|
||||
| `dots`: линия | нажать на «С» и вести курсор по клеткам |
|
||||
| Пропустить мини-игру | `ПРОПУСТИТЬ`/`СДАТЬСЯ` + второй клик |
|
||||
| Результат мини-игры | `ПРОДОЛЖИТЬ` или `Esc` |
|
||||
| Магазин: закрыть | `ЗАКРЫТЬ` или `Esc` |
|
||||
| Пауза | `Esc` |
|
||||
| Панель соединения: отмена (слот свободен, запрос остаётся) | `Esc` |
|
||||
|
||||
## Запуск
|
||||
|
||||
Нужен Godot 4.7.2. В этом окружении:
|
||||
Нужен Godot **4.7.2**. Команды выполняются из корня репозитория; ниже
|
||||
`GODOT` — путь к бинарю Godot.
|
||||
|
||||
```bash
|
||||
GODOT=/home/eq/techlb/tools/godot/Godot_v4.7.2-stable_linux.x86_64
|
||||
|
||||
# один раз после клона: импорт шрифтов и темы
|
||||
"$GODOT" --headless --path . --import
|
||||
|
||||
@@ -84,6 +139,13 @@ GODOT=/home/eq/techlb/tools/godot/Godot_v4.7.2-stable_linux.x86_64
|
||||
"$GODOT" -e --path .
|
||||
```
|
||||
|
||||
В Windows удобно задать путь один раз:
|
||||
|
||||
```powershell
|
||||
$env:GODOT_BIN = "D:\Apps\godot\Godot_v4.7.2-stable_win64_console.exe"
|
||||
& $env:GODOT_BIN --path .
|
||||
```
|
||||
|
||||
## Сборка Web
|
||||
|
||||
```bash
|
||||
@@ -97,20 +159,37 @@ mkdir -p build/web
|
||||
просмотр — только по HTTP:
|
||||
|
||||
```bash
|
||||
python3 -m http.server 8000 --directory build/web
|
||||
python -m http.server 8000 --directory build/web
|
||||
# http://localhost:8000
|
||||
```
|
||||
|
||||
Нужны export templates 4.7.2 (`web_nothreads_release.zip`).
|
||||
Нужны export templates 4.7.2 (`web_nothreads_release.zip`). Каталог шаблонов
|
||||
должен называться `4.7.2.stable`.
|
||||
|
||||
## Тесты
|
||||
|
||||
Полный прогон (21 набор + parse-check):
|
||||
Актуальный список — **полный набор + parse-check** в
|
||||
`scripts/run_all_tests.sh` / `scripts/run_all_tests.ps1` (см. также `tests/`;
|
||||
число наборов и проверок может меняться между волнами, поэтому здесь не
|
||||
хардкодится). Core-наборы (`test_hour`, `test_economy`, `test_save`,
|
||||
`test_slots`, `test_controller`, `test_integration`, `test_fuzz`,
|
||||
`test_full_playthrough`, `test_shop`, `test_shop_modal`) обновлены и зелёные;
|
||||
UI-наборы (`test_main_*`, `ui_smoke`) прогоняются отдельно.
|
||||
|
||||
```powershell
|
||||
# Windows
|
||||
powershell -ExecutionPolicy Bypass -File scripts/run_all_tests.ps1
|
||||
```
|
||||
|
||||
```bash
|
||||
# Linux / macOS
|
||||
bash scripts/run_all_tests.sh
|
||||
```
|
||||
|
||||
Windows-раннер сам ищет Godot (сначала `GODOT_BIN`, затем стандартный путь,
|
||||
затем `godot4`/`godot` из `PATH`); путь можно передать явно:
|
||||
`-GodotBin <path>`.
|
||||
|
||||
Точечно:
|
||||
|
||||
```bash
|
||||
@@ -120,32 +199,49 @@ bash scripts/run_all_tests.sh
|
||||
"$GODOT" --headless --path . --script scripts/ui/ui_smoke.gd
|
||||
```
|
||||
|
||||
Фактический итог: **22/22 PASS** (21 набор + parse-check); ключевые
|
||||
счётчики — `test_main_prep` 92/0, `ui_smoke` 135/0, `test_ambient` 74/0.
|
||||
Подробности — в
|
||||
[AI_REPORT](AI_REPORT.md).
|
||||
Баланс (240 сидов, отчёт `tests/SIM_BALANCE_REPORT.md`):
|
||||
|
||||
```bash
|
||||
python tests/sim_balance.py
|
||||
```
|
||||
|
||||
Отчёт `tests/SIM_BALANCE_REPORT.md` (240 сидов, 9 часов, `q=0.85`, TCP):
|
||||
средний итог **7 205 $** (payout 6 344 + `recv_fee` 177 + атаки 684) в полосе
|
||||
**6 982–8 500 $**, расход магазина **3 964 $** (**44,9 %** дерева **8 825 $**),
|
||||
**0 поражений из 240**, SLA в конце H9 — **64,9**, запросов выполнено — **62,0**,
|
||||
вердикт `PASS`, exit code **0**. H1 `income_target` — **100 $** (2-минутный
|
||||
час), H2, H4 и H5 близко к −20 %.
|
||||
Подробности — в [AI_REPORT](AI_REPORT.md).
|
||||
|
||||
E2E-прогоны на реальной сцене `Main` и веб-смоук проводились в предыдущей
|
||||
волне; после изменений TCP/UDP-модели и UI их нужно перепроверить перед
|
||||
публикацией. Финальный счёт UI-наборов здесь не хардкодится.
|
||||
|
||||
## Структура
|
||||
|
||||
```
|
||||
peak-hour/
|
||||
Game-JAM/
|
||||
├─ project.godot # 1920×1080 / 1280×720, GL Compatibility
|
||||
├─ export_presets.cfg # Web (no threads)
|
||||
├─ scenes/ # Main + dev-превью
|
||||
├─ scenes/ # Main + UI/мини-игры
|
||||
├─ scripts/
|
||||
│ ├─ main.gd # UI, ввод, модалки, drag&drop, туториал
|
||||
│ ├─ game_controller.gd # цикл menu → prep → hour → results
|
||||
│ ├─ run_all_tests.sh # полный headless-прогон
|
||||
│ ├─ run_all_tests.ps1/.sh # полный headless-прогон
|
||||
│ ├─ autoload/ # Game, Data, Audio, Rng, Ambient
|
||||
│ ├─ core/ # HourDirector, FileRegistry, SlotManager, Economy
|
||||
│ ├─ entities/ # файлы, пакеты, запросы, папки, слоты
|
||||
│ ├─ systems/ # атаки, магазин, сохранения
|
||||
│ ├─ ui/ # Theme90 v2, модалки, маскот, VHS-оверлей
|
||||
│ └─ minigames/ # ConnectionGame, MiniGame
|
||||
│ ├─ entities/ # файлы, папки, секции, пакеты, запросы, слоты
|
||||
│ ├─ systems/ # Shop, AttackDirector, SaveManager
|
||||
│ ├─ ui/ # Theme90, FileGrid, SortingTable, ShopModal, VHS,
|
||||
│ │ # TemperatureGauge, StressOverlay, UiMotion
|
||||
│ └─ minigames/ # MiniGame, factory, connection_game, send/, attack/
|
||||
├─ data/ # balance.json, strings_ru.json, names_ru.json
|
||||
├─ art/fonts/ # Handjet, Press Start 2P + OFL
|
||||
├─ art/fonts/ # Kechusilka, DisplayOTF, Golos fallback, Press Start 2P
|
||||
├─ art/icons/ # пиксельные иконки + generate_icons.py
|
||||
├─ audio/ # sfx/, music/, amb/, generate_*py
|
||||
├─ tests/ # headless-наборы
|
||||
├─ docs/ # GDD, DECISIONS, UI_REFRESH, screenshots
|
||||
├─ shaders/ # vhs.gdshader (только главное меню)
|
||||
├─ tests/ # headless-наборы, sim_balance.py
|
||||
├─ docs/ # GDD, DECISIONS, UI_REFRESH, скриншоты
|
||||
└─ build/ # артефакты экспорта (gitignored)
|
||||
```
|
||||
|
||||
@@ -153,23 +249,26 @@ peak-hour/
|
||||
|
||||
- Godot **4.7.2** (GL Compatibility).
|
||||
- Для Web: export templates 4.7.2, `web_nothreads_release.zip`.
|
||||
- Python 3 — только для пересборки SFX.
|
||||
- Python 3 — для баланса и пересборки SFX/ambient.
|
||||
- Никаких секретов и внешних сервисов.
|
||||
|
||||
## Документация
|
||||
|
||||
- [GDD](docs/GDD.md) — правила и числа v1.2.
|
||||
- [DECISIONS](docs/DECISIONS.md) — решения D01–D25.
|
||||
- [GDD](docs/GDD.md) — правила и числа v3.0.
|
||||
- [DECISIONS](docs/DECISIONS.md) — решения D01–D61 (актуальны D45–D61).
|
||||
- [AI_REPORT](AI_REPORT.md) — модели, приёмы и проверки.
|
||||
- [ASSETS](ASSETS.md) — шрифты, звук, лицензии и воспроизведение.
|
||||
- [UI_REFRESH](docs/UI_REFRESH.md) — UI v2 «Kaleidos».
|
||||
- [ASSETS](ASSETS.md) — шрифты, иконки, звук, лицензии и воспроизведение.
|
||||
- [UI_REFRESH](docs/UI_REFRESH.md) — UI v3 «Kaleidos».
|
||||
- [IMPLEMENTATION](docs/IMPLEMENTATION.md) и [TEST_PLAN](docs/TEST_PLAN.md) —
|
||||
контракты и матрица тестов.
|
||||
исторические контракты v1 и матрица тестов (v3-дельты — в начале
|
||||
IMPLEMENTATION).
|
||||
- [ITCH_PAGE](docs/ITCH_PAGE.md) — текст страницы itch.io.
|
||||
- [screenshots](docs/screenshots/) — кадры меню, подготовки, часа и атак.
|
||||
|
||||
## Лицензии
|
||||
|
||||
Код — **MIT** ([LICENSE](LICENSE)). Шрифты — **SIL OFL 1.1**
|
||||
(`art/fonts/OFL-*.txt`). UI и иконка — MIT. SFX и `hum.wav` — MIT/CC0,
|
||||
сгенерированы `audio/generate_sfx.py`. Подробности — в [ASSETS.md](ASSETS.md).
|
||||
Код — **MIT** ([LICENSE](LICENSE)). Шрифты: Golos Text и Press Start 2P —
|
||||
**SIL OFL 1.1**, Kechusilka — лицензия подтверждена владельцем проекта,
|
||||
DisplayOTF — **freeware/personal** (Denis A Serikov). UI, пиксельные иконки
|
||||
(`art/icons/`) и иконка — MIT. SFX и `hum.wav` — MIT/CC0, сгенерированы
|
||||
`audio/generate_sfx.py`. Подробности и тексты лицензий — в [ASSETS.md](ASSETS.md).
|
||||
@@ -0,0 +1 @@
|
||||
Copyright (c) Denis A Serikov, 2003-2006. All rights reserved. denis_box@mtu-net.ru
|
||||
@@ -0,0 +1,36 @@
|
||||
[remap]
|
||||
|
||||
importer="font_data_dynamic"
|
||||
type="FontFile"
|
||||
uid="uid://d26do8xk5bs24"
|
||||
path="res://.godot/imported/DisplayOTF.otf-521c0bcaa41177fabd074bc7be7bb37f.fontdata"
|
||||
|
||||
[deps]
|
||||
|
||||
source_file="res://art/fonts/DisplayOTF.otf"
|
||||
dest_files=["res://.godot/imported/DisplayOTF.otf-521c0bcaa41177fabd074bc7be7bb37f.fontdata"]
|
||||
|
||||
[params]
|
||||
|
||||
Rendering=null
|
||||
antialiasing=1
|
||||
generate_mipmaps=false
|
||||
disable_embedded_bitmaps=true
|
||||
multichannel_signed_distance_field=false
|
||||
msdf_pixel_range=8
|
||||
msdf_size=48
|
||||
allow_system_fallback=true
|
||||
force_autohinter=false
|
||||
modulate_color_glyphs=false
|
||||
hinting=3
|
||||
subpixel_positioning=4
|
||||
keep_rounding_remainders=true
|
||||
oversampling=0.0
|
||||
Fallbacks=null
|
||||
fallbacks=[]
|
||||
Compress=null
|
||||
compress=true
|
||||
preload=[]
|
||||
language_support={}
|
||||
script_support={}
|
||||
opentype_features={}
|
||||
@@ -0,0 +1,36 @@
|
||||
[remap]
|
||||
|
||||
importer="font_data_dynamic"
|
||||
type="FontFile"
|
||||
uid="uid://cwms0uuyam0fk"
|
||||
path="res://.godot/imported/GolosText-VF.ttf-af72b844d15035464cce46e3ecf9f3ea.fontdata"
|
||||
|
||||
[deps]
|
||||
|
||||
source_file="res://art/fonts/GolosText-VF.ttf"
|
||||
dest_files=["res://.godot/imported/GolosText-VF.ttf-af72b844d15035464cce46e3ecf9f3ea.fontdata"]
|
||||
|
||||
[params]
|
||||
|
||||
Rendering=null
|
||||
antialiasing=1
|
||||
generate_mipmaps=false
|
||||
disable_embedded_bitmaps=true
|
||||
multichannel_signed_distance_field=false
|
||||
msdf_pixel_range=8
|
||||
msdf_size=48
|
||||
allow_system_fallback=true
|
||||
force_autohinter=false
|
||||
modulate_color_glyphs=false
|
||||
hinting=3
|
||||
subpixel_positioning=4
|
||||
keep_rounding_remainders=true
|
||||
oversampling=0.0
|
||||
Fallbacks=null
|
||||
fallbacks=[]
|
||||
Compress=null
|
||||
compress=true
|
||||
preload=[]
|
||||
language_support={}
|
||||
script_support={}
|
||||
opentype_features={}
|
||||
@@ -0,0 +1,36 @@
|
||||
[remap]
|
||||
|
||||
importer="font_data_dynamic"
|
||||
type="FontFile"
|
||||
uid="uid://blvusebgit70d"
|
||||
path="res://.godot/imported/Kechusilka-Regular.otf-b002f5d4ceda13c25fba42b2e2fc1ede.fontdata"
|
||||
|
||||
[deps]
|
||||
|
||||
source_file="res://art/fonts/Kechusilka-Regular.otf"
|
||||
dest_files=["res://.godot/imported/Kechusilka-Regular.otf-b002f5d4ceda13c25fba42b2e2fc1ede.fontdata"]
|
||||
|
||||
[params]
|
||||
|
||||
Rendering=null
|
||||
antialiasing=1
|
||||
generate_mipmaps=false
|
||||
disable_embedded_bitmaps=true
|
||||
multichannel_signed_distance_field=false
|
||||
msdf_pixel_range=8
|
||||
msdf_size=48
|
||||
allow_system_fallback=true
|
||||
force_autohinter=false
|
||||
modulate_color_glyphs=false
|
||||
hinting=3
|
||||
subpixel_positioning=4
|
||||
keep_rounding_remainders=true
|
||||
oversampling=0.0
|
||||
Fallbacks=null
|
||||
fallbacks=[]
|
||||
Compress=null
|
||||
compress=true
|
||||
preload=[]
|
||||
language_support={}
|
||||
script_support={}
|
||||
opentype_features={}
|
||||
@@ -0,0 +1,34 @@
|
||||
-------------------------------------------------------------------------------------------------------------------------------------
|
||||
Пожалуйста, перед использованием, прочтите данное соглашение!
|
||||
-------------------------------------------------------------------------------------------------------------------------------------
|
||||
|
||||
|
||||
|
||||
====================================================================================
|
||||
|
||||
Лицензионное соглашение:
|
||||
|
||||
Используя этот шрифт, вы автоматически принимаете данное соглашение.
|
||||
Шрифт является бесплатным (freeware) и предоставляется по принципу "как есть",
|
||||
Следовательно, никаких гарантий со стороны производителя не прилагается
|
||||
и не предусматривается. Вы используете этот шрифт на свой страх и риск.
|
||||
Автор, не будет отвечать ни за какие потери или искажения данных, любую упущенную
|
||||
выгоду в процессе использования, или не правильного использования этого шрифта.
|
||||
Любая продажа шрифта или передача с целью получения дохода, запрещена.
|
||||
Автор оставляет за собой право собственности на данный продукт и возможность
|
||||
внесения любых изменений без предварительного уведомления и предупреждения.
|
||||
Декомпиляция шрифта, изменение формата, и внесение изменений в состав
|
||||
знаков - только с разрешения автора.
|
||||
В случае возникновения вопросов, свяжитесь со мной по E-mail:denis_box@mtu-net.ru
|
||||
|
||||
====================================================================================
|
||||
!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!
|
||||
|
||||
|
||||
Следует помнить, что все логотипы и торговые марки и зарегистрированные торговые
|
||||
марки принадлежат их законным владельцам.
|
||||
|
||||
|
||||
!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!
|
||||
====================================================================================
|
||||
Этот шрифт был скачен с сайта www.otlab.ru
|
||||
@@ -0,0 +1,93 @@
|
||||
Copyright 2019 The Golos Text Project Authors (https://github.com/googlefonts/golos-text)
|
||||
|
||||
This Font Software is licensed under the SIL Open Font License, Version 1.1.
|
||||
This license is copied below, and is also available with a FAQ at:
|
||||
https://scripts.sil.org/OFL
|
||||
|
||||
|
||||
-----------------------------------------------------------
|
||||
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
|
||||
-----------------------------------------------------------
|
||||
|
||||
PREAMBLE
|
||||
The goals of the Open Font License (OFL) are to stimulate worldwide
|
||||
development of collaborative font projects, to support the font creation
|
||||
efforts of academic and linguistic communities, and to provide a free and
|
||||
open framework in which fonts may be shared and improved in partnership
|
||||
with others.
|
||||
|
||||
The OFL allows the licensed fonts to be used, studied, modified and
|
||||
redistributed freely as long as they are not sold by themselves. The
|
||||
fonts, including any derivative works, can be bundled, embedded,
|
||||
redistributed and/or sold with any software provided that any reserved
|
||||
names are not used by derivative works. The fonts and derivatives,
|
||||
however, cannot be released under any other type of license. The
|
||||
requirement for fonts to remain under this license does not apply
|
||||
to any document created using the fonts or their derivatives.
|
||||
|
||||
DEFINITIONS
|
||||
"Font Software" refers to the set of files released by the Copyright
|
||||
Holder(s) under this license and clearly marked as such. This may
|
||||
include source files, build scripts and documentation.
|
||||
|
||||
"Reserved Font Name" refers to any names specified as such after the
|
||||
copyright statement(s).
|
||||
|
||||
"Original Version" refers to the collection of Font Software components as
|
||||
distributed by the Copyright Holder(s).
|
||||
|
||||
"Modified Version" refers to any derivative made by adding to, deleting,
|
||||
or substituting -- in part or in whole -- any of the components of the
|
||||
Original Version, by changing formats or by porting the Font Software to a
|
||||
new environment.
|
||||
|
||||
"Author" refers to any designer, engineer, programmer, technical
|
||||
writer or other person who contributed to the Font Software.
|
||||
|
||||
PERMISSION & CONDITIONS
|
||||
Permission is hereby granted, free of charge, to any person obtaining
|
||||
a copy of the Font Software, to use, study, copy, merge, embed, modify,
|
||||
redistribute, and sell modified and unmodified copies of the Font
|
||||
Software, subject to the following conditions:
|
||||
|
||||
1) Neither the Font Software nor any of its individual components,
|
||||
in Original or Modified Versions, may be sold by itself.
|
||||
|
||||
2) Original or Modified Versions of the Font Software may be bundled,
|
||||
redistributed and/or sold with any software, provided that each copy
|
||||
contains the above copyright notice and this license. These can be
|
||||
included either as stand-alone text files, human-readable headers or
|
||||
in the appropriate machine-readable metadata fields within text or
|
||||
binary files as long as those fields can be easily viewed by the user.
|
||||
|
||||
3) No Modified Version of the Font Software may use the Reserved Font
|
||||
Name(s) unless explicit written permission is granted by the corresponding
|
||||
Copyright Holder. This restriction only applies to the primary font name as
|
||||
presented to the users.
|
||||
|
||||
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
|
||||
Software shall not be used to promote, endorse or advertise any
|
||||
Modified Version, except to acknowledge the contribution(s) of the
|
||||
Copyright Holder(s) and the Author(s) or with their explicit written
|
||||
permission.
|
||||
|
||||
5) The Font Software, modified or unmodified, in part or in whole,
|
||||
must be distributed entirely under this license, and must not be
|
||||
distributed under any other license. The requirement for fonts to
|
||||
remain under this license does not apply to any document created
|
||||
using the Font Software.
|
||||
|
||||
TERMINATION
|
||||
This license becomes null and void if any of the above conditions are
|
||||
not met.
|
||||
|
||||
DISCLAIMER
|
||||
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
||||
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
|
||||
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
|
||||
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
|
||||
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
|
||||
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
|
||||
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
|
||||
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
|
||||
OTHER DEALINGS IN THE FONT SOFTWARE.
|
||||
@@ -1,17 +1,15 @@
|
||||
[gd_resource type="Theme" format=3]
|
||||
|
||||
[ext_resource type="FontFile" path="res://art/fonts/Handjet-VF.ttf" id="1_hjthv"]
|
||||
[ext_resource type="FontFile" path="res://art/fonts/Kechusilka-Regular.otf" id="1_cg7jb"]
|
||||
[ext_resource type="FontFile" path="res://art/fonts/GolosText-VF.ttf" id="1_fum4e"]
|
||||
[ext_resource type="FontFile" path="res://art/fonts/PressStart2P-Regular.ttf" id="2_fum4e"]
|
||||
[ext_resource type="FontFile" path="res://art/fonts/DisplayOTF.otf" id="3_dc2yr"]
|
||||
|
||||
[sub_resource type="FontVariation" id="FontVariation_fum4e"]
|
||||
base_font = ExtResource("1_hjthv")
|
||||
variation_opentype = {
|
||||
1162626898: 2.0,
|
||||
1162629960: 2.0,
|
||||
2003265652: 700.0
|
||||
}
|
||||
[sub_resource type="FontVariation" id="FontVariation_6m2gx"]
|
||||
fallbacks = Array[Font]([ExtResource("1_fum4e")])
|
||||
base_font = ExtResource("1_cg7jb")
|
||||
|
||||
[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_lr7a5"]
|
||||
[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_x3s7b"]
|
||||
bg_color = Color(0.913725, 0.901961, 0.866667, 1)
|
||||
border_width_left = 1
|
||||
border_width_top = 1
|
||||
@@ -24,7 +22,7 @@ corner_radius_bottom_right = 3
|
||||
corner_radius_bottom_left = 3
|
||||
corner_detail = 4
|
||||
|
||||
[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_cg7jb"]
|
||||
[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_ukyte"]
|
||||
bg_color = Color(0, 0, 0, 0)
|
||||
border_width_left = 1
|
||||
border_width_top = 1
|
||||
@@ -37,7 +35,7 @@ corner_radius_bottom_right = 3
|
||||
corner_radius_bottom_left = 3
|
||||
corner_detail = 4
|
||||
|
||||
[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_dc2yr"]
|
||||
[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_wjr02"]
|
||||
bg_color = Color(1, 1, 1, 1)
|
||||
border_width_left = 1
|
||||
border_width_top = 1
|
||||
@@ -53,7 +51,7 @@ shadow_color = Color(0, 0, 0, 0.094)
|
||||
shadow_size = 3
|
||||
shadow_offset = Vector2(0, 2)
|
||||
|
||||
[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_6m2gx"]
|
||||
[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_4munx"]
|
||||
bg_color = Color(0.968627, 0.960784, 0.933333, 1)
|
||||
border_width_left = 1
|
||||
border_width_top = 1
|
||||
@@ -69,7 +67,7 @@ shadow_color = Color(0, 0, 0, 0.094)
|
||||
shadow_size = 3
|
||||
shadow_offset = Vector2(0, 2)
|
||||
|
||||
[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_x3s7b"]
|
||||
[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_oc7r5"]
|
||||
bg_color = Color(0.847059, 0.827451, 0.788235, 1)
|
||||
border_width_left = 1
|
||||
border_width_top = 1
|
||||
@@ -82,33 +80,9 @@ corner_radius_bottom_right = 3
|
||||
corner_radius_bottom_left = 3
|
||||
corner_detail = 4
|
||||
|
||||
[sub_resource type="Image" id="Image_ukyte"]
|
||||
data = {
|
||||
"data": PackedByteArray(0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 47, 126, 142, 255, 47, 126, 142, 255, 47, 126, 142, 255, 47, 126, 142, 255, 47, 126, 142, 255, 47, 126, 142, 255, 47, 126, 142, 255, 47, 126, 142, 255, 47, 126, 142, 255, 47, 126, 142, 255, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 47, 126, 142, 255, 47, 126, 142, 255, 47, 126, 142, 255, 47, 126, 142, 255, 47, 126, 142, 255, 47, 126, 142, 255, 47, 126, 142, 255, 47, 126, 142, 255, 47, 126, 142, 255, 47, 126, 142, 255, 47, 126, 142, 255, 47, 126, 142, 255, 0, 0, 0, 0, 0, 0, 0, 0, 47, 126, 142, 255, 47, 126, 142, 255, 47, 126, 142, 255, 47, 126, 142, 255, 47, 126, 142, 255, 47, 126, 142, 255, 47, 126, 142, 255, 47, 126, 142, 255, 47, 126, 142, 255, 47, 126, 142, 255, 47, 126, 142, 255, 47, 126, 142, 255, 0, 0, 0, 0, 0, 0, 0, 0, 47, 126, 142, 255, 47, 126, 142, 255, 47, 126, 142, 255, 47, 126, 142, 255, 47, 126, 142, 255, 47, 126, 142, 255, 47, 126, 142, 255, 47, 126, 142, 255, 47, 126, 142, 255, 255, 255, 255, 255, 255, 255, 255, 255, 47, 126, 142, 255, 0, 0, 0, 0, 0, 0, 0, 0, 47, 126, 142, 255, 47, 126, 142, 255, 47, 126, 142, 255, 47, 126, 142, 255, 47, 126, 142, 255, 47, 126, 142, 255, 47, 126, 142, 255, 47, 126, 142, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 47, 126, 142, 255, 0, 0, 0, 0, 0, 0, 0, 0, 47, 126, 142, 255, 47, 126, 142, 255, 47, 126, 142, 255, 47, 126, 142, 255, 47, 126, 142, 255, 47, 126, 142, 255, 47, 126, 142, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 47, 126, 142, 255, 47, 126, 142, 255, 0, 0, 0, 0, 0, 0, 0, 0, 47, 126, 142, 255, 47, 126, 142, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 47, 126, 142, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 47, 126, 142, 255, 47, 126, 142, 255, 47, 126, 142, 255, 0, 0, 0, 0, 0, 0, 0, 0, 47, 126, 142, 255, 47, 126, 142, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 47, 126, 142, 255, 47, 126, 142, 255, 47, 126, 142, 255, 0, 0, 0, 0, 0, 0, 0, 0, 47, 126, 142, 255, 47, 126, 142, 255, 47, 126, 142, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 47, 126, 142, 255, 47, 126, 142, 255, 47, 126, 142, 255, 47, 126, 142, 255, 0, 0, 0, 0, 0, 0, 0, 0, 47, 126, 142, 255, 47, 126, 142, 255, 47, 126, 142, 255, 47, 126, 142, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 47, 126, 142, 255, 47, 126, 142, 255, 47, 126, 142, 255, 47, 126, 142, 255, 47, 126, 142, 255, 0, 0, 0, 0, 0, 0, 0, 0, 47, 126, 142, 255, 47, 126, 142, 255, 47, 126, 142, 255, 47, 126, 142, 255, 47, 126, 142, 255, 47, 126, 142, 255, 47, 126, 142, 255, 47, 126, 142, 255, 47, 126, 142, 255, 47, 126, 142, 255, 47, 126, 142, 255, 47, 126, 142, 255, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 47, 126, 142, 255, 47, 126, 142, 255, 47, 126, 142, 255, 47, 126, 142, 255, 47, 126, 142, 255, 47, 126, 142, 255, 47, 126, 142, 255, 47, 126, 142, 255, 47, 126, 142, 255, 47, 126, 142, 255, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0),
|
||||
"format": "RGBA8",
|
||||
"height": 14,
|
||||
"mipmaps": false,
|
||||
"width": 14
|
||||
}
|
||||
|
||||
[sub_resource type="ImageTexture" id="ImageTexture_wjr02"]
|
||||
image = SubResource("Image_ukyte")
|
||||
|
||||
[sub_resource type="Image" id="Image_4munx"]
|
||||
data = {
|
||||
"data": PackedByteArray(0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 47, 126, 142, 255, 47, 126, 142, 255, 47, 126, 142, 255, 47, 126, 142, 255, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 47, 126, 142, 255, 47, 126, 142, 255, 47, 126, 142, 255, 47, 126, 142, 255, 47, 126, 142, 255, 47, 126, 142, 255, 47, 126, 142, 255, 47, 126, 142, 255, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 47, 126, 142, 255, 47, 126, 142, 255, 47, 126, 142, 255, 47, 126, 142, 255, 47, 126, 142, 255, 47, 126, 142, 255, 47, 126, 142, 255, 47, 126, 142, 255, 47, 126, 142, 255, 47, 126, 142, 255, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 47, 126, 142, 255, 47, 126, 142, 255, 47, 126, 142, 255, 47, 126, 142, 255, 47, 126, 142, 255, 47, 126, 142, 255, 47, 126, 142, 255, 47, 126, 142, 255, 47, 126, 142, 255, 47, 126, 142, 255, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 47, 126, 142, 255, 47, 126, 142, 255, 47, 126, 142, 255, 47, 126, 142, 255, 47, 126, 142, 255, 47, 126, 142, 255, 47, 126, 142, 255, 47, 126, 142, 255, 47, 126, 142, 255, 47, 126, 142, 255, 47, 126, 142, 255, 47, 126, 142, 255, 0, 0, 0, 0, 0, 0, 0, 0, 47, 126, 142, 255, 47, 126, 142, 255, 47, 126, 142, 255, 47, 126, 142, 255, 47, 126, 142, 255, 47, 126, 142, 255, 47, 126, 142, 255, 47, 126, 142, 255, 47, 126, 142, 255, 47, 126, 142, 255, 47, 126, 142, 255, 47, 126, 142, 255, 0, 0, 0, 0, 0, 0, 0, 0, 47, 126, 142, 255, 47, 126, 142, 255, 47, 126, 142, 255, 47, 126, 142, 255, 47, 126, 142, 255, 47, 126, 142, 255, 47, 126, 142, 255, 47, 126, 142, 255, 47, 126, 142, 255, 47, 126, 142, 255, 47, 126, 142, 255, 47, 126, 142, 255, 0, 0, 0, 0, 0, 0, 0, 0, 47, 126, 142, 255, 47, 126, 142, 255, 47, 126, 142, 255, 47, 126, 142, 255, 47, 126, 142, 255, 47, 126, 142, 255, 47, 126, 142, 255, 47, 126, 142, 255, 47, 126, 142, 255, 47, 126, 142, 255, 47, 126, 142, 255, 47, 126, 142, 255, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 47, 126, 142, 255, 47, 126, 142, 255, 47, 126, 142, 255, 47, 126, 142, 255, 47, 126, 142, 255, 47, 126, 142, 255, 47, 126, 142, 255, 47, 126, 142, 255, 47, 126, 142, 255, 47, 126, 142, 255, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 47, 126, 142, 255, 47, 126, 142, 255, 47, 126, 142, 255, 47, 126, 142, 255, 47, 126, 142, 255, 47, 126, 142, 255, 47, 126, 142, 255, 47, 126, 142, 255, 47, 126, 142, 255, 47, 126, 142, 255, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 47, 126, 142, 255, 47, 126, 142, 255, 47, 126, 142, 255, 47, 126, 142, 255, 47, 126, 142, 255, 47, 126, 142, 255, 47, 126, 142, 255, 47, 126, 142, 255, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 47, 126, 142, 255, 47, 126, 142, 255, 47, 126, 142, 255, 47, 126, 142, 255, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0),
|
||||
"format": "RGBA8",
|
||||
"height": 14,
|
||||
"mipmaps": false,
|
||||
"width": 14
|
||||
}
|
||||
|
||||
[sub_resource type="ImageTexture" id="ImageTexture_oc7r5"]
|
||||
image = SubResource("Image_4munx")
|
||||
|
||||
[sub_resource type="Image" id="Image_y0w1v"]
|
||||
data = {
|
||||
"data": PackedByteArray(0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 65, 61, 52, 56, 65, 61, 52, 56, 65, 61, 52, 56, 65, 61, 52, 56, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 65, 61, 52, 56, 65, 61, 52, 56, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 65, 61, 52, 56, 65, 61, 52, 56, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 65, 61, 52, 56, 65, 61, 52, 56, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 65, 61, 52, 56, 65, 61, 52, 56, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 65, 61, 52, 56, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 65, 61, 52, 56, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 65, 61, 52, 56, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 65, 61, 52, 56, 0, 0, 0, 0, 0, 0, 0, 0, 65, 61, 52, 56, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 65, 61, 52, 56, 0, 0, 0, 0, 0, 0, 0, 0, 65, 61, 52, 56, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 65, 61, 52, 56, 0, 0, 0, 0, 0, 0, 0, 0, 65, 61, 52, 56, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 65, 61, 52, 56, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 65, 61, 52, 56, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 65, 61, 52, 56, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 65, 61, 52, 56, 65, 61, 52, 56, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 65, 61, 52, 56, 65, 61, 52, 56, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 65, 61, 52, 56, 65, 61, 52, 56, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 65, 61, 52, 56, 65, 61, 52, 56, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 65, 61, 52, 56, 65, 61, 52, 56, 65, 61, 52, 56, 65, 61, 52, 56, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0),
|
||||
"data": PackedByteArray(0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 47, 126, 142, 255, 47, 126, 142, 255, 47, 126, 142, 255, 47, 126, 142, 255, 47, 126, 142, 255, 47, 126, 142, 255, 47, 126, 142, 255, 47, 126, 142, 255, 47, 126, 142, 255, 47, 126, 142, 255, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 47, 126, 142, 255, 47, 126, 142, 255, 47, 126, 142, 255, 47, 126, 142, 255, 47, 126, 142, 255, 47, 126, 142, 255, 47, 126, 142, 255, 47, 126, 142, 255, 47, 126, 142, 255, 47, 126, 142, 255, 47, 126, 142, 255, 47, 126, 142, 255, 0, 0, 0, 0, 0, 0, 0, 0, 47, 126, 142, 255, 47, 126, 142, 255, 47, 126, 142, 255, 47, 126, 142, 255, 47, 126, 142, 255, 47, 126, 142, 255, 47, 126, 142, 255, 47, 126, 142, 255, 47, 126, 142, 255, 47, 126, 142, 255, 47, 126, 142, 255, 47, 126, 142, 255, 0, 0, 0, 0, 0, 0, 0, 0, 47, 126, 142, 255, 47, 126, 142, 255, 47, 126, 142, 255, 47, 126, 142, 255, 47, 126, 142, 255, 47, 126, 142, 255, 47, 126, 142, 255, 47, 126, 142, 255, 47, 126, 142, 255, 255, 255, 255, 255, 255, 255, 255, 255, 47, 126, 142, 255, 0, 0, 0, 0, 0, 0, 0, 0, 47, 126, 142, 255, 47, 126, 142, 255, 47, 126, 142, 255, 47, 126, 142, 255, 47, 126, 142, 255, 47, 126, 142, 255, 47, 126, 142, 255, 47, 126, 142, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 47, 126, 142, 255, 0, 0, 0, 0, 0, 0, 0, 0, 47, 126, 142, 255, 47, 126, 142, 255, 47, 126, 142, 255, 47, 126, 142, 255, 47, 126, 142, 255, 47, 126, 142, 255, 47, 126, 142, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 47, 126, 142, 255, 47, 126, 142, 255, 0, 0, 0, 0, 0, 0, 0, 0, 47, 126, 142, 255, 47, 126, 142, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 47, 126, 142, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 47, 126, 142, 255, 47, 126, 142, 255, 47, 126, 142, 255, 0, 0, 0, 0, 0, 0, 0, 0, 47, 126, 142, 255, 47, 126, 142, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 47, 126, 142, 255, 47, 126, 142, 255, 47, 126, 142, 255, 0, 0, 0, 0, 0, 0, 0, 0, 47, 126, 142, 255, 47, 126, 142, 255, 47, 126, 142, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 47, 126, 142, 255, 47, 126, 142, 255, 47, 126, 142, 255, 47, 126, 142, 255, 0, 0, 0, 0, 0, 0, 0, 0, 47, 126, 142, 255, 47, 126, 142, 255, 47, 126, 142, 255, 47, 126, 142, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 47, 126, 142, 255, 47, 126, 142, 255, 47, 126, 142, 255, 47, 126, 142, 255, 47, 126, 142, 255, 0, 0, 0, 0, 0, 0, 0, 0, 47, 126, 142, 255, 47, 126, 142, 255, 47, 126, 142, 255, 47, 126, 142, 255, 47, 126, 142, 255, 47, 126, 142, 255, 47, 126, 142, 255, 47, 126, 142, 255, 47, 126, 142, 255, 47, 126, 142, 255, 47, 126, 142, 255, 47, 126, 142, 255, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 47, 126, 142, 255, 47, 126, 142, 255, 47, 126, 142, 255, 47, 126, 142, 255, 47, 126, 142, 255, 47, 126, 142, 255, 47, 126, 142, 255, 47, 126, 142, 255, 47, 126, 142, 255, 47, 126, 142, 255, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0),
|
||||
"format": "RGBA8",
|
||||
"height": 14,
|
||||
"mipmaps": false,
|
||||
@@ -120,7 +94,7 @@ image = SubResource("Image_y0w1v")
|
||||
|
||||
[sub_resource type="Image" id="Image_dywbi"]
|
||||
data = {
|
||||
"data": PackedByteArray(0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 65, 61, 52, 56, 65, 61, 52, 56, 65, 61, 52, 56, 65, 61, 52, 56, 65, 61, 52, 56, 65, 61, 52, 56, 65, 61, 52, 56, 65, 61, 52, 56, 65, 61, 52, 56, 65, 61, 52, 56, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 65, 61, 52, 56, 65, 61, 52, 56, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 65, 61, 52, 56, 65, 61, 52, 56, 0, 0, 0, 0, 0, 0, 0, 0, 65, 61, 52, 56, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 65, 61, 52, 56, 0, 0, 0, 0, 0, 0, 0, 0, 65, 61, 52, 56, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 65, 61, 52, 56, 0, 0, 0, 0, 0, 0, 0, 0, 65, 61, 52, 56, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 65, 61, 52, 56, 0, 0, 0, 0, 0, 0, 0, 0, 65, 61, 52, 56, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 65, 61, 52, 56, 0, 0, 0, 0, 0, 0, 0, 0, 65, 61, 52, 56, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 65, 61, 52, 56, 0, 0, 0, 0, 0, 0, 0, 0, 65, 61, 52, 56, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 65, 61, 52, 56, 0, 0, 0, 0, 0, 0, 0, 0, 65, 61, 52, 56, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 65, 61, 52, 56, 0, 0, 0, 0, 0, 0, 0, 0, 65, 61, 52, 56, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 65, 61, 52, 56, 0, 0, 0, 0, 0, 0, 0, 0, 65, 61, 52, 56, 65, 61, 52, 56, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 65, 61, 52, 56, 65, 61, 52, 56, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 65, 61, 52, 56, 65, 61, 52, 56, 65, 61, 52, 56, 65, 61, 52, 56, 65, 61, 52, 56, 65, 61, 52, 56, 65, 61, 52, 56, 65, 61, 52, 56, 65, 61, 52, 56, 65, 61, 52, 56, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0),
|
||||
"data": PackedByteArray(0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 47, 126, 142, 255, 47, 126, 142, 255, 47, 126, 142, 255, 47, 126, 142, 255, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 47, 126, 142, 255, 47, 126, 142, 255, 47, 126, 142, 255, 47, 126, 142, 255, 47, 126, 142, 255, 47, 126, 142, 255, 47, 126, 142, 255, 47, 126, 142, 255, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 47, 126, 142, 255, 47, 126, 142, 255, 47, 126, 142, 255, 47, 126, 142, 255, 47, 126, 142, 255, 47, 126, 142, 255, 47, 126, 142, 255, 47, 126, 142, 255, 47, 126, 142, 255, 47, 126, 142, 255, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 47, 126, 142, 255, 47, 126, 142, 255, 47, 126, 142, 255, 47, 126, 142, 255, 47, 126, 142, 255, 47, 126, 142, 255, 47, 126, 142, 255, 47, 126, 142, 255, 47, 126, 142, 255, 47, 126, 142, 255, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 47, 126, 142, 255, 47, 126, 142, 255, 47, 126, 142, 255, 47, 126, 142, 255, 47, 126, 142, 255, 47, 126, 142, 255, 47, 126, 142, 255, 47, 126, 142, 255, 47, 126, 142, 255, 47, 126, 142, 255, 47, 126, 142, 255, 47, 126, 142, 255, 0, 0, 0, 0, 0, 0, 0, 0, 47, 126, 142, 255, 47, 126, 142, 255, 47, 126, 142, 255, 47, 126, 142, 255, 47, 126, 142, 255, 47, 126, 142, 255, 47, 126, 142, 255, 47, 126, 142, 255, 47, 126, 142, 255, 47, 126, 142, 255, 47, 126, 142, 255, 47, 126, 142, 255, 0, 0, 0, 0, 0, 0, 0, 0, 47, 126, 142, 255, 47, 126, 142, 255, 47, 126, 142, 255, 47, 126, 142, 255, 47, 126, 142, 255, 47, 126, 142, 255, 47, 126, 142, 255, 47, 126, 142, 255, 47, 126, 142, 255, 47, 126, 142, 255, 47, 126, 142, 255, 47, 126, 142, 255, 0, 0, 0, 0, 0, 0, 0, 0, 47, 126, 142, 255, 47, 126, 142, 255, 47, 126, 142, 255, 47, 126, 142, 255, 47, 126, 142, 255, 47, 126, 142, 255, 47, 126, 142, 255, 47, 126, 142, 255, 47, 126, 142, 255, 47, 126, 142, 255, 47, 126, 142, 255, 47, 126, 142, 255, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 47, 126, 142, 255, 47, 126, 142, 255, 47, 126, 142, 255, 47, 126, 142, 255, 47, 126, 142, 255, 47, 126, 142, 255, 47, 126, 142, 255, 47, 126, 142, 255, 47, 126, 142, 255, 47, 126, 142, 255, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 47, 126, 142, 255, 47, 126, 142, 255, 47, 126, 142, 255, 47, 126, 142, 255, 47, 126, 142, 255, 47, 126, 142, 255, 47, 126, 142, 255, 47, 126, 142, 255, 47, 126, 142, 255, 47, 126, 142, 255, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 47, 126, 142, 255, 47, 126, 142, 255, 47, 126, 142, 255, 47, 126, 142, 255, 47, 126, 142, 255, 47, 126, 142, 255, 47, 126, 142, 255, 47, 126, 142, 255, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 47, 126, 142, 255, 47, 126, 142, 255, 47, 126, 142, 255, 47, 126, 142, 255, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0),
|
||||
"format": "RGBA8",
|
||||
"height": 14,
|
||||
"mipmaps": false,
|
||||
@@ -130,38 +104,38 @@ data = {
|
||||
[sub_resource type="ImageTexture" id="ImageTexture_47b07"]
|
||||
image = SubResource("Image_dywbi")
|
||||
|
||||
[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_danrt"]
|
||||
[sub_resource type="Image" id="Image_danrt"]
|
||||
data = {
|
||||
"data": PackedByteArray(0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 65, 61, 52, 56, 65, 61, 52, 56, 65, 61, 52, 56, 65, 61, 52, 56, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 65, 61, 52, 56, 65, 61, 52, 56, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 65, 61, 52, 56, 65, 61, 52, 56, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 65, 61, 52, 56, 65, 61, 52, 56, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 65, 61, 52, 56, 65, 61, 52, 56, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 65, 61, 52, 56, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 65, 61, 52, 56, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 65, 61, 52, 56, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 65, 61, 52, 56, 0, 0, 0, 0, 0, 0, 0, 0, 65, 61, 52, 56, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 65, 61, 52, 56, 0, 0, 0, 0, 0, 0, 0, 0, 65, 61, 52, 56, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 65, 61, 52, 56, 0, 0, 0, 0, 0, 0, 0, 0, 65, 61, 52, 56, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 65, 61, 52, 56, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 65, 61, 52, 56, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 65, 61, 52, 56, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 65, 61, 52, 56, 65, 61, 52, 56, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 65, 61, 52, 56, 65, 61, 52, 56, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 65, 61, 52, 56, 65, 61, 52, 56, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 65, 61, 52, 56, 65, 61, 52, 56, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 65, 61, 52, 56, 65, 61, 52, 56, 65, 61, 52, 56, 65, 61, 52, 56, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0),
|
||||
"format": "RGBA8",
|
||||
"height": 14,
|
||||
"mipmaps": false,
|
||||
"width": 14
|
||||
}
|
||||
|
||||
[sub_resource type="ImageTexture" id="ImageTexture_1j4f6"]
|
||||
image = SubResource("Image_danrt")
|
||||
|
||||
[sub_resource type="Image" id="Image_djhhc"]
|
||||
data = {
|
||||
"data": PackedByteArray(0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 65, 61, 52, 56, 65, 61, 52, 56, 65, 61, 52, 56, 65, 61, 52, 56, 65, 61, 52, 56, 65, 61, 52, 56, 65, 61, 52, 56, 65, 61, 52, 56, 65, 61, 52, 56, 65, 61, 52, 56, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 65, 61, 52, 56, 65, 61, 52, 56, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 65, 61, 52, 56, 65, 61, 52, 56, 0, 0, 0, 0, 0, 0, 0, 0, 65, 61, 52, 56, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 65, 61, 52, 56, 0, 0, 0, 0, 0, 0, 0, 0, 65, 61, 52, 56, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 65, 61, 52, 56, 0, 0, 0, 0, 0, 0, 0, 0, 65, 61, 52, 56, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 65, 61, 52, 56, 0, 0, 0, 0, 0, 0, 0, 0, 65, 61, 52, 56, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 65, 61, 52, 56, 0, 0, 0, 0, 0, 0, 0, 0, 65, 61, 52, 56, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 65, 61, 52, 56, 0, 0, 0, 0, 0, 0, 0, 0, 65, 61, 52, 56, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 65, 61, 52, 56, 0, 0, 0, 0, 0, 0, 0, 0, 65, 61, 52, 56, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 65, 61, 52, 56, 0, 0, 0, 0, 0, 0, 0, 0, 65, 61, 52, 56, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 65, 61, 52, 56, 0, 0, 0, 0, 0, 0, 0, 0, 65, 61, 52, 56, 65, 61, 52, 56, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 65, 61, 52, 56, 65, 61, 52, 56, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 65, 61, 52, 56, 65, 61, 52, 56, 65, 61, 52, 56, 65, 61, 52, 56, 65, 61, 52, 56, 65, 61, 52, 56, 65, 61, 52, 56, 65, 61, 52, 56, 65, 61, 52, 56, 65, 61, 52, 56, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0),
|
||||
"format": "RGBA8",
|
||||
"height": 14,
|
||||
"mipmaps": false,
|
||||
"width": 14
|
||||
}
|
||||
|
||||
[sub_resource type="ImageTexture" id="ImageTexture_a0hrt"]
|
||||
image = SubResource("Image_djhhc")
|
||||
|
||||
[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_jvde3"]
|
||||
bg_color = Color(0, 0, 0, 0)
|
||||
border_color = Color(0, 0, 0, 0)
|
||||
corner_detail = 4
|
||||
|
||||
[sub_resource type="Image" id="Image_1j4f6"]
|
||||
data = {
|
||||
"data": PackedByteArray(0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 154, 149, 137, 255, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 154, 149, 137, 255, 154, 149, 137, 255, 154, 149, 137, 255, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 154, 149, 137, 255, 154, 149, 137, 255, 154, 149, 137, 255, 154, 149, 137, 255, 154, 149, 137, 255, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 154, 149, 137, 255, 154, 149, 137, 255, 154, 149, 137, 255, 154, 149, 137, 255, 154, 149, 137, 255, 154, 149, 137, 255, 154, 149, 137, 255, 0, 0, 0, 0, 154, 149, 137, 255, 154, 149, 137, 255, 154, 149, 137, 255, 154, 149, 137, 255, 154, 149, 137, 255, 154, 149, 137, 255, 154, 149, 137, 255, 154, 149, 137, 255, 154, 149, 137, 255, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0),
|
||||
"format": "RGBA8",
|
||||
"height": 9,
|
||||
"mipmaps": false,
|
||||
"width": 9
|
||||
}
|
||||
|
||||
[sub_resource type="ImageTexture" id="ImageTexture_djhhc"]
|
||||
image = SubResource("Image_1j4f6")
|
||||
|
||||
[sub_resource type="Image" id="Image_a0hrt"]
|
||||
data = {
|
||||
"data": PackedByteArray(0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 43, 91, 103, 255, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 43, 91, 103, 255, 43, 91, 103, 255, 43, 91, 103, 255, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 43, 91, 103, 255, 43, 91, 103, 255, 43, 91, 103, 255, 43, 91, 103, 255, 43, 91, 103, 255, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 43, 91, 103, 255, 43, 91, 103, 255, 43, 91, 103, 255, 43, 91, 103, 255, 43, 91, 103, 255, 43, 91, 103, 255, 43, 91, 103, 255, 0, 0, 0, 0, 43, 91, 103, 255, 43, 91, 103, 255, 43, 91, 103, 255, 43, 91, 103, 255, 43, 91, 103, 255, 43, 91, 103, 255, 43, 91, 103, 255, 43, 91, 103, 255, 43, 91, 103, 255, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0),
|
||||
"format": "RGBA8",
|
||||
"height": 9,
|
||||
"mipmaps": false,
|
||||
"width": 9
|
||||
}
|
||||
|
||||
[sub_resource type="ImageTexture" id="ImageTexture_jvde3"]
|
||||
image = SubResource("Image_a0hrt")
|
||||
|
||||
[sub_resource type="Image" id="Image_372il"]
|
||||
data = {
|
||||
"data": PackedByteArray(0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 154, 149, 137, 255, 154, 149, 137, 255, 154, 149, 137, 255, 154, 149, 137, 255, 154, 149, 137, 255, 154, 149, 137, 255, 154, 149, 137, 255, 154, 149, 137, 255, 154, 149, 137, 255, 0, 0, 0, 0, 154, 149, 137, 255, 154, 149, 137, 255, 154, 149, 137, 255, 154, 149, 137, 255, 154, 149, 137, 255, 154, 149, 137, 255, 154, 149, 137, 255, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 154, 149, 137, 255, 154, 149, 137, 255, 154, 149, 137, 255, 154, 149, 137, 255, 154, 149, 137, 255, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 154, 149, 137, 255, 154, 149, 137, 255, 154, 149, 137, 255, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 154, 149, 137, 255, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0),
|
||||
"data": PackedByteArray(0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 154, 149, 137, 255, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 154, 149, 137, 255, 154, 149, 137, 255, 154, 149, 137, 255, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 154, 149, 137, 255, 154, 149, 137, 255, 154, 149, 137, 255, 154, 149, 137, 255, 154, 149, 137, 255, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 154, 149, 137, 255, 154, 149, 137, 255, 154, 149, 137, 255, 154, 149, 137, 255, 154, 149, 137, 255, 154, 149, 137, 255, 154, 149, 137, 255, 0, 0, 0, 0, 154, 149, 137, 255, 154, 149, 137, 255, 154, 149, 137, 255, 154, 149, 137, 255, 154, 149, 137, 255, 154, 149, 137, 255, 154, 149, 137, 255, 154, 149, 137, 255, 154, 149, 137, 255, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0),
|
||||
"format": "RGBA8",
|
||||
"height": 9,
|
||||
"mipmaps": false,
|
||||
@@ -173,7 +147,7 @@ image = SubResource("Image_372il")
|
||||
|
||||
[sub_resource type="Image" id="Image_11awt"]
|
||||
data = {
|
||||
"data": PackedByteArray(0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 43, 91, 103, 255, 43, 91, 103, 255, 43, 91, 103, 255, 43, 91, 103, 255, 43, 91, 103, 255, 43, 91, 103, 255, 43, 91, 103, 255, 43, 91, 103, 255, 43, 91, 103, 255, 0, 0, 0, 0, 43, 91, 103, 255, 43, 91, 103, 255, 43, 91, 103, 255, 43, 91, 103, 255, 43, 91, 103, 255, 43, 91, 103, 255, 43, 91, 103, 255, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 43, 91, 103, 255, 43, 91, 103, 255, 43, 91, 103, 255, 43, 91, 103, 255, 43, 91, 103, 255, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 43, 91, 103, 255, 43, 91, 103, 255, 43, 91, 103, 255, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 43, 91, 103, 255, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0),
|
||||
"data": PackedByteArray(0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 43, 91, 103, 255, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 43, 91, 103, 255, 43, 91, 103, 255, 43, 91, 103, 255, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 43, 91, 103, 255, 43, 91, 103, 255, 43, 91, 103, 255, 43, 91, 103, 255, 43, 91, 103, 255, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 43, 91, 103, 255, 43, 91, 103, 255, 43, 91, 103, 255, 43, 91, 103, 255, 43, 91, 103, 255, 43, 91, 103, 255, 43, 91, 103, 255, 0, 0, 0, 0, 43, 91, 103, 255, 43, 91, 103, 255, 43, 91, 103, 255, 43, 91, 103, 255, 43, 91, 103, 255, 43, 91, 103, 255, 43, 91, 103, 255, 43, 91, 103, 255, 43, 91, 103, 255, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0),
|
||||
"format": "RGBA8",
|
||||
"height": 9,
|
||||
"mipmaps": false,
|
||||
@@ -183,7 +157,31 @@ data = {
|
||||
[sub_resource type="ImageTexture" id="ImageTexture_loh4e"]
|
||||
image = SubResource("Image_11awt")
|
||||
|
||||
[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_a1mnj"]
|
||||
[sub_resource type="Image" id="Image_a1mnj"]
|
||||
data = {
|
||||
"data": PackedByteArray(0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 154, 149, 137, 255, 154, 149, 137, 255, 154, 149, 137, 255, 154, 149, 137, 255, 154, 149, 137, 255, 154, 149, 137, 255, 154, 149, 137, 255, 154, 149, 137, 255, 154, 149, 137, 255, 0, 0, 0, 0, 154, 149, 137, 255, 154, 149, 137, 255, 154, 149, 137, 255, 154, 149, 137, 255, 154, 149, 137, 255, 154, 149, 137, 255, 154, 149, 137, 255, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 154, 149, 137, 255, 154, 149, 137, 255, 154, 149, 137, 255, 154, 149, 137, 255, 154, 149, 137, 255, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 154, 149, 137, 255, 154, 149, 137, 255, 154, 149, 137, 255, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 154, 149, 137, 255, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0),
|
||||
"format": "RGBA8",
|
||||
"height": 9,
|
||||
"mipmaps": false,
|
||||
"width": 9
|
||||
}
|
||||
|
||||
[sub_resource type="ImageTexture" id="ImageTexture_4uvxq"]
|
||||
image = SubResource("Image_a1mnj")
|
||||
|
||||
[sub_resource type="Image" id="Image_petse"]
|
||||
data = {
|
||||
"data": PackedByteArray(0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 43, 91, 103, 255, 43, 91, 103, 255, 43, 91, 103, 255, 43, 91, 103, 255, 43, 91, 103, 255, 43, 91, 103, 255, 43, 91, 103, 255, 43, 91, 103, 255, 43, 91, 103, 255, 0, 0, 0, 0, 43, 91, 103, 255, 43, 91, 103, 255, 43, 91, 103, 255, 43, 91, 103, 255, 43, 91, 103, 255, 43, 91, 103, 255, 43, 91, 103, 255, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 43, 91, 103, 255, 43, 91, 103, 255, 43, 91, 103, 255, 43, 91, 103, 255, 43, 91, 103, 255, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 43, 91, 103, 255, 43, 91, 103, 255, 43, 91, 103, 255, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 43, 91, 103, 255, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0),
|
||||
"format": "RGBA8",
|
||||
"height": 9,
|
||||
"mipmaps": false,
|
||||
"width": 9
|
||||
}
|
||||
|
||||
[sub_resource type="ImageTexture" id="ImageTexture_j2rjp"]
|
||||
image = SubResource("Image_petse")
|
||||
|
||||
[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_7om8y"]
|
||||
bg_color = Color(0.603922, 0.584314, 0.541176, 0.3)
|
||||
border_color = Color(0, 0, 0, 0)
|
||||
corner_radius_top_left = 2
|
||||
@@ -192,7 +190,7 @@ corner_radius_bottom_right = 2
|
||||
corner_radius_bottom_left = 2
|
||||
corner_detail = 4
|
||||
|
||||
[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_4uvxq"]
|
||||
[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_o55pb"]
|
||||
bg_color = Color(0.184314, 0.498039, 0.560784, 0.55)
|
||||
border_color = Color(0, 0, 0, 0)
|
||||
corner_radius_top_left = 2
|
||||
@@ -201,7 +199,7 @@ corner_radius_bottom_right = 2
|
||||
corner_radius_bottom_left = 2
|
||||
corner_detail = 4
|
||||
|
||||
[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_petse"]
|
||||
[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_wl44i"]
|
||||
bg_color = Color(0.847059, 0.827451, 0.788235, 1)
|
||||
border_width_left = 1
|
||||
border_width_top = 1
|
||||
@@ -214,33 +212,9 @@ corner_radius_bottom_right = 2
|
||||
corner_radius_bottom_left = 2
|
||||
corner_detail = 4
|
||||
|
||||
[sub_resource type="Image" id="Image_j2rjp"]
|
||||
data = {
|
||||
"data": PackedByteArray(0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 131, 198, 212, 255, 131, 198, 212, 255, 131, 198, 212, 255, 131, 198, 212, 255, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 131, 198, 212, 255, 131, 198, 212, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 131, 198, 212, 255, 131, 198, 212, 255, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 131, 198, 212, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 131, 198, 212, 255, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 131, 198, 212, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 131, 198, 212, 255, 0, 0, 0, 0, 0, 0, 0, 0, 131, 198, 212, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 131, 198, 212, 255, 0, 0, 0, 0, 0, 0, 0, 0, 131, 198, 212, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 131, 198, 212, 255, 0, 0, 0, 0, 0, 0, 0, 0, 131, 198, 212, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 131, 198, 212, 255, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 131, 198, 212, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 131, 198, 212, 255, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 131, 198, 212, 255, 131, 198, 212, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 131, 198, 212, 255, 131, 198, 212, 255, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 131, 198, 212, 255, 131, 198, 212, 255, 131, 198, 212, 255, 131, 198, 212, 255, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0),
|
||||
"format": "RGBA8",
|
||||
"height": 12,
|
||||
"mipmaps": false,
|
||||
"width": 12
|
||||
}
|
||||
|
||||
[sub_resource type="ImageTexture" id="ImageTexture_7om8y"]
|
||||
image = SubResource("Image_j2rjp")
|
||||
|
||||
[sub_resource type="Image" id="Image_o55pb"]
|
||||
data = {
|
||||
"data": PackedByteArray(0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 65, 61, 52, 30, 65, 61, 52, 30, 65, 61, 52, 30, 65, 61, 52, 30, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 65, 61, 52, 30, 65, 61, 52, 30, 232, 230, 221, 255, 232, 230, 221, 255, 232, 230, 221, 255, 232, 230, 221, 255, 65, 61, 52, 30, 65, 61, 52, 30, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 65, 61, 52, 30, 232, 230, 221, 255, 232, 230, 221, 255, 232, 230, 221, 255, 232, 230, 221, 255, 232, 230, 221, 255, 232, 230, 221, 255, 65, 61, 52, 30, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 65, 61, 52, 30, 232, 230, 221, 255, 232, 230, 221, 255, 232, 230, 221, 255, 232, 230, 221, 255, 232, 230, 221, 255, 232, 230, 221, 255, 232, 230, 221, 255, 232, 230, 221, 255, 65, 61, 52, 30, 0, 0, 0, 0, 0, 0, 0, 0, 65, 61, 52, 30, 232, 230, 221, 255, 232, 230, 221, 255, 232, 230, 221, 255, 232, 230, 221, 255, 232, 230, 221, 255, 232, 230, 221, 255, 232, 230, 221, 255, 232, 230, 221, 255, 65, 61, 52, 30, 0, 0, 0, 0, 0, 0, 0, 0, 65, 61, 52, 30, 232, 230, 221, 255, 232, 230, 221, 255, 232, 230, 221, 255, 232, 230, 221, 255, 232, 230, 221, 255, 232, 230, 221, 255, 232, 230, 221, 255, 232, 230, 221, 255, 65, 61, 52, 30, 0, 0, 0, 0, 0, 0, 0, 0, 65, 61, 52, 30, 232, 230, 221, 255, 232, 230, 221, 255, 232, 230, 221, 255, 232, 230, 221, 255, 232, 230, 221, 255, 232, 230, 221, 255, 232, 230, 221, 255, 232, 230, 221, 255, 65, 61, 52, 30, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 65, 61, 52, 30, 232, 230, 221, 255, 232, 230, 221, 255, 232, 230, 221, 255, 232, 230, 221, 255, 232, 230, 221, 255, 232, 230, 221, 255, 65, 61, 52, 30, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 65, 61, 52, 30, 65, 61, 52, 30, 232, 230, 221, 255, 232, 230, 221, 255, 232, 230, 221, 255, 232, 230, 221, 255, 65, 61, 52, 30, 65, 61, 52, 30, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 65, 61, 52, 30, 65, 61, 52, 30, 65, 61, 52, 30, 65, 61, 52, 30, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0),
|
||||
"format": "RGBA8",
|
||||
"height": 12,
|
||||
"mipmaps": false,
|
||||
"width": 12
|
||||
}
|
||||
|
||||
[sub_resource type="ImageTexture" id="ImageTexture_wl44i"]
|
||||
image = SubResource("Image_o55pb")
|
||||
|
||||
[sub_resource type="Image" id="Image_mni32"]
|
||||
data = {
|
||||
"data": PackedByteArray(0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 47, 126, 142, 255, 47, 126, 142, 255, 47, 126, 142, 255, 47, 126, 142, 255, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 47, 126, 142, 255, 47, 126, 142, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 47, 126, 142, 255, 47, 126, 142, 255, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 47, 126, 142, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 47, 126, 142, 255, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 47, 126, 142, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 47, 126, 142, 255, 0, 0, 0, 0, 0, 0, 0, 0, 47, 126, 142, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 47, 126, 142, 255, 0, 0, 0, 0, 0, 0, 0, 0, 47, 126, 142, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 47, 126, 142, 255, 0, 0, 0, 0, 0, 0, 0, 0, 47, 126, 142, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 47, 126, 142, 255, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 47, 126, 142, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 47, 126, 142, 255, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 47, 126, 142, 255, 47, 126, 142, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 47, 126, 142, 255, 47, 126, 142, 255, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 47, 126, 142, 255, 47, 126, 142, 255, 47, 126, 142, 255, 47, 126, 142, 255, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0),
|
||||
"data": PackedByteArray(0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 131, 198, 212, 255, 131, 198, 212, 255, 131, 198, 212, 255, 131, 198, 212, 255, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 131, 198, 212, 255, 131, 198, 212, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 131, 198, 212, 255, 131, 198, 212, 255, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 131, 198, 212, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 131, 198, 212, 255, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 131, 198, 212, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 131, 198, 212, 255, 0, 0, 0, 0, 0, 0, 0, 0, 131, 198, 212, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 131, 198, 212, 255, 0, 0, 0, 0, 0, 0, 0, 0, 131, 198, 212, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 131, 198, 212, 255, 0, 0, 0, 0, 0, 0, 0, 0, 131, 198, 212, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 131, 198, 212, 255, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 131, 198, 212, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 131, 198, 212, 255, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 131, 198, 212, 255, 131, 198, 212, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 131, 198, 212, 255, 131, 198, 212, 255, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 131, 198, 212, 255, 131, 198, 212, 255, 131, 198, 212, 255, 131, 198, 212, 255, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0),
|
||||
"format": "RGBA8",
|
||||
"height": 12,
|
||||
"mipmaps": false,
|
||||
@@ -250,7 +224,31 @@ data = {
|
||||
[sub_resource type="ImageTexture" id="ImageTexture_8vjq1"]
|
||||
image = SubResource("Image_mni32")
|
||||
|
||||
[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_w06qd"]
|
||||
[sub_resource type="Image" id="Image_w06qd"]
|
||||
data = {
|
||||
"data": PackedByteArray(0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 65, 61, 52, 30, 65, 61, 52, 30, 65, 61, 52, 30, 65, 61, 52, 30, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 65, 61, 52, 30, 65, 61, 52, 30, 232, 230, 221, 255, 232, 230, 221, 255, 232, 230, 221, 255, 232, 230, 221, 255, 65, 61, 52, 30, 65, 61, 52, 30, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 65, 61, 52, 30, 232, 230, 221, 255, 232, 230, 221, 255, 232, 230, 221, 255, 232, 230, 221, 255, 232, 230, 221, 255, 232, 230, 221, 255, 65, 61, 52, 30, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 65, 61, 52, 30, 232, 230, 221, 255, 232, 230, 221, 255, 232, 230, 221, 255, 232, 230, 221, 255, 232, 230, 221, 255, 232, 230, 221, 255, 232, 230, 221, 255, 232, 230, 221, 255, 65, 61, 52, 30, 0, 0, 0, 0, 0, 0, 0, 0, 65, 61, 52, 30, 232, 230, 221, 255, 232, 230, 221, 255, 232, 230, 221, 255, 232, 230, 221, 255, 232, 230, 221, 255, 232, 230, 221, 255, 232, 230, 221, 255, 232, 230, 221, 255, 65, 61, 52, 30, 0, 0, 0, 0, 0, 0, 0, 0, 65, 61, 52, 30, 232, 230, 221, 255, 232, 230, 221, 255, 232, 230, 221, 255, 232, 230, 221, 255, 232, 230, 221, 255, 232, 230, 221, 255, 232, 230, 221, 255, 232, 230, 221, 255, 65, 61, 52, 30, 0, 0, 0, 0, 0, 0, 0, 0, 65, 61, 52, 30, 232, 230, 221, 255, 232, 230, 221, 255, 232, 230, 221, 255, 232, 230, 221, 255, 232, 230, 221, 255, 232, 230, 221, 255, 232, 230, 221, 255, 232, 230, 221, 255, 65, 61, 52, 30, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 65, 61, 52, 30, 232, 230, 221, 255, 232, 230, 221, 255, 232, 230, 221, 255, 232, 230, 221, 255, 232, 230, 221, 255, 232, 230, 221, 255, 65, 61, 52, 30, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 65, 61, 52, 30, 65, 61, 52, 30, 232, 230, 221, 255, 232, 230, 221, 255, 232, 230, 221, 255, 232, 230, 221, 255, 65, 61, 52, 30, 65, 61, 52, 30, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 65, 61, 52, 30, 65, 61, 52, 30, 65, 61, 52, 30, 65, 61, 52, 30, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0),
|
||||
"format": "RGBA8",
|
||||
"height": 12,
|
||||
"mipmaps": false,
|
||||
"width": 12
|
||||
}
|
||||
|
||||
[sub_resource type="ImageTexture" id="ImageTexture_barbq"]
|
||||
image = SubResource("Image_w06qd")
|
||||
|
||||
[sub_resource type="Image" id="Image_dvi42"]
|
||||
data = {
|
||||
"data": PackedByteArray(0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 47, 126, 142, 255, 47, 126, 142, 255, 47, 126, 142, 255, 47, 126, 142, 255, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 47, 126, 142, 255, 47, 126, 142, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 47, 126, 142, 255, 47, 126, 142, 255, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 47, 126, 142, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 47, 126, 142, 255, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 47, 126, 142, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 47, 126, 142, 255, 0, 0, 0, 0, 0, 0, 0, 0, 47, 126, 142, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 47, 126, 142, 255, 0, 0, 0, 0, 0, 0, 0, 0, 47, 126, 142, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 47, 126, 142, 255, 0, 0, 0, 0, 0, 0, 0, 0, 47, 126, 142, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 47, 126, 142, 255, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 47, 126, 142, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 47, 126, 142, 255, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 47, 126, 142, 255, 47, 126, 142, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 47, 126, 142, 255, 47, 126, 142, 255, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 47, 126, 142, 255, 47, 126, 142, 255, 47, 126, 142, 255, 47, 126, 142, 255, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0),
|
||||
"format": "RGBA8",
|
||||
"height": 12,
|
||||
"mipmaps": false,
|
||||
"width": 12
|
||||
}
|
||||
|
||||
[sub_resource type="ImageTexture" id="ImageTexture_foj1q"]
|
||||
image = SubResource("Image_dvi42")
|
||||
|
||||
[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_jyq6k"]
|
||||
bg_color = Color(0.513726, 0.780392, 0.831373, 1)
|
||||
border_color = Color(0, 0, 0, 0)
|
||||
corner_radius_top_left = 2
|
||||
@@ -259,7 +257,7 @@ corner_radius_bottom_right = 2
|
||||
corner_radius_bottom_left = 2
|
||||
corner_detail = 4
|
||||
|
||||
[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_barbq"]
|
||||
[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_cjati"]
|
||||
bg_color = Color(0.184314, 0.498039, 0.560784, 1)
|
||||
border_color = Color(0, 0, 0, 0)
|
||||
corner_radius_top_left = 2
|
||||
@@ -268,7 +266,7 @@ corner_radius_bottom_right = 2
|
||||
corner_radius_bottom_left = 2
|
||||
corner_detail = 4
|
||||
|
||||
[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_dvi42"]
|
||||
[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_m7381"]
|
||||
bg_color = Color(1, 1, 1, 1)
|
||||
border_width_left = 1
|
||||
border_width_top = 1
|
||||
@@ -281,7 +279,7 @@ corner_radius_bottom_right = 3
|
||||
corner_radius_bottom_left = 3
|
||||
corner_detail = 4
|
||||
|
||||
[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_foj1q"]
|
||||
[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_of6nr"]
|
||||
content_margin_left = 2.0
|
||||
content_margin_top = 2.0
|
||||
content_margin_right = 2.0
|
||||
@@ -298,7 +296,11 @@ corner_radius_bottom_right = 3
|
||||
corner_radius_bottom_left = 3
|
||||
corner_detail = 4
|
||||
|
||||
[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_jyq6k"]
|
||||
[sub_resource type="FontVariation" id="FontVariation_s7l1c"]
|
||||
fallbacks = Array[Font]([ExtResource("1_fum4e")])
|
||||
base_font = ExtResource("3_dc2yr")
|
||||
|
||||
[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_7j4rs"]
|
||||
bg_color = Color(0.968627, 0.960784, 0.933333, 1)
|
||||
border_width_left = 1
|
||||
border_width_top = 1
|
||||
@@ -314,7 +316,7 @@ shadow_color = Color(0, 0, 0, 0.094)
|
||||
shadow_size = 3
|
||||
shadow_offset = Vector2(0, 2)
|
||||
|
||||
[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_cjati"]
|
||||
[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_05v8i"]
|
||||
content_margin_left = 2.0
|
||||
content_margin_top = 2.0
|
||||
content_margin_right = 2.0
|
||||
@@ -327,7 +329,7 @@ corner_radius_bottom_right = 3
|
||||
corner_radius_bottom_left = 3
|
||||
corner_detail = 4
|
||||
|
||||
[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_m7381"]
|
||||
[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_7ph3p"]
|
||||
content_margin_left = 5.0
|
||||
content_margin_top = 5.0
|
||||
content_margin_right = 5.0
|
||||
@@ -347,7 +349,7 @@ shadow_color = Color(0, 0, 0, 0.16)
|
||||
shadow_size = 6
|
||||
shadow_offset = Vector2(0, 3)
|
||||
|
||||
[sub_resource type="Image" id="Image_of6nr"]
|
||||
[sub_resource type="Image" id="Image_2b66g"]
|
||||
data = {
|
||||
"data": PackedByteArray(0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 154, 149, 137, 255, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 154, 149, 137, 255, 154, 149, 137, 255, 154, 149, 137, 255, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 154, 149, 137, 255, 154, 149, 137, 255, 154, 149, 137, 255, 154, 149, 137, 255, 154, 149, 137, 255, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 154, 149, 137, 255, 154, 149, 137, 255, 154, 149, 137, 255, 154, 149, 137, 255, 154, 149, 137, 255, 154, 149, 137, 255, 154, 149, 137, 255, 0, 0, 0, 0, 154, 149, 137, 255, 154, 149, 137, 255, 154, 149, 137, 255, 154, 149, 137, 255, 154, 149, 137, 255, 154, 149, 137, 255, 154, 149, 137, 255, 154, 149, 137, 255, 154, 149, 137, 255, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0),
|
||||
"format": "RGBA8",
|
||||
@@ -356,10 +358,10 @@ data = {
|
||||
"width": 9
|
||||
}
|
||||
|
||||
[sub_resource type="ImageTexture" id="ImageTexture_s7l1c"]
|
||||
image = SubResource("Image_of6nr")
|
||||
[sub_resource type="ImageTexture" id="ImageTexture_1tdcs"]
|
||||
image = SubResource("Image_2b66g")
|
||||
|
||||
[sub_resource type="Image" id="Image_7j4rs"]
|
||||
[sub_resource type="Image" id="Image_hv5yg"]
|
||||
data = {
|
||||
"data": PackedByteArray(0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 43, 91, 103, 255, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 43, 91, 103, 255, 43, 91, 103, 255, 43, 91, 103, 255, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 43, 91, 103, 255, 43, 91, 103, 255, 43, 91, 103, 255, 43, 91, 103, 255, 43, 91, 103, 255, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 43, 91, 103, 255, 43, 91, 103, 255, 43, 91, 103, 255, 43, 91, 103, 255, 43, 91, 103, 255, 43, 91, 103, 255, 43, 91, 103, 255, 0, 0, 0, 0, 43, 91, 103, 255, 43, 91, 103, 255, 43, 91, 103, 255, 43, 91, 103, 255, 43, 91, 103, 255, 43, 91, 103, 255, 43, 91, 103, 255, 43, 91, 103, 255, 43, 91, 103, 255, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0),
|
||||
"format": "RGBA8",
|
||||
@@ -368,10 +370,10 @@ data = {
|
||||
"width": 9
|
||||
}
|
||||
|
||||
[sub_resource type="ImageTexture" id="ImageTexture_05v8i"]
|
||||
image = SubResource("Image_7j4rs")
|
||||
[sub_resource type="ImageTexture" id="ImageTexture_yjsxn"]
|
||||
image = SubResource("Image_hv5yg")
|
||||
|
||||
[sub_resource type="Image" id="Image_7ph3p"]
|
||||
[sub_resource type="Image" id="Image_s5gth"]
|
||||
data = {
|
||||
"data": PackedByteArray(0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 154, 149, 137, 255, 154, 149, 137, 255, 154, 149, 137, 255, 154, 149, 137, 255, 154, 149, 137, 255, 154, 149, 137, 255, 154, 149, 137, 255, 154, 149, 137, 255, 154, 149, 137, 255, 0, 0, 0, 0, 154, 149, 137, 255, 154, 149, 137, 255, 154, 149, 137, 255, 154, 149, 137, 255, 154, 149, 137, 255, 154, 149, 137, 255, 154, 149, 137, 255, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 154, 149, 137, 255, 154, 149, 137, 255, 154, 149, 137, 255, 154, 149, 137, 255, 154, 149, 137, 255, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 154, 149, 137, 255, 154, 149, 137, 255, 154, 149, 137, 255, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 154, 149, 137, 255, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0),
|
||||
"format": "RGBA8",
|
||||
@@ -380,10 +382,10 @@ data = {
|
||||
"width": 9
|
||||
}
|
||||
|
||||
[sub_resource type="ImageTexture" id="ImageTexture_2b66g"]
|
||||
image = SubResource("Image_7ph3p")
|
||||
[sub_resource type="ImageTexture" id="ImageTexture_bkd55"]
|
||||
image = SubResource("Image_s5gth")
|
||||
|
||||
[sub_resource type="Image" id="Image_1tdcs"]
|
||||
[sub_resource type="Image" id="Image_xoku7"]
|
||||
data = {
|
||||
"data": PackedByteArray(0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 43, 91, 103, 255, 43, 91, 103, 255, 43, 91, 103, 255, 43, 91, 103, 255, 43, 91, 103, 255, 43, 91, 103, 255, 43, 91, 103, 255, 43, 91, 103, 255, 43, 91, 103, 255, 0, 0, 0, 0, 43, 91, 103, 255, 43, 91, 103, 255, 43, 91, 103, 255, 43, 91, 103, 255, 43, 91, 103, 255, 43, 91, 103, 255, 43, 91, 103, 255, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 43, 91, 103, 255, 43, 91, 103, 255, 43, 91, 103, 255, 43, 91, 103, 255, 43, 91, 103, 255, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 43, 91, 103, 255, 43, 91, 103, 255, 43, 91, 103, 255, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 43, 91, 103, 255, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0),
|
||||
"format": "RGBA8",
|
||||
@@ -392,74 +394,74 @@ data = {
|
||||
"width": 9
|
||||
}
|
||||
|
||||
[sub_resource type="ImageTexture" id="ImageTexture_hv5yg"]
|
||||
image = SubResource("Image_1tdcs")
|
||||
[sub_resource type="ImageTexture" id="ImageTexture_d1bwe"]
|
||||
image = SubResource("Image_xoku7")
|
||||
|
||||
[resource]
|
||||
default_font = SubResource("FontVariation_fum4e")
|
||||
default_font_size = 12
|
||||
default_font = SubResource("FontVariation_6m2gx")
|
||||
default_font_size = 13
|
||||
Button/colors/font_color = Color(0.121569, 0.117647, 0.105882, 1)
|
||||
Button/colors/font_disabled_color = Color(0.603922, 0.584314, 0.541176, 1)
|
||||
Button/colors/font_hover_color = Color(0.172549, 0.360784, 0.407843, 1)
|
||||
Button/colors/font_pressed_color = Color(0.121569, 0.27451, 0.313726, 1)
|
||||
Button/font_sizes/font_size = 12
|
||||
Button/fonts/font = SubResource("FontVariation_fum4e")
|
||||
Button/styles/disabled = SubResource("StyleBoxFlat_lr7a5")
|
||||
Button/styles/focus = SubResource("StyleBoxFlat_cg7jb")
|
||||
Button/styles/hover = SubResource("StyleBoxFlat_dc2yr")
|
||||
Button/styles/normal = SubResource("StyleBoxFlat_6m2gx")
|
||||
Button/styles/pressed = SubResource("StyleBoxFlat_x3s7b")
|
||||
Button/font_sizes/font_size = 13
|
||||
Button/fonts/font = SubResource("FontVariation_6m2gx")
|
||||
Button/styles/disabled = SubResource("StyleBoxFlat_x3s7b")
|
||||
Button/styles/focus = SubResource("StyleBoxFlat_ukyte")
|
||||
Button/styles/hover = SubResource("StyleBoxFlat_wjr02")
|
||||
Button/styles/normal = SubResource("StyleBoxFlat_4munx")
|
||||
Button/styles/pressed = SubResource("StyleBoxFlat_oc7r5")
|
||||
CheckBox/colors/font_color = Color(0.121569, 0.117647, 0.105882, 1)
|
||||
CheckBox/colors/font_disabled_color = Color(0.603922, 0.584314, 0.541176, 1)
|
||||
CheckBox/colors/font_hover_color = Color(0.172549, 0.360784, 0.407843, 1)
|
||||
CheckBox/colors/font_pressed_color = Color(0.121569, 0.27451, 0.313726, 1)
|
||||
CheckBox/icons/checked = SubResource("ImageTexture_wjr02")
|
||||
CheckBox/icons/radio_checked = SubResource("ImageTexture_oc7r5")
|
||||
CheckBox/icons/radio_unchecked = SubResource("ImageTexture_8m7xh")
|
||||
CheckBox/icons/unchecked = SubResource("ImageTexture_47b07")
|
||||
CheckBox/styles/focus = SubResource("StyleBoxFlat_danrt")
|
||||
CheckBox/styles/hover = SubResource("StyleBoxFlat_danrt")
|
||||
CheckBox/styles/normal = SubResource("StyleBoxFlat_danrt")
|
||||
CheckBox/styles/pressed = SubResource("StyleBoxFlat_danrt")
|
||||
HScrollBar/icons/decrement = SubResource("ImageTexture_djhhc")
|
||||
HScrollBar/icons/decrement_highlight = SubResource("ImageTexture_jvde3")
|
||||
HScrollBar/icons/increment = SubResource("ImageTexture_i4ifm")
|
||||
HScrollBar/icons/increment_highlight = SubResource("ImageTexture_loh4e")
|
||||
HScrollBar/styles/grabber = SubResource("StyleBoxFlat_a1mnj")
|
||||
HScrollBar/styles/grabber_highlight = SubResource("StyleBoxFlat_4uvxq")
|
||||
HScrollBar/styles/grabber_pressed = SubResource("StyleBoxFlat_4uvxq")
|
||||
HScrollBar/styles/scroll = SubResource("StyleBoxFlat_petse")
|
||||
HScrollBar/styles/scroll_focus = SubResource("StyleBoxFlat_petse")
|
||||
CheckBox/icons/checked = SubResource("ImageTexture_8m7xh")
|
||||
CheckBox/icons/radio_checked = SubResource("ImageTexture_47b07")
|
||||
CheckBox/icons/radio_unchecked = SubResource("ImageTexture_1j4f6")
|
||||
CheckBox/icons/unchecked = SubResource("ImageTexture_a0hrt")
|
||||
CheckBox/styles/focus = SubResource("StyleBoxFlat_jvde3")
|
||||
CheckBox/styles/hover = SubResource("StyleBoxFlat_jvde3")
|
||||
CheckBox/styles/normal = SubResource("StyleBoxFlat_jvde3")
|
||||
CheckBox/styles/pressed = SubResource("StyleBoxFlat_jvde3")
|
||||
HScrollBar/icons/decrement = SubResource("ImageTexture_i4ifm")
|
||||
HScrollBar/icons/decrement_highlight = SubResource("ImageTexture_loh4e")
|
||||
HScrollBar/icons/increment = SubResource("ImageTexture_4uvxq")
|
||||
HScrollBar/icons/increment_highlight = SubResource("ImageTexture_j2rjp")
|
||||
HScrollBar/styles/grabber = SubResource("StyleBoxFlat_7om8y")
|
||||
HScrollBar/styles/grabber_highlight = SubResource("StyleBoxFlat_o55pb")
|
||||
HScrollBar/styles/grabber_pressed = SubResource("StyleBoxFlat_o55pb")
|
||||
HScrollBar/styles/scroll = SubResource("StyleBoxFlat_wl44i")
|
||||
HScrollBar/styles/scroll_focus = SubResource("StyleBoxFlat_wl44i")
|
||||
HSlider/constants/grabber_offset = 0
|
||||
HSlider/icons/grabber = SubResource("ImageTexture_7om8y")
|
||||
HSlider/icons/grabber_disabled = SubResource("ImageTexture_wl44i")
|
||||
HSlider/icons/grabber_highlight = SubResource("ImageTexture_8vjq1")
|
||||
HSlider/styles/grabber_area = SubResource("StyleBoxFlat_w06qd")
|
||||
HSlider/styles/grabber_area_highlight = SubResource("StyleBoxFlat_barbq")
|
||||
HSlider/styles/slider = SubResource("StyleBoxFlat_petse")
|
||||
HSlider/icons/grabber = SubResource("ImageTexture_8vjq1")
|
||||
HSlider/icons/grabber_disabled = SubResource("ImageTexture_barbq")
|
||||
HSlider/icons/grabber_highlight = SubResource("ImageTexture_foj1q")
|
||||
HSlider/styles/grabber_area = SubResource("StyleBoxFlat_jyq6k")
|
||||
HSlider/styles/grabber_area_highlight = SubResource("StyleBoxFlat_cjati")
|
||||
HSlider/styles/slider = SubResource("StyleBoxFlat_wl44i")
|
||||
HeadLabel/base_type = &"Label"
|
||||
HeadLabel/font_sizes/font_size = 16
|
||||
HeadLabel/fonts/font = SubResource("FontVariation_fum4e")
|
||||
HeadLabel/font_sizes/font_size = 17
|
||||
HeadLabel/fonts/font = SubResource("FontVariation_6m2gx")
|
||||
Label/colors/font_color = Color(0.121569, 0.117647, 0.105882, 1)
|
||||
Label/font_sizes/font_size = 12
|
||||
Label/fonts/font = SubResource("FontVariation_fum4e")
|
||||
Label/font_sizes/font_size = 13
|
||||
Label/fonts/font = SubResource("FontVariation_6m2gx")
|
||||
LineEdit/colors/font_color = Color(0.121569, 0.117647, 0.105882, 1)
|
||||
LineEdit/colors/font_placeholder_color = Color(0.603922, 0.584314, 0.541176, 1)
|
||||
LineEdit/font_sizes/font_size = 12
|
||||
LineEdit/fonts/font = SubResource("FontVariation_fum4e")
|
||||
LineEdit/styles/focus = SubResource("StyleBoxFlat_dvi42")
|
||||
LineEdit/styles/normal = SubResource("StyleBoxFlat_foj1q")
|
||||
LineEdit/font_sizes/font_size = 13
|
||||
LineEdit/fonts/font = SubResource("FontVariation_6m2gx")
|
||||
LineEdit/styles/focus = SubResource("StyleBoxFlat_m7381")
|
||||
LineEdit/styles/normal = SubResource("StyleBoxFlat_of6nr")
|
||||
LogoLabel/base_type = &"Label"
|
||||
LogoLabel/colors/font_color = Color(0.172549, 0.360784, 0.407843, 1)
|
||||
LogoLabel/font_sizes/font_size = 24
|
||||
LogoLabel/fonts/font = ExtResource("2_fum4e")
|
||||
LogoLabel/font_sizes/font_size = 26
|
||||
LogoLabel/fonts/font = SubResource("FontVariation_s7l1c")
|
||||
MicroLabel/base_type = &"Label"
|
||||
MicroLabel/colors/font_color = Color(0.172549, 0.164706, 0.14902, 1)
|
||||
MicroLabel/font_sizes/font_size = 10
|
||||
MicroLabel/font_sizes/font_size = 11
|
||||
NumLabel/base_type = &"Label"
|
||||
NumLabel/font_sizes/font_size = 12
|
||||
NumLabel/font_sizes/font_size = 13
|
||||
NumLabel/fonts/font = ExtResource("2_fum4e")
|
||||
Panel/styles/panel = SubResource("StyleBoxFlat_jyq6k")
|
||||
Panel/styles/panel = SubResource("StyleBoxFlat_7j4rs")
|
||||
PopupMenu/colors/font_accelerator_color = Color(0.603922, 0.584314, 0.541176, 1)
|
||||
PopupMenu/colors/font_color = Color(0.121569, 0.117647, 0.105882, 1)
|
||||
PopupMenu/colors/font_disabled_color = Color(0.603922, 0.584314, 0.541176, 1)
|
||||
@@ -471,20 +473,20 @@ PopupMenu/constants/item_end_padding = 12
|
||||
PopupMenu/constants/item_start_padding = 8
|
||||
PopupMenu/constants/separator_outline_size = 0
|
||||
PopupMenu/constants/v_separation = 3
|
||||
PopupMenu/font_sizes/font_size = 12
|
||||
PopupMenu/fonts/font = SubResource("FontVariation_fum4e")
|
||||
PopupMenu/styles/hover = SubResource("StyleBoxFlat_cjati")
|
||||
PopupMenu/styles/panel = SubResource("StyleBoxFlat_m7381")
|
||||
PopupMenu/font_sizes/font_size = 13
|
||||
PopupMenu/fonts/font = SubResource("FontVariation_6m2gx")
|
||||
PopupMenu/styles/hover = SubResource("StyleBoxFlat_05v8i")
|
||||
PopupMenu/styles/panel = SubResource("StyleBoxFlat_7ph3p")
|
||||
TooltipLabel/colors/font_color = Color(0.121569, 0.117647, 0.105882, 1)
|
||||
TooltipLabel/font_sizes/font_size = 10
|
||||
TooltipLabel/fonts/font = SubResource("FontVariation_fum4e")
|
||||
TooltipPanel/styles/panel = SubResource("StyleBoxFlat_m7381")
|
||||
VScrollBar/icons/decrement = SubResource("ImageTexture_s7l1c")
|
||||
VScrollBar/icons/decrement_highlight = SubResource("ImageTexture_05v8i")
|
||||
VScrollBar/icons/increment = SubResource("ImageTexture_2b66g")
|
||||
VScrollBar/icons/increment_highlight = SubResource("ImageTexture_hv5yg")
|
||||
VScrollBar/styles/grabber = SubResource("StyleBoxFlat_a1mnj")
|
||||
VScrollBar/styles/grabber_highlight = SubResource("StyleBoxFlat_4uvxq")
|
||||
VScrollBar/styles/grabber_pressed = SubResource("StyleBoxFlat_4uvxq")
|
||||
VScrollBar/styles/scroll = SubResource("StyleBoxFlat_petse")
|
||||
VScrollBar/styles/scroll_focus = SubResource("StyleBoxFlat_petse")
|
||||
TooltipLabel/font_sizes/font_size = 11
|
||||
TooltipLabel/fonts/font = SubResource("FontVariation_6m2gx")
|
||||
TooltipPanel/styles/panel = SubResource("StyleBoxFlat_7ph3p")
|
||||
VScrollBar/icons/decrement = SubResource("ImageTexture_1tdcs")
|
||||
VScrollBar/icons/decrement_highlight = SubResource("ImageTexture_yjsxn")
|
||||
VScrollBar/icons/increment = SubResource("ImageTexture_bkd55")
|
||||
VScrollBar/icons/increment_highlight = SubResource("ImageTexture_d1bwe")
|
||||
VScrollBar/styles/grabber = SubResource("StyleBoxFlat_7om8y")
|
||||
VScrollBar/styles/grabber_highlight = SubResource("StyleBoxFlat_o55pb")
|
||||
VScrollBar/styles/grabber_pressed = SubResource("StyleBoxFlat_o55pb")
|
||||
VScrollBar/styles/scroll = SubResource("StyleBoxFlat_wl44i")
|
||||
VScrollBar/styles/scroll_focus = SubResource("StyleBoxFlat_wl44i")
|
||||
|
After Width: | Height: | Size: 294 B |
@@ -0,0 +1,40 @@
|
||||
[remap]
|
||||
|
||||
importer="texture"
|
||||
type="CompressedTexture2D"
|
||||
uid="uid://djb7w10tao8ns"
|
||||
path="res://.godot/imported/file_photo.png-b23698027d48c718a6fc500f302db87b.ctex"
|
||||
metadata={
|
||||
"vram_texture": false
|
||||
}
|
||||
|
||||
[deps]
|
||||
|
||||
source_file="res://art/icons/file_photo.png"
|
||||
dest_files=["res://.godot/imported/file_photo.png-b23698027d48c718a6fc500f302db87b.ctex"]
|
||||
|
||||
[params]
|
||||
|
||||
compress/mode=0
|
||||
compress/high_quality=false
|
||||
compress/lossy_quality=0.7
|
||||
compress/uastc_level=0
|
||||
compress/rdo_quality_loss=0.0
|
||||
compress/hdr_compression=1
|
||||
compress/normal_map=0
|
||||
compress/channel_pack=0
|
||||
mipmaps/generate=false
|
||||
mipmaps/limit=-1
|
||||
roughness/mode=0
|
||||
roughness/src_normal=""
|
||||
process/channel_remap/red=0
|
||||
process/channel_remap/green=1
|
||||
process/channel_remap/blue=2
|
||||
process/channel_remap/alpha=3
|
||||
process/fix_alpha_border=true
|
||||
process/premult_alpha=false
|
||||
process/normal_map_invert_y=false
|
||||
process/hdr_as_srgb=false
|
||||
process/hdr_clamp_exposure=false
|
||||
process/size_limit=0
|
||||
detect_3d/compress_to=1
|
||||
|
After Width: | Height: | Size: 245 B |
@@ -0,0 +1,40 @@
|
||||
[remap]
|
||||
|
||||
importer="texture"
|
||||
type="CompressedTexture2D"
|
||||
uid="uid://dw5oie5dxfky4"
|
||||
path="res://.godot/imported/file_sound.png-ef9804b30db6fa708a74022e1489cfcd.ctex"
|
||||
metadata={
|
||||
"vram_texture": false
|
||||
}
|
||||
|
||||
[deps]
|
||||
|
||||
source_file="res://art/icons/file_sound.png"
|
||||
dest_files=["res://.godot/imported/file_sound.png-ef9804b30db6fa708a74022e1489cfcd.ctex"]
|
||||
|
||||
[params]
|
||||
|
||||
compress/mode=0
|
||||
compress/high_quality=false
|
||||
compress/lossy_quality=0.7
|
||||
compress/uastc_level=0
|
||||
compress/rdo_quality_loss=0.0
|
||||
compress/hdr_compression=1
|
||||
compress/normal_map=0
|
||||
compress/channel_pack=0
|
||||
mipmaps/generate=false
|
||||
mipmaps/limit=-1
|
||||
roughness/mode=0
|
||||
roughness/src_normal=""
|
||||
process/channel_remap/red=0
|
||||
process/channel_remap/green=1
|
||||
process/channel_remap/blue=2
|
||||
process/channel_remap/alpha=3
|
||||
process/fix_alpha_border=true
|
||||
process/premult_alpha=false
|
||||
process/normal_map_invert_y=false
|
||||
process/hdr_as_srgb=false
|
||||
process/hdr_clamp_exposure=false
|
||||
process/size_limit=0
|
||||
detect_3d/compress_to=1
|
||||
|
After Width: | Height: | Size: 213 B |
@@ -0,0 +1,40 @@
|
||||
[remap]
|
||||
|
||||
importer="texture"
|
||||
type="CompressedTexture2D"
|
||||
uid="uid://gtofenp8bd1n"
|
||||
path="res://.godot/imported/file_text.png-e2cb12a8276ae34819d095735a1e4828.ctex"
|
||||
metadata={
|
||||
"vram_texture": false
|
||||
}
|
||||
|
||||
[deps]
|
||||
|
||||
source_file="res://art/icons/file_text.png"
|
||||
dest_files=["res://.godot/imported/file_text.png-e2cb12a8276ae34819d095735a1e4828.ctex"]
|
||||
|
||||
[params]
|
||||
|
||||
compress/mode=0
|
||||
compress/high_quality=false
|
||||
compress/lossy_quality=0.7
|
||||
compress/uastc_level=0
|
||||
compress/rdo_quality_loss=0.0
|
||||
compress/hdr_compression=1
|
||||
compress/normal_map=0
|
||||
compress/channel_pack=0
|
||||
mipmaps/generate=false
|
||||
mipmaps/limit=-1
|
||||
roughness/mode=0
|
||||
roughness/src_normal=""
|
||||
process/channel_remap/red=0
|
||||
process/channel_remap/green=1
|
||||
process/channel_remap/blue=2
|
||||
process/channel_remap/alpha=3
|
||||
process/fix_alpha_border=true
|
||||
process/premult_alpha=false
|
||||
process/normal_map_invert_y=false
|
||||
process/hdr_as_srgb=false
|
||||
process/hdr_clamp_exposure=false
|
||||
process/size_limit=0
|
||||
detect_3d/compress_to=1
|
||||
|
After Width: | Height: | Size: 239 B |
@@ -0,0 +1,40 @@
|
||||
[remap]
|
||||
|
||||
importer="texture"
|
||||
type="CompressedTexture2D"
|
||||
uid="uid://c8yfny20jcs15"
|
||||
path="res://.godot/imported/file_video.png-ffcbd0629f5e28094e97a5a051839521.ctex"
|
||||
metadata={
|
||||
"vram_texture": false
|
||||
}
|
||||
|
||||
[deps]
|
||||
|
||||
source_file="res://art/icons/file_video.png"
|
||||
dest_files=["res://.godot/imported/file_video.png-ffcbd0629f5e28094e97a5a051839521.ctex"]
|
||||
|
||||
[params]
|
||||
|
||||
compress/mode=0
|
||||
compress/high_quality=false
|
||||
compress/lossy_quality=0.7
|
||||
compress/uastc_level=0
|
||||
compress/rdo_quality_loss=0.0
|
||||
compress/hdr_compression=1
|
||||
compress/normal_map=0
|
||||
compress/channel_pack=0
|
||||
mipmaps/generate=false
|
||||
mipmaps/limit=-1
|
||||
roughness/mode=0
|
||||
roughness/src_normal=""
|
||||
process/channel_remap/red=0
|
||||
process/channel_remap/green=1
|
||||
process/channel_remap/blue=2
|
||||
process/channel_remap/alpha=3
|
||||
process/fix_alpha_border=true
|
||||
process/premult_alpha=false
|
||||
process/normal_map_invert_y=false
|
||||
process/hdr_as_srgb=false
|
||||
process/hdr_clamp_exposure=false
|
||||
process/size_limit=0
|
||||
detect_3d/compress_to=1
|
||||
|
After Width: | Height: | Size: 190 B |
@@ -0,0 +1,40 @@
|
||||
[remap]
|
||||
|
||||
importer="texture"
|
||||
type="CompressedTexture2D"
|
||||
uid="uid://cb7ge0n5gg8mu"
|
||||
path="res://.godot/imported/folder.png-2d34cb1a6aa9dbae664638a5df628136.ctex"
|
||||
metadata={
|
||||
"vram_texture": false
|
||||
}
|
||||
|
||||
[deps]
|
||||
|
||||
source_file="res://art/icons/folder.png"
|
||||
dest_files=["res://.godot/imported/folder.png-2d34cb1a6aa9dbae664638a5df628136.ctex"]
|
||||
|
||||
[params]
|
||||
|
||||
compress/mode=0
|
||||
compress/high_quality=false
|
||||
compress/lossy_quality=0.7
|
||||
compress/uastc_level=0
|
||||
compress/rdo_quality_loss=0.0
|
||||
compress/hdr_compression=1
|
||||
compress/normal_map=0
|
||||
compress/channel_pack=0
|
||||
mipmaps/generate=false
|
||||
mipmaps/limit=-1
|
||||
roughness/mode=0
|
||||
roughness/src_normal=""
|
||||
process/channel_remap/red=0
|
||||
process/channel_remap/green=1
|
||||
process/channel_remap/blue=2
|
||||
process/channel_remap/alpha=3
|
||||
process/fix_alpha_border=true
|
||||
process/premult_alpha=false
|
||||
process/normal_map_invert_y=false
|
||||
process/hdr_as_srgb=false
|
||||
process/hdr_clamp_exposure=false
|
||||
process/size_limit=0
|
||||
detect_3d/compress_to=1
|
||||
@@ -0,0 +1,221 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Генерирует пиксельные иконки файлов для PEAK HOUR (32×32 RGBA PNG).
|
||||
|
||||
Стиль: плоские заливки из палитры Kaleidos, контур 1 px #2C2A26,
|
||||
без сглаживания (nearest). Иконки читаются в размерах 16/24/32 px.
|
||||
|
||||
Запуск:
|
||||
python3 art/icons/generate_icons.py
|
||||
python3 art/icons/generate_icons.py --preview /tmp/opencode/icons_preview.png
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
from pathlib import Path
|
||||
|
||||
from PIL import Image, ImageDraw, ImageFont
|
||||
|
||||
SIZE = 32
|
||||
|
||||
# --- палитра Kaleidos ------------------------------------------------------
|
||||
OUTLINE = (44, 42, 38, 255) # #2C2A26
|
||||
NAVY = (44, 92, 104, 255) # #2C5C68
|
||||
TEAL = (47, 127, 143, 255) # #2F7F8F
|
||||
TEAL_LIGHT = (131, 199, 212, 255) # #83C7D4
|
||||
TEAL_PALE = (230, 245, 247, 255) # #E6F5F7
|
||||
WARN = (197, 138, 46, 255) # #C58A2E
|
||||
SUCCESS = (63, 143, 94, 255) # #3F8F5E
|
||||
ERROR = (194, 91, 74, 255) # #C25B4A
|
||||
PAPER = (247, 245, 238, 255) # #F7F5EE
|
||||
PAPER_LO = (216, 211, 201, 255) # #D8D3C9
|
||||
WHITE = (255, 255, 255, 255)
|
||||
|
||||
|
||||
def canvas() -> Image.Image:
|
||||
return Image.new("RGBA", (SIZE, SIZE), (0, 0, 0, 0))
|
||||
|
||||
|
||||
def shape(d: ImageDraw.ImageDraw, pts: list[tuple[int, int]], fill, outline=OUTLINE) -> None:
|
||||
"""Заливка многоугольника + контур 1 px по его периметру."""
|
||||
d.polygon(pts, fill=fill)
|
||||
if outline is not None:
|
||||
d.line(pts + [pts[0]], fill=outline, width=1, joint="curve")
|
||||
|
||||
|
||||
def box(d: ImageDraw.ImageDraw, xy: tuple[int, int, int, int], fill, outline=OUTLINE) -> None:
|
||||
"""
|
||||
Прямоугольник с координатами включительно (как у ImageDraw.rectangle).
|
||||
"""
|
||||
d.rectangle(xy, fill=fill)
|
||||
if outline is not None:
|
||||
d.rectangle(xy, outline=outline)
|
||||
|
||||
|
||||
def file_text() -> Image.Image:
|
||||
img = canvas()
|
||||
d = ImageDraw.Draw(img)
|
||||
# страница с отрезанным верхним правым углом
|
||||
page = [(7, 4), (19, 4), (25, 10), (25, 27), (7, 27)]
|
||||
shape(d, page, PAPER)
|
||||
# загнутый уголок
|
||||
shape(d, [(19, 4), (25, 10), (19, 10)], PAPER_LO)
|
||||
# три строки текста
|
||||
d.rectangle((10, 13, 22, 14), fill=NAVY)
|
||||
d.rectangle((10, 17, 22, 18), fill=NAVY)
|
||||
d.rectangle((10, 21, 18, 22), fill=NAVY)
|
||||
return img
|
||||
|
||||
|
||||
def file_sound() -> Image.Image:
|
||||
img = canvas()
|
||||
d = ImageDraw.Draw(img)
|
||||
# янтарный бейдж со срезанными углами
|
||||
badge = [(6, 3), (26, 3), (29, 6), (29, 26), (26, 29), (6, 29), (3, 26), (3, 6)]
|
||||
shape(d, badge, WARN)
|
||||
# восьмая нота: головка, штиль, флажок
|
||||
head = [(9, 21), (11, 19), (15, 19), (17, 21), (17, 24), (15, 26), (11, 26), (9, 24)]
|
||||
shape(d, head, OUTLINE)
|
||||
d.rectangle((16, 8, 17, 21), fill=OUTLINE)
|
||||
shape(d, [(17, 8), (23, 11), (23, 14), (20, 14), (20, 13), (17, 12)], OUTLINE)
|
||||
return img
|
||||
|
||||
|
||||
def file_photo() -> Image.Image:
|
||||
img = canvas()
|
||||
d = ImageDraw.Draw(img)
|
||||
# тёмная рамка
|
||||
box(d, (3, 6, 28, 26), NAVY)
|
||||
# светлое поле кадра
|
||||
box(d, (6, 9, 25, 23), TEAL_PALE)
|
||||
# дальняя и ближняя горы
|
||||
shape(d, [(6, 23), (13, 12), (19, 23)], TEAL_LIGHT, outline=None)
|
||||
shape(d, [(11, 23), (18, 14), (25, 23)], SUCCESS, outline=None)
|
||||
# солнце
|
||||
d.rectangle((20, 11, 22, 13), fill=WARN)
|
||||
return img
|
||||
|
||||
|
||||
def file_video() -> Image.Image:
|
||||
img = canvas()
|
||||
d = ImageDraw.Draw(img)
|
||||
# тело плёнки
|
||||
box(d, (3, 8, 28, 24), NAVY)
|
||||
# перфорация сверху и снизу
|
||||
for x in (5, 9, 21, 25):
|
||||
d.rectangle((x, 10, x + 1, 11), fill=PAPER)
|
||||
d.rectangle((x, 21, x + 1, 22), fill=PAPER)
|
||||
# play
|
||||
shape(d, [(13, 11), (22, 16), (13, 21)], PAPER)
|
||||
return img
|
||||
|
||||
|
||||
def folder() -> Image.Image:
|
||||
img = canvas()
|
||||
d = ImageDraw.Draw(img)
|
||||
body = [(3, 27), (3, 9), (13, 9), (16, 12), (29, 12), (29, 27)]
|
||||
shape(d, body, WARN)
|
||||
# светлая кромка передней стенки
|
||||
d.line([(5, 25), (27, 25)], fill=(226, 175, 91, 255), width=1)
|
||||
return img
|
||||
|
||||
|
||||
def packet() -> Image.Image:
|
||||
img = canvas()
|
||||
d = ImageDraw.Draw(img)
|
||||
# конверт
|
||||
box(d, (3, 10, 28, 25), PAPER)
|
||||
# клапан
|
||||
shape(d, [(4, 11), (16, 19), (27, 11)], TEAL_PALE, outline=None)
|
||||
d.line([(4, 11), (16, 19)], fill=NAVY, width=1)
|
||||
d.line([(16, 19), (27, 11)], fill=NAVY, width=1)
|
||||
# нижние сгибы
|
||||
d.line([(4, 24), (12, 18)], fill=PAPER_LO, width=1)
|
||||
d.line([(27, 24), (19, 18)], fill=PAPER_LO, width=1)
|
||||
# входящий пакет: teal-бейдж со стрелкой вниз
|
||||
shape(d, [(21, 19), (29, 19), (29, 27), (21, 27)], TEAL)
|
||||
d.rectangle((24, 21, 25, 23), fill=WHITE)
|
||||
d.polygon([(22, 23), (27, 23), (24, 26)], fill=WHITE)
|
||||
return img
|
||||
|
||||
|
||||
ICONS = {
|
||||
"file_text": file_text,
|
||||
"file_sound": file_sound,
|
||||
"file_photo": file_photo,
|
||||
"file_video": file_video,
|
||||
"folder": folder,
|
||||
"packet": packet,
|
||||
}
|
||||
|
||||
# Проверка: только полностью прозрачные или полностью непрозрачные пиксели.
|
||||
# Это гарантирует отсутствие сглаживания по краям.
|
||||
def assert_hard_edges(img: Image.Image, name: str) -> None:
|
||||
alpha = img.getchannel("A")
|
||||
values = set(alpha.getdata())
|
||||
assert values <= {0, 255}, f"{name}: найден полупрозрачный пиксель {values}"
|
||||
|
||||
|
||||
def build_preview(path: Path) -> None:
|
||||
"""Контактный лист: все иконки в 16/24/32 px + увеличение 32 px ×4."""
|
||||
scale = 4
|
||||
cell_w = 200
|
||||
cell_h = 236
|
||||
cols = 3
|
||||
sheet = Image.new("RGBA", (cols * cell_w, 2 * cell_h), (233, 230, 221, 255))
|
||||
d = ImageDraw.Draw(sheet)
|
||||
try:
|
||||
font = ImageFont.truetype("/usr/share/fonts/dejavu/DejaVuSans.ttf", 15)
|
||||
small = ImageFont.truetype("/usr/share/fonts/dejavu/DejaVuSans.ttf", 11)
|
||||
except OSError: # pragma: no cover - depends on system fonts
|
||||
font = ImageFont.load_default()
|
||||
small = font
|
||||
|
||||
for index, (name, factory) in enumerate(ICONS.items()):
|
||||
col = index % cols
|
||||
row = index // cols
|
||||
x0 = col * cell_w
|
||||
y0 = row * cell_h
|
||||
d.rectangle((x0 + 4, y0 + 4, x0 + cell_w - 6, y0 + cell_h - 6),
|
||||
fill=(247, 245, 238, 255), outline=(154, 149, 137, 255))
|
||||
d.text((x0 + 12, y0 + 10), name, fill=(31, 30, 27, 255), font=font)
|
||||
icon = factory()
|
||||
|
||||
# три реальных размера в ряд, каждый на белой «карточке»
|
||||
cx = x0 + 12
|
||||
for px in (16, 24, 32):
|
||||
d.rectangle((cx - 2, y0 + 44, cx + px + 1, y0 + 47 + px),
|
||||
fill=(255, 255, 255, 255), outline=(216, 211, 201, 255))
|
||||
sheet.alpha_composite(icon.resize((px, px), Image.Resampling.NEAREST),
|
||||
(cx, y0 + 46))
|
||||
d.text((cx, y0 + 52 + px), f"{px}", fill=(90, 86, 78, 255), font=small)
|
||||
cx += 42
|
||||
|
||||
# увеличение 32 px ×4 — чтобы видеть пиксели и контур
|
||||
big = icon.resize((SIZE * scale, SIZE * scale), Image.Resampling.NEAREST)
|
||||
sheet.alpha_composite(big, (x0 + 34, y0 + 92))
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
sheet.save(path)
|
||||
print(f"preview saved: {path}")
|
||||
|
||||
|
||||
def main() -> None:
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("--preview", type=Path, default=None,
|
||||
help="куда сохранить контактный лист (PNG)")
|
||||
args = parser.parse_args()
|
||||
|
||||
out_dir = Path(__file__).resolve().parent
|
||||
for name, factory in ICONS.items():
|
||||
img = factory()
|
||||
assert_hard_edges(img, name)
|
||||
target = out_dir / f"{name}.png"
|
||||
img.save(target)
|
||||
print(f"saved {target.relative_to(out_dir.parent.parent)} {img.size}")
|
||||
|
||||
if args.preview is not None:
|
||||
build_preview(args.preview)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
|
After Width: | Height: | Size: 370 B |
@@ -0,0 +1,40 @@
|
||||
[remap]
|
||||
|
||||
importer="texture"
|
||||
type="CompressedTexture2D"
|
||||
uid="uid://dj6scq4olnonj"
|
||||
path="res://.godot/imported/packet.png-2a847fa0ce4ca153b815c85ae5cddeab.ctex"
|
||||
metadata={
|
||||
"vram_texture": false
|
||||
}
|
||||
|
||||
[deps]
|
||||
|
||||
source_file="res://art/icons/packet.png"
|
||||
dest_files=["res://.godot/imported/packet.png-2a847fa0ce4ca153b815c85ae5cddeab.ctex"]
|
||||
|
||||
[params]
|
||||
|
||||
compress/mode=0
|
||||
compress/high_quality=false
|
||||
compress/lossy_quality=0.7
|
||||
compress/uastc_level=0
|
||||
compress/rdo_quality_loss=0.0
|
||||
compress/hdr_compression=1
|
||||
compress/normal_map=0
|
||||
compress/channel_pack=0
|
||||
mipmaps/generate=false
|
||||
mipmaps/limit=-1
|
||||
roughness/mode=0
|
||||
roughness/src_normal=""
|
||||
process/channel_remap/red=0
|
||||
process/channel_remap/green=1
|
||||
process/channel_remap/blue=2
|
||||
process/channel_remap/alpha=3
|
||||
process/fix_alpha_border=true
|
||||
process/premult_alpha=false
|
||||
process/normal_map_invert_y=false
|
||||
process/hdr_as_srgb=false
|
||||
process/hdr_clamp_exposure=false
|
||||
process/size_limit=0
|
||||
detect_3d/compress_to=1
|
||||
@@ -1,123 +1,649 @@
|
||||
{
|
||||
"_meta": {
|
||||
"file": "balance.json",
|
||||
"version": "1.1",
|
||||
"rules": "docs/GDD.md (компактная сводка) + docs/DECISIONS.md (D01–D17)",
|
||||
"note": "null = параметр не задан в постановке, уточнить до кода (см. _todo). Никаких выдуманных чисел.",
|
||||
"_todo": [
|
||||
"attacks.junk.spawn_probability — не задан в спецификации (явно null)",
|
||||
"income_target_by_hour: часы 4,6,7,8 — интерполяция, уточнить",
|
||||
"difficulty.h2 packet_timer/request_timer: формула даёт 26.325/50.15 (19.5×1.35 и 29.5×1.7 — у запросов отдельный grace)"
|
||||
]
|
||||
"version": "2.0",
|
||||
"rules": "PEAK_HOUR_V2_SHOP_FILES_MINIGAMES.md §2, §3, §6, §8 + Appendix A",
|
||||
"note": "v2: 8 lot families, 3 offers, rerolls, dense send-minigame selection. Legacy sections (difficulty/spawn/progression/timers/connections/channel) kept for the not-yet-rewritten HourDirector. New code reads constants/hours/grid/shop_rules/shop/minigames/attacks/payout/sla."
|
||||
},
|
||||
"constants": {
|
||||
"hour_seconds": 300,
|
||||
"run_hours": 9,
|
||||
"sla_start": 128,
|
||||
"sla_max": 200,
|
||||
"money_start": 150,
|
||||
"buffer_base": 4,
|
||||
"buffer_per_upgrade": 2,
|
||||
"buffer_max": 8,
|
||||
"slots_start": 2,
|
||||
"slots_max": 4,
|
||||
"max_sections": 6,
|
||||
"files_per_folder": 16,
|
||||
"money_start": 50,
|
||||
"sla_start": 60,
|
||||
"sla_base": 60,
|
||||
"sla_per_level": 40,
|
||||
"sla_hard_cap": 220,
|
||||
"sla_regen_per_hour": 15,
|
||||
"slots_start": 1,
|
||||
"slots_max": 5,
|
||||
"channel_capacity": 10,
|
||||
"channel_per_level": 6,
|
||||
"channel_capacity_max": 46,
|
||||
"buffer_base": 6,
|
||||
"buffer_per_level": 1,
|
||||
"buffer_max": 12,
|
||||
"request_timer": 30,
|
||||
"request_timer_per_level": 7,
|
||||
"request_timer_max": 65,
|
||||
"packet_timer": 20,
|
||||
"folder_cols": 5,
|
||||
"folder_rows": 4,
|
||||
"folders_start": 2,
|
||||
"folders_max": 6,
|
||||
"folder_new_cost": 180,
|
||||
"folder_price_start": 150,
|
||||
"folder_price_step": 100,
|
||||
"max_sections": 1,
|
||||
"section_start": "C",
|
||||
"folders_per_section": 12,
|
||||
"files_per_folder": 48,
|
||||
"send_window_visible_cards": 4,
|
||||
"send_scroll_card_height_px": 56,
|
||||
"required_name_char_limit": 12,
|
||||
"file_cooldown_after_send_sec": 20,
|
||||
"required_per_request": { "min": 1, "max": 3 }
|
||||
"file_cooldown_after_send_sec": 0,
|
||||
"required_per_request": { "min": 1, "max": 4 },
|
||||
"spawn_window_ratio": 0.75,
|
||||
"spawn_jitter": 0.2,
|
||||
"buffer_per_upgrade": 1,
|
||||
"udp_success_base": 0.40,
|
||||
"udp_success_max": 0.80,
|
||||
"udp_fail_sla": -3,
|
||||
"udp_retry_cooldown_sec": 4,
|
||||
"udp_request_ratio": 0.35,
|
||||
"send_quality_min": 0.45
|
||||
},
|
||||
"economy": {
|
||||
"start_stats": {
|
||||
"money": 50,
|
||||
"sla": 60,
|
||||
"sla_cap": 60,
|
||||
"slots": 1,
|
||||
"channel_capacity": 10,
|
||||
"buffer": 6,
|
||||
"request_timer": 30,
|
||||
"packet_timer": 20,
|
||||
"folder_grid": [5, 4],
|
||||
"money_mult": 1.0,
|
||||
"speed_mult": 1.0
|
||||
},
|
||||
"formulas": {
|
||||
"max_sla": "clamp(60 + 40 * sla_level, 60, 220)",
|
||||
"sla_regen": "sla = min(sla_cap, sla + 15) в начале каждого часа",
|
||||
"slots": "1 + slot_level (max 5)",
|
||||
"channel_capacity": "10 + 6 * channel_level",
|
||||
"buffer": "min(12, 6 + channel_level)",
|
||||
"request_timer": "min(65, 30 + 7 * timer_level)",
|
||||
"packet_timer": "20 (не покупается)",
|
||||
"speed": "pow(0.90, speed_level)",
|
||||
"money_mult": "1 + 0.05 * money_level",
|
||||
"folder_dims": "[[5,4],[6,4],[7,5],[8,5],[8,6],[9,6]][folder_level]",
|
||||
"transfer_time": "base_time_sec * pow(0.90, speed_level) * max(1.0, load / capacity)",
|
||||
"protocol_time": "UDP ×0.8, TCP ×1.0 (только отправка)"
|
||||
}
|
||||
},
|
||||
"types": {
|
||||
"text": {
|
||||
"name_ru": "Текст",
|
||||
"ext": ".txt",
|
||||
"icon": "▤",
|
||||
"recv_time": 1.0,
|
||||
"send_time": 1.0,
|
||||
"recv_fee": 2,
|
||||
"base_pay": 20,
|
||||
"weight": 6,
|
||||
"minigame_coeff": 1.0,
|
||||
"sla_loss": 2
|
||||
"ext": "txt",
|
||||
"icon": "text",
|
||||
"size_kb": 64,
|
||||
"cells": 1,
|
||||
"base_time_sec": 3,
|
||||
"base_pay": 18,
|
||||
"recv_fee": 1,
|
||||
"weight": 1,
|
||||
"minigame_coeff": 1,
|
||||
"sla_loss": 2,
|
||||
"shapes": ["O1"]
|
||||
},
|
||||
"sound": {
|
||||
"name_ru": "Звук",
|
||||
"ext": ".wav",
|
||||
"icon": "♪",
|
||||
"recv_time": 1.5,
|
||||
"send_time": 2.0,
|
||||
"recv_fee": 3,
|
||||
"base_pay": 35,
|
||||
"weight": 10,
|
||||
"minigame_coeff": 2.0,
|
||||
"sla_loss": 4
|
||||
"ext": "mp3",
|
||||
"icon": "sound",
|
||||
"size_kb": 3072,
|
||||
"cells": 2,
|
||||
"base_time_sec": 5,
|
||||
"base_pay": 30,
|
||||
"recv_fee": 2,
|
||||
"weight": 2,
|
||||
"minigame_coeff": 2,
|
||||
"sla_loss": 4,
|
||||
"shapes": ["I2H", "I2V"]
|
||||
},
|
||||
"photo": {
|
||||
"name_ru": "Фото",
|
||||
"ext": ".jpg",
|
||||
"icon": "▣",
|
||||
"recv_time": 2.0,
|
||||
"send_time": 3.0,
|
||||
"recv_fee": 5,
|
||||
"base_pay": 55,
|
||||
"weight": 14,
|
||||
"minigame_coeff": 3.0,
|
||||
"sla_loss": 8
|
||||
"ext": "jpg",
|
||||
"icon": "photo",
|
||||
"size_kb": 5120,
|
||||
"cells": 3,
|
||||
"base_time_sec": 8,
|
||||
"base_pay": 44,
|
||||
"recv_fee": 4,
|
||||
"weight": 4,
|
||||
"minigame_coeff": 4,
|
||||
"sla_loss": 8,
|
||||
"shapes": ["I3H", "I3V", "L3a", "L3b", "L3c", "L3d"]
|
||||
},
|
||||
"video": {
|
||||
"name_ru": "Видео",
|
||||
"ext": ".mp4",
|
||||
"icon": "▶",
|
||||
"recv_time": 3.0,
|
||||
"send_time": 5.0,
|
||||
"recv_fee": 8,
|
||||
"base_pay": 90,
|
||||
"weight": 22,
|
||||
"minigame_coeff": 4.0,
|
||||
"sla_loss": 16
|
||||
"ext": "mp4",
|
||||
"icon": "video",
|
||||
"size_kb": 20480,
|
||||
"cells": 4,
|
||||
"base_time_sec": 12,
|
||||
"base_pay": 69,
|
||||
"recv_fee": 6,
|
||||
"weight": 8,
|
||||
"minigame_coeff": 8,
|
||||
"sla_loss": 16,
|
||||
"shapes": ["I4H", "I4V", "O4", "T4a", "T4b", "T4c", "T4d", "L4a", "L4b", "L4c", "L4d", "J4a", "J4b", "J4c", "J4d", "S4", "Z4"]
|
||||
}
|
||||
},
|
||||
"hours": [
|
||||
{
|
||||
"hour": 1,
|
||||
"duration": 120,
|
||||
"files": 6,
|
||||
"active_requests": 2,
|
||||
"attacks": 0,
|
||||
"income_target": 100,
|
||||
"send_interval": 20,
|
||||
"type_weights": { "text": 1.0, "sound": 0.0, "photo": 0.0, "video": 0.0 },
|
||||
"request_mix": { "1": 1.0, "2": 0.0, "3": 0.0, "4": 0.0 },
|
||||
"request_type_weights": { "text": 1.0, "sound": 0.0, "photo": 0.0, "video": 0.0 }
|
||||
},
|
||||
{
|
||||
"hour": 2,
|
||||
"duration": 150,
|
||||
"files": 6,
|
||||
"active_requests": 2,
|
||||
"attacks": 1,
|
||||
"income_target": 220,
|
||||
"send_interval": 24,
|
||||
"type_weights": { "text": 0.80, "sound": 0.20, "photo": 0.0, "video": 0.0 },
|
||||
"request_mix": { "1": 0.70, "2": 0.30, "3": 0.0, "4": 0.0 },
|
||||
"request_type_weights": { "text": 0.80, "sound": 0.20, "photo": 0.0, "video": 0.0 }
|
||||
},
|
||||
{
|
||||
"hour": 3,
|
||||
"duration": 180,
|
||||
"files": 7,
|
||||
"active_requests": 2,
|
||||
"attacks": 1,
|
||||
"income_target": 380,
|
||||
"send_interval": 18,
|
||||
"type_weights": { "text": 0.50, "sound": 0.50, "photo": 0.0, "video": 0.0 },
|
||||
"request_mix": { "1": 0.20, "2": 0.80, "3": 0.0, "4": 0.0 },
|
||||
"request_type_weights": { "text": 0.50, "sound": 0.50, "photo": 0.0, "video": 0.0 }
|
||||
},
|
||||
{
|
||||
"hour": 4,
|
||||
"duration": 210,
|
||||
"files": 8,
|
||||
"active_requests": 3,
|
||||
"attacks": 2,
|
||||
"income_target": 560,
|
||||
"send_interval": 24,
|
||||
"type_weights": { "text": 0.40, "sound": 0.30, "photo": 0.30, "video": 0.0 },
|
||||
"request_mix": { "1": 0.0, "2": 0.70, "3": 0.30, "4": 0.0 },
|
||||
"request_type_weights": { "text": 0.40, "sound": 0.30, "photo": 0.30, "video": 0.0 }
|
||||
},
|
||||
{
|
||||
"hour": 5,
|
||||
"duration": 240,
|
||||
"files": 9,
|
||||
"active_requests": 3,
|
||||
"attacks": 2,
|
||||
"income_target": 760,
|
||||
"send_interval": 24,
|
||||
"type_weights": { "text": 0.35, "sound": 0.35, "photo": 0.30, "video": 0.0 },
|
||||
"request_mix": { "1": 0.0, "2": 0.55, "3": 0.45, "4": 0.0 },
|
||||
"request_type_weights": { "text": 0.35, "sound": 0.35, "photo": 0.30, "video": 0.0 }
|
||||
},
|
||||
{
|
||||
"hour": 6,
|
||||
"duration": 270,
|
||||
"files": 9,
|
||||
"active_requests": 3,
|
||||
"attacks": 3,
|
||||
"income_target": 980,
|
||||
"send_interval": 24,
|
||||
"type_weights": { "text": 0.30, "sound": 0.30, "photo": 0.30, "video": 0.10 },
|
||||
"request_mix": { "1": 0.0, "2": 0.25, "3": 0.60, "4": 0.15 },
|
||||
"request_type_weights": { "text": 0.30, "sound": 0.30, "photo": 0.30, "video": 0.10 }
|
||||
},
|
||||
{
|
||||
"hour": 7,
|
||||
"duration": 300,
|
||||
"files": 10,
|
||||
"active_requests": 4,
|
||||
"attacks": 3,
|
||||
"income_target": 1200,
|
||||
"send_interval": 24,
|
||||
"type_weights": { "text": 0.22, "sound": 0.23, "photo": 0.30, "video": 0.25 },
|
||||
"request_mix": { "1": 0.0, "2": 0.15, "3": 0.55, "4": 0.30 },
|
||||
"request_type_weights": { "text": 0.22, "sound": 0.23, "photo": 0.30, "video": 0.25 }
|
||||
},
|
||||
{
|
||||
"hour": 8,
|
||||
"duration": 330,
|
||||
"files": 10,
|
||||
"active_requests": 4,
|
||||
"attacks": 4,
|
||||
"income_target": 1450,
|
||||
"send_interval": 24,
|
||||
"type_weights": { "text": 0.15, "sound": 0.25, "photo": 0.35, "video": 0.25 },
|
||||
"request_mix": { "1": 0.0, "2": 0.10, "3": 0.45, "4": 0.45 },
|
||||
"request_type_weights": { "text": 0.15, "sound": 0.25, "photo": 0.35, "video": 0.25 }
|
||||
},
|
||||
{
|
||||
"hour": 9,
|
||||
"duration": 360,
|
||||
"files": 10,
|
||||
"active_requests": 4,
|
||||
"attacks": 4,
|
||||
"income_target": 1700,
|
||||
"send_interval": 24,
|
||||
"type_weights": { "text": 0.10, "sound": 0.15, "photo": 0.30, "video": 0.45 },
|
||||
"request_mix": { "1": 0.0, "2": 0.0, "3": 0.25, "4": 0.75 },
|
||||
"request_type_weights": { "text": 0.10, "sound": 0.15, "photo": 0.30, "video": 0.45 }
|
||||
}
|
||||
],
|
||||
"payout": {
|
||||
"speed_base": 0.6,
|
||||
"speed_bonus": 0.4,
|
||||
"quality_base": 0.5,
|
||||
"quality_bonus": 0.5,
|
||||
"multi_step": 0.15,
|
||||
"protocol_mult": { "TCP": 1.15, "UDP": 0.85 },
|
||||
"udp_time_mult": 0.8,
|
||||
"streak_step": 0.02,
|
||||
"streak_cap": 10,
|
||||
"money_mult_step": 0.05,
|
||||
"formula": "payout = int(round(float(base_sum) * speed * quality * multi * protocol * streak * money_mult))",
|
||||
"speed_formula": "speed = 0.6 + 0.4 * clamp(time_left / timer_total, 0, 1)",
|
||||
"quality_formula": "quality = 0.5 + 0.5 * q",
|
||||
"multi_formula": "multi = 1 + 0.15 * (n - 1)",
|
||||
"streak_formula": "streak = 1 + 0.02 * min(10, streak)",
|
||||
"money_mult_formula": "money_mult = 1 + 0.05 * money_level",
|
||||
"note": "Только float-арифметика: никаких int/int и никакого ^ для степени."
|
||||
},
|
||||
"sla": {
|
||||
"start": 60,
|
||||
"regen_per_hour": 15,
|
||||
"defeat_at_zero": true,
|
||||
"timeout_fine_base": 10,
|
||||
"timeout_fine_per_hour": 2,
|
||||
"timeout_fine_formula": "-(10 + 2*hour) SLA",
|
||||
"wrong_attach_fine": -2,
|
||||
"lost_file_fine": -5,
|
||||
"buffer_overflow_fine": -5,
|
||||
"reject_money": -5,
|
||||
"reject_sla": -5,
|
||||
"folder_destroyed_fine": -5,
|
||||
"sla_lose": "SLA <= 0 во время часа = поражение забега"
|
||||
},
|
||||
"grid": {
|
||||
"gap": 6,
|
||||
"cell_min": 56,
|
||||
"cell_max": 104,
|
||||
"view_w": 558,
|
||||
"view_h": 470,
|
||||
"folder_cols": 5,
|
||||
"folder_rows": 4,
|
||||
"folder_dims": [[5, 4], [6, 4], [7, 5], [8, 5], [8, 6], [9, 6]],
|
||||
"table_cols": 4,
|
||||
"table_rows": 6,
|
||||
"cell_formula": "cell = clamp(floor(min((view_w - gap*(cols-1))/cols, (view_h - gap*(rows-1))/rows)), 56, 104)",
|
||||
"shapes": {
|
||||
"O1": { "cells": [[0, 0]], "w": 1, "h": 1 },
|
||||
"I2H": { "cells": [[0, 0], [1, 0]], "w": 2, "h": 1 },
|
||||
"I2V": { "cells": [[0, 0], [0, 1]], "w": 1, "h": 2 },
|
||||
"I3H": { "cells": [[0, 0], [1, 0], [2, 0]], "w": 3, "h": 1 },
|
||||
"I3V": { "cells": [[0, 0], [0, 1], [0, 2]], "w": 1, "h": 3 },
|
||||
"L3a": { "cells": [[0, 0], [0, 1], [1, 1]], "w": 2, "h": 2 },
|
||||
"L3b": { "cells": [[0, 0], [1, 0], [0, 1]], "w": 2, "h": 2 },
|
||||
"L3c": { "cells": [[1, 0], [0, 1], [1, 1]], "w": 2, "h": 2 },
|
||||
"L3d": { "cells": [[0, 0], [1, 0], [1, 1]], "w": 2, "h": 2 },
|
||||
"I4H": { "cells": [[0, 0], [1, 0], [2, 0], [3, 0]], "w": 4, "h": 1 },
|
||||
"I4V": { "cells": [[0, 0], [0, 1], [0, 2], [0, 3]], "w": 1, "h": 4 },
|
||||
"O4": { "cells": [[0, 0], [1, 0], [0, 1], [1, 1]], "w": 2, "h": 2 },
|
||||
"T4a": { "cells": [[0, 0], [1, 0], [2, 0], [1, 1]], "w": 3, "h": 2 },
|
||||
"T4b": { "cells": [[0, 1], [1, 1], [2, 1], [1, 0]], "w": 3, "h": 2 },
|
||||
"T4c": { "cells": [[0, 0], [0, 1], [1, 1], [0, 2]], "w": 2, "h": 3 },
|
||||
"T4d": { "cells": [[1, 0], [1, 1], [0, 1], [1, 2]], "w": 2, "h": 3 },
|
||||
"L4a": { "cells": [[0, 0], [0, 1], [0, 2], [1, 2]], "w": 2, "h": 3 },
|
||||
"L4b": { "cells": [[0, 0], [1, 0], [2, 0], [0, 1]], "w": 3, "h": 2 },
|
||||
"L4c": { "cells": [[0, 0], [1, 0], [1, 1], [1, 2]], "w": 2, "h": 3 },
|
||||
"L4d": { "cells": [[2, 0], [0, 1], [1, 1], [2, 1]], "w": 3, "h": 2 },
|
||||
"J4a": { "cells": [[1, 0], [1, 1], [1, 2], [0, 2]], "w": 2, "h": 3 },
|
||||
"J4b": { "cells": [[0, 0], [1, 0], [2, 0], [2, 1]], "w": 3, "h": 2 },
|
||||
"J4c": { "cells": [[0, 0], [0, 1], [1, 1], [2, 1]], "w": 3, "h": 2 },
|
||||
"J4d": { "cells": [[0, 0], [0, 1], [0, 2], [1, 0]], "w": 2, "h": 3 },
|
||||
"S4": { "cells": [[1, 0], [2, 0], [0, 1], [1, 1]], "w": 3, "h": 2 },
|
||||
"Z4": { "cells": [[0, 0], [1, 0], [1, 1], [2, 1]], "w": 3, "h": 2 }
|
||||
},
|
||||
"type_shapes": {
|
||||
"text": ["O1"],
|
||||
"sound": ["I2H", "I2V"],
|
||||
"photo": ["I3H", "I3V", "L3a", "L3b", "L3c", "L3d"],
|
||||
"video": ["I4H", "I4V", "O4", "T4a", "T4b", "T4c", "T4d", "L4a", "L4b", "L4c", "L4d", "J4a", "J4b", "J4c", "J4d", "S4", "Z4"]
|
||||
}
|
||||
},
|
||||
"shop_rules": {
|
||||
"offers": 3,
|
||||
"unlock_hour": 2,
|
||||
"reroll_costs": [40, 80, 120],
|
||||
"max_rerolls": 3,
|
||||
"last_offer_weight": 0.5,
|
||||
"pity_hours": 2,
|
||||
"pity_weight": 1.5,
|
||||
"note": "Веса берутся из shop[].weight_by_hour (9 значений, индекс = hour-1). Фазы H2-3/H4-6/H7-9 — таблица §3.1 — источник правды."
|
||||
},
|
||||
"shop": [
|
||||
{
|
||||
"id": "lot_slots",
|
||||
"name_ru": "Порты коммутатора",
|
||||
"desc_ru": "Ещё один порт: приём и отправка могут идти параллельно.",
|
||||
"effect": "connection_slots",
|
||||
"value": 1,
|
||||
"max_level": 4,
|
||||
"prices": [90, 200, 400, 700],
|
||||
"level_names": ["Второй порт", "Третий порт", "Четвёртый порт", "Магистральный порт"],
|
||||
"weight_by_hour": [1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 0.8, 0.8, 0.8]
|
||||
},
|
||||
{
|
||||
"id": "lot_speed",
|
||||
"name_ru": "Оптоволокно",
|
||||
"desc_ru": "Скорость приёма и отправки +10% за уровень (умножается).",
|
||||
"effect": "transfer_speed",
|
||||
"value": 0.10,
|
||||
"max_level": 6,
|
||||
"prices": [55, 75, 100, 135, 180, 245],
|
||||
"level_names": ["Витая пара", "Экранированная пара", "Оптоволокно", "Гигабитная линия", "Магистраль", "Тёмное волокно"],
|
||||
"weight_by_hour": [1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 0.8, 0.8, 0.8]
|
||||
},
|
||||
{
|
||||
"id": "lot_sla",
|
||||
"name_ru": "Резервный ИБП",
|
||||
"desc_ru": "Держит узел живым: кап SLA +40 и мгновенное восстановление +40.",
|
||||
"effect": "sla_cap",
|
||||
"value": 40,
|
||||
"max_level": 4,
|
||||
"prices": [80, 160, 270, 430],
|
||||
"level_names": ["Резервный ИБП", "Горячий резерв", "Двойное питание", "Автономный контур"],
|
||||
"weight_by_hour": [0.8, 0.8, 0.8, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0]
|
||||
},
|
||||
{
|
||||
"id": "lot_shield",
|
||||
"name_ru": "Заземление",
|
||||
"desc_ru": "Автоматически гасит атаку: 70% награды, без штрафа.",
|
||||
"effect": "shield_charge",
|
||||
"value": 1,
|
||||
"max_level": 3,
|
||||
"prices": [100, 220, 410],
|
||||
"level_names": ["Заземление", "Громоотвод", "Защитный контур"],
|
||||
"weight_by_hour": [0.3, 0.3, 0.3, 0.8, 0.8, 0.8, 1.0, 1.0, 1.0]
|
||||
},
|
||||
{
|
||||
"id": "lot_money",
|
||||
"name_ru": "Корпоративный контракт",
|
||||
"desc_ru": "Оплата за контейнеры +5% за уровень.",
|
||||
"effect": "money_mult",
|
||||
"value": 0.05,
|
||||
"max_level": 8,
|
||||
"prices": [70, 95, 130, 170, 220, 285, 365, 465],
|
||||
"level_names": ["Тарифный план", "Корпоративный контракт", "SLA-пакет", "Госзаказ", "Экспортный контракт", "Франшиза", "Монополия", "Золотая жила"],
|
||||
"weight_by_hour": [0.6, 0.6, 0.6, 1.0, 1.0, 1.0, 1.2, 1.2, 1.2]
|
||||
},
|
||||
{
|
||||
"id": "lot_folder",
|
||||
"name_ru": "Складской стеллаж",
|
||||
"desc_ru": "Больше места под фигуры: 20 → 24 клетки на первом уровне (до 54).",
|
||||
"effect": "folder_cells",
|
||||
"value": 1,
|
||||
"max_level": 5,
|
||||
"prices": [50, 100, 180, 290, 440],
|
||||
"level_names": ["Стеллаж", "Секция стеллажа", "Складская линия", "Автоматизированный склад", "Ангар"],
|
||||
"weight_by_hour": [1.0, 1.0, 1.0, 0.9, 0.9, 0.9, 0.8, 0.8, 0.8]
|
||||
},
|
||||
{
|
||||
"id": "lot_channel",
|
||||
"name_ru": "Пропускная способность",
|
||||
"desc_ru": "Канал +6 и буфер приёма +1 за уровень.",
|
||||
"effect": "channel_capacity",
|
||||
"value": 6,
|
||||
"buffer_value": 1,
|
||||
"max_level": 6,
|
||||
"prices": [45, 70, 95, 130, 175, 230],
|
||||
"level_names": ["Канал 56k", "ISDN", "ADSL", "Выделенная линия", "Оптика", "Магистраль"],
|
||||
"weight_by_hour": [1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 0.9, 0.9, 0.9]
|
||||
},
|
||||
{
|
||||
"id": "lot_timer",
|
||||
"name_ru": "Регламент обработки",
|
||||
"desc_ru": "Больше времени на поиск файлов и прикрепление: 30 → 37 с (до 65).",
|
||||
"effect": "request_timer",
|
||||
"value": 7,
|
||||
"max_level": 5,
|
||||
"prices": [45, 75, 115, 165, 230],
|
||||
"level_names": ["Регламент", "Продление сессии", "Гибкий график", "Приоритетная обработка", "Выделенный менеджер"],
|
||||
"weight_by_hour": [1.0, 1.0, 1.0, 0.9, 0.9, 0.9, 0.7, 0.7, 0.7]
|
||||
},
|
||||
{
|
||||
"id": "lot_udp",
|
||||
"name_ru": "UDP-повторитель",
|
||||
"desc_ru": "Надёжность UDP +10% за уровень: 40% → 80%.",
|
||||
"effect": "udp_success",
|
||||
"value": 0.10,
|
||||
"max_level": 4,
|
||||
"prices": [60, 120, 210, 350],
|
||||
"weight_by_hour": [0.8, 0.8, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0]
|
||||
}
|
||||
],
|
||||
"minigames": {
|
||||
"selection": {
|
||||
"order": ["dots", "sound", "text", "video", "photo", "maze"],
|
||||
"achievable_sums": [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 24, 25, 26, 28, 32],
|
||||
"max_index": 26,
|
||||
"tier_max_rank": [7, 13],
|
||||
"rank_games": ["text", "sound", "dots", "photo", "maze", "dots", "photo", "video", "dots", "text", "video", "maze", "photo", "sound", "video", "text", "maze", "sound", "video", "sound", "photo", "dots", "dots", "maze", "maze", "photo", "video"],
|
||||
"formula": "S=sum(coeff по 1-4 файлам); rank=index of S in achievable_sums (или ближайшей меньшей); tier=1 при rank<=7, 2 при 8<=rank<=13, иначе 3; id=rank_games[rank]; S=0 → dots/T1",
|
||||
"note": "6 игр отправки (wires удалена), 3 ступени, ровно 27 достижимых сумм коэффициентов для 1-4 файлов. Таблица rank_games задаёт игру по rank напрямую (без modulo)."
|
||||
},
|
||||
"games": {
|
||||
"dots": {
|
||||
"name_ru": "Одна линия",
|
||||
"kind": "send",
|
||||
"tiers": [
|
||||
{ "grid": [4, 3], "obstacles": 2, "undo_penalty": 0.04 },
|
||||
{ "grid": [4, 4], "obstacles": 4, "undo_penalty": 0.04 },
|
||||
{ "grid": [5, 4], "obstacles": 6, "undo_penalty": 0.03 }
|
||||
]
|
||||
},
|
||||
"sound": {
|
||||
"name_ru": "Уровни",
|
||||
"kind": "send",
|
||||
"tiers": [
|
||||
{ "channels": 4, "tolerance": 0.11, "drift": 0.0, "drift_hz": 0.0 },
|
||||
{ "channels": 5, "tolerance": 0.08, "drift": 0.02, "drift_hz": 0.15 },
|
||||
{ "channels": 6, "tolerance": 0.06, "drift": 0.03, "drift_hz": 0.25 }
|
||||
]
|
||||
},
|
||||
"text": {
|
||||
"name_ru": "Чужое слово",
|
||||
"kind": "send",
|
||||
"tiers": [
|
||||
{ "words": 15, "aliens": 2, "lines": 2, "alien_kind": "foreign_language+typos" },
|
||||
{ "words": 19, "aliens": 3, "lines": 3, "alien_kind": "foreign_language+typos+semantic" },
|
||||
{ "words": 24, "aliens": 4, "lines": 3, "alien_kind": "foreign_language+typos+semantic+archaic" }
|
||||
]
|
||||
},
|
||||
"video": {
|
||||
"name_ru": "Раскадровка",
|
||||
"kind": "send",
|
||||
"tiers": [
|
||||
{ "frames": 4, "slots": 4, "decoys": 0, "mirror": false },
|
||||
{ "frames": 5, "slots": 4, "decoys": 1, "mirror": false },
|
||||
{ "frames": 6, "slots": 5, "decoys": 2, "mirror": true }
|
||||
]
|
||||
},
|
||||
"photo": {
|
||||
"name_ru": "Кадр",
|
||||
"kind": "send",
|
||||
"tiers": [
|
||||
{ "speed": 140, "frame_w": 180, "frame_h": 120, "shots": 4, "retarget_min": 0.6, "retarget_max": 1.0 },
|
||||
{ "speed": 200, "frame_w": 150, "frame_h": 100, "shots": 5, "retarget_min": 0.5, "retarget_max": 0.8 },
|
||||
{ "speed": 260, "frame_w": 120, "frame_h": 80, "shots": 6, "retarget_min": 0.35, "retarget_max": 0.65 }
|
||||
]
|
||||
},
|
||||
"maze": {
|
||||
"name_ru": "Лабиринт",
|
||||
"kind": "send",
|
||||
"tiers": [
|
||||
{ "cols": 11, "rows": 4, "width": 440, "height": 160, "speed": 470, "collision_penalty": 0.05, "dead_end_penalty": 0.02 },
|
||||
{ "cols": 13, "rows": 4, "width": 494, "height": 152, "speed": 520, "collision_penalty": 0.04, "dead_end_penalty": 0.02 },
|
||||
{ "cols": 15, "rows": 5, "width": 520, "height": 180, "speed": 560, "collision_penalty": 0.04, "dead_end_penalty": 0.02 }
|
||||
]
|
||||
},
|
||||
"junk": {
|
||||
"name_ru": "Фильтр",
|
||||
"kind": "attack",
|
||||
"tiers": [
|
||||
{ "cards": 9, "junk": 2, "hints": 1, "threshold": 0.6 },
|
||||
{ "cards": 10, "junk": 3, "hints": 2, "threshold": 0.65 },
|
||||
{ "cards": 11, "junk": 4, "hints": 2, "threshold": 0.7 },
|
||||
{ "cards": 12, "junk": 5, "hints": 3, "threshold": 0.75 }
|
||||
]
|
||||
},
|
||||
"ddos": {
|
||||
"name_ru": "Щит",
|
||||
"kind": "attack",
|
||||
"tiers": [
|
||||
{ "packets": 8, "speed": 110, "shield_width": 260, "interval": 0.55, "threshold": 0.6 },
|
||||
{ "packets": 10, "speed": 140, "shield_width": 220, "interval": 0.45, "threshold": 0.65 },
|
||||
{ "packets": 12, "speed": 170, "shield_width": 180, "interval": 0.35, "threshold": 0.7 },
|
||||
{ "packets": 14, "speed": 200, "shield_width": 150, "interval": 0.28, "threshold": 0.75 }
|
||||
]
|
||||
},
|
||||
"phishing": {
|
||||
"name_ru": "Паспорт",
|
||||
"kind": "attack",
|
||||
"tiers": [
|
||||
{ "clients": 3, "checks": ["name"], "threshold": 0.75 },
|
||||
{ "clients": 4, "checks": ["name", "department"], "threshold": 0.75 },
|
||||
{ "clients": 5, "checks": ["name", "department", "id"], "threshold": 0.8 },
|
||||
{ "clients": 6, "checks": ["name", "department", "id", "photo", "expiry"], "threshold": 0.8 }
|
||||
]
|
||||
},
|
||||
"ransomware": {
|
||||
"name_ru": "Карантин",
|
||||
"kind": "attack",
|
||||
"tiers": [
|
||||
{ "cards": 12, "exe": 1, "threshold": 0.5, "masking": "extension_visible" },
|
||||
{ "cards": 16, "exe": 2, "threshold": 0.55, "masking": "extension_small" },
|
||||
{ "cards": 20, "exe": 2, "threshold": 0.6, "masking": "document_icon" },
|
||||
{ "cards": 24, "exe": 3, "threshold": 0.65, "masking": "similar_names" }
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
"attacks": {
|
||||
"schedule": [0, 1, 1, 2, 2, 3, 3, 4, 4],
|
||||
"tier_formula": "clamp(1 + floor((hour - 2) / 2.0), 1, 4)",
|
||||
"spawn_window": {
|
||||
"start_ratio": 0.25,
|
||||
"end_ratio": 0.85,
|
||||
"start_min_sec": 20,
|
||||
"start_max_sec": 60,
|
||||
"end_margin_sec": 20
|
||||
},
|
||||
"shield_reward_scale": 0.7,
|
||||
"money_scale": 1.0,
|
||||
"kinds": {
|
||||
"junk": {
|
||||
"name_ru": "Мусорный пакет",
|
||||
"minigame": "junk",
|
||||
"weight_by_hour": [0.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0],
|
||||
"tiers": [
|
||||
{ "reward_money": 5, "reward_sla": 1, "fail_money": -10, "fail_sla": -5, "threshold": 0.6 },
|
||||
{ "reward_money": 10, "reward_sla": 2, "fail_money": -15, "fail_sla": -7, "threshold": 0.65 },
|
||||
{ "reward_money": 15, "reward_sla": 3, "fail_money": -20, "fail_sla": -9, "threshold": 0.7 },
|
||||
{ "reward_money": 20, "reward_sla": 4, "fail_money": -25, "fail_sla": -12, "threshold": 0.75 }
|
||||
]
|
||||
},
|
||||
"ddos": {
|
||||
"name_ru": "DDoS",
|
||||
"minigame": "ddos",
|
||||
"weight_by_hour": [0.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0],
|
||||
"tiers": [
|
||||
{ "reward_money": 30, "reward_sla": 2, "fail_money": -10, "fail_sla": -15, "channel_debuff": 0.4, "debuff_sec": 15, "threshold": 0.6 },
|
||||
{ "reward_money": 40, "reward_sla": 3, "fail_money": -15, "fail_sla": -20, "channel_debuff": 0.4, "debuff_sec": 15, "threshold": 0.65 },
|
||||
{ "reward_money": 50, "reward_sla": 4, "fail_money": -20, "fail_sla": -25, "channel_debuff": 0.4, "debuff_sec": 15, "threshold": 0.7 },
|
||||
{ "reward_money": 60, "reward_sla": 5, "fail_money": -25, "fail_sla": -30, "channel_debuff": 0.4, "debuff_sec": 15, "threshold": 0.75 }
|
||||
]
|
||||
},
|
||||
"phishing": {
|
||||
"name_ru": "Фишинг",
|
||||
"minigame": "phishing",
|
||||
"weight_by_hour": [0.0, 0.0, 0.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0],
|
||||
"tiers": [
|
||||
{ "reward_money": 20, "reward_sla": 2, "fail_money": -40, "fail_sla": -10, "file_lock_sec": 60, "send_penalty_sec": 5, "threshold": 0.75 },
|
||||
{ "reward_money": 30, "reward_sla": 3, "fail_money": -50, "fail_sla": -12, "file_lock_sec": 60, "send_penalty_sec": 5, "threshold": 0.75 },
|
||||
{ "reward_money": 40, "reward_sla": 4, "fail_money": -60, "fail_sla": -15, "file_lock_sec": 60, "send_penalty_sec": 5, "threshold": 0.8 },
|
||||
{ "reward_money": 50, "reward_sla": 5, "fail_money": -70, "fail_sla": -18, "file_lock_sec": 60, "send_penalty_sec": 5, "threshold": 0.8 }
|
||||
]
|
||||
},
|
||||
"ransomware": {
|
||||
"name_ru": "Шифровальщик",
|
||||
"minigame": "ransomware",
|
||||
"weight_by_hour": [0.0, 0.0, 0.0, 0.0, 1.0, 1.0, 1.0, 1.0, 1.0],
|
||||
"tiers": [
|
||||
{ "reward_money": 25, "reward_sla": 2, "fail_money": -30, "fail_sla": -10, "folder_block_sec": 15, "threshold": 0.5 },
|
||||
{ "reward_money": 35, "reward_sla": 3, "fail_money": -40, "fail_sla": -12, "folder_block_sec": 15, "threshold": 0.55 },
|
||||
{ "reward_money": 45, "reward_sla": 4, "fail_money": -50, "fail_sla": -15, "folder_block_sec": 15, "threshold": 0.6 },
|
||||
{ "reward_money": 55, "reward_sla": 5, "fail_money": -60, "fail_sla": -18, "folder_block_sec": 15, "threshold": 0.65 }
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
"difficulty": [
|
||||
{ "hour": 1, "recv_interval": 12, "send_interval": 22, "max_requests": 2, "buffer": 4, "packet_timer": 30, "request_timer": 60, "attacks": 0 },
|
||||
{ "hour": 2, "recv_interval": 11, "send_interval": 20, "max_requests": 2, "buffer": 4, "packet_timer": 26.325, "request_timer": 50.15, "attacks": 1 },
|
||||
{ "hour": 3, "recv_interval": 10, "send_interval": 18, "max_requests": 3, "buffer": 5, "packet_timer": 22.8, "request_timer": 40.6, "attacks": 1 },
|
||||
{ "hour": 4, "recv_interval": 9, "send_interval": 17, "max_requests": 3, "buffer": 5, "packet_timer": 18.5, "request_timer": 28.5, "attacks": 2 },
|
||||
{ "hour": 5, "recv_interval": 8, "send_interval": 15, "max_requests": 4, "buffer": 6, "packet_timer": 18, "request_timer": 28, "attacks": 2 },
|
||||
{ "hour": 6, "recv_interval": 7.5, "send_interval": 14, "max_requests": 4, "buffer": 6, "packet_timer": 17.5, "request_timer": 27.5, "attacks": 3 },
|
||||
{ "hour": 7, "recv_interval": 7, "send_interval": 13, "max_requests": 4, "buffer": 6, "packet_timer": 17, "request_timer": 27, "attacks": 3 },
|
||||
{ "hour": 8, "recv_interval": 6.5, "send_interval": 12, "max_requests": 4, "buffer": 7, "packet_timer": 16.5, "request_timer": 26.5, "attacks": 4 },
|
||||
{ "hour": 9, "recv_interval": 6, "send_interval": 12, "max_requests": 4, "buffer": 7, "packet_timer": 16, "request_timer": 26, "attacks": 4 }
|
||||
{ "hour": 1, "recv_interval": 12, "send_interval": 22, "max_requests": 2, "buffer": 4, "packet_timer": 20, "request_timer": 30, "attacks": 0 },
|
||||
{ "hour": 2, "recv_interval": 11, "send_interval": 20, "max_requests": 5, "buffer": 6, "packet_timer": 20, "request_timer": 30, "attacks": 1 },
|
||||
{ "hour": 3, "recv_interval": 10, "send_interval": 18, "max_requests": 7, "buffer": 6, "packet_timer": 20, "request_timer": 30, "attacks": 1 },
|
||||
{ "hour": 4, "recv_interval": 9, "send_interval": 17, "max_requests": 9, "buffer": 6, "packet_timer": 20, "request_timer": 30, "attacks": 2 },
|
||||
{ "hour": 5, "recv_interval": 8, "send_interval": 15, "max_requests": 10, "buffer": 6, "packet_timer": 20, "request_timer": 30, "attacks": 2 },
|
||||
{ "hour": 6, "recv_interval": 7.5, "send_interval": 14, "max_requests": 11, "buffer": 6, "packet_timer": 20, "request_timer": 30, "attacks": 3 },
|
||||
{ "hour": 7, "recv_interval": 7, "send_interval": 13, "max_requests": 11, "buffer": 6, "packet_timer": 20, "request_timer": 30, "attacks": 3 },
|
||||
{ "hour": 8, "recv_interval": 6.5, "send_interval": 12, "max_requests": 12, "buffer": 6, "packet_timer": 20, "request_timer": 30, "attacks": 4 },
|
||||
{ "hour": 9, "recv_interval": 6, "send_interval": 12, "max_requests": 12, "buffer": 6, "packet_timer": 20, "request_timer": 30, "attacks": 4 }
|
||||
],
|
||||
"difficulty_rules": {
|
||||
"table_end_hour": 8,
|
||||
"after_end_hour": "значения часа 8; +5% к скорости спавна каждые 2 часа",
|
||||
"spawn_speedup_after_end": 0.05,
|
||||
"table_end_hour": 9,
|
||||
"after_end_hour": "значения часа 9",
|
||||
"spawn_speedup_after_end": 0.0,
|
||||
"spawn_speedup_period_hours": 2,
|
||||
"type_availability": "час 1 — text; час 2 — text и sound; час 3 — +photo; час 4+ — все 4 (D23); атаки — с часа 2 (attack_junk)",
|
||||
"timer_grace": "packet_timer: grace h1 1.5, h2 1.35, h3 1.2, h4+ 1.0 (Economy.TIMER_GRACE). request_timer: отдельный grace h1 2.0, h2 1.7, h3 1.4, h4+ 1.0 (Economy.REQUEST_TIMER_GRACE). Формула: (base − 0.5*(h−1))*grace + 4*level. h2: packet 19.5*1.35=26.325, request 29.5*1.7=50.15."
|
||||
"type_availability": "веса типов — hours[].type_weights (единственный источник правды для приёма) и совпадают с hours[].request_type_weights (запросы); H1 только text, H2 text/sound, photo с H4, video с H6",
|
||||
"timer_grace": "v2: таймеры не масштабируются по часу (packet 20, request 30+7*level, cap 65)"
|
||||
},
|
||||
"progression": {
|
||||
"unlocks": {
|
||||
"shop": {"run_hour": 2, "label": "Магазин"},
|
||||
"search": {"meta": "survive_hour_3", "label": "Поиск"},
|
||||
"shield": {"meta": "survive_hour_5", "label": "Щит"},
|
||||
"minigame_text": {"run_hour": 1},
|
||||
"minigame_sound": {"run_hour": 2},
|
||||
"minigame_photo": {"run_hour": 3},
|
||||
"minigame_video": {"run_hour": 4},
|
||||
"minigame_advanced": {"meta": "complete_run", "label": "Продвинутые мини-игры"},
|
||||
"attack_junk": {"run_hour": 2},
|
||||
"attack_ddos": {"run_hour": 2},
|
||||
"attack_phishing": {"run_hour": 3},
|
||||
"attack_ransomware": {"run_hour": 4}
|
||||
"shop": { "run_hour": 2, "label": "Магазин" },
|
||||
"search": { "meta": "survive_hour_3", "label": "Поиск" },
|
||||
"shield": { "meta": "survive_hour_5", "label": "Щит" },
|
||||
"minigame_text": { "run_hour": 1 },
|
||||
"minigame_sound": { "run_hour": 2 },
|
||||
"minigame_photo": { "run_hour": 3 },
|
||||
"minigame_video": { "run_hour": 4 },
|
||||
"attack_junk": { "run_hour": 2 },
|
||||
"attack_ddos": { "run_hour": 2 },
|
||||
"attack_phishing": { "run_hour": 3 },
|
||||
"attack_ransomware": { "run_hour": 4 }
|
||||
},
|
||||
"meta_rewards": {
|
||||
"survive_hour_3": {"min_hours": 3, "unlocks": ["search"]},
|
||||
"survive_hour_5": {"min_hours": 5, "unlocks": ["shield"]},
|
||||
"complete_run": {"min_hours": 9, "unlocks": ["minigame_advanced"]}
|
||||
"survive_hour_3": { "min_hours": 3, "unlocks": ["search"] },
|
||||
"survive_hour_5": { "min_hours": 5, "unlocks": ["shield"] },
|
||||
"complete_run": { "min_hours": 9, "unlocks": ["minigame_advanced"] }
|
||||
}
|
||||
},
|
||||
"timers": {
|
||||
@@ -127,34 +653,20 @@
|
||||
"minigame_timeout": null,
|
||||
"delete_long_press_sec": 0.5
|
||||
},
|
||||
"sla": {
|
||||
"recv_bonus": 0.5,
|
||||
"send_bonus": 1.0,
|
||||
"reflect_bonus": 1.0,
|
||||
"timeout_fine_base": 10,
|
||||
"timeout_fine_per_hour": 2,
|
||||
"timeout_fine_formula": "-(10 + 2*hour) — таймаут и подобные штрафы (D12)",
|
||||
"wrong_attach_fine": -2,
|
||||
"lost_file_fine": -5,
|
||||
"buffer_overflow_fine": -5,
|
||||
"sla_lose": "SLA <= 0"
|
||||
},
|
||||
"payout": {
|
||||
"base_pay": "см. types.*.base_pay (значения в types)",
|
||||
"recv_fee": "см. types.*.recv_fee (значения в types)",
|
||||
"speed_formula": "speed = 0.6 + 0.4*(time_left / timer_total)",
|
||||
"quality_formula": "quality = 0.5 + 0.5*mini_quality",
|
||||
"multi_formula": "multi = 1 + 0.1*(n - 1)",
|
||||
"protocol_mult": { "TCP": 1.15, "UDP": 1.0 },
|
||||
"streak_formula": "streak = 1 + 0.02*min(10, streak)",
|
||||
"income_target_by_hour": {
|
||||
"hours": [1, 2, 3, 5, 9],
|
||||
"approx": [500, 800, 1200, 1800, 2200],
|
||||
"note": "ожидаемо ±10%; часы 4,6,7,8 — интерполяция, уточнить"
|
||||
}
|
||||
"spawn": {
|
||||
"type_pool_by_hour": { "1": ["text", "sound"], "2": ["text", "sound", "photo"], "3+": ["text", "sound", "photo", "video"] },
|
||||
"malicious_ratio": 0.0,
|
||||
"required_count_by_hour": {
|
||||
"1": { "1": 0.7, "2": 0.3, "3": 0.0 },
|
||||
"2": { "1": 0.6, "2": 0.35, "3": 0.05 },
|
||||
"3+": { "1": 0.5, "2": 0.4, "3": 0.1 }
|
||||
},
|
||||
"required_size_distribution": { "1": 0.5, "2": 0.4, "3": 0.1 },
|
||||
"required_count_note": "legacy; v2 читает hours[].request_mix",
|
||||
"attack_schedule": "attacks.schedule (0/1/1/2/2/3/3/4/4), окно 60–240 с часа"
|
||||
},
|
||||
"connections": {
|
||||
"W_formula": "W = clamp(0.30 - 0.005*(hour-1) - 0.005*successes + 0.05*zone_upgrade_level, 0.18, 0.36)",
|
||||
"W_formula": "legacy (полоска соединения удалена в v2)",
|
||||
"W_base": 0.3,
|
||||
"W_hour_coef": -0.005,
|
||||
"W_success_coef": -0.005,
|
||||
@@ -169,103 +681,14 @@
|
||||
"CHANNEL_BG_PER_HOUR": 0,
|
||||
"SLOT_LOAD": 0,
|
||||
"BG_note": "фоновый шум и занятые слоты НЕ грузят канал; load = только активные передачи (loading/sending)",
|
||||
"CHANNEL_CAPACITY": 80,
|
||||
"capacity_per_upgrade": 25,
|
||||
"capacity_upgrade_max_level": 4,
|
||||
"formula": "load = sum(weight(active_transfers)); factor = max(1.0, load / CHANNEL_CAPACITY); actual_time = base_time * factor. idle load = 0"
|
||||
},
|
||||
"attacks": {
|
||||
"junk": {
|
||||
"name_ru": "Мусорный пакет",
|
||||
"spawn_probability": null,
|
||||
"duration_sec": 15,
|
||||
"tell": "серая иконка ?, размер 0 Б, нет таймера",
|
||||
"effect": "авто-занос через duration_sec, занимает место в приёме; уничтожить можно как malicious в любой фазе (D02)",
|
||||
"reward_money": 5,
|
||||
"reward_sla": 1,
|
||||
"fail_money": -10,
|
||||
"fail_sla": -5,
|
||||
"filter_disabled_sec": 10
|
||||
},
|
||||
"ddos": {
|
||||
"name_ru": "DDoS",
|
||||
"cards_min": 8,
|
||||
"cards_max": 12,
|
||||
"spawn_window_sec": 2.0,
|
||||
"shield_hold_sec": 1.5,
|
||||
"tell": "8–12 идентичных карточек без типа и таймера",
|
||||
"effect": "волна карточек; отбивается удержанием щита",
|
||||
"reward_money": 30,
|
||||
"reward_sla": 2,
|
||||
"fail_sla": -15,
|
||||
"channel_debuff": 0.4,
|
||||
"channel_debuff_sec": 15,
|
||||
"packets_lost": 2,
|
||||
"recv_disabled_sec": 5
|
||||
},
|
||||
"phishing": {
|
||||
"name_ru": "Фишинг",
|
||||
"reward_money": 20,
|
||||
"reward_sla": 2,
|
||||
"fail_money": -40,
|
||||
"fail_sla": -10,
|
||||
"file_lock_sec": 60,
|
||||
"send_disabled_sec": 5,
|
||||
"timeout_money": -20,
|
||||
"timeout_sla": -5,
|
||||
"timeout_lock_sec": 30,
|
||||
"tell": "client_id формата id_XXXX с неверным символом (например id_7F3К — кириллическая К), серый аватар",
|
||||
"effect": "фальшивый запрос/пакет"
|
||||
},
|
||||
"ransomware": {
|
||||
"name_ru": "Шифровальщик",
|
||||
"reward_money": 25,
|
||||
"reward_sla": 2,
|
||||
"folder_block_delay_sec": 3,
|
||||
"auto_unlock_sec": 30,
|
||||
"auto_unlock_money": -30,
|
||||
"auto_unlock_sla": -10,
|
||||
"tell": "расширение .exe, красный квадрат 4×4",
|
||||
"effect": "файл становится locked (lock_until), кликабелен для уничтожения (D08)"
|
||||
}
|
||||
},
|
||||
"shop": [
|
||||
{ "id": "shop_slot3", "name_ru": "Третий слот", "effect": "connection_slots", "value": 1, "price": 250, "max_level": 1 },
|
||||
{ "id": "shop_slot4", "name_ru": "Четвёртый слот", "effect": "connection_slots", "value": 1, "price": 450, "max_level": 1 },
|
||||
{ "id": "shop_buffer", "name_ru": "Буфер +2", "effect": "buffer_slots", "value": 2, "price": 200, "max_level": 2 },
|
||||
{ "id": "shop_timer", "name_ru": "Таймер пакетов +4 с", "effect": "packet_timer", "value": 4, "unit": "сек (аддитивно)", "price": 350, "max_level": 3 },
|
||||
{ "id": "shop_zone", "name_ru": "Зона +5%", "effect": "zone_width", "value": 0.05, "unit": "доля W (аддитивно в формуле D04)", "price": 300, "max_level": 3 },
|
||||
{ "id": "shop_channel", "name_ru": "Канал +25", "effect": "channel_capacity", "value": 25, "price": 250, "max_level": 4 },
|
||||
{ "id": "shop_section", "name_ru": "Новая секция", "effect": "sections", "value": 1, "price": 300, "max_level": 5 },
|
||||
{ "id": "shop_search", "name_ru": "Поиск", "effect": "search_highlight", "value": 1, "price": 250, "max_level": 1 },
|
||||
{ "id": "shop_shield", "name_ru": "Щит", "effect": "shield", "value": 1, "price": 400, "max_level": 1 },
|
||||
{ "id": "shop_sla", "name_ru": "SLA +24", "effect": "sla_add", "value": 24, "price": 300, "max_level": 3 }
|
||||
],
|
||||
"shop_reserve": {
|
||||
"items": ["scanner", "autosort"],
|
||||
"note": "резерв — в прототип НЕ входят (D13); позиций в shop НЕТ"
|
||||
},
|
||||
"spawn": {
|
||||
"type_pool_by_hour": {
|
||||
"1": ["text"],
|
||||
"2": ["text", "sound"],
|
||||
"3+": ["text", "sound", "photo", "video"]
|
||||
},
|
||||
"malicious_ratio": 0.0,
|
||||
"required_count_by_hour": {
|
||||
"1": { "1": 1.0 },
|
||||
"2": { "1": 0.75, "2": 0.25 },
|
||||
"3+": { "1": 0.6, "2": 0.3, "3": 0.1 }
|
||||
},
|
||||
"required_size_distribution": { "1": 0.6, "2": 0.3, "3": 0.1 },
|
||||
"required_count_note": "required_count_by_hour — распределение по часу; required_size_distribution — fallback/час 3+",
|
||||
"attack_schedule": "с часа 2, далее по difficulty.attacks, окно 60–240 с часа, не во время мини-игры"
|
||||
"CHANNEL_CAPACITY": 10,
|
||||
"capacity_per_upgrade": 6,
|
||||
"capacity_upgrade_max_level": 6,
|
||||
"formula": "load = sum(weight(active_transfers)); factor = max(1.0, load / capacity); actual_time = base_time_sec * pow(0.90, speed_level) * factor"
|
||||
},
|
||||
"minigame_selection": {
|
||||
"coeff": { "text": 1.0, "sound": 2.0, "photo": 3.0, "video": 4.0 },
|
||||
"difficulty_formula": "sum(coeff(required)) / n",
|
||||
"thresholds": { "text": "< 1.5", "sound": "< 2.5", "photo": "< 3.5", "else": "video" },
|
||||
"call": "MiniGame.start(type, difficulty)",
|
||||
"coeff": { "text": 1, "sound": 2, "photo": 4, "video": 8 },
|
||||
"formula": "см. minigames.selection.formula (27 достижимых сумм для 1-4 файлов, 3 ступени, rank_games)",
|
||||
"quality_stub": 1.0
|
||||
}
|
||||
}
|
||||
@@ -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": {
|
||||
|
||||
@@ -1,15 +1,38 @@
|
||||
# DECISIONS — решения v1.2
|
||||
# DECISIONS — решения v3.0
|
||||
|
||||
> Каждое решение: ID, что решили, что меняется в GDD. Дедлайн
|
||||
> 26.10.2026 23:59 (Minsk). Источник правил — `docs/GDD.md`.
|
||||
> **v1.2 (25.10.2026):** D01–D17 сохранены; добавлены D18–D25. Если D19–D24
|
||||
> меняют числа старых решений, это указано в примечании к D12/D13/D16.
|
||||
> 26.10.2026 23:59 (Minsk). Источник правил — `docs/GDD.md` (v3.0).
|
||||
>
|
||||
> **v3.0 (26.10.2026):** актуальны решения **D45–D61**. Решения **D01–D44**
|
||||
> сохранены как история; конфликтующие с v3.0 помечены пометкой
|
||||
> «**Статус v3.0: история**» или «**заменено**» с указанием замены. Ничего не
|
||||
> удалено. D53–D56 — уточнения v3.0: TCP-соединение как сущность, idle-слот
|
||||
> после отправки, UDP-риск и `lot_udp`, новые шрифты. D57–D61 — текущая волна:
|
||||
> длительность часов, файлы-каталог и темп, запросы без `request_cap`, окно
|
||||
> атак и визуал «ЖАРА».
|
||||
>
|
||||
> **Сдвиг ID:** диапазон D26–D28 занят решениями v1.2 (аудит, VHS, ambient),
|
||||
> D29–D44 — решения v2.0, D45–D52 — решения v3.0, D53–D56 — уточнения v3.0
|
||||
> после проверки реализации, D57–D61 — баланс темпа и UI «ЖАРА».
|
||||
>
|
||||
> **Где смотреть актуальные числа:** GDD v3.0 и `data/balance.json`; этот файл
|
||||
> хранит историю формулировок. Прежние суммы дерева, кап SLA, число файлов,
|
||||
> состав игр отправки и множитель скорости в телах D01–D52 — **история
|
||||
> v1/v2/v3.0**, а не текущая спецификация. Замены: D45–D61.
|
||||
>
|
||||
> **Статус решений v1.x в v3.0 (кратко):** D01→D46, D02→D33/D46,
|
||||
> D03→D34/D38, D04→D45, D05→D29/D46, D06→D46, D07→D38/D39, D08→D38,
|
||||
> D09→D46, D10→D34/D45, D12→D29/D40/D50, D13→D50/D37, D15→D56, D16→D43,
|
||||
> D17→D34/D49, D19→D29/D50, D20→D29/D50, D21→D29, D22→D33/D45, D23→D29/D38,
|
||||
> D25→D43. D11, D14, D18, D24 (кроме магазина), D27, D28 остаются в силе.
|
||||
|
||||
## D01 — карточка запроса
|
||||
**Решение:** в карточке запроса показывать имя требуемого файла
|
||||
(до 12 символов) и его папку, а не только иконку типа. `required` —
|
||||
конкретные id файлов, не фильтры по типу.
|
||||
**GDD:** §7 (нужные файлы — по имени+папке, привязка по id).
|
||||
**Статус v2.0: устарело.** В v2 `required` — это 1–3 слота контейнера, файлы
|
||||
выбирает игрок; конкретные id не зашиты (D32, GDD §7).
|
||||
|
||||
## D02 — расход/удаление файлов и склад
|
||||
**Решение:** файлы **не расходуются** при отправке, но после успешной
|
||||
@@ -19,6 +42,10 @@
|
||||
поражением, но пройденные 30+ часов засчитываются; в итоговом
|
||||
отчёте это указывается явно.
|
||||
**GDD:** §3, §5, §16.
|
||||
**Статус v2.0: устарело.** Файлы **расходуются** при успешной отправке
|
||||
(cooldown 0); при отмене/таймауте сохраняются (D33). «Конец забега по
|
||||
складу» не используется: поражение только по SLA (D40, GDD §16). Удаление
|
||||
неприкреплённого файла в подготовке — без штрафа (D44).
|
||||
|
||||
## D03 — заморозка таймеров модалками
|
||||
**Решение:** пока открыта модалка мини-игры или панель соединения —
|
||||
@@ -26,6 +53,9 @@
|
||||
активного запроса продолжает идти; при полном буфере новые пакеты
|
||||
не спавнятся.
|
||||
**GDD:** §6, §8, §8.4.
|
||||
**Статус v2.0: частично устарело.** Мини-игра отправки не ставит час на
|
||||
паузу: она морозит спавн/загрузку, спавн запросов и таймер своего запроса;
|
||||
другие таймеры идут. Мини-игра атаки ставит на паузу весь час (D34/D38).
|
||||
|
||||
## D04 — полоска соединения
|
||||
**Решение:** `W = clamp(0.30 − 0.005×(hour−1) − 0.005×successes
|
||||
@@ -33,6 +63,11 @@
|
||||
440/340/280 px/с; окна попадания ~130–200 мс; апгрейд зоны применяется
|
||||
внутри формулы, cap 0.36.
|
||||
**GDD:** §11.
|
||||
**Статус v2.0: устарело.** Полоска соединения удалена. TCP/UDP — переключатель
|
||||
оплаты/скорости: TCP ×1.15 к выплате, UDP ×0.8 ко времени (D33, GDD §11).
|
||||
**Статус v3.0: формула снова актуальна.** TCP-соединение вернулось (D45):
|
||||
та же формула зоны, скорости 440/340/280, 3 попадания; промах −4 SLA / 8 с,
|
||||
Esc — 0 SLA / 4 с. UDP соединения не требует.
|
||||
|
||||
## D05 — один источник истины
|
||||
**Решение:** буфер = `4 + 2×buffer_upgrades` (max 8); зона — только
|
||||
@@ -40,18 +75,30 @@
|
||||
(`balance.json → difficulty`); в окне «Отправка» максимум 4 видимых
|
||||
карточки; при 5-м запросе — скролл и высота карточек 56 px.
|
||||
**GDD:** §6, §7, §11.
|
||||
**Статус v2.0: устарело в числах.** Буфер = 6 + уровень `lot_channel`, max 12;
|
||||
`request_cap` берётся из `hours[]` (2/5/5/6/6/7/7/7/8), а не из legacy
|
||||
`difficulty`. Правило «4 карточки, дальше скролл, высота 56 px» остаётся
|
||||
(D29, GDD §6–7).
|
||||
|
||||
## D06 — файл в нескольких запросах
|
||||
**Решение:** файл может входить в `required` нескольких запросов, но
|
||||
`attached` — только в одном; второй запрос показывает файл серым.
|
||||
При `failed` файла все запросы, где он `required`, отменяются со штрафом.
|
||||
**GDD:** §7.
|
||||
**Статус v2.0: частично устарело.** Файл по-прежнему прикрепляется только к
|
||||
одному запросу; но `required` больше не содержит конкретных id, поэтому
|
||||
правило «все запросы, где он `required`» не применяется. При `locked`/
|
||||
`failed`/`malicious` файл просто открепляется (D32).
|
||||
|
||||
## D07 — атаки вне буфера и единая таблица штрафов
|
||||
**Решение:** атаки не занимают буфер и не вытесняются ими; отдельный
|
||||
слой карточек атак. Единая таблица урона: мусор −5 SLA, DDoS −15,
|
||||
фишинг −10, шифровальщик −10 (вместо глобального −15).
|
||||
**GDD:** §12.
|
||||
**Статус v2.0: устарело.** Карточек/оверлеев атак больше нет: каждая атака —
|
||||
мини-игра, которая ставит час на паузу. Урон/награда — по
|
||||
`attacks.kinds.*.tiers`, плюс эффекты DDoS/фишинга/шифровальщика
|
||||
(D38/D39, GDD §12.1).
|
||||
|
||||
## D08 — видимые теллы
|
||||
**Решение:** у пакета показывать расширение файла; у шифровальщика —
|
||||
@@ -59,6 +106,8 @@
|
||||
символом (например `id_7F3К`, кириллическая К) и серый аватар.
|
||||
`locked`/malicious файл остаётся кликабельным для уничтожения.
|
||||
**GDD:** §20.
|
||||
**Статус v2.0: устарело.** Зловредных пакетов/фишинговых запросов и их
|
||||
теллов нет; атаки вынесены в отдельные мини-игры (D38, GDD §12.1, §20).
|
||||
|
||||
## D09 — состояния-флаги
|
||||
**Решение:** состояния — флаги, а не один enum:
|
||||
@@ -68,12 +117,23 @@
|
||||
Таблица переходов при истечении таймера (загрузка/соединение/мини-игра)
|
||||
— в `docs/IMPLEMENTATION.md`.
|
||||
**GDD:** §17.
|
||||
**Статус v2.0: обновлено.** Добавлены `table` и `sending`; `connected` —
|
||||
legacy и всегда `true` (полоски нет). Резервации приёма живут в папке, а не
|
||||
в запросе (D31/D32, GDD §17).
|
||||
**Статус v3.0: обновлено.** `connected` снова рабочий флаг TCP; добавлены
|
||||
`connecting`, `connect_attempts`, `connect_cooldown_until`, `required_by`
|
||||
(D45/D46, GDD §17).
|
||||
|
||||
## D10 — поведение Esc
|
||||
**Решение:** в час — пауза; в мини-игре — no-op; в панели соединения —
|
||||
отмена (слот освобождается, запрос остаётся); закрытие модалок — только
|
||||
кнопкой или таймаутом.
|
||||
**GDD:** §18.
|
||||
**Статус v2.0: частично устарело.** Панели соединения нет. В мини-игре
|
||||
отправки `Esc` во время игры — no-op; в мини-игре атаки — двухшаговое
|
||||
подтверждение `СДАТЬСЯ`; на экране результата — `ПРОДОЛЖИТЬ` (D34, GDD §18).
|
||||
**Статус v3.0: обновлено.** TCP-панель вернулась: `Esc` отменяет соединение
|
||||
без штрафа SLA, с кулдауном 4 с (D45, GDD §11.1, §18).
|
||||
|
||||
## D11 — устойчивость к лагам
|
||||
**Решение:** `delta = min(delta, 0.1)`; авто-пауза при
|
||||
@@ -89,6 +149,10 @@
|
||||
значения, но +5 % к скорости спавна каждые 2 часа.
|
||||
**GDD:** §12, §13, §23.
|
||||
**v1.2:** старт SLA — 128/200; штраф за потерю пакета — по типу (D19).
|
||||
**Статус v2.0: устарело.** Доход — цели 65/220/380/560/760/980/1200/1450/
|
||||
1700; старт SLA 60/60; пассивных +0.5/+1 нет, есть +15 в начале часа и
|
||||
награды атак. «Полный выкуп к H8–H9» не подтверждён (56,7–57,9 % дерева в v3).
|
||||
Штраф `−(10+2×hour)` сохранён (D29/D40/D50, GDD §12–13).
|
||||
|
||||
## D13 — магазин
|
||||
**Решение:** 9 позиций: 3-й слот 250, 4-й слот 450, буфер +2 — 200×2,
|
||||
@@ -97,9 +161,12 @@
|
||||
резерв (вырезаны из прототипа).
|
||||
**GDD:** §14.
|
||||
**v1.2:** добавлена 10-я позиция «SLA +24» (300, max 3); открытия — D23.
|
||||
**Статус v2.0: устарело.** 8 лотов, 3 оффера, рероллы и замки; зоны/секции/
|
||||
поиска в магазине нет. В v3.1 — **9 лотов** и дерево **8 825 $** (D50, D55);
|
||||
прежние числа v1/v2 — история.
|
||||
|
||||
## D14 — фон
|
||||
**Решение:** фон — статичный градиент + слабый шум 320×180,
|
||||
**Решение:** шейдер фона — статичный градиент + слабый шум 320×180,
|
||||
без полноэкранного fbm; CRT-эффект убрать.
|
||||
**GDD:** §21.
|
||||
**v1.2:** фон — тёплый бумажный дизеринг/панели, см. D18.
|
||||
@@ -115,11 +182,18 @@ Press Start 2P остаётся только для крупных заголо
|
||||
Допустимые кегли: **10 / 12 / 16 / 24** (Handjet узкий, поэтому 10
|
||||
разрешён).
|
||||
**GDD:** §21.
|
||||
**Статус v3.0: заменено D51.** Основной шрифт — **Golos Text**, шкала
|
||||
**11/13/17/26**; Press Start 2P — только логотип и цифры; Handjet — legacy.
|
||||
**Статус v3.1:** заменено D56 — тело на Kechusilka, крупный текст на
|
||||
DisplayOTF, Golos Text остаётся fallback.
|
||||
|
||||
## D16 — порядок P0
|
||||
**Решение:** порядок P0: данные/реестр → слоты → приём/отправка → UI.
|
||||
**GDD:** §23.
|
||||
**v1.2:** календарь открытий — D23; в часе 1 атак нет.
|
||||
**Статус v2.0: исторический.** Актуальная структура — `main.gd` +
|
||||
`game_controller.gd` + `core/` + `minigames/`; порядок работ и проверки —
|
||||
D43, GDD §23.
|
||||
|
||||
## D17 — quality/retry
|
||||
**Решение:** заглушка качества возвращает 1.0; кнопка «Переделать»
|
||||
@@ -130,6 +204,9 @@ Press Start 2P остаётся только для крупных заголо
|
||||
`difficulty = sum(coeff)/n`, пороги `<1.5 text, <2.5 sound,
|
||||
<3.5 photo, иначе video`; `MiniGame.start(type, difficulty)`.
|
||||
**GDD:** §4, §8.4, §10.
|
||||
**Статус v2.0: устарело.** Заглушки качества и кнопки «Переделать» нет:
|
||||
`q` даёт мини-игра. Выбор игры — по плотному списку достижимых сумм и
|
||||
коэффициентам 1/2/4/8 (D34/D35, GDD §8.4, §10).
|
||||
|
||||
|
||||
## D18 — UI v2 «Kaleidos»
|
||||
@@ -149,6 +226,9 @@ Press Start 2P остаётся только для крупных заголо
|
||||
10-я позиция **«SLA +24»** — 300, max 3 уровня. Остальные 9 позиций D13
|
||||
сохраняются; итого 10.
|
||||
**GDD:** §4, §12, §14.
|
||||
**Статус v2.0: устарело.** Старт SLA 60/60; лота «SLA +24» нет, кап в v3
|
||||
растёт на +40 за уровень `lot_sla` до 220. Потеря пакета по типу сохранена
|
||||
(D29/D40/D50, GDD §12).
|
||||
|
||||
## D20 — таймеры и grace
|
||||
|
||||
@@ -159,6 +239,8 @@ h2 26.325/39.825, h3 22.8/34.8, h4 18.5/28.5, h5 18/28, далее
|
||||
плавно до h9 16/26. Апгрейд «Таймер пакетов +4 с» даёт чистые +4 с
|
||||
поверх grace.
|
||||
**GDD:** §8.
|
||||
**Статус v2.0: устарело.** Grace и per-hour scaling отменены: пакет 20 с,
|
||||
запрос 30 + 5×уровень (max 55) (D29, GDD §8).
|
||||
|
||||
## D21 — канал v1.2
|
||||
|
||||
@@ -167,6 +249,8 @@ h2 26.325/39.825, h3 22.8/34.8, h4 18.5/28.5, h5 18/28, далее
|
||||
слоты не грузят; idle = 0 %. `capacity = 80 + 25×channel_level`,
|
||||
`factor = max(1.0, load / capacity)`, `actual_time = base_time × factor`.
|
||||
**GDD:** §9.
|
||||
**Статус v2.0: устарело в числах.** `capacity = 10 + 6×level` (10…46);
|
||||
формула нагрузки и idle=0 сохранены (D29, GDD §9).
|
||||
|
||||
## D22 — TCP-соединение
|
||||
|
||||
@@ -175,6 +259,8 @@ h2 26.325/39.825, h3 22.8/34.8, h4 18.5/28.5, h5 18/28, далее
|
||||
после успешного прохождения; при промахе слот освобождается, запрос
|
||||
остаётся. `Esc` — отмена с тем же результатом.
|
||||
**GDD:** §11, §17.
|
||||
**Статус v2.0: устарело.** Полоски/панели соединения нет; `connected` всегда
|
||||
`true`. TCP/UDP — переключатель выплаты/времени (D33, GDD §11).
|
||||
|
||||
## D23 — открытия и мета
|
||||
|
||||
@@ -188,6 +274,11 @@ h2 26.325/39.825, h3 22.8/34.8, h4 18.5/28.5, h5 18/28, далее
|
||||
(`difficulty.attacks = 0,1,1,2,2,3,3,4,4`). `minigame_advanced` — резерв
|
||||
под будущие мини-игры, контента пока нет.
|
||||
**GDD:** §3, §10, §15, §23.
|
||||
**Статус v2.0: обновлено.** Магазин с H2 и мета 3/5/9 сохранены. Типы H1
|
||||
text/sound, photo с H2, video с H3. Атаки: junk/ddos с H2, phishing с H4,
|
||||
ransomware с H5 (по `weight_by_hour`); `minigame_advanced` по-прежнему
|
||||
резерв (D29/D38, GDD §3, §14). Мета-ключ `search` остаётся в данных, но
|
||||
UI-поиск в v2 не подключён — как готовая фича не документируется.
|
||||
|
||||
## D24 — UX и читаемость
|
||||
|
||||
@@ -202,6 +293,8 @@ h2 26.325/39.825, h3 22.8/34.8, h4 18.5/28.5, h5 18/28, далее
|
||||
Маскот **БАЙТ** ведёт короткий туториал первого часа. Esc на итогах забега
|
||||
ведёт в главное меню (не soft-lock).
|
||||
**GDD:** §18, §19, §21.
|
||||
**Статус v2.0: частично.** Поле поиска не подключено (см. D23), остальное —
|
||||
имена, уничтожение папки, магазин без крестика, calm-фон — в силе.
|
||||
|
||||
## D25 — проверки и MR-ревью
|
||||
|
||||
@@ -211,6 +304,9 @@ OpenCode-ревью агентом `oracle` на модели
|
||||
`opencode-go/deepseek-v4.1-flash`: dry-run 74–90 с, комментарий с
|
||||
Blockers/High/Medium/Test gaps/Verdict; бот не мержит.
|
||||
**GDD:** §23, §25.
|
||||
**Статус v2.0: устарело в числах.** Актуальный прогон — Windows/Linux
|
||||
раннеры, **25 тестовых наборов + parse-check (26/26 PASS)**; баланс —
|
||||
`python tests/sim_balance.py` (D43, GDD §23).
|
||||
|
||||
## D26 — фиксы аудита v1.2
|
||||
|
||||
@@ -227,15 +323,17 @@ Blockers/High/Medium/Test gaps/Verdict; бот не мержит.
|
||||
на слот запускает соединение, тост покупки с названием, честный статус
|
||||
сейва, fallback `SLA +24`.
|
||||
- `minigame_advanced` — зарезервированный unlock, контента пока нет.
|
||||
**Статус v2.0: частично устарело.** Пункты про TCP-прогресс, drag на слот и
|
||||
fallback `SLA +24` неактуальны: соединения и лота SLA+24 нет. Остальное
|
||||
(calm-фон, Esc, уничтожение папки, мета, H1 без атак) сохранено.
|
||||
|
||||
## D27 — VHS главного меню
|
||||
|
||||
**Решение:** VHS-слой только на главном меню: процедурный
|
||||
`scripts/ui/vhs_overlay.gd` (`extends RefCounted`), рисуется в
|
||||
`MainMenu._draw` до текста. Скан-линии, слабая хроматика, зерно, виньетка и
|
||||
полоса; без shader/screen texture, ввод не блокирует, текст резкий.
|
||||
Галочка «VHS-эффект» — в настройках, группа «ЭКРАН»; HEADLESS не рендерит.
|
||||
Карточка/кнопки меню под фильтром, модалки — нет; это не CRT на весь UI.
|
||||
**Решение:** VHS-слой только на главном меню: `shaders/vhs.gdshader` +
|
||||
`scripts/ui/vhs_overlay.gd`, screen texture 1920, скан-линии 2 физ. px,
|
||||
слабая хроматика, зерно, виньетка и полоса. Галочка «VHS-эффект» — в
|
||||
настройках, группа «ЭКРАН»; HEADLESS не рендерит. Карточка/кнопки меню под
|
||||
фильтром, модалки — нет; это не CRT на весь UI.
|
||||
**GDD:** §21, §25.
|
||||
|
||||
## D28 — ambient machine room
|
||||
@@ -245,3 +343,556 @@ Blockers/High/Medium/Test gaps/Verdict; бот не мержит.
|
||||
`scripts/autoload/ambient.gd`. Стартует после первого жеста (web autoplay),
|
||||
поддерживает mute, паузу и громкость музыки. `test_ambient` — 74/0.
|
||||
**GDD:** §22, §25.
|
||||
|
||||
---
|
||||
|
||||
# Решения v2.0 (D29–D44)
|
||||
|
||||
## D29 — стартовые статы и формулы
|
||||
|
||||
**Решение:** забег начинается с **50 $** и **SLA 60/60**. Кап SLA —
|
||||
`60 + 40×sla_level`, жёсткий максимум **220**. Слоты — `1 + slot_level`
|
||||
(1…5), канал — `10 + 6×channel_level` (10…46, он же даёт буфер
|
||||
`6 + уровень`, 6…12), таймер запроса — `30 + 7×timer_level` (30…65),
|
||||
таймер пакета — фиксированные **20 с**. Сетка папки —
|
||||
`[5×4, 6×4, 7×5, 8×5, 8×6, 9×6]`. На старте 2 папки, максимум 6, новая —
|
||||
**180 $**. В начале часа SLA +15 и заряды щита восстанавливаются.
|
||||
**GDD:** §4, §6, §8, §9, §12, §14.
|
||||
**Заменяет:** числа D05, D12, D19, D20, D21.
|
||||
**Статус v3.0: обновлено.** Числа приведены к v3 (D50): SLA-кап 220,
|
||||
таймер +7 с до 65, сетки 5×4 → 9×6, новая папка 180 $.
|
||||
|
||||
## D30 — сетки папок и файлы-полимино
|
||||
|
||||
**Решение:** каждая папка — клеточная сетка; файл — полимино: текст 1,
|
||||
звук 2, фото 3, видео 4 клетки. Формы — `grid.shapes` в `data/balance.json`.
|
||||
Поворот — `R` или ПКМ во время drag (0–3). Точное размещение проверяет
|
||||
пересечения и границы; auto-fit идёт first-fit по строкам/столбцам. Подпись
|
||||
рисуется в **верхней левой занятой** клетке, а не в пустом углу bbox
|
||||
(S/Z-формы). Перемещать `attached`/`locked`/`failed`/`malicious` нельзя.
|
||||
**GDD:** §5, §20.
|
||||
**Заменяет:** плоские «слоты файлов» v1.
|
||||
|
||||
## D31 — приём, резервации и стол сортировки
|
||||
|
||||
**Решение:** приём резервирует клетки фигуры в папке на время загрузки;
|
||||
отмена/таймаут освобождает резервацию. Полный буфер замораживает спавн, но
|
||||
не сжигает бюджет часа. Стол сортировки — отдельная сетка **4×6**, доступен
|
||||
в подготовке. Файл на столе нельзя прикрепить к запросу. На старте часа
|
||||
стол автоматически возвращает файлы в папки; если хотя бы один не влез —
|
||||
час не начинается, игрок освобождает место. Позиции на столе входят в сейв.
|
||||
**GDD:** §3, §5–7, §15.
|
||||
**Обновляет:** D03, D05.
|
||||
|
||||
## D32 — запросы и прикрепление
|
||||
|
||||
**Решение:** запрос требует до трёх файлов (слоты контейнера), конкретные id не
|
||||
зашиты — игрок сам выбирает файлы. Файл прикреплён только к одному запросу;
|
||||
со стола не прикрепляется. При `locked`/`failed`/`malicious` файл
|
||||
открепляется, контейнер нужно собрать снова. Таймер запроса замирает в его
|
||||
мини-игре, в очереди out-слота и во время передачи; остальные таймеры идут.
|
||||
`ОТКЛОНИТЬ` — −5 $/−5 SLA.
|
||||
**GDD:** §7, §8, §17.
|
||||
**Заменяет:** D01, D06; обновляет D09.
|
||||
**Статус v3.0: заменено D46.** Запрос снова требует конкретные `file_id`,
|
||||
но их **1–4**; имена видны на карточке, есть «ПОТЕРЯН» и жёсткая резервация.
|
||||
|
||||
## D33 — отправка, расход файлов и TCP/UDP
|
||||
|
||||
**Решение:** отправка — только по явной кнопке `ОТПРАВИТЬ`; прикрепление и
|
||||
закрытие мини-игры автостарт не запускают. TCP/UDP переключаются до старта:
|
||||
TCP — выплата ×1.15, время ×1.0; UDP — время ×0.8, выплата **×0.85**. После
|
||||
мини-игры запрос занимает out-слот или встаёт в очередь «ОЖИДАНИЕ СЛОТА»
|
||||
(таймер заморожен), пока слот не освободится. При успешной отправке файлы
|
||||
**расходуются**; при отмене/таймауте/конце часа сохраняются. Полоска
|
||||
соединения в v2 была удалена; в v3 TCP-соединение вернулось (D45), а
|
||||
`connected` снова рабочий флаг.
|
||||
**GDD:** §7, §11, §17.
|
||||
**Заменяет:** D02 (расход), D04, D22.
|
||||
**Статус v3.0: заменено D45.** TCP снова требует полоску соединения; UDP
|
||||
шлёт сразу. Выплата UDP — **×0.85** (не ×1.0), TCP — ×1.15, время UDP ×0.8.
|
||||
|
||||
## D34 — мини-игры: состав и контракт
|
||||
|
||||
**Решение:** 11 игр — семь отправки (включая удалённую в v3 игру с проводами)
|
||||
и 4 атаки (`junk`, `ddos`, `phishing`, `ransomware`). Общий
|
||||
контракт: без таймеров, конечная цель, локальный seeded RNG, `q ∈ 0…1`,
|
||||
экран результата `q%`/вердикт и `ПРОДОЛЖИТЬ`; `finished` эмитится только
|
||||
после `ПРОДОЛЖИТЬ` в модальном режиме (награда не применяется раньше).
|
||||
`СДАТЬСЯ` (атаки) / `ПРОПУСТИТЬ` (отправки) с подтверждением даёт `q=0`.
|
||||
Формулы `q` — GDD §10.
|
||||
`Esc` во время игры отправки — no-op, в игре атаки — двухшаговое `СДАТЬСЯ`;
|
||||
на результате — продолжить.
|
||||
**GDD:** §10, §18.
|
||||
**Заменяет:** D17 (заглушка q), D08 (теллы).
|
||||
**Примечание:** строка `minigame.surrender` (`СДАТЬСЯ`) используется
|
||||
мини-играми атак; у мини-игр отправки кнопка — `ПРОПУСТИТЬ`.
|
||||
**Статус v3.0: заменено D47/D48.** Сейчас **10** игр: 6 отправки + 4 атаки;
|
||||
игры с соединением проводов нет; у отправки 3 ступени. Общий контракт
|
||||
(без таймеров, seeded RNG, отложенный `finished`) сохранён и дополнен шапкой
|
||||
«название + цель», полосой прогресса и двухкликовым отказом (GDD §10).
|
||||
|
||||
## D35 — выбор мини-игры отправки (B4)
|
||||
|
||||
**Решение:** коэффициенты `text 1 / sound 2 / photo 4 / video 8`;
|
||||
`S = sum(coeff)` по трём файлам максимум. `rank` — позиция S в плотном списке
|
||||
`[1…14,16,17,18,20,24]` (или ближайшая меньшая), `tier = clamp(1 +
|
||||
floor(rank×4/19), 1, 4)`, `id = order[rank % 7]`, где order — семь игр v2
|
||||
(включая удалённую в v3 игру с проводами). Суммы 15/19/21/22/23 недостижимы,
|
||||
поэтому 28-элементный массив не перечисляется. Все 7 игр и 4 ступени были
|
||||
достижимы; `S=24 → video/T4` (старый пример S−1 отменён).
|
||||
**GDD:** §8.4, §10.
|
||||
**Заменяет:** D17 (средний коэффициент/пороги).
|
||||
**Статус v3.0: история.** Актуальная формула — **27 достижимых сумм для 1–4
|
||||
файлов**, 3 ступени и фиксированная таблица `rank_games` (D49, GDD §8.4).
|
||||
Старые «19 сумм / 4 ступени / rank % 7» больше не действуют.
|
||||
|
||||
## D36 — магазин: 8 лотов и 3 оффера
|
||||
|
||||
**Решение:** 8 лотов (`lot_slots`, `lot_speed`, `lot_sla`, `lot_shield`,
|
||||
`lot_money`, `lot_folder`, `lot_channel`, `lot_timer`), ровно **3**
|
||||
предложения в подготовке. Магазин — только в подготовке с H2, в H1 и во
|
||||
время часа недоступен. Максимальные лоты не предлагаются. Сумма всех
|
||||
уровней v2 — историческая; в v3 дерево стоит **8 085 $** (D50). Крестика нет:
|
||||
`ЗАКРЫТЬ`/`Esc`.
|
||||
**GDD:** §3, §14.
|
||||
**Заменяет:** D13, D19 (10 позиций).
|
||||
**Статус v3.0: история в числах.** 8 лотов и 3 оффера сохранены, но цены,
|
||||
эффекты и сумма дерева заменены D50 (**8 085 $**); v3.1 добавляет девятый
|
||||
лот `lot_udp` — актуально **9 лотов / 8 825 $** (D55).
|
||||
|
||||
## D37 — рероллы, замки, веса и сохранение офферов
|
||||
|
||||
**Решение:** реролл стоит **40/80/120 $**, максимум **3** за подготовку;
|
||||
замок отдельного предложения сохраняется при реролле. Вес =
|
||||
`weight_by_hour[hour−1] × 0.5` (был в прошлой выдаче) `× 1.5` (pity, ≥2
|
||||
часов без предложения). По возможности в выдаче есть хотя бы один
|
||||
доступный лот. Офферы, рероллы, `last_seen` и замки сохраняются в сейве и
|
||||
восстанавливаются при загрузке; счётчик/замки сбрасываются на новой
|
||||
подготовке.
|
||||
**GDD:** §14, §15.
|
||||
**Обновляет:** D23 (открытия), D24 (магазин без крестика).
|
||||
|
||||
## D38 — атаки v2
|
||||
|
||||
**Решение:** 20 атак за забег: `[0,1,1,2,2,3,3,4,4]` для H1–H9, время —
|
||||
случайное в окне **60–240 с**. Виды: `junk`, `ddos`, `phishing`,
|
||||
`ransomware`; доступность по `weight_by_hour` (junk/ddos с H2, phishing с
|
||||
H4, ransomware с H5). Ступень: H2–3 T1, H4–5 T2, H6–7 T3, H8–9 T4
|
||||
(`clamp(1+floor((hour−2)/2),1,4)`). Каждая атака — мини-игра, которая
|
||||
ставит на паузу **весь час**; карточек/пакетов/запросов атак нет.
|
||||
**GDD:** §3, §10, §12.1.
|
||||
**Заменяет:** D07, D08, D19 (часть).
|
||||
|
||||
## D39 — щит, награды и последствия провала
|
||||
|
||||
**Решение:** заряды щита = уровень `lot_shield` (макс. 3), восстанавливаются
|
||||
в начале часа. Заряд тратится **до** открытия мини-игры: атака считается
|
||||
отражённой, награда **50 %**, штрафа нет. Деньги наград/штрафов
|
||||
масштабируются по часу: `max(0.75, hour/3) × attacks.money_scale`. Провал:
|
||||
DDoS — канал −40 % (factor ×1.4) на 15 с; фишинг — случайный файл
|
||||
заблокирован на 60 с и отправка недоступна 5 с; ransomware — случайная
|
||||
папка заблокирована на 15 с. SLA/деньги — из `attacks.kinds.*.tiers`.
|
||||
**GDD:** §9, §12, §12.1.
|
||||
**Заменяет:** D07 (таблица урона).
|
||||
**Статус v3.0: история в числах.** Щит теперь даёт **70 %** награды (D50);
|
||||
остальные эффекты провала сохранены.
|
||||
|
||||
## D40 — SLA, реген и поражение
|
||||
|
||||
**Решение:** старт 60/60; в начале часа +15 (не выше капа), кап рос на
|
||||
+35 за `lot_sla` до исторического максимума v2 (в v3 — +40 до 220, D50).
|
||||
Потеря пакета — 2/4/8/16 по типу; таймаут запроса
|
||||
и провал запросов в конце часа — `−(10+2×hour)`; отклонение — −5/−5;
|
||||
уничтожение папки — −5 SLA. Пассивных +0.5/+1 нет. **SLA ≤ 0 во время часа
|
||||
= поражение**; «конца забега по складу» нет.
|
||||
**GDD:** §12, §16.
|
||||
**Заменяет:** D12 (SLA-прирост), D19 (старт), D02 (склад).
|
||||
**Статус v3.0: история в числах.** Кап SLA — **220** (+40 за уровень), не 200;
|
||||
см. D50.
|
||||
|
||||
## D41 — save v2 и миграция
|
||||
|
||||
**Решение:** `SaveManager.VERSION = 2`, поддерживаются 1 и 2. Позиции
|
||||
хранятся **только** в `folder.files[]` и `table[]`; в `files{}` — метаданные.
|
||||
Сейв включает деньги/SLA/час/фазу/streak/stats/upgrades/мету/RNG/реестр/
|
||||
офферы/рероллы/замки/`last_seen`/заряды щита/час SLA-регена. Запись
|
||||
атомарная (`save.tmp` → rename), в Web — `force_fs_sync()`. v1 мигрирует
|
||||
без уменьшения сеток и без потери файлов; лишние файлы уходят на стол или в
|
||||
`registry`.
|
||||
**GDD:** §15, §17.
|
||||
**Заменяет:** старую схему слотов v1.
|
||||
|
||||
## D42 — баланс v2
|
||||
|
||||
**Решение:** цели дохода H1–H9 — **150/220/380/560/760/980/1200/1450/
|
||||
1700 $**. `request_cap`, параметры симулятора и расход магазина в v2 были
|
||||
зафиксированы отдельно; в v3 они заменены (D50, GDD §13). В прогоне v2 все
|
||||
часы укладывались в ±20 %, но расход магазина оставался ниже целевой полосы
|
||||
55–65 % — это было зафиксировано честно, а не задекларировано как «полный
|
||||
выкуп».
|
||||
**GDD:** §13, §14.
|
||||
**Статус v3.0: история.** В отчёте v3 — **7 354 $**, **56,7 %** дерева
|
||||
**8 085 $**, **32/240** поражений; после H1-правки свежий прогон — **7 533 $**,
|
||||
**57,9 %**, **28/240**; см. D50 и `tests/SIM_BALANCE_REPORT.md`. В v3.1 дерево
|
||||
выросло до **8 825 $** (D55), отчёт требует перегенерации.
|
||||
|
||||
## D43 — проверки, запуск и Web
|
||||
|
||||
**Решение:** единый прогон:
|
||||
`powershell -ExecutionPolicy Bypass -File scripts/run_all_tests.ps1`
|
||||
(Windows) или `bash scripts/run_all_tests.sh` (Linux/macOS). Наборы —
|
||||
**25 тестовых наборов + parse-check**; исторический замер — 26/26 PASS.
|
||||
Баланс — `python tests/sim_balance.py` (exit 0, отчёт
|
||||
`tests/SIM_BALANCE_REPORT.md`). Windows-раннер сам ищет Godot и позволяет
|
||||
переопределить путь через `GODOT_BIN` или `-GodotBin`. Web-пресет
|
||||
`export_presets.cfg` существует: `thread_support=false`, экспорт в
|
||||
`build/web/index.html`, просмотр только по HTTP.
|
||||
**Статус v3.1:** core-наборы обновлены и зелёные; UI-наборы (`test_main_*`,
|
||||
`ui_smoke`) ещё прогоняются, итоговый счёт не фиксируется.
|
||||
**GDD:** §23, §24.
|
||||
**Заменяет:** D25 (числа/платформы).
|
||||
|
||||
## D44 — известные отклонения и упрощения
|
||||
|
||||
**Решение:** фиксируем осознанно:
|
||||
- `ПРОПУСТИТЬ` → `q=0` → `quality = 0.5`, то есть половина quality-множителя,
|
||||
а не нулевая выплата;
|
||||
- `speed` выплаты использует `time_left/timer_total` на **старте отправки**,
|
||||
пока таймер был заморожен мини-игрой; быстрый игрок видит ≈1.0;
|
||||
- лимит папок **6**, новая папка **100 $** (review H5);
|
||||
- любой не прикреплённый файл можно уничтожить в подготовке бесплатно —
|
||||
защита от soft-lock «нет денег/нет места» (review M16);
|
||||
- удалённая в v3 игра с проводами: T4 имел `box 34`, `pitch 40`, верх `56`,
|
||||
чтобы низ был ≤320 (review M11); в v3 неактуально;
|
||||
- деньги атак масштабируются по часу `max(0.75, hour/3)` (review M8).
|
||||
**GDD:** §26.
|
||||
**Не является обещанием:** это не баги «на потом», а принятые рамки
|
||||
джем-прототипа; менять только вместе с балансом и тестами.
|
||||
**Статус v3.0: история.** Пункт про геометрию игры с проводами неактуален:
|
||||
игра удалена (D47). Лимит папок теперь 6 при цене **180 $**, магазин усилен
|
||||
(D50). Остальные упрощения (q=0 у пропуска, snapshot `speed`, M16, M8)
|
||||
сохранены; актуальный список — GDD §26.
|
||||
|
||||
---
|
||||
|
||||
# Решения v3.0 (D45–D52) и уточнения (D53–D56)
|
||||
|
||||
## D45 — TCP-соединение возвращено
|
||||
|
||||
**Решение:** TCP перед мини-игрой отправки снова требует мини-игру соединения
|
||||
`scripts/minigames/connection_game.gd`: три палочки по очереди, скорости
|
||||
**440/340/280 px/с**, три попадания в зелёную зону. UDP соединения не требует
|
||||
и открывает мини-игру отправки сразу.
|
||||
|
||||
```
|
||||
W = clamp(0.30 − 0.005×(hour−1) − 0.005×successes + 0.05×timer_level,
|
||||
0.18, 0.36)
|
||||
```
|
||||
|
||||
- Промах/уход за край: **−4 SLA**, кулдаун повтора **8 с**, панель
|
||||
закрывается (автоповтора нет).
|
||||
- `Esc`: **0 SLA**, кулдаун **4 с**, запрос остаётся.
|
||||
- Таймер запроса на время соединения заморожен; час идёт.
|
||||
- Успех сразу открывает мини-игру отправки, увеличивает `connect_successes`
|
||||
и **блокирует протокол** до конца запроса.
|
||||
- Счётчик успехов сбрасывается на новом забеге (`reset_carry()`).
|
||||
- Экономика: TCP — выплата **×1.15**, время ×1.0; UDP — выплата **×0.85**,
|
||||
время **×0.8**.
|
||||
**GDD:** §11, §11.1, §11.2, §17, §18.
|
||||
**Заменяет:** D04 (снова актуальна), D33 (часть про отсутствие соединения).
|
||||
**Статус v3.1:** механика уточнена D53–D54. Кнопка называется
|
||||
`ПОДКЛЮЧИТЬСЯ`; успех больше **не** открывает мини-игру отправки, а занимает
|
||||
out-слот отдельным `TcpConnection`. До соединения файлы прикреплять нельзя.
|
||||
|
||||
## D46 — запросы на конкретные файлы, 1–4
|
||||
|
||||
**Решение:** запрос хранит **конкретные `file_id`** в `required`; число
|
||||
файлов — **1–4** по `hours[].request_mix`. При создании выбираются реальные
|
||||
файлы из пула: свободные, не прикреплённые и не затребованные другим
|
||||
запросом. На карточке имена обязательных файлов видны сеткой 2×2.
|
||||
|
||||
- Прикрепление принимается только для `required`-файла этого запроса; чужой
|
||||
файл отклоняется с подсказкой.
|
||||
- **Жёсткая резервация:** обязательный файл нельзя уничтожить, унести на стол
|
||||
или прикрепить к другому запросу (`required_by`).
|
||||
- Пропавший файл (`failed`/`malicious`/удалён) показывается как **«ПОТЕРЯН»**;
|
||||
директор пытается подставить свободный файл того же типа, иначе отправка
|
||||
невозможна.
|
||||
- Таймер — **30 + 7×timer_level**, максимум **65 с**; замирает в мини-игре,
|
||||
соединении, очереди out-слота и передаче.
|
||||
- `request_cap` — **6/6/5/6/6/7/7/7/7**; `request_mix` H1–H9 — в GDD §13.
|
||||
- H1-затравка: seed-файл `text` + сразу запрос на него; первый пакет в `t=0`.
|
||||
Анти-голодание создаёт запрос, если активных нет и есть свободный файл
|
||||
(минимальный зазор 3 с).
|
||||
**GDD:** §3, §7, §13, §17.
|
||||
**Заменяет:** D32; обновляет D01, D06, D09.
|
||||
|
||||
## D47 — удаление игры с соединением проводов
|
||||
|
||||
**Решение:** из набора отправки удалена send-игра, где нужно было соединять
|
||||
провода с гнёздами. Осталось **6 игр отправки**: `dots`, `sound`, `text`,
|
||||
`video`, `photo`, `maze`. Файла игры и записи в `balance.json` больше нет;
|
||||
`MiniGameFactory` не пропускает старый id наружу. Упоминания в `dev_capture`
|
||||
и тестах остались только как негативные проверки «этот id не выбирается».
|
||||
**GDD:** §10, §24.
|
||||
**Заменяет:** D34 (состав 7 игр отправки).
|
||||
|
||||
## D48 — сдвиг ступеней отправки
|
||||
|
||||
**Решение:** у мини-игр отправки **3 ступени**, у атак — **4**. Старая
|
||||
ступень T1 отправки исчезла: прежние T2/T3/T4 стали T1/T2/T3 в
|
||||
`balance.json → minigames.games.*.tiers`. Параметры игр перенесены без
|
||||
смены смысла: T1 = старый T2, T2 = старый T3, T3 = старый T4.
|
||||
**GDD:** §10, §24.
|
||||
**Заменяет:** D34/D35 в части «4 ступени отправки».
|
||||
|
||||
## D49 — коэффициенты и таблица выбора мини-игры
|
||||
|
||||
**Решение:** коэффициенты — `text 1`, `sound 2`, `photo 4`, `video 8`.
|
||||
`S = sum(coeff)` по **1–4** файлам. Достижимо ровно **27 сумм**:
|
||||
`[1..22, 24, 25, 26, 28, 32]`.
|
||||
|
||||
```
|
||||
rank = позиция S в ACHIEVABLE_SUMS (или ближайшая меньшая)
|
||||
tier = T1 при rank 0–7, T2 при 8–13, T3 при 14–26
|
||||
id = RANK_GAMES[rank]
|
||||
```
|
||||
|
||||
`RANK_GAMES` — фиксированная таблица на 27 позиций (`economy.gd`), без
|
||||
modulo. **Каждая из 6 игр достижима в каждой ступени.** `S=0` → `dots`/T1.
|
||||
H1 — всегда T1; H2 — почти всегда T1 (T2 только у редкого запроса из трёх
|
||||
фото, S12); H9 даёт ≈**33/22/46 %** запросов T1/T2/T3.
|
||||
**GDD:** §8.4.
|
||||
**Заменяет:** D35.
|
||||
|
||||
## D50 — усиление магазина v3
|
||||
|
||||
**Решение:** 8 лотов, полное дерево — **8 085 $**. Новые цены и эффекты:
|
||||
|
||||
| Лот | Эффект | Цены |
|
||||
| --- | --- | --- |
|
||||
| `lot_slots` | +1 слот | 90 / 200 / 400 / 700 |
|
||||
| `lot_speed` | время передачи ×0.90 за уровень | 55 / 75 / 100 / 135 / 180 / 245 |
|
||||
| `lot_sla` | кап +40 и мгновенно +40 SLA (до 220) | 80 / 160 / 270 / 430 |
|
||||
| `lot_shield` | +1 заряд щита, 70 % награды | 100 / 220 / 410 |
|
||||
| `lot_money` | выплата +5 % за уровень | 70 / 95 / 130 / 170 / 220 / 285 / 365 / 465 |
|
||||
| `lot_folder` | сетка папки до 9×6 | 50 / 100 / 180 / 290 / 440 |
|
||||
| `lot_channel` | канал +6, буфер +1 | 45 / 70 / 95 / 130 / 175 / 230 |
|
||||
| `lot_timer` | таймер запроса +7 с (до 65) | 45 / 75 / 115 / 165 / 230 |
|
||||
|
||||
Сетки: `[5,4] → [6,4] → [7,5] → [8,5] → [8,6] → [9,6]`. Новая папка —
|
||||
**180 $**; `sla_hard_cap` — **220**. Базовые выплаты типов: 18/30/44/69.
|
||||
Симулятор v3: в отчёте **7 354 $**, расход **56,7 %** дерева, **32/240**
|
||||
поражений; после H1-правки — **7 533 $**, **57,9 %**, **28/240**.
|
||||
**GDD:** §5, §12, §13, §14.
|
||||
**Заменяет:** числа D29, D36, D39, D40, D42.
|
||||
**Статус v3.1:** 8 лотов и дерево 8 085 $ — история. Актуально **9 лотов**
|
||||
и **8 825 $** после добавления `lot_udp` (D55); остальные цены/эффекты
|
||||
D50 действуют.
|
||||
|
||||
## D51 — Golos Text и пиксельные иконки
|
||||
|
||||
**Решение:** тело интерфейса набрано **Golos Text** (`art/fonts/GolosText-VF.ttf`,
|
||||
SIL OFL 1.1, полная кириллица); **Press Start 2P** — только логотип и
|
||||
цифровые акценты. Шкала — **11/13/17/26 px**; подписи файлов — 12/14/16 px по
|
||||
клетке. Handjet и Pixelify Sans в теме не используются и помечены как legacy.
|
||||
|
||||
Пиксельные иконки файлов — `art/icons/*.png` (32×32, nearest), генерируются
|
||||
`art/icons/generate_icons.py` (Pillow) в палитре Kaleidos. Лицензия — MIT,
|
||||
команда проекта. Исходный тайл-лист был недоступен, поэтому набор
|
||||
**перегенерирован** скриптом, а не вырезан из готового файла.
|
||||
**GDD:** §1–2, §20, §21.
|
||||
**Заменяет:** D15.
|
||||
**Статус v3.1:** роли шрифтов уточнены D56: тело — Kechusilka, крупный
|
||||
текст — DisplayOTF, Golos Text остаётся fallback; Press Start 2P — цифры.
|
||||
|
||||
## D52 — перестройка туториала
|
||||
|
||||
**Решение:** вместо ленты подсказок — **5 событийных шагов**:
|
||||
`packet` → `attach` → `connect` → `send` → `sla`. Каждый шаг ждёт своё
|
||||
действие (аварийные таймауты 30/40/45/40/12 с), вокруг цели рисуется
|
||||
пульсирующая рамка, кнопка **«ДАЛЕЕ»** пропускает шаг.
|
||||
|
||||
- Завершение пишется в `Game.stats["tutorial_done"]` и сохраняется в сейве.
|
||||
- Автозапуск — только в **первом часу первого забега**, если обучение ещё не
|
||||
пройдено.
|
||||
- Кнопка **«ОБУЧЕНИЕ»** в главном меню сбрасывает флаг и запускает повтор:
|
||||
в новом забеге — с подготовки, в идущем — с текущего часа.
|
||||
**GDD:** §18.1.
|
||||
**Заменяет:** старый «таймерный» туториал v2.
|
||||
|
||||
## D53 — TCP-соединение как отдельная сущность
|
||||
|
||||
**Решение:** TCP-соединение хранится отдельно от запроса —
|
||||
`scripts/entities/tcp_connection.gd` (`TcpConnection`): `id`, `client_name`,
|
||||
`client_id`, `ip`, `request_id`, `slot_index`, `hour`, `connected_at`.
|
||||
`HourDirector.active_connections` — словарь живых соединений; `Request`
|
||||
держит только `connection_id` и флаги.
|
||||
|
||||
- **До соединения файлы прикреплять нельзя:** drag в карточку отклоняется,
|
||||
`attach_file` отвечает «СНАЧАЛА УСТАНОВИТЕ СОЕДИНЕНИЕ».
|
||||
- Кнопка `ПОДКЛЮЧИТЬСЯ` — справа снизу на карточке, только TCP. Она запускает
|
||||
`ConnectionGame` (3 попадания). Успех создаёт `TcpConnection` и занимает
|
||||
**out-слот**; в слоте видны имя клиента и случайный IPv4, данных запроса
|
||||
там нет.
|
||||
- После успеха мини-игра отправки **не открывается**: её запускает только
|
||||
`ОТПРАВИТЬ`, когда прикреплены все обязательные файлы.
|
||||
- `ОТКЛОНИТЬ` отменяет запрос и закрывает его соединение; `reject`/`timeout`/
|
||||
конец часа освобождают слоты.
|
||||
- Слоты общие для приёма и отправки: `1 + slot_level`, максимум 5.
|
||||
TCP-соединение может занять единственный слот и заблокировать приём — это
|
||||
осознанное требование.
|
||||
**GDD:** §3, §7, §11, §11.1, §17.
|
||||
**Уточняет:** D45; заменяет часть D33 про отсутствие соединения.
|
||||
|
||||
## D54 — Idle-слот после успешной отправки
|
||||
|
||||
**Решение:** успешная TCP-отправка удаляет запрос и расходует файлы, но
|
||||
**не** закрывает соединение: `TcpConnection.request_id` очищается, слот
|
||||
остаётся занятым (idle) до ручного `ОТКЛЮЧИТЬ` на слоте. Отключение
|
||||
мгновенное, без проверок и мини-игр.
|
||||
|
||||
- Отключение во время подготовки/передачи прерывает передачу: выплаты нет,
|
||||
файлы не расходуются и возвращаются в папки, запрос остаётся активным;
|
||||
соединение нужно устанавливать заново.
|
||||
- Повторное использование idle-соединения новым запросом не предусмотрено:
|
||||
каждый TCP-запрос проходит свою мини-игру соединения и занимает свой слот.
|
||||
- `reject`/`timeout`/конец часа закрывают соединение и освобождают слот.
|
||||
**GDD:** §3, §11, §17.
|
||||
**Уточняет:** D45/D53.
|
||||
|
||||
## D55 — UDP-риск и лот «UDP-повторитель»
|
||||
|
||||
**Решение:** у UDP нет соединения; успех определяется роллом **в конце
|
||||
передачи**. Шанс:
|
||||
|
||||
```
|
||||
P = min(0.40 + 0.10 × udp_level, 0.80)
|
||||
```
|
||||
|
||||
- Новый лот `lot_udp` «UDP-повторитель»: 4 уровня, цены **60 / 120 / 210 /
|
||||
350 $**, шанс **40 → 50 → 60 → 70 → 80 %**. Всего лотов — **9**, дерево —
|
||||
**8 825 $**.
|
||||
- **Успех:** обычная выплата UDP (`×0.85`), файлы расходуются, запрос
|
||||
удаляется.
|
||||
- **Провал:** выплаты и денежного штрафа нет, **−3 SLA**, файлы не
|
||||
расходуются, запрос остаётся, `udp_failures` +1, повтор через **4 с**,
|
||||
out-слот освобождается, streak сбрасывается. Повтор — снова мини-игра
|
||||
отправки и передача.
|
||||
- Карточка показывает шанс (`UDP · ШАНС N %`) и после провала —
|
||||
`ПОТЕРЯ ПАКЕТА · ПОВТОР N с`.
|
||||
**GDD:** §11, §11.2, §14.
|
||||
**Заменяет:** часть D50 (8 лотов / 8 085 $) и числа D33 про UDP.
|
||||
|
||||
## D56 — Kechusilka, DisplayOTF и Press Start 2P
|
||||
|
||||
**Решение:** роли шрифтов разделены:
|
||||
|
||||
- **Тело текста** — `Kechusilka-Regular.otf`, нормализованный под метрики
|
||||
Golos (x-height **0.530 em**), шкала **11/13/17/26**.
|
||||
- **Fallback** — `GolosText-VF.ttf` для глифов, которых нет в Kechusilka:
|
||||
`№ – … · × ← → ↑ ↓ ≤ ≥ § ° € • © ± ≈ −`.
|
||||
- **Крупный текст/заголовки** — `DisplayOTF.otf` (freeware, Denis A Serikov):
|
||||
wordmark **46 px**, «ПОБЕДА/ПОРАЖЕНИЕ» **34 px**, титлбар **14 px**,
|
||||
«РЕЗУЛЬТАТ» **18 px**.
|
||||
- **Цифры/счётчики/брендовый акцент** — `PressStart2P-Regular.ttf`;
|
||||
моноширинные цифры не дают счётчикам «дрожать».
|
||||
- Лицензии и файлы — в `ASSETS.md`; Kechusilka — license confirmed by project
|
||||
owner, DisplayOTF — freeware/personal.
|
||||
**GDD:** §1–2, §20, §21.
|
||||
**Заменяет:** D51 в части «Golos Text — тело текста».
|
||||
|
||||
## D57 — длительность часов 120–360 с
|
||||
|
||||
**Решение:** длительность часа берётся из `hours[].duration` и растёт на 30 с:
|
||||
**120 / 150 / 180 / 210 / 240 / 270 / 300 / 330 / 360 с** для H1–H9
|
||||
(`120 + 30×(h−1)`). Забег — **36 минут** плюс подготовка между часами.
|
||||
`constants.hour_seconds = 300` остаётся только legacy-fallback, если в строке
|
||||
часа нет `duration`; `HourDirector.hour_duration()` кэширует значение на час.
|
||||
|
||||
- Окно спавна файлов — **75 %** длительности (90–270 с), а не фиксированные
|
||||
225 с.
|
||||
- H1 длится 120 с, поэтому его `income_target` снижен до **100 $**; остальные
|
||||
цели прежние.
|
||||
- Визуальные таймеры и подсказки читают фактическую длительность, а не
|
||||
константу 300.
|
||||
**GDD:** §3, §6, §8, §13.
|
||||
**Заменяет:** формулировки «9 часов по 300 с» / «≈45 минут».
|
||||
|
||||
## D58 — файлы-каталог и темп прихода
|
||||
|
||||
**Решение:** файлы **не расходуются**. После успешной отправки
|
||||
`registry.detach_file()` только снимает привязку к запросу; файл остаётся в
|
||||
папке, занимает свои клетки и сразу доступен пулу следующих запросов
|
||||
(`file_cooldown_after_send_sec = 0`). То же — при отмене, таймауте, отключении
|
||||
и UDP-провале.
|
||||
|
||||
- Общего лимита числа файлов на забег нет. Приход задаёт **скорость**:
|
||||
`spawn_window = 0.75 × duration`; интервал — `spawn_window / hours[].files`
|
||||
с джиттером ±20 %, поэтому последний файл успевает до конца окна.
|
||||
- `hours[].files` (**6, 6, 7, 8, 9, 9, 10, 10, 10**) — скорость/ожидаемое число
|
||||
файлов за окно часа, а не жёсткий cap забега и не остаток бюджета. Переноса
|
||||
между часами нет (carry = 0); служебного предела на число файлов нет.
|
||||
- Папки докупаются в подготовке (180 $, до 6); к финалу в симуляторе обычно
|
||||
**5–6 папок**.
|
||||
**GDD:** §6, §11, §11.2, §26.
|
||||
**Заменяет:** D02/D33/D54/D55 в части «файлы расходуются при успешной
|
||||
отправке».
|
||||
|
||||
## D59 — запросы без `request_cap`
|
||||
|
||||
**Решение:** отдельного `request_cap` нет. Темп запросов задаёт
|
||||
`hours[].send_interval` (**20, 24, 18, 24, 24, 24, 24, 24, 24** с), а
|
||||
ограничитель — только число **одновременно активных** запросов
|
||||
`hours[].active_requests` (**2/2/2/3/3/3/4/4/4**). Новые запросы не спавнятся
|
||||
в последние **10 с** часа (`REQUEST_SPAWN_CUTOFF_SEC`).
|
||||
|
||||
- Анти-голодание работает только для **первого** запроса часа
|
||||
(`requests_spawned == 0`); H1 bootstrap создаёт первый запрос сразу.
|
||||
Дальше директор не обгоняет `send_interval`.
|
||||
- Запрос выбирает 1–4 файла из пула папок по `request_mix`; набор не связан
|
||||
с тем, какие файлы пришли в этом часе, — подходят и старые файлы из папок.
|
||||
**GDD:** §7, §13.
|
||||
**Заменяет:** числа `request_cap` из D05/D46 и формулировку «анти-голодание
|
||||
не даёт панели простаивать» (теперь только первый запрос).
|
||||
|
||||
## D60 — окно спавна атак от длительности часа
|
||||
|
||||
**Решение:** атаки планируются внутри часа по формуле:
|
||||
|
||||
```
|
||||
start = clamp(0.25 × duration, 20, 60)
|
||||
end = min(0.85 × duration, duration − 20)
|
||||
```
|
||||
|
||||
Для H2 (`duration = 150`) это **37,5–127,5 с**, для H9 (`duration = 360`) —
|
||||
**60–306 с**. Расписание остаётся `[0,1,1,2,2,3,3,4,4]` (20 атак за забег),
|
||||
ступени — `clamp(1 + floor((hour−2)/2), 1, 4)`.
|
||||
**GDD:** §3, §8, §12.1.
|
||||
**Заменяет:** фиксированное окно «60–240 с» из D42 и прежних таблиц.
|
||||
|
||||
## D61 — визуал «ЖАРА» и `reduce_motion`
|
||||
|
||||
**Решение:** состояние узла показывается двумя слоями.
|
||||
|
||||
- **`TemperatureGauge`** (216×18) в меню-баре вместо часов **03:42**:
|
||||
колба, деления на **0.34 / 0.58 / 0.80**, маркер, температура **36–95 °C**,
|
||||
tooltip с разбором SLA/запросов/атак/канала/буфера/очереди. В покое и в
|
||||
подготовке — пустая колба и 36 °C.
|
||||
- **`StressOverlay`** — полноэкранный `ColorRect` с
|
||||
`shaders/stress.gdshader`; шейдер **alpha-only**, без
|
||||
`hint_screen_texture`, поэтому не тонирует модалки и тосты. Heat-модель —
|
||||
чистая функция от снимка часа плюс импульсы событий, сглаживание через
|
||||
`UiMotion.k()`.
|
||||
- **Пульс SLA/CRIT** в `StatusBar` при SLA < 40 % или уровне 3; тряска панелей
|
||||
— целые пиксели, **≤2 px**, ~140 мс, только `position`.
|
||||
- **Lane B:** сглаживание drag, наклон по скорости, pop/rotate-pop, stagger
|
||||
карточек, плавные meter-полосы, вход/выход тостов — всё через `UiMotion`;
|
||||
layout и drop-логика не меняются.
|
||||
- Настройка **«Снизить эффекты»** (`reduce_motion`) выставляет
|
||||
`UiMotion.reduced` и отключает тряску, наклон/инерцию, пульсы и вспышки;
|
||||
шейдер оставляет статичный тон и виньетку. В **headless** материал не
|
||||
создаётся, оверлей скрыт, движение выключено.
|
||||
**GDD:** §20.1, §21.
|
||||
**Заменяет:** часы 03:42 и скрытую дату в меню-баре.
|
||||
@@ -7,6 +7,63 @@
|
||||
> Язык: GDScript. UI строится кодом; `.tscn` — только корень сцены.
|
||||
> Комментарии/UI — на русском, идентификаторы — на английском.
|
||||
|
||||
## 0. Дельты v3.0 (читать первым)
|
||||
|
||||
Этот файл — исторический контракт v1. Актуальная спецификация — `docs/GDD.md`
|
||||
v3.0 и `docs/DECISIONS.md` D45–D61. Ниже — что в контракте уже неверно:
|
||||
|
||||
- **Длительность и темп:** час длится `hours[].duration` — **120 / 150 / 180 /
|
||||
210 / 240 / 270 / 300 / 330 / 360 с** (`120 + 30×(h−1)`), забег **36 минут**
|
||||
плюс подготовка; `constants.hour_seconds = 300` — только legacy-fallback.
|
||||
Спавн файлов идёт в окно **75 %** часа, интервал — `spawn_window / files`
|
||||
(скорость `hours[].files`), без carry и общего лимита. Атаки — `clamp(0.25×duration, 20, 60)` …
|
||||
`min(0.85×duration, duration−20)`. См. D57/D60, GDD §3/§6/§8.
|
||||
- **Файлы — каталог, не расходник:** успешная отправка снимает только
|
||||
`required_by`/attachment; файл остаётся в папке и доступен новым запросам.
|
||||
Общего лимита на забег нет; `hours[].files` (6,6,7,8,9,9,10,10,10) задаёт
|
||||
темп. См. D58, GDD §6/§11.
|
||||
- **Запросы без `request_cap`:** только `hours[].active_requests`, cutoff
|
||||
последних **10 с** и `send_interval`; анти-голодание — только для первого
|
||||
запроса часа. См. D59, GDD §7/§13.
|
||||
|
||||
- **Запросы:** `required` снова хранит **конкретные `file_id`**, но их 1–4, а
|
||||
не 1–3; запрос резервирует файлы (`required_by`), UI показывает имена
|
||||
сеткой 2×2 и «ПОТЕРЯН». См. GDD §7, D46.
|
||||
- **Соединение:** `ConnectionGame` **вернулось** в v3. Формула зоны и скорости
|
||||
из раздела «ConnectionGame» снова актуальны (D45), но панель вызывается
|
||||
только для TCP и замораживает таймер своего запроса; промах −4 SLA / 8 с,
|
||||
Esc — 0 SLA / 4 с. TCP-соединение — отдельная сущность `TcpConnection`
|
||||
(D53), а не только флаг запроса. До соединения файлы не прикрепляются;
|
||||
успех занимает out-слот и **не** открывает мини-игру отправки. После
|
||||
успешной отправки слот остаётся idle до `ОТКЛЮЧИТЬ` (D54).
|
||||
- **Мини-игры:** не 11, а **10** (6 отправки + 4 атаки); игры с проводами нет.
|
||||
У отправки **3 ступени**, у атак 4; выбор — `Economy.selection_for_sum()` по
|
||||
27 суммам и таблице `rank_games`, без `rank % 7`. См. GDD §8.4, §10.
|
||||
- **Магазин:** не 9 позиций v1, а **9 лотов** v3 (8 прежних + `lot_udp`),
|
||||
дерево **8 825 $** (было 8 085 $); SLA-кап **220**; цены/эффекты — GDD §14,
|
||||
D50/D55. Поля «9 позиций D13», «SLA +24», «секция/поиск/зона» устарели.
|
||||
- **UDP:** соединения нет; шанс успеха — `min(0.40 + 0.10×udp_level, 0.80)`
|
||||
(`lot_udp`, 4 уровня), ролл в конце передачи. Провал: −3 SLA, без выплаты,
|
||||
файлы остаются, повтор через 4 с, слот освобождается, streak сбрасывается.
|
||||
Успех тоже не удаляет файлы — каталог сохраняется (D58). См. D55, GDD §11.2.
|
||||
- **Экономика/буфер:** SLA-кап +40 до 220 (прежняя схема v2 — история),
|
||||
таймер запроса +7 с до 65 (не +5 до 55), буфер 6+уровень (не 4+2×),
|
||||
папка 180 $ (не 100), сетки 5×4 → 9×6. Ускорение передачи `×0.90` за
|
||||
уровень (прежний множитель v2 — история).
|
||||
- **Шрифты:** Kechusilka Regular — тело текста (fallback Golos Text),
|
||||
DisplayOTF — крупный текст, Press Start 2P — только цифры/счётчики; шкала
|
||||
11/13/17/26; иконки — `art/icons/*.png`. Handjet — legacy. См. D56.
|
||||
- **UI «ЖАРА»:** `TemperatureGauge` в меню-баре вместо часов 03:42,
|
||||
`StressOverlay` с alpha-only шейдером без `hint_screen_texture`, пульс
|
||||
SLA/CRIT, тряска панелей ≤2 px, `UiMotion`/`reduce_motion`; в headless
|
||||
слой скрыт. См. D61, GDD §20.1.
|
||||
- **Туториал:** 5 событийных шагов с рамками и «ДАЛЕЕ», а не лента подсказок;
|
||||
прогресс в `Game.stats["tutorial_done"]`.
|
||||
|
||||
Исторические разделы ниже (9 позиций v1, 4+2 буфера, анонимные слоты,
|
||||
`rank % 7`, панель соединения как «удалённая») оставлены для провенанса и не
|
||||
являются текущим контрактом.
|
||||
|
||||
## 1. Автолоады
|
||||
|
||||
| Автолоад | Файл | Ответственность |
|
||||
@@ -70,14 +127,15 @@ fields/настройки: music_volume: float, sfx_volume: float
|
||||
| `Folder` | `scripts/entities/folder.gd` | список файлов, capacity 8, отображалка |
|
||||
| `Section` | `scripts/entities/section.gd` | страницы папок, докупаемые позиции |
|
||||
| `Packet` | `scripts/entities/packet.gd` | входящий пакет + свой таймер (тики слишком идут из директора) |
|
||||
| `Request` | `scripts/entities/request.gd` | клиентский запрос: `required` (id), флаги `slot_reserved/connected/minigame_active`, таймер |
|
||||
| `SlotManager` | `scripts/core/slot_manager.gd` | слоты соединений (start→max по D13), резерв/освобождение |
|
||||
| `HourDirector` | `scripts/core/hour_director.gd` | ЕДИНЫЙ тик `tick(delta)` всех таймеров часа, спавны, канонический Clock |
|
||||
| `Request` | `scripts/entities/request.gd` | клиентский запрос: `required` (id), `connection_id`, флаги `slot_reserved/connected/minigame_active`, UDP-поля, таймер |
|
||||
| `TcpConnection` | `scripts/entities/tcp_connection.gd` | установленное TCP-соединение: клиент, IP, `request_id`, `slot_index`; живёт отдельно от запроса |
|
||||
| `SlotManager` | `scripts/core/slot_manager.gd` | слоты соединений `1 + slot_level` (макс. 5), `kind`/`detail`/`linked_request_id`, резерв/освобождение |
|
||||
| `HourDirector` | `scripts/core/hour_director.gd` | ЕДИНЫЙ тик `tick(delta)` всех таймеров часа, спавны, `active_connections`, канонический Clock |
|
||||
| `AttackDirector` | `scripts/systems/attack_director.gd` | выбор атаки, её эффект, отражение щитом |
|
||||
| `Shop` | `scripts/systems/shop.gd` | 9 позиций D13, уровни, цены, применение эффектов |
|
||||
| `Shop` | `scripts/systems/shop.gd` | 9 лотов v3, уровни, цены, применение эффектов |
|
||||
| `SaveManager` | `scripts/systems/save_system.gd` | save/load между часами, JSON, `commit()` |
|
||||
| `MiniGame` | `scripts/minigames/minigame_base.gd` | базовая модалка мини-игры, `start(type, difficulty)` |
|
||||
| `ConnectionGame` | `scripts/minigames/connection_game.gd` | полоска соединения по формуле D04 |
|
||||
| `ConnectionGame` | `scripts/minigames/connection_game.gd` | полоска соединения по формуле D04; `finished(success, request_id)` |
|
||||
|
||||
### FileRegistry
|
||||
```gdscript
|
||||
@@ -117,26 +175,107 @@ invariants:
|
||||
### ConnectionGame
|
||||
```gdscript
|
||||
signals:
|
||||
connected(success: bool)
|
||||
finished(success: bool, request_id: String)
|
||||
methods:
|
||||
func open(request_id: String, hour: int, successes: int, zone_level: int) -> void
|
||||
func start(hour: int, successes: int, zone_level: int, request_id: String) -> void
|
||||
func open(request_id: String, hour: int, successes: int, zone_level: int) -> void # legacy alias
|
||||
func width() -> float # W-Zone по D04
|
||||
static func compute_zone(hour: int, successes: int, zone_level: int) -> float
|
||||
# W = clamp(0.30 − 0.005*(hour-1) − 0.005*successes + 0.05*zone_upgrade_level, 0.18, 0.36)
|
||||
# скорости: 440/340/280 px/с; окно ~130–200 мс
|
||||
# скорости: 440/340/280 px/с; 3 попадания; Esc / промах → finished(false, id)
|
||||
invariants:
|
||||
- zone — только эта формула (D04/D05);
|
||||
- промах/уход за край: −4 SLA и кулдаун 8 с; Esc: 0 SLA и кулдаун 4 с;
|
||||
- открытая панель останавливает спавн приёма и таймеры пакетов (D03);
|
||||
- Esc — отмена: слот свободен, запрос остаётся (D10).
|
||||
- успех не открывает мини-игру отправки: UI вызывает
|
||||
`HourDirector.connection_finished(id, true)`.
|
||||
```
|
||||
|
||||
### Шоп-Shop (D13)
|
||||
### TcpConnection (D53)
|
||||
```gdscript
|
||||
fields:
|
||||
id: String
|
||||
client_name: String
|
||||
client_id: String
|
||||
ip: String # случайный IPv4 для UI слота
|
||||
request_id: String # "" — соединение idle после отправки
|
||||
slot_index: int # out-слот, который держит соединение
|
||||
hour: int
|
||||
connected_at: float
|
||||
methods:
|
||||
func has_request() -> bool
|
||||
func display_name() -> String
|
||||
func to_dict() -> Dictionary
|
||||
static func from_dict(d: Dictionary) -> TcpConnection
|
||||
invariants:
|
||||
- соединение живёт в `HourDirector.active_connections`, а не в Request;
|
||||
- создаётся только при успехе ConnectionGame и только если есть свободный слот;
|
||||
- занимает ровно один out-слот (`kind == "tcp_connection"`), слот общий с приёмом;
|
||||
- после успешной отправки `request_id` очищается, слот остаётся занятым;
|
||||
- `disconnect_connection(conn_id)` освобождает слот мгновенно, без мини-игр;
|
||||
- reject/timeout/конец часа вызывают `_release_connection()`.
|
||||
```
|
||||
|
||||
### Slot (kind/detail) и SlotManager
|
||||
```gdscript
|
||||
fields Slot:
|
||||
index: int
|
||||
direction: String # "in" | "out"
|
||||
entity_id: String # packet_id / request_id / conn_id
|
||||
entity_name: String # имя клиента для tcp_connection
|
||||
progress: float
|
||||
occupied: bool
|
||||
kind: String # "packet" | "request" | "tcp_connection"
|
||||
detail: String # tcp_connection → IP
|
||||
can_disconnect: bool
|
||||
linked_request_id: String # запрос, который пользуется соединением
|
||||
methods SlotManager:
|
||||
func reserve(direction, entity_id, entity_name = "", kind = "", detail = "",
|
||||
can_disconnect = false, linked_request_id = "") -> int
|
||||
func release(index: int) -> bool
|
||||
func release_by_entity(entity_id: String) -> int
|
||||
func active_weights(registry) -> Array
|
||||
invariants:
|
||||
- всего `1 + slot_level`, максимум 5; слоты общие для приёма и отправки;
|
||||
- `tcp_connection` не хранит имена файлов/required — только клиента и IP;
|
||||
- вес канала для `tcp_connection` считается по файлам `linked_request_id`;
|
||||
- одна сущность не занимает два слота.
|
||||
```
|
||||
|
||||
### HourDirector: соединения и UDP
|
||||
```gdscript
|
||||
fields:
|
||||
active_connections: Dictionary # conn_id → TcpConnection
|
||||
methods:
|
||||
func connect_request(req_id: String) -> bool # старт ConnectionGame
|
||||
func connection_finished(req_id: String, success: bool, missed: bool = false) -> bool
|
||||
func disconnect_connection(conn_id: String) -> bool # мгновенно, без проверок
|
||||
func disconnect_request(req_id: String) -> bool
|
||||
func get_connection(conn_id: String) -> TcpConnection
|
||||
func connection_id_for_request(req_id: String) -> String
|
||||
func udp_success_chance() -> float
|
||||
invariants:
|
||||
- `attach_file` для TCP отклоняется, пока `req.connection_id == ""`;
|
||||
- успех соединения занимает out-слот; `start_send` не создаёт соединение;
|
||||
- успешная отправка отвязывает соединение (`conn.request_id = ""`), но не
|
||||
освобождает слот;
|
||||
- отключение во время передачи обнуляет прогресс, возвращает файлы,
|
||||
запрос оставляет активным;
|
||||
- UDP-провал: `−3 SLA`, файлы остаются, `udp_failures += 1`,
|
||||
`udp_retry_until = hour_elapsed + 4`, слот освобождается, streak = 0.
|
||||
```
|
||||
`Economy.udp_success_chance(upgrades, base, step, cap)` возвращает
|
||||
`clamp(0.40 + 0.10×level, 0.40, 0.80)`; уровень берётся из `lot_udp`.
|
||||
|
||||
### Шоп-Shop (D13/D50/D55)
|
||||
```gdscript
|
||||
methods:
|
||||
func can_buy(item_id: String) -> bool
|
||||
func buy(item_id: String) -> bool
|
||||
func level(item_id: String) -> int
|
||||
invariants:
|
||||
- 9 позиций с ценами/уровнями как в `balance.json → shop`;
|
||||
- **9 лотов** с ценами/уровнями как в `balance.json → shop`, включая `lot_udp`;
|
||||
- полное дерево — **8 825 $**;
|
||||
- сканер/автосортировка не реализуются (D13, позиций «резерв» в данных НЕТ).
|
||||
```
|
||||
|
||||
|
||||
@@ -2,7 +2,8 @@
|
||||
|
||||
**Платформа:** Web (HTML5, Godot 4.7.2)
|
||||
**Жанр:** менеджмент / тайм-менеджмент, retro UI
|
||||
**Длительность:** 9 игровых часов по 5 минут + подготовка между часами
|
||||
**Длительность:** 9 игровых часов — от 2 до 6 минут (120 → 360 с), всего
|
||||
около 36 минут + подготовка между часами
|
||||
**Язык:** русский
|
||||
**Управление:** мышь и клавиатура
|
||||
**Окно:** 1920×1080, логический viewport 1280×720
|
||||
@@ -10,38 +11,70 @@
|
||||
## Короткое описание
|
||||
|
||||
1997 год. Ночная смена в узле файлового обмена. Принимайте пакеты,
|
||||
отправляйте файлы клиентам, держите SLA — и не дайте атакам положить канал.
|
||||
раскладывайте файлы по папкам, отправляйте контейнеры клиентам, держите
|
||||
SLA — и не дайте атакам положить канал.
|
||||
|
||||
## Описание
|
||||
|
||||
Вы — дежурный оператор узла 7. В 03:42 на столе мигает приём: пакеты
|
||||
приходят быстрее, чем вы успеваете разложить их по папкам. Клиенты присылают
|
||||
запросы на конкретные файлы — имя и папку видно в карточке, так что искать
|
||||
приходится по всему диску. Перетащите пакет в папку, найдите нужный файл,
|
||||
прикрепите его к запросу, установите соединение и отправьте. В окне
|
||||
соединения три бегунка — три попадания по очереди, и канал ваш. Промах
|
||||
закрывает панель: придётся нажать «Соединиться» снова.
|
||||
приходят быстрее, чем вы успеваете разложить их по папкам. Каждый файл —
|
||||
фигура из клеток: текст занимает одну, видео — четыре. Фигуру можно
|
||||
повернуть и плотно уложить в сетку папки; на столе сортировки всегда есть
|
||||
место, чтобы разобраться с завалом.
|
||||
|
||||
Смена длится девять игровых часов по пять минут. На старте **SLA 128/200**,
|
||||
а каждый потерянный пакет бьёт по типу файла: текст 2, звук 4, фото 8,
|
||||
видео 16. Первый час проходит без атак; дальше в узел лезут мусор, DDoS,
|
||||
фишинг и шифровальщик — их нужно вовремя заметить и уничтожить. Магазин
|
||||
открывается после первого часа: **10 улучшений** от слотов и буфера до зоны,
|
||||
канала, щита и запаса SLA. Поиск и щит открываются мета-прогрессом после
|
||||
3-го и 5-го часа, продвинутые мини-игры — после 9-го (контент пока
|
||||
в разработке). Дожить до конца смены — победа; уронить SLA — поражение.
|
||||
Клиенты присылают запросы на 1–4 конкретных файла — их имена видны на
|
||||
карточке, и прикрепить нужно именно этот файл. Соберите контейнер, выберите
|
||||
протокол — TCP платит больше, UDP быстрее — и нажмите «ПОДКЛЮЧИТЬСЯ» (TCP) или
|
||||
«ОТПРАВИТЬ». TCP сначала требует провести три палочки через зелёную зону и
|
||||
занимает слот соединения; до этого файлы в запрос не прикрепляются. После
|
||||
соединения отправку запускает отдельная кнопка, а после успешной отправки
|
||||
слот остаётся занятым, пока вы не нажмёте «ОТКЛЮЧИТЬ». UDP соединяется сразу,
|
||||
но в конце передачи может потерять пакет: тогда −3 SLA, файлы остаются, а
|
||||
повтор — через 4 секунды. Отправка не абстрактная: перед ней открывается
|
||||
мини-игра. Всего их десять: шесть для отправки и четыре для атак, у отправки
|
||||
три ступени, у атак четыре. В них нет таймеров — только конечная цель и
|
||||
качество выполнения, от которого зависит выплата. Результат показывает
|
||||
проценты, вердикт и детали, а награда применяется после «ПРОДОЛЖИТЬ».
|
||||
|
||||
Смена длится девять игровых часов: первый — две минуты, каждый следующий на
|
||||
тридцать секунд длиннее, финальный — шесть минут; всего около **36 минут**
|
||||
плюс подготовка. Каждый час приносит больше файлов и запросов, а атаки
|
||||
приходят в своё окно внутри часа. Файлы после отправки не исчезают: они
|
||||
остаются в папках и могут понадобиться следующим клиентам. На старте
|
||||
**SLA 60/60**, кап растёт до 220; в начале часа восстанавливается 15 SLA.
|
||||
Потерянный пакет бьёт по типу файла: текст 2, звук 4, фото 8, видео 16.
|
||||
Первый час проходит без атак; дальше в узел лезут мусор, DDoS, фишинг и
|
||||
шифровальщик. Каждая атака — это отдельная мини-игра, которая ставит час на
|
||||
паузу: отбейте её, и получите деньги и SLA; провалите — получите штраф и
|
||||
временный дебафф. «Заземление» гасит атаку автоматически, но приносит 70 %
|
||||
награды.
|
||||
|
||||
Магазин открывается после первого часа: **9 улучшений** — порты, оптика,
|
||||
ИБП, заземление, контракты, стеллажи, канал, регламент и UDP-повторитель.
|
||||
Каждую подготовку предлагаются три лота; ассортимент можно обновить за
|
||||
40/80/120 $ или закрепить нужное предложение. Полное дерево стоит **8 825 $** —
|
||||
выкупить всё за один забег не получится, придётся выбирать.
|
||||
|
||||
Дожить до конца смены — победа; уронить SLA — поражение. Сохранение
|
||||
работает между часами, включая офферы магазина и стол сортировки.
|
||||
|
||||
Интерфейс вдохновлён Kaleidos: тёплая бумага, тонкие 1 px контуры, мягкие
|
||||
тени и бирюзовый акцент. В первом часе рядом подсказывает маскот **БАЙТ**.
|
||||
В главном меню — лёгкий процедурный VHS-эффект (скан-линии, зерно, виньетка),
|
||||
который отключается в настройках; в игре UI остаётся чистым. Фон — ambient
|
||||
машинного зала, включается после первого жеста и регулируется вместе с музыкой.
|
||||
UI рисуется кодом, без внешних текстур; Handjet держит плотный русский
|
||||
текст, Press Start 2P — логотип и цифры. Звуки синтезированы скриптом
|
||||
проекта. Собрано на Godot 4.7.2 для Web без threads:
|
||||
игра запускается в браузере без COOP/COEP. Это играбельный джем-прототип
|
||||
полного цикла: меню, подготовка, девять часов, итоги, магазин, пауза,
|
||||
настройки, атаки, сохранение и мета-открытия.
|
||||
тени и бирюзовый акцент. В первом часу БАЙТ ведёт пошаговый туториал:
|
||||
пакет → прикрепление → TCP-соединение → мини-игра → SLA и атаки; шаг можно
|
||||
пропустить кнопкой «ДАЛЕЕ», а повторить обучение — кнопкой «ОБУЧЕНИЕ» в
|
||||
меню. В главном меню — лёгкий VHS-эффект, который отключается в настройках;
|
||||
в игре UI остаётся чистым. Фон — ambient машинного зала, включается после
|
||||
первого жеста и регулируется вместе с музыкой.
|
||||
|
||||
Тело интерфейса набрано Kechusilka Regular (fallback — Golos Text), крупный
|
||||
текст — DisplayOTF, Press Start 2P — цифры и счётчики; иконки файлов —
|
||||
пиксельные, из `art/icons/`. В меню-баре вместо часов — термометр «ЖАРА»:
|
||||
он показывает, насколько раскалён узел, а по краям экрана копится тёплый
|
||||
градиент. Эффекты движения отключаются галочкой «Снизить эффекты». Звуки
|
||||
синтезированы скриптом проекта. Собрано на
|
||||
Godot 4.7.2 для Web без threads: игра запускается в браузере без COOP/COEP.
|
||||
Это играбельный джем-прототип полного цикла: меню, подготовка, девять часов,
|
||||
итоги, магазин, пауза, настройки, атаки, сохранение и мета-открытия.
|
||||
|
||||
## Управление
|
||||
|
||||
@@ -49,13 +82,21 @@ UI рисуется кодом, без внешних текстур; Handjet д
|
||||
| --- | --- |
|
||||
| Выбор, меню, перетаскивание | ЛКМ |
|
||||
| Контекстное меню | ПКМ |
|
||||
| Поворот фигуры во время drag | R или ПКМ |
|
||||
| Пакет из «Приёма» → папка | drag&drop |
|
||||
| Файл из папки → карточка запроса | drag&drop |
|
||||
| Поиск по имени в текущей папке | после открытия «Поиска» |
|
||||
| Мини-игра соединения: три попадания | ЛКМ или `Space` |
|
||||
| Мини-игра подготовки: отправить | `Enter` |
|
||||
| Пауза | `Esc` |
|
||||
| Панель соединения: отмена | `Esc` |
|
||||
| Файл из папки → карточка запроса | drag&drop (TCP — только после соединения) |
|
||||
| Файл ↔ стол сортировки | drag&drop |
|
||||
| TCP-соединение | кнопка «ПОДКЛЮЧИТЬСЯ», 3 попадания в зелёную зону |
|
||||
| Отправка контейнера | кнопка «ОТПРАВИТЬ» |
|
||||
| Отклонить запрос | кнопка «ОТКЛОНИТЬ» |
|
||||
| Закрыть TCP-соединение | «ОТКЛЮЧИТЬ» на слоте |
|
||||
| Открепить файл | двойной клик по чипу |
|
||||
| TCP / UDP | переключатель на карточке запроса до соединения |
|
||||
| Мини-игра: действие | ЛКМ / Space / стрелки |
|
||||
| `dots`: линия | нажать на «С» и вести курсор по клеткам |
|
||||
| Пропустить мини-игру | «ПРОПУСТИТЬ»/«СДАТЬСЯ» + второй клик |
|
||||
| Результат мини-игры | «ПРОДОЛЖИТЬ» или Esc |
|
||||
| Пауза | Esc |
|
||||
|
||||
## Сделано на FEIS Game Jam
|
||||
|
||||
@@ -70,10 +111,12 @@ FEIS Game Jam, 2026. Команда — 4 человека. Godot 4.7.2, GDScrip
|
||||
|
||||
## Ассеты и лицензии
|
||||
|
||||
Шрифты **Handjet** и **Press Start 2P** — SIL OFL 1.1. UI, иконки, маскот
|
||||
**БАЙТ** и `icon.svg` нарисованы кодом/командой — MIT. SFX и `hum.wav`
|
||||
сгенерированы скриптом проекта — MIT/CC0. Код — MIT. Полный список и
|
||||
инструкции по воспроизведению: [ASSETS.md](https://git.194-146-38-78.sslip.io/admin/Game-JAM/-/blob/main/ASSETS.md).
|
||||
Шрифты **Golos Text** и **Press Start 2P** — SIL OFL 1.1; **Kechusilka** —
|
||||
лицензия подтверждена владельцем проекта; **DisplayOTF** — freeware/personal
|
||||
(Denis A Serikov). UI, пиксельные иконки (`art/icons/`), маскот **БАЙТ** и
|
||||
icon.svg сделаны командой — MIT. SFX и hum.wav сгенерированы скриптом проекта
|
||||
— MIT/CC0. Код — MIT. Полный список и инструкции по воспроизведению:
|
||||
[ASSETS.md](https://git.194-146-38-78.sslip.io/admin/Game-JAM/-/blob/main/ASSETS.md).
|
||||
|
||||
## Ссылки
|
||||
|
||||
@@ -89,19 +132,19 @@ FEIS Game Jam, 2026. Команда — 4 человека. Godot 4.7.2, GDScrip
|
||||
|
||||

|
||||
|
||||
2. **Подготовка.** «Подготовка: склад и папки, SLA 128/200, магазин откроется после первого часа».
|
||||
2. **Подготовка.** «Подготовка: сетка папок, стол сортировки 4×6, SLA 60/60; магазин откроется после первого часа».
|
||||
|
||||

|
||||
|
||||
3. **Час пик.** «Час пик: 3 пакета в приёме и 2 запроса с таймерами/протоколами/слотами».
|
||||
3. **Час пик.** «Час пик: пакеты-фигуры в приёме и запросы с таймерами, протоколами и слотами».
|
||||
|
||||

|
||||
|
||||
4. **Атаки.** «DDoS-баннер в приёме и фишинговый запрос с ???».
|
||||
4. **Атаки.** «Мини-игра атаки: час на паузе, качество и вердикт после действия».
|
||||
|
||||

|
||||
|
||||
5. **Магазин.** «Магазин после часа: доступные улучшения, SLA +24, без крестика».
|
||||
5. **Магазин.** «Магазин после часа: три предложения, реролл и замок, без крестика».
|
||||
|
||||

|
||||
|
||||
@@ -109,20 +152,20 @@ FEIS Game Jam, 2026. Команда — 4 человека. Godot 4.7.2, GDScrip
|
||||
|
||||
## Теги
|
||||
|
||||
`godot` `game jam` `management` `simulation` `retro` `90s` `pixel art`
|
||||
`singleplayer` `short` `web`
|
||||
godot game jam management simulation retro 90s pixel art singleplayer short web
|
||||
|
||||
---
|
||||
|
||||
## Перед публикацией (служебное, в текст страницы не вставлять)
|
||||
|
||||
- [ ] Пересобрать `build/web` из текущего кода и загрузить в itch.io:
|
||||
**HTML5**, 1920×1080, без fullscreen-lock.
|
||||
- [ ] Загрузить 6 кадров из `docs/screenshots/`: 3 обязательных — `main_menu.png`,
|
||||
`hour.png`, `attacks.png`; дополнительно — `prep.png`, `shop.png`,
|
||||
`settings.png`; `cover.png` (630×500) — обложка страницы.
|
||||
- [ ] Сверить ссылку на репозиторий и ветку (`main`); при другой ветке
|
||||
заменить `main` на фактическую.
|
||||
- [ ] Пересобрать build/web из текущего кода и загрузить в itch.io:
|
||||
HTML5, 1920×1080, без fullscreen-lock.
|
||||
- [ ] Пересобрать скриншоты под v3 (запросы с именами файлов, TCP-полоска,
|
||||
иконки, сетка 9×6, 3 оффера) и загрузить 6 кадров из docs/screenshots/:
|
||||
обязательные — main_menu.png, hour.png, attacks.png; дополнительно —
|
||||
prep.png, shop.png, settings.png; cover.png (630×500) — обложка страницы.
|
||||
- [ ] Сверить ссылку на репозиторий и ветку (main); при другой ветке
|
||||
заменить main на фактическую.
|
||||
- [ ] Прогнать полный забег вручную на itch.io, проверить сохранение после
|
||||
перезагрузки вкладки.
|
||||
- [ ] Отправить страницу и сборку в джем.
|
||||
@@ -1,5 +1,9 @@
|
||||
# TEST_PLAN — матрица тестов PEAK HOUR
|
||||
|
||||
> **Исторический документ v1.** Актуальная матрица и запуск —
|
||||
> `scripts/run_all_tests.sh` + README §Тесты; числа v3 — `data/balance.json`,
|
||||
> GDD v3.
|
||||
|
||||
> Числовые данные — только `data/balance.json` и `docs/DECISIONS.md`.
|
||||
> Юнит-Runner — headless-прогон `tests/run_tests.gd`
|
||||
> (`godot --headless --path . --script tests/run_tests.gd`).
|
||||
@@ -65,6 +69,44 @@
|
||||
| --- | --- | --- | --- | --- |
|
||||
| A01 | Дивиденд и репозиторий | git-история + отчёт об ИИ заполнены | все протоколы/джем критерии проходятся, **git push не сделать** | ревизор feis-regulator |
|
||||
| A02 | RU-only | grep по `RU` в `strings_ru.json` и интерфейсе | пользовательские шапки — на русском, никаких тайных строк | аудит |
|
||||
| A03 | Шрифты и лицензии | `ASSETS.md`: Handjet OFL.txt, Press Start 2P OFL | только OFL/MIT-лицензии, OFL-файлы в репо, Pixelify Sans из темы удалён | аудит |
|
||||
| A03 | Шрифты и лицензии | `ASSETS.md`: Golos/Press Start 2P OFL, Kechusilka owner-confirmed, DisplayOTF freeware | лицензии зафиксированы, файлы/тексты в репо, Handjet/Pixelify Sans вне темы | аудит |
|
||||
| A04 | Без секретов | grep по `password|token|key|secret` в репо | сбосов нет, все данные публичных форматов | аудит |
|
||||
| A05 | itch.io | загрузка `build/web/index.html` | игра стартует в 1280×720, RU, никакого зависания | гей-смоук |
|
||||
|
||||
## 7. Дельты v3.1: TCP, UDP, темп и UI
|
||||
|
||||
Короткая матрица под текущую реализацию; числа — из `data/balance.json` и
|
||||
`scripts/core/hour_director.gd`, а не из исторических таблиц выше. Полный
|
||||
набор + parse-check запускается через `scripts/run_all_tests.sh` /
|
||||
`.ps1`; список в runner — источник истины, счёт наборов здесь не
|
||||
фиксируется. Core-наборы (`test_hour`, `test_economy`, `test_save`,
|
||||
`test_slots`, `test_controller`, `test_integration`, `test_fuzz`,
|
||||
`test_full_playthrough`, `test_shop`, `test_shop_modal`) обновлены и зелёные;
|
||||
UI-наборы (`test_main_*`, `ui_smoke`) прогоняются отдельно.
|
||||
|
||||
| № | Что проверяем | Как | Ожидаемо |
|
||||
| --- | --- | --- | --- |
|
||||
| T01 | Файлы до TCP-соединения | drag файла в TCP-карточку; прямой `attach_file` | drag отклонён; backend: «СНАЧАЛА УСТАНОВИТЕ СОЕДИНЕНИЕ», `attached` пуст |
|
||||
| T02 | Успех соединения | 3 попадания в `ConnectionGame` | создан `TcpConnection`, занят out-слот, в слоте имя + IP, мини-игра отправки не открылась |
|
||||
| T03 | Отправка после соединения | прикрепить все файлы, нажать `ОТПРАВИТЬ` | открылась мини-игра отправки; при нехватке файлов — короткий тост, отправки нет |
|
||||
| T04 | Успешная TCP-отправка | пройти мини-игру и передачу | запрос исчез, файлы вернулись в папку и доступны повторно, выплата TCP, слот `tcp_connection` остался занятым idle |
|
||||
| T05 | Ручное отключение | нажать `ОТКЛЮЧИТЬ` на idle-слоте | слот свободен сразу, без мини-игры и проверок |
|
||||
| T06 | Отключение во время передачи | `disconnect_connection` в `sending` | выплаты нет, файлы не списаны и вернулись в папки, запрос активен, соединения нет |
|
||||
| T07 | Reject/timeout/конец часа | отклонить запрос с соединением; дождаться таймаута/конца часа | соединение закрыто, слот свободен |
|
||||
| T08 | Общий слот | 1 слот, TCP-соединение, затем приём пакета | приём заблокирован: «ВСЕ КАНАЛЫ ЗАНЯТЫ», это ожидаемо |
|
||||
| U01 | Шанс UDP | уровни `lot_udp` 0–4 | 40 / 50 / 60 / 70 / 80 %, выше 80 % не растёт |
|
||||
| U02 | UDP-успех | `udp_roll_override = 0` | обычная выплата UDP (×0.85), файлы остаются в папке, запрос удалён |
|
||||
| U03 | UDP-провал | `udp_roll_override = 1` | −3 SLA, без денег, файлы на месте, запрос активен, `udp_failures +1`, слот свободен, streak 0 |
|
||||
| U04 | Повтор UDP | после провала подождать 4 с и нажать `ОТПРАВИТЬ` | снова мини-игра отправки + передача; до 4 с — «UDP: ПОВТОР ЧЕРЕЗ N с» |
|
||||
| U05 | Индикация UDP | карточка UDP до и после провала | `UDP · ШАНС N %`, затем `ПОТЕРЯ ПАКЕТА · ПОВТОР N с` |
|
||||
| G01 | Магазин | каталог, покупка `lot_udp` до 4 уровня | 9 лотов, дерево 8 825 $, 5-й уровень не продаётся |
|
||||
| UI01 | Кнопки карточки | TCP/UDP-карточка | `ОТПРАВИТЬ`/`ОТКЛОНИТЬ` слева снизу, `ПОДКЛЮЧИТЬСЯ` справа снизу только у TCP |
|
||||
| UI02 | Открепление | двойной клик по прикреплённому чипу | файл откреплён, отдельной кнопки `ОТКРЕПИТЬ` нет |
|
||||
| UI03 | Слот соединения | TCP-слот idle/передача | имя клиента, IP, `ОТКЛЮЧИТЬ`; при передаче — прогресс, без данных запроса |
|
||||
| UI04 | Шрифты/кегли | тема, логотип, счётчики | Kechusilka 11/13/17/26, DisplayOTF 46/34/14/18, цифры Press Start 2P |
|
||||
| UI05 | «ЖАРА» | меню-бар, SLA ниже 40 %, атака | `TemperatureGauge` вместо часов 03:42, пульс SLA/CRIT, тряска панелей ≤2 px; `reduce_motion` и headless отключают движение |
|
||||
| P01 | Длительность часов | H1/H2/H9, `hour_duration()`, таймер | 120/150/360 с; забег 36 мин; окно спавна 75 % |
|
||||
| P02 | Окно атак | H2 и H9 | H2 37,5–127,5 с; H9 60–306 с; атаки не выходят за `duration−20` |
|
||||
| P03 | Каталог файлов | успешная отправка, затем новый запрос | файл остался в папке, `cooldown_after_send = 0`, может быть затребован повторно |
|
||||
| P04 | Приход по темпу | прогон часа с полным буфером | спавн не сгорает, без переноса (carry = 0); интервал = `spawn_window / files` ±20 % |
|
||||
| P05 | Запросы без `request_cap` | H1/H5/H9, конец часа | лимит только `active_requests`; после `duration−10` новых нет; анти-голодание только для первого запроса |
|
||||
@@ -1,15 +1,23 @@
|
||||
# UI_REFRESH — UI v2 «Kaleidos»
|
||||
# UI_REFRESH — UI v3 «Kaleidos»
|
||||
|
||||
Статус: **реализовано** в v1.2. Это короткая карта текущего UI, а не план
|
||||
Статус: **реализовано** в v3. Это короткая карта текущего UI, а не план
|
||||
интеграции. Стиль: тёплая бумага, тонкие 1 px контуры, мягкие тени,
|
||||
скругления 3–4 px, бирюзовый акцент, меньше визуального шума.
|
||||
|
||||
## Окно и масштаб
|
||||
|
||||
- Окно **1600×900**, логический viewport **1280×720**.
|
||||
- Окно **1920×1080**, логический viewport **1280×720**.
|
||||
- `stretch/mode = canvas_items`, `aspect = keep`, `scale_mode = fractional`.
|
||||
- Шрифты: Handjet (текст), Press Start 2P (логотип/цифры).
|
||||
- Всё рисуется кодом; внешних текстур UI нет.
|
||||
- Шрифты: **Kechusilka Regular** — тело текста (нормализован под Golos,
|
||||
x-height 0.530 em); **DisplayOTF** — крупный текст и wordmark; **Press Start
|
||||
2P** — цифры/счётчики/брендовый акцент; **Golos Text** — fallback для
|
||||
отсутствующих глифов. Handjet и Pixelify Sans в теме не используются
|
||||
(D51, D56).
|
||||
- Типографическая шкала: **11 / 13 / 17 / 26 px**. Крупнее: wordmark 46,
|
||||
«ПОБЕДА/ПОРАЖЕНИЕ» 34, титлбар 14, «РЕЗУЛЬТАТ» 18. Подписи файлов —
|
||||
12–16 px по размеру клетки.
|
||||
- Почти всё рисуется кодом; внешняя графика — только пиксельные иконки
|
||||
`art/icons/*.png` (32×32, `nearest`).
|
||||
|
||||
## Палитра
|
||||
|
||||
@@ -22,26 +30,30 @@ error #C25B4A · success #3F8F5E · warn #C58A2E · shadow #00000018
|
||||
|
||||
Контур — 1 px `Theme90.BORDER`; тень только у карточек/панелей/модалок.
|
||||
Радиус: 3 px — кнопки/строки, 4 px — карточки/панели, 6–8 px — модалки.
|
||||
Кегли: 10 / 12 / 16 / 24.
|
||||
Кегли: 11 / 13 / 17 / 26.
|
||||
|
||||
## Компоненты
|
||||
|
||||
| Файл | Поведение v2 |
|
||||
| Файл | Поведение v3 |
|
||||
| --- | --- |
|
||||
| `theme90.gd` | палитра, `StyleBoxFlat`-хелперы, бумажный фон, `draw_meter`, иконки кодом |
|
||||
| `theme90.gd` | палитра, `StyleBoxFlat`-хелперы, бумажный фон, `draw_meter`, пиксельные иконки из `art/icons` |
|
||||
| `button90.gd` | высота 24, скругление 3, teal-hover, pressed inset, `active` — pale-teal |
|
||||
| `panel90.gd` | светлые панели; `GROUP` — тонкая полоса заголовка |
|
||||
| `window_frame.gd` | светлый титлбар, тёмный текст, волосяная линия; крестик только при `close_enabled` |
|
||||
| `status_bar.gd` | одна светлая полоса, 1 px разделители, цветные шкалы |
|
||||
| `file_card.gd` | белая карточка, иконка 24×24, зелёная attached-галочка, tiny locked/malicious |
|
||||
| `request_card.gd` | компактные слоты 20 px, галочка attached, мигающее «ожидается» |
|
||||
| `folder_row.gd`, `tree_panel.gd`, `file_grid.gd` | мягкое teal-выделение, светлые поверхности |
|
||||
| `status_bar.gd` | одна светлая полоса, 1 px разделители, цветные шкалы; при SLA < 40 % или уровне «ЖАРЫ» 3 — пульс подчёркивания SLA и линия CRIT |
|
||||
| `temperature_gauge.gd` | «ЖАРА» в меню-баре: колба 216×18, деления 0.34/0.58/0.80, 36–95 °C, tooltip с разбором состояния |
|
||||
| `stress_overlay.gd` | полноэкранный heat-слой; alpha-only шейдер `stress.gdshader` без чтения экрана, headless-safe |
|
||||
| `ui_motion.gd` | единые тайминги/кривые UI, `UiMotion.reduced` для «Снизить эффекты» |
|
||||
| `file_card.gd` | белая карточка, пиксельная иконка, зелёная attached-галочка, tiny locked/malicious |
|
||||
| `request_card.gd` | **400×128**, сетка обязательных файлов **2×2**, счётчик, прогноз мини-игры, `ОТПРАВИТЬ`/`ОТКЛОНИТЬ` слева снизу, `ПОДКЛЮЧИТЬСЯ` справа снизу (только TCP), `ПОТЕРЯН`, полное имя по hover, открепление двойным кликом по чипу |
|
||||
| `slot_cell.gd` | слот 236×44; для `tcp_connection` — имя клиента, IP, статус/прогресс и кнопка `ОТКЛЮЧИТЬ`; файлов и required в слоте нет |
|
||||
| `folder_row.gd`, `tree_panel.gd`, `file_grid.gd` | мягкое teal-выделение, светлые поверхности, иконки в сетке и drag-preview |
|
||||
| `scroll_bar.gd` | тонкая дорожка, teal только при hover; скрыт без прокрутки |
|
||||
| `shop_modal.gd` | 10 позиций (включая `SLA +24`), фильтр `Data.available_shop()`, без крестика; «Закрыть»/`Esc` |
|
||||
| `main_menu.gd` | логотип, слоган, 4 кнопки, 3 шага смены, сводка; VHS-фон только здесь |
|
||||
| `vhs_overlay.gd` | процедурный CRT/VHS-слой меню (RefCounted): скан-линии, зерно, виньетка, полоса |
|
||||
| `shop_modal.gd` | 9 лотов v3 (включая `lot_udp`), 3 оффера, рероллы/замки, без крестика; «Закрыть»/`Esc` |
|
||||
| `main_menu.gd` | логотип, слоган, кнопки (включая **«ОБУЧЕНИЕ»**), 3 шага смены, сводка; VHS-фон только здесь |
|
||||
| `vhs_overlay.gd` | нативный CRT/VHS-слой меню: скан-линии, зерно, виньетка, полоса |
|
||||
| `tutorial_mascot.gd` | маскот **БАЙТ** 64–96 px + облачко, не ловит мышь |
|
||||
| `main.gd` | сборка экрана, магазин после часа, поиск, автообновление, скролл приёма |
|
||||
| `main.gd` | сборка экрана, магазин после часа, поиск, автообновление, скролл приёма, шаги туториала и пульсирующие рамки |
|
||||
|
||||
## Ключевые сценарии
|
||||
|
||||
@@ -53,54 +65,128 @@ error #C25B4A · success #3F8F5E · warn #C58A2E · shadow #00000018
|
||||
- **Приём:** скролл для 5–8 пакетов; после приёма/перемещения текущая папка и
|
||||
дерево обновляются автоматически.
|
||||
- **Папки:** имена из `names_ru.json`, уникализируются в секции (`Имя`,
|
||||
`Имя 2`); пустые папки можно удалять.
|
||||
- **Attached:** зелёная галочка у файла и слота запроса.
|
||||
- **Туториал:** БАЙТ и короткие подсказки в первом часе; без пошагового
|
||||
сценария.
|
||||
`Имя 2`); пустые папки можно удалять. Сетка растёт до **9×6**.
|
||||
- **Запросы:** карточка 400×128 показывает имена 1–4 обязательных файлов
|
||||
сеткой 2×2. Наведение на чип показывает полное имя строкой внутри карточки
|
||||
и в tooltip; потерянный файл — красный чип с «ПОТЕРЯН». Прогноз
|
||||
`МИНИ-ИГРА: … · Tn` и `СЛОЖНОСТЬ: N` считается по обязательным файлам ещё
|
||||
до прикрепления. Кнопки: `ОТПРАВИТЬ` и `ОТКЛОНИТЬ` — слева снизу,
|
||||
`ПОДКЛЮЧИТЬСЯ` — справа снизу (только TCP). Открепление — двойной клик по
|
||||
чипу; отдельной `ОТКРЕПИТЬ` нет.
|
||||
- **TCP:** кнопка `ПОДКЛЮЧИТЬСЯ` открывает полоску соединения 640×240
|
||||
(3 попадания, 440/340/280 px/с). До соединения drag файла в карточку
|
||||
отклоняется. После успеха слот показывает имя клиента и случайный IP,
|
||||
мини-игра отправки сама не открывается; её запускает `ОТПРАВИТЬ`, когда
|
||||
прикреплены все обязательные файлы. После успешной отправки TCP-слот
|
||||
остаётся занятым (idle) с кнопкой `ОТКЛЮЧИТЬ`; отключение мгновенно
|
||||
освобождает слот.
|
||||
- **UDP:** соединения нет. Карточка показывает `UDP · ШАНС N %`, после
|
||||
провала — `ПОТЕРЯ ПАКЕТА · ПОВТОР N с`. Повтор — снова `ОТПРАВИТЬ`
|
||||
(мини-игра + передача) после 4 с.
|
||||
- **Attached:** зелёная галочка у файла и чипа запроса.
|
||||
- **Пустая отправка:** плашка «ЖДЁМ ЗАПРОС» и подсказка «Следующий запрос
|
||||
~N с» / «До конца часа запросов не будет» / «Нет свободных файлов для
|
||||
запроса».
|
||||
- **Туториал:** 5 шагов (пакет → прикрепление → TCP → мини-игра → SLA/атаки),
|
||||
пульсирующая рамка вокруг цели, кнопка «ДАЛЕЕ», сохранение
|
||||
`tutorial_done`, повтор из меню кнопкой «ОБУЧЕНИЕ».
|
||||
- **Настройки:** кнопка на главном экране открывает рабочую модалку.
|
||||
- **Магазин без X:** закрытие только «Закрыть» или `Esc`.
|
||||
- **Calm-фон:** при включённом «Спокойном фоне» Backdrop рисует плоскую
|
||||
`PAPER` без dither; серый `#C0C0C0` не появляется.
|
||||
- **Итоги забега:** строка «ОТКРЫТО: …» показывает полученные мета-награды;
|
||||
`Esc` ведёт в главное меню, без soft-lock.
|
||||
- **Drag пакета на слот:** перетаскивание пакета на TCP-слот запускает
|
||||
настоящее соединение, а не только визуальный перенос.
|
||||
- **Слоты:** полоса подписана «СЛОТЫ» — слоты общие для приёма и отправки.
|
||||
TCP-соединение может занять единственный слот и заблокировать приём; это
|
||||
видно по занятому слоту `tcp_connection`.
|
||||
- **Подписи занятости диска** в панели файлов и подвале сетки больше нет:
|
||||
остаётся только счётчик файлов/клеток.
|
||||
- **Тосты:** сверху по центру (400 px, x = 440…840), стопкой вниз от `y=44`;
|
||||
панель ОТПРАВКА не перекрывают.
|
||||
- **Подписи файлов:** 3 / 2 / 1 строки при клетке ≥88 / 72–87 / <72 px,
|
||||
кегль 12–16 px с авто-уменьшением до 11 px, полное имя — в tooltip.
|
||||
- **Чипы запросов:** имя 10 px (9 px, только если это позволяет показать имя
|
||||
целиком); при наведении — полное имя и папка в строке карточки и tooltip.
|
||||
|
||||
## Мини-игры
|
||||
|
||||
- Общая шапка 640×360: две строки — название и цель, полоса прогресса в
|
||||
шапке, акцентная линия у цели.
|
||||
- `dots` — непрерывная линия протяжкой; быстрое движение сэмплируется, а
|
||||
недопустимые клетки игнорируются молча.
|
||||
- `ПРОПУСТИТЬ`/`СДАТЬСЯ` требует двух кликов: первый взводит кнопку, второй
|
||||
подтверждает.
|
||||
- Результат: имя игры, `q%`, полоса качества, вердикт, детали, `ПРОДОЛЖИТЬ`;
|
||||
`Esc` = продолжить.
|
||||
- Подсказки атак — внутри окна, в нижней полосе, без обрезки.
|
||||
|
||||
## «ЖАРА» и движение
|
||||
|
||||
**Меню-бар.** Вместо часов **03:42** и скрытой даты стоит `TemperatureGauge`
|
||||
(216×18, высота меню-бара 20 не меняется): подпись уровня, колба, деления на
|
||||
порогах **0.34 / 0.58 / 0.80**, маркер и число **36–95 °C** справа. В покое и
|
||||
в подготовке — пустая колба и 36 °C без инерции. В критическом уровне —
|
||||
штриховка, пульс подчёркивания и «КРИТ». Tooltip: SLA, запросы/срочные, атаки
|
||||
и тир, канал/буфер, очередь/отправка.
|
||||
|
||||
**Оверлей.** `StressOverlay` — полноэкранный `ColorRect` с
|
||||
`shaders/stress.gdshader`. Шейдер **alpha-only**, без `hint_screen_texture`:
|
||||
читает только `UV`/`FRAGCOORD`/`TIME`, поэтому модалки и тосты поверх него не
|
||||
тонируются. Центр почти прозрачен (alpha ≤ 0.045), край ≤ 0.24: тон, виньетка,
|
||||
скан-линии, зерно, медленная полоса, сердцебиение на уровне ≥2 и вспышка.
|
||||
Heat-модель — чистая функция от снимка часа (SLA, запросы, атаки, канал,
|
||||
буфер, очередь, прогресс) плюс импульсы событий; сглаживание — `UiMotion.k()`,
|
||||
без твинов в `_process`. В headless материал не создаётся, слой скрыт.
|
||||
|
||||
**Пульс и тряска.** `StatusBar` пульсирует подчёркиванием SLA при SLA < 40 %
|
||||
или уровне 3 (период 1,1 → 0,55 с). Событийная тряска панелей — только
|
||||
`position`, целые пиксели, **≤2 px**, ~140 мс; layout и drop-логика не
|
||||
меняются.
|
||||
|
||||
**Lane B.** Сглаживание drag (tau 0.035), наклон превью по скорости (до 0.045
|
||||
rad), pop/rotate-pop, stagger появления карточек (0.03 с, максимум 0.09),
|
||||
плавные meter-полосы, вход/выход тостов — всё через `UiMotion`. Анимации не
|
||||
влияют на позиции источника/цели.
|
||||
|
||||
**«Снизить эффекты».** Настройка выставляет `UiMotion.reduced` и отключает
|
||||
тряску, наклон/инерцию drag, пульсы, вспышки и stagger. Шейдер оставляет
|
||||
только статичный тон и виньетку. В headless движение и твины выключены.
|
||||
|
||||
## VHS-эффект главного меню
|
||||
|
||||
Тонкий CRT/VHS-слой есть **только на главном меню** (`scripts/ui/vhs_overlay.gd`).
|
||||
Это `RefCounted`, а не Node/Control: меню само рисует слой в `MainMenu._draw()`.
|
||||
Тонкий CRT/VHS-слой есть **только на главном меню** (`shaders/vhs.gdshader` +
|
||||
`scripts/ui/vhs_overlay.gd`). Полноэкранный `ColorRect` с canvas_item-шейдером
|
||||
читает готовый кадр через `hint_screen_texture`.
|
||||
|
||||
Порядок отрисовки:
|
||||
Ключевое — **native-res**: эффект считается в физических пикселях окна
|
||||
(`FRAGCOORD`/`SCREEN_PIXEL_SIZE`). При 1920×1080 скан-линии идут ровно каждые
|
||||
2 px, без блоков и banding; логика UI при этом остаётся 1280×720
|
||||
(`stretch/mode = canvas_items`), но VHS не растягивает 1280-текстуру.
|
||||
|
||||
1. бумага;
|
||||
2. VHS по фону — скан-линии, зерно, виньетка, полоса;
|
||||
3. карточка, правая панель и декоративные фигуры;
|
||||
4. мягкий VHS-проход по карточке и панели (0.72 силы, без виньетки);
|
||||
5. текст и кнопки.
|
||||
- screen texture сэмплится с линейной фильтрацией 1:1. Identity-проверка
|
||||
при 1920×1080: diff в области текста = 0, то есть картинка не мылится;
|
||||
- хроматика ≤0.0003 UV (~0.6 px у края кадра), зелёный канал не сдвигается —
|
||||
буквы не двоятся;
|
||||
- полоса меняет только яркость и не двигает изображение, поэтому текст
|
||||
под ней не «плывёт».
|
||||
|
||||
Поэтому слой не перекрывает меню, не перехватывает мышь и не может затереть
|
||||
кадр фоном подготовки. Кнопки — дети `MainMenu`, они рисуются поверх.
|
||||
Screen texture (`hint_screen_texture`) не используется: интерфейс не сэмплится
|
||||
и остаётся резким.
|
||||
|
||||
Параметры (`VhsOverlay`):
|
||||
Параметры (`VhsOverlay` / шейдер):
|
||||
|
||||
| Параметр | Значение | Смысл |
|
||||
| --- | --- | --- |
|
||||
| `intensity` | **0.42** (макс. 0.55) | общая сила слоя |
|
||||
| `SCANLINE_STEP` | 2 лог. px | шаг скан-линий, 1 px затемнения |
|
||||
| `SCANLINE_STRENGTH` | 0.07 | затемнение линий |
|
||||
| проход по карточке | ×0.72 | мягче фона, текст рисуется поверх |
|
||||
| зерно | тайл 128×128, 2 Гц | амплитуда ≤0.05, без мерцания |
|
||||
| виньетка | 0.13 | затемнение к краям кадра |
|
||||
| полоса | ~18 с на проход | мягкая тёмная, со светлой кромкой и дрожанием ±4 px |
|
||||
| `intensity` | **0.40** (макс. 0.55) | общая сила слоя |
|
||||
| `scanline_step` | 2 физ. px | тонкие ровные скан-линии |
|
||||
| `scanline_strength` | 0.06 | затемнение ≈2.4% при intensity 0.40 |
|
||||
| `aberration` | 0.0003 UV | мягкая хроматика, без двоения |
|
||||
| `grain` | 0.025, 2 Гц | слабое зерно, без мерцания |
|
||||
| `vignette` | 0.11 | затемнение к краям кадра |
|
||||
| `band` | 0.04, ~18 с на проход | редкая полоса, только яркость |
|
||||
|
||||
Скан-линии рисуются в канве меню (логические 1280×720) и масштабируются
|
||||
вместе с UI: на 1920×1080 шаг ≈3 физических px. Это осознанный компромисс —
|
||||
эффект не читает экранную текстуру, поэтому не бывает мыла и затирания UI.
|
||||
Слой — последний ребёнок `MainMenu`, `mouse_filter = IGNORE`,
|
||||
`process_mode = ALWAYS`. Модалки (настройки, подтверждение) — отдельные
|
||||
соседи в `modal_root`, рисуются поверх и **не** фильтруются.
|
||||
|
||||
В HEADLESS текстуры не создаются и слой не рисуется — тесты не тормозят.
|
||||
В HEADLESS материал не создаётся, слой скрыт — тесты не тормозят.
|
||||
|
||||
**Как отключить:**
|
||||
|
||||
@@ -116,5 +202,7 @@ godot --headless --path . --script scripts/ui/ui_smoke.gd
|
||||
bash scripts/run_all_tests.sh
|
||||
```
|
||||
|
||||
`ui_smoke` — 132 проверки, 0 провалов, `SMOKE OK`. Скриншоты для страницы —
|
||||
в `docs/screenshots/`; после смены UI их нужно переснять в 1600×900.
|
||||
`ui_smoke` и остальные наборы — см. `scripts/run_all_tests.sh` и `tests/`
|
||||
(число проверок/наборов может меняться между волнами). Скриншоты для страницы —
|
||||
в `docs/screenshots/`; после смены UI их нужно переснять под текущий вид
|
||||
(окно 1920×1080, логический кадр 1280×720).
|
||||
|
Before Width: | Height: | Size: 69 KiB After Width: | Height: | Size: 188 KiB |
|
Before Width: | Height: | Size: 52 KiB After Width: | Height: | Size: 1.2 MiB |
|
Before Width: | Height: | Size: 88 KiB After Width: | Height: | Size: 231 KiB |
|
Before Width: | Height: | Size: 82 KiB After Width: | Height: | Size: 1.4 MiB |
|
After Width: | Height: | Size: 133 KiB |
@@ -0,0 +1,40 @@
|
||||
[remap]
|
||||
|
||||
importer="texture"
|
||||
type="CompressedTexture2D"
|
||||
uid="uid://cg7ddssqqlh8q"
|
||||
path="res://.godot/imported/minigame.png-e653e1ec9e6eda804b745f4054348377.ctex"
|
||||
metadata={
|
||||
"vram_texture": false
|
||||
}
|
||||
|
||||
[deps]
|
||||
|
||||
source_file="res://docs/screenshots/minigame.png"
|
||||
dest_files=["res://.godot/imported/minigame.png-e653e1ec9e6eda804b745f4054348377.ctex"]
|
||||
|
||||
[params]
|
||||
|
||||
compress/mode=0
|
||||
compress/high_quality=false
|
||||
compress/lossy_quality=0.7
|
||||
compress/uastc_level=0
|
||||
compress/rdo_quality_loss=0.0
|
||||
compress/hdr_compression=1
|
||||
compress/normal_map=0
|
||||
compress/channel_pack=0
|
||||
mipmaps/generate=false
|
||||
mipmaps/limit=-1
|
||||
roughness/mode=0
|
||||
roughness/src_normal=""
|
||||
process/channel_remap/red=0
|
||||
process/channel_remap/green=1
|
||||
process/channel_remap/blue=2
|
||||
process/channel_remap/alpha=3
|
||||
process/fix_alpha_border=true
|
||||
process/premult_alpha=false
|
||||
process/normal_map_invert_y=false
|
||||
process/hdr_as_srgb=false
|
||||
process/hdr_clamp_exposure=false
|
||||
process/size_limit=0
|
||||
detect_3d/compress_to=1
|
||||
|
Before Width: | Height: | Size: 62 KiB After Width: | Height: | Size: 106 KiB |
|
Before Width: | Height: | Size: 49 KiB After Width: | Height: | Size: 1.2 MiB |
|
Before Width: | Height: | Size: 81 KiB After Width: | Height: | Size: 142 KiB |
@@ -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")
|
||||
|
||||
@@ -1,88 +1,168 @@
|
||||
extends Node
|
||||
## Data — доступ к JSON-данным из res://data (autoload).
|
||||
##
|
||||
## Файлы данных сейчас заготовки, поэтому отсутствующие ключи заменяются
|
||||
## безопасными дефолтами с push_warning; игра не должна падать из-за данных.
|
||||
## v2: новые секции balance.json (constants/hours/grid/shop_rules/shop/
|
||||
## minigames/attacks/payout/sla). Отсутствующие ключи заменяются безопасными
|
||||
## дефолтами с push_warning; игра не должна падать из-за данных.
|
||||
##
|
||||
## Legacy-API (tr, type_def, difficulty_for, shop_item, available_shop,
|
||||
## folder_name) сохранён для ещё не переписанных систем.
|
||||
|
||||
const DATA_DIR := "res://data"
|
||||
|
||||
const DEFAULT_TYPES := {
|
||||
"text": {
|
||||
"name_ru": "Текст", "ext": "txt", "icon": "text",
|
||||
"recv_time": 2.0, "send_time": 1.5, "recv_fee": 1,
|
||||
"base_pay": 80, "weight": 8, "minigame_coeff": 1.0, "size_kb": 64,
|
||||
"sla_loss": 2,
|
||||
"size_kb": 64,
|
||||
"cells": 1, "base_time_sec": 3, "recv_fee": 1,
|
||||
"base_pay": 18, "weight": 1, "minigame_coeff": 1, "sla_loss": 2,
|
||||
"shapes": ["O1"],
|
||||
},
|
||||
"sound": {
|
||||
"name_ru": "Звук", "ext": "mp3", "icon": "sound",
|
||||
"recv_time": 3.5, "send_time": 2.5, "recv_fee": 2,
|
||||
"base_pay": 140, "weight": 12, "minigame_coeff": 2.0, "size_kb": 3072,
|
||||
"sla_loss": 4,
|
||||
"size_kb": 3072,
|
||||
"cells": 2, "base_time_sec": 5, "recv_fee": 2,
|
||||
"base_pay": 30, "weight": 2, "minigame_coeff": 2, "sla_loss": 4,
|
||||
"shapes": ["I2H", "I2V"],
|
||||
},
|
||||
"photo": {
|
||||
"name_ru": "Фото", "ext": "jpg", "icon": "photo",
|
||||
"recv_time": 5.0, "send_time": 3.5, "recv_fee": 3,
|
||||
"base_pay": 220, "weight": 18, "minigame_coeff": 3.0, "size_kb": 5120,
|
||||
"sla_loss": 8,
|
||||
"size_kb": 5120,
|
||||
"cells": 3, "base_time_sec": 8, "recv_fee": 4,
|
||||
"base_pay": 44, "weight": 4, "minigame_coeff": 4, "sla_loss": 8,
|
||||
"shapes": ["I3H", "I3V", "L3a", "L3b", "L3c", "L3d"],
|
||||
},
|
||||
"video": {
|
||||
"name_ru": "Видео", "ext": "mp4", "icon": "video",
|
||||
"recv_time": 8.0, "send_time": 6.0, "recv_fee": 4,
|
||||
"base_pay": 380, "weight": 22, "minigame_coeff": 4.0, "size_kb": 20480,
|
||||
"sla_loss": 16,
|
||||
"size_kb": 20480,
|
||||
"cells": 4, "base_time_sec": 12, "recv_fee": 6,
|
||||
"base_pay": 69, "weight": 8, "minigame_coeff": 8, "sla_loss": 16,
|
||||
"shapes": ["I4H", "I4V", "O4", "T4a", "T4b", "T4c", "T4d",
|
||||
"L4a", "L4b", "L4c", "L4d", "J4a", "J4b", "J4c", "J4d", "S4", "Z4"],
|
||||
},
|
||||
}
|
||||
|
||||
## Таблица сложности — до часа 8 (D12); дальше используется последняя строка.
|
||||
## Таблица сложности v1 — оставлена для legacy difficulty_for().
|
||||
const DEFAULT_DIFFICULTY := [
|
||||
{"hour": 1, "max_requests": 2, "spawn_sec": 7.0},
|
||||
{"hour": 2, "max_requests": 3, "spawn_sec": 6.5},
|
||||
{"hour": 3, "max_requests": 3, "spawn_sec": 6.0},
|
||||
{"hour": 4, "max_requests": 4, "spawn_sec": 5.5},
|
||||
{"hour": 5, "max_requests": 4, "spawn_sec": 5.0},
|
||||
{"hour": 6, "max_requests": 5, "spawn_sec": 4.5},
|
||||
{"hour": 7, "max_requests": 5, "spawn_sec": 4.2},
|
||||
{"hour": 8, "max_requests": 6, "spawn_sec": 4.0},
|
||||
{"hour": 2, "max_requests": 5, "spawn_sec": 6.5},
|
||||
{"hour": 3, "max_requests": 7, "spawn_sec": 6.0},
|
||||
{"hour": 4, "max_requests": 9, "spawn_sec": 5.5},
|
||||
{"hour": 5, "max_requests": 10, "spawn_sec": 5.0},
|
||||
{"hour": 6, "max_requests": 11, "spawn_sec": 4.5},
|
||||
{"hour": 7, "max_requests": 11, "spawn_sec": 4.2},
|
||||
{"hour": 8, "max_requests": 12, "spawn_sec": 4.0},
|
||||
]
|
||||
|
||||
const DEFAULT_SHOP := [
|
||||
{"id": "slot_3", "name": "3-й слот", "cost": 250, "max_level": 1},
|
||||
{"id": "slot_4", "name": "4-й слот", "cost": 450, "max_level": 1},
|
||||
{"id": "buffer", "name": "Буфер +2", "cost": 200, "max_level": 2},
|
||||
{"id": "packet_timer", "name": "Таймер пакетов +4 с", "cost": 350, "max_level": 3},
|
||||
{"id": "zone", "name": "Зона +5 %", "cost": 300, "max_level": 3},
|
||||
{"id": "channel", "name": "Канал +25", "cost": 250, "max_level": 4},
|
||||
{"id": "section", "name": "Секция", "cost": 300, "max_level": 5},
|
||||
{"id": "search", "name": "Поиск", "cost": 250, "max_level": 1},
|
||||
{"id": "shield", "name": "Щит", "cost": 400, "max_level": 1},
|
||||
{"id": "shop_sla", "name": "SLA +24", "effect": "sla_add", "value": 24, "cost": 300, "max_level": 3},
|
||||
const DEFAULT_SEND_ORDER := ["dots", "sound", "text", "video", "photo", "maze"]
|
||||
const DEFAULT_ACHIEVABLE_SUMS := [
|
||||
1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20,
|
||||
21, 22, 24, 25, 26, 28, 32,
|
||||
]
|
||||
|
||||
## Прогрессия/открытия (дублирует data/balance.json → progression).
|
||||
const DEFAULT_PROGRESSION := {
|
||||
"unlocks": {
|
||||
"shop": {"run_hour": 2, "label": "Магазин"},
|
||||
"search": {"meta": "survive_hour_3", "label": "Поиск"},
|
||||
"shield": {"meta": "survive_hour_5", "label": "Щит"},
|
||||
"minigame_text": {"run_hour": 1},
|
||||
"minigame_sound": {"run_hour": 2},
|
||||
"minigame_photo": {"run_hour": 3},
|
||||
"minigame_video": {"run_hour": 4},
|
||||
"minigame_advanced": {"meta": "complete_run", "label": "Продвинутые мини-игры"},
|
||||
"attack_junk": {"run_hour": 2},
|
||||
"attack_ddos": {"run_hour": 2},
|
||||
"attack_phishing": {"run_hour": 3},
|
||||
"attack_ransomware": {"run_hour": 4},
|
||||
},
|
||||
"meta_rewards": {
|
||||
"survive_hour_3": {"min_hours": 3, "unlocks": ["search"]},
|
||||
"survive_hour_5": {"min_hours": 5, "unlocks": ["shield"]},
|
||||
"complete_run": {"min_hours": 9, "unlocks": ["minigame_advanced"]},
|
||||
},
|
||||
const DEFAULT_HOUR_FILES := [6, 6, 7, 8, 9, 9, 10, 10, 10]
|
||||
const DEFAULT_ACTIVE_REQUESTS := [1, 2, 2, 3, 3, 3, 4, 4, 4]
|
||||
const DEFAULT_ATTACKS := [0, 1, 1, 2, 2, 3, 3, 4, 4]
|
||||
const DEFAULT_INCOME_TARGET := [90, 220, 380, 560, 760, 980, 1200, 1450, 1700]
|
||||
const DEFAULT_RECV_INTERVAL := [12, 11, 10, 9, 8, 7.5, 7, 6.5, 6]
|
||||
const DEFAULT_SEND_INTERVAL := [20, 24, 18, 24, 24, 24, 24, 24, 24]
|
||||
## Длительность часа: 120 + 30 * (hour - 1) → 120..360.
|
||||
const DEFAULT_HOUR_DURATION_BASE := 120
|
||||
const DEFAULT_HOUR_DURATION_STEP := 30
|
||||
## Порядок: text, sound, photo, video (§2.5). Совпадает с
|
||||
## DEFAULT_REQUEST_TYPE_WEIGHTS: приём и запросы используют одни типы.
|
||||
const DEFAULT_TYPE_WEIGHTS := [
|
||||
[1.00, 0.00, 0.00, 0.00],
|
||||
[0.80, 0.20, 0.00, 0.00],
|
||||
[0.50, 0.50, 0.00, 0.00],
|
||||
[0.40, 0.30, 0.30, 0.00],
|
||||
[0.35, 0.35, 0.30, 0.00],
|
||||
[0.30, 0.30, 0.30, 0.10],
|
||||
[0.22, 0.23, 0.30, 0.25],
|
||||
[0.15, 0.25, 0.35, 0.25],
|
||||
[0.10, 0.15, 0.30, 0.45],
|
||||
]
|
||||
## Доли числа файлов в запросе: 1..4 (§2.6). Добавлен ключ "4" (4 файла).
|
||||
const DEFAULT_REQUEST_MIX := {"1": 0.76, "2": 0.24, "3": 0.0, "4": 0.0}
|
||||
## Веса выбора ТИПА файлов для required-слотов запроса (§2.6).
|
||||
## Отдельно от type_weights: те задают типы входящих пакетов.
|
||||
## Порядок: text, sound, photo, video.
|
||||
const DEFAULT_REQUEST_TYPE_WEIGHTS := [
|
||||
[1.00, 0.00, 0.00, 0.00],
|
||||
[0.80, 0.20, 0.00, 0.00],
|
||||
[0.50, 0.50, 0.00, 0.00],
|
||||
[0.40, 0.30, 0.30, 0.00],
|
||||
[0.35, 0.35, 0.30, 0.00],
|
||||
[0.30, 0.30, 0.30, 0.10],
|
||||
[0.22, 0.23, 0.30, 0.25],
|
||||
[0.15, 0.25, 0.35, 0.25],
|
||||
[0.10, 0.15, 0.30, 0.45],
|
||||
]
|
||||
|
||||
const DEFAULT_LOT_IDS := [
|
||||
"lot_slots", "lot_speed", "lot_sla", "lot_shield",
|
||||
"lot_money", "lot_folder", "lot_channel", "lot_timer", "lot_udp",
|
||||
]
|
||||
const DEFAULT_LOT_NAMES := [
|
||||
"Порты коммутатора", "Оптоволокно", "Резервный ИБП", "Заземление",
|
||||
"Корпоративный контракт", "Складской стеллаж", "Пропускная способность",
|
||||
"Регламент обработки", "UDP-повторитель",
|
||||
]
|
||||
const DEFAULT_LOT_EFFECTS := [
|
||||
"connection_slots", "transfer_speed", "sla_cap", "shield_charge",
|
||||
"money_mult", "folder_cells", "channel_capacity", "request_timer",
|
||||
"udp_success",
|
||||
]
|
||||
const DEFAULT_LOT_VALUES := [1, 0.10, 40, 1, 0.05, 1, 6, 7, 0.10]
|
||||
const DEFAULT_LOT_MAX := [4, 6, 4, 3, 8, 5, 6, 5, 4]
|
||||
const DEFAULT_LOT_PRICES := [
|
||||
[90, 200, 400, 700],
|
||||
[55, 75, 100, 135, 180, 245],
|
||||
[80, 160, 270, 430],
|
||||
[100, 220, 410],
|
||||
[70, 95, 130, 170, 220, 285, 365, 465],
|
||||
[50, 100, 180, 290, 440],
|
||||
[45, 70, 95, 130, 175, 230],
|
||||
[45, 75, 115, 165, 230],
|
||||
[60, 120, 210, 350],
|
||||
]
|
||||
const DEFAULT_LOT_WEIGHTS := [
|
||||
[1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 0.8, 0.8, 0.8],
|
||||
[1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 0.8, 0.8, 0.8],
|
||||
[0.8, 0.8, 0.8, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0],
|
||||
[0.3, 0.3, 0.3, 0.8, 0.8, 0.8, 1.0, 1.0, 1.0],
|
||||
[0.6, 0.6, 0.6, 1.0, 1.0, 1.0, 1.2, 1.2, 1.2],
|
||||
[1.0, 1.0, 1.0, 0.9, 0.9, 0.9, 0.8, 0.8, 0.8],
|
||||
[1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 0.9, 0.9, 0.9],
|
||||
[1.0, 1.0, 1.0, 0.9, 0.9, 0.9, 0.7, 0.7, 0.7],
|
||||
[0.8, 0.8, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0],
|
||||
]
|
||||
|
||||
const DEFAULT_SHOP_RULES := {
|
||||
"offers": 3,
|
||||
"unlock_hour": 2,
|
||||
"reroll_costs": [40, 80, 120],
|
||||
"max_rerolls": 3,
|
||||
"last_offer_weight": 0.5,
|
||||
"pity_hours": 2,
|
||||
"pity_weight": 1.5,
|
||||
}
|
||||
|
||||
const DEFAULT_GRID_DIMS := [[5, 4], [6, 4], [7, 5], [8, 5], [8, 6], [9, 6]]
|
||||
|
||||
var balance: Dictionary = {}
|
||||
var constants: Dictionary = {}
|
||||
var economy: Dictionary = {}
|
||||
var types: Dictionary = {}
|
||||
var hours: Array = []
|
||||
var grid: Dictionary = {}
|
||||
var shop_lots: Array = []
|
||||
var rules: Dictionary = {}
|
||||
var minigames: Dictionary = {}
|
||||
var attacks: Dictionary = {}
|
||||
var payout_rules: Dictionary = {}
|
||||
var sla_rules: Dictionary = {}
|
||||
|
||||
var difficulty: Array = []
|
||||
var shop: Array = []
|
||||
var names: Dictionary = {}
|
||||
@@ -100,9 +180,27 @@ func _ready() -> void:
|
||||
func load_all() -> void:
|
||||
_cache.clear()
|
||||
balance = load_json("balance.json")
|
||||
constants = _as_dict(balance.get("constants", null))
|
||||
economy = _as_dict(balance.get("economy", null))
|
||||
types = _merge_types(balance.get("types", null))
|
||||
hours = _load_hours(balance.get("hours", null))
|
||||
grid = _as_dict(balance.get("grid", null))
|
||||
if grid.is_empty():
|
||||
push_warning("Data: balance.json → grid отсутствует, используются дефолты")
|
||||
grid = {"folder_cols": 5, "folder_rows": 4, "gap": 6,
|
||||
"cell_min": 56, "cell_max": 104, "table_cols": 4, "table_rows": 6,
|
||||
"folder_dims": DEFAULT_GRID_DIMS.duplicate(true), "shapes": {}}
|
||||
shop_lots = _load_shop_lots(balance.get("shop", null))
|
||||
shop = shop_lots
|
||||
rules = _load_rules(balance.get("shop_rules", null))
|
||||
minigames = _as_dict(balance.get("minigames", null))
|
||||
if minigames.is_empty():
|
||||
push_warning("Data: balance.json → minigames отсутствует")
|
||||
minigames = {"games": {}, "selection": {}}
|
||||
attacks = _as_dict(balance.get("attacks", null))
|
||||
payout_rules = _as_dict(balance.get("payout", null))
|
||||
sla_rules = _as_dict(balance.get("sla", null))
|
||||
difficulty = _load_difficulty(balance.get("difficulty", null))
|
||||
shop = _load_shop(balance.get("shop", null))
|
||||
names = _load_names(load_json("names_ru.json"))
|
||||
strings = load_json("strings_ru.json")
|
||||
var progression = balance.get("progression", null)
|
||||
@@ -182,7 +280,7 @@ func type_def(type: String) -> Dictionary:
|
||||
return DEFAULT_TYPES["text"].duplicate(true)
|
||||
|
||||
|
||||
## Строка таблицы сложности; для hour > max — последняя строка.
|
||||
## Строка таблицы сложности; для hour > max — последняя строка (legacy).
|
||||
func difficulty_for(hour: int) -> Dictionary:
|
||||
var h := maxi(1, int(hour))
|
||||
if difficulty.is_empty():
|
||||
@@ -204,6 +302,141 @@ func difficulty_for(hour: int) -> Dictionary:
|
||||
return last.duplicate(true)
|
||||
|
||||
|
||||
# --- v2: часы --------------------------------------------------------------
|
||||
|
||||
## Строка бюджета часа (duration/files/active_requests/attacks/
|
||||
## income_target/type_weights/request_mix/request_type_weights).
|
||||
## hour > 9 → последняя строка.
|
||||
func hour_def(hour: int) -> Dictionary:
|
||||
var h := maxi(1, int(hour))
|
||||
if hours.is_empty():
|
||||
push_warning("Data: balance.json → hours отсутствует, дефолт часа %d" % h)
|
||||
return _default_hour_row(h)
|
||||
var prev: Dictionary = hours[0]
|
||||
for row in hours:
|
||||
if not (row is Dictionary):
|
||||
continue
|
||||
var row_hour := int(row.get("hour", 0))
|
||||
if row_hour == h:
|
||||
return row.duplicate(true)
|
||||
if row_hour < h:
|
||||
prev = row
|
||||
elif row_hour > h:
|
||||
return prev.duplicate(true)
|
||||
return (hours[hours.size() - 1] as Dictionary).duplicate(true)
|
||||
|
||||
|
||||
func hour_count() -> int:
|
||||
return maxi(1, hours.size())
|
||||
|
||||
|
||||
# --- v2: магазин -----------------------------------------------------------
|
||||
|
||||
func lot_defs() -> Array:
|
||||
return shop_lots.duplicate(true)
|
||||
|
||||
|
||||
func lot_def(id: String) -> Dictionary:
|
||||
for lot in shop_lots:
|
||||
if lot is Dictionary and str(lot.get("id", "")) == str(id):
|
||||
return lot.duplicate(true)
|
||||
push_warning("Data: нет лота '%s'" % str(id))
|
||||
return {}
|
||||
|
||||
|
||||
func shop_rules() -> Dictionary:
|
||||
return rules.duplicate(true)
|
||||
|
||||
|
||||
# --- v2: мини-игры ---------------------------------------------------------
|
||||
|
||||
func minigame_def(id: String) -> Dictionary:
|
||||
var games = minigames.get("games", null)
|
||||
if games is Dictionary:
|
||||
var def = (games as Dictionary).get(str(id), null)
|
||||
if def is Dictionary:
|
||||
return (def as Dictionary).duplicate(true)
|
||||
push_warning("Data: нет мини-игры '%s'" % str(id))
|
||||
return {}
|
||||
|
||||
|
||||
func minigame_selection() -> Dictionary:
|
||||
var out: Dictionary = {}
|
||||
var raw = minigames.get("selection", null)
|
||||
if raw is Dictionary:
|
||||
out = (raw as Dictionary).duplicate(true)
|
||||
if not (out.get("order", null) is Array) or (out.get("order", null) as Array).is_empty():
|
||||
out["order"] = DEFAULT_SEND_ORDER.duplicate()
|
||||
if not (out.get("achievable_sums", null) is Array) or (out.get("achievable_sums", null) as Array).is_empty():
|
||||
out["achievable_sums"] = DEFAULT_ACHIEVABLE_SUMS.duplicate()
|
||||
out["max_index"] = int(out.get("max_index", 26))
|
||||
var coeff: Dictionary = {}
|
||||
for key in ["text", "sound", "photo", "video"]:
|
||||
coeff[key] = int(type_def(key).get("minigame_coeff", DEFAULT_TYPES[key].get("minigame_coeff", 1)))
|
||||
out["coeff"] = coeff
|
||||
return out
|
||||
|
||||
|
||||
## Legacy-ступень для индекса (idx / 6 + 1), три ступени отправки.
|
||||
func minigame_tier(index: int) -> int:
|
||||
return clampi(1 + int(floor(float(maxi(0, index)) / 6.0)), 1, 3)
|
||||
|
||||
|
||||
# --- v2: атаки -------------------------------------------------------------
|
||||
|
||||
func attack_def(id: String) -> Dictionary:
|
||||
var kinds = attacks.get("kinds", null)
|
||||
if kinds is Dictionary:
|
||||
var def = (kinds as Dictionary).get(str(id), null)
|
||||
if def is Dictionary:
|
||||
return (def as Dictionary).duplicate(true)
|
||||
push_warning("Data: нет атаки '%s'" % str(id))
|
||||
return {}
|
||||
|
||||
|
||||
func attack_tier(hour: int) -> int:
|
||||
return clampi(1 + int(floor(float(maxi(1, int(hour)) - 2) / 2.0)), 1, 4)
|
||||
|
||||
|
||||
func attack_schedule() -> Array:
|
||||
var raw = attacks.get("schedule", null)
|
||||
if raw is Array:
|
||||
return (raw as Array).duplicate()
|
||||
return DEFAULT_ATTACKS.duplicate()
|
||||
|
||||
|
||||
# --- v2: сетка -------------------------------------------------------------
|
||||
|
||||
func grid_rules() -> Dictionary:
|
||||
return grid.duplicate(true)
|
||||
|
||||
|
||||
func shape_def(id: String) -> Dictionary:
|
||||
var shapes = grid.get("shapes", null)
|
||||
if shapes is Dictionary:
|
||||
var def = (shapes as Dictionary).get(str(id), null)
|
||||
if def is Dictionary:
|
||||
return (def as Dictionary).duplicate(true)
|
||||
push_warning("Data: нет фигуры '%s'" % str(id))
|
||||
return {}
|
||||
|
||||
|
||||
func shapes() -> Dictionary:
|
||||
var raw = grid.get("shapes", null)
|
||||
if raw is Dictionary:
|
||||
return (raw as Dictionary).duplicate(true)
|
||||
return {}
|
||||
|
||||
|
||||
func folder_dims_table() -> Array:
|
||||
var raw = grid.get("folder_dims", null)
|
||||
if raw is Array and not (raw as Array).is_empty():
|
||||
return (raw as Array).duplicate(true)
|
||||
return DEFAULT_GRID_DIMS.duplicate(true)
|
||||
|
||||
|
||||
# --- legacy shop API -------------------------------------------------------
|
||||
|
||||
func shop_item(id: String) -> Dictionary:
|
||||
for item in shop:
|
||||
if item is Dictionary and str(item.get("id", "")) == str(id):
|
||||
@@ -213,7 +446,6 @@ func shop_item(id: String) -> Dictionary:
|
||||
|
||||
|
||||
## Ключ открытия для позиции магазина. "" — позиция доступна всегда.
|
||||
## shop_sla продаётся вне прогрессии; поиск/щит открываются мета-наградами.
|
||||
func shop_unlock_key(id: String) -> String:
|
||||
match str(id):
|
||||
"shop_sla":
|
||||
@@ -228,7 +460,10 @@ func shop_unlock_key(id: String) -> String:
|
||||
|
||||
## Открыта ли позиция магазина по текущему состоянию Game (run_hour/meta).
|
||||
func shop_item_unlocked(item: Dictionary) -> bool:
|
||||
var key := shop_unlock_key(str(item.get("id", "")))
|
||||
var id := str(item.get("id", ""))
|
||||
if id.begins_with("lot_"):
|
||||
return true
|
||||
var key := shop_unlock_key(id)
|
||||
if key.is_empty():
|
||||
return true
|
||||
var game = _game_node()
|
||||
@@ -237,7 +472,7 @@ func shop_item_unlocked(item: Dictionary) -> bool:
|
||||
return true
|
||||
|
||||
|
||||
## Каталог магазина без закрытых позиций (shop_sla виден всегда).
|
||||
## Каталог магазина без закрытых позиций.
|
||||
func available_shop() -> Array:
|
||||
var out: Array = []
|
||||
for item in shop:
|
||||
@@ -261,6 +496,13 @@ func folder_name() -> String:
|
||||
return str(values[randi_range(0, values.size() - 1)])
|
||||
|
||||
|
||||
## Значение из constants с fallback (для других систем).
|
||||
func const_value(key: String, fallback_value = null):
|
||||
if constants.has(key):
|
||||
return constants[key]
|
||||
return fallback_value
|
||||
|
||||
|
||||
# --- служебное -------------------------------------------------------------
|
||||
|
||||
func _game_node():
|
||||
@@ -270,13 +512,31 @@ func _game_node():
|
||||
return null
|
||||
|
||||
|
||||
func _as_dict(raw) -> Dictionary:
|
||||
if raw is Dictionary:
|
||||
return (raw as Dictionary).duplicate(true)
|
||||
return {}
|
||||
|
||||
|
||||
func _load_unlocks(progression) -> Dictionary:
|
||||
if progression is Dictionary:
|
||||
var raw = progression.get("unlocks", null)
|
||||
if raw is Dictionary and not raw.is_empty():
|
||||
return raw.duplicate(true)
|
||||
push_warning("Data: balance.json → progression.unlocks отсутствует, используются дефолты")
|
||||
return DEFAULT_PROGRESSION["unlocks"].duplicate(true)
|
||||
return {
|
||||
"shop": {"run_hour": 2, "label": "Магазин"},
|
||||
"search": {"meta": "survive_hour_3", "label": "Поиск"},
|
||||
"shield": {"meta": "survive_hour_5", "label": "Щит"},
|
||||
"minigame_text": {"run_hour": 1},
|
||||
"minigame_sound": {"run_hour": 2},
|
||||
"minigame_photo": {"run_hour": 3},
|
||||
"minigame_video": {"run_hour": 4},
|
||||
"attack_junk": {"run_hour": 2},
|
||||
"attack_ddos": {"run_hour": 2},
|
||||
"attack_phishing": {"run_hour": 3},
|
||||
"attack_ransomware": {"run_hour": 4},
|
||||
}
|
||||
|
||||
|
||||
func _load_meta_rewards(progression) -> Dictionary:
|
||||
@@ -285,7 +545,12 @@ func _load_meta_rewards(progression) -> Dictionary:
|
||||
if raw is Dictionary and not raw.is_empty():
|
||||
return raw.duplicate(true)
|
||||
push_warning("Data: balance.json → progression.meta_rewards отсутствует, используются дефолты")
|
||||
return DEFAULT_PROGRESSION["meta_rewards"].duplicate(true)
|
||||
return {
|
||||
"survive_hour_3": {"min_hours": 3, "unlocks": ["search"]},
|
||||
"survive_hour_5": {"min_hours": 5, "unlocks": ["shield"]},
|
||||
"complete_run": {"min_hours": 9, "unlocks": ["minigame_advanced"]},
|
||||
}
|
||||
|
||||
|
||||
func _merge_types(raw) -> Dictionary:
|
||||
var out: Dictionary = DEFAULT_TYPES.duplicate(true)
|
||||
@@ -319,22 +584,81 @@ func _load_difficulty(raw) -> Array:
|
||||
return DEFAULT_DIFFICULTY.duplicate(true)
|
||||
|
||||
|
||||
func _load_shop(raw) -> Array:
|
||||
if raw is Array and not raw.is_empty():
|
||||
func _load_hours(raw) -> Array:
|
||||
if raw is Array and not (raw as Array).is_empty():
|
||||
var out: Array = []
|
||||
for row in raw:
|
||||
if row is Dictionary:
|
||||
out.append(row.duplicate(true))
|
||||
if not out.is_empty():
|
||||
out.sort_custom(func(a, b): return int(a.get("hour", 0)) < int(b.get("hour", 0)))
|
||||
return out
|
||||
push_warning("Data: balance.json → hours отсутствует, используются дефолты")
|
||||
var fallback: Array = []
|
||||
for i in range(9):
|
||||
fallback.append(_default_hour_row(i + 1))
|
||||
return fallback
|
||||
|
||||
|
||||
func _default_hour_row(hour: int) -> Dictionary:
|
||||
var idx := clampi(hour - 1, 0, 8)
|
||||
var type_keys := ["text", "sound", "photo", "video"]
|
||||
var weights: Dictionary = {}
|
||||
var req_weights: Dictionary = {}
|
||||
for i in range(type_keys.size()):
|
||||
weights[type_keys[i]] = DEFAULT_TYPE_WEIGHTS[idx][i]
|
||||
req_weights[type_keys[i]] = DEFAULT_REQUEST_TYPE_WEIGHTS[idx][i]
|
||||
return {
|
||||
"hour": maxi(1, hour),
|
||||
"duration": DEFAULT_HOUR_DURATION_BASE + DEFAULT_HOUR_DURATION_STEP * idx,
|
||||
"files": DEFAULT_HOUR_FILES[idx],
|
||||
"active_requests": DEFAULT_ACTIVE_REQUESTS[idx],
|
||||
"attacks": DEFAULT_ATTACKS[idx],
|
||||
"income_target": DEFAULT_INCOME_TARGET[idx],
|
||||
"send_interval": DEFAULT_SEND_INTERVAL[idx],
|
||||
"type_weights": weights,
|
||||
"request_mix": DEFAULT_REQUEST_MIX.duplicate(),
|
||||
"request_type_weights": req_weights,
|
||||
}
|
||||
|
||||
|
||||
func _load_shop_lots(raw) -> Array:
|
||||
if raw is Array and not (raw as Array).is_empty():
|
||||
var out: Array = []
|
||||
for item in raw:
|
||||
if item is Dictionary and not str(item.get("id", "")).is_empty():
|
||||
out.append(item.duplicate(true))
|
||||
if not out.is_empty():
|
||||
return out
|
||||
push_warning("Data: balance.json → shop отсутствует, используется дефолт")
|
||||
return DEFAULT_SHOP.duplicate(true)
|
||||
push_warning("Data: balance.json → shop отсутствует, используются %d дефолтных лотов" % DEFAULT_LOT_IDS.size())
|
||||
var fallback: Array = []
|
||||
for i in range(DEFAULT_LOT_IDS.size()):
|
||||
fallback.append({
|
||||
"id": DEFAULT_LOT_IDS[i],
|
||||
"name_ru": DEFAULT_LOT_NAMES[i],
|
||||
"desc_ru": "",
|
||||
"effect": DEFAULT_LOT_EFFECTS[i],
|
||||
"value": DEFAULT_LOT_VALUES[i],
|
||||
"max_level": DEFAULT_LOT_MAX[i],
|
||||
"prices": DEFAULT_LOT_PRICES[i].duplicate(),
|
||||
"level_names": [],
|
||||
"weight_by_hour": DEFAULT_LOT_WEIGHTS[i].duplicate(),
|
||||
})
|
||||
return fallback
|
||||
|
||||
|
||||
func _load_rules(raw) -> Dictionary:
|
||||
var out: Dictionary = DEFAULT_SHOP_RULES.duplicate(true)
|
||||
if raw is Dictionary and not raw.is_empty():
|
||||
for key in raw.keys():
|
||||
out[str(key)] = raw[key]
|
||||
return out
|
||||
push_warning("Data: balance.json → shop_rules отсутствует, используются дефолты")
|
||||
return out
|
||||
|
||||
|
||||
func _load_names(raw) -> Dictionary:
|
||||
if raw is Dictionary and not raw.is_empty():
|
||||
# Сохраняем все ключи файла (adjectives/nouns/organizations/folders/...),
|
||||
# а не только жёстко заданный набор.
|
||||
return raw.duplicate(true)
|
||||
push_warning("Data: names_ru.json пуст, используются пустые пулы имён")
|
||||
return {
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
extends Node
|
||||
## Game — верхнеуровневое состояние забега (autoload).
|
||||
##
|
||||
## v2-экономика: старт 50 $, SLA 60/60, 1 слот, сетка 5×4, канал 10,
|
||||
## буфер 6, таймер запроса 30 с. Уровни лотов хранятся в Game.upgrades
|
||||
## одновременно под id лота и под агрегированным ключом эффекта.
|
||||
##
|
||||
## Не знает о сценах и UI: только данные, переходы фаз и сигналы.
|
||||
## Фазы: menu → prep → hour → ... → results.
|
||||
|
||||
@@ -11,28 +15,48 @@ signal phase_changed(p)
|
||||
signal run_ended(reason)
|
||||
signal toast(text)
|
||||
|
||||
const START_MONEY := 150
|
||||
const START_SLA := 128.0
|
||||
const START_MONEY := 50
|
||||
const START_SLA := 60.0
|
||||
const HOURS_PER_RUN := 9
|
||||
const MAX_SLA := 200.0
|
||||
## Жёсткий кап SLA (main.gd показывает его в статус-баре). Рабочий кап —
|
||||
## sla_cap(): 60 + 40 * sla_level, не выше MAX_SLA.
|
||||
const MAX_SLA := 220.0
|
||||
|
||||
const REGISTRY_SCRIPT := preload("res://scripts/core/file_registry.gd")
|
||||
const SAVE_SCRIPT := preload("res://scripts/systems/save_system.gd")
|
||||
const SHOP_SCRIPT := preload("res://scripts/systems/shop.gd")
|
||||
|
||||
## Резерв, если Data ещё не готов (id → товар).
|
||||
const FALLBACK_SHOP := {
|
||||
"shop_slot3": {"id": "shop_slot3", "name_ru": "Третий слот", "price": 250, "max_level": 1},
|
||||
"shop_slot4": {"id": "shop_slot4", "name_ru": "Четвёртый слот", "price": 450, "max_level": 1},
|
||||
"shop_buffer": {"id": "shop_buffer", "name_ru": "Буфер +2", "price": 200, "max_level": 2},
|
||||
"shop_timer": {"id": "shop_timer", "name_ru": "Таймер пакетов +4 с", "price": 350, "max_level": 3},
|
||||
"shop_zone": {"id": "shop_zone", "name_ru": "Зона +5%", "price": 300, "max_level": 3},
|
||||
"shop_channel": {"id": "shop_channel", "name_ru": "Канал +25", "price": 250, "max_level": 4},
|
||||
"shop_section": {"id": "shop_section", "name_ru": "Новая секция", "price": 300, "max_level": 5},
|
||||
"shop_search": {"id": "shop_search", "name_ru": "Поиск", "price": 250, "max_level": 1},
|
||||
"shop_shield": {"id": "shop_shield", "name_ru": "Щит", "price": 400, "max_level": 1},
|
||||
"shop_sla": {"id": "shop_sla", "name_ru": "SLA +24", "price": 300, "max_level": 3},
|
||||
## effect → агрегированный ключ уровня в Game.upgrades.
|
||||
const LOT_EFFECT_KEYS := {
|
||||
"connection_slots": "slot_level",
|
||||
"transfer_speed": "speed_level",
|
||||
"sla_cap": "sla_level",
|
||||
"shield_charge": "shield_level",
|
||||
"money_mult": "money_level",
|
||||
"folder_cells": "folder_level",
|
||||
"channel_capacity": "channel_level",
|
||||
"request_timer": "timer_level",
|
||||
"udp_success": "udp_level",
|
||||
}
|
||||
|
||||
const FALLBACK_LOT_DIMS := [[5, 4], [6, 4], [7, 5], [8, 5], [8, 6], [9, 6]]
|
||||
|
||||
## Резерв, если Data ещё не готов (id → лот минимально необходимых полей).
|
||||
const FALLBACK_SHOP := {
|
||||
"lot_slots": {"id": "lot_slots", "name_ru": "Порты коммутатора", "effect": "connection_slots", "value": 1, "max_level": 4, "prices": [90, 200, 400, 700]},
|
||||
"lot_speed": {"id": "lot_speed", "name_ru": "Оптоволокно", "effect": "transfer_speed", "value": 0.10, "max_level": 6, "prices": [55, 75, 100, 135, 180, 245]},
|
||||
"lot_sla": {"id": "lot_sla", "name_ru": "Резервный ИБП", "effect": "sla_cap", "value": 40, "max_level": 4, "prices": [80, 160, 270, 430]},
|
||||
"lot_shield": {"id": "lot_shield", "name_ru": "Заземление", "effect": "shield_charge", "value": 1, "max_level": 3, "prices": [100, 220, 410]},
|
||||
"lot_money": {"id": "lot_money", "name_ru": "Корпоративный контракт", "effect": "money_mult", "value": 0.05, "max_level": 8, "prices": [70, 95, 130, 170, 220, 285, 365, 465]},
|
||||
"lot_folder": {"id": "lot_folder", "name_ru": "Складской стеллаж", "effect": "folder_cells", "value": 1, "max_level": 5, "prices": [50, 100, 180, 290, 440]},
|
||||
"lot_channel": {"id": "lot_channel", "name_ru": "Пропускная способность", "effect": "channel_capacity", "value": 6, "buffer_value": 1, "max_level": 6, "prices": [45, 70, 95, 130, 175, 230]},
|
||||
"lot_timer": {"id": "lot_timer", "name_ru": "Регламент обработки", "effect": "request_timer", "value": 7, "max_level": 5, "prices": [45, 75, 115, 165, 230]},
|
||||
"lot_udp": {"id": "lot_udp", "name_ru": "UDP-повторитель", "effect": "udp_success", "value": 0.10, "max_level": 4, "prices": [60, 120, 210, 350]},
|
||||
}
|
||||
|
||||
## Публичный алиас каталога-резерва (удобно тестам/UI).
|
||||
const LOT_DEFS := FALLBACK_SHOP
|
||||
|
||||
var money: int = START_MONEY
|
||||
var sla: float = START_SLA
|
||||
var hour: int = 1
|
||||
@@ -44,6 +68,19 @@ var upgrades: Dictionary = {}
|
||||
var meta_unlocks: Dictionary = {}
|
||||
## Награды, выданные в текущем забеге (для итогового экрана); чистится новым забегом.
|
||||
var run_meta_awards: Array[String] = []
|
||||
|
||||
## v2-магазин: 3 id, счётчик рероллов, час генерации, pity-история и замки.
|
||||
var shop_offers: Array[String] = []
|
||||
var shop_rerolls: int = 0
|
||||
var shop_offer_hour: int = 0
|
||||
var shop_last_seen: Dictionary = {}
|
||||
var shop_locked: Array[String] = []
|
||||
|
||||
## Заряды «Заземления»: восстанавливаются в начале часа.
|
||||
var shield_charges: int = 0
|
||||
## Час, за который SLA-реген уже выдан (идемпотентность вызова).
|
||||
var _sla_regen_hour: int = 0
|
||||
|
||||
## FileRegistry; без type hint, чтобы работать без прогретого class cache.
|
||||
var registry = null
|
||||
var rng_seed: int = 0
|
||||
@@ -78,6 +115,8 @@ func start_hour() -> bool:
|
||||
return false
|
||||
if registry == null:
|
||||
registry = _make_registry()
|
||||
regen_sla_for_hour()
|
||||
regen_shield_for_hour()
|
||||
phase = &"hour"
|
||||
phase_changed.emit(phase)
|
||||
toast.emit(_tr("msg.hour_start", "Час %d" % hour))
|
||||
@@ -99,6 +138,9 @@ func end_hour() -> bool:
|
||||
var finished := hour
|
||||
hour += 1
|
||||
phase = &"prep"
|
||||
# Рероллы живут одну подготовку.
|
||||
shop_rerolls = 0
|
||||
shop_locked.clear()
|
||||
hour_changed.emit(hour)
|
||||
phase_changed.emit(phase)
|
||||
toast.emit(_tr("msg.hour_end", "Час %d завершён" % finished))
|
||||
@@ -128,7 +170,7 @@ func add_money(delta: int, reason: String = "") -> void:
|
||||
|
||||
|
||||
func add_sla(delta: float, reason: String = "") -> void:
|
||||
sla = clampf(sla + float(delta), 0.0, MAX_SLA)
|
||||
sla = clampf(sla + float(delta), 0.0, sla_cap())
|
||||
if not reason.is_empty():
|
||||
stats["last_reason"] = reason
|
||||
sla_changed.emit(sla)
|
||||
@@ -136,10 +178,168 @@ func add_sla(delta: float, reason: String = "") -> void:
|
||||
_finish_run("sla")
|
||||
|
||||
|
||||
## +15 SLA в начале часа (не выше динамического капа). Возвращает новый SLA.
|
||||
## Идемпотентно в пределах одного часа: повторный вызов не добавляет SLA.
|
||||
func regen_sla_for_hour() -> float:
|
||||
if _sla_regen_hour == hour:
|
||||
return sla
|
||||
_sla_regen_hour = hour
|
||||
var regen := _const("sla_regen_per_hour", 15.0)
|
||||
sla = clampf(sla + regen, 0.0, sla_cap())
|
||||
sla_changed.emit(sla)
|
||||
return sla
|
||||
|
||||
|
||||
## Заряды щита восстанавливаются до максимума в начале часа.
|
||||
func regen_shield_for_hour() -> int:
|
||||
shield_charges = shield_max()
|
||||
return shield_charges
|
||||
|
||||
|
||||
func add_stat(key: String, delta: int = 1) -> void:
|
||||
stats[key] = int(stats.get(key, 0)) + int(delta)
|
||||
|
||||
|
||||
# --- v2: уровни лотов и производные статы ----------------------------------
|
||||
|
||||
## Уровень по id лота (lot_*) или по эффекту/агрегату
|
||||
## (connection_slots / slot_level / ...).
|
||||
func level(effect_or_lot: String) -> int:
|
||||
var key := str(effect_or_lot)
|
||||
if key.is_empty():
|
||||
return 0
|
||||
if key.begins_with("lot_"):
|
||||
if upgrades.has(key):
|
||||
return int(upgrades[key])
|
||||
var def := _lot_def(key)
|
||||
var effect := str(def.get("effect", ""))
|
||||
var agg := str(LOT_EFFECT_KEYS.get(effect, ""))
|
||||
if not agg.is_empty():
|
||||
return int(upgrades.get(agg, 0))
|
||||
return 0
|
||||
if upgrades.has(key):
|
||||
return int(upgrades[key])
|
||||
if LOT_EFFECT_KEYS.has(key):
|
||||
return int(upgrades.get(str(LOT_EFFECT_KEYS[key]), 0))
|
||||
return 0
|
||||
|
||||
|
||||
## Записать уровень лота в оба ключа и сразу применить мгновенные эффекты
|
||||
## (SLA-кап/щит). Остальные статы читаются геттерами динамически.
|
||||
func set_lot_level(id: String, new_level: int) -> int:
|
||||
var def := _lot_def(id)
|
||||
if def.is_empty():
|
||||
return 0
|
||||
if not (upgrades is Dictionary):
|
||||
upgrades = {}
|
||||
var max_level := maxi(1, int(def.get("max_level", 1)))
|
||||
var old_level := level(id)
|
||||
var lvl := clampi(int(new_level), 0, max_level)
|
||||
upgrades[id] = lvl
|
||||
var effect := str(def.get("effect", ""))
|
||||
var agg := str(LOT_EFFECT_KEYS.get(effect, ""))
|
||||
if not agg.is_empty():
|
||||
upgrades[agg] = lvl
|
||||
if effect == "sla_cap" and lvl > old_level:
|
||||
# Кап уже вырос (sla_cap() читает новый уровень), добавляем +40 сразу.
|
||||
var gain := _lot_value(def, 40.0) * float(lvl - old_level)
|
||||
sla = clampf(sla + gain, 0.0, sla_cap())
|
||||
sla_changed.emit(sla)
|
||||
elif effect == "shield_charge" and lvl > old_level:
|
||||
shield_charges = mini(shield_max(), shield_charges + (lvl - old_level))
|
||||
if effect == "folder_cells" and lvl != old_level and registry != null \
|
||||
and registry.has_method("resize_folders"):
|
||||
# Лот растит уже существующие папки, а не только будущие.
|
||||
registry.resize_folders(folder_dims())
|
||||
return lvl
|
||||
|
||||
|
||||
func sla_cap() -> float:
|
||||
var base := _const("sla_base", START_SLA)
|
||||
var step := _lot_effect_value("lot_sla", "value", _const("sla_per_level", 40.0))
|
||||
var hard := _const("sla_hard_cap", MAX_SLA)
|
||||
return clampf(base + step * float(level("sla_level")), base, hard)
|
||||
|
||||
|
||||
func money_mult() -> float:
|
||||
var step := _lot_effect_value("lot_money", "value", _const("money_mult_step", 0.05))
|
||||
return 1.0 + step * float(level("money_level"))
|
||||
|
||||
|
||||
## pow(0.90, speed_level) — множитель времени передачи (10% за уровень).
|
||||
func speed_mult() -> float:
|
||||
var cut := clampf(_lot_effect_value("lot_speed", "value", 0.10), 0.01, 0.9)
|
||||
return pow(1.0 - cut, float(maxi(0, level("speed_level"))))
|
||||
|
||||
|
||||
func folder_dims() -> Vector2i:
|
||||
var table := _folder_dims_table()
|
||||
if table.is_empty():
|
||||
table = FALLBACK_LOT_DIMS
|
||||
var idx := clampi(level("folder_level"), 0, table.size() - 1)
|
||||
var pair = table[idx]
|
||||
if pair is Array and (pair as Array).size() >= 2:
|
||||
return Vector2i(int(pair[0]), int(pair[1]))
|
||||
return Vector2i(5, 4)
|
||||
|
||||
|
||||
## Цена следующей папки: 150 + 100 за каждую сверх стартовых 2.
|
||||
## count < 0 → берём текущее число папок из реестра.
|
||||
func folder_price(count: int = -1) -> int:
|
||||
var base := int(_const("folder_price_start", 150.0))
|
||||
var step := int(_const("folder_price_step", 100.0))
|
||||
var start := int(_const("folders_start", 2.0))
|
||||
var total := int(count)
|
||||
if total < 0:
|
||||
total = 0
|
||||
if registry != null and registry.has_method("folder_count"):
|
||||
total = int(registry.folder_count())
|
||||
return base + step * maxi(0, total - start)
|
||||
|
||||
|
||||
func connection_slots() -> int:
|
||||
var base := int(_const("slots_start", 1.0))
|
||||
var cap := int(_const("slots_max", 5.0))
|
||||
return clampi(base + level("slot_level"), base, cap)
|
||||
|
||||
|
||||
func channel_capacity() -> float:
|
||||
var base := _const("channel_capacity", 10.0)
|
||||
var step := _lot_effect_value("lot_channel", "value", _const("channel_per_level", 6.0))
|
||||
return base + step * float(level("channel_level"))
|
||||
|
||||
|
||||
func buffer_limit() -> int:
|
||||
var base := int(_const("buffer_base", 6.0))
|
||||
var step := int(_lot_effect_value("lot_channel", "buffer_value", _const("buffer_per_level", 1.0)))
|
||||
var cap := int(_const("buffer_max", 12.0))
|
||||
return clampi(base + step * level("channel_level"), base, cap)
|
||||
|
||||
|
||||
func request_timer() -> float:
|
||||
var base := _const("request_timer", 30.0)
|
||||
var step := _lot_effect_value("lot_timer", "value", _const("request_timer_per_level", 7.0))
|
||||
var cap := _const("request_timer_max", 65.0)
|
||||
return clampf(base + step * float(level("timer_level")), base, cap)
|
||||
|
||||
|
||||
func packet_timer() -> float:
|
||||
return _const("packet_timer", 20.0)
|
||||
|
||||
|
||||
func shield_max() -> int:
|
||||
return maxi(0, level("shield_level"))
|
||||
|
||||
|
||||
## Шанс успешной UDP-отправки: 40% + 10% за уровень «UDP-повторителя»,
|
||||
## не выше 80%. Зеркалит Economy.udp_success_chance() для UI/контроллера.
|
||||
func udp_success_chance() -> float:
|
||||
var base := _const("udp_success_base", 0.40)
|
||||
var cap := _const("udp_success_max", 0.80)
|
||||
var step := _lot_effect_value("lot_udp", "value", 0.10)
|
||||
return clampf(base + step * float(level("udp_level")), base, cap)
|
||||
|
||||
|
||||
# --- прогрессия/открытия ---------------------------------------------------
|
||||
|
||||
## Открыта ли фича по data/balance.json → progression.unlocks.
|
||||
@@ -190,23 +390,23 @@ func award_meta_for_run(hours: int) -> Array[String]:
|
||||
return awarded
|
||||
|
||||
|
||||
# --- магазин (заглушка эффектов) ------------------------------------------
|
||||
# --- магазин (legacy-фасад) ------------------------------------------------
|
||||
|
||||
## Покупка проверяет деньги и max_level, но не применяет эффект (P1).
|
||||
## Покупка по id лота (legacy-путь; UI v2 использует Shop.buy).
|
||||
func buy_upgrade(id: String) -> bool:
|
||||
var item := _shop_item(id)
|
||||
if item.is_empty():
|
||||
return false
|
||||
var level := int(upgrades.get(id, 0))
|
||||
var lvl := level(id)
|
||||
var max_level := int(item.get("max_level", 1))
|
||||
if max_level > 0 and level >= max_level:
|
||||
if max_level > 0 and lvl >= max_level:
|
||||
return false
|
||||
var cost := _item_cost(item, level)
|
||||
var cost := _item_cost(item, lvl)
|
||||
if money < cost:
|
||||
toast.emit(_tr("msg.money_short", "Недостаточно денег"))
|
||||
return false
|
||||
money -= cost
|
||||
upgrades[id] = level + 1
|
||||
set_lot_level(id, lvl + 1)
|
||||
money_changed.emit(money)
|
||||
toast.emit(_tr("msg.upgrade_bought", "Куплено: %s") % _item_name(item, id))
|
||||
return true
|
||||
@@ -234,6 +434,13 @@ func save_game() -> bool:
|
||||
"rng_seed": rng_seed,
|
||||
"rng_state": rng_state,
|
||||
"registry": registry.to_dict(),
|
||||
"shop_offers": shop_offers.duplicate(),
|
||||
"shop_rerolls": shop_rerolls,
|
||||
"shop_offer_hour": shop_offer_hour,
|
||||
"shop_last_seen": shop_last_seen.duplicate(true),
|
||||
"shop_locked": shop_locked.duplicate(),
|
||||
"shield_charges": shield_charges,
|
||||
"sla_regen_hour": _sla_regen_hour,
|
||||
}
|
||||
return SAVE_SCRIPT.save(payload)
|
||||
|
||||
@@ -250,14 +457,31 @@ func load_game() -> bool:
|
||||
if not reg.from_dict(raw_registry):
|
||||
push_warning("Game: не удалось восстановить registry")
|
||||
return false
|
||||
# runtime-резервы под запросы не переживают загрузку: сами запросы не
|
||||
# сохраняются, поэтому file.required_by/attached_request и location
|
||||
# "attached" после load указывали бы на несуществующий запрос. Такой файл
|
||||
# нельзя ни переместить, ни удалить — он навсегда занимает клетку.
|
||||
for file in reg.files.values():
|
||||
file.required_by = ""
|
||||
file.attached_request = ""
|
||||
if String(file.location) == "attached":
|
||||
file.location = &"folder" if not String(file.folder_id).is_empty() else &"registry"
|
||||
# _repair_after_load уже разложил файл по папкам/клеткам. Если папки
|
||||
# (или её bookkeeping) нет, файл должен быть видимым в реестре, а не
|
||||
# числиться в несуществующем месте.
|
||||
if String(file.location) == "folder":
|
||||
var folder = reg.folders.get(String(file.folder_id), null)
|
||||
if folder == null or not folder.file_ids.has(file.id):
|
||||
file.folder_id = ""
|
||||
file.location = &"registry"
|
||||
registry = reg
|
||||
|
||||
upgrades = _dict_of(data.get("upgrades", {}))
|
||||
money = maxi(0, int(data.get("money", START_MONEY)))
|
||||
sla = clampf(float(data.get("sla", START_SLA)), 0.0, MAX_SLA)
|
||||
hour = clampi(int(data.get("hour", 1)), 1, HOURS_PER_RUN)
|
||||
phase = StringName(str(data.get("phase", "menu")))
|
||||
streak = maxi(0, int(data.get("streak", 0)))
|
||||
upgrades = _dict_of(data.get("upgrades", {}))
|
||||
sla = clampf(float(data.get("sla", START_SLA)), 0.0, sla_cap())
|
||||
meta_unlocks = _dict_of(data.get("meta", {}))
|
||||
run_meta_awards = _string_array_of(data.get("run_meta_awards", []))
|
||||
stats = _default_stats()
|
||||
@@ -266,6 +490,10 @@ func load_game() -> bool:
|
||||
for key in raw_stats.keys():
|
||||
stats[key] = raw_stats[key]
|
||||
rng_seed = int(data.get("rng_seed", 0))
|
||||
shield_charges = clampi(int(data.get("shield_charges", 0)), 0, shield_max())
|
||||
_sla_regen_hour = int(data.get("sla_regen_hour", 0))
|
||||
|
||||
_restore_shop_state(data)
|
||||
|
||||
var rng = _rng_node()
|
||||
if rng != null and rng.has_method("restore_state"):
|
||||
@@ -287,6 +515,13 @@ func _reset_fields(keep_meta: bool = true) -> void:
|
||||
streak = 0
|
||||
stats = _default_stats()
|
||||
upgrades = {}
|
||||
shop_offers.clear()
|
||||
shop_rerolls = 0
|
||||
shop_offer_hour = 0
|
||||
shop_last_seen.clear()
|
||||
shop_locked.clear()
|
||||
shield_charges = 0
|
||||
_sla_regen_hour = 0
|
||||
if not keep_meta:
|
||||
meta_unlocks = {}
|
||||
registry = _make_registry()
|
||||
@@ -322,24 +557,138 @@ func _default_stats() -> Dictionary:
|
||||
}
|
||||
|
||||
|
||||
## Восстановить офферы/рероллы/last_seen/замки; выкинуть неизвестные и
|
||||
## максимальные лоты; при необходимости догенерировать до 3.
|
||||
func _restore_shop_state(data: Dictionary) -> void:
|
||||
shop_offers.clear()
|
||||
for raw_id in _string_array_of(data.get("shop_offers", [])):
|
||||
var id := str(raw_id)
|
||||
if not id.begins_with("lot_"):
|
||||
continue
|
||||
var def := _lot_def(id)
|
||||
if def.is_empty():
|
||||
continue
|
||||
if level(id) >= maxi(1, int(def.get("max_level", 1))):
|
||||
continue
|
||||
if shop_offers.has(id):
|
||||
continue
|
||||
shop_offers.append(id)
|
||||
shop_rerolls = clampi(int(data.get("shop_rerolls", 0)), 0, _max_rerolls())
|
||||
shop_offer_hour = int(data.get("shop_offer_hour", 0))
|
||||
shop_last_seen = _int_dict_of(data.get("shop_last_seen", {}))
|
||||
shop_locked.clear()
|
||||
for raw_id in _string_array_of(data.get("shop_locked", [])):
|
||||
var id := str(raw_id)
|
||||
if shop_offers.has(id) and not shop_locked.has(id):
|
||||
shop_locked.append(id)
|
||||
|
||||
if hour <= 1:
|
||||
return
|
||||
var target := int(_shop_rules().get("offers", 3))
|
||||
if shop_offers.size() >= target and shop_offer_hour > 0:
|
||||
return
|
||||
var lots := _lot_defs()
|
||||
if lots.is_empty():
|
||||
return
|
||||
var selected := SHOP_SCRIPT.select_offers(
|
||||
hour, lots, upgrades, money, [], shop_last_seen,
|
||||
shop_offers.duplicate(), target)
|
||||
shop_offers.clear()
|
||||
for id in selected:
|
||||
shop_offers.append(str(id))
|
||||
if shop_offer_hour <= 0:
|
||||
shop_offer_hour = hour
|
||||
for id in shop_offers:
|
||||
shop_last_seen[str(id)] = hour
|
||||
|
||||
|
||||
func _lot_def(id: String) -> Dictionary:
|
||||
var data = _data_node()
|
||||
if data != null and data.has_method("lot_def"):
|
||||
var def = data.lot_def(id)
|
||||
if def is Dictionary and not (def as Dictionary).is_empty():
|
||||
return def
|
||||
var fallback = FALLBACK_SHOP.get(str(id), null)
|
||||
if fallback is Dictionary:
|
||||
return (fallback as Dictionary).duplicate(true)
|
||||
return {}
|
||||
|
||||
|
||||
func _lot_defs() -> Array:
|
||||
var data = _data_node()
|
||||
if data != null and data.has_method("lot_defs"):
|
||||
var lots = data.lot_defs()
|
||||
if lots is Array and not (lots as Array).is_empty():
|
||||
return lots
|
||||
var out: Array = []
|
||||
for id in FALLBACK_SHOP.keys():
|
||||
out.append((FALLBACK_SHOP[id] as Dictionary).duplicate(true))
|
||||
return out
|
||||
|
||||
|
||||
func _shop_rules() -> Dictionary:
|
||||
var data = _data_node()
|
||||
if data != null and data.has_method("shop_rules"):
|
||||
var rules = data.shop_rules()
|
||||
if rules is Dictionary and not (rules as Dictionary).is_empty():
|
||||
return rules
|
||||
return {"offers": 3, "reroll_costs": [40, 80, 120], "max_rerolls": 3}
|
||||
|
||||
|
||||
func _max_rerolls() -> int:
|
||||
return maxi(0, int(_shop_rules().get("max_rerolls", 3)))
|
||||
|
||||
|
||||
func _folder_dims_table() -> Array:
|
||||
var data = _data_node()
|
||||
if data != null and data.has_method("folder_dims_table"):
|
||||
var table = data.folder_dims_table()
|
||||
if table is Array and not (table as Array).is_empty():
|
||||
return table
|
||||
return FALLBACK_LOT_DIMS.duplicate(true)
|
||||
|
||||
|
||||
func _const(key: String, fallback_value: float) -> float:
|
||||
var data = _data_node()
|
||||
if data != null and data.get("constants") is Dictionary:
|
||||
var consts: Dictionary = data.constants
|
||||
if consts.has(key):
|
||||
return float(consts[key])
|
||||
return float(fallback_value)
|
||||
|
||||
|
||||
func _lot_value(def: Dictionary, fallback_value: float) -> float:
|
||||
if def.has("value"):
|
||||
return float(def.get("value"))
|
||||
return float(fallback_value)
|
||||
|
||||
|
||||
## Значение поля лота (source of truth для эффекта), fallback — константа.
|
||||
func _lot_effect_value(id: String, field: String, fallback_value: float) -> float:
|
||||
var def := _lot_def(id)
|
||||
if def.has(field):
|
||||
return float(def.get(field))
|
||||
return float(fallback_value)
|
||||
|
||||
|
||||
func _shop_item(id: String) -> Dictionary:
|
||||
var data = _data_node()
|
||||
if data != null and data.has_method("shop_item"):
|
||||
var item = data.shop_item(id)
|
||||
if item is Dictionary and not item.is_empty():
|
||||
if item is Dictionary and not (item as Dictionary).is_empty():
|
||||
return item
|
||||
var fallback = FALLBACK_SHOP.get(id, null)
|
||||
if fallback is Dictionary:
|
||||
return fallback.duplicate(true)
|
||||
return (fallback as Dictionary).duplicate(true)
|
||||
return {}
|
||||
|
||||
|
||||
func _item_cost(item: Dictionary, level: int) -> int:
|
||||
func _item_cost(item: Dictionary, lvl: int) -> int:
|
||||
var prices = item.get("prices", null)
|
||||
if prices is Array and not prices.is_empty():
|
||||
if prices is Array and not (prices as Array).is_empty():
|
||||
var arr: Array = prices
|
||||
if level < arr.size():
|
||||
return maxi(0, int(arr[level]))
|
||||
if lvl < arr.size():
|
||||
return maxi(0, int(arr[lvl]))
|
||||
return maxi(0, int(arr[arr.size() - 1]))
|
||||
if item.has("price"):
|
||||
return maxi(0, int(item.get("price", 0)))
|
||||
@@ -387,6 +736,14 @@ func _dict_of(value) -> Dictionary:
|
||||
return {}
|
||||
|
||||
|
||||
func _int_dict_of(value) -> Dictionary:
|
||||
var out: Dictionary = {}
|
||||
if value is Dictionary:
|
||||
for key in value.keys():
|
||||
out[str(key)] = int(value[key])
|
||||
return out
|
||||
|
||||
|
||||
func _string_array_of(value) -> Array[String]:
|
||||
var out: Array[String] = []
|
||||
if value is Array:
|
||||
|
||||
@@ -1,48 +1,76 @@
|
||||
class_name Economy
|
||||
extends RefCounted
|
||||
## Чистая экономика и тайминги (D12). Все методы статические, без состояния
|
||||
## Чистая экономика и тайминги v2. Все методы статические, без состояния
|
||||
## и без обращений к сценам — удобно тестировать headless.
|
||||
##
|
||||
## ВАЖНО: только float-арифметика в формулах выплаты (никаких int/int) и
|
||||
## только pow() для степеней (никакого ^).
|
||||
|
||||
## Фон/слоты НЕ входят в load (уточнение п.11): канал грузят только
|
||||
## активные передачи. Константы оставлены нулевыми для совместимости.
|
||||
## Канал: load = Σ weight(активных передач). Фон и занятые слоты не грузят.
|
||||
const CHANNEL_BG_BASE := 0.0
|
||||
const CHANNEL_BG_PER_HOUR := 0.0
|
||||
const SLOT_LOAD := 0.0
|
||||
const CHANNEL_CAPACITY_BASE := 80.0
|
||||
const CHANNEL_CAPACITY_STEP := 25.0
|
||||
const CHANNEL_CAPACITY_BASE := 10.0
|
||||
const CHANNEL_CAPACITY_STEP := 6.0
|
||||
|
||||
const PACKET_TIMER_BASE := 20.0
|
||||
const REQUEST_TIMER_BASE := 30.0
|
||||
const TIMER_PER_HOUR := 0.5
|
||||
const TIMER_UPGRADE_STEP := 4.0
|
||||
const PACKET_TIMER_MIN := 12.0
|
||||
const REQUEST_TIMER_MIN := 20.0
|
||||
const TIMER_MAX := 999.0
|
||||
const REQUEST_TIMER_STEP := 7.0
|
||||
const REQUEST_TIMER_MAX := 65.0
|
||||
|
||||
## Grace-множитель первых часов (п.10): больше времени на старте.
|
||||
## h1 1.5, h2 1.35, h3 1.2, h4+ 1.0 — для таймера пакетов.
|
||||
const TIMER_GRACE := {1: 1.5, 2: 1.35, 3: 1.2}
|
||||
const BUFFER_BASE := 6
|
||||
const BUFFER_PER_LEVEL := 1
|
||||
const BUFFER_MAX := 12
|
||||
|
||||
## Отдельный grace для таймера запросов: в начале игроку нужно больше времени,
|
||||
## чтобы собрать файлы. h1 2.0, h2 1.7, h3 1.4, h4+ 1.0.
|
||||
## h1: 30×2.0 = 60, h2: 29.5×1.7 = 50.15, h3: 29×1.4 = 40.6.
|
||||
const REQUEST_TIMER_GRACE := {1: 2.0, 2: 1.7, 3: 1.4}
|
||||
## Множитель времени передачи за уровень «Оптоволокна» (10% за уровень).
|
||||
const SPEED_STEP := 0.90
|
||||
|
||||
## Коэффициенты мини-игр: text 1, sound 2, photo 4, video 8.
|
||||
const MINIGAME_COEFF := {
|
||||
"text": 1.0,
|
||||
"sound": 2.0,
|
||||
"photo": 3.0,
|
||||
"video": 4.0,
|
||||
"text": 1,
|
||||
"sound": 2,
|
||||
"photo": 4,
|
||||
"video": 8,
|
||||
}
|
||||
|
||||
## Игры отправки (wires удалена). Порядок совпадает с data.gd/minigame_factory.
|
||||
const SEND_ORDER := ["dots", "sound", "text", "video", "photo", "maze"]
|
||||
|
||||
## Игра по rank (позиция суммы в ACHIEVABLE_SUMS). Прямая таблица, без modulo.
|
||||
const RANK_GAMES := [
|
||||
"text", "sound", "dots", "photo", "maze",
|
||||
"dots", "photo", "video", "dots", "text",
|
||||
"video", "maze", "photo", "sound", "video",
|
||||
"text", "maze", "sound", "video", "sound",
|
||||
"photo", "dots", "dots", "maze", "maze",
|
||||
"photo", "video",
|
||||
]
|
||||
|
||||
## Достижимые суммы коэффициентов для 1–4 файлов: 27 значений.
|
||||
const ACHIEVABLE_SUMS := [
|
||||
1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14,
|
||||
15, 16, 17, 18, 19, 20, 21, 22, 24, 25, 26, 28, 32,
|
||||
]
|
||||
|
||||
## rank 0..7 → T1, 8..13 → T2, 14..26 → T3.
|
||||
const TIER_MAX_RANK := [7, 13]
|
||||
|
||||
const PAYOUT_SPEED_BASE := 0.6
|
||||
const PAYOUT_SPEED_BONUS := 0.4
|
||||
const PAYOUT_QUALITY_BASE := 0.5
|
||||
const PAYOUT_QUALITY_BONUS := 0.5
|
||||
const PAYOUT_MULTI_STEP := 0.1
|
||||
const PAYOUT_MULTI_STEP := 0.15
|
||||
const PAYOUT_TCP_MULT := 1.15
|
||||
const PAYOUT_UDP_MULT := 0.85
|
||||
const PAYOUT_UDP_TIME_MULT := 0.8
|
||||
const PAYOUT_STREAK_STEP := 0.02
|
||||
const PAYOUT_STREAK_CAP := 10
|
||||
const PAYOUT_MONEY_STEP := 0.05
|
||||
|
||||
## UDP: база 40%, +10% за уровень «UDP-повторителя», кап 80%.
|
||||
const UDP_SUCCESS_BASE := 0.40
|
||||
const UDP_SUCCESS_STEP := 0.10
|
||||
const UDP_SUCCESS_MAX := 0.80
|
||||
|
||||
|
||||
# --- канал -----------------------------------------------------------------
|
||||
@@ -58,7 +86,7 @@ static func channel_load(_hour: int, _used_slots: int, active_weights: Array, _u
|
||||
|
||||
|
||||
static func channel_capacity(upgrades: Dictionary = {}) -> float:
|
||||
var level := _level(upgrades, ["channel", "channel_level", "channel_lvl", "shop_channel", "channel_capacity"])
|
||||
var level := _level(upgrades, ["channel", "channel_level", "channel_lvl", "lot_channel", "channel_capacity"])
|
||||
return CHANNEL_CAPACITY_BASE + CHANNEL_CAPACITY_STEP * float(level)
|
||||
|
||||
|
||||
@@ -75,54 +103,82 @@ static func channel_factor(load: float, capacity_or_upgrades = null) -> float:
|
||||
return maxf(1.0, maxf(0.0, load) / capacity)
|
||||
|
||||
|
||||
## Базовое время приёма/отправки умножается на фактор канала.
|
||||
static func recv_time(type_def: Dictionary, factor: float, _upgrades: Dictionary = {}) -> float:
|
||||
return maxf(0.0, float(type_def.get("recv_time", 0.0))) * maxf(0.0, factor)
|
||||
static func buffer_limit(upgrades: Dictionary = {}) -> int:
|
||||
var level := _level(upgrades, ["channel", "channel_level", "buffer", "buffer_level", "lot_channel"])
|
||||
return clampi(BUFFER_BASE + BUFFER_PER_LEVEL * level, BUFFER_BASE, BUFFER_MAX)
|
||||
|
||||
|
||||
static func send_time(type_def: Dictionary, factor: float, _upgrades: Dictionary = {}) -> float:
|
||||
return maxf(0.0, float(type_def.get("send_time", 0.0))) * maxf(0.0, factor)
|
||||
## pow(0.90, speed_level): 0 → 1.0, 6 → ≈0.531.
|
||||
static func speed_mult(speed_level: int) -> float:
|
||||
return pow(SPEED_STEP, float(maxi(0, int(speed_level))))
|
||||
|
||||
|
||||
## Базовое время типа: v2-поле base_time_sec с fallback на legacy.
|
||||
static func type_base_time(type_def: Dictionary) -> float:
|
||||
if type_def.has("base_time_sec"):
|
||||
return maxf(0.0, float(type_def.get("base_time_sec", 0.0)))
|
||||
if type_def.has("recv_time"):
|
||||
return maxf(0.0, float(type_def.get("recv_time", 0.0)))
|
||||
return 0.0
|
||||
|
||||
|
||||
## Время приёма: base_time_sec × pow(0.90, speed_level) × channel_factor.
|
||||
## У приёма нет протокольного множителя.
|
||||
static func recv_transfer_time(type_def: Dictionary, channel_factor_value: float = 1.0, speed_level: int = 0) -> float:
|
||||
return type_base_time(type_def) * speed_mult(speed_level) * maxf(0.0, channel_factor_value)
|
||||
|
||||
|
||||
## Время отправки: то же + UDP ×0.8 (TCP ×1.0).
|
||||
static func send_transfer_time(type_def: Dictionary, channel_factor_value: float = 1.0, speed_level: int = 0, protocol = "tcp") -> float:
|
||||
var mult := 1.0
|
||||
if str(protocol).to_lower() == "udp":
|
||||
mult = PAYOUT_UDP_TIME_MULT
|
||||
return type_base_time(type_def) * speed_mult(speed_level) * maxf(0.0, channel_factor_value) * mult
|
||||
|
||||
|
||||
## Legacy-обёртки: factor — channel_factor, upgrades даёт speed_level.
|
||||
static func recv_time(type_def: Dictionary, factor: float, upgrades: Dictionary = {}) -> float:
|
||||
var level := _level(upgrades, ["speed", "speed_level", "transfer_speed", "lot_speed"])
|
||||
return recv_transfer_time(type_def, factor, level)
|
||||
|
||||
|
||||
static func send_time(type_def: Dictionary, factor: float, upgrades: Dictionary = {}) -> float:
|
||||
var level := _level(upgrades, ["speed", "speed_level", "transfer_speed", "lot_speed"])
|
||||
return send_transfer_time(type_def, factor, level, "tcp")
|
||||
|
||||
|
||||
# --- таймеры ---------------------------------------------------------------
|
||||
|
||||
## Формула (п.10): (base − 0.5*(h−1)) * grace(h) + 4*level.
|
||||
## Grace умножает только базовую часть; апгрейд таймера даёт чистые +4 с.
|
||||
static func packet_timer(hour: int, upgrades: Dictionary = {}) -> float:
|
||||
var h := maxi(1, hour)
|
||||
var level := _level(upgrades, ["timer", "timer_level", "packet_timer", "packet_timer_level", "shop_timer"])
|
||||
var base := PACKET_TIMER_BASE - TIMER_PER_HOUR * float(h - 1)
|
||||
var value := base * timer_grace(h) + TIMER_UPGRADE_STEP * float(level)
|
||||
return clampf(value, PACKET_TIMER_MIN, TIMER_MAX)
|
||||
## v2: таймер пакета фиксирован — 20 с, не покупается и не зависит от часа.
|
||||
static func packet_timer(_hour: int = 1, _upgrades: Dictionary = {}) -> float:
|
||||
return PACKET_TIMER_BASE
|
||||
|
||||
|
||||
static func request_timer(hour: int, upgrades: Dictionary = {}) -> float:
|
||||
var h := maxi(1, hour)
|
||||
var level := _level(upgrades, ["timer", "timer_level", "request_timer", "request_timer_level", "shop_timer"])
|
||||
var base := REQUEST_TIMER_BASE - TIMER_PER_HOUR * float(h - 1)
|
||||
var value := base * request_timer_grace(h) + TIMER_UPGRADE_STEP * float(level)
|
||||
return clampf(value, REQUEST_TIMER_MIN, TIMER_MAX)
|
||||
## v2: 30 + 7 × timer_level, не выше 65 с.
|
||||
static func request_timer(_hour: int = 1, upgrades: Dictionary = {}) -> float:
|
||||
var level := _level(upgrades, ["timer", "timer_level", "request_timer", "request_timer_level", "lot_timer"])
|
||||
var value := REQUEST_TIMER_BASE + REQUEST_TIMER_STEP * float(level)
|
||||
return clampf(value, REQUEST_TIMER_BASE, REQUEST_TIMER_MAX)
|
||||
|
||||
|
||||
## Grace-множитель таймеров по часу: h1 1.5, h2 1.35, h3 1.2, h4+ 1.0.
|
||||
static func timer_grace(hour: int) -> float:
|
||||
return float(TIMER_GRACE.get(maxi(1, hour), 1.0))
|
||||
## Legacy-хуки: в v2 grace не используется.
|
||||
static func timer_grace(_hour: int) -> float:
|
||||
return 1.0
|
||||
|
||||
|
||||
## Grace-множитель таймера запросов: h1 2.0, h2 1.7, h3 1.4, h4+ 1.0.
|
||||
static func request_timer_grace(hour: int) -> float:
|
||||
return float(REQUEST_TIMER_GRACE.get(maxi(1, hour), 1.0))
|
||||
static func request_timer_grace(_hour: int) -> float:
|
||||
return 1.0
|
||||
|
||||
|
||||
# --- мини-игры -------------------------------------------------------------
|
||||
|
||||
## Средний коэффициент сложности. Пустой/неизвестный тип трактуем как text.
|
||||
## Средний коэффициент сложности (legacy-эвристика).
|
||||
static func minigame_difficulty(types: Array) -> float:
|
||||
if types.is_empty():
|
||||
return 1.0
|
||||
var total := 0.0
|
||||
for type in types:
|
||||
total += float(MINIGAME_COEFF.get(str(type), 1.0))
|
||||
total += float(MINIGAME_COEFF.get(str(type), 1))
|
||||
return total / float(types.size())
|
||||
|
||||
|
||||
@@ -137,8 +193,59 @@ static func minigame_type(types: Array) -> String:
|
||||
return "video"
|
||||
|
||||
|
||||
## Выбор мини-игры отправки по сумме коэффициентов (1–4 файла).
|
||||
##
|
||||
## rank — позиция суммы S в списке ACHIEVABLE_SUMS (для недостижимой S —
|
||||
## позиция ближайшей меньшей достижимой). tier: rank 0..7 → T1, 8..13 → T2,
|
||||
## 14..26 → T3. Игра берётся напрямую из RANK_GAMES[rank], без modulo.
|
||||
##
|
||||
## S = 0 (нет файлов) → dots/T1.
|
||||
static func send_minigame_for(types: Array) -> Dictionary:
|
||||
var total := 0
|
||||
for type in types:
|
||||
total += int(MINIGAME_COEFF.get(str(type).to_lower(), 1))
|
||||
return selection_for_sum(total)
|
||||
|
||||
|
||||
## Публичный алиас выбора по готовой сумме (для MiniGameFactory/тестов).
|
||||
static func selection_for_sum(total: int) -> Dictionary:
|
||||
var sum := maxi(0, int(total))
|
||||
if sum <= 0:
|
||||
return {"id": "dots", "tier": 1, "index": 0, "sum": 0}
|
||||
var count := ACHIEVABLE_SUMS.size()
|
||||
var rank := 0
|
||||
for i in range(count):
|
||||
if int(ACHIEVABLE_SUMS[i]) <= sum:
|
||||
rank = i
|
||||
else:
|
||||
break
|
||||
var tier := 1
|
||||
if rank > int(TIER_MAX_RANK[1]):
|
||||
tier = 3
|
||||
elif rank > int(TIER_MAX_RANK[0]):
|
||||
tier = 2
|
||||
return {
|
||||
"id": str(RANK_GAMES[clampi(rank, 0, RANK_GAMES.size() - 1)]),
|
||||
"tier": tier,
|
||||
"index": rank,
|
||||
"sum": sum,
|
||||
}
|
||||
|
||||
|
||||
## Список достижимых сумм коэффициентов (копия, чтобы нельзя было испортить const).
|
||||
static func achievable_sums() -> Array:
|
||||
return ACHIEVABLE_SUMS.duplicate()
|
||||
|
||||
|
||||
## Ступень атаки: H2-3 → T1, H4-5 → T2, H6-7 → T3, H8-9 → T4.
|
||||
static func attack_tier(hour: int) -> int:
|
||||
return clampi(1 + int(floor(float(maxi(1, int(hour)) - 2) / 2.0)), 1, 4)
|
||||
|
||||
|
||||
# --- выплата ---------------------------------------------------------------
|
||||
|
||||
## payout = round(base_sum × speed × quality × multi × protocol × streak ×
|
||||
## money_mult). TCP ×1.15, UDP ×0.85; money_level из upgrades.
|
||||
static func payout(
|
||||
base_sum: float,
|
||||
n: int,
|
||||
@@ -147,22 +254,41 @@ static func payout(
|
||||
mini_quality: float,
|
||||
protocol,
|
||||
streak: int,
|
||||
_upgrades: Dictionary = {}
|
||||
upgrades: Dictionary = {}
|
||||
) -> int:
|
||||
if n <= 0:
|
||||
return 0
|
||||
var time_ratio := 1.0
|
||||
if timer_total > 0.0:
|
||||
time_ratio = clampf(time_left / timer_total, 0.0, 1.0)
|
||||
time_ratio = clampf(float(time_left) / float(timer_total), 0.0, 1.0)
|
||||
var speed := PAYOUT_SPEED_BASE + PAYOUT_SPEED_BONUS * time_ratio
|
||||
var quality := PAYOUT_QUALITY_BASE + PAYOUT_QUALITY_BONUS * clampf(mini_quality, 0.0, 1.0)
|
||||
var quality := PAYOUT_QUALITY_BASE + PAYOUT_QUALITY_BONUS * clampf(float(mini_quality), 0.0, 1.0)
|
||||
var multi := 1.0 + PAYOUT_MULTI_STEP * float(n - 1)
|
||||
var protocol_mult := PAYOUT_TCP_MULT if str(protocol).to_lower() == "tcp" else 1.0
|
||||
var protocol_mult := PAYOUT_TCP_MULT if str(protocol).to_lower() == "tcp" else PAYOUT_UDP_MULT
|
||||
var streak_mult := 1.0 + PAYOUT_STREAK_STEP * float(clampi(streak, 0, PAYOUT_STREAK_CAP))
|
||||
var value := maxf(0.0, base_sum) * speed * quality * multi * protocol_mult * streak_mult
|
||||
var money_level := _level(upgrades, ["money", "money_level", "money_mult", "lot_money"])
|
||||
var money_mult := 1.0 + PAYOUT_MONEY_STEP * float(money_level)
|
||||
var value := maxf(0.0, float(base_sum)) * speed * quality * multi * protocol_mult * streak_mult * money_mult
|
||||
return int(round(value))
|
||||
|
||||
|
||||
static func money_mult(upgrades: Dictionary = {}) -> float:
|
||||
var level := _level(upgrades, ["money", "money_level", "money_mult", "lot_money"])
|
||||
return 1.0 + PAYOUT_MONEY_STEP * float(level)
|
||||
|
||||
|
||||
## Шанс успешной UDP-отправки: base + step × уровень, не выше cap.
|
||||
## Чистая функция для тестов и HourDirector/Game.
|
||||
static func udp_success_chance(upgrades: Dictionary = {},
|
||||
base: float = UDP_SUCCESS_BASE, step: float = UDP_SUCCESS_STEP,
|
||||
cap: float = UDP_SUCCESS_MAX) -> float:
|
||||
var level := _level(upgrades, ["udp", "udp_level", "lot_udp", "udp_success"])
|
||||
var safe_base := clampf(float(base), 0.0, 1.0)
|
||||
var safe_cap := clampf(float(cap), safe_base, 1.0)
|
||||
var value := safe_base + maxf(0.0, float(step)) * float(level)
|
||||
return clampf(value, safe_base, safe_cap)
|
||||
|
||||
|
||||
## Итоговый счёт: часы × 1000 + заработанные деньги + SLA × 10.
|
||||
static func score(hours: int, money_earned_total: int, sla: float) -> int:
|
||||
var safe_hours := maxi(0, hours)
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
class_name SlotManager
|
||||
extends RefCounted
|
||||
## Слоты соединений. Всего 2 + slot_level, но не больше 4 (D13/D16).
|
||||
## Слоты соединений v2. Всего 1 + slot_level, но не больше 5.
|
||||
##
|
||||
## Слоты общие для приёма и отправки; направление хранится для UI и канала.
|
||||
## manager не тикает сам: прогресс обновляет HourDirector.
|
||||
@@ -8,8 +8,8 @@ extends RefCounted
|
||||
signal changed
|
||||
|
||||
const SLOT_SCRIPT := preload("res://scripts/entities/slot.gd")
|
||||
const BASE_SLOTS := 2
|
||||
const MAX_SLOTS := 4
|
||||
const BASE_SLOTS := 1
|
||||
const MAX_SLOTS := 5
|
||||
|
||||
var slots: Array = []
|
||||
var _fallback_level: int = 0
|
||||
@@ -64,7 +64,12 @@ func free_count(direction: String = "") -> int:
|
||||
|
||||
|
||||
## Занять свободный слот. Возвращает индекс или -1, если мест нет.
|
||||
func reserve(direction: String, entity_id: String, entity_name: String = "") -> int:
|
||||
## kind: "packet" / "request" / "tcp_connection" (пустой выводится из
|
||||
## направления); detail — IP для tcp_connection; linked_request_id — запрос,
|
||||
## который пользуется TCP-соединением (нужен для весов канала).
|
||||
func reserve(direction: String, entity_id: String, entity_name: String = "",
|
||||
kind: String = "", detail: String = "", can_disconnect: bool = false,
|
||||
linked_request_id: String = "") -> int:
|
||||
refresh_max_slots()
|
||||
if entity_id.is_empty():
|
||||
return -1
|
||||
@@ -74,7 +79,8 @@ func reserve(direction: String, entity_id: String, entity_name: String = "") ->
|
||||
for slot in slots:
|
||||
if slot.occupied:
|
||||
continue
|
||||
slot.occupy(_clean_direction(direction), entity_id, entity_name)
|
||||
slot.occupy(_clean_direction(direction), entity_id, entity_name, kind,
|
||||
detail, can_disconnect, linked_request_id)
|
||||
changed.emit()
|
||||
return slot.index
|
||||
return -1
|
||||
@@ -129,6 +135,7 @@ func set_progress(index: int, progress: float) -> bool:
|
||||
|
||||
## Веса активных передач для формулы канала (D12 §9).
|
||||
## entity_id может быть id файла (приём) или id запроса (отправка).
|
||||
## Для kind=="tcp_connection" вес берётся по файлам связанного запроса.
|
||||
func active_weights(registry) -> Array:
|
||||
var weights: Array = []
|
||||
if registry == null:
|
||||
@@ -136,7 +143,8 @@ func active_weights(registry) -> Array:
|
||||
for slot in slots:
|
||||
if not slot.occupied:
|
||||
continue
|
||||
var weight := _entity_weight(registry, slot.entity_id)
|
||||
var weight := _entity_weight(registry, slot.entity_id, str(slot.kind),
|
||||
str(slot.linked_request_id))
|
||||
if weight > 0.0:
|
||||
weights.append(weight)
|
||||
return weights
|
||||
@@ -162,12 +170,23 @@ func from_dict(d: Dictionary) -> bool:
|
||||
|
||||
# --- служебное -------------------------------------------------------------
|
||||
|
||||
func _entity_weight(registry, entity_id: String) -> float:
|
||||
func _entity_weight(registry, entity_id: String, kind: String = "",
|
||||
linked_request_id: String = "") -> float:
|
||||
if registry == null:
|
||||
return 0.0
|
||||
var files = registry.files if registry.get("files") != null else null
|
||||
if files == null:
|
||||
return 0.0
|
||||
# TCP-соединение само по себе веса не имеет: считаем файлы запроса,
|
||||
# который через него отправляется.
|
||||
if str(kind) == "tcp_connection":
|
||||
if str(linked_request_id).is_empty():
|
||||
return 0.0
|
||||
var conn_total := 0.0
|
||||
for file in files.values():
|
||||
if str(file.attached_request) == str(linked_request_id):
|
||||
conn_total += _type_weight(file.type)
|
||||
return conn_total
|
||||
if files.has(entity_id):
|
||||
return _type_weight(files[entity_id].type)
|
||||
# Запрос: сумма весов прикреплённых к нему файлов.
|
||||
@@ -192,11 +211,11 @@ func _slot_level() -> int:
|
||||
if game == null:
|
||||
return _fallback_level
|
||||
var upgrades: Dictionary = game.upgrades if game.upgrades is Dictionary else {}
|
||||
# Сначала агрегированный уровень, который пишет Shop.
|
||||
for key in ["slot_level", "connection_slots", "slots"]:
|
||||
# v2: агрегированный уровень, который пишет Shop/Game (lot_slots → slot_level).
|
||||
for key in ["slot_level", "connection_slots", "lot_slots"]:
|
||||
if upgrades.has(key):
|
||||
return int(upgrades[key])
|
||||
# Совместимость с покупками по id (Game.buy_upgrade старого образца).
|
||||
# Совместимость с v1-покупками по id (если такие остались в старом сейве).
|
||||
var level := 0
|
||||
for key in ["shop_slot3", "shop_slot4"]:
|
||||
level += int(upgrades.get(key, 0))
|
||||
|
||||
@@ -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
|
||||
@@ -1,26 +1,91 @@
|
||||
class_name Folder
|
||||
extends RefCounted
|
||||
## Папка секции. Хранит только id файлов; сами файлы — в FileRegistry.
|
||||
## Папка секции. Хранит id файлов; сами файлы — в FileRegistry.
|
||||
##
|
||||
## `reserved` — счётчик слотов, зарезервированных под ещё не завершённые
|
||||
## загрузки. В save не пишется: после перезапуска незавершённых загрузок нет.
|
||||
## v2: папка — клеточная сетка `cols` x `rows`. `cells` — служебная карта
|
||||
## Vector2i -> owner_id (id файла или id пакета для резервации). В save
|
||||
## `cells` не пишется: позиции восстанавливаются из placements в FileRegistry.
|
||||
##
|
||||
## `reserved` — резервации пакетов: packet_id -> {shape_id, origin, rotation}.
|
||||
## Клетки резервации тоже лежат в `cells`, поэтому cell_occupied() возвращает
|
||||
## true и для файлов, и для резерваций.
|
||||
|
||||
var id: String = ""
|
||||
var section: String = ""
|
||||
var name: String = ""
|
||||
var cols: int = 5
|
||||
var rows: int = 4
|
||||
var file_ids: Array[String] = []
|
||||
|
||||
## Служебное: зарезервированные слоты (в файл сохранения не попадает).
|
||||
var reserved: int = 0
|
||||
## Служебное: резервации приёма (в save пишутся отдельным списком).
|
||||
var reserved: Dictionary = {}
|
||||
## Служебное: занятость клеток (не сохраняется).
|
||||
var cells: Dictionary = {}
|
||||
## Legacy-совместимость со старым reserve_slot()/release_slot() (v1 API).
|
||||
var legacy_slots: int = 0
|
||||
|
||||
|
||||
func count() -> int:
|
||||
return file_ids.size()
|
||||
|
||||
|
||||
## Свободно ли место с учётом резерваций.
|
||||
func total_cells() -> int:
|
||||
return maxi(0, cols) * maxi(0, rows)
|
||||
|
||||
|
||||
func cell_occupied(c: int, r: int) -> bool:
|
||||
return cells.has(Vector2i(c, r))
|
||||
|
||||
|
||||
func owner_at(c: int, r: int) -> String:
|
||||
return str(cells.get(Vector2i(c, r), ""))
|
||||
|
||||
|
||||
func is_reserved_cell(c: int, r: int) -> bool:
|
||||
var owner := owner_at(c, r)
|
||||
return not owner.is_empty() and reserved.has(owner)
|
||||
|
||||
|
||||
func has_free_cell() -> bool:
|
||||
return cells.size() < total_cells()
|
||||
|
||||
|
||||
func occupied_cell_count() -> int:
|
||||
return cells.size()
|
||||
|
||||
|
||||
func free_cell_count() -> int:
|
||||
return maxi(0, total_cells() - cells.size())
|
||||
|
||||
|
||||
func reserved_count() -> int:
|
||||
return reserved.size()
|
||||
|
||||
|
||||
## Legacy: раньше ёмкость измерялась слотами (16 файлов).
|
||||
func has_free_slot(capacity: int = 16) -> bool:
|
||||
return count() + reserved < capacity
|
||||
return count() + reserved.size() + legacy_slots < capacity
|
||||
|
||||
|
||||
## Служебный API для FileRegistry: занять клетку владельцем.
|
||||
func set_cell(cell: Vector2i, owner_id: String) -> void:
|
||||
cells[cell] = owner_id
|
||||
|
||||
|
||||
## Служебный API: освободить клетки, принадлежащие owner_id.
|
||||
func clear_owner(owner_id: String) -> void:
|
||||
# Не удаляем во время обхода keys(): иначе multi-cell фигура оставляет
|
||||
# «фантомную» клетку в старой папке после переноса.
|
||||
var to_erase: Array = []
|
||||
for cell in cells.keys():
|
||||
if str(cells[cell]) == owner_id:
|
||||
to_erase.append(cell)
|
||||
for cell in to_erase:
|
||||
cells.erase(cell)
|
||||
|
||||
|
||||
func clear_cells() -> void:
|
||||
cells.clear()
|
||||
|
||||
|
||||
func to_dict() -> Dictionary:
|
||||
@@ -28,6 +93,8 @@ func to_dict() -> Dictionary:
|
||||
"id": id,
|
||||
"section": section,
|
||||
"name": name,
|
||||
"cols": cols,
|
||||
"rows": rows,
|
||||
"file_ids": file_ids.duplicate(),
|
||||
}
|
||||
|
||||
@@ -37,9 +104,20 @@ static func from_dict(d: Dictionary):
|
||||
f.id = str(d.get("id", ""))
|
||||
f.section = str(d.get("section", ""))
|
||||
f.name = str(d.get("name", ""))
|
||||
f.cols = maxi(0, int(d.get("cols", 0)))
|
||||
f.rows = maxi(0, int(d.get("rows", 0)))
|
||||
var raw = d.get("file_ids", [])
|
||||
if raw is Array:
|
||||
for value in raw:
|
||||
f.file_ids.append(str(value))
|
||||
f.reserved = 0
|
||||
# v2: placements могут лежать в files — id подтянет FileRegistry.
|
||||
var placements = d.get("files", [])
|
||||
if placements is Array:
|
||||
for entry in placements:
|
||||
if entry is Dictionary:
|
||||
var fid := str(entry.get("id", ""))
|
||||
if not fid.is_empty() and not f.file_ids.has(fid):
|
||||
f.file_ids.append(fid)
|
||||
f.reserved = {}
|
||||
f.cells = {}
|
||||
return f
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -18,9 +18,22 @@ var timer_total: float = 0.0
|
||||
|
||||
var slot_reserved: bool = false
|
||||
var connected: bool = false
|
||||
## TCP: id активного TcpConnection ("" — соединения нет). Файлы нельзя
|
||||
## прикреплять, пока connection_id пуст.
|
||||
var connection_id: String = ""
|
||||
## TCP: идёт мини-игра установления соединения (полоска). Таймер заморожен.
|
||||
var connecting: bool = false
|
||||
## TCP: сколько раз игрок пытался установить соединение в этом часе.
|
||||
var connect_attempts: int = 0
|
||||
## TCP: до какого hour_elapsed действует пауза после неудачи.
|
||||
var connect_cooldown_until: float = 0.0
|
||||
var minigame_active: bool = false
|
||||
var sending: bool = false
|
||||
|
||||
## UDP: число потерь пакета за час и момент, до которого повтор запрещён.
|
||||
var udp_failures: int = 0
|
||||
var udp_retry_until: float = 0.0
|
||||
|
||||
var quality: float = 1.0
|
||||
var send_progress: float = 0.0
|
||||
var is_attack: bool = false
|
||||
@@ -32,16 +45,27 @@ var required_types: Array[String] = []
|
||||
var send_duration: float = 0.0
|
||||
var send_base_sum: float = 0.0
|
||||
|
||||
## v2: выбор мини-игры отправки. Сумма коэффициентов типов, выбранная игра и
|
||||
## её ступень. Заполняются HourDirector/Economy, в save опциональны.
|
||||
var send_sum: int = 0
|
||||
var minigame_id: String = ""
|
||||
var minigame_tier: int = 0
|
||||
|
||||
|
||||
## Готов ли запрос к отправке: все required прикреплены, TCP подключён.
|
||||
func is_ready() -> bool:
|
||||
if missing_count() > 0:
|
||||
return false
|
||||
if protocol.to_lower() == "tcp" and not connected:
|
||||
if protocol.to_lower() == "tcp" and not has_connection():
|
||||
return false
|
||||
return true
|
||||
|
||||
|
||||
## Есть ли реальное TCP-соединение (одного флага connected мало).
|
||||
func has_connection() -> bool:
|
||||
return not connection_id.is_empty()
|
||||
|
||||
|
||||
func missing_count() -> int:
|
||||
var missing := 0
|
||||
for file_id in required:
|
||||
@@ -85,8 +109,14 @@ func to_dict() -> Dictionary:
|
||||
"timer_total": timer_total,
|
||||
"slot_reserved": slot_reserved,
|
||||
"connected": connected,
|
||||
"connection_id": connection_id,
|
||||
"connecting": connecting,
|
||||
"connect_attempts": connect_attempts,
|
||||
"connect_cooldown_until": connect_cooldown_until,
|
||||
"minigame_active": minigame_active,
|
||||
"sending": sending,
|
||||
"udp_failures": udp_failures,
|
||||
"udp_retry_until": udp_retry_until,
|
||||
"quality": quality,
|
||||
"send_progress": send_progress,
|
||||
"is_attack": is_attack,
|
||||
@@ -95,6 +125,9 @@ func to_dict() -> Dictionary:
|
||||
"required_types": required_types.duplicate(),
|
||||
"send_duration": send_duration,
|
||||
"send_base_sum": send_base_sum,
|
||||
"send_sum": send_sum,
|
||||
"minigame_id": minigame_id,
|
||||
"minigame_tier": minigame_tier,
|
||||
}
|
||||
|
||||
|
||||
@@ -111,8 +144,14 @@ static func from_dict(d: Dictionary):
|
||||
r.timer_total = float(d.get("timer_total", 0.0))
|
||||
r.slot_reserved = bool(d.get("slot_reserved", false))
|
||||
r.connected = bool(d.get("connected", false))
|
||||
r.connection_id = str(d.get("connection_id", ""))
|
||||
r.connecting = bool(d.get("connecting", false))
|
||||
r.connect_attempts = maxi(0, int(d.get("connect_attempts", 0)))
|
||||
r.connect_cooldown_until = maxf(0.0, float(d.get("connect_cooldown_until", 0.0)))
|
||||
r.minigame_active = bool(d.get("minigame_active", false))
|
||||
r.sending = bool(d.get("sending", false))
|
||||
r.udp_failures = maxi(0, int(d.get("udp_failures", 0)))
|
||||
r.udp_retry_until = maxf(0.0, float(d.get("udp_retry_until", 0.0)))
|
||||
r.quality = clampf(float(d.get("quality", 1.0)), 0.0, 1.0)
|
||||
r.send_progress = float(d.get("send_progress", 0.0))
|
||||
r.is_attack = bool(d.get("is_attack", false))
|
||||
@@ -121,6 +160,9 @@ static func from_dict(d: Dictionary):
|
||||
r.required_types = _string_array(d.get("required_types", []))
|
||||
r.send_duration = float(d.get("send_duration", 0.0))
|
||||
r.send_base_sum = float(d.get("send_base_sum", 0.0))
|
||||
r.send_sum = int(d.get("send_sum", 0))
|
||||
r.minigame_id = str(d.get("minigame_id", ""))
|
||||
r.minigame_tier = int(d.get("minigame_tier", 0))
|
||||
return r
|
||||
|
||||
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
class_name Slot
|
||||
extends RefCounted
|
||||
## Слот соединения. Направление: "in" (приём TCP) или "out" (отправка).
|
||||
##
|
||||
## kind: "packet" (приём файла) / "request" (UDP-отправка) /
|
||||
## "tcp_connection" (установленное TCP-соединение). Для tcp_connection
|
||||
## entity_id — id соединения, entity_name — имя клиента, detail — IP,
|
||||
## linked_request_id — запрос, который пользуется соединением.
|
||||
|
||||
var index: int = 0
|
||||
var direction: String = ""
|
||||
@@ -9,6 +14,12 @@ var entity_name: String = ""
|
||||
var progress: float = 0.0
|
||||
var occupied: bool = false
|
||||
|
||||
var kind: String = ""
|
||||
## Дополнительная строка для UI: у tcp_connection — IP.
|
||||
var detail: String = ""
|
||||
var can_disconnect: bool = false
|
||||
var linked_request_id: String = ""
|
||||
|
||||
|
||||
func reset() -> void:
|
||||
direction = ""
|
||||
@@ -16,12 +27,22 @@ func reset() -> void:
|
||||
entity_name = ""
|
||||
progress = 0.0
|
||||
occupied = false
|
||||
kind = ""
|
||||
detail = ""
|
||||
can_disconnect = false
|
||||
linked_request_id = ""
|
||||
|
||||
|
||||
func occupy(p_direction: String, p_entity_id: String, p_entity_name: String = "") -> void:
|
||||
func occupy(p_direction: String, p_entity_id: String, p_entity_name: String = "",
|
||||
p_kind: String = "", p_detail: String = "", p_can_disconnect: bool = false,
|
||||
p_linked_request_id: String = "") -> void:
|
||||
direction = p_direction
|
||||
entity_id = p_entity_id
|
||||
entity_name = p_entity_name
|
||||
kind = _clean_kind(p_kind, p_direction)
|
||||
detail = p_detail
|
||||
can_disconnect = p_can_disconnect
|
||||
linked_request_id = p_linked_request_id
|
||||
progress = 0.0
|
||||
occupied = true
|
||||
|
||||
@@ -34,6 +55,10 @@ func to_dict() -> Dictionary:
|
||||
"entity_name": entity_name,
|
||||
"progress": progress,
|
||||
"occupied": occupied,
|
||||
"kind": kind,
|
||||
"detail": detail,
|
||||
"can_disconnect": can_disconnect,
|
||||
"linked_request_id": linked_request_id,
|
||||
}
|
||||
|
||||
|
||||
@@ -45,4 +70,20 @@ static func from_dict(d: Dictionary):
|
||||
s.entity_name = str(d.get("entity_name", ""))
|
||||
s.progress = float(d.get("progress", 0.0))
|
||||
s.occupied = bool(d.get("occupied", false))
|
||||
s.kind = _clean_kind(str(d.get("kind", "")), s.direction)
|
||||
s.detail = str(d.get("detail", ""))
|
||||
s.can_disconnect = bool(d.get("can_disconnect", false))
|
||||
s.linked_request_id = str(d.get("linked_request_id", ""))
|
||||
return s
|
||||
|
||||
|
||||
## Пустой kind выводится из направления (совместимость со старыми сейвами).
|
||||
static func _clean_kind(value: String, direction: String) -> String:
|
||||
var clean := str(value).to_lower()
|
||||
if clean == "packet" or clean == "request" or clean == "tcp_connection":
|
||||
return clean
|
||||
if direction == "in":
|
||||
return "packet"
|
||||
if direction == "out":
|
||||
return "request"
|
||||
return ""
|
||||
@@ -0,0 +1,56 @@
|
||||
class_name TcpConnection
|
||||
extends RefCounted
|
||||
## Установленное TCP-соединение клиента.
|
||||
##
|
||||
## Живёт отдельно от Request: запрос может быть удалён после успешной
|
||||
## отправки, а соединение продолжает занимать out-слот до явного
|
||||
## disconnect_connection() или конца часа. В слоте видны только имя клиента
|
||||
## и IP (detail), данные запроса туда не попадают.
|
||||
|
||||
var id: String = ""
|
||||
var client_name: String = ""
|
||||
var client_id: String = ""
|
||||
var ip: String = ""
|
||||
## Запрос, который сейчас пользуется соединением. "" — соединение свободно
|
||||
## после успешной отправки (слот остаётся занятым).
|
||||
var request_id: String = ""
|
||||
var slot_index: int = -1
|
||||
var hour: int = 0
|
||||
## Момент открытия (hour_elapsed часа) — для отладки/UI.
|
||||
var connected_at: float = 0.0
|
||||
|
||||
|
||||
func has_request() -> bool:
|
||||
return not request_id.is_empty()
|
||||
|
||||
|
||||
func display_name() -> String:
|
||||
if client_name.is_empty():
|
||||
return ip
|
||||
return client_name
|
||||
|
||||
|
||||
func to_dict() -> Dictionary:
|
||||
return {
|
||||
"id": id,
|
||||
"client_name": client_name,
|
||||
"client_id": client_id,
|
||||
"ip": ip,
|
||||
"request_id": request_id,
|
||||
"slot_index": slot_index,
|
||||
"hour": hour,
|
||||
"connected_at": connected_at,
|
||||
}
|
||||
|
||||
|
||||
static func from_dict(d: Dictionary):
|
||||
var c = new()
|
||||
c.id = str(d.get("id", ""))
|
||||
c.client_name = str(d.get("client_name", ""))
|
||||
c.client_id = str(d.get("client_id", ""))
|
||||
c.ip = str(d.get("ip", ""))
|
||||
c.request_id = str(d.get("request_id", ""))
|
||||
c.slot_index = int(d.get("slot_index", -1))
|
||||
c.hour = int(d.get("hour", 0))
|
||||
c.connected_at = float(d.get("connected_at", 0.0))
|
||||
return c
|
||||
@@ -0,0 +1 @@
|
||||
uid://c3tyrouda4ebi
|
||||
@@ -21,6 +21,9 @@ signal shop_changed
|
||||
signal toast(text: String)
|
||||
signal show_connection(game)
|
||||
signal show_minigame(game)
|
||||
signal minigame_closed(req_id: String)
|
||||
signal show_attack_minigame(game, kind: String)
|
||||
signal attack_resolved(kind: String, success: bool, tier: int)
|
||||
## Дополнительно: проброс изменения слотов (удобно для UI во время часа).
|
||||
signal slot_changed
|
||||
|
||||
@@ -35,6 +38,7 @@ const HOURS_PER_RUN := 9
|
||||
const FOLDER_CAPACITY := 16
|
||||
|
||||
const HOUR_DIRECTOR_SCRIPT := preload("res://scripts/core/hour_director.gd")
|
||||
const CONNECTION_GAME_SCRIPT := preload("res://scripts/minigames/connection_game.gd")
|
||||
const SHOP_SCRIPT := preload("res://scripts/systems/shop.gd")
|
||||
const SAVE_SCRIPT := preload("res://scripts/systems/save_system.gd")
|
||||
const ECONOMY := preload("res://scripts/core/economy.gd")
|
||||
@@ -79,7 +83,7 @@ func new_run(seed: int = 0) -> void:
|
||||
|
||||
func continue_run() -> bool:
|
||||
_bind()
|
||||
if game == null or not has_save():
|
||||
if game == null or not can_continue():
|
||||
return false
|
||||
if not bool(game.load_game()):
|
||||
return false
|
||||
@@ -97,6 +101,11 @@ func has_save() -> bool:
|
||||
return SAVE_SCRIPT.has_save()
|
||||
|
||||
|
||||
## «Продолжить» доступно только для незавершённого забега.
|
||||
func can_continue() -> bool:
|
||||
return SAVE_SCRIPT.can_continue()
|
||||
|
||||
|
||||
func _enter_prep() -> void:
|
||||
_bind()
|
||||
phase = PHASE_PREP
|
||||
@@ -104,6 +113,12 @@ func _enter_prep() -> void:
|
||||
modal_open = false
|
||||
if game != null:
|
||||
game.phase = PHASE_PREP
|
||||
# Магазин: 3 лота после каждого часа, начиная с подготовки перед H2.
|
||||
# Во время часа офферы не генерируются и магазин не открывается.
|
||||
if int(game.hour) > 1 and int(game.shop_offer_hour) != int(game.hour):
|
||||
if shop != null:
|
||||
shop.refresh(int(game.hour), int(game.money))
|
||||
shop_changed.emit()
|
||||
# Автосейв на входе в подготовку: прогресс не теряется между часами.
|
||||
game.save_game()
|
||||
phase_changed.emit(phase)
|
||||
@@ -119,11 +134,19 @@ func start_hour() -> bool:
|
||||
# Час начинается только из подготовки (menu допустим, если Game уже в prep).
|
||||
if phase != PHASE_PREP and not (phase == PHASE_MENU and game.phase == PHASE_PREP):
|
||||
return false
|
||||
# M4/M18: стол сортировки возвращает файлы до старта часа. Если места нет —
|
||||
# час не начинается (никакого soft-lock/потери файлов).
|
||||
if game.registry != null and game.registry.has_method("auto_return_table"):
|
||||
if not bool(game.registry.auto_return_table()):
|
||||
var message := "На столе остались файлы, места нет"
|
||||
toast.emit(message)
|
||||
return false
|
||||
if not bool(game.start_hour()):
|
||||
return false
|
||||
hour_director.start_hour(int(game.hour))
|
||||
hour_director.set_paused(false)
|
||||
hour_director.set_modal_open(false)
|
||||
hour_director.set_attack_paused(false)
|
||||
phase = PHASE_HOUR
|
||||
paused = false
|
||||
modal_open = false
|
||||
@@ -225,13 +248,14 @@ func prep_data() -> Dictionary:
|
||||
for file in registry.get_folder_files(str(folder.id)):
|
||||
folder_files.append(_file_entry(file))
|
||||
files_map[str(folder.id)] = folder_files
|
||||
capacity_total += FOLDER_CAPACITY
|
||||
free_slots += maxi(0, FOLDER_CAPACITY - folder.count() - int(folder.reserved))
|
||||
capacity_total += folder.total_cells()
|
||||
free_slots += folder.free_cell_count()
|
||||
folders_data.append({
|
||||
"id": str(folder.id),
|
||||
"name": str(folder.name),
|
||||
"count": folder.count(),
|
||||
"capacity": FOLDER_CAPACITY,
|
||||
"count": folder.occupied_cell_count(),
|
||||
"capacity": folder.total_cells(),
|
||||
"files": folder.count(),
|
||||
})
|
||||
sections_data.append({
|
||||
"id": str(section.id),
|
||||
@@ -262,6 +286,7 @@ func hour_data() -> Dictionary:
|
||||
var status := {
|
||||
"tcp_used": 0,
|
||||
"tcp_max": 0,
|
||||
"udp_chance": 0.0,
|
||||
"channel": 0.0,
|
||||
"channel_factor": 1.0,
|
||||
"channel_percent": 0.0,
|
||||
@@ -288,9 +313,17 @@ func hour_data() -> Dictionary:
|
||||
for id in hour_director.active_requests.keys():
|
||||
var request = hour_director.active_requests[id]
|
||||
var req_entry: Dictionary = request.to_dict()
|
||||
req_entry["ready"] = request.is_ready()
|
||||
req_entry["ready"] = bool(hour_director.is_request_ready(str(id)))
|
||||
req_entry["time_ratio"] = request.time_ratio()
|
||||
req_entry["missing"] = request.missing_count()
|
||||
req_entry["required_count"] = int(hour_director.request_required_count(str(id)))
|
||||
req_entry["missing"] = maxi(0, int(req_entry["required_count"]) - request.attached.size())
|
||||
req_entry["queued"] = bool(hour_director.is_request_queued(str(id)))
|
||||
# UDP: шанс успеха и остаток кулдауна повтора (для карточки/статуса).
|
||||
var is_udp := str(request.protocol) == "udp"
|
||||
req_entry["udp_chance"] = hour_director.udp_success_chance() if is_udp else 0.0
|
||||
req_entry["udp_retry_left"] = maxf(0.0,
|
||||
float(request.udp_retry_until) - float(hour_director.hour_elapsed)) if is_udp else 0.0
|
||||
req_entry["connection_id"] = str(request.connection_id)
|
||||
requests.append(req_entry)
|
||||
|
||||
var used := 0
|
||||
@@ -299,7 +332,20 @@ func hour_data() -> Dictionary:
|
||||
used = int(hour_director.slots.used_count())
|
||||
max_slots = int(hour_director.slots.max_slots())
|
||||
for slot in hour_director.slots.slots:
|
||||
slots_data.append(slot.to_dict())
|
||||
var entry: Dictionary = slot.to_dict()
|
||||
entry["kind"] = str(slot.kind)
|
||||
entry["detail"] = str(slot.detail)
|
||||
entry["can_disconnect"] = bool(slot.can_disconnect)
|
||||
entry["entity_name"] = str(slot.entity_name)
|
||||
if str(slot.kind) == "tcp_connection":
|
||||
# Данные запроса в слот не попадают: только статус передачи.
|
||||
var linked = hour_director.active_requests.get(str(slot.linked_request_id))
|
||||
entry["sending"] = linked != null and bool(linked.sending)
|
||||
var progress := 0.0
|
||||
if linked != null and bool(linked.sending) and float(linked.send_duration) > 0.0:
|
||||
progress = clampf(float(linked.send_progress) / float(linked.send_duration), 0.0, 1.0)
|
||||
entry["progress"] = progress
|
||||
slots_data.append(entry)
|
||||
|
||||
var channel_load := float(hour_director.channel_load())
|
||||
var capacity := float(ECONOMY.channel_capacity(game.upgrades if game != null else {}))
|
||||
@@ -307,9 +353,22 @@ func hour_data() -> Dictionary:
|
||||
if capacity > 0.0:
|
||||
percent = channel_load / capacity * 100.0
|
||||
|
||||
var sending_count := 0
|
||||
for request in hour_director.active_requests.values():
|
||||
if request != null and bool(request.sending):
|
||||
sending_count += 1
|
||||
var attacks_active := 0
|
||||
var attacks_tier := 0
|
||||
if hour_director.attack_director != null:
|
||||
if hour_director.attack_director.has_method("active_count"):
|
||||
attacks_active = int(hour_director.attack_director.active_count())
|
||||
if hour_director.attack_director.has_method("current_tier"):
|
||||
attacks_tier = int(hour_director.attack_director.current_tier())
|
||||
|
||||
status = {
|
||||
"tcp_used": used,
|
||||
"tcp_max": max_slots,
|
||||
"udp_chance": float(hour_director.udp_success_chance()),
|
||||
"channel": channel_load,
|
||||
"channel_factor": float(hour_director.channel_factor()),
|
||||
"channel_percent": percent,
|
||||
@@ -319,24 +378,39 @@ func hour_data() -> Dictionary:
|
||||
"money": int(game.money) if game != null else 0,
|
||||
"hour": int(game.hour) if game != null else int(hour_director.hour),
|
||||
"time_left": float(hour_director.time_left),
|
||||
"hour_total": float(hour_director.hour_seconds()),
|
||||
"requests_active": int(hour_director.active_requests.size()),
|
||||
"request_limit": int(hour_director.active_request_limit),
|
||||
"attacks_active": attacks_active,
|
||||
"attacks_tier": attacks_tier,
|
||||
"sending_count": sending_count,
|
||||
"queued_count": int(hour_director.send_queue_count()),
|
||||
}
|
||||
return {"packets": packets, "requests": requests, "slots": slots_data, "status": status}
|
||||
|
||||
|
||||
## Каталог магазина с текущими уровнями/ценами — для модалки магазина.
|
||||
## 3 текущих предложения магазина (v2). Пустой массив в H1/во время часа.
|
||||
func shop_data() -> Array:
|
||||
_bind()
|
||||
var out: Array = []
|
||||
if shop == null:
|
||||
if shop == null or game == null:
|
||||
return out
|
||||
for item in shop.items():
|
||||
var id := str(item.get("id", ""))
|
||||
if int(game.hour) <= 1 and game.shop_offers.is_empty():
|
||||
return out
|
||||
for raw_id in shop.offers():
|
||||
var id := str(raw_id)
|
||||
var item: Dictionary = shop.get_item(id)
|
||||
if item.is_empty():
|
||||
continue
|
||||
var item_name := str(item.get("name_ru", ""))
|
||||
if item_name.is_empty():
|
||||
item_name = str(item.get("name", id))
|
||||
out.append({
|
||||
"id": id,
|
||||
"name": item_name,
|
||||
"desc": str(item.get("desc_ru", "")),
|
||||
"effect": str(item.get("effect", "")),
|
||||
"value": float(item.get("value", 0.0)),
|
||||
"price": int(shop.price(id)),
|
||||
"level": int(shop.level(id)),
|
||||
"max_level": int(shop.max_level(id)),
|
||||
@@ -371,16 +445,41 @@ func _on_hour_toast(text: String) -> void:
|
||||
toast.emit(text)
|
||||
|
||||
|
||||
func _on_show_connection(game_node) -> void:
|
||||
modal_open = true
|
||||
show_connection.emit(game_node)
|
||||
## HourDirector эмитит трио (request_id, hour, successes): ConnectionGame
|
||||
## создаёт контроллер, а Main только монтирует готовую ноду в modal_root.
|
||||
func _on_show_connection(request_id: String, hour: int, successes: int) -> void:
|
||||
var zone_level := 0
|
||||
if game != null and game.has_method("level"):
|
||||
# Бонус зоны соединения привязан к уровню лота «Регламент обработки».
|
||||
zone_level = maxi(0, int(game.level("timer_level")))
|
||||
var node = CONNECTION_GAME_SCRIPT.new()
|
||||
node.start(int(hour), int(successes), zone_level, str(request_id))
|
||||
# Через set_modal_open(), иначе HourDirector не узнаёт о модалке и час
|
||||
# продолжает спавнить пакеты/запросы под ней.
|
||||
set_modal_open(true)
|
||||
show_connection.emit(node)
|
||||
|
||||
|
||||
func _on_show_minigame(game_node) -> void:
|
||||
modal_open = true
|
||||
set_modal_open(true)
|
||||
show_minigame.emit(game_node)
|
||||
|
||||
|
||||
func _on_minigame_closed(req_id: String) -> void:
|
||||
set_modal_open(false)
|
||||
minigame_closed.emit(req_id)
|
||||
|
||||
|
||||
func _on_show_attack_minigame(game_node, kind: String) -> void:
|
||||
set_modal_open(true)
|
||||
show_attack_minigame.emit(game_node, kind)
|
||||
|
||||
|
||||
func _on_attack_resolved(kind: String, success: bool, tier: int) -> void:
|
||||
set_modal_open(false)
|
||||
attack_resolved.emit(kind, success, tier)
|
||||
|
||||
|
||||
func _on_slot_changed() -> void:
|
||||
slot_changed.emit()
|
||||
|
||||
@@ -391,6 +490,9 @@ func _finish_run(reason: String) -> void:
|
||||
_bind()
|
||||
if game == null:
|
||||
return
|
||||
# Кончился забег посреди часа (SLA=0): останавливаем тик/модалки до смены фазы.
|
||||
if hour_director != null and hour_director.has_method("stop_for_run_end"):
|
||||
hour_director.stop_for_run_end()
|
||||
phase = PHASE_RUN_RESULT
|
||||
paused = false
|
||||
modal_open = false
|
||||
@@ -431,6 +533,9 @@ func _bind() -> void:
|
||||
hour_director.toast.connect(_on_hour_toast)
|
||||
hour_director.show_connection.connect(_on_show_connection)
|
||||
hour_director.show_minigame.connect(_on_show_minigame)
|
||||
hour_director.minigame_closed.connect(_on_minigame_closed)
|
||||
hour_director.show_attack_minigame.connect(_on_show_attack_minigame)
|
||||
hour_director.attack_resolved.connect(_on_attack_resolved)
|
||||
hour_director.slot_changed.connect(_on_slot_changed)
|
||||
if game != null and not game.run_ended.is_connected(_on_game_run_ended):
|
||||
game.run_ended.connect(_on_game_run_ended)
|
||||
@@ -440,7 +545,10 @@ func _bind() -> void:
|
||||
func _reset_director_state() -> void:
|
||||
if hour_director == null:
|
||||
return
|
||||
hour_director.start_hour(1)
|
||||
hour_director.reset_carry()
|
||||
# Подготовка перед H1: seed-файл создаём, но активный запрос/резервации
|
||||
# не оставляем — иначе игрок не видит запрос, а файл уже занят.
|
||||
hour_director.reset_for_prep()
|
||||
hour_director.running = false
|
||||
hour_director.paused = false
|
||||
hour_director.set_modal_open(false)
|
||||
|
||||
@@ -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
|
||||
@@ -0,0 +1 @@
|
||||
uid://djv0odpjkyh8p
|
||||
@@ -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
|
||||
@@ -0,0 +1 @@
|
||||
uid://dmrrnmmspoyhy
|
||||
@@ -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)
|
||||
@@ -0,0 +1 @@
|
||||
uid://lj8d55h1pplr
|
||||
@@ -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
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -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()
|
||||
@@ -0,0 +1 @@
|
||||
uid://co766vx8eagfo
|
||||
@@ -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)
|
||||
@@ -0,0 +1 @@
|
||||
uid://b21ebusrqnwn
|
||||
@@ -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)
|
||||
@@ -0,0 +1 @@
|
||||
uid://cld1besfvk2ow
|
||||
@@ -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)
|
||||
@@ -0,0 +1 @@
|
||||
uid://besue7m8ig8k6
|
||||
@@ -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)
|
||||
@@ -0,0 +1 @@
|
||||
uid://cn5qitwtcdc72
|
||||
@@ -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)
|
||||
@@ -0,0 +1 @@
|
||||
uid://cdhe6b2d04ehx
|
||||
@@ -0,0 +1,237 @@
|
||||
# run_all_tests.ps1 - headless test runner for PEAK HOUR 97 v2 (Windows).
|
||||
#
|
||||
# Usage:
|
||||
# powershell -ExecutionPolicy Bypass -File scripts/run_all_tests.ps1
|
||||
# $env:GODOT_BIN = "D:\path\Godot_v4.7.2-stable_win64_console.exe"
|
||||
# powershell -ExecutionPolicy Bypass -File scripts/run_all_tests.ps1
|
||||
#
|
||||
# Each test is a separate Godot process with a per-test timeout. Missing test
|
||||
# files are skipped (SKIP), not failed. APPDATA is redirected to a temp folder
|
||||
# for the run so tests never touch the real user save. The final parse check
|
||||
# runs `--headless --path <root> --quit`.
|
||||
|
||||
[CmdletBinding()]
|
||||
param(
|
||||
[string]$GodotBin = $env:GODOT_BIN,
|
||||
[int]$TimeoutSec = 180
|
||||
)
|
||||
|
||||
$ErrorActionPreference = "Stop"
|
||||
$Root = (Resolve-Path (Join-Path $PSScriptRoot "..")).Path
|
||||
Set-Location $Root
|
||||
|
||||
if ([string]::IsNullOrWhiteSpace($GodotBin)) {
|
||||
$GodotBin = "D:\Apps\godot\Godot_v4.7.2-stable_win64_console.exe"
|
||||
}
|
||||
if (-not (Test-Path -LiteralPath $GodotBin -PathType Leaf)) {
|
||||
$fallback = Get-Command godot4 -ErrorAction SilentlyContinue
|
||||
if (-not $fallback) { $fallback = Get-Command godot -ErrorAction SilentlyContinue }
|
||||
if ($fallback) { $GodotBin = $fallback.Source }
|
||||
}
|
||||
if (-not (Test-Path -LiteralPath $GodotBin -PathType Leaf)) {
|
||||
Write-Host "FAIL: Godot binary not found: $GodotBin" -ForegroundColor Red
|
||||
Write-Host "Set GODOT_BIN or pass -GodotBin <path>."
|
||||
exit 1
|
||||
}
|
||||
|
||||
$Tests = @(
|
||||
"res://tests/run_tests.gd",
|
||||
"res://tests/test_hour.gd",
|
||||
"res://tests/test_economy.gd",
|
||||
"res://tests/test_save.gd",
|
||||
"res://tests/test_slots.gd",
|
||||
"res://tests/test_shop.gd",
|
||||
"res://tests/test_minigame.gd",
|
||||
"res://tests/test_send_games.gd",
|
||||
"res://tests/test_attack_games.gd",
|
||||
"res://tests/test_file_grid.gd",
|
||||
"res://tests/test_shop_modal.gd",
|
||||
"res://tests/test_audio.gd",
|
||||
"res://tests/test_ambient.gd",
|
||||
"res://tests/test_fuzz.gd",
|
||||
"res://tests/test_integration.gd",
|
||||
"res://tests/test_attacks.gd",
|
||||
"res://tests/test_controller.gd",
|
||||
"res://tests/test_main_prep.gd",
|
||||
"res://tests/test_main_hour.gd",
|
||||
"res://tests/test_main_interactions.gd",
|
||||
"res://tests/test_main_attacks.gd",
|
||||
"res://tests/test_main_pause.gd",
|
||||
"res://tests/test_main_fullscreen.gd",
|
||||
"res://tests/test_main_polish.gd",
|
||||
"res://tests/test_toast.gd",
|
||||
"res://tests/test_full_playthrough.gd",
|
||||
"res://scripts/ui/ui_smoke.gd"
|
||||
)
|
||||
|
||||
$ErrorRe = 'SCRIPT ERROR|Parse Error|USER ERROR|Failed to load|Cannot open file|FAIL|SMOKE FAILED'
|
||||
$LeakRe = 'ObjectDB instances leaked|leaked at exit'
|
||||
|
||||
function Quote-Arg([string]$Value) {
|
||||
return '"' + $Value.Replace('"', '\"') + '"'
|
||||
}
|
||||
|
||||
function Invoke-Godot([string]$Arguments, [int]$Timeout) {
|
||||
$psi = New-Object System.Diagnostics.ProcessStartInfo
|
||||
$psi.FileName = $GodotBin
|
||||
$psi.Arguments = $Arguments
|
||||
$psi.WorkingDirectory = $Root
|
||||
$psi.UseShellExecute = $false
|
||||
$psi.RedirectStandardOutput = $true
|
||||
$psi.RedirectStandardError = $true
|
||||
$psi.CreateNoWindow = $true
|
||||
$process = New-Object System.Diagnostics.Process
|
||||
$process.StartInfo = $psi
|
||||
$null = $process.Start()
|
||||
$outTask = $process.StandardOutput.ReadToEndAsync()
|
||||
$errTask = $process.StandardError.ReadToEndAsync()
|
||||
$exited = $process.WaitForExit($Timeout * 1000)
|
||||
if (-not $exited) {
|
||||
try { $process.Kill() } catch { }
|
||||
try { $process.WaitForExit(5000) | Out-Null } catch { }
|
||||
}
|
||||
$stdout = ""
|
||||
$stderr = ""
|
||||
try { $stdout = $outTask.Result } catch { }
|
||||
try { $stderr = $errTask.Result } catch { }
|
||||
$code = -1
|
||||
if ($exited) { $code = $process.ExitCode }
|
||||
return @{
|
||||
ExitCode = $code
|
||||
TimedOut = (-not $exited)
|
||||
Output = ($stdout + "`n" + $stderr)
|
||||
}
|
||||
}
|
||||
|
||||
function Write-Tail([string]$Text, [int]$Count = 40) {
|
||||
$lines = $Text -split "`r?`n"
|
||||
if ($lines.Count -gt $Count) { $lines = $lines[($lines.Count - $Count)..($lines.Count - 1)] }
|
||||
Write-Host "----- output (last $Count lines) -----"
|
||||
foreach ($line in $lines) { Write-Host $line }
|
||||
Write-Host "-------------------------------------"
|
||||
}
|
||||
|
||||
function Test-Log([string]$Text, [string]$Pattern) {
|
||||
# Case-sensitive on purpose: test summaries print "fails=0", which must not
|
||||
# be mistaken for the uppercase FAIL marker.
|
||||
return [bool]([regex]::IsMatch($Text, $Pattern))
|
||||
}
|
||||
|
||||
$Tmp = Join-Path ([System.IO.Path]::GetTempPath()) ("peak-hour-tests-" + [guid]::NewGuid().ToString("N"))
|
||||
$LogDir = Join-Path $Tmp "logs"
|
||||
$OldAppData = $env:APPDATA
|
||||
New-Item -ItemType Directory -Path $Tmp -Force | Out-Null
|
||||
New-Item -ItemType Directory -Path $LogDir -Force | Out-Null
|
||||
$env:APPDATA = Join-Path $Tmp "appdata"
|
||||
New-Item -ItemType Directory -Path $env:APPDATA -Force | Out-Null
|
||||
|
||||
$Passed = 0
|
||||
$Failed = 0
|
||||
$Skipped = @()
|
||||
$FailedNames = @()
|
||||
|
||||
Write-Host "PEAK HOUR 97 - headless tests (v2, Windows)"
|
||||
Write-Host "Godot: $GodotBin"
|
||||
Write-Host "APPDATA (temp): $env:APPDATA"
|
||||
Write-Host ""
|
||||
|
||||
try {
|
||||
foreach ($script in $Tests) {
|
||||
$localPath = Join-Path $Root ($script -replace '^res://', '' -replace '/', '\')
|
||||
$name = [System.IO.Path]::GetFileNameWithoutExtension($script)
|
||||
if (-not (Test-Path -LiteralPath $localPath -PathType Leaf)) {
|
||||
$Skipped += $name
|
||||
Write-Host "SKIP $name (no file)"
|
||||
continue
|
||||
}
|
||||
|
||||
Write-Host ""
|
||||
Write-Host ">>> $script"
|
||||
$godotArgs = "--headless --path $(Quote-Arg $Root) --script $(Quote-Arg $script)"
|
||||
$result = Invoke-Godot $godotArgs $TimeoutSec
|
||||
$logPath = Join-Path $LogDir ($name + ".log")
|
||||
Set-Content -LiteralPath $logPath -Value $result.Output -Encoding UTF8
|
||||
|
||||
if ($result.TimedOut) {
|
||||
Write-Host "FAIL $name (timeout ${TimeoutSec}s)"
|
||||
Write-Tail $result.Output
|
||||
$Failed++
|
||||
$FailedNames += $name
|
||||
continue
|
||||
}
|
||||
|
||||
if ($result.ExitCode -eq 0) {
|
||||
if (Test-Log $result.Output $ErrorRe) {
|
||||
Write-Host "FAIL $name (exit 0, but errors in log)"
|
||||
Write-Tail $result.Output
|
||||
$Failed++
|
||||
$FailedNames += $name
|
||||
} else {
|
||||
Write-Host "PASS $name"
|
||||
$Passed++
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
if (Test-Log $result.Output $ErrorRe) {
|
||||
Write-Host "FAIL $name (exit $($result.ExitCode))"
|
||||
Write-Tail $result.Output
|
||||
$Failed++
|
||||
$FailedNames += $name
|
||||
continue
|
||||
}
|
||||
|
||||
if ((Test-Log $result.Output $LeakRe) -and -not (Test-Log $result.Output 'ERROR:')) {
|
||||
Write-Host "PASS $name (exit $($result.ExitCode), harmless ObjectDB leak warning)"
|
||||
$Passed++
|
||||
continue
|
||||
}
|
||||
|
||||
Write-Host "FAIL $name (exit $($result.ExitCode))"
|
||||
Write-Tail $result.Output
|
||||
$Failed++
|
||||
$FailedNames += $name
|
||||
}
|
||||
|
||||
Write-Host ""
|
||||
Write-Host ">>> parse check: --headless --path . --quit"
|
||||
$parseArgs = "--headless --path $(Quote-Arg $Root) --quit"
|
||||
$parse = Invoke-Godot $parseArgs $TimeoutSec
|
||||
Set-Content -LiteralPath (Join-Path $LogDir "parse_check.log") -Value $parse.Output -Encoding UTF8
|
||||
$parseOk = $false
|
||||
if (-not $parse.TimedOut) {
|
||||
if ($parse.ExitCode -eq 0) {
|
||||
$parseOk = $true
|
||||
} elseif ((Test-Log $parse.Output $LeakRe) -and -not (Test-Log $parse.Output 'ERROR:')) {
|
||||
$parseOk = $true
|
||||
}
|
||||
}
|
||||
if ($parseOk) {
|
||||
Write-Host "PASS parse-check"
|
||||
$Passed++
|
||||
} else {
|
||||
Write-Host "FAIL parse-check (exit $($parse.ExitCode))"
|
||||
Write-Tail $parse.Output
|
||||
$Failed++
|
||||
$FailedNames += "parse-check"
|
||||
}
|
||||
}
|
||||
finally {
|
||||
$env:APPDATA = $OldAppData
|
||||
try {
|
||||
Remove-Item -LiteralPath $Tmp -Recurse -Force -ErrorAction SilentlyContinue
|
||||
} catch { }
|
||||
}
|
||||
|
||||
Write-Host ""
|
||||
Write-Host "================================"
|
||||
Write-Host "Total: PASS=$Passed FAIL=$Failed SKIP=$($Skipped.Count)"
|
||||
if ($Skipped.Count -gt 0) {
|
||||
Write-Host "Skipped: $($Skipped -join ', ')"
|
||||
}
|
||||
if ($Failed -ne 0) {
|
||||
Write-Host "Failed: $($FailedNames -join ', ')" -ForegroundColor Red
|
||||
exit 1
|
||||
}
|
||||
Write-Host "All tests passed." -ForegroundColor Green
|
||||
exit 0
|
||||
@@ -1,18 +1,18 @@
|
||||
#!/usr/bin/env bash
|
||||
# run_all_tests.sh — прогон всех headless-тестов PEAK HOUR 97.
|
||||
# run_all_tests.sh — прогон всех headless-тестов PEAK HOUR 97 (v2).
|
||||
#
|
||||
# Запуск:
|
||||
# bash scripts/run_all_tests.sh
|
||||
# GODOT_BIN=/path/to/godot bash scripts/run_all_tests.sh
|
||||
# GODOT_BIN="/d/Apps/godot/Godot_v4.7.2-stable_win64_console.exe" bash scripts/run_all_tests.sh
|
||||
#
|
||||
# GODOT_BIN имеет приоритет. Если он не задан и Linux-путь по умолчанию
|
||||
# отсутствует, скрипт пробует `godot4`/`godot` из PATH.
|
||||
#
|
||||
# Каждый тест — отдельный процесс Godot с таймаутом 180 с. XDG_DATA_HOME
|
||||
# выносится во временный каталог, чтобы тесты не трогали пользовательские
|
||||
# данные и не мешали параллельным прогонам. В конце — проверка парсинга
|
||||
# проекта (`--quit`).
|
||||
#
|
||||
# Известный безвредный шум Godot при выходе (ObjectDB instances leaked /
|
||||
# leaked at exit) не считается провалом: тест, завершившийся с таким
|
||||
# предупреждением и без реальных ошибок, помечается PASS.
|
||||
# выносится во временный каталог. В конце — проверка парсинга проекта.
|
||||
# Отсутствующие файлы тестов пропускаются (SKIP), а не считаются FAIL:
|
||||
# волны разработки иногда удаляют модули.
|
||||
set -euo pipefail
|
||||
|
||||
ROOT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
@@ -21,8 +21,17 @@ cd "$ROOT_DIR"
|
||||
GODOT_BIN="${GODOT_BIN:-/home/eq/techlb/tools/godot/Godot_v4.7.2-stable_linux.x86_64}"
|
||||
TEST_TIMEOUT="${TEST_TIMEOUT:-180}"
|
||||
|
||||
if [[ ! -x "$GODOT_BIN" ]]; then
|
||||
for candidate in godot4 godot; do
|
||||
if command -v "$candidate" >/dev/null 2>&1; then
|
||||
GODOT_BIN="$(command -v "$candidate")"
|
||||
break
|
||||
fi
|
||||
done
|
||||
fi
|
||||
if [[ ! -x "$GODOT_BIN" ]]; then
|
||||
echo "FAIL: Godot binary is not executable: $GODOT_BIN" >&2
|
||||
echo "Set GODOT_BIN=... explicitly." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
@@ -33,18 +42,24 @@ mkdir -p "$XDG_DATA_HOME" "$LOG_DIR"
|
||||
export XDG_DATA_HOME
|
||||
|
||||
cleanup() {
|
||||
# Осторожная уборка без rm -rf: только внутри созданного mktemp-каталога.
|
||||
find "$TMP_BASE" -mindepth 1 -delete 2>/dev/null || true
|
||||
rmdir "$TMP_BASE" 2>/dev/null || true
|
||||
}
|
||||
trap cleanup EXIT
|
||||
|
||||
# Aggregator + all standalone v2 suites. New wave-D1 suites are included.
|
||||
TESTS=(
|
||||
"res://tests/run_tests.gd"
|
||||
"res://tests/test_hour.gd"
|
||||
"res://tests/test_economy.gd"
|
||||
"res://tests/test_save.gd"
|
||||
"res://tests/test_slots.gd"
|
||||
"res://tests/test_shop.gd"
|
||||
"res://tests/test_minigame.gd"
|
||||
"res://tests/test_send_games.gd"
|
||||
"res://tests/test_attack_games.gd"
|
||||
"res://tests/test_file_grid.gd"
|
||||
"res://tests/test_shop_modal.gd"
|
||||
"res://tests/test_audio.gd"
|
||||
"res://tests/test_ambient.gd"
|
||||
"res://tests/test_fuzz.gd"
|
||||
@@ -63,7 +78,17 @@ TESTS=(
|
||||
"res://scripts/ui/ui_smoke.gd"
|
||||
)
|
||||
|
||||
# Какие строки означают настоящую ошибку (а не безвредный leak-шум).
|
||||
EXISTING_TESTS=()
|
||||
SKIPPED_NAMES=()
|
||||
for t in "${TESTS[@]}"; do
|
||||
local_path="${ROOT_DIR}/${t#res://}"
|
||||
if [[ -f "$local_path" ]]; then
|
||||
EXISTING_TESTS+=("$t")
|
||||
else
|
||||
SKIPPED_NAMES+=("$(basename "$t" .gd)")
|
||||
fi
|
||||
done
|
||||
|
||||
ERROR_RE='SCRIPT ERROR|Parse Error|USER ERROR|Failed to load|Cannot open file|FAIL|SMOKE FAILED'
|
||||
LEAK_RE='ObjectDB instances leaked|leaked at exit'
|
||||
|
||||
@@ -102,7 +127,6 @@ run_one() {
|
||||
fi
|
||||
|
||||
if [[ "$rc" -eq 0 ]]; then
|
||||
# Godot может вернуть 0, но напечатать SCRIPT ERROR/Parse Error в лог.
|
||||
if grep -qE "$ERROR_RE" "$log"; then
|
||||
echo "FAIL $name (exit 0, но в логе есть ошибки)"
|
||||
print_log_tail "$log"
|
||||
@@ -115,7 +139,6 @@ run_one() {
|
||||
return 0
|
||||
fi
|
||||
|
||||
# Ненулевой код: отличаем реальную ошибку от известного leak-шума.
|
||||
if grep -qE "$ERROR_RE" "$log"; then
|
||||
echo "FAIL $name (exit $rc, ${elapsed}s)"
|
||||
print_log_tail "$log"
|
||||
@@ -136,11 +159,14 @@ run_one() {
|
||||
FAILED_NAMES+=("$name")
|
||||
}
|
||||
|
||||
echo "PEAK HOUR 97 — headless tests"
|
||||
echo "PEAK HOUR 97 — headless tests (v2)"
|
||||
echo "Godot: $GODOT_BIN"
|
||||
echo "XDG_DATA_HOME: $XDG_DATA_HOME"
|
||||
if [[ ${#SKIPPED_NAMES[@]} -gt 0 ]]; then
|
||||
echo "SKIP (нет файлов): ${SKIPPED_NAMES[*]}"
|
||||
fi
|
||||
|
||||
for t in "${TESTS[@]}"; do
|
||||
for t in "${EXISTING_TESTS[@]}"; do
|
||||
run_one "$t"
|
||||
done
|
||||
|
||||
@@ -165,11 +191,10 @@ fi
|
||||
|
||||
echo ""
|
||||
echo "================================"
|
||||
echo "Итог: PASS=$PASSED FAIL=$FAILED"
|
||||
echo "Итог: PASS=$PASSED FAIL=$FAILED SKIP=${#SKIPPED_NAMES[@]}"
|
||||
if [[ "$FAILED" -ne 0 ]]; then
|
||||
echo "Провалены: ${FAILED_NAMES[*]}"
|
||||
echo "Логи: $LOG_DIR"
|
||||
# Сохраняем логи провалов рядом с прогоном для разбора в CI.
|
||||
if [[ -n "${CI:-}" ]]; then
|
||||
mkdir -p "$ROOT_DIR/build/test-logs"
|
||||
for n in "${FAILED_NAMES[@]}"; do
|
||||
|
||||
@@ -8,7 +8,9 @@ extends RefCounted
|
||||
|
||||
const SAVE_PATH := "user://save.json"
|
||||
const TMP_PATH := "user://save.tmp"
|
||||
const VERSION := 1
|
||||
## v2: клеточные сетки/фигуры. v1 читается для миграции (Game/FileRegistry).
|
||||
const VERSION := 2
|
||||
const SUPPORTED_VERSIONS: Array[int] = [1, 2]
|
||||
|
||||
|
||||
static func save(data: Dictionary) -> bool:
|
||||
@@ -70,7 +72,7 @@ static func _read_save() -> Dictionary:
|
||||
return {}
|
||||
var data: Dictionary = parsed
|
||||
var version := int(data.get("version", -1))
|
||||
if version != VERSION:
|
||||
if not SUPPORTED_VERSIONS.has(version):
|
||||
push_warning("SaveManager: версия save %d не поддерживается (ожидается %d)" % [version, VERSION])
|
||||
return {}
|
||||
return data
|
||||
@@ -80,6 +82,27 @@ static func has_save() -> bool:
|
||||
return FileAccess.file_exists(SAVE_PATH)
|
||||
|
||||
|
||||
## Можно ли продолжить забег: файл читается и забег ещё не завершён.
|
||||
## Не мутирует Game и не меняет сейв — только решает, показывать ли «Продолжить».
|
||||
static func can_continue() -> bool:
|
||||
if not FileAccess.file_exists(SAVE_PATH):
|
||||
return false
|
||||
var data := _read_save()
|
||||
if data.is_empty():
|
||||
return false
|
||||
var registry = data.get("registry", null)
|
||||
if not (registry is Dictionary):
|
||||
return false
|
||||
if str(data.get("phase", "")) == "results":
|
||||
return false
|
||||
if float(data.get("sla", 0.0)) <= 0.0:
|
||||
return false
|
||||
# Должно совпадать с GameController.HOURS_PER_RUN (9): 9-й час — финал.
|
||||
if int(data.get("hour", 0)) > 9:
|
||||
return false
|
||||
return true
|
||||
|
||||
|
||||
static func delete_save() -> bool:
|
||||
return _remove_if_exists(SAVE_PATH)
|
||||
|
||||
|
||||
@@ -1,119 +1,107 @@
|
||||
class_name Shop
|
||||
extends RefCounted
|
||||
## Магазин: 10 позиций из data/balance.json → shop (D13 + SLA +24, п.13).
|
||||
## Магазин v2: 8 семейств лотов, ровно 3 предложения, реролл 40/80/120
|
||||
## (максимум 3 за подготовку) и замок отдельного предложения.
|
||||
##
|
||||
## Уровни покупок хранятся в Game.upgrades. Для совместимости с ядром
|
||||
## дублируются агрегированные ключи эффектов: slot_level, buffer_level,
|
||||
## timer_level, zone_level, channel_level, sections_unlocked,
|
||||
## search_unlocked, shield_unlocked.
|
||||
## Уровни покупок хранятся в Game.upgrades под id лота и под агрегированным
|
||||
## ключом эффекта (slot_level, speed_level, ...). Эффекты применяет Game
|
||||
## (set_lot_level + динамические геттеры), магазин только списывает деньги и
|
||||
## повышает уровень.
|
||||
|
||||
signal offers_changed
|
||||
signal purchased(id: String)
|
||||
|
||||
const EFFECT_TARGETS := {
|
||||
"connection_slots": "slot_level",
|
||||
"buffer_slots": "buffer_level",
|
||||
"packet_timer": "timer_level",
|
||||
"zone_width": "zone_level",
|
||||
"transfer_speed": "speed_level",
|
||||
"sla_cap": "sla_level",
|
||||
"shield_charge": "shield_level",
|
||||
"money_mult": "money_level",
|
||||
"folder_cells": "folder_level",
|
||||
"channel_capacity": "channel_level",
|
||||
"sections": "sections_unlocked",
|
||||
"search_highlight": "search_unlocked",
|
||||
"shield": "shield_unlocked",
|
||||
"sla_add": "sla_level",
|
||||
"request_timer": "timer_level",
|
||||
"udp_success": "udp_level",
|
||||
}
|
||||
|
||||
const DEFAULT_RULES := {
|
||||
"offers": 3,
|
||||
"reroll_costs": [40, 80, 120],
|
||||
"max_rerolls": 3,
|
||||
"last_offer_weight": 0.5,
|
||||
"pity_hours": 2,
|
||||
"pity_weight": 1.5,
|
||||
}
|
||||
|
||||
## Резерв, если data/balance.json недоступен. Совпадает с §3.2/3.3.
|
||||
const FALLBACK_ITEMS := [
|
||||
{"id": "shop_slot3", "name_ru": "Третий слот", "effect": "connection_slots", "value": 1, "price": 250, "max_level": 1},
|
||||
{"id": "shop_slot4", "name_ru": "Четвёртый слот", "effect": "connection_slots", "value": 1, "price": 450, "max_level": 1},
|
||||
{"id": "shop_buffer", "name_ru": "Буфер +2", "effect": "buffer_slots", "value": 2, "price": 200, "max_level": 2},
|
||||
{"id": "shop_timer", "name_ru": "Таймер пакетов +4 с", "effect": "packet_timer", "value": 4, "price": 350, "max_level": 3},
|
||||
{"id": "shop_zone", "name_ru": "Зона +5%", "effect": "zone_width", "value": 0.05, "price": 300, "max_level": 3},
|
||||
{"id": "shop_channel", "name_ru": "Канал +25", "effect": "channel_capacity", "value": 25, "price": 250, "max_level": 4},
|
||||
{"id": "shop_section", "name_ru": "Новая секция", "effect": "sections", "value": 1, "price": 300, "max_level": 5},
|
||||
{"id": "shop_search", "name_ru": "Поиск", "effect": "search_highlight", "value": 1, "price": 250, "max_level": 1},
|
||||
{"id": "shop_shield", "name_ru": "Щит", "effect": "shield", "value": 1, "price": 400, "max_level": 1},
|
||||
{"id": "shop_sla", "name_ru": "SLA +24", "effect": "sla_add", "value": 24, "price": 300, "max_level": 3},
|
||||
{"id": "lot_slots", "name_ru": "Порты коммутатора", "effect": "connection_slots", "value": 1,
|
||||
"max_level": 4, "prices": [90, 200, 400, 700],
|
||||
"weight_by_hour": [1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 0.8, 0.8, 0.8]},
|
||||
{"id": "lot_speed", "name_ru": "Оптоволокно", "effect": "transfer_speed", "value": 0.10,
|
||||
"max_level": 6, "prices": [55, 75, 100, 135, 180, 245],
|
||||
"weight_by_hour": [1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 0.8, 0.8, 0.8]},
|
||||
{"id": "lot_sla", "name_ru": "Резервный ИБП", "effect": "sla_cap", "value": 40,
|
||||
"max_level": 4, "prices": [80, 160, 270, 430],
|
||||
"weight_by_hour": [0.8, 0.8, 0.8, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0]},
|
||||
{"id": "lot_shield", "name_ru": "Заземление", "effect": "shield_charge", "value": 1,
|
||||
"max_level": 3, "prices": [100, 220, 410],
|
||||
"weight_by_hour": [0.3, 0.3, 0.3, 0.8, 0.8, 0.8, 1.0, 1.0, 1.0]},
|
||||
{"id": "lot_money", "name_ru": "Корпоративный контракт", "effect": "money_mult", "value": 0.05,
|
||||
"max_level": 8, "prices": [70, 95, 130, 170, 220, 285, 365, 465],
|
||||
"weight_by_hour": [0.6, 0.6, 0.6, 1.0, 1.0, 1.0, 1.2, 1.2, 1.2]},
|
||||
{"id": "lot_folder", "name_ru": "Складской стеллаж", "effect": "folder_cells", "value": 1,
|
||||
"max_level": 5, "prices": [50, 100, 180, 290, 440],
|
||||
"weight_by_hour": [1.0, 1.0, 1.0, 0.9, 0.9, 0.9, 0.8, 0.8, 0.8]},
|
||||
{"id": "lot_channel", "name_ru": "Пропускная способность", "effect": "channel_capacity", "value": 6,
|
||||
"buffer_value": 1, "max_level": 6, "prices": [45, 70, 95, 130, 175, 230],
|
||||
"weight_by_hour": [1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 0.9, 0.9, 0.9]},
|
||||
{"id": "lot_timer", "name_ru": "Регламент обработки", "effect": "request_timer", "value": 7,
|
||||
"max_level": 5, "prices": [45, 75, 115, 165, 230],
|
||||
"weight_by_hour": [1.0, 1.0, 1.0, 0.9, 0.9, 0.9, 0.7, 0.7, 0.7]},
|
||||
{"id": "lot_udp", "name_ru": "UDP-повторитель", "effect": "udp_success", "value": 0.10,
|
||||
"max_level": 4, "prices": [60, 120, 210, 350],
|
||||
"weight_by_hour": [0.8, 0.8, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0]},
|
||||
]
|
||||
|
||||
var _items: Array = []
|
||||
|
||||
|
||||
func _init() -> void:
|
||||
refresh()
|
||||
_load_items()
|
||||
|
||||
|
||||
func refresh() -> void:
|
||||
_items = []
|
||||
var data = _data_node()
|
||||
var raw = null
|
||||
if data != null and data.get("balance") is Dictionary:
|
||||
raw = data.balance.get("shop", null)
|
||||
if raw is Array and not raw.is_empty():
|
||||
for item in raw:
|
||||
if item is Dictionary and not str(item.get("id", "")).is_empty():
|
||||
_items.append(item.duplicate(true))
|
||||
if _items.is_empty():
|
||||
for item in FALLBACK_ITEMS:
|
||||
_items.append(item.duplicate(true))
|
||||
|
||||
# --- каталог ---------------------------------------------------------------
|
||||
|
||||
func items() -> Array:
|
||||
return _items.duplicate(true)
|
||||
|
||||
|
||||
## Каталог без закрытых прогрессией позиций. shop_sla доступен всегда.
|
||||
func available_items() -> Array:
|
||||
var out: Array = []
|
||||
for item in _items:
|
||||
if item is Dictionary and item_unlocked(item):
|
||||
out.append(item.duplicate(true))
|
||||
return out
|
||||
|
||||
|
||||
## Открыта ли позиция: shop_sla всегда; shop_search/shield — по мета-награде;
|
||||
## остальные — по открытию магазина (progression.unlocks.shop).
|
||||
func item_unlocked(item: Dictionary) -> bool:
|
||||
var key := _unlock_key(str(item.get("id", "")))
|
||||
if key.is_empty():
|
||||
return true
|
||||
var game = _game()
|
||||
if game != null and game.has_method("feature_unlocked"):
|
||||
return bool(game.feature_unlocked(key))
|
||||
return true
|
||||
|
||||
|
||||
func item_count() -> int:
|
||||
return _items.size()
|
||||
|
||||
|
||||
func get_item(id: String) -> Dictionary:
|
||||
for item in _items:
|
||||
if str(item.get("id", "")) == str(id):
|
||||
if item is Dictionary and str(item.get("id", "")) == str(id):
|
||||
return item.duplicate(true)
|
||||
return {}
|
||||
|
||||
|
||||
## Позиции каталога без гейта прогрессии (v2: все лоты доступны с H2,
|
||||
## видимость магазина решает фаза/час).
|
||||
func available_items() -> Array:
|
||||
return _items.duplicate(true)
|
||||
|
||||
|
||||
func item_unlocked(_item: Dictionary) -> bool:
|
||||
return true
|
||||
|
||||
|
||||
func level(id: String) -> int:
|
||||
var game = _game()
|
||||
if game == null:
|
||||
return 0
|
||||
var upgrades: Dictionary = game.upgrades if game.upgrades is Dictionary else {}
|
||||
if upgrades.has(str(id)):
|
||||
return int(upgrades[str(id)])
|
||||
var item := get_item(id)
|
||||
if item.is_empty():
|
||||
return 0
|
||||
var effect := str(item.get("effect", ""))
|
||||
var target := str(EFFECT_TARGETS.get(effect, ""))
|
||||
var aggregate := 0
|
||||
if not target.is_empty():
|
||||
aggregate = int(upgrades.get(target, 0))
|
||||
# Для двух слотовых товаров агрегат slot_level раскладывается по порядку
|
||||
# с учётом уже купленного второго товара.
|
||||
if effect == "connection_slots":
|
||||
var other_id := "shop_slot4" if id == "shop_slot3" else "shop_slot3"
|
||||
var other_level := int(upgrades.get(other_id, 0))
|
||||
return clampi(aggregate - other_level, 0, 1)
|
||||
return clampi(aggregate, 0, max_level(id))
|
||||
if game != null and game.has_method("level"):
|
||||
return int(game.level(id))
|
||||
return level_for(get_item(id), _levels(game))
|
||||
|
||||
|
||||
func max_level(id: String) -> int:
|
||||
@@ -123,64 +111,54 @@ func max_level(id: String) -> int:
|
||||
return maxi(1, int(item.get("max_level", 1)))
|
||||
|
||||
|
||||
## Цена следующего уровня. -1, если лот максимален (никогда не индексируем
|
||||
## prices[max_level]).
|
||||
func price(id: String) -> int:
|
||||
var item := get_item(id)
|
||||
if item.is_empty():
|
||||
return 0
|
||||
var current := level(id)
|
||||
var prices = item.get("prices", null)
|
||||
if prices is Array and not (prices as Array).is_empty():
|
||||
var arr: Array = prices
|
||||
return maxi(0, int(arr[mini(current, arr.size() - 1)]))
|
||||
if item.has("price"):
|
||||
return maxi(0, int(item.get("price", 0)))
|
||||
return maxi(0, int(item.get("cost", 0)))
|
||||
return price_for(item, level(id))
|
||||
|
||||
|
||||
func can_buy(id: String) -> bool:
|
||||
var item := get_item(id)
|
||||
if item.is_empty():
|
||||
return false
|
||||
if not item_unlocked(item):
|
||||
return false
|
||||
if level(id) >= max_level(id):
|
||||
return false
|
||||
var game = _game()
|
||||
if game == null:
|
||||
return false
|
||||
return int(game.money) >= price(id)
|
||||
if not offers().has(str(id)):
|
||||
return false
|
||||
var item := get_item(id)
|
||||
if item.is_empty():
|
||||
return false
|
||||
if level(id) >= max_level(id):
|
||||
return false
|
||||
var cost := price(id)
|
||||
if cost < 0:
|
||||
return false
|
||||
return int(game.money) >= cost
|
||||
|
||||
|
||||
func buy(id: String) -> bool:
|
||||
if not can_buy(id):
|
||||
return false
|
||||
var item := get_item(id)
|
||||
var game = _game()
|
||||
if game == null:
|
||||
return false
|
||||
var cost := price(id)
|
||||
if cost < 0:
|
||||
return false
|
||||
game.money = maxi(0, int(game.money) - cost)
|
||||
if game.has_signal("money_changed"):
|
||||
game.money_changed.emit(game.money)
|
||||
if not (game.upgrades is Dictionary):
|
||||
game.upgrades = {}
|
||||
var current := level(id)
|
||||
game.upgrades[id] = current + 1
|
||||
var effect := str(item.get("effect", ""))
|
||||
var target := str(EFFECT_TARGETS.get(effect, ""))
|
||||
if not target.is_empty():
|
||||
game.upgrades[target] = int(game.upgrades.get(target, 0)) + 1
|
||||
if effect == "sections":
|
||||
_unlock_section(game)
|
||||
elif effect == "sla_add":
|
||||
# SLA +24 за уровень; Game.add_sla сам клампит до MAX_SLA.
|
||||
var value := float(item.get("value", 24))
|
||||
if game.has_method("add_sla"):
|
||||
game.add_sla(value, "shop")
|
||||
var new_level := level(id) + 1
|
||||
if game.has_method("set_lot_level"):
|
||||
game.set_lot_level(id, new_level)
|
||||
else:
|
||||
_apply_level_fallback(game, id, new_level)
|
||||
_play_sfx(&"money_down")
|
||||
purchased.emit(id)
|
||||
offers_changed.emit()
|
||||
if game.has_signal("toast"):
|
||||
game.toast.emit("Куплено: %s" % str(item.get("name_ru", id)))
|
||||
game.toast.emit("Куплено: %s" % str(get_item(id).get("name_ru", id)))
|
||||
return true
|
||||
|
||||
|
||||
@@ -189,37 +167,327 @@ func effect_level(effect: String) -> int:
|
||||
var game = _game()
|
||||
if game == null:
|
||||
return 0
|
||||
var target := str(EFFECT_TARGETS.get(effect, ""))
|
||||
var target := str(EFFECT_TARGETS.get(str(effect), ""))
|
||||
if target.is_empty():
|
||||
return 0
|
||||
return int(game.upgrades.get(target, 0))
|
||||
return int(_levels(game).get(target, 0))
|
||||
|
||||
|
||||
# --- v2: предложения/реролл -----------------------------------------------
|
||||
|
||||
func offers() -> Array[String]:
|
||||
var out: Array[String] = []
|
||||
var game = _game()
|
||||
if game == null:
|
||||
return out
|
||||
for id in game.shop_offers:
|
||||
out.append(str(id))
|
||||
return out
|
||||
|
||||
|
||||
## Сгенерировать 3 предложения на подготовку. Идемпотентно: повторный вызов
|
||||
## для того же часа ничего не меняет, пока force = true.
|
||||
func refresh(hour: int = 0, money: int = -1, force: bool = false) -> void:
|
||||
_load_items()
|
||||
if hour <= 0:
|
||||
return
|
||||
var game = _game()
|
||||
if game == null:
|
||||
return
|
||||
var h := maxi(1, int(hour))
|
||||
if not force and int(game.shop_offer_hour) == h and not game.shop_offers.is_empty():
|
||||
offers_changed.emit()
|
||||
return
|
||||
var m := int(money) if money >= 0 else int(game.money)
|
||||
var previous: Array = []
|
||||
for id in game.shop_offers:
|
||||
previous.append(str(id))
|
||||
game.shop_offer_hour = h
|
||||
game.shop_rerolls = 0
|
||||
game.shop_locked.clear()
|
||||
var selected := select_offers(h, _items, _levels(game), m, previous,
|
||||
_seen(game), [], _offer_count())
|
||||
_write_offers(game, selected, h)
|
||||
offers_changed.emit()
|
||||
|
||||
|
||||
func reroll_cost() -> int:
|
||||
var game = _game()
|
||||
if game == null:
|
||||
return -1
|
||||
var max_rerolls := int(_rules().get("max_rerolls", 3))
|
||||
if int(game.shop_rerolls) >= max_rerolls:
|
||||
return -1
|
||||
if _non_maxed_count(_levels(game)) <= 0:
|
||||
return -1
|
||||
return _cost_for(int(game.shop_rerolls))
|
||||
|
||||
|
||||
func can_reroll() -> bool:
|
||||
var game = _game()
|
||||
if game == null:
|
||||
return false
|
||||
if game.shop_offers.is_empty():
|
||||
return false
|
||||
var cost := reroll_cost()
|
||||
if cost < 0:
|
||||
return false
|
||||
return int(game.money) >= cost
|
||||
|
||||
|
||||
## Реролл сохраняет закреплённые предложения. Стоимость 40/80/120.
|
||||
func reroll() -> bool:
|
||||
if not can_reroll():
|
||||
return false
|
||||
var game = _game()
|
||||
if game == null:
|
||||
return false
|
||||
var cost := reroll_cost()
|
||||
if cost < 0:
|
||||
return false
|
||||
game.money = maxi(0, int(game.money) - cost)
|
||||
if game.has_signal("money_changed"):
|
||||
game.money_changed.emit(game.money)
|
||||
game.shop_rerolls = int(game.shop_rerolls) + 1
|
||||
var h := maxi(1, int(game.hour))
|
||||
var previous: Array = []
|
||||
for id in game.shop_offers:
|
||||
previous.append(str(id))
|
||||
var locked: Array = []
|
||||
for id in game.shop_locked:
|
||||
locked.append(str(id))
|
||||
var selected := select_offers(h, _items, _levels(game), int(game.money), previous,
|
||||
_seen(game), locked, _offer_count())
|
||||
game.shop_offer_hour = h
|
||||
_write_offers(game, selected, h)
|
||||
offers_changed.emit()
|
||||
return true
|
||||
|
||||
|
||||
## Переключить замок предложения. Возвращает новое состояние (true = locked).
|
||||
func toggle_lock(index: int) -> bool:
|
||||
var game = _game()
|
||||
if game == null:
|
||||
return false
|
||||
var ofs := offers()
|
||||
if index < 0 or index >= ofs.size():
|
||||
return false
|
||||
var id := ofs[index]
|
||||
if game.shop_locked.has(id):
|
||||
game.shop_locked.erase(id)
|
||||
else:
|
||||
game.shop_locked.append(id)
|
||||
offers_changed.emit()
|
||||
return game.shop_locked.has(id)
|
||||
|
||||
|
||||
func is_locked(index: int) -> bool:
|
||||
var game = _game()
|
||||
if game == null:
|
||||
return false
|
||||
var ofs := offers()
|
||||
if index < 0 or index >= ofs.size():
|
||||
return false
|
||||
return game.shop_locked.has(ofs[index])
|
||||
|
||||
|
||||
func locked_ids() -> Array[String]:
|
||||
var out: Array[String] = []
|
||||
var game = _game()
|
||||
if game == null:
|
||||
return out
|
||||
for id in game.shop_locked:
|
||||
out.append(str(id))
|
||||
return out
|
||||
|
||||
|
||||
# --- v2: генерация (статические, тестируемые) ------------------------------
|
||||
|
||||
## Вес лота: база по часу × 0.5 за прошлое предложение × 1.5 за pity.
|
||||
static func lot_weight(lot: Dictionary, hour: int, last_offers: Array, last_seen: Dictionary) -> float:
|
||||
var id := str(lot.get("id", ""))
|
||||
var w := 1.0
|
||||
var arr = lot.get("weight_by_hour", null)
|
||||
if arr is Array and not (arr as Array).is_empty():
|
||||
var table: Array = arr
|
||||
w = float(table[clampi(int(hour) - 1, 0, table.size() - 1)])
|
||||
var rules: Dictionary = DEFAULT_RULES
|
||||
if last_offers.has(id):
|
||||
w *= float(rules.get("last_offer_weight", 0.5))
|
||||
if last_seen is Dictionary and last_seen.has(id):
|
||||
var since := int(hour) - int(last_seen[id])
|
||||
if since >= int(rules.get("pity_hours", 2)):
|
||||
w *= float(rules.get("pity_weight", 1.5))
|
||||
return maxf(0.0, w)
|
||||
|
||||
|
||||
## Цена следующего уровня лота. -1 при максимальном уровне.
|
||||
static func price_for(lot: Dictionary, lvl: int) -> int:
|
||||
if lot.is_empty():
|
||||
return 0
|
||||
var max_level := maxi(1, int(lot.get("max_level", 1)))
|
||||
if lvl >= max_level:
|
||||
return -1
|
||||
var prices = lot.get("prices", null)
|
||||
if prices is Array and not (prices as Array).is_empty():
|
||||
var arr: Array = prices
|
||||
return maxi(0, int(arr[clampi(lvl, 0, arr.size() - 1)]))
|
||||
if lot.has("price"):
|
||||
return maxi(0, int(lot.get("price", 0)))
|
||||
return maxi(0, int(lot.get("cost", 0)))
|
||||
|
||||
|
||||
## Уровень лота из словаря upgrades: id лота → агрегат эффекта → 0.
|
||||
static func level_for(lot: Dictionary, levels: Dictionary) -> int:
|
||||
if lot.is_empty():
|
||||
return 0
|
||||
var id := str(lot.get("id", ""))
|
||||
var max_level := maxi(1, int(lot.get("max_level", 1)))
|
||||
if levels.has(id):
|
||||
return clampi(int(levels[id]), 0, max_level)
|
||||
var effect := str(lot.get("effect", ""))
|
||||
var agg := str(EFFECT_TARGETS.get(effect, ""))
|
||||
if not agg.is_empty() and levels.has(agg):
|
||||
return clampi(int(levels[agg]), 0, max_level)
|
||||
return 0
|
||||
|
||||
|
||||
## Выбрать до count различных не-максимальных лотов.
|
||||
## must_keep — закреплённые id (сохраняются в выдаче).
|
||||
static func select_offers(
|
||||
hour: int,
|
||||
lots: Array,
|
||||
levels: Dictionary,
|
||||
money: int,
|
||||
last_offers: Array,
|
||||
last_seen: Dictionary,
|
||||
must_keep: Array = [],
|
||||
count: int = 3,
|
||||
rand_source = null
|
||||
) -> Array[String]:
|
||||
var h := maxi(1, int(hour))
|
||||
var lot_map: Dictionary = {}
|
||||
for lot in lots:
|
||||
if lot is Dictionary:
|
||||
var id := str(lot.get("id", ""))
|
||||
if not id.is_empty():
|
||||
lot_map[id] = lot
|
||||
|
||||
var result: Array[String] = []
|
||||
for raw_id in must_keep:
|
||||
var kept := str(raw_id)
|
||||
if result.has(kept) or not lot_map.has(kept):
|
||||
continue
|
||||
var kept_lot: Dictionary = lot_map[kept]
|
||||
if level_for(kept_lot, levels) >= maxi(1, int(kept_lot.get("max_level", 1))):
|
||||
continue
|
||||
result.append(kept)
|
||||
|
||||
var pool: Array = []
|
||||
for lot in lots:
|
||||
if not (lot is Dictionary):
|
||||
continue
|
||||
var id := str(lot.get("id", ""))
|
||||
if id.is_empty() or result.has(id):
|
||||
continue
|
||||
if level_for(lot, levels) >= maxi(1, int(lot.get("max_level", 1))):
|
||||
continue
|
||||
pool.append(lot)
|
||||
|
||||
var target := mini(maxi(0, int(count)), result.size() + pool.size())
|
||||
while result.size() < target and not pool.is_empty():
|
||||
var picked_index := _pick_weighted(pool, h, last_offers, last_seen, rand_source)
|
||||
var picked: Dictionary = pool[picked_index]
|
||||
result.append(str(picked.get("id", "")))
|
||||
pool.remove_at(picked_index)
|
||||
|
||||
# Хотя бы одно доступное по деньгам предложение (если такое существует).
|
||||
if money >= 0 and not _any_affordable(result, lot_map, levels, money):
|
||||
_try_swap_affordable(result, lots, lot_map, levels, money, must_keep)
|
||||
return result
|
||||
|
||||
|
||||
# --- служебное -------------------------------------------------------------
|
||||
|
||||
## Ключ открытия позиции. Данные — из Data, иначе локальная копия правила.
|
||||
func _unlock_key(id: String) -> String:
|
||||
func _load_items() -> void:
|
||||
_items = []
|
||||
var data = _data_node()
|
||||
if data != null and data.has_method("shop_unlock_key"):
|
||||
return str(data.shop_unlock_key(id))
|
||||
match id:
|
||||
"shop_sla":
|
||||
return ""
|
||||
"shop_search":
|
||||
return "search"
|
||||
"shop_shield":
|
||||
return "shield"
|
||||
_:
|
||||
return "shop"
|
||||
if data != null and data.has_method("lot_defs"):
|
||||
var raw = data.lot_defs()
|
||||
if raw is Array and not (raw as Array).is_empty():
|
||||
for item in raw:
|
||||
if item is Dictionary and not str(item.get("id", "")).is_empty():
|
||||
_items.append((item as Dictionary).duplicate(true))
|
||||
if _items.is_empty():
|
||||
for item in FALLBACK_ITEMS:
|
||||
_items.append((item as Dictionary).duplicate(true))
|
||||
|
||||
|
||||
func _unlock_section(game) -> void:
|
||||
if game.registry == null or not game.registry.has_method("create_section"):
|
||||
return
|
||||
for letter in ["D", "E", "F", "G", "H"]:
|
||||
if not game.registry.sections.has(letter):
|
||||
game.registry.create_section(letter)
|
||||
return
|
||||
func _write_offers(game, ids: Array, hour: int) -> void:
|
||||
game.shop_offers.clear()
|
||||
for id in ids:
|
||||
game.shop_offers.append(str(id))
|
||||
if not (game.shop_last_seen is Dictionary):
|
||||
game.shop_last_seen = {}
|
||||
for id in game.shop_offers:
|
||||
game.shop_last_seen[str(id)] = int(hour)
|
||||
|
||||
|
||||
func _apply_level_fallback(game, id: String, new_level: int) -> void:
|
||||
if not (game.upgrades is Dictionary):
|
||||
game.upgrades = {}
|
||||
game.upgrades[id] = new_level
|
||||
var effect := str(get_item(id).get("effect", ""))
|
||||
var target := str(EFFECT_TARGETS.get(effect, ""))
|
||||
if not target.is_empty():
|
||||
game.upgrades[target] = new_level
|
||||
|
||||
|
||||
func _offer_count() -> int:
|
||||
return maxi(1, int(_rules().get("offers", 3)))
|
||||
|
||||
|
||||
func _cost_for(reroll_index: int) -> int:
|
||||
var costs = _rules().get("reroll_costs", [40, 80, 120])
|
||||
if costs is Array and not (costs as Array).is_empty():
|
||||
var arr: Array = costs
|
||||
return maxi(0, int(arr[clampi(int(reroll_index), 0, arr.size() - 1)]))
|
||||
return 40
|
||||
|
||||
|
||||
func _non_maxed_count(levels: Dictionary) -> int:
|
||||
var count := 0
|
||||
for lot in _items:
|
||||
if not (lot is Dictionary):
|
||||
continue
|
||||
if level_for(lot, levels) < maxi(1, int(lot.get("max_level", 1))):
|
||||
count += 1
|
||||
return count
|
||||
|
||||
|
||||
func _rules() -> Dictionary:
|
||||
var data = _data_node()
|
||||
var out: Dictionary = DEFAULT_RULES.duplicate(true)
|
||||
if data != null and data.has_method("shop_rules"):
|
||||
var raw = data.shop_rules()
|
||||
if raw is Dictionary and not (raw as Dictionary).is_empty():
|
||||
for key in raw.keys():
|
||||
out[str(key)] = raw[key]
|
||||
return out
|
||||
|
||||
|
||||
func _seen(game) -> Dictionary:
|
||||
if game == null or not (game.shop_last_seen is Dictionary):
|
||||
return {}
|
||||
return game.shop_last_seen
|
||||
|
||||
|
||||
func _levels(game) -> Dictionary:
|
||||
if game == null:
|
||||
return {}
|
||||
if game.upgrades is Dictionary:
|
||||
return game.upgrades
|
||||
return {}
|
||||
|
||||
|
||||
func _game():
|
||||
@@ -243,3 +511,75 @@ func _play_sfx(id: StringName) -> void:
|
||||
var audio = (loop as SceneTree).root.get_node_or_null("Audio")
|
||||
if audio != null and audio.has_method("play_sfx"):
|
||||
audio.play_sfx(str(id))
|
||||
|
||||
|
||||
# --- статические хелперы выбора -------------------------------------------
|
||||
|
||||
static func _pick_weighted(pool: Array, hour: int, last_offers: Array, last_seen: Dictionary, rand_source) -> int:
|
||||
var weights: Array = []
|
||||
var total := 0.0
|
||||
for lot in pool:
|
||||
var w := lot_weight(lot, hour, last_offers, last_seen)
|
||||
weights.append(w)
|
||||
total += w
|
||||
if total <= 0.0:
|
||||
return int(float(_randf(rand_source)) * float(pool.size())) % pool.size()
|
||||
var roll := _randf(rand_source) * total
|
||||
var acc := 0.0
|
||||
for i in range(weights.size()):
|
||||
acc += float(weights[i])
|
||||
if roll < acc:
|
||||
return i
|
||||
return pool.size() - 1
|
||||
|
||||
|
||||
static func _any_affordable(ids: Array, lot_map: Dictionary, levels: Dictionary, money: int) -> bool:
|
||||
for raw_id in ids:
|
||||
var id := str(raw_id)
|
||||
if not lot_map.has(id):
|
||||
continue
|
||||
var p := price_for(lot_map[id], level_for(lot_map[id], levels))
|
||||
if p >= 0 and p <= money:
|
||||
return true
|
||||
return false
|
||||
|
||||
|
||||
static func _try_swap_affordable(ids: Array, lots: Array, lot_map: Dictionary, levels: Dictionary, money: int, must_keep: Array) -> void:
|
||||
var candidate := ""
|
||||
var best := 2147483647
|
||||
for lot in lots:
|
||||
if not (lot is Dictionary):
|
||||
continue
|
||||
var id := str(lot.get("id", ""))
|
||||
if id.is_empty() or ids.has(id):
|
||||
continue
|
||||
var lvl := level_for(lot, levels)
|
||||
if lvl >= maxi(1, int(lot.get("max_level", 1))):
|
||||
continue
|
||||
var p := price_for(lot, lvl)
|
||||
if p >= 0 and p <= money and p < best:
|
||||
best = p
|
||||
candidate = id
|
||||
if candidate.is_empty():
|
||||
return
|
||||
# Заменяем самое дорогое незакреплённое предложение.
|
||||
var swap_index := -1
|
||||
var worst := -1
|
||||
for i in range(ids.size()):
|
||||
var id := str(ids[i])
|
||||
if must_keep.has(id):
|
||||
continue
|
||||
if not lot_map.has(id):
|
||||
continue
|
||||
var p := price_for(lot_map[id], level_for(lot_map[id], levels))
|
||||
if p > worst:
|
||||
worst = p
|
||||
swap_index = i
|
||||
if swap_index >= 0:
|
||||
ids[swap_index] = candidate
|
||||
|
||||
|
||||
static func _randf(rand_source) -> float:
|
||||
if rand_source != null and rand_source.has_method("randf"):
|
||||
return clampf(float(rand_source.randf()), 0.0, 0.999999)
|
||||
return clampf(randf(), 0.0, 0.999999)
|
||||
@@ -0,0 +1,104 @@
|
||||
class_name AttackAlert
|
||||
extends Control
|
||||
## Короткое полноэкранное предупреждение перед мини-игрой атаки:
|
||||
## затемнение, панель «ИДЁТ АТАКА» + название, пульс и лёгкая тряска.
|
||||
## Живёт 1.1 с и эмитит finished; в reduced/headless — без движения.
|
||||
|
||||
signal finished
|
||||
|
||||
const DURATION := 1.1
|
||||
const PANEL_SIZE := Vector2(560, 168)
|
||||
|
||||
var _title := "ИДЁТ АТАКА"
|
||||
var _subtitle := ""
|
||||
var _duration := DURATION
|
||||
var _t := 0.0
|
||||
var _done := false
|
||||
var _reduced := false
|
||||
|
||||
|
||||
func _ready() -> void:
|
||||
mouse_filter = Control.MOUSE_FILTER_STOP
|
||||
focus_mode = Control.FOCUS_NONE
|
||||
process_mode = Node.PROCESS_MODE_ALWAYS
|
||||
set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT)
|
||||
_reduced = UiMotion.reduced or DisplayServer.get_name() == "headless"
|
||||
set_process(false)
|
||||
|
||||
|
||||
func play(title: String, subtitle: String, duration: float = DURATION) -> void:
|
||||
_title = title
|
||||
_subtitle = subtitle
|
||||
_duration = maxf(0.15, duration)
|
||||
_t = 0.0
|
||||
_done = false
|
||||
_reduced = UiMotion.reduced or DisplayServer.get_name() == "headless"
|
||||
visible = true
|
||||
set_process(true)
|
||||
queue_redraw()
|
||||
|
||||
|
||||
## Досрочно завершить (смена фазы/отмена атаки) без ожидания таймера.
|
||||
func finish_now() -> void:
|
||||
if _done:
|
||||
return
|
||||
_done = true
|
||||
set_process(false)
|
||||
visible = false
|
||||
finished.emit()
|
||||
queue_free()
|
||||
|
||||
|
||||
func _process(delta: float) -> void:
|
||||
if _done:
|
||||
return
|
||||
_t += delta
|
||||
queue_redraw()
|
||||
if _t >= _duration:
|
||||
finish_now()
|
||||
|
||||
|
||||
func _draw() -> void:
|
||||
var full := Rect2(Vector2.ZERO, size)
|
||||
draw_rect(full, Color(0.20, 0.05, 0.04, 0.48))
|
||||
draw_rect(full, Color(0.70, 0.16, 0.12, 0.10))
|
||||
|
||||
var pulse := 0.0
|
||||
var shake := Vector2.ZERO
|
||||
if not _reduced:
|
||||
pulse = 0.5 + 0.5 * sin(TAU * _t / 0.42)
|
||||
shake = Vector2(sin(_t * 46.0) * 2.0, cos(_t * 37.0) * 1.0).round()
|
||||
|
||||
var panel := Rect2(((size - PANEL_SIZE) * 0.5).round() + shake, PANEL_SIZE)
|
||||
# Красное свечение-пульс вокруг панели.
|
||||
if not _reduced:
|
||||
draw_rect(panel.grow(4.0 + 5.0 * pulse),
|
||||
Color(Theme90.ERROR.r, Theme90.ERROR.g, Theme90.ERROR.b,
|
||||
0.08 + 0.16 * pulse), false, 2.0)
|
||||
draw_style_box(Theme90.box(Theme90.PAPER_HI, Theme90.ERROR, 4, 2,
|
||||
Theme90.SHADOW), panel)
|
||||
draw_rect(Rect2(panel.position.x + 1, panel.position.y + 1,
|
||||
panel.size.x - 2, 4), Theme90.ERROR)
|
||||
# Тонкая внутренняя линия, чтобы панель не сливалась с затемнением.
|
||||
draw_rect(Rect2(panel.position.x + 14, panel.position.y + 54,
|
||||
panel.size.x - 28, 1), Theme90.HAIRLINE)
|
||||
|
||||
var title_col := Theme90.ERROR.darkened(0.18)
|
||||
Theme90.text_vcenter(self, Theme90.display(),
|
||||
Rect2(panel.position.x, panel.position.y + 20, panel.size.x, 34),
|
||||
_title, 30, title_col, -1, HORIZONTAL_ALIGNMENT_CENTER)
|
||||
Theme90.text_vcenter(self, Theme90.ui(),
|
||||
Rect2(panel.position.x, panel.position.y + 62, panel.size.x, 26),
|
||||
_subtitle, 18, Theme90.NAVY_DEEP, -1, HORIZONTAL_ALIGNMENT_CENTER)
|
||||
|
||||
var hint_col := Color(Theme90.ERROR.r, Theme90.ERROR.g, Theme90.ERROR.b,
|
||||
0.68 + (0.32 * pulse if not _reduced else 0.0))
|
||||
Theme90.text_vcenter(self, Theme90.ui(),
|
||||
Rect2(panel.position.x, panel.position.y + 100, panel.size.x, 18),
|
||||
"ПРИГОТОВЬТЕСЬ", 10, hint_col, -1, HORIZONTAL_ALIGNMENT_CENTER)
|
||||
# Маленькие красные штрихи-«маячки» по краям панели.
|
||||
var blink := 1.0 if _reduced else (0.45 + 0.55 * pulse)
|
||||
for i in 2:
|
||||
var x := panel.position.x + 18.0 + i * (panel.size.x - 40.0)
|
||||
draw_rect(Rect2(x, panel.end.y - 18, 8, 2),
|
||||
Color(Theme90.ERROR.r, Theme90.ERROR.g, Theme90.ERROR.b, blink))
|
||||
@@ -0,0 +1 @@
|
||||
uid://dl2emfhwp53wf
|
||||
@@ -39,19 +39,138 @@ enum Icon { NONE, X, MIN, MAX, PLUS, MINUS, ARROW_UP, ARROW_DOWN, CHECK, LEFT, R
|
||||
queue_redraw()
|
||||
@export var pad := 9.0
|
||||
|
||||
## Тайминги состояний (UiMotion). Только draw-only: position/size не меняются.
|
||||
const HOVER_IN := 0.10
|
||||
const HOVER_OUT := 0.12
|
||||
const PRESS_IN := 0.06
|
||||
const PRESS_OUT := 0.08
|
||||
const FOCUS_DUR := 0.12
|
||||
## Насколько сдвигается содержимое в нажатом состоянии, px.
|
||||
const PRESS_SHIFT := 1.0
|
||||
## Клик-рябь: draw-only кольцо из точки нажатия.
|
||||
const RIPPLE_DUR := 0.22
|
||||
|
||||
var _hover_amt := 0.0
|
||||
var _press_amt := 0.0
|
||||
var _focus_amt := 0.0
|
||||
## Локальный выключатель движения (Lane A / настройки). UiMotion.reduced тоже.
|
||||
var _motion_on := true
|
||||
var _ripple_t := 1.0
|
||||
var _ripple_pos := Vector2.ZERO
|
||||
|
||||
|
||||
func _ready() -> void:
|
||||
focus_mode = Control.FOCUS_ALL
|
||||
mouse_default_cursor_shape = Control.CURSOR_ARROW
|
||||
mouse_entered.connect(queue_redraw)
|
||||
mouse_exited.connect(queue_redraw)
|
||||
button_down.connect(queue_redraw)
|
||||
button_up.connect(queue_redraw)
|
||||
mouse_entered.connect(_touch)
|
||||
mouse_exited.connect(_touch)
|
||||
button_down.connect(_touch)
|
||||
button_down.connect(_start_ripple)
|
||||
button_up.connect(_touch)
|
||||
focus_entered.connect(_touch)
|
||||
focus_exited.connect(_touch)
|
||||
resized.connect(queue_redraw)
|
||||
if not pressed.is_connected(_play_click):
|
||||
pressed.connect(_play_click)
|
||||
|
||||
|
||||
## Клик-рябь из точки нажатия (0.22 c, только отрисовка).
|
||||
func _start_ripple() -> void:
|
||||
if _reduced():
|
||||
return
|
||||
_ripple_pos = get_local_mouse_position()
|
||||
_ripple_t = 0.0
|
||||
queue_redraw()
|
||||
set_process(true)
|
||||
|
||||
|
||||
## Разбудить плавные состояния. Дальше _process сам остановится на цели.
|
||||
func _touch() -> void:
|
||||
set_process(true)
|
||||
queue_redraw()
|
||||
|
||||
|
||||
## Внешний переключатель (Lane A вызывает через has_method).
|
||||
func set_motion_enabled(on: bool) -> void:
|
||||
_motion_on = on
|
||||
if _reduced():
|
||||
_snap()
|
||||
_ripple_t = 1.0
|
||||
queue_redraw()
|
||||
|
||||
|
||||
func _reduced() -> bool:
|
||||
return not _motion_on or UiMotion.reduced
|
||||
|
||||
|
||||
func _targets() -> Vector3:
|
||||
var mode := get_draw_mode()
|
||||
var dis := mode == BaseButton.DRAW_DISABLED or disabled
|
||||
var hover := (mode == BaseButton.DRAW_HOVER or mode == BaseButton.DRAW_HOVER_PRESSED) and not dis
|
||||
var press := (mode == BaseButton.DRAW_PRESSED or mode == BaseButton.DRAW_HOVER_PRESSED) and not dis
|
||||
if toggle_mode and button_pressed:
|
||||
press = true
|
||||
return Vector3(
|
||||
1.0 if hover else 0.0,
|
||||
1.0 if press else 0.0,
|
||||
1.0 if has_focus() and not dis else 0.0)
|
||||
|
||||
|
||||
func _snap() -> void:
|
||||
var t := _targets()
|
||||
_hover_amt = t.x
|
||||
_press_amt = t.y
|
||||
_focus_amt = t.z
|
||||
|
||||
|
||||
func _process(delta: float) -> void:
|
||||
var t := _targets()
|
||||
if _reduced():
|
||||
if not _at(t) or _ripple_t < 1.0:
|
||||
_snap()
|
||||
_ripple_t = 1.0
|
||||
queue_redraw()
|
||||
set_process(false)
|
||||
return
|
||||
var changed := false
|
||||
if not is_equal_approx(_hover_amt, t.x):
|
||||
_hover_amt = move_toward(_hover_amt, t.x, delta / (HOVER_IN if t.x > _hover_amt else HOVER_OUT))
|
||||
changed = true
|
||||
if not is_equal_approx(_press_amt, t.y):
|
||||
_press_amt = move_toward(_press_amt, t.y, delta / (PRESS_IN if t.y > _press_amt else PRESS_OUT))
|
||||
changed = true
|
||||
if not is_equal_approx(_focus_amt, t.z):
|
||||
_focus_amt = move_toward(_focus_amt, t.z, delta / FOCUS_DUR)
|
||||
changed = true
|
||||
if _ripple_t < 1.0:
|
||||
_ripple_t = minf(1.0, _ripple_t + delta / RIPPLE_DUR)
|
||||
changed = true
|
||||
if changed:
|
||||
queue_redraw()
|
||||
# Цель достигнута — не крутим _process зря.
|
||||
if _at(t) and _ripple_t >= 1.0:
|
||||
_hover_amt = t.x
|
||||
_press_amt = t.y
|
||||
_focus_amt = t.z
|
||||
set_process(false)
|
||||
|
||||
|
||||
func _at(t: Vector3) -> bool:
|
||||
return is_equal_approx(_hover_amt, t.x) \
|
||||
and is_equal_approx(_press_amt, t.y) \
|
||||
and is_equal_approx(_focus_amt, t.z)
|
||||
|
||||
|
||||
## Значения для _draw: в reduced — мгновенные, иначе текущие плавные.
|
||||
func _amts() -> Vector3:
|
||||
if _reduced():
|
||||
return _targets()
|
||||
var t := _targets()
|
||||
if not is_processing() and (not _at(t) or _ripple_t < 1.0):
|
||||
set_process(true)
|
||||
return Vector3(_hover_amt, _press_amt, _focus_amt)
|
||||
|
||||
|
||||
## Клик-звук UI. Audio — autoload; в отрыве от дерева (превью) молча пропускаем.
|
||||
func _play_click() -> void:
|
||||
var audio := get_node_or_null("/root/Audio")
|
||||
@@ -77,13 +196,13 @@ func _accent_border() -> Color:
|
||||
|
||||
|
||||
func _draw() -> void:
|
||||
var amts := _amts()
|
||||
var hov := amts.x
|
||||
var prs := amts.y
|
||||
var foc := amts.z
|
||||
var r := Rect2(Vector2.ZERO, size)
|
||||
var mode := get_draw_mode()
|
||||
var dis := mode == BaseButton.DRAW_DISABLED or disabled
|
||||
var pressed := mode == BaseButton.DRAW_PRESSED or mode == BaseButton.DRAW_HOVER_PRESSED
|
||||
var hover := mode == BaseButton.DRAW_HOVER or mode == BaseButton.DRAW_HOVER_PRESSED
|
||||
if toggle_mode and button_pressed:
|
||||
pressed = true
|
||||
var is_active := active or (toggle_mode and button_pressed)
|
||||
var f := Theme90.ui()
|
||||
|
||||
@@ -91,80 +210,117 @@ func _draw() -> void:
|
||||
var bg := Color(0, 0, 0, 0)
|
||||
if is_active:
|
||||
bg = Theme90.ACCENT_PALE
|
||||
elif hover and not dis:
|
||||
bg = Color(1, 1, 1, 0.5)
|
||||
elif hov > 0.001 and not dis:
|
||||
bg = Color(1, 1, 1, 0.5 * hov)
|
||||
if bg.a > 0.0:
|
||||
draw_style_box(Theme90.box(bg, Color(0, 0, 0, 0), 3, 0), r)
|
||||
var fg := Theme90.INK
|
||||
if dis:
|
||||
fg = Theme90.SHADE
|
||||
elif is_active or hover:
|
||||
elif is_active:
|
||||
fg = Theme90.NAVY
|
||||
else:
|
||||
fg = Theme90.INK.lerp(Theme90.NAVY, hov)
|
||||
if is_active:
|
||||
draw_rect(Rect2(r.position.x + 5, r.end.y - 2, maxf(0.0, r.size.x - 10), 2),
|
||||
Theme90.ACCENT)
|
||||
_draw_content(r, f, fg, pressed, dis)
|
||||
_draw_glow(r, hov, true)
|
||||
_draw_ripple(r, true)
|
||||
_draw_content(r, f, fg, PRESS_SHIFT * roundf(prs), dis)
|
||||
return
|
||||
|
||||
var face := Theme90.PAPER_HI
|
||||
var border := Theme90.BORDER
|
||||
var fg := Theme90.INK
|
||||
var shadow := Theme90.SHADOW
|
||||
var hover_face := Theme90.WHITE
|
||||
var hover_border := _accent_border()
|
||||
var hover_fg := Theme90.NAVY
|
||||
if dis:
|
||||
face = Theme90.PAPER
|
||||
border = Theme90.BORDER_SOFT
|
||||
fg = Theme90.SHADE
|
||||
shadow = Color(0, 0, 0, 0)
|
||||
hover_face = face
|
||||
hover_border = border
|
||||
hover_fg = fg
|
||||
elif accent:
|
||||
face = Theme90.ACCENT.darkened(0.06) if pressed else Theme90.ACCENT
|
||||
face = Theme90.ACCENT
|
||||
border = Theme90.ACCENT.darkened(0.18)
|
||||
fg = Theme90.WHITE
|
||||
if hover:
|
||||
face = Theme90.ACCENT.lightened(0.04)
|
||||
hover_face = Theme90.ACCENT.lightened(0.04)
|
||||
hover_border = border
|
||||
hover_fg = fg
|
||||
elif danger:
|
||||
face = Theme90.PAPER_HI
|
||||
border = Color(Theme90.ERROR.r, Theme90.ERROR.g, Theme90.ERROR.b, 0.42)
|
||||
fg = Theme90.ERROR
|
||||
if hover:
|
||||
face = Color(1, 1, 1, 1)
|
||||
border = Theme90.ERROR
|
||||
hover_face = Color(1, 1, 1, 1)
|
||||
hover_border = Theme90.ERROR
|
||||
hover_fg = fg
|
||||
elif is_active:
|
||||
face = Theme90.ACCENT_PALE
|
||||
border = Color(Theme90.ACCENT.r, Theme90.ACCENT.g, Theme90.ACCENT.b, 0.65)
|
||||
fg = Theme90.NAVY
|
||||
if hover:
|
||||
face = Theme90.ACCENT_PALE.lerp(Theme90.WHITE, 0.35)
|
||||
else:
|
||||
if hover:
|
||||
face = Theme90.WHITE
|
||||
border = _accent_border()
|
||||
fg = Theme90.NAVY
|
||||
hover_face = Theme90.ACCENT_PALE.lerp(Theme90.WHITE, 0.35)
|
||||
hover_border = border
|
||||
hover_fg = fg
|
||||
|
||||
if pressed and not dis:
|
||||
face = face.darkened(0.05) if face.get_luminance() > 0.4 else face.darkened(0.1)
|
||||
shadow = Color(0, 0, 0, 0)
|
||||
face = face.lerp(hover_face, hov)
|
||||
border = border.lerp(hover_border, hov)
|
||||
fg = fg.lerp(hover_fg, hov)
|
||||
|
||||
if not (pressed and not dis):
|
||||
if prs > 0.0 and not dis:
|
||||
var dv := 0.05 if face.get_luminance() > 0.4 else 0.1
|
||||
face = face.darkened(dv * prs)
|
||||
shadow = shadow.lerp(Color(0, 0, 0, 0), prs)
|
||||
|
||||
if prs < 0.5 or dis:
|
||||
draw_style_box(Theme90.box(face, border, 3, 1, shadow), r)
|
||||
else:
|
||||
draw_style_box(Theme90.box(face, border, 3, 1), r)
|
||||
draw_line(Vector2(3, 1), Vector2(r.size.x - 3, 1), Color(0, 0, 0, 0.10), 1.0)
|
||||
|
||||
# мягкий верхний блик
|
||||
if not pressed and not dis and face.get_luminance() > 0.55 and r.size.y >= 16.0:
|
||||
if prs < 0.5 and not dis and face.get_luminance() > 0.55 and r.size.y >= 16.0:
|
||||
draw_line(Vector2(4, 1), Vector2(r.size.x - 4, 1), Color(1, 1, 1, 0.55), 1.0)
|
||||
|
||||
# аккуратный фокус — тонкое кольцо внутри
|
||||
if has_focus() and not dis:
|
||||
# аккуратный фокус — тонкое кольцо внутри, α плавно проявляется
|
||||
if foc > 0.01 and not dis:
|
||||
draw_style_box(Theme90.box(Color(0, 0, 0, 0),
|
||||
Color(Theme90.ACCENT.r, Theme90.ACCENT.g, Theme90.ACCENT.b, 0.75), 3, 1),
|
||||
Color(Theme90.ACCENT.r, Theme90.ACCENT.g, Theme90.ACCENT.b, 0.75 * foc), 3, 1),
|
||||
r.grow(-2.0))
|
||||
|
||||
_draw_content(r, f, fg, pressed, dis)
|
||||
_draw_glow(r, hov, face.get_luminance() > 0.5)
|
||||
_draw_ripple(r, face.get_luminance() > 0.5)
|
||||
_draw_content(r, f, fg, PRESS_SHIFT * roundf(prs), dis)
|
||||
|
||||
|
||||
func _draw_content(r: Rect2, f: Font, fg: Color, pressed: bool, dis: bool) -> void:
|
||||
var shift := Vector2(0, 1) if pressed and not dis else Vector2.ZERO
|
||||
## Мягкая подсветка при наведении — внутри рамки, без изменения layout.
|
||||
func _draw_glow(r: Rect2, hov: float, light: bool) -> void:
|
||||
if hov <= 0.01:
|
||||
return
|
||||
var c := Theme90.ACCENT_LIGHT if light else Theme90.WHITE
|
||||
draw_style_box(Theme90.box(Color(c.r, c.g, c.b, (0.10 if light else 0.09) * hov),
|
||||
Color(0, 0, 0, 0), 3, 0), r.grow(-2.0))
|
||||
|
||||
|
||||
## Клик-рябь: кольцо из точки нажатия, гаснет за 0.22 c (draw-only).
|
||||
func _draw_ripple(r: Rect2, light: bool) -> void:
|
||||
if _ripple_t >= 1.0 or _reduced():
|
||||
return
|
||||
var p := clampf(_ripple_t, 0.0, 1.0)
|
||||
var a := (1.0 - p) * 0.32
|
||||
if a <= 0.01:
|
||||
return
|
||||
var max_r := maxf(2.0, minf(r.size.x, r.size.y) * 0.62)
|
||||
var rad := lerpf(2.0, max_r, p)
|
||||
var c := Theme90.ACCENT if light else Theme90.WHITE
|
||||
draw_arc(_ripple_pos, rad, 0.0, TAU, 40, Color(c.r, c.g, c.b, a), 1.5, true)
|
||||
|
||||
|
||||
func _draw_content(r: Rect2, f: Font, fg: Color, shift_y: float, _dis: bool) -> void:
|
||||
var shift := Vector2(0, shift_y)
|
||||
var icon_w := 0.0
|
||||
if icon != Icon.NONE:
|
||||
icon_w = 12.0
|
||||
|
||||
@@ -21,7 +21,6 @@ var _yes_text := "Да"
|
||||
var _no_text := "Нет"
|
||||
var _btn_yes: Button90
|
||||
var _btn_no: Button90
|
||||
var _btn_x: Button90
|
||||
|
||||
|
||||
func _ready() -> void:
|
||||
@@ -65,12 +64,6 @@ func _build() -> void:
|
||||
add_child(_btn_no)
|
||||
_btn_no.pressed.connect(_answer.bind(false))
|
||||
|
||||
_btn_x = Button90.new()
|
||||
_btn_x.icon = Button90.Icon.X
|
||||
_btn_x.tooltip_text = "Нет"
|
||||
_btn_x.pressed.connect(_answer.bind(false))
|
||||
add_child(_btn_x)
|
||||
|
||||
|
||||
func _answer(value: bool) -> void:
|
||||
if not visible:
|
||||
@@ -88,15 +81,14 @@ func _window_rect() -> Rect2:
|
||||
|
||||
func _layout() -> void:
|
||||
var r := _window_rect()
|
||||
# Нижний ряд: 100 + 12 + 100 = 212, по центру панели.
|
||||
var row_x := r.position.x + roundf((W - 212.0) * 0.5)
|
||||
if _btn_yes != null:
|
||||
_btn_yes.position = r.position + Vector2(236, 102)
|
||||
_btn_yes.position = Vector2(row_x, r.position.y + 102)
|
||||
_btn_yes.size = Vector2(100, 24)
|
||||
if _btn_no != null:
|
||||
_btn_no.position = r.position + Vector2(348, 102)
|
||||
_btn_no.position = Vector2(row_x + 112.0, r.position.y + 102)
|
||||
_btn_no.size = Vector2(100, 24)
|
||||
if _btn_x != null:
|
||||
_btn_x.position = r.position + Vector2(W - 22, 4)
|
||||
_btn_x.size = Vector2(16, 16)
|
||||
|
||||
|
||||
func _unhandled_input(event: InputEvent) -> void:
|
||||
@@ -121,23 +113,31 @@ func _draw_frame(r: Rect2) -> void:
|
||||
Theme90.draw_bevel(self, r, false, Theme90.BEVEL, Theme90.WHITE)
|
||||
Theme90.modal_header(self, r, "ПОДТВЕРЖДЕНИЕ", "ТРЕБУЕТСЯ ОТВЕТ")
|
||||
|
||||
# знак вопроса в утопленной плашке
|
||||
var well := Rect2(r.position + Vector2(18, 28), Vector2(34, 34))
|
||||
# вопрос и пояснение — центрируем иконку и текст как единый блок
|
||||
var f := Theme90.ui()
|
||||
var lines := Theme90.wrap(f, _question, Theme90.FS_BODY, 370, 2)
|
||||
var text_w := 0.0
|
||||
for line in lines:
|
||||
text_w = maxf(text_w, f.get_string_size(line, HORIZONTAL_ALIGNMENT_LEFT, -1,
|
||||
Theme90.FS_BODY).x)
|
||||
var icon_size := 24.0
|
||||
var icon_gap := 10.0
|
||||
var group_w := icon_size + icon_gap + text_w
|
||||
var gx := r.position.x + roundf((W - group_w) * 0.5)
|
||||
var well := Rect2(gx, r.position.y + 27, icon_size, icon_size)
|
||||
Theme90.draw_sunken(self, well)
|
||||
Theme90.text_vcenter(self, Theme90.logo(), well, "?",
|
||||
18, Theme90.ERROR if _danger else Theme90.NAVY)
|
||||
|
||||
# вопрос и пояснение
|
||||
var f := Theme90.ui()
|
||||
var lines := Theme90.wrap(f, _question, 14, 378, 2)
|
||||
var y := r.position.y + 30
|
||||
14, Theme90.ERROR if _danger else Theme90.NAVY)
|
||||
var tx := gx + icon_size + icon_gap
|
||||
var y := r.position.y + 31
|
||||
for line in lines:
|
||||
Theme90.text(self, f, Vector2(r.position.x + 62, y), line, 14, Theme90.INK)
|
||||
Theme90.text(self, f, Vector2(tx, y), line, Theme90.FS_BODY, Theme90.INK,
|
||||
text_w, HORIZONTAL_ALIGNMENT_CENTER)
|
||||
y += 18.0
|
||||
if not _detail.is_empty():
|
||||
Theme90.text(self, f, Vector2(r.position.x + 62, r.position.y + 58),
|
||||
Theme90.fit(f, _detail, Theme90.FS_MICRO, 378), Theme90.FS_MICRO,
|
||||
Theme90.ERROR if _danger else Theme90.DARK)
|
||||
Theme90.text_vcenter(self, f, Rect2(r.position.x + 12, y + 4.0, W - 24, 14),
|
||||
Theme90.fit(f, _detail, Theme90.FS_MICRO, W - 24), Theme90.FS_MICRO,
|
||||
Theme90.ERROR if _danger else Theme90.DARK, -1, HORIZONTAL_ALIGNMENT_CENTER)
|
||||
|
||||
# разделитель над кнопками
|
||||
var ly := r.position.y + 88
|
||||
|
||||
@@ -1,8 +1,63 @@
|
||||
extends SceneTree
|
||||
## Dev-инструмент (не часть игры): снимает кадр сцены в PNG.
|
||||
##
|
||||
## godot --path . --script scripts/ui/dev_capture.gd -- --scene=res://scenes/Main.tscn --out=/tmp/opencode/peak.png
|
||||
## godot --path . --script scripts/ui/dev_capture.gd -- --probe --out=/tmp/opencode/font_probe.png
|
||||
## Базовые примеры:
|
||||
## godot --path . --script scripts/ui/dev_capture.gd -- --scene=res://scenes/Main.tscn --out=/tmp/opencode/peak.png
|
||||
## godot --path . --script scripts/ui/dev_capture.gd -- --probe --out=/tmp/opencode/font_probe.png
|
||||
##
|
||||
## Экраны игры (флаги сами запускают новый забег, если это нужно):
|
||||
## ... -- --run --out=/tmp/opencode/prep.png
|
||||
## ... -- --hour --seconds=8 --spawn=packet,request --out=/tmp/opencode/hour.png
|
||||
## ... -- --minigame=dots --tier=2 --out=/tmp/opencode/dots.png
|
||||
## ... -- --shop --out=/tmp/opencode/shop.png
|
||||
## ... -- --tutorial --out=/tmp/opencode/tutorial.png
|
||||
##
|
||||
## Флаги:
|
||||
## --scene=PATH сцена для загрузки (по умолчанию res://scenes/Main.tscn)
|
||||
## --out=PATH куда сохранить PNG (по умолчанию /tmp/opencode/peak.png)
|
||||
## --wait=N сколько кадров ждать перед снимком (по умолчанию 10)
|
||||
## --probe отрисовать шрифтовой probe вместо Main
|
||||
## --run нажать «НОВЫЙ ЗАБЕГ» (Main._start_new_run)
|
||||
## --seed-demo в подготовку положить sound-файл (2 клетки) для проверки drag
|
||||
## --hour после --run начать час 1 (Main._on_start_hour)
|
||||
## --seconds=N сколько реальных секунд крутить час (по умолчанию 6) — нужен --hour
|
||||
## --spawn=A,B через HourDirector спавнить packet и/или request (нужен --hour)
|
||||
## --folder=ID открыть папку по id (как раньше); с --run без --folder
|
||||
## автоматически открывается первая папка
|
||||
## --minigame=ID dots|sound|text|video|photo|maze|junk|ddos|phishing|ransomware
|
||||
## --tier=N сложность мини-игры, 1..4 (по умолчанию 2)
|
||||
## --seed=N сид мини-игры (по умолчанию 12345)
|
||||
## --shop свернуть офферы для часа 2 и открыть ShopModal
|
||||
## --tutorial запустить забег и показать маскота-туториал
|
||||
## --sla=N выставить SLA (dev, для скриншотов «ЖАРЫ»)
|
||||
## --stress=STATE calm|warm|hot|critical|attack — force_heat оверлея
|
||||
## --reduce включить «Снизить эффекты» (UiMotion.reduced)
|
||||
## --menu контекстное меню 90-х (как раньше)
|
||||
## --filemenu выпадающее меню «ФАЙЛ» (как раньше)
|
||||
## --toast два демо-тоста (как раньше)
|
||||
## --hover=x,y навести курсор
|
||||
## --press=x,y нажать ЛКМ в точке (без отпускания)
|
||||
## --drag=x1,y1,x2,y2 настоящий drag: press → motion ×8 → release
|
||||
## --hold с --drag: остановиться до release (кадр «в процессе»)
|
||||
## --raw-coords координаты --hover/--press/--drag заданы в пикселях окна,
|
||||
## а не в логических 1280×720 (аварийный режим совместимости)
|
||||
##
|
||||
## ПРОСТРАНСТВО КООРДИНАТ:
|
||||
## Все координаты указателя (--hover/--press/--drag) — ЛОГИЧЕСКИЕ 1280×720,
|
||||
## как в макете. Внутри они умножаются на реальный размер окна / 1280×720
|
||||
## (обычно 1920×1080 → ×1.5). Скриншот всегда сохраняется в пикселях окна
|
||||
## (обычно 1920×1080). --raw-coords отключает пересчёт.
|
||||
##
|
||||
## При ошибке (нет метода Main, пустая папка, мини-игра не создалась) скрипт
|
||||
## печатает причину и завершается с кодом 1, а не снимает меню молча.
|
||||
|
||||
const LOGICAL_SIZE := Vector2(1280.0, 720.0)
|
||||
const SEND_GAMES: Array[String] = [
|
||||
"dots", "sound", "text", "video", "photo", "maze",
|
||||
]
|
||||
const ATTACK_GAMES: Array[String] = [
|
||||
"junk", "ddos", "phishing", "ransomware",
|
||||
]
|
||||
|
||||
var _scene_path := "res://scenes/Main.tscn"
|
||||
var _out := "/tmp/opencode/peak.png"
|
||||
@@ -16,6 +71,28 @@ var _folder := ""
|
||||
var _filemenu := false
|
||||
var _main: Node = null
|
||||
|
||||
var _run := false
|
||||
var _seed_demo := false
|
||||
var _hour := false
|
||||
var _seconds := 6.0
|
||||
var _spawn: Array[String] = []
|
||||
var _minigame := ""
|
||||
var _tier := 2
|
||||
var _seed := 12345
|
||||
var _shop := false
|
||||
var _tutorial := false
|
||||
## [start, end] в логических координатах; пустой массив — drag не нужен.
|
||||
var _drag: Array[Vector2] = []
|
||||
var _hold := false
|
||||
var _raw_coords := false
|
||||
## «ЖАРА»: dev-флаги для скриншотов нагрева.
|
||||
var _sla := -1.0
|
||||
var _stress := ""
|
||||
var _reduce := false
|
||||
|
||||
## Снимок делается только после того, как сцена и запрошенные экраны готовы.
|
||||
var _setup_done := false
|
||||
|
||||
|
||||
func _initialize() -> void:
|
||||
for a in OS.get_cmdline_user_args():
|
||||
@@ -33,51 +110,178 @@ func _initialize() -> void:
|
||||
_filemenu = true
|
||||
elif a == "--toast":
|
||||
_toast = true
|
||||
elif a.begins_with("--hover="):
|
||||
var hp := a.substr(8).split(",")
|
||||
_hover = Vector2(float(hp[0]), float(hp[1]))
|
||||
elif a == "--run":
|
||||
_run = true
|
||||
elif a == "--seed-demo":
|
||||
_seed_demo = true
|
||||
elif a == "--hour":
|
||||
_hour = true
|
||||
elif a.begins_with("--seconds="):
|
||||
_seconds = maxf(0.0, float(a.substr(10)))
|
||||
elif a.begins_with("--spawn="):
|
||||
_spawn.clear()
|
||||
for raw in a.substr(8).split(",", false):
|
||||
var token := raw.strip_edges().to_lower()
|
||||
if token.is_empty():
|
||||
continue
|
||||
if token != "packet" and token != "request":
|
||||
_fail("--spawn: неизвестный тип '%s' (ожидается packet или request)" % token)
|
||||
return
|
||||
_spawn.append(token)
|
||||
elif a.begins_with("--folder="):
|
||||
_folder = a.substr(9)
|
||||
elif a.begins_with("--minigame="):
|
||||
_minigame = a.substr(11).strip_edges().to_lower()
|
||||
elif a.begins_with("--tier="):
|
||||
_tier = clampi(int(a.substr(7)), 1, 4)
|
||||
elif a.begins_with("--seed="):
|
||||
_seed = int(a.substr(7))
|
||||
elif a == "--shop":
|
||||
_shop = true
|
||||
elif a == "--tutorial":
|
||||
_tutorial = true
|
||||
elif a.begins_with("--hover="):
|
||||
_hover = _parse_pair(a.substr(8))
|
||||
if _hover.x < 0.0:
|
||||
_fail("--hover: нужен формат x,y")
|
||||
return
|
||||
elif a.begins_with("--press="):
|
||||
var pp := a.substr(8).split(",")
|
||||
_press = Vector2(float(pp[0]), float(pp[1]))
|
||||
_press = _parse_pair(a.substr(8))
|
||||
if _press.x < 0.0:
|
||||
_fail("--press: нужен формат x,y")
|
||||
return
|
||||
elif a.begins_with("--drag="):
|
||||
var parts := a.substr(7).split(",", false)
|
||||
if parts.size() != 4:
|
||||
_fail("--drag: нужен формат x1,y1,x2,y2")
|
||||
return
|
||||
var p1 := _parse_pair("%s,%s" % [parts[0], parts[1]])
|
||||
var p2 := _parse_pair("%s,%s" % [parts[2], parts[3]])
|
||||
if p1.x < 0.0 or p2.x < 0.0:
|
||||
_fail("--drag: координаты должны быть числами x1,y1,x2,y2")
|
||||
return
|
||||
_drag = [p1, p2]
|
||||
elif a == "--hold":
|
||||
_hold = true
|
||||
elif a == "--raw-coords":
|
||||
_raw_coords = true
|
||||
elif a.begins_with("--sla="):
|
||||
_sla = float(a.substr(6))
|
||||
elif a.begins_with("--stress="):
|
||||
_stress = a.substr(9).strip_edges().to_lower()
|
||||
elif a == "--reduce":
|
||||
_reduce = true
|
||||
elif a == "--help" or a == "-h":
|
||||
_print_help()
|
||||
quit(0)
|
||||
return
|
||||
if not _minigame.is_empty() and not SEND_GAMES.has(_minigame) \
|
||||
and not ATTACK_GAMES.has(_minigame):
|
||||
_fail("--minigame: '%s' не поддерживается (см. --help)" % _minigame)
|
||||
return
|
||||
if _hold and _drag.is_empty():
|
||||
_fail("--hold имеет смысл только вместе с --drag")
|
||||
return
|
||||
if not _stress.is_empty() and not ["calm", "warm", "hot", "critical", "attack"].has(_stress):
|
||||
_fail("--stress: неизвестное состояние '%s'" % _stress)
|
||||
return
|
||||
if _probe:
|
||||
root.add_child(Probe.new())
|
||||
else:
|
||||
var ps: PackedScene = load(_scene_path)
|
||||
if ps == null:
|
||||
push_error("cannot load " + _scene_path)
|
||||
quit(1)
|
||||
return
|
||||
_main = ps.instantiate()
|
||||
root.add_child(_main)
|
||||
call_deferred("_after_ready")
|
||||
_setup_done = true
|
||||
return
|
||||
var ps: PackedScene = load(_scene_path)
|
||||
if ps == null:
|
||||
_fail("cannot load " + _scene_path)
|
||||
return
|
||||
_main = ps.instantiate()
|
||||
root.add_child(_main)
|
||||
call_deferred("_after_ready")
|
||||
|
||||
|
||||
func _after_ready() -> void:
|
||||
if _toast and _main != null and _main.has_method("_toast"):
|
||||
# Дадим Main один-два кадра на _ready/_process и ленивые модалки.
|
||||
await _frames(2)
|
||||
if _main == null:
|
||||
_fail("Main не загрузился")
|
||||
return
|
||||
|
||||
if _toast and _main.has_method("_toast"):
|
||||
_main.call("_toast", "СОЕДИНЕНИЕ УСТАНОВЛЕНО · договор_11.doc", "success")
|
||||
_main.call("_toast", "КАПАЛ 45% · БУФЕР 3/4", "warn")
|
||||
if not _folder.is_empty() and _main != null:
|
||||
if _main.has_method("_on_folder_selected"):
|
||||
_main.call("_on_folder_selected", _folder)
|
||||
if _hover.x >= 0.0:
|
||||
await create_timer(0.15).timeout
|
||||
Input.warp_mouse(_hover)
|
||||
if _press.x >= 0.0:
|
||||
await create_timer(0.15).timeout
|
||||
var ev := InputEventMouseButton.new()
|
||||
ev.button_index = MOUSE_BUTTON_LEFT
|
||||
ev.pressed = true
|
||||
ev.position = _press
|
||||
ev.global_position = _press
|
||||
Input.parse_input_event(ev)
|
||||
|
||||
# Игровые экраны невозможны без забега: --hour/--shop/--tutorial/--minigame
|
||||
# и --spawn сами включают его, чтобы команды из документации работали.
|
||||
var need_run: bool = _run or _hour or _shop or _tutorial \
|
||||
or not _minigame.is_empty() or not _spawn.is_empty()
|
||||
if need_run:
|
||||
if not _invoke_main("_start_new_run", [], "start_new_run"):
|
||||
return
|
||||
await _frames(4)
|
||||
|
||||
# --folder имеет приоритет; иначе после --run открываем первую папку,
|
||||
# чтобы в кадре была сетка файлов, а не пустая панель.
|
||||
if not _folder.is_empty():
|
||||
if not _invoke_main("_on_folder_selected", [_folder]):
|
||||
return
|
||||
await _frames(2)
|
||||
elif need_run:
|
||||
if not _select_first_folder():
|
||||
return
|
||||
await _frames(2)
|
||||
|
||||
# Dev-подготовка: 2-клеточный sound-файл рядом с bootstrap text-файлом,
|
||||
# чтобы проверять перенос/фантомы в сетке без запуска часа.
|
||||
if _seed_demo and not _hour:
|
||||
if not _seed_prep_demo():
|
||||
return
|
||||
await _frames(2)
|
||||
|
||||
if _hour or not _spawn.is_empty():
|
||||
# Новый забег начинается с пустого склада: без файлов HourDirector
|
||||
# не сможет создать запросы. Кладём демо-файлы в первую папку.
|
||||
if not _seed_demo_files():
|
||||
return
|
||||
if not _invoke_main("_on_start_hour"):
|
||||
return
|
||||
await _frames(3)
|
||||
if not _spawn.is_empty() and not _spawn_cards():
|
||||
return
|
||||
if _seconds > 0.0:
|
||||
# Настоящее время: HourDirector тикает из Main._process, пакеты и
|
||||
# запросы успевают появиться и отрисоваться.
|
||||
await create_timer(_seconds).timeout
|
||||
await _frames(3)
|
||||
|
||||
if _shop:
|
||||
if not _open_shop():
|
||||
return
|
||||
await _frames(3)
|
||||
|
||||
if _tutorial:
|
||||
if not _show_tutorial():
|
||||
return
|
||||
await _frames(2)
|
||||
|
||||
if not _minigame.is_empty():
|
||||
if not _show_minigame():
|
||||
return
|
||||
await _frames(6)
|
||||
|
||||
# «ЖАРА»: dev-флаги нагрева/SLA/reduce — последними, чтобы кадр был стабилен.
|
||||
if _sla >= 0.0 or _reduce or not _stress.is_empty():
|
||||
if not _apply_stress_flags():
|
||||
return
|
||||
# Даём термометру довести display-ease (tau 0.25) до заданного heat.
|
||||
await create_timer(1.5).timeout
|
||||
|
||||
# Прежние сценарии меню/тостов/файлового меню.
|
||||
if _filemenu and _main != null:
|
||||
await create_timer(0.1).timeout
|
||||
var menus: Dictionary = _main.get("_menus")
|
||||
if menus.has("ФАЙЛ"):
|
||||
var pm: PopupMenu = menus["ФАЙЛ"]
|
||||
pm.popup(Rect2i(Vector2i(6, 44), Vector2i.ZERO))
|
||||
|
||||
if _menu:
|
||||
await create_timer(0.1).timeout
|
||||
ContextMenu90.show_items(_main, [
|
||||
@@ -88,18 +292,424 @@ func _after_ready() -> void:
|
||||
{"label": "Уничтожить содержимое", "id": "destroy", "danger": true},
|
||||
], Vector2(120, 220), func(_id: String) -> void: pass)
|
||||
|
||||
if _hover.x >= 0.0:
|
||||
await create_timer(0.15).timeout
|
||||
Input.warp_mouse(_window_point(_hover))
|
||||
|
||||
if _press.x >= 0.0:
|
||||
await create_timer(0.15).timeout
|
||||
_send_button(_window_point(_press), true)
|
||||
|
||||
if not _drag.is_empty():
|
||||
await _do_drag()
|
||||
|
||||
_setup_done = true
|
||||
|
||||
|
||||
func _process(_delta: float) -> bool:
|
||||
_wait -= 1
|
||||
if _wait > 0:
|
||||
return false
|
||||
var img := root.get_texture().get_image()
|
||||
img.save_png(_out)
|
||||
print("saved ", _out, " ", img.get_size())
|
||||
quit()
|
||||
if not _setup_done:
|
||||
return false
|
||||
_wait -= 1
|
||||
if _wait > 0:
|
||||
return false
|
||||
if DisplayServer.get_name() == "headless":
|
||||
_fail("headless-режим: скриншот невозможен, нужен реальный дисплей")
|
||||
return true
|
||||
var tex := root.get_texture()
|
||||
if tex == null:
|
||||
_fail("нет текстуры окна (headless? нужен реальный дисплей)")
|
||||
return true
|
||||
var img := tex.get_image()
|
||||
if img == null:
|
||||
_fail("не удалось получить изображение кадра")
|
||||
return true
|
||||
var err := img.save_png(_out)
|
||||
if err != OK:
|
||||
_fail("не удалось сохранить %s (ошибка %d)" % [_out, err])
|
||||
return true
|
||||
print("saved ", _out, " ", img.get_size())
|
||||
quit()
|
||||
return true
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# Шаги сценария
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
## Вызвать метод Main; при отсутствии — внятная ошибка и код 1.
|
||||
func _invoke_main(method: String, args: Array = [], fallback: String = "") -> bool:
|
||||
if _main == null:
|
||||
_fail("Main не загружен")
|
||||
return false
|
||||
var target := method
|
||||
if not _main.has_method(target):
|
||||
if fallback.is_empty() or not _main.has_method(fallback):
|
||||
_fail("Main не имеет метода %s()" % method)
|
||||
return false
|
||||
target = fallback
|
||||
_main.callv(target, args)
|
||||
return true
|
||||
|
||||
|
||||
func _select_first_folder() -> bool:
|
||||
var folder_id := _first_folder_id()
|
||||
if folder_id.is_empty():
|
||||
_fail("в подготовке нет ни одной папки для --run")
|
||||
return false
|
||||
return _invoke_main("_on_folder_selected", [folder_id])
|
||||
|
||||
|
||||
func _first_folder_id() -> String:
|
||||
var ctrl = _main.get("controller")
|
||||
if ctrl == null or not ctrl.has_method("prep_data"):
|
||||
return ""
|
||||
var d: Dictionary = ctrl.call("prep_data")
|
||||
for sec in d.get("sections", []):
|
||||
var folders: Array = sec.get("folders", [])
|
||||
if folders.is_empty():
|
||||
continue
|
||||
var folder_id := str(folders[0].get("id", ""))
|
||||
if not folder_id.is_empty():
|
||||
return folder_id
|
||||
return ""
|
||||
|
||||
|
||||
func _folder_exists(folder_id: String) -> bool:
|
||||
var ctrl = _main.get("controller")
|
||||
if ctrl == null or not ctrl.has_method("prep_data"):
|
||||
return false
|
||||
var d: Dictionary = ctrl.call("prep_data")
|
||||
for sec in d.get("sections", []):
|
||||
for fol in sec.get("folders", []):
|
||||
if str(fol.get("id", "")) == folder_id:
|
||||
return true
|
||||
return false
|
||||
|
||||
|
||||
## Dev: sound-файл на 2 клетки в первую папку подготовки (без запуска часа).
|
||||
func _seed_prep_demo() -> bool:
|
||||
var game_node := root.get_node_or_null("Game")
|
||||
if game_node == null:
|
||||
_fail("--seed-demo: автолоад Game недоступен")
|
||||
return false
|
||||
var registry = game_node.get("registry")
|
||||
var folder_id := _first_folder_id()
|
||||
if registry == null or folder_id.is_empty():
|
||||
_fail("--seed-demo: нет реестра или папки")
|
||||
return false
|
||||
if registry.has_method("find_by_name") \
|
||||
and registry.call("find_by_name", "звонок_демо") != null:
|
||||
return true
|
||||
var file = registry.call("create_file", "звонок_демо", "sound", folder_id)
|
||||
if file == null:
|
||||
_fail("--seed-demo: не удалось создать sound-файл")
|
||||
return false
|
||||
# Детерминированно: 2 клетки горизонтально в (1,0).
|
||||
file.shape_id = "I2H"
|
||||
file.shape_cells = registry.call("shape_cells", "I2H", 0)
|
||||
if not bool(registry.call("move_file", str(file.id), folder_id,
|
||||
Vector2i(1, 0), 0)):
|
||||
_fail("--seed-demo: не удалось разместить sound-файл в (1,0)")
|
||||
return false
|
||||
if _main.has_method("_refresh_prep"):
|
||||
_main.call("_refresh_prep")
|
||||
return true
|
||||
|
||||
|
||||
## Демо-файлы для часа: без них запросы не спавнятся (реестр пуст).
|
||||
func _seed_demo_files() -> bool:
|
||||
var game_node := root.get_node_or_null("Game")
|
||||
if game_node == null:
|
||||
_fail("автолоад Game недоступен")
|
||||
return false
|
||||
var registry = game_node.get("registry")
|
||||
if registry == null or not registry.has_method("create_file"):
|
||||
_fail("Game.registry.create_file() недоступен")
|
||||
return false
|
||||
var folder_id := _folder
|
||||
if folder_id.is_empty() or not _folder_exists(folder_id):
|
||||
folder_id = _first_folder_id()
|
||||
if folder_id.is_empty():
|
||||
_fail("некуда положить демо-файлы: нет папок")
|
||||
return false
|
||||
var demo := [["договор_97", "text"], ["смета_демо", "photo"],
|
||||
["отчёт_демо", "video"]]
|
||||
for pair in demo:
|
||||
var file_name := str(pair[0])
|
||||
if registry.has_method("find_by_name") \
|
||||
and registry.call("find_by_name", file_name) != null:
|
||||
continue
|
||||
registry.call("create_file", file_name, str(pair[1]), folder_id)
|
||||
if _main.has_method("_refresh_prep"):
|
||||
_main.call("_refresh_prep")
|
||||
return true
|
||||
|
||||
|
||||
## «ЖАРА»: --sla=N, --stress=..., --reduce — только для dev-скриншотов.
|
||||
func _apply_stress_flags() -> bool:
|
||||
var game_node := root.get_node_or_null("Game")
|
||||
if _sla >= 0.0:
|
||||
if game_node == null:
|
||||
_fail("--sla: автолоад Game недоступен")
|
||||
return false
|
||||
var cap := 60.0
|
||||
if game_node.has_method("sla_cap"):
|
||||
cap = float(game_node.call("sla_cap"))
|
||||
game_node.set("sla", clampf(_sla, 0.0, maxf(1.0, cap)))
|
||||
if _main != null and _main.has_method("_refresh_hour"):
|
||||
_main.call("_refresh_hour")
|
||||
if _reduce:
|
||||
if _main == null or not _main.has_method("_apply_settings"):
|
||||
_fail("--reduce: Main._apply_settings() недоступен")
|
||||
return false
|
||||
_main.call("_apply_settings", {"reduce_motion": true}, false)
|
||||
if _stress.is_empty():
|
||||
return true
|
||||
var overlay = _main.get("stress_overlay") if _main != null else null
|
||||
if overlay == null or not overlay.has_method("force_heat"):
|
||||
_fail("Main.stress_overlay.force_heat() недоступен")
|
||||
return false
|
||||
var heat := 0.0
|
||||
match _stress:
|
||||
"calm":
|
||||
heat = 0.08
|
||||
"warm":
|
||||
heat = 0.46
|
||||
"hot":
|
||||
heat = 0.70
|
||||
"critical":
|
||||
heat = 0.92
|
||||
"attack":
|
||||
heat = 0.88
|
||||
_:
|
||||
_fail("--stress: неизвестное состояние '%s'" % _stress)
|
||||
return false
|
||||
overlay.call("force_heat", heat)
|
||||
if _stress == "attack" and overlay.has_method("notify"):
|
||||
overlay.call("notify", "attack_start")
|
||||
if _main != null and _main.has_method("_sync_stress_ui"):
|
||||
_main.call("_sync_stress_ui")
|
||||
return true
|
||||
|
||||
|
||||
func _spawn_cards() -> bool:
|
||||
var ctrl = _main.get("controller")
|
||||
if ctrl == null:
|
||||
_fail("Main.controller недоступен")
|
||||
return false
|
||||
var director = ctrl.get("hour_director")
|
||||
if director == null:
|
||||
_fail("HourDirector недоступен")
|
||||
return false
|
||||
for token in _spawn:
|
||||
if token == "packet":
|
||||
if not director.has_method("spawn_packet_now"):
|
||||
_fail("HourDirector.spawn_packet_now() отсутствует")
|
||||
return false
|
||||
var spawned := 0
|
||||
for i in 3:
|
||||
if not str(director.call("spawn_packet_now")).is_empty():
|
||||
spawned += 1
|
||||
if spawned == 0:
|
||||
_fail("HourDirector не смог создать ни одного пакета")
|
||||
return false
|
||||
elif token == "request":
|
||||
if not director.has_method("spawn_request_now"):
|
||||
_fail("HourDirector.spawn_request_now() отсутствует")
|
||||
return false
|
||||
var spawned_req := 0
|
||||
for i in 2:
|
||||
var req_id := str(director.call("spawn_request_now"))
|
||||
if req_id.is_empty():
|
||||
# Страховка: один файл на запрос, если обычный ролл не прошёл.
|
||||
req_id = str(director.call("spawn_request_now", 1))
|
||||
if not req_id.is_empty():
|
||||
spawned_req += 1
|
||||
if spawned_req == 0:
|
||||
_fail("HourDirector не смог создать ни одного запроса")
|
||||
return false
|
||||
if _main.has_method("_refresh_hour"):
|
||||
_main.call("_refresh_hour")
|
||||
return true
|
||||
|
||||
|
||||
func _open_shop() -> bool:
|
||||
var ctrl = _main.get("controller")
|
||||
if ctrl == null:
|
||||
_fail("Main.controller недоступен")
|
||||
return false
|
||||
var shop = ctrl.get("shop")
|
||||
if shop == null:
|
||||
_fail("GameController.shop недоступен")
|
||||
return false
|
||||
var modal = _main.get("shop_modal")
|
||||
if modal == null:
|
||||
_fail("Main.shop_modal недоступен")
|
||||
return false
|
||||
if not modal.has_method("open_prep"):
|
||||
_fail("ShopModal.open_prep() отсутствует")
|
||||
return false
|
||||
# Магазин в игре открывается в подготовке ПОСЛЕ часа 1. Main._on_shop()
|
||||
# на часе 1 отказывает, поэтому крутим офферы для часа 2 и открываем
|
||||
# модалку напрямую тем же API, что и игра.
|
||||
if shop.has_method("refresh"):
|
||||
shop.call("refresh", 2, _game_money())
|
||||
var offers: Array = []
|
||||
if shop.has_method("offers"):
|
||||
offers = shop.call("offers")
|
||||
if offers.is_empty():
|
||||
_fail("магазин не выдал офферы для часа 2")
|
||||
return false
|
||||
modal.call("open_prep", shop, _game_money(), 2)
|
||||
if not bool(modal.get("visible")):
|
||||
_fail("ShopModal не открылся")
|
||||
return false
|
||||
return true
|
||||
|
||||
|
||||
func _show_tutorial() -> bool:
|
||||
var mascot = _main.get("mascot")
|
||||
if mascot == null or not mascot.has_method("show_message"):
|
||||
_fail("Main.mascot/TutorialMascot.show_message() недоступен")
|
||||
return false
|
||||
var phase := ""
|
||||
var ctrl = _main.get("controller")
|
||||
if ctrl != null:
|
||||
phase = str(ctrl.get("phase"))
|
||||
if phase == "hour" and _main.has_method("_show_tutorial_step"):
|
||||
_main.call("_show_tutorial_step", 0)
|
||||
elif _main.has_method("_maybe_show_prep_tutorial"):
|
||||
_main.call("_maybe_show_prep_tutorial")
|
||||
if not bool(mascot.get("visible")):
|
||||
# В headless авто-туториал выключен; для инструмента показываем явно.
|
||||
mascot.call("show_message",
|
||||
"Привет, я БАЙТ. Час пик начинается с приёма пакетов.", -1.0)
|
||||
if not bool(mascot.get("visible")):
|
||||
_fail("маскот-туториал не стал видимым")
|
||||
return false
|
||||
return true
|
||||
|
||||
|
||||
func _show_minigame() -> bool:
|
||||
var game = null
|
||||
if SEND_GAMES.has(_minigame):
|
||||
game = MiniGameFactory.create_send(_minigame, _tier, _seed)
|
||||
elif ATTACK_GAMES.has(_minigame):
|
||||
game = MiniGameFactory.create_attack(_minigame, _tier, _seed)
|
||||
if game == null:
|
||||
_fail("MiniGameFactory не создал '%s' (tier %d)" % [_minigame, _tier])
|
||||
return false
|
||||
var host: Node = _main
|
||||
var mr = _main.get("modal_root")
|
||||
if mr != null:
|
||||
host = mr
|
||||
host.add_child(game)
|
||||
if game is Control:
|
||||
# См. Main._show_modal_game: фиксируем min-size, затем центрируем KEEP_SIZE.
|
||||
var ctl := game as Control
|
||||
ctl.size = ctl.get_combined_minimum_size()
|
||||
ctl.set_anchors_and_offsets_preset(Control.PRESET_CENTER, Control.PRESET_MODE_KEEP_SIZE)
|
||||
if game.has_method("start"):
|
||||
game.call("start", _minigame, _tier, _seed)
|
||||
if game is CanvasItem and not (game as CanvasItem).visible:
|
||||
_fail("мини-игра '%s' создана, но не видима" % _minigame)
|
||||
return false
|
||||
return true
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# Ввод: логические 1280×720 → пиксели окна
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
func _window_point(logical: Vector2) -> Vector2:
|
||||
if _raw_coords:
|
||||
return logical
|
||||
var win := Vector2(DisplayServer.window_get_size())
|
||||
if win.x <= 0.0 or win.y <= 0.0:
|
||||
return logical
|
||||
return Vector2(logical.x * win.x / LOGICAL_SIZE.x,
|
||||
logical.y * win.y / LOGICAL_SIZE.y)
|
||||
|
||||
|
||||
func _send_button(pos: Vector2, pressed: bool) -> void:
|
||||
var ev := InputEventMouseButton.new()
|
||||
ev.button_index = MOUSE_BUTTON_LEFT
|
||||
ev.pressed = pressed
|
||||
ev.position = pos
|
||||
ev.global_position = pos
|
||||
ev.button_mask = MOUSE_BUTTON_MASK_LEFT if pressed else 0
|
||||
Input.parse_input_event(ev)
|
||||
|
||||
|
||||
func _do_drag() -> void:
|
||||
var start := _window_point(_drag[0])
|
||||
var finish := _window_point(_drag[1])
|
||||
_send_button(start, true)
|
||||
await _frames(2)
|
||||
var steps := 8
|
||||
var prev := start
|
||||
for i in range(1, steps + 1):
|
||||
var p := start.lerp(finish, float(i) / float(steps))
|
||||
if DisplayServer.get_name() != "headless":
|
||||
# Держим и системный курсор рядом: hover-open папок смотрит на него.
|
||||
Input.warp_mouse(p)
|
||||
var motion := InputEventMouseMotion.new()
|
||||
motion.position = p
|
||||
motion.global_position = p
|
||||
motion.relative = p - prev
|
||||
motion.button_mask = MOUSE_BUTTON_MASK_LEFT
|
||||
Input.parse_input_event(motion)
|
||||
prev = p
|
||||
await process_frame
|
||||
if not _hold:
|
||||
# --hold оставляет кнопку зажатой: снимок ловит середину drag'а.
|
||||
_send_button(finish, false)
|
||||
await _frames(2)
|
||||
|
||||
|
||||
func _frames(count: int) -> void:
|
||||
for i in count:
|
||||
await process_frame
|
||||
|
||||
|
||||
func _parse_pair(text: String) -> Vector2:
|
||||
var parts := text.split(",", false)
|
||||
if parts.size() != 2:
|
||||
return Vector2(-1, -1)
|
||||
if not parts[0].strip_edges().is_valid_float() \
|
||||
or not parts[1].strip_edges().is_valid_float():
|
||||
return Vector2(-1, -1)
|
||||
return Vector2(float(parts[0]), float(parts[1]))
|
||||
|
||||
|
||||
func _game_money() -> int:
|
||||
var node := root.get_node_or_null("Game")
|
||||
if node == null:
|
||||
return 0
|
||||
return int(node.get("money"))
|
||||
|
||||
|
||||
func _fail(message: String) -> void:
|
||||
printerr("dev_capture: ERROR: ", message)
|
||||
push_error("dev_capture: " + message)
|
||||
quit(1)
|
||||
|
||||
|
||||
func _print_help() -> void:
|
||||
print("dev_capture.gd — снимок экрана PEAK HOUR 97")
|
||||
print(" --run --hour --seconds=N --spawn=packet,request")
|
||||
print(" --minigame=ID --tier=N --seed=N --shop --tutorial")
|
||||
print(" --folder=ID --hover=x,y --press=x,y --drag=x1,y1,x2,y2 --hold")
|
||||
print(" --sla=N --stress=calm|warm|hot|critical|attack --reduce")
|
||||
print(" --menu --filemenu --toast --probe --out=PATH --wait=N")
|
||||
print(" Координаты указателя — логические 1280x720; --raw-coords для пикселей окна.")
|
||||
print(" Мини-игры: ", ", ".join(SEND_GAMES + ATTACK_GAMES))
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# Font probe
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
class Probe:
|
||||
extends Control
|
||||
|
||||
@@ -161,4 +771,3 @@ class Probe:
|
||||
var i := 0 if eg == 1.0 else 1
|
||||
var j := [0, 2, 8].find(esh)
|
||||
return 360.0 + (i * 3 + j) * 26.0
|
||||
|
||||
@@ -0,0 +1,321 @@
|
||||
class_name DragController
|
||||
extends Node
|
||||
## Центральный контроллер перетаскивания: держит одно превью (FileShape) и
|
||||
## его режим (shape | square), двигает превью за мышью и отсчитывает 250 мс
|
||||
## наведения на папку БЕЗ Timer-узлов.
|
||||
##
|
||||
## Владелец — Main. Компоненты находят его через группу "drag_controller"
|
||||
## или через свойство drag_controller.
|
||||
##
|
||||
## ВАЖНО: при отсутствии превью контроллер не должен падать — все вызовы
|
||||
## проверяют is_instance_valid.
|
||||
|
||||
signal hover_open(folder_id: String)
|
||||
signal drag_ended
|
||||
## Пользователь повернул фигуру (R / ПКМ). Слушают все сетки, чтобы ghost
|
||||
## обновлялся даже у той, которая не получила событие ввода.
|
||||
signal rotation_changed(rotation: int)
|
||||
|
||||
const HOVER_DELAY := 0.25
|
||||
const PREVIEW_OFFSET := Vector2(9, 9)
|
||||
|
||||
## Сглаживание превью за мышью и «жизнь» drag (Lane B). Всё — только
|
||||
## transform превью: позиции источника/цели и drop-логику не трогаем.
|
||||
const SMOOTH_TAU := 0.035
|
||||
const TILT_TAU := 0.12
|
||||
## Скорость мыши (px/s), при которой наклон максимальный.
|
||||
const TILT_MAX_SPEED := 1400.0
|
||||
const POP_DUR := 0.10
|
||||
const POP_SCALE := 1.04
|
||||
const DRAG_ALPHA := 0.92
|
||||
const FLINCH_DUR := 0.16
|
||||
const FLINCH_PX := 3.0
|
||||
|
||||
var _preview: Control = null
|
||||
var _mode: String = "shape"
|
||||
var _rotation: int = 0
|
||||
var _rotate_frame: int = -1
|
||||
var _hover_id: String = ""
|
||||
var _hover_left: float = 0.0
|
||||
var _hover_fired: bool = false
|
||||
|
||||
var _pos := Vector2.ZERO
|
||||
var _mouse_prev := Vector2.ZERO
|
||||
var _tilt := 0.0
|
||||
var _pop_t := 1.0
|
||||
var _rot_pop_t := 1.0
|
||||
var _flinch_left := 0.0
|
||||
var _placing := false
|
||||
var _motion_on := true
|
||||
|
||||
|
||||
func _ready() -> void:
|
||||
if not is_in_group("drag_controller"):
|
||||
add_to_group("drag_controller")
|
||||
set_process(false)
|
||||
|
||||
|
||||
func begin(preview_control: Control, p_mode: String = "shape",
|
||||
p_initial_rotation: int = 0) -> void:
|
||||
end()
|
||||
_preview = preview_control
|
||||
_mode = _normalize(p_mode)
|
||||
# Пакет создаёт превью со своим поворотом, но зовёт begin() без третьего
|
||||
# аргумента. Берём поворот из превью, чтобы drop не сбрасывал его в 0.
|
||||
var seeded := posmod(int(p_initial_rotation), 4)
|
||||
if seeded == 0 and is_instance_valid(preview_control):
|
||||
var preview_rotation = preview_control.get("shape_rotation")
|
||||
if preview_rotation != null:
|
||||
seeded = posmod(int(preview_rotation), 4)
|
||||
_rotation = seeded
|
||||
_rotate_frame = -1
|
||||
_pos = Vector2.ZERO
|
||||
_tilt = 0.0
|
||||
_pop_t = 1.0
|
||||
_rot_pop_t = 1.0
|
||||
_flinch_left = 0.0
|
||||
if is_instance_valid(_preview):
|
||||
_preview.mouse_filter = Control.MOUSE_FILTER_IGNORE
|
||||
_preview.top_level = true
|
||||
_preview.z_index = 200
|
||||
if _preview.get_parent() == null:
|
||||
add_child(_preview)
|
||||
if _preview.has_method("set_mode"):
|
||||
_preview.set_mode(_mode)
|
||||
else:
|
||||
_preview.set("mode", _mode)
|
||||
_preview.visible = true
|
||||
_preview.modulate.a = DRAG_ALPHA
|
||||
_preview.scale = Vector2.ONE
|
||||
_preview.rotation = 0.0
|
||||
_placing = true
|
||||
_sync_preview_rotation()
|
||||
# Первый кадр — сразу под курсором, чтобы не было рывка из (0,0).
|
||||
_place()
|
||||
_pop_t = 0.0 if not _reduced() else 1.0
|
||||
_mouse_prev = _mouse_pos()
|
||||
_apply_transform()
|
||||
_preview.queue_redraw()
|
||||
set_process(true)
|
||||
|
||||
|
||||
func set_mode(p_mode: String) -> void:
|
||||
var next := _normalize(p_mode)
|
||||
_mode = next
|
||||
if is_instance_valid(_preview):
|
||||
if _preview.has_method("set_mode"):
|
||||
_preview.set_mode(next)
|
||||
else:
|
||||
_preview.set("mode", next)
|
||||
_preview.queue_redraw()
|
||||
|
||||
|
||||
func end(_absorb_to: Variant = null) -> void:
|
||||
var had := is_instance_valid(_preview) or not _hover_id.is_empty()
|
||||
if is_instance_valid(_preview):
|
||||
_preview.visible = false
|
||||
var parent := _preview.get_parent()
|
||||
if parent != null:
|
||||
parent.remove_child(_preview)
|
||||
_preview.queue_free()
|
||||
# preview() обязан вернуть null сразу после end() — без ожидания кадра.
|
||||
_preview = null
|
||||
_rotation = 0
|
||||
_rotate_frame = -1
|
||||
_pos = Vector2.ZERO
|
||||
_tilt = 0.0
|
||||
_pop_t = 1.0
|
||||
_rot_pop_t = 1.0
|
||||
_flinch_left = 0.0
|
||||
_placing = false
|
||||
reset_hover()
|
||||
if had:
|
||||
drag_ended.emit()
|
||||
set_process(false)
|
||||
|
||||
|
||||
func preview() -> Control:
|
||||
return _preview if is_instance_valid(_preview) else null
|
||||
|
||||
|
||||
func mode() -> String:
|
||||
return _mode
|
||||
|
||||
|
||||
# --- поворот ---------------------------------------------------------------
|
||||
|
||||
## Текущий поворот фигуры (0..3). Авторитетное состояние на время drag.
|
||||
func rotation() -> int:
|
||||
return _rotation
|
||||
|
||||
|
||||
func set_rotation(value: int) -> void:
|
||||
var next := posmod(int(value), 4)
|
||||
if next == _rotation:
|
||||
return
|
||||
_rotation = next
|
||||
_sync_preview_rotation()
|
||||
_rot_pop_t = 0.0 if not _reduced() else 1.0
|
||||
_apply_transform()
|
||||
rotation_changed.emit(_rotation)
|
||||
|
||||
|
||||
## +90° по часовой. Повторный вызов в том же кадре игнорируется: R/ПКМ
|
||||
## приходят и источнику, и цели drop, а повернуть нужно один раз.
|
||||
func rotate_cw() -> int:
|
||||
var frame := Engine.get_process_frames()
|
||||
if _rotate_frame == frame:
|
||||
return _rotation
|
||||
_rotate_frame = frame
|
||||
set_rotation(_rotation + 1)
|
||||
return _rotation
|
||||
|
||||
|
||||
## Есть ли активное превью — то есть drag действительно идёт.
|
||||
func is_active() -> bool:
|
||||
return is_instance_valid(_preview)
|
||||
|
||||
|
||||
func _sync_preview_rotation() -> void:
|
||||
if not is_instance_valid(_preview):
|
||||
return
|
||||
if _preview.has_method("setup"):
|
||||
_preview.setup(str(_preview.get("file_type")), str(_preview.get("shape_id")),
|
||||
_rotation, str(_preview.get("file_name")), str(_preview.get("ext")),
|
||||
float(_preview.get("cell_size")))
|
||||
if _preview.has_method("set_mode"):
|
||||
_preview.set_mode(_mode)
|
||||
else:
|
||||
_preview.set("shape_rotation", _rotation)
|
||||
_preview.queue_redraw()
|
||||
# setup() меняет custom_minimum_size — превью должно остаться под курсором.
|
||||
_place()
|
||||
|
||||
|
||||
func hover_folder(folder_id: String) -> void:
|
||||
if folder_id.is_empty():
|
||||
reset_hover()
|
||||
return
|
||||
if folder_id == _hover_id:
|
||||
# повторный вход в ту же папку не сбрасывает и не дублирует таймер
|
||||
return
|
||||
_hover_id = folder_id
|
||||
_hover_left = HOVER_DELAY
|
||||
_hover_fired = false
|
||||
set_process(true)
|
||||
|
||||
|
||||
func reset_hover() -> void:
|
||||
_hover_id = ""
|
||||
_hover_left = 0.0
|
||||
_hover_fired = false
|
||||
if not is_instance_valid(_preview):
|
||||
set_process(false)
|
||||
|
||||
|
||||
func hover_id() -> String:
|
||||
return _hover_id
|
||||
|
||||
|
||||
func _process(delta: float) -> void:
|
||||
if not _hover_id.is_empty() and not _hover_fired:
|
||||
_hover_left -= delta
|
||||
if _hover_left <= 0.0:
|
||||
_hover_fired = true
|
||||
hover_open.emit(_hover_id)
|
||||
if is_instance_valid(_preview) and _preview.visible:
|
||||
_animate(delta)
|
||||
if not is_instance_valid(_preview) and (_hover_id.is_empty() or _hover_fired):
|
||||
set_process(false)
|
||||
|
||||
|
||||
## Локальный выключатель движения (Lane A зовёт через has_method).
|
||||
func set_motion_enabled(on: bool) -> void:
|
||||
_motion_on = on
|
||||
if _reduced():
|
||||
_tilt = 0.0
|
||||
_flinch_left = 0.0
|
||||
_pop_t = 1.0
|
||||
_rot_pop_t = 1.0
|
||||
if is_instance_valid(_preview):
|
||||
_pos = _target_pos()
|
||||
_apply_transform()
|
||||
|
||||
|
||||
## Короткий отказ: превью дёргается по X, не меняя реальную позицию drag.
|
||||
func flinch() -> void:
|
||||
if _reduced():
|
||||
return
|
||||
_flinch_left = FLINCH_DUR
|
||||
if is_instance_valid(_preview):
|
||||
set_process(true)
|
||||
|
||||
|
||||
func _reduced() -> bool:
|
||||
return not _motion_on or UiMotion.reduced
|
||||
|
||||
|
||||
func _mouse_pos() -> Vector2:
|
||||
var vp := get_viewport()
|
||||
return vp.get_mouse_position() if vp != null else Vector2.ZERO
|
||||
|
||||
|
||||
## Куда превью стремится: под курсором, с прежним смещением.
|
||||
func _target_pos() -> Vector2:
|
||||
if not is_instance_valid(_preview):
|
||||
return _pos
|
||||
return _mouse_pos() - _preview.custom_minimum_size * 0.5 + PREVIEW_OFFSET
|
||||
|
||||
|
||||
func _animate(delta: float) -> void:
|
||||
if not is_instance_valid(_preview):
|
||||
return
|
||||
var target := _target_pos()
|
||||
if _reduced():
|
||||
_pos = target
|
||||
_tilt = 0.0
|
||||
_pop_t = 1.0
|
||||
_rot_pop_t = 1.0
|
||||
_flinch_left = 0.0
|
||||
else:
|
||||
_pos = _pos.lerp(target, UiMotion.k(delta, SMOOTH_TAU))
|
||||
var mouse := _mouse_pos()
|
||||
if delta > 0.0001:
|
||||
var vel := (mouse - _mouse_prev) / delta
|
||||
var want := clampf(vel.x / TILT_MAX_SPEED, -1.0, 1.0) * UiMotion.DRAG_TILT
|
||||
_tilt = lerpf(_tilt, want, UiMotion.k(delta, TILT_TAU))
|
||||
_mouse_prev = mouse
|
||||
_pop_t = minf(1.0, _pop_t + delta / POP_DUR)
|
||||
_rot_pop_t = minf(1.0, _rot_pop_t + delta / POP_DUR)
|
||||
_flinch_left = maxf(0.0, _flinch_left - delta)
|
||||
_apply_transform()
|
||||
|
||||
|
||||
func _apply_transform() -> void:
|
||||
if not is_instance_valid(_preview):
|
||||
return
|
||||
_preview.pivot_offset = _preview.custom_minimum_size * 0.5
|
||||
# Лёгкая «пружина»: основная волна + малый обратный горб, мягко гаснет.
|
||||
var pop := sin(PI * _pop_t) + 0.22 * sin(TAU * _pop_t)
|
||||
var rpop := sin(PI * _rot_pop_t) + 0.35 * sin(TAU * _rot_pop_t)
|
||||
var s := 1.0 + (POP_SCALE - 1.0) * pop + (POP_SCALE - 1.0) * rpop
|
||||
_preview.scale = Vector2(s, s)
|
||||
_preview.rotation = _tilt if not _reduced() else 0.0
|
||||
var off := Vector2.ZERO
|
||||
if _flinch_left > 0.0 and not _reduced():
|
||||
var p := _flinch_left / FLINCH_DUR
|
||||
off.x = sin(p * PI * 3.0) * FLINCH_PX * p
|
||||
_preview.position = _pos + off
|
||||
|
||||
|
||||
func _place() -> void:
|
||||
if not is_instance_valid(_preview):
|
||||
return
|
||||
if _placing:
|
||||
_pos = _target_pos()
|
||||
_placing = false
|
||||
_apply_transform()
|
||||
|
||||
|
||||
func _normalize(value: String) -> String:
|
||||
return "square" if value == "square" else "shape"
|
||||
@@ -0,0 +1 @@
|
||||
uid://cybnog1pa4ni4
|
||||
@@ -12,6 +12,17 @@ signal drag_started(id: String)
|
||||
|
||||
const CARD := Vector2(132, 84)
|
||||
|
||||
# --- Motion (Lane B) -------------------------------------------------------
|
||||
const ENTER_DUR := 0.14
|
||||
## Draw-only сдвиг содержимого при входе, px.
|
||||
const ENTER_OFFSET := 2.0
|
||||
const HOVER_DUR := 0.10
|
||||
const SELECT_DUR := 0.12
|
||||
const BADGE_POP_DUR := 0.14
|
||||
## Hover-подъём и отскок выделения — только отрисовка.
|
||||
const HOVER_LIFT := 1.0
|
||||
const SEL_BOUNCE_DUR := 0.18
|
||||
|
||||
var id := ""
|
||||
var file_name := ""
|
||||
var ext := ""
|
||||
@@ -20,16 +31,27 @@ var type := "text"
|
||||
var state := "stored"
|
||||
var selected := false:
|
||||
set(v):
|
||||
if v and not selected:
|
||||
_sel_clock = 0.0
|
||||
selected = v
|
||||
set_process(true)
|
||||
queue_redraw()
|
||||
var hovered := false:
|
||||
set(v):
|
||||
hovered = v
|
||||
set_process(true)
|
||||
queue_redraw()
|
||||
|
||||
var _press_pos := Vector2.ZERO
|
||||
var _drag_ready := false
|
||||
|
||||
var _hover_amt := 0.0
|
||||
var _sel_amt := 0.0
|
||||
var _enter_elapsed := ENTER_DUR
|
||||
var _badge_clock := -1.0
|
||||
var _sel_clock := -1.0
|
||||
var _motion_on := true
|
||||
|
||||
|
||||
func _ready() -> void:
|
||||
mouse_filter = Control.MOUSE_FILTER_STOP
|
||||
@@ -40,6 +62,108 @@ func _ready() -> void:
|
||||
mouse_entered.connect(func() -> void: hovered = true)
|
||||
mouse_exited.connect(func() -> void: hovered = false)
|
||||
_update_tooltip()
|
||||
set_process(false)
|
||||
|
||||
|
||||
# --- motion (Lane B) -------------------------------------------------------
|
||||
|
||||
## Появление: alpha 0.14 c + draw-only подъём на 2 px.
|
||||
func enter(_index: int = 0) -> void:
|
||||
if _reduced():
|
||||
_enter_elapsed = ENTER_DUR
|
||||
modulate.a = 1.0
|
||||
return
|
||||
_enter_elapsed = 0.0
|
||||
modulate.a = 0.0
|
||||
set_process(true)
|
||||
|
||||
|
||||
## Внешний переключатель движения (Lane A / настройки).
|
||||
func set_motion_enabled(on: bool) -> void:
|
||||
_motion_on = on
|
||||
if _reduced():
|
||||
_hover_amt = 1.0 if hovered else 0.0
|
||||
_sel_amt = 1.0 if selected else 0.0
|
||||
_enter_elapsed = ENTER_DUR
|
||||
modulate.a = 1.0
|
||||
_badge_clock = -1.0
|
||||
queue_redraw()
|
||||
|
||||
|
||||
func _reduced() -> bool:
|
||||
return not _motion_on or UiMotion.reduced
|
||||
|
||||
|
||||
func _process(delta: float) -> void:
|
||||
var hov := 1.0 if hovered else 0.0
|
||||
var sel := 1.0 if selected else 0.0
|
||||
if _reduced():
|
||||
_hover_amt = hov
|
||||
_sel_amt = sel
|
||||
elif not is_equal_approx(_hover_amt, hov):
|
||||
_hover_amt = move_toward(_hover_amt, hov, delta / HOVER_DUR)
|
||||
if not is_equal_approx(_sel_amt, sel):
|
||||
if _reduced():
|
||||
_sel_amt = sel
|
||||
else:
|
||||
_sel_amt = move_toward(_sel_amt, sel, delta / SELECT_DUR)
|
||||
if _enter_elapsed < ENTER_DUR:
|
||||
_enter_elapsed = minf(ENTER_DUR, _enter_elapsed + delta)
|
||||
modulate.a = clampf(_enter_elapsed / ENTER_DUR, 0.0, 1.0)
|
||||
if _badge_clock >= 0.0:
|
||||
_badge_clock += delta
|
||||
if _badge_clock >= BADGE_POP_DUR:
|
||||
_badge_clock = -1.0
|
||||
if _sel_clock >= 0.0:
|
||||
_sel_clock += delta
|
||||
if _sel_clock >= SEL_BOUNCE_DUR:
|
||||
_sel_clock = -1.0
|
||||
queue_redraw()
|
||||
if _enter_elapsed >= ENTER_DUR and _badge_clock < 0.0 and _sel_clock < 0.0 \
|
||||
and is_equal_approx(_hover_amt, hov) and is_equal_approx(_sel_amt, sel):
|
||||
set_process(false)
|
||||
|
||||
|
||||
## 0..1 → 0 подскок бейджа attached.
|
||||
func _badge_pop() -> float:
|
||||
if _badge_clock < 0.0:
|
||||
return 0.0
|
||||
return sin(PI * clampf(_badge_clock / BADGE_POP_DUR, 0.0, 1.0))
|
||||
|
||||
|
||||
## 0..1 → 0 отскок выделения (полоска слева и подъём).
|
||||
func _sel_bounce() -> float:
|
||||
if _sel_clock < 0.0:
|
||||
return 0.0
|
||||
return sin(PI * clampf(_sel_clock / SEL_BOUNCE_DUR, 0.0, 1.0))
|
||||
|
||||
|
||||
## Draw-only подъём: hover 1 px, выделение — короткий отскок.
|
||||
func _lift_offset() -> float:
|
||||
if _reduced():
|
||||
return 0.0
|
||||
var off := HOVER_LIFT * _draw_hover()
|
||||
off += 1.5 * _sel_bounce()
|
||||
return off
|
||||
|
||||
|
||||
func _draw_hover() -> float:
|
||||
if _reduced():
|
||||
return 1.0 if hovered else 0.0
|
||||
return _hover_amt
|
||||
|
||||
|
||||
func _draw_sel() -> float:
|
||||
if _reduced():
|
||||
return 1.0 if selected else 0.0
|
||||
return _sel_amt
|
||||
|
||||
|
||||
## Draw-only подъём при входе: position карточки не меняется.
|
||||
func _enter_offset() -> float:
|
||||
if _enter_elapsed >= ENTER_DUR:
|
||||
return 0.0
|
||||
return ENTER_OFFSET * (1.0 - clampf(_enter_elapsed / ENTER_DUR, 0.0, 1.0))
|
||||
|
||||
|
||||
func setup(p_id: String, p_name: String, p_ext: String, p_size: int, p_type: String,
|
||||
@@ -49,8 +173,12 @@ func setup(p_id: String, p_name: String, p_ext: String, p_size: int, p_type: Str
|
||||
ext = p_ext
|
||||
size_kb = p_size
|
||||
type = p_type
|
||||
var was := state
|
||||
state = p_state
|
||||
if state == "attached" and was != "attached":
|
||||
_badge_clock = 0.0
|
||||
_update_tooltip()
|
||||
set_process(true)
|
||||
queue_redraw()
|
||||
|
||||
|
||||
@@ -109,23 +237,33 @@ func _draw() -> void:
|
||||
var f := Theme90.ui()
|
||||
var tc := icon_color(type, ext)
|
||||
var locked := state == "locked"
|
||||
var bad := state == "malicious" or state == "failed"
|
||||
var hov := _draw_hover()
|
||||
var sel := _draw_sel()
|
||||
|
||||
var bg := Theme90.PAPER_HI
|
||||
var border := Theme90.BORDER
|
||||
var shadow := Theme90.SHADOW
|
||||
if selected:
|
||||
bg = Theme90.ACCENT_PALE
|
||||
border = Color(Theme90.ACCENT.r, Theme90.ACCENT.g, Theme90.ACCENT.b, 0.8)
|
||||
shadow = Color(0, 0, 0, 0.12)
|
||||
elif hovered:
|
||||
bg = Theme90.WHITE
|
||||
border = Theme90.ACCENT_LIGHT
|
||||
shadow = Color(0, 0, 0, 0.11)
|
||||
var bg := Theme90.PAPER_HI.lerp(Theme90.WHITE, hov)
|
||||
var border := Theme90.BORDER.lerp(Theme90.ACCENT_LIGHT, hov)
|
||||
var shadow := Theme90.SHADOW.lerp(Color(0, 0, 0, 0.11), hov)
|
||||
if sel > 0.001:
|
||||
bg = bg.lerp(Theme90.ACCENT_PALE, sel)
|
||||
border = border.lerp(
|
||||
Color(Theme90.ACCENT.r, Theme90.ACCENT.g, Theme90.ACCENT.b, 0.8), sel)
|
||||
shadow = shadow.lerp(Color(0, 0, 0, 0.12), sel)
|
||||
|
||||
var off := _enter_offset() - _lift_offset()
|
||||
if off != 0.0:
|
||||
draw_set_transform(Vector2(0, off), 0.0, Vector2.ONE)
|
||||
|
||||
draw_style_box(Theme90.box(bg, border, 4, 1, shadow), r)
|
||||
if selected:
|
||||
draw_rect(Rect2(1.5, 12, 2.5, size.y - 24), Theme90.ACCENT)
|
||||
# Мягкое свечение внутри рамки при наведении/выделении (draw-only).
|
||||
var glow := maxf(hov, sel)
|
||||
if glow > 0.02:
|
||||
var gc := Theme90.ACCENT_LIGHT if sel < 0.5 else Theme90.ACCENT
|
||||
draw_style_box(Theme90.box(Color(gc.r, gc.g, gc.b, 0.10 * glow),
|
||||
Color(0, 0, 0, 0), 4, 0), r.grow(-2.0))
|
||||
if sel > 0.01:
|
||||
var bar_h := size.y - 24.0 + 4.0 * _sel_bounce()
|
||||
draw_rect(Rect2(1.5, (size.y - bar_h) * 0.5, 2.5, bar_h),
|
||||
Color(Theme90.ACCENT.r, Theme90.ACCENT.g, Theme90.ACCENT.b, sel))
|
||||
|
||||
if locked:
|
||||
tc = Theme90.SHADE
|
||||
@@ -133,7 +271,8 @@ func _draw() -> void:
|
||||
|
||||
# состояние — только маленькими значками, без подписей
|
||||
if state == "attached":
|
||||
Theme90.draw_check_badge(self, Vector2(size.x - 14, 14), 7.0)
|
||||
Theme90.draw_check_badge(self, Vector2(size.x - 14, 14),
|
||||
7.0 + 1.2 * _badge_pop())
|
||||
elif locked:
|
||||
draw_circle(Vector2(size.x - 14, 14), 7.0, Color(Theme90.SHADE.r, Theme90.SHADE.g,
|
||||
Theme90.SHADE.b, 0.16))
|
||||
@@ -165,8 +304,12 @@ func _draw() -> void:
|
||||
Theme90.text(self, f, Vector2(10 + ew + 5, 68), _spaced(size_kb) + " КБ", 10,
|
||||
meta_col)
|
||||
|
||||
if hovered and not selected:
|
||||
draw_rect(Rect2(10, 34, size.x - 20, 1), Theme90.HAIRLINE)
|
||||
if hov > 0.02 and sel < 0.5:
|
||||
draw_rect(Rect2(10, 34, size.x - 20, 1),
|
||||
Color(Theme90.HAIRLINE.r, Theme90.HAIRLINE.g, Theme90.HAIRLINE.b,
|
||||
Theme90.HAIRLINE.a * hov))
|
||||
if off != 0.0:
|
||||
draw_set_transform(Vector2.ZERO, 0.0, Vector2.ONE)
|
||||
|
||||
|
||||
static func _spaced(v: int) -> String:
|
||||
|
||||
@@ -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)
|
||||
@@ -0,0 +1 @@
|
||||
uid://cywkgtu02b8pk
|
||||
@@ -7,38 +7,73 @@ extends Control
|
||||
signal folder_selected(folder_id: String)
|
||||
signal expansion_toggled(folder_id: String)
|
||||
signal file_dropped(folder_id: String, payload: Dictionary)
|
||||
signal packet_dropped(folder_id: String, payload: Dictionary)
|
||||
signal context_menu_requested(folder_id: String, global_pos: Vector2)
|
||||
## Папка раскрыта/открыта наведением во время drag (250 мс).
|
||||
signal hover_open_requested(folder_id: String)
|
||||
|
||||
const ROW_H := 24.0
|
||||
const SECTION_H := 22.0
|
||||
|
||||
# --- Motion (Lane B) -------------------------------------------------------
|
||||
const HOVER_DUR := 0.10
|
||||
const SELECT_DUR := 0.12
|
||||
const WAVE_DUR := 0.8
|
||||
const ACCEPT_DUR := 0.20
|
||||
## Отскок полоски выделения.
|
||||
const SEL_BOUNCE_DUR := 0.18
|
||||
|
||||
var folder_id := ""
|
||||
var folder_name := ""
|
||||
## v2: count — занятые КЛЕТКИ, capacity — всего клеток.
|
||||
var count := 0
|
||||
var capacity := 16
|
||||
## Число файлов (для тултипа «N файлов · X из Y клеток»).
|
||||
var files_count := 0
|
||||
var free_text := ""
|
||||
var depth := 0
|
||||
var is_section := false
|
||||
var expandable := true
|
||||
var hover_open := false:
|
||||
set(v):
|
||||
if hover_open == v:
|
||||
return
|
||||
hover_open = v
|
||||
queue_redraw()
|
||||
if v and not is_section:
|
||||
hover_open_requested.emit(folder_id)
|
||||
var expanded := true:
|
||||
set(v):
|
||||
expanded = v
|
||||
queue_redraw()
|
||||
var selected := false:
|
||||
set(v):
|
||||
if v and not selected:
|
||||
_sel_clock = 0.0
|
||||
selected = v
|
||||
set_process(true)
|
||||
queue_redraw()
|
||||
var hovered := false:
|
||||
set(v):
|
||||
hovered = v
|
||||
set_process(true)
|
||||
queue_redraw()
|
||||
var drop_active := false:
|
||||
set(v):
|
||||
if drop_active != v:
|
||||
drop_active = v
|
||||
_wave = 0.0
|
||||
set_process(true)
|
||||
queue_redraw()
|
||||
var locked := false
|
||||
|
||||
var _hover_amt := 0.0
|
||||
var _sel_amt := 0.0
|
||||
var _wave := 0.0
|
||||
var _accept_clock := -1.0
|
||||
var _sel_clock := -1.0
|
||||
var _motion_on := true
|
||||
|
||||
|
||||
func _ready() -> void:
|
||||
mouse_filter = Control.MOUSE_FILTER_PASS
|
||||
@@ -51,6 +86,91 @@ func _ready() -> void:
|
||||
set_process(false)
|
||||
|
||||
|
||||
# --- motion (Lane B) -------------------------------------------------------
|
||||
|
||||
## Внешний переключатель движения (Lane A / настройки).
|
||||
func set_motion_enabled(on: bool) -> void:
|
||||
_motion_on = on
|
||||
if _reduced():
|
||||
_hover_amt = 1.0 if hovered else 0.0
|
||||
_sel_amt = 1.0 if selected else 0.0
|
||||
_accept_clock = -1.0
|
||||
queue_redraw()
|
||||
|
||||
|
||||
func _reduced() -> bool:
|
||||
return not _motion_on or UiMotion.reduced
|
||||
|
||||
|
||||
func _process(delta: float) -> void:
|
||||
var hov := 1.0 if hovered else 0.0
|
||||
var sel := 1.0 if selected else 0.0
|
||||
if _reduced():
|
||||
_hover_amt = hov
|
||||
_sel_amt = sel
|
||||
_wave = 0.0
|
||||
else:
|
||||
if not is_equal_approx(_hover_amt, hov):
|
||||
_hover_amt = move_toward(_hover_amt, hov, delta / HOVER_DUR)
|
||||
if not is_equal_approx(_sel_amt, sel):
|
||||
_sel_amt = move_toward(_sel_amt, sel, delta / SELECT_DUR)
|
||||
if drop_active:
|
||||
_wave = fmod(_wave + delta, WAVE_DUR)
|
||||
if _accept_clock >= 0.0:
|
||||
_accept_clock += delta
|
||||
if _accept_clock >= ACCEPT_DUR:
|
||||
_accept_clock = -1.0
|
||||
if _sel_clock >= 0.0:
|
||||
_sel_clock += delta
|
||||
if _sel_clock >= SEL_BOUNCE_DUR:
|
||||
_sel_clock = -1.0
|
||||
queue_redraw()
|
||||
if not drop_active and _accept_clock < 0.0 and _sel_clock < 0.0 \
|
||||
and is_equal_approx(_hover_amt, hov) and is_equal_approx(_sel_amt, sel):
|
||||
set_process(false)
|
||||
|
||||
|
||||
## 0..1 → 0 отскок полоски выделения.
|
||||
func _sel_bounce() -> float:
|
||||
if _sel_clock < 0.0:
|
||||
return 0.0
|
||||
return sin(PI * clampf(_sel_clock / SEL_BOUNCE_DUR, 0.0, 1.0))
|
||||
|
||||
|
||||
func _draw_hover() -> float:
|
||||
if _reduced():
|
||||
return 1.0 if hovered else 0.0
|
||||
return _hover_amt
|
||||
|
||||
|
||||
func _draw_sel() -> float:
|
||||
if _reduced():
|
||||
return 1.0 if selected else 0.0
|
||||
return _sel_amt
|
||||
|
||||
|
||||
## Флэш «файл принят» после drop — 0.20 c.
|
||||
func _start_accept() -> void:
|
||||
_accept_clock = 0.0
|
||||
set_process(true)
|
||||
|
||||
|
||||
## Волна drop_active и вспышка приёма — только отрисовка, layout не меняется.
|
||||
func _draw_effects(r: Rect2) -> void:
|
||||
if drop_active and not _reduced() and not is_section:
|
||||
var p := clampf(_wave / WAVE_DUR, 0.0, 1.0)
|
||||
var w := 22.0
|
||||
var x := lerpf(-w, size.x, p)
|
||||
var a := 0.06 + 0.20 * (1.0 - p)
|
||||
draw_rect(Rect2(x, 2, w, maxf(0.0, size.y - 4)),
|
||||
Color(Theme90.ACCENT.r, Theme90.ACCENT.g, Theme90.ACCENT.b, a))
|
||||
if _accept_clock >= 0.0:
|
||||
var ae := clampf(1.0 - _accept_clock / ACCEPT_DUR, 0.0, 1.0)
|
||||
draw_style_box(Theme90.box(Color(0, 0, 0, 0),
|
||||
Color(Theme90.ACCENT.r, Theme90.ACCENT.g, Theme90.ACCENT.b, 0.9 * ae), 3, 1),
|
||||
Rect2(2, 1, maxf(0.0, size.x - 4), maxf(0.0, size.y - 2)))
|
||||
|
||||
|
||||
func setup(p_id: String, p_name: String, p_depth: int, p_section: bool = false) -> void:
|
||||
folder_id = p_id
|
||||
folder_name = p_name
|
||||
@@ -65,22 +185,37 @@ func setup(p_id: String, p_name: String, p_depth: int, p_section: bool = false)
|
||||
func _draw() -> void:
|
||||
var r := Rect2(Vector2.ZERO, size)
|
||||
var f := Theme90.ui()
|
||||
var hov := _draw_hover()
|
||||
if is_section:
|
||||
_draw_section(r, f)
|
||||
_draw_section(r, f, hov)
|
||||
_draw_effects(r)
|
||||
return
|
||||
|
||||
if selected:
|
||||
draw_style_box(Theme90.box(Theme90.ACCENT_PALE, Color(0, 0, 0, 0), 3, 0),
|
||||
Rect2(2, 1, size.x - 4, size.y - 2))
|
||||
draw_rect(Rect2(2, 5, 2, size.y - 10), Theme90.ACCENT)
|
||||
elif drop_active:
|
||||
var sel := _draw_sel()
|
||||
if sel > 0.001:
|
||||
draw_style_box(Theme90.box(
|
||||
Color(Theme90.ACCENT_PALE.r, Theme90.ACCENT_PALE.g, Theme90.ACCENT_PALE.b, sel),
|
||||
Color(0, 0, 0, 0), 3, 0), Rect2(2, 1, size.x - 4, size.y - 2))
|
||||
var bar_h := size.y - 10.0 + 4.0 * _sel_bounce()
|
||||
draw_rect(Rect2(2, (size.y - bar_h) * 0.5, 2, bar_h),
|
||||
Color(Theme90.ACCENT.r, Theme90.ACCENT.g, Theme90.ACCENT.b, sel))
|
||||
if hov > 0.02:
|
||||
# Мягкое свечение строки при наведении (draw-only).
|
||||
draw_rect(Rect2(2, 1, size.x - 4, size.y - 2),
|
||||
Color(Theme90.ACCENT_LIGHT.r, Theme90.ACCENT_LIGHT.g,
|
||||
Theme90.ACCENT_LIGHT.b, 0.16 * hov))
|
||||
if drop_active:
|
||||
draw_style_box(Theme90.box(Theme90.ACCENT_PALE, Theme90.ACCENT, 3, 1),
|
||||
Rect2(2, 1, size.x - 4, size.y - 2))
|
||||
elif hovered:
|
||||
draw_rect(Rect2(2, 1, size.x - 4, size.y - 2), Color(1, 1, 1, 0.45))
|
||||
elif hover_open:
|
||||
draw_style_box(Theme90.box(Theme90.ACCENT_PALE, Theme90.ACCENT_LIGHT, 3, 1),
|
||||
Rect2(2, 1, size.x - 4, size.y - 2))
|
||||
draw_rect(Rect2(2, 5, 2, size.y - 10), Theme90.ACCENT_LIGHT)
|
||||
elif hov > 0.02 and sel < 0.5:
|
||||
draw_rect(Rect2(2, 1, size.x - 4, size.y - 2), Color(1, 1, 1, 0.30 * hov))
|
||||
|
||||
var fg := Theme90.NAVY if selected else Theme90.INK
|
||||
var sub := Theme90.ACCENT if selected else Theme90.SHADE
|
||||
var fg := Theme90.INK.lerp(Theme90.NAVY, sel)
|
||||
var sub := Theme90.SHADE.lerp(Theme90.ACCENT, sel)
|
||||
var sx := 5.0 + depth * 12.0
|
||||
|
||||
# тонкий шеврон вместо объёмного квадрата
|
||||
@@ -110,20 +245,21 @@ func _draw() -> void:
|
||||
|
||||
if locked:
|
||||
Theme90.draw_lock_icon(self, Vector2(size.x - 58, 8), Theme90.SHADE)
|
||||
_draw_effects(r)
|
||||
|
||||
|
||||
func _draw_section(r: Rect2, f: Font) -> void:
|
||||
if hovered:
|
||||
draw_rect(r, Color(1, 1, 1, 0.35))
|
||||
func _draw_section(r: Rect2, f: Font, hov: float) -> void:
|
||||
if hov > 0.02:
|
||||
draw_rect(r, Color(1, 1, 1, 0.35 * hov))
|
||||
draw_rect(Rect2(2, size.y - 1, maxf(0.0, size.x - 4), 1), Theme90.HAIRLINE)
|
||||
var sx := 4.0
|
||||
if expandable:
|
||||
Theme90.draw_chevron(self, Vector2(sx + 6, size.y * 0.5), 1 if expanded else 2,
|
||||
Theme90.NAVY, 3.0, 1.3)
|
||||
Theme90.draw_drive_icon(self, Vector2(sx + 16, 6), Theme90.NAVY)
|
||||
var fg := Theme90.NAVY if selected else Theme90.INK
|
||||
var fg := Theme90.INK.lerp(Theme90.NAVY, _draw_sel())
|
||||
Theme90.text_vcenter(self, f, Rect2(sx + 32, 0, size.x - 80, size.y),
|
||||
Theme90.fit(f, folder_name, 11, size.x - 110), 11, fg)
|
||||
Theme90.fit(f, folder_name, 12, size.x - 110), 12, fg)
|
||||
if not free_text.is_empty():
|
||||
var tw := f.get_string_size(free_text, HORIZONTAL_ALIGNMENT_LEFT, -1, 10).x
|
||||
Theme90.text(self, f, Vector2(size.x - tw - 8, 6), free_text, 10, Theme90.SHADE)
|
||||
@@ -155,16 +291,51 @@ func _can_drop_data(_at_position: Vector2, data: Variant) -> bool:
|
||||
if k != "file" and k != "packet":
|
||||
return false
|
||||
drop_active = true
|
||||
if not is_section:
|
||||
_request_hover_open()
|
||||
return true
|
||||
|
||||
|
||||
func _drop_data(_at_position: Vector2, data: Variant) -> void:
|
||||
drop_active = false
|
||||
if is_section:
|
||||
hover_open = false
|
||||
_reset_hover_timer()
|
||||
if is_section or not (data is Dictionary):
|
||||
return
|
||||
file_dropped.emit(folder_id, data)
|
||||
_start_accept()
|
||||
if str(data.get("kind", "")) == "packet":
|
||||
packet_dropped.emit(folder_id, data)
|
||||
else:
|
||||
file_dropped.emit(folder_id, data)
|
||||
|
||||
|
||||
func _notification(what: int) -> void:
|
||||
if what == NOTIFICATION_DRAG_END:
|
||||
drop_active = false
|
||||
hover_open = false
|
||||
_reset_hover_timer()
|
||||
elif what == NOTIFICATION_MOUSE_EXIT:
|
||||
if drop_active or hover_open:
|
||||
drop_active = false
|
||||
hover_open = false
|
||||
_reset_hover_timer()
|
||||
|
||||
|
||||
func set_hover(on: bool) -> void:
|
||||
hover_open = on
|
||||
|
||||
|
||||
func _request_hover_open() -> void:
|
||||
if not is_inside_tree():
|
||||
return
|
||||
var dc := get_tree().get_first_node_in_group("drag_controller")
|
||||
if dc != null and dc.has_method("hover_folder"):
|
||||
dc.call("hover_folder", folder_id)
|
||||
|
||||
|
||||
func _reset_hover_timer() -> void:
|
||||
if not is_inside_tree():
|
||||
return
|
||||
var dc := get_tree().get_first_node_in_group("drag_controller")
|
||||
if dc != null and dc.has_method("reset_hover"):
|
||||
dc.call("reset_hover")
|
||||