Реализация кнопки бой, создана базовая локация мира, отправление армии на соседей , нападение на босса, система нападения на нескольких соседей, разделение армии, гибель части армии, система боя по количеству человеку в армии

This commit is contained in:
Alexander
2026-09-25 19:59:29 +02:00
parent c2124e621c
commit 26c0a20520
33 changed files with 7760 additions and 797 deletions
+1 -1
View File
@@ -67,5 +67,5 @@
"*.sln": "*.csproj",
"*.slnx": "*.csproj"
},
"dotnet.defaultSolution": "MVP1.slnx"
"dotnet.defaultSolution": "Game Project.slnx"
}
+281
View File
@@ -0,0 +1,281 @@
using System.Collections.Generic;
using System.IO;
using TMPro;
using UnityEditor;
using UnityEditor.SceneManagement;
using UnityEngine;
using UnityEngine.EventSystems;
using UnityEngine.UI;
// Tools -> Build World Map Scene
// 1. Создаёт сцену Assets/Scenes/WorldMap.unity: камера, EventSystem, UI карты, WorldMapController.
// 2. Ставит MVP1 и WorldMap в Build Settings (иначе SceneManager.LoadScene их не найдёт).
// 3. Проверяет, что на кнопке Nav_Battle в городе висит OpenBattleButton.
// Можно запускать повторно -- сцена карты пересоздаётся заново.
public static class WorldMapSceneBuilder
{
const string WorldMapScenePath = "Assets/Scenes/WorldMap.unity";
const string CityScenePath = "Assets/Scenes/MVP1.unity";
[MenuItem("Tools/Build World Map Scene")]
public static void Build()
{
if (!EditorSceneManager.SaveCurrentModifiedScenesIfUserWantsTo()) return;
CreateWorldMapScene();
UpdateBuildSettings();
HookUpCityBattleButton();
Debug.Log("[КАРТА] Сцена карты мира создана, кнопка БОЙ в городе подключена. Запускай MVP1 и жми БОЙ.");
}
// ---------------- СЦЕНА КАРТЫ ----------------
static void CreateWorldMapScene()
{
var scene = EditorSceneManager.NewScene(NewSceneSetup.EmptyScene, NewSceneMode.Single);
var camGO = new GameObject("Main Camera", typeof(Camera), typeof(MapCameraController));
camGO.tag = "MainCamera";
camGO.transform.position = new Vector3(0, 0, -10);
Camera cam = camGO.GetComponent<Camera>();
cam.orthographic = true;
cam.orthographicSize = 8f;
cam.clearFlags = CameraClearFlags.SolidColor;
cam.backgroundColor = new Color(0.12f, 0.16f, 0.12f);
new GameObject("EventSystem", typeof(EventSystem), typeof(StandaloneInputModule));
var canvasGO = new GameObject("WorldMap_Canvas", typeof(Canvas), typeof(CanvasScaler), typeof(GraphicRaycaster));
canvasGO.GetComponent<Canvas>().renderMode = RenderMode.ScreenSpaceOverlay;
CanvasScaler scaler = canvasGO.GetComponent<CanvasScaler>();
scaler.uiScaleMode = CanvasScaler.ScaleMode.ScaleWithScreenSize;
scaler.referenceResolution = new Vector2(1920, 1080);
scaler.matchWidthOrHeight = 0.5f;
RectTransform canvasRect = canvasGO.GetComponent<RectTransform>();
var controller = new GameObject("WorldMapController").AddComponent<WorldMapController>();
controller.mapCamera = camGO.GetComponent<MapCameraController>();
// ---- Верхняя панель: назад | войско | мой город ----
RectTransform topBar = CreatePanel(canvasRect, "TopBar", new Color(0.08f, 0.08f, 0.1f, 0.85f));
AnchorStretch(topBar, top: true, height: 110);
controller.soldiersText = CreateText(topBar, "SoldiersText", "Войска дома: 30", 40);
RectTransform back = CreateButton(topBar, "BackButton", "НАЗАД", new Color(0.3f, 0.3f, 0.33f), out _);
AnchorSide(back, left: true, new Vector2(220, 80), 20);
controller.backButton = back.GetComponent<Button>();
RectTransform myCity = CreateButton(topBar, "MyCityButton", "МОЙ ГОРОД", new Color(0.25f, 0.45f, 0.8f), out _);
AnchorSide(myCity, left: false, new Vector2(280, 80), 20);
controller.myCityButton = myCity.GetComponent<Button>();
// ---- Нижняя панель: подсказка ----
RectTransform bottomBar = CreatePanel(canvasRect, "BottomBar", new Color(0.08f, 0.08f, 0.1f, 0.85f));
AnchorStretch(bottomBar, top: false, height: 100);
controller.hintText = CreateText(bottomBar, "Hint", "", 32);
// ---- Окно атаки ----
RectTransform attack = CreateCenterPanel(canvasRect, "AttackPanel", new Vector2(900, 640));
controller.attackPanel = attack.gameObject;
RectTransform attackTitle = CreateTextRect(attack, "Title", "Замок", 56);
AnchorTop(attackTitle, 110, 20);
controller.attackTitleText = attackTitle.GetComponent<TMP_Text>();
RectTransform attackInfo = CreateTextRect(attack, "Info", "", 32);
AnchorTop(attackInfo, 170, 130);
controller.attackInfoText = attackInfo.GetComponent<TMP_Text>();
RectTransform sendCount = CreateTextRect(attack, "SoldiersToSend", "Отправить: 30 из 30", 36);
AnchorTop(sendCount, 60, 310);
controller.soldiersToSendText = sendCount.GetComponent<TMP_Text>();
GameObject sliderGO = DefaultControls.CreateSlider(new DefaultControls.Resources());
sliderGO.name = "SoldiersSlider";
RectTransform sliderRect = sliderGO.GetComponent<RectTransform>();
sliderRect.SetParent(attack, false);
AnchorTop(sliderRect, 50, 390);
sliderRect.offsetMin = new Vector2(80, sliderRect.offsetMin.y);
sliderRect.offsetMax = new Vector2(-80, sliderRect.offsetMax.y);
StyleSlider(sliderGO);
controller.soldiersSlider = sliderGO.GetComponent<Slider>();
RectTransform send = CreateButton(attack, "SendButton", "В ПОХОД", new Color(0.75f, 0.25f, 0.15f), out _);
AnchorBottom(send, new Vector2(320, 100), new Vector2(170, 30));
controller.sendButton = send.GetComponent<Button>();
RectTransform cancel = CreateButton(attack, "CancelButton", "ОТМЕНА", new Color(0.3f, 0.3f, 0.33f), out _);
AnchorBottom(cancel, new Vector2(320, 100), new Vector2(-170, 30));
controller.cancelButton = cancel.GetComponent<Button>();
attack.gameObject.SetActive(false);
// ---- Уведомление ----
RectTransform popup = CreateCenterPanel(canvasRect, "PopupPanel", new Vector2(860, 520));
controller.popupPanel = popup.gameObject;
RectTransform popupTitle = CreateTextRect(popup, "Title", "ПОБЕДА", 72);
AnchorTop(popupTitle, 120, 20);
controller.popupTitleText = popupTitle.GetComponent<TMP_Text>();
RectTransform popupBody = CreateTextRect(popup, "Body", "", 32);
popupBody.offsetMin = new Vector2(40, 140);
popupBody.offsetMax = new Vector2(-40, -140);
controller.popupBodyText = popupBody.GetComponent<TMP_Text>();
RectTransform ok = CreateButton(popup, "OkButton", "OK", new Color(0.2f, 0.5f, 0.25f), out _);
AnchorBottom(ok, new Vector2(300, 100), new Vector2(0, 30));
controller.popupOkButton = ok.GetComponent<Button>();
popup.gameObject.SetActive(false);
EditorSceneManager.SaveScene(scene, WorldMapScenePath);
}
// ---------------- BUILD SETTINGS ----------------
static void UpdateBuildSettings()
{
var scenes = new List<EditorBuildSettingsScene>
{
new EditorBuildSettingsScene(CityScenePath, true),
new EditorBuildSettingsScene(WorldMapScenePath, true)
};
// Остальные уже добавленные сцены сохраняем (кроме удалённых файлов, например старой Battle)
foreach (var existing in EditorBuildSettings.scenes)
{
if (existing.path == CityScenePath || existing.path == WorldMapScenePath) continue;
if (!File.Exists(existing.path)) continue;
scenes.Add(existing);
}
EditorBuildSettings.scenes = scenes.ToArray();
}
// ---------------- КНОПКА В ГОРОДЕ ----------------
static void HookUpCityBattleButton()
{
var city = EditorSceneManager.OpenScene(CityScenePath, OpenSceneMode.Single);
GameObject navBattle = GameObject.Find("Nav_Battle");
if (navBattle == null)
{
Debug.LogWarning("[КАРТА] В сцене MVP1 не найден объект Nav_Battle -- повесь OpenBattleButton на кнопку БОЙ вручную.");
return;
}
if (navBattle.GetComponent<OpenBattleButton>() == null)
{
Undo.AddComponent<OpenBattleButton>(navBattle); // RequireComponent сам добавит Button, если его нет
EditorSceneManager.MarkSceneDirty(city);
EditorSceneManager.SaveScene(city);
}
}
// ---------------- HELPERS ----------------
static RectTransform CreatePanel(RectTransform parent, string name, Color color)
{
var go = new GameObject(name, typeof(RectTransform), typeof(Image));
go.transform.SetParent(parent, false);
go.GetComponent<Image>().color = color;
return go.GetComponent<RectTransform>();
}
static RectTransform CreateCenterPanel(RectTransform parent, string name, Vector2 size)
{
RectTransform rt = CreatePanel(parent, name, new Color(0.05f, 0.05f, 0.07f, 0.94f));
rt.anchorMin = rt.anchorMax = new Vector2(0.5f, 0.5f);
rt.sizeDelta = size;
return rt;
}
static RectTransform CreateTextRect(RectTransform parent, string name, string content, int fontSize)
{
var go = new GameObject(name, typeof(RectTransform), typeof(TextMeshProUGUI));
go.transform.SetParent(parent, false);
var tmp = go.GetComponent<TextMeshProUGUI>();
tmp.text = content;
tmp.fontSize = fontSize;
tmp.color = Color.white;
tmp.alignment = TextAlignmentOptions.Center;
tmp.raycastTarget = false;
RectTransform rt = go.GetComponent<RectTransform>();
rt.anchorMin = Vector2.zero;
rt.anchorMax = Vector2.one;
rt.offsetMin = Vector2.zero;
rt.offsetMax = Vector2.zero;
return rt;
}
static TMP_Text CreateText(RectTransform parent, string name, string content, int fontSize)
{
return CreateTextRect(parent, name, content, fontSize).GetComponent<TMP_Text>();
}
static RectTransform CreateButton(RectTransform parent, string name, string label, Color color, out TMP_Text labelText)
{
var go = new GameObject(name, typeof(RectTransform), typeof(Image), typeof(Button));
go.transform.SetParent(parent, false);
go.GetComponent<Image>().color = color;
RectTransform rt = go.GetComponent<RectTransform>();
labelText = CreateText(rt, "Label", label, 36);
return rt;
}
static void StyleSlider(GameObject sliderGO)
{
Image[] images = sliderGO.GetComponentsInChildren<Image>();
foreach (var img in images)
{
if (img.name == "Background") img.color = new Color(0.25f, 0.25f, 0.28f);
else if (img.name == "Fill") img.color = new Color(0.3f, 0.55f, 1f);
else if (img.name == "Handle") img.color = Color.white;
}
// Ручка крупнее -- удобнее тянуть пальцем
Transform handle = sliderGO.transform.Find("Handle Slide Area/Handle");
if (handle != null) ((RectTransform)handle).sizeDelta = new Vector2(50, 0);
}
static void AnchorStretch(RectTransform rt, bool top, float height)
{
float y = top ? 1 : 0;
rt.anchorMin = new Vector2(0, y);
rt.anchorMax = new Vector2(1, y);
rt.pivot = new Vector2(0.5f, y);
rt.sizeDelta = new Vector2(0, height);
rt.anchoredPosition = Vector2.zero;
}
static void AnchorSide(RectTransform rt, bool left, Vector2 size, float offset)
{
float x = left ? 0 : 1;
rt.anchorMin = new Vector2(x, 0.5f);
rt.anchorMax = new Vector2(x, 0.5f);
rt.pivot = new Vector2(x, 0.5f);
rt.sizeDelta = size;
rt.anchoredPosition = new Vector2(left ? offset : -offset, 0);
}
// Растянуть по ширине родителя, прижать к верху с отступом
static void AnchorTop(RectTransform rt, float height, float topOffset)
{
rt.anchorMin = new Vector2(0, 1);
rt.anchorMax = new Vector2(1, 1);
rt.pivot = new Vector2(0.5f, 1);
rt.sizeDelta = new Vector2(0, height);
rt.anchoredPosition = new Vector2(0, -topOffset);
}
static void AnchorBottom(RectTransform rt, Vector2 size, Vector2 offset)
{
rt.anchorMin = rt.anchorMax = new Vector2(0.5f, 0);
rt.pivot = new Vector2(0.5f, 0);
rt.sizeDelta = size;
rt.anchoredPosition = offset;
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 5fea028791c631d4c88cccc887d23df9
+17 -3
View File
@@ -752,6 +752,7 @@ MonoBehaviour:
startGems: 0
startPower: 0
startPopulation: 0
startSoldiers: 30
--- !u!1 &209860288
GameObject:
m_ObjectHideFlags: 0
@@ -988,7 +989,7 @@ RectTransform:
m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0}
m_AnchorMin: {x: 0, y: 0}
m_AnchorMax: {x: 1, y: 1}
m_AnchoredPosition: {x: 2.7022705, y: 0}
m_AnchoredPosition: {x: 0, y: 0}
m_SizeDelta: {x: 0, y: 0}
m_Pivot: {x: 0.5, y: 0.5}
--- !u!114 &233142736
@@ -1599,6 +1600,7 @@ GameObject:
- component: {fileID: 285283565}
- component: {fileID: 285283564}
- component: {fileID: 285283566}
- component: {fileID: 285283567}
m_Layer: 0
m_Name: Nav_Battle
m_TagString: Untagged
@@ -1709,6 +1711,18 @@ MonoBehaviour:
m_OnClick:
m_PersistentCalls:
m_Calls: []
--- !u!114 &285283567
MonoBehaviour:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 285283562}
m_Enabled: 1
m_EditorHideFlags: 0
m_Script: {fileID: 11500000, guid: 302973c3424883944bfa58ddb070991e, type: 3}
m_Name:
m_EditorClassIdentifier: Assembly-CSharp::OpenBattleButton
--- !u!1 &298091771
GameObject:
m_ObjectHideFlags: 0
@@ -7287,8 +7301,8 @@ RectTransform:
m_Children:
- {fileID: 2137051347}
- {fileID: 1925548012}
- {fileID: 503920959}
- {fileID: 233142735}
- {fileID: 503920959}
m_Father: {fileID: 832195101}
m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0}
m_AnchorMin: {x: 0, y: 0}
@@ -8209,7 +8223,7 @@ MonoBehaviour:
m_Name:
m_EditorClassIdentifier: UnityEngine.UI::UnityEngine.UI.Image
m_Material: {fileID: 0}
m_Color: {r: 1, g: 1, b: 1, a: 0.392}
m_Color: {r: 1, g: 1, b: 1, a: 0.36078432}
m_RaycastTarget: 1
m_RaycastPadding: {x: 0, y: 0, z: 0, w: 0}
m_Maskable: 1
File diff suppressed because it is too large Load Diff
+7
View File
@@ -0,0 +1,7 @@
fileFormatVersion: 2
guid: baa48fafda29f4247b2910dc9622c97a
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:
+5 -1
View File
@@ -14,7 +14,8 @@ public enum ResourceType
Iron = 4, // металл
Gold = 5, // золото
Gems = 6, // кристаллы
Power = 7 // "сила" -- как в референсе HUD
Power = 7, // "сила" -- как в референсе HUD
Soldiers = 8 // солдаты ДОМА (ушедшие в поход сюда не входят)
}
public class ResourceManager : MonoBehaviour
@@ -34,6 +35,8 @@ public class ResourceManager : MonoBehaviour
public float startGems = 0f;
public float startPower = 0f;
public float startPopulation = 0f;
[Tooltip("Стартовая армия -- 30 солдат по концепции экономики")]
public float startSoldiers = 30f;
private Dictionary<ResourceType, float> resources = new Dictionary<ResourceType, float>();
@@ -56,6 +59,7 @@ public class ResourceManager : MonoBehaviour
resources[ResourceType.Gems] = startGems;
resources[ResourceType.Power] = startPower;
resources[ResourceType.Population] = startPopulation;
resources[ResourceType.Soldiers] = startSoldiers;
}
// Добавить ресурс (можно и отрицательное число -- для трат)
+65 -7
View File
@@ -1,7 +1,9 @@
using UnityEngine;
using System.Collections.Generic;
using System.IO;
using UnityEngine.SceneManagement;
using Bogatyri.World;
// Сохраняет и загружает: все ресурсы, уровень Ратуши, список всех построенных зданий
// (какой тип, где стоит, какого уровня).
//
@@ -30,6 +32,7 @@ public class SaveData
public List<ResourceSaveEntry> resources = new List<ResourceSaveEntry>();
public int townHallLevel = 1;
public List<PlacedBuildingSaveEntry> placedBuildings = new List<PlacedBuildingSaveEntry>();
public List<MarchData> marches = new List<MarchData>(); // войска в походе
}
public class SaveManager : MonoBehaviour
@@ -41,7 +44,11 @@ public class SaveManager : MonoBehaviour
public float autoSaveInterval = 30f;
private string SavePath => Path.Combine(Application.persistentDataPath, "save.json");
// Последнее сохранённое/загруженное состояние. Нужно, когда игрок НЕ в городе (например, на карте мира):
// Ратуши и построек на сцене нет, и их берём отсюда, а не затираем пустыми значениями.
private SaveData lastSaveData;
void Awake()
{
if (Instance != null && Instance != this)
@@ -51,8 +58,24 @@ public class SaveManager : MonoBehaviour
}
Instance = this;
DontDestroyOnLoad(gameObject);
SceneManager.sceneLoaded += OnSceneLoaded;
}
void OnDestroy()
{
if (Instance == this) SceneManager.sceneLoaded -= OnSceneLoaded;
}
// Первый вход в город загружает GameBootstrap. При возврате в город (например, с карты мира)
// GameBootstrap уничтожается вместе с дубликатом SaveManager и не срабатывает -- поэтому здесь.
// Ресурсы и походы при этом НЕ перечитываем: они и так живут в памяти между сценами,
// а перечитывание откатило бы всё, что изменилось после последнего сохранения.
void OnSceneLoaded(Scene scene, LoadSceneMode mode)
{
if (scene.name != SceneNavigator.CitySceneName) return;
if (lastSaveData != null) LoadCity(lastSaveData);
}
void Start()
{
InvokeRepeating(nameof(SaveGame), autoSaveInterval, autoSaveInterval);
@@ -89,14 +112,35 @@ public class SaveManager : MonoBehaviour
}
}
// Походы
if (MarchManager.Instance != null)
{
data.marches = MarchManager.Instance.GetMarchesForSave();
}
// Сцена города не загружена (игрок на карте мира) -- город берём из последнего сохранения
bool cityLoaded = BuildManager.Instance != null;
if (!cityLoaded)
{
if (lastSaveData == null)
{
Debug.LogWarning("[СОХРАНЕНИЕ] Город не загружен и прошлого сохранения нет -- пропускаем, чтобы не затереть постройки.");
return;
}
data.townHallLevel = lastSaveData.townHallLevel;
data.placedBuildings = lastSaveData.placedBuildings;
}
// Ратуша
if (TownHall.Instance != null)
if (cityLoaded && TownHall.Instance != null)
{
data.townHallLevel = TownHall.Instance.currentLevel;
}
// Все построенные здания на сцене (найдёт каждый объект с маркером PlacedBuildingId)
PlacedBuildingId[] allPlaced = Object.FindObjectsByType<PlacedBuildingId>(FindObjectsSortMode.None);
PlacedBuildingId[] allPlaced = cityLoaded
? Object.FindObjectsByType<PlacedBuildingId>(FindObjectsSortMode.None)
: new PlacedBuildingId[0];
foreach (var placed in allPlaced)
{
int level = 1;
@@ -114,6 +158,7 @@ public class SaveManager : MonoBehaviour
string json = JsonUtility.ToJson(data, true);
File.WriteAllText(SavePath, json);
lastSaveData = data;
Debug.Log($"[СОХРАНЕНИЕ] Игра сохранена: {SavePath}");
}
@@ -135,7 +180,8 @@ public class SaveManager : MonoBehaviour
string json = File.ReadAllText(SavePath);
SaveData data = JsonUtility.FromJson<SaveData>(json);
lastSaveData = data;
// Ресурсы
if (ResourceManager.Instance != null)
{
@@ -145,6 +191,18 @@ public class SaveManager : MonoBehaviour
}
}
// Походы (если игра была закрыта, MarchManager сам досчитает, чем они закончились)
if (MarchManager.Instance != null)
{
MarchManager.Instance.LoadMarches(data.marches);
}
LoadCity(data);
}
// Только то, что живёт на сцене города: Ратуша и постройки
void LoadCity(SaveData data)
{
// Ратуша
if (TownHall.Instance != null)
{
+8
View File
@@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: 8c845e88953ac6d4590ebb6c4b4b87dd
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,151 @@
using UnityEngine;
using UnityEngine.EventSystems;
// Камера карты мира: перетаскивание (палец/мышь), зум (два пальца/колёсико), границы карты.
// Отличает "тап" от "перетаскивания": тап -- это касание, при котором палец почти не сдвинулся.
[DefaultExecutionOrder(-10)] // считать тап раньше, чем его прочитает WorldMapController
[RequireComponent(typeof(Camera))]
public class MapCameraController : MonoBehaviour
{
[Header("Зум")]
public float minOrthoSize = 4f;
public float maxOrthoSize = 16f;
public float mouseZoomSpeed = 2f;
[Header("Тап")]
[Tooltip("Если палец сдвинулся меньше, чем на столько пикселей, -- это нажатие, а не перетаскивание")]
public float tapMaxMovePixels = 15f;
// Границы карты в клетках Unity (задаёт WorldMapController)
[HideInInspector] public Vector2 mapHalfSize = new Vector2(20f, 14f);
private Camera cam;
private Vector3 lastPointerPosition;
private Vector3 pressPosition;
private bool isPressed;
private bool isDragging;
private bool pressStartedOverUI;
private bool hasTap;
private Vector3 tapWorldPosition;
void Awake()
{
cam = GetComponent<Camera>();
}
// Было ли за этот кадр нажатие по карте (не по UI и не перетаскивание)
public bool TryGetTap(out Vector3 worldPosition)
{
worldPosition = tapWorldPosition;
return hasTap;
}
public void CenterOn(Vector3 worldPosition)
{
transform.position = new Vector3(worldPosition.x, worldPosition.y, transform.position.z);
ClampToMap();
}
void Update()
{
hasTap = false;
if (Input.touchCount >= 2)
{
HandlePinch();
isPressed = false; // после щипка не засчитываем отпускание как тап
return;
}
HandlePointer();
HandleMouseWheel();
ClampToMap();
}
void HandlePointer()
{
if (Input.GetMouseButtonDown(0))
{
isPressed = true;
isDragging = false;
pressStartedOverUI = IsPointerOverUI();
pressPosition = Input.mousePosition;
lastPointerPosition = Input.mousePosition;
}
if (!isPressed || pressStartedOverUI)
{
if (Input.GetMouseButtonUp(0)) isPressed = false;
return;
}
if (Input.GetMouseButton(0))
{
if (!isDragging && (Input.mousePosition - pressPosition).magnitude > tapMaxMovePixels)
isDragging = true;
if (isDragging)
{
// Сдвигаем камеру ровно на столько, на сколько сдвинулся палец в мире -- карта "прилипает" к пальцу
Vector3 previous = cam.ScreenToWorldPoint(lastPointerPosition);
Vector3 current = cam.ScreenToWorldPoint(Input.mousePosition);
transform.position += previous - current;
}
lastPointerPosition = Input.mousePosition;
}
if (Input.GetMouseButtonUp(0))
{
if (!isDragging)
{
hasTap = true;
tapWorldPosition = cam.ScreenToWorldPoint(Input.mousePosition);
tapWorldPosition.z = 0f;
}
isPressed = false;
isDragging = false;
}
}
void HandlePinch()
{
Touch a = Input.GetTouch(0);
Touch b = Input.GetTouch(1);
float previousDistance = ((a.position - a.deltaPosition) - (b.position - b.deltaPosition)).magnitude;
float currentDistance = (a.position - b.position).magnitude;
if (currentDistance <= 0.01f) return;
cam.orthographicSize = Mathf.Clamp(cam.orthographicSize * previousDistance / currentDistance, minOrthoSize, maxOrthoSize);
ClampToMap();
}
void HandleMouseWheel()
{
float scroll = Input.GetAxis("Mouse ScrollWheel");
if (scroll == 0f || IsPointerOverUI()) return;
cam.orthographicSize = Mathf.Clamp(cam.orthographicSize - scroll * mouseZoomSpeed * 5f, minOrthoSize, maxOrthoSize);
}
// Камера не уезжает за край карты (если карта меньше экрана -- центрируем)
void ClampToMap()
{
float halfViewHeight = cam.orthographicSize;
float halfViewWidth = halfViewHeight * cam.aspect;
float maxX = Mathf.Max(0f, mapHalfSize.x - halfViewWidth);
float maxY = Mathf.Max(0f, mapHalfSize.y - halfViewHeight);
Vector3 p = transform.position;
p.x = Mathf.Clamp(p.x, -maxX, maxX);
p.y = Mathf.Clamp(p.y, -maxY, maxY);
transform.position = p;
}
static bool IsPointerOverUI()
{
if (EventSystem.current == null) return false;
if (Input.touchCount > 0) return EventSystem.current.IsPointerOverGameObject(Input.GetTouch(0).fingerId);
return EventSystem.current.IsPointerOverGameObject();
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 4f1ec5f03725c01469789d41afd95f34
+151
View File
@@ -0,0 +1,151 @@
using System;
using System.Collections.Generic;
using Bogatyri.World;
using UnityEngine;
// Хранит мир и походы игрока. Живёт между сценами (создаётся сам при запуске игры),
// поэтому войско идёт и возвращается, даже пока игрок в городе.
//
// Точка расширения под мультиплеер: сейчас WorldSimulation крутится прямо здесь.
// Позже StartMarch будет отправлять запрос на сервер, а походы и их итоги -- приходить с сервера.
// WorldMapController при этом менять не придётся: он работает через события этого класса.
public class MarchManager : MonoBehaviour
{
public const string LocalPlayerId = "local_player";
public static MarchManager Instance { get; private set; }
public WorldSimulation World { get; private set; }
public event Action<MarchData> OnMarchStarted;
public event Action<MarchData> OnBattleResolved;
public event Action<MarchData> OnMarchReturned;
// Время мира. В мультиплеере -- время сервера, а не телефона
public static long NowMs => DateTimeOffset.UtcNow.ToUnixTimeMilliseconds();
private readonly List<WorldEvent> events = new List<WorldEvent>();
[RuntimeInitializeOnLoadMethod(RuntimeInitializeLoadType.BeforeSceneLoad)]
static void CreateInstance()
{
var go = new GameObject("MarchManager");
DontDestroyOnLoad(go);
go.AddComponent<MarchManager>();
}
void Awake()
{
Instance = this;
World = new WorldSimulation(WorldMapLayout.CreateDefault());
}
void Update()
{
// Без ResourceManager некуда зачислять вернувшихся солдат -- ждём, пока загрузится город
if (ResourceManager.Instance == null) return;
events.Clear();
World.Advance(NowMs, events);
foreach (var e in events)
{
if (e.Type == WorldEventType.BattleResolved) HandleBattleResolved(e.March);
else if (e.Type == WorldEventType.MarchReturned) HandleMarchReturned(e.March);
}
if (events.Count > 0 && SaveManager.Instance != null) SaveManager.Instance.SaveGame();
}
public bool TryStartMarch(string targetId, int soldiers, out string error)
{
ResourceManager rm = ResourceManager.Instance;
if (rm == null)
{
error = "Нет данных о войске -- запусти игру из города";
return false;
}
if (!rm.HasEnough(ResourceType.Soldiers, soldiers))
{
error = "Не хватает солдат";
return false;
}
MarchData march = World.StartMarch(LocalPlayerId, WorldMapLayout.PlayerCityId, targetId, soldiers, NowMs, out error);
if (march == null) return false;
// Ушедшие солдаты недоступны дома, пока не вернутся
rm.TrySpend(ResourceType.Soldiers, soldiers);
if (SaveManager.Instance != null) SaveManager.Instance.SaveGame();
Debug.Log($"[ПОХОД] {soldiers} солдат выступили на {World.GetNode(targetId).displayName}");
OnMarchStarted?.Invoke(march);
return true;
}
void HandleBattleResolved(MarchData march)
{
MapNode target = World.GetNode(march.targetNodeId);
Debug.Log($"[ПОХОД] Бой у {target.displayName}: {(march.victory ? "ПОБЕДА" : "поражение")}. " +
$"{march.soldiers} против {target.garrison}, возвращаются {march.survivors}.");
OnBattleResolved?.Invoke(march);
}
// Солдаты и добыча зачисляются, только когда войско дошло до дома
void HandleMarchReturned(MarchData march)
{
ResourceManager rm = ResourceManager.Instance;
rm.AddResource(ResourceType.Soldiers, march.survivors);
foreach (var reward in march.loot)
{
if (Enum.TryParse(reward.resource, out ResourceType type))
rm.AddResource(type, reward.amount);
else
Debug.LogWarning($"[ПОХОД] Неизвестный ресурс награды: {reward.resource}");
}
Debug.Log($"[ПОХОД] Войско вернулось: {march.survivors} солдат, добыча: {FormatLoot(march.loot)}");
OnMarchReturned?.Invoke(march);
}
// ---------------- СОХРАНЕНИЕ ----------------
public List<MarchData> GetMarchesForSave()
{
return new List<MarchData>(World.Marches);
}
public void LoadMarches(List<MarchData> saved)
{
World.LoadMarches(saved);
}
// ---------------- ТЕКСТ ДЛЯ UI ----------------
public static string FormatLoot(List<RewardEntry> loot)
{
if (loot == null || loot.Count == 0) return "нет";
var parts = new List<string>();
foreach (var r in loot)
{
parts.Add($"+{r.amount} {GetResourceName(r.resource)}");
}
return string.Join(", ", parts);
}
static string GetResourceName(string resource)
{
switch (resource)
{
case "Food": return "еды";
case "Wood": return "дерева";
case "Stone": return "камня";
case "Iron": return "металла";
case "Gold": return "золота";
case "Gems": return "кристаллов";
default: return resource;
}
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 915d8ce4678014a4580b9c54e79ffb52
+127
View File
@@ -0,0 +1,127 @@
using Bogatyri.World;
using TMPro;
using UnityEngine;
// Войско в походе на карте: группа маленьких квадратиков + подпись с числом солдат и таймером.
// Сам ничего не решает -- позицию и состояние берёт из WorldSimulation.
public class MarchView : MonoBehaviour
{
const int MaxSquares = 15; // больше квадратиков не рисуем, чтобы не загромождать карту
const int SquaresPerRow = 5;
const float SquareSize = 0.18f;
const float SquareSpacing = 0.26f;
static readonly Color OutboundColor = new Color(0.3f, 0.55f, 1f);
static readonly Color ReturningColor = new Color(0.55f, 0.8f, 1f);
public int MarchId { get; private set; }
private SpriteRenderer[] squares;
private TextMeshPro label;
private GameObject routeLine;
private MarchState shownState;
private int shownSoldiers = -1;
public static MarchView Create(Transform parent, MarchData march)
{
var go = new GameObject($"March_{march.id}");
go.transform.SetParent(parent, false);
var view = go.AddComponent<MarchView>();
view.MarchId = march.id;
view.CreateLabel();
return view;
}
void CreateLabel()
{
var go = new GameObject("Label");
go.transform.SetParent(transform, false);
go.transform.localPosition = new Vector3(0, 0.75f, 0);
label = go.AddComponent<TextMeshPro>();
label.fontSize = 3f;
label.alignment = TextAlignmentOptions.Center;
label.rectTransform.sizeDelta = new Vector2(4f, 0.8f);
label.sortingOrder = 31;
}
// Перестраиваем квадратики, только когда поменялось число солдат (после боя)
void RebuildSquares(int soldiers, Color color)
{
if (squares != null)
{
foreach (var sq in squares) Destroy(sq.gameObject);
}
int count = Mathf.Clamp(soldiers, 1, MaxSquares);
int rows = Mathf.CeilToInt(count / (float)SquaresPerRow);
squares = new SpriteRenderer[count];
for (int i = 0; i < count; i++)
{
int col = i % SquaresPerRow;
int row = i / SquaresPerRow;
int inThisRow = Mathf.Min(SquaresPerRow, count - row * SquaresPerRow);
var go = new GameObject("Soldier");
go.transform.SetParent(transform, false);
go.transform.localPosition = new Vector3(
(col - (inThisRow - 1) / 2f) * SquareSpacing,
((rows - 1) / 2f - row) * SquareSpacing,
0f);
go.transform.localScale = Vector3.one * SquareSize;
var sr = go.AddComponent<SpriteRenderer>();
sr.sprite = PrimitiveSprites.Square;
sr.color = color;
sr.sortingOrder = 30;
squares[i] = sr;
}
}
// Линия от города до цели -- видно, куда идёт войско. Лежит отдельно, чтобы не ездить вместе с отрядом.
void CreateRouteLine(WorldSimulation world, MarchData march)
{
Vector3 home = WorldMapController.ToWorld(world.GetNode(march.fromNodeId));
Vector3 target = WorldMapController.ToWorld(world.GetNode(march.targetNodeId));
Vector3 delta = target - home;
routeLine = new GameObject($"Route_{march.id}");
routeLine.transform.SetParent(transform.parent, false);
routeLine.transform.position = (home + target) / 2f;
routeLine.transform.localScale = new Vector3(delta.magnitude, 0.08f, 1f);
routeLine.transform.rotation = Quaternion.Euler(0, 0, Mathf.Atan2(delta.y, delta.x) * Mathf.Rad2Deg);
var sr = routeLine.AddComponent<SpriteRenderer>();
sr.sprite = PrimitiveSprites.Square;
sr.color = new Color(1f, 1f, 1f, 0.35f);
sr.sortingOrder = -5;
}
void OnDestroy()
{
if (routeLine != null) Destroy(routeLine);
}
public void Refresh(WorldSimulation world, MarchData march, long nowMs)
{
if (routeLine == null) CreateRouteLine(world, march);
world.GetMarchSegment(march, nowMs, out MapNode from, out MapNode to, out double progress);
transform.position = Vector3.Lerp(WorldMapController.ToWorld(from), WorldMapController.ToWorld(to), (float)progress);
bool returning = march.state == MarchState.Returning;
int soldiers = returning ? march.survivors : march.soldiers;
if (soldiers != shownSoldiers || march.state != shownState)
{
shownSoldiers = soldiers;
shownState = march.state;
RebuildSquares(soldiers, returning ? ReturningColor : OutboundColor);
}
long endMs = returning ? march.returnMs : march.arriveMs;
int secondsLeft = Mathf.Max(0, Mathf.CeilToInt((endMs - nowMs) / 1000f));
string direction = returning ? "домой" : "в бой";
label.text = $"{soldiers} сол. {direction} {secondsLeft / 60}:{secondsLeft % 60:00}";
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 84a2f93c2f11a8144a9abfe7c2082c23
@@ -0,0 +1,12 @@
using UnityEngine;
using UnityEngine.UI;
// Вешается на кнопку "БОЙ" в нижней панели города (Nav_Battle) -- открывает карту мира.
[RequireComponent(typeof(Button))]
public class OpenBattleButton : MonoBehaviour
{
void Awake()
{
GetComponent<Button>().onClick.AddListener(SceneNavigator.OpenWorldMap);
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 302973c3424883944bfa58ddb070991e
@@ -0,0 +1,60 @@
using UnityEngine;
// Спрайты-заглушки (квадрат и круг размером 1x1 клетку), генерируются в коде.
// Когда появится арт -- SquadView просто получит настоящие спрайты.
public static class PrimitiveSprites
{
private static Sprite square;
private static Sprite circle;
public static Sprite Square
{
get
{
if (square == null) square = Create(false);
return square;
}
}
public static Sprite Circle
{
get
{
if (circle == null) circle = Create(true);
return circle;
}
}
static Sprite Create(bool round)
{
const int size = 64;
var tex = new Texture2D(size, size, TextureFormat.RGBA32, false)
{
filterMode = FilterMode.Bilinear,
wrapMode = TextureWrapMode.Clamp
};
var pixels = new Color32[size * size];
float center = (size - 1) / 2f;
for (int y = 0; y < size; y++)
{
for (int x = 0; x < size; x++)
{
byte alpha = 255;
if (round)
{
float dx = x - center;
float dy = y - center;
float distance = Mathf.Sqrt(dx * dx + dy * dy);
// мягкий край в 1 пиксель, чтобы круг не был "лесенкой"
alpha = (byte)(Mathf.Clamp01(size / 2f - distance) * 255);
}
pixels[y * size + x] = new Color32(255, 255, 255, alpha);
}
}
tex.SetPixels32(pixels);
tex.Apply();
return Sprite.Create(tex, new Rect(0, 0, size, size), new Vector2(0.5f, 0.5f), size);
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 869ffbb216fe82d4ea1c80c82b7ced3b
+31
View File
@@ -0,0 +1,31 @@
using UnityEngine;
using UnityEngine.SceneManagement;
// Переходы город <-> карта мира
public static class SceneNavigator
{
public const string CitySceneName = "MVP1";
public const string WorldMapSceneName = "WorldMap";
public static void OpenWorldMap()
{
if (!Application.CanStreamedLevelBeLoaded(WorldMapSceneName))
{
Debug.LogError($"[КАРТА] Сцена '{WorldMapSceneName}' не добавлена в Build Settings. " +
"Выйди из Play Mode и выполни Tools -> Build World Map Scene.");
return;
}
// Сохраняемся до ухода из города: постройки на карте мира не существуют,
// и SaveManager берёт их из последнего сохранения
if (SaveManager.Instance != null) SaveManager.Instance.SaveGame();
SceneManager.LoadScene(WorldMapSceneName);
}
public static void ReturnToCity()
{
if (SaveManager.Instance != null) SaveManager.Instance.SaveGame();
SceneManager.LoadScene(CitySceneName);
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 584ab12b6fc23914580631f9f37bf653
+8
View File
@@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: 321752313c0422e40bcda7772e874509
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,14 @@
{
"name": "Bogatyri.World.Simulation",
"rootNamespace": "Bogatyri.World",
"references": [],
"includePlatforms": [],
"excludePlatforms": [],
"allowUnsafeCode": false,
"overrideReferences": false,
"precompiledReferences": [],
"autoReferenced": true,
"defineConstraints": [],
"versionDefines": [],
"noEngineReferences": true
}
@@ -0,0 +1,7 @@
fileFormatVersion: 2
guid: c93980709f2c5264aa4f49a1cf86d9e1
AssemblyDefinitionImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,223 @@
using System;
using System.Collections.Generic;
namespace Bogatyri.World
{
public enum MapNodeType
{
PlayerCity, // город игрока
NeighborCity, // соседний город -- другой игрок
BossCastle // Большой замок с боссом в центре карты
}
[Serializable]
public class RewardEntry
{
public string resource; // имя из ResourceType (Gold, Food, ...) -- логика карты про экономику не знает
public int amount;
}
// Точка на карте мира. Координаты -- целые "милли-единицы" (1000 = 1 клетка Unity),
// чтобы расчёты были одинаковыми на клиенте и на сервере.
[Serializable]
public class MapNode
{
public string id;
public string ownerId; // id игрока-владельца (у замка -- пусто)
public string displayName;
public MapNodeType type;
public int x;
public int y;
public bool canBeAttacked;
public int garrison; // солдат в обороне
public List<RewardEntry> rewards = new List<RewardEntry>();
}
// Раскладка карты: замок с боссом в центре, города игроков разбросаны вокруг хаотично.
// Раскладка строится из seed, поэтому одинакова при каждом запуске и совпадёт с серверной.
// В мультиплеере карту и реальных соседей будет присылать сервер.
public static class WorldMapLayout
{
public const string PlayerCityId = "player_city";
public const string BossCastleId = "boss_castle";
public const int DefaultSeed = 20260925;
public const int NeighborCount = 20;
// Границы карты (от центра)
public const int HalfWidth = 20000;
public const int HalfHeight = 14000;
const int EdgeMargin = 1500; // города не липнут к краю карты
const int CastleClearRadius = 4500; // вокруг замка свободная зона
const int MinCitySpacing = 3600; // минимальное расстояние между городами
const int MaxPlacementAttempts = 1000;
// Пока соседи -- заглушки с именами. Потом это будут ники реальных игроков.
static readonly string[] NeighborNames =
{
"Ратибор", "Светозар", "Добромир", "Велимир", "Ярополк",
"Всеслав", "Мстислав", "Остромир", "Богдан", "Любомир",
"Твердислав", "Радим", "Гостомысл", "Вышеслав", "Путята",
"Ставр", "Чурила", "Микула", "Василиса", "Забава"
};
public static List<MapNode> CreateDefault()
{
return Generate(DefaultSeed, NeighborCount);
}
public static List<MapNode> Generate(int seed, int neighborCount)
{
var rng = new DeterministicRandom(seed);
var nodes = new List<MapNode>();
var castle = new MapNode
{
id = BossCastleId,
displayName = "Замок Змея Горыныча",
type = MapNodeType.BossCastle,
x = 0,
y = 0,
canBeAttacked = true,
garrison = 20 // требует балансировки
};
// Небольшие числа -- по концепции экономики
castle.rewards.Add(new RewardEntry { resource = "Gold", amount = 20 });
castle.rewards.Add(new RewardEntry { resource = "Food", amount = 40 });
castle.rewards.Add(new RewardEntry { resource = "Iron", amount = 10 });
nodes.Add(castle);
if (TryFindFreeSpot(rng, nodes, out int px, out int py))
{
nodes.Add(new MapNode
{
id = PlayerCityId,
ownerId = "local_player",
displayName = "Твой город",
type = MapNodeType.PlayerCity,
x = px,
y = py
});
}
for (int i = 0; i < neighborCount; i++)
{
if (!TryFindFreeSpot(rng, nodes, out int x, out int y)) break; // карта переполнена
int garrison = rng.Range(8, 41);
var city = new MapNode
{
id = $"city_{i + 1}",
ownerId = $"npc_{i + 1}",
displayName = NeighborNames[i % NeighborNames.Length],
type = MapNodeType.NeighborCity,
x = x,
y = y,
canBeAttacked = true,
garrison = garrison
};
// Добыча растёт вместе с гарнизоном: сильный сосед -- богаче, но опаснее
city.rewards.Add(new RewardEntry { resource = "Gold", amount = 5 + garrison / 3 });
city.rewards.Add(new RewardEntry { resource = "Food", amount = 10 + garrison });
city.rewards.Add(new RewardEntry { resource = "Wood", amount = 10 + garrison / 2 });
if (garrison >= 20) city.rewards.Add(new RewardEntry { resource = "Iron", amount = garrison / 4 });
nodes.Add(city);
}
return nodes;
}
// Случайная точка, не слишком близко к замку и к другим городам
static bool TryFindFreeSpot(DeterministicRandom rng, List<MapNode> nodes, out int x, out int y)
{
for (int attempt = 0; attempt < MaxPlacementAttempts; attempt++)
{
x = rng.Range(-HalfWidth + EdgeMargin, HalfWidth - EdgeMargin + 1);
y = rng.Range(-HalfHeight + EdgeMargin, HalfHeight - EdgeMargin + 1);
bool free = true;
foreach (var n in nodes)
{
int minDistance = n.type == MapNodeType.BossCastle ? CastleClearRadius : MinCitySpacing;
if (DistanceSquared(n.x, n.y, x, y) < (long)minDistance * minDistance)
{
free = false;
break;
}
}
if (free) return true;
}
x = 0;
y = 0;
return false;
}
public static MapNode Find(List<MapNode> nodes, string id)
{
foreach (var n in nodes)
{
if (n.id == id) return n;
}
return null;
}
static long DistanceSquared(int x1, int y1, int x2, int y2)
{
long dx = x2 - x1;
long dy = y2 - y1;
return dx * dx + dy * dy;
}
// Целочисленный квадратный корень (метод Ньютона) -- одинаков на любой платформе
public static int Distance(MapNode a, MapNode b)
{
long value = DistanceSquared(a.x, a.y, b.x, b.y);
if (value <= 0) return 0;
long r = value;
long next = (r + 1) / 2;
while (next < r)
{
r = next;
next = (r + value / r) / 2;
}
return (int)r;
}
}
// Детерминированный генератор случайных чисел (xorshift32).
// System.Random не годится: его реализация может отличаться между платформами и версиями .NET,
// а карта у клиента и сервера должна получиться одинаковой.
public sealed class DeterministicRandom
{
private uint state;
public DeterministicRandom(int seed)
{
state = (uint)seed;
if (state == 0) state = 0x9E3779B9; // xorshift не работает с нулевым состоянием
}
public uint NextUInt()
{
uint x = state;
x ^= x << 13;
x ^= x >> 17;
x ^= x << 5;
state = x;
return x;
}
// Число в диапазоне [minInclusive, maxExclusive)
public int Range(int minInclusive, int maxExclusive)
{
uint span = (uint)(maxExclusive - minInclusive);
return minInclusive + (int)(NextUInt() % span);
}
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: a0cbb52ce8b77c943b4d47e3c8db405a
@@ -0,0 +1,225 @@
using System;
using System.Collections.Generic;
namespace Bogatyri.World
{
public enum MarchState
{
Outbound, // идут к цели
Returning, // бой прошёл, возвращаются домой
Finished // вернулись, солдаты и добыча зачислены
}
// Один поход. Всё время -- абсолютное (Unix-время в миллисекундах), а не "осталось N секунд":
// поход продолжается, пока игра закрыта, и одинаково понимается клиентом и сервером.
[Serializable]
public class MarchData
{
public int id;
public string ownerId;
public string fromNodeId;
public string targetNodeId;
public int soldiers; // сколько ушло
public int survivors; // сколько возвращается (известно после боя)
public bool victory;
public int defenders; // гарнизон цели в момент боя
public int defenderLosses; // сколько потерял защитник
public List<RewardEntry> loot = new List<RewardEntry>();
public long departMs;
public long arriveMs;
public long returnMs; // известно после боя
public MarchState state;
}
public struct BattleOutcome
{
public bool Victory;
public int AttackerLosses;
public int DefenderLosses;
public int Survivors;
}
// Правила боя. Пока самые простые: побеждает тот, у кого больше солдат.
// Потом сюда добавятся типы войск, герой, технологии и т.д. -- остальной код менять не придётся.
public static class BattleResolver
{
public static BattleOutcome Resolve(int attackers, int 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
};
}
}
public static class MarchRules
{
// Скорость войска: 400 милли-единиц (0,4 клетки) в секунду.
// Ближний сосед (~4 клетки) -- около 10 сек, замок (~10 клеток) -- около 25 сек, край карты -- до минуты.
public const int SpeedPerSecond = 400;
public static long GetTravelTimeMs(MapNode from, MapNode to)
{
return (long)WorldMapLayout.Distance(from, to) * 1000 / SpeedPerSecond;
}
}
public enum WorldEventType
{
BattleResolved, // войско дошло, бой прошёл
MarchReturned // войско вернулось домой
}
public struct WorldEvent
{
public WorldEventType Type;
public MarchData March;
}
// Состояние мира: карта + походы. Не зависит от Unity -- в мультиплеере этот же код крутится на сервере,
// а клиент только показывает то, что он вернул.
public sealed class WorldSimulation
{
private readonly List<MapNode> nodes;
private readonly List<MarchData> marches = new List<MarchData>();
private int nextMarchId = 1;
public IReadOnlyList<MapNode> Nodes => nodes;
public IReadOnlyList<MarchData> Marches => marches;
public WorldSimulation(List<MapNode> nodes)
{
this.nodes = nodes;
}
public MapNode GetNode(string id)
{
return WorldMapLayout.Find(nodes, id);
}
// Восстановление походов из сохранения (или из ответа сервера)
public void LoadMarches(List<MarchData> saved)
{
marches.Clear();
if (saved == null) return;
foreach (var m in saved)
{
// Поход к точке, которой больше нет на карте (карта поменялась), восстановить нельзя
if (GetNode(m.fromNodeId) == null || GetNode(m.targetNodeId) == null) continue;
marches.Add(m);
if (m.id >= nextMarchId) nextMarchId = m.id + 1;
}
}
public MarchData StartMarch(string ownerId, string fromId, string targetId, int soldiers, long nowMs, out string error)
{
MapNode from = GetNode(fromId);
MapNode target = GetNode(targetId);
if (from == null || target == null) { error = "Неизвестная точка на карте"; return null; }
if (target == from || !target.canBeAttacked) { error = $"На {target.displayName} нападать нельзя"; return null; }
if (soldiers <= 0) { error = "Нужно отправить хотя бы одного солдата"; return null; }
var march = new MarchData
{
id = nextMarchId++,
ownerId = ownerId,
fromNodeId = fromId,
targetNodeId = targetId,
soldiers = soldiers,
survivors = soldiers,
departMs = nowMs,
arriveMs = nowMs + MarchRules.GetTravelTimeMs(from, target),
state = MarchState.Outbound
};
marches.Add(march);
error = null;
return march;
}
// Продвинуть мир до момента nowMs. Если игра была закрыта долго, за один вызов поход
// может и дойти, и вернуться -- события придут в правильном порядке.
public void Advance(long nowMs, List<WorldEvent> events)
{
foreach (var m in marches)
{
if (m.state == MarchState.Outbound && nowMs >= m.arriveMs)
{
ResolveBattle(m);
events.Add(new WorldEvent { Type = WorldEventType.BattleResolved, March = m });
}
if (m.state == MarchState.Returning && nowMs >= m.returnMs)
{
m.state = MarchState.Finished;
events.Add(new WorldEvent { Type = WorldEventType.MarchReturned, March = m });
}
}
marches.RemoveAll(m => m.state == MarchState.Finished);
}
void ResolveBattle(MarchData m)
{
MapNode from = GetNode(m.fromNodeId);
MapNode target = GetNode(m.targetNodeId);
BattleOutcome outcome = BattleResolver.Resolve(m.soldiers, target.garrison);
m.victory = outcome.Victory;
m.survivors = outcome.Survivors;
m.defenders = target.garrison;
m.defenderLosses = outcome.DefenderLosses;
// Гарнизон соседа пока не меняется: соседи -- заглушки. Когда они станут реальными игроками,
// их потери будет списывать сервер из армии того игрока.
m.loot = new List<RewardEntry>();
if (outcome.Victory)
{
foreach (var r in target.rewards)
m.loot.Add(new RewardEntry { resource = r.resource, amount = r.amount });
}
// Обратный путь отсчитывается от момента боя, а не от "сейчас" -- важно для офлайна
m.returnMs = m.arriveMs + MarchRules.GetTravelTimeMs(target, from);
m.state = MarchState.Returning;
}
// Где сейчас войско (для отрисовки). Возвращает долю пути 0..1 и точки начала/конца отрезка.
public void GetMarchSegment(MarchData m, long nowMs, out MapNode segmentFrom, out MapNode segmentTo, out double progress)
{
MapNode home = GetNode(m.fromNodeId);
MapNode target = GetNode(m.targetNodeId);
long start, end;
if (m.state == MarchState.Outbound)
{
segmentFrom = home;
segmentTo = target;
start = m.departMs;
end = m.arriveMs;
}
else
{
segmentFrom = target;
segmentTo = home;
start = m.arriveMs;
end = m.returnMs;
}
progress = end > start ? Math.Min(1.0, Math.Max(0.0, (nowMs - start) / (double)(end - start))) : 1.0;
}
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 2ab9d33e4ab560a4ea1de0e23afd3fd5
@@ -0,0 +1,353 @@
using System.Collections.Generic;
using Bogatyri.World;
using TMPro;
using UnityEngine;
using UnityEngine.UI;
// Сцена карты мира: рисует замок и города, открывает окно атаки, показывает войска в походе
// и итоги боёв. Правил тут нет -- они в WorldSimulation, походы ведёт MarchManager.
public class WorldMapController : MonoBehaviour
{
[Header("Камера")]
public MapCameraController mapCamera;
[Header("Верхняя панель")]
public TMP_Text soldiersText;
public Button backButton;
public Button myCityButton;
[Header("Нижняя панель")]
public TMP_Text hintText;
[Header("Окно атаки")]
public GameObject attackPanel;
public TMP_Text attackTitleText;
public TMP_Text attackInfoText;
public Slider soldiersSlider;
public TMP_Text soldiersToSendText;
public Button sendButton;
public Button cancelButton;
[Header("Уведомление (итог боя / возвращение)")]
public GameObject popupPanel;
public TMP_Text popupTitleText;
public TMP_Text popupBodyText;
public Button popupOkButton;
const string DefaultHint = "Нажми на замок или соседний город, чтобы напасть. Карту можно двигать и приближать";
const float CastleSize = 2.2f;
const float CitySize = 1.1f;
const float StartZoom = 8f;
static readonly Color GroundColor = new Color(0.3f, 0.42f, 0.25f);
static readonly Color CastleColor = new Color(0.55f, 0.15f, 0.15f);
static readonly Color PlayerCityColor = new Color(0.25f, 0.5f, 0.95f);
static readonly Color NeighborCityColor = new Color(0.75f, 0.55f, 0.3f);
private Transform mapRoot;
private readonly Dictionary<int, MarchView> marchViews = new Dictionary<int, MarchView>();
private readonly Queue<(string title, string body)> popupQueue = new Queue<(string, string)>();
private readonly List<int> staleMarchIds = new List<int>();
private MapNode selectedTarget;
WorldSimulation World => MarchManager.Instance.World;
void Start()
{
mapRoot = new GameObject("Map").transform;
BuildMap();
SetUpCamera();
backButton.onClick.AddListener(SceneNavigator.ReturnToCity);
if (myCityButton != null) myCityButton.onClick.AddListener(CenterOnMyCity);
sendButton.onClick.AddListener(OnSendPressed);
cancelButton.onClick.AddListener(CloseAttackPanel);
popupOkButton.onClick.AddListener(ShowNextPopup);
soldiersSlider.onValueChanged.AddListener(_ => UpdateSoldiersToSendText());
attackPanel.SetActive(false);
popupPanel.SetActive(false);
hintText.text = DefaultHint;
if (ResourceManager.Instance == null)
hintText.text = "Запусти игру из сцены города (MVP1), иначе войско неоткуда брать";
}
void OnEnable()
{
if (MarchManager.Instance == null) return;
MarchManager.Instance.OnBattleResolved += HandleBattleResolved;
MarchManager.Instance.OnMarchReturned += HandleMarchReturned;
}
void OnDisable()
{
if (MarchManager.Instance == null) return;
MarchManager.Instance.OnBattleResolved -= HandleBattleResolved;
MarchManager.Instance.OnMarchReturned -= HandleMarchReturned;
}
void Update()
{
HandleMapTap();
SyncMarchViews();
soldiersText.text = $"Войска дома: {GetSoldiersAtHome()} В походе: {World.Marches.Count}";
}
// ---------------- КАРТА ----------------
void BuildMap()
{
float width = WorldMapLayout.HalfWidth / 1000f * 2f;
float height = WorldMapLayout.HalfHeight / 1000f * 2f;
CreateSprite("Ground", PrimitiveSprites.Square, GroundColor, Vector3.zero, new Vector3(width, height, 1f), -10);
foreach (var node in World.Nodes)
{
Vector3 pos = ToWorld(node);
switch (node.type)
{
case MapNodeType.BossCastle:
CreateSprite(node.id, PrimitiveSprites.Square, CastleColor, pos, Vector3.one * CastleSize, 0);
CreateSprite(node.id + "_Keep", PrimitiveSprites.Square, new Color(0.3f, 0.08f, 0.08f), pos, Vector3.one * CastleSize * 0.5f, 1);
CreateLabel(node.displayName, pos + new Vector3(0, CastleSize / 2f + 0.4f, 0), 4f);
CreateLabel($"Гарнизон: {node.garrison}", pos - new Vector3(0, CastleSize / 2f + 0.35f, 0), 3f);
break;
case MapNodeType.PlayerCity:
// Белая рамка -- чтобы свой город сразу бросался в глаза
CreateSprite(node.id + "_Frame", PrimitiveSprites.Square, Color.white, pos, Vector3.one * (CitySize + 0.25f), 0);
CreateSprite(node.id, PrimitiveSprites.Square, PlayerCityColor, pos, Vector3.one * CitySize, 1);
CreateLabel(node.displayName, pos - new Vector3(0, CitySize / 2f + 0.35f, 0), 3.2f);
break;
case MapNodeType.NeighborCity:
CreateSprite(node.id, PrimitiveSprites.Square, NeighborCityColor, pos, Vector3.one * CitySize, 0);
CreateLabel($"{node.displayName}\nГарнизон: {node.garrison}", pos - new Vector3(0, CitySize / 2f + 0.5f, 0), 2.6f);
break;
}
}
}
void SetUpCamera()
{
// Сцена собрана старой версией сборщика -- добавляем контроллер камеры сами
if (mapCamera == null)
{
Camera cam = Camera.main;
mapCamera = cam.GetComponent<MapCameraController>();
if (mapCamera == null) mapCamera = cam.gameObject.AddComponent<MapCameraController>();
}
mapCamera.mapHalfSize = new Vector2(WorldMapLayout.HalfWidth / 1000f, WorldMapLayout.HalfHeight / 1000f);
mapCamera.GetComponent<Camera>().orthographicSize = StartZoom;
CenterOnMyCity();
}
void CenterOnMyCity()
{
MapNode home = World.GetNode(WorldMapLayout.PlayerCityId);
if (home != null) mapCamera.CenterOn(ToWorld(home));
}
SpriteRenderer CreateSprite(string name, Sprite sprite, Color color, Vector3 position, Vector3 scale, int order)
{
var go = new GameObject(name);
go.transform.SetParent(mapRoot, false);
go.transform.position = position;
go.transform.localScale = scale;
var sr = go.AddComponent<SpriteRenderer>();
sr.sprite = sprite;
sr.color = color;
sr.sortingOrder = order;
return sr;
}
void CreateLabel(string text, Vector3 position, float fontSize)
{
var go = new GameObject("Label");
go.transform.SetParent(mapRoot, false);
go.transform.position = position;
var tmp = go.AddComponent<TextMeshPro>();
tmp.text = text;
tmp.fontSize = fontSize;
tmp.alignment = TextAlignmentOptions.Center;
tmp.rectTransform.sizeDelta = new Vector2(6f, 1.2f);
tmp.sortingOrder = 10;
}
public static Vector3 ToWorld(MapNode node)
{
return new Vector3(node.x / 1000f, node.y / 1000f, 0f);
}
// ---------------- ВЫБОР ЦЕЛИ ----------------
void HandleMapTap()
{
if (attackPanel.activeSelf || popupPanel.activeSelf) return;
if (!mapCamera.TryGetTap(out Vector3 world)) return;
MapNode node = FindNodeAt(world);
if (node == null) return;
if (node.type == MapNodeType.PlayerCity) hintText.text = "Это твой город. Нажми НАЗАД, чтобы вернуться в него";
else if (node.canBeAttacked) OpenAttackPanel(node);
}
MapNode FindNodeAt(Vector3 world)
{
foreach (var node in World.Nodes)
{
// Зона нажатия чуть больше самого квадрата -- по мелким городам легче попасть пальцем
float halfSize = (node.type == MapNodeType.BossCastle ? CastleSize : CitySize) / 2f + 0.2f;
Vector3 pos = ToWorld(node);
if (Mathf.Abs(world.x - pos.x) <= halfSize && Mathf.Abs(world.y - pos.y) <= halfSize) return node;
}
return null;
}
// ---------------- ОКНО АТАКИ ----------------
void OpenAttackPanel(MapNode target)
{
selectedTarget = target;
int available = GetSoldiersAtHome();
MapNode home = World.GetNode(WorldMapLayout.PlayerCityId);
int travelSeconds = Mathf.CeilToInt(MarchRules.GetTravelTimeMs(home, target) / 1000f);
attackTitleText.text = target.type == MapNodeType.NeighborCity
? $"Город игрока {target.displayName}"
: target.displayName;
attackInfoText.text = $"Гарнизон: {target.garrison} солдат\n" +
$"Путь в одну сторону: {travelSeconds} сек\n" +
$"Награда за победу: {MarchManager.FormatLoot(target.rewards)}";
soldiersSlider.minValue = available > 0 ? 1 : 0;
soldiersSlider.maxValue = available;
soldiersSlider.wholeNumbers = true;
soldiersSlider.value = available;
soldiersSlider.interactable = available > 0;
sendButton.interactable = available > 0;
UpdateSoldiersToSendText();
attackPanel.SetActive(true);
}
void UpdateSoldiersToSendText()
{
int available = GetSoldiersAtHome();
soldiersToSendText.text = available > 0
? $"Отправить: {(int)soldiersSlider.value} из {available}"
: "Дома нет солдат";
}
void CloseAttackPanel()
{
attackPanel.SetActive(false);
selectedTarget = null;
}
void OnSendPressed()
{
if (selectedTarget == null) return;
int soldiers = (int)soldiersSlider.value;
if (MarchManager.Instance.TryStartMarch(selectedTarget.id, soldiers, out string error))
{
hintText.text = $"Войско выступило: {soldiers} солдат идут на {selectedTarget.displayName}";
}
else
{
hintText.text = "Поход не начат: " + error;
}
CloseAttackPanel();
}
static int GetSoldiersAtHome()
{
return ResourceManager.Instance != null
? Mathf.FloorToInt(ResourceManager.Instance.GetResource(ResourceType.Soldiers))
: 0;
}
// ---------------- ВОЙСКА НА КАРТЕ ----------------
void SyncMarchViews()
{
long now = MarchManager.NowMs;
foreach (var march in World.Marches)
{
if (!marchViews.TryGetValue(march.id, out MarchView view))
{
view = MarchView.Create(mapRoot, march);
marchViews[march.id] = view;
}
view.Refresh(World, march, now);
}
// Убираем войска, которые уже вернулись
staleMarchIds.Clear();
foreach (var id in marchViews.Keys)
{
bool stillActive = false;
foreach (var march in World.Marches)
{
if (march.id == id) { stillActive = true; break; }
}
if (!stillActive) staleMarchIds.Add(id);
}
foreach (var id in staleMarchIds)
{
Destroy(marchViews[id].gameObject);
marchViews.Remove(id);
}
}
// ---------------- УВЕДОМЛЕНИЯ ----------------
void HandleBattleResolved(MarchData march)
{
MapNode target = World.GetNode(march.targetNodeId);
string title = march.victory ? "ПОБЕДА" : "ПОРАЖЕНИЕ";
string body = $"{target.displayName}\n" +
$"Твоё войско: {march.soldiers}, гарнизон: {march.defenders}\n" +
$"Твои потери: {march.soldiers - march.survivors}, потери врага: {march.defenderLosses}\n" +
$"Домой идут: {march.survivors}\n";
body += march.victory
? $"Добыча (придёт с войском): {MarchManager.FormatLoot(march.loot)}"
: "Добычи нет";
EnqueuePopup(title, body);
}
void HandleMarchReturned(MarchData march)
{
string body = $"В город вернулись {march.survivors} солдат\n" +
$"Добыча: {MarchManager.FormatLoot(march.loot)}";
EnqueuePopup("ВОЙСКО ДОМА", body);
}
void EnqueuePopup(string title, string body)
{
popupQueue.Enqueue((title, body));
if (!popupPanel.activeSelf) ShowNextPopup();
}
void ShowNextPopup()
{
if (popupQueue.Count == 0)
{
popupPanel.SetActive(false);
return;
}
var (title, body) = popupQueue.Dequeue();
popupTitleText.text = title;
popupTitleText.color = title == "ПОРАЖЕНИЕ" ? new Color(0.9f, 0.35f, 0.3f) : new Color(1f, 0.85f, 0.3f);
popupBodyText.text = body;
popupPanel.SetActive(true);
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: c5d542295fc24d04b869b14cf0b399c0
File diff suppressed because one or more lines are too long
+1
View File
@@ -1,4 +1,5 @@
<Solution>
<Project Path="Assembly-CSharp.csproj" />
<Project Path="Bogatyri.World.Simulation.csproj" />
<Project Path="Assembly-CSharp-Editor.csproj" />
</Solution>
+5 -2
View File
@@ -6,7 +6,10 @@ EditorBuildSettings:
serializedVersion: 2
m_Scenes:
- enabled: 1
path: Assets/Scenes/SampleScene.unity
guid: 8c9cfa26abfee488c85f1582747f6a02
path: Assets/Scenes/MVP1.unity
guid: beae4025d64976c4f93af3b9233183da
- enabled: 1
path: Assets/Scenes/WorldMap.unity
guid: baa48fafda29f4247b2910dc9622c97a
m_configObjects:
com.unity.input.settings.actions: {fileID: -944628639613478452, guid: 2bcd2660ca9b64942af0de543d8d7100, type: 3}