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_icon: WeatherIcon = $Hud/Root/TopRight/Row/ClockBar/Body/Climate/Icon @onready var _weather_label: Label = $Hud/Root/TopRight/Row/ClockBar/Body/Climate/WeatherLabel @onready var _temp_label: Label = $Hud/Root/TopRight/Row/ClockBar/Body/Climate/TempLabel @onready var _wind_label: Label = $Hud/Root/TopRight/Row/ClockBar/Body/Climate/WindLabel @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) _register_console_commands() _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) # --- Команды консоли -------------------------------------------------------- ## Команды живут здесь, а не в самой консоли: они управляют этой сценой и ## умирают вместе с ней — консоль сама выбросит их, когда Callable протухнет. func _register_console_commands() -> void: var console := get_node_or_null("/root/DevConsole") if console == null: return console.register("stats", "текущее состояние партии", _cmd_stats) console.register("time", "перевести часы", _cmd_time, "ЧЧ:ММ") console.register("date", "сменить дату", _cmd_date, "ДД.ММ.ГГГГ") console.register("speed", "скорость игры, 0 — пауза", _cmd_speed, "0..4") console.register( "weather", "поставить погоду", _cmd_weather, "clear|cloudy|overcast|rain|storm|fog|snow" ) console.register("temp", "задать температуру", _cmd_temp, "°C") console.register("wind", "задать ветер", _cmd_wind, "м/с") console.register("build", "построить комнату", _cmd_build, "тип этаж колонка") console.register("wipe", "снести все комнаты", _cmd_wipe) func _cmd_stats(_arguments: Array[String]) -> String: var totals := _grid.totals() return "\n".join(PackedStringArray([ "Время: %s, %s (%s, x%d)" % [ _clock.date_text(), _clock.time_text(), "идёт" if _clock.running else "пауза", _clock.speed, ], "Погода: %s, %s, ветер %s" % [ _weather.label(), Climate.temperature_text(_weather.temperature), Climate.wind_text(_weather.wind_speed), ], "Облака: %.2f · осадки %.2f · дымка %.2f" % [ _weather.clouds, _weather.precipitation, _weather.fog, ], "Комнат: %d" % _grid.rooms.size(), "Ресурсы: питание %+d, вода %+d, еда %+d" % [ int(totals["power"]), int(totals["water"]), int(totals["food"]), ], ])) func _cmd_time(arguments: Array[String]) -> String: if arguments.is_empty(): return "Нужен аргумент: time ЧЧ:ММ" var parts := arguments[0].split(":") if parts.size() != 2: return "Не разобрал время: %s" % arguments[0] var moment := _clock.now() moment["hour"] = clampi(int(String(parts[0])), 0, 23) moment["minute"] = clampi(int(String(parts[1])), 0, 59) if not _clock.set_moment(moment): return "Этот момент раньше начала партии." _refresh_all() return "Часы: %s" % _clock.time_text() func _cmd_date(arguments: Array[String]) -> String: if arguments.is_empty(): return "Нужен аргумент: date ДД.ММ.ГГГГ" var parts := arguments[0].split(".") if parts.size() != 3: return "Не разобрал дату: %s" % arguments[0] var moment := _clock.now() moment["day"] = clampi(int(String(parts[0])), 1, 31) moment["month"] = clampi(int(String(parts[1])), 1, 12) moment["year"] = int(String(parts[2])) if not _clock.set_moment(moment): return "Этот момент раньше начала партии." _refresh_all() return "Дата: %s" % _clock.date_text() func _cmd_speed(arguments: Array[String]) -> String: if arguments.is_empty(): return "Нужен аргумент: speed 0..4" var value := int(arguments[0]) if value <= 0: _clock.pause() return "Пауза." _clock.set_speed(value) return "Скорость x%d" % _clock.speed func _cmd_weather(arguments: Array[String]) -> String: if arguments.is_empty(): return "Нужен аргумент: weather clear|cloudy|overcast|rain|storm|fog|snow" # Понимаем и латинский ключ, и русское название — как удобнее. var wanted := arguments[0].to_lower() for kind: int in WeatherSystem.KINDS: var entry: Dictionary = WeatherSystem.KINDS[kind] if String(entry["key"]) != wanted and String(entry["label"]).to_lower() != wanted: continue _weather.force_kind(kind) _refresh_all() return "Погода: %s" % _weather.label() return "Не знаю такой погоды: %s" % arguments[0] func _cmd_temp(arguments: Array[String]) -> String: if arguments.is_empty(): return "Нужен аргумент: temp °C" _weather.nudge_temperature(float(arguments[0])) _refresh_all() return "Температура: %s" % Climate.temperature_text(_weather.temperature) func _cmd_wind(arguments: Array[String]) -> String: if arguments.is_empty(): return "Нужен аргумент: wind м/с" _weather.set_wind_speed(float(arguments[0])) _refresh_all() return "Ветер: %s" % Climate.wind_text(_weather.wind_speed) func _cmd_build(arguments: Array[String]) -> String: if arguments.size() < 3: return "Нужно: build тип этаж колонка. Типы: %s" % ", ".join(VaultGrid.KINDS.keys()) var kind := arguments[0] if not VaultGrid.KINDS.has(kind): return "Нет такого типа: %s" % kind var a_floor := int(arguments[1]) - 1 var column := int(arguments[2]) if not _grid.place(kind, a_floor, column): return "Сюда не поставить: занято или вплотную к шахте." _after_change("Построено из консоли: %s" % VaultGrid.KINDS[kind]["label"]) return "Готово: %s на этаже %d" % [VaultGrid.KINDS[kind]["label"], a_floor + 1] func _cmd_wipe(_arguments: Array[String]) -> String: var removed := _grid.rooms.size() _grid.rooms.clear() _after_change("Убежище очищено.") return "Снесено комнат: %d" % removed ## После вмешательства извне проще обновить всё, чем вспоминать, что зависит ## от чего. func _refresh_all() -> void: _refresh_moment() _refresh_environment() _refresh_weather_hud() _refresh_flow() _refresh_stats() # --- Ход времени ------------------------------------------------------------ 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() _refresh_weather_hud() func _on_weather_changed(_kind: int) -> void: _refresh_weather_hud() func _refresh_weather_hud() -> void: var hour := SkyCycle.hour_of(_clock.now()) var is_night := SkyCycle.sun_progress(hour) < 0.0 _weather_icon.set_state(_weather.kind, is_night) _weather_label.text = _weather.label() _temp_label.text = Climate.temperature_text(_weather.temperature) _wind_label.text = Climate.wind_text(_weather.wind_speed) 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 = "Убежище загружено."