Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
77e62189cf | ||
|
|
1c35a9e06c | ||
|
|
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: 8.6 KiB |
@@ -0,0 +1,40 @@
|
||||
[remap]
|
||||
|
||||
importer="texture"
|
||||
type="CompressedTexture2D"
|
||||
uid="uid://das8asb4b05gm"
|
||||
path="res://.godot/imported/file_icons.png-aadd037746f865f0fe6757930f0f608c.ctex"
|
||||
metadata={
|
||||
"vram_texture": false
|
||||
}
|
||||
|
||||
[deps]
|
||||
|
||||
source_file="res://art/icons/file_icons.png"
|
||||
dest_files=["res://.godot/imported/file_icons.png-aadd037746f865f0fe6757930f0f608c.ctex"]
|
||||
|
||||
[params]
|
||||
|
||||
compress/mode=0
|
||||
compress/high_quality=false
|
||||
compress/lossy_quality=0.7
|
||||
compress/uastc_level=0
|
||||
compress/rdo_quality_loss=0.0
|
||||
compress/hdr_compression=1
|
||||
compress/normal_map=0
|
||||
compress/channel_pack=0
|
||||
mipmaps/generate=false
|
||||
mipmaps/limit=-1
|
||||
roughness/mode=0
|
||||
roughness/src_normal=""
|
||||
process/channel_remap/red=0
|
||||
process/channel_remap/green=1
|
||||
process/channel_remap/blue=2
|
||||
process/channel_remap/alpha=3
|
||||
process/fix_alpha_border=true
|
||||
process/premult_alpha=false
|
||||
process/normal_map_invert_y=false
|
||||
process/hdr_as_srgb=false
|
||||
process/hdr_clamp_exposure=false
|
||||
process/size_limit=0
|
||||
detect_3d/compress_to=1
|
||||
|
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
|
||||
|
After Width: | Height: | Size: 4.1 KiB |
@@ -0,0 +1,40 @@
|
||||
[remap]
|
||||
|
||||
importer="texture"
|
||||
type="CompressedTexture2D"
|
||||
uid="uid://dyryfdgvj0s6m"
|
||||
path="res://.godot/imported/_source_upgrades.png-5c2186a86af26623e1e1ba5a3e0ad2eb.ctex"
|
||||
metadata={
|
||||
"vram_texture": false
|
||||
}
|
||||
|
||||
[deps]
|
||||
|
||||
source_file="res://art/icons/shop/_source_upgrades.png"
|
||||
dest_files=["res://.godot/imported/_source_upgrades.png-5c2186a86af26623e1e1ba5a3e0ad2eb.ctex"]
|
||||
|
||||
[params]
|
||||
|
||||
compress/mode=0
|
||||
compress/high_quality=false
|
||||
compress/lossy_quality=0.7
|
||||
compress/uastc_level=0
|
||||
compress/rdo_quality_loss=0.0
|
||||
compress/hdr_compression=1
|
||||
compress/normal_map=0
|
||||
compress/channel_pack=0
|
||||
mipmaps/generate=false
|
||||
mipmaps/limit=-1
|
||||
roughness/mode=0
|
||||
roughness/src_normal=""
|
||||
process/channel_remap/red=0
|
||||
process/channel_remap/green=1
|
||||
process/channel_remap/blue=2
|
||||
process/channel_remap/alpha=3
|
||||
process/fix_alpha_border=true
|
||||
process/premult_alpha=false
|
||||
process/normal_map_invert_y=false
|
||||
process/hdr_as_srgb=false
|
||||
process/hdr_clamp_exposure=false
|
||||
process/size_limit=0
|
||||
detect_3d/compress_to=1
|
||||
|
After Width: | Height: | Size: 475 B |
@@ -0,0 +1,40 @@
|
||||
[remap]
|
||||
|
||||
importer="texture"
|
||||
type="CompressedTexture2D"
|
||||
uid="uid://05ucaefb4807"
|
||||
path="res://.godot/imported/lot_channel.png-86f52ea0f61c04f6a60ada643ca85ccc.ctex"
|
||||
metadata={
|
||||
"vram_texture": false
|
||||
}
|
||||
|
||||
[deps]
|
||||
|
||||
source_file="res://art/icons/shop/lot_channel.png"
|
||||
dest_files=["res://.godot/imported/lot_channel.png-86f52ea0f61c04f6a60ada643ca85ccc.ctex"]
|
||||
|
||||
[params]
|
||||
|
||||
compress/mode=0
|
||||
compress/high_quality=false
|
||||
compress/lossy_quality=0.7
|
||||
compress/uastc_level=0
|
||||
compress/rdo_quality_loss=0.0
|
||||
compress/hdr_compression=1
|
||||
compress/normal_map=0
|
||||
compress/channel_pack=0
|
||||
mipmaps/generate=false
|
||||
mipmaps/limit=-1
|
||||
roughness/mode=0
|
||||
roughness/src_normal=""
|
||||
process/channel_remap/red=0
|
||||
process/channel_remap/green=1
|
||||
process/channel_remap/blue=2
|
||||
process/channel_remap/alpha=3
|
||||
process/fix_alpha_border=true
|
||||
process/premult_alpha=false
|
||||
process/normal_map_invert_y=false
|
||||
process/hdr_as_srgb=false
|
||||
process/hdr_clamp_exposure=false
|
||||
process/size_limit=0
|
||||
detect_3d/compress_to=1
|
||||
|
After Width: | Height: | Size: 846 B |
@@ -0,0 +1,40 @@
|
||||
[remap]
|
||||
|
||||
importer="texture"
|
||||
type="CompressedTexture2D"
|
||||
uid="uid://brc1psekc17bc"
|
||||
path="res://.godot/imported/lot_folder.png-dd754300f7e865cf73009868f7d66e6b.ctex"
|
||||
metadata={
|
||||
"vram_texture": false
|
||||
}
|
||||
|
||||
[deps]
|
||||
|
||||
source_file="res://art/icons/shop/lot_folder.png"
|
||||
dest_files=["res://.godot/imported/lot_folder.png-dd754300f7e865cf73009868f7d66e6b.ctex"]
|
||||
|
||||
[params]
|
||||
|
||||
compress/mode=0
|
||||
compress/high_quality=false
|
||||
compress/lossy_quality=0.7
|
||||
compress/uastc_level=0
|
||||
compress/rdo_quality_loss=0.0
|
||||
compress/hdr_compression=1
|
||||
compress/normal_map=0
|
||||
compress/channel_pack=0
|
||||
mipmaps/generate=false
|
||||
mipmaps/limit=-1
|
||||
roughness/mode=0
|
||||
roughness/src_normal=""
|
||||
process/channel_remap/red=0
|
||||
process/channel_remap/green=1
|
||||
process/channel_remap/blue=2
|
||||
process/channel_remap/alpha=3
|
||||
process/fix_alpha_border=true
|
||||
process/premult_alpha=false
|
||||
process/normal_map_invert_y=false
|
||||
process/hdr_as_srgb=false
|
||||
process/hdr_clamp_exposure=false
|
||||
process/size_limit=0
|
||||
detect_3d/compress_to=1
|
||||
|
After Width: | Height: | Size: 738 B |
@@ -0,0 +1,40 @@
|
||||
[remap]
|
||||
|
||||
importer="texture"
|
||||
type="CompressedTexture2D"
|
||||
uid="uid://0x7o4yo8dvps"
|
||||
path="res://.godot/imported/lot_money.png-9b87cd53690d3369a84cabb7576571b2.ctex"
|
||||
metadata={
|
||||
"vram_texture": false
|
||||
}
|
||||
|
||||
[deps]
|
||||
|
||||
source_file="res://art/icons/shop/lot_money.png"
|
||||
dest_files=["res://.godot/imported/lot_money.png-9b87cd53690d3369a84cabb7576571b2.ctex"]
|
||||
|
||||
[params]
|
||||
|
||||
compress/mode=0
|
||||
compress/high_quality=false
|
||||
compress/lossy_quality=0.7
|
||||
compress/uastc_level=0
|
||||
compress/rdo_quality_loss=0.0
|
||||
compress/hdr_compression=1
|
||||
compress/normal_map=0
|
||||
compress/channel_pack=0
|
||||
mipmaps/generate=false
|
||||
mipmaps/limit=-1
|
||||
roughness/mode=0
|
||||
roughness/src_normal=""
|
||||
process/channel_remap/red=0
|
||||
process/channel_remap/green=1
|
||||
process/channel_remap/blue=2
|
||||
process/channel_remap/alpha=3
|
||||
process/fix_alpha_border=true
|
||||
process/premult_alpha=false
|
||||
process/normal_map_invert_y=false
|
||||
process/hdr_as_srgb=false
|
||||
process/hdr_clamp_exposure=false
|
||||
process/size_limit=0
|
||||
detect_3d/compress_to=1
|
||||
|
After Width: | Height: | Size: 754 B |
@@ -0,0 +1,40 @@
|
||||
[remap]
|
||||
|
||||
importer="texture"
|
||||
type="CompressedTexture2D"
|
||||
uid="uid://dapkbkpx18mgy"
|
||||
path="res://.godot/imported/lot_shield.png-98b0b28b4edcfac112e9f4e5d43c5364.ctex"
|
||||
metadata={
|
||||
"vram_texture": false
|
||||
}
|
||||
|
||||
[deps]
|
||||
|
||||
source_file="res://art/icons/shop/lot_shield.png"
|
||||
dest_files=["res://.godot/imported/lot_shield.png-98b0b28b4edcfac112e9f4e5d43c5364.ctex"]
|
||||
|
||||
[params]
|
||||
|
||||
compress/mode=0
|
||||
compress/high_quality=false
|
||||
compress/lossy_quality=0.7
|
||||
compress/uastc_level=0
|
||||
compress/rdo_quality_loss=0.0
|
||||
compress/hdr_compression=1
|
||||
compress/normal_map=0
|
||||
compress/channel_pack=0
|
||||
mipmaps/generate=false
|
||||
mipmaps/limit=-1
|
||||
roughness/mode=0
|
||||
roughness/src_normal=""
|
||||
process/channel_remap/red=0
|
||||
process/channel_remap/green=1
|
||||
process/channel_remap/blue=2
|
||||
process/channel_remap/alpha=3
|
||||
process/fix_alpha_border=true
|
||||
process/premult_alpha=false
|
||||
process/normal_map_invert_y=false
|
||||
process/hdr_as_srgb=false
|
||||
process/hdr_clamp_exposure=false
|
||||
process/size_limit=0
|
||||
detect_3d/compress_to=1
|
||||
|
After Width: | Height: | Size: 331 B |
@@ -0,0 +1,40 @@
|
||||
[remap]
|
||||
|
||||
importer="texture"
|
||||
type="CompressedTexture2D"
|
||||
uid="uid://d1xbqwgle6adp"
|
||||
path="res://.godot/imported/lot_sla.png-9730f109736cf5b78256df880bad17bc.ctex"
|
||||
metadata={
|
||||
"vram_texture": false
|
||||
}
|
||||
|
||||
[deps]
|
||||
|
||||
source_file="res://art/icons/shop/lot_sla.png"
|
||||
dest_files=["res://.godot/imported/lot_sla.png-9730f109736cf5b78256df880bad17bc.ctex"]
|
||||
|
||||
[params]
|
||||
|
||||
compress/mode=0
|
||||
compress/high_quality=false
|
||||
compress/lossy_quality=0.7
|
||||
compress/uastc_level=0
|
||||
compress/rdo_quality_loss=0.0
|
||||
compress/hdr_compression=1
|
||||
compress/normal_map=0
|
||||
compress/channel_pack=0
|
||||
mipmaps/generate=false
|
||||
mipmaps/limit=-1
|
||||
roughness/mode=0
|
||||
roughness/src_normal=""
|
||||
process/channel_remap/red=0
|
||||
process/channel_remap/green=1
|
||||
process/channel_remap/blue=2
|
||||
process/channel_remap/alpha=3
|
||||
process/fix_alpha_border=true
|
||||
process/premult_alpha=false
|
||||
process/normal_map_invert_y=false
|
||||
process/hdr_as_srgb=false
|
||||
process/hdr_clamp_exposure=false
|
||||
process/size_limit=0
|
||||
detect_3d/compress_to=1
|
||||
|
After Width: | Height: | Size: 386 B |
@@ -0,0 +1,40 @@
|
||||
[remap]
|
||||
|
||||
importer="texture"
|
||||
type="CompressedTexture2D"
|
||||
uid="uid://drgl82ovu7vry"
|
||||
path="res://.godot/imported/lot_slots.png-8d0c9f2a6fd82fdcf134c18f77d41a10.ctex"
|
||||
metadata={
|
||||
"vram_texture": false
|
||||
}
|
||||
|
||||
[deps]
|
||||
|
||||
source_file="res://art/icons/shop/lot_slots.png"
|
||||
dest_files=["res://.godot/imported/lot_slots.png-8d0c9f2a6fd82fdcf134c18f77d41a10.ctex"]
|
||||
|
||||
[params]
|
||||
|
||||
compress/mode=0
|
||||
compress/high_quality=false
|
||||
compress/lossy_quality=0.7
|
||||
compress/uastc_level=0
|
||||
compress/rdo_quality_loss=0.0
|
||||
compress/hdr_compression=1
|
||||
compress/normal_map=0
|
||||
compress/channel_pack=0
|
||||
mipmaps/generate=false
|
||||
mipmaps/limit=-1
|
||||
roughness/mode=0
|
||||
roughness/src_normal=""
|
||||
process/channel_remap/red=0
|
||||
process/channel_remap/green=1
|
||||
process/channel_remap/blue=2
|
||||
process/channel_remap/alpha=3
|
||||
process/fix_alpha_border=true
|
||||
process/premult_alpha=false
|
||||
process/normal_map_invert_y=false
|
||||
process/hdr_as_srgb=false
|
||||
process/hdr_clamp_exposure=false
|
||||
process/size_limit=0
|
||||
detect_3d/compress_to=1
|
||||
|
After Width: | Height: | Size: 447 B |
@@ -0,0 +1,40 @@
|
||||
[remap]
|
||||
|
||||
importer="texture"
|
||||
type="CompressedTexture2D"
|
||||
uid="uid://stbfx7k5pt2s"
|
||||
path="res://.godot/imported/lot_speed.png-17cc7beb1294023e976429ca8555aaaa.ctex"
|
||||
metadata={
|
||||
"vram_texture": false
|
||||
}
|
||||
|
||||
[deps]
|
||||
|
||||
source_file="res://art/icons/shop/lot_speed.png"
|
||||
dest_files=["res://.godot/imported/lot_speed.png-17cc7beb1294023e976429ca8555aaaa.ctex"]
|
||||
|
||||
[params]
|
||||
|
||||
compress/mode=0
|
||||
compress/high_quality=false
|
||||
compress/lossy_quality=0.7
|
||||
compress/uastc_level=0
|
||||
compress/rdo_quality_loss=0.0
|
||||
compress/hdr_compression=1
|
||||
compress/normal_map=0
|
||||
compress/channel_pack=0
|
||||
mipmaps/generate=false
|
||||
mipmaps/limit=-1
|
||||
roughness/mode=0
|
||||
roughness/src_normal=""
|
||||
process/channel_remap/red=0
|
||||
process/channel_remap/green=1
|
||||
process/channel_remap/blue=2
|
||||
process/channel_remap/alpha=3
|
||||
process/fix_alpha_border=true
|
||||
process/premult_alpha=false
|
||||
process/normal_map_invert_y=false
|
||||
process/hdr_as_srgb=false
|
||||
process/hdr_clamp_exposure=false
|
||||
process/size_limit=0
|
||||
detect_3d/compress_to=1
|
||||
|
After Width: | Height: | Size: 405 B |
@@ -0,0 +1,40 @@
|
||||
[remap]
|
||||
|
||||
importer="texture"
|
||||
type="CompressedTexture2D"
|
||||
uid="uid://cixxo6hxeisdl"
|
||||
path="res://.godot/imported/lot_timer.png-9bb944960c84e22cd0cc36cadc18aa86.ctex"
|
||||
metadata={
|
||||
"vram_texture": false
|
||||
}
|
||||
|
||||
[deps]
|
||||
|
||||
source_file="res://art/icons/shop/lot_timer.png"
|
||||
dest_files=["res://.godot/imported/lot_timer.png-9bb944960c84e22cd0cc36cadc18aa86.ctex"]
|
||||
|
||||
[params]
|
||||
|
||||
compress/mode=0
|
||||
compress/high_quality=false
|
||||
compress/lossy_quality=0.7
|
||||
compress/uastc_level=0
|
||||
compress/rdo_quality_loss=0.0
|
||||
compress/hdr_compression=1
|
||||
compress/normal_map=0
|
||||
compress/channel_pack=0
|
||||
mipmaps/generate=false
|
||||
mipmaps/limit=-1
|
||||
roughness/mode=0
|
||||
roughness/src_normal=""
|
||||
process/channel_remap/red=0
|
||||
process/channel_remap/green=1
|
||||
process/channel_remap/blue=2
|
||||
process/channel_remap/alpha=3
|
||||
process/fix_alpha_border=true
|
||||
process/premult_alpha=false
|
||||
process/normal_map_invert_y=false
|
||||
process/hdr_as_srgb=false
|
||||
process/hdr_clamp_exposure=false
|
||||
process/size_limit=0
|
||||
detect_3d/compress_to=1
|
||||
|
After Width: | Height: | Size: 396 B |
@@ -0,0 +1,40 @@
|
||||
[remap]
|
||||
|
||||
importer="texture"
|
||||
type="CompressedTexture2D"
|
||||
uid="uid://dc1sclulaata4"
|
||||
path="res://.godot/imported/lot_udp.png-111b745d551b21f3321882852d252022.ctex"
|
||||
metadata={
|
||||
"vram_texture": false
|
||||
}
|
||||
|
||||
[deps]
|
||||
|
||||
source_file="res://art/icons/shop/lot_udp.png"
|
||||
dest_files=["res://.godot/imported/lot_udp.png-111b745d551b21f3321882852d252022.ctex"]
|
||||
|
||||
[params]
|
||||
|
||||
compress/mode=0
|
||||
compress/high_quality=false
|
||||
compress/lossy_quality=0.7
|
||||
compress/uastc_level=0
|
||||
compress/rdo_quality_loss=0.0
|
||||
compress/hdr_compression=1
|
||||
compress/normal_map=0
|
||||
compress/channel_pack=0
|
||||
mipmaps/generate=false
|
||||
mipmaps/limit=-1
|
||||
roughness/mode=0
|
||||
roughness/src_normal=""
|
||||
process/channel_remap/red=0
|
||||
process/channel_remap/green=1
|
||||
process/channel_remap/blue=2
|
||||
process/channel_remap/alpha=3
|
||||
process/fix_alpha_border=true
|
||||
process/premult_alpha=false
|
||||
process/normal_map_invert_y=false
|
||||
process/hdr_as_srgb=false
|
||||
process/hdr_clamp_exposure=false
|
||||
process/size_limit=0
|
||||
detect_3d/compress_to=1
|
||||
|
After Width: | Height: | Size: 1.8 KiB |
@@ -0,0 +1,40 @@
|
||||
[remap]
|
||||
|
||||
importer="texture"
|
||||
type="CompressedTexture2D"
|
||||
uid="uid://d1vbo2277t4ix"
|
||||
path="res://.godot/imported/tutor_cat.png-ad3ee184ce969bcd494931d604daab36.ctex"
|
||||
metadata={
|
||||
"vram_texture": false
|
||||
}
|
||||
|
||||
[deps]
|
||||
|
||||
source_file="res://art/mascot/tutor_cat.png"
|
||||
dest_files=["res://.godot/imported/tutor_cat.png-ad3ee184ce969bcd494931d604daab36.ctex"]
|
||||
|
||||
[params]
|
||||
|
||||
compress/mode=0
|
||||
compress/high_quality=false
|
||||
compress/lossy_quality=0.7
|
||||
compress/uastc_level=0
|
||||
compress/rdo_quality_loss=0.0
|
||||
compress/hdr_compression=1
|
||||
compress/normal_map=0
|
||||
compress/channel_pack=0
|
||||
mipmaps/generate=false
|
||||
mipmaps/limit=-1
|
||||
roughness/mode=0
|
||||
roughness/src_normal=""
|
||||
process/channel_remap/red=0
|
||||
process/channel_remap/green=1
|
||||
process/channel_remap/blue=2
|
||||
process/channel_remap/alpha=3
|
||||
process/fix_alpha_border=true
|
||||
process/premult_alpha=false
|
||||
process/normal_map_invert_y=false
|
||||
process/hdr_as_srgb=false
|
||||
process/hdr_clamp_exposure=false
|
||||
process/size_limit=0
|
||||
detect_3d/compress_to=1
|
||||
@@ -0,0 +1,19 @@
|
||||
[remap]
|
||||
|
||||
importer="mp3"
|
||||
type="AudioStreamMP3"
|
||||
uid="uid://c5e6gil6gxxmp"
|
||||
path="res://.godot/imported/defeat.mp3-7e698f993d537d59b939608a0abcf708.mp3str"
|
||||
|
||||
[deps]
|
||||
|
||||
source_file="res://audio/music/defeat.mp3"
|
||||
dest_files=["res://.godot/imported/defeat.mp3-7e698f993d537d59b939608a0abcf708.mp3str"]
|
||||
|
||||
[params]
|
||||
|
||||
loop=false
|
||||
loop_offset=0
|
||||
bpm=0
|
||||
beat_count=0
|
||||
bar_beats=4
|
||||
@@ -0,0 +1,19 @@
|
||||
[remap]
|
||||
|
||||
importer="mp3"
|
||||
type="AudioStreamMP3"
|
||||
uid="uid://b6ua70k24xsp7"
|
||||
path="res://.godot/imported/game.mp3-3720d552177761471b052a3420216bb1.mp3str"
|
||||
|
||||
[deps]
|
||||
|
||||
source_file="res://audio/music/game.mp3"
|
||||
dest_files=["res://.godot/imported/game.mp3-3720d552177761471b052a3420216bb1.mp3str"]
|
||||
|
||||
[params]
|
||||
|
||||
loop=false
|
||||
loop_offset=0
|
||||
bpm=0
|
||||
beat_count=0
|
||||
bar_beats=4
|
||||
@@ -0,0 +1,19 @@
|
||||
[remap]
|
||||
|
||||
importer="mp3"
|
||||
type="AudioStreamMP3"
|
||||
uid="uid://b6vxk0i2pd0of"
|
||||
path="res://.godot/imported/low_sla.mp3-a6f69b93336356d63c353431bcf1405a.mp3str"
|
||||
|
||||
[deps]
|
||||
|
||||
source_file="res://audio/music/low_sla.mp3"
|
||||
dest_files=["res://.godot/imported/low_sla.mp3-a6f69b93336356d63c353431bcf1405a.mp3str"]
|
||||
|
||||
[params]
|
||||
|
||||
loop=false
|
||||
loop_offset=0
|
||||
bpm=0
|
||||
beat_count=0
|
||||
bar_beats=4
|
||||
@@ -0,0 +1,19 @@
|
||||
[remap]
|
||||
|
||||
importer="mp3"
|
||||
type="AudioStreamMP3"
|
||||
uid="uid://bxdgubpqsktv3"
|
||||
path="res://.godot/imported/magazine.mp3-b6f09f5db14feb1428451d6c639c4105.mp3str"
|
||||
|
||||
[deps]
|
||||
|
||||
source_file="res://audio/music/magazine.mp3"
|
||||
dest_files=["res://.godot/imported/magazine.mp3-b6f09f5db14feb1428451d6c639c4105.mp3str"]
|
||||
|
||||
[params]
|
||||
|
||||
loop=false
|
||||
loop_offset=0
|
||||
bpm=0
|
||||
beat_count=0
|
||||
bar_beats=4
|
||||
@@ -0,0 +1,19 @@
|
||||
[remap]
|
||||
|
||||
importer="mp3"
|
||||
type="AudioStreamMP3"
|
||||
uid="uid://dp3n8od21isy8"
|
||||
path="res://.godot/imported/menu.mp3-c0c7e41802fae0234a014a5e97694acb.mp3str"
|
||||
|
||||
[deps]
|
||||
|
||||
source_file="res://audio/music/menu.mp3"
|
||||
dest_files=["res://.godot/imported/menu.mp3-c0c7e41802fae0234a014a5e97694acb.mp3str"]
|
||||
|
||||
[params]
|
||||
|
||||
loop=false
|
||||
loop_offset=0
|
||||
bpm=0
|
||||
beat_count=0
|
||||
bar_beats=4
|
||||
@@ -1,123 +1,652 @@
|
||||
{
|
||||
"_meta": {
|
||||
"file": "balance.json",
|
||||
"version": "1.1",
|
||||
"rules": "docs/GDD.md (компактная сводка) + docs/DECISIONS.md (D01–D17)",
|
||||
"note": "null = параметр не задан в постановке, уточнить до кода (см. _todo). Никаких выдуманных чисел.",
|
||||
"_todo": [
|
||||
"attacks.junk.spawn_probability — не задан в спецификации (явно null)",
|
||||
"income_target_by_hour: часы 4,6,7,8 — интерполяция, уточнить",
|
||||
"difficulty.h2 packet_timer/request_timer: формула даёт 26.325/50.15 (19.5×1.35 и 29.5×1.7 — у запросов отдельный grace)"
|
||||
]
|
||||
"version": "2.0",
|
||||
"rules": "PEAK_HOUR_V2_SHOP_FILES_MINIGAMES.md §2, §3, §6, §8 + Appendix A",
|
||||
"note": "v2: 8 lot families, 3 offers, rerolls, dense send-minigame selection. Legacy sections (difficulty/spawn/progression/timers/connections/channel) kept for the not-yet-rewritten HourDirector. New code reads constants/hours/grid/shop_rules/shop/minigames/attacks/payout/sla."
|
||||
},
|
||||
"constants": {
|
||||
"hour_seconds": 300,
|
||||
"run_hours": 9,
|
||||
"sla_start": 128,
|
||||
"sla_max": 200,
|
||||
"money_start": 150,
|
||||
"buffer_base": 4,
|
||||
"buffer_per_upgrade": 2,
|
||||
"buffer_max": 8,
|
||||
"slots_start": 2,
|
||||
"slots_max": 4,
|
||||
"max_sections": 6,
|
||||
"files_per_folder": 16,
|
||||
"money_start": 50,
|
||||
"sla_start": 60,
|
||||
"sla_base": 60,
|
||||
"sla_per_level": 40,
|
||||
"sla_hard_cap": 220,
|
||||
"sla_regen_per_hour": 15,
|
||||
"slots_start": 1,
|
||||
"slots_max": 5,
|
||||
"channel_capacity": 10,
|
||||
"channel_per_level": 6,
|
||||
"channel_capacity_max": 46,
|
||||
"buffer_base": 6,
|
||||
"buffer_per_level": 1,
|
||||
"buffer_max": 12,
|
||||
"request_timer": 30,
|
||||
"request_timer_per_level": 7,
|
||||
"request_timer_max": 65,
|
||||
"packet_timer": 20,
|
||||
"folder_cols": 5,
|
||||
"folder_rows": 4,
|
||||
"folders_start": 2,
|
||||
"folders_max": 6,
|
||||
"folder_new_cost": 180,
|
||||
"folder_price_start": 150,
|
||||
"folder_price_step": 100,
|
||||
"max_sections": 1,
|
||||
"section_start": "C",
|
||||
"folders_per_section": 12,
|
||||
"files_per_folder": 48,
|
||||
"send_window_visible_cards": 4,
|
||||
"send_scroll_card_height_px": 56,
|
||||
"required_name_char_limit": 12,
|
||||
"file_cooldown_after_send_sec": 20,
|
||||
"required_per_request": { "min": 1, "max": 3 }
|
||||
"file_cooldown_after_send_sec": 0,
|
||||
"required_per_request": { "min": 1, "max": 4 },
|
||||
"spawn_window_ratio": 0.75,
|
||||
"spawn_jitter": 0.2,
|
||||
"buffer_per_upgrade": 1,
|
||||
"udp_success_base": 0.40,
|
||||
"udp_success_max": 0.80,
|
||||
"udp_fail_sla": -3,
|
||||
"udp_retry_cooldown_sec": 4,
|
||||
"udp_request_ratio": 0.35,
|
||||
"send_quality_min": 0.45,
|
||||
"file_expire_base": 0.03,
|
||||
"file_expire_step": 0.05,
|
||||
"file_expire_cap": 0.55
|
||||
},
|
||||
"economy": {
|
||||
"start_stats": {
|
||||
"money": 50,
|
||||
"sla": 60,
|
||||
"sla_cap": 60,
|
||||
"slots": 1,
|
||||
"channel_capacity": 10,
|
||||
"buffer": 6,
|
||||
"request_timer": 30,
|
||||
"packet_timer": 20,
|
||||
"folder_grid": [5, 4],
|
||||
"money_mult": 1.0,
|
||||
"speed_mult": 1.0
|
||||
},
|
||||
"formulas": {
|
||||
"max_sla": "clamp(60 + 40 * sla_level, 60, 220)",
|
||||
"sla_regen": "sla = min(sla_cap, sla + 15) в начале каждого часа",
|
||||
"slots": "1 + slot_level (max 5)",
|
||||
"channel_capacity": "10 + 6 * channel_level",
|
||||
"buffer": "min(12, 6 + channel_level)",
|
||||
"request_timer": "min(65, 30 + 7 * timer_level)",
|
||||
"packet_timer": "20 (не покупается)",
|
||||
"speed": "pow(0.90, speed_level)",
|
||||
"money_mult": "1 + 0.05 * money_level",
|
||||
"folder_dims": "[[5,4],[6,4],[7,5],[8,5],[8,6],[9,6]][folder_level]",
|
||||
"transfer_time": "base_time_sec * pow(0.90, speed_level) * max(1.0, load / capacity)",
|
||||
"protocol_time": "UDP ×0.8, TCP ×1.0 (только отправка)"
|
||||
}
|
||||
},
|
||||
"types": {
|
||||
"text": {
|
||||
"name_ru": "Текст",
|
||||
"ext": ".txt",
|
||||
"icon": "▤",
|
||||
"recv_time": 1.0,
|
||||
"send_time": 1.0,
|
||||
"recv_fee": 2,
|
||||
"base_pay": 20,
|
||||
"weight": 6,
|
||||
"minigame_coeff": 1.0,
|
||||
"sla_loss": 2
|
||||
"ext": "txt",
|
||||
"icon": "text",
|
||||
"size_kb": 64,
|
||||
"cells": 1,
|
||||
"base_time_sec": 3,
|
||||
"base_pay": 18,
|
||||
"recv_fee": 1,
|
||||
"weight": 1,
|
||||
"minigame_coeff": 1,
|
||||
"sla_loss": 2,
|
||||
"shapes": ["O1"]
|
||||
},
|
||||
"sound": {
|
||||
"name_ru": "Звук",
|
||||
"ext": ".wav",
|
||||
"icon": "♪",
|
||||
"recv_time": 1.5,
|
||||
"send_time": 2.0,
|
||||
"recv_fee": 3,
|
||||
"base_pay": 35,
|
||||
"weight": 10,
|
||||
"minigame_coeff": 2.0,
|
||||
"sla_loss": 4
|
||||
"ext": "mp3",
|
||||
"icon": "sound",
|
||||
"size_kb": 3072,
|
||||
"cells": 2,
|
||||
"base_time_sec": 5,
|
||||
"base_pay": 30,
|
||||
"recv_fee": 2,
|
||||
"weight": 2,
|
||||
"minigame_coeff": 2,
|
||||
"sla_loss": 4,
|
||||
"shapes": ["I2H", "I2V"]
|
||||
},
|
||||
"photo": {
|
||||
"name_ru": "Фото",
|
||||
"ext": ".jpg",
|
||||
"icon": "▣",
|
||||
"recv_time": 2.0,
|
||||
"send_time": 3.0,
|
||||
"recv_fee": 5,
|
||||
"base_pay": 55,
|
||||
"weight": 14,
|
||||
"minigame_coeff": 3.0,
|
||||
"sla_loss": 8
|
||||
"ext": "jpg",
|
||||
"icon": "photo",
|
||||
"size_kb": 5120,
|
||||
"cells": 3,
|
||||
"base_time_sec": 8,
|
||||
"base_pay": 44,
|
||||
"recv_fee": 4,
|
||||
"weight": 4,
|
||||
"minigame_coeff": 4,
|
||||
"sla_loss": 8,
|
||||
"shapes": ["I3H", "I3V", "L3a", "L3b", "L3c", "L3d"]
|
||||
},
|
||||
"video": {
|
||||
"name_ru": "Видео",
|
||||
"ext": ".mp4",
|
||||
"icon": "▶",
|
||||
"recv_time": 3.0,
|
||||
"send_time": 5.0,
|
||||
"recv_fee": 8,
|
||||
"base_pay": 90,
|
||||
"weight": 22,
|
||||
"minigame_coeff": 4.0,
|
||||
"sla_loss": 16
|
||||
"ext": "mp4",
|
||||
"icon": "video",
|
||||
"size_kb": 20480,
|
||||
"cells": 4,
|
||||
"base_time_sec": 12,
|
||||
"base_pay": 69,
|
||||
"recv_fee": 6,
|
||||
"weight": 8,
|
||||
"minigame_coeff": 8,
|
||||
"sla_loss": 16,
|
||||
"shapes": ["I4H", "I4V", "O4", "T4a", "T4b", "T4c", "T4d", "L4a", "L4b", "L4c", "L4d", "J4a", "J4b", "J4c", "J4d", "S4", "Z4"]
|
||||
}
|
||||
},
|
||||
"hours": [
|
||||
{
|
||||
"hour": 1,
|
||||
"duration": 120,
|
||||
"files": 8,
|
||||
"active_requests": 2,
|
||||
"attacks": 0,
|
||||
"income_target": 100,
|
||||
"send_interval": 20,
|
||||
"type_weights": { "text": 1.0, "sound": 0.0, "photo": 0.0, "video": 0.0 },
|
||||
"request_mix": { "1": 1.0, "2": 0.0, "3": 0.0, "4": 0.0 },
|
||||
"request_type_weights": { "text": 1.0, "sound": 0.0, "photo": 0.0, "video": 0.0 }
|
||||
},
|
||||
{
|
||||
"hour": 2,
|
||||
"duration": 150,
|
||||
"files": 8,
|
||||
"active_requests": 2,
|
||||
"attacks": 1,
|
||||
"income_target": 220,
|
||||
"send_interval": 24,
|
||||
"type_weights": { "text": 0.80, "sound": 0.20, "photo": 0.0, "video": 0.0 },
|
||||
"request_mix": { "1": 0.70, "2": 0.30, "3": 0.0, "4": 0.0 },
|
||||
"request_type_weights": { "text": 0.80, "sound": 0.20, "photo": 0.0, "video": 0.0 }
|
||||
},
|
||||
{
|
||||
"hour": 3,
|
||||
"duration": 180,
|
||||
"files": 9,
|
||||
"active_requests": 2,
|
||||
"attacks": 1,
|
||||
"income_target": 380,
|
||||
"send_interval": 18,
|
||||
"type_weights": { "text": 0.50, "sound": 0.50, "photo": 0.0, "video": 0.0 },
|
||||
"request_mix": { "1": 0.20, "2": 0.80, "3": 0.0, "4": 0.0 },
|
||||
"request_type_weights": { "text": 0.50, "sound": 0.50, "photo": 0.0, "video": 0.0 }
|
||||
},
|
||||
{
|
||||
"hour": 4,
|
||||
"duration": 210,
|
||||
"files": 10,
|
||||
"active_requests": 3,
|
||||
"attacks": 2,
|
||||
"income_target": 560,
|
||||
"send_interval": 24,
|
||||
"type_weights": { "text": 0.40, "sound": 0.30, "photo": 0.30, "video": 0.0 },
|
||||
"request_mix": { "1": 0.0, "2": 0.70, "3": 0.30, "4": 0.0 },
|
||||
"request_type_weights": { "text": 0.40, "sound": 0.30, "photo": 0.30, "video": 0.0 }
|
||||
},
|
||||
{
|
||||
"hour": 5,
|
||||
"duration": 240,
|
||||
"files": 11,
|
||||
"active_requests": 3,
|
||||
"attacks": 2,
|
||||
"income_target": 760,
|
||||
"send_interval": 24,
|
||||
"type_weights": { "text": 0.35, "sound": 0.35, "photo": 0.30, "video": 0.0 },
|
||||
"request_mix": { "1": 0.0, "2": 0.55, "3": 0.45, "4": 0.0 },
|
||||
"request_type_weights": { "text": 0.35, "sound": 0.35, "photo": 0.30, "video": 0.0 }
|
||||
},
|
||||
{
|
||||
"hour": 6,
|
||||
"duration": 270,
|
||||
"files": 11,
|
||||
"active_requests": 3,
|
||||
"attacks": 3,
|
||||
"income_target": 980,
|
||||
"send_interval": 24,
|
||||
"type_weights": { "text": 0.30, "sound": 0.30, "photo": 0.30, "video": 0.10 },
|
||||
"request_mix": { "1": 0.0, "2": 0.25, "3": 0.60, "4": 0.15 },
|
||||
"request_type_weights": { "text": 0.30, "sound": 0.30, "photo": 0.30, "video": 0.10 }
|
||||
},
|
||||
{
|
||||
"hour": 7,
|
||||
"duration": 300,
|
||||
"files": 12,
|
||||
"active_requests": 4,
|
||||
"attacks": 3,
|
||||
"income_target": 1200,
|
||||
"send_interval": 24,
|
||||
"type_weights": { "text": 0.22, "sound": 0.23, "photo": 0.30, "video": 0.25 },
|
||||
"request_mix": { "1": 0.0, "2": 0.15, "3": 0.55, "4": 0.30 },
|
||||
"request_type_weights": { "text": 0.22, "sound": 0.23, "photo": 0.30, "video": 0.25 }
|
||||
},
|
||||
{
|
||||
"hour": 8,
|
||||
"duration": 330,
|
||||
"files": 12,
|
||||
"active_requests": 4,
|
||||
"attacks": 4,
|
||||
"income_target": 1450,
|
||||
"send_interval": 24,
|
||||
"type_weights": { "text": 0.15, "sound": 0.25, "photo": 0.35, "video": 0.25 },
|
||||
"request_mix": { "1": 0.0, "2": 0.10, "3": 0.45, "4": 0.45 },
|
||||
"request_type_weights": { "text": 0.15, "sound": 0.25, "photo": 0.35, "video": 0.25 }
|
||||
},
|
||||
{
|
||||
"hour": 9,
|
||||
"duration": 360,
|
||||
"files": 12,
|
||||
"active_requests": 4,
|
||||
"attacks": 4,
|
||||
"income_target": 1700,
|
||||
"send_interval": 24,
|
||||
"type_weights": { "text": 0.10, "sound": 0.15, "photo": 0.30, "video": 0.45 },
|
||||
"request_mix": { "1": 0.0, "2": 0.0, "3": 0.25, "4": 0.75 },
|
||||
"request_type_weights": { "text": 0.10, "sound": 0.15, "photo": 0.30, "video": 0.45 }
|
||||
}
|
||||
],
|
||||
"payout": {
|
||||
"speed_base": 0.6,
|
||||
"speed_bonus": 0.4,
|
||||
"quality_base": 0.5,
|
||||
"quality_bonus": 0.5,
|
||||
"multi_step": 0.15,
|
||||
"protocol_mult": { "TCP": 1.15, "UDP": 0.85 },
|
||||
"udp_time_mult": 0.8,
|
||||
"streak_step": 0.02,
|
||||
"streak_cap": 10,
|
||||
"money_mult_step": 0.05,
|
||||
"formula": "payout = int(round(float(base_sum) * speed * quality * multi * protocol * streak * money_mult))",
|
||||
"speed_formula": "speed = 0.6 + 0.4 * clamp(time_left / timer_total, 0, 1)",
|
||||
"quality_formula": "quality = 0.5 + 0.5 * q",
|
||||
"multi_formula": "multi = 1 + 0.15 * (n - 1)",
|
||||
"streak_formula": "streak = 1 + 0.02 * min(10, streak)",
|
||||
"money_mult_formula": "money_mult = 1 + 0.05 * money_level",
|
||||
"note": "Только float-арифметика: никаких int/int и никакого ^ для степени."
|
||||
},
|
||||
"sla": {
|
||||
"start": 60,
|
||||
"regen_per_hour": 15,
|
||||
"defeat_at_zero": true,
|
||||
"timeout_fine_base": 10,
|
||||
"timeout_fine_per_hour": 2,
|
||||
"timeout_fine_formula": "-(10 + 2*hour) SLA",
|
||||
"wrong_attach_fine": -2,
|
||||
"lost_file_fine": -5,
|
||||
"buffer_overflow_fine": -5,
|
||||
"reject_money": -5,
|
||||
"reject_sla": -5,
|
||||
"folder_destroyed_fine": -5,
|
||||
"sla_lose": "SLA <= 0 во время часа = поражение забега"
|
||||
},
|
||||
"grid": {
|
||||
"gap": 6,
|
||||
"cell_min": 56,
|
||||
"cell_max": 104,
|
||||
"view_w": 558,
|
||||
"view_h": 470,
|
||||
"folder_cols": 5,
|
||||
"folder_rows": 4,
|
||||
"folder_dims": [[5, 4], [6, 4], [7, 5], [8, 5], [8, 6], [9, 6]],
|
||||
"table_cols": 4,
|
||||
"table_rows": 6,
|
||||
"cell_formula": "cell = clamp(floor(min((view_w - gap*(cols-1))/cols, (view_h - gap*(rows-1))/rows)), 56, 104)",
|
||||
"shapes": {
|
||||
"O1": { "cells": [[0, 0]], "w": 1, "h": 1 },
|
||||
"I2H": { "cells": [[0, 0], [1, 0]], "w": 2, "h": 1 },
|
||||
"I2V": { "cells": [[0, 0], [0, 1]], "w": 1, "h": 2 },
|
||||
"I3H": { "cells": [[0, 0], [1, 0], [2, 0]], "w": 3, "h": 1 },
|
||||
"I3V": { "cells": [[0, 0], [0, 1], [0, 2]], "w": 1, "h": 3 },
|
||||
"L3a": { "cells": [[0, 0], [0, 1], [1, 1]], "w": 2, "h": 2 },
|
||||
"L3b": { "cells": [[0, 0], [1, 0], [0, 1]], "w": 2, "h": 2 },
|
||||
"L3c": { "cells": [[1, 0], [0, 1], [1, 1]], "w": 2, "h": 2 },
|
||||
"L3d": { "cells": [[0, 0], [1, 0], [1, 1]], "w": 2, "h": 2 },
|
||||
"I4H": { "cells": [[0, 0], [1, 0], [2, 0], [3, 0]], "w": 4, "h": 1 },
|
||||
"I4V": { "cells": [[0, 0], [0, 1], [0, 2], [0, 3]], "w": 1, "h": 4 },
|
||||
"O4": { "cells": [[0, 0], [1, 0], [0, 1], [1, 1]], "w": 2, "h": 2 },
|
||||
"T4a": { "cells": [[0, 0], [1, 0], [2, 0], [1, 1]], "w": 3, "h": 2 },
|
||||
"T4b": { "cells": [[0, 1], [1, 1], [2, 1], [1, 0]], "w": 3, "h": 2 },
|
||||
"T4c": { "cells": [[0, 0], [0, 1], [1, 1], [0, 2]], "w": 2, "h": 3 },
|
||||
"T4d": { "cells": [[1, 0], [1, 1], [0, 1], [1, 2]], "w": 2, "h": 3 },
|
||||
"L4a": { "cells": [[0, 0], [0, 1], [0, 2], [1, 2]], "w": 2, "h": 3 },
|
||||
"L4b": { "cells": [[0, 0], [1, 0], [2, 0], [0, 1]], "w": 3, "h": 2 },
|
||||
"L4c": { "cells": [[0, 0], [1, 0], [1, 1], [1, 2]], "w": 2, "h": 3 },
|
||||
"L4d": { "cells": [[2, 0], [0, 1], [1, 1], [2, 1]], "w": 3, "h": 2 },
|
||||
"J4a": { "cells": [[1, 0], [1, 1], [1, 2], [0, 2]], "w": 2, "h": 3 },
|
||||
"J4b": { "cells": [[0, 0], [1, 0], [2, 0], [2, 1]], "w": 3, "h": 2 },
|
||||
"J4c": { "cells": [[0, 0], [0, 1], [1, 1], [2, 1]], "w": 3, "h": 2 },
|
||||
"J4d": { "cells": [[0, 0], [0, 1], [0, 2], [1, 0]], "w": 2, "h": 3 },
|
||||
"S4": { "cells": [[1, 0], [2, 0], [0, 1], [1, 1]], "w": 3, "h": 2 },
|
||||
"Z4": { "cells": [[0, 0], [1, 0], [1, 1], [2, 1]], "w": 3, "h": 2 }
|
||||
},
|
||||
"type_shapes": {
|
||||
"text": ["O1"],
|
||||
"sound": ["I2H", "I2V"],
|
||||
"photo": ["I3H", "I3V", "L3a", "L3b", "L3c", "L3d"],
|
||||
"video": ["I4H", "I4V", "O4", "T4a", "T4b", "T4c", "T4d", "L4a", "L4b", "L4c", "L4d", "J4a", "J4b", "J4c", "J4d", "S4", "Z4"]
|
||||
}
|
||||
},
|
||||
"shop_rules": {
|
||||
"offers": 3,
|
||||
"unlock_hour": 2,
|
||||
"reroll_costs": [40, 80, 120],
|
||||
"max_rerolls": 3,
|
||||
"last_offer_weight": 0.5,
|
||||
"pity_hours": 2,
|
||||
"pity_weight": 1.5,
|
||||
"note": "Веса берутся из shop[].weight_by_hour (9 значений, индекс = hour-1). Фазы H2-3/H4-6/H7-9 — таблица §3.1 — источник правды."
|
||||
},
|
||||
"shop": [
|
||||
{
|
||||
"id": "lot_slots",
|
||||
"name_ru": "Порты коммутатора",
|
||||
"desc_ru": "Ещё один порт: приём и отправка могут идти параллельно.",
|
||||
"effect": "connection_slots",
|
||||
"value": 1,
|
||||
"max_level": 4,
|
||||
"prices": [90, 200, 400, 700],
|
||||
"level_names": ["Второй порт", "Третий порт", "Четвёртый порт", "Магистральный порт"],
|
||||
"weight_by_hour": [1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 0.8, 0.8, 0.8]
|
||||
},
|
||||
{
|
||||
"id": "lot_speed",
|
||||
"name_ru": "Оптоволокно",
|
||||
"desc_ru": "Скорость приёма и отправки +10% за уровень (умножается).",
|
||||
"effect": "transfer_speed",
|
||||
"value": 0.10,
|
||||
"max_level": 6,
|
||||
"prices": [55, 75, 100, 135, 180, 245],
|
||||
"level_names": ["Витая пара", "Экранированная пара", "Оптоволокно", "Гигабитная линия", "Магистраль", "Тёмное волокно"],
|
||||
"weight_by_hour": [1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 0.8, 0.8, 0.8]
|
||||
},
|
||||
{
|
||||
"id": "lot_sla",
|
||||
"name_ru": "Резервный ИБП",
|
||||
"desc_ru": "Держит узел живым: кап SLA +40 и мгновенное восстановление +40.",
|
||||
"effect": "sla_cap",
|
||||
"value": 40,
|
||||
"max_level": 4,
|
||||
"prices": [80, 160, 270, 430],
|
||||
"level_names": ["Резервный ИБП", "Горячий резерв", "Двойное питание", "Автономный контур"],
|
||||
"weight_by_hour": [0.8, 0.8, 0.8, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0]
|
||||
},
|
||||
{
|
||||
"id": "lot_shield",
|
||||
"name_ru": "Заземление",
|
||||
"desc_ru": "Автоматически гасит атаку: 70% награды, без штрафа.",
|
||||
"effect": "shield_charge",
|
||||
"value": 1,
|
||||
"max_level": 3,
|
||||
"prices": [100, 220, 410],
|
||||
"level_names": ["Заземление", "Громоотвод", "Защитный контур"],
|
||||
"weight_by_hour": [0.3, 0.3, 0.3, 0.8, 0.8, 0.8, 1.0, 1.0, 1.0]
|
||||
},
|
||||
{
|
||||
"id": "lot_money",
|
||||
"name_ru": "Корпоративный контракт",
|
||||
"desc_ru": "Оплата за контейнеры +5% за уровень.",
|
||||
"effect": "money_mult",
|
||||
"value": 0.05,
|
||||
"max_level": 8,
|
||||
"prices": [70, 95, 130, 170, 220, 285, 365, 465],
|
||||
"level_names": ["Тарифный план", "Корпоративный контракт", "SLA-пакет", "Госзаказ", "Экспортный контракт", "Франшиза", "Монополия", "Золотая жила"],
|
||||
"weight_by_hour": [0.6, 0.6, 0.6, 1.0, 1.0, 1.0, 1.2, 1.2, 1.2]
|
||||
},
|
||||
{
|
||||
"id": "lot_folder",
|
||||
"name_ru": "Складской стеллаж",
|
||||
"desc_ru": "Больше места под фигуры: 20 → 24 клетки на первом уровне (до 54).",
|
||||
"effect": "folder_cells",
|
||||
"value": 1,
|
||||
"max_level": 5,
|
||||
"prices": [50, 100, 180, 290, 440],
|
||||
"level_names": ["Стеллаж", "Секция стеллажа", "Складская линия", "Автоматизированный склад", "Ангар"],
|
||||
"weight_by_hour": [1.0, 1.0, 1.0, 0.9, 0.9, 0.9, 0.8, 0.8, 0.8]
|
||||
},
|
||||
{
|
||||
"id": "lot_channel",
|
||||
"name_ru": "Пропускная способность",
|
||||
"desc_ru": "Канал +6 и буфер приёма +1 за уровень.",
|
||||
"effect": "channel_capacity",
|
||||
"value": 6,
|
||||
"buffer_value": 1,
|
||||
"max_level": 6,
|
||||
"prices": [45, 70, 95, 130, 175, 230],
|
||||
"level_names": ["Канал 56k", "ISDN", "ADSL", "Выделенная линия", "Оптика", "Магистраль"],
|
||||
"weight_by_hour": [1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 0.9, 0.9, 0.9]
|
||||
},
|
||||
{
|
||||
"id": "lot_timer",
|
||||
"name_ru": "Регламент обработки",
|
||||
"desc_ru": "Больше времени на поиск файлов и прикрепление: 30 → 37 с (до 65).",
|
||||
"effect": "request_timer",
|
||||
"value": 7,
|
||||
"max_level": 5,
|
||||
"prices": [45, 75, 115, 165, 230],
|
||||
"level_names": ["Регламент", "Продление сессии", "Гибкий график", "Приоритетная обработка", "Выделенный менеджер"],
|
||||
"weight_by_hour": [1.0, 1.0, 1.0, 0.9, 0.9, 0.9, 0.7, 0.7, 0.7]
|
||||
},
|
||||
{
|
||||
"id": "lot_udp",
|
||||
"name_ru": "UDP-повторитель",
|
||||
"desc_ru": "Надёжность UDP +10% за уровень: 40% → 80%.",
|
||||
"effect": "udp_success",
|
||||
"value": 0.10,
|
||||
"max_level": 4,
|
||||
"prices": [60, 120, 210, 350],
|
||||
"weight_by_hour": [0.8, 0.8, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0]
|
||||
}
|
||||
],
|
||||
"minigames": {
|
||||
"selection": {
|
||||
"order": ["dots", "sound", "text", "video", "photo", "maze"],
|
||||
"achievable_sums": [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 24, 25, 26, 28, 32],
|
||||
"max_index": 26,
|
||||
"tier_max_rank": [7, 13],
|
||||
"rank_games": ["text", "sound", "dots", "photo", "maze", "dots", "photo", "video", "dots", "text", "video", "maze", "photo", "sound", "video", "text", "maze", "sound", "video", "sound", "photo", "dots", "dots", "maze", "maze", "photo", "video"],
|
||||
"formula": "S=sum(coeff по 1-4 файлам); rank=index of S in achievable_sums (или ближайшей меньшей); tier=1 при rank<=7, 2 при 8<=rank<=13, иначе 3; id=rank_games[rank]; S=0 → dots/T1",
|
||||
"note": "6 игр отправки (wires удалена), 3 ступени, ровно 27 достижимых сумм коэффициентов для 1-4 файлов. Таблица rank_games задаёт игру по rank напрямую (без modulo)."
|
||||
},
|
||||
"games": {
|
||||
"dots": {
|
||||
"name_ru": "Одна линия",
|
||||
"kind": "send",
|
||||
"tiers": [
|
||||
{ "grid": [4, 3], "obstacles": 2, "undo_penalty": 0.04 },
|
||||
{ "grid": [4, 4], "obstacles": 4, "undo_penalty": 0.04 },
|
||||
{ "grid": [5, 4], "obstacles": 6, "undo_penalty": 0.03 }
|
||||
]
|
||||
},
|
||||
"sound": {
|
||||
"name_ru": "Уровни",
|
||||
"kind": "send",
|
||||
"tiers": [
|
||||
{ "channels": 4, "tolerance": 0.11, "drift": 0.0, "drift_hz": 0.0 },
|
||||
{ "channels": 5, "tolerance": 0.08, "drift": 0.02, "drift_hz": 0.15 },
|
||||
{ "channels": 6, "tolerance": 0.06, "drift": 0.03, "drift_hz": 0.25 }
|
||||
]
|
||||
},
|
||||
"text": {
|
||||
"name_ru": "Чужое слово",
|
||||
"kind": "send",
|
||||
"tiers": [
|
||||
{ "words": 15, "aliens": 2, "lines": 2, "alien_kind": "foreign_language+typos" },
|
||||
{ "words": 19, "aliens": 3, "lines": 3, "alien_kind": "foreign_language+typos+semantic" },
|
||||
{ "words": 24, "aliens": 4, "lines": 3, "alien_kind": "foreign_language+typos+semantic+archaic" }
|
||||
]
|
||||
},
|
||||
"video": {
|
||||
"name_ru": "Раскадровка",
|
||||
"kind": "send",
|
||||
"tiers": [
|
||||
{ "frames": 4, "slots": 4, "decoys": 0, "mirror": false },
|
||||
{ "frames": 5, "slots": 4, "decoys": 1, "mirror": false },
|
||||
{ "frames": 6, "slots": 5, "decoys": 2, "mirror": true }
|
||||
]
|
||||
},
|
||||
"photo": {
|
||||
"name_ru": "Кадр",
|
||||
"kind": "send",
|
||||
"tiers": [
|
||||
{ "speed": 140, "frame_w": 180, "frame_h": 120, "shots": 4, "retarget_min": 0.6, "retarget_max": 1.0 },
|
||||
{ "speed": 200, "frame_w": 150, "frame_h": 100, "shots": 5, "retarget_min": 0.5, "retarget_max": 0.8 },
|
||||
{ "speed": 260, "frame_w": 120, "frame_h": 80, "shots": 6, "retarget_min": 0.35, "retarget_max": 0.65 }
|
||||
]
|
||||
},
|
||||
"maze": {
|
||||
"name_ru": "Лабиринт",
|
||||
"kind": "send",
|
||||
"tiers": [
|
||||
{ "cols": 11, "rows": 4, "width": 440, "height": 160, "speed": 470, "collision_penalty": 0.05, "dead_end_penalty": 0.02 },
|
||||
{ "cols": 13, "rows": 4, "width": 494, "height": 152, "speed": 520, "collision_penalty": 0.04, "dead_end_penalty": 0.02 },
|
||||
{ "cols": 15, "rows": 5, "width": 520, "height": 180, "speed": 560, "collision_penalty": 0.04, "dead_end_penalty": 0.02 }
|
||||
]
|
||||
},
|
||||
"junk": {
|
||||
"name_ru": "Фильтр",
|
||||
"kind": "attack",
|
||||
"tiers": [
|
||||
{ "cards": 9, "junk": 2, "hints": 1, "threshold": 0.6 },
|
||||
{ "cards": 10, "junk": 3, "hints": 2, "threshold": 0.65 },
|
||||
{ "cards": 11, "junk": 4, "hints": 2, "threshold": 0.7 },
|
||||
{ "cards": 12, "junk": 5, "hints": 3, "threshold": 0.75 }
|
||||
]
|
||||
},
|
||||
"ddos": {
|
||||
"name_ru": "Щит",
|
||||
"kind": "attack",
|
||||
"tiers": [
|
||||
{ "packets": 8, "speed": 110, "shield_width": 260, "interval": 0.55, "threshold": 0.6 },
|
||||
{ "packets": 10, "speed": 140, "shield_width": 220, "interval": 0.45, "threshold": 0.65 },
|
||||
{ "packets": 12, "speed": 170, "shield_width": 180, "interval": 0.35, "threshold": 0.7 },
|
||||
{ "packets": 14, "speed": 200, "shield_width": 150, "interval": 0.28, "threshold": 0.75 }
|
||||
]
|
||||
},
|
||||
"phishing": {
|
||||
"name_ru": "Паспорт",
|
||||
"kind": "attack",
|
||||
"tiers": [
|
||||
{ "clients": 3, "checks": ["name"], "threshold": 0.75 },
|
||||
{ "clients": 4, "checks": ["name", "department"], "threshold": 0.75 },
|
||||
{ "clients": 5, "checks": ["name", "department", "id"], "threshold": 0.8 },
|
||||
{ "clients": 6, "checks": ["name", "department", "id", "photo", "expiry"], "threshold": 0.8 }
|
||||
]
|
||||
},
|
||||
"ransomware": {
|
||||
"name_ru": "Карантин",
|
||||
"kind": "attack",
|
||||
"tiers": [
|
||||
{ "cards": 12, "exe": 1, "threshold": 0.5, "masking": "extension_visible" },
|
||||
{ "cards": 16, "exe": 2, "threshold": 0.55, "masking": "extension_small" },
|
||||
{ "cards": 20, "exe": 2, "threshold": 0.6, "masking": "document_icon" },
|
||||
{ "cards": 24, "exe": 3, "threshold": 0.65, "masking": "similar_names" }
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
"attacks": {
|
||||
"schedule": [0, 1, 1, 2, 2, 3, 3, 4, 4],
|
||||
"tier_formula": "clamp(1 + floor((hour - 2) / 2.0), 1, 4)",
|
||||
"spawn_window": {
|
||||
"start_ratio": 0.25,
|
||||
"end_ratio": 0.85,
|
||||
"start_min_sec": 20,
|
||||
"start_max_sec": 60,
|
||||
"end_margin_sec": 20
|
||||
},
|
||||
"shield_reward_scale": 0.7,
|
||||
"money_scale": 1.0,
|
||||
"kinds": {
|
||||
"junk": {
|
||||
"name_ru": "Мусорный пакет",
|
||||
"minigame": "junk",
|
||||
"weight_by_hour": [0.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0],
|
||||
"tiers": [
|
||||
{ "reward_money": 5, "reward_sla": 1, "fail_money": -10, "fail_sla": -5, "threshold": 0.6 },
|
||||
{ "reward_money": 10, "reward_sla": 2, "fail_money": -15, "fail_sla": -7, "threshold": 0.65 },
|
||||
{ "reward_money": 15, "reward_sla": 3, "fail_money": -20, "fail_sla": -9, "threshold": 0.7 },
|
||||
{ "reward_money": 20, "reward_sla": 4, "fail_money": -25, "fail_sla": -12, "threshold": 0.75 }
|
||||
]
|
||||
},
|
||||
"ddos": {
|
||||
"name_ru": "DDoS",
|
||||
"minigame": "ddos",
|
||||
"weight_by_hour": [0.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0],
|
||||
"tiers": [
|
||||
{ "reward_money": 30, "reward_sla": 2, "fail_money": -10, "fail_sla": -15, "channel_debuff": 0.4, "debuff_sec": 15, "threshold": 0.6 },
|
||||
{ "reward_money": 40, "reward_sla": 3, "fail_money": -15, "fail_sla": -20, "channel_debuff": 0.4, "debuff_sec": 15, "threshold": 0.65 },
|
||||
{ "reward_money": 50, "reward_sla": 4, "fail_money": -20, "fail_sla": -25, "channel_debuff": 0.4, "debuff_sec": 15, "threshold": 0.7 },
|
||||
{ "reward_money": 60, "reward_sla": 5, "fail_money": -25, "fail_sla": -30, "channel_debuff": 0.4, "debuff_sec": 15, "threshold": 0.75 }
|
||||
]
|
||||
},
|
||||
"phishing": {
|
||||
"name_ru": "Фишинг",
|
||||
"minigame": "phishing",
|
||||
"weight_by_hour": [0.0, 0.0, 0.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0],
|
||||
"tiers": [
|
||||
{ "reward_money": 20, "reward_sla": 2, "fail_money": -40, "fail_sla": -10, "file_lock_sec": 60, "send_penalty_sec": 5, "threshold": 0.75 },
|
||||
{ "reward_money": 30, "reward_sla": 3, "fail_money": -50, "fail_sla": -12, "file_lock_sec": 60, "send_penalty_sec": 5, "threshold": 0.75 },
|
||||
{ "reward_money": 40, "reward_sla": 4, "fail_money": -60, "fail_sla": -15, "file_lock_sec": 60, "send_penalty_sec": 5, "threshold": 0.8 },
|
||||
{ "reward_money": 50, "reward_sla": 5, "fail_money": -70, "fail_sla": -18, "file_lock_sec": 60, "send_penalty_sec": 5, "threshold": 0.8 }
|
||||
]
|
||||
},
|
||||
"ransomware": {
|
||||
"name_ru": "Шифровальщик",
|
||||
"minigame": "ransomware",
|
||||
"weight_by_hour": [0.0, 0.0, 0.0, 0.0, 1.0, 1.0, 1.0, 1.0, 1.0],
|
||||
"tiers": [
|
||||
{ "reward_money": 25, "reward_sla": 2, "fail_money": -30, "fail_sla": -10, "folder_block_sec": 15, "threshold": 0.5 },
|
||||
{ "reward_money": 35, "reward_sla": 3, "fail_money": -40, "fail_sla": -12, "folder_block_sec": 15, "threshold": 0.55 },
|
||||
{ "reward_money": 45, "reward_sla": 4, "fail_money": -50, "fail_sla": -15, "folder_block_sec": 15, "threshold": 0.6 },
|
||||
{ "reward_money": 55, "reward_sla": 5, "fail_money": -60, "fail_sla": -18, "folder_block_sec": 15, "threshold": 0.65 }
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
"difficulty": [
|
||||
{ "hour": 1, "recv_interval": 12, "send_interval": 22, "max_requests": 2, "buffer": 4, "packet_timer": 30, "request_timer": 60, "attacks": 0 },
|
||||
{ "hour": 2, "recv_interval": 11, "send_interval": 20, "max_requests": 2, "buffer": 4, "packet_timer": 26.325, "request_timer": 50.15, "attacks": 1 },
|
||||
{ "hour": 3, "recv_interval": 10, "send_interval": 18, "max_requests": 3, "buffer": 5, "packet_timer": 22.8, "request_timer": 40.6, "attacks": 1 },
|
||||
{ "hour": 4, "recv_interval": 9, "send_interval": 17, "max_requests": 3, "buffer": 5, "packet_timer": 18.5, "request_timer": 28.5, "attacks": 2 },
|
||||
{ "hour": 5, "recv_interval": 8, "send_interval": 15, "max_requests": 4, "buffer": 6, "packet_timer": 18, "request_timer": 28, "attacks": 2 },
|
||||
{ "hour": 6, "recv_interval": 7.5, "send_interval": 14, "max_requests": 4, "buffer": 6, "packet_timer": 17.5, "request_timer": 27.5, "attacks": 3 },
|
||||
{ "hour": 7, "recv_interval": 7, "send_interval": 13, "max_requests": 4, "buffer": 6, "packet_timer": 17, "request_timer": 27, "attacks": 3 },
|
||||
{ "hour": 8, "recv_interval": 6.5, "send_interval": 12, "max_requests": 4, "buffer": 7, "packet_timer": 16.5, "request_timer": 26.5, "attacks": 4 },
|
||||
{ "hour": 9, "recv_interval": 6, "send_interval": 12, "max_requests": 4, "buffer": 7, "packet_timer": 16, "request_timer": 26, "attacks": 4 }
|
||||
{ "hour": 1, "recv_interval": 12, "send_interval": 22, "max_requests": 2, "buffer": 4, "packet_timer": 20, "request_timer": 30, "attacks": 0 },
|
||||
{ "hour": 2, "recv_interval": 11, "send_interval": 20, "max_requests": 5, "buffer": 6, "packet_timer": 20, "request_timer": 30, "attacks": 1 },
|
||||
{ "hour": 3, "recv_interval": 10, "send_interval": 18, "max_requests": 7, "buffer": 6, "packet_timer": 20, "request_timer": 30, "attacks": 1 },
|
||||
{ "hour": 4, "recv_interval": 9, "send_interval": 17, "max_requests": 9, "buffer": 6, "packet_timer": 20, "request_timer": 30, "attacks": 2 },
|
||||
{ "hour": 5, "recv_interval": 8, "send_interval": 15, "max_requests": 10, "buffer": 6, "packet_timer": 20, "request_timer": 30, "attacks": 2 },
|
||||
{ "hour": 6, "recv_interval": 7.5, "send_interval": 14, "max_requests": 11, "buffer": 6, "packet_timer": 20, "request_timer": 30, "attacks": 3 },
|
||||
{ "hour": 7, "recv_interval": 7, "send_interval": 13, "max_requests": 11, "buffer": 6, "packet_timer": 20, "request_timer": 30, "attacks": 3 },
|
||||
{ "hour": 8, "recv_interval": 6.5, "send_interval": 12, "max_requests": 12, "buffer": 6, "packet_timer": 20, "request_timer": 30, "attacks": 4 },
|
||||
{ "hour": 9, "recv_interval": 6, "send_interval": 12, "max_requests": 12, "buffer": 6, "packet_timer": 20, "request_timer": 30, "attacks": 4 }
|
||||
],
|
||||
"difficulty_rules": {
|
||||
"table_end_hour": 8,
|
||||
"after_end_hour": "значения часа 8; +5% к скорости спавна каждые 2 часа",
|
||||
"spawn_speedup_after_end": 0.05,
|
||||
"table_end_hour": 9,
|
||||
"after_end_hour": "значения часа 9",
|
||||
"spawn_speedup_after_end": 0.0,
|
||||
"spawn_speedup_period_hours": 2,
|
||||
"type_availability": "час 1 — text; час 2 — text и sound; час 3 — +photo; час 4+ — все 4 (D23); атаки — с часа 2 (attack_junk)",
|
||||
"timer_grace": "packet_timer: grace h1 1.5, h2 1.35, h3 1.2, h4+ 1.0 (Economy.TIMER_GRACE). request_timer: отдельный grace h1 2.0, h2 1.7, h3 1.4, h4+ 1.0 (Economy.REQUEST_TIMER_GRACE). Формула: (base − 0.5*(h−1))*grace + 4*level. h2: packet 19.5*1.35=26.325, request 29.5*1.7=50.15."
|
||||
"type_availability": "веса типов — hours[].type_weights (единственный источник правды для приёма) и совпадают с hours[].request_type_weights (запросы); H1 только text, H2 text/sound, photo с H4, video с H6",
|
||||
"timer_grace": "v2: таймеры не масштабируются по часу (packet 20, request 30+7*level, cap 65)"
|
||||
},
|
||||
"progression": {
|
||||
"unlocks": {
|
||||
"shop": {"run_hour": 2, "label": "Магазин"},
|
||||
"search": {"meta": "survive_hour_3", "label": "Поиск"},
|
||||
"shield": {"meta": "survive_hour_5", "label": "Щит"},
|
||||
"minigame_text": {"run_hour": 1},
|
||||
"minigame_sound": {"run_hour": 2},
|
||||
"minigame_photo": {"run_hour": 3},
|
||||
"minigame_video": {"run_hour": 4},
|
||||
"minigame_advanced": {"meta": "complete_run", "label": "Продвинутые мини-игры"},
|
||||
"attack_junk": {"run_hour": 2},
|
||||
"attack_ddos": {"run_hour": 2},
|
||||
"attack_phishing": {"run_hour": 3},
|
||||
"attack_ransomware": {"run_hour": 4}
|
||||
"shop": { "run_hour": 2, "label": "Магазин" },
|
||||
"search": { "meta": "survive_hour_3", "label": "Поиск" },
|
||||
"shield": { "meta": "survive_hour_5", "label": "Щит" },
|
||||
"minigame_text": { "run_hour": 1 },
|
||||
"minigame_sound": { "run_hour": 2 },
|
||||
"minigame_photo": { "run_hour": 3 },
|
||||
"minigame_video": { "run_hour": 4 },
|
||||
"attack_junk": { "run_hour": 2 },
|
||||
"attack_ddos": { "run_hour": 2 },
|
||||
"attack_phishing": { "run_hour": 3 },
|
||||
"attack_ransomware": { "run_hour": 4 }
|
||||
},
|
||||
"meta_rewards": {
|
||||
"survive_hour_3": {"min_hours": 3, "unlocks": ["search"]},
|
||||
"survive_hour_5": {"min_hours": 5, "unlocks": ["shield"]},
|
||||
"complete_run": {"min_hours": 9, "unlocks": ["minigame_advanced"]}
|
||||
"survive_hour_3": { "min_hours": 3, "unlocks": ["search"] },
|
||||
"survive_hour_5": { "min_hours": 5, "unlocks": ["shield"] },
|
||||
"complete_run": { "min_hours": 9, "unlocks": ["minigame_advanced"] }
|
||||
}
|
||||
},
|
||||
"timers": {
|
||||
@@ -127,34 +656,20 @@
|
||||
"minigame_timeout": null,
|
||||
"delete_long_press_sec": 0.5
|
||||
},
|
||||
"sla": {
|
||||
"recv_bonus": 0.5,
|
||||
"send_bonus": 1.0,
|
||||
"reflect_bonus": 1.0,
|
||||
"timeout_fine_base": 10,
|
||||
"timeout_fine_per_hour": 2,
|
||||
"timeout_fine_formula": "-(10 + 2*hour) — таймаут и подобные штрафы (D12)",
|
||||
"wrong_attach_fine": -2,
|
||||
"lost_file_fine": -5,
|
||||
"buffer_overflow_fine": -5,
|
||||
"sla_lose": "SLA <= 0"
|
||||
},
|
||||
"payout": {
|
||||
"base_pay": "см. types.*.base_pay (значения в types)",
|
||||
"recv_fee": "см. types.*.recv_fee (значения в types)",
|
||||
"speed_formula": "speed = 0.6 + 0.4*(time_left / timer_total)",
|
||||
"quality_formula": "quality = 0.5 + 0.5*mini_quality",
|
||||
"multi_formula": "multi = 1 + 0.1*(n - 1)",
|
||||
"protocol_mult": { "TCP": 1.15, "UDP": 1.0 },
|
||||
"streak_formula": "streak = 1 + 0.02*min(10, streak)",
|
||||
"income_target_by_hour": {
|
||||
"hours": [1, 2, 3, 5, 9],
|
||||
"approx": [500, 800, 1200, 1800, 2200],
|
||||
"note": "ожидаемо ±10%; часы 4,6,7,8 — интерполяция, уточнить"
|
||||
}
|
||||
"spawn": {
|
||||
"type_pool_by_hour": { "1": ["text", "sound"], "2": ["text", "sound", "photo"], "3+": ["text", "sound", "photo", "video"] },
|
||||
"malicious_ratio": 0.0,
|
||||
"required_count_by_hour": {
|
||||
"1": { "1": 0.7, "2": 0.3, "3": 0.0 },
|
||||
"2": { "1": 0.6, "2": 0.35, "3": 0.05 },
|
||||
"3+": { "1": 0.5, "2": 0.4, "3": 0.1 }
|
||||
},
|
||||
"required_size_distribution": { "1": 0.5, "2": 0.4, "3": 0.1 },
|
||||
"required_count_note": "legacy; v2 читает hours[].request_mix",
|
||||
"attack_schedule": "attacks.schedule (0/1/1/2/2/3/3/4/4), окно 60–240 с часа"
|
||||
},
|
||||
"connections": {
|
||||
"W_formula": "W = clamp(0.30 - 0.005*(hour-1) - 0.005*successes + 0.05*zone_upgrade_level, 0.18, 0.36)",
|
||||
"W_formula": "legacy (полоска соединения удалена в v2)",
|
||||
"W_base": 0.3,
|
||||
"W_hour_coef": -0.005,
|
||||
"W_success_coef": -0.005,
|
||||
@@ -169,103 +684,14 @@
|
||||
"CHANNEL_BG_PER_HOUR": 0,
|
||||
"SLOT_LOAD": 0,
|
||||
"BG_note": "фоновый шум и занятые слоты НЕ грузят канал; load = только активные передачи (loading/sending)",
|
||||
"CHANNEL_CAPACITY": 80,
|
||||
"capacity_per_upgrade": 25,
|
||||
"capacity_upgrade_max_level": 4,
|
||||
"formula": "load = sum(weight(active_transfers)); factor = max(1.0, load / CHANNEL_CAPACITY); actual_time = base_time * factor. idle load = 0"
|
||||
},
|
||||
"attacks": {
|
||||
"junk": {
|
||||
"name_ru": "Мусорный пакет",
|
||||
"spawn_probability": null,
|
||||
"duration_sec": 15,
|
||||
"tell": "серая иконка ?, размер 0 Б, нет таймера",
|
||||
"effect": "авто-занос через duration_sec, занимает место в приёме; уничтожить можно как malicious в любой фазе (D02)",
|
||||
"reward_money": 5,
|
||||
"reward_sla": 1,
|
||||
"fail_money": -10,
|
||||
"fail_sla": -5,
|
||||
"filter_disabled_sec": 10
|
||||
},
|
||||
"ddos": {
|
||||
"name_ru": "DDoS",
|
||||
"cards_min": 8,
|
||||
"cards_max": 12,
|
||||
"spawn_window_sec": 2.0,
|
||||
"shield_hold_sec": 1.5,
|
||||
"tell": "8–12 идентичных карточек без типа и таймера",
|
||||
"effect": "волна карточек; отбивается удержанием щита",
|
||||
"reward_money": 30,
|
||||
"reward_sla": 2,
|
||||
"fail_sla": -15,
|
||||
"channel_debuff": 0.4,
|
||||
"channel_debuff_sec": 15,
|
||||
"packets_lost": 2,
|
||||
"recv_disabled_sec": 5
|
||||
},
|
||||
"phishing": {
|
||||
"name_ru": "Фишинг",
|
||||
"reward_money": 20,
|
||||
"reward_sla": 2,
|
||||
"fail_money": -40,
|
||||
"fail_sla": -10,
|
||||
"file_lock_sec": 60,
|
||||
"send_disabled_sec": 5,
|
||||
"timeout_money": -20,
|
||||
"timeout_sla": -5,
|
||||
"timeout_lock_sec": 30,
|
||||
"tell": "client_id формата id_XXXX с неверным символом (например id_7F3К — кириллическая К), серый аватар",
|
||||
"effect": "фальшивый запрос/пакет"
|
||||
},
|
||||
"ransomware": {
|
||||
"name_ru": "Шифровальщик",
|
||||
"reward_money": 25,
|
||||
"reward_sla": 2,
|
||||
"folder_block_delay_sec": 3,
|
||||
"auto_unlock_sec": 30,
|
||||
"auto_unlock_money": -30,
|
||||
"auto_unlock_sla": -10,
|
||||
"tell": "расширение .exe, красный квадрат 4×4",
|
||||
"effect": "файл становится locked (lock_until), кликабелен для уничтожения (D08)"
|
||||
}
|
||||
},
|
||||
"shop": [
|
||||
{ "id": "shop_slot3", "name_ru": "Третий слот", "effect": "connection_slots", "value": 1, "price": 250, "max_level": 1 },
|
||||
{ "id": "shop_slot4", "name_ru": "Четвёртый слот", "effect": "connection_slots", "value": 1, "price": 450, "max_level": 1 },
|
||||
{ "id": "shop_buffer", "name_ru": "Буфер +2", "effect": "buffer_slots", "value": 2, "price": 200, "max_level": 2 },
|
||||
{ "id": "shop_timer", "name_ru": "Таймер пакетов +4 с", "effect": "packet_timer", "value": 4, "unit": "сек (аддитивно)", "price": 350, "max_level": 3 },
|
||||
{ "id": "shop_zone", "name_ru": "Зона +5%", "effect": "zone_width", "value": 0.05, "unit": "доля W (аддитивно в формуле D04)", "price": 300, "max_level": 3 },
|
||||
{ "id": "shop_channel", "name_ru": "Канал +25", "effect": "channel_capacity", "value": 25, "price": 250, "max_level": 4 },
|
||||
{ "id": "shop_section", "name_ru": "Новая секция", "effect": "sections", "value": 1, "price": 300, "max_level": 5 },
|
||||
{ "id": "shop_search", "name_ru": "Поиск", "effect": "search_highlight", "value": 1, "price": 250, "max_level": 1 },
|
||||
{ "id": "shop_shield", "name_ru": "Щит", "effect": "shield", "value": 1, "price": 400, "max_level": 1 },
|
||||
{ "id": "shop_sla", "name_ru": "SLA +24", "effect": "sla_add", "value": 24, "price": 300, "max_level": 3 }
|
||||
],
|
||||
"shop_reserve": {
|
||||
"items": ["scanner", "autosort"],
|
||||
"note": "резерв — в прототип НЕ входят (D13); позиций в shop НЕТ"
|
||||
},
|
||||
"spawn": {
|
||||
"type_pool_by_hour": {
|
||||
"1": ["text"],
|
||||
"2": ["text", "sound"],
|
||||
"3+": ["text", "sound", "photo", "video"]
|
||||
},
|
||||
"malicious_ratio": 0.0,
|
||||
"required_count_by_hour": {
|
||||
"1": { "1": 1.0 },
|
||||
"2": { "1": 0.75, "2": 0.25 },
|
||||
"3+": { "1": 0.6, "2": 0.3, "3": 0.1 }
|
||||
},
|
||||
"required_size_distribution": { "1": 0.6, "2": 0.3, "3": 0.1 },
|
||||
"required_count_note": "required_count_by_hour — распределение по часу; required_size_distribution — fallback/час 3+",
|
||||
"attack_schedule": "с часа 2, далее по difficulty.attacks, окно 60–240 с часа, не во время мини-игры"
|
||||
"CHANNEL_CAPACITY": 10,
|
||||
"capacity_per_upgrade": 6,
|
||||
"capacity_upgrade_max_level": 6,
|
||||
"formula": "load = sum(weight(active_transfers)); factor = max(1.0, load / capacity); actual_time = base_time_sec * pow(0.90, speed_level) * factor"
|
||||
},
|
||||
"minigame_selection": {
|
||||
"coeff": { "text": 1.0, "sound": 2.0, "photo": 3.0, "video": 4.0 },
|
||||
"difficulty_formula": "sum(coeff(required)) / n",
|
||||
"thresholds": { "text": "< 1.5", "sound": "< 2.5", "photo": "< 3.5", "else": "video" },
|
||||
"call": "MiniGame.start(type, difficulty)",
|
||||
"coeff": { "text": 1, "sound": 2, "photo": 4, "video": 8 },
|
||||
"formula": "см. minigames.selection.formula (27 достижимых сумм для 1-4 файлов, 3 ступени, rank_games)",
|
||||
"quality_stub": 1.0
|
||||
}
|
||||
}
|
||||
@@ -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,10 +1,19 @@
|
||||
extends Node
|
||||
## Audio — пул эффектов и фоновый гул (autoload).
|
||||
## Audio — пул эффектов, музыка с кроссфейдом и low-SLA overlay (autoload).
|
||||
##
|
||||
## Пул из 8 AudioStreamPlayer для одноразовых SFX + отдельный проигрыватель
|
||||
## для музыки/фона. Кастомных шин в проекте нет — работаем через Master
|
||||
## и `volume_db`. AudioEffect намеренно не используются: в web-сборке
|
||||
## (no threads) они недоступны.
|
||||
## Пул из 8 AudioStreamPlayer для одноразовых SFX, два плеера A/B для
|
||||
## музыки (menu/game/magazine/defeat) и отдельный loop-плеер для low_sla.
|
||||
## Кастомных шин в проекте нет — работаем через Master и `volume_db`.
|
||||
## AudioEffect намеренно не используются: в web-сборке (no threads) они
|
||||
## недоступны.
|
||||
##
|
||||
## Приоритет музыки: defeat > shop (magazine) > scene (menu/game).
|
||||
## Смена трека — кроссфейд, быстрые переключения не оставляют наложения:
|
||||
## новый трек всегда стартует на «свободном» плеере, старый уходит в −80 dB
|
||||
## и останавливается по завершении фейда.
|
||||
##
|
||||
## Web autoplay: до первого input музыка не стартует, целевой трек
|
||||
## запоминается. В desktop/headless ограничение не действует.
|
||||
##
|
||||
## Настройки (громкости, mute) живут в полях, без UI.
|
||||
|
||||
@@ -22,17 +31,83 @@ const SFX_IDS: PackedStringArray = [
|
||||
"hour_end", "run_end",
|
||||
]
|
||||
|
||||
## Известный фон (без расширения). Порядок соответствует audio/music/.
|
||||
const MUSIC_IDS: PackedStringArray = ["hum"]
|
||||
## Все известные треки (без расширения) и их файлы.
|
||||
## hum — legacy-фон, остальные — саундтреки.
|
||||
const MUSIC_IDS: PackedStringArray = [
|
||||
"hum", "menu", "game", "magazine", "defeat", "low_sla",
|
||||
]
|
||||
const MUSIC_FILES := {
|
||||
"hum": "hum.wav",
|
||||
"menu": "menu.mp3",
|
||||
"game": "game.mp3",
|
||||
"magazine": "magazine.mp3",
|
||||
"defeat": "defeat.mp3",
|
||||
"low_sla": "low_sla.mp3",
|
||||
}
|
||||
|
||||
## Зацикленные треки. defeat — one-shot.
|
||||
const LOOP_MUSIC_IDS: PackedStringArray = [
|
||||
"hum", "menu", "game", "magazine", "low_sla",
|
||||
]
|
||||
const SCENE_IDS: PackedStringArray = ["menu", "game"]
|
||||
|
||||
const LOW_SLA_ID := "low_sla"
|
||||
const SHOP_MUSIC_ID := "magazine"
|
||||
const DEFEAT_MUSIC_ID := "defeat"
|
||||
|
||||
const SCENE_FADE := 0.9
|
||||
const SHOP_FADE := 0.8
|
||||
const DEFEAT_FADE := 0.6
|
||||
const LOW_FADE := 0.8
|
||||
const LEGACY_FADE := 0.8
|
||||
|
||||
## Overlay «низкий SLA»: тихо подмешивается поверх музыки, не останавливая её.
|
||||
const LOW_SLA_DB := -14.0
|
||||
const LOW_SLA_MAX_DB := -8.0
|
||||
|
||||
var music_volume: float = 0.8 ## линейно 0..1
|
||||
var sfx_volume: float = 0.9 ## линейно 0..1
|
||||
var muted: bool = false ## использовать set_muted()
|
||||
|
||||
var _sfx_streams: Dictionary = {} # id: String -> AudioStream
|
||||
var _music_streams: Dictionary = {} # id: String -> AudioStream
|
||||
## Legacy-словарь одиночного фона (hum). Оставлен для совместимости с
|
||||
## тестами/старым кодом, которые читают/чистят `_music_streams`.
|
||||
var _music_streams: Dictionary = {}
|
||||
var _music_bank: Dictionary = {} # id: String -> AudioStream (все треки)
|
||||
|
||||
var _pool: Array[AudioStreamPlayer] = []
|
||||
var _music_player: AudioStreamPlayer
|
||||
var _music_player: AudioStreamPlayer # плеер A; сохранённое имя для старого API
|
||||
var _music_players: Array[AudioStreamPlayer] = []
|
||||
|
||||
## Состояние фейдов по индексам A(0)/B(1). Гейн в dB: 0 — полная громкость,
|
||||
## SILENCE_DB — тишина. Итоговая громкость = _music_db + гейн.
|
||||
var _music_gain: Array[float] = [SILENCE_DB, SILENCE_DB]
|
||||
var _music_fade_from: Array[float] = [SILENCE_DB, SILENCE_DB]
|
||||
var _music_fade_to: Array[float] = [SILENCE_DB, SILENCE_DB]
|
||||
var _music_fade_elapsed: Array[float] = [0.0, 0.0]
|
||||
var _music_fade_dur: Array[float] = [0.0, 0.0]
|
||||
var _music_stop_after: Array[bool] = [false, false]
|
||||
var _active_music: int = -1
|
||||
var _last_music: int = 1
|
||||
|
||||
var _low_player: AudioStreamPlayer
|
||||
var _low_stream: AudioStream = null
|
||||
var _low_active: bool = false
|
||||
var _low_ratio: float = 0.0
|
||||
var _low_gain: float = SILENCE_DB
|
||||
var _low_fade_from: float = SILENCE_DB
|
||||
var _low_fade_to: float = SILENCE_DB
|
||||
var _low_fade_elapsed: float = 0.0
|
||||
var _low_fade_dur: float = 0.0
|
||||
|
||||
## Логическое состояние: какой трек должен звучать.
|
||||
var _scene_id: String = ""
|
||||
var _shop_open: bool = false
|
||||
var _defeat_active: bool = false
|
||||
var _manual_id: String = "" # явный play_music(), пока не задана сцена
|
||||
var _target_music: String = "" # что уже играет/запрошено ("" — тишина)
|
||||
|
||||
var _unlocked: bool = false
|
||||
var _round_robin: int = 0
|
||||
var _sfx_db: float = 0.0
|
||||
var _music_db: float = 0.0
|
||||
@@ -46,14 +121,34 @@ func _ready() -> void:
|
||||
player.bus = &"Master"
|
||||
add_child(player)
|
||||
_pool.append(player)
|
||||
_music_player = AudioStreamPlayer.new()
|
||||
_music_player.bus = &"Master"
|
||||
add_child(_music_player)
|
||||
|
||||
_music_player = _make_music_player("MusicA")
|
||||
_music_players.append(_music_player)
|
||||
_music_players.append(_make_music_player("MusicB"))
|
||||
|
||||
_low_player = AudioStreamPlayer.new()
|
||||
_low_player.name = "LowSla"
|
||||
_low_player.bus = &"Master"
|
||||
_low_player.volume_db = SILENCE_DB
|
||||
add_child(_low_player)
|
||||
|
||||
_preload_all()
|
||||
# Web: старт только после первого жеста. Desktop/headless не блокируем.
|
||||
_unlocked = not OS.has_feature("web")
|
||||
_apply_volumes()
|
||||
|
||||
|
||||
## Загрузка всех WAV в словари. Отсутствующий файл — предупреждение, не падение.
|
||||
func _make_music_player(player_name: String) -> AudioStreamPlayer:
|
||||
var player := AudioStreamPlayer.new()
|
||||
player.name = player_name
|
||||
player.bus = &"Master"
|
||||
player.volume_db = SILENCE_DB
|
||||
add_child(player)
|
||||
return player
|
||||
|
||||
|
||||
## Загрузка всех SFX и музыкальных треков. Отсутствующий файл — warning,
|
||||
## не падение.
|
||||
func _preload_all() -> void:
|
||||
for id in SFX_IDS:
|
||||
var path := SFX_DIR + String(id) + ".wav"
|
||||
@@ -63,13 +158,16 @@ func _preload_all() -> void:
|
||||
else:
|
||||
_sfx_streams[id] = stream
|
||||
for id in MUSIC_IDS:
|
||||
var path := MUSIC_DIR + String(id) + ".wav"
|
||||
var stream := _load_stream(path)
|
||||
var key := String(id)
|
||||
var stream := _load_music_stream(key)
|
||||
if stream == null:
|
||||
push_warning("Audio: файл музыки не найден: " + path)
|
||||
push_warning("Audio: файл музыки не найден: " + _music_path(key))
|
||||
else:
|
||||
_music_streams[id] = stream
|
||||
_enable_loop(stream, path)
|
||||
_music_bank[key] = stream
|
||||
# Legacy-словарь держим ровно с одним элементом (hum).
|
||||
if key == "hum":
|
||||
_music_streams[key] = stream
|
||||
_low_stream = _music_bank.get(LOW_SLA_ID) as AudioStream
|
||||
|
||||
|
||||
func _load_stream(path: String) -> AudioStream:
|
||||
@@ -78,22 +176,68 @@ func _load_stream(path: String) -> AudioStream:
|
||||
return load(path) as AudioStream
|
||||
|
||||
|
||||
## Зацикливание для фона (гул): loop на весь файл, шов уже совпадает.
|
||||
func _enable_loop(stream: AudioStream, path: String) -> void:
|
||||
func _music_path(id: String) -> String:
|
||||
if MUSIC_FILES.has(id):
|
||||
return MUSIC_DIR + String(MUSIC_FILES[id])
|
||||
# Неизвестный id: пробуем mp3, затем wav (совместимость со старым кодом).
|
||||
var mp3_path := MUSIC_DIR + id + ".mp3"
|
||||
if ResourceLoader.exists(mp3_path):
|
||||
return mp3_path
|
||||
return MUSIC_DIR + id + ".wav"
|
||||
|
||||
|
||||
## Загрузка трека с настройкой loop. defeat — one-shot.
|
||||
func _load_music_stream(id: String) -> AudioStream:
|
||||
var path := _music_path(id)
|
||||
var stream := _load_stream(path)
|
||||
if stream == null:
|
||||
return null
|
||||
_configure_loop(stream, id)
|
||||
return stream
|
||||
|
||||
|
||||
func _get_music_stream(id: String) -> AudioStream:
|
||||
var stream := _music_bank.get(id) as AudioStream
|
||||
if stream != null:
|
||||
return stream
|
||||
stream = _load_music_stream(id)
|
||||
if stream == null:
|
||||
push_warning("Audio: музыка не найдена: " + _music_path(id))
|
||||
return null
|
||||
_music_bank[id] = stream
|
||||
return stream
|
||||
|
||||
|
||||
## MP3: loop=true, loop_offset=0. WAV: loop на весь файл. defeat: loop выкл.
|
||||
func _configure_loop(stream: AudioStream, id: String) -> void:
|
||||
var should_loop := LOOP_MUSIC_IDS.has(id)
|
||||
var mp3 := stream as AudioStreamMP3
|
||||
if mp3 != null:
|
||||
mp3.loop = should_loop
|
||||
if should_loop:
|
||||
mp3.loop_offset = 0.0
|
||||
return
|
||||
var wav := stream as AudioStreamWAV
|
||||
if wav == null or wav.loop_mode != AudioStreamWAV.LOOP_DISABLED:
|
||||
if wav == null:
|
||||
return
|
||||
if not should_loop:
|
||||
wav.loop_mode = AudioStreamWAV.LOOP_DISABLED
|
||||
return
|
||||
if wav.loop_mode != AudioStreamWAV.LOOP_DISABLED:
|
||||
return
|
||||
# Длина берётся из get_length(): одинаково корректна для PCM и для
|
||||
# сжатого импорта (по умолчанию Godot импортирует WAV в QOA).
|
||||
var frames := int(round(wav.get_length() * float(wav.mix_rate)))
|
||||
if frames <= 0:
|
||||
push_warning("Audio: не удалось вычислить длину для loop: " + path)
|
||||
push_warning("Audio: не удалось вычислить длину для loop: " + _music_path(id))
|
||||
return
|
||||
wav.loop_mode = AudioStreamWAV.LOOP_FORWARD
|
||||
wav.loop_begin = 0
|
||||
wav.loop_end = frames
|
||||
|
||||
|
||||
# --- SFX --------------------------------------------------------------------
|
||||
|
||||
## Проиграть одноразовый эффект. `id` — имя файла без расширения;
|
||||
## неизвестный id даёт push_warning и ни при каких условиях не падает.
|
||||
func play_sfx(id: String) -> void:
|
||||
@@ -113,36 +257,297 @@ func play_sfx(id: String) -> void:
|
||||
player.play()
|
||||
|
||||
|
||||
## Проиграть фон (гул) с зацикливанием. Повторный вызов во время игры — no-op.
|
||||
func play_music(id: String = "hum") -> void:
|
||||
var key := id.trim_suffix(".wav")
|
||||
var stream := _music_streams.get(key) as AudioStream
|
||||
if stream == null:
|
||||
var path := MUSIC_DIR + key + ".wav"
|
||||
stream = _load_stream(path)
|
||||
if stream == null:
|
||||
push_warning("Audio: музыка не найдена: " + path)
|
||||
return
|
||||
_music_streams[key] = stream
|
||||
_enable_loop(stream, path)
|
||||
if _music_player.playing and _music_player.stream == stream:
|
||||
# --- музыка: публичный API --------------------------------------------------
|
||||
|
||||
## Явный запуск трека (backward-compatible: по умолчанию hum).
|
||||
## Кроссфейд; повтор того же трека — no-op.
|
||||
func play_music(id: String = "hum", fade: float = LEGACY_FADE) -> void:
|
||||
var key := _normalize_music_id(id)
|
||||
if key == LOW_SLA_ID:
|
||||
set_low_sla(true)
|
||||
return
|
||||
_music_player.stream = stream
|
||||
_music_player.volume_db = _music_db
|
||||
_music_player.play()
|
||||
if key == DEFEAT_MUSIC_ID:
|
||||
play_defeat()
|
||||
return
|
||||
_manual_id = key
|
||||
_request_music(key, fade)
|
||||
|
||||
|
||||
func stop_music() -> void:
|
||||
_music_player.stop()
|
||||
## Смена игровой сцены: menu/game. Кроссфейд 0.9 c, повтор — no-op.
|
||||
func set_scene(id: String) -> void:
|
||||
var key := _normalize_music_id(id)
|
||||
if not SCENE_IDS.has(key):
|
||||
push_warning("Audio: неизвестная сцена для музыки: " + id)
|
||||
return
|
||||
# Новая сцена (в т.ч. menu после defeat) снимает defeat-состояние и
|
||||
# закрывает магазинный overlay: смена сцены — более крупный переход.
|
||||
_defeat_active = false
|
||||
_shop_open = false
|
||||
_manual_id = ""
|
||||
_scene_id = key
|
||||
if key == "menu":
|
||||
# В меню low-SLA overlay не нужен.
|
||||
_disable_low(LOW_FADE)
|
||||
_request_music(_resolve_target(), SCENE_FADE)
|
||||
|
||||
|
||||
## Открыт магазин/журнал: поверх сцены играет magazine, приоритет ниже defeat.
|
||||
func set_shop_open(open: bool) -> void:
|
||||
if _shop_open == open:
|
||||
return
|
||||
_shop_open = open
|
||||
_request_music(_resolve_target(), SHOP_FADE)
|
||||
|
||||
|
||||
## Проигрыш: кроссфейд в one-shot defeat; после конца — тишина до set_scene().
|
||||
func play_defeat() -> void:
|
||||
_defeat_active = true
|
||||
_disable_low(LOW_FADE)
|
||||
# Повторный вызов во время звучания — no-op, после конца — можно заново.
|
||||
if _target_music == DEFEAT_MUSIC_ID and _active_music >= 0 \
|
||||
and _music_players[_active_music].playing:
|
||||
return
|
||||
_request_music(DEFEAT_MUSIC_ID, DEFEAT_FADE, true)
|
||||
|
||||
|
||||
## Остановить музыку (и overlay). fade=0 — сразу (старое поведение).
|
||||
func stop_music(fade: float = 0.0) -> void:
|
||||
_scene_id = ""
|
||||
_shop_open = false
|
||||
_defeat_active = false
|
||||
_manual_id = ""
|
||||
_target_music = ""
|
||||
_stop_all_music(fade)
|
||||
_disable_low(fade)
|
||||
|
||||
|
||||
## Low-SLA overlay: плавно подмешивается поверх музыки, не останавливая её.
|
||||
## ratio 0..1 усиливает сигнал (SLA просел сильнее). Повторное применение
|
||||
## той же цели не перезапускает фейд.
|
||||
func set_low_sla(active: bool, ratio: float = 0.0) -> void:
|
||||
_low_ratio = clampf(ratio, 0.0, 1.0)
|
||||
var target_db := _low_target_db(active)
|
||||
var same := active == _low_active and absf(target_db - _low_fade_to) < 0.05
|
||||
if same and (_low_fade_dur > 0.0 or _low_player.playing or not active or not _unlocked):
|
||||
return
|
||||
_low_active = active
|
||||
_set_low_target(active, LOW_FADE)
|
||||
|
||||
|
||||
## Выключить overlay (defeat/меню) и дать ему уйти в тишину.
|
||||
func _disable_low(fade: float) -> void:
|
||||
_low_active = false
|
||||
_set_low_target(false, fade)
|
||||
|
||||
|
||||
func _low_target_db(active: bool) -> float:
|
||||
if not active:
|
||||
return SILENCE_DB
|
||||
return lerpf(LOW_SLA_DB, LOW_SLA_MAX_DB, _low_ratio)
|
||||
|
||||
|
||||
# --- музыка: внутренняя логика ---------------------------------------------
|
||||
|
||||
func _normalize_music_id(id: String) -> String:
|
||||
var key := id.strip_edges()
|
||||
key = key.trim_suffix(".mp3")
|
||||
key = key.trim_suffix(".wav")
|
||||
return key
|
||||
|
||||
|
||||
func _resolve_target() -> String:
|
||||
if _defeat_active:
|
||||
return DEFEAT_MUSIC_ID
|
||||
if _shop_open:
|
||||
return SHOP_MUSIC_ID
|
||||
if _scene_id != "":
|
||||
return _scene_id
|
||||
return _manual_id
|
||||
|
||||
|
||||
## Запрос трека с учётом web-autoplay и «повтор — no-op».
|
||||
## force нужен только явному play_defeat() после завершения one-shot.
|
||||
func _request_music(id: String, fade: float, force: bool = false) -> void:
|
||||
if not _unlocked:
|
||||
# Запоминаем pending: стартуем после первого input.
|
||||
_target_music = id
|
||||
return
|
||||
if not force and id == _target_music and id != "":
|
||||
return
|
||||
_start_music(id, fade)
|
||||
|
||||
|
||||
func _start_music(id: String, fade: float) -> void:
|
||||
if id == "":
|
||||
_stop_all_music(fade)
|
||||
return
|
||||
var stream := _get_music_stream(id)
|
||||
if stream == null:
|
||||
return
|
||||
|
||||
var idx := 0
|
||||
if _active_music >= 0:
|
||||
idx = 1 - _active_music
|
||||
# Старый активный трек уходит в тишину и затем останавливается.
|
||||
_begin_fade_out(_active_music, fade)
|
||||
else:
|
||||
idx = 1 - _last_music
|
||||
# Хвост предыдущего stop_music не должен накладываться на новый трек.
|
||||
for i in _music_players.size():
|
||||
if i != idx and _music_players[i].playing:
|
||||
_begin_fade_out(i, minf(fade, 0.2))
|
||||
|
||||
var player := _music_players[idx]
|
||||
player.stop()
|
||||
player.stream = stream
|
||||
player.pitch_scale = 1.0
|
||||
_music_gain[idx] = SILENCE_DB
|
||||
_music_fade_from[idx] = SILENCE_DB
|
||||
_music_fade_to[idx] = 0.0
|
||||
_music_fade_elapsed[idx] = 0.0
|
||||
_music_fade_dur[idx] = maxf(fade, 0.0)
|
||||
_music_stop_after[idx] = false
|
||||
if fade <= 0.0:
|
||||
_music_gain[idx] = 0.0
|
||||
player.play()
|
||||
|
||||
_active_music = idx
|
||||
_last_music = idx
|
||||
_target_music = id
|
||||
_apply_volumes()
|
||||
|
||||
|
||||
func _stop_all_music(fade: float) -> void:
|
||||
_active_music = -1
|
||||
for i in _music_players.size():
|
||||
if _music_players[i].playing or _music_gain[i] > SILENCE_DB + 0.01:
|
||||
_begin_fade_out(i, fade)
|
||||
_apply_volumes()
|
||||
|
||||
|
||||
func _begin_fade_out(idx: int, duration: float) -> void:
|
||||
_music_fade_from[idx] = _music_gain[idx]
|
||||
_music_fade_to[idx] = SILENCE_DB
|
||||
_music_fade_elapsed[idx] = 0.0
|
||||
_music_fade_dur[idx] = maxf(duration, 0.0)
|
||||
_music_stop_after[idx] = true
|
||||
if duration <= 0.0:
|
||||
_music_gain[idx] = SILENCE_DB
|
||||
_music_players[idx].stop()
|
||||
|
||||
|
||||
# --- low SLA overlay --------------------------------------------------------
|
||||
|
||||
func _set_low_target(active: bool, fade: float) -> void:
|
||||
var target_db := _low_target_db(active)
|
||||
|
||||
if not active:
|
||||
_low_fade_from = _low_gain
|
||||
_low_fade_to = SILENCE_DB
|
||||
_low_fade_elapsed = 0.0
|
||||
_low_fade_dur = maxf(fade, 0.0)
|
||||
if fade <= 0.0:
|
||||
_low_gain = SILENCE_DB
|
||||
_low_player.stop()
|
||||
_apply_volumes()
|
||||
return
|
||||
|
||||
if not _unlocked:
|
||||
# Запомнили намерение; запустим после первого input.
|
||||
_low_fade_to = target_db
|
||||
return
|
||||
if _low_stream == null:
|
||||
_low_stream = _get_music_stream(LOW_SLA_ID)
|
||||
if _low_stream == null:
|
||||
return
|
||||
if not _low_player.playing:
|
||||
_low_player.stop()
|
||||
_low_player.stream = _low_stream
|
||||
_low_player.pitch_scale = 1.0
|
||||
_low_gain = SILENCE_DB
|
||||
_low_player.play()
|
||||
|
||||
_low_fade_from = _low_gain
|
||||
_low_fade_to = target_db
|
||||
_low_fade_elapsed = 0.0
|
||||
_low_fade_dur = maxf(fade, 0.0)
|
||||
if fade <= 0.0:
|
||||
_low_gain = target_db
|
||||
_apply_volumes()
|
||||
|
||||
|
||||
# --- web autoplay -----------------------------------------------------------
|
||||
|
||||
func _input(event: InputEvent) -> void:
|
||||
if _unlocked:
|
||||
return
|
||||
if event is InputEventMouseButton and (event as InputEventMouseButton).pressed:
|
||||
_unlock()
|
||||
elif event is InputEventKey and (event as InputEventKey).pressed:
|
||||
_unlock()
|
||||
|
||||
|
||||
func _unlock() -> void:
|
||||
_unlocked = true
|
||||
# Overlay и музыка стартуют только сейчас, с фейда.
|
||||
if _low_active:
|
||||
_set_low_target(true, LOW_FADE)
|
||||
if _target_music != "":
|
||||
var pending := _target_music
|
||||
_target_music = ""
|
||||
_start_music(pending, SCENE_FADE)
|
||||
|
||||
|
||||
# --- процесс: фейды ---------------------------------------------------------
|
||||
|
||||
func _process(delta: float) -> void:
|
||||
if not _unlocked:
|
||||
return
|
||||
_tick_music_fades(delta)
|
||||
_tick_low_fade(delta)
|
||||
|
||||
|
||||
func _tick_music_fades(delta: float) -> void:
|
||||
var changed := false
|
||||
for i in _music_players.size():
|
||||
if _music_fade_dur[i] <= 0.0:
|
||||
continue
|
||||
changed = true
|
||||
_music_fade_elapsed[i] = minf(_music_fade_elapsed[i] + delta, _music_fade_dur[i])
|
||||
var t := _music_fade_elapsed[i] / _music_fade_dur[i]
|
||||
_music_gain[i] = lerpf(_music_fade_from[i], _music_fade_to[i], t)
|
||||
if _music_fade_elapsed[i] >= _music_fade_dur[i]:
|
||||
_music_gain[i] = _music_fade_to[i]
|
||||
_music_fade_dur[i] = 0.0
|
||||
if _music_stop_after[i] and i != _active_music:
|
||||
_music_stop_after[i] = false
|
||||
_music_players[i].stop()
|
||||
if changed:
|
||||
_apply_volumes()
|
||||
|
||||
|
||||
func _tick_low_fade(delta: float) -> void:
|
||||
if _low_fade_dur > 0.0:
|
||||
_low_fade_elapsed = minf(_low_fade_elapsed + delta, _low_fade_dur)
|
||||
var t := _low_fade_elapsed / _low_fade_dur
|
||||
_low_gain = lerpf(_low_fade_from, _low_fade_to, t)
|
||||
if _low_fade_elapsed >= _low_fade_dur:
|
||||
_low_gain = _low_fade_to
|
||||
_low_fade_dur = 0.0
|
||||
if not _low_active and _low_gain <= SILENCE_DB + 0.01:
|
||||
_low_player.stop()
|
||||
_apply_volumes()
|
||||
|
||||
|
||||
# --- громкости --------------------------------------------------------------
|
||||
|
||||
## Громкость эффектов, линейно 0..1 (0.5 — вдвое тише).
|
||||
func set_sfx_volume(v: float) -> void:
|
||||
sfx_volume = clampf(v, 0.0, 1.0)
|
||||
_apply_volumes()
|
||||
|
||||
|
||||
## Громкость фона, линейно 0..1.
|
||||
## Громкость музыки, линейно 0..1. Применяется ко всем музыкальным плеерам.
|
||||
func set_music_volume(v: float) -> void:
|
||||
music_volume = clampf(v, 0.0, 1.0)
|
||||
_apply_volumes()
|
||||
@@ -159,7 +564,17 @@ func _apply_volumes() -> void:
|
||||
_music_db = SILENCE_DB if muted else _to_db(music_volume)
|
||||
for player in _pool:
|
||||
player.volume_db = _sfx_db
|
||||
_music_player.volume_db = _music_db
|
||||
for i in _music_players.size():
|
||||
_music_players[i].volume_db = _combine_db(_music_db, _music_gain[i])
|
||||
if _low_player != null:
|
||||
_low_player.volume_db = _combine_db(_music_db, _low_gain)
|
||||
|
||||
|
||||
## Суммарная громкость: базовая (пользователь/mute) + гейн фейда.
|
||||
func _combine_db(base_db: float, gain_db: float) -> float:
|
||||
if base_db <= SILENCE_DB + 0.01 or gain_db <= SILENCE_DB + 0.01:
|
||||
return SILENCE_DB
|
||||
return clampf(base_db + gain_db, SILENCE_DB, 0.0)
|
||||
|
||||
|
||||
func _to_db(linear: float) -> float:
|
||||
|
||||
@@ -1,88 +1,168 @@
|
||||
extends Node
|
||||
## Data — доступ к JSON-данным из res://data (autoload).
|
||||
##
|
||||
## Файлы данных сейчас заготовки, поэтому отсутствующие ключи заменяются
|
||||
## безопасными дефолтами с push_warning; игра не должна падать из-за данных.
|
||||
## v2: новые секции balance.json (constants/hours/grid/shop_rules/shop/
|
||||
## minigames/attacks/payout/sla). Отсутствующие ключи заменяются безопасными
|
||||
## дефолтами с push_warning; игра не должна падать из-за данных.
|
||||
##
|
||||
## Legacy-API (tr, type_def, difficulty_for, shop_item, available_shop,
|
||||
## folder_name) сохранён для ещё не переписанных систем.
|
||||
|
||||
const DATA_DIR := "res://data"
|
||||
|
||||
const DEFAULT_TYPES := {
|
||||
"text": {
|
||||
"name_ru": "Текст", "ext": "txt", "icon": "text",
|
||||
"recv_time": 2.0, "send_time": 1.5, "recv_fee": 1,
|
||||
"base_pay": 80, "weight": 8, "minigame_coeff": 1.0, "size_kb": 64,
|
||||
"sla_loss": 2,
|
||||
"size_kb": 64,
|
||||
"cells": 1, "base_time_sec": 3, "recv_fee": 1,
|
||||
"base_pay": 18, "weight": 1, "minigame_coeff": 1, "sla_loss": 2,
|
||||
"shapes": ["O1"],
|
||||
},
|
||||
"sound": {
|
||||
"name_ru": "Звук", "ext": "mp3", "icon": "sound",
|
||||
"recv_time": 3.5, "send_time": 2.5, "recv_fee": 2,
|
||||
"base_pay": 140, "weight": 12, "minigame_coeff": 2.0, "size_kb": 3072,
|
||||
"sla_loss": 4,
|
||||
"size_kb": 3072,
|
||||
"cells": 2, "base_time_sec": 5, "recv_fee": 2,
|
||||
"base_pay": 30, "weight": 2, "minigame_coeff": 2, "sla_loss": 4,
|
||||
"shapes": ["I2H", "I2V"],
|
||||
},
|
||||
"photo": {
|
||||
"name_ru": "Фото", "ext": "jpg", "icon": "photo",
|
||||
"recv_time": 5.0, "send_time": 3.5, "recv_fee": 3,
|
||||
"base_pay": 220, "weight": 18, "minigame_coeff": 3.0, "size_kb": 5120,
|
||||
"sla_loss": 8,
|
||||
"size_kb": 5120,
|
||||
"cells": 3, "base_time_sec": 8, "recv_fee": 4,
|
||||
"base_pay": 44, "weight": 4, "minigame_coeff": 4, "sla_loss": 8,
|
||||
"shapes": ["I3H", "I3V", "L3a", "L3b", "L3c", "L3d"],
|
||||
},
|
||||
"video": {
|
||||
"name_ru": "Видео", "ext": "mp4", "icon": "video",
|
||||
"recv_time": 8.0, "send_time": 6.0, "recv_fee": 4,
|
||||
"base_pay": 380, "weight": 22, "minigame_coeff": 4.0, "size_kb": 20480,
|
||||
"sla_loss": 16,
|
||||
"size_kb": 20480,
|
||||
"cells": 4, "base_time_sec": 12, "recv_fee": 6,
|
||||
"base_pay": 69, "weight": 8, "minigame_coeff": 8, "sla_loss": 16,
|
||||
"shapes": ["I4H", "I4V", "O4", "T4a", "T4b", "T4c", "T4d",
|
||||
"L4a", "L4b", "L4c", "L4d", "J4a", "J4b", "J4c", "J4d", "S4", "Z4"],
|
||||
},
|
||||
}
|
||||
|
||||
## Таблица сложности — до часа 8 (D12); дальше используется последняя строка.
|
||||
## Таблица сложности v1 — оставлена для legacy difficulty_for().
|
||||
const DEFAULT_DIFFICULTY := [
|
||||
{"hour": 1, "max_requests": 2, "spawn_sec": 7.0},
|
||||
{"hour": 2, "max_requests": 3, "spawn_sec": 6.5},
|
||||
{"hour": 3, "max_requests": 3, "spawn_sec": 6.0},
|
||||
{"hour": 4, "max_requests": 4, "spawn_sec": 5.5},
|
||||
{"hour": 5, "max_requests": 4, "spawn_sec": 5.0},
|
||||
{"hour": 6, "max_requests": 5, "spawn_sec": 4.5},
|
||||
{"hour": 7, "max_requests": 5, "spawn_sec": 4.2},
|
||||
{"hour": 8, "max_requests": 6, "spawn_sec": 4.0},
|
||||
{"hour": 2, "max_requests": 5, "spawn_sec": 6.5},
|
||||
{"hour": 3, "max_requests": 7, "spawn_sec": 6.0},
|
||||
{"hour": 4, "max_requests": 9, "spawn_sec": 5.5},
|
||||
{"hour": 5, "max_requests": 10, "spawn_sec": 5.0},
|
||||
{"hour": 6, "max_requests": 11, "spawn_sec": 4.5},
|
||||
{"hour": 7, "max_requests": 11, "spawn_sec": 4.2},
|
||||
{"hour": 8, "max_requests": 12, "spawn_sec": 4.0},
|
||||
]
|
||||
|
||||
const DEFAULT_SHOP := [
|
||||
{"id": "slot_3", "name": "3-й слот", "cost": 250, "max_level": 1},
|
||||
{"id": "slot_4", "name": "4-й слот", "cost": 450, "max_level": 1},
|
||||
{"id": "buffer", "name": "Буфер +2", "cost": 200, "max_level": 2},
|
||||
{"id": "packet_timer", "name": "Таймер пакетов +4 с", "cost": 350, "max_level": 3},
|
||||
{"id": "zone", "name": "Зона +5 %", "cost": 300, "max_level": 3},
|
||||
{"id": "channel", "name": "Канал +25", "cost": 250, "max_level": 4},
|
||||
{"id": "section", "name": "Секция", "cost": 300, "max_level": 5},
|
||||
{"id": "search", "name": "Поиск", "cost": 250, "max_level": 1},
|
||||
{"id": "shield", "name": "Щит", "cost": 400, "max_level": 1},
|
||||
{"id": "shop_sla", "name": "SLA +24", "effect": "sla_add", "value": 24, "cost": 300, "max_level": 3},
|
||||
const DEFAULT_SEND_ORDER := ["dots", "sound", "text", "video", "photo", "maze"]
|
||||
const DEFAULT_ACHIEVABLE_SUMS := [
|
||||
1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20,
|
||||
21, 22, 24, 25, 26, 28, 32,
|
||||
]
|
||||
|
||||
## Прогрессия/открытия (дублирует data/balance.json → progression).
|
||||
const DEFAULT_PROGRESSION := {
|
||||
"unlocks": {
|
||||
"shop": {"run_hour": 2, "label": "Магазин"},
|
||||
"search": {"meta": "survive_hour_3", "label": "Поиск"},
|
||||
"shield": {"meta": "survive_hour_5", "label": "Щит"},
|
||||
"minigame_text": {"run_hour": 1},
|
||||
"minigame_sound": {"run_hour": 2},
|
||||
"minigame_photo": {"run_hour": 3},
|
||||
"minigame_video": {"run_hour": 4},
|
||||
"minigame_advanced": {"meta": "complete_run", "label": "Продвинутые мини-игры"},
|
||||
"attack_junk": {"run_hour": 2},
|
||||
"attack_ddos": {"run_hour": 2},
|
||||
"attack_phishing": {"run_hour": 3},
|
||||
"attack_ransomware": {"run_hour": 4},
|
||||
},
|
||||
"meta_rewards": {
|
||||
"survive_hour_3": {"min_hours": 3, "unlocks": ["search"]},
|
||||
"survive_hour_5": {"min_hours": 5, "unlocks": ["shield"]},
|
||||
"complete_run": {"min_hours": 9, "unlocks": ["minigame_advanced"]},
|
||||
},
|
||||
const DEFAULT_HOUR_FILES := [8, 8, 9, 10, 11, 11, 12, 12, 12]
|
||||
const DEFAULT_ACTIVE_REQUESTS := [1, 2, 2, 3, 3, 3, 4, 4, 4]
|
||||
const DEFAULT_ATTACKS := [0, 1, 1, 2, 2, 3, 3, 4, 4]
|
||||
const DEFAULT_INCOME_TARGET := [90, 220, 380, 560, 760, 980, 1200, 1450, 1700]
|
||||
const DEFAULT_RECV_INTERVAL := [12, 11, 10, 9, 8, 7.5, 7, 6.5, 6]
|
||||
const DEFAULT_SEND_INTERVAL := [20, 24, 18, 24, 24, 24, 24, 24, 24]
|
||||
## Длительность часа: 120 + 30 * (hour - 1) → 120..360.
|
||||
const DEFAULT_HOUR_DURATION_BASE := 120
|
||||
const DEFAULT_HOUR_DURATION_STEP := 30
|
||||
## Порядок: text, sound, photo, video (§2.5). Совпадает с
|
||||
## DEFAULT_REQUEST_TYPE_WEIGHTS: приём и запросы используют одни типы.
|
||||
const DEFAULT_TYPE_WEIGHTS := [
|
||||
[1.00, 0.00, 0.00, 0.00],
|
||||
[0.80, 0.20, 0.00, 0.00],
|
||||
[0.50, 0.50, 0.00, 0.00],
|
||||
[0.40, 0.30, 0.30, 0.00],
|
||||
[0.35, 0.35, 0.30, 0.00],
|
||||
[0.30, 0.30, 0.30, 0.10],
|
||||
[0.22, 0.23, 0.30, 0.25],
|
||||
[0.15, 0.25, 0.35, 0.25],
|
||||
[0.10, 0.15, 0.30, 0.45],
|
||||
]
|
||||
## Доли числа файлов в запросе: 1..4 (§2.6). Добавлен ключ "4" (4 файла).
|
||||
const DEFAULT_REQUEST_MIX := {"1": 0.76, "2": 0.24, "3": 0.0, "4": 0.0}
|
||||
## Веса выбора ТИПА файлов для required-слотов запроса (§2.6).
|
||||
## Отдельно от type_weights: те задают типы входящих пакетов.
|
||||
## Порядок: text, sound, photo, video.
|
||||
const DEFAULT_REQUEST_TYPE_WEIGHTS := [
|
||||
[1.00, 0.00, 0.00, 0.00],
|
||||
[0.80, 0.20, 0.00, 0.00],
|
||||
[0.50, 0.50, 0.00, 0.00],
|
||||
[0.40, 0.30, 0.30, 0.00],
|
||||
[0.35, 0.35, 0.30, 0.00],
|
||||
[0.30, 0.30, 0.30, 0.10],
|
||||
[0.22, 0.23, 0.30, 0.25],
|
||||
[0.15, 0.25, 0.35, 0.25],
|
||||
[0.10, 0.15, 0.30, 0.45],
|
||||
]
|
||||
|
||||
const DEFAULT_LOT_IDS := [
|
||||
"lot_slots", "lot_speed", "lot_sla", "lot_shield",
|
||||
"lot_money", "lot_folder", "lot_channel", "lot_timer", "lot_udp",
|
||||
]
|
||||
const DEFAULT_LOT_NAMES := [
|
||||
"Порты коммутатора", "Оптоволокно", "Резервный ИБП", "Заземление",
|
||||
"Корпоративный контракт", "Складской стеллаж", "Пропускная способность",
|
||||
"Регламент обработки", "UDP-повторитель",
|
||||
]
|
||||
const DEFAULT_LOT_EFFECTS := [
|
||||
"connection_slots", "transfer_speed", "sla_cap", "shield_charge",
|
||||
"money_mult", "folder_cells", "channel_capacity", "request_timer",
|
||||
"udp_success",
|
||||
]
|
||||
const DEFAULT_LOT_VALUES := [1, 0.10, 40, 1, 0.05, 1, 6, 7, 0.10]
|
||||
const DEFAULT_LOT_MAX := [4, 6, 4, 3, 8, 5, 6, 5, 4]
|
||||
const DEFAULT_LOT_PRICES := [
|
||||
[90, 200, 400, 700],
|
||||
[55, 75, 100, 135, 180, 245],
|
||||
[80, 160, 270, 430],
|
||||
[100, 220, 410],
|
||||
[70, 95, 130, 170, 220, 285, 365, 465],
|
||||
[50, 100, 180, 290, 440],
|
||||
[45, 70, 95, 130, 175, 230],
|
||||
[45, 75, 115, 165, 230],
|
||||
[60, 120, 210, 350],
|
||||
]
|
||||
const DEFAULT_LOT_WEIGHTS := [
|
||||
[1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 0.8, 0.8, 0.8],
|
||||
[1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 0.8, 0.8, 0.8],
|
||||
[0.8, 0.8, 0.8, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0],
|
||||
[0.3, 0.3, 0.3, 0.8, 0.8, 0.8, 1.0, 1.0, 1.0],
|
||||
[0.6, 0.6, 0.6, 1.0, 1.0, 1.0, 1.2, 1.2, 1.2],
|
||||
[1.0, 1.0, 1.0, 0.9, 0.9, 0.9, 0.8, 0.8, 0.8],
|
||||
[1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 0.9, 0.9, 0.9],
|
||||
[1.0, 1.0, 1.0, 0.9, 0.9, 0.9, 0.7, 0.7, 0.7],
|
||||
[0.8, 0.8, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0],
|
||||
]
|
||||
|
||||
const DEFAULT_SHOP_RULES := {
|
||||
"offers": 3,
|
||||
"unlock_hour": 2,
|
||||
"reroll_costs": [40, 80, 120],
|
||||
"max_rerolls": 3,
|
||||
"last_offer_weight": 0.5,
|
||||
"pity_hours": 2,
|
||||
"pity_weight": 1.5,
|
||||
}
|
||||
|
||||
const DEFAULT_GRID_DIMS := [[5, 4], [6, 4], [7, 5], [8, 5], [8, 6], [9, 6]]
|
||||
|
||||
var balance: Dictionary = {}
|
||||
var constants: Dictionary = {}
|
||||
var economy: Dictionary = {}
|
||||
var types: Dictionary = {}
|
||||
var hours: Array = []
|
||||
var grid: Dictionary = {}
|
||||
var shop_lots: Array = []
|
||||
var rules: Dictionary = {}
|
||||
var minigames: Dictionary = {}
|
||||
var attacks: Dictionary = {}
|
||||
var payout_rules: Dictionary = {}
|
||||
var sla_rules: Dictionary = {}
|
||||
|
||||
var difficulty: Array = []
|
||||
var shop: Array = []
|
||||
var names: Dictionary = {}
|
||||
@@ -100,9 +180,27 @@ func _ready() -> void:
|
||||
func load_all() -> void:
|
||||
_cache.clear()
|
||||
balance = load_json("balance.json")
|
||||
constants = _as_dict(balance.get("constants", null))
|
||||
economy = _as_dict(balance.get("economy", null))
|
||||
types = _merge_types(balance.get("types", null))
|
||||
hours = _load_hours(balance.get("hours", null))
|
||||
grid = _as_dict(balance.get("grid", null))
|
||||
if grid.is_empty():
|
||||
push_warning("Data: balance.json → grid отсутствует, используются дефолты")
|
||||
grid = {"folder_cols": 5, "folder_rows": 4, "gap": 6,
|
||||
"cell_min": 56, "cell_max": 104, "table_cols": 4, "table_rows": 6,
|
||||
"folder_dims": DEFAULT_GRID_DIMS.duplicate(true), "shapes": {}}
|
||||
shop_lots = _load_shop_lots(balance.get("shop", null))
|
||||
shop = shop_lots
|
||||
rules = _load_rules(balance.get("shop_rules", null))
|
||||
minigames = _as_dict(balance.get("minigames", null))
|
||||
if minigames.is_empty():
|
||||
push_warning("Data: balance.json → minigames отсутствует")
|
||||
minigames = {"games": {}, "selection": {}}
|
||||
attacks = _as_dict(balance.get("attacks", null))
|
||||
payout_rules = _as_dict(balance.get("payout", null))
|
||||
sla_rules = _as_dict(balance.get("sla", null))
|
||||
difficulty = _load_difficulty(balance.get("difficulty", null))
|
||||
shop = _load_shop(balance.get("shop", null))
|
||||
names = _load_names(load_json("names_ru.json"))
|
||||
strings = load_json("strings_ru.json")
|
||||
var progression = balance.get("progression", null)
|
||||
@@ -182,7 +280,7 @@ func type_def(type: String) -> Dictionary:
|
||||
return DEFAULT_TYPES["text"].duplicate(true)
|
||||
|
||||
|
||||
## Строка таблицы сложности; для hour > max — последняя строка.
|
||||
## Строка таблицы сложности; для hour > max — последняя строка (legacy).
|
||||
func difficulty_for(hour: int) -> Dictionary:
|
||||
var h := maxi(1, int(hour))
|
||||
if difficulty.is_empty():
|
||||
@@ -204,6 +302,141 @@ func difficulty_for(hour: int) -> Dictionary:
|
||||
return last.duplicate(true)
|
||||
|
||||
|
||||
# --- v2: часы --------------------------------------------------------------
|
||||
|
||||
## Строка бюджета часа (duration/files/active_requests/attacks/
|
||||
## income_target/type_weights/request_mix/request_type_weights).
|
||||
## hour > 9 → последняя строка.
|
||||
func hour_def(hour: int) -> Dictionary:
|
||||
var h := maxi(1, int(hour))
|
||||
if hours.is_empty():
|
||||
push_warning("Data: balance.json → hours отсутствует, дефолт часа %d" % h)
|
||||
return _default_hour_row(h)
|
||||
var prev: Dictionary = hours[0]
|
||||
for row in hours:
|
||||
if not (row is Dictionary):
|
||||
continue
|
||||
var row_hour := int(row.get("hour", 0))
|
||||
if row_hour == h:
|
||||
return row.duplicate(true)
|
||||
if row_hour < h:
|
||||
prev = row
|
||||
elif row_hour > h:
|
||||
return prev.duplicate(true)
|
||||
return (hours[hours.size() - 1] as Dictionary).duplicate(true)
|
||||
|
||||
|
||||
func hour_count() -> int:
|
||||
return maxi(1, hours.size())
|
||||
|
||||
|
||||
# --- v2: магазин -----------------------------------------------------------
|
||||
|
||||
func lot_defs() -> Array:
|
||||
return shop_lots.duplicate(true)
|
||||
|
||||
|
||||
func lot_def(id: String) -> Dictionary:
|
||||
for lot in shop_lots:
|
||||
if lot is Dictionary and str(lot.get("id", "")) == str(id):
|
||||
return lot.duplicate(true)
|
||||
push_warning("Data: нет лота '%s'" % str(id))
|
||||
return {}
|
||||
|
||||
|
||||
func shop_rules() -> Dictionary:
|
||||
return rules.duplicate(true)
|
||||
|
||||
|
||||
# --- v2: мини-игры ---------------------------------------------------------
|
||||
|
||||
func minigame_def(id: String) -> Dictionary:
|
||||
var games = minigames.get("games", null)
|
||||
if games is Dictionary:
|
||||
var def = (games as Dictionary).get(str(id), null)
|
||||
if def is Dictionary:
|
||||
return (def as Dictionary).duplicate(true)
|
||||
push_warning("Data: нет мини-игры '%s'" % str(id))
|
||||
return {}
|
||||
|
||||
|
||||
func minigame_selection() -> Dictionary:
|
||||
var out: Dictionary = {}
|
||||
var raw = minigames.get("selection", null)
|
||||
if raw is Dictionary:
|
||||
out = (raw as Dictionary).duplicate(true)
|
||||
if not (out.get("order", null) is Array) or (out.get("order", null) as Array).is_empty():
|
||||
out["order"] = DEFAULT_SEND_ORDER.duplicate()
|
||||
if not (out.get("achievable_sums", null) is Array) or (out.get("achievable_sums", null) as Array).is_empty():
|
||||
out["achievable_sums"] = DEFAULT_ACHIEVABLE_SUMS.duplicate()
|
||||
out["max_index"] = int(out.get("max_index", 26))
|
||||
var coeff: Dictionary = {}
|
||||
for key in ["text", "sound", "photo", "video"]:
|
||||
coeff[key] = int(type_def(key).get("minigame_coeff", DEFAULT_TYPES[key].get("minigame_coeff", 1)))
|
||||
out["coeff"] = coeff
|
||||
return out
|
||||
|
||||
|
||||
## Legacy-ступень для индекса (idx / 6 + 1), три ступени отправки.
|
||||
func minigame_tier(index: int) -> int:
|
||||
return clampi(1 + int(floor(float(maxi(0, index)) / 6.0)), 1, 3)
|
||||
|
||||
|
||||
# --- v2: атаки -------------------------------------------------------------
|
||||
|
||||
func attack_def(id: String) -> Dictionary:
|
||||
var kinds = attacks.get("kinds", null)
|
||||
if kinds is Dictionary:
|
||||
var def = (kinds as Dictionary).get(str(id), null)
|
||||
if def is Dictionary:
|
||||
return (def as Dictionary).duplicate(true)
|
||||
push_warning("Data: нет атаки '%s'" % str(id))
|
||||
return {}
|
||||
|
||||
|
||||
func attack_tier(hour: int) -> int:
|
||||
return clampi(1 + int(floor(float(maxi(1, int(hour)) - 2) / 2.0)), 1, 4)
|
||||
|
||||
|
||||
func attack_schedule() -> Array:
|
||||
var raw = attacks.get("schedule", null)
|
||||
if raw is Array:
|
||||
return (raw as Array).duplicate()
|
||||
return DEFAULT_ATTACKS.duplicate()
|
||||
|
||||
|
||||
# --- v2: сетка -------------------------------------------------------------
|
||||
|
||||
func grid_rules() -> Dictionary:
|
||||
return grid.duplicate(true)
|
||||
|
||||
|
||||
func shape_def(id: String) -> Dictionary:
|
||||
var shapes = grid.get("shapes", null)
|
||||
if shapes is Dictionary:
|
||||
var def = (shapes as Dictionary).get(str(id), null)
|
||||
if def is Dictionary:
|
||||
return (def as Dictionary).duplicate(true)
|
||||
push_warning("Data: нет фигуры '%s'" % str(id))
|
||||
return {}
|
||||
|
||||
|
||||
func shapes() -> Dictionary:
|
||||
var raw = grid.get("shapes", null)
|
||||
if raw is Dictionary:
|
||||
return (raw as Dictionary).duplicate(true)
|
||||
return {}
|
||||
|
||||
|
||||
func folder_dims_table() -> Array:
|
||||
var raw = grid.get("folder_dims", null)
|
||||
if raw is Array and not (raw as Array).is_empty():
|
||||
return (raw as Array).duplicate(true)
|
||||
return DEFAULT_GRID_DIMS.duplicate(true)
|
||||
|
||||
|
||||
# --- legacy shop API -------------------------------------------------------
|
||||
|
||||
func shop_item(id: String) -> Dictionary:
|
||||
for item in shop:
|
||||
if item is Dictionary and str(item.get("id", "")) == str(id):
|
||||
@@ -213,7 +446,6 @@ func shop_item(id: String) -> Dictionary:
|
||||
|
||||
|
||||
## Ключ открытия для позиции магазина. "" — позиция доступна всегда.
|
||||
## shop_sla продаётся вне прогрессии; поиск/щит открываются мета-наградами.
|
||||
func shop_unlock_key(id: String) -> String:
|
||||
match str(id):
|
||||
"shop_sla":
|
||||
@@ -228,7 +460,10 @@ func shop_unlock_key(id: String) -> String:
|
||||
|
||||
## Открыта ли позиция магазина по текущему состоянию Game (run_hour/meta).
|
||||
func shop_item_unlocked(item: Dictionary) -> bool:
|
||||
var key := shop_unlock_key(str(item.get("id", "")))
|
||||
var id := str(item.get("id", ""))
|
||||
if id.begins_with("lot_"):
|
||||
return true
|
||||
var key := shop_unlock_key(id)
|
||||
if key.is_empty():
|
||||
return true
|
||||
var game = _game_node()
|
||||
@@ -237,7 +472,7 @@ func shop_item_unlocked(item: Dictionary) -> bool:
|
||||
return true
|
||||
|
||||
|
||||
## Каталог магазина без закрытых позиций (shop_sla виден всегда).
|
||||
## Каталог магазина без закрытых позиций.
|
||||
func available_shop() -> Array:
|
||||
var out: Array = []
|
||||
for item in shop:
|
||||
@@ -261,6 +496,13 @@ func folder_name() -> String:
|
||||
return str(values[randi_range(0, values.size() - 1)])
|
||||
|
||||
|
||||
## Значение из constants с fallback (для других систем).
|
||||
func const_value(key: String, fallback_value = null):
|
||||
if constants.has(key):
|
||||
return constants[key]
|
||||
return fallback_value
|
||||
|
||||
|
||||
# --- служебное -------------------------------------------------------------
|
||||
|
||||
func _game_node():
|
||||
@@ -270,13 +512,31 @@ func _game_node():
|
||||
return null
|
||||
|
||||
|
||||
func _as_dict(raw) -> Dictionary:
|
||||
if raw is Dictionary:
|
||||
return (raw as Dictionary).duplicate(true)
|
||||
return {}
|
||||
|
||||
|
||||
func _load_unlocks(progression) -> Dictionary:
|
||||
if progression is Dictionary:
|
||||
var raw = progression.get("unlocks", null)
|
||||
if raw is Dictionary and not raw.is_empty():
|
||||
return raw.duplicate(true)
|
||||
push_warning("Data: balance.json → progression.unlocks отсутствует, используются дефолты")
|
||||
return DEFAULT_PROGRESSION["unlocks"].duplicate(true)
|
||||
return {
|
||||
"shop": {"run_hour": 2, "label": "Магазин"},
|
||||
"search": {"meta": "survive_hour_3", "label": "Поиск"},
|
||||
"shield": {"meta": "survive_hour_5", "label": "Щит"},
|
||||
"minigame_text": {"run_hour": 1},
|
||||
"minigame_sound": {"run_hour": 2},
|
||||
"minigame_photo": {"run_hour": 3},
|
||||
"minigame_video": {"run_hour": 4},
|
||||
"attack_junk": {"run_hour": 2},
|
||||
"attack_ddos": {"run_hour": 2},
|
||||
"attack_phishing": {"run_hour": 3},
|
||||
"attack_ransomware": {"run_hour": 4},
|
||||
}
|
||||
|
||||
|
||||
func _load_meta_rewards(progression) -> Dictionary:
|
||||
@@ -285,7 +545,12 @@ func _load_meta_rewards(progression) -> Dictionary:
|
||||
if raw is Dictionary and not raw.is_empty():
|
||||
return raw.duplicate(true)
|
||||
push_warning("Data: balance.json → progression.meta_rewards отсутствует, используются дефолты")
|
||||
return DEFAULT_PROGRESSION["meta_rewards"].duplicate(true)
|
||||
return {
|
||||
"survive_hour_3": {"min_hours": 3, "unlocks": ["search"]},
|
||||
"survive_hour_5": {"min_hours": 5, "unlocks": ["shield"]},
|
||||
"complete_run": {"min_hours": 9, "unlocks": ["minigame_advanced"]},
|
||||
}
|
||||
|
||||
|
||||
func _merge_types(raw) -> Dictionary:
|
||||
var out: Dictionary = DEFAULT_TYPES.duplicate(true)
|
||||
@@ -319,22 +584,81 @@ func _load_difficulty(raw) -> Array:
|
||||
return DEFAULT_DIFFICULTY.duplicate(true)
|
||||
|
||||
|
||||
func _load_shop(raw) -> Array:
|
||||
if raw is Array and not raw.is_empty():
|
||||
func _load_hours(raw) -> Array:
|
||||
if raw is Array and not (raw as Array).is_empty():
|
||||
var out: Array = []
|
||||
for row in raw:
|
||||
if row is Dictionary:
|
||||
out.append(row.duplicate(true))
|
||||
if not out.is_empty():
|
||||
out.sort_custom(func(a, b): return int(a.get("hour", 0)) < int(b.get("hour", 0)))
|
||||
return out
|
||||
push_warning("Data: balance.json → hours отсутствует, используются дефолты")
|
||||
var fallback: Array = []
|
||||
for i in range(9):
|
||||
fallback.append(_default_hour_row(i + 1))
|
||||
return fallback
|
||||
|
||||
|
||||
func _default_hour_row(hour: int) -> Dictionary:
|
||||
var idx := clampi(hour - 1, 0, 8)
|
||||
var type_keys := ["text", "sound", "photo", "video"]
|
||||
var weights: Dictionary = {}
|
||||
var req_weights: Dictionary = {}
|
||||
for i in range(type_keys.size()):
|
||||
weights[type_keys[i]] = DEFAULT_TYPE_WEIGHTS[idx][i]
|
||||
req_weights[type_keys[i]] = DEFAULT_REQUEST_TYPE_WEIGHTS[idx][i]
|
||||
return {
|
||||
"hour": maxi(1, hour),
|
||||
"duration": DEFAULT_HOUR_DURATION_BASE + DEFAULT_HOUR_DURATION_STEP * idx,
|
||||
"files": DEFAULT_HOUR_FILES[idx],
|
||||
"active_requests": DEFAULT_ACTIVE_REQUESTS[idx],
|
||||
"attacks": DEFAULT_ATTACKS[idx],
|
||||
"income_target": DEFAULT_INCOME_TARGET[idx],
|
||||
"send_interval": DEFAULT_SEND_INTERVAL[idx],
|
||||
"type_weights": weights,
|
||||
"request_mix": DEFAULT_REQUEST_MIX.duplicate(),
|
||||
"request_type_weights": req_weights,
|
||||
}
|
||||
|
||||
|
||||
func _load_shop_lots(raw) -> Array:
|
||||
if raw is Array and not (raw as Array).is_empty():
|
||||
var out: Array = []
|
||||
for item in raw:
|
||||
if item is Dictionary and not str(item.get("id", "")).is_empty():
|
||||
out.append(item.duplicate(true))
|
||||
if not out.is_empty():
|
||||
return out
|
||||
push_warning("Data: balance.json → shop отсутствует, используется дефолт")
|
||||
return DEFAULT_SHOP.duplicate(true)
|
||||
push_warning("Data: balance.json → shop отсутствует, используются %d дефолтных лотов" % DEFAULT_LOT_IDS.size())
|
||||
var fallback: Array = []
|
||||
for i in range(DEFAULT_LOT_IDS.size()):
|
||||
fallback.append({
|
||||
"id": DEFAULT_LOT_IDS[i],
|
||||
"name_ru": DEFAULT_LOT_NAMES[i],
|
||||
"desc_ru": "",
|
||||
"effect": DEFAULT_LOT_EFFECTS[i],
|
||||
"value": DEFAULT_LOT_VALUES[i],
|
||||
"max_level": DEFAULT_LOT_MAX[i],
|
||||
"prices": DEFAULT_LOT_PRICES[i].duplicate(),
|
||||
"level_names": [],
|
||||
"weight_by_hour": DEFAULT_LOT_WEIGHTS[i].duplicate(),
|
||||
})
|
||||
return fallback
|
||||
|
||||
|
||||
func _load_rules(raw) -> Dictionary:
|
||||
var out: Dictionary = DEFAULT_SHOP_RULES.duplicate(true)
|
||||
if raw is Dictionary and not raw.is_empty():
|
||||
for key in raw.keys():
|
||||
out[str(key)] = raw[key]
|
||||
return out
|
||||
push_warning("Data: balance.json → shop_rules отсутствует, используются дефолты")
|
||||
return out
|
||||
|
||||
|
||||
func _load_names(raw) -> Dictionary:
|
||||
if raw is Dictionary and not raw.is_empty():
|
||||
# Сохраняем все ключи файла (adjectives/nouns/organizations/folders/...),
|
||||
# а не только жёстко заданный набор.
|
||||
return raw.duplicate(true)
|
||||
push_warning("Data: names_ru.json пуст, используются пустые пулы имён")
|
||||
return {
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
extends Node
|
||||
## Game — верхнеуровневое состояние забега (autoload).
|
||||
##
|
||||
## v2-экономика: старт 50 $, SLA 60/60, 1 слот, сетка 5×4, канал 10,
|
||||
## буфер 6, таймер запроса 30 с. Уровни лотов хранятся в Game.upgrades
|
||||
## одновременно под id лота и под агрегированным ключом эффекта.
|
||||
##
|
||||
## Не знает о сценах и UI: только данные, переходы фаз и сигналы.
|
||||
## Фазы: menu → prep → hour → ... → results.
|
||||
|
||||
@@ -11,28 +15,48 @@ signal phase_changed(p)
|
||||
signal run_ended(reason)
|
||||
signal toast(text)
|
||||
|
||||
const START_MONEY := 150
|
||||
const START_SLA := 128.0
|
||||
const START_MONEY := 50
|
||||
const START_SLA := 60.0
|
||||
const HOURS_PER_RUN := 9
|
||||
const MAX_SLA := 200.0
|
||||
## Жёсткий кап SLA (main.gd показывает его в статус-баре). Рабочий кап —
|
||||
## sla_cap(): 60 + 40 * sla_level, не выше MAX_SLA.
|
||||
const MAX_SLA := 220.0
|
||||
|
||||
const REGISTRY_SCRIPT := preload("res://scripts/core/file_registry.gd")
|
||||
const SAVE_SCRIPT := preload("res://scripts/systems/save_system.gd")
|
||||
const SHOP_SCRIPT := preload("res://scripts/systems/shop.gd")
|
||||
|
||||
## Резерв, если Data ещё не готов (id → товар).
|
||||
const FALLBACK_SHOP := {
|
||||
"shop_slot3": {"id": "shop_slot3", "name_ru": "Третий слот", "price": 250, "max_level": 1},
|
||||
"shop_slot4": {"id": "shop_slot4", "name_ru": "Четвёртый слот", "price": 450, "max_level": 1},
|
||||
"shop_buffer": {"id": "shop_buffer", "name_ru": "Буфер +2", "price": 200, "max_level": 2},
|
||||
"shop_timer": {"id": "shop_timer", "name_ru": "Таймер пакетов +4 с", "price": 350, "max_level": 3},
|
||||
"shop_zone": {"id": "shop_zone", "name_ru": "Зона +5%", "price": 300, "max_level": 3},
|
||||
"shop_channel": {"id": "shop_channel", "name_ru": "Канал +25", "price": 250, "max_level": 4},
|
||||
"shop_section": {"id": "shop_section", "name_ru": "Новая секция", "price": 300, "max_level": 5},
|
||||
"shop_search": {"id": "shop_search", "name_ru": "Поиск", "price": 250, "max_level": 1},
|
||||
"shop_shield": {"id": "shop_shield", "name_ru": "Щит", "price": 400, "max_level": 1},
|
||||
"shop_sla": {"id": "shop_sla", "name_ru": "SLA +24", "price": 300, "max_level": 3},
|
||||
## effect → агрегированный ключ уровня в Game.upgrades.
|
||||
const LOT_EFFECT_KEYS := {
|
||||
"connection_slots": "slot_level",
|
||||
"transfer_speed": "speed_level",
|
||||
"sla_cap": "sla_level",
|
||||
"shield_charge": "shield_level",
|
||||
"money_mult": "money_level",
|
||||
"folder_cells": "folder_level",
|
||||
"channel_capacity": "channel_level",
|
||||
"request_timer": "timer_level",
|
||||
"udp_success": "udp_level",
|
||||
}
|
||||
|
||||
const FALLBACK_LOT_DIMS := [[5, 4], [6, 4], [7, 5], [8, 5], [8, 6], [9, 6]]
|
||||
|
||||
## Резерв, если Data ещё не готов (id → лот минимально необходимых полей).
|
||||
const FALLBACK_SHOP := {
|
||||
"lot_slots": {"id": "lot_slots", "name_ru": "Порты коммутатора", "effect": "connection_slots", "value": 1, "max_level": 4, "prices": [90, 200, 400, 700]},
|
||||
"lot_speed": {"id": "lot_speed", "name_ru": "Оптоволокно", "effect": "transfer_speed", "value": 0.10, "max_level": 6, "prices": [55, 75, 100, 135, 180, 245]},
|
||||
"lot_sla": {"id": "lot_sla", "name_ru": "Резервный ИБП", "effect": "sla_cap", "value": 40, "max_level": 4, "prices": [80, 160, 270, 430]},
|
||||
"lot_shield": {"id": "lot_shield", "name_ru": "Заземление", "effect": "shield_charge", "value": 1, "max_level": 3, "prices": [100, 220, 410]},
|
||||
"lot_money": {"id": "lot_money", "name_ru": "Корпоративный контракт", "effect": "money_mult", "value": 0.05, "max_level": 8, "prices": [70, 95, 130, 170, 220, 285, 365, 465]},
|
||||
"lot_folder": {"id": "lot_folder", "name_ru": "Складской стеллаж", "effect": "folder_cells", "value": 1, "max_level": 5, "prices": [50, 100, 180, 290, 440]},
|
||||
"lot_channel": {"id": "lot_channel", "name_ru": "Пропускная способность", "effect": "channel_capacity", "value": 6, "buffer_value": 1, "max_level": 6, "prices": [45, 70, 95, 130, 175, 230]},
|
||||
"lot_timer": {"id": "lot_timer", "name_ru": "Регламент обработки", "effect": "request_timer", "value": 7, "max_level": 5, "prices": [45, 75, 115, 165, 230]},
|
||||
"lot_udp": {"id": "lot_udp", "name_ru": "UDP-повторитель", "effect": "udp_success", "value": 0.10, "max_level": 4, "prices": [60, 120, 210, 350]},
|
||||
}
|
||||
|
||||
## Публичный алиас каталога-резерва (удобно тестам/UI).
|
||||
const LOT_DEFS := FALLBACK_SHOP
|
||||
|
||||
var money: int = START_MONEY
|
||||
var sla: float = START_SLA
|
||||
var hour: int = 1
|
||||
@@ -44,6 +68,19 @@ var upgrades: Dictionary = {}
|
||||
var meta_unlocks: Dictionary = {}
|
||||
## Награды, выданные в текущем забеге (для итогового экрана); чистится новым забегом.
|
||||
var run_meta_awards: Array[String] = []
|
||||
|
||||
## v2-магазин: 3 id, счётчик рероллов, час генерации, pity-история и замки.
|
||||
var shop_offers: Array[String] = []
|
||||
var shop_rerolls: int = 0
|
||||
var shop_offer_hour: int = 0
|
||||
var shop_last_seen: Dictionary = {}
|
||||
var shop_locked: Array[String] = []
|
||||
|
||||
## Заряды «Заземления»: восстанавливаются в начале часа.
|
||||
var shield_charges: int = 0
|
||||
## Час, за который SLA-реген уже выдан (идемпотентность вызова).
|
||||
var _sla_regen_hour: int = 0
|
||||
|
||||
## FileRegistry; без type hint, чтобы работать без прогретого class cache.
|
||||
var registry = null
|
||||
var rng_seed: int = 0
|
||||
@@ -78,6 +115,8 @@ func start_hour() -> bool:
|
||||
return false
|
||||
if registry == null:
|
||||
registry = _make_registry()
|
||||
regen_sla_for_hour()
|
||||
regen_shield_for_hour()
|
||||
phase = &"hour"
|
||||
phase_changed.emit(phase)
|
||||
toast.emit(_tr("msg.hour_start", "Час %d" % hour))
|
||||
@@ -98,10 +137,21 @@ func end_hour() -> bool:
|
||||
return true
|
||||
var finished := hour
|
||||
hour += 1
|
||||
# Старение файлов — только на переходе часа (не во время часа и не на победе).
|
||||
var expired := 0
|
||||
if registry != null and registry.has_method("age_files_between_hours"):
|
||||
expired = int(registry.age_files_between_hours())
|
||||
if expired > 0:
|
||||
stats["files_expired"] = int(stats.get("files_expired", 0)) + expired
|
||||
phase = &"prep"
|
||||
# Рероллы живут одну подготовку.
|
||||
shop_rerolls = 0
|
||||
shop_locked.clear()
|
||||
hour_changed.emit(hour)
|
||||
phase_changed.emit(phase)
|
||||
toast.emit(_tr("msg.hour_end", "Час %d завершён" % finished))
|
||||
if expired > 0:
|
||||
toast.emit(_tr("msg.files_expired", "АРХИВ: %d файлов списано") % expired)
|
||||
return true
|
||||
|
||||
|
||||
@@ -128,7 +178,7 @@ func add_money(delta: int, reason: String = "") -> void:
|
||||
|
||||
|
||||
func add_sla(delta: float, reason: String = "") -> void:
|
||||
sla = clampf(sla + float(delta), 0.0, MAX_SLA)
|
||||
sla = clampf(sla + float(delta), 0.0, sla_cap())
|
||||
if not reason.is_empty():
|
||||
stats["last_reason"] = reason
|
||||
sla_changed.emit(sla)
|
||||
@@ -136,10 +186,168 @@ func add_sla(delta: float, reason: String = "") -> void:
|
||||
_finish_run("sla")
|
||||
|
||||
|
||||
## +15 SLA в начале часа (не выше динамического капа). Возвращает новый SLA.
|
||||
## Идемпотентно в пределах одного часа: повторный вызов не добавляет SLA.
|
||||
func regen_sla_for_hour() -> float:
|
||||
if _sla_regen_hour == hour:
|
||||
return sla
|
||||
_sla_regen_hour = hour
|
||||
var regen := _const("sla_regen_per_hour", 15.0)
|
||||
sla = clampf(sla + regen, 0.0, sla_cap())
|
||||
sla_changed.emit(sla)
|
||||
return sla
|
||||
|
||||
|
||||
## Заряды щита восстанавливаются до максимума в начале часа.
|
||||
func regen_shield_for_hour() -> int:
|
||||
shield_charges = shield_max()
|
||||
return shield_charges
|
||||
|
||||
|
||||
func add_stat(key: String, delta: int = 1) -> void:
|
||||
stats[key] = int(stats.get(key, 0)) + int(delta)
|
||||
|
||||
|
||||
# --- v2: уровни лотов и производные статы ----------------------------------
|
||||
|
||||
## Уровень по id лота (lot_*) или по эффекту/агрегату
|
||||
## (connection_slots / slot_level / ...).
|
||||
func level(effect_or_lot: String) -> int:
|
||||
var key := str(effect_or_lot)
|
||||
if key.is_empty():
|
||||
return 0
|
||||
if key.begins_with("lot_"):
|
||||
if upgrades.has(key):
|
||||
return int(upgrades[key])
|
||||
var def := _lot_def(key)
|
||||
var effect := str(def.get("effect", ""))
|
||||
var agg := str(LOT_EFFECT_KEYS.get(effect, ""))
|
||||
if not agg.is_empty():
|
||||
return int(upgrades.get(agg, 0))
|
||||
return 0
|
||||
if upgrades.has(key):
|
||||
return int(upgrades[key])
|
||||
if LOT_EFFECT_KEYS.has(key):
|
||||
return int(upgrades.get(str(LOT_EFFECT_KEYS[key]), 0))
|
||||
return 0
|
||||
|
||||
|
||||
## Записать уровень лота в оба ключа и сразу применить мгновенные эффекты
|
||||
## (SLA-кап/щит). Остальные статы читаются геттерами динамически.
|
||||
func set_lot_level(id: String, new_level: int) -> int:
|
||||
var def := _lot_def(id)
|
||||
if def.is_empty():
|
||||
return 0
|
||||
if not (upgrades is Dictionary):
|
||||
upgrades = {}
|
||||
var max_level := maxi(1, int(def.get("max_level", 1)))
|
||||
var old_level := level(id)
|
||||
var lvl := clampi(int(new_level), 0, max_level)
|
||||
upgrades[id] = lvl
|
||||
var effect := str(def.get("effect", ""))
|
||||
var agg := str(LOT_EFFECT_KEYS.get(effect, ""))
|
||||
if not agg.is_empty():
|
||||
upgrades[agg] = lvl
|
||||
if effect == "sla_cap" and lvl > old_level:
|
||||
# Кап уже вырос (sla_cap() читает новый уровень), добавляем +40 сразу.
|
||||
var gain := _lot_value(def, 40.0) * float(lvl - old_level)
|
||||
sla = clampf(sla + gain, 0.0, sla_cap())
|
||||
sla_changed.emit(sla)
|
||||
elif effect == "shield_charge" and lvl > old_level:
|
||||
shield_charges = mini(shield_max(), shield_charges + (lvl - old_level))
|
||||
if effect == "folder_cells" and lvl != old_level and registry != null \
|
||||
and registry.has_method("resize_folders"):
|
||||
# Лот растит уже существующие папки, а не только будущие.
|
||||
registry.resize_folders(folder_dims())
|
||||
return lvl
|
||||
|
||||
|
||||
func sla_cap() -> float:
|
||||
var base := _const("sla_base", START_SLA)
|
||||
var step := _lot_effect_value("lot_sla", "value", _const("sla_per_level", 40.0))
|
||||
var hard := _const("sla_hard_cap", MAX_SLA)
|
||||
return clampf(base + step * float(level("sla_level")), base, hard)
|
||||
|
||||
|
||||
func money_mult() -> float:
|
||||
var step := _lot_effect_value("lot_money", "value", _const("money_mult_step", 0.05))
|
||||
return 1.0 + step * float(level("money_level"))
|
||||
|
||||
|
||||
## pow(0.90, speed_level) — множитель времени передачи (10% за уровень).
|
||||
func speed_mult() -> float:
|
||||
var cut := clampf(_lot_effect_value("lot_speed", "value", 0.10), 0.01, 0.9)
|
||||
return pow(1.0 - cut, float(maxi(0, level("speed_level"))))
|
||||
|
||||
|
||||
func folder_dims() -> Vector2i:
|
||||
var table := _folder_dims_table()
|
||||
if table.is_empty():
|
||||
table = FALLBACK_LOT_DIMS
|
||||
var idx := clampi(level("folder_level"), 0, table.size() - 1)
|
||||
var pair = table[idx]
|
||||
if pair is Array and (pair as Array).size() >= 2:
|
||||
return Vector2i(int(pair[0]), int(pair[1]))
|
||||
return Vector2i(5, 4)
|
||||
|
||||
|
||||
## Цена следующей папки: 150 + 100 за каждую сверх стартовых 2.
|
||||
## count < 0 → берём текущее число папок из реестра.
|
||||
func folder_price(count: int = -1) -> int:
|
||||
var base := int(_const("folder_price_start", 150.0))
|
||||
var step := int(_const("folder_price_step", 100.0))
|
||||
var start := int(_const("folders_start", 2.0))
|
||||
var total := int(count)
|
||||
if total < 0:
|
||||
total = 0
|
||||
if registry != null and registry.has_method("folder_count"):
|
||||
total = int(registry.folder_count())
|
||||
return base + step * maxi(0, total - start)
|
||||
|
||||
|
||||
func connection_slots() -> int:
|
||||
var base := int(_const("slots_start", 1.0))
|
||||
var cap := int(_const("slots_max", 5.0))
|
||||
return clampi(base + level("slot_level"), base, cap)
|
||||
|
||||
|
||||
func channel_capacity() -> float:
|
||||
var base := _const("channel_capacity", 10.0)
|
||||
var step := _lot_effect_value("lot_channel", "value", _const("channel_per_level", 6.0))
|
||||
return base + step * float(level("channel_level"))
|
||||
|
||||
|
||||
func buffer_limit() -> int:
|
||||
var base := int(_const("buffer_base", 6.0))
|
||||
var step := int(_lot_effect_value("lot_channel", "buffer_value", _const("buffer_per_level", 1.0)))
|
||||
var cap := int(_const("buffer_max", 12.0))
|
||||
return clampi(base + step * level("channel_level"), base, cap)
|
||||
|
||||
|
||||
func request_timer() -> float:
|
||||
var base := _const("request_timer", 30.0)
|
||||
var step := _lot_effect_value("lot_timer", "value", _const("request_timer_per_level", 7.0))
|
||||
var cap := _const("request_timer_max", 65.0)
|
||||
return clampf(base + step * float(level("timer_level")), base, cap)
|
||||
|
||||
|
||||
func packet_timer() -> float:
|
||||
return _const("packet_timer", 20.0)
|
||||
|
||||
|
||||
func shield_max() -> int:
|
||||
return maxi(0, level("shield_level"))
|
||||
|
||||
|
||||
## Шанс успешной UDP-отправки: 40% + 10% за уровень «UDP-повторителя»,
|
||||
## не выше 80%. Зеркалит Economy.udp_success_chance() для UI/контроллера.
|
||||
func udp_success_chance() -> float:
|
||||
var base := _const("udp_success_base", 0.40)
|
||||
var cap := _const("udp_success_max", 0.80)
|
||||
var step := _lot_effect_value("lot_udp", "value", 0.10)
|
||||
return clampf(base + step * float(level("udp_level")), base, cap)
|
||||
|
||||
|
||||
# --- прогрессия/открытия ---------------------------------------------------
|
||||
|
||||
## Открыта ли фича по data/balance.json → progression.unlocks.
|
||||
@@ -190,23 +398,23 @@ func award_meta_for_run(hours: int) -> Array[String]:
|
||||
return awarded
|
||||
|
||||
|
||||
# --- магазин (заглушка эффектов) ------------------------------------------
|
||||
# --- магазин (legacy-фасад) ------------------------------------------------
|
||||
|
||||
## Покупка проверяет деньги и max_level, но не применяет эффект (P1).
|
||||
## Покупка по id лота (legacy-путь; UI v2 использует Shop.buy).
|
||||
func buy_upgrade(id: String) -> bool:
|
||||
var item := _shop_item(id)
|
||||
if item.is_empty():
|
||||
return false
|
||||
var level := int(upgrades.get(id, 0))
|
||||
var lvl := level(id)
|
||||
var max_level := int(item.get("max_level", 1))
|
||||
if max_level > 0 and level >= max_level:
|
||||
if max_level > 0 and lvl >= max_level:
|
||||
return false
|
||||
var cost := _item_cost(item, level)
|
||||
var cost := _item_cost(item, lvl)
|
||||
if money < cost:
|
||||
toast.emit(_tr("msg.money_short", "Недостаточно денег"))
|
||||
return false
|
||||
money -= cost
|
||||
upgrades[id] = level + 1
|
||||
set_lot_level(id, lvl + 1)
|
||||
money_changed.emit(money)
|
||||
toast.emit(_tr("msg.upgrade_bought", "Куплено: %s") % _item_name(item, id))
|
||||
return true
|
||||
@@ -234,6 +442,13 @@ func save_game() -> bool:
|
||||
"rng_seed": rng_seed,
|
||||
"rng_state": rng_state,
|
||||
"registry": registry.to_dict(),
|
||||
"shop_offers": shop_offers.duplicate(),
|
||||
"shop_rerolls": shop_rerolls,
|
||||
"shop_offer_hour": shop_offer_hour,
|
||||
"shop_last_seen": shop_last_seen.duplicate(true),
|
||||
"shop_locked": shop_locked.duplicate(),
|
||||
"shield_charges": shield_charges,
|
||||
"sla_regen_hour": _sla_regen_hour,
|
||||
}
|
||||
return SAVE_SCRIPT.save(payload)
|
||||
|
||||
@@ -250,14 +465,31 @@ func load_game() -> bool:
|
||||
if not reg.from_dict(raw_registry):
|
||||
push_warning("Game: не удалось восстановить registry")
|
||||
return false
|
||||
# runtime-резервы под запросы не переживают загрузку: сами запросы не
|
||||
# сохраняются, поэтому file.required_by/attached_request и location
|
||||
# "attached" после load указывали бы на несуществующий запрос. Такой файл
|
||||
# нельзя ни переместить, ни удалить — он навсегда занимает клетку.
|
||||
for file in reg.files.values():
|
||||
file.required_by = ""
|
||||
file.attached_request = ""
|
||||
if String(file.location) == "attached":
|
||||
file.location = &"folder" if not String(file.folder_id).is_empty() else &"registry"
|
||||
# _repair_after_load уже разложил файл по папкам/клеткам. Если папки
|
||||
# (или её bookkeeping) нет, файл должен быть видимым в реестре, а не
|
||||
# числиться в несуществующем месте.
|
||||
if String(file.location) == "folder":
|
||||
var folder = reg.folders.get(String(file.folder_id), null)
|
||||
if folder == null or not folder.file_ids.has(file.id):
|
||||
file.folder_id = ""
|
||||
file.location = &"registry"
|
||||
registry = reg
|
||||
|
||||
upgrades = _dict_of(data.get("upgrades", {}))
|
||||
money = maxi(0, int(data.get("money", START_MONEY)))
|
||||
sla = clampf(float(data.get("sla", START_SLA)), 0.0, MAX_SLA)
|
||||
hour = clampi(int(data.get("hour", 1)), 1, HOURS_PER_RUN)
|
||||
phase = StringName(str(data.get("phase", "menu")))
|
||||
streak = maxi(0, int(data.get("streak", 0)))
|
||||
upgrades = _dict_of(data.get("upgrades", {}))
|
||||
sla = clampf(float(data.get("sla", START_SLA)), 0.0, sla_cap())
|
||||
meta_unlocks = _dict_of(data.get("meta", {}))
|
||||
run_meta_awards = _string_array_of(data.get("run_meta_awards", []))
|
||||
stats = _default_stats()
|
||||
@@ -266,6 +498,10 @@ func load_game() -> bool:
|
||||
for key in raw_stats.keys():
|
||||
stats[key] = raw_stats[key]
|
||||
rng_seed = int(data.get("rng_seed", 0))
|
||||
shield_charges = clampi(int(data.get("shield_charges", 0)), 0, shield_max())
|
||||
_sla_regen_hour = int(data.get("sla_regen_hour", 0))
|
||||
|
||||
_restore_shop_state(data)
|
||||
|
||||
var rng = _rng_node()
|
||||
if rng != null and rng.has_method("restore_state"):
|
||||
@@ -287,6 +523,13 @@ func _reset_fields(keep_meta: bool = true) -> void:
|
||||
streak = 0
|
||||
stats = _default_stats()
|
||||
upgrades = {}
|
||||
shop_offers.clear()
|
||||
shop_rerolls = 0
|
||||
shop_offer_hour = 0
|
||||
shop_last_seen.clear()
|
||||
shop_locked.clear()
|
||||
shield_charges = 0
|
||||
_sla_regen_hour = 0
|
||||
if not keep_meta:
|
||||
meta_unlocks = {}
|
||||
registry = _make_registry()
|
||||
@@ -319,27 +562,142 @@ func _default_stats() -> Dictionary:
|
||||
"files_received": 0,
|
||||
"attacks_blocked": 0,
|
||||
"failed": 0,
|
||||
"files_expired": 0,
|
||||
}
|
||||
|
||||
|
||||
## Восстановить офферы/рероллы/last_seen/замки; выкинуть неизвестные и
|
||||
## максимальные лоты; при необходимости догенерировать до 3.
|
||||
func _restore_shop_state(data: Dictionary) -> void:
|
||||
shop_offers.clear()
|
||||
for raw_id in _string_array_of(data.get("shop_offers", [])):
|
||||
var id := str(raw_id)
|
||||
if not id.begins_with("lot_"):
|
||||
continue
|
||||
var def := _lot_def(id)
|
||||
if def.is_empty():
|
||||
continue
|
||||
if level(id) >= maxi(1, int(def.get("max_level", 1))):
|
||||
continue
|
||||
if shop_offers.has(id):
|
||||
continue
|
||||
shop_offers.append(id)
|
||||
shop_rerolls = clampi(int(data.get("shop_rerolls", 0)), 0, _max_rerolls())
|
||||
shop_offer_hour = int(data.get("shop_offer_hour", 0))
|
||||
shop_last_seen = _int_dict_of(data.get("shop_last_seen", {}))
|
||||
shop_locked.clear()
|
||||
for raw_id in _string_array_of(data.get("shop_locked", [])):
|
||||
var id := str(raw_id)
|
||||
if shop_offers.has(id) and not shop_locked.has(id):
|
||||
shop_locked.append(id)
|
||||
|
||||
if hour <= 1:
|
||||
return
|
||||
var target := int(_shop_rules().get("offers", 3))
|
||||
if shop_offers.size() >= target and shop_offer_hour > 0:
|
||||
return
|
||||
var lots := _lot_defs()
|
||||
if lots.is_empty():
|
||||
return
|
||||
var selected := SHOP_SCRIPT.select_offers(
|
||||
hour, lots, upgrades, money, [], shop_last_seen,
|
||||
shop_offers.duplicate(), target)
|
||||
shop_offers.clear()
|
||||
for id in selected:
|
||||
shop_offers.append(str(id))
|
||||
if shop_offer_hour <= 0:
|
||||
shop_offer_hour = hour
|
||||
for id in shop_offers:
|
||||
shop_last_seen[str(id)] = hour
|
||||
|
||||
|
||||
func _lot_def(id: String) -> Dictionary:
|
||||
var data = _data_node()
|
||||
if data != null and data.has_method("lot_def"):
|
||||
var def = data.lot_def(id)
|
||||
if def is Dictionary and not (def as Dictionary).is_empty():
|
||||
return def
|
||||
var fallback = FALLBACK_SHOP.get(str(id), null)
|
||||
if fallback is Dictionary:
|
||||
return (fallback as Dictionary).duplicate(true)
|
||||
return {}
|
||||
|
||||
|
||||
func _lot_defs() -> Array:
|
||||
var data = _data_node()
|
||||
if data != null and data.has_method("lot_defs"):
|
||||
var lots = data.lot_defs()
|
||||
if lots is Array and not (lots as Array).is_empty():
|
||||
return lots
|
||||
var out: Array = []
|
||||
for id in FALLBACK_SHOP.keys():
|
||||
out.append((FALLBACK_SHOP[id] as Dictionary).duplicate(true))
|
||||
return out
|
||||
|
||||
|
||||
func _shop_rules() -> Dictionary:
|
||||
var data = _data_node()
|
||||
if data != null and data.has_method("shop_rules"):
|
||||
var rules = data.shop_rules()
|
||||
if rules is Dictionary and not (rules as Dictionary).is_empty():
|
||||
return rules
|
||||
return {"offers": 3, "reroll_costs": [40, 80, 120], "max_rerolls": 3}
|
||||
|
||||
|
||||
func _max_rerolls() -> int:
|
||||
return maxi(0, int(_shop_rules().get("max_rerolls", 3)))
|
||||
|
||||
|
||||
func _folder_dims_table() -> Array:
|
||||
var data = _data_node()
|
||||
if data != null and data.has_method("folder_dims_table"):
|
||||
var table = data.folder_dims_table()
|
||||
if table is Array and not (table as Array).is_empty():
|
||||
return table
|
||||
return FALLBACK_LOT_DIMS.duplicate(true)
|
||||
|
||||
|
||||
func _const(key: String, fallback_value: float) -> float:
|
||||
var data = _data_node()
|
||||
if data != null and data.get("constants") is Dictionary:
|
||||
var consts: Dictionary = data.constants
|
||||
if consts.has(key):
|
||||
return float(consts[key])
|
||||
return float(fallback_value)
|
||||
|
||||
|
||||
func _lot_value(def: Dictionary, fallback_value: float) -> float:
|
||||
if def.has("value"):
|
||||
return float(def.get("value"))
|
||||
return float(fallback_value)
|
||||
|
||||
|
||||
## Значение поля лота (source of truth для эффекта), fallback — константа.
|
||||
func _lot_effect_value(id: String, field: String, fallback_value: float) -> float:
|
||||
var def := _lot_def(id)
|
||||
if def.has(field):
|
||||
return float(def.get(field))
|
||||
return float(fallback_value)
|
||||
|
||||
|
||||
func _shop_item(id: String) -> Dictionary:
|
||||
var data = _data_node()
|
||||
if data != null and data.has_method("shop_item"):
|
||||
var item = data.shop_item(id)
|
||||
if item is Dictionary and not item.is_empty():
|
||||
if item is Dictionary and not (item as Dictionary).is_empty():
|
||||
return item
|
||||
var fallback = FALLBACK_SHOP.get(id, null)
|
||||
if fallback is Dictionary:
|
||||
return fallback.duplicate(true)
|
||||
return (fallback as Dictionary).duplicate(true)
|
||||
return {}
|
||||
|
||||
|
||||
func _item_cost(item: Dictionary, level: int) -> int:
|
||||
func _item_cost(item: Dictionary, lvl: int) -> int:
|
||||
var prices = item.get("prices", null)
|
||||
if prices is Array and not prices.is_empty():
|
||||
if prices is Array and not (prices as Array).is_empty():
|
||||
var arr: Array = prices
|
||||
if level < arr.size():
|
||||
return maxi(0, int(arr[level]))
|
||||
if lvl < arr.size():
|
||||
return maxi(0, int(arr[lvl]))
|
||||
return maxi(0, int(arr[arr.size() - 1]))
|
||||
if item.has("price"):
|
||||
return maxi(0, int(item.get("price", 0)))
|
||||
@@ -387,6 +745,14 @@ func _dict_of(value) -> Dictionary:
|
||||
return {}
|
||||
|
||||
|
||||
func _int_dict_of(value) -> Dictionary:
|
||||
var out: Dictionary = {}
|
||||
if value is Dictionary:
|
||||
for key in value.keys():
|
||||
out[str(key)] = int(value[key])
|
||||
return out
|
||||
|
||||
|
||||
func _string_array_of(value) -> Array[String]:
|
||||
var out: Array[String] = []
|
||||
if value is Array:
|
||||
|
||||
@@ -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,11 +31,26 @@ var type: String = "text"
|
||||
var folder_id: String = ""
|
||||
var location: StringName = &"folder"
|
||||
var attached_request: String = ""
|
||||
## Runtime-резерв: id запроса, для которого файл обязателен (не сохраняется).
|
||||
var required_by: String = ""
|
||||
var malicious_kind: String = ""
|
||||
var lock_until: float = 0.0
|
||||
var cooldown_until: float = 0.0
|
||||
var size_kb: int = 0
|
||||
var created_at: float = 0.0
|
||||
## Стабильный индекс иконки (0..FILE_ICON_COUNT-1); -1 — ещё не назначен.
|
||||
var icon_id: int = -1
|
||||
## Сколько часов файл провёл на складе (растёт в конце часа).
|
||||
var hours_lived: int = 0
|
||||
|
||||
## Форма на сетке (v2).
|
||||
var shape_id: String = "O1"
|
||||
## Производное от shape_id/rotation; в save не входит.
|
||||
var shape_cells: Array = []
|
||||
## Верхняя левая клетка bounding box в сетке папки/стола.
|
||||
var origin: Vector2i = Vector2i.ZERO
|
||||
## 0..3, по часовой стрелке.
|
||||
var rotation: int = 0
|
||||
|
||||
## Куда файл вернётся после снятия lock (служебное поле, в save пишется).
|
||||
var _location_before_lock: StringName = &"folder"
|
||||
@@ -76,8 +96,21 @@ func can_attach(now: float = -1.0) -> bool:
|
||||
return is_available(now)
|
||||
|
||||
|
||||
func to_dict() -> Dictionary:
|
||||
return {
|
||||
func is_on_table() -> bool:
|
||||
return location == &"table"
|
||||
|
||||
|
||||
func cell_count() -> int:
|
||||
if not shape_cells.is_empty():
|
||||
return shape_cells.size()
|
||||
return 1
|
||||
|
||||
|
||||
## Позиция не входит в метаданные save v2 (единый источник — folder.files /
|
||||
## table), поэтому по умолчанию include_placement=true нужен только для
|
||||
## прямого roundtrip FileItem.
|
||||
func to_dict(include_placement: bool = true) -> Dictionary:
|
||||
var out := {
|
||||
"id": id,
|
||||
"name": name,
|
||||
"ext": ext,
|
||||
@@ -90,8 +123,15 @@ func to_dict() -> Dictionary:
|
||||
"cooldown_until": cooldown_until,
|
||||
"size_kb": size_kb,
|
||||
"created_at": created_at,
|
||||
"icon_id": icon_id,
|
||||
"hours_lived": hours_lived,
|
||||
"location_before_lock": String(_location_before_lock),
|
||||
"shape_id": shape_id,
|
||||
}
|
||||
if include_placement:
|
||||
out["origin"] = [origin.x, origin.y]
|
||||
out["rotation"] = rotation
|
||||
return out
|
||||
|
||||
|
||||
static func from_dict(d: Dictionary):
|
||||
@@ -111,13 +151,39 @@ static func from_dict(d: Dictionary):
|
||||
f.cooldown_until = float(d.get("cooldown_until", 0.0))
|
||||
f.size_kb = int(d.get("size_kb", 0))
|
||||
f.created_at = float(d.get("created_at", 0.0))
|
||||
f.icon_id = int(d.get("icon_id", -1))
|
||||
# Старые сейвы без hours_lived: файл считается только что полученным.
|
||||
f.hours_lived = maxi(0, int(d.get("hours_lived", 0)))
|
||||
var prev := StringName(str(d.get("location_before_lock", "folder")))
|
||||
if prev != &"registry":
|
||||
if prev != &"registry" and prev != &"folder" and prev != &"table":
|
||||
prev = &"folder"
|
||||
f._location_before_lock = prev
|
||||
f.shape_id = str(d.get("shape_id", "O1"))
|
||||
if f.shape_id.is_empty():
|
||||
f.shape_id = "O1"
|
||||
f.rotation = posmod(int(d.get("rotation", 0)), 4)
|
||||
f.origin = _vec_from(d.get("origin", [0, 0]))
|
||||
var raw_cells = d.get("shape_cells", [])
|
||||
if raw_cells is Array:
|
||||
for cell in raw_cells:
|
||||
f.shape_cells.append(_vec_from(cell))
|
||||
# Инварианты: attached без запроса и failed/malicious не бывают прикреплены.
|
||||
if f.location == &"attached" and f.attached_request.is_empty():
|
||||
f.location = &"folder" if not f.folder_id.is_empty() else &"registry"
|
||||
if f.location == &"failed" or f.location == &"malicious":
|
||||
f.attached_request = ""
|
||||
if f.location == &"table":
|
||||
f.attached_request = ""
|
||||
return f
|
||||
|
||||
|
||||
static func _vec_from(value) -> Vector2i:
|
||||
if value is Vector2i:
|
||||
return value
|
||||
if value is Vector2:
|
||||
return Vector2i(int(value.x), int(value.y))
|
||||
if value is Array and value.size() >= 2:
|
||||
return Vector2i(int(value[0]), int(value[1]))
|
||||
if value is Dictionary:
|
||||
return Vector2i(int(value.get("x", 0)), int(value.get("y", 0)))
|
||||
return Vector2i.ZERO
|
||||
@@ -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
|
||||
@@ -27,6 +27,14 @@ var loading_folder_id: String = ""
|
||||
var is_attack: bool = false
|
||||
var attack_kind: String = ""
|
||||
var size_kb: int = 0
|
||||
## Стабильный индекс иконки (0..FILE_ICON_COUNT-1); -1 — ещё не назначен.
|
||||
var icon_id: int = -1
|
||||
|
||||
## Форма, которую пакет займёт в папке. Выбирается при спавне, чтобы показать
|
||||
## превью ещё до приёма. `shape_cells` — производное (не сохраняется).
|
||||
var shape_id: String = "O1"
|
||||
var rotation: int = 0
|
||||
var shape_cells: Array = []
|
||||
|
||||
## Служебные поля (не входят в минимальный контракт, но нужны директору).
|
||||
var lost_left: float = 0.0
|
||||
@@ -88,10 +96,13 @@ func to_dict() -> Dictionary:
|
||||
"is_attack": is_attack,
|
||||
"attack_kind": attack_kind,
|
||||
"size_kb": size_kb,
|
||||
"icon_id": icon_id,
|
||||
"lost_left": lost_left,
|
||||
"loading_duration": loading_duration,
|
||||
"order": order,
|
||||
"attack_id": attack_id,
|
||||
"shape_id": shape_id,
|
||||
"rotation": rotation,
|
||||
}
|
||||
|
||||
|
||||
@@ -111,10 +122,23 @@ static func from_dict(d: Dictionary):
|
||||
p.is_attack = bool(d.get("is_attack", false))
|
||||
p.attack_kind = str(d.get("attack_kind", ""))
|
||||
p.size_kb = int(d.get("size_kb", 0))
|
||||
p.icon_id = int(d.get("icon_id", -1))
|
||||
p.lost_left = float(d.get("lost_left", 0.0))
|
||||
p.loading_duration = float(d.get("loading_duration", 0.0))
|
||||
p.order = int(d.get("order", 0))
|
||||
p.attack_id = str(d.get("attack_id", ""))
|
||||
p.shape_id = str(d.get("shape_id", "O1"))
|
||||
if p.shape_id.is_empty():
|
||||
p.shape_id = "O1"
|
||||
p.rotation = posmod(int(d.get("rotation", 0)), 4)
|
||||
p.shape_cells = []
|
||||
var raw_cells = d.get("shape_cells", [])
|
||||
if raw_cells is Array:
|
||||
for cell in raw_cells:
|
||||
if cell is Vector2i:
|
||||
p.shape_cells.append(cell)
|
||||
elif cell is Array and cell.size() >= 2:
|
||||
p.shape_cells.append(Vector2i(int(cell[0]), int(cell[1])))
|
||||
return p
|
||||
|
||||
|
||||
|
||||
@@ -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,
|
||||
@@ -271,6 +296,7 @@ func hour_data() -> Dictionary:
|
||||
"money": int(game.money) if game != null else 0,
|
||||
"hour": int(game.hour) if game != null else 1,
|
||||
"time_left": 0.0,
|
||||
"overtime": false,
|
||||
}
|
||||
if hour_director == null:
|
||||
return {"packets": packets, "requests": requests, "slots": slots_data, "status": status}
|
||||
@@ -288,9 +314,17 @@ func hour_data() -> Dictionary:
|
||||
for id in hour_director.active_requests.keys():
|
||||
var request = hour_director.active_requests[id]
|
||||
var req_entry: Dictionary = request.to_dict()
|
||||
req_entry["ready"] = request.is_ready()
|
||||
req_entry["ready"] = bool(hour_director.is_request_ready(str(id)))
|
||||
req_entry["time_ratio"] = request.time_ratio()
|
||||
req_entry["missing"] = request.missing_count()
|
||||
req_entry["required_count"] = int(hour_director.request_required_count(str(id)))
|
||||
req_entry["missing"] = maxi(0, int(req_entry["required_count"]) - request.attached.size())
|
||||
req_entry["queued"] = bool(hour_director.is_request_queued(str(id)))
|
||||
# UDP: шанс успеха и остаток кулдауна повтора (для карточки/статуса).
|
||||
var is_udp := str(request.protocol) == "udp"
|
||||
req_entry["udp_chance"] = hour_director.udp_success_chance() if is_udp else 0.0
|
||||
req_entry["udp_retry_left"] = maxf(0.0,
|
||||
float(request.udp_retry_until) - float(hour_director.hour_elapsed)) if is_udp else 0.0
|
||||
req_entry["connection_id"] = str(request.connection_id)
|
||||
requests.append(req_entry)
|
||||
|
||||
var used := 0
|
||||
@@ -299,7 +333,20 @@ func hour_data() -> Dictionary:
|
||||
used = int(hour_director.slots.used_count())
|
||||
max_slots = int(hour_director.slots.max_slots())
|
||||
for slot in hour_director.slots.slots:
|
||||
slots_data.append(slot.to_dict())
|
||||
var entry: Dictionary = slot.to_dict()
|
||||
entry["kind"] = str(slot.kind)
|
||||
entry["detail"] = str(slot.detail)
|
||||
entry["can_disconnect"] = bool(slot.can_disconnect)
|
||||
entry["entity_name"] = str(slot.entity_name)
|
||||
if str(slot.kind) == "tcp_connection":
|
||||
# Данные запроса в слот не попадают: только статус передачи.
|
||||
var linked = hour_director.active_requests.get(str(slot.linked_request_id))
|
||||
entry["sending"] = linked != null and bool(linked.sending)
|
||||
var progress := 0.0
|
||||
if linked != null and bool(linked.sending) and float(linked.send_duration) > 0.0:
|
||||
progress = clampf(float(linked.send_progress) / float(linked.send_duration), 0.0, 1.0)
|
||||
entry["progress"] = progress
|
||||
slots_data.append(entry)
|
||||
|
||||
var channel_load := float(hour_director.channel_load())
|
||||
var capacity := float(ECONOMY.channel_capacity(game.upgrades if game != null else {}))
|
||||
@@ -307,9 +354,22 @@ func hour_data() -> Dictionary:
|
||||
if capacity > 0.0:
|
||||
percent = channel_load / capacity * 100.0
|
||||
|
||||
var sending_count := 0
|
||||
for request in hour_director.active_requests.values():
|
||||
if request != null and bool(request.sending):
|
||||
sending_count += 1
|
||||
var attacks_active := 0
|
||||
var attacks_tier := 0
|
||||
if hour_director.attack_director != null:
|
||||
if hour_director.attack_director.has_method("active_count"):
|
||||
attacks_active = int(hour_director.attack_director.active_count())
|
||||
if hour_director.attack_director.has_method("current_tier"):
|
||||
attacks_tier = int(hour_director.attack_director.current_tier())
|
||||
|
||||
status = {
|
||||
"tcp_used": used,
|
||||
"tcp_max": max_slots,
|
||||
"udp_chance": float(hour_director.udp_success_chance()),
|
||||
"channel": channel_load,
|
||||
"channel_factor": float(hour_director.channel_factor()),
|
||||
"channel_percent": percent,
|
||||
@@ -319,24 +379,40 @@ func hour_data() -> Dictionary:
|
||||
"money": int(game.money) if game != null else 0,
|
||||
"hour": int(game.hour) if game != null else int(hour_director.hour),
|
||||
"time_left": float(hour_director.time_left),
|
||||
"hour_total": float(hour_director.hour_seconds()),
|
||||
"overtime": hour_director.is_overtime(),
|
||||
"requests_active": int(hour_director.active_requests.size()),
|
||||
"request_limit": int(hour_director.active_request_limit),
|
||||
"attacks_active": attacks_active,
|
||||
"attacks_tier": attacks_tier,
|
||||
"sending_count": sending_count,
|
||||
"queued_count": int(hour_director.send_queue_count()),
|
||||
}
|
||||
return {"packets": packets, "requests": requests, "slots": slots_data, "status": status}
|
||||
|
||||
|
||||
## Каталог магазина с текущими уровнями/ценами — для модалки магазина.
|
||||
## 3 текущих предложения магазина (v2). Пустой массив в H1/во время часа.
|
||||
func shop_data() -> Array:
|
||||
_bind()
|
||||
var out: Array = []
|
||||
if shop == null:
|
||||
if shop == null or game == null:
|
||||
return out
|
||||
for item in shop.items():
|
||||
var id := str(item.get("id", ""))
|
||||
if int(game.hour) <= 1 and game.shop_offers.is_empty():
|
||||
return out
|
||||
for raw_id in shop.offers():
|
||||
var id := str(raw_id)
|
||||
var item: Dictionary = shop.get_item(id)
|
||||
if item.is_empty():
|
||||
continue
|
||||
var item_name := str(item.get("name_ru", ""))
|
||||
if item_name.is_empty():
|
||||
item_name = str(item.get("name", id))
|
||||
out.append({
|
||||
"id": id,
|
||||
"name": item_name,
|
||||
"desc": str(item.get("desc_ru", "")),
|
||||
"effect": str(item.get("effect", "")),
|
||||
"value": float(item.get("value", 0.0)),
|
||||
"price": int(shop.price(id)),
|
||||
"level": int(shop.level(id)),
|
||||
"max_level": int(shop.max_level(id)),
|
||||
@@ -371,16 +447,41 @@ func _on_hour_toast(text: String) -> void:
|
||||
toast.emit(text)
|
||||
|
||||
|
||||
func _on_show_connection(game_node) -> void:
|
||||
modal_open = true
|
||||
show_connection.emit(game_node)
|
||||
## HourDirector эмитит трио (request_id, hour, successes): ConnectionGame
|
||||
## создаёт контроллер, а Main только монтирует готовую ноду в modal_root.
|
||||
func _on_show_connection(request_id: String, hour: int, successes: int) -> void:
|
||||
var zone_level := 0
|
||||
if game != null and game.has_method("level"):
|
||||
# Бонус зоны соединения привязан к уровню лота «Регламент обработки».
|
||||
zone_level = maxi(0, int(game.level("timer_level")))
|
||||
var node = CONNECTION_GAME_SCRIPT.new()
|
||||
node.start(int(hour), int(successes), zone_level, str(request_id))
|
||||
# Через set_modal_open(), иначе HourDirector не узнаёт о модалке и час
|
||||
# продолжает спавнить пакеты/запросы под ней.
|
||||
set_modal_open(true)
|
||||
show_connection.emit(node)
|
||||
|
||||
|
||||
func _on_show_minigame(game_node) -> void:
|
||||
modal_open = true
|
||||
set_modal_open(true)
|
||||
show_minigame.emit(game_node)
|
||||
|
||||
|
||||
func _on_minigame_closed(req_id: String) -> void:
|
||||
set_modal_open(false)
|
||||
minigame_closed.emit(req_id)
|
||||
|
||||
|
||||
func _on_show_attack_minigame(game_node, kind: String) -> void:
|
||||
set_modal_open(true)
|
||||
show_attack_minigame.emit(game_node, kind)
|
||||
|
||||
|
||||
func _on_attack_resolved(kind: String, success: bool, tier: int) -> void:
|
||||
set_modal_open(false)
|
||||
attack_resolved.emit(kind, success, tier)
|
||||
|
||||
|
||||
func _on_slot_changed() -> void:
|
||||
slot_changed.emit()
|
||||
|
||||
@@ -391,6 +492,9 @@ func _finish_run(reason: String) -> void:
|
||||
_bind()
|
||||
if game == null:
|
||||
return
|
||||
# Кончился забег посреди часа (SLA=0): останавливаем тик/модалки до смены фазы.
|
||||
if hour_director != null and hour_director.has_method("stop_for_run_end"):
|
||||
hour_director.stop_for_run_end()
|
||||
phase = PHASE_RUN_RESULT
|
||||
paused = false
|
||||
modal_open = false
|
||||
@@ -431,6 +535,9 @@ func _bind() -> void:
|
||||
hour_director.toast.connect(_on_hour_toast)
|
||||
hour_director.show_connection.connect(_on_show_connection)
|
||||
hour_director.show_minigame.connect(_on_show_minigame)
|
||||
hour_director.minigame_closed.connect(_on_minigame_closed)
|
||||
hour_director.show_attack_minigame.connect(_on_show_attack_minigame)
|
||||
hour_director.attack_resolved.connect(_on_attack_resolved)
|
||||
hour_director.slot_changed.connect(_on_slot_changed)
|
||||
if game != null and not game.run_ended.is_connected(_on_game_run_ended):
|
||||
game.run_ended.connect(_on_game_run_ended)
|
||||
@@ -440,7 +547,10 @@ func _bind() -> void:
|
||||
func _reset_director_state() -> void:
|
||||
if hour_director == null:
|
||||
return
|
||||
hour_director.start_hour(1)
|
||||
hour_director.reset_carry()
|
||||
# Подготовка перед H1: seed-файл создаём, но активный запрос/резервации
|
||||
# не оставляем — иначе игрок не видит запрос, а файл уже занят.
|
||||
hour_director.reset_for_prep()
|
||||
hour_director.running = false
|
||||
hour_director.paused = false
|
||||
hour_director.set_modal_open(false)
|
||||
|
||||
@@ -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
|
||||