Files
g-world/scripts/game/vault_scene.gd
T

588 lines
21 KiB
GDScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 = "Убежище загружено."