diff --git a/CLAUDE.md b/CLAUDE.md index c36ec78..15b1b58 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -67,7 +67,10 @@ - `game_clock.gd` — игровые дата и время, скорость, своя пауза. Такт наружу — сигнал `minute_passed`; темп задаётся `MINUTES_PER_SECOND`. - `weather_system.gd` — тип погоды, матрица переходов и плавная смена параметров. - Такт берёт у часов, поэтому стоит вместе с паузой. + Такт берёт у часов, поэтому стоит вместе с паузой. Форма осадков решается + температурой, а не месяцем: у нуля дождь становится снегом. +- `climate.gd` — сезонная норма температуры и ветра (средняя полоса Европы). + Перечисление погоды сюда не тянем — вышла бы циклическая ссылка. - `sky_cycle.gd` — палитра неба по часу суток, положение солнца и луны. Чистые вычисления. Отрисовка — только `_draw()`, читают модель и ничего не решают: @@ -84,14 +87,58 @@ ### Текстуры фона +**Ловушка с плиткой.** `draw_texture_rect(tex, rect, tile = true)` повторяет +текстуру в её натуральную величину и **по обеим осям**. Если растянуть +прямоугольник ради масштаба, текстура просто уложится в него несколько раз, и +на стыке копий пойдёт горизонтальная полоса через весь экран. Плюс при +`texture_repeat = ENABLED` фильтрация на краю подмешивает противоположный край +текстуры — ещё одна тонкая линия. + +Поэтому полосы пейзажа кладутся плитками вручную. При этом важен ещё один +момент: крайние столбцы пикселей у этих текстур полупрозрачные от сглаживания, +и встык они складываются дважды, давая **вертикальную** линию. Отсюда +`EDGE_INSET` — плитки рисуются через `draw_texture_rect_region()` с отступом +в пиксель с каждой стороны, плюс пиксель нахлёста. Повтор на узле выключен. + `background/` — набор Kenney Background Elements. Два стиля, и они не взаимозаменяемы: `PNG/Flat/*` — бледные, почти белые силуэты, их место на дальних планах, где цвет всё равно задаётся тонировкой; `PNG/*` — цветные спрайты для ближнего плана. Перепутать легко: деревья из Flat на переднем плане выглядят выцветшими. +## Консоль разработчика + +Открывается тильдой или F1 в любой сцене, работает и на паузе. Сама об игре +ничего не знает: команды регистрируют сцены через `DevConsole.register(...)`, +и они умирают вместе со сценой — консоль выбрасывает команду, когда её +`Callable` протухает. + +Команды игровой сцены живут в `vault_scene.gd`, в разделе «Команды консоли»: +`stats`, `time`, `date`, `speed`, `weather`, `temp`, `wind`, `build`, `wipe`. +У погоды есть латинские ключи (`snow`, `rain`, …) — набирать русские названия +с латинской раскладки посреди отладки неудобно. + +### Файловый мост — как выполнять команды из MCP + +Набрать команду через `game_manage(op="input_key")` нельзя: внедрённые события +доносят до `LineEdit` код клавиши, но не символ. Enter при этом проходит — то +есть дело не в фокусе. Поэтому в консоли есть мост через файлы в `user://` +(только `OS.is_debug_build()`, в релиз не попадает): + +```bash +DIR="$APPDATA/Godot/app_userdata/GWorld" +printf 'weather storm\ntemp -3\nstats\n' > "$DIR/console_in.txt" +cat "$DIR/console_log.txt" +``` + +Консоль опрашивает `console_in.txt` четыре раза в секунду, выполняет строки и +удаляет файл. Весь вывод дублируется в `console_log.txt`, который +перезаписывается на каждый запуск игры. Работает и с закрытой консолью, и на +паузе. + ## Автозагрузки -`GameSettings` (`GameSettingsService`) и `SaveManager` (`SaveManagerService`). +`GameSettings` (`GameSettingsService`), `SaveManager` (`SaveManagerService`) +и `DevConsole` (сцена `scenes/ui/dev_console.tscn`). У обеих есть `class_name`: константы, перечисления и типы берём через него, экземпляр — через `get_node("/root/...")`. Имя автозагрузки редактор не резолвит без перезапуска проекта. diff --git a/project.godot b/project.godot index 4ee180c..8b3a1c5 100644 --- a/project.godot +++ b/project.godot @@ -20,6 +20,7 @@ config/icon="res://icon.svg" _mcp_game_helper="*res://addons/godot_ai/runtime/game_helper.gd" GameSettings="*res://scripts/autoload/game_settings.gd" SaveManager="*res://scripts/autoload/save_manager.gd" +DevConsole="*res://scenes/ui/dev_console.tscn" [display] diff --git a/scenes/game/vault.tscn b/scenes/game/vault.tscn index 955e1ec..fb617b2 100644 --- a/scenes/game/vault.tscn +++ b/scenes/game/vault.tscn @@ -8,6 +8,7 @@ [ext_resource type="Theme" uid="uid://c6kkopjj3snag" path="res://themes/main_menu.theme.tres" id="4_sch52"] [ext_resource type="PackedScene" uid="uid://cvx4ot7ugyprk" path="res://scenes/ui/pause_menu.tscn" id="5_lffpp"] [ext_resource type="Script" uid="uid://7wstqw8ve2he" path="res://scripts/game/game_clock.gd" id="6_mkhle"] +[ext_resource type="Script" uid="uid://dpifh4u0gmgxw" path="res://scripts/ui/weather_icon.gd" id="7_lvdr8"] [ext_resource type="Script" uid="uid://by1cwvs3fc3pa" path="res://scripts/game/weather_system.gd" id="9_m0p8k"] [sub_resource type="StyleBoxFlat" id="StyleBoxFlat_lvdr8"] @@ -154,18 +155,43 @@ theme_override_styles/panel = SubResource("StyleBoxFlat_mkhle") layout_mode = 2 theme_override_constants/separation = 6 +[node name="Climate" type="HBoxContainer" parent="Hud/Root/TopRight/Row/ClockBar/Body" unique_id=1529257714] +layout_mode = 2 +theme_override_constants/separation = 8 +alignment = 2 + +[node name="Icon" type="Control" parent="Hud/Root/TopRight/Row/ClockBar/Body/Climate" unique_id=788552715] +custom_minimum_size = Vector2(22, 22) +layout_mode = 2 +size_flags_vertical = 4 +script = ExtResource("7_lvdr8") + +[node name="WeatherLabel" type="Label" parent="Hud/Root/TopRight/Row/ClockBar/Body/Climate" unique_id=214206235] +layout_mode = 2 +theme_override_colors/font_color = Color(0.7764706, 0.83137256, 0.8784314, 1) +theme_override_font_sizes/font_size = 14 +text = "Ясно" +vertical_alignment = 1 + +[node name="TempLabel" type="Label" parent="Hud/Root/TopRight/Row/ClockBar/Body/Climate" unique_id=663157161] +layout_mode = 2 +theme_override_colors/font_color = Color(0.9490196, 0.69411767, 0.20392157, 1) +theme_override_font_sizes/font_size = 14 +text = "8°C" +vertical_alignment = 1 + +[node name="WindLabel" type="Label" parent="Hud/Root/TopRight/Row/ClockBar/Body/Climate" unique_id=298035379] +layout_mode = 2 +theme_override_colors/font_color = Color(0.48235294, 0.56078434, 0.6392157, 1) +theme_override_font_sizes/font_size = 14 +text = "2 м/с" +vertical_alignment = 1 + [node name="Moment" type="HBoxContainer" parent="Hud/Root/TopRight/Row/ClockBar/Body" unique_id=195744038] layout_mode = 2 theme_override_constants/separation = 10 alignment = 2 -[node name="WeatherLabel" type="Label" parent="Hud/Root/TopRight/Row/ClockBar/Body/Moment" unique_id=895640468] -layout_mode = 2 -theme_override_colors/font_color = Color(0.48235294, 0.56078434, 0.6392157, 1) -theme_override_font_sizes/font_size = 14 -text = "Ясно" -vertical_alignment = 1 - [node name="DateLabel" type="Label" parent="Hud/Root/TopRight/Row/ClockBar/Body/Moment" unique_id=1045252723] layout_mode = 2 theme_override_colors/font_color = Color(0.56078434, 0.64705884, 0.7294118, 1) diff --git a/scenes/ui/dev_console.tscn b/scenes/ui/dev_console.tscn new file mode 100644 index 0000000..a84453f --- /dev/null +++ b/scenes/ui/dev_console.tscn @@ -0,0 +1,60 @@ +[gd_scene format=3 uid="uid://wddada4uxq4s"] + +[ext_resource type="Script" uid="uid://bp5qxf7p5q3ty" path="res://scripts/ui/dev_console.gd" id="1_0be3j"] +[ext_resource type="Theme" uid="uid://c6kkopjj3snag" path="res://themes/main_menu.theme.tres" id="2_4dryq"] + +[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_fk3y3"] +content_margin_left = 20.0 +content_margin_top = 16.0 +content_margin_right = 20.0 +content_margin_bottom = 16.0 +bg_color = Color(0.019607844, 0.039215688, 0.078431375, 0.9490196) +border_width_bottom = 2 +border_color = Color(0.18039216, 0.9019608, 0.77254903, 0.6) + +[node name="DevConsole" type="CanvasLayer" unique_id=1036744236] +process_mode = 3 +layer = 200 +script = ExtResource("1_0be3j") + +[node name="Root" type="Control" parent="." unique_id=1883814073] +visible = false +layout_mode = 3 +anchors_preset = 15 +anchor_right = 1.0 +anchor_bottom = 1.0 +mouse_filter = 2 +theme = ExtResource("2_4dryq") + +[node name="Panel" type="PanelContainer" parent="Root" unique_id=323575355] +layout_mode = 0 +anchor_right = 1.0 +anchor_bottom = 0.46 +theme_override_styles/panel = SubResource("StyleBoxFlat_fk3y3") + +[node name="Body" type="VBoxContainer" parent="Root/Panel" unique_id=1904841198] +layout_mode = 2 +theme_override_constants/separation = 8 + +[node name="Output" type="RichTextLabel" parent="Root/Panel/Body" unique_id=1032431750] +layout_mode = 2 +size_flags_vertical = 3 +theme_override_font_sizes/normal_font_size = 15 +bbcode_enabled = true +scroll_following = true + +[node name="InputRow" type="HBoxContainer" parent="Root/Panel/Body" unique_id=1053997656] +layout_mode = 2 +theme_override_constants/separation = 8 + +[node name="Prompt" type="Label" parent="Root/Panel/Body/InputRow" unique_id=1647652394] +layout_mode = 2 +theme_override_colors/font_color = Color(0.18039216, 0.9019608, 0.77254903, 1) +theme_override_font_sizes/font_size = 16 +text = ">" + +[node name="Input" type="LineEdit" parent="Root/Panel/Body/InputRow" unique_id=653784574] +layout_mode = 2 +size_flags_horizontal = 3 +theme_override_font_sizes/font_size = 16 +placeholder_text = "команда" diff --git a/scripts/game/climate.gd b/scripts/game/climate.gd new file mode 100644 index 0000000..0c912aa --- /dev/null +++ b/scripts/game/climate.gd @@ -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) diff --git a/scripts/game/climate.gd.uid b/scripts/game/climate.gd.uid new file mode 100644 index 0000000..82223ca --- /dev/null +++ b/scripts/game/climate.gd.uid @@ -0,0 +1 @@ +uid://lfpudig0j8s7 diff --git a/scripts/game/game_clock.gd b/scripts/game/game_clock.gd index 4b0e306..7a2874f 100644 --- a/scripts/game/game_clock.gd +++ b/scripts/game/game_clock.gd @@ -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: diff --git a/scripts/game/surface_view.gd b/scripts/game/surface_view.gd index 2e414d5..d19938c 100644 --- a/scripts/game/surface_view.gd +++ b/scripts/game/surface_view.gd @@ -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 diff --git a/scripts/game/vault_scene.gd b/scripts/game/vault_scene.gd index f65c727..806a500 100644 --- a/scripts/game/vault_scene.gd +++ b/scripts/game/vault_scene.gd @@ -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: diff --git a/scripts/game/weather_system.gd b/scripts/game/weather_system.gd index 5eb28f4..38de286 100644 --- a/scripts/game/weather_system.gd +++ b/scripts/game/weather_system.gd @@ -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 diff --git a/scripts/ui/dev_console.gd b/scripts/ui/dev_console.gd new file mode 100644 index 0000000..f3a2a15 --- /dev/null +++ b/scripts/ui/dev_console.gd @@ -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 "" diff --git a/scripts/ui/dev_console.gd.uid b/scripts/ui/dev_console.gd.uid new file mode 100644 index 0000000..85ee774 --- /dev/null +++ b/scripts/ui/dev_console.gd.uid @@ -0,0 +1 @@ +uid://bp5qxf7p5q3ty diff --git a/scripts/ui/weather_icon.gd b/scripts/ui/weather_icon.gd new file mode 100644 index 0000000..55ed273 --- /dev/null +++ b/scripts/ui/weather_icon.gd @@ -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 + ) diff --git a/scripts/ui/weather_icon.gd.uid b/scripts/ui/weather_icon.gd.uid new file mode 100644 index 0000000..4e1bf17 --- /dev/null +++ b/scripts/ui/weather_icon.gd.uid @@ -0,0 +1 @@ +uid://dpifh4u0gmgxw