Слияние: наши панели HUD, стройка, казармы, камера + карта мира, чат и рейтинг от Саши

This commit is contained in:
2026-10-12 23:54:03 +03:00
parent c2124e621c
commit ababe380df
164 changed files with 50529 additions and 473 deletions
+27
View File
@@ -0,0 +1,27 @@
using UnityEngine;
using UnityEditor;
using UnityEditor.SceneManagement;
// Tools -> Fix Camera & HUD: один раз исправить сцену и сохранить.
// - HUD_Canvas -> Screen Space Overlay, отвязан от камеры
// - с камеры убраны CameraZoom / CameraController / FlyCamera, добавлена CityCamera
// - удалены компоненты с "Missing script" (скрипты, которых в проекте уже нет)
public static class CameraHUDFixer
{
// [MenuItem("Tools/Fix Camera & HUD")] -- отключено, см. RestoreOldCamera
public static void Fix()
{
int missing = 0;
var hud = GameObject.Find("HUD_Canvas");
if (hud != null) missing += GameObjectUtility.RemoveMonoBehavioursWithMissingScript(hud);
if (Camera.main != null) missing += GameObjectUtility.RemoveMonoBehavioursWithMissingScript(Camera.main.gameObject);
int changes = HUDCameraSetup.Apply(true);
if (hud != null) EditorSceneManager.MarkSceneDirty(hud.scene);
else if (Camera.main != null) EditorSceneManager.MarkSceneDirty(Camera.main.gameObject.scene);
Debug.Log("Камера и HUD исправлены: изменений " + changes + ", удалено пустых скриптов " + missing +
". Сохрани сцену (Ctrl+S). Границы карты камеры: компонент CityCamera на Main Camera.");
}
}
+2
View File
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 43380bdc85562cc4abb95b707e06cfb6
+337
View File
@@ -0,0 +1,337 @@
using System;
using UnityEngine;
using UnityEngine.UI;
using UnityEditor;
using UnityEditor.Events;
using UnityEditor.SceneManagement;
using TMPro;
// Кладётся в Assets/Editor/.
// Tools -> Build HUD Panels:
// 1) находит HUD_Canvas,
// 2) создаёт для каждой кнопки свою панель (заголовок + крестик + область Content),
// 3) вешает на панель её скрипт (QuestsPanel, ShopPanel...) -- он сам рисует содержимое в игре,
// 4) вешает UIPanelManager и подключает onClick ВСЕХ кнопок по их именам,
// 5) добавляет красные значки (HUDBadge) на кнопки, где бывает "что забрать".
// Можно запускать повторно -- старые панели пересоздаются.
public class HUDPanelsBuilder
{
class Entry
{
public string button, id, title; public Type panel; public bool badge;
public Entry(string button, string id, string title, Type panel, bool badge)
{ this.button = button; this.id = id; this.title = title; this.panel = panel; this.badge = badge; }
}
// имя кнопки в иерархии -> id панели, заголовок, скрипт панели, нужен ли значок
static readonly Entry[] Map =
{
new Entry("RightBtn_Quests", "Quests", "ЗАДАНИЯ", typeof(QuestsPanel), true),
new Entry("RightBtn_Events", "Events", "СОБЫТИЯ", typeof(EventsPanel), true),
new Entry("RightBtn_Rewards", "Rewards", "НАГРАДЫ", typeof(RewardsPanel), true),
new Entry("NavBtn_Mail", "Mail", "ПОЧТА", typeof(MailPanel), true),
new Entry("SettingsButton", "Settings", "НАСТРОЙКИ", typeof(SettingsPanel), false),
new Entry("PlayerAvatar", "Profile", "ПРОФИЛЬ", typeof(ProfilePanel), false),
new Entry("Nav_Hero", "Hero", "ГЕРОЙ", typeof(HeroPanel), true),
new Entry("Nav_Battle", "Battle", "БОЙ", typeof(BattlePanel), false),
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_Home", "Home", null, null, false), // без панели: закрывает всё
};
[MenuItem("Tools/Build HUD Panels")]
public static void Build()
{
GameObject canvasGO = GameObject.Find("HUD_Canvas");
if (canvasGO == null) { Debug.LogError("Не найден объект HUD_Canvas на сцене."); return; }
// казармы: префаб + данные здания + в список стройки
EnsureBarracks();
// менеджер
UIPanelManager manager = canvasGO.GetComponent<UIPanelManager>();
if (manager == null) manager = canvasGO.AddComponent<UIPanelManager>();
manager.panels.Clear();
// корень для панелей (пересоздаём)
Transform old = canvasGO.transform.Find("Panels");
if (old != null) UnityEngine.Object.DestroyImmediate(old.gameObject);
GameObject root = new GameObject("Panels", typeof(RectTransform));
root.transform.SetParent(canvasGO.transform, false);
Stretch(root.GetComponent<RectTransform>(), Vector4.zero);
// Корень сам не блокирует клики (нет Image), поэтому нижняя/верхняя панели HUD остаются живыми.
int wired = 0;
string missing = "";
foreach (var m in Map)
{
if (m.title != null)
{
// карте мира нужно больше места -- её окно почти на весь экран между верхней и нижней панелями
Vector4 offsets = m.id == "World" ? new Vector4(16, 145, 16, 195) : new Vector4(30, 250, 30, 230);
GameObject panel = CreatePanel(root.transform, m.id, m.title, manager, offsets);
if (m.panel != null) panel.AddComponent(m.panel);
manager.panels.Add(new UIPanelManager.PanelEntry { id = m.id, panel = panel });
}
Button btn = FindButton(canvasGO.transform, m.button);
if (btn == null && m.id == "World") btn = CreateWorldButton(canvasGO.transform, root.transform);
if (btn == null) { Debug.LogWarning("Кнопка не найдена: " + m.button); missing += " " + m.button; continue; }
// убираем старые подписки из Inspector и вешаем свою
for (int i = btn.onClick.GetPersistentEventCount() - 1; i >= 0; i--)
UnityEventTools.RemovePersistentListener(btn.onClick, i);
UnityEventTools.AddStringPersistentListener(btn.onClick, manager.Open, m.id);
// красный значок "есть что забрать"
HUDBadge badge = btn.GetComponent<HUDBadge>();
if (m.badge)
{
if (badge == null) badge = btn.gameObject.AddComponent<HUDBadge>();
badge.panelId = m.id;
}
else if (badge != null) UnityEngine.Object.DestroyImmediate(badge);
wired++;
}
// Кнопка "СТРОЙКА": включает/выключает режим стройки (тап по участку -> выбор здания).
// Старое меню стройки прячем: при запуске игры оно больше не открывается.
GameObject buildMenu = null;
foreach (var t in canvasGO.GetComponentsInChildren<Transform>(true))
if (t.name == "BuildMenuPanel") { buildMenu = t.gameObject; break; }
if (buildMenu != null) buildMenu.SetActive(false);
BuildModeController buildMode = canvasGO.GetComponent<BuildModeController>();
if (buildMode == null) buildMode = canvasGO.AddComponent<BuildModeController>();
Button buildBtn = FindButton(canvasGO.transform, "Nav_Build");
if (buildBtn == null)
buildBtn = CreateSideButton(canvasGO.transform, root.transform, "Nav_Build", "СТРОЙКА",
"Assets/UI/HUD v2/Рюкзак/v1.png", new Vector2(30, 400), new Color(0.55f, 0.4f, 0.2f));
for (int i = buildBtn.onClick.GetPersistentEventCount() - 1; i >= 0; i--)
UnityEventTools.RemovePersistentListener(buildBtn.onClick, i);
UnityEventTools.AddVoidPersistentListener(buildBtn.onClick, buildMode.ToggleMode);
buildMode.modeButton = buildBtn;
buildMode.legacyBuildMenu = buildMenu;
EditorUtility.SetDirty(buildMode);
// Меню стройки растянуто на весь экран и закрывало все кнопки HUD (ловило клики и белило экран).
// Делаем его фон прозрачным и "сквозным" -- кнопки зданий внутри продолжают работать.
foreach (string see in new[] { "BuildMenuPanel", "ButtonContainer" })
{
foreach (var t in canvasGO.GetComponentsInChildren<Transform>(true))
{
if (t.name != see) continue;
var img = t.GetComponent<Image>();
if (img == null) continue;
var c = img.color; c.a = 0f; img.color = c;
img.raycastTarget = false;
EditorUtility.SetDirty(img);
}
}
EditorUtility.SetDirty(manager);
EditorSceneManager.MarkSceneDirty(canvasGO.scene);
Debug.Log("Готово: панелей " + manager.panels.Count + ", подключено кнопок " + wired + " из " + Map.Length +
(missing.Length > 0 ? ". Не найдены:" + missing : "") + ". Не забудь сохранить сцену (Ctrl+S).");
}
// ===================== КАЗАРМЫ =====================
const string BarracksPrefabPath = "Assets/Prefab/Buildings/Barracks.prefab";
const string BarracksDataPath = "Assets/Prefab/Buildings/Data_Barracks.asset";
[MenuItem("Tools/Create Barracks")]
public static void EnsureBarracks()
{
Sprite icon = LoadSprite("Assets/UI/HUD v2/Войска/v1.png");
GameObject prefab = AssetDatabase.LoadAssetAtPath<GameObject>(BarracksPrefabPath);
if (prefab == null)
{
// берём готовое здание как шаблон: тот же масштаб, слой, материал, коллайдер
string template = "Assets/Prefab/Buildings/Food.prefab";
if (AssetDatabase.LoadAssetAtPath<GameObject>(template) == null)
{
var bmT = UnityEngine.Object.FindAnyObjectByType<BuildManager>();
if (bmT != null)
foreach (var b in bmT.allBuildings)
if (b != null && b.prefab != null) { template = AssetDatabase.GetAssetPath(b.prefab); break; }
}
if (!AssetDatabase.CopyAsset(template, BarracksPrefabPath))
{
Debug.LogError("Не удалось создать префаб казарм из " + template);
return;
}
GameObject root = PrefabUtility.LoadPrefabContents(BarracksPrefabPath);
root.name = "Barracks";
foreach (var c in root.GetComponents<ResourceCollector>()) UnityEngine.Object.DestroyImmediate(c);
foreach (var c in root.GetComponents<ResourceProducer>()) UnityEngine.Object.DestroyImmediate(c);
foreach (var c in root.GetComponents<BuildingUI>()) UnityEngine.Object.DestroyImmediate(c);
var kids = new System.Collections.Generic.List<GameObject>();
foreach (Transform ch in root.transform) kids.Add(ch.gameObject); // полоска запаса ресурса -- не нужна
foreach (var k in kids) UnityEngine.Object.DestroyImmediate(k);
var sr = root.GetComponent<SpriteRenderer>();
if (sr != null)
{
if (icon != null)
{
Vector2 oldSize = sr.sprite != null ? (Vector2)sr.sprite.bounds.size : Vector2.one;
sr.sprite = icon;
sr.color = Color.white;
float k = Mathf.Min(oldSize.x / icon.bounds.size.x, oldSize.y / icon.bounds.size.y);
root.transform.localScale = Vector3.Scale(root.transform.localScale, new Vector3(k, k, 1));
}
else sr.color = new Color(1f, 0.6f, 0.6f); // нет иконки -- хотя бы отличаем цветом
var box = root.GetComponent<BoxCollider2D>();
if (box != null && sr.sprite != null) { box.size = sr.sprite.bounds.size; box.offset = sr.sprite.bounds.center; }
}
if (root.GetComponent<Barracks>() == null) root.AddComponent<Barracks>();
PrefabUtility.SaveAsPrefabAsset(root, BarracksPrefabPath);
PrefabUtility.UnloadPrefabContents(root);
prefab = AssetDatabase.LoadAssetAtPath<GameObject>(BarracksPrefabPath);
Debug.Log("Создан префаб казарм: " + BarracksPrefabPath);
}
BuildingData data = AssetDatabase.LoadAssetAtPath<BuildingData>(BarracksDataPath);
if (data == null)
{
data = ScriptableObject.CreateInstance<BuildingData>();
data.buildingName = "Казармы";
data.icon = icon;
data.prefab = prefab;
data.requiredTownHallLevel = 1;
data.cost = new[]
{
new ResourceCost { type = ResourceType.Wood, amount = 300 },
new ResourceCost { type = ResourceType.Stone, amount = 200 },
};
AssetDatabase.CreateAsset(data, BarracksDataPath);
AssetDatabase.SaveAssets();
Debug.Log("Созданы данные здания: " + BarracksDataPath);
}
var bm = UnityEngine.Object.FindAnyObjectByType<BuildManager>();
if (bm != null && !bm.allBuildings.Contains(data))
{
bm.allBuildings.Add(data);
EditorUtility.SetDirty(bm);
EditorSceneManager.MarkSceneDirty(bm.gameObject.scene);
Debug.Log("Казармы добавлены в список стройки (BuildManager)");
}
}
static Sprite LoadSprite(string path)
{
foreach (var a in AssetDatabase.LoadAllAssetsAtPath(path))
if (a is Sprite) return (Sprite)a;
return null;
}
// ищем объект по имени среди всех детей (включая выключенные); Button может быть на нём или внутри
static Button FindButton(Transform root, string name)
{
foreach (var t in root.GetComponentsInChildren<Transform>(true))
{
if (t.name != name) continue;
Button b = t.GetComponent<Button>();
if (b == null) b = t.GetComponentInChildren<Button>(true);
if (b == null) b = t.gameObject.AddComponent<Button>(); // на случай если там просто Image
return b;
}
return null;
}
// Кнопка "МИР" слева над нижней панелью (если дизайнер ещё не сделал свою с именем Nav_World)
static Button CreateWorldButton(Transform canvas, Transform panelsRoot)
{
return CreateSideButton(canvas, panelsRoot, "Nav_World", "МИР", "Assets/UI/HUD v2/Мир/v1.png",
new Vector2(30, 210), new Color(0.2f, 0.45f, 0.3f));
}
static Button CreateSideButton(Transform canvas, Transform panelsRoot, string objName, string label, string spritePath, Vector2 pos, Color fallback)
{
GameObject go = new GameObject(objName, typeof(RectTransform), typeof(Image), typeof(Button));
go.transform.SetParent(canvas, false);
go.transform.SetSiblingIndex(panelsRoot.GetSiblingIndex()); // под панелями, чтобы открытая панель её закрывала
RectTransform rt = go.GetComponent<RectTransform>();
rt.anchorMin = rt.anchorMax = rt.pivot = new Vector2(0, 0);
rt.sizeDelta = new Vector2(150, 150);
rt.anchoredPosition = pos;
var img = go.GetComponent<Image>();
Sprite sprite = null;
foreach (var a in AssetDatabase.LoadAllAssetsAtPath(spritePath))
if (a is Sprite) { sprite = (Sprite)a; break; }
if (sprite != null) { img.sprite = sprite; img.preserveAspect = true; }
else img.color = fallback;
GameObject t = new GameObject("Label", typeof(RectTransform), typeof(TextMeshProUGUI));
t.transform.SetParent(go.transform, false);
var tmp = t.GetComponent<TextMeshProUGUI>();
tmp.text = label; tmp.fontSize = 30; tmp.fontStyle = FontStyles.Bold;
tmp.alignment = TextAlignmentOptions.Center; tmp.color = Color.white; tmp.raycastTarget = false;
tmp.outlineWidth = 0.25f; tmp.outlineColor = Color.black;
RectTransform tr = t.GetComponent<RectTransform>();
tr.anchorMin = new Vector2(0, 0); tr.anchorMax = new Vector2(1, 0); tr.pivot = new Vector2(0.5f, 1);
tr.sizeDelta = new Vector2(0, 40); tr.anchoredPosition = new Vector2(0, 8);
return go.GetComponent<Button>();
}
static GameObject CreatePanel(Transform parent, string id, string title, UIPanelManager manager, Vector4 offsets)
{
// окно оставляет сверху и снизу место под TopPanel и BottomNavBar, чтобы они были кликабельны
GameObject go = new GameObject("Panel_" + id, typeof(RectTransform), typeof(Image));
go.transform.SetParent(parent, false);
go.GetComponent<Image>().color = new Color(0.1f, 0.12f, 0.15f, 0.97f);
Stretch(go.GetComponent<RectTransform>(), offsets); // left, top, right, bottom
// заголовок
GameObject t = new GameObject("Title", typeof(RectTransform), typeof(TextMeshProUGUI));
t.transform.SetParent(go.transform, false);
var tmp = t.GetComponent<TextMeshProUGUI>();
tmp.text = title; tmp.fontSize = 52; tmp.fontStyle = FontStyles.Bold;
tmp.alignment = TextAlignmentOptions.Center; tmp.color = new Color(0.95f, 0.75f, 0.25f);
tmp.raycastTarget = false;
RectTransform tr = t.GetComponent<RectTransform>();
tr.anchorMin = new Vector2(0, 1); tr.anchorMax = new Vector2(1, 1); tr.pivot = new Vector2(0.5f, 1);
tr.sizeDelta = new Vector2(0, 110); tr.anchoredPosition = Vector2.zero;
// область содержимого: сюда скрипт панели рисует списки, кнопки и т.д.
GameObject content = new GameObject("Content", typeof(RectTransform));
content.transform.SetParent(go.transform, false);
Stretch(content.GetComponent<RectTransform>(), new Vector4(20, 120, 20, 20));
// крестик
GameObject c = new GameObject("CloseButton", typeof(RectTransform), typeof(Image), typeof(Button));
c.transform.SetParent(go.transform, false);
c.GetComponent<Image>().color = new Color(0.6f, 0.15f, 0.15f);
RectTransform cr = c.GetComponent<RectTransform>();
cr.anchorMin = cr.anchorMax = cr.pivot = new Vector2(1, 1);
cr.sizeDelta = new Vector2(90, 90); cr.anchoredPosition = new Vector2(-12, -10);
GameObject x = new GameObject("X", typeof(RectTransform), typeof(TextMeshProUGUI));
x.transform.SetParent(c.transform, false);
var xt = x.GetComponent<TextMeshProUGUI>();
xt.text = "X"; xt.fontSize = 44; xt.alignment = TextAlignmentOptions.Center; xt.color = Color.white;
xt.raycastTarget = false;
Stretch(x.GetComponent<RectTransform>(), Vector4.zero);
UnityEventTools.AddVoidPersistentListener(c.GetComponent<Button>().onClick, manager.CloseAll);
go.SetActive(false);
return go;
}
// left, top, right, bottom -- отступы от краёв родителя
static void Stretch(RectTransform rt, Vector4 ltrb)
{
rt.anchorMin = Vector2.zero; rt.anchorMax = Vector2.one;
rt.offsetMin = new Vector2(ltrb.x, ltrb.w);
rt.offsetMax = new Vector2(-ltrb.z, -ltrb.y);
}
}
+2
View File
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 26bcfec97167cdb4b9dee113e5f85abc
+84
View File
@@ -0,0 +1,84 @@
using UnityEngine;
using UnityEngine.UI;
using UnityEditor;
using UnityEditor.SceneManagement;
// HUD ПРИКРЕПЛЁН К КАМЕРЕ (Canvas: Screen Space - Camera):
// - HUD рисуется камерой и всегда занимает экран одинаково -- при зуме и перемещении не плывёт и не масштабируется
// - карту можно двигать и зумить (CameraController), клики по кнопкам HUD работают
// - спрайты внутри HUD (например "Рамка в середине") рисуются правильно -- в этом режиме Canvas живёт в мире перед камерой
// - CameraZoom больше не нужен (он масштабировал HUD вручную и делал это в обратную сторону)
//
// Применяется сам один раз после компиляции, если сцена ещё не в этом режиме. Вручную: Tools -> HUD: Attach To Camera.
[InitializeOnLoad]
public static class RestoreOldCamera
{
static readonly Vector2 DesignSize = new Vector2(2007.7732f, 1032.786f); // размер, под который собран HUD
static RestoreOldCamera() { EditorApplication.delayCall += Auto; }
static void Auto()
{
if (EditorApplication.isPlayingOrWillChangePlaymode) return;
var hud = GameObject.Find("HUD_Canvas");
var cam = Camera.main;
if (hud == null || cam == null) return;
var canvas = hud.GetComponent<Canvas>();
var sc = hud.GetComponent<CanvasScaler>();
bool ok = canvas != null && canvas.renderMode == RenderMode.ScreenSpaceCamera && canvas.worldCamera == cam
&& cam.GetComponent<CameraZoom>() == null && cam.GetComponent<CityCamera>() == null
&& sc != null && sc.enabled && sc.screenMatchMode == CanvasScaler.ScreenMatchMode.Expand;
if (!ok) Apply();
}
[MenuItem("Tools/HUD: Attach To Camera")]
public static void Apply()
{
var cam = Camera.main;
var hud = GameObject.Find("HUD_Canvas");
if (cam == null || hud == null) { Debug.LogError("Нужны Main Camera и HUD_Canvas"); return; }
// ---- камера: только CameraController (перемещение + зум) ----
foreach (var c in cam.GetComponents<CityCamera>()) Object.DestroyImmediate(c);
foreach (var c in cam.GetComponents<CameraZoom>()) Object.DestroyImmediate(c);
var ctrl = cam.GetComponent<CameraController>();
if (ctrl == null)
{
ctrl = cam.gameObject.AddComponent<CameraController>();
ctrl.moveSpeed = 5f; ctrl.zoomSpeed = 2f; ctrl.minOrthoSize = 2f; ctrl.maxOrthoSize = 50f;
}
ctrl.enabled = true;
cam.orthographic = true;
cam.transform.localScale = Vector3.one;
cam.transform.rotation = Quaternion.identity;
if (cam.orthographicSize < 2f || cam.orthographicSize > 50f) cam.orthographicSize = 10f;
// ---- HUD: Screen Space - Camera ----
var canvas = hud.GetComponent<Canvas>();
var rt = (RectTransform)hud.transform;
if (rt.parent != null) rt.SetParent(null, false);
canvas.renderMode = RenderMode.ScreenSpaceCamera;
canvas.worldCamera = cam;
canvas.planeDistance = 5f; // между камерой (z=-10) и картой (z=0)
canvas.sortingOrder = 100; // поверх зданий
rt.localRotation = Quaternion.identity;
var scaler = hud.GetComponent<CanvasScaler>();
if (scaler == null) scaler = hud.AddComponent<CanvasScaler>();
scaler.enabled = true;
scaler.uiScaleMode = CanvasScaler.ScaleMode.ScaleWithScreenSize;
scaler.referenceResolution = DesignSize;
// Expand: весь HUD (2007x1032) всегда помещается на экран целиком -- и в узком окне, и на широком телефоне
scaler.screenMatchMode = CanvasScaler.ScreenMatchMode.Expand;
// спрайты внутри HUD -- поверх карты
foreach (var sr in hud.GetComponentsInChildren<SpriteRenderer>(true))
{
if (sr.sortingOrder < 100) sr.sortingOrder = 100;
if (!sr.gameObject.activeSelf) sr.gameObject.SetActive(true);
}
EditorSceneManager.MarkSceneDirty(hud.scene);
Debug.Log("HUD прикреплён к камере (Screen Space - Camera), CameraZoom убран. Сохрани сцену: Ctrl+S.");
}
}
+2
View File
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: f4f66fb7b626a0e41af6fe3fe793e917
+34
View File
@@ -0,0 +1,34 @@
using System.Collections.Generic;
using UnityEditor;
// Следит, чтобы сцена карты мира (WorldMap, от Саши) была в Build Settings --
// иначе переход город -> карта не работает. Срабатывает сам после каждой компиляции.
[InitializeOnLoad]
public static class SceneListFixer
{
const string CityScene = "Assets/Scenes/MVP1.unity";
const string WorldScene = "Assets/Scenes/WorldMap.unity";
static SceneListFixer() { EditorApplication.delayCall += Ensure; }
static void Ensure()
{
var scenes = new List<EditorBuildSettingsScene>(EditorBuildSettings.scenes);
bool changed = false;
foreach (var path in new[] { CityScene, WorldScene })
{
if (AssetDatabase.LoadAssetAtPath<SceneAsset>(path) == null) continue;
if (scenes.Exists(s => s.path == path)) continue;
scenes.Add(new EditorBuildSettingsScene(path, true));
changed = true;
}
// город должен быть первым (с него стартует игра)
int city = scenes.FindIndex(s => s.path == CityScene);
if (city > 0) { var c = scenes[city]; scenes.RemoveAt(city); scenes.Insert(0, c); changed = true; }
if (changed)
{
EditorBuildSettings.scenes = scenes.ToArray();
UnityEngine.Debug.Log("[СЦЕНЫ] Build Settings: город + карта мира добавлены");
}
}
}
+2
View File
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 89a48f1e99f1cd4469929f3c418ce55b
+521
View File
@@ -0,0 +1,521 @@
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 hintBar = CreatePanel(canvasRect, "HintBar", new Color(0.08f, 0.08f, 0.1f, 0.75f));
SetRect(hintBar, new Vector2(0, 0), new Vector2(1, 0), new Vector2(0.5f, 0), Vector2.zero, Vector2.zero);
hintBar.offsetMin = new Vector2(ChatWidth + 24, 172);
hintBar.offsetMax = new Vector2(-12, 222);
controller.hintText = CreateText(hintBar, "Hint", "", 28);
// ---- Чат слева внизу ----
BuildChat(canvasRect);
// ---- Нижняя панель: Карта мира | События | Круг | Поселение ----
BuildNavBar(canvasRect, controller);
// ---- Кнопка "Рейтинг" слева ----
RectTransform ratingBtn = CreateButton(canvasRect, "RatingButton", "Рейтинг", new Color(0.12f, 0.2f, 0.4f, 0.95f), out _);
SetRect(ratingBtn, new Vector2(0, 1), new Vector2(0, 1), new Vector2(0, 1), new Vector2(12, -122), new Vector2(210, 110));
controller.ratingButton = ratingBtn.GetComponent<Button>();
// ---- Панель рейтинга (выше всего, кроме окна атаки и уведомлений) ----
controller.ratingPanel = BuildRatingPanel(canvasRect);
// ---- Окно атаки ----
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);
}
// ---------------- ЧАТ ----------------
const float ChatWidth = 620f;
static void BuildChat(RectTransform canvasRect)
{
RectTransform panel = CreatePanel(canvasRect, "ChatPanel", new Color(0.05f, 0.06f, 0.09f, 0.9f));
SetRect(panel, Vector2.zero, Vector2.zero, Vector2.zero, new Vector2(12, 12), new Vector2(ChatWidth, 350));
ChatPanel chat = panel.gameObject.AddComponent<ChatPanel>();
RectTransform worldTab = CreateButton(panel, "WorldTab", "Общий мир", new Color(0.16f, 0.35f, 0.7f), out _);
SetRect(worldTab, new Vector2(0, 1), new Vector2(0, 1), new Vector2(0, 1), new Vector2(10, -10), new Vector2(220, 54));
chat.worldTabButton = worldTab.GetComponent<Button>();
RectTransform circleTab = CreateButton(panel, "CircleTab", "Круг", new Color(0.12f, 0.13f, 0.17f), out _);
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>();
// Лента: текст прижат к низу, старые строки уходят вверх и обрезаются маской
var messagesGO = new GameObject("Messages", typeof(RectTransform), typeof(RectMask2D));
RectTransform messages = messagesGO.GetComponent<RectTransform>();
messages.SetParent(panel, false);
messages.anchorMin = Vector2.zero;
messages.anchorMax = Vector2.one;
messages.offsetMin = new Vector2(14, 86);
messages.offsetMax = new Vector2(-14, -74);
RectTransform messagesText = CreateTextRect(messages, "MessagesText", "", 24, TextAlignmentOptions.BottomLeft);
TMP_Text tmp = messagesText.GetComponent<TMP_Text>();
tmp.overflowMode = TextOverflowModes.Overflow;
chat.messagesText = tmp;
// Поле ввода
GameObject inputGO = TMP_DefaultControls.CreateInputField(new TMP_DefaultControls.Resources());
inputGO.name = "InputField";
RectTransform inputRect = inputGO.GetComponent<RectTransform>();
inputRect.SetParent(panel, false);
SetRect(inputRect, Vector2.zero, new Vector2(1, 0), new Vector2(0.5f, 0), Vector2.zero, Vector2.zero);
inputRect.offsetMin = new Vector2(12, 12);
inputRect.offsetMax = new Vector2(-124, 74);
inputGO.GetComponent<Image>().color = new Color(0.12f, 0.13f, 0.17f);
TMP_InputField input = inputGO.GetComponent<TMP_InputField>();
input.pointSize = 26;
input.textComponent.color = Color.white;
((TMP_Text)input.placeholder).text = "Введите сообщение...";
((TMP_Text)input.placeholder).color = new Color(1f, 1f, 1f, 0.35f);
chat.inputField = input;
RectTransform send = CreateButton(panel, "SendButton", ">", new Color(0.2f, 0.45f, 0.85f), out _);
SetRect(send, new Vector2(1, 0), new Vector2(1, 0), new Vector2(1, 0), new Vector2(-12, 12), new Vector2(100, 62));
chat.sendButton = send.GetComponent<Button>();
}
// ---------------- НИЖНЯЯ ПАНЕЛЬ ----------------
static void BuildNavBar(RectTransform canvasRect, WorldMapController controller)
{
RectTransform bar = CreatePanel(canvasRect, "BottomNavBar", new Color(0.06f, 0.07f, 0.1f, 0.92f));
SetRect(bar, Vector2.zero, new Vector2(1, 0), new Vector2(0.5f, 0), Vector2.zero, Vector2.zero);
bar.offsetMin = new Vector2(ChatWidth + 24, 12);
bar.offsetMax = new Vector2(-12, 162);
var layout = bar.gameObject.AddComponent<HorizontalLayoutGroup>();
layout.padding = new RectOffset(10, 10, 10, 10);
layout.spacing = 10;
layout.childForceExpandWidth = true;
layout.childForceExpandHeight = true;
layout.childControlWidth = true;
layout.childControlHeight = true;
Color navColor = new Color(0.14f, 0.16f, 0.22f);
controller.worldMapNavButton = CreateButton(bar, "Nav_WorldMap", "Карта мира", navColor, out _).GetComponent<Button>();
controller.eventsNavButton = CreateButton(bar, "Nav_Events", "События", navColor, out _).GetComponent<Button>();
controller.circleNavButton = CreateButton(bar, "Nav_Circle", "Круг", navColor, out _).GetComponent<Button>();
controller.settlementNavButton = CreateButton(bar, "Nav_Settlement", "Поселение", new Color(0.18f, 0.25f, 0.45f), out _).GetComponent<Button>();
}
// ---------------- РЕЙТИНГ ----------------
static readonly Color RatingBg = new Color(0.04f, 0.06f, 0.1f, 0.97f);
static readonly Color RatingGold = new Color(0.95f, 0.8f, 0.5f);
const float RatingTableLeft = 340f;
const float RatingTableRight = 25f;
static RatingPanel BuildRatingPanel(RectTransform canvasRect)
{
RectTransform panel = CreatePanel(canvasRect, "RatingPanel", RatingBg);
SetRect(panel, new Vector2(0.5f, 0.5f), new Vector2(0.5f, 0.5f), new Vector2(0.5f, 0.5f), Vector2.zero, new Vector2(1640, 920));
RatingPanel rating = panel.gameObject.AddComponent<RatingPanel>();
// Заголовок-баннер
RectTransform banner = CreatePanel(panel, "TitleBanner", new Color(0.12f, 0.25f, 0.55f));
SetRect(banner, new Vector2(0.5f, 1), new Vector2(0.5f, 1), new Vector2(0.5f, 1), new Vector2(0, -15), new Vector2(640, 90));
CreateText(banner, "Title", "Рейтинг", 60).color = RatingGold;
RectTransform close = CreateButton(panel, "CloseButton", "X", new Color(0.35f, 0.15f, 0.15f), out _);
SetRect(close, new Vector2(1, 1), new Vector2(1, 1), new Vector2(1, 1), new Vector2(-15, -15), new Vector2(80, 80));
rating.closeButton = close.GetComponent<Button>();
// Разделы слева -- порядок совпадает с RatingCategory
string[] categories = { "Игроки", "Круги", "Мощь", "PvP", "Развитие", "Сезон" };
rating.categoryButtons = new Button[categories.Length];
for (int i = 0; i < categories.Length; i++)
{
RectTransform tab = CreateButton(panel, "Category_" + i, categories[i], new Color(0.1f, 0.12f, 0.17f), out _);
SetRect(tab, new Vector2(0, 1), new Vector2(0, 1), new Vector2(0, 1), new Vector2(25, -130 - i * 112), new Vector2(290, 100));
rating.categoryButtons[i] = tab.GetComponent<Button>();
}
// Таблица
var tableGO = new GameObject("Table", typeof(RectTransform));
RectTransform table = tableGO.GetComponent<RectTransform>();
table.SetParent(panel, false);
table.anchorMin = Vector2.zero;
table.anchorMax = Vector2.one;
table.offsetMin = new Vector2(RatingTableLeft, 110);
table.offsetMax = new Vector2(-RatingTableRight, -130);
RectTransform header = CreatePanel(table, "Header", new Color(0.08f, 0.1f, 0.15f));
SetRect(header, new Vector2(0, 1), new Vector2(1, 1), new Vector2(0.5f, 1), Vector2.zero, new Vector2(0, 50));
Color headerColor = new Color(0.8f, 0.82f, 0.88f);
PlaceInRow(CreateTextRect(header, "Place", "Место", 26), 0, 140, 50).GetComponent<TMP_Text>().color = headerColor;
TMP_Text nameHeader = PlaceInRow(CreateTextRect(header, "Name", "Игрок", 26, TextAlignmentOptions.Left), 225, 330, 50).GetComponent<TMP_Text>();
nameHeader.color = headerColor;
rating.nameColumnHeader = nameHeader;
TMP_Text circleHeader = PlaceInRow(CreateTextRect(header, "Circle", "Круг", 26, TextAlignmentOptions.Left), 625, 320, 50).GetComponent<TMP_Text>();
circleHeader.color = headerColor;
rating.circleColumnHeader = circleHeader;
PlaceInRow(CreateTextRect(header, "Power", "Мощь", 26), 950, 220, 50).GetComponent<TMP_Text>().color = headerColor;
rating.rows = new RatingRowView[RatingPanel.PageSize];
for (int i = 0; i < rating.rows.Length; i++)
{
rating.rows[i] = BuildRatingRow(table, i);
}
rating.emptyText = CreateText(table, "EmptyText", "Раздел в разработке", 40);
rating.emptyText.gameObject.SetActive(false);
RectTransform status = CreateTextRect(panel, "StatusText", "", 26, TextAlignmentOptions.Left);
SetRect(status, Vector2.zero, Vector2.zero, Vector2.zero, new Vector2(RatingTableLeft, 30), new Vector2(520, 60));
rating.statusText = status.GetComponent<TMP_Text>();
// Страницы: < 1 2 3 4 5 > -- по центру под таблицей (а не под всей панелью)
float tableCenterX = (RatingTableLeft - RatingTableRight) / 2f;
Color pageColor = new Color(0.1f, 0.12f, 0.17f);
RectTransform prev = CreateButton(panel, "PrevPage", "<", pageColor, out _);
SetRect(prev, new Vector2(0.5f, 0), new Vector2(0.5f, 0), new Vector2(0.5f, 0), new Vector2(tableCenterX - 240, 25), new Vector2(64, 64));
rating.prevPageButton = prev.GetComponent<Button>();
rating.pageButtons = new Button[5];
for (int i = 0; i < rating.pageButtons.Length; i++)
{
RectTransform page = CreateButton(panel, "Page_" + i, (i + 1).ToString(), pageColor, out _);
SetRect(page, new Vector2(0.5f, 0), new Vector2(0.5f, 0), new Vector2(0.5f, 0), new Vector2(tableCenterX - 150 + i * 75, 25), new Vector2(64, 64));
rating.pageButtons[i] = page.GetComponent<Button>();
}
RectTransform next = CreateButton(panel, "NextPage", ">", pageColor, out _);
SetRect(next, new Vector2(0.5f, 0), new Vector2(0.5f, 0), new Vector2(0.5f, 0), new Vector2(tableCenterX + 240, 25), new Vector2(64, 64));
rating.nextPageButton = next.GetComponent<Button>();
panel.gameObject.SetActive(false);
return rating;
}
static RatingRowView BuildRatingRow(RectTransform table, int index)
{
const float rowHeight = 54f;
var view = new RatingRowView();
RectTransform row = CreatePanel(table, "Row_" + (index + 1), new Color(0.07f, 0.09f, 0.13f));
SetRect(row, new Vector2(0, 1), new Vector2(1, 1), new Vector2(0.5f, 1), new Vector2(0, -56 - index * 58), new Vector2(0, rowHeight));
view.root = row.gameObject;
view.background = row.GetComponent<Image>();
RectTransform medal = CreatePanel(row, "Medal", Color.white);
PlaceInRow(medal, 45, 50, 50);
view.medal = medal.GetComponent<Image>();
view.placeText = PlaceInRow(CreateTextRect(row, "Place", "1", 30), 0, 140, rowHeight).GetComponent<TMP_Text>();
RectTransform avatar = CreatePanel(row, "Avatar", Color.gray);
PlaceInRow(avatar, 150, 48, 48);
view.avatar = avatar.GetComponent<Image>();
view.avatarLetter = CreateText(avatar, "Letter", "В", 28);
view.nameText = PlaceInRow(CreateTextRect(row, "Name", "Игрок", 30, TextAlignmentOptions.Left), 225, 330, rowHeight).GetComponent<TMP_Text>();
RectTransform circleBanner = CreatePanel(row, "CircleBanner", Color.blue);
PlaceInRow(circleBanner, 570, 36, 46);
view.circleBanner = circleBanner.GetComponent<Image>();
view.circleText = PlaceInRow(CreateTextRect(row, "Circle", "Круг", 28, TextAlignmentOptions.Left), 625, 320, rowHeight).GetComponent<TMP_Text>();
TMP_Text power = PlaceInRow(CreateTextRect(row, "Power", "0", 28), 950, 220, rowHeight).GetComponent<TMP_Text>();
power.color = RatingGold;
view.powerText = power;
RectTransform inspect = CreateButton(row, "InspectButton", "?", new Color(0.18f, 0.2f, 0.27f), out _);
PlaceInRow(inspect, 1190, 56, 44);
view.inspectButton = inspect.GetComponent<Button>();
return view;
}
// Элемент строки: прижат к левому краю строки с отступом x, по вертикали по центру
static RectTransform PlaceInRow(RectTransform rt, float x, float width, float height)
{
SetRect(rt, new Vector2(0, 0.5f), new Vector2(0, 0.5f), new Vector2(0, 0.5f), new Vector2(x, 0), new Vector2(width, height));
return rt;
}
static void SetRect(RectTransform rt, Vector2 anchorMin, Vector2 anchorMax, Vector2 pivot, Vector2 position, Vector2 size)
{
rt.anchorMin = anchorMin;
rt.anchorMax = anchorMax;
rt.pivot = pivot;
rt.sizeDelta = size;
rt.anchoredPosition = position;
}
// ---------------- 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,
TextAlignmentOptions alignment = TextAlignmentOptions.Center)
{
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 = alignment;
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
+154
View File
@@ -0,0 +1,154 @@
%YAML 1.1
%TAG !u! tag:unity3d.com,2011:
--- !u!1 &8587519695404222676
GameObject:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
serializedVersion: 6
m_Component:
- component: {fileID: 5056236775830869539}
- component: {fileID: 7776370013680216338}
- component: {fileID: 1708035113988781431}
- component: {fileID: 2152423632773446457}
m_Layer: 3
m_Name: Barracks
m_TagString: Untagged
m_Icon: {fileID: 0}
m_NavMeshLayer: 0
m_StaticEditorFlags: 0
m_IsActive: 1
--- !u!4 &5056236775830869539
Transform:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 8587519695404222676}
serializedVersion: 2
m_LocalRotation: {x: -0, y: -0, z: -0, w: 1}
m_LocalPosition: {x: -3.48, y: -1.22, z: 0}
m_LocalScale: {x: 0.16310804, y: 0.15743585, z: 1}
m_ConstrainProportionsScale: 0
m_Children: []
m_Father: {fileID: 0}
m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0}
--- !u!212 &7776370013680216338
SpriteRenderer:
serializedVersion: 3
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 8587519695404222676}
m_Enabled: 1
m_CastShadows: 0
m_ReceiveShadows: 0
m_DynamicOccludee: 1
m_StaticShadowCaster: 0
m_MotionVectors: 1
m_LightProbeUsage: 1
m_ReflectionProbeUsage: 1
m_RayTracingMode: 0
m_RayTraceProcedural: 0
m_RayTracingAccelStructBuildFlagsOverride: 0
m_RayTracingAccelStructBuildFlags: 1
m_SmallMeshCulling: 1
m_ForceMeshLod: -1
m_MeshLodSelectionBias: 0
m_RenderingLayerMask: 1
m_RendererPriority: 0
m_Materials:
- {fileID: 2100000, guid: a97c105638bdf8b4a8650670310a4cd3, type: 2}
m_StaticBatchInfo:
firstSubMesh: 0
subMeshCount: 0
m_StaticBatchRoot: {fileID: 0}
m_ProbeAnchor: {fileID: 0}
m_LightProbeVolumeOverride: {fileID: 0}
m_ScaleInLightmap: 1
m_ReceiveGI: 1
m_PreserveUVs: 0
m_IgnoreNormalsForChartDetection: 0
m_ImportantGI: 0
m_StitchLightmapSeams: 1
m_SelectedEditorRenderState: 0
m_MinimumChartSize: 4
m_AutoUVMaxDistance: 0.5
m_AutoUVMaxAngle: 89
m_LightmapParameters: {fileID: 0}
m_GlobalIlluminationMeshLod: 0
m_SortingLayerID: 0
m_SortingLayer: 0
m_SortingOrder: 2
m_MaskInteraction: 0
m_Sprite: {fileID: 2801366364622282508, guid: b35488e828328a54f88edf516b780ea5, type: 3}
m_Color: {r: 1, g: 1, b: 1, a: 1}
m_FlipX: 0
m_FlipY: 0
m_DrawMode: 0
m_Size: {x: 1, y: 1}
m_AdaptiveModeThreshold: 0.5
m_SpriteTileMode: 0
m_WasSpriteAssigned: 1
m_SpriteSortPoint: 0
m_BlendShapeWeights: []
--- !u!61 &1708035113988781431
BoxCollider2D:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 8587519695404222676}
m_Enabled: 1
serializedVersion: 3
m_Density: 1
m_Material: {fileID: 0}
m_IncludeLayers:
serializedVersion: 2
m_Bits: 0
m_ExcludeLayers:
serializedVersion: 2
m_Bits: 0
m_LayerOverridePriority: 0
m_ForceSendLayers:
serializedVersion: 2
m_Bits: 4294967295
m_ForceReceiveLayers:
serializedVersion: 2
m_Bits: 4294967295
m_ContactCaptureLayers:
serializedVersion: 2
m_Bits: 4294967295
m_CallbackLayers:
serializedVersion: 2
m_Bits: 4294967295
m_IsTrigger: 0
m_UsedByEffector: 0
m_CompositeOperation: 0
m_CompositeOrder: 0
m_Offset: {x: 0, y: 0}
m_SpriteTilingProperty:
border: {x: 0, y: 0, z: 0, w: 0}
pivot: {x: 0.5, y: 0.5}
oldSize: {x: 12.54, y: 12.54}
newSize: {x: 1, y: 1}
adaptiveTilingThreshold: 0.5
drawMode: 0
adaptiveTiling: 0
m_AutoTiling: 0
m_Size: {x: 12.54, y: 12.54}
m_EdgeRadius: 0
--- !u!114 &2152423632773446457
MonoBehaviour:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 8587519695404222676}
m_Enabled: 1
m_EditorHideFlags: 0
m_Script: {fileID: 11500000, guid: 96f3807928801df4a840181e38da1317, type: 3}
m_Name:
m_EditorClassIdentifier: Assembly-CSharp::Barracks
@@ -0,0 +1,7 @@
fileFormatVersion: 2
guid: bd259e52fed42bc48a1352e78ce97819
PrefabImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,23 @@
%YAML 1.1
%TAG !u! tag:unity3d.com,2011:
--- !u!114 &11400000
MonoBehaviour:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 0}
m_Enabled: 1
m_EditorHideFlags: 0
m_Script: {fileID: 11500000, guid: 6ce7334b9f0b4914ba6750d2a2d35155, type: 3}
m_Name: Data_Barracks
m_EditorClassIdentifier: Assembly-CSharp::BuildingData
buildingName: "\u041A\u0430\u0437\u0430\u0440\u043C\u044B"
icon: {fileID: 2801366364622282508, guid: b35488e828328a54f88edf516b780ea5, type: 3}
prefab: {fileID: 8587519695404222676, guid: bd259e52fed42bc48a1352e78ce97819, type: 3}
requiredTownHallLevel: 1
cost:
- type: 2
amount: 300
- type: 3
amount: 200
@@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: 16e55e952b334ec488bbee6b42448828
NativeFormatImporter:
externalObjects: {}
mainObjectFileID: 11400000
userData:
assetBundleName:
assetBundleVariant:
+4 -4
View File
@@ -237,7 +237,7 @@ MonoBehaviour:
m_FallbackScreenDPI: 96
m_DefaultSpriteDPI: 96
m_DynamicPixelsPerUnit: 100
m_PresetInfoIsWorld: 0
m_PresetInfoIsWorld: 1
--- !u!114 &3086347143011804087
MonoBehaviour:
m_ObjectHideFlags: 0
@@ -720,9 +720,9 @@ SpriteRenderer:
m_GlobalIlluminationMeshLod: 0
m_SortingLayerID: 0
m_SortingLayer: 0
m_SortingOrder: 0
m_SortingOrder: 2
m_MaskInteraction: 0
m_Sprite: {fileID: -6104705389903059128, guid: 2e8301040a0e9964c82256869c0a9498, type: 3}
m_Sprite: {fileID: -767853006991775525, guid: 0f4911df226c33e40975fb78c06568f5, type: 3}
m_Color: {r: 0.8627452, g: 0.8627452, b: 0.8627452, a: 1}
m_FlipX: 0
m_FlipY: 0
@@ -806,7 +806,7 @@ BoxCollider2D:
m_SpriteTilingProperty:
border: {x: 0, y: 0, z: 0, w: 0}
pivot: {x: 0.5, y: 0.5}
oldSize: {x: 1.7914286, y: 1.7914286}
oldSize: {x: 3.382857, y: 3.0857143}
newSize: {x: 1, y: 1}
adaptiveTilingThreshold: 0.5
drawMode: 0
+4 -4
View File
@@ -83,10 +83,10 @@ SpriteRenderer:
m_GlobalIlluminationMeshLod: 0
m_SortingLayerID: 0
m_SortingLayer: 0
m_SortingOrder: 0
m_SortingOrder: 2
m_MaskInteraction: 0
m_Sprite: {fileID: 2801366364622282508, guid: 7ba171d8f7d5a8c498a0f9423ccf858d, type: 3}
m_Color: {r: 0.9622642, g: 0.9069921, b: 0.063545614, a: 1}
m_Sprite: {fileID: 6491965565530788803, guid: 7eac2cc728cf1a54f962afb35703891e, type: 3}
m_Color: {r: 1, g: 1, b: 1, a: 1}
m_FlipX: 0
m_FlipY: 0
m_DrawMode: 0
@@ -152,7 +152,7 @@ BoxCollider2D:
m_SpriteTilingProperty:
border: {x: 0, y: 0, z: 0, w: 0}
pivot: {x: 0.5, y: 0.5}
oldSize: {x: 1.8138462, y: 1.843077}
oldSize: {x: 3.9466667, y: 3.5866666}
newSize: {x: 1, y: 1}
adaptiveTilingThreshold: 0.5
drawMode: 0
+3 -3
View File
@@ -194,9 +194,9 @@ SpriteRenderer:
m_GlobalIlluminationMeshLod: 0
m_SortingLayerID: 0
m_SortingLayer: 0
m_SortingOrder: 0
m_SortingOrder: 2
m_MaskInteraction: 0
m_Sprite: {fileID: 2801366364622282508, guid: 53891195a0ed1e84490e5a1b49760252, type: 3}
m_Sprite: {fileID: 4535865847468002376, guid: 6aa747c9218e809439e10ede0685b401, type: 3}
m_Color: {r: 0.8274511, g: 0.8274511, b: 0.8274511, a: 1}
m_FlipX: 0
m_FlipY: 0
@@ -263,7 +263,7 @@ BoxCollider2D:
m_SpriteTilingProperty:
border: {x: 0, y: 0, z: 0, w: 0}
pivot: {x: 0.5, y: 0.5}
oldSize: {x: 1.6471429, y: 1.7428571}
oldSize: {x: 16.22, y: 10.8}
newSize: {x: 1, y: 1}
adaptiveTilingThreshold: 0.5
drawMode: 0
+1 -1
View File
@@ -645,7 +645,7 @@ SpriteRenderer:
m_GlobalIlluminationMeshLod: 0
m_SortingLayerID: 0
m_SortingLayer: 0
m_SortingOrder: 0
m_SortingOrder: 2
m_MaskInteraction: 0
m_Sprite: {fileID: 2801366364622282508, guid: 8d11281e09df9ae47951c737bac19189, type: 3}
m_Color: {r: 0.8274511, g: 0.8274511, b: 0.8274511, a: 1}
+6 -6
View File
@@ -37,8 +37,8 @@ RectTransform:
m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0}
m_AnchorMin: {x: 0.5, y: 0.5}
m_AnchorMax: {x: 0.5, y: 0.5}
m_AnchoredPosition: {x: -20, y: -0.36}
m_SizeDelta: {x: 2.1285, y: 1.0159}
m_AnchoredPosition: {x: -19.849152, y: -0.30836427}
m_SizeDelta: {x: 2.2826, y: 1.0159}
m_Pivot: {x: 0.5, y: 0.5}
--- !u!212 &1626548957784810666
SpriteRenderer:
@@ -87,9 +87,9 @@ SpriteRenderer:
m_GlobalIlluminationMeshLod: 0
m_SortingLayerID: 0
m_SortingLayer: 0
m_SortingOrder: 0
m_SortingOrder: 2
m_MaskInteraction: 0
m_Sprite: {fileID: 2475969391606739167, guid: 50ec29d5e415278408ddf08fae2f56a8, type: 3}
m_Sprite: {fileID: -2103617111491369785, guid: 73513117b87e55849b59b2a21a09cc71, type: 3}
m_Color: {r: 0.8274511, g: 0.8274511, b: 0.8274511, a: 1}
m_FlipX: 0
m_FlipY: 0
@@ -156,7 +156,7 @@ BoxCollider2D:
m_SpriteTilingProperty:
border: {x: 0, y: 0, z: 0, w: 0}
pivot: {x: 0.5, y: 0.5}
oldSize: {x: 1.6157143, y: 1.72}
oldSize: {x: 12, y: 10.68}
newSize: {x: 1, y: 1}
adaptiveTilingThreshold: 0.5
drawMode: 0
@@ -356,7 +356,7 @@ MonoBehaviour:
m_FallbackScreenDPI: 96
m_DefaultSpriteDPI: 96
m_DynamicPixelsPerUnit: 100
m_PresetInfoIsWorld: 1
m_PresetInfoIsWorld: 0
--- !u!114 &6507110458437413086
MonoBehaviour:
m_ObjectHideFlags: 0
+1 -1
View File
@@ -683,7 +683,7 @@ SpriteRenderer:
m_GlobalIlluminationMeshLod: 0
m_SortingLayerID: 0
m_SortingLayer: 0
m_SortingOrder: 0
m_SortingOrder: 2
m_MaskInteraction: 0
m_Sprite: {fileID: 2420144614108001737, guid: be1f4bd6f5da7444faa44a2e1e786511, type: 3}
m_Color: {r: 0.9607844, g: 0.9607844, b: 0.9607844, a: 1}
+1 -1
View File
@@ -395,7 +395,7 @@ SpriteRenderer:
m_GlobalIlluminationMeshLod: 0
m_SortingLayerID: 0
m_SortingLayer: 0
m_SortingOrder: 0
m_SortingOrder: 2
m_MaskInteraction: 0
m_Sprite: {fileID: -6104705389903059128, guid: 0b3398c71c26e0b4596b76da3680f310, type: 3}
m_Color: {r: 1, g: 1, b: 1, a: 1}
+11561 -143
View File
File diff suppressed because it is too large Load Diff
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:
+2 -2
View File
@@ -16,7 +16,7 @@ public class CameraController : MonoBehaviour
void Update()
{
// --- Перемещение по зажатой левой кнопке мыши ---
if (Input.GetMouseButtonDown(0))
if (Input.GetMouseButtonDown(0) && !UIBlocker.IsPointerOverUI()) // не тащим камеру, когда листаем панель
{
_lastMousePosition = Input.mousePosition;
_isDragging = true;
@@ -39,7 +39,7 @@ public class CameraController : MonoBehaviour
// --- Зум по колёсику мыши ---
float scroll = Input.GetAxis("Mouse ScrollWheel");
if (scroll != 0f)
if (scroll != 0f && !UIBlocker.IsPointerOverUI())
{
Camera cam = GetComponent<Camera>();
float targetSize = cam.orthographicSize - scroll * zoomSpeed;
+161
View File
@@ -0,0 +1,161 @@
using UnityEngine;
// КАМЕРА ГОРОДА (как в мобильных стратегиях):
// - перетаскивание одним пальцем / левой кнопкой мыши -- двигает карту
// - щипок двумя пальцами / колёсико мыши -- приближение и отдаление (к точке под пальцем/курсором)
// - камера не уезжает за границы карты (bounds)
// - над интерфейсом (HUD, панели) ничего не делает
// - без поворотов и наклонов: изометрия уже нарисована в спрайтах
// Вешается на Main Camera вместо CameraController / CameraZoom / FlyCamera.
[RequireComponent(typeof(Camera))]
public class CityCamera : MonoBehaviour
{
[Header("Зум (orthographic size)")]
public float minZoom = 3f;
public float maxZoom = 14f;
public float wheelZoomSpeed = 2.5f;
public float zoomSmooth = 12f;
[Header("Границы карты (мировые координаты). Пусто = посчитать по объектам сцены")]
public bool autoBounds = true;
public Rect bounds = new Rect(-25, -15, 50, 30);
[Tooltip("Запас за краем карты, чтобы край не прилипал к экрану")]
public float boundsPadding = 4f;
[Header("Инерция после свайпа")]
public float inertiaDamping = 6f;
Camera cam;
float targetZoom;
Vector3 lastWorld;
bool dragging;
Vector3 velocity;
float lastPinchDist;
void Awake()
{
cam = GetComponent<Camera>();
cam.orthographic = true;
transform.rotation = Quaternion.identity;
transform.localScale = Vector3.one; // старый CameraZoom масштабировал камеру -- сбрасываем
targetZoom = Mathf.Clamp(cam.orthographicSize, minZoom, maxZoom);
}
void Start()
{
if (autoBounds) CalculateBounds();
ClampPosition();
}
// Границы по всем спрайтам и тайлмапам сцены (кроме UI)
public void CalculateBounds()
{
bool any = false;
Bounds b = new Bounds();
foreach (var r in FindObjectsByType<Renderer>(FindObjectsSortMode.None))
{
string tn = r.GetType().Name;
if (tn != "SpriteRenderer" && tn != "TilemapRenderer") continue; // только карта и здания
if (r.gameObject.layer == 5) continue; // UI
if (r.GetComponentInParent<Canvas>() != null) continue; // всё, что внутри интерфейса
if (!any) { b = r.bounds; any = true; } else b.Encapsulate(r.bounds);
}
if (any) bounds = new Rect(b.min.x, b.min.y, b.size.x, b.size.y);
}
void Update()
{
bool overUI = UIBlocker.IsPointerOverUI();
if (Input.touchCount >= 2) HandlePinch();
else HandleDrag(overUI);
// колёсико мыши -- зум к курсору
float wheel = Input.GetAxis("Mouse ScrollWheel");
if (Mathf.Abs(wheel) > 0.001f && !overUI)
ZoomTowards(Input.mousePosition, targetZoom - wheel * wheelZoomSpeed * 4f);
// плавный зум
if (Mathf.Abs(cam.orthographicSize - targetZoom) > 0.001f)
{
Vector3 before = cam.ScreenToWorldPoint(zoomAnchor);
cam.orthographicSize = Mathf.Lerp(cam.orthographicSize, targetZoom, Time.unscaledDeltaTime * zoomSmooth);
Vector3 after = cam.ScreenToWorldPoint(zoomAnchor);
transform.position += before - after; // точка под пальцем остаётся на месте
}
// инерция
if (!dragging && velocity.sqrMagnitude > 0.0001f)
{
transform.position += velocity * Time.unscaledDeltaTime;
velocity = Vector3.Lerp(velocity, Vector3.zero, Time.unscaledDeltaTime * inertiaDamping);
}
ClampPosition();
}
Vector3 zoomAnchor;
void ZoomTowards(Vector3 screenPoint, float zoom)
{
zoomAnchor = screenPoint;
targetZoom = Mathf.Clamp(zoom, minZoom, maxZoom);
}
void HandleDrag(bool overUI)
{
if (Input.GetMouseButtonDown(0))
{
dragging = !overUI; // начали тащить на интерфейсе -- карту не двигаем
velocity = Vector3.zero;
lastWorld = cam.ScreenToWorldPoint(Input.mousePosition);
}
if (Input.GetMouseButtonUp(0)) dragging = false;
if (!dragging || !Input.GetMouseButton(0)) return;
Vector3 now = cam.ScreenToWorldPoint(Input.mousePosition);
Vector3 delta = lastWorld - now;
delta.z = 0;
transform.position += delta;
if (Time.unscaledDeltaTime > 0) velocity = Vector3.Lerp(velocity, delta / Time.unscaledDeltaTime, 0.5f);
lastWorld = cam.ScreenToWorldPoint(Input.mousePosition); // после сдвига камеры
}
void HandlePinch()
{
dragging = false;
velocity = Vector3.zero;
Touch a = Input.GetTouch(0), b = Input.GetTouch(1);
float dist = Vector2.Distance(a.position, b.position);
if (a.phase == TouchPhase.Began || b.phase == TouchPhase.Began || lastPinchDist <= 0f)
{
lastPinchDist = dist;
return;
}
if (dist > 1f)
{
float ratio = lastPinchDist / dist;
ZoomTowards((a.position + b.position) * 0.5f, targetZoom * ratio);
}
lastPinchDist = dist;
if (a.phase == TouchPhase.Ended || b.phase == TouchPhase.Ended) lastPinchDist = 0f;
}
void ClampPosition()
{
float halfH = cam.orthographicSize;
float halfW = halfH * cam.aspect;
Rect r = new Rect(bounds.xMin - boundsPadding, bounds.yMin - boundsPadding,
bounds.width + boundsPadding * 2, bounds.height + boundsPadding * 2);
Vector3 p = transform.position;
p.x = r.width > halfW * 2 ? Mathf.Clamp(p.x, r.xMin + halfW, r.xMax - halfW) : r.center.x;
p.y = r.height > halfH * 2 ? Mathf.Clamp(p.y, r.yMin + halfH, r.yMax - halfH) : r.center.y;
transform.position = p;
}
void OnDrawGizmosSelected()
{
Gizmos.color = Color.cyan;
Gizmos.DrawWireCube(bounds.center, bounds.size);
}
}
+2
View File
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: d9530f2e8714d3746b25452c7fd56cd3
+130
View File
@@ -0,0 +1,130 @@
using UnityEngine;
using UnityEngine.UI;
// Отвязывает HUD от камеры и ставит одну нормальную камеру.
//
// Было: HUD_Canvas в режиме World Space был дочерним объектом камеры, а CameraZoom масштабировал
// саму камеру, чтобы HUD не уменьшался при зуме. Поэтому HUD ехал, дрожал и масштабировался криво.
// Стало: HUD_Canvas -- Screen Space Overlay (всегда приклеен к экрану, камера на него не влияет),
// на камере -- только CityCamera (перетаскивание, щипок, колёсико, границы карты).
//
// Работает в двух местах:
// - при запуске игры (автоматически), если сцену ещё не исправили в редакторе;
// - Tools -> Fix Camera & HUD (или Build HUD Panels) -- исправляет и сохраняет сцену навсегда.
public static class HUDCameraSetup
{
// ОТКЛЮЧЕНО по просьбе команды: камера и HUD вернулись к прежней схеме (Tools -> Restore Old Camera & HUD).
// Автоисправление при запуске больше не выполняется.
static void AutoFix()
{
// и при каждой следующей загрузке города (возврат с карты мира)
UnityEngine.SceneManagement.SceneManager.sceneLoaded += (s, m) => Apply(false);
int changes = Apply(false);
if (changes > 0)
Debug.Log("[КАМЕРА/HUD] Исправлено при запуске: " + changes +
". Чтобы сохранить в сцене навсегда -- Tools -> Fix Camera & HUD");
}
// editMode = true -- вызывается из редактора: лишние компоненты удаляются, а не выключаются
public static int Apply(bool editMode)
{
int changes = 0;
// ---------- HUD ----------
var hudGO = GameObject.Find("HUD_Canvas");
if (hudGO != null)
{
var canvas = hudGO.GetComponent<Canvas>();
var rt = (RectTransform)hudGO.transform;
if (canvas != null && canvas.renderMode != RenderMode.ScreenSpaceOverlay)
{
Vector2 designSize = rt.sizeDelta; // размер, под который дизайнер расставил HUD
if (designSize.x < 100 || designSize.y < 100) designSize = new Vector2(1080, 1920);
if (hudGO.transform.parent != null) hudGO.transform.SetParent(null, false);
canvas.renderMode = RenderMode.ScreenSpaceOverlay;
canvas.sortingOrder = 100;
rt.localScale = Vector3.one;
rt.localRotation = Quaternion.identity;
var scaler = hudGO.GetComponent<CanvasScaler>();
if (scaler == null) scaler = hudGO.AddComponent<CanvasScaler>();
scaler.uiScaleMode = CanvasScaler.ScaleMode.ScaleWithScreenSize;
scaler.referenceResolution = designSize; // HUD выглядит так же, как его собрал дизайнер
scaler.screenMatchMode = CanvasScaler.ScreenMatchMode.MatchWidthOrHeight;
scaler.matchWidthOrHeight = 1f; // как было в мире: высота HUD = высота экрана
changes++;
}
// высота HUD = высота экрана (так HUD выглядел, пока был привязан к камере)
var sc = hudGO.GetComponent<CanvasScaler>();
if (sc != null && canvas != null && canvas.renderMode == RenderMode.ScreenSpaceOverlay && sc.matchWidthOrHeight != 1f)
{ sc.matchWidthOrHeight = 1f; changes++; }
changes += ConvertSpritesToImages(hudGO, editMode);
changes += RemoveOrDisable(hudGO, "HUDWorldSpaceSync", editMode);
}
// ---------- КАМЕРА ----------
var cam = Camera.main;
if (cam != null)
{
changes += RemoveOrDisable(cam.gameObject, "CameraZoom", editMode);
changes += RemoveOrDisable(cam.gameObject, "CameraController", editMode);
changes += RemoveOrDisable(cam.gameObject, "FlyCamera", editMode);
if (cam.transform.localScale != Vector3.one) { cam.transform.localScale = Vector3.one; changes++; }
if (cam.transform.rotation != Quaternion.identity) { cam.transform.rotation = Quaternion.identity; changes++; }
if (cam.GetComponent<CityCamera>() == null) { cam.gameObject.AddComponent<CityCamera>(); changes++; }
}
return changes;
}
// В экранном Canvas обычные SpriteRenderer не рисуются как часть интерфейса -- они улетают в мир
// огромными картинками (отсюда белая "клетка" на весь экран). Переделываем их в UI Image того же размера.
static int ConvertSpritesToImages(GameObject hud, bool editMode)
{
int n = 0;
foreach (var sr in hud.GetComponentsInChildren<SpriteRenderer>(true))
{
if (sr.sprite == null) { sr.enabled = false; continue; }
var t = sr.transform;
var parent = t.parent as RectTransform;
if (parent == null) { sr.enabled = false; continue; }
var go = new GameObject(t.name + "_UI", typeof(RectTransform), typeof(Image));
var rt = (RectTransform)go.transform;
rt.SetParent(parent, false);
rt.SetSiblingIndex(t.GetSiblingIndex());
rt.anchorMin = rt.anchorMax = parent.pivot; // localPosition считается от pivot родителя
rt.pivot = new Vector2(0.5f, 0.5f);
Vector2 size = Vector2.Scale(sr.sprite.bounds.size, new Vector2(Mathf.Abs(t.localScale.x), Mathf.Abs(t.localScale.y)));
rt.sizeDelta = size;
rt.localPosition = t.localPosition + (Vector3)Vector2.Scale(sr.sprite.bounds.center, t.localScale);
rt.localRotation = t.localRotation;
var img = go.GetComponent<Image>();
img.sprite = sr.sprite;
img.color = sr.color;
img.raycastTarget = false;
img.preserveAspect = false;
if (editMode) Object.DestroyImmediate(t.gameObject);
else t.gameObject.SetActive(false);
n++;
}
return n;
}
// Компоненты ищем по имени класса: часть из них (FlyCamera, HUDWorldSpaceSync) в проекте уже нет
static int RemoveOrDisable(GameObject go, string typeName, bool editMode)
{
int n = 0;
foreach (var mb in go.GetComponents<MonoBehaviour>())
{
if (mb == null || mb.GetType().Name != typeName) continue;
if (editMode) Object.DestroyImmediate(mb);
else if (mb.enabled) mb.enabled = false;
else continue;
n++;
}
return n;
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 256165dd8ffe7dc4490c9e5b9796b782
@@ -57,7 +57,7 @@ public class BuildManager : MonoBehaviour
slotUnderPointer.SetHighlight(canAfford ? highlightValidColor : highlightInvalidColor);
lastHighlightedSlot = slotUnderPointer;
if (Input.GetMouseButtonDown(0))
if (Input.GetMouseButtonDown(0) && !UIBlocker.IsPointerOverUI())
{
TryConfirmPlacement(slotUnderPointer, canAfford);
}
@@ -113,6 +113,7 @@ public class BuildManager : MonoBehaviour
currentlyPlacing.PayCost();
GameObject built = Instantiate(currentlyPlacing.prefab, slot.transform.position, Quaternion.identity);
if (!built.activeSelf) built.SetActive(true); // шаблон казарм хранится выключенным
PlacedBuildingId idMarker = built.AddComponent<PlacedBuildingId>();
idMarker.buildingName = currentlyPlacing.buildingName;
@@ -139,6 +140,39 @@ public class BuildManager : MonoBehaviour
Debug.Log("[СТРОЙКА] Размещение отменено.");
}
// ===== Новая схема стройки: тап по участку -> выбор здания (BuildModeController) =====
// Построить здание сразу на указанном участке. true -- получилось.
public bool BuildOnSlot(BuildingData data, BuildSlot slot)
{
if (data == null || slot == null || slot.isOccupied) return false;
if (!data.CanAfford()) return false;
currentlyPlacing = data;
TryConfirmPlacement(slot, true);
return true;
}
// Участок под точкой экрана (с допуском, чтобы попадать с первого раза пальцем)
public BuildSlot SlotAtScreen(Vector2 screenPos, float tolerance = 0.35f)
{
if (mainCam == null) mainCam = Camera.main;
if (mainCam == null) return null;
Vector3 w = mainCam.ScreenToWorldPoint(screenPos);
Vector2 p = new Vector2(w.x, w.y);
// Перебираем все участки и берём ближайший: работает и с триггер-коллайдерами, и без настроенного слоя
BuildSlot best = null;
float bestDist = tolerance;
foreach (var slot in FindObjectsByType<BuildSlot>(FindObjectsSortMode.None))
{
var col = slot.GetComponent<Collider2D>();
Bounds b = col != null ? col.bounds : new Bounds(slot.transform.position, Vector3.one * 0.5f);
b.center = new Vector3(b.center.x, b.center.y, 0f);
float d = Mathf.Sqrt(b.SqrDistance(new Vector3(p.x, p.y, 0f)));
if (d <= bestDist) { bestDist = d; best = slot; }
}
return best;
}
// Найти пустой (свободный) BuildSlot по названию -- используется при восстановлении сохранения
public BuildSlot FindSlotAtPosition(Vector3 position, float tolerance = 0.05f)
{
@@ -74,6 +74,7 @@ public class TownHall : MonoBehaviour
}
void OnMouseDown()
{
if (UIBlocker.IsPointerOverUI()) return; // клик по панели HUD не должен улучшать Ратушу под ней
Debug.Log($"[Ратуша] Клик! текущий уровень: {currentLevel}. Улучшение...");
Upgrade();
}
@@ -46,6 +46,7 @@ public class ResourceCollector : MonoBehaviour
void OnMouseDown()
{
if (UIBlocker.IsPointerOverUI()) return; // клик по панели HUD не должен собирать ресурс под ней
if (isRecharging)
{
Debug.Log($"[КЛИК] {gameObject.name}: уже идёт восстановление, ждём.");
@@ -1,4 +1,3 @@
using Unity.Microsoft.GDK;
using UnityEngine;
// Вешается на ЛЮБОЕ здание (лесопилка, каменоломня, шахта) -- настраиваешь
+8
View File
@@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: a1ddaca768e77fb4eb13bdb65900c14c
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:
+8
View File
@@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: f4d34c90fa3d07d4c8f7b6cd6dcc3864
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:
+73
View File
@@ -0,0 +1,73 @@
using System.Collections.Generic;
using UnityEngine;
using TMPro;
// КАЗАРМЫ -- здание для найма войск.
// - Без казарм нанимать войска нельзя.
// - Каждые казармы дают +1 место в очереди обучения (1 казармы = 3 места, максимум 5).
// - Нажатие на казармы открывает панель "Войска" (найм).
// - Над зданием подпись: что сейчас обучается и сколько осталось.
// Вешается на префаб Barracks (его создаёт Tools -> Build HUD Panels).
public class Barracks : MonoBehaviour
{
public static readonly List<Barracks> All = new List<Barracks>();
public static int Count { get { return All.Count; } }
// Размер очереди обучения в зависимости от числа казарм
public static int QueueSize { get { return Count == 0 ? 0 : Mathf.Min(5, 2 + Count); } }
TextMeshPro label;
float next;
void OnEnable() { if (!All.Contains(this)) All.Add(this); }
void OnDisable() { All.Remove(this); }
void Start()
{
// подпись над зданием (обычный 3D-текст, не UI)
var go = new GameObject("BarracksLabel");
go.transform.SetParent(transform, false);
var sr = GetComponent<SpriteRenderer>();
float top = sr != null && sr.sprite != null ? sr.sprite.bounds.max.y : 1f;
go.transform.localPosition = new Vector3(0, top + 0.15f, 0);
label = go.AddComponent<TextMeshPro>();
label.fontSize = 3f;
label.alignment = TextAlignmentOptions.Center;
label.rectTransform.sizeDelta = new Vector2(6f, 1.2f);
label.color = Color.white;
label.outlineWidth = 0.2f;
label.outlineColor = Color.black;
var r = label.GetComponent<MeshRenderer>();
if (r != null) r.sortingOrder = (sr != null ? sr.sortingOrder : 0) + 10;
Refresh();
}
void Update()
{
if (Time.unscaledTime < next) return;
next = Time.unscaledTime + 0.5f;
Refresh();
}
void Refresh()
{
if (label == null) return;
var d = GameProgress.Instance.Data;
if (d.training.Count == 0)
{
label.text = "Казармы\n<size=70%>нажмите, чтобы нанять</size>";
return;
}
var o = d.training[0];
label.text = "Казармы\n<size=70%>" + GameData.Troops[o.troopIndex].name + " ×" + o.count + " " +
GameData.FormatTime(GameProgress.Instance.TrainingSecondsLeft(o)) +
(d.training.Count > 1 ? " (+" + (d.training.Count - 1) + ")" : "") + "</size>";
}
void OnMouseDown()
{
if (UIBlocker.IsPointerOverUI()) return;
if (BuildModeController.Instance != null && BuildModeController.Instance.IsActive) return;
if (UIPanelManager.Instance != null) UIPanelManager.Instance.Open("Troops");
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 96f3807928801df4a840181e38da1317
@@ -0,0 +1,67 @@
using UnityEngine;
// Добавляет "Казармы" в список стройки САМ при запуске сцены -- даже если никто не запускал
// Tools -> Build HUD Panels. Если в BuildManager уже есть здание "Казармы" (из Data_Barracks.asset),
// ничего не делает.
//
// Срабатывает после Awake всех объектов, но ДО Start -- то есть раньше, чем GameBootstrap загрузит
// сохранение. Поэтому построенные казармы корректно восстанавливаются из save.json.
public static class BarracksBootstrap
{
public const string BarracksName = "Казармы";
static GameObject runtimeTemplate;
[RuntimeInitializeOnLoadMethod(RuntimeInitializeLoadType.AfterSceneLoad)]
static void Init()
{
Ensure();
// при каждом новом входе в город (например, возврат с карты мира) -- снова добавить в новый BuildManager
UnityEngine.SceneManagement.SceneManager.sceneLoaded += (s, m) => Ensure();
}
public static void Ensure()
{
var bm = BuildManager.Instance != null ? BuildManager.Instance : Object.FindAnyObjectByType<BuildManager>();
if (bm == null || bm.allBuildings == null) return;
BuildingData template = null;
foreach (var b in bm.allBuildings)
{
if (b == null) continue;
if (b.buildingName == BarracksName) return; // уже есть
if (template == null && b.prefab != null) template = b;
}
if (template == null) { Debug.LogWarning("[КАЗАРМЫ] Нет ни одного здания-шаблона в BuildManager"); return; }
// Префаб-шаблон в памяти: копия обычного здания без сбора ресурсов, с компонентом Barracks
if (runtimeTemplate == null)
{
runtimeTemplate = Object.Instantiate(template.prefab);
runtimeTemplate.name = "Barracks";
runtimeTemplate.SetActive(false);
Object.DontDestroyOnLoad(runtimeTemplate);
foreach (var c in runtimeTemplate.GetComponents<ResourceCollector>()) Object.DestroyImmediate(c);
foreach (var c in runtimeTemplate.GetComponents<ResourceProducer>()) Object.DestroyImmediate(c);
foreach (var c in runtimeTemplate.GetComponents<BuildingUI>()) Object.DestroyImmediate(c);
for (int i = runtimeTemplate.transform.childCount - 1; i >= 0; i--)
Object.DestroyImmediate(runtimeTemplate.transform.GetChild(i).gameObject); // полоска запаса ресурса
var sr = runtimeTemplate.GetComponent<SpriteRenderer>();
if (sr != null) sr.color = new Color(1f, 0.55f, 0.55f); // пока нет арта -- отличаем цветом
runtimeTemplate.AddComponent<Barracks>();
}
var data = ScriptableObject.CreateInstance<BuildingData>();
data.name = "Data_Barracks_Runtime";
data.buildingName = BarracksName;
data.icon = template.icon;
data.prefab = runtimeTemplate;
data.requiredTownHallLevel = 1;
data.cost = new[]
{
new ResourceCost { type = ResourceType.Wood, amount = 300 },
new ResourceCost { type = ResourceType.Stone, amount = 200 },
};
bm.allBuildings.Add(data);
Debug.Log("[КАЗАРМЫ] Добавлены в список стройки");
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 27c12847dd0a97744aab16c30fab3f66
@@ -0,0 +1,234 @@
using System.Collections.Generic;
using UnityEngine;
using UnityEngine.UI;
using TMPro;
// НОВАЯ СХЕМА СТРОЙКИ (как в Rise of Kingdoms):
// 1. Кнопка "СТРОЙКА" включает режим строительства -- появляются свободные участки и подсказка сверху.
// 2. Тап по свободному участку -> окно со зданиями (цена, что доступно по уровню Ратуши).
// 3. "Построить" -> здание встаёт на участок. Режим остаётся включённым, можно строить дальше.
// 4. Кнопка "ВЫЙТИ" (та же кнопка или в подсказке) -- выключает режим, участки снова прячутся.
// Вне режима пустые участки не видны и не мешают, при запуске игры ничего не открывается.
//
// Вешается билдером (Tools -> Build HUD Panels) на HUD_Canvas, кнопка Nav_Build вызывает ToggleMode().
public class BuildModeController : MonoBehaviour
{
public static BuildModeController Instance { get; private set; }
[Tooltip("Кнопка HUD, которая включает/выключает режим (надпись меняется сама)")]
public Button modeButton;
[Tooltip("Старое меню стройки -- прячем, оно больше не используется")]
public GameObject legacyBuildMenu;
public bool IsActive { get; private set; }
GameObject banner;
GameObject chooser;
Vector2 downPos;
bool downOverUI;
float nextVisualRefresh;
static readonly Color SlotColor = new Color(0.3f, 1f, 0.4f, 0.75f);
void Awake() { Instance = this; }
void OnDestroy() { if (Instance == this) Instance = null; }
void Start()
{
if (legacyBuildMenu != null) legacyBuildMenu.SetActive(false);
SetMode(false);
}
public void ToggleMode() { SetMode(!IsActive); }
public void SetMode(bool active)
{
IsActive = active;
CloseChooser();
ApplySlotVisuals();
UpdateButtonLabel();
if (banner != null) { Destroy(banner); banner = null; }
if (active)
{
if (UIPanelManager.Instance != null) UIPanelManager.Instance.CloseAll();
banner = CreateBanner();
if (FreeSlots().Count == 0) UIKit.Toast("Свободных участков нет. Улучшайте Ратушу и сносите старое.");
}
}
void Update()
{
if (Time.unscaledTime >= nextVisualRefresh)
{
nextVisualRefresh = Time.unscaledTime + 0.3f;
ApplySlotVisuals(); // участки могли освободиться/заняться (снос, загрузка сохранения)
}
if (!IsActive) return;
// открыли любую панель HUD -- выходим из режима стройки
if (UIPanelManager.Instance != null && UIPanelManager.Instance.CurrentId != "") { SetMode(false); return; }
if (chooser != null) return;
// Тап = нажали и отпустили почти на том же месте (чтобы перетаскивание камеры не считалось тапом)
if (Input.GetMouseButtonDown(0))
{
downPos = Input.mousePosition;
downOverUI = UIBlocker.IsPointerOverUI();
}
if (Input.GetMouseButtonUp(0))
{
Vector2 up = Input.mousePosition;
if (downOverUI || UIBlocker.IsPointerOverUI() || (up - downPos).magnitude > 25f) return;
HandleTap(up);
}
}
void HandleTap(Vector2 screenPos)
{
if (BuildManager.Instance == null) return;
var slot = BuildManager.Instance.SlotAtScreen(screenPos);
if (slot == null) return;
if (slot.isOccupied)
{
var id = slot.occupyingBuilding != null ? slot.occupyingBuilding.GetComponent<PlacedBuildingId>() : null;
UIKit.Toast("Участок занят" + (id != null ? ": " + id.buildingName : ""));
return;
}
OpenChooser(slot);
}
// ---------------- участки ----------------
List<BuildSlot> FreeSlots()
{
var list = new List<BuildSlot>();
foreach (var s in FindObjectsByType<BuildSlot>(FindObjectsSortMode.None)) if (!s.isOccupied) list.Add(s);
return list;
}
void ApplySlotVisuals()
{
foreach (var s in FindObjectsByType<BuildSlot>(FindObjectsSortMode.None))
{
var sr = s.GetComponent<SpriteRenderer>();
if (sr == null) continue;
bool show = IsActive && !s.isOccupied;
if (sr.enabled != show) sr.enabled = show;
if (show) sr.color = SlotColor;
}
}
// ---------------- окно выбора здания ----------------
void OpenChooser(BuildSlot slot)
{
CloseChooser();
var bm = BuildManager.Instance;
GameObject root;
var card = UIKit.Modal("Что построить?", out root);
chooser = root;
// окно фиксированного размера с прокруткой, чтобы влезало на любом экране
var fit = card.GetComponent<ContentSizeFitter>();
if (fit != null) DestroyImmediate(fit);
card.anchorMin = new Vector2(0.08f, 0.08f); card.anchorMax = new Vector2(0.92f, 0.92f);
card.offsetMin = Vector2.zero; card.offsetMax = Vector2.zero;
int th = TownHall.Instance != null ? TownHall.Instance.currentLevel : 1;
UIKit.Text(card, "Участок: " + slot.name + " • Ратуша " + th + " ур.", 24, UIKit.Theme.TextDim, TextAlignmentOptions.Center);
var holder = UIKit.Make("ListHolder", card);
UIKit.Size(holder, -1, -1).flexibleHeight = 1;
ScrollRect scroll;
var list = UIKit.ScrollList(holder, out scroll, 10);
// доступные сверху, закрытые по уровню Ратуши -- ниже
var all = new List<BuildingData>(bm.allBuildings);
all.RemoveAll(b => b == null);
all.Sort((a, b) => a.requiredTownHallLevel.CompareTo(b.requiredTownHallLevel));
foreach (var data in all)
{
var d = data;
bool locked = d.requiredTownHallLevel > th;
bool afford = !locked && d.CanAfford();
var row = UIKit.Row(list, 120, 14, locked ? UIKit.Theme.CardDark : UIKit.Theme.Card, 10);
var icon = UIKit.Box(row, d.icon != null ? Color.white : UIKit.Theme.Gray);
if (d.icon != null) { icon.sprite = d.icon; icon.preserveAspect = true; }
UIKit.Size(icon, 100, 100);
var info = UIKit.Card(row, new Color(0, 0, 0, 0), 0, 4);
UIKit.Size(info, -1, -1, 1);
UIKit.Text(info, d.buildingName, 30, locked ? UIKit.Theme.TextDim : UIKit.Theme.Text).fontStyle = FontStyles.Bold;
UIKit.Text(info, locked ? "Откроется на Ратуше " + d.requiredTownHallLevel + " уровня" : CostText(d), 24,
locked ? UIKit.Theme.Gold : UIKit.Theme.TextDim);
string label = locked ? "Закрыто" : (afford ? "Построить" : "Мало ресурсов");
var b = UIKit.Button(row, label, afford ? UIKit.Theme.Green : UIKit.Theme.Gray, () =>
{
if (BuildManager.Instance.BuildOnSlot(d, slot))
{
UIKit.Toast("Построено: " + d.buildingName);
CloseChooser();
ApplySlotVisuals();
if (FreeSlots().Count == 0) SetMode(false);
}
else UIKit.Toast(GameProgress.Instance.MissingText(ToRewards(d.cost)));
}, 230, 90, 26);
b.interactable = !locked;
}
UIKit.Button(card, "Отмена", UIKit.Theme.Gray, CloseChooser, -1, 90, 30);
}
void CloseChooser()
{
if (chooser != null) Destroy(chooser);
chooser = null;
}
static RewardItem[] ToRewards(ResourceCost[] cost)
{
if (cost == null) return new RewardItem[0];
var r = new RewardItem[cost.Length];
for (int i = 0; i < cost.Length; i++) r[i] = new RewardItem(cost[i].type, Mathf.CeilToInt(cost[i].amount));
return r;
}
// Цена по-русски, красным -- чего не хватает
static string CostText(BuildingData d)
{
if (d.cost == null || d.cost.Length == 0) return "Бесплатно";
var parts = new List<string>();
foreach (var c in d.cost)
{
bool ok = ResourceManager.Instance != null && ResourceManager.Instance.HasEnough(c.type, c.amount);
parts.Add("<color=" + (ok ? "#9be59b" : "#ff7a7a") + ">" + GameData.Short(c.amount) + "</color> " + GameData.ResName(c.type));
}
return string.Join(", ", parts.ToArray());
}
// ---------------- подсказка сверху и кнопка ----------------
GameObject CreateBanner()
{
// полоска сверху экрана (не модальная: остальной экран остаётся кликабельным)
var bar = UIKit.Row(UIKit.Overlay, -1, 16, new Color(0.1f, 0.25f, 0.12f, 0.92f), 14);
bar.name = "BuildModeBanner";
bar.anchorMin = new Vector2(0.15f, 1); bar.anchorMax = new Vector2(0.85f, 1);
bar.pivot = new Vector2(0.5f, 1);
bar.sizeDelta = new Vector2(0, 110); bar.anchoredPosition = new Vector2(0, -150);
var t = UIKit.Text(bar, "РЕЖИМ СТРОЙКИ: нажмите на зелёный участок", 30, Color.white, TextAlignmentOptions.Left);
UIKit.Size(t, -1, -1, 1);
UIKit.Button(bar, "Выйти", UIKit.Theme.Red, () => SetMode(false), 180, 80, 28);
return bar.gameObject;
}
void UpdateButtonLabel()
{
if (modeButton == null) return;
var t = modeButton.GetComponentInChildren<TextMeshProUGUI>();
if (t != null) t.text = IsActive ? "ГОТОВО" : "СТРОЙКА";
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: c6e7cd0571b63f04088968696ac03a6a
+8
View File
@@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: 20c276885f20df54494e440a5e240f50
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:
+312
View File
@@ -0,0 +1,312 @@
using System;
using System.Collections.Generic;
using UnityEngine;
// ВСЕ ИГРОВЫЕ ТАБЛИЦЫ В ОДНОМ МЕСТЕ: задания, войска, награды, магазин, событие.
// Баланс правится здесь -- числа, названия, награды. Логика лежит в GameProgress и панелях.
[Serializable]
public class RewardItem
{
public ResourceType type;
public int amount;
public RewardItem() { }
public RewardItem(ResourceType type, int amount) { this.type = type; this.amount = amount; }
}
public enum QuestKind
{
TownHallLevel, // улучшить Ратушу до уровня N
BuildingsBuilt, // построить N зданий
Collect, // собрать N ресурса
BattlesWon, // выиграть N боёв
TroopsTrained, // обучить N воинов
HeroLevel, // герой N уровня
ArenaFights, // N боёв на арене
PlayerLevel, // уровень игрока N
Login, // зайти в игру (ежедневное)
WorldMarches, // отправить N походов на глобальной карте
WorldWins, // победить N раз на глобальной карте
BarracksBuilt // построить N казарм
}
public class QuestDef
{
public string id;
public string title;
public QuestKind kind;
public ResourceType resource;
public int target;
public int xp;
public RewardItem[] rewards;
public QuestDef(string id, string title, QuestKind kind, int target, int xp, params RewardItem[] rewards)
{
this.id = id; this.title = title; this.kind = kind; this.target = target; this.xp = xp; this.rewards = rewards;
}
public QuestDef Res(ResourceType r) { resource = r; return this; }
}
public class TroopDef
{
public string name;
public string description;
public int power; // сила одного воина
public float secondsPerUnit; // время обучения одного
public int requiredTownHall;
public RewardItem[] cost; // цена одного воина
public Color color;
public TroopDef(string name, string description, int power, float seconds, int th, Color color, params RewardItem[] cost)
{
this.name = name; this.description = description; this.power = power; secondsPerUnit = seconds;
requiredTownHall = th; this.color = color; this.cost = cost;
}
}
public class ShopItemDef
{
public string id;
public string title;
public RewardItem[] gives;
public ResourceType priceType;
public int price; // 0 = бесплатно
public int dailyLimit; // 0 = без лимита
public bool comingSoon; // покупка за реальные деньги -- пока недоступна
public string priceLabel;
public ShopItemDef(string id, string title, ResourceType priceType, int price, int dailyLimit, params RewardItem[] gives)
{
this.id = id; this.title = title; this.priceType = priceType; this.price = price; this.dailyLimit = dailyLimit; this.gives = gives;
}
}
public static class GameData
{
// ---------- Названия ресурсов на русском ----------
public static string ResName(ResourceType t)
{
switch (t)
{
case ResourceType.Population: return "Люди";
case ResourceType.Food: return "Еда";
case ResourceType.Wood: return "Дерево";
case ResourceType.Stone: return "Камень";
case ResourceType.Iron: return "Железо";
case ResourceType.Gold: return "Золото";
case ResourceType.Gems: return "Кристаллы";
case ResourceType.Power: return "Сила";
}
return t.ToString();
}
public static Color ResColor(ResourceType t)
{
switch (t)
{
case ResourceType.Food: return new Color(0.85f, 0.45f, 0.3f);
case ResourceType.Wood: return new Color(0.6f, 0.4f, 0.2f);
case ResourceType.Stone: return new Color(0.6f, 0.6f, 0.65f);
case ResourceType.Iron: return new Color(0.45f, 0.55f, 0.7f);
case ResourceType.Gold: return new Color(1f, 0.8f, 0.2f);
case ResourceType.Gems: return new Color(0.4f, 0.85f, 1f);
case ResourceType.Power: return new Color(1f, 0.4f, 0.3f);
}
return new Color(0.8f, 0.8f, 0.8f);
}
static RewardItem R(ResourceType t, int a) { return new RewardItem(t, a); }
// ---------- Опыт игрока ----------
public static int XpToNextLevel(int level) { return 100 + (level - 1) * 80; }
public const int MaxPlayerLevel = 60;
// ---------- Основные задания (цепочка) ----------
public static readonly List<QuestDef> MainQuests = new List<QuestDef>
{
new QuestDef("m_collect_wood_1", "Собрать 200 дерева", QuestKind.Collect, 200, 30, R(ResourceType.Gold, 200)).Res(ResourceType.Wood),
new QuestDef("m_collect_food_1", "Собрать 200 еды", QuestKind.Collect, 200, 30, R(ResourceType.Gold, 200)).Res(ResourceType.Food),
new QuestDef("m_build_1", "Построить первое здание", QuestKind.BuildingsBuilt, 1, 50, R(ResourceType.Wood, 300), R(ResourceType.Stone, 200)),
new QuestDef("m_barracks", "Построить Казармы", QuestKind.BarracksBuilt, 1, 50, R(ResourceType.Food, 300), R(ResourceType.Wood, 200)),
new QuestDef("m_train_10", "Обучить 10 воинов", QuestKind.TroopsTrained, 10, 50, R(ResourceType.Food, 400)),
new QuestDef("m_battle_1", "Выиграть первый бой", QuestKind.BattlesWon, 1, 60, R(ResourceType.Gems, 30)),
new QuestDef("m_world_send", "Отправить армию в поход на карте мира", QuestKind.WorldMarches, 1, 60, R(ResourceType.Food, 500)),
new QuestDef("m_world_win", "Разграбить поселение на карте мира", QuestKind.WorldWins, 1, 100, R(ResourceType.Gems, 50), R(ResourceType.Gold, 500)),
new QuestDef("m_hero_3", "Герой 3 уровня", QuestKind.HeroLevel, 3, 60, R(ResourceType.Gold, 500)),
new QuestDef("m_th_2", "Ратуша 2 уровня", QuestKind.TownHallLevel, 2, 100, R(ResourceType.Gems, 50), R(ResourceType.Stone, 500)),
new QuestDef("m_build_3", "Построить 3 здания", QuestKind.BuildingsBuilt, 3, 80, R(ResourceType.Wood, 600), R(ResourceType.Iron, 200)),
new QuestDef("m_arena_1", "Сразиться на арене", QuestKind.ArenaFights, 1, 60, R(ResourceType.Gems, 20)),
new QuestDef("m_collect_stone_1", "Собрать 1000 камня", QuestKind.Collect, 1000, 80, R(ResourceType.Gold, 800)).Res(ResourceType.Stone),
new QuestDef("m_train_100", "Обучить 100 воинов", QuestKind.TroopsTrained, 100, 120, R(ResourceType.Food, 1500), R(ResourceType.Gems, 30)),
new QuestDef("m_battle_5", "Выиграть 5 боёв", QuestKind.BattlesWon, 5, 150, R(ResourceType.Gems, 60)),
new QuestDef("m_player_5", "Достигнуть 5 уровня", QuestKind.PlayerLevel, 5, 100, R(ResourceType.Gems, 100)),
new QuestDef("m_th_3", "Ратуша 3 уровня", QuestKind.TownHallLevel, 3, 200, R(ResourceType.Gems, 100), R(ResourceType.Iron, 800)),
new QuestDef("m_hero_10", "Герой 10 уровня", QuestKind.HeroLevel, 10, 200, R(ResourceType.Gold, 3000)),
new QuestDef("m_battle_15", "Выиграть 15 боёв", QuestKind.BattlesWon, 15, 300, R(ResourceType.Gems, 150)),
new QuestDef("m_th_5", "Ратуша 5 уровня", QuestKind.TownHallLevel, 5, 400, R(ResourceType.Gems, 250)),
};
// ---------- Ежедневные задания (сбрасываются каждый день) ----------
public static readonly List<QuestDef> DailyQuests = new List<QuestDef>
{
new QuestDef("d_login", "Зайти в игру", QuestKind.Login, 1, 10, R(ResourceType.Gold, 100)),
new QuestDef("d_wood", "Собрать 300 дерева", QuestKind.Collect, 300, 20, R(ResourceType.Food, 200)).Res(ResourceType.Wood),
new QuestDef("d_food", "Собрать 300 еды", QuestKind.Collect, 300, 20, R(ResourceType.Wood, 200)).Res(ResourceType.Food),
new QuestDef("d_train", "Обучить 20 воинов", QuestKind.TroopsTrained, 20, 25, R(ResourceType.Iron, 150)),
new QuestDef("d_battle", "Выиграть 1 бой", QuestKind.BattlesWon, 1, 30, R(ResourceType.Gems, 10)),
new QuestDef("d_arena", "2 боя на арене", QuestKind.ArenaFights, 2, 30, R(ResourceType.Gems, 10)),
new QuestDef("d_world", "Отправить поход на карте мира", QuestKind.WorldMarches, 1, 25, R(ResourceType.Gold, 200)),
};
// ---------- Войска ----------
public static readonly List<TroopDef> Troops = new List<TroopDef>
{
new TroopDef("Пехота", "Дешёвые и надёжные бойцы ближнего боя.", 4, 3f, 1, new Color(0.55f, 0.6f, 0.7f),
R(ResourceType.Food, 20), R(ResourceType.Wood, 10)),
new TroopDef("Лучники", "Бьют издалека, хороши против пехоты.", 5, 4f, 1, new Color(0.45f, 0.7f, 0.4f),
R(ResourceType.Food, 15), R(ResourceType.Wood, 25)),
new TroopDef("Кавалерия", "Быстрые и сильные всадники.", 9, 7f, 2, new Color(0.75f, 0.55f, 0.3f),
R(ResourceType.Food, 40), R(ResourceType.Iron, 15)),
new TroopDef("Осадные", "Катапульты: огромная сила, долгая сборка.", 16, 12f, 3, new Color(0.7f, 0.35f, 0.35f),
R(ResourceType.Wood, 60), R(ResourceType.Stone, 40), R(ResourceType.Iron, 30)),
};
// Размер очереди обучения зависит от числа казарм: см. Barracks.QueueSize
// ---------- Герой ----------
public static int HeroMaxLevel(int townHallLevel) { return Mathf.Min(50, Mathf.Max(1, townHallLevel) * 5); }
public static int HeroAttack(int lvl) { return 10 + (lvl - 1) * 5; }
public static int HeroDefense(int lvl) { return 8 + (lvl - 1) * 4; }
public static int HeroArmyBonusPercent(int lvl) { return lvl * 2; }
public static int HeroPower(int lvl) { return 60 + lvl * 40; }
public static RewardItem[] HeroUpgradeCost(int lvl)
{
float m = Mathf.Pow(1.35f, lvl - 1);
return new[] { R(ResourceType.Gold, Mathf.RoundToInt(100 * m)), R(ResourceType.Food, Mathf.RoundToInt(150 * m)) };
}
public static readonly int[] HeroSkillLevels = { 1, 5, 10, 20, 35 };
public static readonly string[] HeroSkillNames =
{
"Боевой клич: +5% к силе армии",
"Железная стена: +10% защиты войск",
"Вдохновение: +10% к наградам за бой",
"Стратег: потери в бою -20%",
"Легенда: +15% к силе армии",
};
// ---------- Кампания (вкладка "Бой") ----------
public static readonly string[] EnemyNames =
{
"Лагерь разбойников", "Волчье логово", "Застава мародёров", "Гоблинский форт", "Тёмная роща",
"Пиратская бухта", "Крепость орков", "Проклятые руины", "Логово тролля", "Цитадель некроманта"
};
public static string StageName(int stage) { return EnemyNames[(stage - 1) % EnemyNames.Length] + (stage > EnemyNames.Length ? " II" : ""); }
public static int StageEnemyPower(int stage) { return Mathf.RoundToInt(80f * Mathf.Pow(1.22f, stage - 1)); }
public static RewardItem[] StageRewards(int stage)
{
return new[]
{
R(ResourceType.Gold, 60 + stage * 25),
R(ResourceType.Food, 80 + stage * 30),
R(ResourceType.Iron, 20 + stage * 10),
};
}
public static int StageXp(int stage) { return 20 + stage * 6; }
public static int StageGems(int stage) { return stage % 5 == 0 ? 25 : 0; } // каждый 5-й этап -- кристаллы
// ---------- Арена ----------
public const int ArenaMaxTickets = 5;
public const int ArenaTicketMinutes = 20;
public static readonly string[] BotNames =
{
"Ярослав", "Мирон", "Север", "Ведана", "Ратибор", "Злата", "Горыня", "Любава", "Святогор", "Огнеслав",
"Ingvar", "Sigrun", "Bjorn", "Astrid", "Leif", "Ragnar", "Freya", "Ulf", "Helga", "Torvald"
};
public static string ArenaLeague(int rating)
{
if (rating < 1100) return "Бронза";
if (rating < 1300) return "Серебро";
if (rating < 1600) return "Золото";
if (rating < 2000) return "Платина";
return "Алмаз";
}
// ---------- Ежедневная награда за вход (7 дней) ----------
public static readonly RewardItem[][] LoginRewards =
{
new[] { R(ResourceType.Gold, 500) },
new[] { R(ResourceType.Food, 1000) },
new[] { R(ResourceType.Wood, 800), R(ResourceType.Stone, 500) },
new[] { R(ResourceType.Gems, 50) },
new[] { R(ResourceType.Iron, 600) },
new[] { R(ResourceType.Gold, 2000) },
new[] { R(ResourceType.Gems, 200) },
};
// ---------- Магазин ----------
public static readonly List<ShopItemDef> ShopItems = BuildShop();
static List<ShopItemDef> BuildShop()
{
var list = new List<ShopItemDef>
{
new ShopItemDef("free_chest", "Бесплатный сундук (раз в день)", ResourceType.Gems, 0, 1),
new ShopItemDef("wood_1k", "Пакет дерева", ResourceType.Gems, 50, 5, R(ResourceType.Wood, 1000)),
new ShopItemDef("stone_1k", "Пакет камня", ResourceType.Gems, 50, 5, R(ResourceType.Stone, 1000)),
new ShopItemDef("food_15", "Пакет еды", ResourceType.Gems, 50, 5, R(ResourceType.Food, 1500)),
new ShopItemDef("iron_500", "Пакет железа", ResourceType.Gems, 60, 5, R(ResourceType.Iron, 500)),
new ShopItemDef("gold_1k", "Мешок золота", ResourceType.Gems, 80, 3, R(ResourceType.Gold, 1000)),
new ShopItemDef("gems_10", "Обмен золота на кристаллы", ResourceType.Gold, 1000, 3, R(ResourceType.Gems, 10)),
};
var p1 = new ShopItemDef("iap_small", "Горсть кристаллов", ResourceType.Gems, 0, 0, R(ResourceType.Gems, 500));
p1.comingSoon = true; p1.priceLabel = "99 руб.";
var p2 = new ShopItemDef("iap_big", "Сундук кристаллов", ResourceType.Gems, 0, 0, R(ResourceType.Gems, 3000));
p2.comingSoon = true; p2.priceLabel = "499 руб.";
list.Add(p1); list.Add(p2);
return list;
}
// Содержимое бесплатного сундука (выбирается случайно)
public static readonly RewardItem[] FreeChestPool =
{
R(ResourceType.Wood, 500), R(ResourceType.Stone, 400), R(ResourceType.Food, 600),
R(ResourceType.Iron, 250), R(ResourceType.Gold, 400), R(ResourceType.Gems, 15),
};
// ---------- Еженедельное событие ----------
public const string EventTitle = "Сбор войск";
public const string EventDescription =
"Очки события даются за: победу в бою (+10), разграбление на карте мира (+15), бой на арене (+5), выполненное задание (+20), каждые 5 обученных воинов (+1).";
public static readonly int[] EventTiers = { 50, 150, 300, 500, 800 };
public static readonly RewardItem[][] EventTierRewards =
{
new[] { R(ResourceType.Gold, 500) },
new[] { R(ResourceType.Food, 1500), R(ResourceType.Wood, 1000) },
new[] { R(ResourceType.Gems, 50) },
new[] { R(ResourceType.Iron, 1500), R(ResourceType.Gold, 2000) },
new[] { R(ResourceType.Gems, 200) },
};
// ---------- Текст наград ----------
public static string RewardsText(RewardItem[] rewards)
{
if (rewards == null || rewards.Length == 0) return "";
var parts = new List<string>();
foreach (var r in rewards) parts.Add(Short(r.amount) + " " + ResName(r.type));
return string.Join(", ", parts.ToArray());
}
public static string Short(float value)
{
if (value >= 1000000f) return (value / 1000000f).ToString("0.#") + "M";
if (value >= 10000f) return (value / 1000f).ToString("0.#") + "K";
return Mathf.FloorToInt(value).ToString();
}
public static string FormatTime(double seconds)
{
if (seconds < 0) seconds = 0;
TimeSpan t = TimeSpan.FromSeconds(Math.Ceiling(seconds));
if (t.TotalDays >= 1) return string.Format("{0}д {1}ч", (int)t.TotalDays, t.Hours);
if (t.TotalHours >= 1) return string.Format("{0}ч {1:00}м", (int)t.TotalHours, t.Minutes);
return string.Format("{0:00}:{1:00}", t.Minutes, t.Seconds);
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 203996cec7ec1514fb39e444697880ec
+930
View File
@@ -0,0 +1,930 @@
using System;
using System.Collections.Generic;
using System.IO;
using UnityEngine;
// "МОЗГ" всех панелей HUD: профиль, задания, почта, магазин, герой, войска, бой, арена, событие.
// Панели только РИСУЮТ и вызывают методы отсюда -- вся логика и сохранение здесь.
//
// Создаётся сам при запуске сцены (ничего вешать руками не нужно).
// Сохраняется в persistentDataPath/progress.json -- рядом с save.json от SaveManager.
public class GameProgress : MonoBehaviour
{
static GameProgress instance;
public static GameProgress Instance
{
get
{
if (instance == null)
{
instance = FindAnyObjectByType<GameProgress>();
if (instance == null) instance = new GameObject("GameProgress").AddComponent<GameProgress>();
}
return instance;
}
}
[RuntimeInitializeOnLoadMethod(RuntimeInitializeLoadType.AfterSceneLoad)]
static void AutoCreate() { var _ = Instance; }
public ProgressData Data { get; private set; }
// Любое изменение прогресса -> панели перерисовываются
public event Action OnChanged;
string SavePath { get { return Path.Combine(Application.persistentDataPath, "progress.json"); } }
bool dirty;
float saveAt = -1f;
float nextTick;
int suppressTracking; // >0 -- выдаём награду, не считаем это "сбором"
float trackingStartsAt; // первые секунды после старта не считаем (идёт загрузка сохранения)
bool subscribed;
readonly float[] lastValues = new float[8];
int cachedBuildings;
static readonly DateTime Epoch = new DateTime(2024, 1, 1); // понедельник
// =====================================================================
// ЖИЗНЕННЫЙ ЦИКЛ
// =====================================================================
void Awake()
{
if (instance != null && instance != this) { Destroy(gameObject); return; }
instance = this;
DontDestroyOnLoad(gameObject);
Load();
trackingStartsAt = Time.unscaledTime + 2f;
}
void Start()
{
ApplySettings();
}
void Update()
{
if (!subscribed) TrySubscribe();
if (Time.unscaledTime >= nextTick)
{
nextTick = Time.unscaledTime + 1f;
EnsureDailyAndWeekly();
ProcessTraining();
WorldSystem.Process();
UpdateBuildingsCount();
UpdatePowerResource();
}
if (saveAt > 0 && Time.unscaledTime >= saveAt) { saveAt = -1f; Save(); }
}
void LateUpdate()
{
if (dirty)
{
dirty = false;
if (OnChanged != null) OnChanged();
}
}
void OnApplicationPause(bool paused) { if (paused) Save(); }
void OnApplicationQuit() { Save(); }
// Сохранить без перерисовки панелей (для ползунков настроек)
public void SaveSoon()
{
if (saveAt < 0) saveAt = Time.unscaledTime + 1.5f;
}
public void MarkChanged()
{
dirty = true;
if (saveAt < 0) saveAt = Time.unscaledTime + 1.5f;
}
// =====================================================================
// СОХРАНЕНИЕ
// =====================================================================
void Load()
{
Data = null;
try
{
if (File.Exists(SavePath)) Data = JsonUtility.FromJson<ProgressData>(File.ReadAllText(SavePath));
}
catch (Exception e) { Debug.LogWarning("[ПРОГРЕСС] Не удалось прочитать progress.json: " + e.Message); }
if (Data == null) Data = new ProgressData();
FixData();
EnsureDailyAndWeekly();
// Новый день -> считаем день в игре
int today = Today;
if (Data.lastLoginDay != today) { Data.lastLoginDay = today; Data.daysPlayed++; }
if (!Data.starterMailSent) SendStarterMail();
MarkChanged();
}
// Защита от старых/кривых файлов: массивы нужной длины, списки не null
void FixData()
{
if (string.IsNullOrEmpty(Data.playerId)) Data.playerId = UnityEngine.Random.Range(10000000, 99999999).ToString();
if (Data.firstLaunchTicks == 0) Data.firstLaunchTicks = DateTime.UtcNow.Ticks;
if (Data.collected == null || Data.collected.Length < 8) Data.collected = Resize(Data.collected, 8);
if (Data.dailyCollected == null || Data.dailyCollected.Length < 8) Data.dailyCollected = Resize(Data.dailyCollected, 8);
if (Data.troops == null || Data.troops.Length < GameData.Troops.Count)
{
int[] t = new int[GameData.Troops.Count];
if (Data.troops != null) Array.Copy(Data.troops, t, Mathf.Min(Data.troops.Length, t.Length));
Data.troops = t;
}
if (Data.claimedQuests == null) Data.claimedQuests = new List<string>();
if (Data.claimedDaily == null) Data.claimedDaily = new List<string>();
if (Data.mail == null) Data.mail = new List<MailItem>();
if (Data.shopIds == null) Data.shopIds = new List<string>();
if (Data.shopCounts == null) Data.shopCounts = new List<int>();
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>();
if (Data.sashaMarches == null) Data.sashaMarches = new List<Bogatyri.World.MarchData>();
if (Data.sashaMarchUnits == null) Data.sashaMarchUnits = new List<ArmyBridge.MarchUnits>();
if (Data.level < 1) Data.level = 1;
if (Data.heroLevel < 1) Data.heroLevel = 1;
if (Data.battleStage < 1) Data.battleStage = 1;
}
static float[] Resize(float[] src, int len)
{
float[] r = new float[len];
if (src != null) Array.Copy(src, r, Mathf.Min(src.Length, len));
return r;
}
public void Save()
{
try
{
File.WriteAllText(SavePath, JsonUtility.ToJson(Data, true));
}
catch (Exception e) { Debug.LogWarning("[ПРОГРЕСС] Не удалось сохранить: " + e.Message); }
}
// Полный сброс: прогресс, ресурсы, здания, Ратуша. Кнопка в Настройках.
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++;
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--;
}
Data = new ProgressData();
FixData();
Data.lastLoginDay = Today; Data.daysPlayed = 1;
EnsureDailyAndWeekly();
SendStarterMail();
ApplySettings();
Save();
MarkChanged();
UIKit.Toast("Прогресс сброшен. Новая игра!");
}
// =====================================================================
// ВРЕМЯ
// =====================================================================
public static int Today { get { return (DateTime.Now.Date - Epoch).Days; } }
public static int CurrentWeek { get { return Today / 7; } }
public static double SecondsToNextDay { get { return (DateTime.Now.Date.AddDays(1) - DateTime.Now).TotalSeconds; } }
public static double SecondsToWeekEnd
{
get { return (Epoch.AddDays((CurrentWeek + 1) * 7) - DateTime.Now).TotalSeconds; }
}
static long Now { get { return DateTime.UtcNow.Ticks; } }
void EnsureDailyAndWeekly()
{
int today = Today;
if (Data.dailyDay != today)
{
Data.dailyDay = today;
Data.claimedDaily.Clear();
Data.dailyCollected = new float[8];
Data.dailyBattles = 0; Data.dailyTroops = 0; Data.dailyArena = 0; Data.dailyMarches = 0;
MarkChanged();
}
if (Data.shopDay != today)
{
Data.shopDay = today;
Data.shopIds.Clear(); Data.shopCounts.Clear();
}
if (Data.eventWeek != CurrentWeek)
{
Data.eventWeek = CurrentWeek;
Data.eventPoints = 0;
Data.eventTroopRemainder = 0;
Data.eventClaimed.Clear();
MarkChanged();
}
}
// =====================================================================
// РЕСУРСЫ И НАГРАДЫ
// =====================================================================
public float Res(ResourceType t) { return ResourceManager.Instance != null ? ResourceManager.Instance.GetResource(t) : 0f; }
public bool CanAfford(RewardItem[] cost, int multiplier = 1)
{
if (ResourceManager.Instance == null) return false;
foreach (var c in cost)
if (!ResourceManager.Instance.HasEnough(c.type, (float)c.amount * multiplier)) return false;
return true;
}
public string MissingText(RewardItem[] cost, int multiplier = 1)
{
var parts = new List<string>();
foreach (var c in cost)
{
float need = (float)c.amount * multiplier - Res(c.type);
if (need > 0) parts.Add(GameData.Short(Mathf.Ceil(need)) + " " + GameData.ResName(c.type));
}
return parts.Count == 0 ? "" : "Не хватает: " + string.Join(", ", parts.ToArray());
}
// Списать цену. false -- не хватило (ничего не списано + всплывашка)
public bool Pay(RewardItem[] cost, int multiplier = 1)
{
if (!CanAfford(cost, multiplier))
{
UIKit.Toast(MissingText(cost, multiplier));
return false;
}
foreach (var c in cost) ResourceManager.Instance.TrySpend(c.type, (float)c.amount * multiplier);
MarkChanged();
return true;
}
public void Grant(RewardItem[] rewards, int xp = 0, bool toast = true)
{
if (ResourceManager.Instance != null && rewards != null)
{
suppressTracking++;
foreach (var r in rewards) ResourceManager.Instance.AddResource(r.type, r.amount);
suppressTracking--;
}
if (xp > 0) AddXp(xp);
if (toast)
{
string text = GameData.RewardsText(rewards);
if (xp > 0) text += (text.Length > 0 ? ", " : "") + xp + " опыта";
if (text.Length > 0) UIKit.Toast("Получено: " + text);
}
MarkChanged();
}
// =====================================================================
// ОТСЛЕЖИВАНИЕ СБОРА РЕСУРСОВ (для заданий и статистики)
// =====================================================================
void TrySubscribe()
{
var rm = ResourceManager.Instance;
if (rm == null) return;
rm.OnResourceChanged += HandleResourceChanged;
foreach (ResourceType t in Enum.GetValues(typeof(ResourceType)))
if ((int)t < lastValues.Length) lastValues[(int)t] = rm.GetResource(t);
subscribed = true;
}
void OnDestroy()
{
if (subscribed && ResourceManager.Instance != null) ResourceManager.Instance.OnResourceChanged -= HandleResourceChanged;
}
void HandleResourceChanged(ResourceType type, float value)
{
int i = (int)type;
if (i < 0 || i >= lastValues.Length) return;
float delta = value - lastValues[i];
lastValues[i] = value;
if (type == ResourceType.Power) return;
if (delta <= 0 || suppressTracking > 0 || Time.unscaledTime < trackingStartsAt) { MarkChanged(); return; }
Data.collected[i] += delta;
Data.dailyCollected[i] += delta;
MarkChanged();
}
void UpdateBuildingsCount()
{
int n = FindObjectsByType<PlacedBuildingId>(FindObjectsSortMode.None).Length;
if (n != cachedBuildings) { cachedBuildings = n; MarkChanged(); }
}
public int BuildingsCount { get { return cachedBuildings; } }
public int TownHallLevel { get { return TownHall.Instance != null ? TownHall.Instance.currentLevel : 1; } }
// =====================================================================
// ОПЫТ И УРОВЕНЬ
// =====================================================================
public void AddXp(int amount)
{
if (Data.level >= GameData.MaxPlayerLevel) return;
Data.xp += amount;
while (Data.level < GameData.MaxPlayerLevel && Data.xp >= GameData.XpToNextLevel(Data.level))
{
Data.xp -= GameData.XpToNextLevel(Data.level);
Data.level++;
int gems = 10 * Data.level;
SendMail("Новый уровень: " + Data.level + "!",
"Поздравляем, командир! Ваша слава растёт. Примите подарок от совета королевства.",
new RewardItem(ResourceType.Gems, gems), new RewardItem(ResourceType.Gold, 300 * Data.level));
UIKit.Toast("Новый уровень: " + Data.level + "! Подарок в почте.");
Vibrate();
}
MarkChanged();
}
// =====================================================================
// СИЛА
// =====================================================================
public int TroopsPower
{
get
{
int p = 0;
for (int i = 0; i < GameData.Troops.Count; i++) p += Data.troops[i] * GameData.Troops[i].power;
return p;
}
}
public float ArmyBonus
{
get
{
float b = GameData.HeroArmyBonusPercent(Data.heroLevel) / 100f;
if (HasHeroSkill(0)) b += 0.05f;
if (HasHeroSkill(4)) b += 0.15f;
return b;
}
}
// Сила армии в бою = войска * бонус героя + сила героя
public int ArmyPower { get { return Mathf.RoundToInt(TroopsPower * (1f + ArmyBonus)) + GameData.HeroPower(Data.heroLevel); } }
public int TotalPower { get { return ArmyPower + TownHallLevel * 100 + BuildingsCount * 25; } }
void UpdatePowerResource()
{
var rm = ResourceManager.Instance;
if (rm == null) return;
int p = TotalPower;
if (Mathf.RoundToInt(rm.GetResource(ResourceType.Power)) != p) rm.SetResource(ResourceType.Power, p);
}
// =====================================================================
// ЗАДАНИЯ
// =====================================================================
public int QuestProgress(QuestDef q, bool daily)
{
switch (q.kind)
{
case QuestKind.TownHallLevel: return TownHallLevel;
case QuestKind.BuildingsBuilt: return BuildingsCount;
case QuestKind.Collect: return Mathf.FloorToInt(daily ? Data.dailyCollected[(int)q.resource] : Data.collected[(int)q.resource]);
case QuestKind.BattlesWon: return daily ? Data.dailyBattles : Data.battlesWon;
case QuestKind.TroopsTrained: return daily ? Data.dailyTroops : Data.troopsTrained;
case QuestKind.HeroLevel: return Data.heroLevel;
case QuestKind.ArenaFights: return daily ? Data.dailyArena : Data.arenaFights;
case QuestKind.PlayerLevel: return Data.level;
case QuestKind.Login: return 1;
case QuestKind.WorldMarches: return daily ? Data.dailyMarches : Data.marchesSent;
case QuestKind.WorldWins: return Data.worldWins;
case QuestKind.BarracksBuilt: return Barracks.Count;
}
return 0;
}
public bool IsQuestClaimed(QuestDef q, bool daily) { return (daily ? Data.claimedDaily : Data.claimedQuests).Contains(q.id); }
public bool IsQuestDone(QuestDef q, bool daily) { return QuestProgress(q, daily) >= q.target; }
public void ClaimQuest(QuestDef q, bool daily)
{
if (IsQuestClaimed(q, daily) || !IsQuestDone(q, daily)) return;
(daily ? Data.claimedDaily : Data.claimedQuests).Add(q.id);
Data.questsDone++;
AddEventPoints(20);
Grant(q.rewards, q.xp);
}
public int ClaimableQuestsCount
{
get
{
int n = 0;
foreach (var q in GameData.MainQuests) if (!IsQuestClaimed(q, false) && IsQuestDone(q, false)) n++;
foreach (var q in GameData.DailyQuests) if (!IsQuestClaimed(q, true) && IsQuestDone(q, true)) n++;
return n;
}
}
// =====================================================================
// НАГРАДА ЗА ВХОД (7 дней)
// =====================================================================
public bool CanClaimLogin { get { return Data.lastLoginRewardDay != Today; } }
public void ClaimLogin()
{
if (!CanClaimLogin) return;
int idx = Data.loginRewardIndex % GameData.LoginRewards.Length;
Data.lastLoginRewardDay = Today;
Data.loginRewardIndex = (idx + 1) % GameData.LoginRewards.Length;
Grant(GameData.LoginRewards[idx], 15);
}
// =====================================================================
// ПОЧТА
// =====================================================================
public void SendMail(string title, string body, params RewardItem[] rewards)
{
var m = new MailItem { id = Data.nextMailId++, title = title, body = body, sentTicks = DateTime.Now.Ticks };
if (rewards != null) m.rewards.AddRange(rewards);
Data.mail.Insert(0, m);
if (Data.mail.Count > 50) Data.mail.RemoveAt(Data.mail.Count - 1);
MarkChanged();
}
void SendStarterMail()
{
Data.starterMailSent = true;
SendMail("Как играть",
"1. Собирайте ресурсы, нажимая на здания.\n2. Стройте новые здания и улучшайте Ратушу.\n" +
"3. Обучайте войска и проходите этапы во вкладке «Бой».\n4. Выполняйте задания и забирайте ежедневные награды.\n" +
"5. Сражайтесь на арене и поднимайтесь в рейтинге.");
SendMail("Добро пожаловать, командир!",
"Королевство ждёт своего правителя. Примите стартовый подарок, чтобы начать строительство.",
new RewardItem(ResourceType.Gold, 500), new RewardItem(ResourceType.Wood, 500),
new RewardItem(ResourceType.Food, 500), new RewardItem(ResourceType.Gems, 100));
}
public int UnreadMailCount
{
get { int n = 0; foreach (var m in Data.mail) if (!m.read || (m.HasRewards && !m.claimed)) n++; return n; }
}
public MailItem GetMail(int id) { return Data.mail.Find(m => m.id == id); }
public void ReadMail(MailItem m) { if (m != null && !m.read) { m.read = true; MarkChanged(); } }
public void ClaimMail(MailItem m, bool toast = true)
{
if (m == null || m.claimed || !m.HasRewards) return;
m.claimed = true; m.read = true;
Grant(m.rewards.ToArray(), 0, toast);
}
public void DeleteMail(MailItem m)
{
if (m == null) return;
if (m.HasRewards && !m.claimed) { UIKit.Toast("Сначала заберите награду из письма"); return; }
Data.mail.Remove(m);
MarkChanged();
}
public void ClaimAllMail()
{
var all = new List<RewardItem>();
foreach (var m in Data.mail)
{
m.read = true;
if (m.HasRewards && !m.claimed) { m.claimed = true; all.AddRange(m.rewards); }
}
if (all.Count > 0) Grant(all.ToArray()); else UIKit.Toast("Все письма прочитаны");
MarkChanged();
}
public void DeleteReadMail()
{
int removed = Data.mail.RemoveAll(m => m.read && (!m.HasRewards || m.claimed));
UIKit.Toast(removed > 0 ? "Удалено писем: " + removed : "Нечего удалять");
MarkChanged();
}
// =====================================================================
// МАГАЗИН
// =====================================================================
public int BoughtToday(string id)
{
int i = Data.shopIds.IndexOf(id);
return i >= 0 ? Data.shopCounts[i] : 0;
}
public bool CanBuy(ShopItemDef item)
{
if (item.comingSoon) return false;
if (item.dailyLimit > 0 && BoughtToday(item.id) >= item.dailyLimit) return false;
return true;
}
public void Buy(ShopItemDef item)
{
if (item.comingSoon) { UIKit.Toast("Покупки за реальные деньги появятся позже"); return; }
if (!CanBuy(item)) { UIKit.Toast("Лимит на сегодня исчерпан"); return; }
if (item.price > 0 && !Pay(new[] { new RewardItem(item.priceType, item.price) })) return;
int i = Data.shopIds.IndexOf(item.id);
if (i < 0) { Data.shopIds.Add(item.id); Data.shopCounts.Add(1); } else Data.shopCounts[i]++;
RewardItem[] gives = item.gives;
if (item.id == "free_chest")
gives = new[] { GameData.FreeChestPool[UnityEngine.Random.Range(0, GameData.FreeChestPool.Length)] };
Grant(gives);
}
public bool FreeChestAvailable { get { return BoughtToday("free_chest") == 0; } }
// =====================================================================
// ГЕРОЙ
// =====================================================================
public int HeroMaxLevel { get { return GameData.HeroMaxLevel(TownHallLevel); } }
public bool HasHeroSkill(int index) { return Data.heroLevel >= GameData.HeroSkillLevels[index]; }
public bool CanUpgradeHero { get { return Data.heroLevel < HeroMaxLevel && CanAfford(GameData.HeroUpgradeCost(Data.heroLevel)); } }
public void UpgradeHero()
{
if (Data.heroLevel >= HeroMaxLevel)
{
UIKit.Toast("Улучшите Ратушу, чтобы поднять предел уровня героя");
return;
}
if (!Pay(GameData.HeroUpgradeCost(Data.heroLevel))) return;
Data.heroLevel++;
AddXp(10);
UIKit.Toast("Герой достиг " + Data.heroLevel + " уровня!");
MarkChanged();
}
// =====================================================================
// ВОЙСКА
// =====================================================================
public int TotalTroops
{
get { int n = 0; foreach (var t in Data.troops) n += t; return n; }
}
public bool TroopUnlocked(int index) { return TownHallLevel >= GameData.Troops[index].requiredTownHall; }
public void Train(int index, int count)
{
var def = GameData.Troops[index];
if (!TroopUnlocked(index)) { UIKit.Toast("Нужна Ратуша " + def.requiredTownHall + " уровня"); return; }
if (Barracks.Count == 0) { UIKit.Toast("Сначала постройте Казармы"); return; }
if (Data.training.Count >= Barracks.QueueSize) { UIKit.Toast("Очередь обучения заполнена. Больше казарм -- длиннее очередь"); return; }
if (!Pay(def.cost, count)) return;
long start = Data.training.Count > 0 ? Data.training[Data.training.Count - 1].endTicks : Now;
if (start < Now) start = Now;
long end = start + TimeSpan.FromSeconds(def.secondsPerUnit * count).Ticks;
Data.training.Add(new TrainingOrder { troopIndex = index, count = count, startTicks = start, endTicks = end });
UIKit.Toast("Обучение начато: " + count + " × " + def.name);
MarkChanged();
}
public double TrainingSecondsLeft(TrainingOrder o) { return TimeSpan.FromTicks(Math.Max(0, o.endTicks - Now)).TotalSeconds; }
public float TrainingProgress(TrainingOrder o)
{
long total = o.endTicks - o.startTicks;
if (total <= 0) return 1f;
return Mathf.Clamp01((float)(Now - o.startTicks) / total);
}
public int SpeedUpCost
{
get
{
if (Data.training.Count == 0) return 0;
double secs = TrainingSecondsLeft(Data.training[Data.training.Count - 1]);
return Mathf.Max(1, Mathf.CeilToInt((float)secs / 30f)); // 1 кристалл за 30 сек
}
}
public void SpeedUpTraining()
{
if (Data.training.Count == 0) return;
if (!Pay(new[] { new RewardItem(ResourceType.Gems, SpeedUpCost) })) return;
foreach (var o in Data.training) o.endTicks = Now;
ProcessTraining();
}
void ProcessTraining()
{
bool any = false;
while (Data.training.Count > 0 && Data.training[0].endTicks <= Now)
{
var o = Data.training[0];
Data.training.RemoveAt(0);
Data.troops[o.troopIndex] += o.count;
Data.troopsTrained += o.count;
Data.dailyTroops += o.count;
Data.eventTroopRemainder += o.count;
AddEventPoints(Data.eventTroopRemainder / 5);
Data.eventTroopRemainder %= 5;
UIKit.Toast("Обучено: " + o.count + " × " + GameData.Troops[o.troopIndex].name);
any = true;
}
if (any) MarkChanged();
}
// Потери в бою: убираем процент от каждого типа войск
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;
}
// =====================================================================
// БОЙ (кампания)
// =====================================================================
public static float WinChance(int myPower, int enemyPower)
{
if (myPower <= 0) return 0f;
float ratio = (float)myPower / Mathf.Max(1, enemyPower);
if (ratio >= 2f) return 1f;
return Mathf.Clamp(0.5f + (ratio - 1f) * 1.25f, 0.03f, 0.97f);
}
public class BattleResult
{
public bool win;
public string enemy;
public int stage;
public RewardItem[] rewards;
public int xp;
public int lost;
}
public BattleResult FightStage()
{
if (TotalTroops == 0) { UIKit.Toast("Нет войск! Обучите их во вкладке «Войска»"); return null; }
int stage = Data.battleStage;
float chance = WinChance(ArmyPower, GameData.StageEnemyPower(stage));
var res = new BattleResult { stage = stage, enemy = GameData.StageName(stage) };
res.win = UnityEngine.Random.value < chance;
if (res.win)
{
float bonus = HasHeroSkill(2) ? 1.1f : 1f;
var list = new List<RewardItem>();
foreach (var r in GameData.StageRewards(stage)) list.Add(new RewardItem(r.type, Mathf.RoundToInt(r.amount * bonus)));
int gems = GameData.StageGems(stage);
if (gems > 0) list.Add(new RewardItem(ResourceType.Gems, gems));
res.rewards = list.ToArray();
res.xp = GameData.StageXp(stage);
res.lost = LoseTroops(UnityEngine.Random.Range(0.05f, 0.12f));
Data.battleStage++;
Data.battlesWon++;
Data.dailyBattles++;
AddEventPoints(10);
Grant(res.rewards, res.xp, false);
}
else
{
res.rewards = new RewardItem[0];
res.lost = LoseTroops(UnityEngine.Random.Range(0.2f, 0.3f));
Data.battlesLost++;
AddXp(5);
}
MarkChanged();
return res;
}
// =====================================================================
// АРЕНА
// =====================================================================
public int ArenaTickets
{
get
{
RefreshTickets();
return Data.arenaTickets;
}
}
void RefreshTickets()
{
if (Data.arenaTickets >= GameData.ArenaMaxTickets) return;
long period = TimeSpan.FromMinutes(GameData.ArenaTicketMinutes).Ticks;
long elapsed = Now - Data.arenaTicketTicks;
if (elapsed < period) return;
int gained = (int)(elapsed / period);
Data.arenaTickets = Mathf.Min(GameData.ArenaMaxTickets, Data.arenaTickets + gained);
Data.arenaTicketTicks += gained * period;
MarkChanged();
}
public double SecondsToNextTicket
{
get
{
if (Data.arenaTickets >= GameData.ArenaMaxTickets) return 0;
long period = TimeSpan.FromMinutes(GameData.ArenaTicketMinutes).Ticks;
return TimeSpan.FromTicks(Math.Max(0, Data.arenaTicketTicks + period - Now)).TotalSeconds;
}
}
public List<ArenaOpponent> ArenaOpponents
{
get
{
if (Data.arenaOpponents.Count == 0) GenerateOpponents();
return Data.arenaOpponents;
}
}
public void GenerateOpponents()
{
Data.arenaOpponents.Clear();
int baseP = Mathf.Max(150, ArmyPower);
float[] mult = { 0.75f, 1.0f, 1.3f }; // лёгкий, равный, сильный
for (int i = 0; i < 3; i++)
{
Data.arenaOpponents.Add(new ArenaOpponent
{
name = GameData.BotNames[UnityEngine.Random.Range(0, GameData.BotNames.Length)],
power = Mathf.RoundToInt(baseP * mult[i] * UnityEngine.Random.Range(0.9f, 1.1f)),
rating = Mathf.Max(0, Data.arenaRating + (i - 1) * 60 + UnityEngine.Random.Range(-25, 26)),
});
}
MarkChanged();
}
public class ArenaResult { public bool win; public string opponent; public int ratingChange; public RewardItem[] rewards; }
public ArenaResult FightArena(int index)
{
if (TotalTroops == 0) { UIKit.Toast("Нет войск! Обучите их во вкладке «Войска»"); return null; }
if (ArenaTickets <= 0) { UIKit.Toast("Нет билетов. Следующий через " + GameData.FormatTime(SecondsToNextTicket)); return null; }
var opp = ArenaOpponents[index];
if (Data.arenaTickets == GameData.ArenaMaxTickets) Data.arenaTicketTicks = Now;
Data.arenaTickets--;
var res = new ArenaResult { opponent = opp.name };
res.win = UnityEngine.Random.value < WinChance(ArmyPower, opp.power);
Data.arenaFights++;
Data.dailyArena++;
AddEventPoints(5);
if (res.win)
{
res.ratingChange = 15 + Mathf.Clamp((opp.rating - Data.arenaRating) / 10, 0, 15) + UnityEngine.Random.Range(0, 6);
res.rewards = new[] { new RewardItem(ResourceType.Gold, 150), new RewardItem(ResourceType.Gems, 5) };
Data.arenaWins++;
Grant(res.rewards, 15, false);
}
else
{
res.ratingChange = -UnityEngine.Random.Range(8, 15);
res.rewards = new RewardItem[0];
AddXp(5);
}
Data.arenaRating = Mathf.Max(0, Data.arenaRating + res.ratingChange);
GenerateOpponents();
MarkChanged();
return res;
}
// Таблица лидеров: стабильные боты (зависят от ID игрока) + сам игрок
public List<ArenaOpponent> Leaderboard()
{
var list = new List<ArenaOpponent>();
int seed;
int.TryParse(Data.playerId, out seed); // ID из цифр -> одинаковая таблица при каждом запуске
var rnd = new System.Random(seed);
for (int i = 0; i < 15; i++)
{
list.Add(new ArenaOpponent
{
name = GameData.BotNames[rnd.Next(GameData.BotNames.Length)] + rnd.Next(10, 99),
rating = 2300 - i * 90 - rnd.Next(0, 60),
});
}
list.Add(new ArenaOpponent { name = Data.playerName + " (вы)", rating = Data.arenaRating, power = -1 });
list.Sort((a, b) => b.rating.CompareTo(a.rating));
return list;
}
// =====================================================================
// СОБЫТИЕ
// =====================================================================
public void AddEventPoints(int points)
{
if (points <= 0) return;
EnsureDailyAndWeekly();
Data.eventPoints += points;
MarkChanged();
}
public bool EventTierClaimed(int i) { return Data.eventClaimed.Contains(i); }
public bool EventTierReady(int i) { return Data.eventPoints >= GameData.EventTiers[i] && !EventTierClaimed(i); }
public void ClaimEventTier(int i)
{
if (!EventTierReady(i)) return;
Data.eventClaimed.Add(i);
Grant(GameData.EventTierRewards[i], 20);
}
public bool AnyEventTierReady
{
get { for (int i = 0; i < GameData.EventTiers.Length; i++) if (EventTierReady(i)) return true; return false; }
}
// =====================================================================
// НАСТРОЙКИ
// =====================================================================
public void ApplySettings()
{
AudioListener.volume = Data.soundVolume;
int count = QualitySettings.names.Length;
if (count > 0)
{
int level = Data.quality == 0 ? 0 : (Data.quality == 1 ? count / 2 : count - 1);
QualitySettings.SetQualityLevel(level, true);
}
Application.targetFrameRate = Data.fps60 ? 60 : 30;
}
public void Vibrate()
{
#if UNITY_ANDROID || UNITY_IOS
if (Data.vibration) Handheld.Vibrate();
#endif
}
// =====================================================================
// КРАСНЫЕ ЗНАЧКИ НА КНОПКАХ HUD
// =====================================================================
public int BadgeCount(string panelId)
{
switch (panelId)
{
case "Quests": return ClaimableQuestsCount;
case "Rewards": return CanClaimLogin ? 1 : 0;
case "Mail": return UnreadMailCount;
case "Events": return AnyEventTierReady ? 1 : 0;
case "Shop": return FreeChestAvailable ? 1 : 0;
case "Hero": return CanUpgradeHero ? 1 : 0;
case "Arena": return ArenaTickets >= GameData.ArenaMaxTickets ? 1 : 0;
case "World": return Data.marches.Count;
}
return 0;
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 5b23537588a39f74cabea0bb5b6f4ca5
+125
View File
@@ -0,0 +1,125 @@
using System;
using System.Collections.Generic;
// Всё, что сохраняется в progress.json (отдельно от save.json с ресурсами и зданиями).
// JsonUtility умеет: числа, строки, bool, массивы, List<> из [Serializable]-классов.
[Serializable]
public class MailItem
{
public int id;
public string title;
public string body;
public long sentTicks;
public bool read;
public bool claimed;
public List<RewardItem> rewards = new List<RewardItem>();
public bool HasRewards { get { return rewards != null && rewards.Count > 0; } }
}
[Serializable]
public class TrainingOrder
{
public int troopIndex;
public int count;
public long startTicks;
public long endTicks;
}
[Serializable]
public class ArenaOpponent
{
public string name;
public int power;
public int rating;
}
[Serializable]
public class ProgressData
{
// --- Профиль ---
public string playerName = "Командир";
public string playerId = "";
public int avatarIndex = 0;
public int level = 1;
public int xp = 0;
public long firstLaunchTicks = 0;
public int daysPlayed = 0;
public int lastLoginDay = -1;
// --- Настройки ---
public float musicVolume = 0.8f;
public float soundVolume = 0.8f;
public bool vibration = true;
public bool notifications = true;
public int quality = 1; // 0 низкое, 1 среднее, 2 высокое
public bool fps60 = true;
// --- Статистика (для профиля и заданий) ---
public float[] collected = new float[8]; // индекс = (int)ResourceType
public int battlesWon;
public int battlesLost;
public int troopsTrained;
public int arenaFights;
public int arenaWins;
public int questsDone;
// --- Задания ---
public List<string> claimedQuests = new List<string>();
public int dailyDay = -1;
public List<string> claimedDaily = new List<string>();
public float[] dailyCollected = new float[8];
public int dailyBattles;
public int dailyTroops;
public int dailyArena;
public int dailyMarches;
// --- Награда за вход ---
public int loginRewardIndex = 0; // какой день из 7 следующий
public int lastLoginRewardDay = -1;
// --- Почта ---
public List<MailItem> mail = new List<MailItem>();
public int nextMailId = 1;
public bool starterMailSent;
// --- Магазин ---
public int shopDay = -1;
public List<string> shopIds = new List<string>();
public List<int> shopCounts = new List<int>();
// --- Герой ---
public int heroLevel = 1;
// --- Войска ---
public int[] troops = new int[4];
public List<TrainingOrder> training = new List<TrainingOrder>();
// --- Бой (кампания) ---
public int battleStage = 1;
// --- Арена ---
public int arenaRating = 1000;
public int arenaTickets = 5;
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;
public int marchesSent;
public int worldWins;
public int worldLosses;
// --- Большая карта мира (сцена WorldMap, код Саши) ---
public List<Bogatyri.World.MarchData> sashaMarches = new List<Bogatyri.World.MarchData>();
public List<ArmyBridge.MarchUnits> sashaMarchUnits = new List<ArmyBridge.MarchUnits>();
// --- Событие ---
public int eventWeek = -1;
public int eventPoints;
public int eventTroopRemainder;
public List<int> eventClaimed = new List<int>();
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 5573100d4811a00429946a55af8f0204
+24
View File
@@ -0,0 +1,24 @@
using UnityEngine;
using UnityEngine.EventSystems;
// Проверка "палец/мышь сейчас над UI?".
// Нужна, чтобы клик по кнопке панели НЕ проходил насквозь в мир:
// не собирал ресурсы со здания под панелью, не улучшал Ратушу, не двигал камеру.
public static class UIBlocker
{
public static bool IsPointerOverUI()
{
if (EventSystem.current == null) return false;
// мышь (редактор/ПК) и новый Input System
if (EventSystem.current.IsPointerOverGameObject()) return true;
// тач на телефоне (старый input module требует id пальца)
for (int i = 0; i < Input.touchCount; i++)
{
if (EventSystem.current.IsPointerOverGameObject(Input.GetTouch(i).fingerId))
return true;
}
return false;
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 54a1703640ad8894cb8f75fb798c6685
+8
View File
@@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: 83ba3a90e5d8dbe44a8658c267eab743
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:
+109
View File
@@ -0,0 +1,109 @@
using UnityEngine;
using TMPro;
// АРЕНА: бои с другими "игроками" (пока боты) за рейтинг. Билеты: 5 штук, 1 восстанавливается за 20 минут.
// Вкладка "Рейтинг" -- таблица лидеров и ваше место. Лиги: Бронза -> Серебро -> Золото -> Платина -> Алмаз.
public class ArenaPanel : BasePanel
{
int tab;
TextMeshProUGUI ticketsText;
protected override void Build(RectTransform root)
{
var col = UIKit.Card(root, new Color(0, 0, 0, 0), 0, 12);
UIKit.Stretch(col);
UIKit.Tabs(col, new[] { "Бой", "Рейтинг" }, tab, i => { tab = i; RequestRebuild(); });
var holder = UIKit.Make("ListHolder", col);
UIKit.Size(holder, -1, -1).flexibleHeight = 1;
var list = ScrollList(holder);
ticketsText = null;
if (tab == 0) BuildFights(list); else BuildRating(list);
}
void BuildFights(RectTransform list)
{
var d = GP.Data;
int mine = GP.ArmyPower;
var head = UIKit.Card(list, new Color(0.15f, 0.2f, 0.35f));
UIKit.Text(head, "Рейтинг: " + d.arenaRating + " • Лига: " + GameData.ArenaLeague(d.arenaRating),
32, UIKit.Theme.Gold, TextAlignmentOptions.Center).fontStyle = FontStyles.Bold;
UIKit.Text(head, "Ваша сила: " + mine.ToString("N0") + " • Побед: " + d.arenaWins + " из " + d.arenaFights,
24, UIKit.Theme.TextDim, TextAlignmentOptions.Center);
ticketsText = UIKit.Text(head, "", 28, UIKit.Theme.Text, TextAlignmentOptions.Center);
Tick();
if (GP.TotalTroops == 0)
{
UIKit.Text(list, "Для боёв на арене нужны войска.", 28, UIKit.Theme.Red, TextAlignmentOptions.Center, 60);
UIKit.Button(list, "Перейти к войскам", UIKit.Theme.Blue, () => OpenPanel("Troops"), -1, 90);
}
var opps = GP.ArenaOpponents;
string[] tags = { "Лёгкий", "Равный", "Сильный" };
for (int i = 0; i < opps.Count; i++)
{
int idx = i;
var o = opps[i];
float chance = GameProgress.WinChance(mine, o.power);
var card = UIKit.Card(list);
var row = UIKit.Row(card, 120, 16);
UIKit.Icon(row, ProfilePanel.AvatarColors[(o.name.Length + i) % ProfilePanel.AvatarColors.Length], o.name.Substring(0, 1), 110);
var info = UIKit.Card(row, new Color(0, 0, 0, 0), 0, 2);
UIKit.Size(info, -1, -1, 1);
UIKit.Text(info, o.name + " <size=22><color=#c8c8c8>" + (i < tags.Length ? tags[i] : "") + "</color></size>", 30).fontStyle = FontStyles.Bold;
UIKit.Text(info, "Сила " + o.power.ToString("N0") + " • рейтинг " + o.rating, 24, UIKit.Theme.TextDim);
Color cc = chance >= 0.7f ? UIKit.Theme.Green : (chance >= 0.4f ? UIKit.Theme.Gold : UIKit.Theme.Red);
UIKit.Text(info, "Шанс победы " + Mathf.RoundToInt(chance * 100) + "%", 24, cc);
bool can = GP.TotalTroops > 0 && GP.ArenaTickets > 0;
var b = UIKit.Button(row, "Атаковать", can ? UIKit.Theme.Red : UIKit.Theme.Gray, () => Fight(idx), 200, 90);
b.interactable = can;
}
UIKit.Button(list, "Обновить соперников", UIKit.Theme.Gray, () => GP.GenerateOpponents(), -1, 80);
UIKit.Text(list, "Победа: +15..35 рейтинга, 150 золота, 5 кристаллов. Поражение: -8..14 рейтинга.",
22, UIKit.Theme.TextDim, TextAlignmentOptions.Center);
}
void BuildRating(RectTransform list)
{
var board = GP.Leaderboard();
int myRank = board.FindIndex(e => e.power == -1) + 1;
UIKit.Text(list, "Ваше место: " + myRank + " из " + board.Count, 32, UIKit.Theme.Gold, TextAlignmentOptions.Center, 60)
.fontStyle = FontStyles.Bold;
for (int i = 0; i < board.Count; i++)
{
var e = board[i];
bool me = e.power == -1;
var row = UIKit.Row(list, 70, 14, me ? new Color(0.45f, 0.36f, 0.12f) : (i % 2 == 0 ? UIKit.Theme.Card : UIKit.Theme.CardDark), 10);
Color place = i == 0 ? UIKit.Theme.Gold : (i == 1 ? new Color(0.8f, 0.8f, 0.85f) : (i == 2 ? new Color(0.8f, 0.5f, 0.3f) : UIKit.Theme.TextDim));
UIKit.Size(UIKit.Text(row, (i + 1).ToString(), 30, place, TextAlignmentOptions.Center), 70);
var n = UIKit.Text(row, e.name, 28, me ? UIKit.Theme.Gold : UIKit.Theme.Text);
UIKit.Size(n, -1, -1, 1);
UIKit.Size(UIKit.Text(row, GameData.ArenaLeague(e.rating), 24, UIKit.Theme.TextDim, TextAlignmentOptions.Right), 140);
UIKit.Size(UIKit.Text(row, e.rating.ToString(), 28, UIKit.Theme.Text, TextAlignmentOptions.Right), 100);
}
}
void Fight(int index)
{
var r = GP.FightArena(index);
if (r == null) return;
if (r.win)
UIKit.Message("ПОБЕДА!", "Вы победили игрока " + r.opponent + "!\n\nРейтинг: +" + r.ratingChange +
"\nНаграда: " + GameData.RewardsText(r.rewards) + ", 15 опыта", "Отлично");
else
UIKit.Message("Поражение", r.opponent + " оказался сильнее.\n\nРейтинг: " + r.ratingChange, "Понятно");
}
protected override void Tick()
{
if (ticketsText == null) return;
int t = GP.ArenaTickets;
string next = t < GameData.ArenaMaxTickets ? " • +1 через " + GameData.FormatTime(GP.SecondsToNextTicket) : "";
ticketsText.text = "Билеты: " + t + " / " + GameData.ArenaMaxTickets + next;
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 78b7d6900d49bfd449f67923e9f74315
+87
View File
@@ -0,0 +1,87 @@
using UnityEngine;
using TMPro;
// БОЙ: кампания из этапов. Сила армии против силы врага -> шанс победы.
// Победа: ресурсы, опыт, следующий этап, небольшие потери. Поражение: большие потери войск.
public class BattlePanel : BasePanel
{
protected override void Build(RectTransform root)
{
var d = GP.Data;
int stage = d.battleStage;
int enemy = GameData.StageEnemyPower(stage);
int mine = GP.ArmyPower;
float chance = GameProgress.WinChance(mine, enemy);
var list = ScrollList(root);
// --- Моя армия ---
var army = UIKit.Card(list, UIKit.Theme.CardDark);
var ar = UIKit.Row(army, 60, 10);
var at = UIKit.Text(ar, "Ваша армия: " + mine.ToString("N0"), 32, UIKit.Theme.Text);
at.fontStyle = FontStyles.Bold;
UIKit.Size(at, -1, -1, 1);
UIKit.Size(UIKit.Button(ar, "Войска", UIKit.Theme.Gray, () => OpenPanel("Troops"), 160, 60, 24), 160, 60);
UIKit.Text(army, "Воинов: " + GP.TotalTroops.ToString("N0") + " • Герой " + d.heroLevel + " ур. (+" +
Mathf.RoundToInt(GP.ArmyBonus * 100) + "%)", 24, UIKit.Theme.TextDim);
// --- Глобальная карта ---
var world = UIKit.Card(list, new Color(0.16f, 0.27f, 0.17f));
UIKit.Text(world, "Карта мира: отправляйте армию грабить поселения и приносите добычу домой.", 24, UIKit.Theme.Text);
UIKit.Button(world, "ОТКРЫТЬ КАРТУ МИРА", UIKit.Theme.Green, () => OpenPanel("World"), -1, 90, 30);
// --- Текущий этап ---
var card = UIKit.Card(list, new Color(0.3f, 0.14f, 0.13f), 24, 12);
UIKit.Text(card, "ЭТАП " + stage, 28, UIKit.Theme.TextDim, TextAlignmentOptions.Center);
UIKit.Text(card, GameData.StageName(stage), 42, UIKit.Theme.Gold, TextAlignmentOptions.Center).fontStyle = FontStyles.Bold;
UIKit.Text(card, "Сила врага: " + enemy.ToString("N0"), 32, UIKit.Theme.Text, TextAlignmentOptions.Center);
Color cc = chance >= 0.7f ? UIKit.Theme.Green : (chance >= 0.4f ? UIKit.Theme.Gold : UIKit.Theme.Red);
UIKit.Bar(card, chance, cc, 40, "Шанс победы: " + Mathf.RoundToInt(chance * 100) + "%");
string rewards = GameData.RewardsText(GameData.StageRewards(stage)) + ", " + GameData.StageXp(stage) + " опыта";
if (GameData.StageGems(stage) > 0) rewards += ", " + GameData.StageGems(stage) + " кристаллов";
UIKit.Text(card, "Награда: " + rewards, 26, UIKit.Theme.TextDim, TextAlignmentOptions.Center);
if (GP.TotalTroops == 0)
{
UIKit.Text(card, "У вас нет войск! Сначала обучите их.", 28, UIKit.Theme.Red, TextAlignmentOptions.Center);
UIKit.Button(card, "Перейти к войскам", UIKit.Theme.Blue, () => OpenPanel("Troops"), -1, 100, 32);
}
else
{
UIKit.Button(card, "В БОЙ!", UIKit.Theme.Red, Fight, -1, 120, 44);
if (chance < 0.4f)
UIKit.Text(card, "Совет: обучите больше войск или улучшите героя.", 24, UIKit.Theme.TextDim, TextAlignmentOptions.Center);
}
// --- Следующие этапы ---
var next = UIKit.Card(list);
ProfilePanel.Header(next, "Впереди");
for (int s = stage + 1; s <= stage + 4; s++)
{
string extra = GameData.StageGems(s) > 0 ? " • кристаллы" : "";
ProfilePanel.KV(next, s + ". " + GameData.StageName(s), "сила " + GameData.StageEnemyPower(s).ToString("N0") + extra);
}
UIKit.Text(next, "Пройдено этапов: " + (stage - 1) + " • Побед: " + d.battlesWon + " • Поражений: " + d.battlesLost,
24, UIKit.Theme.TextDim, TextAlignmentOptions.Center);
}
void Fight()
{
var r = GP.FightStage();
if (r == null) return;
if (r.win)
{
UIKit.Message("ПОБЕДА!",
r.enemy + " повержен!\n\nНаграда: " + GameData.RewardsText(r.rewards) + ", " + r.xp + " опыта\n" +
"Потери: " + r.lost + " воинов", "Отлично");
GP.Vibrate();
}
else
{
UIKit.Message("Поражение",
"Армия отступила от «" + r.enemy + "».\n\nПотери: " + r.lost + " воинов\n\n" +
"Обучите больше войск или улучшите героя и попробуйте снова.", "Понятно");
}
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 619f3860f6abf3343882e23cfae60c1b
+59
View File
@@ -0,0 +1,59 @@
using UnityEngine;
using TMPro;
// СОБЫТИЯ: еженедельное событие "Сбор войск". Очки за бои, арену, задания, обучение войск.
// Пороги очков открывают награды. В понедельник всё обнуляется -- новое событие.
public class EventsPanel : BasePanel
{
TextMeshProUGUI timer;
protected override void Build(RectTransform root)
{
var d = GP.Data;
var list = ScrollList(root);
var head = UIKit.Card(list, new Color(0.35f, 0.18f, 0.12f));
var title = UIKit.Text(head, GameData.EventTitle, 44, UIKit.Theme.Gold, TextAlignmentOptions.Center);
title.fontStyle = FontStyles.Bold;
timer = UIKit.Text(head, "", 26, UIKit.Theme.Text, TextAlignmentOptions.Center);
Tick();
UIKit.Text(head, GameData.EventDescription, 24, UIKit.Theme.TextDim, TextAlignmentOptions.Center);
int maxPoints = GameData.EventTiers[GameData.EventTiers.Length - 1];
var prog = UIKit.Card(list);
UIKit.Text(prog, "Ваши очки: " + d.eventPoints, 34, UIKit.Theme.Text, TextAlignmentOptions.Center).fontStyle = FontStyles.Bold;
UIKit.Bar(prog, (float)d.eventPoints / maxPoints, UIKit.Theme.Gold, 34, d.eventPoints + " / " + maxPoints);
for (int i = 0; i < GameData.EventTiers.Length; i++)
{
int idx = i;
bool claimed = GP.EventTierClaimed(i);
bool ready = GP.EventTierReady(i);
var card = UIKit.Card(list, ready ? UIKit.Theme.CardDone : (claimed ? UIKit.Theme.CardDark : UIKit.Theme.Card));
var row = UIKit.Row(card, 100, 14);
UIKit.Icon(row, ready ? UIKit.Theme.Green : UIKit.Theme.Gray, (i + 1).ToString(), 90);
var info = UIKit.Card(row, new Color(0, 0, 0, 0), 0, 4);
UIKit.Size(info, -1, -1, 1);
UIKit.Text(info, GameData.EventTiers[i] + " очков", 30).fontStyle = FontStyles.Bold;
UIKit.Text(info, GameData.RewardsText(GameData.EventTierRewards[i]) + ", 20 опыта", 24, UIKit.Theme.TextDim);
string label = claimed ? "Получено" : (ready ? "Забрать" : "Нужно ещё " + (GameData.EventTiers[i] - d.eventPoints));
var b = UIKit.Button(row, label, ready ? UIKit.Theme.Green : UIKit.Theme.Gray, () => GP.ClaimEventTier(idx), 230, 90, 24);
b.interactable = ready;
}
// Быстрые переходы к источникам очков
var go = UIKit.Card(list, UIKit.Theme.CardDark);
UIKit.Text(go, "Где заработать очки:", 28, UIKit.Theme.TextDim);
var r = UIKit.Row(go, 80, 10);
UIKit.Size(UIKit.Button(r, "Бой", UIKit.Theme.Red, () => OpenPanel("Battle")), -1, 80, 1);
UIKit.Size(UIKit.Button(r, "Арена", UIKit.Theme.Blue, () => OpenPanel("Arena")), -1, 80, 1);
UIKit.Size(UIKit.Button(r, "Войска", UIKit.Theme.Gray, () => OpenPanel("Troops")), -1, 80, 1);
}
protected override void Tick()
{
if (timer != null) timer.text = "До конца события: " + GameData.FormatTime(GameProgress.SecondsToWeekEnd);
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 35abdf9dc29995549b2c60dc6e2da580
+78
View File
@@ -0,0 +1,78 @@
using UnityEngine;
using TMPro;
// ГЕРОЙ: уровень, характеристики, бонус к армии, прокачка за золото и еду, навыки.
// Предел уровня героя = Ратуша * 5 -- чтобы качать героя дальше, нужно улучшать Ратушу.
public class HeroPanel : BasePanel
{
protected override void Build(RectTransform root)
{
var d = GP.Data;
int lvl = d.heroLevel;
int maxLvl = GP.HeroMaxLevel;
var list = ScrollList(root);
// --- Портрет и уровень ---
var head = UIKit.Card(list, UIKit.Theme.CardDark);
var row = UIKit.Row(head, 180, 20);
UIKit.Icon(row, new Color(0.55f, 0.4f, 0.2f), "Г", 180);
var info = UIKit.Card(row, new Color(0, 0, 0, 0), 0, 6);
UIKit.Size(info, -1, -1, 1);
UIKit.Text(info, "Верховный командир", 36, UIKit.Theme.Gold).fontStyle = FontStyles.Bold;
UIKit.Text(info, "Уровень " + lvl + " / " + maxLvl, 30);
UIKit.Text(info, "Сила героя: " + GameData.HeroPower(lvl), 26, UIKit.Theme.TextDim);
UIKit.Bar(head, (float)lvl / GameData.HeroMaxLevel(10), UIKit.Theme.Gold, 24, "Прогресс к максимуму: " + lvl + " / " + GameData.HeroMaxLevel(10));
// --- Характеристики ---
var stats = UIKit.Card(list);
ProfilePanel.Header(stats, "Характеристики");
StatRow(stats, "Атака", GameData.HeroAttack(lvl), GameData.HeroAttack(lvl + 1));
StatRow(stats, "Защита", GameData.HeroDefense(lvl), GameData.HeroDefense(lvl + 1));
StatRow(stats, "Бонус к силе армии, %", GameData.HeroArmyBonusPercent(lvl), GameData.HeroArmyBonusPercent(lvl + 1));
StatRow(stats, "Сила героя", GameData.HeroPower(lvl), GameData.HeroPower(lvl + 1));
ProfilePanel.KV(stats, "Итоговый бонус армии", "+" + Mathf.RoundToInt(GP.ArmyBonus * 100) + "%");
// --- Улучшение ---
var up = UIKit.Card(list, UIKit.Theme.Card);
ProfilePanel.Header(up, "Улучшение");
if (lvl >= maxLvl)
{
UIKit.Text(up, "Достигнут предел уровня. Улучшите Ратушу до " + (GP.TownHallLevel + 1) +
" уровня, чтобы поднять предел до " + GameData.HeroMaxLevel(GP.TownHallLevel + 1) + ".", 28, UIKit.Theme.TextDim);
}
else
{
var cost = GameData.HeroUpgradeCost(lvl);
foreach (var c in cost)
{
bool enough = GP.Res(c.type) >= c.amount;
ProfilePanel.KV(up, GameData.ResName(c.type),
"<color=" + (enough ? "#9be59b" : "#ff7a7a") + ">" + c.amount.ToString("N0") + "</color> / " +
Mathf.FloorToInt(GP.Res(c.type)).ToString("N0"));
}
bool can = GP.CanUpgradeHero;
var b = UIKit.Button(up, can ? "УЛУЧШИТЬ ДО " + (lvl + 1) + " УР." : "Не хватает ресурсов",
can ? UIKit.Theme.Green : UIKit.Theme.Gray, () => GP.UpgradeHero(), -1, 100, 32);
b.interactable = can;
}
// --- Навыки ---
var skills = UIKit.Card(list);
ProfilePanel.Header(skills, "Навыки");
for (int i = 0; i < GameData.HeroSkillNames.Length; i++)
{
bool has = GP.HasHeroSkill(i);
var r = UIKit.Row(skills, 70, 14);
UIKit.Icon(r, has ? UIKit.Theme.Gold : UIKit.Theme.Gray, (i + 1).ToString(), 56);
var t = UIKit.Text(r, GameData.HeroSkillNames[i], 26, has ? UIKit.Theme.Text : UIKit.Theme.TextDim);
UIKit.Size(t, -1, -1, 1);
UIKit.Size(UIKit.Text(r, has ? "Открыт" : "с " + GameData.HeroSkillLevels[i] + " ур.", 24,
has ? UIKit.Theme.Green : UIKit.Theme.TextDim, TextAlignmentOptions.Right), 140);
}
}
static void StatRow(Transform parent, string name, int now, int next)
{
ProfilePanel.KV(parent, name, now + " <color=#9be59b>(+" + (next - now) + ")</color>");
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: f65b505a260db5b4c9842e18522033e5
+98
View File
@@ -0,0 +1,98 @@
using System;
using UnityEngine;
using UnityEngine.UI;
using TMPro;
// ПОЧТА: список писем (новые подсвечены), чтение письма, подарки во вложениях,
// "Забрать всё" и "Удалить прочитанные". Письма приходят сами: приветствие, новый уровень и т.д.
public class MailPanel : BasePanel
{
int openedId; // 0 -- показываем список
protected override void OnEnable()
{
base.OnEnable();
openedId = 0;
}
protected override void Build(RectTransform root)
{
var mail = openedId != 0 ? GP.GetMail(openedId) : null;
if (mail == null) { openedId = 0; BuildList(root); }
else BuildLetter(root, mail);
}
void BuildList(RectTransform root)
{
var col = UIKit.Card(root, new Color(0, 0, 0, 0), 0, 12);
UIKit.Stretch(col);
var top = UIKit.Row(col, 80, 12);
UIKit.Size(UIKit.Button(top, "Забрать всё", UIKit.Theme.Green, () => GP.ClaimAllMail()), -1, 80, 1);
UIKit.Size(UIKit.Button(top, "Удалить прочитанные", UIKit.Theme.Gray, () => GP.DeleteReadMail()), -1, 80, 1);
var holder = UIKit.Make("ListHolder", col);
UIKit.Size(holder, -1, -1).flexibleHeight = 1;
var list = ScrollList(holder, 10);
if (GP.Data.mail.Count == 0)
{
UIKit.Text(list, "Писем нет", 32, UIKit.Theme.TextDim, TextAlignmentOptions.Center, 150);
return;
}
foreach (var m in GP.Data.mail)
{
var mm = m;
bool unread = !m.read;
bool gift = m.HasRewards && !m.claimed;
var card = UIKit.Card(list, unread ? new Color(0.24f, 0.24f, 0.3f) : UIKit.Theme.CardDark, 16, 4);
var btn = card.gameObject.AddComponent<Button>();
btn.onClick.AddListener(() => { openedId = mm.id; GP.ReadMail(mm); RequestRebuild(); });
var row = UIKit.Row(card, 46, 10);
var title = UIKit.Text(row, m.title, 30, unread ? UIKit.Theme.Gold : UIKit.Theme.Text);
title.fontStyle = unread ? FontStyles.Bold : FontStyles.Normal;
UIKit.Size(title, -1, -1, 1);
if (gift) UIKit.Size(UIKit.Text(row, "ПОДАРОК", 22, UIKit.Theme.Green, TextAlignmentOptions.Right), 150);
UIKit.Text(card, new DateTime(m.sentTicks).ToString("dd.MM.yyyy HH:mm") + (unread ? " • новое" : ""),
22, UIKit.Theme.TextDim);
}
}
void BuildLetter(RectTransform root, MailItem m)
{
var list = ScrollList(root);
UIKit.Button(list, "< Назад к письмам", UIKit.Theme.Gray, () => { openedId = 0; RequestRebuild(); }, -1, 76);
var card = UIKit.Card(list, UIKit.Theme.Card, 24, 14);
var title = UIKit.Text(card, m.title, 36, UIKit.Theme.Gold);
title.fontStyle = FontStyles.Bold;
UIKit.Text(card, new DateTime(m.sentTicks).ToString("dd.MM.yyyy HH:mm"), 22, UIKit.Theme.TextDim);
UIKit.Text(card, m.body, 30);
if (m.HasRewards)
{
var gift = UIKit.Card(list, m.claimed ? UIKit.Theme.CardDark : UIKit.Theme.CardDone);
ProfilePanel.Header(gift, "Вложения");
foreach (var r in m.rewards)
{
var row = UIKit.Row(gift, 50, 12);
UIKit.Icon(row, GameData.ResColor(r.type), "", 36);
var n = UIKit.Text(row, GameData.ResName(r.type), 28);
UIKit.Size(n, -1, -1, 1);
UIKit.Size(UIKit.Text(row, r.amount.ToString("N0"), 28, UIKit.Theme.Gold, TextAlignmentOptions.Right), 200);
}
var b = UIKit.Button(gift, m.claimed ? "Получено" : "Забрать", m.claimed ? UIKit.Theme.Gray : UIKit.Theme.Green,
() => GP.ClaimMail(m), -1, 90);
b.interactable = !m.claimed;
}
UIKit.Button(list, "Удалить письмо", UIKit.Theme.Red, () =>
{
if (m.HasRewards && !m.claimed) { UIKit.Toast("Сначала заберите подарок"); return; }
GP.DeleteMail(m); openedId = 0; RequestRebuild();
}, -1, 80);
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 9bff136fda8c360499ec2c3915d622ab
+109
View File
@@ -0,0 +1,109 @@
using System;
using UnityEngine;
using TMPro;
// ПРОФИЛЬ: аватар, имя, ID, уровень и опыт, сила, королевство, все ресурсы, статистика.
public class ProfilePanel : BasePanel
{
public static readonly Color[] AvatarColors =
{
new Color(0.3f, 0.45f, 0.8f), new Color(0.75f, 0.3f, 0.3f), new Color(0.3f, 0.65f, 0.4f),
new Color(0.7f, 0.5f, 0.2f), new Color(0.55f, 0.35f, 0.7f), new Color(0.25f, 0.6f, 0.65f),
};
protected override void Build(RectTransform root)
{
var d = GP.Data;
var list = ScrollList(root);
// --- Шапка: аватар + имя ---
var head = UIKit.Card(list, UIKit.Theme.CardDark);
var row = UIKit.Row(head, 150, 20);
string letter = d.playerName.Length > 0 ? d.playerName.Substring(0, 1).ToUpper() : "?";
UIKit.Icon(row, AvatarColors[d.avatarIndex % AvatarColors.Length], letter, 150);
var info = UIKit.Card(row, UIKit.Theme.CardDark, 0, 4);
UIKit.Size(info, -1, -1, 1);
var name = UIKit.Text(info, d.playerName, 40, UIKit.Theme.Text);
name.fontStyle = FontStyles.Bold;
UIKit.Text(info, "ID: " + d.playerId, 24, UIKit.Theme.TextDim);
UIKit.Text(info, "Уровень " + d.level + " • " + GameData.ArenaLeague(d.arenaRating), 28, UIKit.Theme.Gold);
var btns = UIKit.Row(head, 80, 12);
var bName = UIKit.Button(btns, "Сменить имя", UIKit.Theme.Blue, () =>
UIKit.InputDialog("Имя командира", d.playerName, 16, v => { d.playerName = v; GP.MarkChanged(); UIKit.Toast("Имя изменено"); }));
UIKit.Size(bName, -1, 80, 1);
var bAva = UIKit.Button(btns, "Сменить аватар", UIKit.Theme.Gray, () =>
{
d.avatarIndex = (d.avatarIndex + 1) % AvatarColors.Length;
GP.MarkChanged();
});
UIKit.Size(bAva, -1, 80, 1);
// --- Опыт ---
var xpCard = UIKit.Card(list);
bool max = d.level >= GameData.MaxPlayerLevel;
int need = GameData.XpToNextLevel(d.level);
UIKit.Text(xpCard, max ? "Максимальный уровень" : "Опыт до " + (d.level + 1) + " уровня", 26, UIKit.Theme.TextDim);
UIKit.Bar(xpCard, max ? 1f : (float)d.xp / need, UIKit.Theme.Blue, 40, max ? "MAX" : d.xp + " / " + need);
// --- Сила ---
var power = UIKit.Card(list);
var pt = UIKit.Text(power, "СИЛА: " + GP.TotalPower.ToString("N0"), 44, GameData.ResColor(ResourceType.Power), TextAlignmentOptions.Center);
pt.fontStyle = FontStyles.Bold;
UIKit.Text(power, "Армия " + GP.ArmyPower.ToString("N0") + " • Ратуша " + (GP.TownHallLevel * 100) +
" • Здания " + (GP.BuildingsCount * 25), 24, UIKit.Theme.TextDim, TextAlignmentOptions.Center);
// --- Королевство ---
var kingdom = UIKit.Card(list);
Header(kingdom, "Королевство");
KV(kingdom, "Ратуша", GP.TownHallLevel + " ур.");
KV(kingdom, "Построено зданий", GP.BuildingsCount.ToString());
KV(kingdom, "Герой", d.heroLevel + " ур.");
KV(kingdom, "Войска", GP.TotalTroops.ToString("N0"));
KV(kingdom, "Этап кампании", d.battleStage.ToString());
// --- Ресурсы ---
var res = UIKit.Card(list);
Header(res, "Ресурсы");
foreach (ResourceType t in Enum.GetValues(typeof(ResourceType)))
{
if (t == ResourceType.Power) continue;
var r = UIKit.Row(res, 56, 14);
UIKit.Icon(r, GameData.ResColor(t), "", 40);
var n = UIKit.Text(r, GameData.ResName(t), 28);
UIKit.Size(n, -1, -1, 1);
var v = UIKit.Text(r, Mathf.FloorToInt(GP.Res(t)).ToString("N0"), 28, UIKit.Theme.Gold, TextAlignmentOptions.Right);
UIKit.Size(v, 260);
}
// --- Статистика ---
var stats = UIKit.Card(list);
Header(stats, "Статистика");
KV(stats, "Боёв выиграно / проиграно", d.battlesWon + " / " + d.battlesLost);
KV(stats, "Карта мира: походов / побед", d.marchesSent + " / " + d.worldWins);
KV(stats, "Рейтинг арены", d.arenaRating + " (" + GameData.ArenaLeague(d.arenaRating) + ")");
KV(stats, "Побед на арене", d.arenaWins + " из " + d.arenaFights);
KV(stats, "Воинов обучено", d.troopsTrained.ToString("N0"));
KV(stats, "Заданий выполнено", d.questsDone.ToString());
float total = 0; foreach (var c in d.collected) total += c;
KV(stats, "Ресурсов собрано", GameData.Short(total));
KV(stats, "Дней в игре", d.daysPlayed.ToString());
KV(stats, "Играет с", new DateTime(d.firstLaunchTicks, DateTimeKind.Utc).ToLocalTime().ToString("dd.MM.yyyy"));
}
public static void Header(Transform parent, string text)
{
var t = UIKit.Text(parent, text, 32, UIKit.Theme.Gold);
t.fontStyle = FontStyles.Bold;
}
public static void KV(Transform parent, string key, string value)
{
var r = UIKit.Row(parent, 50, 10);
var k = UIKit.Text(r, key, 27, UIKit.Theme.TextDim);
UIKit.Size(k, -1, -1, 1);
var v = UIKit.Text(r, value, 27, UIKit.Theme.Text, TextAlignmentOptions.Right);
UIKit.Size(v, 300);
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 38a461aef5dc6674c8f0b1831b2e50fa
+76
View File
@@ -0,0 +1,76 @@
using System.Collections.Generic;
using UnityEngine;
using UnityEngine.UI;
using TMPro;
// ЗАДАНИЯ: две вкладки -- основная цепочка и ежедневные (сброс в полночь).
// Прогресс считается сам из игры: сбор ресурсов, стройка, Ратуша, бои, войска, герой, арена.
public class QuestsPanel : BasePanel
{
int tab;
TextMeshProUGUI resetTimer;
protected override void Build(RectTransform root)
{
var col = UIKit.Card(root, new Color(0, 0, 0, 0), 0, 12);
UIKit.Stretch(col);
UIKit.Tabs(col, new[] { "Основные", "Ежедневные" }, tab, i => { tab = i; RequestRebuild(); });
bool daily = tab == 1;
resetTimer = null;
if (daily)
{
resetTimer = UIKit.Text(col, "", 26, UIKit.Theme.TextDim, TextAlignmentOptions.Center, 40);
Tick();
}
var holder = UIKit.Make("ListHolder", col);
UIKit.Size(holder, -1, -1).flexibleHeight = 1;
var list = ScrollList(holder);
var source = daily ? GameData.DailyQuests : GameData.MainQuests;
var shown = new List<QuestDef>();
int claimedCount = 0;
// Сначала готовые к получению, потом в процессе. В основных -- не больше 6 активных.
foreach (var q in source) if (!GP.IsQuestClaimed(q, daily) && GP.IsQuestDone(q, daily)) shown.Add(q);
foreach (var q in source)
{
if (GP.IsQuestClaimed(q, daily)) { claimedCount++; continue; }
if (GP.IsQuestDone(q, daily)) continue;
if (!daily && shown.Count >= 6) continue;
shown.Add(q);
}
foreach (var q in shown) QuestCard(list, q, daily);
if (shown.Count == 0)
UIKit.Text(list, daily ? "Все ежедневные задания выполнены! Приходите завтра." : "Все задания выполнены! Скоро будут новые.",
30, UIKit.Theme.Gold, TextAlignmentOptions.Center, 120);
UIKit.Text(list, "Выполнено: " + claimedCount + " из " + source.Count, 26, UIKit.Theme.TextDim, TextAlignmentOptions.Center, 50);
}
void QuestCard(Transform parent, QuestDef q, bool daily)
{
bool done = GP.IsQuestDone(q, daily);
int progress = Mathf.Min(GP.QuestProgress(q, daily), q.target);
var card = UIKit.Card(parent, done ? UIKit.Theme.CardDone : UIKit.Theme.Card);
var title = UIKit.Text(card, q.title, 32);
title.fontStyle = FontStyles.Bold;
UIKit.Bar(card, (float)progress / q.target, done ? UIKit.Theme.Green : UIKit.Theme.Gold, 30, progress + " / " + q.target);
var row = UIKit.Row(card, 80, 12);
var reward = UIKit.Text(row, "Награда: " + GameData.RewardsText(q.rewards) + ", " + q.xp + " опыта", 24, UIKit.Theme.TextDim);
UIKit.Size(reward, -1, -1, 1);
var b = UIKit.Button(row, done ? "Забрать" : "В процессе", done ? UIKit.Theme.Green : UIKit.Theme.Gray,
() => GP.ClaimQuest(q, daily), 220, 80);
b.interactable = done;
}
protected override void Tick()
{
if (resetTimer != null) resetTimer.text = "Обновление через " + GameData.FormatTime(GameProgress.SecondsToNextDay);
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 4f03a70f374e3ea4c87b82f82e7e270e
@@ -0,0 +1,56 @@
using UnityEngine;
using TMPro;
// НАГРАДЫ: ежедневный вход, календарь на 7 дней. Один раз в сутки -- забрать подарок дня.
public class RewardsPanel : BasePanel
{
TextMeshProUGUI timer;
protected override void Build(RectTransform root)
{
var d = GP.Data;
var list = ScrollList(root);
UIKit.Text(list, "Заходите каждый день и забирайте подарки. На 7-й день -- главный приз!",
28, UIKit.Theme.TextDim, TextAlignmentOptions.Center);
bool canClaim = GP.CanClaimLogin;
int total = GameData.LoginRewards.Length;
int claimedUpTo = d.loginRewardIndex; // сколько дней цикла уже забрано
if (!canClaim && claimedUpTo == 0 && d.lastLoginRewardDay >= 0) claimedUpTo = total; // только что забрали 7-й
RectTransform row = null;
for (int i = 0; i < total; i++)
{
if (i % 4 == 0) row = UIKit.Row(list, 230, 12);
bool claimed = i < claimedUpTo;
bool current = canClaim && i == d.loginRewardIndex;
Color bg = claimed ? UIKit.Theme.CardDone : (current ? new Color(0.45f, 0.36f, 0.12f) : UIKit.Theme.Card);
var cell = UIKit.Card(row, bg, 10, 6);
UIKit.Size(cell, -1, -1, 1);
var day = UIKit.Text(cell, (i == total - 1 ? "ДЕНЬ 7!" : "День " + (i + 1)), 28,
current ? UIKit.Theme.Gold : UIKit.Theme.Text, TextAlignmentOptions.Center);
day.fontStyle = FontStyles.Bold;
var r = GameData.LoginRewards[i];
UIKit.Box(cell, GameData.ResColor(r[0].type), 12);
UIKit.Text(cell, GameData.RewardsText(r), 22, UIKit.Theme.Text, TextAlignmentOptions.Center);
UIKit.Text(cell, claimed ? "Получено" : (current ? "Сегодня" : ""), 22,
claimed ? UIKit.Theme.Green : UIKit.Theme.Gold, TextAlignmentOptions.Center);
}
UIKit.Spacer(list, 10);
var b = UIKit.Button(list, canClaim ? "ЗАБРАТЬ ПОДАРОК" : "Подарок получен", canClaim ? UIKit.Theme.Green : UIKit.Theme.Gray,
() => GP.ClaimLogin(), -1, 110, 36);
b.interactable = canClaim;
timer = UIKit.Text(list, "", 26, UIKit.Theme.TextDim, TextAlignmentOptions.Center, 50);
Tick();
}
protected override void Tick()
{
if (timer == null) return;
timer.text = GP.CanClaimLogin ? "" : "Следующий подарок через " + GameData.FormatTime(GameProgress.SecondsToNextDay);
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: d4a45b2e2b0cbbf4eadbd85526ecf379
@@ -0,0 +1,80 @@
using UnityEngine;
using TMPro;
// НАСТРОЙКИ: звук, вибрация, уведомления, графика, частота кадров, сохранение, сброс, о игре.
public class SettingsPanel : BasePanel
{
// Ползунки не должны пересоздаваться посреди перетаскивания
protected override bool RebuildOnChange { get { return false; } }
static readonly string[] QualityNames = { "Низкое", "Среднее", "Высокое" };
protected override void Build(RectTransform root)
{
var d = GP.Data;
var list = ScrollList(root);
// --- Звук ---
var sound = UIKit.Card(list);
ProfilePanel.Header(sound, "Звук");
SliderRow(sound, "Музыка", d.musicVolume, v => { d.musicVolume = v; GP.ApplySettings(); GP.SaveSoon(); });
SliderRow(sound, "Звуки", d.soundVolume, v => { d.soundVolume = v; GP.ApplySettings(); GP.SaveSoon(); });
// --- Общие ---
var general = UIKit.Card(list);
ProfilePanel.Header(general, "Общие");
SwitchRow(general, "Вибрация", d.vibration, v => { d.vibration = v; GP.SaveSoon(); if (v) GP.Vibrate(); });
SwitchRow(general, "Уведомления", d.notifications, v => { d.notifications = v; GP.SaveSoon(); });
// --- Графика ---
var gfx = UIKit.Card(list);
ProfilePanel.Header(gfx, "Графика");
UIKit.Text(gfx, "Качество", 28, UIKit.Theme.TextDim);
UIKit.Tabs(gfx, QualityNames, d.quality, i =>
{
d.quality = i; GP.ApplySettings(); GP.SaveSoon(); RequestRebuild();
});
SwitchRow(gfx, "60 кадров/сек (больше расход батареи)", d.fps60, v => { d.fps60 = v; GP.ApplySettings(); GP.SaveSoon(); });
// --- Игра ---
var game = UIKit.Card(list);
ProfilePanel.Header(game, "Игра");
UIKit.Button(game, "Сохранить игру сейчас", UIKit.Theme.Blue, () =>
{
if (SaveManager.Instance != null) SaveManager.Instance.SaveGame();
GP.Save();
UIKit.Toast("Игра сохранена");
}, -1, 90);
UIKit.Button(game, "Сбросить весь прогресс", UIKit.Theme.Red, () =>
UIKit.Confirm("Сбросить прогресс?",
"Удалятся все здания, ресурсы, войска, герой и задания. Отменить это нельзя.",
"Сбросить", () => { GP.ResetAll(); if (UIPanelManager.Instance != null) UIPanelManager.Instance.CloseAll(); }),
-1, 90);
// --- О игре ---
var about = UIKit.Card(list);
ProfilePanel.Header(about, "О игре");
ProfilePanel.KV(about, "Версия", Application.version);
ProfilePanel.KV(about, "ID игрока", d.playerId);
UIKit.Text(about, "MVP1 • команда 4bit", 24, UIKit.Theme.TextDim, TextAlignmentOptions.Center);
}
static void SliderRow(Transform parent, string label, float value, System.Action<float> onChange)
{
var r = UIKit.Row(parent, 70, 20);
var t = UIKit.Text(r, label, 28);
UIKit.Size(t, 200);
var pct = UIKit.Text(r, Mathf.RoundToInt(value * 100) + "%", 26, UIKit.Theme.TextDim, TextAlignmentOptions.Right);
UIKit.Slider(r, value, v => { pct.text = Mathf.RoundToInt(v * 100) + "%"; onChange(v); });
pct.transform.SetAsLastSibling();
UIKit.Size(pct, 90);
}
static void SwitchRow(Transform parent, string label, bool value, System.Action<bool> onChange)
{
var r = UIKit.Row(parent, 76, 20);
var t = UIKit.Text(r, label, 28);
UIKit.Size(t, -1, -1, 1);
UIKit.Switch(r, value, onChange);
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 2f269d44d36aae44b8bdee765662fb45
+66
View File
@@ -0,0 +1,66 @@
using UnityEngine;
using TMPro;
// МАГАЗИН: бесплатный сундук раз в день, пакеты ресурсов за кристаллы, обмен золота,
// витрина платных наборов (пока "скоро"). Лимиты покупок сбрасываются в полночь.
public class ShopPanel : BasePanel
{
TextMeshProUGUI timer;
protected override void Build(RectTransform root)
{
var list = ScrollList(root);
var wallet = UIKit.Row(list, 70, 20, UIKit.Theme.CardDark, 10);
WalletItem(wallet, ResourceType.Gems);
WalletItem(wallet, ResourceType.Gold);
timer = UIKit.Text(list, "", 24, UIKit.Theme.TextDim, TextAlignmentOptions.Center, 40);
Tick();
foreach (var item in GameData.ShopItems)
{
var it = item;
bool free = item.price == 0 && !item.comingSoon;
int bought = GP.BoughtToday(item.id);
bool canBuy = GP.CanBuy(item);
var card = UIKit.Card(list, free && canBuy ? UIKit.Theme.CardDone : UIKit.Theme.Card);
var row = UIKit.Row(card, 130, 16);
var iconColor = item.gives != null && item.gives.Length > 0 ? GameData.ResColor(item.gives[0].type) : UIKit.Theme.Gold;
UIKit.Icon(row, iconColor, item.id == "free_chest" ? "?" : "", 110);
var info = UIKit.Card(row, new Color(0, 0, 0, 0), 0, 4);
UIKit.Size(info, -1, -1, 1);
var title = UIKit.Text(info, item.title, 30);
title.fontStyle = FontStyles.Bold;
string gives = item.id == "free_chest" ? "Случайный набор ресурсов" : GameData.RewardsText(item.gives);
UIKit.Text(info, gives, 24, UIKit.Theme.TextDim);
if (item.dailyLimit > 0)
UIKit.Text(info, "Сегодня: " + bought + " / " + item.dailyLimit, 22, UIKit.Theme.TextDim);
string label;
Color color;
if (item.comingSoon) { label = item.priceLabel + "\nскоро"; color = UIKit.Theme.Gray; }
else if (!canBuy) { label = "Лимит"; color = UIKit.Theme.Gray; }
else if (free) { label = "Бесплатно"; color = UIKit.Theme.Green; }
else { label = item.price + " " + GameData.ResName(item.priceType); color = UIKit.Theme.Blue; }
var b = UIKit.Button(row, label, color, () => GP.Buy(it), 220, 100, 26);
b.interactable = canBuy;
}
}
static void WalletItem(Transform parent, ResourceType t)
{
UIKit.Icon(parent, GameData.ResColor(t), "", 40);
var txt = UIKit.Text(parent, GameData.ResName(t) + ": " + Mathf.FloorToInt(GameProgress.Instance.Res(t)).ToString("N0"), 28);
UIKit.Size(txt, -1, -1, 1);
}
protected override void Tick()
{
if (timer != null) timer.text = "Лимиты обновятся через " + GameData.FormatTime(GameProgress.SecondsToNextDay);
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: b67875d9e05857c4eae2c30b9edbef77
+112
View File
@@ -0,0 +1,112 @@
using System.Collections.Generic;
using UnityEngine;
using UnityEngine.UI;
using TMPro;
// ВОЙСКА: армия, очередь обучения (идёт и когда игра закрыта), найм воинов за ресурсы,
// ускорение за кристаллы. Новые типы войск открываются уровнем Ратуши.
public class TroopsPanel : BasePanel
{
readonly List<Image> queueBars = new List<Image>();
readonly List<TextMeshProUGUI> queueTimes = new List<TextMeshProUGUI>();
Button speedUp;
static readonly int[] Amounts = { 1, 10, 50 };
protected override void Build(RectTransform root)
{
var d = GP.Data;
queueBars.Clear(); queueTimes.Clear(); speedUp = null;
var list = ScrollList(root);
// --- Армия ---
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, "Сила войск", GP.TroopsPower.ToString("N0"));
ProfilePanel.KV(army, "Сила армии с героем", GP.ArmyPower.ToString("N0"));
UIKit.Button(army, "Отправить армию на карте мира", UIKit.Theme.Green, () => OpenPanel("World"), -1, 80, 26);
// --- Нет казарм -- нанимать негде ---
if (Barracks.Count == 0)
{
var warn = UIKit.Card(list, new Color(0.35f, 0.18f, 0.12f));
UIKit.Text(warn, "Нанимать войска можно только в Казармах. Постройте их на свободном участке.", 28, UIKit.Theme.Text);
UIKit.Button(warn, "ПОСТРОИТЬ КАЗАРМЫ", UIKit.Theme.Green, () =>
{
if (BuildModeController.Instance != null) BuildModeController.Instance.SetMode(true);
else UIKit.Toast("Нажмите кнопку СТРОЙКА");
}, -1, 96, 32);
}
// --- Очередь обучения ---
var queue = UIKit.Card(list);
ProfilePanel.Header(queue, "Обучение (" + d.training.Count + " / " + Barracks.QueueSize + ") • казарм: " + Barracks.Count);
if (d.training.Count == 0)
{
UIKit.Text(queue, "Никто не обучается. Выберите войска ниже.", 26, UIKit.Theme.TextDim);
}
else
{
foreach (var o in d.training)
{
var def = GameData.Troops[o.troopIndex];
var r = UIKit.Row(queue, 44, 10);
var n = UIKit.Text(r, def.name + " × " + o.count, 28);
UIKit.Size(n, -1, -1, 1);
var t = UIKit.Text(r, "", 26, UIKit.Theme.Gold, TextAlignmentOptions.Right);
UIKit.Size(t, 180);
queueTimes.Add(t);
queueBars.Add(UIKit.Bar(queue, 0, def.color, 20));
}
speedUp = UIKit.Button(queue, "", UIKit.Theme.Blue, () => GP.SpeedUpTraining(), -1, 80);
}
Tick();
// --- Типы войск ---
for (int i = 0; i < GameData.Troops.Count; i++)
{
int idx = i;
var def = GameData.Troops[i];
bool unlocked = GP.TroopUnlocked(i);
var card = UIKit.Card(list, unlocked ? UIKit.Theme.Card : UIKit.Theme.CardDark);
var top = UIKit.Row(card, 120, 16);
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.description, 22, UIKit.Theme.TextDim);
UIKit.Text(info, "Сила " + def.power + " • " + def.secondsPerUnit + " сек/воин • " + GameData.RewardsText(def.cost), 22, UIKit.Theme.TextDim);
if (!unlocked)
{
UIKit.Text(card, "Откроется на Ратуше " + def.requiredTownHall + " уровня", 26, UIKit.Theme.Gold, TextAlignmentOptions.Center);
continue;
}
var btns = UIKit.Row(card, 80, 10);
foreach (int amount in Amounts)
{
int a = amount;
bool can = Barracks.Count > 0 && GP.CanAfford(def.cost, a) && d.training.Count < Barracks.QueueSize;
var b = UIKit.Button(btns, "+" + a, can ? UIKit.Theme.Green : UIKit.Theme.Gray, () => GP.Train(idx, a), -1, 80, 30);
UIKit.Size(b, -1, 80, 1);
}
}
}
protected override void Tick()
{
var d = GP.Data;
for (int i = 0; i < queueBars.Count && i < d.training.Count; i++)
{
var o = d.training[i];
UIKit.SetBar(queueBars[i], GP.TrainingProgress(o));
queueTimes[i].text = GameData.FormatTime(GP.TrainingSecondsLeft(o));
}
if (speedUp != null && d.training.Count > 0)
UIKit.SetButton(speedUp, "Ускорить всё за " + GP.SpeedUpCost + " кристаллов", true);
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 6372f8b3f4381ac44b9000f7d11bb95e
+8
View File
@@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: a0c8f3b4e01d3314d8db01fcd150ec37
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:
+101
View File
@@ -0,0 +1,101 @@
using UnityEngine;
using UnityEngine.UI;
// Общая основа всех панелей HUD.
// Каждая панель (Profile, Quests, Shop...) наследуется от BasePanel и реализует Build():
// "нарисуй своё содержимое в root". Перерисовка происходит сама:
// - при открытии панели,
// - когда GameProgress сообщает об изменениях (получил награду, обучил войска и т.д.).
// Tick() вызывается 2 раза в секунду -- для таймеров, без полной перерисовки.
public abstract class BasePanel : MonoBehaviour
{
protected GameProgress GP { get { return GameProgress.Instance; } }
RectTransform content;
ScrollRect scroll;
bool needRebuild;
bool resetScroll;
float nextTick;
// false -- панель НЕ перерисовывается от чужих изменений (нужно для ползунков в Настройках)
protected virtual bool RebuildOnChange { get { return true; } }
protected virtual void OnEnable()
{
if (RebuildOnChange) GP.OnChanged += RequestRebuild;
needRebuild = true;
resetScroll = true;
}
protected virtual void OnDisable()
{
if (GameProgress.Instance != null) GameProgress.Instance.OnChanged -= RequestRebuild;
}
public void RequestRebuild() { needRebuild = true; }
void Update()
{
if (needRebuild)
{
needRebuild = false;
Rebuild();
}
if (Time.unscaledTime >= nextTick)
{
nextTick = Time.unscaledTime + 0.5f;
Tick();
}
Frame();
}
RectTransform Content
{
get
{
if (content == null)
{
var t = transform.Find("Content");
if (t == null)
{
// запасной вариант для старых панелей без Content
var body = transform.Find("Body");
if (body != null) body.gameObject.SetActive(false);
t = UIKit.Stretch(UIKit.Make("Content", transform), 24, 130, 24, 24);
}
content = (RectTransform)t;
}
return content;
}
}
void Rebuild()
{
float pos = (scroll != null && !resetScroll) ? scroll.verticalNormalizedPosition : 1f;
resetScroll = false;
scroll = null;
UIKit.Clear(Content);
Build(Content);
if (scroll != null)
{
Canvas.ForceUpdateCanvases();
scroll.verticalNormalizedPosition = pos;
}
}
// Прокручиваемый список на всю панель (позиция прокрутки сохраняется между перерисовками)
protected RectTransform ScrollList(Transform parent, float spacing = 14)
{
return UIKit.ScrollList(parent, out scroll, spacing);
}
protected abstract void Build(RectTransform root);
protected virtual void Tick() { }
protected virtual void Frame() { } // каждый кадр -- для плавного движения (армии на карте)
// Открыть другую панель (например, из "Боя" перейти в "Войска")
protected void OpenPanel(string id)
{
if (UIPanelManager.Instance != null) UIPanelManager.Instance.Open(id);
}
}
+2
View File
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 6ce45e75df7b9fe49b3246ce676ac33a
+44
View File
@@ -0,0 +1,44 @@
using UnityEngine;
using UnityEngine.UI;
using TMPro;
// Красный кружок с числом на кнопке HUD: "есть что забрать".
// Вешается билдером на кнопки (Задания, Почта, Награды...). panelId = id панели.
public class HUDBadge : MonoBehaviour
{
public string panelId;
GameObject badge;
TextMeshProUGUI label;
float next;
void Start()
{
// Если на кнопке уже есть старый значок-заглушка из скелета -- прячем его
var old = transform.Find("CountBadge");
if (old != null) old.gameObject.SetActive(false);
var rt = UIKit.Make("HUDBadge", transform);
rt.anchorMin = rt.anchorMax = new Vector2(1, 1);
rt.pivot = new Vector2(1, 1);
rt.sizeDelta = new Vector2(44, 44);
rt.anchoredPosition = new Vector2(4, 4);
var img = rt.gameObject.AddComponent<Image>();
img.color = new Color(0.9f, 0.15f, 0.15f);
img.raycastTarget = false;
label = UIKit.Text(rt, "", 26, Color.white, TextAlignmentOptions.Center);
UIKit.Stretch(label.rectTransform);
label.fontStyle = FontStyles.Bold;
badge = rt.gameObject;
badge.SetActive(false);
}
void Update()
{
if (badge == null || Time.unscaledTime < next) return;
next = Time.unscaledTime + 0.5f;
int n = GameProgress.Instance.BadgeCount(panelId);
badge.SetActive(n > 0);
label.text = n > 9 ? "9+" : n.ToString();
}
}
+2
View File
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 702c55f5c56bb7d48aa8b28f51947acf
+410
View File
@@ -0,0 +1,410 @@
using System;
using System.Collections;
using UnityEngine;
using UnityEngine.UI;
using TMPro;
// Конструктор интерфейса из кода: текст, кнопки, строки, прокрутка, полоски прогресса,
// всплывающие сообщения (Toast) и окна (подтверждение, ввод имени, результат боя).
//
// Почему из кода: списки (задания, письма, товары) меняются во время игры, их всё равно
// надо создавать на лету. Цвета и размеры -- в Theme ниже, дизайнер может поменять их тут.
public static class UIKit
{
// ---------------- ТЕМА ----------------
public static class Theme
{
public static readonly Color Card = new Color(0.17f, 0.19f, 0.24f, 1f);
public static readonly Color CardDark = new Color(0.12f, 0.13f, 0.17f, 1f);
public static readonly Color CardDone = new Color(0.16f, 0.26f, 0.18f, 1f);
public static readonly Color Gold = new Color(0.95f, 0.75f, 0.25f, 1f);
public static readonly Color Green = new Color(0.25f, 0.62f, 0.3f, 1f);
public static readonly Color Red = new Color(0.72f, 0.24f, 0.22f, 1f);
public static readonly Color Blue = new Color(0.25f, 0.45f, 0.75f, 1f);
public static readonly Color Gray = new Color(0.32f, 0.33f, 0.37f, 1f);
public static readonly Color Text = new Color(0.95f, 0.95f, 0.95f, 1f);
public static readonly Color TextDim = new Color(0.7f, 0.72f, 0.78f, 1f);
public static readonly Color BarBg = new Color(0.08f, 0.08f, 0.1f, 1f);
}
// ---------------- БАЗА ----------------
public static RectTransform Make(string name, Transform parent)
{
var go = new GameObject(name, typeof(RectTransform));
go.transform.SetParent(parent, false);
return (RectTransform)go.transform;
}
public static RectTransform Stretch(RectTransform rt, float left = 0, float top = 0, float right = 0, float bottom = 0)
{
rt.anchorMin = Vector2.zero; rt.anchorMax = Vector2.one;
rt.offsetMin = new Vector2(left, bottom);
rt.offsetMax = new Vector2(-right, -top);
return rt;
}
public static LayoutElement Size(Component c, float width = -1, float height = -1, float flexW = -1)
{
var le = c.GetComponent<LayoutElement>();
if (le == null) le = c.gameObject.AddComponent<LayoutElement>();
if (width >= 0) { le.preferredWidth = width; le.minWidth = width; }
if (height >= 0) { le.preferredHeight = height; le.minHeight = height; }
if (flexW >= 0) le.flexibleWidth = flexW;
return le;
}
public static void Clear(Transform t)
{
for (int i = t.childCount - 1; i >= 0; i--)
{
var c = t.GetChild(i).gameObject;
c.SetActive(false);
UnityEngine.Object.Destroy(c);
}
}
// ---------------- ЭЛЕМЕНТЫ ----------------
public static Image Box(Transform parent, Color color, float height = -1)
{
var rt = Make("Box", parent);
var img = rt.gameObject.AddComponent<Image>();
img.color = color;
if (height >= 0) Size(img, -1, height);
return img;
}
public static TextMeshProUGUI Text(Transform parent, string text, float size = 30, Color? color = null,
TextAlignmentOptions align = TextAlignmentOptions.Left, float height = -1)
{
var rt = Make("Text", parent);
var t = rt.gameObject.AddComponent<TextMeshProUGUI>();
t.text = text;
t.fontSize = size;
t.color = color ?? Theme.Text;
t.alignment = align;
t.raycastTarget = false;
t.richText = true;
if (height >= 0) Size(t, -1, height);
return t;
}
public static Button Button(Transform parent, string label, Color color, Action onClick,
float width = -1, float height = 86, float fontSize = 28)
{
var rt = Make("Btn_" + label, parent);
var img = rt.gameObject.AddComponent<Image>();
img.color = color;
var b = rt.gameObject.AddComponent<Button>();
var colors = b.colors;
colors.disabledColor = new Color(0.55f, 0.55f, 0.55f, 0.6f);
b.colors = colors;
if (onClick != null) b.onClick.AddListener(() => onClick());
var t = Text(rt, label, fontSize, Theme.Text, TextAlignmentOptions.Center);
Stretch(t.rectTransform, 8, 4, 8, 4);
t.enableAutoSizing = true; t.fontSizeMin = 16; t.fontSizeMax = fontSize;
Size(b, width, height);
return b;
}
public static void SetButton(Button b, string label, bool interactable)
{
if (b == null) return;
var t = b.GetComponentInChildren<TextMeshProUGUI>();
if (t != null && label != null) t.text = label;
b.interactable = interactable;
}
// Горизонтальная строка: элементы слева направо
public static RectTransform Row(Transform parent, float height = 90, float spacing = 12, Color? bg = null, int padding = 0)
{
var rt = Make("Row", parent);
if (bg.HasValue) rt.gameObject.AddComponent<Image>().color = bg.Value;
var h = rt.gameObject.AddComponent<HorizontalLayoutGroup>();
h.spacing = spacing;
h.padding = new RectOffset(padding, padding, padding, padding);
h.childAlignment = TextAnchor.MiddleLeft;
h.childControlWidth = true; h.childControlHeight = true;
h.childForceExpandWidth = false; h.childForceExpandHeight = true;
if (height >= 0) Size(rt, -1, height);
return rt;
}
// Вертикальная "карточка": элементы сверху вниз, высота по содержимому
public static RectTransform Card(Transform parent, Color? bg = null, int padding = 18, float spacing = 10)
{
var rt = Make("Card", parent);
rt.gameObject.AddComponent<Image>().color = bg ?? Theme.Card;
var v = rt.gameObject.AddComponent<VerticalLayoutGroup>();
v.padding = new RectOffset(padding, padding, padding, padding);
v.spacing = spacing;
v.childControlWidth = true; v.childControlHeight = true;
v.childForceExpandWidth = true; v.childForceExpandHeight = false;
return rt;
}
public static RectTransform Spacer(Transform parent, float height)
{
var rt = Make("Spacer", parent);
Size(rt, -1, height);
return rt;
}
// Прокручиваемый список на всю область parent. Возвращает контейнер для элементов.
public static RectTransform ScrollList(Transform parent, out ScrollRect scroll, float spacing = 14)
{
var root = Stretch(Make("Scroll", parent));
var bg = root.gameObject.AddComponent<Image>();
bg.color = new Color(0, 0, 0, 0); // прозрачный, но ловит свайпы
scroll = root.gameObject.AddComponent<ScrollRect>();
scroll.horizontal = false;
scroll.movementType = ScrollRect.MovementType.Elastic;
scroll.scrollSensitivity = 40f;
var viewport = Stretch(Make("Viewport", root));
viewport.gameObject.AddComponent<RectMask2D>();
var content = Make("Content", viewport);
content.anchorMin = new Vector2(0, 1); content.anchorMax = new Vector2(1, 1);
content.pivot = new Vector2(0.5f, 1);
content.offsetMin = Vector2.zero; content.offsetMax = Vector2.zero;
var v = content.gameObject.AddComponent<VerticalLayoutGroup>();
v.spacing = spacing;
v.padding = new RectOffset(6, 6, 6, 24);
v.childControlWidth = true; v.childControlHeight = true;
v.childForceExpandWidth = true; v.childForceExpandHeight = false;
var fit = content.gameObject.AddComponent<ContentSizeFitter>();
fit.verticalFit = ContentSizeFitter.FitMode.PreferredSize;
scroll.viewport = viewport;
scroll.content = content;
return content;
}
// Полоска прогресса. Возвращает картинку заливки (для обновления через SetBar)
public static Image Bar(Transform parent, float fraction, Color fill, float height = 26, string label = null)
{
var bg = Box(parent, Theme.BarBg, height);
var f = Make("Fill", bg.transform);
var img = f.gameObject.AddComponent<Image>();
img.color = fill;
img.raycastTarget = false;
f.anchorMin = Vector2.zero; f.offsetMin = Vector2.zero; f.offsetMax = Vector2.zero;
SetBar(img, fraction);
if (label != null)
{
var t = Text(bg.transform, label, height * 0.7f, Theme.Text, TextAlignmentOptions.Center);
Stretch(t.rectTransform);
t.name = "Label";
}
return img;
}
public static void SetBar(Image fill, float fraction, string label = null)
{
if (fill == null) return;
var rt = fill.rectTransform;
rt.anchorMax = new Vector2(Mathf.Clamp01(fraction), 1);
if (label != null)
{
var t = fill.transform.parent.Find("Label");
if (t != null) t.GetComponent<TextMeshProUGUI>().text = label;
}
}
// Цветной квадрат-иконка (заглушка, пока нет спрайтов) с буквой
public static Image Icon(Transform parent, Color color, string letter, float size = 80)
{
var img = Box(parent, color);
Size(img, size, size);
if (!string.IsNullOrEmpty(letter))
{
var t = Text(img.transform, letter, size * 0.5f, Color.white, TextAlignmentOptions.Center);
Stretch(t.rectTransform);
t.fontStyle = FontStyles.Bold;
}
return img;
}
public static Slider Slider(Transform parent, float value, Action<float> onChange)
{
var go = DefaultControls.CreateSlider(new DefaultControls.Resources());
go.transform.SetParent(parent, false);
var s = go.GetComponent<Slider>();
s.minValue = 0; s.maxValue = 1; s.value = value;
foreach (var img in go.GetComponentsInChildren<Image>())
{
if (img.name == "Background") img.color = Theme.BarBg;
else if (img.name == "Fill") img.color = Theme.Gold;
else if (img.name == "Handle") img.color = Color.white;
}
if (onChange != null) s.onValueChanged.AddListener(v => onChange(v));
Size(s, -1, 50, 1);
return s;
}
// Кнопка-переключатель ВКЛ/ВЫКЛ
public static Button Switch(Transform parent, bool on, Action<bool> onChange)
{
Button b = null;
bool state = on;
b = Button(parent, on ? "ВКЛ" : "ВЫКЛ", on ? Theme.Green : Theme.Gray, () =>
{
state = !state;
b.GetComponent<Image>().color = state ? Theme.Green : Theme.Gray;
b.GetComponentInChildren<TextMeshProUGUI>().text = state ? "ВКЛ" : "ВЫКЛ";
if (onChange != null) onChange(state);
}, 160, 70);
return b;
}
// Вкладки: ряд кнопок, текущая подсвечена
public static void Tabs(Transform parent, string[] names, int current, Action<int> onSelect)
{
var row = Row(parent, 80, 8);
for (int i = 0; i < names.Length; i++)
{
int idx = i;
var b = Button(row, names[i], i == current ? Theme.Gold : Theme.CardDark, () => onSelect(idx), -1, 80, 28);
Size(b, -1, 80, 1);
if (i == current) b.GetComponentInChildren<TextMeshProUGUI>().color = new Color(0.15f, 0.1f, 0.05f);
}
}
// ---------------- ВЕРХНИЙ СЛОЙ (тосты и окна) ----------------
static RectTransform overlay;
public static RectTransform Overlay
{
get
{
if (overlay == null)
{
var go = new GameObject("UI_Overlay", typeof(RectTransform), typeof(Canvas), typeof(CanvasScaler), typeof(GraphicRaycaster));
var canvas = go.GetComponent<Canvas>();
canvas.renderMode = RenderMode.ScreenSpaceOverlay;
canvas.sortingOrder = 1000;
var scaler = go.GetComponent<CanvasScaler>();
scaler.uiScaleMode = CanvasScaler.ScaleMode.ScaleWithScreenSize;
scaler.referenceResolution = new Vector2(1080, 1920);
scaler.matchWidthOrHeight = 0.5f;
overlay = (RectTransform)go.transform;
}
return overlay;
}
}
static ToastRunner runner;
static ToastRunner Runner
{
get
{
if (runner == null) runner = Overlay.gameObject.AddComponent<ToastRunner>();
return runner;
}
}
// Всплывающее сообщение внизу экрана на 2.5 секунды
public static void Toast(string message)
{
if (string.IsNullOrEmpty(message)) return;
if (!Application.isPlaying) { Debug.Log(message); return; }
Runner.Show(message);
}
class ToastRunner : MonoBehaviour
{
int active;
public void Show(string message) { StartCoroutine(Run(message)); }
IEnumerator Run(string message)
{
var rt = Make("Toast", Overlay);
rt.anchorMin = new Vector2(0.08f, 0); rt.anchorMax = new Vector2(0.92f, 0);
rt.pivot = new Vector2(0.5f, 0);
rt.anchoredPosition = new Vector2(0, 300 + active * 110);
rt.sizeDelta = new Vector2(0, 96);
var img = rt.gameObject.AddComponent<Image>();
img.color = new Color(0.05f, 0.05f, 0.07f, 0.92f);
img.raycastTarget = false;
var t = Text(rt, message, 28, Theme.Text, TextAlignmentOptions.Center);
Stretch(t.rectTransform, 20, 6, 20, 6);
t.enableAutoSizing = true; t.fontSizeMin = 16; t.fontSizeMax = 28;
var group = rt.gameObject.AddComponent<CanvasGroup>();
group.blocksRaycasts = false;
active++;
yield return new WaitForSecondsRealtime(2.2f);
for (float a = 1; a > 0; a -= Time.unscaledDeltaTime * 3f) { group.alpha = a; yield return null; }
active--;
Destroy(rt.gameObject);
}
}
// Окно поверх всего: затемнение + карточка по центру. Возвращает карточку для наполнения.
public static RectTransform Modal(string title, out GameObject root)
{
var dim = Stretch(Make("Modal", Overlay));
dim.gameObject.AddComponent<Image>().color = new Color(0, 0, 0, 0.65f);
root = dim.gameObject;
var card = Card(dim, Theme.CardDark, 32, 20);
card.anchorMin = new Vector2(0.06f, 0.5f); card.anchorMax = new Vector2(0.94f, 0.5f);
card.pivot = new Vector2(0.5f, 0.5f);
card.anchoredPosition = Vector2.zero;
var fit = card.gameObject.AddComponent<ContentSizeFitter>();
fit.verticalFit = ContentSizeFitter.FitMode.PreferredSize;
var t = Text(card, title, 42, Theme.Gold, TextAlignmentOptions.Center);
t.fontStyle = FontStyles.Bold;
return card;
}
public static void Message(string title, string text, string button = "ОК", Action onClose = null)
{
GameObject root;
var card = Modal(title, out root);
Text(card, text, 30, Theme.Text, TextAlignmentOptions.Center);
Spacer(card, 10);
Button(card, button, Theme.Green, () => { UnityEngine.Object.Destroy(root); if (onClose != null) onClose(); }, -1, 96, 32);
}
public static void Confirm(string title, string text, string yes, Action onYes)
{
GameObject root;
var card = Modal(title, out root);
Text(card, text, 30, Theme.Text, TextAlignmentOptions.Center);
var row = Row(card, 96, 20);
var no = Button(row, "Отмена", Theme.Gray, () => UnityEngine.Object.Destroy(root));
Size(no, -1, 96, 1);
var ok = Button(row, yes, Theme.Red, () => { UnityEngine.Object.Destroy(root); if (onYes != null) onYes(); });
Size(ok, -1, 96, 1);
}
public static void InputDialog(string title, string initial, int maxLength, Action<string> onOk)
{
GameObject root;
var card = Modal(title, out root);
var go = TMP_DefaultControls.CreateInputField(new TMP_DefaultControls.Resources());
go.transform.SetParent(card, false);
var input = go.GetComponent<TMP_InputField>();
input.text = initial;
input.characterLimit = maxLength;
input.pointSize = 34;
go.GetComponent<Image>().color = new Color(0.9f, 0.9f, 0.92f);
Size(input, -1, 90);
var row = Row(card, 96, 20);
var no = Button(row, "Отмена", Theme.Gray, () => UnityEngine.Object.Destroy(root));
Size(no, -1, 96, 1);
var ok = Button(row, "Сохранить", Theme.Green, () =>
{
string v = input.text.Trim();
if (v.Length == 0) { Toast("Имя не может быть пустым"); return; }
UnityEngine.Object.Destroy(root);
if (onOk != null) onOk(v);
});
Size(ok, -1, 96, 1);
}
}
+2
View File
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 1a737e5709aa73d4da0d72f02009e6e8
+8
View File
@@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: 29c363642fd8d494cb9127032e7e2083
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:
+126
View File
@@ -0,0 +1,126 @@
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;
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: bb78904d07b16a64682697f8a240feb5
+34
View File
@@ -0,0 +1,34 @@
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);
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 7dda3d4605bc23a4fa4655209a6c30ba
+383
View File
@@ -0,0 +1,383 @@
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);
}
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 8c8463f829cbc0840b0b660e08974aa6
+415
View File
@@ -0,0 +1,415 @@
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());
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: fb4b3ef256809b94f817e98df155b714
+30 -2
View File
@@ -1,6 +1,7 @@
using UnityEngine;
using System.Collections.Generic;
using System.IO;
using UnityEngine.SceneManagement;
// Сохраняет и загружает: все ресурсы, уровень Ратуши, список всех построенных зданий
// (какой тип, где стоит, какого уровня).
@@ -51,6 +52,24 @@ public class SaveManager : MonoBehaviour
}
Instance = this;
DontDestroyOnLoad(gameObject);
SceneManager.sceneLoaded += OnSceneLoaded;
}
void OnDestroy()
{
if (Instance == this) SceneManager.sceneLoaded -= OnSceneLoaded;
}
// [объединение с картой Саши] Первый вход в город загружает GameBootstrap.
// При возврате в город со сцены WorldMap GameBootstrap уничтожается вместе с дубликатом SaveManager,
// поэтому здания восстанавливаем здесь (из сохранения, сделанного прямо перед уходом из города).
bool cityLoadedOnce;
void OnSceneLoaded(Scene scene, LoadSceneMode mode)
{
if (BuildManager.Instance == null) return; // это не город
// ресурсы НЕ перечитываем: они живут в памяти между сценами (добыча с карты мира не должна пропасть)
if (cityLoadedOnce && HasSaveFile()) LoadGame(false);
cityLoadedOnce = true;
}
void Start()
@@ -78,6 +97,10 @@ public class SaveManager : MonoBehaviour
public void SaveGame()
{
// На карте мира города нет -- сохранение затёрло бы все здания. Город сохраняется перед уходом.
if (BuildManager.Instance == null) return;
cityLoadedOnce = true;
SaveData data = new SaveData();
// Ресурсы
@@ -125,8 +148,12 @@ public class SaveManager : MonoBehaviour
return File.Exists(SavePath);
}
public void LoadGame()
public void LoadGame() { LoadGame(true); }
public void LoadGame(bool includeResources)
{
BarracksBootstrap.Ensure(); // казармы должны быть в списке стройки до восстановления зданий
if (!HasSaveFile())
{
Debug.Log("[ЗАГРУЗКА] Файл сохранения не найден, начинаем новую игру.");
@@ -137,7 +164,7 @@ public class SaveManager : MonoBehaviour
SaveData data = JsonUtility.FromJson<SaveData>(json);
// Ресурсы
if (ResourceManager.Instance != null)
if (includeResources && ResourceManager.Instance != null)
{
foreach (var entry in data.resources)
{
@@ -171,6 +198,7 @@ public class SaveManager : MonoBehaviour
Vector3 pos = new Vector3(entry.posX, entry.posY, 0f);
GameObject built = Instantiate(matchingData.prefab, pos, Quaternion.identity);
if (!built.activeSelf) built.SetActive(true); // шаблон казарм хранится выключенным
PlacedBuildingId idMarker = built.AddComponent<PlacedBuildingId>();
idMarker.buildingName = entry.buildingName;
+60
View File
@@ -0,0 +1,60 @@
using UnityEngine;
public class UINavigation : MonoBehaviour
{
[Header("Родитель панелей (например, HUD_Canvas или PanelsContainer)")]
[SerializeField] private Transform panelsRoot;
// Публичные методы для кнопок (вешаем в OnClick)
public void OpenHome() => ShowPanel("Panel_Home");
public void OpenHero() => ShowPanel("Panel_Hero");
public void OpenArena() => ShowPanel("Panel_Arena");
public void OpenBattle() => ShowPanel("Panel_Battle");
public void OpenTroops() => ShowPanel("Panel_Troops");
public void OpenShop() => ShowPanel("Panel_Shop");
public void OpenMail() => ShowPanel("Panel_Mail");
public void OpenMissions() => ShowPanel("Panel_Missions");
public void OpenProfile() => ShowPanel("Panel_Profile");
public void OpenReward() => ShowPanel("Panel_Reward");
public void OpenEvents() => ShowPanel("Panel_Events");
private void ShowPanel(string panelName)
{
GameObject target = null; // Объявляем переменную ОДИН раз в начале метода
// Вариант 1: Если задан родитель, ищем только внутри него
if (panelsRoot != null)
{
Transform found = panelsRoot.Find(panelName);
if (found != null)
target = found.gameObject;
}
// Вариант 2: Если родителя нет, ищем по всей сцене
else
{
target = GameObject.Find(panelName);
}
// Если панель найдена — показываем её
if (target != null)
{
HideAllPanels();
target.SetActive(true);
Debug.Log("Открыта панель: " + panelName);
}
else
{
Debug.LogError("Не удалось найти панель с именем: " + panelName + ". Проверь название!");
}
}
private void HideAllPanels()
{
if (panelsRoot == null) return;
foreach (Transform child in panelsRoot)
{
child.gameObject.SetActive(false);
}
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 44d831ce8edf82d4393ffaae110a0ed2
+8
View File
@@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: c2bd5411b154aeb4d94a3df600743672
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:
+97
View File
@@ -0,0 +1,97 @@
using System;
using System.Collections.Generic;
using System.Text;
using TMPro;
using UnityEngine;
using UnityEngine.UI;
// Панель чата: вкладки "Общий мир" / "Круг", лента сообщений, поле ввода и кнопка отправки.
public class ChatPanel : MonoBehaviour
{
[Header("Вкладки")]
public Button worldTabButton;
public Button circleTabButton;
[Header("Сообщения")]
public TMP_Text messagesText;
[Header("Ввод")]
public TMP_InputField inputField;
public Button sendButton;
const int MaxShownMessages = 30; // старые всё равно уходят за верхний край ленты
static readonly Color ActiveTabColor = new Color(0.16f, 0.35f, 0.7f);
static readonly Color InactiveTabColor = new Color(0.12f, 0.13f, 0.17f);
static readonly string[] NameColors = { "#6EC6FF", "#FF8A65", "#C792EA", "#81C784", "#FFD54F", "#4DD0E1" };
const string OwnNameColor = "#FFC857";
private ChatChannel currentChannel = ChatChannel.World;
private readonly StringBuilder sb = new StringBuilder();
void Start()
{
worldTabButton.onClick.AddListener(() => SwitchChannel(ChatChannel.World));
circleTabButton.onClick.AddListener(() => SwitchChannel(ChatChannel.Circle));
sendButton.onClick.AddListener(Send);
inputField.onSubmit.AddListener(_ => Send()); // Enter на клавиатуре
inputField.characterLimit = ChatService.MaxMessageLength;
ChatService.Instance.MessageReceived += OnMessageReceived;
SwitchChannel(ChatChannel.World);
}
void OnDestroy()
{
ChatService.Instance.MessageReceived -= OnMessageReceived;
}
void SwitchChannel(ChatChannel channel)
{
currentChannel = channel;
worldTabButton.image.color = channel == ChatChannel.World ? ActiveTabColor : InactiveTabColor;
circleTabButton.image.color = channel == ChatChannel.Circle ? ActiveTabColor : InactiveTabColor;
Redraw();
}
void Send()
{
if (ChatService.Instance.Send(currentChannel, inputField.text))
{
inputField.text = "";
}
inputField.ActivateInputField(); // курсор остаётся в поле -- можно писать дальше
}
void OnMessageReceived(ChatMessage message)
{
if (message.channel == currentChannel) Redraw();
}
void Redraw()
{
IReadOnlyList<ChatMessage> messages = ChatService.Instance.GetMessages(currentChannel);
int start = Mathf.Max(0, messages.Count - MaxShownMessages);
sb.Clear();
for (int i = start; i < messages.Count; i++)
{
ChatMessage m = messages[i];
string time = DateTimeOffset.FromUnixTimeMilliseconds(m.timeMs).ToLocalTime().ToString("HH:mm");
string color = m.senderId == MarchManager.LocalPlayerId ? OwnNameColor : GetNameColor(m.senderId);
if (sb.Length > 0) sb.Append('\n');
// <noparse> -- чтобы игрок не мог вставить в сообщение теги оформления
sb.Append($"<color=#9AA3B5>[{time}]</color> <color={color}>{m.senderName}:</color> <noparse>{m.text}</noparse>");
}
messagesText.text = sb.ToString();
}
// Один и тот же игрок -- всегда один цвет
static string GetNameColor(string senderId)
{
int hash = 0;
foreach (char c in senderId) hash = hash * 31 + c;
return NameColors[(hash & int.MaxValue) % NameColors.Length];
}
}
+2
View File
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: baf21465e5a2a3f448d21131289986a9
+95
View File
@@ -0,0 +1,95 @@
using System;
using System.Collections.Generic;
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-время в миллисекундах
}
// История чата. Обычный класс со статическим экземпляром -- переживает смену сцен,
// поэтому переписка не теряется при переходе город <-> карта. Пока всё локально;
// когда появится сервер, Send будет отправлять сообщение туда, а входящие -- приходить через MessageReceived.
public class ChatService
{
public const int MaxMessageLength = 200;
public const int MaxStoredPerChannel = 100;
// Пока аккаунтов нет -- одно имя для локального игрока
public const string LocalPlayerName = "Богатырь";
private static ChatService instance;
public static ChatService Instance => instance ??= new ChatService(MarchManager.NowMs);
public event Action<ChatMessage> MessageReceived;
private readonly Dictionary<ChatChannel, List<ChatMessage>> history = new Dictionary<ChatChannel, List<ChatMessage>>
{
{ ChatChannel.World, new List<ChatMessage>() },
{ ChatChannel.Circle, new List<ChatMessage>() }
};
ChatService(long nowMs)
{
SeedDemoMessages(nowMs);
}
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 (text.Length > MaxMessageLength) text = text.Substring(0, MaxMessageLength);
Add(new ChatMessage
{
channel = channel,
senderId = MarchManager.LocalPlayerId,
senderName = LocalPlayerName,
text = text,
timeMs = MarchManager.NowMs
});
return true;
}
void Add(ChatMessage message)
{
List<ChatMessage> list = history[message.channel];
list.Add(message);
if (list.Count > MaxStoredPerChannel) list.RemoveAt(0);
MessageReceived?.Invoke(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);
AddDemo(ChatChannel.Circle, "npc_ratibor", "Ратибор", "Братья, завтра вместе идём на замок.", nowMs - 10 * minute);
AddDemo(ChatChannel.Circle, "npc_svetozar", "Светозар", "Скиньте еды в казну Круга, кому не жалко.", nowMs - 6 * 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,2 @@
fileFormatVersion: 2
guid: ee8544a4534237643ada27a572040574
+199
View File
@@ -0,0 +1,199 @@
using System;
using System.Collections.Generic;
using System.Globalization;
using TMPro;
using UnityEngine;
using UnityEngine.UI;
// Одна строка таблицы рейтинга (ссылки на её элементы; заполняет RatingPanel)
[Serializable]
public class RatingRowView
{
public GameObject root;
public Image background;
public Image medal;
public TMP_Text placeText;
public Image avatar;
public TMP_Text avatarLetter;
public TMP_Text nameText;
public Image circleBanner;
public TMP_Text circleText;
public TMP_Text powerText;
public Button inspectButton;
}
// Панель "Рейтинг": разделы слева, таблица "Место / Игрок / Круг / Мощь", страницы внизу.
public class RatingPanel : MonoBehaviour
{
public const int PageSize = 10;
[Header("Общее")]
public Button closeButton;
public TMP_Text statusText;
[Header("Разделы (порядок как в RatingCategory)")]
public Button[] categoryButtons;
[Header("Таблица")]
public TMP_Text nameColumnHeader;
public TMP_Text circleColumnHeader;
public RatingRowView[] rows;
public TMP_Text emptyText;
[Header("Страницы")]
public Button prevPageButton;
public Button nextPageButton;
public Button[] pageButtons;
static readonly Color GoldRow = new Color(0.45f, 0.32f, 0.1f, 0.95f);
static readonly Color SilverRow = new Color(0.28f, 0.31f, 0.36f, 0.95f);
static readonly Color BronzeRow = new Color(0.36f, 0.22f, 0.12f, 0.95f);
static readonly Color EvenRow = new Color(0.07f, 0.09f, 0.13f, 0.95f);
static readonly Color OddRow = new Color(0.1f, 0.12f, 0.17f, 0.95f);
static readonly Color[] MedalColors = { new Color(0.95f, 0.75f, 0.3f), new Color(0.8f, 0.83f, 0.88f), new Color(0.8f, 0.5f, 0.28f) };
static readonly Color ActiveButton = new Color(0.16f, 0.35f, 0.7f);
static readonly Color InactiveButton = new Color(0.1f, 0.12f, 0.17f);
static readonly Color[] BannerColors =
{
new Color(0.15f, 0.35f, 0.75f), new Color(0.45f, 0.2f, 0.18f), new Color(0.7f, 0.15f, 0.15f),
new Color(0.2f, 0.45f, 0.2f), new Color(0.75f, 0.5f, 0.1f), new Color(0.35f, 0.35f, 0.4f)
};
private RatingCategory category = RatingCategory.Players;
private int pageIndex;
void Awake()
{
closeButton.onClick.AddListener(Close);
prevPageButton.onClick.AddListener(() => ShowPage(pageIndex - 1));
nextPageButton.onClick.AddListener(() => ShowPage(pageIndex + 1));
for (int i = 0; i < categoryButtons.Length; i++)
{
RatingCategory c = (RatingCategory)i;
categoryButtons[i].onClick.AddListener(() => SelectCategory(c));
}
foreach (var row in rows)
{
// Круглая медаль -- спрайт генерируется в коде, поэтому ставим его здесь, а не в сборщике сцены
row.medal.sprite = PrimitiveSprites.Circle;
}
}
public void Open()
{
gameObject.SetActive(true);
SelectCategory(category);
}
public void Close()
{
gameObject.SetActive(false);
}
void SelectCategory(RatingCategory newCategory)
{
category = newCategory;
for (int i = 0; i < categoryButtons.Length; i++)
categoryButtons[i].image.color = i == (int)category ? ActiveButton : InactiveButton;
bool circles = category == RatingCategory.Circles;
nameColumnHeader.text = circles ? "Круг" : "Игрок";
circleColumnHeader.text = circles ? "Глава" : "Круг";
ShowPage(0);
}
int PageCount => Mathf.Max(1, Mathf.CeilToInt(RatingService.Instance.GetTotalCount(category) / (float)PageSize));
void ShowPage(int newPageIndex)
{
pageIndex = Mathf.Clamp(newPageIndex, 0, PageCount - 1);
statusText.text = "";
bool available = RatingService.Instance.IsAvailable(category);
emptyText.gameObject.SetActive(!available);
emptyText.text = "Раздел в разработке";
List<RatingEntry> entries = available
? RatingService.Instance.GetPage(category, pageIndex, PageSize)
: new List<RatingEntry>();
for (int i = 0; i < rows.Length; i++)
{
if (i < entries.Count) FillRow(rows[i], entries[i], i);
else rows[i].root.SetActive(false);
}
UpdatePagination(available);
}
void FillRow(RatingRowView row, RatingEntry entry, int indexOnPage)
{
row.root.SetActive(true);
bool top3 = entry.place <= 3;
row.background.color = entry.place == 1 ? GoldRow
: entry.place == 2 ? SilverRow
: entry.place == 3 ? BronzeRow
: indexOnPage % 2 == 0 ? EvenRow : OddRow;
row.medal.gameObject.SetActive(top3);
if (top3) row.medal.color = MedalColors[entry.place - 1];
row.placeText.text = entry.place.ToString();
row.placeText.color = top3 ? new Color(0.2f, 0.12f, 0.05f) : Color.white;
// Аватар-заглушка: цветной квадрат с первой буквой имени
row.avatar.color = Color.Lerp(BannerColors[StableHash(entry.name) % BannerColors.Length], Color.black, 0.25f);
row.avatarLetter.text = entry.name.Substring(0, 1).ToUpper();
row.nameText.text = entry.name;
row.circleText.text = entry.circleName;
row.circleBanner.color = BannerColors[StableHash(category == RatingCategory.Circles ? entry.name : entry.circleName) % BannerColors.Length];
row.powerText.text = FormatPower(entry.power);
string name = entry.name;
row.inspectButton.onClick.RemoveAllListeners();
row.inspectButton.onClick.AddListener(() => statusText.text = $"Профиль «{name}» пока недоступен");
}
void UpdatePagination(bool available)
{
int pageCount = PageCount;
prevPageButton.interactable = available && pageIndex > 0;
nextPageButton.interactable = available && pageIndex < pageCount - 1;
// Окно из 5 номеров вокруг текущей страницы
int first = Mathf.Clamp(pageIndex - pageButtons.Length / 2, 0, Mathf.Max(0, pageCount - pageButtons.Length));
for (int i = 0; i < pageButtons.Length; i++)
{
int page = first + i;
Button button = pageButtons[i];
bool visible = available && page < pageCount;
button.gameObject.SetActive(visible);
if (!visible) continue;
button.GetComponentInChildren<TMP_Text>().text = (page + 1).ToString();
button.image.color = page == pageIndex ? ActiveButton : InactiveButton;
button.onClick.RemoveAllListeners();
button.onClick.AddListener(() => ShowPage(page));
}
}
// 128530000 -> "128 530 000"
static string FormatPower(long power)
{
return power.ToString("N0", CultureInfo.InvariantCulture).Replace(',', ' ');
}
// string.GetHashCode в Unity может меняться между запусками -- для цвета баннера нужен стабильный
static int StableHash(string s)
{
int hash = 17;
foreach (char c in s) hash = hash * 31 + c;
return hash & int.MaxValue;
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 8c9bb8e0e4ad65a4d886aa354a85da52
+125
View File
@@ -0,0 +1,125 @@
using System;
using System.Collections.Generic;
// Разделы рейтинга (как вкладки слева на панели)
public enum RatingCategory
{
Players,
Circles,
Power,
PvP,
Development,
Season
}
[Serializable]
public class RatingEntry
{
public int place;
public string id; // id игрока или Круга
public string name; // имя игрока или название Круга
public string circleName; // Круг игрока (для рейтинга Кругов -- имя главы)
public long power;
}
// Рейтинг. Пока заглушка: 50 выдуманных игроков, числа -- как на референсе, для проверки вёрстки.
// Когда появится сервер, он будет считать рейтинг и отдавать его постранично через GetPage.
public class RatingService
{
private static RatingService instance;
public static RatingService Instance => instance ??= new RatingService();
private readonly List<RatingEntry> players = new List<RatingEntry>();
private readonly List<RatingEntry> circles = new List<RatingEntry>();
static readonly string[] PlayerNames =
{
"Велес", "Север", "Тайрон", "DreadWolf", "Мороз", "Леший", "Князь", "Варяг", "Сокол", "Ратибор",
"Светозар", "Добромир", "Велимир", "Ярополк", "Всеслав", "Мстислав", "Остромир", "Богдан", "Любомир", "Твердислав",
"Радим", "Гостомысл", "Вышеслав", "Путята", "Ставр", "Чурила", "Микула", "Василиса", "Забава", "IronBear",
"Ярослав", "Горазд", "Будимир", "Ждан", "Первуша", "Томир", "Вадим", "Олег", "Игорь", "Святослав",
"Рогволд", "Судислав", "Любава", "Милава", "Велеслава", "Snowfox", "Berserk", "Кузьма", "Потап", "Никита"
};
static readonly string[] CircleNames =
{
"Круг Перуна", "Стальные Волки", "Сыны Руси", "Богатыри", "Северный Страж",
"Вольные", "Златые Земли", "Серые Горны", "Речные", "Перунов Круг"
};
RatingService()
{
var rng = new Random(20260926);
long power = 128_530_000;
for (int i = 0; i < PlayerNames.Length; i++)
{
players.Add(new RatingEntry
{
place = i + 1,
id = $"player_{i + 1}",
name = PlayerNames[i],
circleName = CircleNames[i % CircleNames.Length],
power = power
});
// Каждый следующий слабее на 5-15%
power = power * rng.Next(85, 96) / 100;
}
BuildCircleRating();
}
void BuildCircleRating()
{
var totals = new Dictionary<string, long>();
var leaders = new Dictionary<string, string>();
foreach (var p in players)
{
totals.TryGetValue(p.circleName, out long sum);
totals[p.circleName] = sum + p.power;
if (!leaders.ContainsKey(p.circleName)) leaders[p.circleName] = p.name; // игроки уже по убыванию мощи
}
foreach (var name in CircleNames)
{
circles.Add(new RatingEntry { id = name, name = name, circleName = leaders[name], power = totals[name] });
}
circles.Sort((a, b) => b.power.CompareTo(a.power));
for (int i = 0; i < circles.Count; i++) circles[i].place = i + 1;
}
List<RatingEntry> GetList(RatingCategory category)
{
switch (category)
{
case RatingCategory.Players:
case RatingCategory.Power: // пока мощь = рейтинг игроков
return players;
case RatingCategory.Circles:
return circles;
default:
return null;
}
}
public bool IsAvailable(RatingCategory category)
{
return GetList(category) != null;
}
public int GetTotalCount(RatingCategory category)
{
return GetList(category)?.Count ?? 0;
}
public List<RatingEntry> GetPage(RatingCategory category, int pageIndex, int pageSize)
{
var result = new List<RatingEntry>();
List<RatingEntry> list = GetList(category);
if (list == null) return result;
int start = pageIndex * pageSize;
for (int i = start; i < start + pageSize && i < list.Count; i++) result.Add(list[i]);
return result;
}
}

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