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)