Здание «Госпиталь»: уровни I-V, 20-60 мест, окно с ранеными и улучшением

Места -- модулю Армии через IHospital (HospitalBeds); без госпиталя мест нет, раненые ждут.
Добавляется в список стройки кодом, как Казармы. Числа -- HospitalConfig (ТЕСТ).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Alexander
2026-11-01 13:52:47 +01:00
co-authored by Claude Opus 5.5
parent 0214bdb268
commit af57a13f0f
13 changed files with 247 additions and 11 deletions
+5 -1
View File
@@ -12,7 +12,11 @@ MonoBehaviour:
m_Script: {fileID: 11500000, guid: c25bbe993b314a15a6d1a8aaf0017d55, type: 3}
m_Name: HospitalConfig
m_EditorClassIdentifier:
temporaryBeds: 20
bedsByLevel: 140000001e00000028000000320000003c000000
temporaryBeds: 0
requiredTownHallLevel: 1
buildWood: 250
buildStone: 150
healGameHours: 04000000040000000600000008000000
healFoodPerSoldier: 00000000000000000000000000000000
healGoldPerSoldier: 00000000000000000000000000000000
+44 -2
View File
@@ -54,10 +54,12 @@ public static class BuildingWindow
var storage = focus != null ? focus.GetComponent<StorageBuilding>() : null;
var townHall = focus != null ? focus.GetComponent<TownHall>() : null;
var barracks = focus != null ? focus.GetComponent<Barracks>() : null;
var hospital = focus != null ? focus.GetComponent<Hospital>() : null;
if (focus == null) WorkersList(card);
else if (townHall != null) TownHallSection(card, townHall);
else if (barracks != null) BarracksSection(card, barracks);
else if (hospital != null) HospitalSection(card, hospital);
else if (rc != null && rc.IsWorkplace) WorkplaceSection(card, rc);
else if (rc != null && rc.IsHouse) HouseSection(card, rc);
else if (rc != null && rc.resourceType == ResourceType.Gems) CrystalSection(card);
@@ -298,6 +300,46 @@ public static class BuildingWindow
}, -1, 80, 26);
}
// ---------------- Госпиталь (решения 31.10, п. 7) ----------------
static void HospitalSection(RectTransform card, Hospital hospital)
{
UIKit.Text(card, $"Госпиталь лечит раненых после боёв. Мест: {Hospital.BedsAt(Hospital.MaxLevel)} " +
"(считается самый прокачанный госпиталь). Кто не поместился -- ждёт места.",
24, UIKit.Theme.TextDim, TextAlignmentOptions.Center);
if (GameServices.TryGet(out IArmy army))
{
Header(card, "Раненые");
Live(card, () =>
{
int healingNow = 0, waiting = 0;
long readyMs = long.MaxValue;
foreach (var h in army.Healing) { healingNow += h.Count; readyMs = Math.Min(readyMs, h.EndMs); }
for (int t = 0; t < army.TypeCount; t++) waiting += army.GetWoundedWaiting(t);
if (healingNow + waiting == 0) return "Раненых нет";
string next = readyMs != long.MaxValue
? $", ближайшие вылечатся через {GameData.FormatTime(Math.Max(0, readyMs - GameClock.UnixNowMs) / 1000.0)}"
: "";
return $"Лечатся {healingNow} из {army.HospitalBeds} мест, ждут места {waiting}{next}";
}, 24, UIKit.Theme.Text);
Live(card, () =>
{
var lines = new List<string>();
foreach (var h in army.Healing)
if (h.Type >= 0 && h.Type < GameData.Troops.Count)
lines.Add($"{GameData.Troops[h.Type].name} ×{h.Count} — {GameData.FormatTime(Math.Max(0, h.EndMs - GameClock.UnixNowMs) / 1000.0)}");
return lines.Count > 0 ? string.Join("\n", lines.ToArray()) : "";
}, 22, UIKit.Theme.TextDim);
var rules = HospitalConfig.Load().healGameHours;
UIKit.Text(card, $"Лечение одного воина: пехота и лучники {rules[0]}, кавалерия {rules[2]}, осадные {rules[3]} игровых ч " +
"(игровой час = 5 мин). Лечатся и пока игра закрыта.", 22, UIKit.Theme.TextDim, TextAlignmentOptions.Center);
}
UpgradeBlock(card, hospital.level, $"мест {Hospital.BedsAt(hospital.level)} → {Hospital.BedsAt(hospital.level + 1)}",
() => hospital.Level = hospital.level + 1, Hospital.MaxBuildLevel);
}
// Резерв для найма на уровне Ратуши (те же числа, что у модуля Населения)
static int ReserveAt(int townHallLevel)
{
@@ -320,10 +362,10 @@ public static class BuildingWindow
// ---------------- Улучшение (общее для зданий с уровнем, кроме Ратуши) ----------------
static void UpgradeBlock(RectTransform card, int level, string effect, Action raiseLevel)
static void UpgradeBlock(RectTransform card, int level, string effect, Action raiseLevel, int maxLevel = 0)
{
var config = EconomyConfig.Load();
if (level >= config.upgrade.maxLevel)
if (level >= (maxLevel > 0 ? maxLevel : config.upgrade.maxLevel)) // у госпиталя свой предел: I-V
{
UIKit.Text(card, "Максимальный уровень", 26, UIKit.Theme.Gold, TextAlignmentOptions.Center);
return;
+4
View File
@@ -99,6 +99,10 @@ public static class GameModules
// Казармы появляются в списке стройки кодом -- до восстановления зданий из сохранения
EventBus.Subscribe<SettlementRestoringEvent>(_ => BarracksBootstrap.Ensure());
// Госпиталь (решения 31.10, п. 7): здание города даёт места для лечения Армии через контракт IHospital
GameServices.Register<IHospital>(new HospitalBeds());
EventBus.Subscribe<SettlementRestoringEvent>(_ => HospitalBootstrap.Ensure());
// Статистика для заданий (GameProgress) -- по событиям Мира
EventBus.Subscribe<MarchStartedEvent>(OnMarchStarted);
EventBus.Subscribe<MarchReturnedEvent>(OnMarchReturned);
+12 -4
View File
@@ -7,13 +7,21 @@ using UnityEngine;
// Create -> Bogatyri -> Hospital Config, имя файла -- HospitalConfig, папка -- Resources.
//
// Модуль Армии таблицу не видит: App/GameModules передаёт числа в ArmyService.
// Здание госпиталя (уровни I-V, 20-60 мест) -- следующий шаг; пока его нет, работают временные места отсюда.
// Места даёт здание «Госпиталь» (Meta/Build/Hospital.cs) через контракт IHospital.
[CreateAssetMenu(fileName = "HospitalConfig", menuName = "Bogatyri/Hospital Config")]
public class HospitalConfig : ScriptableObject
{
[Header("Места")]
[Tooltip("Мест для лечения, пока в городе нет здания госпиталя. Регламент 31.10: госпиталь I уровня -- 20")]
public int temporaryBeds = 20;
[Header("Здание «Госпиталь» (ТЕСТ)")]
[Tooltip("Мест для лечения по уровням госпиталя I..V. Регламент 31.10: 20..60. Число строк -- число уровней")]
public int[] bedsByLevel = { 20, 30, 40, 50, 60 };
[Tooltip("Мест, пока в городе нет госпиталя. 0 -- раненые ждут, пока госпиталь не построят")]
public int temporaryBeds = 0;
[Tooltip("С какого уровня Ратуши можно строить")]
public int requiredTownHallLevel = 1;
[Tooltip("Цена постройки: дерево")]
public int buildWood = 250;
[Tooltip("Цена постройки: камень")]
public int buildStone = 150;
[Header("Лечение одного воина, ИГРОВЫХ часов (1 игровой час = 5 реальных минут)")]
[Tooltip("Пехота, лучники, кавалерия, осадные. Регламент 31.10: 4 / 4 / 6 / 8")]
+60
View File
@@ -0,0 +1,60 @@
using System.Collections.Generic;
using UnityEngine;
// ГОСПИТАЛЬ -- здание для лечения раненых (регламент 31.10; решения по 8 вопросам Саши от 31.10, п. 7).
// - Даёт места для лечения: уровни I-V, 20-60 мест (таблица HospitalConfig, ТЕСТ).
// - Считаются самые прокачанные госпитали: второй госпиталь мест не добавляет (регламент про несколько не говорит).
// - Раненых хранит и лечит модуль Армии; здание только сообщает число мест через контракт IHospital (HospitalBeds).
// - Тап открывает окно госпиталя (App/BuildingWindow): места, кто лечится и ждёт, улучшение.
// - Уровень сохраняет модуль Поселения (ILeveledBuilding).
// Вешается на копию обычного здания (HospitalBootstrap).
public class Hospital : MonoBehaviour, ILeveledBuilding
{
public static readonly List<Hospital> All = new List<Hospital>();
public static int Count => All.Count;
public int level = 1;
public int Level { get => level; set => level = Mathf.Clamp(value, 1, MaxBuildLevel); }
// Уровней у госпиталя столько, сколько строк мест в таблице (регламент: I-V)
public static int MaxBuildLevel => Mathf.Max(1, HospitalConfig.Load().bedsByLevel.Length);
// Уровень самого прокачанного госпиталя (0 -- госпиталя нет)
public static int MaxLevel
{
get { int m = 0; foreach (var h in All) if (h != null && h.level > m) m = h.level; return m; }
}
// Мест на уровне (1..5) по таблице
public static int BedsAt(int lvl)
{
var beds = HospitalConfig.Load().bedsByLevel;
if (lvl <= 0 || beds == null || beds.Length == 0) return 0;
return Mathf.Max(0, beds[Mathf.Min(lvl, beds.Length) - 1]);
}
void OnEnable() { if (!All.Contains(this)) All.Add(this); }
void OnDisable() { All.Remove(this); }
Vector3 downPos;
void OnMouseDown()
{
downPos = Input.mousePosition;
}
// Тап (а не перетаскивание камеры) -- окно госпиталя
void OnMouseUpAsButton()
{
if (UIBlocker.IsPointerOverUI()) return;
if ((Input.mousePosition - downPos).magnitude > 25f) return;
BuildingWindow.Open(gameObject);
}
}
// Места госпиталя для модуля Армии (контракт IHospital). Регистрирует App/GameModules.
// Госпиталя нет -- 0 мест: Армия берёт HospitalConfig.temporaryBeds.
public sealed class HospitalBeds : IHospital
{
public int Beds => Hospital.BedsAt(Hospital.MaxLevel);
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 2f4c537a26bd44b3ae851af08c652a34
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,65 @@
using UnityEngine;
// Добавляет «Госпиталь» в список стройки САМ при запуске сцены (как Казармы -- BarracksBootstrap).
// Срабатывает до восстановления зданий из сохранения (SettlementRestoringEvent), поэтому построенный
// госпиталь и его уровень корректно восстанавливаются из save.json.
public static class HospitalBootstrap
{
public const string HospitalName = "Госпиталь";
static GameObject runtimeTemplate;
[RuntimeInitializeOnLoadMethod(RuntimeInitializeLoadType.AfterSceneLoad)]
static void Init()
{
Ensure();
// при каждом новом входе в город (например, возврат с карты мира) -- снова добавить в новый BuildManager
UnityEngine.SceneManagement.SceneManager.sceneLoaded += (s, m) => Ensure();
}
public static void Ensure()
{
var bm = BuildManager.Instance != null ? BuildManager.Instance : Object.FindAnyObjectByType<BuildManager>();
if (bm == null || bm.allBuildings == null) return;
BuildingData template = null;
foreach (var b in bm.allBuildings)
{
if (b == null) continue;
if (b.buildingName == HospitalName) return; // уже есть
if (template == null && b.prefab != null) template = b;
}
if (template == null) { Debug.LogWarning("[ГОСПИТАЛЬ] Нет ни одного здания-шаблона в BuildManager"); return; }
// Префаб-шаблон в памяти: копия обычного здания без сбора ресурсов, с компонентом Hospital
if (runtimeTemplate == null)
{
runtimeTemplate = Object.Instantiate(template.prefab);
runtimeTemplate.name = "Hospital";
runtimeTemplate.SetActive(false);
Object.DontDestroyOnLoad(runtimeTemplate);
foreach (var c in runtimeTemplate.GetComponents<ResourceCollector>()) Object.DestroyImmediate(c);
foreach (var c in runtimeTemplate.GetComponents<ResourceProducer>()) Object.DestroyImmediate(c);
foreach (var c in runtimeTemplate.GetComponents<BuildingUI>()) Object.DestroyImmediate(c);
for (int i = runtimeTemplate.transform.childCount - 1; i >= 0; i--)
Object.DestroyImmediate(runtimeTemplate.transform.GetChild(i).gameObject); // полоска запаса ресурса
var sr = runtimeTemplate.GetComponent<SpriteRenderer>();
if (sr != null) sr.color = new Color(0.85f, 0.95f, 1f); // пока нет арта -- отличаем цветом (светло-голубой)
runtimeTemplate.AddComponent<Hospital>();
}
var config = HospitalConfig.Load();
var data = ScriptableObject.CreateInstance<BuildingData>();
data.name = "Data_Hospital_Runtime";
data.buildingName = HospitalName;
data.icon = template.icon;
data.prefab = runtimeTemplate;
data.requiredTownHallLevel = Mathf.Max(1, config.requiredTownHallLevel);
data.cost = new[]
{
new ResourceCost { type = ResourceType.Wood, amount = config.buildWood },
new ResourceCost { type = ResourceType.Stone, amount = config.buildStone },
};
bm.allBuildings.Add(data);
Debug.Log("[ГОСПИТАЛЬ] Добавлен в список стройки");
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 3301bf2246b4444182d1362fe7bffcc5
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -1921,4 +1921,30 @@ public class ModuleTests
Assert.Greater(wounded, 0, "в бою есть раненые");
Assert.LessOrEqual(m.survivors + wounded, 40, "выжившие + раненые не больше ушедших");
}
class FakeHospital : IHospital { public int Beds { get; set; } }
// Места даёт здание госпиталя (IHospital); без здания -- временные из таблицы (0 -- ждут постройки)
[Test]
public void Hospital_BedsFromBuilding_UpgradeFreesWaiting()
{
var army = new ArmyService(2, new HospitalRules { TemporaryBeds = 0 });
GameServices.Register<IArmy>(army);
army.AddTroops(0, 50);
army.TakeForMarch(1, 50);
army.ReturnUnits(1, null, new[] { 50, 0, 0, 0 }, 0);
Assert.AreEqual(0, army.HospitalBeds, "госпиталя нет -- мест нет");
Assert.AreEqual(50, army.GetWoundedWaiting(0), "все ждут");
var hospital = new FakeHospital { Beds = 20 };
GameServices.Register<IHospital>(hospital);
army.ProcessHealing(1000);
Assert.AreEqual(20, army.Healing[0].Count, "построили госпиталь I уровня -- 20 легли");
Assert.AreEqual(1000, army.Healing[0].StartMs);
hospital.Beds = 40; // улучшили до III
army.ProcessHealing(2000);
Assert.AreEqual(20, army.GetWoundedWaiting(0) + 10, "ещё 20 легли сразу после улучшения: ждут 10");
Assert.AreEqual(40, army.Healing[0].Count + army.Healing[1].Count);
}
}