extends Node2D ## Игровой слой: убежище в разрезе, вид сбоку. ## ## Связывает три части: модель (VaultGrid), отрисовку (VaultView) и HUD. ## Здесь же живёт контракт сохранения — сцена лежит в группе "persistent". ## ## Управление: ЛКМ действует по режиму из нижней панели (по умолчанию — выбор ## комнаты), ПКМ/СКМ — тащить вид, колесо — зум, WASD и стрелки — прокрутка. ## Строки сводки ресурсов: ключ из VaultGrid.totals() → подпись и цвет. const STAT_ROWS := { "power": {"label": "Питание", "color": "#f2b134"}, "water": {"label": "Вода", "color": "#4aa8f2"}, "food": {"label": "Еда", "color": "#f2704a"}, } const HINT := "ЛКМ — выбрать комнату · ПКМ — тащить вид · колесо — зум" ## Насколько мышь может сместиться между нажатием и отпусканием, чтобы это ## всё ещё считалось кликом, а не перетаскиванием вида. const CLICK_SLACK := 6.0 ## Режим курсора. По умолчанию сцена ничего не строит: строительство ## включается только выбором типа комнаты в нижней панели. enum Mode { SELECT, BUILD, DEMOLISH } @onready var _saves: SaveManagerService = get_node("/root/SaveManager") @onready var _vault: VaultView = $View @onready var _camera: VaultCamera = $Camera @onready var _clock: GameClock = $Clock @onready var _weather: WeatherSystem = $Weather @onready var _sky: SkyView = $Sky @onready var _surface: SurfaceView = $Surface @onready var _weather_label: Label = $Hud/Root/TopRight/Row/ClockBar/Body/Moment/WeatherLabel @onready var _stats: HBoxContainer = $Hud/Root/TopBar/Stats @onready var _date_label: Label = $Hud/Root/TopRight/Row/ClockBar/Body/Moment/DateLabel @onready var _time_label: Label = $Hud/Root/TopRight/Row/ClockBar/Body/Moment/TimeLabel @onready var _flow_controls: HBoxContainer = $Hud/Root/TopRight/Row/ClockBar/Body/Controls @onready var _tools: HBoxContainer = $Hud/Root/BottomAnchor/Row/BuildBar/Panel/Tools @onready var _status: Label = $Hud/Root/BottomAnchor/Row/BuildBar/Panel/Status var _grid := VaultGrid.new() var _mode := Mode.SELECT ## Тип комнаты, который построится в режиме Mode.BUILD. var _build_kind := "" ## Якорная ячейка выбранной комнаты; (-1, -1) — ничего не выбрано. var _selected_cell := Vector2i(-1, -1) var _stat_labels := {} var _tool_group := ButtonGroup.new() var _right_pressed := false var _right_press_position := Vector2.ZERO var _flow_group := ButtonGroup.new() var _speed_group := ButtonGroup.new() var _play_button: Button var _pause_button: Button var _speed_buttons := {} func _ready() -> void: _grid.reset_to_starter() _vault.grid = _grid add_to_group(SaveManagerService.PERSIST_GROUP) _saves.begin_session(scene_file_path) _build_stats() _build_tools() _build_flow_controls() _refresh_stats() _weather.attach_clock(_clock) _weather.changed.connect(_on_weather_changed) _clock.minute_passed.connect(_on_minute_passed) _clock.flow_changed.connect(_refresh_flow) _refresh_moment() _refresh_flow() _on_weather_changed(_weather.kind) _status.text = HINT func _process(_delta: float) -> void: # В режиме выбора призрака нет: пустой тип и (-1, -1) гасят подсветку. if _mode == Mode.SELECT: _vault.set_ghost("", Vector2i(-1, -1), false) return _vault.set_ghost(_build_kind, _cell_under_mouse(), _mode == Mode.DEMOLISH) ## ПКМ обслуживается здесь, а не в _unhandled_input: камера забирает эти ## события себе под перетаскивание вида. Событие не гасим — камера должна ## получить его как обычно. func _input(event: InputEvent) -> void: if not (event is InputEventMouseButton): return var button := event as InputEventMouseButton if button.button_index != MOUSE_BUTTON_RIGHT: return if button.pressed: _right_pressed = true _right_press_position = button.position return if not _right_pressed: return _right_pressed = false # Вид не двигали — значит это клик, а не перетаскивание. if button.position.distance_to(_right_press_position) <= CLICK_SLACK: _reset_to_select() func _unhandled_input(event: InputEvent) -> void: if not (event is InputEventMouseButton): return var button := event as InputEventMouseButton if button.button_index != MOUSE_BUTTON_LEFT or not button.pressed: return # Берём точку из самого события, а не текущее положение курсора: клик # должен срабатывать там, где кликнули. _apply_at(_cell_at_screen(button.position)) get_viewport().set_input_as_handled() # --- Строительство ---------------------------------------------------------- func _cell_under_mouse() -> Vector2i: return VaultGrid.cell_at(_vault.get_local_mouse_position()) ## Ячейка под точкой экрана. Учитывает камеру: переводим из координат ## окна в локальные координаты вида. func _cell_at_screen(screen_position: Vector2) -> Vector2i: var local := _vault.get_global_transform_with_canvas().affine_inverse() * screen_position return VaultGrid.cell_at(local) func _apply_at(cell: Vector2i) -> void: match _mode: Mode.SELECT: _select_at(cell) Mode.DEMOLISH: if _grid.remove_at(cell.y, cell.x): _after_change("Комната снесена.") else: _status.text = "Здесь нечего сносить." Mode.BUILD: if _grid.place(_build_kind, cell.y, cell.x): _after_change("Построено: %s" % VaultGrid.KINDS[_build_kind]["label"]) else: _status.text = "Здесь строить нельзя: занято или вплотную к шахте." func _after_change(message: String) -> void: # Индексы комнат после изменения съезжают — выделение сбрасываем. _set_selection(Vector2i(-1, -1)) _vault.queue_redraw() _refresh_stats() _status.text = message # --- Выбор ------------------------------------------------------------------ func _select_at(cell: Vector2i) -> void: var index := _grid.room_index_at(cell.y, cell.x) if index < 0: _set_selection(Vector2i(-1, -1)) _status.text = HINT return _set_selection(cell) _status.text = _describe(_grid.rooms[index]) func _set_selection(cell: Vector2i) -> void: _selected_cell = cell _vault.set_selection(cell) ## Строка для нижней панели: что за комната, на каком этаже и её баланс. func _describe(room: Dictionary) -> String: var kind: Dictionary = VaultGrid.KINDS[String(room["kind"])] var parts := PackedStringArray() parts.append(String(kind["label"])) parts.append("этаж %d" % (int(room["floor"]) + 1)) for key: String in STAT_ROWS: var amount := int(kind[key]) if amount != 0: parts.append("%s %+d" % [String(STAT_ROWS[key]["label"]).to_lower(), amount]) return " · ".join(parts) # --- Ход времени ------------------------------------------------------------ func _build_flow_controls() -> void: # Тема рассчитана на крупные кнопки меню; для панели времени нужен свой, # компактный набор стилей. Ресурсы общие на все кнопки — их всего четыре. var styles := { "normal": _flow_style("#ffffff0d", "#ffffff1f"), "hover": _flow_style("#2ee6c51f", "#2ee6c566"), "pressed": _flow_style("#2ee6c52e", "#2ee6c5cc"), "focus": _flow_style("#2ee6c51a", "#2ee6c599"), } _play_button = _add_flow_button("▶", _flow_group, styles) _play_button.pressed.connect(_clock.play) _pause_button = _add_flow_button("❚❚", _flow_group, styles) _pause_button.pressed.connect(_clock.pause) var gap := Control.new() gap.custom_minimum_size.x = 8 _flow_controls.add_child(gap) for multiplier: int in GameClock.SPEEDS: # Выбор скорости заодно снимает паузу — отдельно жать «play» не нужно. var button := _add_flow_button("x%d" % multiplier, _speed_group, styles) button.pressed.connect(_clock.set_speed.bind(multiplier)) _speed_buttons[multiplier] = button func _add_flow_button(label: String, group: ButtonGroup, styles: Dictionary) -> Button: var button := Button.new() button.text = label button.toggle_mode = true button.button_group = group button.custom_minimum_size.x = 34 button.add_theme_font_size_override("font_size", 13) for state: String in styles: button.add_theme_stylebox_override(state, styles[state]) _flow_controls.add_child(button) return button func _flow_style(background: String, border: String) -> StyleBoxFlat: var style := StyleBoxFlat.new() style.bg_color = Color(background) style.border_color = Color(border) style.set_border_width_all(1) style.set_corner_radius_all(5) style.content_margin_left = 9.0 style.content_margin_right = 9.0 style.content_margin_top = 4.0 style.content_margin_bottom = 4.0 return style func _on_minute_passed(_total_minutes: int) -> void: _refresh_moment() _refresh_environment() func _on_weather_changed(_kind: int) -> void: _weather_label.text = _weather.label() func _refresh_moment() -> void: _date_label.text = _clock.date_text() _time_label.text = _clock.time_text() ## Небо и поверхность зависят от одних и тех же двух вещей — часа и погоды. func _refresh_environment() -> void: var hour := SkyCycle.hour_of(_clock.now()) _sky.set_conditions(hour, _weather.clouds) _surface.set_conditions(hour, _weather) ## Кнопки только отражают состояние часов — источник правды там. func _refresh_flow() -> void: # Облака и осадки идут в такт игре: на паузе они замирают. _surface.time_scale = float(_clock.speed) if _clock.running else 0.0 _play_button.set_pressed_no_signal(_clock.running) _pause_button.set_pressed_no_signal(not _clock.running) for multiplier: int in _speed_buttons: var button: Button = _speed_buttons[multiplier] button.set_pressed_no_signal(multiplier == _clock.speed) # --- HUD -------------------------------------------------------------------- func _build_stats() -> void: for key: String in STAT_ROWS: var row := VBoxContainer.new() row.add_theme_constant_override("separation", 0) var caption := Label.new() caption.text = String(STAT_ROWS[key]["label"]) caption.add_theme_font_size_override("font_size", 14) caption.add_theme_color_override("font_color", Color("#8fa5ba")) row.add_child(caption) var value := Label.new() value.add_theme_font_size_override("font_size", 24) value.add_theme_color_override("font_color", Color(String(STAT_ROWS[key]["color"]))) row.add_child(value) _stat_labels[key] = value _stats.add_child(row) func _refresh_stats() -> void: var totals := _grid.totals() for key: String in _stat_labels: var amount := int(totals[key]) var label: Label = _stat_labels[key] label.text = "%+d" % amount # Дефицит важнее оформления: красим в тревожный цвет, а не в цвет ресурса. if amount < 0: label.add_theme_color_override("font_color", Color("#f2704a")) else: label.add_theme_color_override("font_color", Color(String(STAT_ROWS[key]["color"]))) func _build_tools() -> void: # Отдельной кнопки «Выбор» нет: выбор — это состояние по умолчанию, когда # ни один инструмент не нажат. allow_unpress даёт отжать активный # инструмент повторным кликом и вернуться к выбору. _tool_group.allow_unpress = true for kind: String in VaultGrid.KINDS: var button := Button.new() button.text = String(VaultGrid.KINDS[kind]["label"]) button.toggle_mode = true button.button_group = _tool_group button.add_theme_font_size_override("font_size", 18) button.toggled.connect(_on_kind_toggled.bind(kind)) _tools.add_child(button) var separator := Control.new() separator.custom_minimum_size.x = 20 _tools.add_child(separator) var demolish := Button.new() demolish.text = "Снести" demolish.toggle_mode = true demolish.button_group = _tool_group demolish.add_theme_font_size_override("font_size", 18) demolish.toggled.connect(_on_demolish_toggled) _tools.add_child(demolish) ## Возврат к выбору: ни один инструмент не нажат. func _reset_to_select() -> void: if _mode == Mode.SELECT: return _mode = Mode.SELECT _build_kind = "" var active := _tool_group.get_pressed_button() if active != null: active.set_pressed_no_signal(false) _status.text = HINT func _on_kind_toggled(is_on: bool, kind: String) -> void: if not is_on: # Инструмент отжали — если он и был активным, возвращаемся к выбору. if _mode == Mode.BUILD and _build_kind == kind: _reset_to_select() return _mode = Mode.BUILD _build_kind = kind _set_selection(Vector2i(-1, -1)) _status.text = "Стройка: %s · ЛКМ — поставить · ПКМ — отмена" % VaultGrid.KINDS[kind]["label"] func _on_demolish_toggled(is_on: bool) -> void: if not is_on: if _mode == Mode.DEMOLISH: _reset_to_select() return _mode = Mode.DEMOLISH _build_kind = "" _set_selection(Vector2i(-1, -1)) _status.text = "Снос: кликните по комнате · ПКМ — отмена" # --- Сохранение ------------------------------------------------------------- func save_data() -> Dictionary: return { "rooms": _grid.to_array(), "clock": _clock.save_data(), "weather": _weather.save_data(), "camera": { "x": _camera.position.x, "y": _camera.position.y, "zoom": _camera.zoom.x, }, } func load_data(data: Dictionary) -> void: _grid.from_array(data.get("rooms", [])) _clock.load_data(data.get("clock", {})) _weather.load_data(data.get("weather", {})) _refresh_environment() var camera_state: Dictionary = data.get("camera", {}) if not camera_state.is_empty(): _camera.position = Vector2( float(camera_state.get("x", 0.0)), float(camera_state.get("y", 0.0)) ) var level := float(camera_state.get("zoom", 1.0)) _camera.zoom = Vector2(level, level) _set_selection(Vector2i(-1, -1)) _vault.queue_redraw() _refresh_stats() _status.text = "Убежище загружено."