diff --git a/CLAUDE.md b/CLAUDE.md
index ab3d850..c36ec78 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -57,16 +57,37 @@
## Игровой слой
-Три файла в `scripts/game/`, разделены намеренно:
+Файлы в `scripts/game/` разделены по принципу «данные и правила отдельно, отрисовка отдельно».
-- `vault_grid.gd` — только данные и правила (`RefCounted`, без узлов). Сюда идут изменения
- логики: типы комнат, размещение, ресурсы.
-- `vault_view.gd` — только `_draw()`. Сюда идут изменения картинки. Когда появится графика,
- переписывается этот файл, остальные не трогаются.
-- `vault_scene.gd` — связка модели, вида, камеры и HUD плюс контракт сохранения.
+Модель и правила — без узлов и без `_draw()`:
+
+- `vault_grid.gd` — сетка и комнаты. Сюда идут типы комнат, размещение, ресурсы.
+ Вертикальное смещение бункера под землю — константа `DEPTH`, её учитывают и
+ `cell_rect()`, и `cell_at()`, поэтому картинка и попадание мышью не разъезжаются.
+- `game_clock.gd` — игровые дата и время, скорость, своя пауза. Такт наружу — сигнал
+ `minute_passed`; темп задаётся `MINUTES_PER_SECOND`.
+- `weather_system.gd` — тип погоды, матрица переходов и плавная смена параметров.
+ Такт берёт у часов, поэтому стоит вместе с паузой.
+- `sky_cycle.gd` — палитра неба по часу суток, положение солнца и луны. Чистые вычисления.
+
+Отрисовка — только `_draw()`, читают модель и ничего не решают:
+
+- `sky_view.gd` — градиент неба, звёзды, светила.
+- `surface_view.gd` — поверхность: горы, холмы, деревья, облака, осадки.
+- `vault_view.gd` — разрез: порода, корпус, шахта, комнаты, будка входа.
+
+`vault_scene.gd` связывает всё это с камерой и HUD и держит контракт сохранения.
Новый тип комнаты добавляется одной записью в `VaultGrid.KINDS`: кнопка в панели строительства
-и подсчёт ресурсов подхватятся сами.
+и подсчёт ресурсов подхватятся сами. Новый тип погоды — записью в `WeatherSystem.KINDS`
+плюс строкой в `TRANSITIONS`.
+
+### Текстуры фона
+
+`background/` — набор Kenney Background Elements. Два стиля, и они не взаимозаменяемы:
+`PNG/Flat/*` — бледные, почти белые силуэты, их место на дальних планах, где цвет всё равно
+задаётся тонировкой; `PNG/*` — цветные спрайты для ближнего плана. Перепутать легко: деревья
+из Flat на переднем плане выглядят выцветшими.
## Автозагрузки
diff --git a/background/Samples/colored_castle.png b/background/Samples/colored_castle.png
deleted file mode 100644
index 107d276..0000000
Binary files a/background/Samples/colored_castle.png and /dev/null differ
diff --git a/background/Samples/colored_castle.png.import b/background/Samples/colored_castle.png.import
deleted file mode 100644
index 194e50e..0000000
--- a/background/Samples/colored_castle.png.import
+++ /dev/null
@@ -1,40 +0,0 @@
-[remap]
-
-importer="texture"
-type="CompressedTexture2D"
-uid="uid://caxx1gqjw4p3f"
-path="res://.godot/imported/colored_castle.png-41cd94defcf5b6bd80a752d0fb461a53.ctex"
-metadata={
-"vram_texture": false
-}
-
-[deps]
-
-source_file="res://background/Samples/colored_castle.png"
-dest_files=["res://.godot/imported/colored_castle.png-41cd94defcf5b6bd80a752d0fb461a53.ctex"]
-
-[params]
-
-compress/mode=0
-compress/high_quality=false
-compress/lossy_quality=0.7
-compress/uastc_level=0
-compress/rdo_quality_loss=0.0
-compress/hdr_compression=1
-compress/normal_map=0
-compress/channel_pack=0
-mipmaps/generate=false
-mipmaps/limit=-1
-roughness/mode=0
-roughness/src_normal=""
-process/channel_remap/red=0
-process/channel_remap/green=1
-process/channel_remap/blue=2
-process/channel_remap/alpha=3
-process/fix_alpha_border=true
-process/premult_alpha=false
-process/normal_map_invert_y=false
-process/hdr_as_srgb=false
-process/hdr_clamp_exposure=false
-process/size_limit=0
-detect_3d/compress_to=1
diff --git a/background/Samples/colored_desert.png b/background/Samples/colored_desert.png
deleted file mode 100644
index 5ce4ad6..0000000
Binary files a/background/Samples/colored_desert.png and /dev/null differ
diff --git a/background/Samples/colored_desert.png.import b/background/Samples/colored_desert.png.import
deleted file mode 100644
index 20e3730..0000000
--- a/background/Samples/colored_desert.png.import
+++ /dev/null
@@ -1,40 +0,0 @@
-[remap]
-
-importer="texture"
-type="CompressedTexture2D"
-uid="uid://crsg8yt6dsw4x"
-path="res://.godot/imported/colored_desert.png-dc25543c1ff9794be0c25da84f41ae84.ctex"
-metadata={
-"vram_texture": false
-}
-
-[deps]
-
-source_file="res://background/Samples/colored_desert.png"
-dest_files=["res://.godot/imported/colored_desert.png-dc25543c1ff9794be0c25da84f41ae84.ctex"]
-
-[params]
-
-compress/mode=0
-compress/high_quality=false
-compress/lossy_quality=0.7
-compress/uastc_level=0
-compress/rdo_quality_loss=0.0
-compress/hdr_compression=1
-compress/normal_map=0
-compress/channel_pack=0
-mipmaps/generate=false
-mipmaps/limit=-1
-roughness/mode=0
-roughness/src_normal=""
-process/channel_remap/red=0
-process/channel_remap/green=1
-process/channel_remap/blue=2
-process/channel_remap/alpha=3
-process/fix_alpha_border=true
-process/premult_alpha=false
-process/normal_map_invert_y=false
-process/hdr_as_srgb=false
-process/hdr_clamp_exposure=false
-process/size_limit=0
-detect_3d/compress_to=1
diff --git a/background/Samples/colored_forest.png b/background/Samples/colored_forest.png
deleted file mode 100644
index 68cc7c5..0000000
Binary files a/background/Samples/colored_forest.png and /dev/null differ
diff --git a/background/Samples/colored_forest.png.import b/background/Samples/colored_forest.png.import
deleted file mode 100644
index 1009346..0000000
--- a/background/Samples/colored_forest.png.import
+++ /dev/null
@@ -1,40 +0,0 @@
-[remap]
-
-importer="texture"
-type="CompressedTexture2D"
-uid="uid://dqgm3ros2l86y"
-path="res://.godot/imported/colored_forest.png-b603082a8e002be5f2044e5aeed8e028.ctex"
-metadata={
-"vram_texture": false
-}
-
-[deps]
-
-source_file="res://background/Samples/colored_forest.png"
-dest_files=["res://.godot/imported/colored_forest.png-b603082a8e002be5f2044e5aeed8e028.ctex"]
-
-[params]
-
-compress/mode=0
-compress/high_quality=false
-compress/lossy_quality=0.7
-compress/uastc_level=0
-compress/rdo_quality_loss=0.0
-compress/hdr_compression=1
-compress/normal_map=0
-compress/channel_pack=0
-mipmaps/generate=false
-mipmaps/limit=-1
-roughness/mode=0
-roughness/src_normal=""
-process/channel_remap/red=0
-process/channel_remap/green=1
-process/channel_remap/blue=2
-process/channel_remap/alpha=3
-process/fix_alpha_border=true
-process/premult_alpha=false
-process/normal_map_invert_y=false
-process/hdr_as_srgb=false
-process/hdr_clamp_exposure=false
-process/size_limit=0
-detect_3d/compress_to=1
diff --git a/background/Samples/colored_talltrees.png b/background/Samples/colored_talltrees.png
deleted file mode 100644
index f335acf..0000000
Binary files a/background/Samples/colored_talltrees.png and /dev/null differ
diff --git a/background/Samples/colored_talltrees.png.import b/background/Samples/colored_talltrees.png.import
deleted file mode 100644
index 117e852..0000000
--- a/background/Samples/colored_talltrees.png.import
+++ /dev/null
@@ -1,40 +0,0 @@
-[remap]
-
-importer="texture"
-type="CompressedTexture2D"
-uid="uid://clkg3a7j25vcy"
-path="res://.godot/imported/colored_talltrees.png-c8c61e11c017dc56870d082ab76b2bd0.ctex"
-metadata={
-"vram_texture": false
-}
-
-[deps]
-
-source_file="res://background/Samples/colored_talltrees.png"
-dest_files=["res://.godot/imported/colored_talltrees.png-c8c61e11c017dc56870d082ab76b2bd0.ctex"]
-
-[params]
-
-compress/mode=0
-compress/high_quality=false
-compress/lossy_quality=0.7
-compress/uastc_level=0
-compress/rdo_quality_loss=0.0
-compress/hdr_compression=1
-compress/normal_map=0
-compress/channel_pack=0
-mipmaps/generate=false
-mipmaps/limit=-1
-roughness/mode=0
-roughness/src_normal=""
-process/channel_remap/red=0
-process/channel_remap/green=1
-process/channel_remap/blue=2
-process/channel_remap/alpha=3
-process/fix_alpha_border=true
-process/premult_alpha=false
-process/normal_map_invert_y=false
-process/hdr_as_srgb=false
-process/hdr_clamp_exposure=false
-process/size_limit=0
-detect_3d/compress_to=1
diff --git a/background/Samples/uncolored_castle.png b/background/Samples/uncolored_castle.png
deleted file mode 100644
index 9ddc542..0000000
Binary files a/background/Samples/uncolored_castle.png and /dev/null differ
diff --git a/background/Samples/uncolored_castle.png.import b/background/Samples/uncolored_castle.png.import
deleted file mode 100644
index 0a251a0..0000000
--- a/background/Samples/uncolored_castle.png.import
+++ /dev/null
@@ -1,40 +0,0 @@
-[remap]
-
-importer="texture"
-type="CompressedTexture2D"
-uid="uid://btmicau3ia0gf"
-path="res://.godot/imported/uncolored_castle.png-9dc9ddef5f268efb7b94ac168d74c32a.ctex"
-metadata={
-"vram_texture": false
-}
-
-[deps]
-
-source_file="res://background/Samples/uncolored_castle.png"
-dest_files=["res://.godot/imported/uncolored_castle.png-9dc9ddef5f268efb7b94ac168d74c32a.ctex"]
-
-[params]
-
-compress/mode=0
-compress/high_quality=false
-compress/lossy_quality=0.7
-compress/uastc_level=0
-compress/rdo_quality_loss=0.0
-compress/hdr_compression=1
-compress/normal_map=0
-compress/channel_pack=0
-mipmaps/generate=false
-mipmaps/limit=-1
-roughness/mode=0
-roughness/src_normal=""
-process/channel_remap/red=0
-process/channel_remap/green=1
-process/channel_remap/blue=2
-process/channel_remap/alpha=3
-process/fix_alpha_border=true
-process/premult_alpha=false
-process/normal_map_invert_y=false
-process/hdr_as_srgb=false
-process/hdr_clamp_exposure=false
-process/size_limit=0
-detect_3d/compress_to=1
diff --git a/background/Samples/uncolored_desert.png b/background/Samples/uncolored_desert.png
deleted file mode 100644
index 781110b..0000000
Binary files a/background/Samples/uncolored_desert.png and /dev/null differ
diff --git a/background/Samples/uncolored_desert.png.import b/background/Samples/uncolored_desert.png.import
deleted file mode 100644
index 23b3505..0000000
--- a/background/Samples/uncolored_desert.png.import
+++ /dev/null
@@ -1,40 +0,0 @@
-[remap]
-
-importer="texture"
-type="CompressedTexture2D"
-uid="uid://lssvxv0eijl4"
-path="res://.godot/imported/uncolored_desert.png-78b9bf49dd60ef634b76a8b132ec0dd5.ctex"
-metadata={
-"vram_texture": false
-}
-
-[deps]
-
-source_file="res://background/Samples/uncolored_desert.png"
-dest_files=["res://.godot/imported/uncolored_desert.png-78b9bf49dd60ef634b76a8b132ec0dd5.ctex"]
-
-[params]
-
-compress/mode=0
-compress/high_quality=false
-compress/lossy_quality=0.7
-compress/uastc_level=0
-compress/rdo_quality_loss=0.0
-compress/hdr_compression=1
-compress/normal_map=0
-compress/channel_pack=0
-mipmaps/generate=false
-mipmaps/limit=-1
-roughness/mode=0
-roughness/src_normal=""
-process/channel_remap/red=0
-process/channel_remap/green=1
-process/channel_remap/blue=2
-process/channel_remap/alpha=3
-process/fix_alpha_border=true
-process/premult_alpha=false
-process/normal_map_invert_y=false
-process/hdr_as_srgb=false
-process/hdr_clamp_exposure=false
-process/size_limit=0
-detect_3d/compress_to=1
diff --git a/background/Samples/uncolored_forest.png b/background/Samples/uncolored_forest.png
deleted file mode 100644
index 47bc261..0000000
Binary files a/background/Samples/uncolored_forest.png and /dev/null differ
diff --git a/background/Samples/uncolored_forest.png.import b/background/Samples/uncolored_forest.png.import
deleted file mode 100644
index c3be63b..0000000
--- a/background/Samples/uncolored_forest.png.import
+++ /dev/null
@@ -1,40 +0,0 @@
-[remap]
-
-importer="texture"
-type="CompressedTexture2D"
-uid="uid://c5338moupk3w8"
-path="res://.godot/imported/uncolored_forest.png-18a539af26d75f1bbaff45fcd1a43d00.ctex"
-metadata={
-"vram_texture": false
-}
-
-[deps]
-
-source_file="res://background/Samples/uncolored_forest.png"
-dest_files=["res://.godot/imported/uncolored_forest.png-18a539af26d75f1bbaff45fcd1a43d00.ctex"]
-
-[params]
-
-compress/mode=0
-compress/high_quality=false
-compress/lossy_quality=0.7
-compress/uastc_level=0
-compress/rdo_quality_loss=0.0
-compress/hdr_compression=1
-compress/normal_map=0
-compress/channel_pack=0
-mipmaps/generate=false
-mipmaps/limit=-1
-roughness/mode=0
-roughness/src_normal=""
-process/channel_remap/red=0
-process/channel_remap/green=1
-process/channel_remap/blue=2
-process/channel_remap/alpha=3
-process/fix_alpha_border=true
-process/premult_alpha=false
-process/normal_map_invert_y=false
-process/hdr_as_srgb=false
-process/hdr_clamp_exposure=false
-process/size_limit=0
-detect_3d/compress_to=1
diff --git a/background/Samples/uncolored_hills.png b/background/Samples/uncolored_hills.png
deleted file mode 100644
index 7bc084d..0000000
Binary files a/background/Samples/uncolored_hills.png and /dev/null differ
diff --git a/background/Samples/uncolored_hills.png.import b/background/Samples/uncolored_hills.png.import
deleted file mode 100644
index 5dc13dc..0000000
--- a/background/Samples/uncolored_hills.png.import
+++ /dev/null
@@ -1,40 +0,0 @@
-[remap]
-
-importer="texture"
-type="CompressedTexture2D"
-uid="uid://3hpiabdluup4"
-path="res://.godot/imported/uncolored_hills.png-5dcac44bd26d4ba7c0ba6cc14c962e90.ctex"
-metadata={
-"vram_texture": false
-}
-
-[deps]
-
-source_file="res://background/Samples/uncolored_hills.png"
-dest_files=["res://.godot/imported/uncolored_hills.png-5dcac44bd26d4ba7c0ba6cc14c962e90.ctex"]
-
-[params]
-
-compress/mode=0
-compress/high_quality=false
-compress/lossy_quality=0.7
-compress/uastc_level=0
-compress/rdo_quality_loss=0.0
-compress/hdr_compression=1
-compress/normal_map=0
-compress/channel_pack=0
-mipmaps/generate=false
-mipmaps/limit=-1
-roughness/mode=0
-roughness/src_normal=""
-process/channel_remap/red=0
-process/channel_remap/green=1
-process/channel_remap/blue=2
-process/channel_remap/alpha=3
-process/fix_alpha_border=true
-process/premult_alpha=false
-process/normal_map_invert_y=false
-process/hdr_as_srgb=false
-process/hdr_clamp_exposure=false
-process/size_limit=0
-detect_3d/compress_to=1
diff --git a/background/Samples/uncolored_peaks.png b/background/Samples/uncolored_peaks.png
deleted file mode 100644
index 98c8f14..0000000
Binary files a/background/Samples/uncolored_peaks.png and /dev/null differ
diff --git a/background/Samples/uncolored_peaks.png.import b/background/Samples/uncolored_peaks.png.import
deleted file mode 100644
index 9494161..0000000
--- a/background/Samples/uncolored_peaks.png.import
+++ /dev/null
@@ -1,40 +0,0 @@
-[remap]
-
-importer="texture"
-type="CompressedTexture2D"
-uid="uid://bq8myo1ptombq"
-path="res://.godot/imported/uncolored_peaks.png-5d7a74c628db7a38aee0bffd844eaeea.ctex"
-metadata={
-"vram_texture": false
-}
-
-[deps]
-
-source_file="res://background/Samples/uncolored_peaks.png"
-dest_files=["res://.godot/imported/uncolored_peaks.png-5d7a74c628db7a38aee0bffd844eaeea.ctex"]
-
-[params]
-
-compress/mode=0
-compress/high_quality=false
-compress/lossy_quality=0.7
-compress/uastc_level=0
-compress/rdo_quality_loss=0.0
-compress/hdr_compression=1
-compress/normal_map=0
-compress/channel_pack=0
-mipmaps/generate=false
-mipmaps/limit=-1
-roughness/mode=0
-roughness/src_normal=""
-process/channel_remap/red=0
-process/channel_remap/green=1
-process/channel_remap/blue=2
-process/channel_remap/alpha=3
-process/fix_alpha_border=true
-process/premult_alpha=false
-process/normal_map_invert_y=false
-process/hdr_as_srgb=false
-process/hdr_clamp_exposure=false
-process/size_limit=0
-detect_3d/compress_to=1
diff --git a/background/Samples/uncolored_piramids.png b/background/Samples/uncolored_piramids.png
deleted file mode 100644
index 867961a..0000000
Binary files a/background/Samples/uncolored_piramids.png and /dev/null differ
diff --git a/background/Samples/uncolored_piramids.png.import b/background/Samples/uncolored_piramids.png.import
deleted file mode 100644
index aaab802..0000000
--- a/background/Samples/uncolored_piramids.png.import
+++ /dev/null
@@ -1,40 +0,0 @@
-[remap]
-
-importer="texture"
-type="CompressedTexture2D"
-uid="uid://dowtuhvq6hxiu"
-path="res://.godot/imported/uncolored_piramids.png-a11e1df6b36de6418f938866ad2259d0.ctex"
-metadata={
-"vram_texture": false
-}
-
-[deps]
-
-source_file="res://background/Samples/uncolored_piramids.png"
-dest_files=["res://.godot/imported/uncolored_piramids.png-a11e1df6b36de6418f938866ad2259d0.ctex"]
-
-[params]
-
-compress/mode=0
-compress/high_quality=false
-compress/lossy_quality=0.7
-compress/uastc_level=0
-compress/rdo_quality_loss=0.0
-compress/hdr_compression=1
-compress/normal_map=0
-compress/channel_pack=0
-mipmaps/generate=false
-mipmaps/limit=-1
-roughness/mode=0
-roughness/src_normal=""
-process/channel_remap/red=0
-process/channel_remap/green=1
-process/channel_remap/blue=2
-process/channel_remap/alpha=3
-process/fix_alpha_border=true
-process/premult_alpha=false
-process/normal_map_invert_y=false
-process/hdr_as_srgb=false
-process/hdr_clamp_exposure=false
-process/size_limit=0
-detect_3d/compress_to=1
diff --git a/background/Samples/uncolored_plain.png b/background/Samples/uncolored_plain.png
deleted file mode 100644
index f096bce..0000000
Binary files a/background/Samples/uncolored_plain.png and /dev/null differ
diff --git a/background/Samples/uncolored_plain.png.import b/background/Samples/uncolored_plain.png.import
deleted file mode 100644
index f4be3c8..0000000
--- a/background/Samples/uncolored_plain.png.import
+++ /dev/null
@@ -1,40 +0,0 @@
-[remap]
-
-importer="texture"
-type="CompressedTexture2D"
-uid="uid://bjx5sgy01v5bh"
-path="res://.godot/imported/uncolored_plain.png-81be7210a0ef6c6bb12da9811f304d6a.ctex"
-metadata={
-"vram_texture": false
-}
-
-[deps]
-
-source_file="res://background/Samples/uncolored_plain.png"
-dest_files=["res://.godot/imported/uncolored_plain.png-81be7210a0ef6c6bb12da9811f304d6a.ctex"]
-
-[params]
-
-compress/mode=0
-compress/high_quality=false
-compress/lossy_quality=0.7
-compress/uastc_level=0
-compress/rdo_quality_loss=0.0
-compress/hdr_compression=1
-compress/normal_map=0
-compress/channel_pack=0
-mipmaps/generate=false
-mipmaps/limit=-1
-roughness/mode=0
-roughness/src_normal=""
-process/channel_remap/red=0
-process/channel_remap/green=1
-process/channel_remap/blue=2
-process/channel_remap/alpha=3
-process/fix_alpha_border=true
-process/premult_alpha=false
-process/normal_map_invert_y=false
-process/hdr_as_srgb=false
-process/hdr_clamp_exposure=false
-process/size_limit=0
-detect_3d/compress_to=1
diff --git a/background/Samples/uncolored_talltrees.png b/background/Samples/uncolored_talltrees.png
deleted file mode 100644
index 3f82b5f..0000000
Binary files a/background/Samples/uncolored_talltrees.png and /dev/null differ
diff --git a/background/Samples/uncolored_talltrees.png.import b/background/Samples/uncolored_talltrees.png.import
deleted file mode 100644
index 3cd2fd7..0000000
--- a/background/Samples/uncolored_talltrees.png.import
+++ /dev/null
@@ -1,40 +0,0 @@
-[remap]
-
-importer="texture"
-type="CompressedTexture2D"
-uid="uid://dveara1yud63p"
-path="res://.godot/imported/uncolored_talltrees.png-6782893694c5a062ddb71ccc51cbd94b.ctex"
-metadata={
-"vram_texture": false
-}
-
-[deps]
-
-source_file="res://background/Samples/uncolored_talltrees.png"
-dest_files=["res://.godot/imported/uncolored_talltrees.png-6782893694c5a062ddb71ccc51cbd94b.ctex"]
-
-[params]
-
-compress/mode=0
-compress/high_quality=false
-compress/lossy_quality=0.7
-compress/uastc_level=0
-compress/rdo_quality_loss=0.0
-compress/hdr_compression=1
-compress/normal_map=0
-compress/channel_pack=0
-mipmaps/generate=false
-mipmaps/limit=-1
-roughness/mode=0
-roughness/src_normal=""
-process/channel_remap/red=0
-process/channel_remap/green=1
-process/channel_remap/blue=2
-process/channel_remap/alpha=3
-process/fix_alpha_border=true
-process/premult_alpha=false
-process/normal_map_invert_y=false
-process/hdr_as_srgb=false
-process/hdr_clamp_exposure=false
-process/size_limit=0
-detect_3d/compress_to=1
diff --git a/background/Spritesheet/bgElements_spritesheet.png b/background/Spritesheet/bgElements_spritesheet.png
deleted file mode 100644
index d6b5830..0000000
Binary files a/background/Spritesheet/bgElements_spritesheet.png and /dev/null differ
diff --git a/background/Spritesheet/bgElements_spritesheet.png.import b/background/Spritesheet/bgElements_spritesheet.png.import
deleted file mode 100644
index 1089a17..0000000
--- a/background/Spritesheet/bgElements_spritesheet.png.import
+++ /dev/null
@@ -1,40 +0,0 @@
-[remap]
-
-importer="texture"
-type="CompressedTexture2D"
-uid="uid://rgpbket2k73e"
-path="res://.godot/imported/bgElements_spritesheet.png-aa30f72db7f148897f99e11cf82e2edc.ctex"
-metadata={
-"vram_texture": false
-}
-
-[deps]
-
-source_file="res://background/Spritesheet/bgElements_spritesheet.png"
-dest_files=["res://.godot/imported/bgElements_spritesheet.png-aa30f72db7f148897f99e11cf82e2edc.ctex"]
-
-[params]
-
-compress/mode=0
-compress/high_quality=false
-compress/lossy_quality=0.7
-compress/uastc_level=0
-compress/rdo_quality_loss=0.0
-compress/hdr_compression=1
-compress/normal_map=0
-compress/channel_pack=0
-mipmaps/generate=false
-mipmaps/limit=-1
-roughness/mode=0
-roughness/src_normal=""
-process/channel_remap/red=0
-process/channel_remap/green=1
-process/channel_remap/blue=2
-process/channel_remap/alpha=3
-process/fix_alpha_border=true
-process/premult_alpha=false
-process/normal_map_invert_y=false
-process/hdr_as_srgb=false
-process/hdr_clamp_exposure=false
-process/size_limit=0
-detect_3d/compress_to=1
diff --git a/background/Spritesheet/bgElements_spritesheet.xml b/background/Spritesheet/bgElements_spritesheet.xml
deleted file mode 100644
index abf5afc..0000000
--- a/background/Spritesheet/bgElements_spritesheet.xml
+++ /dev/null
@@ -1,111 +0,0 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
\ No newline at end of file
diff --git a/background/Vector/bgElements_elements.ai b/background/Vector/bgElements_elements.ai
deleted file mode 100644
index f755b72..0000000
Binary files a/background/Vector/bgElements_elements.ai and /dev/null differ
diff --git a/background/Vector/bgElements_elements.svg b/background/Vector/bgElements_elements.svg
deleted file mode 100644
index 60e048f..0000000
--- a/background/Vector/bgElements_elements.svg
+++ /dev/null
@@ -1,2596 +0,0 @@
-
-
diff --git a/background/Vector/bgElements_elements.svg.import b/background/Vector/bgElements_elements.svg.import
deleted file mode 100644
index 27763c7..0000000
--- a/background/Vector/bgElements_elements.svg.import
+++ /dev/null
@@ -1,43 +0,0 @@
-[remap]
-
-importer="texture"
-type="CompressedTexture2D"
-uid="uid://cm7l4bo0ikkdq"
-path="res://.godot/imported/bgElements_elements.svg-5571e3fb12427e8e66317a647726aa5c.ctex"
-metadata={
-"vram_texture": false
-}
-
-[deps]
-
-source_file="res://background/Vector/bgElements_elements.svg"
-dest_files=["res://.godot/imported/bgElements_elements.svg-5571e3fb12427e8e66317a647726aa5c.ctex"]
-
-[params]
-
-compress/mode=0
-compress/high_quality=false
-compress/lossy_quality=0.7
-compress/uastc_level=0
-compress/rdo_quality_loss=0.0
-compress/hdr_compression=1
-compress/normal_map=0
-compress/channel_pack=0
-mipmaps/generate=false
-mipmaps/limit=-1
-roughness/mode=0
-roughness/src_normal=""
-process/channel_remap/red=0
-process/channel_remap/green=1
-process/channel_remap/blue=2
-process/channel_remap/alpha=3
-process/fix_alpha_border=true
-process/premult_alpha=false
-process/normal_map_invert_y=false
-process/hdr_as_srgb=false
-process/hdr_clamp_exposure=false
-process/size_limit=0
-detect_3d/compress_to=1
-svg/scale=1.0
-editor/scale_with_editor_scale=false
-editor/convert_colors_with_editor_theme=false
diff --git a/background/Vector/bgElements_samples.ai b/background/Vector/bgElements_samples.ai
deleted file mode 100644
index 4fc77e3..0000000
Binary files a/background/Vector/bgElements_samples.ai and /dev/null differ
diff --git a/background/Vector/bgElements_samples.svg b/background/Vector/bgElements_samples.svg
deleted file mode 100644
index 2d862ec..0000000
--- a/background/Vector/bgElements_samples.svg
+++ /dev/null
@@ -1,1176 +0,0 @@
-
-
diff --git a/background/Vector/bgElements_samples.svg.import b/background/Vector/bgElements_samples.svg.import
deleted file mode 100644
index 6859a6f..0000000
--- a/background/Vector/bgElements_samples.svg.import
+++ /dev/null
@@ -1,43 +0,0 @@
-[remap]
-
-importer="texture"
-type="CompressedTexture2D"
-uid="uid://daju353jwtama"
-path="res://.godot/imported/bgElements_samples.svg-7ee724785130274b980b75c813b26fa2.ctex"
-metadata={
-"vram_texture": false
-}
-
-[deps]
-
-source_file="res://background/Vector/bgElements_samples.svg"
-dest_files=["res://.godot/imported/bgElements_samples.svg-7ee724785130274b980b75c813b26fa2.ctex"]
-
-[params]
-
-compress/mode=0
-compress/high_quality=false
-compress/lossy_quality=0.7
-compress/uastc_level=0
-compress/rdo_quality_loss=0.0
-compress/hdr_compression=1
-compress/normal_map=0
-compress/channel_pack=0
-mipmaps/generate=false
-mipmaps/limit=-1
-roughness/mode=0
-roughness/src_normal=""
-process/channel_remap/red=0
-process/channel_remap/green=1
-process/channel_remap/blue=2
-process/channel_remap/alpha=3
-process/fix_alpha_border=true
-process/premult_alpha=false
-process/normal_map_invert_y=false
-process/hdr_as_srgb=false
-process/hdr_clamp_exposure=false
-process/size_limit=0
-detect_3d/compress_to=1
-svg/scale=1.0
-editor/scale_with_editor_scale=false
-editor/convert_colors_with_editor_theme=false
diff --git a/scenes/game/vault.tscn b/scenes/game/vault.tscn
index 4a12d05..955e1ec 100644
--- a/scenes/game/vault.tscn
+++ b/scenes/game/vault.tscn
@@ -1,10 +1,14 @@
[gd_scene format=3 uid="uid://dprxap0ioe3ps"]
[ext_resource type="Script" uid="uid://lsclvc1dgviq" path="res://scripts/game/vault_scene.gd" id="1_o8yry"]
+[ext_resource type="Script" uid="uid://ddaqvur2yl61c" path="res://scripts/game/sky_view.gd" id="2_lffpp"]
[ext_resource type="Script" uid="uid://bvimo3kgmefgf" path="res://scripts/game/vault_view.gd" id="2_mkhle"]
+[ext_resource type="Script" uid="uid://c4c36o4o35gqw" path="res://scripts/game/surface_view.gd" id="3_lvdr8"]
[ext_resource type="Script" uid="uid://dg751mcd0v178" path="res://scripts/game/vault_camera.gd" id="3_x7j0k"]
[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://by1cwvs3fc3pa" path="res://scripts/game/weather_system.gd" id="9_m0p8k"]
[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_lvdr8"]
content_margin_left = 22.0
@@ -38,9 +42,31 @@ corner_radius_top_right = 14
corner_radius_bottom_right = 14
corner_radius_bottom_left = 14
+[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_mkhle"]
+content_margin_left = 14.0
+content_margin_top = 8.0
+content_margin_right = 14.0
+content_margin_bottom = 8.0
+bg_color = Color(0.043137256, 0.07450981, 0.13333334, 0.8784314)
+border_width_left = 1
+border_width_top = 1
+border_width_right = 1
+border_width_bottom = 1
+border_color = Color(0.18039216, 0.9019608, 0.77254903, 0.2)
+corner_radius_top_left = 10
+corner_radius_top_right = 10
+corner_radius_bottom_right = 10
+corner_radius_bottom_left = 10
+
[node name="Vault" type="Node2D" unique_id=1382797618]
script = ExtResource("1_o8yry")
+[node name="Sky" type="Node2D" parent="." unique_id=1428657021]
+script = ExtResource("2_lffpp")
+
+[node name="Surface" type="Node2D" parent="." unique_id=320825958]
+script = ExtResource("3_lvdr8")
+
[node name="View" type="Node2D" parent="." unique_id=1238774489]
script = ExtResource("2_mkhle")
@@ -104,4 +130,64 @@ theme_override_colors/font_color = Color(0.3647059, 0.44705883, 0.53333336, 1)
theme_override_font_sizes/font_size = 15
horizontal_alignment = 1
+[node name="TopRight" type="MarginContainer" parent="Hud/Root" unique_id=417759891]
+layout_mode = 0
+anchor_right = 1.0
+anchor_bottom = 1.0
+mouse_filter = 2
+theme_override_constants/margin_left = 36
+theme_override_constants/margin_top = 36
+theme_override_constants/margin_right = 36
+theme_override_constants/margin_bottom = 36
+
+[node name="Row" type="HBoxContainer" parent="Hud/Root/TopRight" unique_id=705018436]
+layout_mode = 2
+size_flags_vertical = 0
+mouse_filter = 2
+alignment = 2
+
+[node name="ClockBar" type="PanelContainer" parent="Hud/Root/TopRight/Row" unique_id=2028708536]
+layout_mode = 2
+theme_override_styles/panel = SubResource("StyleBoxFlat_mkhle")
+
+[node name="Body" type="VBoxContainer" parent="Hud/Root/TopRight/Row/ClockBar" unique_id=220234733]
+layout_mode = 2
+theme_override_constants/separation = 6
+
+[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)
+theme_override_font_sizes/font_size = 14
+text = "1 апреля 2012"
+vertical_alignment = 1
+
+[node name="TimeLabel" type="Label" parent="Hud/Root/TopRight/Row/ClockBar/Body/Moment" unique_id=686404941]
+layout_mode = 2
+theme_override_colors/font_color = Color(0.18039216, 0.9019608, 0.77254903, 1)
+theme_override_font_sizes/font_size = 20
+text = "06:00"
+
+[node name="Controls" type="HBoxContainer" parent="Hud/Root/TopRight/Row/ClockBar/Body" unique_id=278368540]
+layout_mode = 2
+theme_override_constants/separation = 4
+alignment = 2
+
[node name="PauseMenu" parent="." unique_id=1649378803 instance=ExtResource("5_lffpp")]
+
+[node name="Clock" type="Node" parent="." unique_id=1353564659]
+script = ExtResource("6_mkhle")
+
+[node name="Weather" type="Node" parent="." unique_id=1790958949]
+script = ExtResource("9_m0p8k")
diff --git a/scripts/game/game_clock.gd b/scripts/game/game_clock.gd
new file mode 100644
index 0000000..4b0e306
--- /dev/null
+++ b/scripts/game/game_clock.gd
@@ -0,0 +1,142 @@
+class_name GameClock
+extends Node
+## Игровые дата и время.
+##
+## Отдельная сущность, а не поле сцены: ход времени понадобится производству,
+## жителям и событиям, и сохранение обязано его восстанавливать.
+##
+## Пауза здесь своя, не движковая. `get_tree().paused` останавливает вообще всё
+## и занята меню паузы; здесь же нужно останавливать только ход партии, оставляя
+## интерфейс живым. При этом узел обычный, PAUSABLE, поэтому меню паузы
+## замораживает время само собой.
+
+## Старт партии: 1 апреля 2012 года, шесть утра.
+const START := {"year": 2012, "month": 4, "day": 1, "hour": 6, "minute": 0, "second": 0}
+
+## Сколько игровых минут проходит за секунду реального времени на скорости x1.
+## Сутки при этом занимают 8 минут реального времени. Множители x2…x4
+## умножают именно это значение, так что менять темп достаточно здесь.
+const MINUTES_PER_SECOND := 3.0
+
+## Доступные множители скорости.
+const SPEEDS: Array[int] = [1, 2, 3, 4]
+
+const MINUTES_PER_DAY := 24 * 60
+
+const MONTHS: Array[String] = [
+ "января", "февраля", "марта", "апреля", "мая", "июня",
+ "июля", "августа", "сентября", "октября", "ноября", "декабря",
+]
+
+## Прошла игровая минута — сюда подключаются HUD, производство, события.
+signal minute_passed(total_minutes: int)
+## Сменились сутки.
+signal day_passed(day_index: int)
+## Изменились скорость или состояние паузы.
+signal flow_changed
+
+var running := true
+var speed := 1
+
+var _start_unix := int(Time.get_unix_time_from_datetime_dict(START))
+var _elapsed_minutes := 0.0
+var _last_whole_minute := 0
+var _last_day := 0
+
+
+func _process(delta: float) -> void:
+ if not running:
+ return
+ _advance(delta * MINUTES_PER_SECOND * float(speed))
+
+
+# --- Текущий момент ---------------------------------------------------------
+
+## Момент как словарь Time: year, month, day, hour, minute, second.
+func now() -> Dictionary:
+ return Time.get_datetime_dict_from_unix_time(unix_time())
+
+
+func unix_time() -> int:
+ return _start_unix + int(_elapsed_minutes) * 60
+
+
+## «1 апреля 2012»
+func date_text() -> String:
+ var moment := now()
+ return "%d %s %d" % [
+ int(moment["day"]), MONTHS[int(moment["month"]) - 1], int(moment["year"]),
+ ]
+
+
+## «06:00»
+func time_text() -> String:
+ var moment := now()
+ return "%02d:%02d" % [int(moment["hour"]), int(moment["minute"])]
+
+
+# --- Управление ходом -------------------------------------------------------
+
+func set_speed(value: int) -> void:
+ speed = clampi(value, SPEEDS[0], SPEEDS[SPEEDS.size() - 1])
+ running = true
+ flow_changed.emit()
+
+
+func play() -> void:
+ if running:
+ return
+ running = true
+ flow_changed.emit()
+
+
+func pause() -> void:
+ if not running:
+ return
+ running = false
+ flow_changed.emit()
+
+
+func toggle() -> void:
+ running = not running
+ flow_changed.emit()
+
+
+# --- Сохранение -------------------------------------------------------------
+
+func save_data() -> Dictionary:
+ return {"minutes": _elapsed_minutes, "speed": speed, "running": running}
+
+
+func load_data(data: Dictionary) -> void:
+ _elapsed_minutes = maxf(float(data.get("minutes", 0.0)), 0.0)
+ speed = clampi(int(data.get("speed", 1)), SPEEDS[0], SPEEDS[SPEEDS.size() - 1])
+ running = bool(data.get("running", true))
+ _last_whole_minute = int(_elapsed_minutes)
+ _last_day = _day_index()
+ flow_changed.emit()
+ minute_passed.emit(_last_whole_minute)
+
+
+# --- Внутреннее -------------------------------------------------------------
+
+func _advance(minutes: float) -> void:
+ _elapsed_minutes += minutes
+
+ # Сигналим по целым минутам, а не каждый кадр: подписчикам нужен такт,
+ # а не дробное значение.
+ var whole := int(_elapsed_minutes)
+ if whole == _last_whole_minute:
+ return
+ _last_whole_minute = whole
+ minute_passed.emit(whole)
+
+ var day := _day_index()
+ if day == _last_day:
+ return
+ _last_day = day
+ day_passed.emit(day)
+
+
+func _day_index() -> int:
+ return floori(_elapsed_minutes / float(MINUTES_PER_DAY))
diff --git a/scripts/game/game_clock.gd.uid b/scripts/game/game_clock.gd.uid
new file mode 100644
index 0000000..d677756
--- /dev/null
+++ b/scripts/game/game_clock.gd.uid
@@ -0,0 +1 @@
+uid://7wstqw8ve2he
diff --git a/scripts/game/sky_cycle.gd b/scripts/game/sky_cycle.gd
new file mode 100644
index 0000000..f0016e2
--- /dev/null
+++ b/scripts/game/sky_cycle.gd
@@ -0,0 +1,81 @@
+class_name SkyCycle
+extends RefCounted
+## Палитра неба по времени суток.
+##
+## Только вычисления, без узлов: по часу суток отдаёт цвета неба, цвет света
+## для сцены, яркость звёзд и положение светил. Рисование — в sky_view.gd,
+## пейзаж — в surface_view.gd.
+
+## Ключевые моменты суток; между ними всё интерполируется линейно.
+## `light` — общий тон, которым красится пейзаж: он делает утро розоватым,
+## полдень белым, а ночь синей.
+const KEYFRAMES := [
+ {"hour": 0.0, "top": "#040711", "bottom": "#0b1626", "light": "#4a5876", "stars": 1.0},
+ {"hour": 4.5, "top": "#070d1e", "bottom": "#1b2340", "light": "#55637f", "stars": 1.0},
+ {"hour": 6.0, "top": "#16294a", "bottom": "#6b4a68", "light": "#8a7c92", "stars": 0.35},
+ {"hour": 7.0, "top": "#2c5183", "bottom": "#e0895a", "light": "#d0a184", "stars": 0.0},
+ {"hour": 9.0, "top": "#3d7cba", "bottom": "#a9d6ec", "light": "#f2f7fa", "stars": 0.0},
+ {"hour": 13.0, "top": "#4a90d9", "bottom": "#c3e6f5", "light": "#ffffff", "stars": 0.0},
+ {"hour": 17.0, "top": "#4585c8", "bottom": "#d2e2ec", "light": "#f7efe2", "stars": 0.0},
+ {"hour": 19.5, "top": "#27456f", "bottom": "#e0734a", "light": "#c78a6e", "stars": 0.05},
+ {"hour": 21.0, "top": "#0d1a30", "bottom": "#2b2b4c", "light": "#666b8a", "stars": 0.6},
+ {"hour": 24.0, "top": "#040711", "bottom": "#0b1626", "light": "#4a5876", "stars": 1.0},
+]
+
+const SUNRISE := 6.0
+const SUNSET := 20.0
+
+
+## Состояние неба на указанный час суток: top, bottom, light, stars.
+static func sample(hour: float) -> Dictionary:
+ var moment := fposmod(hour, 24.0)
+
+ for index in range(KEYFRAMES.size() - 1):
+ var from: Dictionary = KEYFRAMES[index]
+ var to: Dictionary = KEYFRAMES[index + 1]
+ var start := float(from["hour"])
+ var end := float(to["hour"])
+ if moment < start or moment > end:
+ continue
+
+ var span := end - start
+ var weight := 0.0 if is_zero_approx(span) else (moment - start) / span
+ return {
+ "top": Color(String(from["top"])).lerp(Color(String(to["top"])), weight),
+ "bottom": Color(String(from["bottom"])).lerp(Color(String(to["bottom"])), weight),
+ "light": Color(String(from["light"])).lerp(Color(String(to["light"])), weight),
+ "stars": lerpf(float(from["stars"]), float(to["stars"]), weight),
+ }
+
+ var last: Dictionary = KEYFRAMES[KEYFRAMES.size() - 1]
+ return {
+ "top": Color(String(last["top"])),
+ "bottom": Color(String(last["bottom"])),
+ "light": Color(String(last["light"])),
+ "stars": float(last["stars"]),
+ }
+
+
+## Доля пути солнца по небу: 0 — восход слева, 1 — закат справа.
+## -1, если солнце под горизонтом.
+static func sun_progress(hour: float) -> float:
+ var moment := fposmod(hour, 24.0)
+ if moment < SUNRISE or moment > SUNSET:
+ return -1.0
+ return (moment - SUNRISE) / (SUNSET - SUNRISE)
+
+
+## То же для луны — она идёт по небу оставшуюся часть суток.
+static func moon_progress(hour: float) -> float:
+ var moment := fposmod(hour, 24.0)
+ if moment > SUNRISE and moment < SUNSET:
+ return -1.0
+
+ var night_length := 24.0 - (SUNSET - SUNRISE)
+ var elapsed := moment - SUNSET if moment >= SUNSET else moment + (24.0 - SUNSET)
+ return elapsed / night_length
+
+
+## Час суток дробным числом из словаря Time.
+static func hour_of(moment: Dictionary) -> float:
+ return float(int(moment["hour"])) + float(int(moment["minute"])) / 60.0
diff --git a/scripts/game/sky_cycle.gd.uid b/scripts/game/sky_cycle.gd.uid
new file mode 100644
index 0000000..e3d35d9
--- /dev/null
+++ b/scripts/game/sky_cycle.gd.uid
@@ -0,0 +1 @@
+uid://gdc8itmwcajy
diff --git a/scripts/game/sky_view.gd b/scripts/game/sky_view.gd
new file mode 100644
index 0000000..29e7829
--- /dev/null
+++ b/scripts/game/sky_view.gd
@@ -0,0 +1,115 @@
+class_name SkyView
+extends Node2D
+## Небо над убежищем: градиент по времени суток, звёзды, солнце и луна.
+##
+## Рисуется первым, за пейзажем и породой. Что рисовать — решает SkyCycle,
+## насколько это видно сквозь облака — WeatherSystem.
+
+const SUN_TEXTURE := preload("res://background/PNG/sun.png")
+const MOON_TEXTURE := preload("res://background/PNG/moon_full.png")
+
+## Небо начинается здесь и идёт до уровня земли (y = 0).
+const TOP := -1700.0
+const LEFT := -1600.0
+const RIGHT := 2900.0
+
+## Дуга, по которой идут светила.
+const ARC_RADIUS := Vector2(900.0, 620.0)
+const ARC_BASE := -40.0
+
+const STAR_COUNT := 240
+const STAR_SEED := 20120401
+
+## Час суток; сцена обновляет его вслед за часами.
+var hour := 6.0
+## Плотность облаков 0..1 — сквозь них хуже видно звёзды и светила.
+var clouds := 0.0
+
+var _stars: Array[Dictionary] = []
+
+
+func _ready() -> void:
+ _generate_stars()
+
+
+## Одним вызовом — чтобы не гонять перерисовку дважды.
+func set_conditions(hour_of_day: float, cloudiness: float) -> void:
+ hour = hour_of_day
+ clouds = cloudiness
+ queue_redraw()
+
+
+func _draw() -> void:
+ var sky := SkyCycle.sample(hour)
+ _draw_gradient(sky)
+ _draw_stars(float(sky["stars"]))
+ _draw_bodies()
+
+
+func _draw_gradient(sky: Dictionary) -> void:
+ var points := PackedVector2Array([
+ Vector2(LEFT, TOP), Vector2(RIGHT, TOP), Vector2(RIGHT, 0.0), Vector2(LEFT, 0.0),
+ ])
+ var top: Color = sky["top"]
+ var bottom: Color = sky["bottom"]
+ draw_polygon(points, PackedColorArray([top, top, bottom, bottom]))
+
+
+func _draw_stars(brightness: float) -> void:
+ # Плотная облачность гасит звёзды почти полностью.
+ var visibility := brightness * (1.0 - clampf(clouds, 0.0, 1.0) * 0.9)
+ if visibility <= 0.01:
+ return
+
+ for star: Dictionary in _stars:
+ var alpha: float = float(star["alpha"]) * visibility
+ draw_circle(star["position"], float(star["radius"]), Color(star["tint"], alpha))
+
+
+func _draw_bodies() -> void:
+ # Сквозь облака светило просвечивает, но тускло.
+ var through := 1.0 - clampf(clouds, 0.0, 1.0) * 0.75
+
+ var sun := SkyCycle.sun_progress(hour)
+ if sun >= 0.0:
+ var position := _arc_position(sun)
+ draw_circle(position, 150.0, Color("#ffd27a", 0.10 * through))
+ draw_circle(position, 92.0, Color("#ffdc93", 0.14 * through))
+ _draw_body(SUN_TEXTURE, position, 1.7, Color(1.0, 1.0, 1.0, through))
+ return
+
+ var moon := SkyCycle.moon_progress(hour)
+ if moon >= 0.0:
+ var position := _arc_position(moon)
+ draw_circle(position, 110.0, Color("#cfe0ff", 0.07 * through))
+ _draw_body(MOON_TEXTURE, position, 1.35, Color(1.0, 1.0, 1.0, through))
+
+
+func _draw_body(texture: Texture2D, position: Vector2, scale: float, tint: Color) -> void:
+ var size := Vector2(texture.get_size()) * scale
+ draw_texture_rect(texture, Rect2(position - size * 0.5, size), false, tint)
+
+
+## Точка на дуге: 0 — у левого горизонта, 1 — у правого.
+func _arc_position(progress: float) -> Vector2:
+ var angle := PI * (1.0 - progress)
+ var center_x := VaultGrid.bounds().get_center().x
+ return Vector2(
+ center_x + cos(angle) * ARC_RADIUS.x,
+ ARC_BASE - sin(angle) * ARC_RADIUS.y
+ )
+
+
+func _generate_stars() -> void:
+ var rng := RandomNumberGenerator.new()
+ rng.seed = STAR_SEED
+
+ for index in STAR_COUNT:
+ _stars.append({
+ "position": Vector2(
+ rng.randf_range(LEFT, RIGHT), rng.randf_range(TOP, -140.0)
+ ),
+ "radius": rng.randf_range(1.4, 3.6),
+ "alpha": rng.randf_range(0.35, 1.0),
+ "tint": Color("#bcd2ff").lerp(Color("#fff2d8"), rng.randf()),
+ })
diff --git a/scripts/game/sky_view.gd.uid b/scripts/game/sky_view.gd.uid
new file mode 100644
index 0000000..f7aceab
--- /dev/null
+++ b/scripts/game/sky_view.gd.uid
@@ -0,0 +1 @@
+uid://ddaqvur2yl61c
diff --git a/scripts/game/surface_view.gd b/scripts/game/surface_view.gd
new file mode 100644
index 0000000..2e414d5
--- /dev/null
+++ b/scripts/game/surface_view.gd
@@ -0,0 +1,255 @@
+class_name SurfaceView
+extends Node2D
+## Поверхность над убежищем: горы, холмы, деревья, облака и осадки.
+##
+## Текстуры — набор Kenney из `background/PNG/Flat`. Слои рисуются с разной
+## скоростью относительно камеры (параллакс) и подмешивают цвет неба тем
+## сильнее, чем дальше стоят, — это даёт воздушную перспективу.
+##
+## Как и остальной вид, всё живёт в _draw(): узлов-спрайтов нет, поэтому
+## перекрасить пейзаж под время суток — это один параметр, а не обход дерева.
+
+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")
+
+## Ближний план берём из основного набора: там деревья и трава уже цветные.
+## Набор Flat — бледные силуэты, он идёт на дальние слои, которые всё равно
+## перекрашиваются под воздушную перспективу.
+const TREE_PATHS := [
+ "res://background/PNG/tree01.png", "res://background/PNG/tree05.png",
+ "res://background/PNG/tree09.png", "res://background/PNG/tree12.png",
+ "res://background/PNG/tree18.png", "res://background/PNG/tree24.png",
+ "res://background/PNG/tree30.png",
+]
+const GRASS_PATHS := [
+ "res://background/PNG/grass1.png", "res://background/PNG/grass2.png",
+ "res://background/PNG/grass3.png", "res://background/PNG/grass4.png",
+]
+
+## Собственный цвет дальних слоёв: сами текстуры почти белые, тон задаём тут.
+const MOUNTAIN_TONE := Color("#7e93ad")
+const HILLS_FAR_TONE := Color("#6b8f5e")
+const HILLS_NEAR_TONE := Color("#5d8248")
+
+const LEFT := -1600.0
+const RIGHT := 2900.0
+const SCENERY_SEED := 20120401
+
+## Полоса дёрна на уровне земли.
+const TURF := Color("#4a6b3a")
+const TURF_HEIGHT := 16.0
+
+## Не ставим деревья вплотную к будке входа, чтобы её не загораживали.
+const ENTRANCE_CLEAR := Vector2(-140.0, 300.0)
+
+## Час суток и погода — сцена обновляет их вслед за часами.
+var hour := 6.0
+var clouds := 0.0
+var precipitation := 0.0
+var fog := 0.0
+var wind := 0.3
+var snowing := false
+## Множитель хода: 0 на паузе, иначе скорость игры.
+var time_scale := 1.0
+
+var _trees: Array[Dictionary] = []
+var _grass: Array[Dictionary] = []
+var _drops: Array[Vector2] = []
+var _drift := 0.0
+var _fall := 0.0
+
+
+func _ready() -> void:
+ # Плиточная заливка широких полос требует повтора текстуры на узле.
+ texture_repeat = CanvasItem.TEXTURE_REPEAT_ENABLED
+ _scatter_scenery()
+ _scatter_drops()
+
+
+func _process(delta: float) -> void:
+ if is_zero_approx(time_scale):
+ return
+ var step := delta * time_scale
+ _drift += step * (18.0 + wind * 120.0)
+ _fall += step * (900.0 + precipitation * 900.0)
+ queue_redraw()
+
+
+## Одним вызовом — чтобы не гонять перерисовку на каждый параметр.
+func set_conditions(
+ hour_of_day: float, weather: WeatherSystem
+) -> void:
+ hour = hour_of_day
+ clouds = weather.clouds
+ precipitation = weather.precipitation
+ fog = weather.fog
+ wind = weather.wind
+ snowing = weather.is_snowing()
+ queue_redraw()
+
+
+func _draw() -> void:
+ var sky := SkyCycle.sample(hour)
+ var light: Color = sky["light"]
+ var haze: Color = sky["bottom"]
+ var view := _view_rect()
+
+ _draw_band(MOUNTAINS, MOUNTAIN_TONE, 1.7, 0.45, 0.25, light, haze, view)
+ _draw_band(HILLS_FAR, HILLS_FAR_TONE, 1.5, 0.24, 0.5, light, haze, view)
+ _draw_clouds(view, light)
+ _draw_band(HILLS_NEAR, HILLS_NEAR_TONE, 1.15, 0.08, 0.75, light, haze, view)
+ _draw_turf(light)
+ _draw_scenery(light)
+ _draw_fog(haze)
+ _draw_precipitation(view, light)
+
+
+# --- Слои пейзажа -----------------------------------------------------------
+
+## Широкая полоса, замощённая по горизонтали и стоящая на уровне земли.
+## `distance` — насколько слой выцветает в цвет неба, `parallax` — насколько
+## он привязан к миру (1.0 — намертво, меньше — отстаёт от камеры).
+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 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))
+
+
+func _draw_turf(light: Color) -> void:
+ draw_rect(Rect2(Vector2(LEFT, -TURF_HEIGHT), Vector2(RIGHT - LEFT, TURF_HEIGHT)), TURF * light)
+
+
+func _draw_scenery(light: Color) -> void:
+ for tuft: Dictionary in _grass:
+ _draw_item(tuft, light)
+ for tree: Dictionary in _trees:
+ _draw_item(tree, light)
+
+
+func _draw_item(item: Dictionary, light: Color) -> void:
+ var texture: Texture2D = item["texture"]
+ var size := Vector2(texture.get_size()) * float(item["scale"])
+ var origin := Vector2(float(item["x"]) - size.x * 0.5, -size.y + TURF_HEIGHT * 0.4)
+ draw_texture_rect(texture, Rect2(origin, size), false, light * Color(item["tint"]))
+
+
+# --- Погода -----------------------------------------------------------------
+
+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)
+
+
+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))
+
+
+## Дымка у горизонта: чем гуще, тем сильнее пейзаж растворяется в небе.
+func _draw_fog(haze: Color) -> void:
+ if fog <= 0.01:
+ return
+ var top := -520.0
+ var points := PackedVector2Array([
+ Vector2(LEFT, top), Vector2(RIGHT, top), Vector2(RIGHT, 0.0), Vector2(LEFT, 0.0),
+ ])
+ var clear := Color(haze, 0.0)
+ var dense := Color(haze, clampf(fog, 0.0, 1.0) * 0.85)
+ # Ниже нуля тумана нет — там уже разрез породы.
+ draw_polygon(points, PackedColorArray([clear, clear, dense, dense]))
+
+
+func _draw_precipitation(view: Rect2, light: Color) -> void:
+ if precipitation <= 0.02:
+ return
+
+ # Рисуем только то, что попадает в кадр и находится над землёй.
+ var band := view.intersection(Rect2(LEFT, -1700.0, RIGHT - LEFT, 1700.0))
+ if band.size.x <= 0.0 or band.size.y <= 0.0:
+ return
+
+ var count := int(float(_drops.size()) * clampf(precipitation, 0.0, 1.0))
+ var slant := wind * 0.45
+
+ for index in count:
+ var drop := _drops[index]
+ var x := band.position.x + drop.x * band.size.x
+ var y := band.position.y + fposmod(drop.y * band.size.y + _fall, band.size.y)
+
+ if snowing:
+ draw_circle(Vector2(x, y), 3.0, Color(light, 0.75))
+ continue
+ var length := 16.0 + precipitation * 12.0
+ draw_line(
+ Vector2(x, y), Vector2(x - slant * length, y + length),
+ Color(light.lerp(Color("#9fc4e8"), 0.5), 0.5), 1.5
+ )
+
+
+# --- Расстановка ------------------------------------------------------------
+
+func _scatter_scenery() -> void:
+ var rng := RandomNumberGenerator.new()
+ rng.seed = SCENERY_SEED
+
+ var trees: Array[Texture2D] = []
+ for path: String in TREE_PATHS:
+ trees.append(load(path) as Texture2D)
+
+ var grass: Array[Texture2D] = []
+ for path: String in GRASS_PATHS:
+ grass.append(load(path) as Texture2D)
+
+ var x := LEFT
+ while x < RIGHT:
+ x += rng.randf_range(90.0, 260.0)
+ if x > ENTRANCE_CLEAR.x and x < ENTRANCE_CLEAR.y:
+ continue
+ _trees.append({
+ "texture": trees[rng.randi_range(0, trees.size() - 1)],
+ "x": x,
+ "scale": rng.randf_range(0.55, 1.0),
+ "tint": Color("#ffffff").lerp(Color("#9db98a"), rng.randf() * 0.45),
+ })
+
+ var tuft_x := LEFT
+ while tuft_x < RIGHT:
+ tuft_x += rng.randf_range(34.0, 130.0)
+ _grass.append({
+ "texture": grass[rng.randi_range(0, grass.size() - 1)],
+ "x": tuft_x,
+ "scale": rng.randf_range(0.9, 1.7),
+ "tint": Color("#ffffff"),
+ })
+
+
+func _scatter_drops() -> void:
+ var rng := RandomNumberGenerator.new()
+ rng.seed = SCENERY_SEED + 7
+ for index in 520:
+ _drops.append(Vector2(rng.randf(), rng.randf()))
+
+
+## Прямоугольник, который сейчас видно камерой, в координатах мира.
+func _view_rect() -> Rect2:
+ var canvas := get_canvas_transform()
+ var zoom := canvas.get_scale()
+ return Rect2(-canvas.origin / zoom, get_viewport_rect().size / zoom)
diff --git a/scripts/game/surface_view.gd.uid b/scripts/game/surface_view.gd.uid
new file mode 100644
index 0000000..b893f7d
--- /dev/null
+++ b/scripts/game/surface_view.gd.uid
@@ -0,0 +1 @@
+uid://c4c36o4o35gqw
diff --git a/scripts/game/vault_camera.gd b/scripts/game/vault_camera.gd
index 7be2f5f..36faf32 100644
--- a/scripts/game/vault_camera.gd
+++ b/scripts/game/vault_camera.gd
@@ -10,6 +10,8 @@ const MIN_ZOOM := 0.45
const MAX_ZOOM := 2.2
const KEY_PAN_SPEED := 900.0
const EDGE_MARGIN := 420.0
+## Сверху запас больше: там небо со светилами, и на него хочется смотреть.
+const SKY_MARGIN := 900.0
var _dragging := false
@@ -20,7 +22,7 @@ func _ready() -> void:
limit_right = int(bounds.end.x + EDGE_MARGIN)
# Сверху отмеряем от поверхности, а не от бункера: над землёй стоит будка
# входа, и до неё нужно доезжать.
- limit_top = int(-EDGE_MARGIN)
+ limit_top = int(-SKY_MARGIN)
limit_bottom = int(bounds.end.y + EDGE_MARGIN)
# Открываемся на верхних этажах — оттуда видно и вход, и застройку.
position = Vector2(bounds.get_center().x, bounds.position.y + VaultGrid.CELL.y)
diff --git a/scripts/game/vault_scene.gd b/scripts/game/vault_scene.gd
index 1c1df4e..f65c727 100644
--- a/scripts/game/vault_scene.gd
+++ b/scripts/game/vault_scene.gd
@@ -27,7 +27,15 @@ enum Mode { SELECT, BUILD, DEMOLISH }
@onready var _saves: SaveManagerService = get_node("/root/SaveManager")
@onready var _vault: VaultView = $View
@onready var _camera: VaultCamera = $Camera
+@onready var _clock: GameClock = $Clock
+@onready var _weather: WeatherSystem = $Weather
+@onready var _sky: SkyView = $Sky
+@onready var _surface: SurfaceView = $Surface
+@onready var _weather_label: Label = $Hud/Root/TopRight/Row/ClockBar/Body/Moment/WeatherLabel
@onready var _stats: HBoxContainer = $Hud/Root/TopBar/Stats
+@onready var _date_label: Label = $Hud/Root/TopRight/Row/ClockBar/Body/Moment/DateLabel
+@onready var _time_label: Label = $Hud/Root/TopRight/Row/ClockBar/Body/Moment/TimeLabel
+@onready var _flow_controls: HBoxContainer = $Hud/Root/TopRight/Row/ClockBar/Body/Controls
@onready var _tools: HBoxContainer = $Hud/Root/BottomAnchor/Row/BuildBar/Panel/Tools
@onready var _status: Label = $Hud/Root/BottomAnchor/Row/BuildBar/Panel/Status
@@ -41,6 +49,11 @@ 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:
@@ -52,7 +65,18 @@ func _ready() -> void:
_build_stats()
_build_tools()
+ _build_flow_controls()
_refresh_stats()
+
+ _weather.attach_clock(_clock)
+ _weather.changed.connect(_on_weather_changed)
+
+ _clock.minute_passed.connect(_on_minute_passed)
+ _clock.flow_changed.connect(_refresh_flow)
+ _refresh_moment()
+ _refresh_flow()
+ _on_weather_changed(_weather.kind)
+
_status.text = HINT
@@ -167,6 +191,93 @@ func _describe(room: Dictionary) -> String:
return " · ".join(parts)
+# --- Ход времени ------------------------------------------------------------
+
+func _build_flow_controls() -> void:
+ # Тема рассчитана на крупные кнопки меню; для панели времени нужен свой,
+ # компактный набор стилей. Ресурсы общие на все кнопки — их всего четыре.
+ var styles := {
+ "normal": _flow_style("#ffffff0d", "#ffffff1f"),
+ "hover": _flow_style("#2ee6c51f", "#2ee6c566"),
+ "pressed": _flow_style("#2ee6c52e", "#2ee6c5cc"),
+ "focus": _flow_style("#2ee6c51a", "#2ee6c599"),
+ }
+
+ _play_button = _add_flow_button("▶", _flow_group, styles)
+ _play_button.pressed.connect(_clock.play)
+
+ _pause_button = _add_flow_button("❚❚", _flow_group, styles)
+ _pause_button.pressed.connect(_clock.pause)
+
+ var gap := Control.new()
+ gap.custom_minimum_size.x = 8
+ _flow_controls.add_child(gap)
+
+ for multiplier: int in GameClock.SPEEDS:
+ # Выбор скорости заодно снимает паузу — отдельно жать «play» не нужно.
+ var button := _add_flow_button("x%d" % multiplier, _speed_group, styles)
+ button.pressed.connect(_clock.set_speed.bind(multiplier))
+ _speed_buttons[multiplier] = button
+
+
+func _add_flow_button(label: String, group: ButtonGroup, styles: Dictionary) -> Button:
+ var button := Button.new()
+ button.text = label
+ button.toggle_mode = true
+ button.button_group = group
+ button.custom_minimum_size.x = 34
+ button.add_theme_font_size_override("font_size", 13)
+ for state: String in styles:
+ button.add_theme_stylebox_override(state, styles[state])
+ _flow_controls.add_child(button)
+ return button
+
+
+func _flow_style(background: String, border: String) -> StyleBoxFlat:
+ var style := StyleBoxFlat.new()
+ style.bg_color = Color(background)
+ style.border_color = Color(border)
+ style.set_border_width_all(1)
+ style.set_corner_radius_all(5)
+ style.content_margin_left = 9.0
+ style.content_margin_right = 9.0
+ style.content_margin_top = 4.0
+ style.content_margin_bottom = 4.0
+ return style
+
+
+func _on_minute_passed(_total_minutes: int) -> void:
+ _refresh_moment()
+ _refresh_environment()
+
+
+func _on_weather_changed(_kind: int) -> void:
+ _weather_label.text = _weather.label()
+
+
+func _refresh_moment() -> void:
+ _date_label.text = _clock.date_text()
+ _time_label.text = _clock.time_text()
+
+
+## Небо и поверхность зависят от одних и тех же двух вещей — часа и погоды.
+func _refresh_environment() -> void:
+ var hour := SkyCycle.hour_of(_clock.now())
+ _sky.set_conditions(hour, _weather.clouds)
+ _surface.set_conditions(hour, _weather)
+
+
+## Кнопки только отражают состояние часов — источник правды там.
+func _refresh_flow() -> void:
+ # Облака и осадки идут в такт игре: на паузе они замирают.
+ _surface.time_scale = float(_clock.speed) if _clock.running else 0.0
+ _play_button.set_pressed_no_signal(_clock.running)
+ _pause_button.set_pressed_no_signal(not _clock.running)
+ for multiplier: int in _speed_buttons:
+ var button: Button = _speed_buttons[multiplier]
+ button.set_pressed_no_signal(multiplier == _clock.speed)
+
+
# --- HUD --------------------------------------------------------------------
func _build_stats() -> void:
@@ -274,6 +385,8 @@ func _on_demolish_toggled(is_on: bool) -> void:
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,
@@ -284,6 +397,9 @@ func save_data() -> Dictionary:
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():
diff --git a/scripts/game/vault_view.gd b/scripts/game/vault_view.gd
index a492347..5890368 100644
--- a/scripts/game/vault_view.gd
+++ b/scripts/game/vault_view.gd
@@ -7,8 +7,6 @@ extends Node2D
## Когда появится графика, заменять нужно только этот файл — модель
## (VaultGrid) и логика сцены от него не зависят.
-const SKY_TOP := Color("#050912")
-const SKY_HORIZON := Color("#16333f")
const DIRT_TOP := Color("#2a231d")
const DIRT_BOTTOM := Color("#100d0b")
const SURFACE := Color("#3d4f3a")
@@ -32,7 +30,6 @@ const GHOST_BAD := Color("#f2704a")
const SELECTION := Color("#ffffff")
const MARGIN := 1200.0
-const SKY_HEIGHT := 900.0
const DIRT_DEPTH := 460.0
const PEBBLE_SEED := 20260810
@@ -74,7 +71,6 @@ func set_selection(cell: Vector2i) -> void:
func _draw() -> void:
if grid == null:
return
- _draw_sky()
_draw_dirt()
_draw_shell()
_draw_empty_cells()
@@ -89,14 +85,6 @@ func _draw() -> void:
# --- Фон --------------------------------------------------------------------
-func _draw_sky() -> void:
- var bounds := VaultGrid.bounds()
- var left := -MARGIN
- var right := bounds.size.x + MARGIN
- _fill_gradient(Rect2(Vector2(left, -SKY_HEIGHT), Vector2(right - left, SKY_HEIGHT)),
- SKY_TOP, SKY_HORIZON)
-
-
func _draw_dirt() -> void:
var bounds := VaultGrid.bounds()
var left := -MARGIN
diff --git a/scripts/game/weather_system.gd b/scripts/game/weather_system.gd
new file mode 100644
index 0000000..5eb28f4
--- /dev/null
+++ b/scripts/game/weather_system.gd
@@ -0,0 +1,148 @@
+class_name WeatherSystem
+extends Node
+## Погода: текущий тип, плавные переходы и смена по игровому времени.
+##
+## Данные и правила, без отрисовки. Вид (surface_view.gd, sky_view.gd) читает
+## отсюда `clouds`, `precipitation`, `fog` и `wind` и рисует по ним.
+##
+## Такт берётся у GameClock, поэтому погода стоит вместе с паузой и ускоряется
+## вместе со скоростью игры — своего таймера здесь нет намеренно.
+
+enum Kind { CLEAR, CLOUDY, OVERCAST, RAIN, STORM, FOG, SNOW }
+
+## Целевые параметры каждого типа. `clouds` — плотность облаков, `precipitation`
+## — сила осадков, `fog` — дымка у горизонта, `wind` — скорость сноса.
+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},
+}
+
+## Куда погода может перейти и с каким относительным весом. Матрица, а не
+## общий мешок: так дождь приходит через пасмурно, а не из ясного неба.
+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.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
+
+## Сменился тип погоды — для HUD и звука.
+signal changed(kind: int)
+
+var kind := Kind.CLEAR
+var clouds := 0.12
+var precipitation := 0.0
+var fog := 0.0
+var wind := 0.3
+
+var _minutes_left := MIN_DURATION
+var _rng := RandomNumberGenerator.new()
+var _clock: GameClock
+
+
+func _ready() -> void:
+ _rng.randomize()
+ _apply_target(1.0)
+
+
+## Сцена связывает погоду с часами: без этого она стоит на месте.
+func attach_clock(clock: GameClock) -> void:
+ _clock = clock
+ _clock.minute_passed.connect(_on_minute_passed)
+
+
+func label() -> String:
+ return String(KINDS[kind]["label"])
+
+
+## Идёт ли снег, а не дождь — виду нужно рисовать разное.
+func is_snowing() -> bool:
+ return kind == Kind.SNOW
+
+
+func save_data() -> Dictionary:
+ return {
+ "kind": int(kind),
+ "minutes_left": _minutes_left,
+ "clouds": clouds,
+ "precipitation": precipitation,
+ "fog": fog,
+ "wind": wind,
+ }
+
+
+func load_data(data: Dictionary) -> void:
+ kind = clampi(int(data.get("kind", Kind.CLEAR)), 0, KINDS.size() - 1)
+ _minutes_left = int(data.get("minutes_left", MIN_DURATION))
+ 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))
+ changed.emit(kind)
+
+
+# --- Внутреннее -------------------------------------------------------------
+
+func _on_minute_passed(_total_minutes: int) -> void:
+ _blend_toward_target()
+
+ _minutes_left -= 1
+ if _minutes_left > 0:
+ return
+
+ kind = _roll_next()
+ _minutes_left = _rng.randi_range(MIN_DURATION, MAX_DURATION)
+ 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 _roll_next() -> int:
+ var options: Dictionary = TRANSITIONS[kind]
+ var total := 0
+ for weight: int in options.values():
+ total += weight
+
+ var pick := _rng.randi_range(1, total)
+ for candidate: int in options:
+ pick -= int(options[candidate])
+ if pick <= 0:
+ return _seasonal(candidate)
+ return kind
+
+
+## Снег уместен только зимой; в остальное время он превращается в дождь.
+func _seasonal(candidate: int) -> int:
+ if candidate != Kind.SNOW:
+ return candidate
+ if _clock == null:
+ return Kind.RAIN
+ var month := int(_clock.now()["month"])
+ return Kind.SNOW if WINTER_MONTHS.has(month) else Kind.RAIN
diff --git a/scripts/game/weather_system.gd.uid b/scripts/game/weather_system.gd.uid
new file mode 100644
index 0000000..8ef3ac0
--- /dev/null
+++ b/scripts/game/weather_system.gd.uid
@@ -0,0 +1 @@
+uid://by1cwvs3fc3pa