Compare commits
8
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f10fb3b3aa | ||
|
|
d1c8215ed8 | ||
|
|
d570fddbc8 | ||
|
|
15b06b8f42 | ||
|
|
57fc7e0006 | ||
|
|
964cb23d9d | ||
|
|
c86f43a806 | ||
|
|
26c0a20520 |
@@ -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), // без панели: закрывает всё
|
||||
};
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 972f2f5a1fa0c38439b03355997f98e9
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
fileFormatVersion: 2
|
||||
guid: bb59bee2c4134da4b8a13896c9b2e177
|
||||
folderAsset: yes
|
||||
DefaultImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 2afadf4e4024d5c48a2c26eddb75cbb1
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
// =====================================================================
|
||||
|
||||
@@ -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];
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -0,0 +1,8 @@
|
||||
fileFormatVersion: 2
|
||||
guid: ad9d63e3dce07ca4ab1ae01670f1046f
|
||||
folderAsset: yes
|
||||
DefaultImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,8 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 2e9b04cfaf4d8d54abf9b9656431a844
|
||||
folderAsset: yes
|
||||
DefaultImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -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
|
||||
@@ -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:
|
||||
@@ -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:
|
||||
@@ -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:
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
@@ -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:
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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:
|
||||
+5
-1
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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).
|
||||
// При старте сцены пытается загрузить сохранение, если оно есть.
|
||||
@@ -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("[СОХРАНЕНИЕ] Файл сохранения удалён.");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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:
|
||||
+8
@@ -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;
|
||||
+12
-14
@@ -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
|
||||
+4
-12
@@ -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()
|
||||
@@ -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
Reference in New Issue
Block a user