Implement weather system and developer console features. Added functionality for setting weather conditions, temperature, and wind speed through a new developer console. Enhanced the vault scene to display current weather, temperature, and wind information, and updated game clock to allow time manipulation. Improved cloud rendering and introduced a new climate management system for seasonal temperature adjustments.

This commit is contained in:
Leonid Pershin
2026-08-11 11:37:56 +03:00
parent a81747781d
commit 3b886ab3ab
14 changed files with 928 additions and 63 deletions
+65
View File
@@ -0,0 +1,65 @@
class_name Climate
extends RefCounted
## Климатическая норма: какой температуры и ветра ждать в это время года.
##
## Средняя полоса Европы — ориентир, а не точная модель. Только вычисления:
## погода (weather_system.gd) берёт отсюда цель и плавно к ней идёт.
## Среднемесячная температура, °C, с января по декабрь.
const MONTHLY_MEAN: Array[float] = [
0.5, 1.5, 5.0, 9.5, 14.0, 17.0, 19.0, 18.5, 14.5, 9.5, 4.5, 1.5,
]
## Суточный размах по месяцам: зимой день и ночь ближе друг к другу, чем летом.
const MONTHLY_SWING: Array[float] = [
4.0, 5.0, 7.0, 9.0, 10.0, 11.0, 11.0, 10.5, 9.0, 7.0, 4.5, 3.5,
]
## Самый тёплый час суток; самый холодный — через двенадцать часов от него.
const WARMEST_HOUR := 15.0
## Поправка к температуре по типу погоды, °C. Индекс — WeatherSystem.Kind;
## перечисление сюда не тянем, иначе получится циклическая ссылка между
## классами: погода уже зависит от климата.
const WEATHER_OFFSET: Array[float] = [1.5, 0.0, -1.0, -2.0, -3.0, -1.5, -3.0]
## Характерная скорость ветра по тем же типам, м/с.
const WEATHER_WIND: Array[float] = [2.5, 4.0, 5.5, 7.5, 15.0, 1.0, 5.0]
## Выше этого осадки идут дождём, ниже — снегом.
const SNOW_THRESHOLD := 0.5
## Ветер сильнее этого считаем предельным для визуализации.
const WIND_REFERENCE := 18.0
## Норма для месяца (1..12) и часа суток, без учёта погоды.
static func base_temperature(month: int, hour: float) -> float:
var index := clampi(month - 1, 0, 11)
var mean := MONTHLY_MEAN[index]
var swing := MONTHLY_SWING[index]
# Суточный ход — косинус с максимумом в WARMEST_HOUR.
var phase := (hour - WARMEST_HOUR) / 24.0 * TAU
return mean + swing * 0.5 * cos(phase)
static func weather_offset(kind: int) -> float:
if kind < 0 or kind >= WEATHER_OFFSET.size():
return 0.0
return WEATHER_OFFSET[kind]
static func base_wind(kind: int) -> float:
if kind < 0 or kind >= WEATHER_WIND.size():
return 3.0
return WEATHER_WIND[kind]
## «12°C» — для HUD.
static func temperature_text(celsius: float) -> String:
return "%d°C" % roundi(celsius)
## «4 м/с» — для HUD.
static func wind_text(speed: float) -> String:
return "%d м/с" % roundi(speed)
+1
View File
@@ -0,0 +1 @@
uid://lfpudig0j8s7
+14
View File
@@ -102,6 +102,20 @@ func toggle() -> void:
flow_changed.emit()
## Перевести часы на указанный момент (словарь как у Time). Возвращает false,
## если момент раньше начала партии — назад отматывать нечего.
func set_moment(moment: Dictionary) -> bool:
var target := int(Time.get_unix_time_from_datetime_dict(moment))
if target < _start_unix:
return false
_elapsed_minutes = float(target - _start_unix) / 60.0
_last_whole_minute = int(_elapsed_minutes)
_last_day = _day_index()
minute_passed.emit(_last_whole_minute)
return true
# --- Сохранение -------------------------------------------------------------
func save_data() -> Dictionary:
+80 -20
View File
@@ -12,8 +12,17 @@ extends Node2D
const MOUNTAINS := preload("res://background/PNG/Flat/pointy_mountains.png")
const HILLS_FAR := preload("res://background/PNG/Flat/hills2.png")
const HILLS_NEAR := preload("res://background/PNG/Flat/hills1.png")
const CLOUDS_HIGH := preload("res://background/PNG/Flat/clouds1.png")
const CLOUDS_LOW := preload("res://background/PNG/Flat/clouds2.png")
## Облака — отдельными клубами, а не сплошной лентой: у ленты прямоугольник
## обрезает текстуру, и на небе видно горизонтальный шов, который вдобавок
## едет вместе с ветром.
const CLOUD_PATHS := [
"res://background/PNG/Flat/cloud1.png", "res://background/PNG/Flat/cloud2.png",
"res://background/PNG/Flat/cloud3.png", "res://background/PNG/Flat/cloud4.png",
"res://background/PNG/Flat/cloud5.png", "res://background/PNG/Flat/cloud6.png",
"res://background/PNG/Flat/cloud7.png", "res://background/PNG/Flat/cloud8.png",
"res://background/PNG/Flat/cloud9.png",
]
const CLOUD_COUNT := 30
## Ближний план берём из основного набора: там деревья и трава уже цветные.
## Набор Flat — бледные силуэты, он идёт на дальние слои, которые всё равно
@@ -36,6 +45,8 @@ const HILLS_NEAR_TONE := Color("#5d8248")
const LEFT := -1600.0
const RIGHT := 2900.0
## Сколько крайних столбцов текстуры отбрасываем на стыке плиток.
const EDGE_INSET := 1.0
const SCENERY_SEED := 20120401
## Полоса дёрна на уровне земли.
@@ -57,15 +68,19 @@ var time_scale := 1.0
var _trees: Array[Dictionary] = []
var _grass: Array[Dictionary] = []
var _cloud_puffs: Array[Dictionary] = []
var _drops: Array[Vector2] = []
var _drift := 0.0
var _fall := 0.0
func _ready() -> void:
# Плиточная заливка широких полос требует повтора текстуры на узле.
texture_repeat = CanvasItem.TEXTURE_REPEAT_ENABLED
# Повтор выключен намеренно: полосы мы кладём плитками сами, а с включённым
# повтором фильтрация на краю кадра подмешивает противоположный край
# текстуры — по небу шла тонкая линия во всю ширину.
texture_repeat = CanvasItem.TEXTURE_REPEAT_DISABLED
_scatter_scenery()
_scatter_clouds()
_scatter_drops()
@@ -116,10 +131,28 @@ func _draw_band(
texture: Texture2D, tone: Color, scale: float, distance: float, parallax: float,
light: Color, haze: Color, view: Rect2
) -> void:
var size := Vector2(texture.get_size()) * scale
var tint := (tone * light).lerp(haze, distance)
var offset := view.get_center().x * (1.0 - parallax)
var rect := Rect2(Vector2(LEFT + offset, -size.y), Vector2(RIGHT - LEFT, size.y))
draw_texture_rect(texture, rect, true, (tone * light).lerp(haze, distance))
var texture_size := Vector2(texture.get_size())
# Плитку кладём вручную: draw_texture_rect(tile = true) повторяет текстуру
# в натуральную величину и по обеим осям сразу, из-за чего масштаб
# превращался в лишние ряды с полосой на стыке.
#
# Крайние столбцы пикселей у этих текстур полупрозрачные (сглаживание).
# Встык они складываются дважды и дают вертикальную линию, поэтому берём
# область с отступом в пиксель с каждой стороны и ещё на пиксель
# перекрываем соседние плитки.
var source := Rect2(
Vector2(EDGE_INSET, 0.0), Vector2(texture_size.x - EDGE_INSET * 2.0, texture_size.y)
)
var tile := Vector2(source.size.x * scale, texture_size.y * scale)
var step := tile.x - 1.0
var x := LEFT + fposmod(offset, step) - step
while x < RIGHT:
draw_texture_rect_region(texture, Rect2(Vector2(x, -tile.y), tile), source, tint)
x += step
func _draw_turf(light: Color) -> void:
@@ -146,21 +179,28 @@ func _draw_clouds(view: Rect2, light: Color) -> void:
if clouds <= 0.02:
return
# Полосы держим выше линии горизонта: их нижний край не должен наезжать
# на землю, поэтому высота и масштаб подобраны в паре.
_draw_cloud_band(CLOUDS_HIGH, 1.4, -900.0, 0.35, 0.75, view, light)
_draw_cloud_band(CLOUDS_LOW, 1.6, -620.0, 0.6, 1.0, view, light)
var span := RIGHT - LEFT
var density := clampf(clouds, 0.0, 1.0)
# Чем плотнее облачность, тем свинцовее облака.
var tint := light.lerp(light * Color("#8ea3b8"), density)
for cloud: Dictionary in _cloud_puffs:
# У каждого облака свой порог: облачность прибавляет их числом, а не
# только непрозрачностью, иначе пасмурно выглядит как ясно, но мутнее.
var threshold: float = cloud["threshold"]
if density <= threshold:
continue
var alpha := clampf((density - threshold) * 5.0, 0.0, 1.0) * 0.9
func _draw_cloud_band(
texture: Texture2D, scale: float, height: float, parallax: float,
weight: float, view: Rect2, light: Color
) -> void:
var size := Vector2(texture.get_size()) * scale
var offset := view.get_center().x * (1.0 - parallax) - _drift * parallax
var rect := Rect2(Vector2(LEFT + offset, height), Vector2(RIGHT - LEFT, size.y))
var alpha := clampf(clouds * weight, 0.0, 1.0)
draw_texture_rect(texture, rect, true, Color(light, alpha))
var depth: float = cloud["depth"]
var texture: Texture2D = cloud["texture"]
var size := Vector2(texture.get_size()) * float(cloud["scale"])
var offset := view.get_center().x * (1.0 - depth) - _drift * depth
var x := LEFT + fposmod(float(cloud["x"]) + offset - LEFT, span)
draw_texture_rect(
texture, Rect2(Vector2(x, float(cloud["y"])), size), false, Color(tint, alpha)
)
## Дымка у горизонта: чем гуще, тем сильнее пейзаж растворяется в небе.
@@ -241,6 +281,26 @@ func _scatter_scenery() -> void:
})
func _scatter_clouds() -> void:
var rng := RandomNumberGenerator.new()
rng.seed = SCENERY_SEED + 3
var textures: Array[Texture2D] = []
for path: String in CLOUD_PATHS:
textures.append(load(path) as Texture2D)
for index in CLOUD_COUNT:
_cloud_puffs.append({
"texture": textures[rng.randi_range(0, textures.size() - 1)],
"x": rng.randf_range(LEFT, RIGHT),
"y": rng.randf_range(-1020.0, -430.0),
"scale": rng.randf_range(1.3, 3.4),
# Ближние облака едут быстрее дальних.
"depth": rng.randf_range(0.25, 0.75),
"threshold": rng.randf_range(0.0, 0.9),
})
func _scatter_drops() -> void:
var rng := RandomNumberGenerator.new()
rng.seed = SCENERY_SEED + 7
+173 -1
View File
@@ -31,7 +31,10 @@ enum Mode { SELECT, BUILD, DEMOLISH }
@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 _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
@@ -76,6 +79,7 @@ func _ready() -> void:
_refresh_moment()
_refresh_flow()
_on_weather_changed(_weather.kind)
_register_console_commands()
_status.text = HINT
@@ -191,6 +195,163 @@ func _describe(room: Dictionary) -> String:
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:
@@ -249,10 +410,21 @@ func _flow_style(background: String, border: String) -> StyleBoxFlat:
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:
+100 -33
View File
@@ -1,25 +1,50 @@
class_name WeatherSystem
extends Node
## Погода: текущий тип, плавные переходы и смена по игровому времени.
## Погода: тип, температура, ветер и плавные переходы между состояниями.
##
## Данные и правила, без отрисовки. Вид (surface_view.gd, sky_view.gd) читает
## отсюда `clouds`, `precipitation`, `fog` и `wind` и рисует по ним.
## отсюда `clouds`, `precipitation`, `fog` и `wind`, HUD — `temperature`
## и `wind_speed`. Норму по сезону даёт climate.gd.
##
## Такт берётся у GameClock, поэтому погода стоит вместе с паузой и ускоряется
## вместе со скоростью игры — своего таймера здесь нет намеренно.
enum Kind { CLEAR, CLOUDY, OVERCAST, RAIN, STORM, FOG, SNOW }
## Целевые параметры каждого типа. `clouds` — плотность облаков, `precipitation`
## — сила осадков, `fog` — дымка у горизонта, `wind` — скорость сноса.
## Целевые параметры картинки для каждого типа. Температура и ветер сюда не
## входят: они зависят ещё и от сезона, их считает Climate.
##
## `key` — латинское имя для консоли: набирать русские названия с латинской
## раскладки неудобно, а переключаться посреди отладки — тем более.
const KINDS := {
Kind.CLEAR: {"label": "Ясно", "clouds": 0.12, "precipitation": 0.0, "fog": 0.0, "wind": 0.3},
Kind.CLOUDY: {"label": "Облачно", "clouds": 0.45, "precipitation": 0.0, "fog": 0.0, "wind": 0.5},
Kind.OVERCAST: {"label": "Пасмурно", "clouds": 0.85, "precipitation": 0.0, "fog": 0.06, "wind": 0.4},
Kind.RAIN: {"label": "Дождь", "clouds": 0.95, "precipitation": 0.55, "fog": 0.14, "wind": 0.7},
Kind.STORM: {"label": "Ливень", "clouds": 1.0, "precipitation": 1.0, "fog": 0.22, "wind": 1.0},
Kind.FOG: {"label": "Туман", "clouds": 0.3, "precipitation": 0.0, "fog": 0.62, "wind": 0.12},
Kind.SNOW: {"label": "Снег", "clouds": 0.9, "precipitation": 0.5, "fog": 0.24, "wind": 0.35},
Kind.CLEAR: {
"label": "Ясно", "key": "clear",
"clouds": 0.12, "precipitation": 0.0, "fog": 0.0,
},
Kind.CLOUDY: {
"label": "Облачно", "key": "cloudy",
"clouds": 0.45, "precipitation": 0.0, "fog": 0.0,
},
Kind.OVERCAST: {
"label": "Пасмурно", "key": "overcast",
"clouds": 0.85, "precipitation": 0.0, "fog": 0.06,
},
Kind.RAIN: {
"label": "Дождь", "key": "rain",
"clouds": 0.95, "precipitation": 0.55, "fog": 0.14,
},
Kind.STORM: {
"label": "Ливень", "key": "storm",
"clouds": 1.0, "precipitation": 1.0, "fog": 0.22,
},
Kind.FOG: {
"label": "Туман", "key": "fog",
"clouds": 0.3, "precipitation": 0.0, "fog": 0.62,
},
Kind.SNOW: {
"label": "Снег", "key": "snow",
"clouds": 0.9, "precipitation": 0.5, "fog": 0.24,
},
}
## Куда погода может перейти и с каким относительным весом. Матрица, а не
@@ -27,20 +52,19 @@ const KINDS := {
const TRANSITIONS := {
Kind.CLEAR: {Kind.CLEAR: 4, Kind.CLOUDY: 5, Kind.FOG: 1},
Kind.CLOUDY: {Kind.CLEAR: 4, Kind.CLOUDY: 2, Kind.OVERCAST: 4, Kind.FOG: 1},
Kind.OVERCAST: {Kind.CLOUDY: 4, Kind.OVERCAST: 2, Kind.RAIN: 3, Kind.SNOW: 2},
Kind.OVERCAST: {Kind.CLOUDY: 4, Kind.OVERCAST: 2, Kind.RAIN: 4, Kind.SNOW: 1},
Kind.RAIN: {Kind.RAIN: 2, Kind.STORM: 2, Kind.OVERCAST: 5},
Kind.STORM: {Kind.RAIN: 5, Kind.OVERCAST: 2},
Kind.FOG: {Kind.FOG: 2, Kind.CLEAR: 4, Kind.CLOUDY: 3},
Kind.SNOW: {Kind.SNOW: 3, Kind.OVERCAST: 4, Kind.CLOUDY: 2},
}
## Месяцы, когда осадки выпадают снегом.
const WINTER_MONTHS: Array[int] = [11, 12, 1, 2]
const MIN_DURATION := 120
const MAX_DURATION := 420
## За сколько игровых минут параметры доходят до целевых.
const BLEND_MINUTES := 45.0
## Насколько воздушная масса может отклонить температуру от нормы, °C.
const DRIFT_RANGE := 3.0
## Сменился тип погоды — для HUD и звука.
signal changed(kind: int)
@@ -49,9 +73,14 @@ var kind := Kind.CLEAR
var clouds := 0.12
var precipitation := 0.0
var fog := 0.0
var wind := 0.3
## Ветер 0..1 для картинки; в метрах в секунду — wind_speed.
var wind := 0.15
var wind_speed := 2.5
var temperature := 8.0
var _minutes_left := MIN_DURATION
## Отклонение текущей воздушной массы от климатической нормы, °C.
var _temperature_drift := 0.0
var _rng := RandomNumberGenerator.new()
var _clock: GameClock
@@ -65,6 +94,7 @@ func _ready() -> void:
func attach_clock(clock: GameClock) -> void:
_clock = clock
_clock.minute_passed.connect(_on_minute_passed)
_apply_climate(1.0)
func label() -> String:
@@ -76,6 +106,29 @@ func is_snowing() -> bool:
return kind == Kind.SNOW
# --- Ручное управление (консоль разработчика) -------------------------------
## Поставить конкретную погоду немедленно, минуя матрицу переходов.
func force_kind(value: int, hold_minutes := MAX_DURATION) -> void:
kind = clampi(value, 0, KINDS.size() - 1)
_minutes_left = hold_minutes
changed.emit(kind)
## Сдвинуть воздушную массу так, чтобы сейчас было примерно `celsius`.
## Значение держится до следующей смены погоды, а не жёстко фиксируется.
func nudge_temperature(celsius: float) -> void:
_temperature_drift += celsius - temperature
temperature = celsius
func set_wind_speed(speed: float) -> void:
wind_speed = maxf(speed, 0.0)
wind = clampf(wind_speed / Climate.WIND_REFERENCE, 0.0, 1.0)
# --- Сохранение -------------------------------------------------------------
func save_data() -> Dictionary:
return {
"kind": int(kind),
@@ -83,7 +136,9 @@ func save_data() -> Dictionary:
"clouds": clouds,
"precipitation": precipitation,
"fog": fog,
"wind": wind,
"wind_speed": wind_speed,
"temperature": temperature,
"drift": _temperature_drift,
}
@@ -93,14 +148,17 @@ func load_data(data: Dictionary) -> void:
clouds = float(data.get("clouds", 0.12))
precipitation = float(data.get("precipitation", 0.0))
fog = float(data.get("fog", 0.0))
wind = float(data.get("wind", 0.3))
temperature = float(data.get("temperature", 8.0))
_temperature_drift = float(data.get("drift", 0.0))
set_wind_speed(float(data.get("wind_speed", 2.5)))
changed.emit(kind)
# --- Внутреннее -------------------------------------------------------------
func _on_minute_passed(_total_minutes: int) -> void:
_blend_toward_target()
_apply_target(1.0 / BLEND_MINUTES)
_apply_climate(1.0 / BLEND_MINUTES)
_minutes_left -= 1
if _minutes_left > 0:
@@ -108,20 +166,29 @@ func _on_minute_passed(_total_minutes: int) -> void:
kind = _roll_next()
_minutes_left = _rng.randi_range(MIN_DURATION, MAX_DURATION)
# Новая погода — новая воздушная масса.
_temperature_drift = _rng.randf_range(-DRIFT_RANGE, DRIFT_RANGE)
changed.emit(kind)
## Параметры не прыгают вместе с типом: они доходят до цели за BLEND_MINUTES.
func _blend_toward_target() -> void:
_apply_target(1.0 / BLEND_MINUTES)
## Параметры картинки не прыгают вместе с типом: они доходят до цели плавно.
func _apply_target(weight: float) -> void:
var target: Dictionary = KINDS[kind]
clouds = lerpf(clouds, float(target["clouds"]), weight)
precipitation = lerpf(precipitation, float(target["precipitation"]), weight)
fog = lerpf(fog, float(target["fog"]), weight)
wind = lerpf(wind, float(target["wind"]), weight)
## Температура и ветер идут к сезонной норме с поправкой на тип погоды.
func _apply_climate(weight: float) -> void:
if _clock == null:
return
var moment := _clock.now()
var target := Climate.base_temperature(int(moment["month"]), SkyCycle.hour_of(moment))
target += Climate.weather_offset(kind) + _temperature_drift
temperature = lerpf(temperature, target, weight)
set_wind_speed(lerpf(wind_speed, Climate.base_wind(kind), weight))
func _roll_next() -> int:
@@ -134,15 +201,15 @@ func _roll_next() -> int:
for candidate: int in options:
pick -= int(options[candidate])
if pick <= 0:
return _seasonal(candidate)
return _by_temperature(candidate)
return kind
## Снег уместен только зимой; в остальное время он превращается в дождь.
func _seasonal(candidate: int) -> int:
if candidate != Kind.SNOW:
return candidate
if _clock == null:
## Форму осадков решает градусник, а не календарь: у нуля дождь становится
## снегом и наоборот. Так это можно проверить, подкрутив температуру.
func _by_temperature(candidate: int) -> int:
if candidate == Kind.SNOW and temperature > Climate.SNOW_THRESHOLD + 1.5:
return Kind.RAIN
var month := int(_clock.now()["month"])
return Kind.SNOW if WINTER_MONTHS.has(month) else Kind.RAIN
if candidate == Kind.RAIN and temperature < Climate.SNOW_THRESHOLD:
return Kind.SNOW
return candidate
+230
View File
@@ -0,0 +1,230 @@
extends CanvasLayer
## Консоль разработчика: ввод команд поверх игры.
##
## Автозагрузка, поэтому доступна из любой сцены. Своих знаний об игре у неё
## нет — команды регистрируют сцены через register(). Так консоль не тянет за
## собой игровую логику, а команды живут рядом с тем, чем управляют.
##
## Открывается тильдой или F1. Слой обрабатывается всегда, поэтому работает
## и на паузе — иначе отлаживать остановленную игру было бы нечем.
const HISTORY_LIMIT := 40
## Файловый мост для внешних инструментов (MCP, скрипты, CI).
##
## Внедрённые события клавиатуры доносят до LineEdit код клавиши, но не символ,
## поэтому набрать команду извне нельзя. Мост обходит клавиатуру: во входной
## файл кладут строки, консоль их выполняет и удаляет файл, а весь вывод
## дублирует в лог. Только в отладочной сборке — в релиз это не попадёт.
const BRIDGE_IN := "user://console_in.txt"
const BRIDGE_LOG := "user://console_log.txt"
const BRIDGE_INTERVAL := 0.25
const COLOR_ECHO := "#5d7288"
const COLOR_INFO := "#dbe7f0"
const COLOR_OK := "#2ee6c5"
const COLOR_ERROR := "#f2704a"
@onready var _root: Control = $Root
@onready var _output: RichTextLabel = $Root/Panel/Body/Output
@onready var _entry: LineEdit = $Root/Panel/Body/InputRow/Input
## Имя команды -> {description, usage, handler}
var _commands := {}
var _history: Array[String] = []
var _history_index := 0
var _log: FileAccess
func _ready() -> void:
_root.visible = false
_entry.text_submitted.connect(_on_submitted)
_register_builtin()
_start_bridge()
print_line("Консоль разработчика. `help` — список команд.", COLOR_INFO)
func _input(event: InputEvent) -> void:
if not (event is InputEventKey and event.is_pressed() and not event.is_echo()):
return
var key := (event as InputEventKey).keycode
if key == KEY_QUOTELEFT or key == KEY_F1:
toggle()
get_viewport().set_input_as_handled()
return
if not _root.visible:
return
# История — стрелками, как в настоящей консоли.
match key:
KEY_UP:
_recall(-1)
get_viewport().set_input_as_handled()
KEY_DOWN:
_recall(1)
get_viewport().set_input_as_handled()
KEY_ESCAPE:
close()
get_viewport().set_input_as_handled()
# --- Открытие / закрытие ----------------------------------------------------
func toggle() -> void:
if _root.visible:
close()
else:
open()
func open() -> void:
_root.visible = true
_entry.clear()
_entry.grab_focus()
func close() -> void:
_root.visible = false
_entry.release_focus()
# --- Реестр команд ----------------------------------------------------------
## Сцены регистрируют свои команды здесь. `handler` принимает Array[String]
## аргументов и возвращает String — что напечатать.
func register(command: String, description: String, handler: Callable, usage := "") -> void:
_commands[command] = {
"description": description,
"usage": usage,
"handler": handler,
}
func unregister(command: String) -> void:
_commands.erase(command)
func print_line(text: String, color := COLOR_INFO) -> void:
_output.append_text("[color=%s]%s[/color]\n" % [color, text])
if _log != null:
# Сбрасываем сразу: внешний инструмент читает файл, пока игра жива.
_log.store_line(text)
_log.flush()
# --- Файловый мост ----------------------------------------------------------
func _start_bridge() -> void:
if not OS.is_debug_build():
return
# Лог перезаписывается на каждый запуск — так в нём только текущая сессия.
_log = FileAccess.open(BRIDGE_LOG, FileAccess.WRITE)
var timer := Timer.new()
timer.wait_time = BRIDGE_INTERVAL
timer.autostart = true
timer.timeout.connect(_poll_bridge)
add_child(timer)
func _poll_bridge() -> void:
if not FileAccess.file_exists(BRIDGE_IN):
return
var file := FileAccess.open(BRIDGE_IN, FileAccess.READ)
if file == null:
return
var payload := file.get_as_text()
file.close()
# Удаляем сразу, иначе команды выполнятся повторно на следующем тике.
DirAccess.remove_absolute(BRIDGE_IN)
for line: String in payload.split("\n", false):
_on_submitted(line)
# --- Выполнение -------------------------------------------------------------
func _on_submitted(line: String) -> void:
_entry.clear()
var trimmed := line.strip_edges()
if trimmed.is_empty():
return
_remember(trimmed)
print_line("> " + trimmed, COLOR_ECHO)
var parts := trimmed.split(" ", false)
var command := String(parts[0]).to_lower()
var arguments: Array[String] = []
for index in range(1, parts.size()):
arguments.append(String(parts[index]))
_execute(command, arguments)
func _execute(command: String, arguments: Array[String]) -> void:
if not _commands.has(command):
print_line("Неизвестная команда: %s. `help` — список." % command, COLOR_ERROR)
return
var entry: Dictionary = _commands[command]
var handler: Callable = entry["handler"]
# Сцена могла смениться — её команды умирают вместе с ней.
if not handler.is_valid():
_commands.erase(command)
print_line("Команда %s больше недоступна в этой сцене." % command, COLOR_ERROR)
return
var result: Variant = handler.call(arguments)
if result is String and not String(result).is_empty():
print_line(String(result), COLOR_OK)
# --- История ----------------------------------------------------------------
func _remember(line: String) -> void:
_history.append(line)
if _history.size() > HISTORY_LIMIT:
_history.remove_at(0)
_history_index = _history.size()
func _recall(step: int) -> void:
if _history.is_empty():
return
_history_index = clampi(_history_index + step, 0, _history.size())
if _history_index == _history.size():
_entry.clear()
return
_entry.text = _history[_history_index]
_entry.caret_column = _entry.text.length()
# --- Встроенные команды -----------------------------------------------------
func _register_builtin() -> void:
register("help", "список команд", _cmd_help)
register("cls", "очистить вывод", _cmd_clear)
func _cmd_help(_arguments: Array[String]) -> String:
var names := _commands.keys()
names.sort()
var lines := PackedStringArray()
for name: String in names:
var entry: Dictionary = _commands[name]
var usage := String(entry["usage"])
var head := name if usage.is_empty() else "%s %s" % [name, usage]
lines.append(" %-24s %s" % [head, String(entry["description"])])
return "\n".join(lines)
func _cmd_clear(_arguments: Array[String]) -> String:
_output.clear()
return ""
+1
View File
@@ -0,0 +1 @@
uid://bp5qxf7p5q3ty
+120
View File
@@ -0,0 +1,120 @@
class_name WeatherIcon
extends Control
## Значок погоды для HUD.
##
## Рисуется процедурно: иконок в наборе фона нет, а тащить шрифт с символами
## ради семи состояний неоправданно. Заодно значок красится темой, а не
## растром, и одинаково чёткий на любом масштабе.
const SUN := Color("#f2b134")
const MOON := Color("#cfd8e6")
const CLOUD := Color("#c6d4e0")
const CLOUD_DARK := Color("#8fa5ba")
const RAIN := Color("#4aa8f2")
const SNOW := Color("#e6f1f7")
const BOLT := Color("#f2b134")
## Тип погоды из WeatherSystem.Kind.
var kind := 0
## Ночью в ясную погоду вместо солнца показываем луну.
var night := false
func _ready() -> void:
custom_minimum_size = Vector2(24.0, 24.0)
func set_state(weather_kind: int, is_night: bool) -> void:
if weather_kind == kind and is_night == night:
return
kind = weather_kind
night = is_night
queue_redraw()
func _draw() -> void:
var box := Rect2(Vector2.ZERO, size)
var center := box.get_center()
match kind:
WeatherSystem.Kind.CLEAR:
_draw_body(center, size.x * 0.30)
WeatherSystem.Kind.CLOUDY:
_draw_body(center - Vector2(size.x * 0.14, size.y * 0.14), size.x * 0.22)
_draw_cloud(center + Vector2(size.x * 0.06, size.y * 0.10), CLOUD)
WeatherSystem.Kind.OVERCAST:
_draw_cloud(center - Vector2(size.x * 0.10, size.y * 0.06), CLOUD_DARK)
_draw_cloud(center + Vector2(size.x * 0.08, size.y * 0.08), CLOUD)
WeatherSystem.Kind.RAIN:
_draw_cloud(center - Vector2(0.0, size.y * 0.14), CLOUD)
_draw_streaks(center, RAIN)
WeatherSystem.Kind.STORM:
_draw_cloud(center - Vector2(0.0, size.y * 0.14), CLOUD_DARK)
_draw_streaks(center, RAIN)
_draw_bolt(center)
WeatherSystem.Kind.FOG:
_draw_fog(box)
WeatherSystem.Kind.SNOW:
_draw_cloud(center - Vector2(0.0, size.y * 0.14), CLOUD)
_draw_flakes(center)
## Солнце или луна — смотря какое время суток.
func _draw_body(center: Vector2, radius: float) -> void:
if night:
# Полумесяц: круг с вырезом, вырез закрашен фоном значка.
draw_circle(center, radius, MOON)
draw_circle(center + Vector2(radius * 0.55, -radius * 0.30), radius * 0.92, Color(0, 0, 0, 0))
draw_arc(center, radius, PI * 0.35, PI * 1.65, 20, MOON, 2.0)
return
draw_circle(center, radius, SUN)
for index in 8:
var angle := TAU * float(index) / 8.0
var direction := Vector2(cos(angle), sin(angle))
draw_line(center + direction * (radius * 1.35), center + direction * (radius * 1.85), SUN, 1.6)
func _draw_cloud(center: Vector2, tint: Color) -> void:
var unit := size.x * 0.12
draw_circle(center + Vector2(-unit * 1.1, 0.0), unit * 1.05, tint)
draw_circle(center + Vector2(unit * 1.1, unit * 0.15), unit * 0.9, tint)
draw_circle(center + Vector2(0.0, -unit * 0.55), unit * 1.25, tint)
draw_rect(Rect2(center + Vector2(-unit * 1.9, -unit * 0.1), Vector2(unit * 3.8, unit * 1.1)), tint)
func _draw_streaks(center: Vector2, tint: Color) -> void:
var unit := size.x * 0.12
for index in 3:
var start := center + Vector2((float(index) - 1.0) * unit * 1.4, unit * 1.3)
draw_line(start, start + Vector2(-unit * 0.35, unit * 1.3), tint, 1.8)
func _draw_flakes(center: Vector2) -> void:
var unit := size.x * 0.12
for index in 3:
draw_circle(center + Vector2((float(index) - 1.0) * unit * 1.4, unit * 1.9), unit * 0.32, SNOW)
func _draw_bolt(center: Vector2) -> void:
var unit := size.x * 0.12
draw_polygon(
PackedVector2Array([
center + Vector2(unit * 0.2, unit * 1.1),
center + Vector2(unit * 1.2, unit * 1.1),
center + Vector2(unit * 0.4, unit * 2.6),
center + Vector2(unit * 0.7, unit * 1.7),
center + Vector2(-unit * 0.2, unit * 1.7),
]),
PackedColorArray([BOLT, BOLT, BOLT, BOLT, BOLT])
)
func _draw_fog(box: Rect2) -> void:
for index in 3:
var y := box.position.y + box.size.y * (0.32 + float(index) * 0.18)
var inset := box.size.x * (0.08 + float(index % 2) * 0.12)
draw_line(
Vector2(box.position.x + inset, y), Vector2(box.end.x - inset, y),
CLOUD_DARK, 2.0
)
+1
View File
@@ -0,0 +1 @@
uid://dpifh4u0gmgxw