Author SHA1 Message Date
AlexanderandClaude Opus 5.5 f10fb3b3aa Модуль Армии хранит войска; окно «Войско вернулось» в городе
Модуль Армии (Bogatyri.Army, без Unity):
- ArmyService — единственное хранилище войск (4 типа), секция сохранения "army"
- IArmy расширен: войска по типам, обучение (AddTroops), потери (LoseFraction), войска в походах
- событие TroopsChangedEvent — по нему перерисовываются панели HUD
- GameProgress и панели работают с войсками только через IArmy
- перенос из progress.json (troops, sashaMarchUnits) при первой загрузке;
  старые данные удаляются только после записи save.json (флаг armyInModule)
- ArmyBridge удалён: контракты и перенос сохранений проверены тестами
- тесты: поход не создаёт и не теряет солдат, потери по типам, сохранение, перенос, сброс

Окно «ВОЙСКО ВЕРНУЛОСЬ» в городе (UI/MarchReturnPopup):
- куда ходили, победа/поражение, сколько вернулось из скольких, добыча
- показывается и за походы, закончившиеся пока игра была закрыта; несколько — по очереди
- в MarchReturnedEvent добавлены Sent, TargetName, LootText

GameLoadedEvent публикуется и без файла сохранения. Документы Army, Core, Save, World, README обновлены.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 18:33:04 +02:00
AlexanderandClaude Opus 5.5 d1c8215ed8 Слияние develop в feature/modules: модульная система становится общей
develop содержал первую версию карты мира, чата и рейтинга (коммиты 26c0a20, c86f43a),
которая уже была вручную перенесена в проект при слиянии ababe38 и затем развита.
Все конфликты разрешены в пользу feature/modules:
- ResourceManager/SaveManager/MarchManager/ChatService/Simulation переехали в Assets/Scripts/Modules
- ресурс Soldiers заменён армией из казарм (ArmyBridge), сохранение города и походов — секциями модулей
- сцены MVP1 и WorldMap — более новые версии (кнопка «БОЙ» с OpenBattleButton сохранена)
Содержимое ветки после слияния не изменилось.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 18:17:11 +02:00
AlexanderandClaude Opus 5.5 d570fddbc8 Одна карта мира: удалена маленькая карта Meta/World
- удалены WorldSystem, WorldMapPanel, WorldData, WorldLine и окно Panel_World из сцены города
- кнопка «МИР» в городе открывает большую карту (сцена WorldMap) через UIPanelManager.Open("World")
- перенос сохранений: солдаты из походов старой карты возвращаются в город при загрузке
  (отправленные, а если отряд шёл назад — выжившие); добыча таких походов не переносится
- ArmyBridge.TroopsInMarches: панель «Войска» показывает воинов в походах большой карты
- HUDPanelsBuilder: кнопка «МИР» без отдельной панели
- документы World, Army, README обновлены

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 19:27:34 +02:00
AlexanderandClaude Opus 5.5 15b06b8f42 Модули Чата и Круга, разворот чата на весь экран, кнопка «Почта» в Safe Area
Модуль Чата (Bogatyri.Chat, без Unity):
- чат вынесен из Social в отдельный модуль; контракт IChat в ядре,
  событие ChatMessageReceivedEvent, LocalPlayer (id и имя игрока)
- панель чата работает только через контракт и события
- разворот чата на весь экран: кнопка «Развернуть/Свернуть» или Esc

Модуль Круга (Bogatyri.Circle, без Unity):
- основать Круг, вступить, выйти; роли главы и участника; 30 мест
- уход главы передаёт главенство, последний участник распускает Круг
- секция сохранения "circle", участвует в сбросе сохранений
- окно «Круг» (кнопка внизу карты мира), демо-Круги до появления сервера
- канал «Круг» в чате — только для участников Круга

Прочее:
- кнопка «Почта» привязана к правому краю нижней панели
  (в безопасной зоне она уезжала за экран)
- тесты Чата и Круга; документы Chat.md, Circle.md, обновлены README, Core, Save

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 19:07:45 +02:00
AlexanderandClaude Opus 5.5 57fc7e0006 HUD учитывает Safe Area (вырез, скругления, полоска «домой»)
- UI/SafeArea: подгоняет контейнер под Screen.safeArea, следит за поворотом
- UI/SafeAreaInstaller: при загрузке сцены переносит элементы HUD_Canvas и
  WorldMap_Canvas в контейнер SafeArea; окна на весь экран остаются на весь экран

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 18:41:43 +02:00
AlexanderandClaude Opus 5.5 964cb23d9d Модульная система: игровое ядро + 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>
2026-10-19 18:39:28 +02:00
Alexander c86f43a806 HUD мира 2026-09-26 17:50:11 +02:00
Alexander 26c0a20520 Реализация кнопки бой, создана базовая локация мира, отправление армии на соседей , нападение на босса, система нападения на нескольких соседей, разделение армии, гибель части армии, система боя по количеству человеку в армии 2026-09-25 19:59:29 +02:00
141 changed files with 3999 additions and 2322 deletions
+1 -1
View File
@@ -37,7 +37,7 @@ public class HUDPanelsBuilder
new Entry("Nav_Arena", "Arena", "АРЕНА", typeof(ArenaPanel), true),
new Entry("Nav_Troops", "Troops", "ВОЙСКА", typeof(TroopsPanel), false),
new Entry("Nav_Shop", "Shop", "МАГАЗИН", typeof(ShopPanel), true),
new Entry("Nav_World", "World", "КАРТА МИРА", typeof(WorldMapPanel), true), // кнопки нет -- билдер создаст
new Entry("Nav_World", "World", null, null, false), // кнопка "МИР" -> сцена большой карты (UIPanelManager.Open)
new Entry("Nav_Home", "Home", null, null, false), // без панели: закрывает всё
};
+43
View File
@@ -0,0 +1,43 @@
using System.IO;
using UnityEditor;
using UnityEngine;
// Tools -> Сбросить сохранения. Для разработчиков: новая игра одной кнопкой.
// - в Play Mode -- тот же сброс, что кнопка в Настройках (все модули + прогресс);
// - вне Play Mode -- просто удаляет файлы save.json и progress.json.
// Документ: Docs/Modules/Save.md
public static class SaveResetMenu
{
static readonly string[] SaveFiles = { "save.json", "progress.json" };
[MenuItem("Tools/Сбросить сохранения")]
static void ResetSaves()
{
if (!EditorUtility.DisplayDialog("Сбросить сохранения?",
"Ресурсы, здания, Ратуша, войска, походы, задания и почта начнутся заново.\nЭто нельзя отменить.",
"Сбросить", "Отмена"))
return;
if (Application.isPlaying)
{
GameProgress.Instance.ResetAll();
return;
}
int deleted = 0;
foreach (var name in SaveFiles)
{
string path = Path.Combine(Application.persistentDataPath, name);
if (!File.Exists(path)) continue;
File.Delete(path);
deleted++;
}
Debug.Log($"[СОХРАНЕНИЕ] Удалено файлов сохранения: {deleted} ({Application.persistentDataPath}). Следующий запуск -- новая игра.");
}
[MenuItem("Tools/Открыть папку сохранений")]
static void OpenSaveFolder()
{
EditorUtility.RevealInFinder(Application.persistentDataPath);
}
}
+2
View File
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 972f2f5a1fa0c38439b03355997f98e9
+5
View File
@@ -168,6 +168,11 @@ public static class WorldMapSceneBuilder
SetRect(circleTab, new Vector2(0, 1), new Vector2(0, 1), new Vector2(0, 1), new Vector2(240, -10), new Vector2(220, 54));
chat.circleTabButton = circleTab.GetComponent<Button>();
RectTransform expand = CreateButton(panel, "ExpandButton", "Развернуть", new Color(0.12f, 0.13f, 0.17f), out TMP_Text expandText);
SetRect(expand, new Vector2(1, 1), new Vector2(1, 1), new Vector2(1, 1), new Vector2(-10, -10), new Vector2(150, 54));
expandText.fontSize = 24;
chat.expandButton = expand.GetComponent<Button>();
// Лента: текст прижат к низу, старые строки уходят вверх и обрезаются маской
var messagesGO = new GameObject("Messages", typeof(RectTransform), typeof(RectMask2D));
RectTransform messages = messagesGO.GetComponent<RectTransform>();
+3 -539
View File
@@ -3971,143 +3971,6 @@ CanvasRenderer:
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 328760585}
m_CullTransparentMesh: 1
--- !u!1 &334263487
GameObject:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
serializedVersion: 6
m_Component:
- component: {fileID: 334263488}
- component: {fileID: 334263490}
- component: {fileID: 334263489}
m_Layer: 0
m_Name: X
m_TagString: Untagged
m_Icon: {fileID: 0}
m_NavMeshLayer: 0
m_StaticEditorFlags: 0
m_IsActive: 1
--- !u!224 &334263488
RectTransform:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 334263487}
m_LocalRotation: {x: 0, y: 0, z: 0, w: 1}
m_LocalPosition: {x: 0, y: 0, z: 0}
m_LocalScale: {x: 1, y: 1, z: 1}
m_ConstrainProportionsScale: 0
m_Children: []
m_Father: {fileID: 1708139837}
m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0}
m_AnchorMin: {x: 0, y: 0}
m_AnchorMax: {x: 1, y: 1}
m_AnchoredPosition: {x: 0, y: 0}
m_SizeDelta: {x: 0, y: 0}
m_Pivot: {x: 0.5, y: 0.5}
--- !u!114 &334263489
MonoBehaviour:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 334263487}
m_Enabled: 1
m_EditorHideFlags: 0
m_Script: {fileID: 11500000, guid: f4688fdb7df04437aeb418b961361dc5, type: 3}
m_Name:
m_EditorClassIdentifier: Unity.TextMeshPro::TMPro.TextMeshProUGUI
m_Material: {fileID: 0}
m_Color: {r: 1, g: 1, b: 1, a: 1}
m_RaycastTarget: 0
m_RaycastPadding: {x: 0, y: 0, z: 0, w: 0}
m_Maskable: 1
m_OnCullStateChanged:
m_PersistentCalls:
m_Calls: []
m_text: X
m_isRightToLeft: 0
m_fontAsset: {fileID: 11400000, guid: 8f586378b4e144a9851e7b34d9b748ee, type: 2}
m_sharedMaterial: {fileID: 2180264, guid: 8f586378b4e144a9851e7b34d9b748ee, type: 2}
m_fontSharedMaterials: []
m_fontMaterial: {fileID: 0}
m_fontMaterials: []
m_fontColor32:
serializedVersion: 2
rgba: 4294967295
m_fontColor: {r: 1, g: 1, b: 1, a: 1}
m_enableVertexGradient: 0
m_colorMode: 3
m_fontColorGradient:
topLeft: {r: 1, g: 1, b: 1, a: 1}
topRight: {r: 1, g: 1, b: 1, a: 1}
bottomLeft: {r: 1, g: 1, b: 1, a: 1}
bottomRight: {r: 1, g: 1, b: 1, a: 1}
m_fontColorGradientPreset: {fileID: 0}
m_spriteAsset: {fileID: 0}
m_tintAllSprites: 0
m_StyleSheet: {fileID: 0}
m_TextStyleHashCode: 0
m_overrideHtmlColors: 0
m_faceColor:
serializedVersion: 2
rgba: 4294967295
m_fontSize: 44
m_fontSizeBase: 44
m_fontWeight: 400
m_enableAutoSizing: 0
m_fontSizeMin: 18
m_fontSizeMax: 72
m_fontStyle: 0
m_HorizontalAlignment: 2
m_VerticalAlignment: 512
m_textAlignment: 65535
m_characterSpacing: 0
m_characterHorizontalScale: 1
m_wordSpacing: 0
m_lineSpacing: 0
m_lineSpacingMax: 0
m_paragraphSpacing: 0
m_charWidthMaxAdj: 0
m_TextWrappingMode: 1
m_wordWrappingRatios: 0.4
m_overflowMode: 0
m_linkedTextComponent: {fileID: 0}
parentLinkedComponent: {fileID: 0}
m_enableKerning: 0
m_ActiveFontFeatures: 6e72656b
m_enableExtraPadding: 0
checkPaddingRequired: 0
m_isRichText: 1
m_EmojiFallbackSupport: 1
m_parseCtrlCharacters: 1
m_isOrthographic: 1
m_isCullingEnabled: 0
m_horizontalMapping: 0
m_verticalMapping: 0
m_uvLineOffset: 0
m_geometrySortingOrder: 0
m_IsTextObjectScaleStatic: 0
m_VertexBufferAutoSizeReduction: 0
m_useMaxVisibleDescender: 1
m_pageToDisplay: 1
m_margin: {x: 0, y: 0, z: 0, w: 0}
m_isUsingLegacyAnimationComponent: 0
m_isVolumetricText: 0
m_hasFontAssetChanged: 0
m_baseMaterial: {fileID: 0}
m_maskOffset: {x: 0, y: 0, z: 0, w: 0}
--- !u!222 &334263490
CanvasRenderer:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 334263487}
m_CullTransparentMesh: 1
--- !u!1 &341518303
GameObject:
m_ObjectHideFlags: 0
@@ -4627,41 +4490,6 @@ Transform:
m_Children: []
m_Father: {fileID: 0}
m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0}
--- !u!1 &397755583
GameObject:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
serializedVersion: 6
m_Component:
- component: {fileID: 397755584}
m_Layer: 0
m_Name: Content
m_TagString: Untagged
m_Icon: {fileID: 0}
m_NavMeshLayer: 0
m_StaticEditorFlags: 0
m_IsActive: 1
--- !u!224 &397755584
RectTransform:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 397755583}
m_LocalRotation: {x: 0, y: 0, z: 0, w: 1}
m_LocalPosition: {x: 0, y: 0, z: 0}
m_LocalScale: {x: 1, y: 1, z: 1}
m_ConstrainProportionsScale: 0
m_Children: []
m_Father: {fileID: 510675372}
m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0}
m_AnchorMin: {x: 0, y: 0}
m_AnchorMax: {x: 1, y: 1}
m_AnchoredPosition: {x: 0, y: -50}
m_SizeDelta: {x: -40, y: -140}
m_Pivot: {x: 0.5, y: 0.5}
--- !u!1 &398822858
GameObject:
m_ObjectHideFlags: 0
@@ -6474,97 +6302,6 @@ MonoBehaviour:
m_StringArgument: Troops
m_BoolArgument: 0
m_CallState: 2
--- !u!1 &510675368
GameObject:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
serializedVersion: 6
m_Component:
- component: {fileID: 510675372}
- component: {fileID: 510675371}
- component: {fileID: 510675370}
- component: {fileID: 510675369}
m_Layer: 0
m_Name: Panel_World
m_TagString: Untagged
m_Icon: {fileID: 0}
m_NavMeshLayer: 0
m_StaticEditorFlags: 0
m_IsActive: 0
--- !u!114 &510675369
MonoBehaviour:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 510675368}
m_Enabled: 1
m_EditorHideFlags: 0
m_Script: {fileID: 11500000, guid: 8c8463f829cbc0840b0b660e08974aa6, type: 3}
m_Name:
m_EditorClassIdentifier: Assembly-CSharp::WorldMapPanel
--- !u!114 &510675370
MonoBehaviour:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 510675368}
m_Enabled: 1
m_EditorHideFlags: 0
m_Script: {fileID: 11500000, guid: fe87c0e1cc204ed48ad3b37840f39efc, type: 3}
m_Name:
m_EditorClassIdentifier: UnityEngine.UI::UnityEngine.UI.Image
m_Material: {fileID: 0}
m_Color: {r: 0.1, g: 0.12, b: 0.15, a: 0.97}
m_RaycastTarget: 1
m_RaycastPadding: {x: 0, y: 0, z: 0, w: 0}
m_Maskable: 1
m_OnCullStateChanged:
m_PersistentCalls:
m_Calls: []
m_Sprite: {fileID: 0}
m_Type: 0
m_PreserveAspect: 0
m_FillCenter: 1
m_FillMethod: 4
m_FillAmount: 1
m_FillClockwise: 1
m_FillOrigin: 0
m_UseSpriteMesh: 0
m_PixelsPerUnitMultiplier: 1
--- !u!222 &510675371
CanvasRenderer:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 510675368}
m_CullTransparentMesh: 1
--- !u!224 &510675372
RectTransform:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 510675368}
m_LocalRotation: {x: 0, y: 0, z: 0, w: 1}
m_LocalPosition: {x: 0, y: 0, z: 0}
m_LocalScale: {x: 1, y: 1, z: 1}
m_ConstrainProportionsScale: 0
m_Children:
- {fileID: 923071314}
- {fileID: 397755584}
- {fileID: 1708139837}
m_Father: {fileID: 649118781}
m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0}
m_AnchorMin: {x: 0, y: 0}
m_AnchorMax: {x: 1, y: 1}
m_AnchoredPosition: {x: 0, y: 25}
m_SizeDelta: {x: -32, y: -340}
m_Pivot: {x: 0.5, y: 0.5}
--- !u!1 &520651314
GameObject:
m_ObjectHideFlags: 0
@@ -8362,7 +8099,6 @@ RectTransform:
- {fileID: 504179948}
- {fileID: 589304973}
- {fileID: 1203581059}
- {fileID: 510675372}
m_Father: {fileID: 1094697982}
m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0}
m_AnchorMin: {x: 0, y: 0}
@@ -12103,143 +11839,6 @@ CanvasRenderer:
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 909229333}
m_CullTransparentMesh: 1
--- !u!1 &923071313
GameObject:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
serializedVersion: 6
m_Component:
- component: {fileID: 923071314}
- component: {fileID: 923071316}
- component: {fileID: 923071315}
m_Layer: 0
m_Name: Title
m_TagString: Untagged
m_Icon: {fileID: 0}
m_NavMeshLayer: 0
m_StaticEditorFlags: 0
m_IsActive: 1
--- !u!224 &923071314
RectTransform:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 923071313}
m_LocalRotation: {x: 0, y: 0, z: 0, w: 1}
m_LocalPosition: {x: 0, y: 0, z: 0}
m_LocalScale: {x: 1, y: 1, z: 1}
m_ConstrainProportionsScale: 0
m_Children: []
m_Father: {fileID: 510675372}
m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0}
m_AnchorMin: {x: 0, y: 1}
m_AnchorMax: {x: 1, y: 1}
m_AnchoredPosition: {x: 0, y: 0}
m_SizeDelta: {x: 0, y: 110}
m_Pivot: {x: 0.5, y: 1}
--- !u!114 &923071315
MonoBehaviour:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 923071313}
m_Enabled: 1
m_EditorHideFlags: 0
m_Script: {fileID: 11500000, guid: f4688fdb7df04437aeb418b961361dc5, type: 3}
m_Name:
m_EditorClassIdentifier: Unity.TextMeshPro::TMPro.TextMeshProUGUI
m_Material: {fileID: 0}
m_Color: {r: 1, g: 1, b: 1, a: 1}
m_RaycastTarget: 0
m_RaycastPadding: {x: 0, y: 0, z: 0, w: 0}
m_Maskable: 1
m_OnCullStateChanged:
m_PersistentCalls:
m_Calls: []
m_text: "\u041A\u0410\u0420\u0422\u0410 \u041C\u0418\u0420\u0410"
m_isRightToLeft: 0
m_fontAsset: {fileID: 11400000, guid: 8f586378b4e144a9851e7b34d9b748ee, type: 2}
m_sharedMaterial: {fileID: 2180264, guid: 8f586378b4e144a9851e7b34d9b748ee, type: 2}
m_fontSharedMaterials: []
m_fontMaterial: {fileID: 0}
m_fontMaterials: []
m_fontColor32:
serializedVersion: 2
rgba: 4294967295
m_fontColor: {r: 0.95, g: 0.75, b: 0.25, a: 1}
m_enableVertexGradient: 0
m_colorMode: 3
m_fontColorGradient:
topLeft: {r: 1, g: 1, b: 1, a: 1}
topRight: {r: 1, g: 1, b: 1, a: 1}
bottomLeft: {r: 1, g: 1, b: 1, a: 1}
bottomRight: {r: 1, g: 1, b: 1, a: 1}
m_fontColorGradientPreset: {fileID: 0}
m_spriteAsset: {fileID: 0}
m_tintAllSprites: 0
m_StyleSheet: {fileID: 0}
m_TextStyleHashCode: 0
m_overrideHtmlColors: 0
m_faceColor:
serializedVersion: 2
rgba: 4294967295
m_fontSize: 52
m_fontSizeBase: 52
m_fontWeight: 400
m_enableAutoSizing: 0
m_fontSizeMin: 18
m_fontSizeMax: 72
m_fontStyle: 1
m_HorizontalAlignment: 2
m_VerticalAlignment: 512
m_textAlignment: 65535
m_characterSpacing: 0
m_characterHorizontalScale: 1
m_wordSpacing: 0
m_lineSpacing: 0
m_lineSpacingMax: 0
m_paragraphSpacing: 0
m_charWidthMaxAdj: 0
m_TextWrappingMode: 1
m_wordWrappingRatios: 0.4
m_overflowMode: 0
m_linkedTextComponent: {fileID: 0}
parentLinkedComponent: {fileID: 0}
m_enableKerning: 0
m_ActiveFontFeatures: 6e72656b
m_enableExtraPadding: 0
checkPaddingRequired: 0
m_isRichText: 1
m_EmojiFallbackSupport: 1
m_parseCtrlCharacters: 1
m_isOrthographic: 1
m_isCullingEnabled: 0
m_horizontalMapping: 0
m_verticalMapping: 0
m_uvLineOffset: 0
m_geometrySortingOrder: 0
m_IsTextObjectScaleStatic: 0
m_VertexBufferAutoSizeReduction: 0
m_useMaxVisibleDescender: 1
m_pageToDisplay: 1
m_margin: {x: 0, y: 0, z: 0, w: 0}
m_isUsingLegacyAnimationComponent: 0
m_isVolumetricText: 0
m_hasFontAssetChanged: 0
m_baseMaterial: {fileID: 0}
m_maskOffset: {x: 0, y: 0, z: 0, w: 0}
--- !u!222 &923071316
CanvasRenderer:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 923071313}
m_CullTransparentMesh: 1
--- !u!1 &933469920
GameObject:
m_ObjectHideFlags: 0
@@ -13903,8 +13502,6 @@ MonoBehaviour:
panel: {fileID: 589304972}
- id: Shop
panel: {fileID: 1203581058}
- id: World
panel: {fileID: 510675368}
--- !u!114 &1094697985
MonoBehaviour:
m_ObjectHideFlags: 0
@@ -15069,9 +14666,9 @@ RectTransform:
- {fileID: 54912383}
m_Father: {fileID: 503920959}
m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0}
m_AnchorMin: {x: 0, y: 1}
m_AnchorMax: {x: 0, y: 1}
m_AnchoredPosition: {x: 2073.0068, y: 172.02252}
m_AnchorMin: {x: 1, y: 1}
m_AnchorMax: {x: 1, y: 1}
m_AnchoredPosition: {x: -162, y: 172.02252}
m_SizeDelta: {x: 128.34, y: 102.34}
m_Pivot: {x: 0, y: 1}
--- !u!114 &1226487462
@@ -19233,139 +18830,6 @@ CanvasRenderer:
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 1684877102}
m_CullTransparentMesh: 1
--- !u!1 &1708139836
GameObject:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
serializedVersion: 6
m_Component:
- component: {fileID: 1708139837}
- component: {fileID: 1708139840}
- component: {fileID: 1708139839}
- component: {fileID: 1708139838}
m_Layer: 0
m_Name: CloseButton
m_TagString: Untagged
m_Icon: {fileID: 0}
m_NavMeshLayer: 0
m_StaticEditorFlags: 0
m_IsActive: 1
--- !u!224 &1708139837
RectTransform:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 1708139836}
m_LocalRotation: {x: 0, y: 0, z: 0, w: 1}
m_LocalPosition: {x: 0, y: 0, z: 0}
m_LocalScale: {x: 1, y: 1, z: 1}
m_ConstrainProportionsScale: 0
m_Children:
- {fileID: 334263488}
m_Father: {fileID: 510675372}
m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0}
m_AnchorMin: {x: 1, y: 1}
m_AnchorMax: {x: 1, y: 1}
m_AnchoredPosition: {x: -12, y: -10}
m_SizeDelta: {x: 90, y: 90}
m_Pivot: {x: 1, y: 1}
--- !u!114 &1708139838
MonoBehaviour:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 1708139836}
m_Enabled: 1
m_EditorHideFlags: 0
m_Script: {fileID: 11500000, guid: 4e29b1a8efbd4b44bb3f3716e73f07ff, type: 3}
m_Name:
m_EditorClassIdentifier: UnityEngine.UI::UnityEngine.UI.Button
m_Navigation:
m_Mode: 3
m_WrapAround: 0
m_SelectOnUp: {fileID: 0}
m_SelectOnDown: {fileID: 0}
m_SelectOnLeft: {fileID: 0}
m_SelectOnRight: {fileID: 0}
m_Transition: 1
m_Colors:
m_NormalColor: {r: 1, g: 1, b: 1, a: 1}
m_HighlightedColor: {r: 0.9607843, g: 0.9607843, b: 0.9607843, a: 1}
m_PressedColor: {r: 0.78431374, g: 0.78431374, b: 0.78431374, a: 1}
m_SelectedColor: {r: 0.9607843, g: 0.9607843, b: 0.9607843, a: 1}
m_DisabledColor: {r: 0.78431374, g: 0.78431374, b: 0.78431374, a: 0.5019608}
m_ColorMultiplier: 1
m_FadeDuration: 0.1
m_SpriteState:
m_HighlightedSprite: {fileID: 0}
m_PressedSprite: {fileID: 0}
m_SelectedSprite: {fileID: 0}
m_DisabledSprite: {fileID: 0}
m_AnimationTriggers:
m_NormalTrigger: Normal
m_HighlightedTrigger: Highlighted
m_PressedTrigger: Pressed
m_SelectedTrigger: Selected
m_DisabledTrigger: Disabled
m_Interactable: 1
m_TargetGraphic: {fileID: 1708139839}
m_OnClick:
m_PersistentCalls:
m_Calls:
- m_Target: {fileID: 1094697984}
m_TargetAssemblyTypeName: UIPanelManager, Assembly-CSharp
m_MethodName: CloseAll
m_Mode: 1
m_Arguments:
m_ObjectArgument: {fileID: 0}
m_ObjectArgumentAssemblyTypeName:
m_IntArgument: 0
m_FloatArgument: 0
m_StringArgument:
m_BoolArgument: 0
m_CallState: 2
--- !u!114 &1708139839
MonoBehaviour:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 1708139836}
m_Enabled: 1
m_EditorHideFlags: 0
m_Script: {fileID: 11500000, guid: fe87c0e1cc204ed48ad3b37840f39efc, type: 3}
m_Name:
m_EditorClassIdentifier: UnityEngine.UI::UnityEngine.UI.Image
m_Material: {fileID: 0}
m_Color: {r: 0.6, g: 0.15, b: 0.15, a: 1}
m_RaycastTarget: 1
m_RaycastPadding: {x: 0, y: 0, z: 0, w: 0}
m_Maskable: 1
m_OnCullStateChanged:
m_PersistentCalls:
m_Calls: []
m_Sprite: {fileID: 0}
m_Type: 0
m_PreserveAspect: 0
m_FillCenter: 1
m_FillMethod: 4
m_FillAmount: 1
m_FillClockwise: 1
m_FillOrigin: 0
m_UseSpriteMesh: 0
m_PixelsPerUnitMultiplier: 1
--- !u!222 &1708139840
CanvasRenderer:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 1708139836}
m_CullTransparentMesh: 1
--- !u!1 &1753763060
GameObject:
m_ObjectHideFlags: 0
+8
View File
@@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: bb59bee2c4134da4b8a13896c9b2e177
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:
+114
View File
@@ -0,0 +1,114 @@
using UnityEngine;
// ТОЧКА СБОРКИ МОДУЛЕЙ (слой приложения). Документ: Docs/Modules/README.md
//
// Здесь и только здесь модули соединяются с остальной игрой:
// 1. регистрируются реализации контрактов, которые не регистрируются сами;
// 2. события модулей подключаются к системам, которые ещё не разнесены по модулям (GameProgress, Казармы).
// Модули друг о друге ничего не знают -- всю "проводку" видно в одном файле.
public static class GameModules
{
// После SubsystemRegistration (там ядро очищается) и раньше любых Awake
[RuntimeInitializeOnLoadMethod(RuntimeInitializeLoadType.AfterAssembliesLoaded)]
static void Register()
{
// Боевая система -- чистый C#, сама зарегистрироваться не может
GameServices.Register<IBattleSystem>(new BattleSystem());
// Армия -- чистый C#, единственное место хранения войск (секция "army")
var army = new ArmyService();
GameServices.Register<IArmy>(army);
SaveRegistry.Register(army);
EventBus.Subscribe<TroopsChangedEvent>(_ => { if (GameProgress.HasInstance) GameProgress.Instance.MarkChanged(); });
EventBus.Subscribe<GameLoadedEvent>(_ => MigrateLegacyArmy(army));
// Круг -- чистый C#, сам сохраняет, в каком Круге игрок (секция "circle")
var circle = new CircleService();
GameServices.Register<ICircle>(circle);
SaveRegistry.Register(circle);
// Чат -- чистый C#; история живёт, пока открыта игра (между сценами тоже)
GameServices.Register<IChat>(new ChatService(System.DateTimeOffset.UtcNow.ToUnixTimeMilliseconds()));
// Казармы появляются в списке стройки кодом -- до восстановления зданий из сохранения
EventBus.Subscribe<SettlementRestoringEvent>(_ => BarracksBootstrap.Ensure());
// Статистика для заданий (GameProgress) -- по событиям Мира
EventBus.Subscribe<MarchStartedEvent>(OnMarchStarted);
EventBus.Subscribe<MarchReturnedEvent>(OnMarchReturned);
// Окно "ВОЙСКО ВЕРНУЛОСЬ" в городе
MarchReturnPopup.Reset();
EventBus.Subscribe<MarchReturnedEvent>(MarchReturnPopup.OnMarchReturned);
}
// Сцена загружена, Awake всех объектов прошли, Start -- ещё нет (сохранение грузится в Start)
[RuntimeInitializeOnLoadMethod(RuntimeInitializeLoadType.AfterSceneLoad)]
static void MigrateLegacyMarches()
{
// Раньше походы хранились в progress.json (GameProgress.Data.sashaMarches).
// Теперь их сохраняет модуль Мира в save.json, секция "world". Переносим один раз.
var data = GameProgress.Instance.Data;
if (data.sashaMarches == null || data.sashaMarches.Count == 0 || MarchManager.Instance == null) return;
// Если в save.json уже есть секция "world", загрузка сохранения заменит эти походы на сохранённые
MarchManager.Instance.LoadMarches(new System.Collections.Generic.List<Bogatyri.World.MarchData>(data.sashaMarches));
// Из progress.json убираем, только когда походы уже записаны в save.json -- иначе вылет игры их потеряет
EventBus.Subscribe<GameSavedEvent>(ClearLegacyMarches);
}
static void ClearLegacyMarches(GameSavedEvent e)
{
EventBus.Unsubscribe<GameSavedEvent>(ClearLegacyMarches);
GameProgress.Instance.Data.sashaMarches.Clear();
GameProgress.Instance.MarkChanged();
Debug.Log("[МОДУЛИ] Походы перенесены из progress.json в модуль Мира.");
}
// Раньше войска хранились в progress.json (GameProgress.Data.troops и sashaMarchUnits моста ArmyBridge).
// Теперь -- в модуле Армии, save.json, секция "army". Переносим один раз, после загрузки сохранения.
static void MigrateLegacyArmy(ArmyService army)
{
var data = GameProgress.Instance.Data;
if (data.armyInModule) return;
if (!army.RestoredFromSave)
{
var legacyMarches = new System.Collections.Generic.List<System.Collections.Generic.KeyValuePair<int, int[]>>();
foreach (var m in data.sashaMarchUnits)
legacyMarches.Add(new System.Collections.Generic.KeyValuePair<int, int[]>(m.marchId, m.units));
army.ImportLegacy(data.troops, legacyMarches);
Debug.Log($"[МОДУЛИ] Войска перенесены из progress.json в модуль Армии: в городе {army.SoldiersAtHome}, в походах {army.TroopsInMarches}.");
}
// Из progress.json убираем, только когда войска уже записаны в save.json -- иначе вылет игры их потеряет
if (army.RestoredFromSave) ClearLegacyArmy(default);
else EventBus.Subscribe<GameSavedEvent>(ClearLegacyArmy);
}
static void ClearLegacyArmy(GameSavedEvent e)
{
EventBus.Unsubscribe<GameSavedEvent>(ClearLegacyArmy);
var data = GameProgress.Instance.Data;
data.armyInModule = true;
System.Array.Clear(data.troops, 0, data.troops.Length);
data.sashaMarchUnits.Clear();
GameProgress.Instance.MarkChanged();
}
static void OnMarchStarted(MarchStartedEvent e)
{
var data = GameProgress.Instance.Data;
data.marchesSent++;
data.dailyMarches++;
GameProgress.Instance.MarkChanged();
}
static void OnMarchReturned(MarchReturnedEvent e)
{
if (!e.Victory) return;
GameProgress.Instance.Data.worldWins++;
GameProgress.Instance.MarkChanged();
}
}
+2
View File
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 2afadf4e4024d5c48a2c26eddb75cbb1
-106
View File
@@ -1,106 +0,0 @@
using System;
using System.Collections.Generic;
using UnityEngine;
// Центральное хранилище всех ресурсов игрока.
// Один объект на сцену (Singleton) -- любой скрипт обращается через ResourceManager.Instance
public enum ResourceType
{
Population = 0, // люди
Food = 1, // еда
Wood = 2, // дерево
Stone = 3, // камень
Iron = 4, // металл
Gold = 5, // золото
Gems = 6, // кристаллы
Power = 7 // "сила" -- как в референсе HUD
}
public class ResourceManager : MonoBehaviour
{
public static ResourceManager Instance { get; private set; }
// Событие: вызывается каждый раз, когда любой ресурс меняется.
// UI подписывается на него, чтобы обновлять текст автоматически, без Update() каждый кадр.
public event Action<ResourceType, float> OnResourceChanged;
[Header("Стартовые значения (для теста/баланса)")]
public float startWood = 0f;
public float startStone = 0f;
public float startIron = 0f;
public float startFood = 0f;
public float startGold = 0f;
public float startGems = 0f;
public float startPower = 0f;
public float startPopulation = 0f;
private Dictionary<ResourceType, float> resources = new Dictionary<ResourceType, float>();
void Awake()
{
// Классический паттерн Singleton: если уже есть экземпляр -- уничтожаем дубликат
if (Instance != null && Instance != this)
{
Destroy(gameObject);
return;
}
Instance = this;
DontDestroyOnLoad(gameObject); // менеджер ресурсов должен жить между сценами (меню -> игра)
resources[ResourceType.Wood] = startWood;
resources[ResourceType.Stone] = startStone;
resources[ResourceType.Iron] = startIron;
resources[ResourceType.Food] = startFood;
resources[ResourceType.Gold] = startGold;
resources[ResourceType.Gems] = startGems;
resources[ResourceType.Power] = startPower;
resources[ResourceType.Population] = startPopulation;
}
// Добавить ресурс (можно и отрицательное число -- для трат)
public void AddResource(ResourceType type, float amount)
{
if (!resources.ContainsKey(type))
resources[type] = 0f;
resources[type] += amount;
if (resources[type] < 0f) resources[type] = 0f; // ресурс не уходит в минус
OnResourceChanged?.Invoke(type, resources[type]);
}
// Проверить, хватает ли ресурса (для трат/построек в будущем)
public bool HasEnough(ResourceType type, float amount)
{
return GetResource(type) >= amount;
}
// Попробовать потратить -- вернёт false, если не хватило (и ничего не спишет)
public bool TrySpend(ResourceType type, float amount)
{
if (!HasEnough(type, amount)) return false;
AddResource(type, -amount);
return true;
}
public float GetResource(ResourceType type)
{
return resources.TryGetValue(type, out float value) ? value : 0f;
}
// Устанавливает значение напрямую, БЕЗ проверок и без учёта прошлого значения.
// Используется только при загрузке сохранения -- обычная игровая логика должна
// использовать AddResource/TrySpend, а не этот метод.
public void SetResource(ResourceType type, float value)
{
resources[type] = value;
OnResourceChanged?.Invoke(type, value);
}
// Все ресурсы разом -- удобно для сохранения
public Dictionary<ResourceType, float> GetAllResources()
{
return new Dictionary<ResourceType, float>(resources);
}
}
+50 -33
View File
@@ -11,6 +11,7 @@ using UnityEngine;
public class GameProgress : MonoBehaviour
{
static GameProgress instance;
public static bool HasInstance { get { return instance != null; } }
public static GameProgress Instance
{
get
@@ -72,7 +73,6 @@ public class GameProgress : MonoBehaviour
nextTick = Time.unscaledTime + 1f;
EnsureDailyAndWeekly();
ProcessTraining();
WorldSystem.Process();
UpdateBuildingsCount();
UpdatePowerResource();
}
@@ -150,11 +150,9 @@ public class GameProgress : MonoBehaviour
if (Data.training == null) Data.training = new List<TrainingOrder>();
if (Data.arenaOpponents == null) Data.arenaOpponents = new List<ArenaOpponent>();
if (Data.eventClaimed == null) Data.eventClaimed = new List<int>();
if (Data.settlements == null) Data.settlements = new List<SettlementState>();
if (Data.marches == null) Data.marches = new List<WorldMarch>();
if (Data.reports == null) Data.reports = new List<BattleReport>();
ReturnLegacyWorldMarches();
if (Data.sashaMarches == null) Data.sashaMarches = new List<Bogatyri.World.MarchData>();
if (Data.sashaMarchUnits == null) Data.sashaMarchUnits = new List<ArmyBridge.MarchUnits>();
if (Data.sashaMarchUnits == null) Data.sashaMarchUnits = new List<LegacyMarchUnits>();
if (Data.level < 1) Data.level = 1;
if (Data.heroLevel < 1) Data.heroLevel = 1;
if (Data.battleStage < 1) Data.battleStage = 1;
@@ -176,32 +174,53 @@ public class GameProgress : MonoBehaviour
catch (Exception e) { Debug.LogWarning("[ПРОГРЕСС] Не удалось сохранить: " + e.Message); }
}
// Полный сброс: прогресс, ресурсы, здания, Ратуша. Кнопка в Настройках.
// Старая маленькая карта мира удалена: солдаты из её походов возвращаются в город.
// Ещё идут к цели -- возвращаются все отправленные; уже идут обратно -- выжившие. Добыча таких походов не переносится.
void ReturnLegacyWorldMarches()
{
if (Data.marches == null || Data.marches.Count == 0) { Data.marches = new List<LegacyWorldMarch>(); return; }
int returned = 0;
foreach (var m in Data.marches)
{
int[] back = m.returning ? m.survivors : m.units;
if (back == null) continue;
for (int i = 0; i < back.Length && i < Data.troops.Length; i++)
{
Data.troops[i] += Mathf.Max(0, back[i]);
returned += Mathf.Max(0, back[i]);
}
}
Data.marches.Clear();
dirty = true;
SaveSoon();
Debug.Log($"[ПРОГРЕСС] Старая карта мира удалена: в город вернулось воинов из её походов: {returned}.");
}
// СБРОС СОХРАНЕНИЙ: прогресс (progress.json) + все модули (save.json): ресурсы, здания, Ратуша, походы.
// Кнопка в Настройках и меню Tools -> Сбросить сохранения. Документ: Docs/Modules/Save.md
public void ResetAll()
{
if (File.Exists(SavePath)) File.Delete(SavePath);
if (SaveManager.Instance != null) SaveManager.Instance.DeleteSave();
// Здания: удаляем -- BuildingSlotLink сам освободит слоты
foreach (var b in FindObjectsByType<PlacedBuildingId>(FindObjectsSortMode.None)) Destroy(b.gameObject);
if (TownHall.Instance != null) TownHall.Instance.currentLevel = 1;
var rm = ResourceManager.Instance;
if (rm != null)
// Модули сбрасывают себя сами через модуль Сохранений; возврат ресурсов к старту -- не "сбор"
suppressTracking++;
if (GameServices.TryGet(out ISaveSystem save))
{
suppressTracking++;
rm.SetResource(ResourceType.Wood, rm.startWood);
rm.SetResource(ResourceType.Stone, rm.startStone);
rm.SetResource(ResourceType.Iron, rm.startIron);
rm.SetResource(ResourceType.Food, rm.startFood);
rm.SetResource(ResourceType.Gold, rm.startGold);
rm.SetResource(ResourceType.Gems, rm.startGems);
rm.SetResource(ResourceType.Population, rm.startPopulation);
suppressTracking--;
save.ResetAll();
}
else
{
// Сцена без SaveManager (например, карту мира запустили напрямую из редактора)
string modulesSave = Path.Combine(Application.persistentDataPath, "save.json");
if (File.Exists(modulesSave)) File.Delete(modulesSave);
foreach (var participant in SaveRegistry.All) participant.ResetState();
}
suppressTracking--;
Data = new ProgressData();
FixData();
Data.armyInModule = true; // войска сбросил модуль Армии, переносить из progress.json нечего
Data.lastLoginDay = Today; Data.daysPlayed = 1;
EnsureDailyAndWeekly();
SendStarterMail();
@@ -380,7 +399,7 @@ public class GameProgress : MonoBehaviour
get
{
int p = 0;
for (int i = 0; i < GameData.Troops.Count; i++) p += Data.troops[i] * GameData.Troops[i].power;
for (int i = 0; i < GameData.Troops.Count; i++) p += TroopCount(i) * GameData.Troops[i].power;
return p;
}
}
@@ -600,9 +619,14 @@ public class GameProgress : MonoBehaviour
// ВОЙСКА
// =====================================================================
// Войска хранит модуль Армии (Docs/Modules/Army.md) -- здесь только обращение к нему
static IArmy Army => GameServices.Get<IArmy>();
public int TroopCount(int type) { return Army.GetTroops(type); }
public int TotalTroops
{
get { int n = 0; foreach (var t in Data.troops) n += t; return n; }
get { return Army.SoldiersAtHome; }
}
public bool TroopUnlocked(int index) { return TownHallLevel >= GameData.Troops[index].requiredTownHall; }
@@ -657,7 +681,7 @@ public class GameProgress : MonoBehaviour
{
var o = Data.training[0];
Data.training.RemoveAt(0);
Data.troops[o.troopIndex] += o.count;
Army.AddTroops(o.troopIndex, o.count);
Data.troopsTrained += o.count;
Data.dailyTroops += o.count;
Data.eventTroopRemainder += o.count;
@@ -673,14 +697,7 @@ public class GameProgress : MonoBehaviour
int LoseTroops(float fraction)
{
if (HasHeroSkill(3)) fraction *= 0.8f;
int lost = 0;
for (int i = 0; i < Data.troops.Length; i++)
{
int l = Mathf.FloorToInt(Data.troops[i] * fraction);
Data.troops[i] -= l;
lost += l;
}
return lost;
return Army.LoseFraction(fraction);
}
// =====================================================================
+26 -7
View File
@@ -92,7 +92,10 @@ public class ProgressData
public int heroLevel = 1;
// --- Войска ---
// Войска теперь хранит модуль Армии (save.json, секция "army"). Это поле -- только для переноса
// из старых сохранений: при первой загрузке его читает App/GameModules, потом оно обнуляется.
public int[] troops = new int[4];
public bool armyInModule; // войска уже перенесены в модуль Армии
public List<TrainingOrder> training = new List<TrainingOrder>();
// --- Бой (кампания) ---
@@ -104,18 +107,17 @@ public class ProgressData
public long arenaTicketTicks = 0;
public List<ArenaOpponent> arenaOpponents = new List<ArenaOpponent>();
// --- Глобальная карта ---
public List<SettlementState> settlements = new List<SettlementState>();
public List<WorldMarch> marches = new List<WorldMarch>();
public List<BattleReport> reports = new List<BattleReport>();
public int nextMarchId = 1;
// --- Карта мира (статистика для заданий; походы ведёт модуль Мира) ---
// Старая маленькая карта (панель "МИР", Meta/World) удалена 19.10.2026 -- осталась одна большая карта.
// marches оставлено только для переноса: солдаты из её походов возвращаются в город при загрузке.
public List<LegacyWorldMarch> marches = new List<LegacyWorldMarch>();
public int marchesSent;
public int worldWins;
public int worldLosses;
// --- Большая карта мира (сцена WorldMap, код Саши) ---
// --- Карта мира (сцена WorldMap) ---
public List<Bogatyri.World.MarchData> sashaMarches = new List<Bogatyri.World.MarchData>();
public List<ArmyBridge.MarchUnits> sashaMarchUnits = new List<ArmyBridge.MarchUnits>();
public List<LegacyMarchUnits> sashaMarchUnits = new List<LegacyMarchUnits>(); // только для переноса в модуль Армии
// --- Событие ---
public int eventWeek = -1;
@@ -123,3 +125,20 @@ public class ProgressData
public int eventTroopRemainder;
public List<int> eventClaimed = new List<int>();
}
// Поход старой маленькой карты -- только чтобы при загрузке вернуть солдат в город (см. GameProgress.FixData)
[Serializable]
public class LegacyWorldMarch
{
public int[] units = new int[4]; // отправлено
public int[] survivors = new int[4]; // вернутся домой
public bool returning;
}
// Состав похода из старого progress.json (раньше хранил мост ArmyBridge) -- только для переноса в модуль Армии
[Serializable]
public class LegacyMarchUnits
{
public int marchId;
public int[] units = new int[4];
}
+2 -2
View File
@@ -23,7 +23,7 @@ public class TroopsPanel : BasePanel
var army = UIKit.Card(list, UIKit.Theme.CardDark);
ProfilePanel.Header(army, "Армия");
ProfilePanel.KV(army, "Всего воинов в городе", GP.TotalTroops.ToString("N0"));
ProfilePanel.KV(army, "В походе на карте мира", WorldSystem.TroopsInMarches.ToString("N0"));
ProfilePanel.KV(army, "В походе на карте мира", GameServices.Get<IArmy>().TroopsInMarches.ToString("N0"));
ProfilePanel.KV(army, "Сила войск", GP.TroopsPower.ToString("N0"));
ProfilePanel.KV(army, "Сила армии с героем", GP.ArmyPower.ToString("N0"));
UIKit.Button(army, "Отправить армию на карте мира", UIKit.Theme.Green, () => OpenPanel("World"), -1, 80, 26);
@@ -76,7 +76,7 @@ public class TroopsPanel : BasePanel
UIKit.Icon(top, unlocked ? def.color : UIKit.Theme.Gray, def.name.Substring(0, 1), 110);
var info = UIKit.Card(top, new Color(0, 0, 0, 0), 0, 2);
UIKit.Size(info, -1, -1, 1);
UIKit.Text(info, def.name + " <color=#c8c8c8><size=24>в армии: " + d.troops[i].ToString("N0") + "</size></color>", 32).fontStyle = FontStyles.Bold;
UIKit.Text(info, def.name + " <color=#c8c8c8><size=24>в армии: " + GP.TroopCount(i).ToString("N0") + "</size></color>", 32).fontStyle = FontStyles.Bold;
UIKit.Text(info, def.description, 22, UIKit.Theme.TextDim);
UIKit.Text(info, "Сила " + def.power + " • " + def.secondsPerUnit + " сек/воин • " + GameData.RewardsText(def.cost), 22, UIKit.Theme.TextDim);
-126
View File
@@ -1,126 +0,0 @@
using System;
using System.Collections.Generic;
using UnityEngine;
// ГЛОБАЛЬНАЯ КАРТА: таблицы. Характеристики юнитов, поселения на карте, скорость походов.
// Баланс правится здесь. Логика -- в WorldSystem.cs, отображение -- в WorldMapPanel.cs.
// Боевые характеристики одного воина (индексы совпадают с GameData.Troops)
public class UnitStats
{
public int hp; // здоровье
public int attack; // урон за раунд
public int defense; // снижает получаемый урон (каждая единица = -8%)
public float speed; // скорость на карте (1 = обычная). Армия идёт со скоростью самого медленного
public int carry; // сколько ресурсов уносит один воин
public UnitStats(int hp, int attack, int defense, float speed, int carry)
{
this.hp = hp; this.attack = attack; this.defense = defense; this.speed = speed; this.carry = carry;
}
}
// Поселение на карте
public class SettlementDef
{
public string id;
public string name;
public Vector2 pos; // позиция на карте, 0..1 по ширине и высоте
public bool isHome; // город игрока
public int[] baseGarrison; // защитники на 1 уровне (по типам войск)
public RewardItem[] baseLoot; // что можно унести на 1 уровне
public float respawnMinutes; // через сколько поселение отстраивается после разграбления
public Color color;
public SettlementDef(string id, string name, float x, float y, Color color)
{
this.id = id; this.name = name; pos = new Vector2(x, y); this.color = color;
}
}
public static class WorldData
{
// Индексы: 0 Пехота, 1 Лучники, 2 Кавалерия, 3 Осадные
public static readonly UnitStats[] Units =
{
new UnitStats(hp: 10, attack: 3, defense: 3, speed: 1.0f, carry: 10),
new UnitStats(hp: 7, attack: 5, defense: 1, speed: 1.0f, carry: 8),
new UnitStats(hp: 14, attack: 6, defense: 2, speed: 1.6f, carry: 15),
new UnitStats(hp: 20, attack: 12, defense: 1, speed: 0.6f, carry: 5),
};
// Сколько секунд идёт армия со скоростью 1 через всю карту (расстояние 1.0).
// Для тестов маленькое -- поход туда-обратно занимает около минуты-двух.
public const float SecondsPerMapUnit = 90f;
public const int MaxMarches = 2; // одновременно в походе
public const int MaxRounds = 10; // раундов в бою, потом атакующий отступает
public const float DefenderBonus = 0.1f; // защитники бьют на 10% сильнее (стены)
public const float GarrisonRegenMinutes = 3f; // недобитый гарнизон восстанавливается
public static readonly List<SettlementDef> Settlements = Build();
static List<SettlementDef> Build()
{
var home = new SettlementDef("home", "Ваш город", 0.18f, 0.5f, new Color(0.25f, 0.5f, 0.9f));
home.isHome = true;
var bandits = new SettlementDef("bandits", "Деревня разбойников", 0.55f, 0.25f, new Color(0.75f, 0.55f, 0.25f));
bandits.baseGarrison = new[] { 8, 4, 0, 0 };
bandits.baseLoot = new[] { new RewardItem(ResourceType.Wood, 400), new RewardItem(ResourceType.Food, 400), new RewardItem(ResourceType.Gold, 150) };
bandits.respawnMinutes = 3f;
var fortress = new SettlementDef("fortress", "Крепость варваров", 0.82f, 0.72f, new Color(0.75f, 0.25f, 0.25f));
fortress.baseGarrison = new[] { 25, 15, 8, 2 };
fortress.baseLoot = new[] { new RewardItem(ResourceType.Stone, 900), new RewardItem(ResourceType.Iron, 600), new RewardItem(ResourceType.Gold, 500), new RewardItem(ResourceType.Gems, 20) };
fortress.respawnMinutes = 10f;
return new List<SettlementDef> { home, bandits, fortress };
}
public static SettlementDef Get(string id) { return Settlements.Find(s => s.id == id); }
public static SettlementDef Home { get { return Settlements[0]; } }
// С каждым разграблением поселение отстраивается на уровень выше: больше защитников и добычи
public static float LevelMultiplier(int level) { return 1f + 0.5f * (level - 1); }
public static int UnitPower(int index) { return GameData.Troops[index].power; }
}
// --- Сохраняемое состояние (часть ProgressData) ---
[Serializable]
public class SettlementState
{
public string id;
public int level = 1;
public int[] garrison = new int[4]; // текущие защитники
public long ruinedUntilTicks; // > сейчас -- поселение разграблено и пустует
public long lastBattleTicks;
public int timesRaided;
}
[Serializable]
public class WorldMarch
{
public int id;
public string targetId;
public int[] units = new int[4]; // отправлено
public int[] survivors = new int[4]; // вернутся домой
public long startTicks;
public long arriveTicks;
public long returnTicks;
public bool returning;
public bool recalled;
public float turnPoint = 1f; // где развернулись: 1 = у цели, меньше -- отозвали по дороге
public List<RewardItem> loot = new List<RewardItem>();
}
[Serializable]
public class BattleReport
{
public long ticks;
public string title;
public string text;
public bool win;
}
@@ -1,2 +0,0 @@
fileFormatVersion: 2
guid: bb78904d07b16a64682697f8a240feb5
-34
View File
@@ -1,34 +0,0 @@
using UnityEngine;
// Линия-маршрут на карте между двумя точками (координаты 0..1 внутри родителя).
// Пересчитывается каждый кадр, поэтому не зависит от размера экрана.
[RequireComponent(typeof(RectTransform))]
public class WorldLine : MonoBehaviour
{
public Vector2 from;
public Vector2 to;
public float thickness = 6f;
RectTransform rt;
RectTransform parent;
void Awake()
{
rt = (RectTransform)transform;
parent = (RectTransform)transform.parent;
rt.pivot = new Vector2(0f, 0.5f);
}
void LateUpdate()
{
if (parent == null) return;
Vector2 size = parent.rect.size;
Vector2 a = Vector2.Scale(from, size);
Vector2 b = Vector2.Scale(to, size);
Vector2 d = b - a;
rt.anchorMin = rt.anchorMax = from;
rt.anchoredPosition = Vector2.zero;
rt.sizeDelta = new Vector2(d.magnitude, thickness);
rt.localRotation = Quaternion.Euler(0, 0, Mathf.Atan2(d.y, d.x) * Mathf.Rad2Deg);
}
}
@@ -1,2 +0,0 @@
fileFormatVersion: 2
guid: 7dda3d4605bc23a4fa4655209a6c30ba
-383
View File
@@ -1,383 +0,0 @@
using System.Collections.Generic;
using UnityEngine;
using UnityEngine.UI;
using TMPro;
// ГЛОБАЛЬНАЯ КАРТА (панель "Мир").
// Слева/сверху -- карта: ваш город, два тестовых поселения, маршруты, армии в пути с таймерами.
// Справа/снизу -- информация: армия в городе, выбранное поселение, походы, отчёты о боях,
// и окно отправки войск (сколько каких воинов, время в пути, шанс победы).
public class WorldMapPanel : BasePanel
{
string selectedId = "bandits";
bool sendMode;
readonly int[] sendUnits = new int[4];
// то, что обновляется каждый кадр без перерисовки
class MarchView { public WorldMarch m; public RectTransform marker; public TextMeshProUGUI label; public TextMeshProUGUI timer; }
readonly List<MarchView> marchViews = new List<MarchView>();
readonly List<KeyValuePair<WorldMarch, TextMeshProUGUI>> listTimers = new List<KeyValuePair<WorldMarch, TextMeshProUGUI>>();
readonly Dictionary<string, TextMeshProUGUI> ruinTimers = new Dictionary<string, TextMeshProUGUI>();
readonly TextMeshProUGUI[] sendCounts = new TextMeshProUGUI[4];
TextMeshProUGUI sendSummary;
Button sendButton;
protected override void OnEnable()
{
base.OnEnable();
sendMode = false;
}
protected override void Build(RectTransform root)
{
marchViews.Clear(); listTimers.Clear(); ruinTimers.Clear(); sendSummary = null; sendButton = null;
for (int i = 0; i < 4; i++) sendCounts[i] = null;
bool wide = root.rect.width > root.rect.height * 1.2f;
RectTransform layout;
if (wide)
{
layout = UIKit.Stretch(UIKit.Row(root, -1, 16));
}
else
{
layout = UIKit.Stretch(UIKit.Card(root, new Color(0, 0, 0, 0), 0, 12));
}
// --- карта ---
var mapHolder = UIKit.Box(layout, new Color(0.16f, 0.27f, 0.17f));
var mapLe = UIKit.Size(mapHolder, -1, -1, 1);
mapLe.flexibleHeight = wide ? 1 : 1.3f;
BuildMap((RectTransform)mapHolder.transform);
// --- боковая колонка ---
var side = UIKit.Make("Side", layout);
var sideLe = UIKit.Size(side, wide ? 640 : -1, -1);
sideLe.flexibleHeight = 1;
if (!wide) sideLe.flexibleWidth = 1;
var list = ScrollList(side, 12);
if (sendMode) BuildSend(list); else BuildInfo(list);
Tick();
}
// ===================== КАРТА =====================
void BuildMap(RectTransform map)
{
map.gameObject.AddComponent<RectMask2D>();
// декорации: "леса", "горы", "озеро" -- просто пятна, чтобы карта не была пустой
var rnd = new System.Random(7);
for (int i = 0; i < 26; i++)
{
float x = (float)rnd.NextDouble(), y = (float)rnd.NextDouble();
int kind = rnd.Next(3);
Color c = kind == 0 ? new Color(0.11f, 0.21f, 0.12f) : (kind == 1 ? new Color(0.35f, 0.35f, 0.33f) : new Color(0.2f, 0.32f, 0.45f));
float s = 30 + (float)rnd.NextDouble() * 60;
var blob = UIKit.Box(map, c);
blob.raycastTarget = false;
var brt = blob.rectTransform;
brt.anchorMin = brt.anchorMax = new Vector2(x, y);
brt.sizeDelta = new Vector2(s, s * 0.7f);
brt.localRotation = Quaternion.Euler(0, 0, rnd.Next(0, 90));
}
// сетка
for (int i = 1; i < 5; i++)
{
Line(map, new Vector2(i / 5f, 0), new Vector2(i / 5f, 1), new Color(1, 1, 1, 0.05f), 2);
Line(map, new Vector2(0, i / 5f), new Vector2(1, i / 5f), new Color(1, 1, 1, 0.05f), 2);
}
var home = WorldData.Home;
// постоянные дороги от города к поселениям
foreach (var s in WorldData.Settlements)
if (!s.isHome) Line(map, home.pos, s.pos, new Color(0.85f, 0.75f, 0.5f, 0.25f), 5);
// активные маршруты -- ярче
foreach (var m in GP.Data.marches)
{
var target = WorldData.Get(m.targetId);
if (target == null) continue;
Line(map, home.pos, target.pos, m.returning ? new Color(0.4f, 0.9f, 0.5f, 0.8f) : new Color(1f, 0.55f, 0.2f, 0.85f), 8);
}
// поселения
foreach (var s in WorldData.Settlements) SettlementIcon(map, s);
// армии в пути
foreach (var m in GP.Data.marches)
{
var v = new MarchView { m = m };
var marker = UIKit.Box(map, m.returning ? new Color(0.2f, 0.7f, 0.3f) : new Color(0.95f, 0.5f, 0.15f));
marker.raycastTarget = false;
v.marker = marker.rectTransform;
v.marker.sizeDelta = new Vector2(64, 64);
v.label = UIKit.Text(v.marker, WorldSystem.Count(m.returning ? m.survivors : m.units).ToString(), 26, Color.white, TextAlignmentOptions.Center);
UIKit.Stretch(v.label.rectTransform);
v.label.fontStyle = FontStyles.Bold;
v.timer = UIKit.Text(v.marker, "", 24, Color.white, TextAlignmentOptions.Center);
var trt = v.timer.rectTransform;
trt.anchorMin = new Vector2(0.5f, 1); trt.anchorMax = new Vector2(0.5f, 1);
trt.pivot = new Vector2(0.5f, 0); trt.sizeDelta = new Vector2(220, 34); trt.anchoredPosition = new Vector2(0, 4);
v.timer.outlineWidth = 0.25f; v.timer.outlineColor = Color.black;
marchViews.Add(v);
}
Frame();
}
void SettlementIcon(RectTransform map, SettlementDef s)
{
var st = s.isHome ? null : WorldSystem.State(s.id);
bool ruined = !s.isHome && WorldSystem.IsRuined(s.id, System.DateTime.UtcNow.Ticks);
bool selected = s.id == selectedId;
float size = s.isHome ? 130 : 110;
if (selected)
{
// золотая рамка выбранного поселения (под иконкой)
var ring = UIKit.Box(map, UIKit.Theme.Gold);
ring.raycastTarget = false;
ring.rectTransform.anchorMin = ring.rectTransform.anchorMax = s.pos;
ring.rectTransform.sizeDelta = new Vector2(size + 16, size + 16);
}
var holder = UIKit.Make("Settlement_" + s.id, map);
holder.anchorMin = holder.anchorMax = s.pos;
holder.sizeDelta = new Vector2(size, size);
var img = holder.gameObject.AddComponent<Image>();
img.color = ruined ? new Color(0.3f, 0.3f, 0.3f) : s.color;
var btn = holder.gameObject.AddComponent<Button>();
string id = s.id;
btn.onClick.AddListener(() => { selectedId = id; sendMode = false; RequestRebuild(); });
var letter = UIKit.Text(holder, s.isHome ? "ДОМ" : (ruined ? "X" : st.level.ToString()), s.isHome ? 32 : 44, Color.white, TextAlignmentOptions.Center);
UIKit.Stretch(letter.rectTransform);
letter.fontStyle = FontStyles.Bold;
var name = UIKit.Text(holder, s.name, 24, Color.white, TextAlignmentOptions.Center);
var nrt = name.rectTransform;
nrt.anchorMin = new Vector2(0.5f, 0); nrt.anchorMax = new Vector2(0.5f, 0);
nrt.pivot = new Vector2(0.5f, 1); nrt.sizeDelta = new Vector2(300, 60); nrt.anchoredPosition = new Vector2(0, -6);
name.outlineWidth = 0.25f; name.outlineColor = Color.black;
if (s.isHome) name.text = s.name + "\nсила армии " + GP.ArmyPower.ToString("N0");
else if (ruined)
{
name.text = s.name + "\nразграблено";
ruinTimers[s.id] = name;
}
else name.text = s.name + "\nзащита " + WorldSystem.GarrisonPower(st.garrison).ToString("N0");
}
WorldLine Line(RectTransform parent, Vector2 a, Vector2 b, Color c, float thickness)
{
var img = UIKit.Box(parent, c);
img.raycastTarget = false;
var l = img.gameObject.AddComponent<WorldLine>();
l.from = a; l.to = b; l.thickness = thickness;
return l;
}
// ===================== ИНФОРМАЦИЯ =====================
void BuildInfo(RectTransform list)
{
var d = GP.Data;
// --- большая карта мира (сцена WorldMap от Саши: соседи, босс, чат, рейтинг) ---
var big = UIKit.Card(list, new Color(0.16f, 0.22f, 0.3f));
UIKit.Text(big, "Большая карта: соседи, Замок Змея Горыныча, чат и рейтинг. Армия та же -- ваши воины из казарм.", 24, UIKit.Theme.Text);
UIKit.Button(big, "ОТКРЫТЬ БОЛЬШУЮ КАРТУ", UIKit.Theme.Blue, () => SceneNavigator.OpenWorldMap(), -1, 96, 32);
// --- армия в городе ---
var army = UIKit.Card(list, UIKit.Theme.CardDark);
ProfilePanel.Header(army, "Армия в городе");
UIKit.Text(army, WorldSystem.UnitsText(d.troops), 26);
UIKit.Text(army, "Воинов: " + GP.TotalTroops + " • сила " + GP.ArmyPower.ToString("N0") +
" • в походе: " + WorldSystem.TroopsInMarches, 24, UIKit.Theme.TextDim);
var ar = UIKit.Row(army, 76, 10);
UIKit.Size(UIKit.Button(ar, "Нанять войска", UIKit.Theme.Blue, () => OpenPanel("Troops"), -1, 76, 26), -1, 76, 1);
UIKit.Size(UIKit.Button(ar, "Улучшить героя", UIKit.Theme.Gray, () => OpenPanel("Hero"), -1, 76, 26), -1, 76, 1);
// --- выбранное поселение ---
var def = WorldData.Get(selectedId) ?? WorldData.Settlements[1];
var card = UIKit.Card(list, UIKit.Theme.Card);
var title = UIKit.Text(card, def.name, 34, UIKit.Theme.Gold);
title.fontStyle = FontStyles.Bold;
if (def.isHome)
{
UIKit.Text(card, "Отсюда выходят армии и сюда приносят добычу. Нажмите на поселение на карте, чтобы его атаковать.", 26, UIKit.Theme.TextDim);
UIKit.Button(card, "Вернуться к городу", UIKit.Theme.Gray, () => { if (UIPanelManager.Instance != null) UIPanelManager.Instance.CloseAll(); }, -1, 80);
}
else
{
var st = WorldSystem.State(def.id);
bool ruined = WorldSystem.IsRuined(def.id, System.DateTime.UtcNow.Ticks);
ProfilePanel.KV(card, "Уровень", st.level.ToString());
ProfilePanel.KV(card, "Расстояние", "~" + GameData.FormatTime(WorldSystem.TravelSeconds(def, new[] { 1, 0, 0, 0 })) + " пешком");
if (ruined)
{
var t = UIKit.Text(card, "", 28, UIKit.Theme.Red);
ruinTimers["card_" + def.id] = t;
}
else
{
ProfilePanel.KV(card, "Защитники", WorldSystem.UnitsText(st.garrison));
ProfilePanel.KV(card, "Сила защиты", WorldSystem.GarrisonPower(st.garrison).ToString("N0"));
ProfilePanel.KV(card, "Добыча", GameData.RewardsText(WorldSystem.FullLoot(def, st.level)));
}
string why = ruined ? "Отстраивается" : (d.marches.Count >= WorldData.MaxMarches ? "Все отряды в походе" : (GP.TotalTroops == 0 ? "Нет войск в городе" : null));
var b = UIKit.Button(card, why ?? "АТАКОВАТЬ", why == null ? UIKit.Theme.Red : UIKit.Theme.Gray, () =>
{
sendMode = true;
for (int i = 0; i < 4; i++) sendUnits[i] = GP.Data.troops[i]; // по умолчанию -- все
RequestRebuild();
}, -1, 96, 34);
b.interactable = why == null;
if (GP.TotalTroops == 0)
UIKit.Button(card, "Нанять войска", UIKit.Theme.Blue, () => OpenPanel("Troops"), -1, 80);
}
// --- походы ---
var marches = UIKit.Card(list, UIKit.Theme.Card);
ProfilePanel.Header(marches, "Походы (" + d.marches.Count + " / " + WorldData.MaxMarches + ")");
if (d.marches.Count == 0) UIKit.Text(marches, "Армии в городе. Выберите цель на карте.", 24, UIKit.Theme.TextDim);
foreach (var m in d.marches)
{
var mm = m;
var target = WorldData.Get(m.targetId);
var row = UIKit.Row(marches, 90, 10);
var info = UIKit.Card(row, new Color(0, 0, 0, 0), 0, 2);
UIKit.Size(info, -1, -1, 1);
UIKit.Text(info, (m.returning ? "Домой из: " : "К цели: ") + target.name, 26).fontStyle = FontStyles.Bold;
var timer = UIKit.Text(info, "", 24, m.returning ? UIKit.Theme.Green : UIKit.Theme.Gold);
listTimers.Add(new KeyValuePair<WorldMarch, TextMeshProUGUI>(m, timer));
if (!m.returning)
UIKit.Button(row, "Отозвать", UIKit.Theme.Gray, () => WorldSystem.Recall(mm), 170, 70, 24);
}
// --- отчёты ---
var reports = UIKit.Card(list, UIKit.Theme.CardDark);
ProfilePanel.Header(reports, "Отчёты о боях");
if (d.reports.Count == 0) UIKit.Text(reports, "Пока не было сражений.", 24, UIKit.Theme.TextDim);
for (int i = 0; i < d.reports.Count && i < 5; i++)
{
var r = d.reports[i];
UIKit.Button(reports, r.title + " " + new System.DateTime(r.ticks).ToString("HH:mm"),
r.win ? new Color(0.2f, 0.4f, 0.25f) : new Color(0.45f, 0.2f, 0.2f), () => UIKit.Message(r.title, r.text), -1, 70, 24);
}
// --- подсказка цикла ---
UIKit.Text(list, "Цикл: стройте здания -> собирайте ресурсы -> нанимайте войска -> отправляйте армию -> забирайте добычу -> развивайте город.",
22, UIKit.Theme.TextDim, TextAlignmentOptions.Center);
}
// ===================== ОТПРАВКА =====================
void BuildSend(RectTransform list)
{
var def = WorldData.Get(selectedId);
var card = UIKit.Card(list, UIKit.Theme.Card);
UIKit.Text(card, "Поход: " + def.name, 34, UIKit.Theme.Gold).fontStyle = FontStyles.Bold;
UIKit.Text(card, "Выберите, кого отправить. Армия идёт со скоростью самого медленного воина.", 22, UIKit.Theme.TextDim);
for (int i = 0; i < 4; i++)
{
int idx = i;
var t = GameData.Troops[i];
var row = UIKit.Row(card, 70, 6);
var n = UIKit.Text(row, t.name + "\n<size=20><color=#b0b0b0>в городе " + GP.Data.troops[i] + "</color></size>", 26);
UIKit.Size(n, -1, -1, 1);
Step(row, "-10", idx, -10); Step(row, "-1", idx, -1);
sendCounts[i] = UIKit.Text(row, sendUnits[i].ToString(), 30, UIKit.Theme.Text, TextAlignmentOptions.Center);
UIKit.Size(sendCounts[i], 80);
Step(row, "+1", idx, 1); Step(row, "+10", idx, 10);
}
var quick = UIKit.Row(card, 70, 10);
UIKit.Size(UIKit.Button(quick, "Все войска", UIKit.Theme.Gray, () => { for (int i = 0; i < 4; i++) sendUnits[i] = GP.Data.troops[i]; RefreshSend(); }, -1, 70, 24), -1, 70, 1);
UIKit.Size(UIKit.Button(quick, "Сбросить", UIKit.Theme.Gray, () => { for (int i = 0; i < 4; i++) sendUnits[i] = 0; RefreshSend(); }, -1, 70, 24), -1, 70, 1);
sendSummary = UIKit.Text(card, "", 26);
var btns = UIKit.Row(card, 100, 12);
UIKit.Size(UIKit.Button(btns, "Назад", UIKit.Theme.Gray, () => { sendMode = false; RequestRebuild(); }, -1, 100, 30), -1, 100, 1);
sendButton = UIKit.Button(btns, "ОТПРАВИТЬ", UIKit.Theme.Red, () =>
{
var m = WorldSystem.Send(selectedId, (int[])sendUnits.Clone());
if (m != null) { sendMode = false; RequestRebuild(); }
}, -1, 100, 34);
UIKit.Size(sendButton, -1, 100, 1.5f);
RefreshSend();
}
void Step(Transform row, string label, int idx, int delta)
{
UIKit.Button(row, label, UIKit.Theme.CardDark, () =>
{
sendUnits[idx] = Mathf.Clamp(sendUnits[idx] + delta, 0, GP.Data.troops[idx]);
RefreshSend();
}, 74, 64, 24);
}
void RefreshSend()
{
for (int i = 0; i < 4; i++)
{
sendUnits[i] = Mathf.Clamp(sendUnits[i], 0, GP.Data.troops[i]);
if (sendCounts[i] != null) sendCounts[i].text = sendUnits[i].ToString();
}
if (sendSummary == null) return;
var def = WorldData.Get(selectedId);
var st = WorldSystem.State(selectedId);
int count = WorldSystem.Count(sendUnits);
float chance = WorldSystem.EstimateWinChance(sendUnits, selectedId);
string cc = chance >= 0.7f ? "#7ee08a" : (chance >= 0.4f ? "#f2c14e" : "#ff7a7a");
sendSummary.text =
"Воинов: " + count + " • сила " + Mathf.RoundToInt(WorldSystem.Power(sendUnits) * (1 + GP.ArmyBonus)).ToString("N0") +
" против " + WorldSystem.GarrisonPower(st.garrison).ToString("N0") + "\n" +
"Унесут добычи: " + WorldSystem.Carry(sendUnits).ToString("N0") + "\n" +
"В пути: " + (count > 0 ? GameData.FormatTime(WorldSystem.TravelSeconds(def, sendUnits)) + " туда и столько же обратно" : "-") + "\n" +
"Шанс победы: <color=" + cc + ">" + Mathf.RoundToInt(chance * 100) + "%</color>";
string why = WorldSystem.CanSendReason(selectedId, sendUnits);
if (sendButton != null) UIKit.SetButton(sendButton, why == null ? "ОТПРАВИТЬ" : why, why == null);
}
// ===================== ОБНОВЛЕНИЕ =====================
protected override void Frame()
{
var home = WorldData.Home.pos;
foreach (var v in marchViews)
{
if (v.marker == null) continue;
var target = WorldData.Get(v.m.targetId);
float p = WorldSystem.Progress(v.m);
v.marker.anchorMin = v.marker.anchorMax = Vector2.Lerp(home, target.pos, p);
v.timer.text = (v.m.returning ? "домой " : "бой через ") + GameData.FormatTime(WorldSystem.SecondsLeft(v.m));
}
}
protected override void Tick()
{
foreach (var kv in listTimers)
{
if (kv.Value == null) continue;
var m = kv.Key;
int n = WorldSystem.Count(m.returning ? m.survivors : m.units);
kv.Value.text = n + " воинов • " + (m.returning ? "вернутся через " : "прибытие через ") + GameData.FormatTime(WorldSystem.SecondsLeft(m)) +
(m.returning && m.loot.Count > 0 ? " • несут: " + GameData.RewardsText(m.loot.ToArray()) : "");
}
foreach (var kv in ruinTimers)
{
if (kv.Value == null) continue;
string id = kv.Key.StartsWith("card_") ? kv.Key.Substring(5) : kv.Key;
double left = WorldSystem.RuinedSecondsLeft(id);
if (left <= 0) { RequestRebuild(); return; }
kv.Value.text = kv.Key.StartsWith("card_")
? "Разграблено. Отстроится через " + GameData.FormatTime(left)
: WorldData.Get(id).name + "\nотстроится " + GameData.FormatTime(left);
}
}
}
@@ -1,2 +0,0 @@
fileFormatVersion: 2
guid: 8c8463f829cbc0840b0b660e08974aa6
-415
View File
@@ -1,415 +0,0 @@
using System;
using System.Collections.Generic;
using System.Text;
using UnityEngine;
// ЛОГИКА ГЛОБАЛЬНОЙ КАРТЫ:
// отправка армии -> марш по маршруту (таймер) -> прибытие -> бой по раундам -> потери, добыча
// -> возвращение домой (таймер) -> выжившие снова в городе, добыча на складе, отчёт в почте.
// Время реальное (как у обучения войск): поход продолжается, даже если игра закрыта.
public static class WorldSystem
{
static GameProgress GP { get { return GameProgress.Instance; } }
static ProgressData D { get { return GameProgress.Instance.Data; } }
static long Now { get { return DateTime.UtcNow.Ticks; } }
// ================= ПОСЕЛЕНИЯ =================
public static SettlementState State(string id)
{
var s = D.settlements.Find(x => x.id == id);
if (s == null)
{
s = new SettlementState { id = id };
var def = WorldData.Get(id);
if (def != null && def.baseGarrison != null) s.garrison = FullGarrison(def, 1);
D.settlements.Add(s);
}
if (s.garrison == null || s.garrison.Length < 4) s.garrison = new int[4];
return s;
}
public static int[] FullGarrison(SettlementDef def, int level)
{
var g = new int[4];
if (def.baseGarrison == null) return g;
float m = WorldData.LevelMultiplier(level);
for (int i = 0; i < 4 && i < def.baseGarrison.Length; i++) g[i] = Mathf.RoundToInt(def.baseGarrison[i] * m);
return g;
}
public static RewardItem[] FullLoot(SettlementDef def, int level)
{
if (def.baseLoot == null) return new RewardItem[0];
float m = WorldData.LevelMultiplier(level);
var list = new RewardItem[def.baseLoot.Length];
for (int i = 0; i < list.Length; i++) list[i] = new RewardItem(def.baseLoot[i].type, Mathf.RoundToInt(def.baseLoot[i].amount * m));
return list;
}
// Разграблено и пока пустует?
public static bool IsRuined(string id, long at) { return State(id).ruinedUntilTicks > at; }
public static double RuinedSecondsLeft(string id) { return TimeSpan.FromTicks(Math.Max(0, State(id).ruinedUntilTicks - Now)).TotalSeconds; }
// Восстановление поселений со временем
static void RefreshSettlement(SettlementDef def, long at)
{
if (def.isHome) return;
var s = State(def.id);
if (s.ruinedUntilTicks > 0 && s.ruinedUntilTicks <= at)
{
s.ruinedUntilTicks = 0; // отстроились, уже на новом уровне
s.garrison = FullGarrison(def, s.level);
GP.MarkChanged();
}
else if (s.ruinedUntilTicks == 0 && s.lastBattleTicks > 0 &&
at - s.lastBattleTicks >= TimeSpan.FromMinutes(WorldData.GarrisonRegenMinutes).Ticks)
{
var full = FullGarrison(def, s.level);
bool changed = false;
for (int i = 0; i < 4; i++) if (s.garrison[i] != full[i]) { s.garrison[i] = full[i]; changed = true; }
s.lastBattleTicks = 0;
if (changed) GP.MarkChanged();
}
}
public static int GarrisonPower(int[] g)
{
int p = 0;
for (int i = 0; i < 4; i++) p += g[i] * WorldData.UnitPower(i);
return p;
}
// ================= МАРШРУТ И ВРЕМЯ =================
public static float Distance(SettlementDef target) { return Vector2.Distance(WorldData.Home.pos, target.pos); }
public static float ArmySpeed(int[] units)
{
float speed = float.MaxValue;
for (int i = 0; i < 4; i++) if (units[i] > 0) speed = Mathf.Min(speed, WorldData.Units[i].speed);
return speed == float.MaxValue ? 1f : speed;
}
public static double TravelSeconds(SettlementDef target, int[] units)
{
return Distance(target) * WorldData.SecondsPerMapUnit / ArmySpeed(units);
}
public static int Carry(int[] units)
{
int c = 0;
for (int i = 0; i < 4; i++) c += units[i] * WorldData.Units[i].carry;
return c;
}
public static int Count(int[] units) { int n = 0; foreach (var u in units) n += u; return n; }
public static int Power(int[] units) { return GarrisonPower(units); }
// Где сейчас армия: 0 = дома, 1 = у цели
public static float Progress(WorldMarch m)
{
long now = Now;
if (!m.returning)
return Mathf.Clamp01((float)(now - m.startTicks) / Math.Max(1, m.arriveTicks - m.startTicks));
// на обратном пути: из точки разворота (1 = от цели, меньше -- если отозвали по дороге) домой
float t = Mathf.Clamp01((float)(now - m.arriveTicks) / Math.Max(1, m.returnTicks - m.arriveTicks));
return Mathf.Lerp(m.turnPoint, 0f, t);
}
public static double SecondsLeft(WorldMarch m)
{
long end = m.returning ? m.returnTicks : m.arriveTicks;
return TimeSpan.FromTicks(Math.Max(0, end - Now)).TotalSeconds;
}
public static int TroopsInMarches
{
get
{
int n = 0;
foreach (var m in D.marches) n += Count(m.returning ? m.survivors : m.units);
return n;
}
}
// ================= ОТПРАВКА / ОТЗЫВ =================
public static string CanSendReason(string targetId, int[] units)
{
var def = WorldData.Get(targetId);
if (def == null || def.isHome) return "Выберите цель";
if (D.marches.Count >= WorldData.MaxMarches) return "Все отряды уже в походе (макс. " + WorldData.MaxMarches + ")";
if (Count(units) <= 0) return "Выберите войска";
for (int i = 0; i < 4; i++) if (units[i] > D.troops[i]) return "Столько войск нет в городе";
if (IsRuined(targetId, Now)) return "Поселение разграблено, ещё отстраивается";
return null;
}
public static WorldMarch Send(string targetId, int[] units)
{
string why = CanSendReason(targetId, units);
if (why != null) { UIKit.Toast(why); return null; }
var def = WorldData.Get(targetId);
var m = new WorldMarch { id = D.nextMarchId++, targetId = targetId, startTicks = Now };
for (int i = 0; i < 4; i++) { m.units[i] = units[i]; D.troops[i] -= units[i]; }
m.arriveTicks = m.startTicks + (long)(TravelSeconds(def, units) * TimeSpan.TicksPerSecond);
D.marches.Add(m);
D.marchesSent++;
D.dailyMarches++;
UIKit.Toast("Армия выступила: " + def.name + ". Прибытие через " + GameData.FormatTime(SecondsLeft(m)));
GP.MarkChanged();
return m;
}
// Развернуть армию домой, пока она не дошла (обратно идёт столько же, сколько прошла)
public static void Recall(WorldMarch m)
{
if (m == null || m.returning) return;
long now = Now;
long walked = now - m.startTicks;
m.turnPoint = Progress(m);
m.returning = true;
m.recalled = true;
for (int i = 0; i < 4; i++) m.survivors[i] = m.units[i];
m.arriveTicks = now;
m.returnTicks = now + walked;
UIKit.Toast("Армия разворачивается домой");
GP.MarkChanged();
}
// ================= ОБРАБОТКА (вызывается раз в секунду из GameProgress) =================
public static void Process()
{
long now = Now;
foreach (var def in WorldData.Settlements) RefreshSettlement(def, now);
// копия списка: внутри можем удалять
foreach (var m in D.marches.ToArray())
{
if (!m.returning && now >= m.arriveTicks) Arrive(m);
if (m.returning && now >= m.returnTicks) ReturnHome(m);
}
}
static void Arrive(WorldMarch m)
{
var def = WorldData.Get(m.targetId);
RefreshSettlement(def, m.arriveTicks);
var s = State(m.targetId);
long travel = m.arriveTicks - m.startTicks;
if (s.ruinedUntilTicks > m.arriveTicks)
{
// пока шли, поселение уже разграбили -- возвращаемся ни с чем
for (int i = 0; i < 4; i++) m.survivors[i] = m.units[i];
m.returning = true;
m.returnTicks = m.arriveTicks + travel;
AddReport(false, "Поход отменён: " + def.name, "Поселение уже разграблено. Армия возвращается домой без боя.");
GP.MarkChanged();
return;
}
int level = s.level;
var defenders = (int[])s.garrison.Clone();
var sim = Simulate(m.units, defenders, GP.ArmyBonus, WorldData.DefenderBonus, true);
for (int i = 0; i < 4; i++) m.survivors[i] = sim.attLeft[i];
s.garrison = sim.defLeft;
s.lastBattleTicks = m.arriveTicks;
var sb = new StringBuilder();
sb.AppendLine("Цель: " + def.name + " (ур. " + level + ")");
sb.AppendLine("Раундов: " + sim.rounds);
sb.AppendLine();
sb.AppendLine("Ваша армия: " + UnitsText(m.units));
sb.AppendLine("Ваши потери: " + LossText(m.units, sim.attLeft));
sb.AppendLine("Защитники: " + UnitsText(defenders));
sb.AppendLine("Потери врага: " + LossText(defenders, sim.defLeft));
sb.AppendLine();
foreach (var line in sim.log) sb.AppendLine(line);
if (sim.win)
{
// добыча ограничена грузоподъёмностью выживших
var loot = FullLoot(def, level);
int total = 0; foreach (var r in loot) total += r.amount;
int carry = Carry(sim.attLeft);
float k = total > 0 ? Mathf.Min(1f, (float)carry / total) : 0f;
m.loot.Clear();
foreach (var r in loot)
{
int a = Mathf.FloorToInt(r.amount * k);
if (a > 0) m.loot.Add(new RewardItem(r.type, a));
}
s.timesRaided++;
s.level++;
s.garrison = new int[4];
s.ruinedUntilTicks = m.arriveTicks + TimeSpan.FromMinutes(def.respawnMinutes).Ticks;
D.worldWins++;
GP.AddEventPoints(15);
sb.AppendLine();
sb.AppendLine("Добыча: " + (m.loot.Count > 0 ? GameData.RewardsText(m.loot.ToArray()) : "нечего унести") +
(k < 1f ? " (не хватило грузоподъёмности)" : ""));
sb.AppendLine("Поселение отстроится через " + GameData.FormatTime(def.respawnMinutes * 60) + " и станет сильнее.");
AddReport(true, "Победа: " + def.name, sb.ToString());
}
else
{
D.worldLosses++;
sb.AppendLine();
sb.AppendLine(Count(sim.attLeft) > 0 ? "Армия не смогла взять поселение и отступает." : "Армия разбита.");
AddReport(false, "Поражение: " + def.name, sb.ToString());
}
if (Count(m.survivors) == 0)
{
D.marches.Remove(m); // некому возвращаться
UIKit.Toast("Армия погибла у «" + def.name + "»");
}
else
{
m.returning = true;
m.returnTicks = m.arriveTicks + travel;
UIKit.Message(sim.win ? "ПОБЕДА!" : "Поражение",
def.name + "\nВаши потери: " + LossText(m.units, sim.attLeft) +
(sim.win ? "\nДобыча: " + (m.loot.Count > 0 ? GameData.RewardsText(m.loot.ToArray()) : "-") : "") +
"\n\nАрмия возвращается домой: " + GameData.FormatTime(SecondsLeft(m)) + "\nПолный отчёт -- в Почте.", "ОК");
}
GP.MarkChanged();
}
static void ReturnHome(WorldMarch m)
{
for (int i = 0; i < 4; i++) D.troops[i] += m.survivors[i];
D.marches.Remove(m);
var def = WorldData.Get(m.targetId);
if (m.loot.Count > 0)
{
int xp = 20 + 10 * State(m.targetId).timesRaided;
GP.Grant(m.loot.ToArray(), xp, false);
UIKit.Toast("Армия вернулась из похода (" + def.name + "). Добыча на складе: " + GameData.RewardsText(m.loot.ToArray()));
}
else
{
UIKit.Toast("Армия вернулась домой: " + Count(m.survivors) + " воинов");
}
GP.MarkChanged();
}
static void AddReport(bool win, string title, string text)
{
D.reports.Insert(0, new BattleReport { ticks = DateTime.Now.Ticks, title = title, text = text, win = win });
if (D.reports.Count > 15) D.reports.RemoveAt(D.reports.Count - 1);
GP.SendMail("Отчёт о бою: " + title, text);
}
// ================= БОЙ =================
public class SimResult
{
public bool win;
public int rounds;
public int[] attLeft = new int[4];
public int[] defLeft = new int[4];
public List<string> log = new List<string>();
}
// Пошаговый бой: каждый раунд обе стороны одновременно наносят урон.
// Урон стороны = сумма атак всех воинов * бонус * случайность (±15%).
// Урон делится между типами врага пропорционально их суммарному здоровью,
// защита снижает урон (каждая единица -8%). Погибшие = урон / здоровье.
// Победа -- у врага никого не осталось. За 10 раундов не справились -- отступление (поражение).
public static SimResult Simulate(int[] attackers, int[] defenders, float attBonus, float defBonus, bool withLog)
{
var r = new SimResult();
float[] a = new float[4], d = new float[4];
for (int i = 0; i < 4; i++) { a[i] = attackers[i]; d[i] = defenders[i]; }
for (int round = 1; round <= WorldData.MaxRounds; round++)
{
if (Sum(a) < 0.5f || Sum(d) < 0.5f) break;
float dmgA = Damage(a, attBonus);
float dmgD = Damage(d, defBonus);
float killedD = Apply(d, dmgA);
float killedA = Apply(a, dmgD);
r.rounds = round;
if (withLog)
r.log.Add(string.Format("Раунд {0}: вы нанесли {1:0} урона (убито {2:0}), враг нанёс {3:0} (убито {4:0})",
round, dmgA, killedD, dmgD, killedA));
}
for (int i = 0; i < 4; i++)
{
r.attLeft[i] = Mathf.Clamp(Mathf.RoundToInt(a[i]), 0, attackers[i]);
r.defLeft[i] = Mathf.Clamp(Mathf.RoundToInt(d[i]), 0, defenders[i]);
}
r.win = Count(r.defLeft) == 0 && Count(r.attLeft) > 0;
return r;
}
static float Sum(float[] x) { float s = 0; foreach (var v in x) s += v; return s; }
static float Damage(float[] side, float bonus)
{
float dmg = 0;
for (int i = 0; i < 4; i++) dmg += side[i] * WorldData.Units[i].attack;
return dmg * (1f + bonus) * UnityEngine.Random.Range(0.85f, 1.15f);
}
static float Apply(float[] side, float dmg)
{
float totalHp = 0;
for (int i = 0; i < 4; i++) totalHp += side[i] * WorldData.Units[i].hp;
if (totalHp <= 0) return 0;
float killed = 0;
for (int i = 0; i < 4; i++)
{
if (side[i] <= 0) continue;
var u = WorldData.Units[i];
float share = side[i] * u.hp / totalHp;
float k = share * dmg / (u.hp * (1f + u.defense * 0.08f));
k = Mathf.Min(k, side[i]);
side[i] -= k;
killed += k;
}
return killed;
}
// Оценка шанса: прогоняем бой много раз и считаем долю побед
public static float EstimateWinChance(int[] attackers, string targetId)
{
if (Count(attackers) == 0) return 0f;
var s = State(targetId);
int wins = 0;
const int runs = 40;
for (int k = 0; k < runs; k++)
if (Simulate(attackers, s.garrison, GP.ArmyBonus, WorldData.DefenderBonus, false).win) wins++;
return (float)wins / runs;
}
// ================= ТЕКСТЫ =================
public static string UnitsText(int[] units)
{
var parts = new List<string>();
for (int i = 0; i < 4; i++) if (units[i] > 0) parts.Add(GameData.Troops[i].name + " " + units[i]);
return parts.Count == 0 ? "нет" : string.Join(", ", parts.ToArray());
}
public static string LossText(int[] before, int[] after)
{
var parts = new List<string>();
for (int i = 0; i < 4; i++)
{
int lost = before[i] - after[i];
if (lost > 0) parts.Add(GameData.Troops[i].name + " " + lost);
}
return parts.Count == 0 ? "нет" : string.Join(", ", parts.ToArray());
}
}
@@ -1,2 +0,0 @@
fileFormatVersion: 2
guid: fb4b3ef256809b94f817e98df155b714
+8
View File
@@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: ad9d63e3dce07ca4ab1ae01670f1046f
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:
+8
View File
@@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: 2e9b04cfaf4d8d54abf9b9656431a844
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:
+58
View File
@@ -0,0 +1,58 @@
using System;
using System.Collections.Generic;
// МОДУЛЬ АРМИИ: войска и их распределение. Документ: Docs/Modules/Army.md
// Без Unity: правила деления войск одинаковы на клиенте и на сервере.
public static class ArmyMath
{
// Сколько воинов каждого типа взять, чтобы набрать count, пропорционально составу армии.
// Остаток округления раздаётся типам с наибольшей дробной частью. null -- столько воинов нет.
public static int[] TakeProportional(int[] troops, int count)
{
int total = 0;
foreach (var t in troops) total += t;
if (count <= 0 || count > total) return null;
var taken = new int[troops.Length];
int given = 0;
var remainders = new List<KeyValuePair<int, double>>();
for (int i = 0; i < troops.Length; i++)
{
double exact = (double)count * troops[i] / total;
taken[i] = Math.Min(troops[i], (int)Math.Floor(exact));
given += taken[i];
remainders.Add(new KeyValuePair<int, double>(i, exact - taken[i]));
}
remainders.Sort((a, b) => b.Value.CompareTo(a.Value));
for (int k = 0; given < count && k < remainders.Count * 2; k++)
{
int i = remainders[k % remainders.Count].Key;
if (taken[i] < troops[i]) { taken[i]++; given++; }
}
return taken;
}
// Как разложить survivors выживших по типам, если в поход ушли sentUnits.
// Остаток округления -- в самый многочисленный тип похода.
public static int[] SplitSurvivors(int[] sentUnits, int survivors)
{
var back = new int[sentUnits.Length];
survivors = Math.Max(0, survivors);
int sent = 0;
foreach (var u in sentUnits) sent += u;
int given = 0;
for (int i = 0; i < sentUnits.Length; i++)
{
back[i] = sent > 0 ? (int)Math.Floor((double)survivors * sentUnits[i] / sent) : 0;
given += back[i];
}
int best = 0;
for (int i = 1; i < sentUnits.Length; i++) if (sentUnits[i] > sentUnits[best]) best = i;
if (back.Length > 0) back[best] += survivors - given;
return back;
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: f4460b101c5560a41ab054b5465462a8
+198
View File
@@ -0,0 +1,198 @@
using System;
using System.Collections.Generic;
using System.Globalization;
using System.Text;
// МОДУЛЬ АРМИИ: единственное место, где хранятся войска. Документ: Docs/Modules/Army.md
// Без Unity: правила те же, что будут на сервере.
//
// Один солдат -- в одном месте: либо в городе (troops), либо в походе (marches).
// Поход забирает воинов из города и запоминает состав, возвращение раздаёт выживших по тем же типам.
public sealed class ArmyService : IArmy, ISaveParticipant
{
public const int Types = 4; // пехота, лучники, кавалерия, осадные -- порядок как в GameData.Troops
private class MarchUnits
{
public int marchId;
public int[] units = new int[Types];
}
private readonly int[] troops = new int[Types];
private readonly List<MarchUnits> marches = new List<MarchUnits>();
// Секция "army" была в сохранении -- старые войска из progress.json переносить не нужно
public bool RestoredFromSave { get; private set; }
// ---------------- Контракт IArmy ----------------
public int TypeCount => Types;
public int GetTroops(int type)
{
return type >= 0 && type < Types ? troops[type] : 0;
}
public int SoldiersAtHome
{
get { int n = 0; foreach (var t in troops) n += t; return n; }
}
public int TroopsInMarches
{
get
{
int n = 0;
foreach (var m in marches) foreach (var u in m.units) n += u;
return n;
}
}
public void AddTroops(int type, int count)
{
if (type < 0 || type >= Types || count <= 0) return;
troops[type] += count;
Changed();
}
public int LoseFraction(double fraction)
{
if (fraction <= 0) return 0;
if (fraction > 1) fraction = 1;
int lost = 0;
for (int i = 0; i < Types; i++)
{
int l = (int)Math.Floor(troops[i] * fraction);
troops[i] -= l;
lost += l;
}
if (lost > 0) Changed();
return lost;
}
public bool TakeForMarch(int marchId, int count)
{
int[] taken = ArmyMath.TakeProportional(troops, count);
if (taken == null) return false;
for (int i = 0; i < Types; i++) troops[i] -= taken[i];
marches.RemoveAll(m => m.marchId == marchId);
marches.Add(new MarchUnits { marchId = marchId, units = taken });
Changed();
return true;
}
public void ReturnFromMarch(int marchId, int survivors)
{
MarchUnits entry = marches.Find(m => m.marchId == marchId);
if (entry == null)
{
troops[0] += Math.Max(0, survivors); // состав неизвестен (очень старое сохранение) -- считаем пехотой
}
else
{
int[] back = ArmyMath.SplitSurvivors(entry.units, survivors);
for (int i = 0; i < Types; i++) troops[i] += back[i];
marches.Remove(entry);
}
Changed();
}
void Changed()
{
EventBus.Publish(new TroopsChangedEvent { AtHome = SoldiersAtHome, InMarches = TroopsInMarches });
}
// ---------------- Перенос из старого progress.json ----------------
// Войска раньше хранил GameProgress (troops) и мост ArmyBridge (sashaMarchUnits).
// Вызывается один раз, если в save.json ещё нет секции "army".
public void ImportLegacy(int[] legacyTroops, IEnumerable<KeyValuePair<int, int[]>> legacyMarches)
{
if (legacyTroops != null)
for (int i = 0; i < Types && i < legacyTroops.Length; i++) troops[i] = Math.Max(0, legacyTroops[i]);
marches.Clear();
if (legacyMarches != null)
{
foreach (var m in legacyMarches)
{
var units = new int[Types];
for (int i = 0; i < Types && m.Value != null && i < m.Value.Length; i++) units[i] = Math.Max(0, m.Value[i]);
marches.Add(new MarchUnits { marchId = m.Key, units = units });
}
}
Changed();
}
// ---------------- Сохранение (секция "army") ----------------
// Модуль без Unity, поэтому формат -- простые строки:
// troops=<пехота>,<лучники>,<кавалерия>,<осадные>
// march=<id похода>:<пехота>,<лучники>,<кавалерия>,<осадные>
public string SaveKey => "army";
public string Capture()
{
var sb = new StringBuilder();
sb.Append("troops=").Append(Join(troops)).Append('\n');
foreach (var m in marches)
sb.Append("march=").Append(m.marchId.ToString(CultureInfo.InvariantCulture)).Append(':').Append(Join(m.units)).Append('\n');
return sb.ToString();
}
public void Restore(string data)
{
Array.Clear(troops, 0, Types);
marches.Clear();
RestoredFromSave = true;
if (!string.IsNullOrEmpty(data))
{
foreach (var raw in data.Split('\n'))
{
string line = raw.Trim();
if (line.StartsWith("troops="))
{
int[] t = Parse(line.Substring("troops=".Length));
Array.Copy(t, troops, Types);
}
else if (line.StartsWith("march="))
{
string[] parts = line.Substring("march=".Length).Split(':');
if (parts.Length == 2 && int.TryParse(parts[0], NumberStyles.Integer, CultureInfo.InvariantCulture, out int id))
marches.Add(new MarchUnits { marchId = id, units = Parse(parts[1]) });
}
}
}
Changed();
}
// Новая игра: войск нет, походов нет
public void ResetState()
{
Array.Clear(troops, 0, Types);
marches.Clear();
Changed();
}
static string Join(int[] values)
{
var parts = new string[values.Length];
for (int i = 0; i < values.Length; i++) parts[i] = values[i].ToString(CultureInfo.InvariantCulture);
return string.Join(",", parts);
}
static int[] Parse(string csv)
{
var result = new int[Types];
string[] parts = csv.Split(',');
for (int i = 0; i < Types && i < parts.Length; i++)
{
if (int.TryParse(parts[i].Trim(), NumberStyles.Integer, CultureInfo.InvariantCulture, out int v))
result[i] = Math.Max(0, v);
}
return result;
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 933b37f69803d5b4eaf560fabe246137
@@ -0,0 +1,14 @@
{
"name": "Bogatyri.Army",
"rootNamespace": "",
"references": ["Bogatyri.Core"],
"includePlatforms": [],
"excludePlatforms": [],
"allowUnsafeCode": false,
"overrideReferences": false,
"precompiledReferences": [],
"autoReferenced": true,
"defineConstraints": [],
"versionDefines": [],
"noEngineReferences": true
}
@@ -0,0 +1,7 @@
fileFormatVersion: 2
guid: 87359e74a3d522242ab7014f9512ff00
AssemblyDefinitionImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:
+8
View File
@@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: 872c434d8b5a5fb489b82412fbc08b16
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,26 @@
// МОДУЛЬ БОЕВОЙ СИСТЕМЫ: урон, потери, исход. Документ: Docs/Modules/Battle.md
// Без Unity и без float: этот же код без изменений сможет считать бои на сервере.
//
// Правила пока самые простые: побеждает тот, у кого больше солдат.
// Потом сюда добавятся типы войск, герой, технологии -- остальные модули менять не придётся.
public sealed class BattleSystem : IBattleSystem
{
public BattleOutcome Resolve(BattleRequest request)
{
int attackers = request.Attackers;
int defenders = request.Defenders;
bool victory = attackers > defenders; // ничья -- в пользу защитника
// Проигравший теряет половину своего войска, победитель -- столько же
int loserArmy = victory ? defenders : attackers;
int losses = (loserArmy + 1) / 2;
return new BattleOutcome
{
Victory = victory,
AttackerLosses = losses,
DefenderLosses = losses,
Survivors = attackers - losses
};
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 258ab05e5beedbd4588480adc03be0b6
@@ -0,0 +1,14 @@
{
"name": "Bogatyri.Battle",
"rootNamespace": "",
"references": ["Bogatyri.Core"],
"includePlatforms": [],
"excludePlatforms": [],
"allowUnsafeCode": false,
"overrideReferences": false,
"precompiledReferences": [],
"autoReferenced": true,
"defineConstraints": [],
"versionDefines": [],
"noEngineReferences": true
}
@@ -0,0 +1,7 @@
fileFormatVersion: 2
guid: 5b5c1ae272a80b9418c2bca3b0d6aa50
AssemblyDefinitionImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:
+8
View File
@@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: ee8294dcaae498342ac5506a282a11a4
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,16 @@
{
"name": "Bogatyri.Chat",
"rootNamespace": "",
"references": [
"Bogatyri.Core"
],
"includePlatforms": [],
"excludePlatforms": [],
"allowUnsafeCode": false,
"overrideReferences": false,
"precompiledReferences": [],
"autoReferenced": true,
"defineConstraints": [],
"versionDefines": [],
"noEngineReferences": true
}
@@ -0,0 +1,7 @@
fileFormatVersion: 2
guid: b915ec65359a90e4ab597d99b40cdacb
AssemblyDefinitionImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:
+118
View File
@@ -0,0 +1,118 @@
using System;
using System.Collections.Generic;
// МОДУЛЬ ЧАТА: общий чат мира и чат Круга. Документ: Docs/Modules/Chat.md
// Без Unity: когда появится сервер, этот класс заменится сетевой реализацией IChat,
// а панель чата (слой приложения) менять не придётся.
//
// История живёт, пока открыта игра: модуль зарегистрирован в GameServices и переживает смену сцен.
// В файл сохранения чат не пишется -- переписку будет хранить сервер.
public sealed class ChatService : IChat
{
public const int MaxLength = 200;
public const int MaxStoredPerChannel = 100;
public int MaxMessageLength => MaxLength;
private readonly Dictionary<ChatChannel, List<ChatMessage>> history = new Dictionary<ChatChannel, List<ChatMessage>>
{
{ ChatChannel.World, new List<ChatMessage>() },
{ ChatChannel.Circle, new List<ChatMessage>() }
};
public ChatService(long nowMs)
{
SeedDemoMessages(nowMs);
// Канал "Круг" -- переписка текущего Круга игрока (модуль Круга сообщает о вступлении и выходе)
EventBus.Subscribe<CircleJoinedEvent>(OnCircleJoined);
EventBus.Subscribe<CircleLeftEvent>(OnCircleLeft);
EventBus.Subscribe<GameResetEvent>(_ => history[ChatChannel.Circle].Clear()); // новая игра -- без Круга
}
public const string SystemSenderId = "system";
// Писать в канал "Круг" можно, только состоя в Круге (если модуль Круга подключён)
public bool CanWrite(ChatChannel channel)
{
if (channel != ChatChannel.Circle) return true;
return !GameServices.TryGet(out ICircle circle) || circle.IsInCircle;
}
void OnCircleJoined(CircleJoinedEvent e)
{
history[ChatChannel.Circle].Clear();
string text = e.Created
? $"Вы основали Круг «{e.CircleName}». Зовите соратников!"
: $"Вы вступили в Круг «{e.CircleName}».";
Add(new ChatMessage { channel = ChatChannel.Circle, senderId = SystemSenderId, senderName = "Круг", text = text, timeMs = NowMs });
}
void OnCircleLeft(CircleLeftEvent e)
{
history[ChatChannel.Circle].Clear();
Add(new ChatMessage
{
channel = ChatChannel.Circle,
senderId = SystemSenderId,
senderName = "Круг",
text = e.Disbanded ? $"Круг «{e.CircleName}» распущен." : $"Вы покинули Круг «{e.CircleName}».",
timeMs = NowMs
});
}
static long NowMs => DateTimeOffset.UtcNow.ToUnixTimeMilliseconds();
public IReadOnlyList<ChatMessage> GetMessages(ChatChannel channel)
{
return history[channel];
}
public bool Send(ChatChannel channel, string text)
{
text = text?.Trim();
if (string.IsNullOrEmpty(text)) return false;
if (!CanWrite(channel)) return false;
if (text.Length > MaxLength) text = text.Substring(0, MaxLength);
Add(new ChatMessage
{
channel = channel,
senderId = LocalPlayer.Id,
senderName = LocalPlayer.Name,
text = text,
timeMs = NowMs
});
return true;
}
// Входящее сообщение (сейчас -- только свои; позже сюда придут сообщения с сервера)
public void Receive(ChatMessage message)
{
Add(message);
}
void Add(ChatMessage message)
{
List<ChatMessage> list = history[message.channel];
list.Add(message);
if (list.Count > MaxStoredPerChannel) list.RemoveAt(0);
EventBus.Publish(new ChatMessageReceivedEvent { Message = message });
}
// Несколько сообщений "от других игроков", чтобы чат не был пустым. Уберётся с приходом сервера.
void SeedDemoMessages(long nowMs)
{
const long minute = 60_000;
AddDemo(ChatChannel.World, "npc_sever", "Север", "Кто идёт на Змея Горыныча?", nowMs - 5 * minute);
AddDemo(ChatChannel.World, "npc_dreadwolf", "DreadWolf", "Набор в Круг, нужны активные игроки!", nowMs - 4 * minute);
AddDemo(ChatChannel.World, "npc_moroz", "Мороз", "На западе замечен Круг Стальные Волки.", nowMs - 3 * minute);
AddDemo(ChatChannel.World, "npc_tairon", "Тайрон", "Собираем отряд на древний храм.", nowMs - 2 * minute);
AddDemo(ChatChannel.World, "npc_veles", "Велес", "Всем удачной охоты!", nowMs - minute);
}
void AddDemo(ChatChannel channel, string id, string name, string text, long timeMs)
{
history[channel].Add(new ChatMessage { channel = channel, senderId = id, senderName = name, text = text, timeMs = timeMs });
}
}
+8
View File
@@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: 2e7af4ee1c7f41c69fc07d7c840a13dc
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,16 @@
{
"name": "Bogatyri.Circle",
"rootNamespace": "",
"references": [
"Bogatyri.Core"
],
"includePlatforms": [],
"excludePlatforms": [],
"allowUnsafeCode": false,
"overrideReferences": false,
"precompiledReferences": [],
"autoReferenced": true,
"defineConstraints": [],
"versionDefines": [],
"noEngineReferences": true
}
@@ -0,0 +1,7 @@
fileFormatVersion: 2
guid: 9f3dadf8e557473d840cd0d7e6900e64
AssemblyDefinitionImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,205 @@
using System;
using System.Collections.Generic;
using System.Text;
// МОДУЛЬ КРУГА: объединение игроков -- создать, вступить, выйти, роли главы и участника.
// Документ: Docs/Modules/Circle.md
//
// Без Unity: когда появится сервер, список Кругов и состав будут приходить с него,
// а этот класс заменится сетевой реализацией ICircle.
// Пока других игроков нет -- Круги из рейтинга заполнены выдуманными участниками.
public sealed class CircleService : ICircle, ISaveParticipant
{
public const int MaxMembersPerCircle = 30;
public const int MinNameLength = 3;
public const int MaxNameLength = 24;
public int MaxMembers => MaxMembersPerCircle;
private readonly List<CircleInfo> circles = new List<CircleInfo>();
private CircleInfo current;
public CircleInfo Current => current;
public bool IsInCircle => current != null;
public bool IsLeader => current != null && FindMember(current, LocalPlayer.Id)?.role == CircleRole.Leader;
public CircleService()
{
CreateDemoCircles();
}
public IReadOnlyList<CircleInfo> GetCircles()
{
return circles;
}
// ---------------- Действия игрока ----------------
public bool TryCreate(string name, out string error)
{
name = name?.Trim() ?? "";
if (IsInCircle) { error = "Сначала выйдите из своего Круга"; return false; }
if (!IsValidName(name, out error)) return false;
if (circles.Exists(c => string.Equals(c.name, name, StringComparison.OrdinalIgnoreCase)))
{
error = "Круг с таким названием уже есть";
return false;
}
var circle = new CircleInfo { id = "player_" + Guid.NewGuid().ToString("N").Substring(0, 8), name = name };
circle.members.Add(new CircleMember { playerId = LocalPlayer.Id, name = LocalPlayer.Name, role = CircleRole.Leader });
circles.Insert(0, circle);
current = circle;
EventBus.Publish(new CircleJoinedEvent { CircleId = circle.id, CircleName = circle.name, Created = true });
error = null;
return true;
}
public bool TryJoin(string circleId, out string error)
{
if (IsInCircle) { error = "Вы уже состоите в Круге. Сначала выйдите из него"; return false; }
CircleInfo circle = circles.Find(c => c.id == circleId);
if (circle == null) { error = "Такого Круга нет"; return false; }
if (circle.members.Count >= MaxMembersPerCircle) { error = "В Круге нет свободных мест"; return false; }
circle.members.Add(new CircleMember { playerId = LocalPlayer.Id, name = LocalPlayer.Name, role = CircleRole.Member });
current = circle;
EventBus.Publish(new CircleJoinedEvent { CircleId = circle.id, CircleName = circle.name, Created = false });
error = null;
return true;
}
public void Leave()
{
if (current == null) return;
CircleInfo circle = current;
CircleMember me = FindMember(circle, LocalPlayer.Id);
circle.members.Remove(me);
current = null;
bool disbanded = circle.members.Count == 0;
if (disbanded)
{
circles.Remove(circle);
}
else if (me != null && me.role == CircleRole.Leader)
{
circle.members[0].role = CircleRole.Leader; // главой становится старейший участник
}
EventBus.Publish(new CircleLeftEvent { CircleId = circle.id, CircleName = circle.name, Disbanded = disbanded });
}
public static bool IsValidName(string name, out string error)
{
name = name?.Trim() ?? "";
if (name.Length < MinNameLength) { error = $"Название -- не короче {MinNameLength} символов"; return false; }
if (name.Length > MaxNameLength) { error = $"Название -- не длиннее {MaxNameLength} символов"; return false; }
if (name.IndexOfAny(new[] { '\n', '\r', '|', '<', '>' }) >= 0) { error = "В названии есть недопустимые символы"; return false; }
error = null;
return true;
}
static CircleMember FindMember(CircleInfo circle, string playerId)
{
return circle.members.Find(m => m.playerId == playerId);
}
// ---------------- Сохранение (секция "circle") ----------------
// Модуль без Unity, поэтому формат -- простые строки, а не JSON:
// joined=<id Круга игрока>
// created=<id>|<название>|<роль игрока: Leader/Member> -- Круги, созданные игроком
// Демо-Круги не сохраняются: они одинаковые при каждом запуске.
public string SaveKey => "circle";
public string Capture()
{
var sb = new StringBuilder();
if (current != null) sb.Append("joined=").Append(current.id).Append('\n');
foreach (var c in circles)
{
if (!c.id.StartsWith("player_")) continue;
var me = FindMember(c, LocalPlayer.Id);
if (me == null) continue; // свой Круг, из которого игрок вышел, уже распущен
sb.Append("created=").Append(c.id).Append('|').Append(c.name).Append('|').Append(me.role).Append('\n');
}
return sb.ToString();
}
public void Restore(string data)
{
ResetState();
if (string.IsNullOrEmpty(data)) return;
string joinedId = null;
foreach (var raw in data.Split('\n'))
{
string line = raw.Trim();
if (line.StartsWith("joined=")) joinedId = line.Substring("joined=".Length);
else if (line.StartsWith("created="))
{
string[] parts = line.Substring("created=".Length).Split('|');
if (parts.Length < 2) continue;
var circle = new CircleInfo { id = parts[0], name = parts[1] };
CircleRole role = parts.Length > 2 && parts[2] == nameof(CircleRole.Member) ? CircleRole.Member : CircleRole.Leader;
circle.members.Add(new CircleMember { playerId = LocalPlayer.Id, name = LocalPlayer.Name, role = role });
circles.Insert(0, circle);
}
}
if (joinedId == null) return;
CircleInfo joined = circles.Find(c => c.id == joinedId);
if (joined == null) return; // Круга больше нет
if (FindMember(joined, LocalPlayer.Id) == null)
joined.members.Add(new CircleMember { playerId = LocalPlayer.Id, name = LocalPlayer.Name, role = CircleRole.Member });
current = joined;
}
// Новая игра: игрок ни в каком Круге, свои Круги распущены
public void ResetState()
{
current = null;
circles.Clear();
CreateDemoCircles();
}
// ---------------- Демо-Круги (до появления сервера) ----------------
static readonly string[] DemoCircleNames =
{
"Круг Перуна", "Стальные Волки", "Сыны Руси", "Богатыри", "Северный Страж",
"Вольные", "Златые Земли", "Серые Горны", "Речные", "Перунов Круг"
};
static readonly string[] DemoPlayerNames =
{
"Велес", "Север", "Тайрон", "DreadWolf", "Мороз", "Леший", "Князь", "Варяг", "Сокол", "Ратибор",
"Светозар", "Добромир", "Велимир", "Ярополк", "Всеслав", "Мстислав", "Остромир", "Богдан", "Любомир", "Твердислав",
"Радим", "Гостомысл", "Вышеслав", "Путята", "Ставр", "Чурила", "Микула", "Василиса", "Забава", "IronBear"
};
// Сколько участников в каждом демо-Круге (последний -- полный, чтобы было видно "мест нет")
static readonly int[] DemoMemberCounts = { 28, 24, 22, 19, 17, 14, 12, 9, 6, MaxMembersPerCircle };
void CreateDemoCircles()
{
for (int i = 0; i < DemoCircleNames.Length; i++)
{
var circle = new CircleInfo { id = "circle_" + (i + 1), name = DemoCircleNames[i] };
for (int k = 0; k < DemoMemberCounts[i]; k++)
{
string name = DemoPlayerNames[(i + k) % DemoPlayerNames.Length];
circle.members.Add(new CircleMember
{
playerId = $"npc_{i + 1}_{k + 1}",
name = k < DemoPlayerNames.Length ? name : name + " " + (k + 1),
role = k == 0 ? CircleRole.Leader : CircleRole.Member
});
}
circles.Add(circle);
}
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 388a95c3ce2349e394b400f27d0724e1
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
+8
View File
@@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: 78fe3f117b017be48a5f870d88e6a2a4
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,14 @@
{
"name": "Bogatyri.Core.Unity",
"rootNamespace": "",
"references": ["Bogatyri.Core"],
"includePlatforms": [],
"excludePlatforms": [],
"allowUnsafeCode": false,
"overrideReferences": false,
"precompiledReferences": [],
"autoReferenced": true,
"defineConstraints": [],
"versionDefines": [],
"noEngineReferences": false
}
@@ -0,0 +1,7 @@
fileFormatVersion: 2
guid: 189d07a7182d0984599ea459f965a9ea
AssemblyDefinitionImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,16 @@
using UnityEngine;
// Unity-часть ядра: подключает чистое ядро (без Unity) к движку.
public static class CoreUnityBootstrap
{
// Самый ранний момент запуска игры -- раньше любых Awake.
// Чистим статику: в редакторе с выключенной перезагрузкой домена она переживает прошлый Play.
[RuntimeInitializeOnLoadMethod(RuntimeInitializeLoadType.SubsystemRegistration)]
static void ResetCore()
{
GameServices.Clear();
EventBus.Clear();
SaveRegistry.Clear();
EventBus.OnHandlerError = Debug.LogException;
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: e1017811ac1606e48b62e813e60ee061
+8
View File
@@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: f5a125ad92983cc4fad8c6163ebe05c8
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,14 @@
{
"name": "Bogatyri.Core",
"rootNamespace": "",
"references": [],
"includePlatforms": [],
"excludePlatforms": [],
"allowUnsafeCode": false,
"overrideReferences": false,
"precompiledReferences": [],
"autoReferenced": true,
"defineConstraints": [],
"versionDefines": [],
"noEngineReferences": true
}
@@ -0,0 +1,7 @@
fileFormatVersion: 2
guid: 0e8277a21993ac64d82edc7a08e61d26
AssemblyDefinitionImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: d32ca5b87f2cfcb4f8a7bdd8e1bc06ef
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,191 @@
using System;
using System.Collections.Generic;
// Контракты модулей: всё, что один модуль может попросить у другого.
// Реализация живёт в своём модуле, здесь -- только "что умеет".
// Добавляя метод сюда, обнови документ модуля в Docs/Modules.
// ---------------- Экономика: ресурсы и производство ----------------
public interface IEconomy
{
float Get(ResourceType type);
bool Has(ResourceType type, float amount);
void Add(ResourceType type, float amount);
bool TrySpend(ResourceType type, float amount);
bool CanAfford(IReadOnlyList<ResourceAmount> cost);
// Списывает всё или ничего
bool TrySpend(IReadOnlyList<ResourceAmount> cost);
}
// ---------------- Поселение: здания и развитие города ----------------
public interface ISettlement
{
int TownHallLevel { get; }
int BuildingCount { get; }
bool IsCityLoaded { get; } // открыта ли сейчас сцена города
}
// Здание с уровнем (добывающие постройки и т.п.) -- Поселение сохраняет уровень, не зная, чьё это здание
public interface ILeveledBuilding
{
int Level { get; set; }
}
// ---------------- Армия: войска ----------------
public interface IArmy
{
int TypeCount { get; } // типов войск (пехота, лучники, кавалерия, осадные)
int GetTroops(int type); // воинов этого типа в городе
int SoldiersAtHome { get; } // всего воинов в городе
int TroopsInMarches { get; } // воинов в походах
void AddTroops(int type, int count); // обучены / вернулись
int LoseFraction(double fraction); // потери в бою: доля от каждого типа; возвращает, сколько погибло
// Забрать воинов из города в поход (запоминает состав похода)
bool TakeForMarch(int marchId, int count);
// Вернуть выживших из похода по тем же типам войск
void ReturnFromMarch(int marchId, int survivors);
}
// ---------------- Глобальный мир: карта и перемещения ----------------
public interface IWorld
{
int ActiveMarches { get; }
bool TryStartMarch(string targetId, int soldiers, out string error);
}
// ---------------- Боевая система: урон, потери, исход ----------------
public struct BattleRequest
{
public int Attackers;
public int Defenders;
}
public struct BattleOutcome
{
public bool Victory; // победил атакующий
public int AttackerLosses;
public int DefenderLosses;
public int Survivors; // сколько атакующих осталось
}
public interface IBattleSystem
{
BattleOutcome Resolve(BattleRequest request);
}
// ---------------- Чат: общий мир и Круг ----------------
public enum ChatChannel
{
World, // общий чат всего мира
Circle // чат своего Круга
}
[Serializable]
public class ChatMessage
{
public ChatChannel channel;
public string senderId;
public string senderName;
public string text;
public long timeMs; // Unix-время в миллисекундах
}
public interface IChat
{
int MaxMessageLength { get; }
IReadOnlyList<ChatMessage> GetMessages(ChatChannel channel);
bool CanWrite(ChatChannel channel); // в канал "Круг" -- только состоя в Круге
// Отправить от имени локального игрока. false -- пустое сообщение или канал "Круг" без Круга
bool Send(ChatChannel channel, string text);
}
// ---------------- Круг: объединение игроков ----------------
public enum CircleRole
{
Leader, // глава
Member // участник
}
[Serializable]
public class CircleMember
{
public string playerId;
public string name;
public CircleRole role;
}
[Serializable]
public class CircleInfo
{
public string id;
public string name;
public List<CircleMember> members = new List<CircleMember>();
public string LeaderName
{
get
{
foreach (var m in members) if (m.role == CircleRole.Leader) return m.name;
return "";
}
}
}
public interface ICircle
{
int MaxMembers { get; }
CircleInfo Current { get; } // Круг игрока; null -- игрок не в Круге
bool IsInCircle { get; }
bool IsLeader { get; }
IReadOnlyList<CircleInfo> GetCircles(); // все Круги, в которые можно вступить
bool TryCreate(string name, out string error); // создатель становится главой
bool TryJoin(string circleId, out string error);
void Leave(); // глава один в Круге -- Круг распускается
}
// ---------------- Сохранения: игровое состояние ----------------
public interface ISaveSystem
{
bool HasSave { get; }
void SaveNow();
void DeleteSave();
// Новая игра: все модули возвращаются к стартовому состоянию, файл перезаписывается
void ResetAll();
}
// Участник сохранения: каждый модуль сам решает, что и как хранит, в своей секции файла.
public interface ISaveParticipant
{
string SaveKey { get; } // имя секции в файле: "economy", "settlement", "world"...
string Capture(); // JSON секции; null -- оставить то, что уже было в файле
void Restore(string json);
void ResetState(); // вернуться к состоянию новой игры (сброс сохранений)
}
// Список участников. Модули регистрируются сами, модуль Сохранений обходит их при записи и загрузке.
public static class SaveRegistry
{
private static readonly List<ISaveParticipant> participants = new List<ISaveParticipant>();
public static IReadOnlyList<ISaveParticipant> All => participants;
public static void Register(ISaveParticipant participant)
{
participants.RemoveAll(p => p.SaveKey == participant.SaveKey);
participants.Add(participant);
}
public static void Unregister(ISaveParticipant participant)
{
participants.Remove(participant);
}
public static void Clear()
{
participants.Clear();
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 6232ac4b6b090c04e9897a596e5a1810
+54
View File
@@ -0,0 +1,54 @@
using System;
using System.Collections.Generic;
// Шина событий: модуль сообщает "что случилось", не зная, кто это слушает.
//
// EventBus.Publish(new MarchReturnedEvent { ... }); // модуль Мира
// EventBus.Subscribe<MarchReturnedEvent>(OnMarchReturned); // задания, статистика, UI
//
// Подписчик обязан отписаться (OnDestroy / OnDisable), иначе получит событие уже уничтоженным.
public static class EventBus
{
private static readonly Dictionary<Type, Delegate> handlers = new Dictionary<Type, Delegate>();
// Ошибка в одном подписчике не должна ломать остальных. Ядро не знает про Unity,
// поэтому куда писать ошибку, задаёт Core.Unity (там это Debug.LogException).
public static Action<Exception> OnHandlerError;
public static void Subscribe<T>(Action<T> handler)
{
handlers.TryGetValue(typeof(T), out Delegate existing);
handlers[typeof(T)] = Delegate.Combine(existing, handler);
}
public static void Unsubscribe<T>(Action<T> handler)
{
if (!handlers.TryGetValue(typeof(T), out Delegate existing)) return;
Delegate remaining = Delegate.Remove(existing, handler);
if (remaining == null) handlers.Remove(typeof(T));
else handlers[typeof(T)] = remaining;
}
public static void Publish<T>(T gameEvent)
{
if (!handlers.TryGetValue(typeof(T), out Delegate existing)) return;
// Копия списка: подписчик может отписаться прямо во время рассылки
foreach (Delegate d in existing.GetInvocationList())
{
try
{
((Action<T>)d)(gameEvent);
}
catch (Exception e)
{
OnHandlerError?.Invoke(e);
}
}
}
public static void Clear()
{
handlers.Clear();
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: db68092ecfa3d154e99d73096b844bb0
+8
View File
@@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: 3cc9acbef2da16f44ae9d2766065c6d0
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,90 @@
// События, которыми модули сообщают друг другу о случившемся.
// Событие -- это факт в прошедшем времени ("ресурс изменился"), а не просьба что-то сделать.
// Добавляя событие, впиши его в документ модуля-отправителя (Docs/Modules).
// ---------------- Экономика ----------------
public struct ResourceChangedEvent
{
public ResourceType Type;
public float Value;
}
// ---------------- Поселение ----------------
public struct BuildingPlacedEvent
{
public string BuildingName;
}
public struct TownHallLevelChangedEvent
{
public int Level;
}
// Поселение начинает восстанавливать здания из сохранения: самое время добавить в список стройки
// здания, которые появляются кодом (например, Казармы)
public struct SettlementRestoringEvent { }
// ---------------- Армия ----------------
// Число воинов изменилось (обучение, потери, поход, возвращение, загрузка)
public struct TroopsChangedEvent
{
public int AtHome;
public int InMarches;
}
// ---------------- Глобальный мир ----------------
public struct MarchStartedEvent
{
public int MarchId;
public string TargetId;
public int Soldiers;
}
public struct MarchBattleResolvedEvent
{
public int MarchId;
public string TargetId;
public bool Victory;
public int Soldiers;
public int Survivors;
}
public struct MarchReturnedEvent
{
public int MarchId;
public bool Victory;
public int Survivors;
public int Sent; // сколько воинов уходило в поход
public string TargetName; // куда ходили ("Замок Змея Горыныча", имя соседа)
public string LootText; // добыча текстом: "+20 золота, +40 еды" или "нет"
}
// ---------------- Чат ----------------
// Новое сообщение в любом канале (своё или чужое)
public struct ChatMessageReceivedEvent
{
public ChatMessage Message;
}
// ---------------- Круг ----------------
// Игрок вступил в Круг или создал свой
public struct CircleJoinedEvent
{
public string CircleId;
public string CircleName;
public bool Created;
}
// Игрок вышел из Круга (Disbanded -- Круг распущен, игрок был в нём один)
public struct CircleLeftEvent
{
public string CircleId;
public string CircleName;
public bool Disbanded;
}
// ---------------- Сохранения ----------------
public struct GameLoadedEvent { }
public struct GameSavedEvent { }
// Сохранения сброшены, все модули уже в состоянии новой игры
public struct GameResetEvent { }
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 4d6f60490c6ffb04caeb3a70906349bb
@@ -0,0 +1,54 @@
using System;
using System.Collections.Generic;
// Реестр модулей: модуль регистрирует свой контракт, остальные получают его отсюда.
// Так модули не ссылаются на классы друг друга: Армия знает про IEconomy, но не про ResourceManager.
//
// GameServices.Register<IEconomy>(this); // в модуле Экономики
// GameServices.Get<IEconomy>().TrySpend(ResourceType.Food, 15); // в любом другом модуле
public static class GameServices
{
private static readonly Dictionary<Type, object> services = new Dictionary<Type, object>();
public static void Register<T>(T service) where T : class
{
services[typeof(T)] = service ?? throw new ArgumentNullException(nameof(service));
}
// Снимает регистрацию, только если зарегистрирован именно этот экземпляр
// (дубликат синглтона, уничтожаясь, не должен снять настоящий)
public static void Unregister<T>(T service) where T : class
{
if (services.TryGetValue(typeof(T), out object current) && ReferenceEquals(current, service))
services.Remove(typeof(T));
}
public static T Get<T>() where T : class
{
if (services.TryGetValue(typeof(T), out object service)) return (T)service;
throw new InvalidOperationException(
$"Модуль {typeof(T).Name} не зарегистрирован. Проверь, что он есть на сцене или в App/GameModules.");
}
public static bool TryGet<T>(out T service) where T : class
{
if (services.TryGetValue(typeof(T), out object found))
{
service = (T)found;
return true;
}
service = null;
return false;
}
public static bool Has<T>() where T : class
{
return services.ContainsKey(typeof(T));
}
// Полная очистка -- при старте игры (в редакторе статика может пережить прошлый запуск)
public static void Clear()
{
services.Clear();
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: a565fc9a2c0580a46b91c4cb10d49c75
@@ -0,0 +1,7 @@
// Локальный игрок. Пока аккаунтов нет -- одно имя и один id на устройство.
// Когда появится сервер, эти значения будут приходить из аккаунта.
public static class LocalPlayer
{
public const string Id = "local_player";
public const string Name = "Богатырь";
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 79b22348748626644bd5142c32db5821
@@ -0,0 +1,27 @@
// Типы ресурсов игры. Лежат в ядре, потому что о ресурсах знают почти все модули
// (стоимость построек, найм войск, добыча в походах).
// Числовые значения НЕ менять: они записаны в сохранениях.
public enum ResourceType
{
Population = 0, // люди
Food = 1, // еда
Wood = 2, // дерево
Stone = 3, // камень
Iron = 4, // металл
Gold = 5, // золото
Gems = 6, // кристаллы
Power = 7 // "сила" -- как в референсе HUD
}
// Количество одного ресурса: цена, награда, добыча
public struct ResourceAmount
{
public ResourceType Type;
public float Amount;
public ResourceAmount(ResourceType type, float amount)
{
Type = type;
Amount = amount;
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 15600a3fb45578a48afb3d5443890368
+8
View File
@@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: bf7925db56b0b99438ee9c9a2ae14d43
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,14 @@
{
"name": "Bogatyri.Economy",
"rootNamespace": "",
"references": ["Bogatyri.Core", "Bogatyri.Core.Unity"],
"includePlatforms": [],
"excludePlatforms": [],
"allowUnsafeCode": false,
"overrideReferences": false,
"precompiledReferences": [],
"autoReferenced": true,
"defineConstraints": [],
"versionDefines": [],
"noEngineReferences": false
}
@@ -0,0 +1,7 @@
fileFormatVersion: 2
guid: d0d3d05504183fb40ba7f1ccfe994fa2
AssemblyDefinitionImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:
@@ -1,7 +1,11 @@
using UnityEngine;
public class ResourceCollector : MonoBehaviour
// Модуль Экономики: временная добыча по клику (уйдёт в этапе 3 -- производство через рабочих).
public class ResourceCollector : MonoBehaviour, ILeveledBuilding
{
// Уровень здания для Поселения (оно сохраняет уровни, не зная, что это за здание)
public int Level { get => level; set => level = value; }
[Header("Что производит это здание")]
public ResourceType resourceType;
@@ -0,0 +1,187 @@
using System;
using System.Collections.Generic;
using UnityEngine;
// МОДУЛЬ ЭКОНОМИКИ. Центральное хранилище всех ресурсов игрока.
// Другие модули работают с ним только через контракт IEconomy (GameServices.Get<IEconomy>()),
// а изменения узнают из события ResourceChangedEvent. Документ: Docs/Modules/Economy.md
//
// Старый доступ ResourceManager.Instance оставлен для интерфейса (панели, HUD) -- модулям им пользоваться нельзя.
public class ResourceManager : MonoBehaviour, IEconomy, ISaveParticipant
{
public static ResourceManager Instance { get; private set; }
// Событие для интерфейса (HUD). Модули слушают ResourceChangedEvent через EventBus.
public event Action<ResourceType, float> OnResourceChanged;
[Header("Стартовые значения (для теста/баланса)")]
public float startWood = 0f;
public float startStone = 0f;
public float startIron = 0f;
public float startFood = 0f;
public float startGold = 0f;
public float startGems = 0f;
public float startPower = 0f;
public float startPopulation = 0f;
private Dictionary<ResourceType, float> resources = new Dictionary<ResourceType, float>();
void Awake()
{
// Классический паттерн Singleton: если уже есть экземпляр -- уничтожаем дубликат
if (Instance != null && Instance != this)
{
Destroy(gameObject);
return;
}
Instance = this;
DontDestroyOnLoad(gameObject); // менеджер ресурсов должен жить между сценами (меню -> игра)
SetStartValues(notify: false);
GameServices.Register<IEconomy>(this);
SaveRegistry.Register(this);
}
void OnDestroy()
{
if (Instance != this) return;
GameServices.Unregister<IEconomy>(this);
SaveRegistry.Unregister(this);
}
// Добавить ресурс (можно и отрицательное число -- для трат)
public void AddResource(ResourceType type, float amount)
{
if (!resources.ContainsKey(type))
resources[type] = 0f;
resources[type] += amount;
if (resources[type] < 0f) resources[type] = 0f; // ресурс не уходит в минус
NotifyChanged(type);
}
// Проверить, хватает ли ресурса (для трат/построек в будущем)
public bool HasEnough(ResourceType type, float amount)
{
return GetResource(type) >= amount;
}
// Попробовать потратить -- вернёт false, если не хватило (и ничего не спишет)
public bool TrySpend(ResourceType type, float amount)
{
if (!HasEnough(type, amount)) return false;
AddResource(type, -amount);
return true;
}
public float GetResource(ResourceType type)
{
return resources.TryGetValue(type, out float value) ? value : 0f;
}
// Устанавливает значение напрямую, БЕЗ проверок и без учёта прошлого значения.
// Используется только при загрузке сохранения -- обычная игровая логика должна
// использовать AddResource/TrySpend, а не этот метод.
public void SetResource(ResourceType type, float value)
{
resources[type] = value;
NotifyChanged(type);
}
// Все ресурсы разом -- удобно для сохранения
public Dictionary<ResourceType, float> GetAllResources()
{
return new Dictionary<ResourceType, float>(resources);
}
void SetStartValues(bool notify)
{
var start = new Dictionary<ResourceType, float>
{
{ ResourceType.Wood, startWood },
{ ResourceType.Stone, startStone },
{ ResourceType.Iron, startIron },
{ ResourceType.Food, startFood },
{ ResourceType.Gold, startGold },
{ ResourceType.Gems, startGems },
{ ResourceType.Power, startPower },
{ ResourceType.Population, startPopulation },
};
resources.Clear();
foreach (var kvp in start)
{
resources[kvp.Key] = kvp.Value;
if (notify) NotifyChanged(kvp.Key);
}
}
void NotifyChanged(ResourceType type)
{
float value = resources[type];
OnResourceChanged?.Invoke(type, value);
EventBus.Publish(new ResourceChangedEvent { Type = type, Value = value });
}
// ---------------- Контракт IEconomy ----------------
float IEconomy.Get(ResourceType type) => GetResource(type);
bool IEconomy.Has(ResourceType type, float amount) => HasEnough(type, amount);
void IEconomy.Add(ResourceType type, float amount) => AddResource(type, amount);
public bool CanAfford(IReadOnlyList<ResourceAmount> cost)
{
foreach (var c in cost)
{
if (!HasEnough(c.Type, c.Amount)) return false;
}
return true;
}
public bool TrySpend(IReadOnlyList<ResourceAmount> cost)
{
if (!CanAfford(cost)) return false;
foreach (var c in cost) AddResource(c.Type, -c.Amount);
return true;
}
// ---------------- Сохранение (секция "economy") ----------------
[Serializable]
class ResourceSaveEntry
{
public ResourceType type;
public float amount;
}
// Имена полей совпадают со старым save.json -- старые сохранения читаются без переделки
[Serializable]
class EconomySaveData
{
public List<ResourceSaveEntry> resources = new List<ResourceSaveEntry>();
}
public string SaveKey => "economy";
public string Capture()
{
var data = new EconomySaveData();
foreach (var kvp in resources)
data.resources.Add(new ResourceSaveEntry { type = kvp.Key, amount = kvp.Value });
return JsonUtility.ToJson(data);
}
public void Restore(string json)
{
var data = JsonUtility.FromJson<EconomySaveData>(json);
if (data?.resources == null) return;
foreach (var entry in data.resources) SetResource(entry.type, entry.amount);
}
// Новая игра -- стартовые значения из Inspector
public void ResetState()
{
SetStartValues(notify: true);
}
}
+8
View File
@@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: 0c3352a3c46136049aabd5fdfe2bd415
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,14 @@
{
"name": "Bogatyri.Save",
"rootNamespace": "",
"references": ["Bogatyri.Core", "Bogatyri.Core.Unity"],
"includePlatforms": [],
"excludePlatforms": [],
"allowUnsafeCode": false,
"overrideReferences": false,
"precompiledReferences": [],
"autoReferenced": true,
"defineConstraints": [],
"versionDefines": [],
"noEngineReferences": false
}
@@ -0,0 +1,7 @@
fileFormatVersion: 2
guid: fd66e6fd0a173ec45ac2ed27bc05e617
AssemblyDefinitionImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:
@@ -1,5 +1,6 @@
using UnityEngine;
// МОДУЛЬ СОХРАНЕНИЙ. Документ: Docs/Modules/Save.md
// Вешается на любой объект, который есть в игровой сцене с самого начала
// (например, на сам SaveManager или на ResourceManager).
// При старте сцены пытается загрузить сохранение, если оно есть.
+183
View File
@@ -0,0 +1,183 @@
using System;
using System.Collections.Generic;
using System.IO;
using UnityEngine;
// МОДУЛЬ СОХРАНЕНИЙ: игровое состояние. Документ: Docs/Modules/Save.md
//
// Модуль не знает, ЧТО сохраняют другие модули: каждый модуль сам регистрирует себя в SaveRegistry
// (ISaveParticipant) и отдаёт свою секцию -- "economy", "settlement", "world" и т.д.
//
// Формат save.json, версия 2: { version: 2, sections: [ { key, json }, ... ] }.
// Версия 1 (старый плоский файл: resources, townHallLevel, placedBuildings) читается автоматически --
// имена полей в секциях Экономики и Поселения совпадают со старыми.
//
// Файл лежит в Application.persistentDataPath -- папка переживает переустановку/обновление игры на телефоне.
public class SaveManager : MonoBehaviour, ISaveSystem
{
public const int CurrentVersion = 2;
public static SaveManager Instance { get; private set; }
[Header("Автосохранение")]
[Tooltip("Как часто автоматически сохранять прогресс, в секундах")]
public float autoSaveInterval = 30f;
[Serializable]
class SaveSection
{
public string key;
public string json;
}
[Serializable]
class SaveFile
{
public int version;
public List<SaveSection> sections = new List<SaveSection>();
}
private string SavePath => Path.Combine(Application.persistentDataPath, "save.json");
// Последние известные секции: секция модуля, которого сейчас нет (например, город на карте мира),
// не должна пропасть из файла при следующем сохранении
private readonly Dictionary<string, string> sections = new Dictionary<string, string>();
private bool loaded; // сохранение уже загружено (или файла нет) -- только после этого можно писать
void Awake()
{
if (Instance != null && Instance != this)
{
Destroy(gameObject);
return;
}
Instance = this;
DontDestroyOnLoad(gameObject);
GameServices.Register<ISaveSystem>(this);
}
void OnDestroy()
{
if (Instance == this) GameServices.Unregister<ISaveSystem>(this);
}
void Start()
{
InvokeRepeating(nameof(SaveGame), autoSaveInterval, autoSaveInterval);
}
// Мобильные ОС часто "убивают" приложение без вызова OnApplicationQuit,
// поэтому OnApplicationPause -- самый надёжный момент для сохранения на телефоне.
void OnApplicationPause(bool pauseStatus)
{
if (pauseStatus) SaveGame();
}
void OnApplicationQuit()
{
SaveGame();
}
// ---------------- Контракт ISaveSystem ----------------
public bool HasSave => HasSaveFile();
public void SaveNow() => SaveGame();
// ---------------- СОХРАНЕНИЕ ----------------
public void SaveGame()
{
// Запись раньше загрузки затёрла бы файл стартовыми значениями модулей (ресурсы ещё нулевые)
if (!loaded && HasSaveFile())
{
Debug.LogWarning("[СОХРАНЕНИЕ] Пропущено: сохранение ещё не загружено.");
return;
}
loaded = true;
foreach (var participant in SaveRegistry.All)
{
string json = participant.Capture();
if (json != null) sections[participant.SaveKey] = json; // null -- модуль сейчас не может сохраниться
}
var file = new SaveFile { version = CurrentVersion };
foreach (var kvp in sections) file.sections.Add(new SaveSection { key = kvp.Key, json = kvp.Value });
File.WriteAllText(SavePath, JsonUtility.ToJson(file, true));
EventBus.Publish(new GameSavedEvent());
Debug.Log($"[СОХРАНЕНИЕ] Игра сохранена: {SavePath}");
}
// ---------------- ЗАГРУЗКА ----------------
public bool HasSaveFile()
{
return File.Exists(SavePath);
}
public void LoadGame()
{
loaded = true;
if (!HasSaveFile())
{
Debug.Log("[ЗАГРУЗКА] Файл сохранения не найден, начинаем новую игру.");
EventBus.Publish(new GameLoadedEvent()); // загрузка завершена, просто без файла
return;
}
string text = File.ReadAllText(SavePath);
ReadSections(text);
foreach (var participant in SaveRegistry.All)
{
if (sections.TryGetValue(participant.SaveKey, out string json))
participant.Restore(json);
}
EventBus.Publish(new GameLoadedEvent());
Debug.Log($"[ЗАГРУЗКА] Игра загружена, секций: {sections.Count}");
}
void ReadSections(string text)
{
sections.Clear();
SaveFile file = JsonUtility.FromJson<SaveFile>(text);
if (file != null && file.version >= 2)
{
foreach (var s in file.sections) sections[s.key] = s.json;
return;
}
// Версия 1: старый плоский файл. Отдаём его целиком Экономике и Поселению --
// их секции читают свои поля (resources / townHallLevel, placedBuildings) по старым именам.
sections["economy"] = text;
sections["settlement"] = text;
Debug.Log("[ЗАГРУЗКА] Старый формат save.json (версия 1) -- перенесён в секции.");
}
// СБРОС СОХРАНЕНИЙ: новая игра. Каждый модуль сам возвращается к старту, файл пишется заново.
// GameProgress (progress.json) сбрасывает себя сам -- см. GameProgress.ResetAll.
public void ResetAll()
{
DeleteSave();
foreach (var participant in SaveRegistry.All) participant.ResetState();
loaded = true;
SaveGame(); // сразу записать новую игру, чтобы старый прогресс не вернулся после перезапуска
EventBus.Publish(new GameResetEvent());
Debug.Log("[СОХРАНЕНИЕ] Сохранения сброшены, начата новая игра.");
}
// Только стирает файл сохранения (состояние модулей в памяти не трогает)
public void DeleteSave()
{
sections.Clear();
if (File.Exists(SavePath))
{
File.Delete(SavePath);
Debug.Log("[СОХРАНЕНИЕ] Файл сохранения удалён.");
}
}
}
+8
View File
@@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: 042a753badc24aa4689e3b461e1cbd40
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,14 @@
{
"name": "Bogatyri.Settlement",
"rootNamespace": "",
"references": ["Bogatyri.Core", "Bogatyri.Core.Unity"],
"includePlatforms": [],
"excludePlatforms": [],
"allowUnsafeCode": false,
"overrideReferences": false,
"precompiledReferences": [],
"autoReferenced": true,
"defineConstraints": [],
"versionDefines": [],
"noEngineReferences": false
}
@@ -0,0 +1,7 @@
fileFormatVersion: 2
guid: 76e5cc16f8a054f4b8e9d167047cbc74
AssemblyDefinitionImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:
@@ -1,6 +1,7 @@
using UnityEngine;
using System.Collections.Generic;
// МОДУЛЬ ПОСЕЛЕНИЯ. Документ: Docs/Modules/Settlement.md
// Управляет режимом строительства. Теперь здание можно поставить ТОЛЬКО
// на заранее подготовленный участок (BuildSlot) -- свободное размещение убрано.
// Наведение на пустой слот -> подсветка (можно строить).
@@ -35,6 +36,12 @@ public class BuildManager : MonoBehaviour
Instance = this;
mainCam = Camera.main;
}
void Start()
{
// Повторный вход в город (например, с карты мира): здания восстанавливаем из памяти модуля
SettlementModule.ApplyToScene();
}
void Update()
{
@@ -124,6 +131,7 @@ public class BuildManager : MonoBehaviour
link.linkedSlot = slot;
Debug.Log($"[СТРОЙКА] Построено: {currentlyPlacing.buildingName} на участке {slot.name}");
EventBus.Publish(new BuildingPlacedEvent { BuildingName = currentlyPlacing.buildingName });
currentlyPlacing = null;
lastHighlightedSlot = null;
@@ -20,26 +20,24 @@ public class BuildingData : ScriptableObject
[Header("Стоимость")]
public ResourceCost[] cost;
// Проверка - хватает ли ресурсов? (только проверяет)
public bool CanAfford()
// Проверка - хватает ли ресурсов? (только проверяет)
// Ресурсы -- модуль Экономики, поэтому только через контракт IEconomy
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;
return GameServices.TryGet(out IEconomy economy) && economy.CanAfford(ToAmounts(cost));
}
// Списывает стоимость постройки. Вызывать только после CanAfford() == true
public void PayCost()
{
foreach (var c in cost)
{
ResourceManager.Instance.TrySpend(c.type, c.amount);
}
if (GameServices.TryGet(out IEconomy economy)) economy.TrySpend(ToAmounts(cost));
}
public static ResourceAmount[] ToAmounts(ResourceCost[] costs)
{
var result = new ResourceAmount[costs?.Length ?? 0];
for (int i = 0; i < result.Length; i++) result[i] = new ResourceAmount(costs[i].type, costs[i].amount);
return result;
}
// Текст стоимости для UI, например "50 Wood, 20 Stone"
@@ -0,0 +1,182 @@
using System;
using System.Collections.Generic;
using UnityEngine;
// МОДУЛЬ ПОСЕЛЕНИЯ: точка входа. Реализует ISettlement и сам сохраняет город (секция "settlement").
// Документ: Docs/Modules/Settlement.md
//
// Город живёт на сцене MVP1, а сохраняться игра может и на карте мира. Поэтому модуль держит
// последнее известное состояние города в памяти: на карте мира сохраняется оно, а не пустая сцена.
public static class SettlementModule
{
[Serializable]
public class PlacedBuildingSaveEntry
{
public string buildingName; // совпадает с BuildingData.buildingName
public float posX;
public float posY;
public int level;
}
// Имена полей совпадают со старым save.json -- старые сохранения читаются без переделки
[Serializable]
public class SettlementSaveData
{
public int townHallLevel = 1;
public List<PlacedBuildingSaveEntry> placedBuildings = new List<PlacedBuildingSaveEntry>();
}
static SettlementSaveData cached; // последнее состояние города (из сцены или из файла)
static BuildManager appliedToBuildManager; // BuildManager сцены, для которой город уже восстановлен
static readonly Service service = new Service();
[RuntimeInitializeOnLoadMethod(RuntimeInitializeLoadType.AfterAssembliesLoaded)]
static void Init()
{
cached = null;
appliedToBuildManager = null;
GameServices.Register<ISettlement>(service);
SaveRegistry.Register(service);
}
static bool CityLoaded => BuildManager.Instance != null;
// ---------------- Сцена города <-> данные ----------------
static SettlementSaveData CaptureScene()
{
var data = new SettlementSaveData();
if (TownHall.Instance != null) data.townHallLevel = TownHall.Instance.currentLevel;
foreach (var placed in UnityEngine.Object.FindObjectsByType<PlacedBuildingId>(FindObjectsSortMode.None))
{
if (string.IsNullOrEmpty(placed.buildingName)) continue; // уже убрано, Destroy сработает в конце кадра
var leveled = placed.GetComponent<ILeveledBuilding>();
data.placedBuildings.Add(new PlacedBuildingSaveEntry
{
buildingName = placed.buildingName,
posX = placed.transform.position.x,
posY = placed.transform.position.y,
level = leveled != null ? leveled.Level : 1
});
}
return data;
}
// Восстановить город на сцене из последнего известного состояния.
// Без force -- один раз на каждую загрузку сцены города (BuildManager.Start и загрузка сохранения
// могут прийти в любом порядке, повторное восстановление не нужно).
public static void ApplyToScene(bool force = false)
{
if (cached == null || !CityLoaded) return;
if (!force && appliedToBuildManager == BuildManager.Instance) return;
appliedToBuildManager = BuildManager.Instance;
EventBus.Publish(new SettlementRestoringEvent()); // например, Казармы должны попасть в список стройки
if (TownHall.Instance != null) TownHall.Instance.currentLevel = cached.townHallLevel;
// Сначала убираем уже заспавненные здания, чтобы не задвоить постройки
ClearPlacedBuildings();
int restored = 0;
foreach (var entry in cached.placedBuildings)
{
BuildingData matchingData = FindBuildingDataByName(entry.buildingName);
if (matchingData == null)
{
Debug.LogWarning($"[ПОСЕЛЕНИЕ] Не найден BuildingData для '{entry.buildingName}', здание пропущено.");
continue;
}
Vector3 pos = new Vector3(entry.posX, entry.posY, 0f);
GameObject built = UnityEngine.Object.Instantiate(matchingData.prefab, pos, Quaternion.identity);
if (!built.activeSelf) built.SetActive(true); // шаблон казарм хранится выключенным
built.AddComponent<PlacedBuildingId>().buildingName = entry.buildingName;
var leveled = built.GetComponent<ILeveledBuilding>();
if (leveled != null) leveled.Level = entry.level;
// Помечаем слот занятым, чтобы поверх нельзя было поставить ещё одно здание
BuildSlot slot = BuildManager.Instance.FindSlotAtPosition(pos);
if (slot != null)
{
slot.Occupy(built);
built.AddComponent<BuildingSlotLink>().linkedSlot = slot;
}
restored++;
}
Debug.Log($"[ПОСЕЛЕНИЕ] Город восстановлен. Построек: {restored}");
}
// Убрать все постройки со сцены. Участок освобождаем сразу: Destroy срабатывает в конце кадра,
// и иначе старое здание освободило бы участок, на который уже встало новое
static void ClearPlacedBuildings()
{
foreach (var e in UnityEngine.Object.FindObjectsByType<PlacedBuildingId>(FindObjectsSortMode.None))
{
var link = e.GetComponent<BuildingSlotLink>();
if (link != null && link.linkedSlot != null)
{
link.linkedSlot.Free();
link.linkedSlot = null;
}
e.buildingName = null; // чтобы сохранение в этом же кадре уже не видело здание
UnityEngine.Object.Destroy(e.gameObject);
}
}
static BuildingData FindBuildingDataByName(string buildingName)
{
foreach (var b in BuildManager.Instance.allBuildings)
{
if (b != null && b.buildingName == buildingName) return b;
}
return null;
}
// ---------------- Контракты ----------------
class Service : ISettlement, ISaveParticipant
{
public int TownHallLevel =>
TownHall.Instance != null ? TownHall.Instance.currentLevel : (cached?.townHallLevel ?? 1);
public int BuildingCount =>
CityLoaded
? UnityEngine.Object.FindObjectsByType<PlacedBuildingId>(FindObjectsSortMode.None).Length
: (cached?.placedBuildings.Count ?? 0);
public bool IsCityLoaded => CityLoaded;
public string SaveKey => "settlement";
public string Capture()
{
if (CityLoaded) cached = CaptureScene();
// Город не открыт и ни разу не загружался -- не трогаем то, что уже лежит в файле
return cached != null ? JsonUtility.ToJson(cached) : null;
}
// Новая игра: Ратуша 1 уровня, построек нет -- и на сцене, и в памяти модуля
public void ResetState()
{
cached = new SettlementSaveData();
if (!CityLoaded) return;
ClearPlacedBuildings();
if (TownHall.Instance != null) TownHall.Instance.currentLevel = 1;
appliedToBuildManager = BuildManager.Instance; // город уже в состоянии новой игры
EventBus.Publish(new TownHallLevelChangedEvent { Level = 1 });
}
public void Restore(string json)
{
cached = JsonUtility.FromJson<SettlementSaveData>(json) ?? new SettlementSaveData();
if (cached.placedBuildings == null) cached.placedBuildings = new List<PlacedBuildingSaveEntry>();
ApplyToScene(force: true);
}
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 146dd14fec3d97249bc58c96f748d78b
@@ -1,5 +1,6 @@
using UnityEngine;
// МОДУЛЬ ПОСЕЛЕНИЯ. Документ: Docs/Modules/Settlement.md
// Вешается на объект Ратуши, который уже стоит на карте.
// Хранит текущий уровень -- от него зависит, какие здания разблокированы в BuildManager.
@@ -46,14 +47,7 @@ public class TownHall : MonoBehaviour
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;
return GameServices.TryGet(out IEconomy economy) && economy.CanAfford(BuildingData.ToAmounts(GetUpgradeCost()));
}
public void Upgrade()
@@ -64,12 +58,10 @@ public class TownHall : MonoBehaviour
return;
}
foreach (var c in GetUpgradeCost())
{
ResourceManager.Instance.TrySpend(c.type, c.amount);
}
GameServices.Get<IEconomy>().TrySpend(BuildingData.ToAmounts(GetUpgradeCost()));
currentLevel++;
EventBus.Publish(new TownHallLevelChangedEvent { Level = currentLevel });
Debug.Log($"[РАТУША] Улучшена до уровня {currentLevel}!");
}
void OnMouseDown()
+8
View File
@@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: 7bebbb8fb5871d042952add239421e77
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,29 @@
{
"name": "Bogatyri.Tests",
"rootNamespace": "",
"references": [
"UnityEngine.TestRunner",
"UnityEditor.TestRunner",
"Bogatyri.Core",
"Bogatyri.Army",
"Bogatyri.Battle",
"Bogatyri.World.Simulation",
"Bogatyri.Chat",
"Bogatyri.Circle"
],
"includePlatforms": [
"Editor"
],
"excludePlatforms": [],
"allowUnsafeCode": false,
"overrideReferences": true,
"precompiledReferences": [
"nunit.framework.dll"
],
"autoReferenced": false,
"defineConstraints": [
"UNITY_INCLUDE_TESTS"
],
"versionDefines": [],
"noEngineReferences": false
}

Some files were not shown because too many files have changed in this diff Show More