3008 lines
109 KiB
GDScript
3008 lines
109 KiB
GDScript
extends Control
|
||
## Главный экран PEAK HOUR 97 — собранный каркас в стиле shareware 90-х.
|
||
##
|
||
## Только UI и мок-данные: настоящей игровой логики здесь нет.
|
||
## Раскладка фиксированная, 1280×720, без контейнеров — всё на абсолютных
|
||
## координатах, чтобы пиксели совпадали с макетом.
|
||
|
||
const W := 1280.0
|
||
const H := 720.0
|
||
## Размер окна в оконном режиме (project.godot: window_width/height_override).
|
||
const DESKTOP_WINDOW_SIZE := Vector2i(1920, 1080)
|
||
|
||
## Preload вместо глобального имени: работает и без прогретого class cache.
|
||
const GAME_CONTROLLER := preload("res://scripts/game_controller.gd")
|
||
const RENAME_MODAL := preload("res://scripts/ui/rename_modal.gd")
|
||
|
||
var title_bar: WindowFrame
|
||
var menu_bar: Panel90
|
||
var workspace: Panel90
|
||
var tree_window: WindowFrame
|
||
var files_window: WindowFrame
|
||
var receive_window: WindowFrame
|
||
var send_window: WindowFrame
|
||
var slots_strip: Panel90
|
||
var status_bar: StatusBar
|
||
var toast_layer: ToastLayer
|
||
|
||
var tree: TreePanel
|
||
var grid: FileGrid
|
||
## Маскот-туториал (scripts/ui/tutorial_mascot.gd).
|
||
var mascot: TutorialMascot = null
|
||
## Кнопка «МАГАЗИН» в строке меню: видна только в подготовке и после открытия.
|
||
var _shop_btn: Button90 = null
|
||
## Кнопка «НАЧАТЬ ЧАС»: акцентная и только в подготовке.
|
||
var _start_btn: Button90 = null
|
||
## Кнопка «МЕНЮ» в верхней строке (единственная, без демо-пунктов).
|
||
var _menu_btn: Button90 = null
|
||
|
||
var packets: Array[PacketCard] = []
|
||
var requests: Array[RequestCard] = []
|
||
var slot_cells: Array[SlotCell] = []
|
||
var slot_count_label: Label
|
||
|
||
var _menus: Dictionary = {}
|
||
var _selected_file := ""
|
||
var _extra_folders: Array = []
|
||
|
||
## Интеграция с игровым циклом (см. scripts/game_controller.gd).
|
||
var controller = null
|
||
var modal_root: Control
|
||
var main_menu: MainMenu
|
||
var shop_modal: ShopModal
|
||
var hour_result_modal: HourResultModal
|
||
var run_result_modal: RunResultModal
|
||
var pause_modal: PauseModal
|
||
var settings_modal: SettingsModal
|
||
var confirm_modal: ConfirmModal
|
||
var rename_modal = null
|
||
var _rename_folder_id := ""
|
||
## Какое действие подтверждает confirm_modal: "" / "menu" (см. _confirm_to_menu).
|
||
var _confirm_action := ""
|
||
var _backdrop: Control = null
|
||
var _hour_ui_shown := false
|
||
var _hour_signals_connected := false
|
||
## Активная модалка мини-игры (соединение/отправка); вторая не открывается.
|
||
var _modal_game: Control = null
|
||
|
||
## Туториал первого часа показывается один раз за запуск.
|
||
var _tutorial_shown := false
|
||
var _tutorial_intro_shown := false
|
||
var _tutorial_after_shown := false
|
||
## Шаги первого часа: текст, номер шага и задержка от старта часа.
|
||
const TUTORIAL_STEPS := [
|
||
{"text": "Перетащи пакет в папку", "step": 1, "delay": 2.0},
|
||
{"text": "Собери файлы в запрос", "step": 2, "delay": 20.0},
|
||
{"text": "TCP: нажми Соединиться и попади три раза", "step": 3, "delay": 40.0},
|
||
]
|
||
const TUTORIAL_STEP_SECONDS := 7.0
|
||
|
||
## --- Автообновление подготовки по сигналам FileRegistry -------------------
|
||
## Реестр, к которому подключены сигналы (меняется после load/new_run).
|
||
var _bound_registry = null
|
||
var _registry_refresh_queued := false
|
||
## Последние применённые настройки: Game.start_new_run очищает stats,
|
||
## поэтому кэшируем их здесь и возвращаем в stats после нового забега.
|
||
var _settings_cache: Dictionary = {}
|
||
|
||
## --- Атаки часа: оверлей DDoS и щит-карантин -----------------------------
|
||
var _ddos_overlay = null
|
||
var _packet_shift := 0.0
|
||
var _shield_receive = null
|
||
var _shield_send = null
|
||
var _shield_holding := false
|
||
var _shield_hold_acc := 0.0
|
||
|
||
## Автопауза часа, пока открыты служебные модалки (настройки/подтверждение/имя).
|
||
var _auto_paused := false
|
||
## Проверка web-fullscreen после асинхронного запроса браузера.
|
||
var _fs_verify_token := 0
|
||
|
||
## --- Скролл списка ПРИЁМ (5–8 пакетов не влезают в 416×264) ---------------
|
||
var _recv_bar: ScrollBar90
|
||
var _recv_scroll := 0.0
|
||
const RECV_CARD_H := 48.0
|
||
const RECV_WHEEL_STEP := 24.0
|
||
|
||
## --- Карточки ОТПРАВКИ: 3–4 запроса не влезают в 252 px при шаге 64 -------
|
||
## Внутри RequestCard контент заканчивается на y=56, поэтому 60 px достаточно.
|
||
const REQ_CARD_H := 64.0
|
||
const REQ_CARD_H_COMPACT := 60.0
|
||
|
||
## --- Поиск по файлам (апгрейд shop_search) --------------------------------
|
||
var _search_edit: LineEdit
|
||
## Полный список файлов текущей папки; в grid уходит отфильтрованный.
|
||
var _current_files: Array = []
|
||
|
||
|
||
func _ready() -> void:
|
||
_build()
|
||
_connect_all()
|
||
controller = GAME_CONTROLLER.new()
|
||
controller.name = "GameController"
|
||
add_child(controller)
|
||
controller.phase_changed.connect(_on_phase)
|
||
_build_modals()
|
||
_apply_saved_settings()
|
||
_init_mascot()
|
||
_bind_registry_signals()
|
||
main_menu.set_has_save(controller.has_save())
|
||
main_menu.visible = true
|
||
_update_shop_visibility()
|
||
if _demo_requested():
|
||
call_deferred("_start_demo")
|
||
|
||
|
||
func _process(delta: float) -> void:
|
||
if controller == null:
|
||
return
|
||
if controller.phase == GAME_CONTROLLER.PHASE_HOUR:
|
||
_sync_modal_pause()
|
||
_tick_shield_hold(delta)
|
||
controller.tick(delta)
|
||
# tick мог завершить час — тогда экран уже переключился на отчёт.
|
||
if controller.phase == GAME_CONTROLLER.PHASE_HOUR:
|
||
_refresh_hour()
|
||
|
||
|
||
## Отпускание ЛКМ в любом месте снимает удержание щита.
|
||
func _input(event: InputEvent) -> void:
|
||
if not (event is InputEventMouseButton):
|
||
return
|
||
var mb := event as InputEventMouseButton
|
||
if mb.button_index == MOUSE_BUTTON_LEFT and not mb.pressed:
|
||
_on_shield_release()
|
||
|
||
|
||
## Esc: закрывает верхнюю модалку, ставит смену на паузу или игнорируется.
|
||
func _unhandled_input(event: InputEvent) -> void:
|
||
if not (event is InputEventKey):
|
||
return
|
||
var key := event as InputEventKey
|
||
if not key.pressed or key.echo or key.keycode != KEY_ESCAPE:
|
||
return
|
||
# Мини-игра сама обрабатывает Esc — не перехватываем.
|
||
if _modal_game != null:
|
||
return
|
||
if settings_modal != null and settings_modal.visible:
|
||
settings_modal.close()
|
||
_mark_input_handled()
|
||
return
|
||
if rename_modal != null and rename_modal.visible:
|
||
rename_modal.close()
|
||
_mark_input_handled()
|
||
return
|
||
if confirm_modal != null and confirm_modal.visible:
|
||
confirm_modal._answer(false)
|
||
_mark_input_handled()
|
||
return
|
||
if pause_modal != null and pause_modal.visible:
|
||
_resume_game()
|
||
_mark_input_handled()
|
||
return
|
||
if controller != null and controller.phase == GAME_CONTROLLER.PHASE_HOUR:
|
||
_pause_game()
|
||
_mark_input_handled()
|
||
return
|
||
# Главное меню и остальные экраны Esc не трогают.
|
||
|
||
|
||
func _mark_input_handled() -> void:
|
||
if is_inside_tree() and get_viewport() != null:
|
||
get_viewport().set_input_as_handled()
|
||
|
||
|
||
## Потеря фокуса окна во время часа — автоматическая пауза.
|
||
func _notification(what: int) -> void:
|
||
if what != NOTIFICATION_APPLICATION_FOCUS_OUT:
|
||
return
|
||
if controller == null or controller.phase != GAME_CONTROLLER.PHASE_HOUR:
|
||
return
|
||
if _modal_game != null:
|
||
return
|
||
if pause_modal != null and pause_modal.visible:
|
||
return
|
||
_pause_game()
|
||
|
||
|
||
# --------------------------------------------------------------------------
|
||
# Пауза, настройки, выход в меню
|
||
# --------------------------------------------------------------------------
|
||
|
||
func _pause_game() -> void:
|
||
if controller == null or controller.phase != GAME_CONTROLLER.PHASE_HOUR:
|
||
return
|
||
if _modal_game != null:
|
||
return
|
||
controller.set_paused(true)
|
||
if pause_modal != null:
|
||
pause_modal.open()
|
||
|
||
|
||
func _resume_game() -> void:
|
||
if controller != null:
|
||
controller.set_paused(false)
|
||
_auto_paused = false
|
||
if pause_modal != null:
|
||
pause_modal.visible = false
|
||
|
||
|
||
## Служебные окна часа (настройки, подтверждение, переименование) держат
|
||
## таймеры на паузе. Игровые окна ConnectionGame/MiniGame сюда не входят.
|
||
func _sync_modal_pause() -> void:
|
||
if controller == null or controller.phase != GAME_CONTROLLER.PHASE_HOUR:
|
||
_auto_paused = false
|
||
return
|
||
if _modal_game != null:
|
||
return
|
||
var blocking := false
|
||
if settings_modal != null and settings_modal.visible:
|
||
blocking = true
|
||
elif confirm_modal != null and confirm_modal.visible:
|
||
blocking = true
|
||
elif rename_modal != null and rename_modal.visible:
|
||
blocking = true
|
||
if blocking:
|
||
if not controller.paused:
|
||
controller.set_paused(true)
|
||
_auto_paused = true
|
||
elif _auto_paused and (pause_modal == null or not pause_modal.visible):
|
||
controller.set_paused(false)
|
||
_auto_paused = false
|
||
|
||
|
||
func _on_settings_closed() -> void:
|
||
_sync_modal_pause()
|
||
|
||
|
||
func _open_settings() -> void:
|
||
if settings_modal == null:
|
||
return
|
||
if mascot != null:
|
||
mascot.hide_message()
|
||
var calm := false
|
||
if _backdrop != null:
|
||
calm = bool(_backdrop.calm)
|
||
# Галочка полного экрана — применённая настройка. DisplayServer.window_get_mode()
|
||
# на Wayland/niri может ложно сообщать FULLSCREEN для окна 1920×1080, поэтому
|
||
# фактический режим окна используем только если настройка ещё не известна.
|
||
var full := bool(_settings_cache.get("fullscreen", false))
|
||
if not _settings_cache.has("fullscreen") and DisplayServer.get_name() != "headless":
|
||
var mode := DisplayServer.window_get_mode()
|
||
full = mode == DisplayServer.WINDOW_MODE_FULLSCREEN \
|
||
or mode == DisplayServer.WINDOW_MODE_EXCLUSIVE_FULLSCREEN
|
||
settings_modal.open({
|
||
"sfx": int(round(Audio.sfx_volume * 100.0)),
|
||
"music": int(round(Audio.music_volume * 100.0)),
|
||
"calm_bg": calm,
|
||
"fullscreen": full,
|
||
"vhs": main_menu.is_vhs_enabled() if main_menu != null \
|
||
else bool(_settings_cache.get("vhs", true)),
|
||
})
|
||
_sync_modal_pause()
|
||
|
||
|
||
## Применить настройки из SettingsModal и (по умолчанию) сохранить их.
|
||
func _apply_settings(values: Dictionary, persist: bool = true) -> void:
|
||
var sfx := clampi(int(values.get("sfx", int(round(Audio.sfx_volume * 100.0)))), 0, 100)
|
||
var music := clampi(int(values.get("music", int(round(Audio.music_volume * 100.0)))), 0, 100)
|
||
var calm := bool(values.get("calm_bg", true))
|
||
var full := bool(values.get("fullscreen", false))
|
||
var vhs := bool(values.get("vhs", true))
|
||
Audio.set_sfx_volume(float(sfx) / 100.0)
|
||
Audio.set_music_volume(float(music) / 100.0)
|
||
if _backdrop != null:
|
||
# Спокойный фон — та же подложка, но залитая бумагой вместо dither.
|
||
_backdrop.visible = true
|
||
_backdrop.calm = calm
|
||
_backdrop.queue_redraw()
|
||
if main_menu != null:
|
||
# VHS живёт только на главном меню; игровые панели и модалки не трогаем.
|
||
main_menu.set_vhs_enabled(vhs)
|
||
_apply_fullscreen(full)
|
||
_settings_cache = {"sfx": sfx, "music": music, "calm_bg": calm,
|
||
"fullscreen": full, "vhs": vhs}
|
||
if Game.stats != null:
|
||
Game.stats["settings"] = _settings_cache.duplicate(true)
|
||
# В главном меню забега ещё нет: настройки живут в Game.stats, но не должны
|
||
# создавать/перезаписывать save.json пустым состоянием холодного старта.
|
||
if persist and _settings_persist_allowed():
|
||
Game.save_game()
|
||
|
||
|
||
## Можно ли писать настройки в игровой save.
|
||
## Явный whitelist активного забега: только подготовка и час. В MENU,
|
||
## HOUR_RESULT и RUN_RESULT настройки применяются в памяти, но save.json
|
||
## не трогаем — иначе настройки из меню перезапишут отчёт/итоги забега.
|
||
func _settings_persist_allowed() -> bool:
|
||
if controller == null:
|
||
return false
|
||
return controller.phase == GAME_CONTROLLER.PHASE_PREP \
|
||
or controller.phase == GAME_CONTROLLER.PHASE_HOUR
|
||
|
||
|
||
## Загрузить сохранённые настройки (если забег их принёс из сейва).
|
||
func _apply_saved_settings() -> void:
|
||
var saved = Game.stats.get("settings", {})
|
||
if not (saved is Dictionary) or (saved as Dictionary).is_empty():
|
||
return
|
||
_settings_cache = (saved as Dictionary).duplicate(true)
|
||
_apply_settings(_settings_cache, false)
|
||
|
||
|
||
func _apply_fullscreen(on: bool) -> void:
|
||
if DisplayServer.get_name() == "headless":
|
||
return
|
||
# В web DisplayServer-режим может игнорироваться внутри iframe: просим
|
||
# Fullscreen API у браузера и проверяем результат асинхронно.
|
||
if OS.has_feature("web"):
|
||
_apply_fullscreen_web(on)
|
||
return
|
||
if not on:
|
||
# Wayland/niri после выхода из fullscreen тайлит окно (~936×979):
|
||
# сразу просим оконный размер, а затем подтверждаем его следующим
|
||
# кадром — tiling-WM применяет свой тайл уже после смены режима и
|
||
# игнорирует размер, запрошенный в том же кадре.
|
||
if DisplayServer.window_get_mode() != DisplayServer.WINDOW_MODE_WINDOWED:
|
||
DisplayServer.window_set_mode(DisplayServer.WINDOW_MODE_WINDOWED)
|
||
DisplayServer.window_set_size(DESKTOP_WINDOW_SIZE)
|
||
_confirm_desktop_size()
|
||
return
|
||
if DisplayServer.window_get_mode() == DisplayServer.WINDOW_MODE_FULLSCREEN:
|
||
return
|
||
DisplayServer.window_set_mode(DisplayServer.WINDOW_MODE_FULLSCREEN)
|
||
|
||
|
||
## Повторно просит оконный размер после того, как WM применил WINDOWED.
|
||
func _confirm_desktop_size() -> void:
|
||
await get_tree().process_frame
|
||
if DisplayServer.get_name() == "headless" or OS.has_feature("web"):
|
||
return
|
||
if DisplayServer.window_get_mode() == DisplayServer.WINDOW_MODE_WINDOWED \
|
||
and DisplayServer.window_get_size() != DESKTOP_WINDOW_SIZE:
|
||
DisplayServer.window_set_size(DESKTOP_WINDOW_SIZE)
|
||
|
||
|
||
func _apply_fullscreen_web(on: bool) -> void:
|
||
var script := ""
|
||
if on:
|
||
script = "(function(){try{var d=document.documentElement;"
|
||
script += "var p=d.requestFullscreen?d.requestFullscreen():Promise.reject();"
|
||
script += "if(p&&p.catch){p.catch(function(){});}return true;}catch(e){return false;}})()"
|
||
else:
|
||
script = "(function(){try{if(document.fullscreenElement){"
|
||
script += "var p=document.exitFullscreen();if(p&&p.catch){p.catch(function(){});}"
|
||
script += "}return true;}catch(e){return false;}})()"
|
||
var js_ok := false
|
||
if JavaScriptBridge.get_interface("document") != null:
|
||
js_ok = bool(JavaScriptBridge.eval(script, true))
|
||
if not js_ok:
|
||
# Fallback: платформенный режим окна (работает, если браузер разрешает).
|
||
var mode := DisplayServer.WINDOW_MODE_FULLSCREEN if on \
|
||
else DisplayServer.WINDOW_MODE_WINDOWED
|
||
DisplayServer.window_set_mode(mode)
|
||
return
|
||
# requestFullscreen асинхронен: подтвердим через кадр-другой.
|
||
_fs_verify_token += 1
|
||
_verify_web_fullscreen.call_deferred(on, _fs_verify_token)
|
||
|
||
|
||
func _verify_web_fullscreen(on: bool, token: int) -> void:
|
||
await get_tree().create_timer(0.25).timeout
|
||
if token != _fs_verify_token or not is_inside_tree():
|
||
return
|
||
var actual := false
|
||
if JavaScriptBridge.get_interface("document") != null:
|
||
actual = bool(JavaScriptBridge.eval("!!document.fullscreenElement", true))
|
||
if on and not actual:
|
||
_on_fullscreen_denied()
|
||
|
||
|
||
## Браузер/iframe не дал полный экран — снимаем ложную галочку и сообщаем.
|
||
func _on_fullscreen_denied() -> void:
|
||
_settings_cache["fullscreen"] = false
|
||
if Game.stats != null:
|
||
Game.stats["settings"] = _settings_cache.duplicate(true)
|
||
if settings_modal != null:
|
||
settings_modal.set_fullscreen(false)
|
||
_toast("БРАУЗЕР НЕ РАЗРЕШИЛ ПОЛНЫЙ ЭКРАН", "warn")
|
||
|
||
|
||
func _confirm_to_menu() -> void:
|
||
if confirm_modal == null:
|
||
return
|
||
_confirm_action = "menu"
|
||
var in_hour: bool = controller != null \
|
||
and controller.phase == GAME_CONTROLLER.PHASE_HOUR
|
||
var detail := "Текущий час будет прерван, прогресс подготовки сохранён."
|
||
if not in_hour:
|
||
detail = "Прогресс подготовки сохранён."
|
||
confirm_modal.ask("Выйти в главное меню?", detail, "Выйти", "Отмена", true)
|
||
_sync_modal_pause()
|
||
|
||
|
||
## Информационное окно «О программе» (пункт меню МЕНЮ).
|
||
func _show_about() -> void:
|
||
if confirm_modal == null:
|
||
return
|
||
_confirm_action = ""
|
||
confirm_modal.ask("PEAK HOUR 97",
|
||
"Диспетчер файлового обмена · версия 0.1.0 · Godot 4.7.2",
|
||
"OK", "Закрыть")
|
||
_sync_modal_pause()
|
||
|
||
|
||
func _to_menu() -> void:
|
||
if controller != null:
|
||
controller.save()
|
||
if pause_modal != null:
|
||
pause_modal.visible = false
|
||
if settings_modal != null:
|
||
settings_modal.visible = false
|
||
if confirm_modal != null:
|
||
confirm_modal.visible = false
|
||
_auto_paused = false
|
||
if controller != null:
|
||
# В час фаза остаётся hour: таймер держим на паузе до «Продолжить».
|
||
controller.set_paused(controller.phase == GAME_CONTROLLER.PHASE_HOUR)
|
||
if main_menu != null:
|
||
main_menu.set_has_save(true)
|
||
main_menu.visible = true
|
||
|
||
|
||
# --------------------------------------------------------------------------
|
||
# Интеграция: меню, магазин, подготовка
|
||
# --------------------------------------------------------------------------
|
||
|
||
func _build_modals() -> void:
|
||
modal_root = Control.new()
|
||
modal_root.name = "ModalRoot"
|
||
modal_root.mouse_filter = Control.MOUSE_FILTER_IGNORE
|
||
modal_root.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT)
|
||
add_child(modal_root)
|
||
|
||
main_menu = MainMenu.new()
|
||
main_menu.name = "MainMenu"
|
||
modal_root.add_child(main_menu)
|
||
main_menu.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT)
|
||
main_menu.new_run.connect(_start_new_run)
|
||
main_menu.continue_run.connect(_continue_run)
|
||
main_menu.settings.connect(_open_settings)
|
||
main_menu.about.connect(_show_about)
|
||
main_menu.visible = true
|
||
|
||
shop_modal = ShopModal.new()
|
||
shop_modal.name = "ShopModal"
|
||
modal_root.add_child(shop_modal)
|
||
shop_modal.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT)
|
||
shop_modal.buy_requested.connect(_on_shop_buy)
|
||
shop_modal.closed.connect(func() -> void: shop_modal.visible = false)
|
||
shop_modal.visible = false
|
||
|
||
hour_result_modal = HourResultModal.new()
|
||
hour_result_modal.name = "HourResultModal"
|
||
modal_root.add_child(hour_result_modal)
|
||
hour_result_modal.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT)
|
||
hour_result_modal.next_pressed.connect(_on_hour_result_next)
|
||
hour_result_modal.closed.connect(func() -> void: hour_result_modal.visible = false)
|
||
hour_result_modal.visible = false
|
||
|
||
run_result_modal = RunResultModal.new()
|
||
run_result_modal.name = "RunResultModal"
|
||
modal_root.add_child(run_result_modal)
|
||
run_result_modal.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT)
|
||
run_result_modal.new_run.connect(_on_run_result_new)
|
||
run_result_modal.to_menu.connect(_on_run_result_menu)
|
||
run_result_modal.closed.connect(func() -> void: run_result_modal.visible = false)
|
||
run_result_modal.visible = false
|
||
|
||
pause_modal = PauseModal.new()
|
||
pause_modal.name = "PauseModal"
|
||
modal_root.add_child(pause_modal)
|
||
pause_modal.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT)
|
||
pause_modal.resumed.connect(_resume_game)
|
||
pause_modal.settings_requested.connect(_open_settings)
|
||
pause_modal.menu_requested.connect(_confirm_to_menu)
|
||
pause_modal.visible = false
|
||
|
||
settings_modal = SettingsModal.new()
|
||
settings_modal.name = "SettingsModal"
|
||
modal_root.add_child(settings_modal)
|
||
settings_modal.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT)
|
||
settings_modal.settings_changed.connect(_apply_settings)
|
||
settings_modal.closed.connect(_on_settings_closed)
|
||
settings_modal.visible = false
|
||
|
||
confirm_modal = ConfirmModal.new()
|
||
confirm_modal.name = "ConfirmModal"
|
||
modal_root.add_child(confirm_modal)
|
||
confirm_modal.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT)
|
||
confirm_modal.answered.connect(func(yes: bool) -> void:
|
||
var action := _confirm_action
|
||
_confirm_action = ""
|
||
if yes:
|
||
if action == "menu":
|
||
_to_menu()
|
||
elif action.begins_with("destroy:"):
|
||
_destroy_folder_contents(action.substr("destroy:".length()))
|
||
_sync_modal_pause()
|
||
)
|
||
confirm_modal.visible = false
|
||
|
||
rename_modal = RENAME_MODAL.new()
|
||
rename_modal.name = "RenameModal"
|
||
modal_root.add_child(rename_modal)
|
||
rename_modal.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT)
|
||
rename_modal.rename_confirmed.connect(_on_rename_confirmed)
|
||
rename_modal.closed.connect(func() -> void:
|
||
rename_modal.visible = false
|
||
_sync_modal_pause())
|
||
rename_modal.visible = false
|
||
|
||
|
||
func _start_new_run() -> void:
|
||
if controller == null:
|
||
return
|
||
_hour_ui_shown = false
|
||
_tutorial_shown = false
|
||
_tutorial_intro_shown = false
|
||
_tutorial_after_shown = false
|
||
if mascot != null:
|
||
mascot.hide_message()
|
||
if hour_result_modal != null:
|
||
hour_result_modal.visible = false
|
||
if run_result_modal != null:
|
||
run_result_modal.visible = false
|
||
controller.new_run(int(Time.get_unix_time_from_system()))
|
||
# new_run сбрасывает stats — возвращаем настройки, чтобы они жили между забегами.
|
||
if not _settings_cache.is_empty() and Game.stats != null:
|
||
Game.stats["settings"] = _settings_cache.duplicate(true)
|
||
# Game.start_new_run создаёт новый FileRegistry — переподключаем сигналы.
|
||
_bind_registry_signals()
|
||
main_menu.visible = false
|
||
_refresh_prep()
|
||
|
||
|
||
## Публичный алиас для тестов/инструментов.
|
||
func start_new_run() -> void:
|
||
_start_new_run()
|
||
|
||
|
||
# --------------------------------------------------------------------------
|
||
# Demo-режим для скриншотов (?demo=1 в web-сборке)
|
||
# --------------------------------------------------------------------------
|
||
|
||
func _demo_requested() -> bool:
|
||
if not OS.has_feature("web"):
|
||
return false
|
||
var query := str(JavaScriptBridge.eval("window.location.search", true))
|
||
return query.find("demo=1") >= 0
|
||
|
||
|
||
## Чистая сцена часа для скриншотов: 3 пакета и 2 запроса, без атак.
|
||
func _start_demo() -> void:
|
||
if controller == null:
|
||
return
|
||
_start_new_run()
|
||
# Запросы спавнятся только при наличии файлов в реестре.
|
||
var prep: Dictionary = controller.prep_data()
|
||
var folder_id := ""
|
||
for sec in prep.get("sections", []):
|
||
var folders: Array = sec.get("folders", [])
|
||
if not folders.is_empty():
|
||
folder_id = str(folders[0].get("id", ""))
|
||
break
|
||
if Game.registry != null and not folder_id.is_empty():
|
||
var demo_files := [["договор_97", "text"], ["смета_демо", "text"],
|
||
["логотип_демо", "photo"]]
|
||
for pair in demo_files:
|
||
if Game.registry.find_by_name(str(pair[0])) == null:
|
||
Game.registry.create_file(str(pair[0]), str(pair[1]), folder_id)
|
||
_refresh_prep()
|
||
_on_start_hour()
|
||
var director = controller.hour_director
|
||
if director != null:
|
||
director.spawn_packet_now("text", "tcp")
|
||
director.spawn_packet_now("sound", "udp")
|
||
director.spawn_packet_now("photo", "tcp")
|
||
director.spawn_request_now()
|
||
director.spawn_request_now()
|
||
_refresh_hour()
|
||
_toast("DEMO", "success")
|
||
|
||
|
||
func _continue_run() -> void:
|
||
if controller == null:
|
||
return
|
||
if not controller.continue_run():
|
||
_toast("СОХРАНЕНИЕ НЕ НАЙДЕНО", "warn")
|
||
return
|
||
# load_game восстанавливает реестр — сигналы старого больше не нужны.
|
||
_bind_registry_signals()
|
||
_apply_saved_settings()
|
||
main_menu.visible = false
|
||
_refresh_prep()
|
||
|
||
|
||
func _refresh_prep() -> void:
|
||
if controller == null:
|
||
return
|
||
_sync_search_layout()
|
||
var d: Dictionary = controller.prep_data()
|
||
var sections: Array = d.get("sections", [])
|
||
tree.set_data(sections)
|
||
tree_window.set_title("ДИСКИ",
|
||
_plural_ru(_count_folders(sections), "ПАПКА", "ПАПКИ", "ПАПОК"))
|
||
|
||
var folder_id := ""
|
||
var folder_name := ""
|
||
var section_id := ""
|
||
for sec in sections:
|
||
var folders: Array = sec.get("folders", [])
|
||
if not folders.is_empty():
|
||
folder_id = str(folders[0].get("id", ""))
|
||
folder_name = str(folders[0].get("name", ""))
|
||
section_id = str(sec.get("id", ""))
|
||
break
|
||
|
||
_clear_hour_panels()
|
||
if folder_id.is_empty():
|
||
grid.set_folder("", "ПАПКА", "C: \\")
|
||
_current_files = []
|
||
grid.set_files([])
|
||
files_window.set_title("ФАЙЛЫ · ПУСТО",
|
||
_plural_ru(0, "ОБЪЕКТ", "ОБЪЕКТА", "ОБЪЕКТОВ"))
|
||
if workspace != null:
|
||
workspace.title_right = _plural_ru(0, "ОБЪЕКТ", "ОБЪЕКТА", "ОБЪЕКТОВ")
|
||
else:
|
||
tree.select_folder(folder_id)
|
||
_show_folder(folder_id, folder_name, section_id, d)
|
||
_update_status(d)
|
||
_update_shop_visibility()
|
||
_maybe_show_prep_tutorial()
|
||
|
||
|
||
func _count_folders(sections: Array) -> int:
|
||
var n := 0
|
||
for sec in sections:
|
||
n += (sec.get("folders", []) as Array).size()
|
||
return n
|
||
|
||
|
||
## Русская плюрализация счётчика: 1 папка / 2 папки / 5 папок.
|
||
func _plural_ru(n: int, one: String, few: String, many: String) -> String:
|
||
var a := absi(n) % 100
|
||
if a >= 11 and a <= 14:
|
||
return "%d %s" % [n, many]
|
||
match a % 10:
|
||
1: return "%d %s" % [n, one]
|
||
2, 3, 4: return "%d %s" % [n, few]
|
||
_: return "%d %s" % [n, many]
|
||
|
||
|
||
func _show_folder(folder_id: String, folder_name: String, section_id: String, d: Dictionary) -> void:
|
||
var letter := section_id.left(1).to_upper()
|
||
if letter.is_empty():
|
||
letter = "C"
|
||
var path := "%s: \\ %s" % [letter, folder_name.to_upper()]
|
||
# Смена папки — сбрасываем строку поиска, чтобы фильтр не «терял» файлы.
|
||
if _search_edit != null and grid != null and grid.folder_id != folder_id:
|
||
_search_edit.text = ""
|
||
grid.set_folder(folder_id, folder_name.to_upper(), path)
|
||
var files: Array = (d.get("files", {}) as Dictionary).get(folder_id, [])
|
||
_current_files = files.duplicate()
|
||
_apply_file_filter()
|
||
|
||
|
||
# --------------------------------------------------------------------------
|
||
# Маскот-туториал (БАЙТ)
|
||
# --------------------------------------------------------------------------
|
||
|
||
func _init_mascot() -> void:
|
||
mascot = TutorialMascot.new()
|
||
mascot.name = "TutorialMascot"
|
||
# Компактный БАЙТ: 64 px робот + облачко 210 px, в свободном низу сетки.
|
||
mascot.bubble_width = 210.0
|
||
mascot.set_robot_scale(0.67)
|
||
add_child(mascot)
|
||
mascot.position = Vector2(548, 492)
|
||
mascot.visible = false
|
||
# Маскот живёт под модалками, но над рабочим столом.
|
||
if modal_root != null:
|
||
move_child(mascot, modal_root.get_index())
|
||
|
||
|
||
## В headless маскот не показываем: он не должен влиять на тесты.
|
||
func _tutorial_enabled() -> bool:
|
||
return mascot != null and DisplayServer.get_name() != "headless"
|
||
|
||
|
||
func _maybe_show_prep_tutorial() -> void:
|
||
if not _tutorial_enabled() or controller == null:
|
||
return
|
||
if controller.phase != GAME_CONTROLLER.PHASE_PREP:
|
||
return
|
||
if not _tutorial_intro_shown:
|
||
_tutorial_intro_shown = true
|
||
mascot.set_step(0)
|
||
mascot.show_message("Привет, я БАЙТ. Час пик начинается с приёма пакетов.", 8.0)
|
||
return
|
||
var hours_done := int(Game.stats.get("hours_done", 0)) if Game.stats != null else 0
|
||
if _tutorial_after_shown or Game.hour <= 1 or hours_done <= 0:
|
||
return
|
||
_tutorial_after_shown = true
|
||
mascot.set_step(0)
|
||
if Game.feature_unlocked("shop"):
|
||
mascot.show_message("Магазин открылся — загляни в «МАГАЗИН» в строке меню.", 7.0)
|
||
else:
|
||
mascot.show_message("Дальше будет сложнее. Держи SLA выше нуля.", 7.0)
|
||
|
||
|
||
## Шаг туториала часа 1; вызывается по таймеру и не блокирует игру.
|
||
func _show_tutorial_step(index: int) -> void:
|
||
if not _tutorial_enabled() or controller == null:
|
||
return
|
||
if controller.phase != GAME_CONTROLLER.PHASE_HOUR or Game.hour != 1:
|
||
return
|
||
if index < 0 or index >= TUTORIAL_STEPS.size():
|
||
return
|
||
var step: Dictionary = TUTORIAL_STEPS[index]
|
||
mascot.set_step(int(step.get("step", index + 1)))
|
||
mascot.show_message(str(step.get("text", "")), TUTORIAL_STEP_SECONDS)
|
||
|
||
|
||
# --------------------------------------------------------------------------
|
||
# Автообновление подготовки по сигналам FileRegistry
|
||
# --------------------------------------------------------------------------
|
||
|
||
## Переподключить registry_changed к актуальному реестру Game.
|
||
func _bind_registry_signals() -> void:
|
||
var reg = Game.registry
|
||
if reg == _bound_registry:
|
||
return
|
||
if _bound_registry != null and is_instance_valid(_bound_registry):
|
||
if _bound_registry.has_signal("registry_changed") \
|
||
and _bound_registry.registry_changed.is_connected(_on_registry_changed):
|
||
_bound_registry.registry_changed.disconnect(_on_registry_changed)
|
||
_bound_registry = reg
|
||
if reg != null and reg.has_signal("registry_changed") \
|
||
and not reg.registry_changed.is_connected(_on_registry_changed):
|
||
reg.registry_changed.connect(_on_registry_changed)
|
||
|
||
|
||
func _on_registry_changed() -> void:
|
||
# Сигнал приходит пачками (file + folder + registry) — склеиваем в один кадр.
|
||
if _registry_refresh_queued:
|
||
return
|
||
_registry_refresh_queued = true
|
||
call_deferred("_apply_registry_refresh")
|
||
|
||
|
||
func _apply_registry_refresh() -> void:
|
||
_registry_refresh_queued = false
|
||
if controller == null or not is_inside_tree():
|
||
return
|
||
var d: Dictionary = controller.prep_data()
|
||
var folder_id := str(grid.folder_id) if grid != null else ""
|
||
var files_map: Dictionary = d.get("files", {})
|
||
if not folder_id.is_empty() and files_map.has(folder_id):
|
||
_refresh_tree_counts(d)
|
||
_refresh_current_folder(d, folder_id)
|
||
elif controller.phase == GAME_CONTROLLER.PHASE_PREP:
|
||
# Текущей папки больше нет — безопасно перерисовать подготовку.
|
||
_refresh_prep()
|
||
else:
|
||
_refresh_tree_counts(d)
|
||
|
||
|
||
## Обновить только сетку текущей папки, не пересоздавая дерево.
|
||
func _refresh_current_folder(d: Dictionary, folder_id: String) -> void:
|
||
var files_map: Dictionary = d.get("files", {})
|
||
_current_files = (files_map.get(folder_id, []) as Array).duplicate()
|
||
var meta := _folder_meta(d, folder_id)
|
||
if not meta.is_empty():
|
||
var folder_name := str(meta.get("name", ""))
|
||
var section_id := str(meta.get("section", ""))
|
||
var letter := section_id.left(1).to_upper()
|
||
if letter.is_empty():
|
||
letter = "C"
|
||
grid.set_folder(folder_id, folder_name.to_upper(),
|
||
"%s: \\ %s" % [letter, folder_name.to_upper()])
|
||
# set_files заново строит FileCard с актуальным state (attached/locked/…).
|
||
_apply_file_filter()
|
||
|
||
|
||
func _refresh_tree_counts(d: Dictionary) -> void:
|
||
if tree == null:
|
||
return
|
||
var sections: Array = d.get("sections", [])
|
||
var known: Dictionary = {}
|
||
for sec in sections:
|
||
for fol in sec.get("folders", []):
|
||
known[str(fol.get("id", ""))] = int(fol.get("count", 0))
|
||
var current: Dictionary = {}
|
||
for e in tree.get("_rows"):
|
||
var row = e["row"]
|
||
if row != null and not row.is_section:
|
||
current[str(row.folder_id)] = true
|
||
var same := known.size() == current.size()
|
||
if same:
|
||
for id in known.keys():
|
||
if not current.has(id):
|
||
same = false
|
||
break
|
||
if not same:
|
||
var selected := str(grid.folder_id) if grid != null else ""
|
||
tree.set_data(sections)
|
||
if known.has(selected):
|
||
tree.select_folder(selected)
|
||
for id in known.keys():
|
||
if same:
|
||
tree.update_count(str(id), int(known[id]))
|
||
if tree_window != null:
|
||
tree_window.set_title("ДИСКИ",
|
||
_plural_ru(_count_folders(sections), "ПАПКА", "ПАПКИ", "ПАПОК"))
|
||
|
||
|
||
# --------------------------------------------------------------------------
|
||
# Магазин: доступен только в подготовке и после открытия (час 2)
|
||
# --------------------------------------------------------------------------
|
||
|
||
func _update_shop_visibility() -> void:
|
||
if _shop_btn == null:
|
||
return
|
||
var in_prep: bool = controller != null \
|
||
and controller.phase == GAME_CONTROLLER.PHASE_PREP
|
||
_shop_btn.visible = in_prep and Game.feature_unlocked("shop")
|
||
if _start_btn != null:
|
||
_start_btn.visible = in_prep
|
||
|
||
|
||
# --------------------------------------------------------------------------
|
||
# Поиск по файлам (апгрейд shop_search)
|
||
# --------------------------------------------------------------------------
|
||
|
||
func _file_search_unlocked() -> bool:
|
||
return int(Game.upgrades.get("search_unlocked", 0)) > 0
|
||
|
||
|
||
## Показать/скрыть поле поиска и освободить под него полосу над сеткой.
|
||
func _sync_search_layout() -> void:
|
||
if _search_edit == null or grid == null or files_window == null \
|
||
or files_window.content == null:
|
||
return
|
||
var on := _file_search_unlocked()
|
||
_search_edit.visible = on
|
||
var top := 22.0 if on else 0.0
|
||
grid.position = Vector2(0, top)
|
||
grid.size = Vector2(files_window.content.size.x,
|
||
maxf(80.0, files_window.content.size.y - top))
|
||
|
||
|
||
func _on_search_changed(_text: String) -> void:
|
||
_apply_file_filter()
|
||
|
||
|
||
## Заголовок окна ФАЙЛЫ: при активном фильтре показываем «N ИЗ M».
|
||
func _update_files_title() -> void:
|
||
if files_window == null or grid == null:
|
||
return
|
||
var folder_label := _folder_name(grid.folder_id).to_upper()
|
||
if folder_label.is_empty():
|
||
folder_label = "ПАПКА"
|
||
var total := _current_files.size()
|
||
var shown := grid.get_cards().size()
|
||
var hint := "ПУСТО"
|
||
if total > 0:
|
||
if shown == total:
|
||
hint = _plural_ru(total, "ОБЪЕКТ", "ОБЪЕКТА", "ОБЪЕКТОВ")
|
||
else:
|
||
hint = "%d из %s" % [shown,
|
||
_plural_ru(total, "ОБЪЕКТ", "ОБЪЕКТА", "ОБЪЕКТОВ")]
|
||
files_window.set_title("ФАЙЛЫ · %s" % folder_label, hint)
|
||
if workspace != null:
|
||
workspace.title_right = _plural_ru(total, "ОБЪЕКТ", "ОБЪЕКТА", "ОБЪЕКТОВ")
|
||
|
||
|
||
## Фильтр текущей папки по подстроке имени (регистронезависимо).
|
||
func _apply_file_filter() -> void:
|
||
if grid == null:
|
||
return
|
||
var query := ""
|
||
if _search_edit != null and _search_edit.visible:
|
||
query = _search_edit.text.strip_edges().to_lower()
|
||
if query.is_empty():
|
||
grid.set_files(_current_files)
|
||
_update_files_title()
|
||
return
|
||
var filtered: Array = []
|
||
for f in _current_files:
|
||
var name := str(f.get("name", "")).to_lower()
|
||
var ext := str(f.get("ext", "")).to_lower()
|
||
if name.find(query) >= 0 or ("%s.%s" % [name, ext]).find(query) >= 0:
|
||
filtered.append(f)
|
||
grid.set_files(filtered)
|
||
_update_files_title()
|
||
|
||
|
||
func _folder_meta(d: Dictionary, folder_id: String) -> Dictionary:
|
||
for sec in d.get("sections", []):
|
||
for fol in sec.get("folders", []):
|
||
if str(fol.get("id", "")) == folder_id:
|
||
return {"name": str(fol.get("name", "")), "section": str(sec.get("id", ""))}
|
||
return {}
|
||
|
||
|
||
func _tcp_max(upgrades: Dictionary) -> int:
|
||
return 2 + int(upgrades.get("shop_slot3", 0)) + int(upgrades.get("shop_slot4", 0))
|
||
|
||
|
||
func _update_status(d: Dictionary) -> void:
|
||
var sla := int(round(float(d.get("sla", 0.0))))
|
||
var hour := int(d.get("hour", 1))
|
||
var tcp_max := _tcp_max(d.get("upgrades", {}))
|
||
var max_sla := maxi(1, int(Game.MAX_SLA))
|
||
status_bar.set_metric("money", "$ %d" % int(d.get("money", 0)))
|
||
status_bar.set_metric("sla", "%d / %d" % [sla, max_sla],
|
||
clampf(float(sla) / float(max_sla), 0.0, 1.0))
|
||
status_bar.set_metric("hour", "ЧАС %d" % hour, float(hour - 1) / 9.0)
|
||
status_bar.set_metric("tcp", "0 / %d" % tcp_max, 0.0)
|
||
var buffer_max := 4
|
||
if controller != null and controller.hour_director != null:
|
||
buffer_max = maxi(1, int(controller.hour_director.buffer_limit()))
|
||
status_bar.set_metric("buffer", "0 / %d" % buffer_max, 0.0)
|
||
status_bar.set_metric("channel", "0%", 0.0)
|
||
|
||
|
||
func _clear_hour_panels() -> void:
|
||
_ddos_overlay = null
|
||
_packet_shift = 0.0
|
||
_shield_holding = false
|
||
_shield_hold_acc = 0.0
|
||
for c in receive_window.content.get_children():
|
||
# Скроллбар ПРИЁМА живёт всё время — его не пересоздаём.
|
||
if c == _recv_bar:
|
||
continue
|
||
receive_window.content.remove_child(c)
|
||
c.queue_free()
|
||
for c in send_window.content.get_children():
|
||
send_window.content.remove_child(c)
|
||
c.queue_free()
|
||
packets.clear()
|
||
requests.clear()
|
||
_recv_scroll = 0.0
|
||
if _recv_bar != null:
|
||
var h := maxf(1.0, receive_window.content.size.y)
|
||
_recv_bar.set_metrics(h, h, 0.0)
|
||
_recv_bar.visible = false
|
||
for c in slot_cells:
|
||
if is_instance_valid(c):
|
||
if c.get_parent() != null:
|
||
c.get_parent().remove_child(c)
|
||
c.queue_free()
|
||
slot_cells.clear()
|
||
if slot_count_label != null:
|
||
slot_count_label.text = "0/%d" % _tcp_max(Game.upgrades)
|
||
receive_window.set_title(_padded_title("ПРИЁМ"),
|
||
_plural_ru(0, "ПАКЕТ", "ПАКЕТА", "ПАКЕТОВ"))
|
||
send_window.set_title(_padded_title("ОТПРАВКА"),
|
||
_plural_ru(0, "ЗАПРОС", "ЗАПРОСА", "ЗАПРОСОВ"))
|
||
_update_shield_visuals({})
|
||
|
||
|
||
func _on_phase(phase: StringName) -> void:
|
||
match phase:
|
||
GAME_CONTROLLER.PHASE_PREP:
|
||
_hour_ui_shown = false
|
||
if main_menu != null:
|
||
main_menu.visible = false
|
||
if hour_result_modal != null:
|
||
hour_result_modal.visible = false
|
||
_refresh_prep()
|
||
GAME_CONTROLLER.PHASE_HOUR:
|
||
_show_hour()
|
||
_maybe_show_tutorial()
|
||
GAME_CONTROLLER.PHASE_HOUR_RESULT:
|
||
# Час закончен: _process больше не тикает (phase != hour).
|
||
_hour_ui_shown = false
|
||
if mascot != null:
|
||
mascot.hide_message()
|
||
_open_hour_result()
|
||
GAME_CONTROLLER.PHASE_RUN_RESULT:
|
||
_hour_ui_shown = false
|
||
if mascot != null:
|
||
mascot.hide_message()
|
||
if hour_result_modal != null:
|
||
hour_result_modal.visible = false
|
||
_show_run_result()
|
||
_:
|
||
pass
|
||
_update_shop_visibility()
|
||
|
||
|
||
## Настоящий экран часа: карточки пакетов/запросов, слоты и статус.
|
||
func _show_hour() -> void:
|
||
if _hour_ui_shown:
|
||
_refresh_hour()
|
||
return
|
||
_hour_ui_shown = true
|
||
if mascot != null:
|
||
mascot.hide_message()
|
||
_clear_hour_panels()
|
||
_connect_hour_signals()
|
||
_refresh_hour()
|
||
_update_shop_visibility()
|
||
_toast("ЧАС %d НАЧАЛСЯ" % Game.hour, "success")
|
||
|
||
|
||
## Подсказки первого часа: три шага маскота с задержкой, один раз за запуск.
|
||
func _maybe_show_tutorial() -> void:
|
||
if _tutorial_shown or not _tutorial_enabled():
|
||
return
|
||
if Game.hour != 1:
|
||
return
|
||
_tutorial_shown = true
|
||
for i in TUTORIAL_STEPS.size():
|
||
var step: Dictionary = TUTORIAL_STEPS[i]
|
||
var timer := get_tree().create_timer(float(step.get("delay", 0.0)))
|
||
timer.timeout.connect(_show_tutorial_step.bind(i))
|
||
|
||
|
||
## Сигналы контроллера, которые нужны UI часа (подключаются один раз).
|
||
func _connect_hour_signals() -> void:
|
||
if controller == null or _hour_signals_connected:
|
||
return
|
||
_hour_signals_connected = true
|
||
controller.hour_finished.connect(_on_hour_finished)
|
||
controller.run_finished.connect(_on_run_finished)
|
||
controller.toast.connect(func(text: String) -> void: _toast(text, "info"))
|
||
controller.show_connection.connect(_on_show_connection)
|
||
controller.show_minigame.connect(_on_show_minigame)
|
||
var director = controller.hour_director
|
||
if director != null:
|
||
director.attack_added.connect(_on_attack_added_ui)
|
||
director.attack_changed.connect(_on_attack_changed_ui)
|
||
director.attack_removed.connect(_on_attack_removed_ui)
|
||
|
||
|
||
func _on_attack_added_ui(_id: String) -> void:
|
||
_refresh_attacks()
|
||
_refresh_hour()
|
||
|
||
|
||
func _on_attack_changed_ui(_id: String) -> void:
|
||
_refresh_attacks()
|
||
_refresh_hour()
|
||
|
||
|
||
func _on_attack_removed_ui(_id: String) -> void:
|
||
_refresh_attacks()
|
||
_refresh_hour()
|
||
|
||
|
||
## HourDirector уже вызвал start() — здесь только показываем готовую модалку.
|
||
func _on_show_connection(game_node) -> void:
|
||
_show_modal_game(game_node)
|
||
|
||
|
||
## Отправка: HourDirector уже вызвал start() и создал MiniGame.
|
||
func _on_show_minigame(game_node) -> void:
|
||
_show_modal_game(game_node)
|
||
|
||
|
||
func _show_modal_game(game_node) -> void:
|
||
if game_node == null or modal_root == null:
|
||
return
|
||
# Вторую модалку не показываем: директор сам блокирует параллельный старт,
|
||
# это защита от двойного сигнала. Но если старая уже завершилась (running
|
||
# сброшен до emit finished, например UDP-перезапуск мини-игры) — заменяем.
|
||
if _modal_game != null and _modal_game != game_node:
|
||
if is_instance_valid(_modal_game) and not _modal_game.is_queued_for_deletion():
|
||
var old_running = _modal_game.get("running")
|
||
if old_running == null or bool(old_running):
|
||
return
|
||
_modal_game = null
|
||
_modal_game = game_node
|
||
if game_node.get_parent() == null:
|
||
modal_root.add_child(game_node)
|
||
elif game_node.get_parent() != modal_root:
|
||
game_node.reparent(modal_root)
|
||
if game_node is Control:
|
||
# Окна мини-игр фиксированного размера — ставим по центру, не растягиваем.
|
||
(game_node as Control).set_anchors_and_offsets_preset(Control.PRESET_CENTER)
|
||
if game_node.has_signal("finished"):
|
||
game_node.finished.connect(_on_modal_game_finished.bind(game_node))
|
||
|
||
|
||
## Общий обработчик finished у ConnectionGame (2 арг) и MiniGame (1 арг).
|
||
## Третий параметр приходит из bind(); для мини-игры он окажется во втором.
|
||
func _on_modal_game_finished(_a = null, _b = null, game_node = null) -> void:
|
||
if game_node == null:
|
||
if _b is Node:
|
||
game_node = _b
|
||
elif _a is Node:
|
||
game_node = _a
|
||
if game_node != null and is_instance_valid(game_node) and not game_node.is_queued_for_deletion():
|
||
game_node.queue_free()
|
||
if game_node == null or _modal_game == game_node:
|
||
_modal_game = null
|
||
if controller != null:
|
||
controller.modal_open = false
|
||
# TCP-запрос подключён: если файлы уже собраны, отправка стартует сразу,
|
||
# без дополнительного клика по карточке (start_send сам защищён от повтора).
|
||
if _b is String and controller != null and controller.hour_director != null:
|
||
var req_id := str(_b)
|
||
if controller.hour_director.active_requests.has(req_id):
|
||
_maybe_start_send(req_id)
|
||
_refresh_hour()
|
||
|
||
|
||
# --------------------------------------------------------------------------
|
||
# Час: синхронизация карточек со снимком контроллера
|
||
# --------------------------------------------------------------------------
|
||
|
||
func _refresh_hour() -> void:
|
||
if controller == null:
|
||
return
|
||
var d: Dictionary = controller.hour_data()
|
||
var packet_data: Array = d.get("packets", [])
|
||
var request_data: Array = d.get("requests", [])
|
||
var slots_data: Array = d.get("slots", [])
|
||
var status: Dictionary = d.get("status", {})
|
||
|
||
_refresh_attacks()
|
||
_refresh_packets(packet_data)
|
||
_refresh_requests(request_data)
|
||
_refresh_slots(slots_data, packet_data, request_data)
|
||
_refresh_hour_status(status)
|
||
_refresh_hour_titles(packet_data, request_data, status)
|
||
|
||
|
||
func _refresh_hour_titles(packets_data: Array, requests_data: Array, _status: Dictionary) -> void:
|
||
receive_window.set_title(_padded_title("ПРИЁМ"),
|
||
_plural_ru(packets_data.size(), "ПАКЕТ", "ПАКЕТА", "ПАКЕТОВ"))
|
||
send_window.set_title(_padded_title("ОТПРАВКА"),
|
||
_plural_ru(requests_data.size(), "ЗАПРОС", "ЗАПРОСА", "ЗАПРОСОВ"))
|
||
|
||
|
||
# --------------------------------------------------------------------------
|
||
# Атаки: оверлей DDoS и щит-карантин
|
||
# --------------------------------------------------------------------------
|
||
|
||
## Активная DDoS-атака (Dictionary) или {}.
|
||
func _active_ddos() -> Dictionary:
|
||
if controller == null or controller.hour_director == null:
|
||
return {}
|
||
var director = controller.hour_director
|
||
if director.attack_director == null:
|
||
return {}
|
||
for id in director.attack_director.active_attacks:
|
||
var data: Dictionary = director.attack_director.active_attacks[id]
|
||
if str(data.get("kind", "")) == "ddos":
|
||
return data
|
||
return {}
|
||
|
||
|
||
func _reposition_packet_cards() -> void:
|
||
var card_w := 392.0
|
||
if receive_window != null and receive_window.content != null:
|
||
# Оставляем 14 px справа под скроллбар + 6 px отступа слева.
|
||
card_w = maxf(120.0, receive_window.content.size.x - 20.0)
|
||
for i in packets.size():
|
||
if is_instance_valid(packets[i]):
|
||
packets[i].position = Vector2(6,
|
||
1 + _packet_shift + i * RECV_CARD_H - _recv_scroll)
|
||
packets[i].size = Vector2(card_w, 44)
|
||
|
||
|
||
## Высота списка пакетов: сдвиг DDoS-плашки + по 48 px на карточку.
|
||
func _recv_content_height(count: int) -> float:
|
||
return _packet_shift + float(maxi(0, count)) * RECV_CARD_H
|
||
|
||
|
||
## Пересчитать метрики скроллбара и вернуть карточки в актуальные позиции.
|
||
func _sync_recv_scroll(count: int = -1) -> void:
|
||
if _recv_bar == null or receive_window == null or receive_window.content == null:
|
||
return
|
||
var n := packets.size() if count < 0 else count
|
||
var view_h := maxf(1.0, receive_window.content.size.y)
|
||
var content_h := _recv_content_height(n)
|
||
_recv_bar.position = Vector2(
|
||
maxf(0.0, receive_window.content.size.x - Theme90.SCROLLBAR), 0.0)
|
||
_recv_bar.size = Vector2(Theme90.SCROLLBAR, view_h)
|
||
_recv_bar.visible = content_h > view_h
|
||
_recv_scroll = clampf(_recv_scroll, 0.0, maxf(0.0, content_h - view_h))
|
||
_recv_bar.set_metrics(view_h, maxf(view_h, content_h), _recv_scroll)
|
||
_reposition_packet_cards()
|
||
|
||
|
||
func _on_recv_scroll(value: float) -> void:
|
||
_recv_scroll = maxf(0.0, value)
|
||
_reposition_packet_cards()
|
||
|
||
|
||
func _nudge_recv_scroll(delta: float) -> void:
|
||
if _recv_bar == null:
|
||
return
|
||
var next := clampf(_recv_scroll + delta, 0.0, _recv_bar._limit())
|
||
_recv_scroll = next
|
||
_recv_bar.value = next
|
||
_reposition_packet_cards()
|
||
|
||
|
||
## Колесо мыши над ПРИЁМОМ: карточки пропускают событие вверх к content.
|
||
func _on_recv_gui_input(event: InputEvent) -> void:
|
||
if not (event is InputEventMouseButton):
|
||
return
|
||
var mb := event as InputEventMouseButton
|
||
if not mb.pressed:
|
||
return
|
||
var step := 0.0
|
||
if mb.button_index == MOUSE_BUTTON_WHEEL_UP:
|
||
step = -RECV_WHEEL_STEP
|
||
elif mb.button_index == MOUSE_BUTTON_WHEEL_DOWN:
|
||
step = RECV_WHEEL_STEP
|
||
if is_zero_approx(step):
|
||
return
|
||
_nudge_recv_scroll(step)
|
||
if receive_window != null and receive_window.content != null:
|
||
receive_window.content.accept_event()
|
||
|
||
|
||
## Оверлей DDoS в ПРИЁМЕ (плашка + красные мини-карточки) и состояние щитов.
|
||
func _refresh_attacks() -> void:
|
||
var ddos := _active_ddos()
|
||
if ddos.is_empty():
|
||
if _ddos_overlay != null and is_instance_valid(_ddos_overlay):
|
||
var parent = _ddos_overlay.get_parent()
|
||
if parent != null:
|
||
parent.remove_child(_ddos_overlay)
|
||
_ddos_overlay.queue_free()
|
||
_ddos_overlay = null
|
||
_packet_shift = 0.0
|
||
_shield_holding = false
|
||
_update_shield_visuals({})
|
||
_sync_recv_scroll()
|
||
return
|
||
|
||
if _ddos_overlay == null or not is_instance_valid(_ddos_overlay):
|
||
_ddos_overlay = DdosOverlay.new()
|
||
_ddos_overlay.name = "DdosOverlay"
|
||
receive_window.content.add_child(_ddos_overlay)
|
||
receive_window.content.move_child(_ddos_overlay, 0)
|
||
_ddos_overlay.position = Vector2(6, 1)
|
||
_ddos_overlay.size = Vector2(
|
||
maxf(120.0, receive_window.content.size.x - 20.0), 34)
|
||
_packet_shift = 36.0
|
||
_ddos_overlay.cards = int(ddos.get("cards", 10))
|
||
_ddos_overlay.hold = float(ddos.get("shield_hold", 0.0))
|
||
_ddos_overlay.needed = maxf(0.1, float(ddos.get("shield_needed", 1.5)))
|
||
_ddos_overlay.queue_redraw()
|
||
_update_shield_visuals(ddos)
|
||
_sync_recv_scroll()
|
||
|
||
|
||
func _update_shield_visuals(ddos: Dictionary) -> void:
|
||
var active := not ddos.is_empty()
|
||
var progress := 0.0
|
||
var needed := 1.5
|
||
if active:
|
||
needed = maxf(0.1, float(ddos.get("shield_needed", 1.5)))
|
||
progress = clampf(float(ddos.get("shield_hold", 0.0)) / needed, 0.0, 1.0)
|
||
for shield in [_shield_receive, _shield_send]:
|
||
if shield == null or not is_instance_valid(shield):
|
||
continue
|
||
shield.active = active
|
||
shield.progress = progress
|
||
shield.holding = _shield_holding
|
||
shield.tooltip_text = "ЩИТ · УДЕРЖИВАЙТЕ ПРОТИВ DDoS" if active \
|
||
else "ЩИТ · ПЕРЕТАЩИТЕ СЮДА УГРОЗУ"
|
||
shield.queue_redraw()
|
||
|
||
|
||
func _tick_shield_hold(delta: float) -> void:
|
||
if not _shield_holding:
|
||
return
|
||
if controller == null or controller.hour_director == null:
|
||
return
|
||
var ddos := _active_ddos()
|
||
if ddos.is_empty():
|
||
_shield_holding = false
|
||
_update_shield_visuals({})
|
||
return
|
||
var step := minf(maxf(delta, 0.0), 0.1)
|
||
if step <= 0.0:
|
||
return
|
||
_shield_hold_acc += step
|
||
controller.hour_director.hold_shield(step)
|
||
_update_shield_visuals(_active_ddos())
|
||
|
||
|
||
func _on_shield_press(_target: String = "") -> void:
|
||
if controller == null or controller.hour_director == null:
|
||
return
|
||
if _active_ddos().is_empty():
|
||
_toast("DDoS НЕТ · ЩИТ НЕ НУЖЕН", "info")
|
||
return
|
||
_shield_holding = true
|
||
_shield_hold_acc = 0.0
|
||
_toast("ДЕРЖИТЕ ЩИТ · ОТРАЖЕНИЕ DDoS", "warn")
|
||
_update_shield_visuals(_active_ddos())
|
||
|
||
|
||
func _on_shield_release() -> void:
|
||
if not _shield_holding:
|
||
return
|
||
_shield_holding = false
|
||
_update_shield_visuals(_active_ddos())
|
||
|
||
|
||
## Drop любой карточки-угрозы на щит. Возвращает true, если угроза обезврежена.
|
||
func _on_quarantine_drop(payload: Dictionary) -> bool:
|
||
if controller == null or controller.hour_director == null:
|
||
return false
|
||
if str(payload.get("kind", "")) != "packet":
|
||
return false
|
||
var is_threat := bool(payload.get("is_attack", false)) \
|
||
or not str(payload.get("attack_kind", "")).is_empty()
|
||
if not is_threat:
|
||
_toast("ЭТО НЕ УГРОЗА · −2 SLA", "warn")
|
||
Game.add_sla(-2.0, "quarantine")
|
||
_refresh_hour()
|
||
return false
|
||
var director = controller.hour_director
|
||
var attack_id := str(payload.get("attack_id", ""))
|
||
var packet_id := str(payload.get("id", ""))
|
||
var ok := false
|
||
if not attack_id.is_empty():
|
||
ok = director.quarantine_attack(attack_id)
|
||
if not ok and not packet_id.is_empty():
|
||
ok = director.destroy_attack_packet(packet_id)
|
||
if ok:
|
||
_toast("УГРОЗА В КАРАНТИНЕ", "success")
|
||
else:
|
||
_toast("НЕ УДАЛОСЬ ОБЕЗВРЕДИТЬ УГРОЗУ", "warn")
|
||
_refresh_hour()
|
||
return ok
|
||
|
||
|
||
func _card_ids(cards: Array) -> Array:
|
||
var out: Array = []
|
||
for c in cards:
|
||
out.append(str(c.id))
|
||
return out
|
||
|
||
|
||
func _same_ids(a: Array, b: Array) -> bool:
|
||
if a.size() != b.size():
|
||
return false
|
||
for i in a.size():
|
||
if str(a[i]) != str(b[i]):
|
||
return false
|
||
return true
|
||
|
||
|
||
func _ext_for_type(type: String) -> String:
|
||
if type.is_empty():
|
||
return ""
|
||
var def = Data.types.get(type, null)
|
||
if def is Dictionary:
|
||
return str(def.get("ext", "")).lstrip(".")
|
||
return ""
|
||
|
||
|
||
func _refresh_packets(data: Array) -> void:
|
||
var ids: Array = []
|
||
for p in data:
|
||
ids.append(str(p.get("id", "")))
|
||
var rebuilt := false
|
||
if not _same_ids(ids, _card_ids(packets)):
|
||
rebuilt = true
|
||
for c in packets:
|
||
receive_window.content.remove_child(c)
|
||
c.queue_free()
|
||
packets.clear()
|
||
for i in data.size():
|
||
var card := PacketCard.new()
|
||
card.name = "Packet_%s" % str(data[i].get("id", ""))
|
||
receive_window.content.add_child(card)
|
||
card.custom_minimum_size = Vector2.ZERO
|
||
card.position = Vector2(6, 1 + _packet_shift + i * RECV_CARD_H - _recv_scroll)
|
||
card.size = Vector2(maxf(120.0, receive_window.content.size.x - 20.0), 44)
|
||
card.connect_pressed.connect(_on_packet_connect)
|
||
packets.append(card)
|
||
|
||
for i in data.size():
|
||
if i >= packets.size():
|
||
break
|
||
var p: Dictionary = data[i]
|
||
var card := packets[i]
|
||
var left := float(p.get("time_left", 0.0))
|
||
var total := float(p.get("timer_total", 0.0))
|
||
card.setup(str(p.get("id", "")), str(p.get("file_name", "")),
|
||
_ext_for_type(str(p.get("type", ""))), str(p.get("type", "")),
|
||
str(p.get("protocol", "")).to_upper(), left, total,
|
||
float(p.get("loading_progress", 0.0)), int(p.get("size_kb", 0)),
|
||
bool(p.get("is_attack", false)), str(p.get("attack_kind", "")),
|
||
str(p.get("attack_id", "")), str(p.get("state", "waiting")))
|
||
card.set_time(left, total)
|
||
|
||
_sync_recv_scroll(data.size())
|
||
if rebuilt:
|
||
# Пришли новые пакеты — показываем последний, не заставляя скроллить.
|
||
_recv_scroll = maxf(0.0,
|
||
_recv_content_height(data.size()) - maxf(1.0, receive_window.content.size.y))
|
||
_sync_recv_scroll(data.size())
|
||
|
||
|
||
func _refresh_requests(data: Array) -> void:
|
||
var visible: Array = data.slice(0, 4)
|
||
var ids: Array = []
|
||
for r in visible:
|
||
ids.append(str(r.get("id", "")))
|
||
if not _same_ids(ids, _card_ids(requests)):
|
||
for c in requests:
|
||
send_window.content.remove_child(c)
|
||
c.queue_free()
|
||
requests.clear()
|
||
for i in visible.size():
|
||
var card := RequestCard.new()
|
||
card.name = "Request_%s" % str(visible[i].get("id", ""))
|
||
send_window.content.add_child(card)
|
||
card.custom_minimum_size = Vector2.ZERO
|
||
card.position = Vector2(6, i * REQ_CARD_H)
|
||
card.size = Vector2(400, REQ_CARD_H)
|
||
card.connect_pressed.connect(_on_request_connect)
|
||
card.reject_pressed.connect(_on_request_reject)
|
||
card.file_dropped.connect(_on_file_to_request)
|
||
card.detach_pressed.connect(_on_request_detach)
|
||
requests.append(card)
|
||
# Раскладка каждый кадр: при 3–4 запросах шаг меньше, чтобы все влезли в 252 px.
|
||
_layout_request_cards(visible.size())
|
||
|
||
for i in visible.size():
|
||
if i >= requests.size():
|
||
break
|
||
var r: Dictionary = visible[i]
|
||
var progress := 0.0
|
||
var duration := float(r.get("send_duration", 0.0))
|
||
if bool(r.get("sending", false)) and duration > 0.0:
|
||
progress = clampf(float(r.get("send_progress", 0.0)) / duration, 0.0, 1.0)
|
||
var is_phish := bool(r.get("is_attack", false)) and str(r.get("attack_kind", "")) == "phishing"
|
||
requests[i].setup(str(r.get("id", "")), str(r.get("client_name", "")),
|
||
str(r.get("client_id", "")), str(r.get("protocol", "")).to_upper(),
|
||
_request_slots(r), float(r.get("time_left", 0.0)),
|
||
float(r.get("timer_total", 0.0)), progress,
|
||
bool(r.get("is_attack", false)), str(r.get("attack_kind", "")),
|
||
str(r.get("attack_id", "")), is_phish)
|
||
requests[i].queue_redraw()
|
||
|
||
|
||
## Высота карточки запроса: 3–4 штуки должны целиком влезать в 252 px.
|
||
func _request_card_height(count: int) -> float:
|
||
return REQ_CARD_H_COMPACT if count >= 3 else REQ_CARD_H
|
||
|
||
|
||
## Разложить карточки запросов по вертикали без пересоздания.
|
||
func _layout_request_cards(count: int) -> void:
|
||
if send_window == null or send_window.content == null:
|
||
return
|
||
var h := _request_card_height(count)
|
||
var w := maxf(120.0, send_window.content.size.x - 12.0)
|
||
for i in requests.size():
|
||
var card := requests[i]
|
||
if card == null or not is_instance_valid(card):
|
||
continue
|
||
card.position = Vector2(6, i * h)
|
||
card.size = Vector2(w, h)
|
||
card.queue_redraw()
|
||
|
||
|
||
## Слоты запроса: required-файлы из реестра; отсутствующий файл — null.
|
||
func _request_slots(request: Dictionary) -> Array:
|
||
var out: Array = []
|
||
var attached: Array = request.get("attached", [])
|
||
var registry = Game.registry
|
||
for file_id in request.get("required", []):
|
||
var fid := str(file_id)
|
||
var file = null
|
||
if registry != null:
|
||
file = registry.files.get(fid)
|
||
if file == null:
|
||
out.append(null)
|
||
continue
|
||
var folder_name := ""
|
||
if not str(file.folder_id).is_empty() and registry.folders.has(file.folder_id):
|
||
folder_name = str(registry.folders[file.folder_id].name)
|
||
out.append({
|
||
"name": str(file.name),
|
||
"ext": str(file.ext),
|
||
"type": str(file.type),
|
||
"folder": folder_name,
|
||
"attached": attached.has(fid),
|
||
})
|
||
return out
|
||
|
||
|
||
func _refresh_slots(data: Array, packets_data: Array = [],
|
||
requests_data: Array = []) -> void:
|
||
if slot_cells.size() != 4:
|
||
for c in slot_cells:
|
||
if is_instance_valid(c):
|
||
if c.get_parent() != null:
|
||
c.get_parent().remove_child(c)
|
||
c.queue_free()
|
||
slot_cells.clear()
|
||
for i in 4:
|
||
var cell := SlotCell.new()
|
||
cell.name = "Slot_%d" % i
|
||
slots_strip.add_child(cell)
|
||
cell.position = Vector2(58 + i * 240, 5)
|
||
cell.size = Vector2(236, 44)
|
||
cell.close_pressed.connect(_on_slot_close)
|
||
cell.cell_pressed.connect(_on_slot_press)
|
||
cell.packet_dropped.connect(_on_packet_to_slot)
|
||
slot_cells.append(cell)
|
||
|
||
for i in 4:
|
||
var cell := slot_cells[i]
|
||
if i >= data.size():
|
||
cell.setup("", "", "down", 0.0, "empty", "СВОБОДЕН", "")
|
||
continue
|
||
var s: Dictionary = data[i]
|
||
var occupied := bool(s.get("occupied", false))
|
||
var dir := "down" if str(s.get("direction", "")) == "in" else "up"
|
||
var progress := 0.0
|
||
if occupied:
|
||
progress = _slot_entity_progress(str(s.get("entity_id", "")), dir,
|
||
packets_data, requests_data)
|
||
cell.setup(str(s.get("entity_id", "")), str(s.get("entity_name", "")), dir,
|
||
progress,
|
||
"active" if occupied else "empty",
|
||
"" if occupied else "СВОБОДЕН", str(int(s.get("index", i)) + 1))
|
||
|
||
|
||
## Прогресс занятого слота: загрузка пакета или отправка запроса.
|
||
func _slot_entity_progress(entity_id: String, dir: String, packets_data: Array,
|
||
requests_data: Array) -> float:
|
||
if entity_id.is_empty():
|
||
return 0.0
|
||
if dir == "down":
|
||
for p in packets_data:
|
||
if str(p.get("id", "")) != entity_id:
|
||
continue
|
||
if str(p.get("state", "")) != "loading":
|
||
return 0.0
|
||
var total := float(p.get("timer_total", 0.0))
|
||
if total <= 0.0:
|
||
return 0.0
|
||
return clampf(1.0 - float(p.get("time_left", 0.0)) / total, 0.0, 1.0)
|
||
return 0.0
|
||
for r in requests_data:
|
||
if str(r.get("id", "")) != entity_id:
|
||
continue
|
||
if not bool(r.get("sending", false)):
|
||
return 0.0
|
||
var duration := float(r.get("send_duration", 0.0))
|
||
if duration <= 0.0:
|
||
return 0.0
|
||
return clampf(float(r.get("send_progress", 0.0)) / duration, 0.0, 1.0)
|
||
return 0.0
|
||
|
||
|
||
func _refresh_hour_status(status: Dictionary) -> void:
|
||
var used := int(status.get("tcp_used", 0))
|
||
var max_slots := maxi(1, int(status.get("tcp_max", 0)))
|
||
var percent := float(status.get("channel_percent", 0.0))
|
||
var sla := int(round(float(status.get("sla", 0.0))))
|
||
var buffer_used := int(status.get("buffer_used", 0))
|
||
var buffer_max := maxi(1, int(status.get("buffer_max", 0)))
|
||
var hour := int(status.get("hour", 1))
|
||
var left := maxf(0.0, float(status.get("time_left", 0.0)))
|
||
var secs := int(ceilf(left))
|
||
|
||
status_bar.set_metric("tcp", "%d / %d" % [used, max_slots],
|
||
clampf(float(used) / float(max_slots), 0.0, 1.0))
|
||
status_bar.set_metric("channel", "%d%%" % int(round(percent)),
|
||
clampf(percent / 100.0, 0.0, 1.0))
|
||
var max_sla := maxi(1, int(Game.MAX_SLA))
|
||
status_bar.set_metric("sla", "%d / %d" % [sla, max_sla],
|
||
clampf(float(sla) / float(max_sla), 0.0, 1.0))
|
||
status_bar.set_metric("buffer", "%d / %d" % [buffer_used, buffer_max],
|
||
clampf(float(buffer_used) / float(buffer_max), 0.0, 1.0))
|
||
status_bar.set_metric("money", "$ %d" % int(status.get("money", 0)))
|
||
status_bar.set_metric("hour", "ЧАС %d %02d:%02d" % [hour, secs / 60, secs % 60],
|
||
float(hour - 1) / float(GAME_CONTROLLER.HOURS_PER_RUN))
|
||
if slot_count_label != null:
|
||
slot_count_label.text = "%d/%d" % [used, max_slots]
|
||
|
||
|
||
# --------------------------------------------------------------------------
|
||
# Итоги часа и забега
|
||
# --------------------------------------------------------------------------
|
||
|
||
func _on_hour_finished(_report: Dictionary) -> void:
|
||
_open_hour_result()
|
||
|
||
|
||
func _open_hour_result() -> void:
|
||
if hour_result_modal == null or controller == null:
|
||
return
|
||
if hour_result_modal.visible:
|
||
return
|
||
var report: Dictionary = controller.current_report
|
||
var sla := float(Game.sla)
|
||
hour_result_modal.open({
|
||
"hour": int(report.get("hour", Game.hour)),
|
||
"hours_total": GAME_CONTROLLER.HOURS_PER_RUN,
|
||
"earned": int(report.get("earned", 0)),
|
||
"done": int(report.get("completed", 0)),
|
||
"failed": int(report.get("failed", 0)),
|
||
"reflected": int(report.get("attacks", 0)),
|
||
"missed": int(report.get("attacks_failed", 0)),
|
||
"sla_delta": int(round(float(report.get("sla_delta", 0.0)))),
|
||
"sla": int(round(sla)),
|
||
"sla_max": maxi(1, int(Game.MAX_SLA)),
|
||
})
|
||
|
||
|
||
func _on_hour_result_next() -> void:
|
||
if controller == null:
|
||
return
|
||
controller.next_after_hour()
|
||
if controller.phase == GAME_CONTROLLER.PHASE_RUN_RESULT:
|
||
_show_run_result()
|
||
elif controller.phase == GAME_CONTROLLER.PHASE_PREP:
|
||
# _on_phase(prep) уже перерисовал экран; повторный вызов безвреден.
|
||
_refresh_prep()
|
||
|
||
|
||
func _on_run_finished(_report: Dictionary) -> void:
|
||
_show_run_result()
|
||
|
||
|
||
func _show_run_result() -> void:
|
||
if run_result_modal == null or controller == null:
|
||
return
|
||
if run_result_modal.visible:
|
||
return
|
||
var r: Dictionary = controller.run_result()
|
||
run_result_modal.open({
|
||
"won": bool(r.get("won", false)),
|
||
"reason": _run_reason_text(str(r.get("reason", ""))),
|
||
"score": int(r.get("score", 0)),
|
||
"best": int(r.get("best", 0)),
|
||
"hours": int(r.get("hours", 0)),
|
||
"hours_total": GAME_CONTROLLER.HOURS_PER_RUN,
|
||
"money": int(r.get("money", 0)),
|
||
"sla": int(round(float(r.get("sla", 0.0)))),
|
||
"sla_max": maxi(1, int(Game.MAX_SLA)),
|
||
"unlocked": (r.get("unlocked", []) as Array).duplicate(),
|
||
})
|
||
var unlocked: Array = r.get("unlocked", [])
|
||
if not unlocked.is_empty():
|
||
_toast("ОТКРЫТО: %s" % ", ".join(unlocked), "success")
|
||
|
||
|
||
func _run_reason_text(reason: String) -> String:
|
||
match reason:
|
||
"victory":
|
||
return "СМЕНА ОТРАБОТАНА. ВСЕ 9 ЧАСОВ."
|
||
"sla":
|
||
return "SLA УПАЛ ДО НУЛЯ. СМЕНА СОРВАНА."
|
||
_:
|
||
return reason
|
||
|
||
|
||
func _on_run_result_new() -> void:
|
||
_start_new_run()
|
||
|
||
|
||
func _on_run_result_menu() -> void:
|
||
if run_result_modal != null:
|
||
run_result_modal.visible = false
|
||
if hour_result_modal != null:
|
||
hour_result_modal.visible = false
|
||
_hour_ui_shown = false
|
||
if main_menu != null:
|
||
main_menu.set_has_save(controller != null and controller.has_save())
|
||
main_menu.visible = true
|
||
|
||
|
||
# --------------------------------------------------------------------------
|
||
# Сборка экрана
|
||
# --------------------------------------------------------------------------
|
||
|
||
func _build() -> void:
|
||
mouse_filter = Control.MOUSE_FILTER_STOP
|
||
custom_minimum_size = Vector2(W, H)
|
||
|
||
var desk := Backdrop.new()
|
||
_backdrop = desk
|
||
desk.name = "Desktop"
|
||
desk.mouse_filter = Control.MOUSE_FILTER_IGNORE
|
||
desk.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT)
|
||
add_child(desk)
|
||
|
||
# титлбар приложения (0,0,1280,24)
|
||
title_bar = WindowFrame.new()
|
||
title_bar.name = "TitleBar"
|
||
title_bar.title = "PEAK HOUR 97"
|
||
title_bar.title_logo = true
|
||
title_bar.icon_kind = "app"
|
||
title_bar.title_hint = "УЗЕЛ 7 · СМЕНА 3"
|
||
title_bar.accent_line = true
|
||
title_bar.title_only = true
|
||
title_bar.close_enabled = false
|
||
_place(self, title_bar, Rect2(0, 0, W, 24))
|
||
|
||
# строка меню (0,24,1280,20)
|
||
menu_bar = Panel90.new()
|
||
menu_bar.name = "MenuBar"
|
||
menu_bar.kind = Panel90.Kind.FLAT
|
||
_place(self, menu_bar, Rect2(0, 24, W, 20))
|
||
|
||
var sep := ColorRect.new()
|
||
sep.color = Theme90.SHADE
|
||
sep.mouse_filter = Control.MOUSE_FILTER_IGNORE
|
||
menu_bar.add_child(sep)
|
||
sep.position = Vector2(0, 19)
|
||
sep.size = Vector2(W, 1)
|
||
|
||
# Одна рабочая кнопка меню вместо демо-пунктов ФАЙЛ/ВИД/СПРАВКА.
|
||
var menu_btn := Button90.new()
|
||
menu_btn.name = "MenuButton"
|
||
menu_btn.caption = "МЕНЮ"
|
||
menu_btn.flat_style = true
|
||
menu_btn.caption_size = Theme90.FS_BODY
|
||
menu_bar.add_child(menu_btn)
|
||
menu_btn.position = Vector2(6, 1)
|
||
menu_btn.size = Vector2(72, 18)
|
||
menu_btn.pressed.connect(func() -> void: _popup_main_menu(menu_btn))
|
||
_menu_btn = menu_btn
|
||
|
||
# правая часть меню-бара: дата, часы, магазин, старт часа
|
||
var date_lbl := Label.new()
|
||
date_lbl.name = "DateLabel"
|
||
date_lbl.text = "СБ 12 ОКТ 1997"
|
||
date_lbl.add_theme_font_override("font", Theme90.ui())
|
||
date_lbl.add_theme_font_size_override("font_size", Theme90.FS_MICRO)
|
||
date_lbl.add_theme_color_override("font_color", Theme90.DARK)
|
||
date_lbl.horizontal_alignment = HORIZONTAL_ALIGNMENT_RIGHT
|
||
date_lbl.vertical_alignment = VERTICAL_ALIGNMENT_CENTER
|
||
date_lbl.mouse_filter = Control.MOUSE_FILTER_IGNORE
|
||
menu_bar.add_child(date_lbl)
|
||
date_lbl.position = Vector2(852, 1)
|
||
date_lbl.size = Vector2(140, 18)
|
||
date_lbl.visible = false
|
||
|
||
var clock := Label.new()
|
||
clock.name = "Clock"
|
||
clock.text = "03:42"
|
||
clock.add_theme_font_override("font", Theme90.logo())
|
||
clock.add_theme_font_size_override("font_size", 10)
|
||
clock.add_theme_color_override("font_color", Theme90.DARK)
|
||
clock.horizontal_alignment = HORIZONTAL_ALIGNMENT_LEFT
|
||
clock.vertical_alignment = VERTICAL_ALIGNMENT_CENTER
|
||
clock.mouse_filter = Control.MOUSE_FILTER_IGNORE
|
||
menu_bar.add_child(clock)
|
||
clock.position = Vector2(998, 1)
|
||
clock.size = Vector2(54, 18)
|
||
|
||
var shop := Button90.new()
|
||
shop.name = "ShopButton"
|
||
shop.caption = "МАГАЗИН"
|
||
shop.flat_style = true
|
||
shop.size = Vector2(82, 18)
|
||
menu_bar.add_child(shop)
|
||
shop.position = Vector2(1078, 1)
|
||
shop.pressed.connect(_on_shop)
|
||
_shop_btn = shop
|
||
|
||
var start := Button90.new()
|
||
start.name = "StartHourButton"
|
||
start.caption = "НАЧАТЬ ЧАС"
|
||
start.accent = true
|
||
start.size = Vector2(108, 18)
|
||
menu_bar.add_child(start)
|
||
start.position = Vector2(1164, 1)
|
||
start.pressed.connect(_on_start_hour)
|
||
_start_btn = start
|
||
|
||
# рабочая область (8,48,840,548) — утопленная группа
|
||
workspace = Panel90.new()
|
||
workspace.name = "Workspace"
|
||
workspace.kind = Panel90.Kind.GROUP
|
||
workspace.title = "ДИСК C:"
|
||
workspace.title_right = _plural_ru(0, "ОБЪЕКТ", "ОБЪЕКТА", "ОБЪЕКТОВ")
|
||
_place(self, workspace, Rect2(8, 48, 840, 548))
|
||
|
||
# окно дерева (16,72,240,516)
|
||
tree_window = WindowFrame.new()
|
||
tree_window.name = "TreeWindow"
|
||
tree_window.title = "ДИСКИ"
|
||
tree_window.title_hint = _plural_ru(6, "ПАПКА", "ПАПКИ", "ПАПОК")
|
||
tree_window.icon_kind = "tree"
|
||
_place(workspace, tree_window, Rect2(8, 24, 240, 516))
|
||
|
||
tree = TreePanel.new()
|
||
tree.name = "TreePanel"
|
||
tree.set_anchors_and_offsets_preset(Control.PRESET_TOP_LEFT)
|
||
add_child_to(tree_window.content, tree, Vector2.ZERO, tree_window.content.size)
|
||
|
||
# окно файлов (264,72,576,516)
|
||
files_window = WindowFrame.new()
|
||
files_window.name = "FilesWindow"
|
||
files_window.title = "ФАЙЛЫ · ПРИЁМ"
|
||
files_window.title_hint = _plural_ru(12, "ОБЪЕКТ", "ОБЪЕКТА", "ОБЪЕКТОВ")
|
||
files_window.icon_kind = "files"
|
||
_place(workspace, files_window, Rect2(256, 24, 576, 516))
|
||
|
||
grid = FileGrid.new()
|
||
grid.name = "FileGrid"
|
||
add_child_to(files_window.content, grid, Vector2.ZERO, files_window.content.size)
|
||
_build_search_field()
|
||
|
||
# окно ПРИЁМ (856,48,416,264)
|
||
receive_window = WindowFrame.new()
|
||
receive_window.name = "ReceiveWindow"
|
||
receive_window.title = _padded_title("ПРИЁМ")
|
||
receive_window.title_hint = _plural_ru(5, "ПАКЕТ", "ПАКЕТА", "ПАКЕТОВ")
|
||
receive_window.icon_kind = "inbox"
|
||
_place(self, receive_window, Rect2(856, 48, 416, 264))
|
||
_build_receive_scroll()
|
||
|
||
# окно ОТПРАВКА (856,320,416,276)
|
||
send_window = WindowFrame.new()
|
||
send_window.name = "SendWindow"
|
||
send_window.title = _padded_title("ОТПРАВКА")
|
||
send_window.title_hint = _plural_ru(4, "ЗАПРОС", "ЗАПРОСА", "ЗАПРОСОВ")
|
||
send_window.icon_kind = "outbox"
|
||
_place(self, send_window, Rect2(856, 320, 416, 276))
|
||
_build_shields()
|
||
|
||
# полоса TCP-слотов (8,604,1264,52)
|
||
slots_strip = Panel90.new()
|
||
slots_strip.name = "SlotsStrip"
|
||
slots_strip.kind = Panel90.Kind.RAISED
|
||
_place(self, slots_strip, Rect2(8, 604, 1264, 52))
|
||
|
||
var head := Panel90.new()
|
||
head.name = "SlotHead"
|
||
head.kind = Panel90.Kind.SUNKEN
|
||
slots_strip.add_child(head)
|
||
head.position = Vector2(6, 5)
|
||
head.size = Vector2(46, 42)
|
||
|
||
var tcp_lbl := Label.new()
|
||
tcp_lbl.text = "TCP"
|
||
tcp_lbl.add_theme_font_override("font", Theme90.ui(800))
|
||
tcp_lbl.add_theme_font_size_override("font_size", Theme90.FS_BODY)
|
||
tcp_lbl.add_theme_color_override("font_color", Theme90.NAVY)
|
||
tcp_lbl.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
|
||
tcp_lbl.vertical_alignment = VERTICAL_ALIGNMENT_CENTER
|
||
tcp_lbl.mouse_filter = Control.MOUSE_FILTER_IGNORE
|
||
head.add_child(tcp_lbl)
|
||
tcp_lbl.position = Vector2(0, 4)
|
||
tcp_lbl.size = Vector2(46, 18)
|
||
|
||
var tcp_cnt := Label.new()
|
||
tcp_cnt.name = "SlotCount"
|
||
slot_count_label = tcp_cnt
|
||
tcp_cnt.text = "2/5"
|
||
tcp_cnt.add_theme_font_override("font", Theme90.logo())
|
||
tcp_cnt.add_theme_font_size_override("font_size", 8)
|
||
tcp_cnt.add_theme_color_override("font_color", Theme90.DARK)
|
||
tcp_cnt.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
|
||
tcp_cnt.vertical_alignment = VERTICAL_ALIGNMENT_CENTER
|
||
tcp_cnt.mouse_filter = Control.MOUSE_FILTER_IGNORE
|
||
head.add_child(tcp_cnt)
|
||
tcp_cnt.position = Vector2(0, 22)
|
||
tcp_cnt.size = Vector2(46, 14)
|
||
|
||
# статус-бар (0,660,1280,60)
|
||
status_bar = StatusBar.new()
|
||
status_bar.name = "StatusBar"
|
||
_place(self, status_bar, Rect2(0, 660, W, 60))
|
||
|
||
# слой сообщений
|
||
toast_layer = ToastLayer.new()
|
||
toast_layer.name = "ToastLayer"
|
||
toast_layer.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT)
|
||
add_child(toast_layer)
|
||
|
||
|
||
func _place(parent: Node, c: Control, rect: Rect2) -> void:
|
||
parent.add_child(c)
|
||
c.custom_minimum_size = Vector2.ZERO
|
||
c.size = rect.size
|
||
c.position = rect.position
|
||
|
||
|
||
# --------------------------------------------------------------------------
|
||
# Скролл ПРИЁМА и поле поиска
|
||
# --------------------------------------------------------------------------
|
||
|
||
func _build_receive_scroll() -> void:
|
||
if receive_window == null or receive_window.content == null:
|
||
return
|
||
receive_window.content.clip_contents = true
|
||
_recv_bar = ScrollBar90.new()
|
||
_recv_bar.name = "ReceiveScroll"
|
||
receive_window.content.add_child(_recv_bar)
|
||
_recv_bar.position = Vector2(
|
||
maxf(0.0, receive_window.content.size.x - Theme90.SCROLLBAR), 0.0)
|
||
_recv_bar.size = Vector2(Theme90.SCROLLBAR, receive_window.content.size.y)
|
||
_recv_bar.visible = false
|
||
_recv_bar.value_changed.connect(_on_recv_scroll)
|
||
receive_window.content.gui_input.connect(_on_recv_gui_input)
|
||
|
||
|
||
func _build_search_field() -> void:
|
||
if files_window == null or files_window.content == null:
|
||
return
|
||
_search_edit = LineEdit.new()
|
||
_search_edit.name = "FileSearch"
|
||
_search_edit.placeholder_text = "поиск по имени…"
|
||
_search_edit.max_length = 48
|
||
_search_edit.add_theme_font_override("font", Theme90.ui())
|
||
_search_edit.add_theme_font_size_override("font_size", Theme90.FS_MICRO)
|
||
_search_edit.add_theme_color_override("font_color", Theme90.INK)
|
||
_search_edit.add_theme_color_override("font_placeholder_color", Theme90.SHADE)
|
||
var box := Theme90.sunken_box()
|
||
box.set_content_margin_all(1)
|
||
box.content_margin_left = 4
|
||
box.content_margin_right = 2
|
||
_search_edit.add_theme_stylebox_override("normal", box)
|
||
_search_edit.add_theme_stylebox_override("focus", box)
|
||
_search_edit.text_changed.connect(_on_search_changed)
|
||
files_window.content.add_child(_search_edit)
|
||
files_window.content.move_child(_search_edit, 0)
|
||
_search_edit.position = Vector2(2, 2)
|
||
_search_edit.size = Vector2(180, 18)
|
||
_search_edit.visible = false
|
||
|
||
|
||
func add_child_to(parent: Node, c: Control, pos: Vector2, sz: Vector2) -> void:
|
||
parent.add_child(c)
|
||
c.custom_minimum_size = Vector2.ZERO
|
||
c.position = pos
|
||
c.size = sz
|
||
|
||
|
||
# --------------------------------------------------------------------------
|
||
# Щит-карантин в заголовках ПРИЁМ/ОТПРАВКА
|
||
# --------------------------------------------------------------------------
|
||
|
||
const SHIELD_W := 48.0
|
||
const SHIELD_H := 20.0
|
||
const SHIELD_X := 22.0
|
||
|
||
## Отступ заголовка, чтобы текст не заезжал под щит (щит стоит сразу за иконкой).
|
||
func _padded_title(t: String) -> String:
|
||
var f := Theme90.ui()
|
||
var space_w := f.get_string_size(" ", HORIZONTAL_ALIGNMENT_LEFT, -1, Theme90.FS_BODY).x
|
||
if space_w <= 0.0:
|
||
return " " + t
|
||
var n := int(ceil((SHIELD_W + 34.0) / space_w))
|
||
return " ".repeat(n) + t
|
||
|
||
|
||
func _build_shields() -> void:
|
||
_shield_receive = _make_shield("receive", receive_window)
|
||
_shield_send = _make_shield("send", send_window)
|
||
|
||
|
||
func _make_shield(target: String, frame: WindowFrame) -> Control:
|
||
var shield := ShieldDrop.new()
|
||
shield.name = "ShieldDrop"
|
||
shield.main = self
|
||
shield.target = target
|
||
frame.add_child(shield)
|
||
shield.position = Vector2(SHIELD_X, 2)
|
||
shield.size = Vector2(SHIELD_W, SHIELD_H)
|
||
return shield
|
||
|
||
|
||
func _make_menu(labels: Array) -> PopupMenu:
|
||
var pm := ContextMenu90.new()
|
||
pm.name = "Popup"
|
||
add_child(pm)
|
||
var idx := 0
|
||
for l in labels:
|
||
var label := str(l)
|
||
if label == "-":
|
||
pm.add_separator()
|
||
continue
|
||
pm.add_item(label, idx)
|
||
idx += 1
|
||
pm.id_pressed.connect(func(id: int) -> void:
|
||
var i := pm.get_item_index(id)
|
||
if i >= 0:
|
||
_on_menu_pick(pm.get_item_text(i)))
|
||
pm.popup_hide.connect(func() -> void:
|
||
if is_instance_valid(pm):
|
||
pm.queue_free())
|
||
return pm
|
||
|
||
|
||
## Пункты верхнего меню зависят от фазы: в меню нет выхода в само меню.
|
||
func _main_menu_items() -> Array:
|
||
var items: Array = ["Настройки", "О программе"]
|
||
if controller != null and (controller.phase == GAME_CONTROLLER.PHASE_PREP \
|
||
or controller.phase == GAME_CONTROLLER.PHASE_HOUR):
|
||
items.append_array(["-", "В главное меню"])
|
||
if not OS.has_feature("web"):
|
||
items.append_array(["-", "Выход"])
|
||
return items
|
||
|
||
|
||
func _popup_main_menu(button: Button90) -> void:
|
||
if button == null:
|
||
return
|
||
var pm := _make_menu(_main_menu_items())
|
||
_menus["МЕНЮ"] = pm
|
||
button.active = true
|
||
pm.popup_hide.connect(func() -> void:
|
||
if is_instance_valid(button):
|
||
button.active = false)
|
||
pm.popup(Rect2i(Vector2i(int(button.global_position.x), 44), Vector2i.ZERO))
|
||
|
||
|
||
## Реальные действия верхнего меню (алиасы сохранены для старых тестов).
|
||
func _on_menu_pick(text: String) -> void:
|
||
var key := text.to_lower()
|
||
if key.contains("параметр") or key.contains("настрой"):
|
||
_open_settings()
|
||
return
|
||
if key.contains("о программе"):
|
||
_show_about()
|
||
return
|
||
if key.contains("главное меню"):
|
||
_confirm_to_menu()
|
||
return
|
||
if key.contains("выход"):
|
||
if OS.has_feature("web"):
|
||
_toast("ЗАКРОЙТЕ ВКЛАДКУ БРАУЗЕРА", "info")
|
||
else:
|
||
get_tree().quit()
|
||
return
|
||
_toast("ПУНКТ МЕНЮ НЕДОСТУПЕН: %s" % text.to_upper(), "warn")
|
||
|
||
|
||
# --------------------------------------------------------------------------
|
||
# Мок-данные
|
||
# --------------------------------------------------------------------------
|
||
|
||
func _apply_mock() -> void:
|
||
tree.set_data(_mock_tree())
|
||
tree.select_folder("inbox")
|
||
|
||
grid.set_folder("inbox", "ПРИЁМ", "C: \\ ПРИЁМ")
|
||
grid.set_files(_files_for("inbox"))
|
||
files_window.set_title("ФАЙЛЫ · ПРИЁМ",
|
||
_plural_ru(12, "ОБЪЕКТ", "ОБЪЕКТА", "ОБЪЕКТОВ"))
|
||
|
||
var pk := _mock_packets()
|
||
var y := 1.0
|
||
for d in pk:
|
||
var card := PacketCard.new()
|
||
card.name = "Packet_%s" % str(d["id"])
|
||
receive_window.content.add_child(card)
|
||
card.custom_minimum_size = Vector2.ZERO
|
||
card.position = Vector2(6, y)
|
||
card.size = Vector2(400, 44)
|
||
card.setup(str(d["id"]), str(d["name"]), str(d["ext"]), str(d["type"]),
|
||
str(d["protocol"]), float(d["left"]), float(d["total"]), float(d["progress"]),
|
||
int(d["size_kb"]))
|
||
card.connect_pressed.connect(_on_packet_connect)
|
||
packets.append(card)
|
||
y += 48.0
|
||
|
||
var rq := _mock_requests()
|
||
y = 0.0
|
||
for d in rq:
|
||
var card := RequestCard.new()
|
||
card.name = "Request_%s" % str(d["id"])
|
||
send_window.content.add_child(card)
|
||
card.custom_minimum_size = Vector2.ZERO
|
||
card.position = Vector2(6, y)
|
||
card.size = Vector2(400, 60)
|
||
card.setup(str(d["id"]), str(d["client"]), str(d["client_id"]), str(d["protocol"]),
|
||
d["slots"], float(d["left"]), float(d["total"]), float(d["progress"]))
|
||
card.connect_pressed.connect(_on_request_connect)
|
||
card.reject_pressed.connect(_on_request_reject)
|
||
card.file_dropped.connect(_on_file_to_request)
|
||
card.detach_pressed.connect(_on_request_detach)
|
||
requests.append(card)
|
||
y += 64.0
|
||
|
||
var sl := _mock_slots()
|
||
for i in sl.size():
|
||
var d: Dictionary = sl[i]
|
||
var cell := SlotCell.new()
|
||
cell.name = "Slot_%d" % i
|
||
slots_strip.add_child(cell)
|
||
cell.position = Vector2(58 + i * 240, 5)
|
||
cell.size = Vector2(236, 44)
|
||
cell.setup(str(d["id"]), str(d["name"]), str(d["dir"]), float(d["progress"]),
|
||
str(d["state"]), str(d.get("sub", "")), str(d.get("label", "")))
|
||
cell.close_pressed.connect(_on_slot_close)
|
||
cell.cell_pressed.connect(_on_slot_press)
|
||
cell.packet_dropped.connect(_on_packet_to_slot)
|
||
slot_cells.append(cell)
|
||
|
||
|
||
func _mock_tree() -> Array:
|
||
var c_folders: Array = [
|
||
{"id": "inbox", "name": "ПРИЁМ", "count": 3, "capacity": 16},
|
||
{"id": "outbox", "name": "ОТПРАВКА", "count": 2, "capacity": 16},
|
||
{"id": "archive", "name": "АРХИВ", "count": 7, "capacity": 16},
|
||
]
|
||
for f in _extra_folders:
|
||
c_folders.append(f)
|
||
return [
|
||
{"id": "sec_c", "name": "C: ЛОКАЛЬНЫЙ", "free": "412 МБ", "folders": c_folders},
|
||
{"id": "sec_d", "name": "D: РАБОЧИЙ", "free": "1,2 ГБ", "folders": [
|
||
{"id": "projects", "name": "ПРОЕКТЫ", "count": 5, "capacity": 16},
|
||
{"id": "scans", "name": "СКАНЫ", "count": 1, "capacity": 16},
|
||
]},
|
||
{"id": "sec_e", "name": "E: СЕТЕВОЙ", "free": "—", "folders": [
|
||
{"id": "y1996", "name": "1996", "count": 12, "capacity": 16, "locked": true},
|
||
]},
|
||
]
|
||
|
||
|
||
func _file_pool() -> Array:
|
||
return [
|
||
# ПРИЁМ — 12 объектов
|
||
{"id": "f_report", "name": "отчёт_смены", "ext": "xls", "size_kb": 148, "type": "text", "state": "stored"},
|
||
{"id": "f_contract", "name": "договор_11", "ext": "doc", "size_kb": 96, "type": "text", "state": "attached"},
|
||
{"id": "f_estimate", "name": "смета_объекта", "ext": "xls", "size_kb": 212, "type": "text", "state": "stored"},
|
||
{"id": "f_radio", "name": "реклама_радио", "ext": "mp3", "size_kb": 1840, "type": "sound", "state": "stored"},
|
||
{"id": "f_jingle", "name": "джингл", "ext": "wav", "size_kb": 620, "type": "sound", "state": "locked"},
|
||
{"id": "f_passport", "name": "скан_паспорта", "ext": "jpg", "size_kb": 780, "type": "photo", "state": "stored"},
|
||
{"id": "f_frame4", "name": "кадр_4", "ext": "jpg", "size_kb": 340, "type": "photo", "state": "attached"},
|
||
{"id": "f_logo2", "name": "логотип_2", "ext": "png", "size_kb": 72, "type": "photo", "state": "stored"},
|
||
{"id": "f_pres", "name": "презентация_97", "ext": "avi", "size_kb": 12400, "type": "video", "state": "stored"},
|
||
{"id": "f_promo", "name": "промо_ролик", "ext": "avi", "size_kb": 8800, "type": "video", "state": "malicious"},
|
||
{"id": "f_invoice", "name": "счёт_фактура", "ext": "doc", "size_kb": 64, "type": "text", "state": "failed"},
|
||
{"id": "f_price", "name": "прайс_весна", "ext": "xls", "size_kb": 38, "type": "text", "state": "stored"},
|
||
# ОТПРАВКА — 4
|
||
{"id": "f_letter", "name": "письмо_клиенту", "ext": "doc", "size_kb": 24, "type": "text", "state": "stored"},
|
||
{"id": "f_sticker", "name": "наклейка", "ext": "png", "size_kb": 18, "type": "photo", "state": "stored"},
|
||
{"id": "f_horn", "name": "сигнал_эфир", "ext": "wav", "size_kb": 410, "type": "sound", "state": "stored"},
|
||
{"id": "f_reel", "name": "ролик_осень", "ext": "avi", "size_kb": 7600, "type": "video", "state": "stored"},
|
||
# АРХИВ — 3
|
||
{"id": "f_old1", "name": "акт_1995", "ext": "doc", "size_kb": 12, "type": "text", "state": "stored"},
|
||
{"id": "f_old2", "name": "фото_склад", "ext": "jpg", "size_kb": 520, "type": "photo", "state": "stored"},
|
||
{"id": "f_old3", "name": "запись_эфир", "ext": "wav", "size_kb": 2300, "type": "sound", "state": "stored"},
|
||
# ПРОЕКТЫ — 2
|
||
{"id": "f_proj1", "name": "план_смены", "ext": "xls", "size_kb": 44, "type": "text", "state": "stored"},
|
||
{"id": "f_proj2", "name": "макет_узла", "ext": "png", "size_kb": 220, "type": "photo", "state": "stored"},
|
||
# СКАНЫ — 1
|
||
{"id": "f_scan1", "name": "скан_акта", "ext": "jpg", "size_kb": 640, "type": "photo", "state": "locked"},
|
||
]
|
||
|
||
|
||
func _files_for(folder_id: String) -> Array:
|
||
var pool := _file_pool()
|
||
match folder_id:
|
||
"outbox":
|
||
return pool.slice(12, 16)
|
||
"archive":
|
||
return pool.slice(16, 19)
|
||
"projects":
|
||
return pool.slice(19, 21)
|
||
"scans":
|
||
return pool.slice(21, 22)
|
||
"y1996":
|
||
return []
|
||
_:
|
||
return pool.slice(0, 12)
|
||
|
||
|
||
func _folder_name(fid: String) -> String:
|
||
# Сначала настоящий реестр: мок-дерево нужно только демо-сцене.
|
||
if Game.registry != null and Game.registry.folders.has(fid):
|
||
return str(Game.registry.folders[fid].name)
|
||
if grid != null and grid.folder_id == fid and not grid.folder_name.is_empty():
|
||
return grid.folder_name
|
||
for s in _mock_tree():
|
||
for f in s["folders"]:
|
||
if str(f["id"]) == fid:
|
||
return str(f["name"])
|
||
if str(s["id"]) == fid:
|
||
return str(s["name"])
|
||
return "ПАПКА"
|
||
|
||
|
||
func _mock_packets() -> Array:
|
||
return [
|
||
{"id": "p1", "name": "договор_11", "ext": "doc", "type": "text", "protocol": "TCP",
|
||
"left": 28.0, "total": 40.0, "progress": 0.0, "size_kb": 96},
|
||
{"id": "p2", "name": "смета_объекта", "ext": "xls", "type": "text", "protocol": "UDP",
|
||
"left": 12.0, "total": 30.0, "progress": 0.0, "size_kb": 212},
|
||
{"id": "p3", "name": "реклама_радио", "ext": "mp3", "type": "sound", "protocol": "TCP",
|
||
"left": 4.0, "total": 25.0, "progress": 0.42, "size_kb": 1840},
|
||
{"id": "p4", "name": "скан_паспорта", "ext": "jpg", "type": "photo", "protocol": "TCP",
|
||
"left": 18.0, "total": 35.0, "progress": 0.78, "size_kb": 780},
|
||
{"id": "p5", "name": "промо_ролик", "ext": "avi", "type": "video", "protocol": "TCP",
|
||
"left": 1.4, "total": 30.0, "progress": 0.0, "size_kb": 8800},
|
||
]
|
||
|
||
|
||
func _mock_requests() -> Array:
|
||
return [
|
||
{"id": "r1", "client": "ООО «Вектор»", "client_id": "cl-4217", "protocol": "TCP",
|
||
"slots": [
|
||
{"name": "отчёт_смены", "ext": "xls", "type": "text"},
|
||
{"name": "смета", "ext": "doc", "type": "text"},
|
||
null,
|
||
],
|
||
"left": 45.0, "total": 60.0, "progress": 0.0},
|
||
{"id": "r2", "client": "ИП Кузнецов", "client_id": "cl-1189", "protocol": "UDP",
|
||
"slots": [
|
||
{"name": "логотип_2", "ext": "png", "type": "photo"},
|
||
null,
|
||
null,
|
||
],
|
||
"left": 24.0, "total": 45.0, "progress": 0.0},
|
||
{"id": "r3", "client": "Студия «Гром»", "client_id": "cl-9314", "protocol": "TCP",
|
||
"slots": [
|
||
{"name": "джингл", "ext": "wav", "type": "sound"},
|
||
null,
|
||
null,
|
||
],
|
||
"left": 8.0, "total": 40.0, "progress": 0.0},
|
||
{"id": "r4", "client": "АО «Северный»", "client_id": "cl-7772", "protocol": "TCP",
|
||
"slots": [
|
||
{"name": "промо_осень", "ext": "avi", "type": "video"},
|
||
{"name": "кадр_4", "ext": "jpg", "type": "photo"},
|
||
null,
|
||
],
|
||
"left": 38.0, "total": 60.0, "progress": 0.62},
|
||
]
|
||
|
||
|
||
func _mock_slots() -> Array:
|
||
return [
|
||
{"id": "s1", "name": "реклама_радио.mp3", "dir": "down", "progress": 0.42, "state": "active"},
|
||
{"id": "s2", "name": "отчёт_07.xls", "dir": "up", "progress": 0.78, "state": "active"},
|
||
{"id": "s3", "name": "", "dir": "down", "progress": 0.0, "state": "empty",
|
||
"sub": "СВОБОДЕН"},
|
||
{"id": "s4", "name": "", "dir": "down", "progress": 0.0, "state": "locked",
|
||
"slot": "4", "sub": "КУПИТЬ В МАГАЗИНЕ · 450 $", "label": "4"},
|
||
{"id": "s5", "name": "", "dir": "down", "progress": 0.0, "state": "locked",
|
||
"slot": "5", "sub": "КУПИТЬ В МАГАЗИНЕ · 700 $", "label": "5"},
|
||
]
|
||
|
||
|
||
# --------------------------------------------------------------------------
|
||
# Сигналы
|
||
# --------------------------------------------------------------------------
|
||
|
||
func _connect_all() -> void:
|
||
tree.folder_selected.connect(_on_folder_selected)
|
||
tree.file_dropped.connect(_on_drop_to_folder)
|
||
tree.context_menu_requested.connect(_on_tree_context)
|
||
tree.new_folder_requested.connect(_on_new_folder)
|
||
|
||
grid.file_selected.connect(_on_file_selected)
|
||
grid.file_activated.connect(_on_file_activated)
|
||
grid.file_context_requested.connect(_on_file_context)
|
||
grid.file_dropped.connect(_on_drop_to_folder)
|
||
grid.service_filter_changed.connect(_on_service_filter)
|
||
|
||
|
||
func _on_folder_selected(folder_id: String) -> void:
|
||
if controller == null:
|
||
return
|
||
var d: Dictionary = controller.prep_data()
|
||
var meta := _folder_meta(d, folder_id)
|
||
if meta.is_empty():
|
||
return
|
||
_show_folder(folder_id, str(meta["name"]), str(meta["section"]), d)
|
||
|
||
|
||
func _on_file_selected(id: String) -> void:
|
||
_selected_file = id
|
||
for c in grid.get_cards():
|
||
if c.id == id:
|
||
files_window.set_title("ФАЙЛЫ · %s" % _folder_name(grid.folder_id),
|
||
"ВЫБРАН: %s.%s" % [c.file_name, c.ext])
|
||
return
|
||
|
||
|
||
func _on_file_activated(id: String) -> void:
|
||
for c in grid.get_cards():
|
||
if c.id == id:
|
||
_toast("ПРОСМОТР: %s.%s" % [c.file_name, c.ext], "info")
|
||
|
||
|
||
func _on_service_filter(on: bool) -> void:
|
||
if on:
|
||
var hidden := 0
|
||
if grid != null:
|
||
hidden = grid.hidden_count()
|
||
_toast("СКРЫТО ОПАСНЫХ ОБЪЕКТОВ · %d" % hidden,
|
||
"warn" if hidden > 0 else "info")
|
||
else:
|
||
_toast("ПОКАЗАНЫ ВСЕ ОБЪЕКТЫ", "info")
|
||
|
||
|
||
func _on_packet_connect(id: String) -> void:
|
||
if controller == null or controller.hour_director == null:
|
||
return
|
||
var director = controller.hour_director
|
||
var packet = director.active_packets.get(id)
|
||
if packet == null:
|
||
return
|
||
if str(packet.protocol).to_lower() != "tcp":
|
||
_toast("UDP-ПАКЕТ: СОЕДИНЕНИЕ НЕ ТРЕБУЕТСЯ", "warn")
|
||
return
|
||
# Успех: HourDirector сам эмитит show_connection, модалку покажет _on_show_connection.
|
||
if director.connect_packet(id):
|
||
return
|
||
_toast("НЕ УДАЛОСЬ СОЕДИНИТЬСЯ: НЕТ СЛОТА ИЛИ ПАКЕТ НЕ ГОТОВ", "warn")
|
||
|
||
|
||
func _on_request_connect(id: String) -> void:
|
||
if controller == null or controller.hour_director == null:
|
||
return
|
||
var director = controller.hour_director
|
||
var req = director.active_requests.get(id)
|
||
if req == null:
|
||
return
|
||
if str(req.protocol).to_lower() != "tcp":
|
||
_toast("UDP-ЗАПРОС · СОЕДИНЕНИЕ НЕ ТРЕБУЕТСЯ", "warn")
|
||
return
|
||
if director.connect_request(id):
|
||
return
|
||
_toast("НЕ УДАЛОСЬ СОЕДИНИТЬСЯ: НЕТ СЛОТА ИЛИ ЗАПРОС УЖЕ ПОДКЛЮЧЁН", "warn")
|
||
|
||
|
||
func _on_request_reject(id: String) -> void:
|
||
if controller == null or controller.hour_director == null:
|
||
return
|
||
if controller.hour_director.reject_request(id):
|
||
_toast("ЗАПРОС ОТКЛОНЁН", "error")
|
||
_refresh_hour()
|
||
else:
|
||
_toast("ЗАПРОС НЕ НАЙДЕН", "warn")
|
||
|
||
|
||
func _on_request_detach(request_id: String, slot_index: int) -> void:
|
||
if controller == null or controller.hour_director == null:
|
||
return
|
||
var req = controller.hour_director.active_requests.get(request_id)
|
||
if req == null:
|
||
return
|
||
if slot_index < 0 or slot_index >= req.required.size():
|
||
return
|
||
var file_id := str(req.required[slot_index])
|
||
if not req.attached.has(file_id):
|
||
_toast("ФАЙЛ НЕ ПРИКРЕПЛЁН", "info")
|
||
return
|
||
if controller.hour_director.detach_file(request_id, file_id):
|
||
_toast("ФАЙЛ ОТСОЕДИНЁН ОТ ЗАПРОСА", "info")
|
||
_refresh_hour()
|
||
else:
|
||
_toast("НЕ УДАЛОСЬ ОТСОЕДИНИТЬ", "warn")
|
||
|
||
|
||
func _on_file_to_request(request_id: String, _slot_index: int, payload: Dictionary) -> void:
|
||
if controller == null or controller.hour_director == null:
|
||
return
|
||
var file_id := str(payload.get("id", ""))
|
||
if file_id.is_empty():
|
||
return
|
||
if controller.hour_director.attach_file(request_id, file_id):
|
||
_toast("ФАЙЛ ПРИКРЕПЛЁН", "success")
|
||
_refresh_hour()
|
||
_maybe_start_send(request_id)
|
||
else:
|
||
_toast("НЕ УДАЛОСЬ ПРИКРЕПИТЬ ФАЙЛ", "warn")
|
||
|
||
|
||
## Автостарт отправки, когда запрос собран и не занят мини-игрой/слотом.
|
||
func _maybe_start_send(req_id: String) -> void:
|
||
if controller == null or controller.hour_director == null:
|
||
return
|
||
var director = controller.hour_director
|
||
var req = director.active_requests.get(req_id)
|
||
if req == null:
|
||
return
|
||
if not req.is_ready() or req.minigame_active or req.sending:
|
||
return
|
||
if director.start_send(req_id):
|
||
return
|
||
_toast("ОТПРАВКА НЕ НАЧАЛАСЬ: НЕТ СЛОТА ИЛИ ПАПКА ЗАБЛОКИРОВАНА", "warn")
|
||
|
||
|
||
func _on_slot_close(id: String) -> void:
|
||
if controller == null or controller.hour_director == null or id.is_empty():
|
||
return
|
||
var director = controller.hour_director
|
||
var handled := false
|
||
var req = director.active_requests.get(id)
|
||
if req != null:
|
||
if director.slots != null:
|
||
director.slots.release_by_entity(id)
|
||
req.connected = false
|
||
req.slot_reserved = false
|
||
req.sending = false
|
||
req.send_progress = 0.0
|
||
handled = true
|
||
else:
|
||
var packet = director.active_packets.get(id)
|
||
if packet != null:
|
||
if director.slots != null:
|
||
director.slots.release_by_entity(id)
|
||
# Прерванная загрузка возвращает резерв слота папки.
|
||
if str(packet.state) == "loading" and not str(packet.loading_folder_id).is_empty():
|
||
if Game.registry != null:
|
||
Game.registry.release_slot(str(packet.loading_folder_id))
|
||
packet.loading_folder_id = ""
|
||
packet.state = "waiting"
|
||
packet.loading_progress = 0.0
|
||
handled = true
|
||
if handled:
|
||
_toast("ПЕРЕДАЧА ПРЕРВАНА · СЛОТ СВОБОДЕН", "warn")
|
||
_refresh_hour()
|
||
|
||
|
||
func _on_slot_press(id: String) -> void:
|
||
for c in slot_cells:
|
||
if c.id == id and c.state == "locked":
|
||
_toast("СЛОТ ДОСТУПЕН В МАГАЗИНЕ", "info")
|
||
|
||
|
||
func _on_packet_to_slot(_cell_id: String, payload: Dictionary) -> void:
|
||
# Слот занимает только настоящее соединение; drop — это попытка подключить.
|
||
if bool(payload.get("is_attack", false)) \
|
||
or not str(payload.get("attack_kind", "")).is_empty():
|
||
_toast("УГРОЗУ — В ЩИТ", "warn")
|
||
return
|
||
var packet_id := str(payload.get("id", ""))
|
||
if packet_id.is_empty():
|
||
return
|
||
_on_packet_connect(packet_id)
|
||
|
||
|
||
func _on_drop_to_folder(folder_id: String, payload: Dictionary) -> void:
|
||
if controller == null:
|
||
return
|
||
var kind := str(payload.get("kind", "file"))
|
||
var entity_id := str(payload.get("id", ""))
|
||
var folder_label := folder_id
|
||
if Game.registry != null and Game.registry.folders.has(folder_id):
|
||
folder_label = str(Game.registry.folders[folder_id].name)
|
||
if kind == "packet":
|
||
if entity_id.is_empty() or controller.hour_director == null:
|
||
return
|
||
if controller.hour_director.receive_packet(entity_id, folder_id):
|
||
_toast("ПАКЕТ ПРИНЯТ В «%s»" % folder_label, "success")
|
||
_refresh_hour()
|
||
else:
|
||
_toast("ПАПКА ЗАПОЛНЕНА ИЛИ НЕТ СОЕДИНЕНИЯ", "warn")
|
||
return
|
||
if kind != "file":
|
||
return
|
||
if entity_id.is_empty() or Game.registry == null:
|
||
return
|
||
if Game.registry.move_file(entity_id, folder_id):
|
||
controller.save()
|
||
if controller.phase == GAME_CONTROLLER.PHASE_PREP:
|
||
_refresh_prep()
|
||
else:
|
||
_refresh_hour()
|
||
_toast("ПЕРЕМЕЩЕНО: %s → «%s»" % [str(payload.get("name", "файл")), folder_label], "success")
|
||
else:
|
||
_toast("НЕ УДАЛОСЬ ПЕРЕМЕСТИТЬ ФАЙЛ", "warn")
|
||
|
||
|
||
func _on_tree_context(folder_id: String, pos: Vector2) -> void:
|
||
var is_section := folder_id.begins_with("sec_")
|
||
var items := [
|
||
{"label": "Новая папка", "id": "new"},
|
||
{"label": "Переименовать", "id": "rename", "disabled": is_section},
|
||
{"label": "Удалить", "id": "delete", "disabled": is_section},
|
||
{"sep": true},
|
||
{"label": "Уничтожить содержимое", "id": "destroy", "danger": true},
|
||
]
|
||
ContextMenu90.show_items(self, items, pos, _on_tree_menu.bind(folder_id))
|
||
|
||
|
||
func _on_tree_menu(action: String, folder_id: String) -> void:
|
||
match action:
|
||
"new":
|
||
_new_folder_in(folder_id)
|
||
"rename":
|
||
_open_rename(folder_id)
|
||
"delete":
|
||
_delete_folder(folder_id)
|
||
"destroy":
|
||
_confirm_destroy_folder(folder_id)
|
||
_:
|
||
_toast("НЕИЗВЕСТНОЕ ДЕЙСТВИЕ", "warn")
|
||
|
||
|
||
## «Уничтожить содержимое»: спрашиваем подтверждение, если папка не пуста.
|
||
func _confirm_destroy_folder(folder_id: String) -> void:
|
||
if Game.registry == null or not Game.registry.folders.has(folder_id):
|
||
_toast("ПАПКА НЕ НАЙДЕНА", "warn")
|
||
return
|
||
var file_ids := _folder_file_ids(folder_id)
|
||
if file_ids.is_empty():
|
||
_toast("ПАПКА ПУСТА", "warn")
|
||
return
|
||
if confirm_modal == null:
|
||
return
|
||
var folder_name := str(Game.registry.folders[folder_id].name)
|
||
_confirm_action = "destroy:" + folder_id
|
||
confirm_modal.ask("Уничтожить содержимое?",
|
||
"Файлы папки «%s» будут удалены. Штраф −5 SLA." % folder_name,
|
||
"Уничтожить", "Отмена", true)
|
||
|
||
|
||
func _folder_file_ids(folder_id: String) -> Array:
|
||
var out: Array = []
|
||
if Game.registry == null:
|
||
return out
|
||
for file_id in Game.registry.files.keys():
|
||
var file = Game.registry.files[file_id]
|
||
if file != null and str(file.folder_id) == folder_id:
|
||
out.append(str(file_id))
|
||
return out
|
||
|
||
|
||
## Реальное уничтожение: удаляем файлы папки и списываем 5 SLA.
|
||
func _destroy_folder_contents(folder_id: String) -> void:
|
||
if Game.registry == null or not Game.registry.folders.has(folder_id):
|
||
_toast("ПАПКА НЕ НАЙДЕНА", "warn")
|
||
return
|
||
var file_ids := _folder_file_ids(folder_id)
|
||
if file_ids.is_empty():
|
||
_toast("ПАПКА ПУСТА", "warn")
|
||
return
|
||
var registry = Game.registry
|
||
for file_id in file_ids:
|
||
# remove_file штатно удаляет только malicious/failed; для осознанного
|
||
# уничтожения помечаем файл и сразу удаляем.
|
||
if not registry.remove_file(file_id):
|
||
registry.mark_malicious(file_id, "destroy")
|
||
registry.remove_file(file_id)
|
||
if _selected_file == file_id:
|
||
_selected_file = ""
|
||
Game.add_sla(-5.0, "destroy")
|
||
if controller != null:
|
||
controller.save()
|
||
if controller != null and controller.phase == GAME_CONTROLLER.PHASE_PREP:
|
||
_refresh_prep()
|
||
else:
|
||
_refresh_hour()
|
||
_toast("СОДЕРЖИМОЕ УНИЧТОЖЕНО · SLA −5", "error")
|
||
if status_bar != null:
|
||
status_bar.flash("sla", "−5", Theme90.ERROR)
|
||
|
||
|
||
## Открыть диалог переименования папки (только для реальных папок, не секций).
|
||
func _open_rename(folder_id: String) -> void:
|
||
if Game.registry == null or not Game.registry.folders.has(folder_id):
|
||
_toast("ПАПКА НЕ НАЙДЕНА", "warn")
|
||
return
|
||
if rename_modal == null:
|
||
return
|
||
_rename_folder_id = folder_id
|
||
rename_modal.open(str(Game.registry.folders[folder_id].name))
|
||
_sync_modal_pause()
|
||
|
||
|
||
## Применить новое имя из RenameModal: пустое/длинное/дубль — только тост.
|
||
func _on_rename_confirmed(new_name: String) -> void:
|
||
_sync_modal_pause()
|
||
var folder_id := _rename_folder_id
|
||
_rename_folder_id = ""
|
||
if Game.registry == null or not Game.registry.folders.has(folder_id):
|
||
_toast("ПАПКА НЕ НАЙДЕНА", "warn")
|
||
return
|
||
var clean := new_name.strip_edges()
|
||
if clean.is_empty():
|
||
_toast("ИМЯ ПАПКИ НЕ МОЖЕТ БЫТЬ ПУСТЫМ", "warn")
|
||
return
|
||
if clean.length() > 16:
|
||
_toast("ИМЯ ДЛИННЕЕ 16 СИМВОЛОВ", "warn")
|
||
return
|
||
if not Game.registry.rename_folder(folder_id, clean):
|
||
_toast("ПАПКА С ТАКИМ ИМЕНЕМ УЖЕ ЕСТЬ", "warn")
|
||
return
|
||
if controller != null:
|
||
controller.save()
|
||
_refresh_prep()
|
||
_toast("ПАПКА ПЕРЕИМЕНОВАНА", "success")
|
||
|
||
|
||
## Удаление папки: реестр разрешает только пустые (без файлов и резерваций).
|
||
func _delete_folder(folder_id: String) -> void:
|
||
if Game.registry == null:
|
||
_toast("ХРАНИЛИЩЕ НЕДОСТУПНО", "error")
|
||
return
|
||
if not Game.registry.delete_folder(folder_id):
|
||
_toast("МОЖНО УДАЛИТЬ ТОЛЬКО ПУСТУЮ ПАПКУ", "warn")
|
||
return
|
||
if controller != null:
|
||
controller.save()
|
||
if _selected_file.is_empty() or not Game.registry.files.has(_selected_file):
|
||
_selected_file = ""
|
||
if controller != null and controller.phase == GAME_CONTROLLER.PHASE_PREP:
|
||
_refresh_prep()
|
||
elif controller != null:
|
||
_refresh_hour()
|
||
_toast("ПАПКА УДАЛЕНА", "success")
|
||
|
||
|
||
func _on_file_context(id: String, pos: Vector2) -> void:
|
||
var items := [
|
||
{"label": "Открыть", "id": "open"},
|
||
{"label": "Прикрепить к запросу", "id": "attach"},
|
||
{"label": "Переименовать", "id": "rename"},
|
||
{"sep": true},
|
||
{"label": "Уничтожить", "id": "destroy", "danger": true},
|
||
]
|
||
ContextMenu90.show_items(self, items, pos, _on_file_menu.bind(id))
|
||
|
||
|
||
func _on_file_menu(action: String, file_id: String) -> void:
|
||
match action:
|
||
"open":
|
||
_on_file_activated(file_id)
|
||
"attach":
|
||
_toast("ВЫБЕРИТЕ ЗАПРОС В ОКНЕ «ОТПРАВКА»", "info")
|
||
"rename":
|
||
_toast("ПЕРЕИМЕНОВАНИЕ ФАЙЛА НЕДОСТУПНО", "info")
|
||
"destroy":
|
||
_destroy_selected_file(file_id)
|
||
_:
|
||
_toast("НЕИЗВЕСТНОЕ ДЕЙСТВИЕ", "warn")
|
||
|
||
|
||
## Уничтожение файла: malicious/locked — это угроза, остальное не удаляется.
|
||
func _destroy_selected_file(file_id: String) -> void:
|
||
if Game.registry == null or not Game.registry.files.has(file_id):
|
||
_toast("ФАЙЛ НЕ НАЙДЕН", "warn")
|
||
return
|
||
var file = Game.registry.files[file_id]
|
||
var loc := str(file.location)
|
||
if loc != "malicious" and loc != "locked":
|
||
_toast("ОБЫЧНЫЕ ФАЙЛЫ НЕ УДАЛЯЮТСЯ", "warn")
|
||
return
|
||
var director = controller.hour_director if controller != null else null
|
||
var ok := false
|
||
if director != null and director.has_method("destroy_attack_file"):
|
||
ok = bool(director.destroy_attack_file(file_id))
|
||
if not ok and Game.registry.has_method("destroy_malicious"):
|
||
ok = bool(Game.registry.destroy_malicious(file_id))
|
||
if not ok:
|
||
_toast("НЕ УДАЛОСЬ УНИЧТОЖИТЬ УГРОЗУ", "warn")
|
||
return
|
||
if controller != null:
|
||
controller.save()
|
||
if controller != null and controller.phase == GAME_CONTROLLER.PHASE_PREP:
|
||
_refresh_prep()
|
||
else:
|
||
_refresh_hour()
|
||
_toast("УГРОЗА УНИЧТОЖЕНА", "success")
|
||
|
||
|
||
func _on_new_folder() -> void:
|
||
_new_folder_in(grid.folder_id)
|
||
|
||
|
||
## «Новая папка» — реальная папка в FileRegistry, затем перерисовка подготовки.
|
||
func _new_folder_in(folder_id: String) -> void:
|
||
var section_id := _section_of_folder(folder_id)
|
||
if section_id.is_empty() and controller != null:
|
||
var d: Dictionary = controller.prep_data()
|
||
if not (d.get("sections", []) as Array).is_empty():
|
||
section_id = str(d["sections"][0].get("id", ""))
|
||
if section_id.is_empty():
|
||
_toast("НЕ ВЫБРАНА СЕКЦИЯ", "warn")
|
||
return
|
||
if Game.registry == null:
|
||
_toast("ХРАНИЛИЩЕ НЕДОСТУПНО", "error")
|
||
return
|
||
if _section_folder_count(section_id) >= 12:
|
||
_toast("В СЕКЦИИ УЖЕ 12 ПАПОК", "warn")
|
||
return
|
||
# Имя генерируется из пула Data и делается уникальным внутри секции.
|
||
var folder_name := _unique_folder_name(section_id, Data.folder_name())
|
||
var folder = Game.registry.create_folder(section_id, folder_name)
|
||
if folder == null:
|
||
_toast("НЕ УДАЛОСЬ СОЗДАТЬ ПАПКУ", "error")
|
||
return
|
||
if controller != null:
|
||
controller.save()
|
||
_refresh_prep()
|
||
_on_folder_selected(str(folder.id))
|
||
_toast("СОЗДАНА ПАПКА «%s»" % str(folder.name), "success")
|
||
|
||
|
||
## Уникальное имя в секции: «Имя», «Имя 2», «Имя 3», … с обрезкой до 16.
|
||
func _unique_folder_name(section_id: String, base: String) -> String:
|
||
var clean := base.strip_edges()
|
||
if clean.is_empty():
|
||
clean = "Разное"
|
||
if clean.length() > 16:
|
||
clean = clean.substr(0, 16)
|
||
if not _folder_name_taken(section_id, clean):
|
||
return clean
|
||
var index := 2
|
||
while index < 100:
|
||
var suffix := " %d" % index
|
||
var stem := clean.substr(0, maxi(1, 16 - suffix.length()))
|
||
var candidate := (stem + suffix).strip_edges()
|
||
if not _folder_name_taken(section_id, candidate):
|
||
return candidate
|
||
index += 1
|
||
return clean.substr(0, 16)
|
||
|
||
|
||
func _folder_name_taken(section_id: String, candidate: String) -> bool:
|
||
if Game.registry == null:
|
||
return false
|
||
var probe := candidate.to_lower()
|
||
for folder in Game.registry.folders.values():
|
||
if str(folder.section) == section_id \
|
||
and str(folder.name).to_lower() == probe:
|
||
return true
|
||
return false
|
||
|
||
|
||
func _section_folder_count(section_id: String) -> int:
|
||
if Game.registry == null:
|
||
return 0
|
||
var n := 0
|
||
for folder in Game.registry.folders.values():
|
||
if str(folder.section) == section_id:
|
||
n += 1
|
||
return n
|
||
|
||
|
||
func _section_of_folder(folder_id: String) -> String:
|
||
if folder_id.is_empty():
|
||
return ""
|
||
if folder_id.begins_with("sec_"):
|
||
return folder_id
|
||
if Game.registry == null:
|
||
return ""
|
||
var folder = Game.registry.folders.get(folder_id)
|
||
if folder == null:
|
||
return ""
|
||
return str(folder.section)
|
||
|
||
|
||
func _on_shop() -> void:
|
||
if shop_modal == null:
|
||
return
|
||
# Магазин доступен только в подготовке и после открытия (час 2).
|
||
if controller == null or controller.phase != GAME_CONTROLLER.PHASE_PREP:
|
||
_toast("МАГАЗИН ДОСТУПЕН В ПОДГОТОВКЕ", "warn")
|
||
return
|
||
if not Game.feature_unlocked("shop"):
|
||
_toast("МАГАЗИН ОТКРОЕТСЯ ПОСЛЕ ПЕРВОГО ЧАСА", "warn")
|
||
return
|
||
if mascot != null:
|
||
mascot.hide_message()
|
||
shop_modal.open(Game.money, Game.upgrades)
|
||
|
||
|
||
func _on_shop_buy(id: String) -> void:
|
||
if controller == null:
|
||
return
|
||
if controller.buy(id):
|
||
_toast("КУПЛЕНО: %s" % _shop_item_name(id), "success")
|
||
shop_modal.refresh(Game.money, Game.upgrades)
|
||
_refresh_prep()
|
||
|
||
|
||
## Название позиции магазина для тоста (id — только фолбэк).
|
||
func _shop_item_name(id: String) -> String:
|
||
var data := get_node_or_null("/root/Data")
|
||
if data != null and data.has_method("shop_item"):
|
||
var item: Dictionary = data.call("shop_item", id)
|
||
var item_name := str(item.get("name_ru", item.get("name", "")))
|
||
if not item_name.is_empty():
|
||
return item_name
|
||
if controller != null and controller.shop != null \
|
||
and controller.shop.has_method("get_item"):
|
||
var item2: Dictionary = controller.shop.get_item(id)
|
||
var item_name2 := str(item2.get("name_ru", item2.get("name", "")))
|
||
if not item_name2.is_empty():
|
||
return item_name2
|
||
return id
|
||
|
||
|
||
func _on_start_hour() -> void:
|
||
if controller == null:
|
||
return
|
||
if controller.phase != GAME_CONTROLLER.PHASE_PREP:
|
||
_toast("ЧАС УЖЕ ИДЁТ", "warn")
|
||
return
|
||
if not controller.start_hour():
|
||
_toast("ЧАС НЕ МОЖЕТ НАЧАТЬСЯ", "warn")
|
||
return
|
||
# phase_changed уже вызвал _on_phase("hour"); вызов идемпотентен.
|
||
_on_phase(GAME_CONTROLLER.PHASE_HOUR)
|
||
|
||
|
||
func _toast(text: String, kind: String = "info") -> void:
|
||
if toast_layer != null:
|
||
toast_layer.toast(text, kind)
|
||
|
||
|
||
# --------------------------------------------------------------------------
|
||
# Фон
|
||
# --------------------------------------------------------------------------
|
||
|
||
class Backdrop:
|
||
extends Control
|
||
|
||
## Спокойный фон: без dither, ровная бумажная заливка.
|
||
var calm := false
|
||
|
||
func _ready() -> void:
|
||
mouse_filter = Control.MOUSE_FILTER_IGNORE
|
||
set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT)
|
||
|
||
func _draw() -> void:
|
||
if calm:
|
||
draw_rect(Rect2(Vector2.ZERO, size), Theme90.PAPER)
|
||
else:
|
||
draw_texture_rect(Theme90.dither_texture(), Rect2(Vector2.ZERO, size), true)
|
||
draw_rect(Rect2(0, 0, size.x, 1), Theme90.SHADE)
|
||
draw_rect(Rect2(0, 0, 1, size.y), Theme90.SHADE)
|
||
draw_rect(Rect2(size.x - 1, 0, 1, size.y), Theme90.FACE_HI)
|
||
draw_rect(Rect2(0, size.y - 1, size.x, 1), Theme90.FACE_HI)
|
||
|
||
|
||
# --------------------------------------------------------------------------
|
||
# Щит-карантин (drop-зона + кнопка удержания против DDoS)
|
||
# --------------------------------------------------------------------------
|
||
|
||
class ShieldDrop:
|
||
extends Control
|
||
|
||
var main: Node = null
|
||
var target := "receive"
|
||
var active := false
|
||
var progress := 0.0
|
||
var holding := false
|
||
var _hover := false
|
||
|
||
|
||
func _ready() -> void:
|
||
mouse_filter = Control.MOUSE_FILTER_STOP
|
||
mouse_default_cursor_shape = Control.CURSOR_POINTING_HAND
|
||
mouse_entered.connect(func() -> void:
|
||
_hover = true
|
||
queue_redraw())
|
||
mouse_exited.connect(func() -> void:
|
||
_hover = false
|
||
queue_redraw())
|
||
tooltip_text = "ЩИТ · ПЕРЕТАЩИТЕ СЮДА УГРОЗУ"
|
||
|
||
|
||
func _draw() -> void:
|
||
var r := Rect2(Vector2.ZERO, size)
|
||
var bg := Theme90.NAVY_DEEP if active else Theme90.NAVY
|
||
draw_rect(r, bg)
|
||
var edge := Theme90.ERROR if active else (Theme90.ACCENT_LIGHT if _hover else Theme90.FACE_LO)
|
||
draw_rect(r, edge, false, 1.0)
|
||
|
||
# щит: полигон + крест
|
||
var fill := Theme90.ERROR if active and holding else Theme90.FACE_HI
|
||
var shield := PackedVector2Array([
|
||
Vector2(10, 2), Vector2(17, 4), Vector2(16, 11),
|
||
Vector2(10, 16), Vector2(4, 11), Vector2(3, 4),
|
||
])
|
||
draw_colored_polygon(shield, fill)
|
||
draw_polyline(PackedVector2Array([
|
||
Vector2(10, 2), Vector2(17, 4), Vector2(16, 11),
|
||
Vector2(10, 16), Vector2(4, 11), Vector2(3, 4), Vector2(10, 2),
|
||
]), Theme90.WHITE, 1.0)
|
||
var cross := Theme90.ERROR if not active else Theme90.WHITE
|
||
draw_line(Vector2(10, 5), Vector2(10, 12), cross, 1.0)
|
||
draw_line(Vector2(6, 8), Vector2(14, 8), cross, 1.0)
|
||
|
||
var f := Theme90.ui()
|
||
Theme90.text(self, f, Vector2(20, 3), "ЩИТ", 8,
|
||
Theme90.ERROR if active else Theme90.ACCENT_LIGHT, 26)
|
||
|
||
# прогресс удержания
|
||
if active:
|
||
var bar := Rect2(20, 13, 25, 3)
|
||
draw_rect(bar, Theme90.NAVY_DEEP)
|
||
var ratio := clampf(progress, 0.0, 1.0)
|
||
draw_rect(Rect2(bar.position, Vector2(floorf(bar.size.x * ratio), bar.size.y)),
|
||
Theme90.SUCCESS if ratio >= 1.0 else Theme90.ERROR)
|
||
else:
|
||
Theme90.text(self, f, Vector2(20, 12), "DROP", 7, Theme90.FACE_LO, 26)
|
||
|
||
|
||
func _can_drop_data(_at_position: Vector2, data: Variant) -> bool:
|
||
if not (data is Dictionary):
|
||
return false
|
||
if str(data.get("kind", "")) != "packet":
|
||
return false
|
||
return bool(data.get("is_attack", false)) or not str(data.get("attack_kind", "")).is_empty()
|
||
|
||
|
||
func _drop_data(_at_position: Vector2, data: Variant) -> void:
|
||
if main != null and data is Dictionary and main.has_method("_on_quarantine_drop"):
|
||
main.call("_on_quarantine_drop", data)
|
||
|
||
|
||
func _gui_input(event: InputEvent) -> void:
|
||
if not (event is InputEventMouseButton):
|
||
return
|
||
var mb := event as InputEventMouseButton
|
||
if mb.button_index != MOUSE_BUTTON_LEFT:
|
||
return
|
||
if mb.pressed:
|
||
if main != null and main.has_method("_on_shield_press"):
|
||
main.call("_on_shield_press", target)
|
||
else:
|
||
if main != null and main.has_method("_on_shield_release"):
|
||
main.call("_on_shield_release")
|
||
accept_event()
|
||
|
||
|
||
# --------------------------------------------------------------------------
|
||
# Плашка DDoS над карточками приёма
|
||
# --------------------------------------------------------------------------
|
||
|
||
class DdosOverlay:
|
||
extends Control
|
||
|
||
var cards := 10
|
||
var hold := 0.0
|
||
var needed := 1.5
|
||
var _t := 0.0
|
||
|
||
|
||
func _ready() -> void:
|
||
mouse_filter = Control.MOUSE_FILTER_IGNORE
|
||
set_process(true)
|
||
|
||
|
||
func _process(delta: float) -> void:
|
||
_t += delta
|
||
queue_redraw()
|
||
|
||
|
||
func _draw() -> void:
|
||
var r := Rect2(Vector2.ZERO, size)
|
||
draw_rect(r, Theme90.NAVY_DEEP)
|
||
var pulse := 0.45 + 0.55 * absf(sin(_t * 4.0))
|
||
draw_rect(r, Color(Theme90.ERROR, pulse), false, 1.0)
|
||
draw_rect(Rect2(0, 0, size.x, 1), Theme90.ERROR)
|
||
|
||
var f := Theme90.ui()
|
||
Theme90.text(self, f, Vector2(6, 3), "DDoS · %d КАРТОЧЕК · ДЕРЖИТЕ ЩИТ" % cards,
|
||
10, Theme90.WHITE, 392)
|
||
|
||
# 8–12 маленьких красных прямоугольников
|
||
var shown := mini(maxi(cards, 8), 12)
|
||
for i in shown:
|
||
draw_rect(Rect2(6.0 + i * 13.0, 19.0, 9.0, 8.0), Theme90.ERROR)
|
||
draw_rect(Rect2(6.0 + i * 13.0, 19.0, 9.0, 1.0), Theme90.ERROR.lightened(0.45))
|
||
|
||
# шкала удержания щита
|
||
var bar := Rect2(180, 21, 214, 4)
|
||
draw_rect(bar, Theme90.NAVY)
|
||
draw_rect(Rect2(bar.position, Vector2(1, bar.size.y)), Theme90.SHADE)
|
||
var ratio := clampf(hold / maxf(0.1, needed), 0.0, 1.0)
|
||
draw_rect(Rect2(bar.position, Vector2(floorf(bar.size.x * ratio), bar.size.y)),
|
||
Theme90.SUCCESS if ratio >= 1.0 else Theme90.ERROR)
|