Модульная система: игровое ядро + 6 модулей, документы Docs/Modules

- Ядро (Bogatyri.Core): контракты модулей, EventBus, GameServices, SaveRegistry
- Модули: Экономика, Поселение, Армия, Глобальный мир, Боевая система, Сохранения
  (каждый — отдельный asmdef, видит только ядро)
- App/GameModules: регистрация модулей и связь с GameProgress/Казармами
- Сохранения v2 по секциям модулей, перенос старого save.json и походов из progress.json
- Сброс сохранений: ISaveSystem.ResetAll, ResetState у модулей, меню Tools
- EditMode-тесты модулей, документы по каждому модулю с журналом изменений

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Alexander
2026-10-19 18:39:28 +02:00
co-authored by Claude Opus 5.5
parent bcb870f62e
commit 964cb23d9d
99 changed files with 2296 additions and 631 deletions
@@ -1,194 +0,0 @@
using UnityEngine;
using System.Collections.Generic;
// Управляет режимом строительства. Теперь здание можно поставить ТОЛЬКО
// на заранее подготовленный участок (BuildSlot) -- свободное размещение убрано.
// Наведение на пустой слот -> подсветка (можно строить).
// Наведение на занятый слот / мимо слотов -> подсветки нет, клик ничего не делает.
public class BuildManager : MonoBehaviour
{
public static BuildManager Instance { get; private set; }
[Header("Все типы зданий в игре")]
public List<BuildingData> allBuildings;
[Header("Поиск участков под застройку")]
[Tooltip("Слой, на котором находятся объекты BuildSlot")]
public LayerMask buildSlotsLayerMask;
[Header("Подсветка участка при наведении")]
public Color highlightValidColor = new Color(1f, 1f, 1f, 0.9f); // белая -- можно строить
public Color highlightInvalidColor = new Color(1f, 0.2f, 0.2f, 0.9f); // красная -- нельзя (не хватает ресурсов)
private BuildingData currentlyPlacing;
private BuildSlot lastHighlightedSlot;
private Camera mainCam;
void Awake()
{
if (Instance != null && Instance != this)
{
Destroy(gameObject);
return;
}
Instance = this;
mainCam = Camera.main;
}
void Update()
{
if (currentlyPlacing == null) return; // не в режиме строительства -- ничего не делаем
Vector3 worldPos = GetPointerWorldPosition();
worldPos.z = 0;
BuildSlot slotUnderPointer = GetSlotAtPosition(worldPos);
// Убираем подсветку с предыдущего слота, если курсор ушёл с него
if (lastHighlightedSlot != null && lastHighlightedSlot != slotUnderPointer)
{
lastHighlightedSlot.ClearHighlight();
}
if (slotUnderPointer != null && !slotUnderPointer.isOccupied)
{
bool canAfford = currentlyPlacing.CanAfford();
slotUnderPointer.SetHighlight(canAfford ? highlightValidColor : highlightInvalidColor);
lastHighlightedSlot = slotUnderPointer;
if (Input.GetMouseButtonDown(0) && !UIBlocker.IsPointerOverUI())
{
TryConfirmPlacement(slotUnderPointer, canAfford);
}
}
else
{
lastHighlightedSlot = null;
}
// Отмена по правой кнопке / Escape
if (Input.GetMouseButtonDown(1) || Input.GetKeyDown(KeyCode.Escape))
{
CancelPlacement();
}
}
BuildSlot GetSlotAtPosition(Vector3 position)
{
Collider2D hit = Physics2D.OverlapPoint(position, buildSlotsLayerMask);
if (hit == null) return null;
return hit.GetComponent<BuildSlot>();
}
// Список зданий, доступных для стройки ПРЯМО СЕЙЧАС (по уровню Ратуши)
public List<BuildingData> GetAvailableBuildings()
{
List<BuildingData> available = new List<BuildingData>();
int townHallLevel = TownHall.Instance != null ? TownHall.Instance.currentLevel : 1;
foreach (var b in allBuildings)
{
if (b.requiredTownHallLevel <= townHallLevel)
available.Add(b);
}
return available;
}
// Вызывается из UI-кнопки конкретного здания в меню стройки
public void StartPlacing(BuildingData data)
{
currentlyPlacing = data;
Debug.Log($"[СТРОЙКА] Выбрано здание: {data.buildingName}. Кликни на пустой участок.");
}
void TryConfirmPlacement(BuildSlot slot, bool canAfford)
{
if (!canAfford)
{
Debug.Log("[СТРОЙКА] Не хватает ресурсов!");
return;
}
currentlyPlacing.PayCost();
GameObject built = Instantiate(currentlyPlacing.prefab, slot.transform.position, Quaternion.identity);
if (!built.activeSelf) built.SetActive(true); // шаблон казарм хранится выключенным
PlacedBuildingId idMarker = built.AddComponent<PlacedBuildingId>();
idMarker.buildingName = currentlyPlacing.buildingName;
slot.Occupy(built);
BuildingSlotLink link = built.AddComponent<BuildingSlotLink>();
link.linkedSlot = slot;
Debug.Log($"[СТРОЙКА] Построено: {currentlyPlacing.buildingName} на участке {slot.name}");
currentlyPlacing = null;
lastHighlightedSlot = null;
}
void CancelPlacement()
{
if (lastHighlightedSlot != null)
{
lastHighlightedSlot.ClearHighlight();
lastHighlightedSlot = null;
}
currentlyPlacing = null;
Debug.Log("[СТРОЙКА] Размещение отменено.");
}
// ===== Новая схема стройки: тап по участку -> выбор здания (BuildModeController) =====
// Построить здание сразу на указанном участке. true -- получилось.
public bool BuildOnSlot(BuildingData data, BuildSlot slot)
{
if (data == null || slot == null || slot.isOccupied) return false;
if (!data.CanAfford()) return false;
currentlyPlacing = data;
TryConfirmPlacement(slot, true);
return true;
}
// Участок под точкой экрана (с допуском, чтобы попадать с первого раза пальцем)
public BuildSlot SlotAtScreen(Vector2 screenPos, float tolerance = 0.35f)
{
if (mainCam == null) mainCam = Camera.main;
if (mainCam == null) return null;
Vector3 w = mainCam.ScreenToWorldPoint(screenPos);
Vector2 p = new Vector2(w.x, w.y);
// Перебираем все участки и берём ближайший: работает и с триггер-коллайдерами, и без настроенного слоя
BuildSlot best = null;
float bestDist = tolerance;
foreach (var slot in FindObjectsByType<BuildSlot>(FindObjectsSortMode.None))
{
var col = slot.GetComponent<Collider2D>();
Bounds b = col != null ? col.bounds : new Bounds(slot.transform.position, Vector3.one * 0.5f);
b.center = new Vector3(b.center.x, b.center.y, 0f);
float d = Mathf.Sqrt(b.SqrDistance(new Vector3(p.x, p.y, 0f)));
if (d <= bestDist) { bestDist = d; best = slot; }
}
return best;
}
// Найти пустой (свободный) BuildSlot по названию -- используется при восстановлении сохранения
public BuildSlot FindSlotAtPosition(Vector3 position, float tolerance = 0.05f)
{
BuildSlot[] allSlots = FindObjectsByType<BuildSlot>(FindObjectsSortMode.None);
foreach (var slot in allSlots)
{
if (Vector3.Distance(slot.transform.position, position) < tolerance)
return slot;
}
return null;
}
Vector3 GetPointerWorldPosition()
{
Vector3 screenPos = Input.mousePosition;
return mainCam.ScreenToWorldPoint(screenPos);
}
}
@@ -1,11 +0,0 @@
fileFormatVersion: 2
guid: 9de6545e2f73cd04f9d95334879c4fc4
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 100
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -1,56 +0,0 @@
using UnityEngine;
[System.Serializable]
public class ResourceCost
{
public ResourceType type;
public float amount;
}
[CreateAssetMenu(fileName = "NewBuilding", menuName = "Game/Building Data")]
public class BuildingData : ScriptableObject
{
[Header("Основное")]
public string buildingName;
public Sprite icon;
public GameObject prefab;
[Header("Требования")]
[Tooltip("Необходимый уровень ратуши")]
public int requiredTownHallLevel = 1;
[Header("Стоимость")]
public ResourceCost[] cost;
// Проверка - хватает ли ресурсов? (только проверяет)
public bool CanAfford()
{
if (ResourceManager.Instance == null) return false;
foreach (var c in cost)
{
if (!ResourceManager.Instance.HasEnough(c.type, c.amount))
return false;
}
return true;
}
// Списывает стоимость постройки. Вызывать только после CanAfford() == true
public void PayCost()
{
foreach (var c in cost)
{
ResourceManager.Instance.TrySpend(c.type, c.amount);
}
}
// Текст стоимости для UI, например "50 Wood, 20 Stone"
public string GetCostText()
{
string result = "";
for (int i = 0; i < cost.Length; i++)
{
result += $"{cost[i].amount} {cost[i].type}";
if (i < cost.Length - 1) result += ", ";
}
return result;
}
}
@@ -1,2 +0,0 @@
fileFormatVersion: 2
guid: 6ce7334b9f0b4914ba6750d2a2d35155
@@ -1,81 +0,0 @@
using UnityEngine;
// Вешается на объект Ратуши, который уже стоит на карте.
// Хранит текущий уровень -- от него зависит, какие здания разблокированы в BuildManager.
public class TownHall : MonoBehaviour
{
public static TownHall Instance { get; private set; }
[Header("Уровень")]
public int currentLevel = 1;
public int maxLevel = 10;
[Header("Стоимость апгрейда (растёт с каждым уровнем)")]
public ResourceCost[] baseUpgradeCost;
[Tooltip("Во сколько раз дорожает апгрейд за каждый следующий уровень")]
public float costMultiplierPerLevel = 1.5f;
void Awake()
{
if (Instance != null && Instance != this)
{
Destroy(gameObject);
return;
}
Instance = this;
}
// Стоимость апгрейда на текущем уровне (растёт по экспоненте)
public ResourceCost[] GetUpgradeCost()
{
ResourceCost[] scaled = new ResourceCost[baseUpgradeCost.Length];
float multiplier = Mathf.Pow(costMultiplierPerLevel, currentLevel - 1);
for (int i = 0; i < baseUpgradeCost.Length; i++)
{
scaled[i] = new ResourceCost
{
type = baseUpgradeCost[i].type,
amount = baseUpgradeCost[i].amount * multiplier
};
}
return scaled;
}
public bool CanUpgrade()
{
if (currentLevel >= maxLevel) return false;
if (ResourceManager.Instance == null) return false;
foreach (var c in GetUpgradeCost())
{
if (!ResourceManager.Instance.HasEnough(c.type, c.amount))
return false;
}
return true;
}
public void Upgrade()
{
if (!CanUpgrade())
{
Debug.Log("[РАТУША] Не хватает ресурсов на апгрейд или максимальный уровень.");
return;
}
foreach (var c in GetUpgradeCost())
{
ResourceManager.Instance.TrySpend(c.type, c.amount);
}
currentLevel++;
Debug.Log($"[РАТУША] Улучшена до уровня {currentLevel}!");
}
void OnMouseDown()
{
if (UIBlocker.IsPointerOverUI()) return; // клик по панели HUD не должен улучшать Ратушу под ней
Debug.Log($"[Ратуша] Клик! текущий уровень: {currentLevel}. Улучшение...");
Upgrade();
}
}
@@ -1,2 +0,0 @@
fileFormatVersion: 2
guid: aafc612f8913ade44b6b60cf76d8932d
@@ -1,13 +0,0 @@
using UnityEngine;
// Небольшой "маркер", который автоматически ставится на КАЖДОЕ здание,
// построенное через BuildManager. Хранит имя типа здания (совпадает с
// BuildingData.buildingName), чтобы SaveManager знал, что именно это за постройка,
// когда будет сохранять/восстанавливать сцену.
//
// НЕ добавляй этот компонент вручную -- BuildManager делает это сам при постройке.
public class PlacedBuildingId : MonoBehaviour
{
public string buildingName;
}
@@ -1,2 +0,0 @@
fileFormatVersion: 2
guid: d25db0d2362937c4b8bbdeffa97b1860