Compare commits
57
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0ebbf56306 | ||
|
|
f78eb7ea7e | ||
|
|
8a5f7253db | ||
|
|
a6b5926e39 | ||
|
|
aa7789dc97 | ||
|
|
422f55f22e | ||
|
|
65f5039f74 | ||
|
|
4fe2dced76 | ||
|
|
d7348dd23d | ||
|
|
7d932e3cbc | ||
|
|
35319be8ca | ||
|
|
5502489c07 | ||
|
|
f307c83352 | ||
|
|
c29a4ae708 | ||
|
|
56b66af0c3 | ||
|
|
a03098ffe0 | ||
|
|
413c627d5c | ||
|
|
e26eeaffaf | ||
|
|
88f460a4a6 | ||
|
|
235ff124ee | ||
|
|
bb3baba3b7 | ||
|
|
1f0676ffc9 | ||
|
|
b3296c7a55 | ||
|
|
b1bb863807 | ||
|
|
8b6a7869a4 | ||
|
|
61ba46947c | ||
|
|
6c39c8b102 | ||
|
|
cfbdfd9876 | ||
|
|
fae42716a7 | ||
|
|
e69d2bd7db | ||
|
|
9a91926b2c | ||
|
|
9a6e5a2e7b | ||
|
|
a3c4a99a79 | ||
|
|
f50591d5fe | ||
|
|
d30fd34810 | ||
|
|
1b7da37c7e | ||
|
|
af57a13f0f | ||
|
|
0214bdb268 | ||
|
|
482721a704 | ||
|
|
33db41002e | ||
|
|
24268f3cfb | ||
|
|
3a86eadc0b | ||
|
|
5c4ba98fef | ||
|
|
20691e0511 | ||
|
|
b9ce85cdbb | ||
|
|
ed4e661cce | ||
|
|
54036eafcf | ||
|
|
d5747f845d | ||
|
|
552694f882 | ||
|
|
c1832a2ae7 | ||
|
|
a7416ac99d | ||
|
|
ded11b5a3f | ||
|
|
ae9c141b49 | ||
|
|
e8b17a7efa | ||
|
|
d9c0039bd2 | ||
|
|
8e9dbdf1cf | ||
|
|
6772292650 |
@@ -72,3 +72,7 @@ crashlytics-build.properties
|
||||
/[Aa]ssets/[Ss]treamingAssets/aa.meta
|
||||
/[Aa]ssets/[Ss]treamingAssets/aa/*
|
||||
|
||||
# Сборки APK (Tools -> Bogatyri -> Собрать APK) и служебные папки сборки
|
||||
/Builds/
|
||||
/build/
|
||||
/MVP1_BurstDebugInformation_DoNotShip/
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
using System;
|
||||
using System.IO;
|
||||
using System.Text.RegularExpressions;
|
||||
using UnityEditor;
|
||||
using UnityEditor.Android;
|
||||
using UnityEngine;
|
||||
|
||||
// Починка сборки Android на этом компьютере (05.11):
|
||||
// 1. Имя пользователя Windows с русскими буквами (C:\Users\Вальдемар). Gradle хранит свои файлы в
|
||||
// C:\Users\<имя>\.gradle, и одна из его программ (prefab) не понимает такой путь -- сборка падает.
|
||||
// Перед сборкой переносим папку Gradle в C:\gradle (переменная GRADLE_USER_HOME для процесса Unity,
|
||||
// сборщик её наследует). Ничего в Windows менять не нужно.
|
||||
// 2. Два Kotlin в зависимостях (1.8 у Unity и 1.6 у одного из пакетов) -- "Duplicate class kotlin...".
|
||||
// В сгенерированный проект Gradle дописываем правило: все kotlin-stdlib -- одной версии 1.8.22.
|
||||
[InitializeOnLoad]
|
||||
public class AndroidBuildFixes : IPostGenerateGradleAndroidProject
|
||||
{
|
||||
const string GradleHome = @"C:\gradle";
|
||||
const string Marker = "// Bogatyri: kotlin-stdlib одной версии";
|
||||
|
||||
static AndroidBuildFixes() { EnsureAsciiGradleHome(); }
|
||||
|
||||
public static void EnsureAsciiGradleHome()
|
||||
{
|
||||
string current = Environment.GetEnvironmentVariable("GRADLE_USER_HOME");
|
||||
if (!string.IsNullOrEmpty(current) && IsAscii(current)) return;
|
||||
if (IsAscii(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile))) return; // путь и так латиницей
|
||||
try
|
||||
{
|
||||
Directory.CreateDirectory(GradleHome);
|
||||
Environment.SetEnvironmentVariable("GRADLE_USER_HOME", GradleHome, EnvironmentVariableTarget.Process);
|
||||
Debug.Log("[СБОРКА] Папка Gradle: " + GradleHome + " (в пути пользователя есть русские буквы)");
|
||||
}
|
||||
catch (Exception e) { Debug.LogWarning("[СБОРКА] Не удалось задать GRADLE_USER_HOME: " + e.Message); }
|
||||
}
|
||||
|
||||
static bool IsAscii(string s) => !Regex.IsMatch(s ?? "", @"[^\u0000-\u007F]");
|
||||
|
||||
public int callbackOrder => 999;
|
||||
|
||||
// path -- папка unityLibrary сгенерированного проекта
|
||||
public void OnPostGenerateGradleAndroidProject(string path)
|
||||
{
|
||||
string root = Directory.GetParent(path).FullName;
|
||||
foreach (var file in new[] { Path.Combine(root, "launcher", "build.gradle"), Path.Combine(path, "build.gradle") })
|
||||
{
|
||||
if (!File.Exists(file)) continue;
|
||||
string text = File.ReadAllText(file);
|
||||
if (text.Contains(Marker)) continue;
|
||||
text += "\n" + Marker + "\n" +
|
||||
"configurations.all {\n" +
|
||||
" resolutionStrategy.eachDependency { details ->\n" +
|
||||
" if (details.requested.group == 'org.jetbrains.kotlin' && details.requested.name.startsWith('kotlin-stdlib')) {\n" +
|
||||
" details.useVersion('1.8.22')\n" +
|
||||
" }\n" +
|
||||
" }\n" +
|
||||
"}\n";
|
||||
File.WriteAllText(file, text);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 030e644cb052c0f4d959e499015370fd
|
||||
@@ -0,0 +1,102 @@
|
||||
using System;
|
||||
using System.Diagnostics;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Text;
|
||||
using UnityEditor;
|
||||
using UnityEditor.Build.Reporting;
|
||||
|
||||
// Сборка APK одной кнопкой (05.11): Tools -> Bogatyri -> Собрать APK.
|
||||
// Файл: C:\MVP1\Builds\Bogatyri_<ветка>_v<версия>_<дата>.apk; сцены -- из списка сборки (первая -- стартовая).
|
||||
// Итог сборки (успех, размер, первые ошибки) пишется в Builds\last_build.txt.
|
||||
// Сборку можно запустить и файлом-запросом Builds\build_request.txt (так её запускает Claude): Unity заметит
|
||||
// его в течение пары секунд, удалит и начнёт сборку.
|
||||
[InitializeOnLoad]
|
||||
public static class ApkBuilder
|
||||
{
|
||||
const string Folder = "Builds";
|
||||
const string RequestFile = "Builds/build_request.txt";
|
||||
const string ResultFile = "Builds/last_build.txt";
|
||||
static double nextCheck;
|
||||
|
||||
static ApkBuilder()
|
||||
{
|
||||
EditorApplication.update += Poll;
|
||||
}
|
||||
|
||||
static void Poll()
|
||||
{
|
||||
if (EditorApplication.timeSinceStartup < nextCheck) return;
|
||||
nextCheck = EditorApplication.timeSinceStartup + 2;
|
||||
if (!File.Exists(RequestFile) || BuildPipeline.isBuildingPlayer || EditorApplication.isCompiling || EditorApplication.isPlaying) return;
|
||||
File.Delete(RequestFile);
|
||||
EditorApplication.delayCall += Build;
|
||||
}
|
||||
|
||||
[MenuItem("Tools/Bogatyri/Собрать APK")]
|
||||
public static void Build()
|
||||
{
|
||||
AndroidBuildFixes.EnsureAsciiGradleHome();
|
||||
Directory.CreateDirectory(Folder);
|
||||
string branch = Git("rev-parse --abbrev-ref HEAD").Replace('/', '-');
|
||||
if (branch.Length == 0) branch = "local";
|
||||
string file = $"{Folder}/Bogatyri_{branch}_v{PlayerSettings.bundleVersion}_{DateTime.Now:yyyyMMdd_HHmm}.apk";
|
||||
var scenes = EditorBuildSettings.scenes.Where(s => s.enabled).Select(s => s.path).ToArray();
|
||||
|
||||
File.WriteAllText(ResultFile, $"Сборка идёт: {DateTime.Now:dd.MM.yyyy HH:mm}\n{file}\n");
|
||||
BuildReport report;
|
||||
try
|
||||
{
|
||||
EditorUserBuildSettings.buildAppBundle = false; // обычный APK
|
||||
report = BuildPipeline.BuildPlayer(new BuildPlayerOptions
|
||||
{
|
||||
scenes = scenes,
|
||||
locationPathName = file,
|
||||
target = BuildTarget.Android,
|
||||
options = BuildOptions.None
|
||||
});
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
File.WriteAllText(ResultFile, $"ОШИБКА {DateTime.Now:dd.MM.yyyy HH:mm}\n{e}\n");
|
||||
throw;
|
||||
}
|
||||
|
||||
var s = report.summary;
|
||||
var text = new StringBuilder();
|
||||
text.AppendLine($"{(s.result == BuildResult.Succeeded ? "ГОТОВО" : "ОШИБКА")} {DateTime.Now:dd.MM.yyyy HH:mm} результат: {s.result}");
|
||||
text.AppendLine($"Файл: {Path.GetFullPath(file)}");
|
||||
if (s.result == BuildResult.Succeeded && File.Exists(file)) text.AppendLine($"Размер: {new FileInfo(file).Length / 1048576.0:0.0} МБ");
|
||||
text.AppendLine($"Версия: {PlayerSettings.bundleVersion}, код {PlayerSettings.Android.bundleVersionCode}, ветка {branch} {Git("rev-parse --short HEAD")}");
|
||||
text.AppendLine($"Сцены: {string.Join(", ", scenes)}");
|
||||
text.AppendLine($"Время: {s.totalTime}, ошибок: {s.totalErrors}, предупреждений: {s.totalWarnings}");
|
||||
int shown = 0;
|
||||
foreach (var step in report.steps)
|
||||
foreach (var m in step.messages)
|
||||
if (m.type == UnityEngine.LogType.Error || m.type == UnityEngine.LogType.Exception)
|
||||
{
|
||||
if (shown++ >= 15) break;
|
||||
string msg = m.content.Length > 1500 ? m.content.Substring(0, 1500) + " ..." : m.content;
|
||||
text.AppendLine("--- " + msg);
|
||||
}
|
||||
File.WriteAllText(ResultFile, text.ToString());
|
||||
UnityEngine.Debug.Log("[СБОРКА] " + text.ToString().Split('\n')[0] + " -> " + file);
|
||||
if (s.result == BuildResult.Succeeded) EditorUtility.RevealInFinder(file);
|
||||
}
|
||||
|
||||
static string Git(string args)
|
||||
{
|
||||
try
|
||||
{
|
||||
var p = Process.Start(new ProcessStartInfo("git", args)
|
||||
{
|
||||
RedirectStandardOutput = true, UseShellExecute = false, CreateNoWindow = true,
|
||||
WorkingDirectory = Directory.GetCurrentDirectory()
|
||||
});
|
||||
string o = p.StandardOutput.ReadToEnd().Trim();
|
||||
p.WaitForExit(5000);
|
||||
return o;
|
||||
}
|
||||
catch { return ""; }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 904233d7647ed024d920bdbbd4058aba
|
||||
@@ -0,0 +1,51 @@
|
||||
using System;
|
||||
using System.Diagnostics;
|
||||
using System.IO;
|
||||
using UnityEditor;
|
||||
using UnityEditor.Build;
|
||||
using UnityEditor.Build.Reporting;
|
||||
|
||||
// Перед каждым билдом записывает в Assets/Resources/BuildInfo.txt: ветку, коммит и дату сборки (05.11).
|
||||
// Игра показывает это в углу экрана рядом с версией (App/GameVersionLabel). После билда файл удаляется,
|
||||
// чтобы не попадать в git.
|
||||
public class BuildInfoWriter : IPreprocessBuildWithReport, IPostprocessBuildWithReport
|
||||
{
|
||||
const string Path = "Assets/Resources/BuildInfo.txt";
|
||||
public int callbackOrder => 0;
|
||||
|
||||
public void OnPreprocessBuild(BuildReport report)
|
||||
{
|
||||
string branch = Or(Git("rev-parse --abbrev-ref HEAD")), commit = Or(Git("rev-parse --short HEAD"));
|
||||
bool dirty = Git("status --porcelain --untracked-files=no").Length > 0;
|
||||
string info = $"{branch} {commit}{(dirty ? "+" : "")} • {DateTime.Now:dd.MM.yyyy HH:mm}";
|
||||
Directory.CreateDirectory("Assets/Resources");
|
||||
File.WriteAllText(Path, info);
|
||||
AssetDatabase.ImportAsset(Path);
|
||||
UnityEngine.Debug.Log("[ВЕРСИЯ] Сборка: v" + PlayerSettings.bundleVersion + " • " + info);
|
||||
}
|
||||
|
||||
public void OnPostprocessBuild(BuildReport report)
|
||||
{
|
||||
AssetDatabase.DeleteAsset(Path);
|
||||
}
|
||||
|
||||
static string Git(string args)
|
||||
{
|
||||
try
|
||||
{
|
||||
var p = Process.Start(new ProcessStartInfo("git", args)
|
||||
{
|
||||
RedirectStandardOutput = true,
|
||||
UseShellExecute = false,
|
||||
CreateNoWindow = true,
|
||||
WorkingDirectory = Directory.GetCurrentDirectory()
|
||||
});
|
||||
string output = p.StandardOutput.ReadToEnd().Trim();
|
||||
p.WaitForExit(5000);
|
||||
return output;
|
||||
}
|
||||
catch { return ""; }
|
||||
}
|
||||
|
||||
static string Or(string s) => string.IsNullOrEmpty(s) ? "?" : s;
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 1218c67fef1faaf448c6623df897ac12
|
||||
@@ -0,0 +1,119 @@
|
||||
using UnityEditor;
|
||||
using UnityEditor.SceneManagement;
|
||||
using UnityEngine;
|
||||
using UnityEngine.Rendering;
|
||||
using UnityEngine.Rendering.Universal;
|
||||
using UnityEngine.SceneManagement;
|
||||
|
||||
// Создаёт сцену 3D-поселения (прототип, этап I ТЗ «Система строительства поселения» от 04.11.2026):
|
||||
// Tools -> Bogatyri -> 3D-город: создать сцену City3D.
|
||||
//
|
||||
// Что делает:
|
||||
// 1. Добавляет в настройки URP второй рендерер -- обычный 3D (Assets/Settings/Renderer3D.asset).
|
||||
// Старые 2D-сцены продолжают рисоваться 2D-рендерером: он остаётся рендерером по умолчанию.
|
||||
// 2. Создаёт Assets/Scenes/City3D.unity: камера (3D-рендерер, перспектива), солнце с тенями,
|
||||
// объект City3D с City3DController -- всё остальное (земля, деревья, здания) он строит сам при запуске.
|
||||
// 3. Добавляет сцену в Build Settings и открывает её. Дальше -- просто Play.
|
||||
public static class City3DSetup
|
||||
{
|
||||
const string ScenePath = "Assets/Scenes/City3D.unity";
|
||||
const string RendererPath = "Assets/Settings/Renderer3D.asset";
|
||||
|
||||
[MenuItem("Tools/Bogatyri/3D-город: создать сцену City3D")]
|
||||
static void Create()
|
||||
{
|
||||
if (!EditorSceneManager.SaveCurrentModifiedScenesIfUserWantsTo()) return;
|
||||
|
||||
int rendererIndex = EnsureRenderer3D();
|
||||
|
||||
var scene = EditorSceneManager.NewScene(NewSceneSetup.EmptyScene, NewSceneMode.Single);
|
||||
|
||||
var camGo = new GameObject("Main Camera") { tag = "MainCamera" };
|
||||
var cam = camGo.AddComponent<Camera>();
|
||||
cam.orthographic = false;
|
||||
cam.fieldOfView = 40f;
|
||||
cam.nearClipPlane = 0.3f;
|
||||
cam.farClipPlane = 300f;
|
||||
cam.clearFlags = CameraClearFlags.SolidColor;
|
||||
cam.backgroundColor = new Color(0.55f, 0.72f, 0.9f);
|
||||
camGo.AddComponent<AudioListener>();
|
||||
var data = camGo.AddComponent<UniversalAdditionalCameraData>();
|
||||
if (rendererIndex >= 0) data.SetRenderer(rendererIndex);
|
||||
camGo.AddComponent<City3DCamera>();
|
||||
|
||||
var sunGo = new GameObject("Sun");
|
||||
var sun = sunGo.AddComponent<Light>();
|
||||
sun.type = LightType.Directional;
|
||||
sun.intensity = 1.2f;
|
||||
sun.shadows = LightShadows.Soft;
|
||||
sunGo.transform.rotation = Quaternion.Euler(50f, -30f, 0f);
|
||||
RenderSettings.ambientMode = AmbientMode.Flat;
|
||||
RenderSettings.ambientLight = new Color(0.55f, 0.58f, 0.62f);
|
||||
|
||||
new GameObject("City3D").AddComponent<City3DController>();
|
||||
|
||||
EditorSceneManager.SaveScene(scene, ScenePath);
|
||||
AddToBuildSettings();
|
||||
Debug.Log($"[3D-ГОРОД] Сцена создана: {ScenePath}. 3D-рендерер: индекс {rendererIndex}. Нажмите Play.");
|
||||
}
|
||||
|
||||
// Второй рендерер URP -- обычный (3D). Добавляется в URP Asset из Graphics Settings и из КАЖДОГО уровня качества
|
||||
// (в этом проекте URP задан только в Quality). Возвращает индекс рендерера в списке (или -1)
|
||||
[MenuItem("Tools/Bogatyri/3D: добавить 3D-рендерер в URP")]
|
||||
static void AddRendererMenu()
|
||||
{
|
||||
int index = EnsureRenderer3D();
|
||||
Debug.Log(index >= 0 ? $"[3D] 3D-рендерер подключён (индекс {index}). Можно запускать карту мира и City3D."
|
||||
: "[3D] URP Asset не найден ни в Graphics, ни в Quality Settings.");
|
||||
}
|
||||
|
||||
static int EnsureRenderer3D()
|
||||
{
|
||||
var pipelines = new System.Collections.Generic.List<UniversalRenderPipelineAsset>();
|
||||
void Add(RenderPipelineAsset a) { if (a is UniversalRenderPipelineAsset u && !pipelines.Contains(u)) pipelines.Add(u); }
|
||||
Add(GraphicsSettings.defaultRenderPipeline);
|
||||
for (int q = 0; q < QualitySettings.names.Length; q++) Add(QualitySettings.GetRenderPipelineAssetAt(q));
|
||||
if (pipelines.Count == 0)
|
||||
{
|
||||
Debug.LogWarning("[3D-ГОРОД] Нет URP Asset ни в Graphics, ни в Quality Settings -- камера будет рисовать рендерером по умолчанию");
|
||||
return -1;
|
||||
}
|
||||
|
||||
var renderer = AssetDatabase.LoadAssetAtPath<UniversalRendererData>(RendererPath);
|
||||
if (renderer == null)
|
||||
{
|
||||
renderer = ScriptableObject.CreateInstance<UniversalRendererData>();
|
||||
AssetDatabase.CreateAsset(renderer, RendererPath);
|
||||
AssetDatabase.SaveAssets();
|
||||
}
|
||||
|
||||
int result = -1;
|
||||
foreach (var pipeline in pipelines)
|
||||
{
|
||||
var so = new SerializedObject(pipeline);
|
||||
var list = so.FindProperty("m_RendererDataList");
|
||||
int found = -1;
|
||||
for (int i = 0; i < list.arraySize; i++)
|
||||
if (list.GetArrayElementAtIndex(i).objectReferenceValue == renderer) found = i;
|
||||
if (found < 0)
|
||||
{
|
||||
list.arraySize++;
|
||||
list.GetArrayElementAtIndex(list.arraySize - 1).objectReferenceValue = renderer;
|
||||
so.ApplyModifiedProperties();
|
||||
EditorUtility.SetDirty(pipeline);
|
||||
found = list.arraySize - 1;
|
||||
}
|
||||
if (result < 0) result = found;
|
||||
}
|
||||
AssetDatabase.SaveAssets();
|
||||
return result;
|
||||
}
|
||||
|
||||
static void AddToBuildSettings()
|
||||
{
|
||||
var scenes = new System.Collections.Generic.List<EditorBuildSettingsScene>(EditorBuildSettings.scenes);
|
||||
foreach (var s in scenes) if (s.path == ScenePath) return;
|
||||
scenes.Add(new EditorBuildSettingsScene(ScenePath, true));
|
||||
EditorBuildSettings.scenes = scenes.ToArray();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 37d458150cc84d538a23b4c6612995c7
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -3,11 +3,13 @@ using UnityEditor;
|
||||
|
||||
// Следит, чтобы сцена карты мира (WorldMap, от Саши) была в Build Settings --
|
||||
// иначе переход город -> карта не работает. Срабатывает сам после каждой компиляции.
|
||||
// Стартовая сцена (05.11): 3D-поселение City3D, если оно есть в проекте, иначе 2D-город MVP1.
|
||||
[InitializeOnLoad]
|
||||
public static class SceneListFixer
|
||||
{
|
||||
const string CityScene = "Assets/Scenes/MVP1.unity";
|
||||
const string WorldScene = "Assets/Scenes/WorldMap.unity";
|
||||
const string City3DScene = "Assets/Scenes/City3D.unity";
|
||||
|
||||
static SceneListFixer() { EditorApplication.delayCall += Ensure; }
|
||||
|
||||
@@ -15,15 +17,16 @@ public static class SceneListFixer
|
||||
{
|
||||
var scenes = new List<EditorBuildSettingsScene>(EditorBuildSettings.scenes);
|
||||
bool changed = false;
|
||||
foreach (var path in new[] { CityScene, WorldScene })
|
||||
string start = AssetDatabase.LoadAssetAtPath<SceneAsset>(City3DScene) != null ? City3DScene : CityScene;
|
||||
foreach (var path in new[] { start, 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);
|
||||
// стартовый город должен быть первым (с него стартует игра)
|
||||
int city = scenes.FindIndex(s => s.path == start);
|
||||
if (city > 0) { var c = scenes[city]; scenes.RemoveAt(city); scenes.Insert(0, c); changed = true; }
|
||||
if (changed)
|
||||
{
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,7 @@
|
||||
fileFormatVersion: 2
|
||||
guid: dd067b5f8139dbc46b51ef157b0f0613
|
||||
PrefabImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -12,12 +12,41 @@ MonoBehaviour:
|
||||
m_Script: {fileID: 11500000, guid: 7264ce138a844adc81fa69b77a27c8a6, type: 3}
|
||||
m_Name: BattleConfig
|
||||
m_EditorClassIdentifier:
|
||||
version: 2
|
||||
version: 8
|
||||
counterPercent: 25
|
||||
wallsPercent: 20
|
||||
siegeVsWallsPercent: 50
|
||||
siegeInFieldPercent: -25
|
||||
loserLossPercent: 50
|
||||
randomPercent: 5
|
||||
famineStrengthPercent: 50
|
||||
fatigueStrengthPercent: 80
|
||||
lossRatioPermille:
|
||||
- 1000
|
||||
- 3000
|
||||
- 5000
|
||||
- 7000
|
||||
- 10000
|
||||
strongLossPermille:
|
||||
- 450
|
||||
- 160
|
||||
- 110
|
||||
- 74
|
||||
- 20
|
||||
weakLossPermille:
|
||||
- 450
|
||||
- 740
|
||||
- 880
|
||||
- 920
|
||||
- 950
|
||||
weakLossMaxPermille: 990
|
||||
deadRatioPermille:
|
||||
- 200
|
||||
- 500
|
||||
- 1000
|
||||
- 3000
|
||||
deadSharePermille:
|
||||
- 700
|
||||
- 450
|
||||
- 300
|
||||
- 200
|
||||
missingSharePermille: 0
|
||||
|
||||
@@ -18,6 +18,11 @@ MonoBehaviour:
|
||||
startSoldiers: 30
|
||||
foodPerCivilianPerHour: 0.1
|
||||
foodPerSoldierPerHour: 0.2
|
||||
foodPerTroopTypePerHour:
|
||||
- 0.15
|
||||
- 0.2
|
||||
- 0.15
|
||||
- 0.15
|
||||
growthPerHour: 2.5
|
||||
housingBase: 300
|
||||
housingPerTownHallLevel: 50
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
%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: c25bbe993b314a15a6d1a8aaf0017d55, type: 3}
|
||||
m_Name: HospitalConfig
|
||||
m_EditorClassIdentifier:
|
||||
bedsByLevel: 140000001e00000028000000320000003c000000
|
||||
temporaryBeds: 5
|
||||
requiredTownHallLevel: 1
|
||||
buildWood: 250
|
||||
buildStone: 150
|
||||
healGameHours: 04000000040000000600000008000000
|
||||
healFoodPerSoldier: 00000000000000000000000000000000
|
||||
healGoldPerSoldier: 01000000010000000200000003000000
|
||||
missingReturnPercent: 50
|
||||
missingWoundedPercent: 30
|
||||
missingWindowGameHours: 6
|
||||
missingMaxGroups: 4
|
||||
@@ -0,0 +1,8 @@
|
||||
fileFormatVersion: 2
|
||||
guid: d92d3f1bd32b432e9a30ee269f436ca0
|
||||
NativeFormatImporter:
|
||||
externalObjects: {}
|
||||
mainObjectFileID: 11400000
|
||||
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: c249ad34e2f94460b9f99b49c71760c8, type: 3}
|
||||
m_Name: IntelligenceConfig
|
||||
m_EditorClassIdentifier:
|
||||
settlementRadius: 5
|
||||
armyRadius: 1
|
||||
observationPercent: 0a000000140000001e000000
|
||||
testObservationLevel: 0
|
||||
lastContactMinutes: 10
|
||||
approxPercent: 20
|
||||
identifyPercent: 50
|
||||
sampleStepSeconds: 10
|
||||
hideUnexploredObjects: 0
|
||||
@@ -0,0 +1,8 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 4972288700954eda8b97a86b3f7a5492
|
||||
NativeFormatImporter:
|
||||
externalObjects: {}
|
||||
mainObjectFileID: 11400000
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,8 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 18e162eeff424f8bb3a71543eafba0c8
|
||||
folderAsset: yes
|
||||
DefaultImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,137 @@
|
||||
%YAML 1.1
|
||||
%TAG !u! tag:unity3d.com,2011:
|
||||
--- !u!114 &-7493335797097805923
|
||||
MonoBehaviour:
|
||||
m_ObjectHideFlags: 11
|
||||
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: d0353a89b1f911e48b9e16bdc9f2e058, type: 3}
|
||||
m_Name:
|
||||
m_EditorClassIdentifier: Unity.RenderPipelines.Universal.Editor::UnityEditor.Rendering.Universal.AssetVersion
|
||||
version: 10
|
||||
--- !u!21 &2100000
|
||||
Material:
|
||||
serializedVersion: 8
|
||||
m_ObjectHideFlags: 0
|
||||
m_CorrespondingSourceObject: {fileID: 0}
|
||||
m_PrefabInstance: {fileID: 0}
|
||||
m_PrefabAsset: {fileID: 0}
|
||||
m_Name: Map3DLit
|
||||
m_Shader: {fileID: 4800000, guid: 933532a4fcc9baf4fa0491de14d08ed7, type: 3}
|
||||
m_Parent: {fileID: 0}
|
||||
m_ModifiedSerializedProperties: 0
|
||||
m_ValidKeywords: []
|
||||
m_InvalidKeywords: []
|
||||
m_LightmapFlags: 4
|
||||
m_EnableInstancingVariants: 1
|
||||
m_DoubleSidedGI: 0
|
||||
m_CustomRenderQueue: -1
|
||||
stringTagMap:
|
||||
RenderType: Opaque
|
||||
disabledShaderPasses:
|
||||
- MOTIONVECTORS
|
||||
m_LockedProperties:
|
||||
m_SavedProperties:
|
||||
serializedVersion: 3
|
||||
m_TexEnvs:
|
||||
- _BaseMap:
|
||||
m_Texture: {fileID: 0}
|
||||
m_Scale: {x: 1, y: 1}
|
||||
m_Offset: {x: 0, y: 0}
|
||||
- _BumpMap:
|
||||
m_Texture: {fileID: 0}
|
||||
m_Scale: {x: 1, y: 1}
|
||||
m_Offset: {x: 0, y: 0}
|
||||
- _DetailAlbedoMap:
|
||||
m_Texture: {fileID: 0}
|
||||
m_Scale: {x: 1, y: 1}
|
||||
m_Offset: {x: 0, y: 0}
|
||||
- _DetailMask:
|
||||
m_Texture: {fileID: 0}
|
||||
m_Scale: {x: 1, y: 1}
|
||||
m_Offset: {x: 0, y: 0}
|
||||
- _DetailNormalMap:
|
||||
m_Texture: {fileID: 0}
|
||||
m_Scale: {x: 1, y: 1}
|
||||
m_Offset: {x: 0, y: 0}
|
||||
- _EmissionMap:
|
||||
m_Texture: {fileID: 0}
|
||||
m_Scale: {x: 1, y: 1}
|
||||
m_Offset: {x: 0, y: 0}
|
||||
- _MainTex:
|
||||
m_Texture: {fileID: 0}
|
||||
m_Scale: {x: 1, y: 1}
|
||||
m_Offset: {x: 0, y: 0}
|
||||
- _MetallicGlossMap:
|
||||
m_Texture: {fileID: 0}
|
||||
m_Scale: {x: 1, y: 1}
|
||||
m_Offset: {x: 0, y: 0}
|
||||
- _OcclusionMap:
|
||||
m_Texture: {fileID: 0}
|
||||
m_Scale: {x: 1, y: 1}
|
||||
m_Offset: {x: 0, y: 0}
|
||||
- _ParallaxMap:
|
||||
m_Texture: {fileID: 0}
|
||||
m_Scale: {x: 1, y: 1}
|
||||
m_Offset: {x: 0, y: 0}
|
||||
- _SpecGlossMap:
|
||||
m_Texture: {fileID: 0}
|
||||
m_Scale: {x: 1, y: 1}
|
||||
m_Offset: {x: 0, y: 0}
|
||||
- unity_Lightmaps:
|
||||
m_Texture: {fileID: 0}
|
||||
m_Scale: {x: 1, y: 1}
|
||||
m_Offset: {x: 0, y: 0}
|
||||
- unity_LightmapsInd:
|
||||
m_Texture: {fileID: 0}
|
||||
m_Scale: {x: 1, y: 1}
|
||||
m_Offset: {x: 0, y: 0}
|
||||
- unity_ShadowMasks:
|
||||
m_Texture: {fileID: 0}
|
||||
m_Scale: {x: 1, y: 1}
|
||||
m_Offset: {x: 0, y: 0}
|
||||
m_Ints: []
|
||||
m_Floats:
|
||||
- _AddPrecomputedVelocity: 0
|
||||
- _AlphaClip: 0
|
||||
- _AlphaToMask: 0
|
||||
- _Blend: 0
|
||||
- _BlendModePreserveSpecular: 1
|
||||
- _BumpScale: 1
|
||||
- _ClearCoatMask: 0
|
||||
- _ClearCoatSmoothness: 0
|
||||
- _Cull: 2
|
||||
- _Cutoff: 0.5
|
||||
- _DetailAlbedoMapScale: 1
|
||||
- _DetailNormalMapScale: 1
|
||||
- _DstBlend: 0
|
||||
- _DstBlendAlpha: 0
|
||||
- _EnvironmentReflections: 1
|
||||
- _GlossMapScale: 0
|
||||
- _Glossiness: 0
|
||||
- _GlossyReflections: 0
|
||||
- _Metallic: 0
|
||||
- _OcclusionStrength: 1
|
||||
- _Parallax: 0.005
|
||||
- _QueueOffset: 0
|
||||
- _ReceiveShadows: 1
|
||||
- _Smoothness: 0.12
|
||||
- _SmoothnessTextureChannel: 0
|
||||
- _SpecularHighlights: 1
|
||||
- _SrcBlend: 1
|
||||
- _SrcBlendAlpha: 1
|
||||
- _Surface: 0
|
||||
- _WorkflowMode: 1
|
||||
- _XRMotionVectorsPass: 1
|
||||
- _ZWrite: 1
|
||||
m_Colors:
|
||||
- _BaseColor: {r: 1, g: 1, b: 1, a: 1}
|
||||
- _Color: {r: 1, g: 1, b: 1, a: 1}
|
||||
- _EmissionColor: {r: 0, g: 0, b: 0, a: 1}
|
||||
- _SpecColor: {r: 0.19999996, g: 0.19999996, b: 0.19999996, a: 1}
|
||||
m_BuildTextureStacks: []
|
||||
m_AllowLocking: 1
|
||||
@@ -0,0 +1,8 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 0ec4d2749e73404eba2facea709d9f24
|
||||
NativeFormatImporter:
|
||||
externalObjects: {}
|
||||
mainObjectFileID: 2100000
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,26 @@
|
||||
%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: 0427eef4612041d2b8a359f9bf358779, type: 3}
|
||||
m_Name: MarchConfig
|
||||
m_EditorClassIdentifier:
|
||||
infantry: 8
|
||||
archers: 9
|
||||
cavalry: 12
|
||||
siege: 4
|
||||
typeSpeedBonusPercent: 00000000000000000000000000000000
|
||||
armySpeedBonusPercent: 0
|
||||
baseMarchSlots: 2
|
||||
patrolsEnabled: 1
|
||||
patrolMaxActive: 3
|
||||
patrolIntervalMinutes: 10
|
||||
patrolSoldiersMin: 10
|
||||
patrolSoldiersMax: 40
|
||||
@@ -0,0 +1,8 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 99295aa9eabc4ab6bf8cc687f4d89d96
|
||||
NativeFormatImporter:
|
||||
externalObjects: {}
|
||||
mainObjectFileID: 11400000
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,58 @@
|
||||
%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: 6ee3759b377140c0b759a4a386327510, type: 3}
|
||||
m_Name: WorldResourceConfig
|
||||
m_EditorClassIdentifier:
|
||||
woodStock:
|
||||
- 200
|
||||
- 600
|
||||
- 1500
|
||||
stoneStock:
|
||||
- 150
|
||||
- 450
|
||||
- 1000
|
||||
metalStock:
|
||||
- 80
|
||||
- 250
|
||||
- 600
|
||||
goldStock:
|
||||
- 30
|
||||
- 100
|
||||
- 250
|
||||
woodPerGameHour: 2
|
||||
stonePerGameHour: 1.5
|
||||
metalPerGameHour: 1
|
||||
goldPerGameHour: 0.5
|
||||
woodCargo: 12
|
||||
stoneCargo: 10
|
||||
metalCargo: 8
|
||||
goldCargo: 5
|
||||
lifetimeRealHours:
|
||||
- 12
|
||||
- 24
|
||||
- 48
|
||||
woodCount:
|
||||
- 4
|
||||
- 3
|
||||
- 1
|
||||
stoneCount:
|
||||
- 4
|
||||
- 2
|
||||
- 1
|
||||
metalCount:
|
||||
- 2
|
||||
- 2
|
||||
- 1
|
||||
goldCount:
|
||||
- 2
|
||||
- 1
|
||||
- 1
|
||||
@@ -0,0 +1,8 @@
|
||||
fileFormatVersion: 2
|
||||
guid: b95775cbe52447a6abff99bfeb488500
|
||||
NativeFormatImporter:
|
||||
externalObjects: {}
|
||||
mainObjectFileID: 11400000
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,799 @@
|
||||
%YAML 1.1
|
||||
%TAG !u! tag:unity3d.com,2011:
|
||||
--- !u!29 &1
|
||||
OcclusionCullingSettings:
|
||||
m_ObjectHideFlags: 0
|
||||
serializedVersion: 2
|
||||
m_OcclusionBakeSettings:
|
||||
smallestOccluder: 5
|
||||
smallestHole: 0.25
|
||||
backfaceThreshold: 100
|
||||
m_SceneGUID: 00000000000000000000000000000000
|
||||
m_OcclusionCullingData: {fileID: 0}
|
||||
--- !u!104 &2
|
||||
RenderSettings:
|
||||
m_ObjectHideFlags: 0
|
||||
serializedVersion: 10
|
||||
m_Fog: 0
|
||||
m_FogColor: {r: 0.5, g: 0.5, b: 0.5, a: 1}
|
||||
m_FogMode: 3
|
||||
m_FogDensity: 0.01
|
||||
m_LinearFogStart: 0
|
||||
m_LinearFogEnd: 300
|
||||
m_AmbientSkyColor: {r: 0.55, g: 0.58, b: 0.62, a: 1}
|
||||
m_AmbientEquatorColor: {r: 0.114, g: 0.125, b: 0.133, a: 1}
|
||||
m_AmbientGroundColor: {r: 0.047, g: 0.043, b: 0.035, a: 1}
|
||||
m_AmbientIntensity: 1
|
||||
m_AmbientMode: 3
|
||||
m_SubtractiveShadowColor: {r: 0.42, g: 0.478, b: 0.627, a: 1}
|
||||
m_SkyboxMaterial: {fileID: 0}
|
||||
m_HaloStrength: 0.5
|
||||
m_FlareStrength: 1
|
||||
m_FlareFadeSpeed: 3
|
||||
m_HaloTexture: {fileID: 0}
|
||||
m_SpotCookie: {fileID: 10001, guid: 0000000000000000e000000000000000, type: 0}
|
||||
m_DefaultReflectionMode: 0
|
||||
m_DefaultReflectionResolution: 128
|
||||
m_ReflectionBounces: 1
|
||||
m_ReflectionIntensity: 1
|
||||
m_CustomReflection: {fileID: 0}
|
||||
m_Sun: {fileID: 0}
|
||||
m_UseRadianceAmbientProbe: 0
|
||||
--- !u!157 &3
|
||||
LightmapSettings:
|
||||
m_ObjectHideFlags: 0
|
||||
serializedVersion: 13
|
||||
m_BakeOnSceneLoad: 0
|
||||
m_GISettings:
|
||||
serializedVersion: 2
|
||||
m_BounceScale: 1
|
||||
m_IndirectOutputScale: 1
|
||||
m_AlbedoBoost: 1
|
||||
m_EnvironmentLightingMode: 0
|
||||
m_EnableBakedLightmaps: 0
|
||||
m_EnableRealtimeLightmaps: 0
|
||||
m_LightmapEditorSettings:
|
||||
serializedVersion: 12
|
||||
m_Resolution: 2
|
||||
m_BakeResolution: 40
|
||||
m_AtlasSize: 1024
|
||||
m_AO: 0
|
||||
m_AOMaxDistance: 1
|
||||
m_CompAOExponent: 1
|
||||
m_CompAOExponentDirect: 0
|
||||
m_ExtractAmbientOcclusion: 0
|
||||
m_Padding: 2
|
||||
m_LightmapParameters: {fileID: 0}
|
||||
m_LightmapsBakeMode: 1
|
||||
m_TextureCompression: 1
|
||||
m_ReflectionCompression: 2
|
||||
m_MixedBakeMode: 2
|
||||
m_BakeBackend: 2
|
||||
m_PVRSampling: 1
|
||||
m_PVRDirectSampleCount: 32
|
||||
m_PVRSampleCount: 512
|
||||
m_PVRBounces: 2
|
||||
m_PVREnvironmentSampleCount: 256
|
||||
m_PVREnvironmentReferencePointCount: 2048
|
||||
m_PVRFilteringMode: 1
|
||||
m_PVRDenoiserTypeDirect: 1
|
||||
m_PVRDenoiserTypeIndirect: 1
|
||||
m_PVRDenoiserTypeAO: 1
|
||||
m_PVRFilterTypeDirect: 0
|
||||
m_PVRFilterTypeIndirect: 0
|
||||
m_PVRFilterTypeAO: 0
|
||||
m_PVREnvironmentMIS: 1
|
||||
m_PVRCulling: 1
|
||||
m_PVRFilteringGaussRadiusDirect: 1
|
||||
m_PVRFilteringGaussRadiusIndirect: 1
|
||||
m_PVRFilteringGaussRadiusAO: 1
|
||||
m_PVRFilteringAtrousPositionSigmaDirect: 0.5
|
||||
m_PVRFilteringAtrousPositionSigmaIndirect: 2
|
||||
m_PVRFilteringAtrousPositionSigmaAO: 1
|
||||
m_ExportTrainingData: 0
|
||||
m_TrainingDataDestination: TrainingData
|
||||
m_LightProbeSampleCountMultiplier: 4
|
||||
m_LightingDataAsset: {fileID: 20201, guid: 0000000000000000f000000000000000, type: 0}
|
||||
m_LightingSettings: {fileID: 0}
|
||||
--- !u!196 &4
|
||||
NavMeshSettings:
|
||||
serializedVersion: 2
|
||||
m_ObjectHideFlags: 0
|
||||
m_BuildSettings:
|
||||
serializedVersion: 3
|
||||
agentTypeID: 0
|
||||
agentRadius: 0.5
|
||||
agentHeight: 2
|
||||
agentSlope: 45
|
||||
agentClimb: 0.4
|
||||
ledgeDropHeight: 0
|
||||
maxJumpAcrossDistance: 0
|
||||
minRegionArea: 2
|
||||
manualCellSize: 0
|
||||
cellSize: 0.16666667
|
||||
manualTileSize: 0
|
||||
tileSize: 256
|
||||
buildHeightMesh: 0
|
||||
maxJobWorkers: 0
|
||||
preserveTilesOutsideBounds: 0
|
||||
debug:
|
||||
m_Flags: 0
|
||||
m_NavMeshData: {fileID: 0}
|
||||
--- !u!1 &110794169
|
||||
GameObject:
|
||||
m_ObjectHideFlags: 0
|
||||
m_CorrespondingSourceObject: {fileID: 0}
|
||||
m_PrefabInstance: {fileID: 0}
|
||||
m_PrefabAsset: {fileID: 0}
|
||||
serializedVersion: 6
|
||||
m_Component:
|
||||
- component: {fileID: 110794174}
|
||||
- component: {fileID: 110794173}
|
||||
- component: {fileID: 110794172}
|
||||
- component: {fileID: 110794171}
|
||||
- component: {fileID: 110794170}
|
||||
- component: {fileID: 110794175}
|
||||
m_Layer: 0
|
||||
m_Name: Main Camera
|
||||
m_TagString: MainCamera
|
||||
m_Icon: {fileID: 0}
|
||||
m_NavMeshLayer: 0
|
||||
m_StaticEditorFlags: 0
|
||||
m_IsActive: 1
|
||||
--- !u!114 &110794170
|
||||
MonoBehaviour:
|
||||
m_ObjectHideFlags: 0
|
||||
m_CorrespondingSourceObject: {fileID: 0}
|
||||
m_PrefabInstance: {fileID: 0}
|
||||
m_PrefabAsset: {fileID: 0}
|
||||
m_GameObject: {fileID: 110794169}
|
||||
m_Enabled: 1
|
||||
m_EditorHideFlags: 0
|
||||
m_Script: {fileID: 11500000, guid: e8645f0028574904969b5802443f100f, type: 3}
|
||||
m_Name:
|
||||
m_EditorClassIdentifier: Assembly-CSharp::City3DCamera
|
||||
pitch: 52
|
||||
yaw: 45
|
||||
distance: 26
|
||||
minDistance: 8
|
||||
maxDistance: 55
|
||||
target: {x: 0, y: 0, z: 0}
|
||||
bounds:
|
||||
serializedVersion: 2
|
||||
x: 0
|
||||
y: 0
|
||||
width: 48
|
||||
height: 48
|
||||
--- !u!114 &110794171
|
||||
MonoBehaviour:
|
||||
m_ObjectHideFlags: 0
|
||||
m_CorrespondingSourceObject: {fileID: 0}
|
||||
m_PrefabInstance: {fileID: 0}
|
||||
m_PrefabAsset: {fileID: 0}
|
||||
m_GameObject: {fileID: 110794169}
|
||||
m_Enabled: 1
|
||||
m_EditorHideFlags: 0
|
||||
m_Script: {fileID: 11500000, guid: a79441f348de89743a2939f4d699eac1, type: 3}
|
||||
m_Name:
|
||||
m_EditorClassIdentifier: Unity.RenderPipelines.Universal.Runtime::UnityEngine.Rendering.Universal.UniversalAdditionalCameraData
|
||||
m_RenderShadows: 1
|
||||
m_RequiresDepthTextureOption: 2
|
||||
m_RequiresOpaqueTextureOption: 2
|
||||
m_CameraType: 0
|
||||
m_Cameras: []
|
||||
m_RendererIndex: -1
|
||||
m_VolumeLayerMask:
|
||||
serializedVersion: 2
|
||||
m_Bits: 1
|
||||
m_VolumeTrigger: {fileID: 0}
|
||||
m_VolumeFrameworkUpdateModeOption: 2
|
||||
m_RenderPostProcessing: 0
|
||||
m_Antialiasing: 0
|
||||
m_AntialiasingQuality: 2
|
||||
m_StopNaN: 0
|
||||
m_Dithering: 0
|
||||
m_ClearDepth: 1
|
||||
m_AllowXRRendering: 1
|
||||
m_AllowHDROutput: 1
|
||||
m_UseScreenCoordOverride: 0
|
||||
m_ScreenSizeOverride: {x: 0, y: 0, z: 0, w: 0}
|
||||
m_ScreenCoordScaleBias: {x: 0, y: 0, z: 0, w: 0}
|
||||
m_RequiresDepthTexture: 0
|
||||
m_RequiresColorTexture: 0
|
||||
m_TaaSettings:
|
||||
m_Quality: 3
|
||||
m_FrameInfluence: 0.1
|
||||
m_JitterScale: 1
|
||||
m_MipBias: 0
|
||||
m_VarianceClampScale: 0.9
|
||||
m_ContrastAdaptiveSharpening: 0
|
||||
m_Version: 2
|
||||
--- !u!81 &110794172
|
||||
AudioListener:
|
||||
m_ObjectHideFlags: 0
|
||||
m_CorrespondingSourceObject: {fileID: 0}
|
||||
m_PrefabInstance: {fileID: 0}
|
||||
m_PrefabAsset: {fileID: 0}
|
||||
m_GameObject: {fileID: 110794169}
|
||||
m_Enabled: 1
|
||||
--- !u!20 &110794173
|
||||
Camera:
|
||||
m_ObjectHideFlags: 0
|
||||
m_CorrespondingSourceObject: {fileID: 0}
|
||||
m_PrefabInstance: {fileID: 0}
|
||||
m_PrefabAsset: {fileID: 0}
|
||||
m_GameObject: {fileID: 110794169}
|
||||
m_Enabled: 1
|
||||
serializedVersion: 2
|
||||
m_ClearFlags: 2
|
||||
m_BackGroundColor: {r: 0.55, g: 0.72, b: 0.9, a: 1}
|
||||
m_projectionMatrixMode: 1
|
||||
m_GateFitMode: 2
|
||||
m_FOVAxisMode: 0
|
||||
m_Iso: 200
|
||||
m_ShutterSpeed: 0.005
|
||||
m_Aperture: 16
|
||||
m_FocusDistance: 10
|
||||
m_FocalLength: 50
|
||||
m_BladeCount: 5
|
||||
m_Curvature: {x: 2, y: 11}
|
||||
m_BarrelClipping: 0.25
|
||||
m_Anamorphism: 0
|
||||
m_SensorSize: {x: 36, y: 24}
|
||||
m_LensShift: {x: 0, y: 0}
|
||||
m_NormalizedViewPortRect:
|
||||
serializedVersion: 2
|
||||
x: 0
|
||||
y: 0
|
||||
width: 1
|
||||
height: 1
|
||||
near clip plane: 0.3
|
||||
far clip plane: 300
|
||||
field of view: 40
|
||||
orthographic: 1
|
||||
orthographic size: 5
|
||||
m_Depth: 0
|
||||
m_CullingMask:
|
||||
serializedVersion: 2
|
||||
m_Bits: 4294967295
|
||||
m_RenderingPath: -1
|
||||
m_TargetTexture: {fileID: 0}
|
||||
m_TargetDisplay: 0
|
||||
m_TargetEye: 3
|
||||
m_HDR: 1
|
||||
m_AllowMSAA: 1
|
||||
m_AllowDynamicResolution: 0
|
||||
m_ForceIntoRT: 0
|
||||
m_OcclusionCulling: 1
|
||||
m_StereoConvergence: 10
|
||||
m_StereoSeparation: 0.022
|
||||
--- !u!4 &110794174
|
||||
Transform:
|
||||
m_ObjectHideFlags: 0
|
||||
m_CorrespondingSourceObject: {fileID: 0}
|
||||
m_PrefabInstance: {fileID: 0}
|
||||
m_PrefabAsset: {fileID: 0}
|
||||
m_GameObject: {fileID: 110794169}
|
||||
serializedVersion: 2
|
||||
m_LocalRotation: {x: 0, y: 0, z: 0, w: 1}
|
||||
m_LocalPosition: {x: 0, y: 0, z: 0}
|
||||
m_LocalScale: {x: 1, y: 1, z: 1}
|
||||
m_ConstrainProportionsScale: 0
|
||||
m_Children: []
|
||||
m_Father: {fileID: 0}
|
||||
m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0}
|
||||
--- !u!114 &110794175
|
||||
MonoBehaviour:
|
||||
m_ObjectHideFlags: 0
|
||||
m_CorrespondingSourceObject: {fileID: 0}
|
||||
m_PrefabInstance: {fileID: 0}
|
||||
m_PrefabAsset: {fileID: 0}
|
||||
m_GameObject: {fileID: 110794169}
|
||||
m_Enabled: 1
|
||||
m_EditorHideFlags: 0
|
||||
m_Script: {fileID: 11500000, guid: 772c25e1ad11ec84191ddd62a2559d77, type: 3}
|
||||
m_Name:
|
||||
m_EditorClassIdentifier: Assembly-CSharp::CameraController
|
||||
moveSpeed: 5
|
||||
zoomSpeed: 2
|
||||
minOrthoSize: 2
|
||||
maxOrthoSize: 50
|
||||
--- !u!1 &429953705
|
||||
GameObject:
|
||||
m_ObjectHideFlags: 0
|
||||
m_CorrespondingSourceObject: {fileID: 0}
|
||||
m_PrefabInstance: {fileID: 0}
|
||||
m_PrefabAsset: {fileID: 0}
|
||||
serializedVersion: 6
|
||||
m_Component:
|
||||
- component: {fileID: 429953708}
|
||||
- component: {fileID: 429953707}
|
||||
- component: {fileID: 429953706}
|
||||
m_Layer: 0
|
||||
m_Name: EventSystem
|
||||
m_TagString: Untagged
|
||||
m_Icon: {fileID: 0}
|
||||
m_NavMeshLayer: 0
|
||||
m_StaticEditorFlags: 0
|
||||
m_IsActive: 1
|
||||
--- !u!114 &429953706
|
||||
MonoBehaviour:
|
||||
m_ObjectHideFlags: 0
|
||||
m_CorrespondingSourceObject: {fileID: 0}
|
||||
m_PrefabInstance: {fileID: 0}
|
||||
m_PrefabAsset: {fileID: 0}
|
||||
m_GameObject: {fileID: 429953705}
|
||||
m_Enabled: 1
|
||||
m_EditorHideFlags: 0
|
||||
m_Script: {fileID: 11500000, guid: 4f231c4fb786f3946a6b90b886c48677, type: 3}
|
||||
m_Name:
|
||||
m_EditorClassIdentifier: UnityEngine.UI::UnityEngine.EventSystems.StandaloneInputModule
|
||||
m_SendPointerHoverToParent: 1
|
||||
m_HorizontalAxis: Horizontal
|
||||
m_VerticalAxis: Vertical
|
||||
m_SubmitButton: Submit
|
||||
m_CancelButton: Cancel
|
||||
m_InputActionsPerSecond: 10
|
||||
m_RepeatDelay: 0.5
|
||||
m_ForceModuleActive: 0
|
||||
--- !u!114 &429953707
|
||||
MonoBehaviour:
|
||||
m_ObjectHideFlags: 0
|
||||
m_CorrespondingSourceObject: {fileID: 0}
|
||||
m_PrefabInstance: {fileID: 0}
|
||||
m_PrefabAsset: {fileID: 0}
|
||||
m_GameObject: {fileID: 429953705}
|
||||
m_Enabled: 1
|
||||
m_EditorHideFlags: 0
|
||||
m_Script: {fileID: 11500000, guid: 76c392e42b5098c458856cdf6ecaaaa1, type: 3}
|
||||
m_Name:
|
||||
m_EditorClassIdentifier: UnityEngine.UI::UnityEngine.EventSystems.EventSystem
|
||||
m_FirstSelected: {fileID: 0}
|
||||
m_sendNavigationEvents: 1
|
||||
m_DragThreshold: 10
|
||||
--- !u!4 &429953708
|
||||
Transform:
|
||||
m_ObjectHideFlags: 0
|
||||
m_CorrespondingSourceObject: {fileID: 0}
|
||||
m_PrefabInstance: {fileID: 0}
|
||||
m_PrefabAsset: {fileID: 0}
|
||||
m_GameObject: {fileID: 429953705}
|
||||
serializedVersion: 2
|
||||
m_LocalRotation: {x: 0, y: 0, z: 0, w: 1}
|
||||
m_LocalPosition: {x: 0, y: 0, z: 0}
|
||||
m_LocalScale: {x: 1, y: 1, z: 1}
|
||||
m_ConstrainProportionsScale: 0
|
||||
m_Children: []
|
||||
m_Father: {fileID: 0}
|
||||
m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0}
|
||||
--- !u!1001 &695231209
|
||||
PrefabInstance:
|
||||
m_ObjectHideFlags: 0
|
||||
serializedVersion: 2
|
||||
m_Modification:
|
||||
serializedVersion: 3
|
||||
m_TransformParent: {fileID: 0}
|
||||
m_Modifications:
|
||||
- target: {fileID: 390569600188462384, guid: dd067b5f8139dbc46b51ef157b0f0613, type: 3}
|
||||
propertyPath: m_AnchoredPosition.x
|
||||
value: -23.000854
|
||||
objectReference: {fileID: 0}
|
||||
- target: {fileID: 628544459168404126, guid: dd067b5f8139dbc46b51ef157b0f0613, type: 3}
|
||||
propertyPath: m_AnchorMax.y
|
||||
value: 0
|
||||
objectReference: {fileID: 0}
|
||||
- target: {fileID: 628544459168404126, guid: dd067b5f8139dbc46b51ef157b0f0613, type: 3}
|
||||
propertyPath: m_AnchorMin.y
|
||||
value: 0
|
||||
objectReference: {fileID: 0}
|
||||
- target: {fileID: 628544459168404126, guid: dd067b5f8139dbc46b51ef157b0f0613, type: 3}
|
||||
propertyPath: m_AnchoredPosition.x
|
||||
value: 0
|
||||
objectReference: {fileID: 0}
|
||||
- target: {fileID: 628544459168404126, guid: dd067b5f8139dbc46b51ef157b0f0613, type: 3}
|
||||
propertyPath: m_AnchoredPosition.y
|
||||
value: 0
|
||||
objectReference: {fileID: 0}
|
||||
- target: {fileID: 2090086157864901368, guid: dd067b5f8139dbc46b51ef157b0f0613, type: 3}
|
||||
propertyPath: m_Pivot.x
|
||||
value: 0
|
||||
objectReference: {fileID: 0}
|
||||
- target: {fileID: 2090086157864901368, guid: dd067b5f8139dbc46b51ef157b0f0613, type: 3}
|
||||
propertyPath: m_Pivot.y
|
||||
value: 0
|
||||
objectReference: {fileID: 0}
|
||||
- target: {fileID: 2090086157864901368, guid: dd067b5f8139dbc46b51ef157b0f0613, type: 3}
|
||||
propertyPath: m_AnchorMax.x
|
||||
value: 0
|
||||
objectReference: {fileID: 0}
|
||||
- target: {fileID: 2090086157864901368, guid: dd067b5f8139dbc46b51ef157b0f0613, type: 3}
|
||||
propertyPath: m_AnchorMax.y
|
||||
value: 0
|
||||
objectReference: {fileID: 0}
|
||||
- target: {fileID: 2090086157864901368, guid: dd067b5f8139dbc46b51ef157b0f0613, type: 3}
|
||||
propertyPath: m_AnchorMin.x
|
||||
value: 0
|
||||
objectReference: {fileID: 0}
|
||||
- target: {fileID: 2090086157864901368, guid: dd067b5f8139dbc46b51ef157b0f0613, type: 3}
|
||||
propertyPath: m_AnchorMin.y
|
||||
value: 0
|
||||
objectReference: {fileID: 0}
|
||||
- target: {fileID: 2090086157864901368, guid: dd067b5f8139dbc46b51ef157b0f0613, type: 3}
|
||||
propertyPath: m_SizeDelta.x
|
||||
value: 0
|
||||
objectReference: {fileID: 0}
|
||||
- target: {fileID: 2090086157864901368, guid: dd067b5f8139dbc46b51ef157b0f0613, type: 3}
|
||||
propertyPath: m_SizeDelta.y
|
||||
value: 0
|
||||
objectReference: {fileID: 0}
|
||||
- target: {fileID: 2090086157864901368, guid: dd067b5f8139dbc46b51ef157b0f0613, type: 3}
|
||||
propertyPath: m_LocalPosition.x
|
||||
value: 0
|
||||
objectReference: {fileID: 0}
|
||||
- target: {fileID: 2090086157864901368, guid: dd067b5f8139dbc46b51ef157b0f0613, type: 3}
|
||||
propertyPath: m_LocalPosition.y
|
||||
value: 0
|
||||
objectReference: {fileID: 0}
|
||||
- target: {fileID: 2090086157864901368, guid: dd067b5f8139dbc46b51ef157b0f0613, type: 3}
|
||||
propertyPath: m_LocalPosition.z
|
||||
value: 0
|
||||
objectReference: {fileID: 0}
|
||||
- target: {fileID: 2090086157864901368, guid: dd067b5f8139dbc46b51ef157b0f0613, type: 3}
|
||||
propertyPath: m_LocalRotation.w
|
||||
value: 1
|
||||
objectReference: {fileID: 0}
|
||||
- target: {fileID: 2090086157864901368, guid: dd067b5f8139dbc46b51ef157b0f0613, type: 3}
|
||||
propertyPath: m_LocalRotation.x
|
||||
value: 0
|
||||
objectReference: {fileID: 0}
|
||||
- target: {fileID: 2090086157864901368, guid: dd067b5f8139dbc46b51ef157b0f0613, type: 3}
|
||||
propertyPath: m_LocalRotation.y
|
||||
value: 0
|
||||
objectReference: {fileID: 0}
|
||||
- target: {fileID: 2090086157864901368, guid: dd067b5f8139dbc46b51ef157b0f0613, type: 3}
|
||||
propertyPath: m_LocalRotation.z
|
||||
value: 0
|
||||
objectReference: {fileID: 0}
|
||||
- target: {fileID: 2090086157864901368, guid: dd067b5f8139dbc46b51ef157b0f0613, type: 3}
|
||||
propertyPath: m_AnchoredPosition.x
|
||||
value: 0
|
||||
objectReference: {fileID: 0}
|
||||
- target: {fileID: 2090086157864901368, guid: dd067b5f8139dbc46b51ef157b0f0613, type: 3}
|
||||
propertyPath: m_AnchoredPosition.y
|
||||
value: 0
|
||||
objectReference: {fileID: 0}
|
||||
- target: {fileID: 2090086157864901368, guid: dd067b5f8139dbc46b51ef157b0f0613, type: 3}
|
||||
propertyPath: m_LocalEulerAnglesHint.x
|
||||
value: 0
|
||||
objectReference: {fileID: 0}
|
||||
- target: {fileID: 2090086157864901368, guid: dd067b5f8139dbc46b51ef157b0f0613, type: 3}
|
||||
propertyPath: m_LocalEulerAnglesHint.y
|
||||
value: 0
|
||||
objectReference: {fileID: 0}
|
||||
- target: {fileID: 2090086157864901368, guid: dd067b5f8139dbc46b51ef157b0f0613, type: 3}
|
||||
propertyPath: m_LocalEulerAnglesHint.z
|
||||
value: 0
|
||||
objectReference: {fileID: 0}
|
||||
- target: {fileID: 3476476116216033849, guid: dd067b5f8139dbc46b51ef157b0f0613, type: 3}
|
||||
propertyPath: m_AnchorMax.y
|
||||
value: 0
|
||||
objectReference: {fileID: 0}
|
||||
- target: {fileID: 3476476116216033849, guid: dd067b5f8139dbc46b51ef157b0f0613, type: 3}
|
||||
propertyPath: m_AnchorMin.y
|
||||
value: 0
|
||||
objectReference: {fileID: 0}
|
||||
- target: {fileID: 3476476116216033849, guid: dd067b5f8139dbc46b51ef157b0f0613, type: 3}
|
||||
propertyPath: m_AnchoredPosition.x
|
||||
value: 0
|
||||
objectReference: {fileID: 0}
|
||||
- target: {fileID: 3476476116216033849, guid: dd067b5f8139dbc46b51ef157b0f0613, type: 3}
|
||||
propertyPath: m_AnchoredPosition.y
|
||||
value: 0
|
||||
objectReference: {fileID: 0}
|
||||
- target: {fileID: 3561707029477629692, guid: dd067b5f8139dbc46b51ef157b0f0613, type: 3}
|
||||
propertyPath: m_AnchorMax.y
|
||||
value: 0
|
||||
objectReference: {fileID: 0}
|
||||
- target: {fileID: 3561707029477629692, guid: dd067b5f8139dbc46b51ef157b0f0613, type: 3}
|
||||
propertyPath: m_AnchorMin.y
|
||||
value: 0
|
||||
objectReference: {fileID: 0}
|
||||
- target: {fileID: 3561707029477629692, guid: dd067b5f8139dbc46b51ef157b0f0613, type: 3}
|
||||
propertyPath: m_AnchoredPosition.x
|
||||
value: 0
|
||||
objectReference: {fileID: 0}
|
||||
- target: {fileID: 4178416176766117917, guid: dd067b5f8139dbc46b51ef157b0f0613, type: 3}
|
||||
propertyPath: m_SizeDelta.x
|
||||
value: 0
|
||||
objectReference: {fileID: 0}
|
||||
- target: {fileID: 4178416176766117917, guid: dd067b5f8139dbc46b51ef157b0f0613, type: 3}
|
||||
propertyPath: m_SizeDelta.y
|
||||
value: 0
|
||||
objectReference: {fileID: 0}
|
||||
- target: {fileID: 5046265208400373706, guid: dd067b5f8139dbc46b51ef157b0f0613, type: 3}
|
||||
propertyPath: m_sharedMaterial
|
||||
value:
|
||||
objectReference: {fileID: 2180264, guid: 8f586378b4e144a9851e7b34d9b748ee, type: 2}
|
||||
- target: {fileID: 5449404431136268404, guid: dd067b5f8139dbc46b51ef157b0f0613, type: 3}
|
||||
propertyPath: m_Camera
|
||||
value:
|
||||
objectReference: {fileID: 110794173}
|
||||
- target: {fileID: 5795784041573784673, guid: dd067b5f8139dbc46b51ef157b0f0613, type: 3}
|
||||
propertyPath: m_Name
|
||||
value: HUD_Canvas
|
||||
objectReference: {fileID: 0}
|
||||
- target: {fileID: 6329222681380648929, guid: dd067b5f8139dbc46b51ef157b0f0613, type: 3}
|
||||
propertyPath: m_sharedMaterial
|
||||
value:
|
||||
objectReference: {fileID: 2180264, guid: 8f586378b4e144a9851e7b34d9b748ee, type: 2}
|
||||
- target: {fileID: 7064072091532955446, guid: dd067b5f8139dbc46b51ef157b0f0613, type: 3}
|
||||
propertyPath: m_AnchoredPosition.x
|
||||
value: -6.4188232
|
||||
objectReference: {fileID: 0}
|
||||
m_RemovedComponents: []
|
||||
m_RemovedGameObjects: []
|
||||
m_AddedGameObjects: []
|
||||
m_AddedComponents: []
|
||||
m_SourcePrefab: {fileID: 100100000, guid: dd067b5f8139dbc46b51ef157b0f0613, type: 3}
|
||||
--- !u!1 &842753593
|
||||
GameObject:
|
||||
m_ObjectHideFlags: 0
|
||||
m_CorrespondingSourceObject: {fileID: 0}
|
||||
m_PrefabInstance: {fileID: 0}
|
||||
m_PrefabAsset: {fileID: 0}
|
||||
serializedVersion: 6
|
||||
m_Component:
|
||||
- component: {fileID: 842753595}
|
||||
- component: {fileID: 842753594}
|
||||
m_Layer: 0
|
||||
m_Name: RecourceManager
|
||||
m_TagString: Untagged
|
||||
m_Icon: {fileID: 0}
|
||||
m_NavMeshLayer: 0
|
||||
m_StaticEditorFlags: 0
|
||||
m_IsActive: 1
|
||||
--- !u!114 &842753594
|
||||
MonoBehaviour:
|
||||
m_ObjectHideFlags: 0
|
||||
m_CorrespondingSourceObject: {fileID: 0}
|
||||
m_PrefabInstance: {fileID: 0}
|
||||
m_PrefabAsset: {fileID: 0}
|
||||
m_GameObject: {fileID: 842753593}
|
||||
m_Enabled: 1
|
||||
m_EditorHideFlags: 0
|
||||
m_Script: {fileID: 11500000, guid: 5505825aa4d83af49a3a641e48af3ff7, type: 3}
|
||||
m_Name:
|
||||
m_EditorClassIdentifier: Assembly-CSharp::ResourceManager
|
||||
startWood: 0
|
||||
startStone: 0
|
||||
startIron: 0
|
||||
startFood: 0
|
||||
startGold: 0
|
||||
startGems: 0
|
||||
startPower: 0
|
||||
startPopulation: 0
|
||||
--- !u!4 &842753595
|
||||
Transform:
|
||||
m_ObjectHideFlags: 0
|
||||
m_CorrespondingSourceObject: {fileID: 0}
|
||||
m_PrefabInstance: {fileID: 0}
|
||||
m_PrefabAsset: {fileID: 0}
|
||||
m_GameObject: {fileID: 842753593}
|
||||
serializedVersion: 2
|
||||
m_LocalRotation: {x: 0, y: 0, z: 0, w: 1}
|
||||
m_LocalPosition: {x: 746.5865, y: 295.47598, z: 0}
|
||||
m_LocalScale: {x: 1, y: 1, z: 1}
|
||||
m_ConstrainProportionsScale: 0
|
||||
m_Children: []
|
||||
m_Father: {fileID: 0}
|
||||
m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0}
|
||||
--- !u!1 &1080091918
|
||||
GameObject:
|
||||
m_ObjectHideFlags: 0
|
||||
m_CorrespondingSourceObject: {fileID: 0}
|
||||
m_PrefabInstance: {fileID: 0}
|
||||
m_PrefabAsset: {fileID: 0}
|
||||
serializedVersion: 6
|
||||
m_Component:
|
||||
- component: {fileID: 1080091921}
|
||||
- component: {fileID: 1080091920}
|
||||
- component: {fileID: 1080091919}
|
||||
m_Layer: 0
|
||||
m_Name: SaveManager
|
||||
m_TagString: Untagged
|
||||
m_Icon: {fileID: 0}
|
||||
m_NavMeshLayer: 0
|
||||
m_StaticEditorFlags: 0
|
||||
m_IsActive: 1
|
||||
--- !u!114 &1080091919
|
||||
MonoBehaviour:
|
||||
m_ObjectHideFlags: 0
|
||||
m_CorrespondingSourceObject: {fileID: 0}
|
||||
m_PrefabInstance: {fileID: 0}
|
||||
m_PrefabAsset: {fileID: 0}
|
||||
m_GameObject: {fileID: 1080091918}
|
||||
m_Enabled: 1
|
||||
m_EditorHideFlags: 0
|
||||
m_Script: {fileID: 11500000, guid: 0f2bb66f2486f084b813736d3a8768d7, type: 3}
|
||||
m_Name:
|
||||
m_EditorClassIdentifier: Assembly-CSharp::GameBootstrap
|
||||
--- !u!114 &1080091920
|
||||
MonoBehaviour:
|
||||
m_ObjectHideFlags: 0
|
||||
m_CorrespondingSourceObject: {fileID: 0}
|
||||
m_PrefabInstance: {fileID: 0}
|
||||
m_PrefabAsset: {fileID: 0}
|
||||
m_GameObject: {fileID: 1080091918}
|
||||
m_Enabled: 1
|
||||
m_EditorHideFlags: 0
|
||||
m_Script: {fileID: 11500000, guid: 749721a5613594941adaed89b39ea5fe, type: 3}
|
||||
m_Name:
|
||||
m_EditorClassIdentifier: Assembly-CSharp::SaveManager
|
||||
autoSaveInterval: 30
|
||||
--- !u!4 &1080091921
|
||||
Transform:
|
||||
m_ObjectHideFlags: 0
|
||||
m_CorrespondingSourceObject: {fileID: 0}
|
||||
m_PrefabInstance: {fileID: 0}
|
||||
m_PrefabAsset: {fileID: 0}
|
||||
m_GameObject: {fileID: 1080091918}
|
||||
serializedVersion: 2
|
||||
m_LocalRotation: {x: 0, y: 0, z: 0, w: 1}
|
||||
m_LocalPosition: {x: -11.03001, y: 0.33877, z: 0}
|
||||
m_LocalScale: {x: 1, y: 1, z: 1}
|
||||
m_ConstrainProportionsScale: 0
|
||||
m_Children: []
|
||||
m_Father: {fileID: 0}
|
||||
m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0}
|
||||
--- !u!1 &1225367365
|
||||
GameObject:
|
||||
m_ObjectHideFlags: 0
|
||||
m_CorrespondingSourceObject: {fileID: 0}
|
||||
m_PrefabInstance: {fileID: 0}
|
||||
m_PrefabAsset: {fileID: 0}
|
||||
serializedVersion: 6
|
||||
m_Component:
|
||||
- component: {fileID: 1225367367}
|
||||
- component: {fileID: 1225367366}
|
||||
m_Layer: 0
|
||||
m_Name: City3D
|
||||
m_TagString: Untagged
|
||||
m_Icon: {fileID: 0}
|
||||
m_NavMeshLayer: 0
|
||||
m_StaticEditorFlags: 0
|
||||
m_IsActive: 1
|
||||
--- !u!114 &1225367366
|
||||
MonoBehaviour:
|
||||
m_ObjectHideFlags: 0
|
||||
m_CorrespondingSourceObject: {fileID: 0}
|
||||
m_PrefabInstance: {fileID: 0}
|
||||
m_PrefabAsset: {fileID: 0}
|
||||
m_GameObject: {fileID: 1225367365}
|
||||
m_Enabled: 1
|
||||
m_EditorHideFlags: 0
|
||||
m_Script: {fileID: 11500000, guid: 44d822c903694362bef912c055adda5b, type: 3}
|
||||
m_Name:
|
||||
m_EditorClassIdentifier: Assembly-CSharp::City3DController
|
||||
--- !u!4 &1225367367
|
||||
Transform:
|
||||
m_ObjectHideFlags: 0
|
||||
m_CorrespondingSourceObject: {fileID: 0}
|
||||
m_PrefabInstance: {fileID: 0}
|
||||
m_PrefabAsset: {fileID: 0}
|
||||
m_GameObject: {fileID: 1225367365}
|
||||
serializedVersion: 2
|
||||
m_LocalRotation: {x: 0, y: 0, z: 0, w: 1}
|
||||
m_LocalPosition: {x: 0, y: 0, z: 0}
|
||||
m_LocalScale: {x: 1, y: 1, z: 1}
|
||||
m_ConstrainProportionsScale: 0
|
||||
m_Children: []
|
||||
m_Father: {fileID: 0}
|
||||
m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0}
|
||||
--- !u!1 &1935466178
|
||||
GameObject:
|
||||
m_ObjectHideFlags: 0
|
||||
m_CorrespondingSourceObject: {fileID: 0}
|
||||
m_PrefabInstance: {fileID: 0}
|
||||
m_PrefabAsset: {fileID: 0}
|
||||
serializedVersion: 6
|
||||
m_Component:
|
||||
- component: {fileID: 1935466180}
|
||||
- component: {fileID: 1935466179}
|
||||
m_Layer: 0
|
||||
m_Name: Sun
|
||||
m_TagString: Untagged
|
||||
m_Icon: {fileID: 0}
|
||||
m_NavMeshLayer: 0
|
||||
m_StaticEditorFlags: 0
|
||||
m_IsActive: 1
|
||||
--- !u!108 &1935466179
|
||||
Light:
|
||||
m_ObjectHideFlags: 0
|
||||
m_CorrespondingSourceObject: {fileID: 0}
|
||||
m_PrefabInstance: {fileID: 0}
|
||||
m_PrefabAsset: {fileID: 0}
|
||||
m_GameObject: {fileID: 1935466178}
|
||||
m_Enabled: 1
|
||||
serializedVersion: 13
|
||||
m_Type: 1
|
||||
m_Color: {r: 1, g: 1, b: 1, a: 1}
|
||||
m_Intensity: 1.2
|
||||
m_Range: 10
|
||||
m_SpotAngle: 30
|
||||
m_InnerSpotAngle: 21.80208
|
||||
m_CookieSize2D: {x: 0.5, y: 0.5}
|
||||
m_Shadows:
|
||||
m_Type: 2
|
||||
m_Resolution: -1
|
||||
m_CustomResolution: -1
|
||||
m_Strength: 1
|
||||
m_Bias: 0.05
|
||||
m_NormalBias: 0.4
|
||||
m_NearPlane: 0.2
|
||||
m_CullingMatrixOverride:
|
||||
e00: 1
|
||||
e01: 0
|
||||
e02: 0
|
||||
e03: 0
|
||||
e10: 0
|
||||
e11: 1
|
||||
e12: 0
|
||||
e13: 0
|
||||
e20: 0
|
||||
e21: 0
|
||||
e22: 1
|
||||
e23: 0
|
||||
e30: 0
|
||||
e31: 0
|
||||
e32: 0
|
||||
e33: 1
|
||||
m_UseCullingMatrixOverride: 0
|
||||
m_Cookie: {fileID: 0}
|
||||
m_DrawHalo: 0
|
||||
m_Flare: {fileID: 0}
|
||||
m_RenderMode: 0
|
||||
m_CullingMask:
|
||||
serializedVersion: 2
|
||||
m_Bits: 4294967295
|
||||
m_RenderingLayerMask: 1
|
||||
m_Lightmapping: 4
|
||||
m_LightShadowCasterMode: 0
|
||||
m_AreaSize: {x: 1, y: 1}
|
||||
m_BounceIntensity: 1
|
||||
m_ColorTemperature: 6570
|
||||
m_UseColorTemperature: 0
|
||||
m_BoundingSphereOverride: {x: 0, y: 0, z: 0, w: 0}
|
||||
m_UseBoundingSphereOverride: 0
|
||||
m_UseViewFrustumForShadowCasterCull: 1
|
||||
m_ForceVisible: 0
|
||||
m_ShapeRadius: 0.025
|
||||
m_ShadowAngle: 0
|
||||
m_LightUnit: 1
|
||||
m_LuxAtDistance: 1
|
||||
m_EnableSpotReflector: 1
|
||||
--- !u!4 &1935466180
|
||||
Transform:
|
||||
m_ObjectHideFlags: 0
|
||||
m_CorrespondingSourceObject: {fileID: 0}
|
||||
m_PrefabInstance: {fileID: 0}
|
||||
m_PrefabAsset: {fileID: 0}
|
||||
m_GameObject: {fileID: 1935466178}
|
||||
serializedVersion: 2
|
||||
m_LocalRotation: {x: 0.40821794, y: -0.23456973, z: 0.10938166, w: 0.8754261}
|
||||
m_LocalPosition: {x: 0, y: 0, z: 0}
|
||||
m_LocalScale: {x: 1, y: 1, z: 1}
|
||||
m_ConstrainProportionsScale: 0
|
||||
m_Children: []
|
||||
m_Father: {fileID: 0}
|
||||
m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0}
|
||||
--- !u!1660057539 &9223372036854775807
|
||||
SceneRoots:
|
||||
m_ObjectHideFlags: 0
|
||||
m_Roots:
|
||||
- {fileID: 110794174}
|
||||
- {fileID: 1935466180}
|
||||
- {fileID: 1225367367}
|
||||
- {fileID: 429953708}
|
||||
- {fileID: 1080091921}
|
||||
- {fileID: 842753595}
|
||||
- {fileID: 695231209}
|
||||
@@ -0,0 +1,7 @@
|
||||
fileFormatVersion: 2
|
||||
guid: c1c34719f0605c247a3575d077f3d656
|
||||
DefaultImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -13,7 +13,7 @@ using UnityEngine;
|
||||
public class BattleConfig : ScriptableObject
|
||||
{
|
||||
[Tooltip("Версия баланса боя. Поменял числа -- подними версию: итог боя запоминает, по какой версии посчитан")]
|
||||
public int version = 2;
|
||||
public int version = 4;
|
||||
|
||||
[Tooltip("Контра, %: пехота > кавалерия > лучники > пехота. Взвешивается по доле нужного типа у врага")]
|
||||
public int counterPercent = 25;
|
||||
@@ -23,11 +23,28 @@ public class BattleConfig : ScriptableObject
|
||||
public int siegeVsWallsPercent = 50;
|
||||
[Tooltip("Осадные в поле и в обороне, % (минус)")]
|
||||
public int siegeInFieldPercent = -25;
|
||||
[Tooltip("Потери проигравшего, % от каждого типа. Не окончательная формула")]
|
||||
public int loserLossPercent = 50;
|
||||
[Tooltip("Случайность каждой стороны: от -N% до +N%. 0 -- без случайности")]
|
||||
public int randomPercent = 5;
|
||||
|
||||
[Header("Кривая потерь (решение №14, регламент 31.10, ТЕСТ)")]
|
||||
[Tooltip("Опорные точки соотношения сил (сильная / слабая) в десятых долях процента: 1000 = 1:1, 3000 = 3:1. Первая -- обязательно 1000, дальше по возрастанию")]
|
||||
public int[] lossRatioPermille = { 1000, 3000, 5000, 7000, 10000 };
|
||||
[Tooltip("Доля выбывших у сильной стороны в каждой точке, десятые доли процента: 450 = 45%")]
|
||||
public int[] strongLossPermille = { 450, 160, 110, 74, 20 };
|
||||
[Tooltip("Доля выбывших у слабой стороны в каждой точке, десятые доли процента: 740 = 74%")]
|
||||
public int[] weakLossPermille = { 450, 740, 880, 920, 950 };
|
||||
[Tooltip("Разгром дальше последней точки: слабая сторона плавно стремится к этому пределу, десятые доли процента. Решения 31.10, п. 5: 990 = 99%")]
|
||||
public int weakLossMaxPermille = 990;
|
||||
|
||||
[Header("Погибшие и раненые (регламент 31.10, решения 31.10, п. 7, ТЕСТ)")]
|
||||
[Tooltip("Соотношение сил стороны (своя / чужая) в десятых долях процента: 200 = 1:5, 1000 = 1:1, 3000 = 3:1. По возрастанию")]
|
||||
public int[] deadRatioPermille = { 200, 500, 1000, 3000 };
|
||||
[Tooltip("Доля погибших среди выбывших в каждой точке, десятые доли процента: 700 = 70%. Остальные выбывшие -- раненые")]
|
||||
public int[] deadSharePermille = { 700, 450, 300, 200 };
|
||||
|
||||
[Tooltip("Пропавшие без вести (решения 01.11, п. 5): доля выбывших, десятые доли процента. НЕ УТВЕРЖДЕНА -- 0, механика выключена. Для проверки -- например 150 (15%)")]
|
||||
public int missingSharePermille = 0;
|
||||
|
||||
[Tooltip("Голод: сколько силы остаётся, %. Утверждено 27.10.2026: -50% (остаётся 50). Проверяется на момент боя")]
|
||||
public int famineStrengthPercent = 50;
|
||||
[Tooltip("Усталость: сколько силы остаётся, %. Утверждено 27.10.2026: -20% (остаётся 80). Вместе с голодом -- 40%")]
|
||||
@@ -58,7 +75,13 @@ public class BattleConfig : ScriptableObject
|
||||
WallsPercent = wallsPercent,
|
||||
SiegeVsWallsPercent = siegeVsWallsPercent,
|
||||
SiegeInFieldPercent = siegeInFieldPercent,
|
||||
LoserLossPercent = loserLossPercent,
|
||||
LossRatioPermille = lossRatioPermille,
|
||||
StrongLossPermille = strongLossPermille,
|
||||
WeakLossPermille = weakLossPermille,
|
||||
WeakLossMaxPermille = weakLossMaxPermille,
|
||||
DeadRatioPermille = deadRatioPermille,
|
||||
DeadSharePermille = deadSharePermille,
|
||||
MissingSharePermille = Mathf.Clamp(missingSharePermille, 0, 1000),
|
||||
RandomPercent = Mathf.Max(0, randomPercent),
|
||||
FamineStrengthPercent = Mathf.Clamp(famineStrengthPercent, 0, 100),
|
||||
FatigueStrengthPercent = Mathf.Clamp(fatigueStrengthPercent, 0, 100)
|
||||
|
||||
@@ -14,6 +14,9 @@ using UnityEngine.UI;
|
||||
// - Кристалл: где добывается, запас / лимит;
|
||||
// - Казармы: очередь обучения, какие войска открыты и что нужно для остальных, улучшение (открывает войска),
|
||||
// кнопка "Нанять войска" (панель "Войска").
|
||||
// - Стройка и улучшение идут по времени (BuildTimer, 01.11, ТЕСТ): полоска, сколько осталось, ускорение за кристаллы.
|
||||
// Пока здание строится, в окне только стройка; при улучшении здание работает на старом уровне.
|
||||
// - У построенных зданий (кроме Ратуши) -- кнопка "Перенести" (бесплатно, уровень и стройка сохраняются). Сноса нет (04.11).
|
||||
// Без здания (кнопка "Рабочие" в панели DEV и "Все рабочие" в окне) -- список рабочих по всем профессиям.
|
||||
// Окно -- только интерфейс: рабочих ставит модуль Населения, добычу считает модуль Экономики.
|
||||
// Пока окно открыто, цифры обновляются сами (раз в полсекунды).
|
||||
@@ -47,23 +50,64 @@ public static class BuildingWindow
|
||||
{
|
||||
if (root != null) UnityEngine.Object.Destroy(root);
|
||||
live.Clear();
|
||||
var card = UIKit.Modal(TitleFor(focus), out root);
|
||||
var modal = UIKit.Modal(TitleFor(focus), out root);
|
||||
root.AddComponent<BuildingWindowTicker>();
|
||||
var card = ScrollBody(modal); // длинное окно прокручивается, "Закрыть" всегда видна внизу (05.11)
|
||||
|
||||
var rc = focus != null ? focus.GetComponent<ResourceCollector>() : null;
|
||||
var storage = focus != null ? focus.GetComponent<StorageBuilding>() : null;
|
||||
var townHall = focus != null ? focus.GetComponent<TownHall>() : null;
|
||||
var barracks = focus != null ? focus.GetComponent<Barracks>() : null;
|
||||
var hospital = focus != null ? focus.GetComponent<Hospital>() : null;
|
||||
var heroCastle = focus != null ? focus.GetComponent<HeroCastle>() : null;
|
||||
|
||||
bool constructing = focus != null && BuildTimer.IsConstructing(focus.transform);
|
||||
if (focus != null) TimerBlock(card, focus);
|
||||
|
||||
if (focus == null) WorkersList(card);
|
||||
else if (constructing) UIKit.Text(card, "Пока здание строится, оно не работает.", 22, UIKit.Theme.TextDim, TextAlignmentOptions.Center);
|
||||
else if (townHall != null) TownHallSection(card, townHall);
|
||||
else if (barracks != null) BarracksSection(card, barracks);
|
||||
else if (hospital != null) HospitalSection(card, hospital);
|
||||
else if (heroCastle != null) HeroCastleSection(card);
|
||||
else if (rc != null && rc.IsWorkplace) WorkplaceSection(card, rc);
|
||||
else if (rc != null && rc.IsHouse) HouseSection(card, rc);
|
||||
else if (rc != null && rc.resourceType == ResourceType.Gems) CrystalSection(card);
|
||||
else if (storage != null) StorageSection(card, storage);
|
||||
|
||||
UIKit.Button(card, "Закрыть", UIKit.Theme.Gray, Close, -1, 80, 28);
|
||||
if (focus != null) ActionsBlock(card, focus);
|
||||
UIKit.Button(modal, "Закрыть", UIKit.Theme.Gray, Close, -1, 80, 28);
|
||||
}
|
||||
|
||||
// Окно не выше экрана: заголовок сверху, содержимое прокручивается, кнопка "Закрыть" -- внизу, вне прокрутки.
|
||||
// Закрыть можно и тапом по затемнению вокруг окна, и кнопкой "Назад" (Escape).
|
||||
static RectTransform ScrollBody(RectTransform modal)
|
||||
{
|
||||
var fit = modal.GetComponent<ContentSizeFitter>();
|
||||
if (fit != null) UnityEngine.Object.DestroyImmediate(fit);
|
||||
modal.anchorMin = new Vector2(0.06f, 0.05f);
|
||||
modal.anchorMax = new Vector2(0.94f, 0.95f);
|
||||
modal.offsetMin = Vector2.zero;
|
||||
modal.offsetMax = Vector2.zero;
|
||||
|
||||
var dim = root.GetComponent<Image>();
|
||||
if (dim != null)
|
||||
{
|
||||
var outside = root.AddComponent<Button>();
|
||||
outside.transition = Selectable.Transition.None;
|
||||
outside.onClick.AddListener(Close);
|
||||
}
|
||||
|
||||
var holder = UIKit.Make("Body", modal);
|
||||
UIKit.Size(holder, -1, -1).flexibleHeight = 1;
|
||||
ScrollRect scroll;
|
||||
return UIKit.ScrollList(holder, out scroll, 14);
|
||||
}
|
||||
|
||||
// Стройка закончилась (BuildTimer.Completed): окно этого здания показывает новый уровень
|
||||
internal static void OnBuildCompleted(BuildTimer timer)
|
||||
{
|
||||
if (root != null && timer != null && focus == timer.gameObject) Rebuild();
|
||||
}
|
||||
|
||||
internal static void Tick()
|
||||
@@ -190,6 +234,26 @@ public static class BuildingWindow
|
||||
}
|
||||
else StorageBar(card, economy, ResourceType.Food);
|
||||
UIKit.Text(card, "Сверх лимита ресурс не прибавляется — пропадает.", 22, UIKit.Theme.TextDim, TextAlignmentOptions.Center);
|
||||
|
||||
if (GameServices.TryGet(out IResourceProtection protection))
|
||||
{
|
||||
Header(card, "Защита от грабежа");
|
||||
var p = EconomyConfig.Load().protection;
|
||||
ResourceType[] kinds = warehouse
|
||||
? new[] { ResourceType.Wood, ResourceType.Stone, ResourceType.Iron, ResourceType.Gold }
|
||||
: new[] { ResourceType.Food };
|
||||
Live(card, () =>
|
||||
{
|
||||
var lines = new List<string>();
|
||||
foreach (var k in kinds)
|
||||
lines.Add($"{GameData.ResName(k)}: спрятано {protection.Protected(k):0}, можно унести {protection.Lootable(k):0}");
|
||||
return string.Join("\n", lines.ToArray());
|
||||
}, 22, UIKit.Theme.Text);
|
||||
UIKit.Text(card, warehouse
|
||||
? $"Каждый уровень Склада прячет ещё {p.perWarehouseLevel:0} каждого ресурса — в набеге их не унесут."
|
||||
: $"Каждый уровень Амбара прячет ещё {p.perGranaryLevel:0} еды — в набеге её не унесут.",
|
||||
22, UIKit.Theme.TextDim, TextAlignmentOptions.Center);
|
||||
}
|
||||
}
|
||||
|
||||
UpgradeBlock(card, storage.level, $"лимит +{perLevel * storage.level:0} → +{perLevel * (storage.level + 1):0}",
|
||||
@@ -233,18 +297,45 @@ public static class BuildingWindow
|
||||
string unlocks = UnlocksAt(next);
|
||||
if (unlocks.Length > 0) UIKit.Text(card, "Откроется: " + unlocks, 24, UIKit.Theme.Gold, TextAlignmentOptions.Center);
|
||||
|
||||
string more = MoreBuildingsAt(level, next);
|
||||
if (more.Length > 0) UIKit.Text(card, "Больше зданий: " + more, 22, UIKit.Theme.TextDim, TextAlignmentOptions.Center);
|
||||
if (TimerRunning(townHall.gameObject)) return; // улучшение уже идёт -- полоска сверху
|
||||
|
||||
var cost = BuildingData.ToAmounts(townHall.GetUpgradeCost());
|
||||
UIKit.Text(card, "Цена: " + CostText(cost), 24, UIKit.Theme.Text, TextAlignmentOptions.Center);
|
||||
LiveCost(card, cost);
|
||||
long ms = config.ConstructionMs(true, next);
|
||||
TimeText(card, ms);
|
||||
bool canPay = townHall.CanUpgrade();
|
||||
var b = UIKit.Button(card, $"Улучшить до {next} ур.", canPay ? UIKit.Theme.Green : UIKit.Theme.Gray, () =>
|
||||
{
|
||||
if (!townHall.CanUpgrade()) { UIKit.Toast("Не хватает ресурсов"); return; }
|
||||
townHall.Upgrade();
|
||||
if (CitySync.Instance != null) CitySync.Instance.SyncNow();
|
||||
UIKit.Toast($"Ратуша улучшена до {townHall.currentLevel} уровня");
|
||||
if (ms > 0 && BuildTimer.FreeBuilders == 0) { UIKit.Toast("Все строители заняты"); return; }
|
||||
if (!GameServices.TryGet(out IEconomy e) || !e.TrySpend(cost)) { UIKit.Toast("Не хватает ресурсов"); return; }
|
||||
if (ms <= 0) BuildTimer.Begin(townHall.gameObject, BuildTimer.Kind.Upgrade, next, 0, 0).Complete();
|
||||
else
|
||||
{
|
||||
long now = GameClock.UnixNowMs;
|
||||
BuildTimer.Begin(townHall.gameObject, BuildTimer.Kind.Upgrade, next, now, now + ms);
|
||||
UIKit.Toast($"Ратуша улучшается до {next} уровня");
|
||||
}
|
||||
Rebuild();
|
||||
}, -1, 80, 26);
|
||||
b.interactable = canPay;
|
||||
LiveAfford(b, canPay, () => townHall != null && townHall.CanUpgrade());
|
||||
}
|
||||
|
||||
// Каких зданий на следующем уровне Ратуши можно построить больше: "Лес 2 → 3, Дом 3 → 4"
|
||||
static string MoreBuildingsAt(int level, int next)
|
||||
{
|
||||
var config = EconomyConfig.Load();
|
||||
var parts = new List<string>();
|
||||
if (config.buildingLimits != null)
|
||||
foreach (var l in config.buildingLimits)
|
||||
{
|
||||
if (l == null) continue;
|
||||
int a = config.BuildingLimitFor(l.buildingName, level), b = config.BuildingLimitFor(l.buildingName, next);
|
||||
if (b > a) parts.Add($"{l.buildingName} {a} → {b}");
|
||||
}
|
||||
return string.Join(", ", parts.ToArray());
|
||||
}
|
||||
|
||||
// ---------------- Казармы ----------------
|
||||
@@ -261,11 +352,11 @@ public static class BuildingWindow
|
||||
Header(card, "Обучение");
|
||||
Live(card, () =>
|
||||
{
|
||||
var d = gp.Data;
|
||||
if (d.training.Count == 0) return "Никто не обучается";
|
||||
var o = d.training[0];
|
||||
string more = d.training.Count > 1 ? $" (ещё заказов: {d.training.Count - 1})" : "";
|
||||
return $"{GameData.Troops[o.troopIndex].name} ×{o.count} — осталось {GameData.FormatTime(gp.TrainingSecondsLeft(o))}{more}";
|
||||
var training = gp.Training;
|
||||
if (training.Count == 0) return "Никто не обучается";
|
||||
var o = training[0];
|
||||
string more = training.Count > 1 ? $" (ещё заказов: {training.Count - 1})" : "";
|
||||
return $"{GameData.Troops[o.Type].name} ×{o.Count} — осталось {GameData.FormatTime(gp.TrainingSecondsLeft(o))}{more}";
|
||||
}, 24, UIKit.Theme.Text);
|
||||
if (GameServices.TryGet(out IPopulation population))
|
||||
Live(card, () => $"Можно нанять из жителей: {population.Recruitable} • в Казармах: {population.InTraining}", 22, UIKit.Theme.TextDim);
|
||||
@@ -298,6 +389,112 @@ public static class BuildingWindow
|
||||
}, -1, 80, 26);
|
||||
}
|
||||
|
||||
// ---------------- Госпиталь (решения 31.10, п. 7) ----------------
|
||||
|
||||
static void HospitalSection(RectTransform card, Hospital hospital)
|
||||
{
|
||||
UIKit.Text(card, $"Госпиталь лечит раненых после боёв. Мест: {Hospital.BedsAt(Hospital.MaxLevel)} " +
|
||||
"(считается самый прокачанный госпиталь). Кто не поместился -- ждёт места.",
|
||||
24, UIKit.Theme.TextDim, TextAlignmentOptions.Center);
|
||||
|
||||
if (GameServices.TryGet(out IArmy army))
|
||||
{
|
||||
Header(card, "Раненые");
|
||||
Live(card, () =>
|
||||
{
|
||||
int healingNow = 0, waiting = 0;
|
||||
long readyMs = long.MaxValue;
|
||||
foreach (var h in army.Healing) { healingNow += h.Count; readyMs = Math.Min(readyMs, h.EndMs); }
|
||||
for (int t = 0; t < army.TypeCount; t++) waiting += army.GetWoundedWaiting(t);
|
||||
if (healingNow + waiting == 0) return "Раненых нет";
|
||||
string next = readyMs != long.MaxValue
|
||||
? $", ближайшие вылечатся через {GameData.FormatTime(Math.Max(0, readyMs - GameClock.UnixNowMs) / 1000.0)}"
|
||||
: "";
|
||||
return $"Лечатся {healingNow} из {army.HospitalBeds} мест, ждут места {waiting}{next}";
|
||||
}, 24, UIKit.Theme.Text);
|
||||
Live(card, () =>
|
||||
{
|
||||
var lines = new List<string>();
|
||||
foreach (var h in army.Healing)
|
||||
if (h.Type >= 0 && h.Type < GameData.Troops.Count)
|
||||
lines.Add($"{GameData.Troops[h.Type].name} ×{h.Count} — {GameData.FormatTime(Math.Max(0, h.EndMs - GameClock.UnixNowMs) / 1000.0)}");
|
||||
return lines.Count > 0 ? string.Join("\n", lines.ToArray()) : "";
|
||||
}, 22, UIKit.Theme.TextDim);
|
||||
var rules = HospitalConfig.Load().healGameHours;
|
||||
UIKit.Text(card, $"Лечение одного воина: пехота и лучники {rules[0]}, кавалерия {rules[2]}, осадные {rules[3]} игровых ч " +
|
||||
"(игровой час = 5 мин). Лечатся и пока игра закрыта.", 22, UIKit.Theme.TextDim, TextAlignmentOptions.Center);
|
||||
}
|
||||
|
||||
UpgradeBlock(card, hospital.level, $"мест {Hospital.BedsAt(hospital.level)} → {Hospital.BedsAt(hospital.level + 1)}",
|
||||
() => hospital.Level = hospital.level + 1, Hospital.MaxBuildLevel);
|
||||
}
|
||||
|
||||
// ---------------- Терем богатыря: окно героя ----------------
|
||||
// Числа героя -- те же, что в панели "Герой" (GameProgress / GameData), механика не меняется.
|
||||
|
||||
static void HeroCastleSection(RectTransform card)
|
||||
{
|
||||
var gp = GameProgress.Instance;
|
||||
UIKit.Text(card, "В Тереме живёт богатырь -- верховный командир. Он гуляет по городу; тапните по нему, чтобы поговорить.",
|
||||
24, UIKit.Theme.TextDim, TextAlignmentOptions.Center);
|
||||
if (gp == null) return;
|
||||
|
||||
int lvl = gp.Data.heroLevel, maxLvl = gp.HeroMaxLevel;
|
||||
var head = UIKit.Row(card, 140, 16);
|
||||
UIKit.Icon(head, new Color(0.55f, 0.4f, 0.2f), "Б", 130); // место под портрет
|
||||
var info = UIKit.Card(head, new Color(0, 0, 0, 0), 0, 4);
|
||||
UIKit.Size(info, -1, -1, 1);
|
||||
UIKit.Text(info, $"Уровень {lvl} / {maxLvl}", 30, UIKit.Theme.Gold).fontStyle = FontStyles.Bold;
|
||||
UIKit.Text(info, $"Сила героя: {GameData.HeroPower(lvl)}", 24, UIKit.Theme.Text);
|
||||
UIKit.Text(info, $"Бонус к силе армии: +{Mathf.RoundToInt(gp.ArmyBonus * 100)}%", 24, UIKit.Theme.Text);
|
||||
|
||||
Header(card, "Навыки");
|
||||
for (int i = 0; i < GameData.HeroSkillNames.Length; i++)
|
||||
{
|
||||
bool has = gp.HasHeroSkill(i);
|
||||
UIKit.Text(card, has ? $"<color=#9be59b>✓</color> {GameData.HeroSkillNames[i]}"
|
||||
: $"{GameData.HeroSkillNames[i]} — с {GameData.HeroSkillLevels[i]} ур.",
|
||||
22, has ? UIKit.Theme.Text : UIKit.Theme.TextDim);
|
||||
}
|
||||
|
||||
Header(card, "Улучшение героя");
|
||||
if (lvl >= maxLvl)
|
||||
{
|
||||
UIKit.Text(card, $"Предел уровня. Улучшите Ратушу до {gp.TownHallLevel + 1} ур., чтобы поднять предел до " +
|
||||
$"{GameData.HeroMaxLevel(gp.TownHallLevel + 1)}.", 22, UIKit.Theme.TextDim, TextAlignmentOptions.Center);
|
||||
}
|
||||
else
|
||||
{
|
||||
var cost = GameData.HeroUpgradeCost(lvl);
|
||||
Live(card, () =>
|
||||
{
|
||||
var parts = new List<string>();
|
||||
foreach (var c in cost)
|
||||
{
|
||||
bool enough = gp.Res(c.type) >= c.amount;
|
||||
parts.Add($"<color={(enough ? "#9be59b" : "#ff7a7a")}>{GameData.ResName(c.type)} {c.amount:N0}</color>");
|
||||
}
|
||||
return "Цена: " + string.Join(", ", parts.ToArray());
|
||||
}, 22, UIKit.Theme.Text);
|
||||
UIKit.Text(card, $"Дальше: сила {GameData.HeroPower(lvl)} → {GameData.HeroPower(lvl + 1)}, " +
|
||||
$"бонус армии +{GameData.HeroArmyBonusPercent(lvl)}% → +{GameData.HeroArmyBonusPercent(lvl + 1)}%",
|
||||
22, UIKit.Theme.TextDim, TextAlignmentOptions.Center);
|
||||
var up = UIKit.Button(card, $"Улучшить до {lvl + 1} ур.", UIKit.Theme.Green, () =>
|
||||
{
|
||||
if (!gp.CanUpgradeHero) { UIKit.Toast("Не хватает ресурсов"); return; }
|
||||
gp.UpgradeHero();
|
||||
Rebuild();
|
||||
}, -1, 80, 26);
|
||||
live.Add(() => up.interactable = gp.CanUpgradeHero);
|
||||
}
|
||||
|
||||
UIKit.Button(card, "Подробнее о герое", UIKit.Theme.Blue, () =>
|
||||
{
|
||||
Close();
|
||||
if (UIPanelManager.Instance != null) UIPanelManager.Instance.Open("Hero");
|
||||
}, -1, 80, 26);
|
||||
}
|
||||
|
||||
// Резерв для найма на уровне Ратуши (те же числа, что у модуля Населения)
|
||||
static int ReserveAt(int townHallLevel)
|
||||
{
|
||||
@@ -320,32 +517,135 @@ public static class BuildingWindow
|
||||
|
||||
// ---------------- Улучшение (общее для зданий с уровнем, кроме Ратуши) ----------------
|
||||
|
||||
static void UpgradeBlock(RectTransform card, int level, string effect, Action raiseLevel)
|
||||
static void UpgradeBlock(RectTransform card, int level, string effect, Action raiseLevel, int maxLevel = 0)
|
||||
{
|
||||
var config = EconomyConfig.Load();
|
||||
if (level >= config.upgrade.maxLevel)
|
||||
if (level >= (maxLevel > 0 ? maxLevel : config.upgrade.maxLevel)) // у госпиталя свой предел: I-V
|
||||
{
|
||||
UIKit.Text(card, "Максимальный уровень", 26, UIKit.Theme.Gold, TextAlignmentOptions.Center);
|
||||
return;
|
||||
}
|
||||
Header(card, $"Улучшение до {level + 1} ур.");
|
||||
UIKit.Text(card, effect, 24, UIKit.Theme.Text, TextAlignmentOptions.Center);
|
||||
if (focus != null && TimerRunning(focus)) return; // улучшение уже идёт -- полоска сверху
|
||||
var cost = config.UpgradeCost(level);
|
||||
UIKit.Text(card, "Цена: " + CostText(cost), 24, UIKit.Theme.Text, TextAlignmentOptions.Center);
|
||||
LiveCost(card, cost);
|
||||
long ms = config.ConstructionMs(false, level + 1);
|
||||
TimeText(card, ms);
|
||||
var building = focus;
|
||||
|
||||
bool canPay = GameServices.TryGet(out IEconomy economy) && economy.CanAfford(cost);
|
||||
var b = UIKit.Button(card, $"Улучшить до {level + 1} ур.", canPay ? UIKit.Theme.Green : UIKit.Theme.Gray, () =>
|
||||
{
|
||||
bool timed = ms > 0 && building != null;
|
||||
if (timed && BuildTimer.FreeBuilders == 0) { UIKit.Toast("Все строители заняты"); return; }
|
||||
if (!GameServices.TryGet(out IEconomy e) || !e.TrySpend(cost)) { UIKit.Toast("Не хватает ресурсов"); return; }
|
||||
raiseLevel();
|
||||
if (CitySync.Instance != null) CitySync.Instance.SyncNow();
|
||||
UIKit.Toast($"Улучшено до {level + 1} уровня");
|
||||
if (timed)
|
||||
{
|
||||
long now = GameClock.UnixNowMs;
|
||||
BuildTimer.Begin(building, BuildTimer.Kind.Upgrade, level + 1, now, now + ms);
|
||||
UIKit.Toast($"Улучшение до {level + 1} уровня началось");
|
||||
}
|
||||
else
|
||||
{
|
||||
raiseLevel();
|
||||
if (CitySync.Instance != null) CitySync.Instance.SyncNow();
|
||||
UIKit.Toast($"Улучшено до {level + 1} уровня");
|
||||
}
|
||||
Rebuild();
|
||||
}, -1, 80, 26);
|
||||
LiveAfford(b, canPay, () => GameServices.TryGet(out IEconomy now) && now.CanAfford(cost));
|
||||
}
|
||||
|
||||
// Кнопка «Улучшить» следит за ресурсами, пока окно открыто: ресурсов стало хватать -- зелёная и нажимается,
|
||||
// перестало -- серая (05.11: раньше нужно было закрыть и открыть окно)
|
||||
static void LiveAfford(Button b, bool canPay, Func<bool> can)
|
||||
{
|
||||
b.interactable = canPay;
|
||||
bool shown = canPay;
|
||||
live.Add(() =>
|
||||
{
|
||||
if (b == null) return;
|
||||
bool c = can();
|
||||
if (c == shown) return;
|
||||
shown = c;
|
||||
b.interactable = c;
|
||||
var img = b.GetComponent<Image>();
|
||||
if (img != null) img.color = c ? UIKit.Theme.Green : UIKit.Theme.Gray;
|
||||
});
|
||||
}
|
||||
|
||||
// ---------------- Стройка по времени (BuildTimer) ----------------
|
||||
|
||||
static bool TimerRunning(GameObject building)
|
||||
{
|
||||
var t = BuildTimer.Of(building);
|
||||
return t != null && BuildTimer.All.Contains(t);
|
||||
}
|
||||
|
||||
static void TimeText(RectTransform card, long ms)
|
||||
{
|
||||
if (ms <= 0) return;
|
||||
Live(card, () => $"Время: {GameData.FormatTime(ms / 1000.0)} • строителей свободно: {BuildTimer.FreeBuilders} из {BuildTimer.Builders}",
|
||||
22, UIKit.Theme.TextDim);
|
||||
}
|
||||
|
||||
// Полоска стройки или улучшения, сколько осталось, ускорение за кристаллы
|
||||
static void TimerBlock(RectTransform card, GameObject building)
|
||||
{
|
||||
var t = BuildTimer.Of(building);
|
||||
if (t == null || !BuildTimer.All.Contains(t)) return;
|
||||
Header(card, t.IsConstruction ? "Идёт стройка" : $"Идёт улучшение до {t.targetLevel} ур.");
|
||||
var bar = UIKit.Bar(card, t.Progress, UIKit.Theme.Gold, 26);
|
||||
live.Add(() => { if (t != null) UIKit.SetBar(bar, t.Progress); });
|
||||
Live(card, () => t != null ? "Осталось " + GameData.FormatTime(t.SecondsLeft) : "Готово", 24, UIKit.Theme.Text);
|
||||
|
||||
int perGem = Mathf.Max(1, EconomyConfig.Load().construction.secondsPerGem);
|
||||
Func<int> gems = () => t != null ? Mathf.Max(1, Mathf.CeilToInt((float)t.SecondsLeft / perGem)) : 0;
|
||||
var speed = UIKit.Button(card, "", UIKit.Theme.Blue, () =>
|
||||
{
|
||||
if (t == null) return;
|
||||
int price = gems();
|
||||
if (!GameServices.TryGet(out IEconomy e) || !e.TrySpend(ResourceType.Gems, price))
|
||||
{
|
||||
UIKit.Toast($"Нужно {price} кристаллов");
|
||||
return;
|
||||
}
|
||||
t.Complete(); // окно перестроит OnBuildCompleted
|
||||
}, -1, 80, 26);
|
||||
live.Add(() => { if (t != null) UIKit.SetButton(speed, $"Ускорить за {gems()} кристаллов", true); });
|
||||
UIKit.SetButton(speed, $"Ускорить за {gems()} кристаллов", true);
|
||||
}
|
||||
|
||||
// ---------------- Перенос (решения 04.11: сноса нет) ----------------
|
||||
// Здание можно бесплатно переставить в пределах поселения: уровень, стройка или улучшение сохраняются.
|
||||
// Снести здание и вернуть ресурсы нельзя -- такой кнопки нет.
|
||||
|
||||
static void ActionsBlock(RectTransform card, GameObject building)
|
||||
{
|
||||
var id = building.GetComponent<PlacedBuildingId>();
|
||||
if (id == null || string.IsNullOrEmpty(id.buildingName)) return; // Ратуша и стартовые постройки сцены
|
||||
UIKit.Button(card, "Перенести", UIKit.Theme.Blue, () =>
|
||||
{
|
||||
Close();
|
||||
if (BuildModeController.Instance != null) BuildModeController.Instance.StartMove(building);
|
||||
}, -1, 80, 26);
|
||||
}
|
||||
|
||||
// Цена: чего хватает -- зелёным, чего нет -- красным, со сколько есть
|
||||
// Строка «Цена: …» обновляется, пока окно открыто: хватает ресурса -- зелёная цифра, нет -- красная и «есть N» (05.11)
|
||||
static void LiveCost(Transform card, ResourceAmount[] cost)
|
||||
{
|
||||
var text = UIKit.Text(card, "Цена: " + CostText(cost), 24, UIKit.Theme.Text, TextAlignmentOptions.Center);
|
||||
string shown = text.text;
|
||||
live.Add(() =>
|
||||
{
|
||||
if (text == null) return;
|
||||
string now = "Цена: " + CostText(cost);
|
||||
if (now != shown) { shown = now; text.text = now; }
|
||||
});
|
||||
}
|
||||
|
||||
static string CostText(ResourceAmount[] cost)
|
||||
{
|
||||
GameServices.TryGet(out IEconomy economy);
|
||||
@@ -469,6 +769,7 @@ public class BuildingWindowTicker : MonoBehaviour
|
||||
|
||||
void Update()
|
||||
{
|
||||
if (Input.GetKeyDown(KeyCode.Escape)) { BuildingWindow.Close(); return; } // "Назад" на телефоне
|
||||
if (Time.unscaledTime < next) return;
|
||||
next = Time.unscaledTime + 0.5f;
|
||||
BuildingWindow.Tick();
|
||||
|
||||
@@ -5,7 +5,8 @@ using UnityEngine;
|
||||
// Слой приложения: сообщает модулям, какие здания стоят в городе.
|
||||
// - рабочие места (Лес, Еда, Камень, Металл, Золото) и их уровни -> модуль добычи (места и эффективность);
|
||||
// - Дома (здание "Население") -> модуль Населения (предел жилья);
|
||||
// - Склады и Амбары -> лимиты склада в Экономике.
|
||||
// - Склады и Амбары -> лимиты склада в Экономике и защита ресурсов от грабежа (App/ResourceProtection).
|
||||
// Здания, которые ещё строятся (BuildTimer), не считаются: они пока не работают.
|
||||
// Модули сами здания не ищут: они без Unity и живут и на карте мира, где города нет.
|
||||
//
|
||||
// Проверяет город раз в полсекунды и применяет изменения, только когда картина устоялась
|
||||
@@ -16,6 +17,10 @@ public class CitySync : MonoBehaviour
|
||||
|
||||
public static CitySync Instance { get; private set; }
|
||||
|
||||
// Город открыт не в 2D-сцене, а в 3D (City3D): здания с PlacedBuildingId там тоже есть (05.11)
|
||||
public static bool ExternalCity;
|
||||
static bool CityOpen => BuildManager.Instance != null || ExternalCity;
|
||||
|
||||
float next;
|
||||
string pending; // замер, который ждёт подтверждения
|
||||
string applied; // последний применённый замер
|
||||
@@ -38,7 +43,7 @@ public class CitySync : MonoBehaviour
|
||||
{
|
||||
if (Time.unscaledTime < next) return;
|
||||
next = Time.unscaledTime + Interval;
|
||||
if (BuildManager.Instance == null) return; // город не открыт -- модули помнят последнее
|
||||
if (!CityOpen) return; // город не открыт -- модули помнят последнее
|
||||
|
||||
var city = Scan();
|
||||
if (city.Signature == applied) { pending = null; return; }
|
||||
@@ -49,7 +54,7 @@ public class CitySync : MonoBehaviour
|
||||
// Применить сразу (после улучшения здания в окне -- чтобы окно показало новые места)
|
||||
public void SyncNow()
|
||||
{
|
||||
if (BuildManager.Instance == null) return;
|
||||
if (!CityOpen) return;
|
||||
Apply(Scan());
|
||||
}
|
||||
|
||||
@@ -67,6 +72,7 @@ public class CitySync : MonoBehaviour
|
||||
foreach (var placed in FindObjectsByType<PlacedBuildingId>(FindObjectsSortMode.None))
|
||||
{
|
||||
if (string.IsNullOrEmpty(placed.buildingName)) continue; // убирается в этом кадре
|
||||
if (BuildTimer.IsConstructing(placed)) continue; // ещё строится -- не работает
|
||||
|
||||
var rc = placed.GetComponent<ResourceCollector>();
|
||||
if (rc != null)
|
||||
@@ -112,6 +118,7 @@ public class CitySync : MonoBehaviour
|
||||
ResourceType[] stored = { ResourceType.Wood, ResourceType.Stone, ResourceType.Iron, ResourceType.Gold, ResourceType.Food, ResourceType.Gems };
|
||||
foreach (var t in stored) rm.SetCapacity(t, config.GetCapacity(t, city.WarehouseLevels, city.GranaryLevels));
|
||||
}
|
||||
ResourceProtection.SetLevels(city.WarehouseLevels, city.GranaryLevels); // защита от грабежа
|
||||
|
||||
var production = GameModules.Production;
|
||||
var population = GameModules.Population;
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
// Стройка и офлайн (решения 04.11): стройка, которая закончилась, пока игра была закрыта, вступает в силу
|
||||
// в тот момент офлайн-расчёта, когда время дошло до её конца. Тогда добыча здания (и его новый уровень)
|
||||
// считается ровно с момента завершения, а не с начала офлайна и не с момента входа в игру.
|
||||
//
|
||||
// Как: модуль Населения на каждом шаге расчёта сообщает, до какого момента дошёл (GameTimeSimulatedEvent).
|
||||
// Здесь завершаются стройки с концом не позже этого момента, и город сразу пересчитывается (CitySync) --
|
||||
// следующий шаг добычи идёт уже с новым зданием. Пока расчёт не дошёл до конца стройки, BuildTimer сам её не
|
||||
// завершает (BuildTimer.MayComplete). Шаг расчёта -- 10 минут, поэтому добыча начинается не раньше конца
|
||||
// стройки и не позже чем через 10 минут после него: лишнего не начисляется никогда.
|
||||
public static class ConstructionClock
|
||||
{
|
||||
// До какого момента (мс) расчёт добычи уже дошёл. 0 -- ещё ни разу не считал (сразу после запуска)
|
||||
public static long SimulatedUntilMs { get; private set; }
|
||||
|
||||
static bool subscribed;
|
||||
|
||||
public static void Init()
|
||||
{
|
||||
SimulatedUntilMs = 0;
|
||||
BuildTimer.MayComplete = endMs => endMs <= SimulatedUntilMs;
|
||||
if (subscribed) return;
|
||||
subscribed = true;
|
||||
EventBus.Subscribe<GameTimeSimulatedEvent>(OnSimulated);
|
||||
}
|
||||
|
||||
static void OnSimulated(GameTimeSimulatedEvent e)
|
||||
{
|
||||
long ms = e.UnixNow * 1000;
|
||||
if (ms > SimulatedUntilMs) SimulatedUntilMs = ms; // время расчёта только растёт
|
||||
|
||||
bool any = false;
|
||||
foreach (var timer in BuildTimer.All.ToArray())
|
||||
{
|
||||
if (timer == null || timer.endMs > SimulatedUntilMs) continue;
|
||||
timer.Complete(); // уровень только растёт, второй раз не завершается
|
||||
any = true;
|
||||
}
|
||||
if (any && CitySync.Instance != null) CitySync.Instance.SyncNow();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 6104b5c531ff479eb569880ebd75d68c
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -17,6 +17,7 @@ public class DebugPanel : MonoBehaviour
|
||||
|
||||
GameObject panel;
|
||||
TextMeshProUGUI info;
|
||||
UnityEngine.UI.Button gridButton;
|
||||
float nextRefresh;
|
||||
|
||||
[RuntimeInitializeOnLoadMethod(RuntimeInitializeLoadType.AfterSceneLoad)]
|
||||
@@ -72,8 +73,9 @@ public class DebugPanel : MonoBehaviour
|
||||
Btn(row1, "+10 часов", UIKit.Theme.Blue, () => GameClock.Advance(36000));
|
||||
|
||||
var row2 = UIKit.Row(card, 70, 10);
|
||||
Btn(row2, "+100 всех ресурсов", UIKit.Theme.Green, AddAllResources);
|
||||
Btn(row2, "+1000 всех ресурсов", UIKit.Theme.Green, AddAllResources);
|
||||
Btn(row2, "+1000 еды", UIKit.Theme.Green, () => AddResource(ResourceType.Food, 1000));
|
||||
Btn(row2, "Кристаллы = макс", UIKit.Theme.Blue, FillGems);
|
||||
|
||||
var row3 = UIKit.Row(card, 70, 10);
|
||||
Btn(row3, "Еда = 0", UIKit.Theme.Red, ZeroFood);
|
||||
@@ -81,11 +83,132 @@ public class DebugPanel : MonoBehaviour
|
||||
|
||||
var row4 = UIKit.Row(card, 70, 10);
|
||||
Btn(row4, "Рабочие", UIKit.Theme.Blue, () => BuildingWindow.Open(null));
|
||||
Btn(row4, "Закрыть", UIKit.Theme.Gray, () => panel.SetActive(false));
|
||||
gridButton = UIKit.Button(row4, GridLabel, UIKit.Theme.Blue, ToggleGrid, -1, 70, 24);
|
||||
UIKit.Size(gridButton, -1, 70, 1);
|
||||
Btn(row4, "Камера", UIKit.Theme.Blue, ToggleCameraCard);
|
||||
Btn(row4, "Закрыть", UIKit.Theme.Gray, () => { panel.SetActive(false); if (cameraCard != null) cameraCard.SetActive(false); });
|
||||
|
||||
panel.SetActive(false);
|
||||
}
|
||||
|
||||
// ---------------- Камера 3D-поселения (решение 05.11: перспектива, числа подбираем в Unity и на телефоне) ----------------
|
||||
|
||||
GameObject cameraCard;
|
||||
TextMeshProUGUI cameraInfo;
|
||||
|
||||
// Ползунок: подпись, значение, диапазон. get/set -- поле камеры
|
||||
struct CamSlider { public string Label, Format; public float Min, Max; public System.Func<City3DCamera, float> Get; public System.Action<City3DCamera, float> Set; public string Pref; }
|
||||
|
||||
static readonly CamSlider[] CamSliders =
|
||||
{
|
||||
new CamSlider { Label = "Наклон", Format = "0°", Min = 25, Max = 85, Pref = City3DCamera.PrefPitch,
|
||||
Get = c => c.pitch, Set = (c, v) => c.pitch = v },
|
||||
new CamSlider { Label = "Перспектива", Format = "0°", Min = 15, Max = 75, Pref = City3DCamera.PrefFov,
|
||||
Get = c => c.fieldOfView, Set = (c, v) => c.SetFieldOfView(v) },
|
||||
new CamSlider { Label = "Ближе всего", Format = "0", Min = 3, Max = 30, Pref = City3DCamera.PrefMin,
|
||||
Get = c => c.minDistance, Set = (c, v) => { c.minDistance = Mathf.Min(v, c.maxDistance - 1); c.distance = Mathf.Max(c.distance, c.minDistance); } },
|
||||
new CamSlider { Label = "Дальше всего", Format = "0", Min = 20, Max = 120, Pref = City3DCamera.PrefMax,
|
||||
Get = c => c.maxDistance, Set = (c, v) => { c.maxDistance = Mathf.Max(v, c.minDistance + 1); c.distance = Mathf.Min(c.distance, c.maxDistance); } },
|
||||
new CamSlider { Label = "Сейчас", Format = "0", Min = 3, Max = 120, Pref = null,
|
||||
Get = c => c.distance, Set = (c, v) => c.distance = Mathf.Clamp(v, c.minDistance, c.maxDistance) },
|
||||
};
|
||||
|
||||
static City3DCamera Cam3D => Object.FindAnyObjectByType<City3DCamera>();
|
||||
|
||||
void ToggleCameraCard()
|
||||
{
|
||||
if (cameraCard != null) { bool show = !cameraCard.activeSelf; if (show) BuildCameraCard(); else cameraCard.SetActive(false); return; }
|
||||
BuildCameraCard();
|
||||
}
|
||||
|
||||
void BuildCameraCard()
|
||||
{
|
||||
if (cameraCard != null) Destroy(cameraCard);
|
||||
var cam = Cam3D;
|
||||
var safe = panel.transform.parent;
|
||||
var card = UIKit.Card(safe, new Color(0.08f, 0.08f, 0.11f, 0.95f), 20, 10);
|
||||
card.anchorMin = card.anchorMax = new Vector2(0, 0.5f);
|
||||
card.pivot = new Vector2(0, 0.5f);
|
||||
card.anchoredPosition = new Vector2(135 + 640, 0);
|
||||
card.sizeDelta = new Vector2(640, 0);
|
||||
card.gameObject.AddComponent<ContentSizeFitter>().verticalFit = ContentSizeFitter.FitMode.PreferredSize;
|
||||
cameraCard = card.gameObject;
|
||||
|
||||
var title = UIKit.Text(card, "КАМЕРА 3D-ПОСЕЛЕНИЯ (ТЕСТ)", 28, UIKit.Theme.Gold, TextAlignmentOptions.Center);
|
||||
title.fontStyle = FontStyles.Bold;
|
||||
if (cam == null)
|
||||
{
|
||||
UIKit.Text(card, "Откройте 3D-город (сцена City3D)", 24, UIKit.Theme.TextDim, TextAlignmentOptions.Center);
|
||||
}
|
||||
else
|
||||
{
|
||||
foreach (var def in CamSliders)
|
||||
{
|
||||
var d = def;
|
||||
var row = UIKit.Row(card, 60, 12);
|
||||
var label = UIKit.Text(row, d.Label, 24);
|
||||
UIKit.Size(label, 170);
|
||||
var value = UIKit.Text(row, d.Get(cam).ToString(d.Format), 24, UIKit.Theme.TextDim, TextAlignmentOptions.Right);
|
||||
UIKit.Slider(row, Mathf.InverseLerp(d.Min, d.Max, d.Get(cam)), t =>
|
||||
{
|
||||
var c = Cam3D;
|
||||
if (c == null) return;
|
||||
d.Set(c, Mathf.Lerp(d.Min, d.Max, t));
|
||||
value.text = d.Get(c).ToString(d.Format);
|
||||
if (d.Pref != null) PlayerPrefs.SetFloat(d.Pref, d.Get(c)); // запомнить на этом устройстве
|
||||
c.Apply();
|
||||
UpdateCameraInfo();
|
||||
});
|
||||
value.transform.SetAsLastSibling();
|
||||
UIKit.Size(value, 80);
|
||||
}
|
||||
}
|
||||
cameraInfo = UIKit.Text(card, "", 22, UIKit.Theme.TextDim, TextAlignmentOptions.Center);
|
||||
UpdateCameraInfo();
|
||||
|
||||
var row2 = UIKit.Row(card, 64, 10);
|
||||
Btn(row2, "Сбросить", UIKit.Theme.Gray, ResetCamera);
|
||||
Btn(row2, "Закрыть", UIKit.Theme.Gray, () => cameraCard.SetActive(false));
|
||||
}
|
||||
|
||||
// Строка, которую удобно переписать в отчёт / в сцену
|
||||
void UpdateCameraInfo()
|
||||
{
|
||||
var c = Cam3D;
|
||||
if (cameraInfo == null) return;
|
||||
cameraInfo.text = c == null ? "" :
|
||||
$"pitch {c.pitch:0} • fieldOfView {c.fieldOfView:0} • minDistance {c.minDistance:0} • maxDistance {c.maxDistance:0} • distance {c.distance:0}\n" +
|
||||
"Подобранное запоминается на этом устройстве. В сцену (для всех) -- вписать числа в Main Camera → City3DCamera";
|
||||
}
|
||||
|
||||
void ResetCamera()
|
||||
{
|
||||
foreach (var d in CamSliders) if (d.Pref != null) PlayerPrefs.DeleteKey(d.Pref);
|
||||
var c = Cam3D;
|
||||
if (c != null)
|
||||
{
|
||||
c.pitch = c.DefaultPitch;
|
||||
c.SetFieldOfView(c.DefaultFov);
|
||||
c.minDistance = c.DefaultMin;
|
||||
c.maxDistance = c.DefaultMax;
|
||||
c.distance = Mathf.Clamp(c.distance, c.minDistance, c.maxDistance);
|
||||
c.Apply();
|
||||
}
|
||||
BuildCameraCard();
|
||||
}
|
||||
|
||||
// Отладочная сетка стройки (3D-поселение и 2D-город): видна всегда, ярче, каждая 5-я линия жёлтая (05.11, просьба Миши)
|
||||
void ToggleGrid()
|
||||
{
|
||||
bool on = !(City3DController.DebugGrid || BuildGrid.DebugVisible);
|
||||
City3DController.SetDebugGrid(on);
|
||||
bool building = BuildModeController.Instance != null && BuildModeController.Instance.IsActive;
|
||||
BuildGrid.SetDebugVisible(on, building);
|
||||
if (gridButton != null) UIKit.SetButton(gridButton, GridLabel, true);
|
||||
}
|
||||
|
||||
static string GridLabel => City3DController.DebugGrid || BuildGrid.DebugVisible ? "Сетка: вкл" : "Сетка: выкл";
|
||||
|
||||
static void Btn(Transform row, string label, Color color, System.Action onClick)
|
||||
{
|
||||
var b = UIKit.Button(row, label, color, onClick, -1, 70, 24);
|
||||
@@ -142,7 +265,18 @@ public class DebugPanel : MonoBehaviour
|
||||
void AddAllResources()
|
||||
{
|
||||
ResourceType[] types = { ResourceType.Wood, ResourceType.Food, ResourceType.Stone, ResourceType.Iron, ResourceType.Gold, ResourceType.Gems };
|
||||
foreach (var t in types) AddResource(t, 100);
|
||||
foreach (var t in types) AddResource(t, 1000); // лишнее обрежет лимит склада
|
||||
}
|
||||
|
||||
// Кристаллы до предела склада (лимит 50 -- решение 19.10, предел не меняется)
|
||||
void FillGems()
|
||||
{
|
||||
var rm = ResourceManager.Instance;
|
||||
if (rm == null) { UIKit.Toast("Нет склада (ResourceManager)"); return; }
|
||||
float cap = rm.GetCapacity(ResourceType.Gems);
|
||||
if (float.IsInfinity(cap)) cap = EconomyConfig.Load().storage.crystalCap;
|
||||
rm.SetResource(ResourceType.Gems, cap);
|
||||
Refresh();
|
||||
}
|
||||
|
||||
void AddResource(ResourceType type, float amount)
|
||||
|
||||
@@ -24,8 +24,11 @@ public class EconomyConfig : ScriptableObject
|
||||
|
||||
[Tooltip("Еды в час на одного жителя")]
|
||||
public float foodPerCivilianPerHour = 0.1f;
|
||||
[Tooltip("Еды в час на одного воина")]
|
||||
[Tooltip("Еды в час на одного воина (и новобранца в Казармах), если норма по типам не задана")]
|
||||
public float foodPerSoldierPerHour = 0.2f;
|
||||
[Tooltip("Еды в час на воина по типам: пехота, лучники, кавалерия, осадные. Решения 01.11, п. 4: 0,15 / 0,20 / 0,15 / 0,15. " +
|
||||
"Раненые едят так же, даже ожидая койку. Пусто -- все по foodPerSoldierPerHour")]
|
||||
public float[] foodPerTroopTypePerHour = { 0.15f, 0.2f, 0.15f, 0.15f };
|
||||
|
||||
[Tooltip("Прирост людей в час, если есть еда (документ: 2–3)")]
|
||||
public float growthPerHour = 2.5f;
|
||||
@@ -123,6 +126,48 @@ public class EconomyConfig : ScriptableObject
|
||||
public int[] requiredBarracksLevel = { 1, 2, 3, 5 };
|
||||
}
|
||||
|
||||
[Serializable]
|
||||
public class ConstructionSettings // время стройки и улучшения, строители (01.11, ТЕСТ)
|
||||
{
|
||||
[Tooltip("Сколько строек и улучшений идёт одновременно (строители). ТЕСТ")]
|
||||
public int builders = 2;
|
||||
[Tooltip("Постройка нового здания, секунд. ТЕСТ")]
|
||||
public int buildSeconds = 60;
|
||||
[Tooltip("Улучшение до уровня 2, 3, 4... (секунд): [0] -- до 2 ур., [1] -- до 3 ур. ... ТЕСТ")]
|
||||
public int[] upgradeSecondsToLevel = { 60, 120, 300, 600, 1200, 1800, 3600, 5400, 7200 };
|
||||
[Tooltip("Ратуша строится дольше остальных: множитель времени улучшения. ТЕСТ")]
|
||||
public float townHallTimeMultiplier = 2f;
|
||||
[Tooltip("Ускорение: секунд стройки за 1 кристалл. ТЕСТ")]
|
||||
public int secondsPerGem = 30;
|
||||
// Сноса зданий нет (решение 04.11): только бесплатный перенос
|
||||
}
|
||||
|
||||
[Serializable]
|
||||
public class BuildingLimit
|
||||
{
|
||||
public string buildingName;
|
||||
[Tooltip("Сколько таких зданий можно иметь на Ратуше 1, 2, 3 ... уровня. 0 -- нельзя")]
|
||||
public int[] maxByTownHall;
|
||||
|
||||
public BuildingLimit() { }
|
||||
public BuildingLimit(string name, params int[] max) { buildingName = name; maxByTownHall = max; }
|
||||
}
|
||||
|
||||
[Serializable]
|
||||
public class ProtectionSettings // защита ресурсов от грабежа (01.11, ТЕСТ)
|
||||
{
|
||||
[Tooltip("Защищено без Складов: дерево, камень, металл, золото. ТЕСТ")]
|
||||
public float baseGoods = 100f;
|
||||
[Tooltip("+ к защите дерева, камня, металла, золота за каждый уровень Склада. ТЕСТ")]
|
||||
public float perWarehouseLevel = 200f;
|
||||
[Tooltip("Защищено без Амбаров: еда. ТЕСТ")]
|
||||
public float baseFood = 100f;
|
||||
[Tooltip("+ к защите еды за каждый уровень Амбара. ТЕСТ")]
|
||||
public float perGranaryLevel = 300f;
|
||||
[Tooltip("Кристаллы: без технологии «Грабёж» нападающий не уносит ничего (решение 04.11). С ней -- не больше этой доли запаса. ТЕСТ, точные ограничения определят отдельно")]
|
||||
[Range(0f, 1f)] public float plunderGemsShare = 0.1f;
|
||||
}
|
||||
|
||||
[Serializable]
|
||||
public class StartResources
|
||||
{
|
||||
@@ -156,6 +201,30 @@ public class EconomyConfig : ScriptableObject
|
||||
[Header("Солдат T1 (этап 5)")]
|
||||
public SoldierSettings soldier = new SoldierSettings();
|
||||
|
||||
[Header("Стройка: время, строители, снос (ТЕСТ)")]
|
||||
public ConstructionSettings construction = new ConstructionSettings();
|
||||
|
||||
[Header("Лимит зданий по уровню Ратуши (ТЕСТ). Здания не из списка -- без лимита")]
|
||||
public BuildingLimit[] buildingLimits =
|
||||
{
|
||||
// Ратуша: 1 2 3 4 5 6 7 8 9 10
|
||||
new BuildingLimit("Лес", 2, 3, 3, 4, 4, 5, 5, 6, 6, 7),
|
||||
new BuildingLimit("Еда", 2, 3, 3, 4, 4, 5, 5, 6, 6, 7),
|
||||
new BuildingLimit("Камень", 2, 2, 3, 3, 4, 4, 5, 5, 6, 6),
|
||||
new BuildingLimit("Металл", 0, 0, 1, 1, 2, 2, 3, 3, 4, 4),
|
||||
new BuildingLimit("Золото", 0, 1, 1, 2, 2, 3, 3, 3, 4, 4),
|
||||
new BuildingLimit("Население", 3, 4, 5, 6, 7, 8, 9, 10, 11, 12),
|
||||
new BuildingLimit("Склад", 1, 1, 2, 2, 2, 3, 3, 3, 4, 4),
|
||||
new BuildingLimit("Амбар", 1, 1, 2, 2, 2, 3, 3, 3, 4, 4),
|
||||
new BuildingLimit("Казармы", 1, 1, 2, 2, 3, 3, 3, 3, 3, 3),
|
||||
new BuildingLimit("Госпиталь", 1, 1, 1, 1, 1, 1, 1, 1, 1, 1),
|
||||
new BuildingLimit("Терем богатыря", 1, 1, 1, 1, 1, 1, 1, 1, 1, 1),
|
||||
new BuildingLimit("Кристалл", 0, 0, 0, 0, 0, 1, 1, 1, 1, 1),
|
||||
};
|
||||
|
||||
[Header("Защита ресурсов от грабежа (ТЕСТ)")]
|
||||
public ProtectionSettings protection = new ProtectionSettings();
|
||||
|
||||
// ---------------- Загрузка ----------------
|
||||
|
||||
static EconomyConfig cached;
|
||||
@@ -189,6 +258,49 @@ public class EconomyConfig : ScriptableObject
|
||||
}
|
||||
}
|
||||
|
||||
// Сколько зданий с таким именем можно иметь на этом уровне Ратуши. -1 -- без лимита (здания нет в списке)
|
||||
public int BuildingLimitFor(string buildingName, int townHallLevel)
|
||||
{
|
||||
if (buildingLimits == null || string.IsNullOrEmpty(buildingName)) return -1;
|
||||
foreach (var l in buildingLimits)
|
||||
{
|
||||
if (l == null || l.buildingName != buildingName || l.maxByTownHall == null || l.maxByTownHall.Length == 0) continue;
|
||||
return Mathf.Max(0, l.maxByTownHall[Mathf.Clamp(townHallLevel, 1, l.maxByTownHall.Length) - 1]);
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
// Время стройки (targetLevel = 1) или улучшения до targetLevel, мс. Ратуша -- с множителем
|
||||
public long ConstructionMs(bool townHall, int targetLevel)
|
||||
{
|
||||
var c = construction;
|
||||
double seconds;
|
||||
if (targetLevel <= 1) seconds = c.buildSeconds;
|
||||
else
|
||||
{
|
||||
var t = c.upgradeSecondsToLevel;
|
||||
seconds = t == null || t.Length == 0 ? c.buildSeconds : t[Mathf.Clamp(targetLevel - 2, 0, t.Length - 1)];
|
||||
}
|
||||
if (townHall) seconds *= Mathf.Max(0.01f, c.townHallTimeMultiplier);
|
||||
return (long)(Math.Max(0, seconds) * 1000.0);
|
||||
}
|
||||
|
||||
// Сколько ресурса не отнять при грабеже: часть запаса, которую прячут Склады и Амбары
|
||||
public float GetProtected(ResourceType type, int warehouseLevels, int granaryLevels)
|
||||
{
|
||||
var p = protection;
|
||||
switch (type)
|
||||
{
|
||||
case ResourceType.Wood:
|
||||
case ResourceType.Stone:
|
||||
case ResourceType.Iron:
|
||||
case ResourceType.Gold: return p.baseGoods + p.perWarehouseLevel * warehouseLevels;
|
||||
case ResourceType.Food: return p.baseFood + p.perGranaryLevel * granaryLevels;
|
||||
case ResourceType.Gems: return float.PositiveInfinity; // без «Грабежа» кристаллы не грабятся (с ним -- ResourceProtection)
|
||||
default: return 0f;
|
||||
}
|
||||
}
|
||||
|
||||
// Цена улучшения здания с уровня level на level + 1
|
||||
public ResourceAmount[] UpgradeCost(int level)
|
||||
{
|
||||
|
||||
@@ -22,12 +22,52 @@ public static class GameModules
|
||||
for (int i = 0; i < TroopType.Count && i < GameData.Troops.Count; i++) unitPower[i] = GameData.Troops[i].power;
|
||||
GameServices.Register<IBattleSystem>(new BattleSystem(BattleConfig.Load().ToRules(unitPower)));
|
||||
|
||||
// Марши: скорости по типам -- в модуль Мира, слоты похода -- в модуль Армии (таблица MarchConfig)
|
||||
var marchConfig = MarchConfig.Load();
|
||||
MarchManager.SpeedRules = marchConfig.ToSpeedRules();
|
||||
MarchManager.PointRules = WorldResourceConfig.Load().ToRules(); // ресурсные точки (регламент 31.10)
|
||||
MarchManager.Patrols = marchConfig.ToPatrolRules(); // патрули-заглушки соседей (Разведка М1)
|
||||
|
||||
// Разведка -- чистый C#: туман, обзор, чужие армии (секция "intelligence"). Карту и армии читает у Мира
|
||||
// через IWorldMap; досчитывает наблюдение перед каждым расчётом мира (WorldAdvancingEvent)
|
||||
var intelConfig = IntelligenceConfig.Load();
|
||||
var intelligence = new IntelligenceService(intelConfig.ToRules());
|
||||
GameServices.Register<IIntelligence>(intelligence);
|
||||
SaveRegistry.Register(intelligence);
|
||||
EventBus.Subscribe<WorldAdvancingEvent>(e => intelligence.ObserveUntil(e.ToMs));
|
||||
// «Обзор» для теста, пока нет дерева технологий (после загрузки и в новой игре)
|
||||
void TestObservation() { if (intelConfig.testObservationLevel > 0) intelligence.ObservationLevel = intelConfig.testObservationLevel; }
|
||||
TestObservation();
|
||||
EventBus.Subscribe<GameLoadedEvent>(_ => TestObservation());
|
||||
EventBus.Subscribe<GameResetEvent>(_ => TestObservation());
|
||||
|
||||
// Армия -- чистый C#, единственное место хранения войск (секция "army")
|
||||
var army = new ArmyService();
|
||||
var army = new ArmyService(marchConfig.baseMarchSlots, HospitalConfig.Load().ToRules(), HospitalConfig.Load().ToMissingRules()); // госпиталь, пропавшие
|
||||
GameServices.Register<IArmy>(army);
|
||||
SaveRegistry.Register(army);
|
||||
EventBus.Subscribe<TroopsChangedEvent>(_ => { if (GameProgress.HasInstance) GameProgress.Instance.MarkChanged(); });
|
||||
EventBus.Subscribe<GameLoadedEvent>(_ => MigrateLegacyArmy(army));
|
||||
// Очередь обучения -- у Армии (решения 31.10, п. 8): статистика заданий по её событию, старая очередь -- переносится
|
||||
EventBus.Subscribe<TroopsTrainedEvent>(e => { if (GameProgress.HasInstance) GameProgress.Instance.OnTroopsTrained(e); });
|
||||
EventBus.Subscribe<GameLoadedEvent>(_ => MigrateLegacyTraining(army));
|
||||
// Пропавшие без вести вернулись / списаны (решения 01.11, п. 5)
|
||||
EventBus.Subscribe<MissingReturnedEvent>(e =>
|
||||
{
|
||||
if (e.Healthy + e.Wounded > 0)
|
||||
UIKit.Toast($"Вернулись пропавшие без вести: {e.Healthy + e.Wounded}" + (e.Wounded > 0 ? $" (ранены {e.Wounded} -- в госпиталь)" : ""));
|
||||
if (GameProgress.HasInstance) GameProgress.Instance.MarkChanged();
|
||||
});
|
||||
EventBus.Subscribe<MissingWrittenOffEvent>(e =>
|
||||
{
|
||||
if (e.Count > 0) UIKit.Toast($"Не вернулись и списаны пропавшие без вести: {e.Count}");
|
||||
if (GameProgress.HasInstance) GameProgress.Instance.MarkChanged();
|
||||
});
|
||||
// Раненые вылечены -- вернулись в город
|
||||
EventBus.Subscribe<SoldiersHealedEvent>(e =>
|
||||
{
|
||||
if (e.Type >= 0 && e.Type < GameData.Troops.Count) UIKit.Toast("Вылечены в госпитале: " + e.Count + " × " + GameData.Troops[e.Type].name);
|
||||
if (GameProgress.HasInstance) GameProgress.Instance.MarkChanged();
|
||||
});
|
||||
|
||||
// Население -- чистый C#: жители, рабочие, еда, прирост (секция "population"). Числа -- из EconomyConfig.
|
||||
var config = EconomyConfig.Load();
|
||||
@@ -52,6 +92,21 @@ public static class GameModules
|
||||
StorageBuilding.Tapped += BuildingWindow.OnStorageTapped;
|
||||
TownHall.Tapped -= BuildingWindow.OnTownHallTapped;
|
||||
TownHall.Tapped += BuildingWindow.OnTownHallTapped;
|
||||
|
||||
// Стройка и улучшение по времени, строители (01.11, ТЕСТ): числа -- EconomyConfig, раздел "Стройка"
|
||||
BuildTimer.Builders = Mathf.Max(1, config.construction.builders);
|
||||
ConstructionClock.Init(); // офлайн: стройка вступает в силу в момент своего завершения (решения 04.11)
|
||||
BuildTimer.DurationMs = (townHall, level) => EconomyConfig.Load().ConstructionMs(townHall, level);
|
||||
BuildTimer.Tapped -= OnBuildTimerTapped;
|
||||
BuildTimer.Tapped += OnBuildTimerTapped;
|
||||
BuildTimer.Completed -= OnBuildCompleted;
|
||||
BuildTimer.Completed += OnBuildCompleted;
|
||||
|
||||
// 3D-поселение (сцена City3D, 05.11): свой город, своя секция сохранения "settlement3d"
|
||||
SaveRegistry.Register(City3DSave.Instance);
|
||||
|
||||
// Защита ресурсов от грабежа (01.11, ТЕСТ): сколько у города нельзя унести в набеге
|
||||
GameServices.Register<IResourceProtection>(new ResourceProtection());
|
||||
EventBus.Subscribe<GameLoadedEvent>(_ => population.OnGameLoaded(GameClock.UnixNow)); // офлайн-прогресс
|
||||
EventBus.Subscribe<GameTimeAdvancedEvent>(e => population.Tick(e.UnixNow)); // перемотка (отладка)
|
||||
EventBus.Subscribe<TroopsChangedEvent>(_ => population.Changed()); // воины -- тоже люди
|
||||
@@ -71,6 +126,13 @@ public static class GameModules
|
||||
// Казармы появляются в списке стройки кодом -- до восстановления зданий из сохранения
|
||||
EventBus.Subscribe<SettlementRestoringEvent>(_ => BarracksBootstrap.Ensure());
|
||||
|
||||
// Госпиталь (решения 31.10, п. 7): здание города даёт места для лечения Армии через контракт IHospital
|
||||
GameServices.Register<IHospital>(new HospitalBeds());
|
||||
EventBus.Subscribe<SettlementRestoringEvent>(_ => HospitalBootstrap.Ensure());
|
||||
|
||||
// Терем богатыря: дом героя, герой гуляет по городу (Meta/Build/HeroCastle, HeroWalker)
|
||||
EventBus.Subscribe<SettlementRestoringEvent>(_ => HeroCastleBootstrap.Ensure());
|
||||
|
||||
// Статистика для заданий (GameProgress) -- по событиям Мира
|
||||
EventBus.Subscribe<MarchStartedEvent>(OnMarchStarted);
|
||||
EventBus.Subscribe<MarchReturnedEvent>(OnMarchReturned);
|
||||
@@ -80,6 +142,21 @@ public static class GameModules
|
||||
EventBus.Subscribe<MarchReturnedEvent>(MarchReturnPopup.OnMarchReturned);
|
||||
}
|
||||
|
||||
static void OnBuildTimerTapped(BuildTimer timer)
|
||||
{
|
||||
if (timer != null) BuildingWindow.Open(timer.gameObject);
|
||||
}
|
||||
|
||||
static void OnBuildCompleted(BuildTimer timer)
|
||||
{
|
||||
if (timer == null) return;
|
||||
if (CitySync.Instance != null) CitySync.Instance.SyncNow();
|
||||
var id = timer.GetComponent<PlacedBuildingId>();
|
||||
string name = timer.GetComponent<TownHall>() != null ? "Ратуша" : id != null ? id.buildingName : timer.name;
|
||||
UIKit.Toast(timer.IsConstruction ? $"Построено: {name}" : $"{name}: улучшено до {timer.targetLevel} уровня");
|
||||
BuildingWindow.OnBuildCompleted(timer);
|
||||
}
|
||||
|
||||
// Сцена загружена, Awake всех объектов прошли, Start -- ещё нет (сохранение грузится в Start)
|
||||
[RuntimeInitializeOnLoadMethod(RuntimeInitializeLoadType.AfterSceneLoad)]
|
||||
static void MigrateLegacyMarches()
|
||||
@@ -135,6 +212,36 @@ public static class GameModules
|
||||
GameProgress.Instance.MarkChanged();
|
||||
}
|
||||
|
||||
// Очередь обучения раньше вёл GameProgress (progress.json, Data.training, время -- тики реальных часов).
|
||||
// Теперь -- Армия (save.json, "army", мс часов игры). Переносим один раз, с тем же оставшимся временем.
|
||||
static void MigrateLegacyTraining(ArmyService army)
|
||||
{
|
||||
var data = GameProgress.Instance.Data;
|
||||
if (data.training == null || data.training.Count == 0) return;
|
||||
|
||||
// В Армии уже есть заказы -- перенос уже был (сохранение записалось, а progress.json -- ещё нет): только убрать старое
|
||||
if (army.Training.Count == 0)
|
||||
{
|
||||
long epochTicks = new System.DateTime(1970, 1, 1, 0, 0, 0, System.DateTimeKind.Utc).Ticks;
|
||||
long offsetMs = GameClock.OffsetSeconds * 1000; // старые тики -- реальное время, Армия -- часы игры
|
||||
var orders = new System.Collections.Generic.List<(int, int, long, long, bool)>();
|
||||
foreach (var o in data.training)
|
||||
orders.Add((o.troopIndex, o.count, (o.startTicks - epochTicks) / 10_000 + offsetMs, (o.endTicks - epochTicks) / 10_000 + offsetMs, o.recruited));
|
||||
army.ImportLegacyTraining(orders);
|
||||
Debug.Log($"[МОДУЛИ] Очередь обучения перенесена из progress.json в модуль Армии: заказов {orders.Count}.");
|
||||
}
|
||||
|
||||
// Из progress.json убираем, только когда очередь уже записана в save.json -- иначе вылет игры её потеряет
|
||||
EventBus.Subscribe<GameSavedEvent>(ClearLegacyTraining);
|
||||
}
|
||||
|
||||
static void ClearLegacyTraining(GameSavedEvent e)
|
||||
{
|
||||
EventBus.Unsubscribe<GameSavedEvent>(ClearLegacyTraining);
|
||||
GameProgress.Instance.Data.training.Clear();
|
||||
GameProgress.Instance.MarkChanged();
|
||||
}
|
||||
|
||||
// ---------------- Население и экономика ----------------
|
||||
|
||||
static PopulationRules ToPopulationRules(EconomyConfig config)
|
||||
@@ -147,6 +254,8 @@ public static class GameModules
|
||||
StartSoldiers = p.startSoldiers,
|
||||
FoodPerCivilianPerHour = p.foodPerCivilianPerHour,
|
||||
FoodPerSoldierPerHour = p.foodPerSoldierPerHour,
|
||||
FoodPerTroopTypePerHour = p.foodPerTroopTypePerHour != null && p.foodPerTroopTypePerHour.Length > 0
|
||||
? System.Array.ConvertAll(p.foodPerTroopTypePerHour, f => (double)f) : null,
|
||||
GrowthPerHour = p.growthPerHour,
|
||||
HousingBase = p.housingBase,
|
||||
HousingPerTownHallLevel = p.housingPerTownHallLevel,
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
using UnityEngine;
|
||||
|
||||
// Версия игры на экране (05.11, требование Миши: в каждом билде видно, что это за версия).
|
||||
// В углу экрана: версия из Player Settings (Application.version) и сборка -- ветка, коммит, дата.
|
||||
// Сборку пишет Editor/BuildInfoWriter перед билдом в Resources/BuildInfo.txt; в редакторе -- "редактор".
|
||||
// Создаётся сам при запуске, живёт между сценами.
|
||||
public class GameVersionLabel : MonoBehaviour
|
||||
{
|
||||
static GameVersionLabel instance;
|
||||
string text;
|
||||
GUIStyle style, shadow;
|
||||
|
||||
public static string BuildInfo
|
||||
{
|
||||
get
|
||||
{
|
||||
var asset = Resources.Load<TextAsset>("BuildInfo");
|
||||
return asset != null ? asset.text.Trim() : "редактор";
|
||||
}
|
||||
}
|
||||
|
||||
public static string FullVersion => $"v{Application.version} • {BuildInfo}";
|
||||
|
||||
[RuntimeInitializeOnLoadMethod(RuntimeInitializeLoadType.AfterSceneLoad)]
|
||||
static void Create()
|
||||
{
|
||||
if (instance != null) return;
|
||||
var go = new GameObject("GameVersionLabel");
|
||||
DontDestroyOnLoad(go);
|
||||
instance = go.AddComponent<GameVersionLabel>();
|
||||
instance.text = FullVersion;
|
||||
Debug.Log("[ВЕРСИЯ] " + instance.text);
|
||||
}
|
||||
|
||||
void OnGUI()
|
||||
{
|
||||
if (style == null)
|
||||
{
|
||||
int size = Mathf.Max(12, Screen.height / 60);
|
||||
style = new GUIStyle(GUI.skin.label) { fontSize = size, alignment = TextAnchor.LowerLeft };
|
||||
style.normal.textColor = new Color(1f, 1f, 1f, 0.75f);
|
||||
shadow = new GUIStyle(style);
|
||||
shadow.normal.textColor = new Color(0f, 0f, 0f, 0.6f);
|
||||
}
|
||||
GUI.depth = -1000; // поверх всего
|
||||
var r = new Rect(8, Screen.height - style.fontSize * 2 - 4, Screen.width * 0.6f, style.fontSize * 2);
|
||||
GUI.Label(new Rect(r.x + 1, r.y + 1, r.width, r.height), text, shadow);
|
||||
GUI.Label(r, text, style);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 4dae86c365fe55948b878547633d712a
|
||||
@@ -0,0 +1,89 @@
|
||||
using UnityEngine;
|
||||
|
||||
// ТАБЛИЦА ГОСПИТАЛЯ: места и время лечения раненых (регламент 31.10, решения по 8 вопросам Саши от 31.10, п. 7). Числа -- ТЕСТ.
|
||||
//
|
||||
// Ассет: Assets/Resources/HospitalConfig.asset (выдели его в окне Project и меняй числа в Inspector;
|
||||
// изменения действуют со следующего запуска игры). Создать заново: правый клик в Project ->
|
||||
// Create -> Bogatyri -> Hospital Config, имя файла -- HospitalConfig, папка -- Resources.
|
||||
//
|
||||
// Модуль Армии таблицу не видит: App/GameModules передаёт числа в ArmyService.
|
||||
// Места даёт здание «Госпиталь» (Meta/Build/Hospital.cs) через контракт IHospital.
|
||||
[CreateAssetMenu(fileName = "HospitalConfig", menuName = "Bogatyri/Hospital Config")]
|
||||
public class HospitalConfig : ScriptableObject
|
||||
{
|
||||
[Header("Здание «Госпиталь» (ТЕСТ)")]
|
||||
[Tooltip("Мест для лечения по уровням госпиталя I..V. Регламент 31.10: 20..60. Число строк -- число уровней")]
|
||||
public int[] bedsByLevel = { 20, 30, 40, 50, 60 };
|
||||
[Tooltip("Мест, пока в городе нет госпиталя. Решения 01.11, п. 3: 5. После постройки заменяются штатными, не складываются")]
|
||||
public int temporaryBeds = 5;
|
||||
[Tooltip("С какого уровня Ратуши можно строить")]
|
||||
public int requiredTownHallLevel = 1;
|
||||
[Tooltip("Цена постройки: дерево")]
|
||||
public int buildWood = 250;
|
||||
[Tooltip("Цена постройки: камень")]
|
||||
public int buildStone = 150;
|
||||
|
||||
[Header("Лечение одного воина, ИГРОВЫХ часов (1 игровой час = 5 реальных минут)")]
|
||||
[Tooltip("Пехота, лучники, кавалерия, осадные. Регламент 31.10: 4 / 4 / 6 / 8")]
|
||||
public int[] healGameHours = { 4, 4, 6, 8 };
|
||||
|
||||
[Header("Цена лечения одного воина (решения 01.11, п. 3: золото 1 / 1 / 2 / 3)")]
|
||||
[Tooltip("Еды за воина: пехота, лучники, кавалерия, осадные")]
|
||||
public int[] healFoodPerSoldier = { 0, 0, 0, 0 };
|
||||
[Tooltip("Золота за воина: пехота, лучники, кавалерия, осадные")]
|
||||
public int[] healGoldPerSoldier = { 1, 1, 2, 3 };
|
||||
|
||||
[Header("Пропавшие без вести (решения 01.11, п. 5). Долю пропавших в бою задаёт BattleConfig.missingSharePermille")]
|
||||
[Tooltip("Сколько пропавших вернётся, %. Утверждён диапазон 10-90%, формула НЕ утверждена -- ТЕСТ")]
|
||||
public int missingReturnPercent = 50;
|
||||
[Tooltip("Из вернувшихся ранены, %. Утверждено: 30 (70% здоровыми)")]
|
||||
public int missingWoundedPercent = 30;
|
||||
[Tooltip("Окно возвращения после обратного пути, ИГРОВЫХ часов. Утверждено: до 6")]
|
||||
public int missingWindowGameHours = 6;
|
||||
[Tooltip("Возвращаются группами: от 2 до стольких. Утверждено: 2-4")]
|
||||
public int missingMaxGroups = 4;
|
||||
|
||||
static HospitalConfig cached;
|
||||
|
||||
public static HospitalConfig Load()
|
||||
{
|
||||
if (cached != null) return cached;
|
||||
cached = Resources.Load<HospitalConfig>("HospitalConfig");
|
||||
if (cached == null)
|
||||
{
|
||||
Debug.LogWarning("[ГОСПИТАЛЬ] Нет Assets/Resources/HospitalConfig.asset -- берутся числа по умолчанию из HospitalConfig.cs.");
|
||||
cached = CreateInstance<HospitalConfig>();
|
||||
}
|
||||
return cached;
|
||||
}
|
||||
|
||||
public MissingRules ToMissingRules()
|
||||
{
|
||||
return new MissingRules
|
||||
{
|
||||
ReturnPercent = Mathf.Clamp(missingReturnPercent, 0, 100),
|
||||
WoundedPercent = Mathf.Clamp(missingWoundedPercent, 0, 100),
|
||||
WindowGameHours = Mathf.Max(0, missingWindowGameHours),
|
||||
MaxGroups = Mathf.Max(2, missingMaxGroups)
|
||||
};
|
||||
}
|
||||
|
||||
public HospitalRules ToRules()
|
||||
{
|
||||
var hours = new int[TroopType.Count];
|
||||
var cost = new ResourceAmount[TroopType.Count][];
|
||||
for (int i = 0; i < TroopType.Count; i++)
|
||||
{
|
||||
hours[i] = healGameHours != null && i < healGameHours.Length ? Mathf.Max(0, healGameHours[i]) : 4;
|
||||
int food = healFoodPerSoldier != null && i < healFoodPerSoldier.Length ? Mathf.Max(0, healFoodPerSoldier[i]) : 0;
|
||||
int gold = healGoldPerSoldier != null && i < healGoldPerSoldier.Length ? Mathf.Max(0, healGoldPerSoldier[i]) : 0;
|
||||
cost[i] = new[] { new ResourceAmount(ResourceType.Food, food), new ResourceAmount(ResourceType.Gold, gold) };
|
||||
}
|
||||
return new HospitalRules
|
||||
{
|
||||
TemporaryBeds = Mathf.Max(0, temporaryBeds),
|
||||
HealGameHours = hours,
|
||||
HealCostPerSoldier = cost
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: c25bbe993b314a15a6d1a8aaf0017d55
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,73 @@
|
||||
using UnityEngine;
|
||||
|
||||
// ТАБЛИЦА РАЗВЕДКИ, этап М1: радиусы обзора, последний контакт, примерная численность, опознание (ТЗ разведки 31.10).
|
||||
// Числа -- ТЕСТ.
|
||||
//
|
||||
// Ассет: Assets/Resources/IntelligenceConfig.asset (выдели его в окне Project и меняй числа в Inspector;
|
||||
// изменения действуют со следующего запуска игры). Создать заново: правый клик в Project ->
|
||||
// Create -> Bogatyri -> Intelligence Config, имя файла -- IntelligenceConfig, папка -- Resources.
|
||||
//
|
||||
// Модуль таблицу не видит: App/GameModules передаёт числа в модуль Разведки.
|
||||
// Патрули-заглушки соседей включаются в таблице MarchConfig (это походы Мира).
|
||||
[CreateAssetMenu(fileName = "IntelligenceConfig", menuName = "Bogatyri/Intelligence Config")]
|
||||
public class IntelligenceConfig : ScriptableObject
|
||||
{
|
||||
[Header("Радиус обзора, клеток карты")]
|
||||
[Tooltip("Своё поселение. ТЗ разведки: 5")]
|
||||
public float settlementRadius = 5f;
|
||||
[Tooltip("Обычная армия в походе. ТЗ разведки: 1")]
|
||||
public float armyRadius = 1f;
|
||||
|
||||
[Header("Технология «Обзор» (не складывается, уровень заменяет)")]
|
||||
[Tooltip("Прибавка к радиусам на уровнях I, II, III, %. ТЗ: 10 / 20 / 30")]
|
||||
public int[] observationPercent = { 10, 20, 30 };
|
||||
[Tooltip("ТОЛЬКО ДЛЯ ТЕСТА: уровень «Обзора» 0..3, пока нет дерева технологий")]
|
||||
public int testObservationLevel = 0;
|
||||
|
||||
[Header("Чужие армии")]
|
||||
[Tooltip("Метка последнего контакта живёт, РЕАЛЬНЫХ минут. ТЗ: 10")]
|
||||
public float lastContactMinutes = 10f;
|
||||
[Tooltip("Примерная численность: ±%. ТЗ: 20")]
|
||||
public int approxPercent = 20;
|
||||
[Tooltip("Шанс опознать владельца за одно событие наблюдения, %. ТЗ: 50")]
|
||||
public int identifyPercent = 50;
|
||||
[Tooltip("Шаг расчёта наблюдения за время, пока игра была закрыта, РЕАЛЬНЫХ секунд")]
|
||||
public float sampleStepSeconds = 10f;
|
||||
|
||||
[Header("Туман на карте")]
|
||||
[Tooltip("Выключено (по умолчанию): города, лагеря и точки видны всегда, туман только затемняет землю. " +
|
||||
"Включено: неисследованная местность закрыта полностью вместе с объектами. Решения 31.10, п. 1: для М1 не требуется, оставлено переключателем")]
|
||||
public bool hideUnexploredObjects = false;
|
||||
|
||||
static IntelligenceConfig cached;
|
||||
|
||||
public static IntelligenceConfig Load()
|
||||
{
|
||||
if (cached != null) return cached;
|
||||
cached = Resources.Load<IntelligenceConfig>("IntelligenceConfig");
|
||||
if (cached == null)
|
||||
{
|
||||
Debug.LogWarning("[РАЗВЕДКА] Нет Assets/Resources/IntelligenceConfig.asset -- берутся числа по умолчанию из IntelligenceConfig.cs.");
|
||||
cached = CreateInstance<IntelligenceConfig>();
|
||||
}
|
||||
return cached;
|
||||
}
|
||||
|
||||
public IntelligenceRules ToRules()
|
||||
{
|
||||
var levels = new int[4];
|
||||
for (int i = 0; i < 3 && observationPercent != null && i < observationPercent.Length; i++) levels[i + 1] = Mathf.Max(0, observationPercent[i]);
|
||||
return new IntelligenceRules
|
||||
{
|
||||
SettlementRadius = Milli(settlementRadius),
|
||||
ArmyRadius = Milli(armyRadius),
|
||||
ObservationPercent = levels,
|
||||
LastContactMs = (long)(Mathf.Max(0f, lastContactMinutes) * 60_000f),
|
||||
ApproxPercent = Mathf.Clamp(approxPercent, 0, 100),
|
||||
IdentifyPercent = Mathf.Clamp(identifyPercent, 0, 100),
|
||||
SampleStepMs = (long)(Mathf.Max(1f, sampleStepSeconds) * 1000f)
|
||||
};
|
||||
}
|
||||
|
||||
static int Milli(float cells) => Mathf.Max(0, Mathf.RoundToInt(cells * 1000f));
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: c249ad34e2f94460b9f99b49c71760c8
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,82 @@
|
||||
using UnityEngine;
|
||||
|
||||
// ТАБЛИЦА МАРШЕЙ: скорости войск по типам и слоты похода (регламент 31.10, раздел 7). Числа -- ТЕСТ.
|
||||
//
|
||||
// Ассет: Assets/Resources/MarchConfig.asset (выдели его в окне Project и меняй числа в Inspector;
|
||||
// изменения действуют со следующего запуска игры). Создать заново: правый клик в Project ->
|
||||
// Create -> Bogatyri -> March Config, имя файла -- MarchConfig, папка -- Resources.
|
||||
//
|
||||
// Модули таблицу не видят: App/GameModules передаёт скорости в модуль Мира, слоты -- в модуль Армии.
|
||||
// Бонусы героя (+15%) и технологии (+20%) к скорости появятся вместе с героями и технологиями.
|
||||
[CreateAssetMenu(fileName = "MarchConfig", menuName = "Bogatyri/March Config")]
|
||||
public class MarchConfig : ScriptableObject
|
||||
{
|
||||
[Header("Скорость: клеток карты за 1 РЕАЛЬНЫЙ час (марш идёт в реальном времени)")]
|
||||
[Tooltip("Пехота. Регламент 31.10: 8")]
|
||||
public float infantry = 8f;
|
||||
[Tooltip("Лучники. Регламент 31.10: 9")]
|
||||
public float archers = 9f;
|
||||
[Tooltip("Кавалерия. Регламент 31.10: 12")]
|
||||
public float cavalry = 12f;
|
||||
[Tooltip("Осадные. Регламент 31.10: 4")]
|
||||
public float siege = 4f;
|
||||
|
||||
[Header("Модификаторы скорости, % (решения 31.10, п. 4: смешанное войско -- средняя по численности)")]
|
||||
[Tooltip("По типам: пехота, лучники, кавалерия, осадные. +10 -- быстрее на 10%. Позже -- бонусы героев и технологий. ТЕСТ")]
|
||||
public int[] typeSpeedBonusPercent = { 0, 0, 0, 0 };
|
||||
[Tooltip("Общий модификатор всего войска, применяется один раз. ТЕСТ")]
|
||||
public int armySpeedBonusPercent = 0;
|
||||
|
||||
[Header("Слоты похода")]
|
||||
[Tooltip("Сколько походов игрока могут идти одновременно. Регламент 31.10: 2 (технология «Военная логистика» -- до 7)")]
|
||||
public int baseMarchSlots = 2;
|
||||
|
||||
[Header("Патрули-заглушки соседей (Разведка М1, ВРЕМЕННАЯ тестовая механика)")]
|
||||
[Tooltip("Соседние города иногда отправляют отряды к другим городам и лагерям -- проверить обнаружение, численность, опознание и потерю контакта. Выключить, когда появится настоящий PvP")]
|
||||
public bool patrolsEnabled = true;
|
||||
[Tooltip("Сколько патрулей одновременно на карте")]
|
||||
public int patrolMaxActive = 3;
|
||||
[Tooltip("Новый патруль -- раз в столько РЕАЛЬНЫХ минут (если не превышен предел)")]
|
||||
public float patrolIntervalMinutes = 10f;
|
||||
public int patrolSoldiersMin = 10;
|
||||
public int patrolSoldiersMax = 40;
|
||||
|
||||
static MarchConfig cached;
|
||||
|
||||
public static MarchConfig Load()
|
||||
{
|
||||
if (cached != null) return cached;
|
||||
cached = Resources.Load<MarchConfig>("MarchConfig");
|
||||
if (cached == null)
|
||||
{
|
||||
Debug.LogWarning("[МАРШИ] Нет Assets/Resources/MarchConfig.asset -- берутся числа по умолчанию из MarchConfig.cs.");
|
||||
cached = CreateInstance<MarchConfig>();
|
||||
}
|
||||
return cached;
|
||||
}
|
||||
|
||||
// Скорости для модуля Мира: милли-единиц карты (1000 = клетка) за реальный час
|
||||
public Bogatyri.World.MarchSpeedRules ToSpeedRules()
|
||||
{
|
||||
return new Bogatyri.World.MarchSpeedRules
|
||||
{
|
||||
SpeedMilliPerHour = new[] { Milli(infantry), Milli(archers), Milli(cavalry), Milli(siege) },
|
||||
TypeBonusPercent = (int[])(typeSpeedBonusPercent ?? new int[4]).Clone(),
|
||||
ArmyBonusPercent = armySpeedBonusPercent
|
||||
};
|
||||
}
|
||||
|
||||
public Bogatyri.World.PatrolRules ToPatrolRules()
|
||||
{
|
||||
return new Bogatyri.World.PatrolRules
|
||||
{
|
||||
Enabled = patrolsEnabled,
|
||||
MaxActive = Mathf.Max(0, patrolMaxActive),
|
||||
IntervalMs = (long)(Mathf.Max(1f, patrolIntervalMinutes) * 60_000f),
|
||||
SoldiersMin = Mathf.Max(1, patrolSoldiersMin),
|
||||
SoldiersMax = Mathf.Max(1, patrolSoldiersMax)
|
||||
};
|
||||
}
|
||||
|
||||
static int Milli(float cellsPerHour) => Mathf.Max(1, Mathf.RoundToInt(cellsPerHour * 1000f));
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 0427eef4612041d2b8a359f9bf358779
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,33 @@
|
||||
using UnityEngine;
|
||||
|
||||
// Защита ресурсов от грабежа (контракт IResourceProtection, 01.11, ТЕСТ).
|
||||
// Часть запаса прячут Склады и Амбары: EconomyConfig, раздел "Защита ресурсов". Уровни зданий сообщает
|
||||
// App/CitySync; на карте мира, где города нет, действуют последние известные уровни.
|
||||
public sealed class ResourceProtection : IResourceProtection
|
||||
{
|
||||
public static int WarehouseLevels { get; private set; }
|
||||
public static int GranaryLevels { get; private set; }
|
||||
|
||||
public static void SetLevels(int warehouseLevels, int granaryLevels)
|
||||
{
|
||||
WarehouseLevels = Mathf.Max(0, warehouseLevels);
|
||||
GranaryLevels = Mathf.Max(0, granaryLevels);
|
||||
}
|
||||
|
||||
public float Protected(ResourceType type)
|
||||
{
|
||||
return EconomyConfig.Load().GetProtected(type, WarehouseLevels, GranaryLevels);
|
||||
}
|
||||
|
||||
public float Lootable(ResourceType type) => Lootable(type, false);
|
||||
|
||||
// Кристаллы: без «Грабежа» -- 0; с ним -- не больше доли запаса (EconomyConfig, ТЕСТ, точные ограничения -- позже)
|
||||
public float Lootable(ResourceType type, bool attackerHasPlunder)
|
||||
{
|
||||
if (!GameServices.TryGet(out IEconomy economy)) return 0f;
|
||||
float have = Mathf.Max(0f, economy.Get(type));
|
||||
if (type == ResourceType.Gems)
|
||||
return attackerHasPlunder ? Mathf.Floor(have * Mathf.Clamp01(EconomyConfig.Load().protection.plunderGemsShare)) : 0f;
|
||||
return Mathf.Max(0f, have - Protected(type));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: cea38890d8f5d8d4f804c095c4c113b4
|
||||
@@ -0,0 +1,84 @@
|
||||
using UnityEngine;
|
||||
|
||||
// ТАБЛИЦА РЕСУРСНЫХ ТОЧЕК (регламент 31.10, раздел 7: «Изменять числа через WorldResourceConfig.asset»). Числа -- ТЕСТ.
|
||||
//
|
||||
// Ассет: Assets/Resources/WorldResourceConfig.asset (выдели его в окне Project и меняй числа в Inspector;
|
||||
// изменения действуют со следующего запуска игры). Создать заново: правый клик в Project ->
|
||||
// Create -> Bogatyri -> World Resource Config, имя файла -- WorldResourceConfig, папка -- Resources.
|
||||
//
|
||||
// Модуль Мира таблицу не видит: App/GameModules переводит её в ResourcePointRules.
|
||||
// Состояние точек (запас, сроки) ведёт Мир; зачисление добычи и складские лимиты -- Экономика (решение №46).
|
||||
[CreateAssetMenu(fileName = "WorldResourceConfig", menuName = "Bogatyri/World Resource Config")]
|
||||
public class WorldResourceConfig : ScriptableObject
|
||||
{
|
||||
[Header("Запас точки: малая / средняя / крупная")]
|
||||
public int[] woodStock = { 200, 600, 1500 };
|
||||
public int[] stoneStock = { 150, 450, 1000 };
|
||||
public int[] metalStock = { 80, 250, 600 };
|
||||
public int[] goldStock = { 30, 100, 250 };
|
||||
|
||||
[Header("Добыча одного воина за ИГРОВОЙ час (1 игровой час = 5 реальных минут)")]
|
||||
public float woodPerGameHour = 2f;
|
||||
public float stonePerGameHour = 1.5f;
|
||||
public float metalPerGameHour = 1f;
|
||||
public float goldPerGameHour = 0.5f;
|
||||
|
||||
[Header("Груз одного воина (осадные машины груза не несут)")]
|
||||
public int woodCargo = 12;
|
||||
public int stoneCargo = 10;
|
||||
public int metalCargo = 8;
|
||||
public int goldCargo = 5;
|
||||
|
||||
[Header("Срок жизни точки, РЕАЛЬНЫХ часов с момента появления: малая / средняя / крупная")]
|
||||
public int[] lifetimeRealHours = { 12, 24, 48 };
|
||||
|
||||
[Header("Сколько точек на карте: малых / средних / крупных (решения 31.10, п. 6, ТЕСТ)")]
|
||||
[Tooltip("Решения 31.10: всего 24 -- лес 8, камень 7, металл 5, золото 4; по размерам 12 / 8 / 4. Раскладка по ресурсам -- здесь")]
|
||||
public int[] woodCount = { 4, 3, 1 };
|
||||
public int[] stoneCount = { 4, 2, 1 };
|
||||
public int[] metalCount = { 2, 2, 1 };
|
||||
public int[] goldCount = { 2, 1, 1 };
|
||||
|
||||
static WorldResourceConfig cached;
|
||||
|
||||
public static WorldResourceConfig Load()
|
||||
{
|
||||
if (cached != null) return cached;
|
||||
cached = Resources.Load<WorldResourceConfig>("WorldResourceConfig");
|
||||
if (cached == null)
|
||||
{
|
||||
Debug.LogWarning("[РЕСУРСНЫЕ ТОЧКИ] Нет Assets/Resources/WorldResourceConfig.asset -- берутся числа по умолчанию.");
|
||||
cached = CreateInstance<WorldResourceConfig>();
|
||||
}
|
||||
return cached;
|
||||
}
|
||||
|
||||
public Bogatyri.World.ResourcePointRules ToRules()
|
||||
{
|
||||
var d = Bogatyri.World.ResourcePointRules.Default;
|
||||
return new Bogatyri.World.ResourcePointRules
|
||||
{
|
||||
Stock = new[] { Three(woodStock, d.Stock[0]), Three(stoneStock, d.Stock[1]), Three(metalStock, d.Stock[2]), Three(goldStock, d.Stock[3]) },
|
||||
MiningMilliPerGameHour = new[] { Milli(woodPerGameHour), Milli(stonePerGameHour), Milli(metalPerGameHour), Milli(goldPerGameHour) },
|
||||
CargoPerSoldier = new[] { Mathf.Max(0, woodCargo), Mathf.Max(0, stoneCargo), Mathf.Max(0, metalCargo), Mathf.Max(0, goldCargo) },
|
||||
LifetimeRealHours = Three(lifetimeRealHours, d.LifetimeRealHours),
|
||||
Count = new[] { Counts(woodCount, d.Count[0]), Counts(stoneCount, d.Count[1]), Counts(metalCount, d.Count[2]), Counts(goldCount, d.Count[3]) }
|
||||
};
|
||||
}
|
||||
|
||||
static int Milli(float perHour) => Mathf.Max(0, Mathf.RoundToInt(perHour * 1000f));
|
||||
|
||||
// Число точек: ровно три значения, не меньше 0 (0 -- такого размера нет), иначе -- значения по умолчанию
|
||||
static int[] Counts(int[] values, int[] fallback)
|
||||
{
|
||||
if (values == null || values.Length != 3) return (int[])fallback.Clone();
|
||||
return new[] { Mathf.Max(0, values[0]), Mathf.Max(0, values[1]), Mathf.Max(0, values[2]) };
|
||||
}
|
||||
|
||||
// Ровно три значения (малая / средняя / крупная), иначе -- значения по умолчанию
|
||||
static int[] Three(int[] values, int[] fallback)
|
||||
{
|
||||
if (values == null || values.Length != 3) return (int[])fallback.Clone();
|
||||
return new[] { Mathf.Max(1, values[0]), Mathf.Max(1, values[1]), Mathf.Max(1, values[2]) };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 6ee3759b377140c0b759a4a386327510
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,8 @@
|
||||
fileFormatVersion: 2
|
||||
guid: efa023adb1ce408c971a4f76ced19585
|
||||
folderAsset: yes
|
||||
DefaultImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,38 @@
|
||||
using TMPro;
|
||||
using UnityEngine;
|
||||
|
||||
// 3D-здание игры: его тип в 3D-сцене и подпись над ним -- имя, уровень, идёт ли стройка (05.11, Валдемар).
|
||||
// Вешает City3DGameplay.Attach. Подпись обновляется дважды в секунду.
|
||||
public class City3DBuildingTag : MonoBehaviour
|
||||
{
|
||||
public string type3d;
|
||||
|
||||
TextMeshPro label;
|
||||
float next;
|
||||
|
||||
void Update()
|
||||
{
|
||||
if (Time.unscaledTime < next) return;
|
||||
next = Time.unscaledTime + 0.5f;
|
||||
if (label == null)
|
||||
{
|
||||
var t = transform.Find("Label");
|
||||
if (t != null) label = t.GetComponent<TextMeshPro>();
|
||||
if (label == null) return;
|
||||
}
|
||||
string name = type3d;
|
||||
int level = City3DGameplay.LevelOf(gameObject);
|
||||
var timer = BuildTimer.Of(gameObject);
|
||||
if (timer != null && BuildTimer.All.Contains(timer))
|
||||
{
|
||||
string left = GameData.FormatTime(timer.SecondsLeft);
|
||||
label.text = timer.IsConstruction ? $"{name}\n<size=70%>стройка {left}</size>" : $"{name} {level} ур.\n<size=70%>→ {timer.targetLevel} ур. {left}</size>";
|
||||
label.color = new Color(1f, 0.85f, 0.3f);
|
||||
}
|
||||
else
|
||||
{
|
||||
label.text = $"{name} {level} ур.";
|
||||
label.color = Color.white;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 0bcf6ea4f7ae10b4686b3fd647b0c02f
|
||||
@@ -0,0 +1,170 @@
|
||||
using UnityEngine;
|
||||
|
||||
// КАМЕРА 3D-ПОСЕЛЕНИЯ (2.5D): наклонный вид под фиксированным углом, без вращения.
|
||||
// Перемещение -- перетаскиванием (палец / мышь), приближение -- двумя пальцами или колесом.
|
||||
// Не уходит за пределы полезной карты. FocusOn -- плавно показать область (открытие новой зоны).
|
||||
public class City3DCamera : MonoBehaviour
|
||||
{
|
||||
public float pitch = 52f; // наклон, градусы от горизонта
|
||||
public float yaw = 45f; // поворот вида вокруг вертикали
|
||||
public float distance = 26f;
|
||||
public float minDistance = 8f;
|
||||
public float maxDistance = 55f;
|
||||
|
||||
public Vector3 target; // точка на земле, на которую смотрит камера
|
||||
public Rect bounds = new Rect(0, 0, 48, 48); // где может быть target (x, z)
|
||||
|
||||
Camera cam;
|
||||
Vector3 focusTarget;
|
||||
float focusDistance;
|
||||
bool focusing;
|
||||
Vector3 lastPointer;
|
||||
bool panning;
|
||||
|
||||
// Перемещение камеры пальцем можно выключить на время перетаскивания здания
|
||||
public bool AllowPan { get; set; } = true;
|
||||
|
||||
// Камера перспективная (решение 05.11): поселение объёмнее, есть глубина. Угол, высота и поле зрения -- ТЕСТ,
|
||||
// подбираются в Unity и на телефоне. Ортографическую можно вернуть галочкой (тогда зум -- размер кадра, см. Apply)
|
||||
public bool perspective = true;
|
||||
public float fieldOfView = 40f;
|
||||
|
||||
// Подбор камеры в панели DEV (окно «Камера»): наклон, поле зрения, ближе / дальше всего. Запоминается на этом
|
||||
// устройстве -- только в редакторе и отладочной сборке; в обычной сборке -- числа из сцены (05.11)
|
||||
public const string PrefPitch = "dev_cam3d_pitch", PrefFov = "dev_cam3d_fov", PrefMin = "dev_cam3d_min", PrefMax = "dev_cam3d_max";
|
||||
|
||||
// Числа по умолчанию (из сцены) -- для кнопки «Сбросить» в DEV
|
||||
public float DefaultPitch { get; private set; }
|
||||
public float DefaultFov { get; private set; }
|
||||
public float DefaultMin { get; private set; }
|
||||
public float DefaultMax { get; private set; }
|
||||
|
||||
public void SetFieldOfView(float fov)
|
||||
{
|
||||
fieldOfView = fov;
|
||||
if (cam != null && !cam.orthographic) cam.fieldOfView = fov;
|
||||
}
|
||||
|
||||
void LoadDevTuning()
|
||||
{
|
||||
DefaultPitch = pitch; DefaultFov = fieldOfView; DefaultMin = minDistance; DefaultMax = maxDistance;
|
||||
#if UNITY_EDITOR || DEVELOPMENT_BUILD
|
||||
pitch = PlayerPrefs.GetFloat(PrefPitch, pitch);
|
||||
fieldOfView = PlayerPrefs.GetFloat(PrefFov, fieldOfView);
|
||||
minDistance = PlayerPrefs.GetFloat(PrefMin, minDistance);
|
||||
maxDistance = Mathf.Max(minDistance + 1f, PlayerPrefs.GetFloat(PrefMax, maxDistance));
|
||||
distance = Mathf.Clamp(distance, minDistance, maxDistance);
|
||||
#endif
|
||||
}
|
||||
|
||||
void Awake()
|
||||
{
|
||||
cam = GetComponent<Camera>();
|
||||
LoadDevTuning();
|
||||
if (cam != null && perspective)
|
||||
{
|
||||
cam.orthographic = false;
|
||||
cam.fieldOfView = fieldOfView;
|
||||
}
|
||||
}
|
||||
|
||||
public void FocusOn(Rect area)
|
||||
{
|
||||
focusTarget = new Vector3(area.center.x, 0, area.center.y);
|
||||
// отойти так, чтобы область поместилась по высоте экрана (с запасом)
|
||||
float size = Mathf.Max(area.width, area.height);
|
||||
focusDistance = Mathf.Clamp(size * 1.15f, minDistance, maxDistance);
|
||||
focusing = true;
|
||||
}
|
||||
|
||||
// Начать / продолжить / закончить перемещение камеры. Зовёт контроллер ввода (он решает, что под пальцем)
|
||||
public void BeginPan(Vector3 screen) { panning = AllowPan; lastPointer = screen; focusing = false; }
|
||||
public void EndPan() { panning = false; }
|
||||
|
||||
// Палец ещё не сдвинулся настолько, чтобы считать это перетаскиванием: камера стоит, но точка запоминается --
|
||||
// когда перетаскивание начнётся, карта не прыгнет (как на карте мира, 05.11)
|
||||
public void Track(Vector3 screen) { lastPointer = screen; }
|
||||
|
||||
public void Pan(Vector3 screen)
|
||||
{
|
||||
if (!panning || cam == null) return;
|
||||
Vector3 a = GroundPoint(lastPointer), b = GroundPoint(screen);
|
||||
target += a - b;
|
||||
lastPointer = screen;
|
||||
Clamp();
|
||||
Apply(); // сразу: иначе следующий шаг считался бы от старого положения камеры -- камера дёргалась (05.11)
|
||||
}
|
||||
|
||||
public void Zoom(float delta)
|
||||
{
|
||||
focusing = false;
|
||||
distance = Mathf.Clamp(distance * (1f - delta), minDistance, maxDistance);
|
||||
}
|
||||
|
||||
void Update()
|
||||
{
|
||||
// Колесо мыши (редактор, ПК)
|
||||
float wheel = Input.mouseScrollDelta.y;
|
||||
if (City3DInput.OverHud(Input.mousePosition)) wheel = 0f; // колесо над окнами HUD -- не приближать камеру (05.11)
|
||||
if (Mathf.Abs(wheel) > 0.01f) Zoom(wheel * 0.1f);
|
||||
|
||||
// Два пальца -- приближение, как на карте мира: расстояние камеры меняется во столько раз,
|
||||
// во сколько изменилось расстояние между пальцами за кадр (не зависит от того, увидели ли касание) (05.11)
|
||||
if (Input.touchCount >= 2)
|
||||
{
|
||||
Touch a = Input.GetTouch(0), b = Input.GetTouch(1);
|
||||
float previous = ((a.position - a.deltaPosition) - (b.position - b.deltaPosition)).magnitude;
|
||||
float current = (a.position - b.position).magnitude;
|
||||
if (previous > 0.01f && current > 0.01f)
|
||||
{
|
||||
focusing = false;
|
||||
distance = Mathf.Clamp(distance * previous / current, minDistance, maxDistance);
|
||||
}
|
||||
}
|
||||
|
||||
if (focusing)
|
||||
{
|
||||
target = Vector3.Lerp(target, focusTarget, Time.unscaledDeltaTime * 2.5f);
|
||||
distance = Mathf.Lerp(distance, focusDistance, Time.unscaledDeltaTime * 2.5f);
|
||||
if (Vector3.Distance(target, focusTarget) < 0.05f && Mathf.Abs(distance - focusDistance) < 0.05f) focusing = false;
|
||||
}
|
||||
Apply();
|
||||
}
|
||||
|
||||
void Clamp()
|
||||
{
|
||||
target.x = Mathf.Clamp(target.x, bounds.xMin, bounds.xMax);
|
||||
target.z = Mathf.Clamp(target.z, bounds.yMin, bounds.yMax);
|
||||
target.y = 0;
|
||||
}
|
||||
|
||||
// Дымка начинается за городом, а не на фиксированном расстоянии от камеры: иначе при отдалении весь город
|
||||
// уходил в серую дымку -- казалось, что камера не отдаляется, а сцена сереет (05.11)
|
||||
public float fogStartBehind = 4f, fogEndBehind = 59f;
|
||||
|
||||
public void Apply()
|
||||
{
|
||||
Quaternion rot = Quaternion.Euler(pitch, yaw, 0);
|
||||
transform.rotation = rot;
|
||||
transform.position = target - rot * Vector3.forward * distance;
|
||||
// Камера сцены может быть ортографической (вид 2.5D без перспективы): у неё расстояние не меняет размер города
|
||||
// на экране -- «+ / −» и щипок ничего не делали (05.11). Тогда приближение -- через размер кадра: столько же
|
||||
// земли, сколько видно перспективной камерой с этого расстояния
|
||||
if (cam == null) cam = GetComponent<Camera>();
|
||||
if (cam != null && cam.orthographic)
|
||||
cam.orthographicSize = distance * Mathf.Tan(cam.fieldOfView * 0.5f * Mathf.Deg2Rad);
|
||||
if (RenderSettings.fog && RenderSettings.fogMode == FogMode.Linear)
|
||||
{
|
||||
RenderSettings.fogStartDistance = distance + fogStartBehind;
|
||||
RenderSettings.fogEndDistance = distance + fogEndBehind;
|
||||
}
|
||||
}
|
||||
|
||||
// Точка на плоскости земли (y = 0) под экранной точкой
|
||||
Vector3 GroundPoint(Vector3 screen)
|
||||
{
|
||||
Ray ray = cam.ScreenPointToRay(screen);
|
||||
var plane = new Plane(Vector3.up, Vector3.zero);
|
||||
return plane.Raycast(ray, out float t) ? ray.GetPoint(t) : target;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: e8645f0028574904969b5802443f100f
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 44d822c903694362bef912c055adda5b
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,234 @@
|
||||
using System.Collections.Generic;
|
||||
using UnityEngine;
|
||||
|
||||
// 3D-ПОСЕЛЕНИЕ: здания 3D-сцены становятся зданиями игры (05.11, Валдемар).
|
||||
// На 3D-здание вешаются те же компоненты, что на префабы 2D-города: рабочее место или Дом (ResourceCollector),
|
||||
// Склад / Амбар (StorageBuilding), Казармы, Госпиталь, Ратуша (TownHall) и PlacedBuildingId с именем здания.
|
||||
// Поэтому дальше всё работает так же, как в 2D: CitySync даёт места, жильё и лимиты; окно здания (BuildingWindow) --
|
||||
// рабочие, добыча, улучшение по таймеру; стройка идёт через BuildTimer (цена, строители, лимиты, ускорение, офлайн).
|
||||
// Место на карте проверяет SettlementMap Саши, цену, время и лимиты -- этот код и EconomyConfig.
|
||||
public static class City3DGameplay
|
||||
{
|
||||
// Имя типа 3D-сцены -> имя здания игры (как в 2D: по нему лимиты, окна, сохранение)
|
||||
public static string GameName(string type3d)
|
||||
{
|
||||
switch (type3d)
|
||||
{
|
||||
case "Дом": return "Население";
|
||||
case "Ферма": return "Еда";
|
||||
case "Лесопилка": return "Лес";
|
||||
case "Каменоломня": return "Камень";
|
||||
case "Шахта": return "Металл";
|
||||
case "Склад": return StorageBootstrap.WarehouseName;
|
||||
case "Амбар": return StorageBootstrap.GranaryName;
|
||||
case "Казармы": return BarracksBootstrap.BarracksName;
|
||||
case "Госпиталь": return HospitalBootstrap.HospitalName;
|
||||
case "Золотой рудник": return "Золото";
|
||||
case "Кристалл": return "Кристалл";
|
||||
case "Терем": return HeroCastleBootstrap.CastleName;
|
||||
default: return type3d; // "Ратуша"
|
||||
}
|
||||
}
|
||||
|
||||
// Обратно: имя здания игры (как в 2D-сохранении) -> тип 3D-сцены. null -- такого здания в 3D нет
|
||||
public static string Type3DFor(string gameName)
|
||||
{
|
||||
foreach (var t in AllTypes3D)
|
||||
if (GameName(t) == gameName) return t;
|
||||
return null;
|
||||
}
|
||||
|
||||
static readonly string[] AllTypes3D =
|
||||
{
|
||||
"Ратуша", "Дом", "Ферма", "Лесопилка", "Каменоломня", "Шахта", "Склад", "Амбар", "Казармы", "Госпиталь",
|
||||
"Золотой рудник", "Кристалл", "Терем",
|
||||
};
|
||||
|
||||
public static bool IsTownHall(string type3d) => type3d == "Ратуша";
|
||||
|
||||
// Цена постройки -- та же, что у зданий 2D-города (Data_*.asset, Склады -- EconomyConfig, Госпиталь -- HospitalConfig)
|
||||
public static ResourceAmount[] BuildCost(string type3d)
|
||||
{
|
||||
var e = EconomyConfig.Load().storage;
|
||||
switch (type3d)
|
||||
{
|
||||
case "Дом": return Cost(ResourceType.Wood, 100, ResourceType.Food, 200, ResourceType.Stone, 150);
|
||||
case "Ферма": return Cost(ResourceType.Wood, 100);
|
||||
case "Лесопилка": return Cost();
|
||||
case "Каменоломня": return Cost(ResourceType.Wood, 50);
|
||||
case "Шахта": return Cost(ResourceType.Wood, 600, ResourceType.Stone, 550);
|
||||
case "Склад": return Cost(ResourceType.Wood, e.warehouseWood, ResourceType.Stone, e.warehouseStone);
|
||||
case "Амбар": return Cost(ResourceType.Wood, e.granaryWood, ResourceType.Stone, e.granaryStone);
|
||||
case "Казармы": return Cost(ResourceType.Wood, 300, ResourceType.Stone, 200);
|
||||
case "Госпиталь":
|
||||
var h = HospitalConfig.Load();
|
||||
return Cost(ResourceType.Wood, h.buildWood, ResourceType.Stone, h.buildStone);
|
||||
// Перенесены из 2D-города (решение 05.11): цены -- из Data_GoldMine, Data_Kristall и HeroCastleBootstrap
|
||||
case "Золотой рудник": return Cost(ResourceType.Stone, 50);
|
||||
case "Кристалл": return Cost(ResourceType.Wood, 2000, ResourceType.Stone, 3200, ResourceType.Gold, 12000, ResourceType.Iron, 1600);
|
||||
case "Терем": return Cost(ResourceType.Wood, HeroCastleBootstrap.CostWood, ResourceType.Stone, HeroCastleBootstrap.CostStone);
|
||||
default: return Cost();
|
||||
}
|
||||
}
|
||||
|
||||
// С какого уровня Ратуши можно строить (как в 2D)
|
||||
public static int RequiredTownHall(string type3d)
|
||||
{
|
||||
switch (type3d)
|
||||
{
|
||||
case "Шахта": return 3;
|
||||
case "Золотой рудник": return 2;
|
||||
case "Кристалл": return 6;
|
||||
case "Госпиталь": return Mathf.Max(1, HospitalConfig.Load().requiredTownHallLevel);
|
||||
default: return 1;
|
||||
}
|
||||
}
|
||||
|
||||
static ResourceAmount[] Cost(params object[] pairs)
|
||||
{
|
||||
var list = new List<ResourceAmount>();
|
||||
for (int i = 0; i + 1 < pairs.Length; i += 2)
|
||||
list.Add(new ResourceAmount((ResourceType)pairs[i], System.Convert.ToSingle(pairs[i + 1])));
|
||||
return list.ToArray();
|
||||
}
|
||||
|
||||
public static int TownHallLevel => GameServices.TryGet(out ISettlement s) ? s.TownHallLevel : 1;
|
||||
|
||||
// Можно ли строить: уровень Ратуши, лимит зданий, строители, ресурсы. null -- можно, иначе текст для игрока
|
||||
public static string CanBuild(string type3d)
|
||||
{
|
||||
int th = TownHallLevel;
|
||||
if (th < RequiredTownHall(type3d)) return $"Откроется на Ратуше {RequiredTownHall(type3d)} ур.";
|
||||
string name = GameName(type3d);
|
||||
int max = EconomyConfig.Load().BuildingLimitFor(name, th), have = CountBuilt(name);
|
||||
if (max >= 0 && have >= max) return $"Лимит: {have} / {max} на Ратуше {th} ур.";
|
||||
if (BuildTimer.DurationMs(false, 1) > 0 && BuildTimer.FreeBuilders == 0) return "Все строители заняты";
|
||||
var cost = BuildCost(type3d);
|
||||
if (cost.Length > 0 && (!GameServices.TryGet(out IEconomy economy) || !economy.CanAfford(cost))) return "Не хватает ресурсов: " + CostText(cost);
|
||||
return null;
|
||||
}
|
||||
|
||||
public static string CostText(ResourceAmount[] cost)
|
||||
{
|
||||
if (cost.Length == 0) return "бесплатно";
|
||||
var parts = new List<string>();
|
||||
foreach (var c in cost) parts.Add($"{c.Amount:0} {GameData.ResName(c.Type).ToLower()}");
|
||||
return string.Join(", ", parts.ToArray());
|
||||
}
|
||||
|
||||
public static int CountBuilt(string gameName)
|
||||
{
|
||||
int n = 0;
|
||||
foreach (var id in Object.FindObjectsByType<PlacedBuildingId>(FindObjectsSortMode.None))
|
||||
if (id.buildingName == gameName) n++;
|
||||
return n;
|
||||
}
|
||||
|
||||
// Новое здание поставлено на карту: списать цену и начать стройку по времени (BuildTimer)
|
||||
public static void StartConstruction(GameObject view)
|
||||
{
|
||||
var tag = view.GetComponent<City3DBuildingTag>();
|
||||
if (tag == null) return;
|
||||
var cost = BuildCost(tag.type3d);
|
||||
if (cost.Length > 0 && GameServices.TryGet(out IEconomy economy)) economy.TrySpend(cost);
|
||||
long ms = BuildTimer.DurationMs(false, 1);
|
||||
if (ms > 0)
|
||||
{
|
||||
long now = GameClock.UnixNowMs;
|
||||
BuildTimer.Begin(view, BuildTimer.Kind.Build, 1, now, now + ms);
|
||||
}
|
||||
if (CitySync.Instance != null) CitySync.Instance.SyncNow();
|
||||
}
|
||||
|
||||
// Повесить на 3D-здание компоненты здания игры (как у префабов 2D). Без цены и таймера
|
||||
public static void Attach(GameObject view, string type3d, int level)
|
||||
{
|
||||
var tag = view.GetComponent<City3DBuildingTag>() ?? view.AddComponent<City3DBuildingTag>();
|
||||
tag.type3d = type3d;
|
||||
|
||||
if (IsTownHall(type3d))
|
||||
{
|
||||
var th = view.GetComponent<TownHall>() ?? view.AddComponent<TownHall>();
|
||||
// Та же цена улучшения, что у Ратуши 2D-города (сцена MVP1): 250 дерева, 160 камня, 25 людей, ×1.5 за уровень
|
||||
th.baseUpgradeCost = new[]
|
||||
{
|
||||
new ResourceCost { type = ResourceType.Wood, amount = 250 },
|
||||
new ResourceCost { type = ResourceType.Stone, amount = 160 },
|
||||
new ResourceCost { type = ResourceType.Population, amount = 25 },
|
||||
};
|
||||
th.costMultiplierPerLevel = 1.5f;
|
||||
th.maxLevel = 10;
|
||||
th.currentLevel = Mathf.Max(1, level);
|
||||
return;
|
||||
}
|
||||
|
||||
view.AddComponent<PlacedBuildingId>().buildingName = GameName(type3d);
|
||||
switch (type3d)
|
||||
{
|
||||
case "Дом": Collector(view, ResourceType.Population, level); break;
|
||||
case "Ферма": Collector(view, ResourceType.Food, level); break;
|
||||
case "Лесопилка": Collector(view, ResourceType.Wood, level); break;
|
||||
case "Каменоломня": Collector(view, ResourceType.Stone, level); break;
|
||||
case "Шахта": Collector(view, ResourceType.Iron, level); break;
|
||||
case "Склад": Storage(view, StorageBuilding.Kind.Warehouse, level); break;
|
||||
case "Амбар": Storage(view, StorageBuilding.Kind.Granary, level); break;
|
||||
case "Казармы": view.AddComponent<Barracks>().Level = Mathf.Max(1, level); break;
|
||||
case "Госпиталь": view.AddComponent<Hospital>().Level = Mathf.Max(1, level); break;
|
||||
case "Золотой рудник": Collector(view, ResourceType.Gold, level); break;
|
||||
case "Кристалл": Collector(view, ResourceType.Gems, level); break; // рабочих нет, как в 2D
|
||||
case "Терем": view.AddComponent<HeroCastle>(); break; // окно героя -- BuildingWindow
|
||||
}
|
||||
}
|
||||
|
||||
static void Collector(GameObject view, ResourceType type, int level)
|
||||
{
|
||||
var rc = view.AddComponent<ResourceCollector>();
|
||||
rc.resourceType = type;
|
||||
rc.Level = Mathf.Max(1, level);
|
||||
}
|
||||
|
||||
static void Storage(GameObject view, StorageBuilding.Kind kind, int level)
|
||||
{
|
||||
var sb = view.AddComponent<StorageBuilding>();
|
||||
sb.kind = kind;
|
||||
sb.Level = Mathf.Max(1, level);
|
||||
}
|
||||
|
||||
// Уровень здания (для сохранения и подписи)
|
||||
public static int LevelOf(GameObject view)
|
||||
{
|
||||
var th = view.GetComponent<TownHall>();
|
||||
if (th != null) return th.currentLevel;
|
||||
var leveled = view.GetComponent<ILeveledBuilding>();
|
||||
return leveled != null ? leveled.Level : 1;
|
||||
}
|
||||
|
||||
// HUD 2D-города нарисован на холсте "в камере" (Screen Space - Camera). В 3D-сцене холст стоял бы на расстоянии
|
||||
// от камеры, и холмы и деревья перекрывали бы кнопки. Ставим холст вплотную к камере -- он всегда поверх 3D.
|
||||
public static void PutHudInFront(Camera cam)
|
||||
{
|
||||
if (cam == null) return;
|
||||
foreach (var canvas in Object.FindObjectsByType<Canvas>(FindObjectsSortMode.None))
|
||||
{
|
||||
if (!canvas.isRootCanvas || canvas.renderMode != RenderMode.ScreenSpaceCamera) continue;
|
||||
canvas.worldCamera = cam;
|
||||
canvas.planeDistance = cam.nearClipPlane + 0.05f;
|
||||
HideWorldSprites(canvas);
|
||||
}
|
||||
}
|
||||
|
||||
// В HUD есть картинка-спрайт мира (SpriteRenderer) -- плашка с мечом по центру нижней панели. На холсте у
|
||||
// камеры она рисуется поверх всего HUD, даже поверх открытых окон, а сама картинка без прозрачности
|
||||
// ("шахматка"). Решение 05.11 (Миша, Володя): в 3D-сцене её убираем, пока не будет новой картинки или кнопки.
|
||||
static void HideWorldSprites(Canvas canvas)
|
||||
{
|
||||
foreach (var sr in canvas.GetComponentsInChildren<SpriteRenderer>(true))
|
||||
sr.enabled = false;
|
||||
}
|
||||
|
||||
// Награда за расчистку (решение 04.11): +10 дерева за дерево или +10 камня за валун, один раз, в пределах склада
|
||||
public static void RewardClearing(bool tree)
|
||||
{
|
||||
if (GameServices.TryGet(out IEconomy economy)) economy.Add(tree ? ResourceType.Wood : ResourceType.Stone, 10);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 3b010df83fccefa4aa9c394f84b8214f
|
||||
@@ -0,0 +1,66 @@
|
||||
using System.Collections.Generic;
|
||||
using UnityEngine;
|
||||
using UnityEngine.EventSystems;
|
||||
using UnityEngine.UI;
|
||||
|
||||
// Палец над HUD в 3D-поселении (05.11): «над интерфейсом» -- только если под пальцем то, что нажимают или крутят
|
||||
// (кнопка, поле, ползунок, прокручиваемое окно) или небольшая панель. Невидимый слой во весь экран
|
||||
// (затемнение, контейнер безопасной зоны, фон) карту не блокирует -- иначе не работают ни тап, ни зум, ни стройка.
|
||||
// Такой слой пишется в Console один раз, чтобы его можно было найти и убрать.
|
||||
public static class City3DInput
|
||||
{
|
||||
static readonly List<RaycastResult> hits = new List<RaycastResult>();
|
||||
static readonly HashSet<string> reported = new HashSet<string>();
|
||||
|
||||
public static bool OverHud(Vector2 screen)
|
||||
{
|
||||
var es = EventSystem.current;
|
||||
if (es == null) return false;
|
||||
// Открыто окно HUD (панель) -- весь ввод его, карта не трогается
|
||||
if (UIPanelManager.Instance != null && UIPanelManager.Instance.CurrentId != "")
|
||||
{
|
||||
if (reported.Add("panel:" + UIPanelManager.Instance.CurrentId))
|
||||
Debug.Log($"[3D] Открыта панель HUD «{UIPanelManager.Instance.CurrentId}» -- 3D-карта не принимает нажатия, пока её не закрыть");
|
||||
return true;
|
||||
}
|
||||
hits.Clear();
|
||||
es.RaycastAll(new PointerEventData(es) { position = screen }, hits);
|
||||
foreach (var h in hits)
|
||||
{
|
||||
if (h.gameObject == null) continue;
|
||||
if (IsInteractive(h.gameObject) || !CoversScreen(h.gameObject)) return true;
|
||||
if (reported.Add(h.gameObject.name))
|
||||
Debug.Log($"[3D] Слой интерфейса во весь экран ловит нажатия, 3D-карта его пропускает: «{Path(h.gameObject.transform)}». " +
|
||||
"Если он не нужен -- выключить у него Raycast Target");
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
static bool IsInteractive(GameObject go)
|
||||
{
|
||||
// кнопки, прокрутка, поля, а также всё, что само ловит нажатие (затемнение модального окна закрывает его тапом)
|
||||
return go.GetComponentInParent<Selectable>() != null || go.GetComponentInParent<ScrollRect>() != null
|
||||
|| go.GetComponentInParent<IPointerClickHandler>() != null || go.GetComponentInParent<IPointerDownHandler>() != null
|
||||
|| go.name == "Modal" || go.GetComponentInParent<TMPro.TMP_InputField>() != null;
|
||||
}
|
||||
|
||||
// Занимает почти весь экран (больше 80% по ширине и высоте)
|
||||
static bool CoversScreen(GameObject go)
|
||||
{
|
||||
if (!(go.transform is RectTransform rt)) return false;
|
||||
var corners = new Vector3[4];
|
||||
rt.GetWorldCorners(corners);
|
||||
var canvas = go.GetComponentInParent<Canvas>();
|
||||
Camera cam = canvas != null && canvas.renderMode != RenderMode.ScreenSpaceOverlay ? canvas.worldCamera : null;
|
||||
Vector2 a = RectTransformUtility.WorldToScreenPoint(cam, corners[0]);
|
||||
Vector2 b = RectTransformUtility.WorldToScreenPoint(cam, corners[2]);
|
||||
return Mathf.Abs(b.x - a.x) > Screen.width * 0.8f && Mathf.Abs(b.y - a.y) > Screen.height * 0.8f;
|
||||
}
|
||||
|
||||
static string Path(Transform t)
|
||||
{
|
||||
string p = t.name;
|
||||
for (var x = t.parent; x != null; x = x.parent) p = x.name + "/" + p;
|
||||
return p;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: c71f24a7cb164064b98f198eec9665d3
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,97 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using Bogatyri.Settlement.Placement;
|
||||
using UnityEngine;
|
||||
|
||||
// ТЕСТОВЫЙ УЧАСТОК 3D-ПОСЕЛЕНИЯ (ТЗ «Система строительства поселения» от 04.11.2026, этап I, прототип).
|
||||
// Холмы, река, скалы, деревья и валуны строятся из seed -- одинаково при каждом запуске.
|
||||
// Зоны стройки открываются уровнем Ратуши квадратами от центра (ТЕСТ, число и пороги зон -- на согласование).
|
||||
// Все числа здесь -- предложение для прототипа, не утверждённый баланс.
|
||||
public sealed class City3DLayout
|
||||
{
|
||||
public const int Size = 48; // клеток по каждой стороне
|
||||
public const int Seed = 4112026;
|
||||
|
||||
public struct Obstacle
|
||||
{
|
||||
public string Id;
|
||||
public bool IsTree; // дерево (иначе валун)
|
||||
public List<(int x, int y)> Cells;
|
||||
}
|
||||
|
||||
public SettlementMap Map;
|
||||
public readonly List<Obstacle> Obstacles = new List<Obstacle>();
|
||||
public readonly List<Vector2> Grass = new List<Vector2>(); // мелкий декор: скрывается под зданиями
|
||||
|
||||
// Сколько клеток от центра открывает уровень Ратуши: 1 -- 16 x 16, 2 -- 24 x 24, ... 5 -- весь участок
|
||||
public static int ZoneHalfSize(int townHallLevel) => Mathf.Min(Size / 2, 8 + 4 * (Mathf.Max(1, townHallLevel) - 1));
|
||||
|
||||
// Высота земли в точке (мировые единицы, клетка = 1)
|
||||
public static float HeightAt(float x, float z)
|
||||
{
|
||||
float cx = x - Size / 2f, cz = z - Size / 2f;
|
||||
float fromCenter = Mathf.Sqrt(cx * cx + cz * cz) / (Size / 2f);
|
||||
// Холмы: в центре почти ровно, к краям -- выше и круче
|
||||
float hills = (Mathf.Sin(x * 0.27f) * 0.5f + Mathf.Cos(z * 0.21f) * 0.45f + Mathf.PerlinNoise(x * 0.11f, z * 0.11f) * 0.9f)
|
||||
* Mathf.Lerp(0.25f, 1.6f, Mathf.Clamp01(fromCenter));
|
||||
if (IsMountain(x, z)) hills += 2.5f + Mathf.PerlinNoise(x * 0.3f, z * 0.3f) * 2f;
|
||||
if (IsRiver(x, z)) hills = -0.5f;
|
||||
return hills;
|
||||
}
|
||||
|
||||
static float RiverX(float z) => 37f + 3f * Mathf.Sin(z * 0.17f);
|
||||
static bool IsRiver(float x, float z) => Mathf.Abs(x - RiverX(z)) < 1.3f;
|
||||
static bool IsMountain(float x, float z) => x < 9f && z > 34f || x < 4f && z > 24f;
|
||||
|
||||
public static City3DLayout Create(PlacementRules rules = null)
|
||||
{
|
||||
var layout = new City3DLayout { Map = new SettlementMap(Size, Size, rules) };
|
||||
var map = layout.Map;
|
||||
int c = Size / 2;
|
||||
|
||||
for (int y = 0; y < Size; y++)
|
||||
for (int x = 0; x < Size; x++)
|
||||
{
|
||||
float px = x + 0.5f, pz = y + 0.5f;
|
||||
CellKind kind = IsRiver(px, pz) ? CellKind.Water : IsMountain(px, pz) ? CellKind.Mountain : CellKind.Ground;
|
||||
float dist = Mathf.Max(Mathf.Abs(px - c), Mathf.Abs(pz - c)); // квадратное кольцо от центра
|
||||
int zone = 0;
|
||||
for (int lvl = 1; lvl <= 5; lvl++) if (dist < ZoneHalfSize(lvl)) { zone = lvl; break; }
|
||||
map.SetCell(x, y, kind, Mathf.RoundToInt(HeightAt(px, pz) * 1000f), zone);
|
||||
}
|
||||
|
||||
// Деревья и валуны: не в центральной поляне 10 x 10 (там Ратуша), не на воде и скалах
|
||||
var rng = new System.Random(Seed);
|
||||
var taken = new HashSet<int>();
|
||||
bool Free(int x, int y) => map.InMap(x, y) && map.KindAt(x, y) == CellKind.Ground && !taken.Contains(y * Size + x)
|
||||
&& (Math.Abs(x - c) > 5 || Math.Abs(y - c) > 5);
|
||||
int trees = 0, rocks = 0;
|
||||
for (int attempt = 0; attempt < 4000 && trees < 90; attempt++)
|
||||
{
|
||||
int x = rng.Next(Size), y = rng.Next(Size);
|
||||
if (!Free(x, y)) continue;
|
||||
taken.Add(y * Size + x);
|
||||
layout.Obstacles.Add(new Obstacle { Id = "tree_" + (++trees), IsTree = true, Cells = new List<(int, int)> { (x, y) } });
|
||||
}
|
||||
for (int attempt = 0; attempt < 4000 && rocks < 22; attempt++)
|
||||
{
|
||||
int x = rng.Next(Size - 1), y = rng.Next(Size - 1);
|
||||
bool big = rng.Next(3) == 0; // каждый третий валун -- 2 x 2
|
||||
var cells = big ? new List<(int, int)> { (x, y), (x + 1, y), (x, y + 1), (x + 1, y + 1) } : new List<(int, int)> { (x, y) };
|
||||
bool ok = true;
|
||||
foreach (var cell in cells) if (!Free(cell.Item1, cell.Item2)) { ok = false; break; }
|
||||
if (!ok) continue;
|
||||
foreach (var cell in cells) taken.Add(cell.Item2 * Size + cell.Item1);
|
||||
layout.Obstacles.Add(new Obstacle { Id = "rock_" + (++rocks), IsTree = false, Cells = cells });
|
||||
}
|
||||
foreach (var o in layout.Obstacles) map.AddObstacle(o.Id, o.Cells);
|
||||
|
||||
// Трава -- только украшение: не мешает стройке, прячется под зданием и возвращается, когда здание ушло
|
||||
for (int i = 0; i < 260; i++)
|
||||
{
|
||||
float x = (float)rng.NextDouble() * Size, z = (float)rng.NextDouble() * Size;
|
||||
if (map.KindAt((int)x, (int)z) == CellKind.Ground) layout.Grass.Add(new Vector2(x, z));
|
||||
}
|
||||
return layout;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 31e2f86e3dff430ea8a67cc6f1cb4038
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,89 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using UnityEngine;
|
||||
|
||||
// Сохранение 3D-поселения: секция "settlement3d" (05.11, Валдемар). 2D-город сохраняется отдельно ("settlement"),
|
||||
// поэтому 3D-сцена его не трогает. Здания: id, тип, клетка, поворот, уровень, стройка или улучшение, которое идёт;
|
||||
// Ратуша: уровень и её улучшение; расчищенные деревья и валуны.
|
||||
// Регистрирует App/GameModules. Пока сцена City3D открыта, данные берутся у неё (Live), иначе -- последние известные.
|
||||
public sealed class City3DSave : ISaveParticipant
|
||||
{
|
||||
[Serializable]
|
||||
public class Entry
|
||||
{
|
||||
public string id;
|
||||
public string type; // тип 3D-сцены: "Ратуша", "Дом", "Ферма"...
|
||||
public int x, y, rot;
|
||||
public int level = 1;
|
||||
public int timerKind; // 0 -- нет, 1 -- постройка, 2 -- улучшение (BuildTimer.Kind)
|
||||
public long timerStartMs, timerEndMs;
|
||||
public int timerTargetLevel;
|
||||
}
|
||||
|
||||
[Serializable]
|
||||
public class Data
|
||||
{
|
||||
public int nextId = 1;
|
||||
public List<Entry> buildings = new List<Entry>();
|
||||
public List<string> cleared = new List<string>();
|
||||
// Здания 2D-города, которым при переносе не нашлось места (зона закрыта, всё занято): ждут и ставятся,
|
||||
// как только место появится. Не теряются (05.11)
|
||||
public List<Entry> pendingFrom2D = new List<Entry>();
|
||||
}
|
||||
|
||||
public interface ILive
|
||||
{
|
||||
Data CaptureState();
|
||||
void OnSaveLoaded(); // загрузили другое сохранение или сбросили -- сцена должна перестроиться
|
||||
}
|
||||
|
||||
public static readonly City3DSave Instance = new City3DSave();
|
||||
public static Data Cached { get; private set; }
|
||||
public static ILive Live;
|
||||
|
||||
public string SaveKey => "settlement3d";
|
||||
|
||||
public string Capture()
|
||||
{
|
||||
if (Live != null) Cached = Live.CaptureState();
|
||||
return Cached != null ? JsonUtility.ToJson(Cached) : null;
|
||||
}
|
||||
|
||||
public void Restore(string json)
|
||||
{
|
||||
Cached = string.IsNullOrEmpty(json) ? null : JsonUtility.FromJson<Data>(json);
|
||||
if (Cached != null)
|
||||
{
|
||||
if (Cached.buildings == null) Cached.buildings = new List<Entry>();
|
||||
if (Cached.cleared == null) Cached.cleared = new List<string>();
|
||||
if (Cached.pendingFrom2D == null) Cached.pendingFrom2D = new List<Entry>();
|
||||
}
|
||||
Live?.OnSaveLoaded();
|
||||
}
|
||||
|
||||
public void ResetState()
|
||||
{
|
||||
Cached = null;
|
||||
Live?.OnSaveLoaded();
|
||||
}
|
||||
|
||||
// Запись таймера здания в Entry и обратно
|
||||
public static void WriteTimer(Entry e, GameObject view)
|
||||
{
|
||||
var t = BuildTimer.Of(view);
|
||||
bool on = t != null && BuildTimer.All.Contains(t);
|
||||
e.timerKind = on ? (int)t.kind : 0;
|
||||
e.timerStartMs = on ? t.startMs : 0;
|
||||
e.timerEndMs = on ? t.endMs : 0;
|
||||
e.timerTargetLevel = on ? t.targetLevel : 0;
|
||||
}
|
||||
|
||||
// Таймер из файла -- только осмысленный: постройка у 1 уровня, улучшение до уровня выше текущего
|
||||
public static void ReadTimer(Entry e, GameObject view)
|
||||
{
|
||||
bool build = e.timerKind == (int)BuildTimer.Kind.Build && e.level <= 1;
|
||||
bool upgrade = e.timerKind == (int)BuildTimer.Kind.Upgrade && e.timerTargetLevel > Mathf.Max(1, e.level);
|
||||
if ((build || upgrade) && e.timerEndMs > 0)
|
||||
BuildTimer.Begin(view, (BuildTimer.Kind)e.timerKind, Mathf.Max(1, e.timerTargetLevel), e.timerStartMs, e.timerEndMs);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 037c1ed1fee52f74da1eaaeb4c20e3b1
|
||||
@@ -66,16 +66,16 @@ public class Barracks : MonoBehaviour, ILeveledBuilding
|
||||
void Refresh()
|
||||
{
|
||||
if (label == null) return;
|
||||
var d = GameProgress.Instance.Data;
|
||||
if (d.training.Count == 0)
|
||||
var training = GameProgress.Instance.Training;
|
||||
if (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 + " " +
|
||||
var o = training[0];
|
||||
label.text = "Казармы\n<size=70%>" + GameData.Troops[o.Type].name + " ×" + o.Count + " " +
|
||||
GameData.FormatTime(GameProgress.Instance.TrainingSecondsLeft(o)) +
|
||||
(d.training.Count > 1 ? " (+" + (d.training.Count - 1) + ")" : "") + "</size>";
|
||||
(training.Count > 1 ? " (+" + (training.Count - 1) + ")" : "") + "</size>";
|
||||
}
|
||||
|
||||
Vector3 downPos;
|
||||
|
||||
@@ -13,6 +13,10 @@ using TMPro;
|
||||
// 4. Внизу панель: "Поставить" (если место свободно и хватает ресурсов), "Другое здание", "Отмена".
|
||||
// 5. После постройки режим остаётся включённым -- можно строить дальше. "Выйти" -- сетка прячется.
|
||||
//
|
||||
// 6. Лимит зданий по уровню Ратуши (EconomyConfig, ТЕСТ): в списке видно "2 / 3", сверх лимита строить нельзя.
|
||||
// 7. Стройка идёт по времени и занимает строителя (BuildTimer): все заняты -- поставить нельзя.
|
||||
// 8. Перенос (кнопка "Перенести" в окне здания): тот же призрак, но здание переезжает бесплатно.
|
||||
//
|
||||
// Вешается билдером (Tools -> Build HUD Panels) на HUD_Canvas, кнопка Nav_Build вызывает ToggleMode().
|
||||
[DefaultExecutionOrder(-50)] // раньше камеры: захват пальца призраком должен быть виден ей в том же кадре
|
||||
public class BuildModeController : MonoBehaviour
|
||||
@@ -26,6 +30,13 @@ public class BuildModeController : MonoBehaviour
|
||||
|
||||
public bool IsActive { get; private set; }
|
||||
|
||||
// 3D-поселение (сцена City3D): своей 2D-стройки там нет -- кнопка "СТРОЙКА" общего HUD
|
||||
// открывает стройку 3D-сцены. Задаёт City3DController, пока сцена открыта (05.11).
|
||||
public static System.Action ExternalToggle;
|
||||
// То же для кнопки "Перенести" в окне здания: в 3D-сцене здание переносит сама сцена
|
||||
public static System.Action<GameObject> ExternalMove;
|
||||
static bool Has2DCity => BuildManager.Instance != null;
|
||||
|
||||
GameObject banner;
|
||||
GameObject chooser;
|
||||
GameObject placePanel;
|
||||
@@ -33,6 +44,7 @@ public class BuildModeController : MonoBehaviour
|
||||
Button placeButton;
|
||||
|
||||
BuildGhost ghost;
|
||||
GameObject moving; // здание, которое переносим (null -- строим новое)
|
||||
bool dragging;
|
||||
Vector2 grabOffset;
|
||||
Vector2 downPos;
|
||||
@@ -56,15 +68,20 @@ public class BuildModeController : MonoBehaviour
|
||||
SetMode(false);
|
||||
}
|
||||
|
||||
public void ToggleMode() { SetMode(!IsActive); }
|
||||
public void ToggleMode()
|
||||
{
|
||||
if (!Has2DCity && ExternalToggle != null) { ExternalToggle(); return; } // 3D-сцена: своя стройка
|
||||
SetMode(!IsActive);
|
||||
}
|
||||
|
||||
public void SetMode(bool active)
|
||||
{
|
||||
if (!Has2DCity) active = false; // в 3D-сцене 2D-стройки нет (и сетку 2D не создаём)
|
||||
IsActive = active;
|
||||
CloseChooser();
|
||||
CancelGhost();
|
||||
UpdateButtonLabel();
|
||||
BuildGrid.Instance.ShowOverlay(active);
|
||||
if (Has2DCity) BuildGrid.Instance.ShowOverlay(active);
|
||||
|
||||
if (banner != null) { Destroy(banner); banner = null; }
|
||||
if (active)
|
||||
@@ -145,6 +162,33 @@ public class BuildModeController : MonoBehaviour
|
||||
|
||||
// ---------------- призрак ----------------
|
||||
|
||||
// Перенос построенного здания: призрак на его месте, "Сюда" -- переезжает бесплатно
|
||||
public void StartMove(GameObject building)
|
||||
{
|
||||
if (!Has2DCity && ExternalMove != null) { ExternalMove(building); return; } // 3D-сцена
|
||||
var id = building != null ? building.GetComponent<PlacedBuildingId>() : null;
|
||||
var data = id != null && BuildManager.Instance != null ? BuildManager.Instance.FindData(id.buildingName) : null;
|
||||
if (data == null) { UIKit.Toast("Это здание перенести нельзя"); return; }
|
||||
|
||||
if (!IsActive)
|
||||
{
|
||||
IsActive = true;
|
||||
UpdateButtonLabel();
|
||||
BuildGrid.Instance.ShowOverlay(true);
|
||||
if (UIPanelManager.Instance != null) UIPanelManager.Instance.CloseAll();
|
||||
if (banner == null) banner = CreateBanner();
|
||||
}
|
||||
CloseChooser();
|
||||
CancelGhost();
|
||||
|
||||
moving = building;
|
||||
BuildGrid.IgnoredBuilding = building; // его клетки считаются свободными, пока выбираем новое место
|
||||
BuildGrid.Instance.Refresh();
|
||||
ghost = BuildGhost.Create(data);
|
||||
ghost.MoveNear((Vector2)building.transform.position + ghost.Footprint.anchorOffset);
|
||||
CreatePlacePanel();
|
||||
}
|
||||
|
||||
void StartGhost(BuildingData data)
|
||||
{
|
||||
CancelGhost();
|
||||
@@ -166,6 +210,12 @@ public class BuildModeController : MonoBehaviour
|
||||
void CancelGhost()
|
||||
{
|
||||
StopDrag();
|
||||
if (moving != null || BuildGrid.IgnoredBuilding != null)
|
||||
{
|
||||
moving = null;
|
||||
BuildGrid.IgnoredBuilding = null; // здание остаётся на старом месте
|
||||
if (BuildGrid.Instance != null) BuildGrid.Instance.Refresh();
|
||||
}
|
||||
if (ghost != null) Destroy(ghost.gameObject);
|
||||
ghost = null;
|
||||
if (placePanel != null) Destroy(placePanel);
|
||||
@@ -178,11 +228,28 @@ public class BuildModeController : MonoBehaviour
|
||||
var d = ghost.Data;
|
||||
ghost.Recheck();
|
||||
if (!ghost.CellsFree) { UIKit.Toast("Здесь строить нельзя: клетки заняты"); return; }
|
||||
|
||||
if (moving != null)
|
||||
{
|
||||
var building = moving;
|
||||
var id = building != null ? building.GetComponent<PlacedBuildingId>() : null;
|
||||
if (id == null || string.IsNullOrEmpty(id.buildingName)) { CancelGhost(); return; } // здания уже нет
|
||||
// Перенос бесплатный: меняется только место. Уровень, стройка и улучшение остаются у того же здания,
|
||||
// ресурсы не тратятся и не возвращаются (решения 04.11)
|
||||
building.transform.position = ghost.PrefabPosition;
|
||||
CancelGhost(); // снимает "игнор" клеток -- здание уже на новом месте
|
||||
UIKit.Toast("Перенесено: " + d.buildingName);
|
||||
return;
|
||||
}
|
||||
|
||||
string limit = LimitProblem(d);
|
||||
if (limit != null) { UIKit.Toast(limit); return; }
|
||||
if (BuildTimer.DurationMs(false, 1) > 0 && BuildTimer.FreeBuilders == 0) { UIKit.Toast("Все строители заняты"); return; }
|
||||
if (!d.CanAfford()) { UIKit.Toast(GameProgress.Instance.MissingText(ToRewards(d.cost))); return; }
|
||||
|
||||
if (BuildManager.Instance.BuildAt(d, ghost.Cell) != null)
|
||||
{
|
||||
UIKit.Toast("Построено: " + d.buildingName);
|
||||
UIKit.Toast(BuildTimer.DurationMs(false, 1) > 0 ? "Стройка началась: " + d.buildingName : "Построено: " + d.buildingName);
|
||||
CancelGhost(); // режим остаётся: "Здания" в подсказке сверху -- строить дальше
|
||||
}
|
||||
else UIKit.Toast("Не получилось построить");
|
||||
@@ -202,8 +269,8 @@ public class BuildModeController : MonoBehaviour
|
||||
|
||||
placeInfo = UIKit.Text(bar, "", 26, Color.white, TextAlignmentOptions.Left);
|
||||
UIKit.Size(placeInfo, -1, -1, 1);
|
||||
placeButton = UIKit.Button(bar, "Поставить", UIKit.Theme.Green, Place, 230, 90, 28);
|
||||
UIKit.Button(bar, "Другое", UIKit.Theme.Blue, OpenChooser, 180, 90, 26);
|
||||
placeButton = UIKit.Button(bar, moving != null ? "Сюда" : "Поставить", UIKit.Theme.Green, Place, 230, 90, 28);
|
||||
if (moving == null) UIKit.Button(bar, "Другое", UIKit.Theme.Blue, OpenChooser, 180, 90, 26);
|
||||
UIKit.Button(bar, "Отмена", UIKit.Theme.Gray, CancelGhost, 180, 90, 26);
|
||||
RefreshPlacePanel();
|
||||
}
|
||||
@@ -212,12 +279,22 @@ public class BuildModeController : MonoBehaviour
|
||||
{
|
||||
if (ghost == null || placeInfo == null) return;
|
||||
var d = ghost.Data;
|
||||
bool afford = d.CanAfford();
|
||||
string where = ghost.CellsFree ? "<color=#9be59b>место свободно</color>" : "<color=#ff7a7a>клетки заняты</color>";
|
||||
if (moving != null)
|
||||
{
|
||||
placeInfo.text = "<b>Перенос: " + d.buildingName + "</b> " + ghost.Footprint.size.x + "×" + ghost.Footprint.size.y +
|
||||
" кл. • " + where + "\nБесплатно";
|
||||
UIKit.SetButton(placeButton, ghost.CellsFree ? "Сюда" : "Занято", ghost.CellsFree);
|
||||
return;
|
||||
}
|
||||
bool afford = d.CanAfford();
|
||||
string limit = LimitProblem(d);
|
||||
bool builders = BuildTimer.DurationMs(false, 1) <= 0 || BuildTimer.FreeBuilders > 0;
|
||||
placeInfo.text = "<b>" + d.buildingName + "</b> " + ghost.Footprint.size.x + "×" + ghost.Footprint.size.y +
|
||||
" кл. • " + where + "\n" + CostText(d);
|
||||
bool ok = ghost.CellsFree && afford;
|
||||
UIKit.SetButton(placeButton, ok ? "Поставить" : (ghost.CellsFree ? "Мало ресурсов" : "Занято"), ok);
|
||||
" кл. • " + where + "\n" + CostText(d) + BuildTimeText();
|
||||
bool ok = ghost.CellsFree && afford && limit == null && builders;
|
||||
string label = ok ? "Поставить" : !ghost.CellsFree ? "Занято" : limit != null ? "Лимит" : !builders ? "Строители заняты" : "Мало ресурсов";
|
||||
UIKit.SetButton(placeButton, label, ok);
|
||||
}
|
||||
|
||||
// ---------------- окно выбора здания ----------------
|
||||
@@ -239,7 +316,8 @@ public class BuildModeController : MonoBehaviour
|
||||
card.offsetMin = Vector2.zero; card.offsetMax = Vector2.zero;
|
||||
|
||||
int th = TownHall.Instance != null ? TownHall.Instance.currentLevel : 1;
|
||||
UIKit.Text(card, "Ратуша " + th + " ур. • выберите здание, потом место на сетке", 24, UIKit.Theme.TextDim, TextAlignmentOptions.Center);
|
||||
UIKit.Text(card, "Ратуша " + th + " ур. • строителей свободно: " + BuildTimer.FreeBuilders + " из " + BuildTimer.Builders +
|
||||
"\nвыберите здание, потом место на сетке", 24, UIKit.Theme.TextDim, TextAlignmentOptions.Center);
|
||||
|
||||
var holder = UIKit.Make("ListHolder", card);
|
||||
UIKit.Size(holder, -1, -1).flexibleHeight = 1;
|
||||
@@ -254,7 +332,10 @@ public class BuildModeController : MonoBehaviour
|
||||
foreach (var data in all)
|
||||
{
|
||||
var d = data;
|
||||
bool locked = d.requiredTownHallLevel > th;
|
||||
int max = EconomyConfig.Load().BuildingLimitFor(d.buildingName, th);
|
||||
int have = CountBuilt(d.buildingName);
|
||||
bool full = max >= 0 && have >= max;
|
||||
bool locked = d.requiredTownHallLevel > th || full;
|
||||
bool afford = !locked && d.CanAfford();
|
||||
var size = BuildFootprint.Of(d).size;
|
||||
|
||||
@@ -265,13 +346,15 @@ public class BuildModeController : MonoBehaviour
|
||||
|
||||
var info = UIKit.Card(row, new Color(0, 0, 0, 0), 0, 4);
|
||||
UIKit.Size(info, -1, -1, 1);
|
||||
UIKit.Text(info, d.buildingName + " <size=22><color=#aab>" + size.x + "×" + size.y + " кл.</color></size>", 30,
|
||||
string count = max >= 0 ? " <size=22><color=#aab>построено " + have + " / " + max + "</color></size>" : "";
|
||||
UIKit.Text(info, d.buildingName + " <size=22><color=#aab>" + size.x + "×" + size.y + " кл.</color></size>" + count, 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 why = d.requiredTownHallLevel > th ? "Откроется на Ратуше " + d.requiredTownHallLevel + " уровня"
|
||||
: full ? NextLimitText(d.buildingName, th, have) : CostText(d);
|
||||
UIKit.Text(info, why, 24, locked ? UIKit.Theme.Gold : UIKit.Theme.TextDim);
|
||||
|
||||
// выбрать можно и без ресурсов: место присмотреть, а "Поставить" загорится, когда хватит
|
||||
string label = locked ? "Закрыто" : "Выбрать";
|
||||
string label = d.requiredTownHallLevel > th ? "Закрыто" : full ? "Лимит" : "Выбрать";
|
||||
var b = UIKit.Button(row, label, afford ? UIKit.Theme.Green : UIKit.Theme.Gray, () => StartGhost(d), 230, 90, 26);
|
||||
b.interactable = !locked;
|
||||
}
|
||||
@@ -279,6 +362,43 @@ public class BuildModeController : MonoBehaviour
|
||||
UIKit.Button(card, "Закрыть", UIKit.Theme.Gray, CloseChooser, -1, 90, 30);
|
||||
}
|
||||
|
||||
// ---------------- лимит зданий по Ратуше (EconomyConfig, ТЕСТ) ----------------
|
||||
|
||||
static int CountBuilt(string buildingName)
|
||||
{
|
||||
int n = 0;
|
||||
foreach (var id in FindObjectsByType<PlacedBuildingId>(FindObjectsSortMode.None))
|
||||
if (id.buildingName == buildingName) n++;
|
||||
return n;
|
||||
}
|
||||
|
||||
// null -- лимит позволяет; иначе текст для игрока
|
||||
static string LimitProblem(BuildingData d)
|
||||
{
|
||||
int th = TownHall.Instance != null ? TownHall.Instance.currentLevel : 1;
|
||||
int max = EconomyConfig.Load().BuildingLimitFor(d.buildingName, th);
|
||||
int have = CountBuilt(d.buildingName);
|
||||
if (max < 0 || have < max) return null;
|
||||
return NextLimitText(d.buildingName, th, have);
|
||||
}
|
||||
|
||||
static string NextLimitText(string buildingName, int th, int have)
|
||||
{
|
||||
var config = EconomyConfig.Load();
|
||||
for (int lvl = th + 1; lvl <= 10; lvl++)
|
||||
if (config.BuildingLimitFor(buildingName, lvl) > have)
|
||||
return "Лимит " + have + " / " + config.BuildingLimitFor(buildingName, th) + ": больше — на Ратуше " + lvl + " ур.";
|
||||
return "Достигнут лимит: " + have;
|
||||
}
|
||||
|
||||
static string BuildTimeText()
|
||||
{
|
||||
long ms = BuildTimer.DurationMs(false, 1);
|
||||
if (ms <= 0) return "";
|
||||
string free = BuildTimer.FreeBuilders > 0 ? "" : " <color=#ff7a7a>все строители заняты</color>";
|
||||
return " • стройка " + GameData.FormatTime(ms / 1000.0) + free;
|
||||
}
|
||||
|
||||
void CloseChooser()
|
||||
{
|
||||
if (chooser != null) Destroy(chooser);
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
using System.Collections.Generic;
|
||||
using UnityEngine;
|
||||
|
||||
// ТЕРЕМ БОГАТЫРЯ -- дом героя в городе.
|
||||
// - Пока Терем построен, у его ворот появляется герой и гуляет по свободным клеткам города (HeroWalker).
|
||||
// Тап по герою или по Терему -- окно героя (App/BuildingWindow): уровень, сила, бонус армии, навыки, улучшение.
|
||||
// - Механику героя не меняет: уровень, цена улучшения и бонусы те же, что в панели "Герой" (GameProgress).
|
||||
// Нужен ли Терем для улучшения героя, даёт ли уровни и сколько стоит -- решает Дима (сейчас ТЕСТ).
|
||||
// - Терем один на город: второй ничего не добавляет, герой живёт у первого.
|
||||
// Вешается на копию обычного здания (HeroCastleBootstrap).
|
||||
public class HeroCastle : MonoBehaviour
|
||||
{
|
||||
public static readonly List<HeroCastle> All = new List<HeroCastle>();
|
||||
public static int Count => All.Count;
|
||||
public static HeroCastle Main => All.Count > 0 ? All[0] : null;
|
||||
|
||||
void OnEnable()
|
||||
{
|
||||
if (!All.Contains(this)) All.Add(this);
|
||||
}
|
||||
|
||||
void OnDisable()
|
||||
{
|
||||
All.Remove(this);
|
||||
}
|
||||
|
||||
void Start()
|
||||
{
|
||||
// Герой-фигурка ходит по клеткам 2D-города (BuildGrid). В 3D-поселении сетки нет -- Терем работает без прогулки
|
||||
if (Main == this && BuildGrid.Instance != null) HeroWalker.Spawn(this);
|
||||
}
|
||||
|
||||
void OnDestroy()
|
||||
{
|
||||
All.Remove(this);
|
||||
HeroWalker.OnCastleRemoved(this);
|
||||
}
|
||||
|
||||
Vector3 downPos;
|
||||
|
||||
void OnMouseDown()
|
||||
{
|
||||
downPos = Input.mousePosition;
|
||||
}
|
||||
|
||||
// Тап (а не перетаскивание камеры) -- окно героя
|
||||
void OnMouseUpAsButton()
|
||||
{
|
||||
if (UIBlocker.IsPointerOverUI()) return;
|
||||
if ((Input.mousePosition - downPos).magnitude > 25f) return;
|
||||
BuildingWindow.Open(gameObject);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: b3edb04b487c1554a91d69b16f7876e4
|
||||
@@ -0,0 +1,65 @@
|
||||
using UnityEngine;
|
||||
|
||||
// Добавляет «Терем богатыря» в список стройки САМ при запуске сцены (как Казармы и Госпиталь).
|
||||
// Срабатывает до восстановления зданий из сохранения (SettlementRestoringEvent), поэтому построенный
|
||||
// Терем корректно восстанавливается из save.json. Цена -- ТЕСТ (согласовать с Димой).
|
||||
public static class HeroCastleBootstrap
|
||||
{
|
||||
public const string CastleName = "Терем богатыря";
|
||||
public const int CostWood = 400; // ТЕСТ
|
||||
public const int CostStone = 300; // ТЕСТ
|
||||
static GameObject runtimeTemplate;
|
||||
|
||||
[RuntimeInitializeOnLoadMethod(RuntimeInitializeLoadType.AfterSceneLoad)]
|
||||
static void Init()
|
||||
{
|
||||
Ensure();
|
||||
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 == CastleName) return; // уже есть
|
||||
if (template == null && b.prefab != null) template = b;
|
||||
}
|
||||
if (template == null) { Debug.LogWarning("[ТЕРЕМ] Нет ни одного здания-шаблона в BuildManager"); return; }
|
||||
|
||||
// Префаб-шаблон в памяти: копия обычного здания без сбора ресурсов, с компонентом HeroCastle
|
||||
if (runtimeTemplate == null)
|
||||
{
|
||||
runtimeTemplate = Object.Instantiate(template.prefab);
|
||||
runtimeTemplate.name = "HeroCastle";
|
||||
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.85f, 0.35f); // пока нет арта -- отличаем цветом (золотой)
|
||||
runtimeTemplate.AddComponent<HeroCastle>();
|
||||
}
|
||||
|
||||
var data = ScriptableObject.CreateInstance<BuildingData>();
|
||||
data.name = "Data_HeroCastle_Runtime";
|
||||
data.buildingName = CastleName;
|
||||
data.icon = template.icon;
|
||||
data.prefab = runtimeTemplate;
|
||||
data.requiredTownHallLevel = 1; // ТЕСТ
|
||||
data.cost = new[]
|
||||
{
|
||||
new ResourceCost { type = ResourceType.Wood, amount = CostWood },
|
||||
new ResourceCost { type = ResourceType.Stone, amount = CostStone },
|
||||
};
|
||||
bm.allBuildings.Add(data);
|
||||
Debug.Log("[ТЕРЕМ] Добавлен в список стройки");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: b1671b6bc9bd9174f9a8f213d07ef757
|
||||
@@ -0,0 +1,203 @@
|
||||
using UnityEngine;
|
||||
|
||||
// ГЕРОЙ В ГОРОДЕ -- живёт у Терема богатыря и гуляет по свободным клеткам рядом с ним.
|
||||
// - Пока без арта: фигурка из простых форм (тело, голова, щит) цвета героя. Арт подставим в BuildFigure.
|
||||
// - Идёт к случайной свободной клетке в радиусе WalkRadius от Терема, стоит 1-3 с, идёт дальше.
|
||||
// - Тап по герою -- он отвечает фразой и открывается окно героя.
|
||||
// - В режиме стройки герой прячется, чтобы не мешать ставить здания.
|
||||
// Это только оживление города: на экономику, бой и сохранение не влияет.
|
||||
public class HeroWalker : MonoBehaviour
|
||||
{
|
||||
public static HeroWalker Instance { get; private set; }
|
||||
|
||||
const float Speed = 1.3f; // клеток в секунду
|
||||
const int WalkRadius = 6; // насколько далеко уходит от Терема
|
||||
const int SortingOrder = 40; // поверх зданий
|
||||
|
||||
static readonly string[] Phrases =
|
||||
{
|
||||
"Город под защитой!",
|
||||
"Веди, воевода, — не подведу.",
|
||||
"Силушка богатырская при мне.",
|
||||
"Дозором обхожу владения.",
|
||||
"Есть ли где ворог? Только скажи.",
|
||||
};
|
||||
|
||||
HeroCastle castle;
|
||||
Vector3 target;
|
||||
float waitLeft;
|
||||
Transform figure;
|
||||
Vector3 downPos;
|
||||
BoxCollider2D tapArea;
|
||||
|
||||
public static void Spawn(HeroCastle home)
|
||||
{
|
||||
if (Instance != null) { Instance.castle = home; return; }
|
||||
var go = new GameObject("Hero");
|
||||
var w = go.AddComponent<HeroWalker>();
|
||||
w.castle = home;
|
||||
w.BuildFigure();
|
||||
go.transform.position = w.Gate();
|
||||
w.target = go.transform.position;
|
||||
w.waitLeft = 1f;
|
||||
Instance = w;
|
||||
}
|
||||
|
||||
public static void OnCastleRemoved(HeroCastle removed)
|
||||
{
|
||||
if (Instance == null || Instance.castle != removed) return;
|
||||
var next = HeroCastle.Main;
|
||||
if (next != null) Instance.castle = next;
|
||||
else Destroy(Instance.gameObject);
|
||||
}
|
||||
|
||||
void OnDestroy()
|
||||
{
|
||||
if (Instance == this) Instance = null;
|
||||
}
|
||||
|
||||
// ---------------- Внешний вид (заглушка) ----------------
|
||||
|
||||
void BuildFigure()
|
||||
{
|
||||
figure = new GameObject("Figure").transform;
|
||||
figure.SetParent(transform, false);
|
||||
Part("Body", Square(), new Color(0.55f, 0.4f, 0.2f), new Vector2(0f, 0.28f), new Vector2(0.38f, 0.48f), 0);
|
||||
Part("Head", Circle(), new Color(1f, 0.85f, 0.7f), new Vector2(0f, 0.66f), new Vector2(0.3f, 0.3f), 1);
|
||||
Part("Helmet", Circle(), new Color(0.75f, 0.75f, 0.8f), new Vector2(0f, 0.76f), new Vector2(0.32f, 0.16f), 2);
|
||||
Part("Shield", Circle(), new Color(0.8f, 0.15f, 0.15f), new Vector2(0.2f, 0.28f), new Vector2(0.22f, 0.3f), 3);
|
||||
Part("Shadow", Circle(), new Color(0f, 0f, 0f, 0.3f), new Vector2(0f, 0.02f), new Vector2(0.45f, 0.14f), -1);
|
||||
|
||||
// Коллайдер для тапа -- чуть больше фигурки, ближе к камере, чем здания
|
||||
tapArea = gameObject.AddComponent<BoxCollider2D>();
|
||||
tapArea.size = new Vector2(0.7f, 1f);
|
||||
tapArea.offset = new Vector2(0f, 0.45f);
|
||||
var p = transform.position; p.z = -0.5f; transform.position = p;
|
||||
}
|
||||
|
||||
SpriteRenderer Part(string name, Sprite sprite, Color color, Vector2 pos, Vector2 size, int order)
|
||||
{
|
||||
var t = new GameObject(name).transform;
|
||||
t.SetParent(figure, false);
|
||||
t.localPosition = pos;
|
||||
t.localScale = new Vector3(size.x, size.y, 1f);
|
||||
var sr = t.gameObject.AddComponent<SpriteRenderer>();
|
||||
sr.sprite = sprite;
|
||||
sr.color = color;
|
||||
sr.sortingOrder = SortingOrder + order;
|
||||
return sr;
|
||||
}
|
||||
|
||||
static Sprite square, circle;
|
||||
|
||||
static Sprite Square()
|
||||
{
|
||||
if (square != null) return square;
|
||||
var tex = new Texture2D(4, 4, TextureFormat.RGBA32, false);
|
||||
var px = new Color32[16];
|
||||
for (int i = 0; i < px.Length; i++) px[i] = new Color32(255, 255, 255, 255);
|
||||
tex.SetPixels32(px); tex.Apply();
|
||||
square = Sprite.Create(tex, new Rect(0, 0, 4, 4), new Vector2(0.5f, 0.5f), 4);
|
||||
return square;
|
||||
}
|
||||
|
||||
static Sprite Circle()
|
||||
{
|
||||
if (circle != null) return circle;
|
||||
const int n = 64;
|
||||
var tex = new Texture2D(n, n, TextureFormat.RGBA32, false);
|
||||
var px = new Color32[n * n];
|
||||
float r = n * 0.5f;
|
||||
for (int y = 0; y < n; y++)
|
||||
for (int x = 0; x < n; x++)
|
||||
{
|
||||
float d = Mathf.Sqrt((x + 0.5f - r) * (x + 0.5f - r) + (y + 0.5f - r) * (y + 0.5f - r));
|
||||
byte a = (byte)(Mathf.Clamp01(r - d) * 255);
|
||||
px[y * n + x] = new Color32(255, 255, 255, a);
|
||||
}
|
||||
tex.SetPixels32(px); tex.Apply();
|
||||
circle = Sprite.Create(tex, new Rect(0, 0, n, n), new Vector2(0.5f, 0.5f), n);
|
||||
return circle;
|
||||
}
|
||||
|
||||
// ---------------- Прогулка ----------------
|
||||
|
||||
Vector3 Gate()
|
||||
{
|
||||
var c = castle != null ? castle.transform.position : Vector3.zero;
|
||||
var grid = BuildGrid.Instance;
|
||||
if (grid != null && grid.FindFreeNear(c, Vector2Int.one, out Vector2Int cell))
|
||||
{
|
||||
Vector2 p = grid.RectCenter(cell, Vector2Int.one);
|
||||
return new Vector3(p.x, p.y - 0.4f, -0.5f);
|
||||
}
|
||||
return new Vector3(c.x, c.y - 1f, -0.5f);
|
||||
}
|
||||
|
||||
Vector3 PickTarget()
|
||||
{
|
||||
var grid = BuildGrid.Instance;
|
||||
if (grid == null || castle == null) return transform.position;
|
||||
grid.Refresh();
|
||||
Vector2Int home = grid.WorldToCell(castle.transform.position);
|
||||
for (int attempt = 0; attempt < 20; attempt++)
|
||||
{
|
||||
int x = home.x + Random.Range(-WalkRadius, WalkRadius + 1);
|
||||
int y = home.y + Random.Range(-WalkRadius, WalkRadius + 1);
|
||||
if (grid.IsBusy(x, y)) continue;
|
||||
Vector2 p = grid.RectCenter(new Vector2Int(x, y), Vector2Int.one);
|
||||
return new Vector3(p.x, p.y - 0.4f, -0.5f);
|
||||
}
|
||||
return Gate();
|
||||
}
|
||||
|
||||
void Update()
|
||||
{
|
||||
bool building = BuildModeController.Instance != null && BuildModeController.Instance.IsActive;
|
||||
if (figure.gameObject.activeSelf == building)
|
||||
{
|
||||
figure.gameObject.SetActive(!building);
|
||||
if (tapArea != null) tapArea.enabled = !building;
|
||||
}
|
||||
if (building) return;
|
||||
|
||||
if (waitLeft > 0f) { waitLeft -= Time.deltaTime; return; }
|
||||
|
||||
Vector3 pos = transform.position;
|
||||
Vector3 delta = target - pos;
|
||||
delta.z = 0f;
|
||||
if (delta.magnitude < 0.05f)
|
||||
{
|
||||
target = PickTarget();
|
||||
waitLeft = Random.Range(1f, 3f);
|
||||
return;
|
||||
}
|
||||
Vector3 step = delta.normalized * Speed * Time.deltaTime;
|
||||
if (step.magnitude > delta.magnitude) step = delta;
|
||||
transform.position = pos + step;
|
||||
if (Mathf.Abs(delta.x) > 0.01f)
|
||||
{
|
||||
var s = figure.localScale;
|
||||
s.x = delta.x < 0 ? -1f : 1f; // смотрит туда, куда идёт
|
||||
figure.localScale = s;
|
||||
}
|
||||
// лёгкое покачивание при ходьбе
|
||||
figure.localPosition = new Vector3(0f, Mathf.Abs(Mathf.Sin(Time.time * 10f)) * 0.05f, 0f);
|
||||
}
|
||||
|
||||
// ---------------- Тап ----------------
|
||||
|
||||
void OnMouseDown()
|
||||
{
|
||||
downPos = Input.mousePosition;
|
||||
}
|
||||
|
||||
void OnMouseUpAsButton()
|
||||
{
|
||||
if (UIBlocker.IsPointerOverUI()) return;
|
||||
if ((Input.mousePosition - downPos).magnitude > 25f) return;
|
||||
UIKit.Toast("Богатырь: " + Phrases[Random.Range(0, Phrases.Length)]);
|
||||
waitLeft = 2f; // остановился поговорить
|
||||
if (castle != null) BuildingWindow.Open(castle.gameObject);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: caa21d91fd476684ba0fd70c830fe04f
|
||||
@@ -0,0 +1,60 @@
|
||||
using System.Collections.Generic;
|
||||
using UnityEngine;
|
||||
|
||||
// ГОСПИТАЛЬ -- здание для лечения раненых (регламент 31.10; решения по 8 вопросам Саши от 31.10, п. 7).
|
||||
// - Даёт места для лечения: уровни I-V, 20-60 мест (таблица HospitalConfig, ТЕСТ).
|
||||
// - Считаются самые прокачанные госпитали: второй госпиталь мест не добавляет (регламент про несколько не говорит).
|
||||
// - Раненых хранит и лечит модуль Армии; здание только сообщает число мест через контракт IHospital (HospitalBeds).
|
||||
// - Тап открывает окно госпиталя (App/BuildingWindow): места, кто лечится и ждёт, улучшение.
|
||||
// - Уровень сохраняет модуль Поселения (ILeveledBuilding).
|
||||
// Вешается на копию обычного здания (HospitalBootstrap).
|
||||
public class Hospital : MonoBehaviour, ILeveledBuilding
|
||||
{
|
||||
public static readonly List<Hospital> All = new List<Hospital>();
|
||||
public static int Count => All.Count;
|
||||
|
||||
public int level = 1;
|
||||
public int Level { get => level; set => level = Mathf.Clamp(value, 1, MaxBuildLevel); }
|
||||
|
||||
// Уровней у госпиталя столько, сколько строк мест в таблице (регламент: I-V)
|
||||
public static int MaxBuildLevel => Mathf.Max(1, HospitalConfig.Load().bedsByLevel.Length);
|
||||
|
||||
// Уровень самого прокачанного госпиталя (0 -- госпиталя нет)
|
||||
public static int MaxLevel
|
||||
{
|
||||
get { int m = 0; foreach (var h in All) if (h != null && h.level > m) m = h.level; return m; }
|
||||
}
|
||||
|
||||
// Мест на уровне (1..5) по таблице
|
||||
public static int BedsAt(int lvl)
|
||||
{
|
||||
var beds = HospitalConfig.Load().bedsByLevel;
|
||||
if (lvl <= 0 || beds == null || beds.Length == 0) return 0;
|
||||
return Mathf.Max(0, beds[Mathf.Min(lvl, beds.Length) - 1]);
|
||||
}
|
||||
|
||||
void OnEnable() { if (!All.Contains(this)) All.Add(this); }
|
||||
void OnDisable() { All.Remove(this); }
|
||||
|
||||
Vector3 downPos;
|
||||
|
||||
void OnMouseDown()
|
||||
{
|
||||
downPos = Input.mousePosition;
|
||||
}
|
||||
|
||||
// Тап (а не перетаскивание камеры) -- окно госпиталя
|
||||
void OnMouseUpAsButton()
|
||||
{
|
||||
if (UIBlocker.IsPointerOverUI()) return;
|
||||
if ((Input.mousePosition - downPos).magnitude > 25f) return;
|
||||
BuildingWindow.Open(gameObject);
|
||||
}
|
||||
}
|
||||
|
||||
// Места госпиталя для модуля Армии (контракт IHospital). Регистрирует App/GameModules.
|
||||
// Госпиталя нет -- 0 мест: Армия берёт HospitalConfig.temporaryBeds.
|
||||
public sealed class HospitalBeds : IHospital
|
||||
{
|
||||
public int Beds => Hospital.BedsAt(Hospital.MaxLevel);
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 2f4c537a26bd44b3ae851af08c652a34
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,65 @@
|
||||
using UnityEngine;
|
||||
|
||||
// Добавляет «Госпиталь» в список стройки САМ при запуске сцены (как Казармы -- BarracksBootstrap).
|
||||
// Срабатывает до восстановления зданий из сохранения (SettlementRestoringEvent), поэтому построенный
|
||||
// госпиталь и его уровень корректно восстанавливаются из save.json.
|
||||
public static class HospitalBootstrap
|
||||
{
|
||||
public const string HospitalName = "Госпиталь";
|
||||
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 == HospitalName) return; // уже есть
|
||||
if (template == null && b.prefab != null) template = b;
|
||||
}
|
||||
if (template == null) { Debug.LogWarning("[ГОСПИТАЛЬ] Нет ни одного здания-шаблона в BuildManager"); return; }
|
||||
|
||||
// Префаб-шаблон в памяти: копия обычного здания без сбора ресурсов, с компонентом Hospital
|
||||
if (runtimeTemplate == null)
|
||||
{
|
||||
runtimeTemplate = Object.Instantiate(template.prefab);
|
||||
runtimeTemplate.name = "Hospital";
|
||||
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(0.85f, 0.95f, 1f); // пока нет арта -- отличаем цветом (светло-голубой)
|
||||
runtimeTemplate.AddComponent<Hospital>();
|
||||
}
|
||||
|
||||
var config = HospitalConfig.Load();
|
||||
var data = ScriptableObject.CreateInstance<BuildingData>();
|
||||
data.name = "Data_Hospital_Runtime";
|
||||
data.buildingName = HospitalName;
|
||||
data.icon = template.icon;
|
||||
data.prefab = runtimeTemplate;
|
||||
data.requiredTownHallLevel = Mathf.Max(1, config.requiredTownHallLevel);
|
||||
data.cost = new[]
|
||||
{
|
||||
new ResourceCost { type = ResourceType.Wood, amount = config.buildWood },
|
||||
new ResourceCost { type = ResourceType.Stone, amount = config.buildStone },
|
||||
};
|
||||
bm.allBuildings.Add(data);
|
||||
Debug.Log("[ГОСПИТАЛЬ] Добавлен в список стройки");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 3301bf2246b4444182d1362fe7bffcc5
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -123,6 +123,8 @@ public static class GameData
|
||||
public const int MaxPlayerLevel = 60;
|
||||
|
||||
// ---------- Основные задания (цепочка) ----------
|
||||
// Синие кристаллы (регламент 05.11, разд. 3): простое задание -- 1, сложное -- 2 (было 20-250). В ежедневных
|
||||
// кристаллов нет: кристальное задание -- не чаще раза в 7-14 дней. Числа ТЕСТ
|
||||
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),
|
||||
@@ -130,21 +132,21 @@ public static class GameData
|
||||
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_battle_1", "Выиграть первый бой", QuestKind.BattlesWon, 1, 60, R(ResourceType.Gems, 1)),
|
||||
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_world_win", "Разграбить поселение на карте мира", QuestKind.WorldWins, 1, 100, R(ResourceType.Gems, 2), 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_th_2", "Ратуша 2 уровня", QuestKind.TownHallLevel, 2, 100, R(ResourceType.Gems, 1), 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_arena_1", "Сразиться на арене", QuestKind.ArenaFights, 1, 60, R(ResourceType.Gems, 1)),
|
||||
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_train_100", "Обучить 100 воинов", QuestKind.TroopsTrained, 100, 120, R(ResourceType.Food, 1500), R(ResourceType.Gems, 1)),
|
||||
new QuestDef("m_battle_5", "Выиграть 5 боёв", QuestKind.BattlesWon, 5, 150, R(ResourceType.Gems, 1)),
|
||||
new QuestDef("m_player_5", "Достигнуть 5 уровня", QuestKind.PlayerLevel, 5, 100, R(ResourceType.Gems, 1)),
|
||||
new QuestDef("m_th_3", "Ратуша 3 уровня", QuestKind.TownHallLevel, 3, 200, R(ResourceType.Gems, 2), 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)),
|
||||
new QuestDef("m_battle_15", "Выиграть 15 боёв", QuestKind.BattlesWon, 15, 300, R(ResourceType.Gems, 2)),
|
||||
new QuestDef("m_th_5", "Ратуша 5 уровня", QuestKind.TownHallLevel, 5, 400, R(ResourceType.Gems, 2)),
|
||||
};
|
||||
|
||||
// ---------- Ежедневные задания (сбрасываются каждый день) ----------
|
||||
@@ -154,8 +156,8 @@ public static class GameData
|
||||
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_battle", "Выиграть 1 бой", QuestKind.BattlesWon, 1, 30, R(ResourceType.Gold, 200)),
|
||||
new QuestDef("d_arena", "2 боя на арене", QuestKind.ArenaFights, 2, 30, R(ResourceType.Gold, 200)),
|
||||
new QuestDef("d_world", "Отправить поход на карте мира", QuestKind.WorldMarches, 1, 25, R(ResourceType.Gold, 200)),
|
||||
};
|
||||
|
||||
|
||||
@@ -144,6 +144,8 @@ public class GameProgress : MonoBehaviour
|
||||
}
|
||||
if (Data.claimedQuests == null) Data.claimedQuests = new List<string>();
|
||||
if (Data.claimedDaily == null) Data.claimedDaily = new List<string>();
|
||||
if (Data.crystalWaiting == null) Data.crystalWaiting = new List<string>();
|
||||
crystals.Load(Data.crystalWaiting);
|
||||
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>();
|
||||
@@ -307,24 +309,76 @@ public class GameProgress : MonoBehaviour
|
||||
return true;
|
||||
}
|
||||
|
||||
// Выдать награду. Синие кристаллы -- не сверх предела хранилища (регламент 05.11): не поместившиеся приходят
|
||||
// письмом «Кристаллы ждут места» и забираются из него, когда место появится. Ничего не теряется
|
||||
public void Grant(RewardItem[] rewards, int xp = 0, bool toast = true)
|
||||
{
|
||||
if (ResourceManager.Instance != null && rewards != null)
|
||||
int gems = GemsIn(rewards), fit = CrystalRewards.Fits(gems, GemsHave, GemsCap), left = gems - fit;
|
||||
var given = new List<RewardItem>(WithoutGems(rewards));
|
||||
if (fit > 0) given.Add(new RewardItem(ResourceType.Gems, fit));
|
||||
if (ResourceManager.Instance != null)
|
||||
{
|
||||
suppressTracking++;
|
||||
foreach (var r in rewards) ResourceManager.Instance.AddResource(r.type, r.amount);
|
||||
foreach (var r in given) ResourceManager.Instance.AddResource(r.type, r.amount);
|
||||
suppressTracking--;
|
||||
}
|
||||
if (left > 0)
|
||||
SendMail("Кристаллы ждут места",
|
||||
$"Хранилище синих кристаллов заполнено ({GemsHave:0} / {GemsCap:0}). {left} кристаллов ждут: потратьте кристаллы и заберите их из этого письма.",
|
||||
new RewardItem(ResourceType.Gems, left));
|
||||
if (xp > 0) AddXp(xp);
|
||||
if (toast)
|
||||
{
|
||||
string text = GameData.RewardsText(rewards);
|
||||
string text = GameData.RewardsText(given.ToArray());
|
||||
if (xp > 0) text += (text.Length > 0 ? ", " : "") + xp + " опыта";
|
||||
if (left > 0) text += (text.Length > 0 ? ". " : "") + $"Ещё {left} кристаллов ждут места -- в почте";
|
||||
if (text.Length > 0) UIKit.Toast("Получено: " + text);
|
||||
}
|
||||
MarkChanged();
|
||||
}
|
||||
|
||||
// ---------------- Синие кристаллы при пределе 50 (регламент 05.11, ред. 1.1; правила -- CrystalRewards) ----------------
|
||||
|
||||
readonly CrystalRewards crystals = new CrystalRewards();
|
||||
|
||||
// Нет склада (сцена без ResourceManager) -- места нет: кристаллы ждут, а не теряются
|
||||
public static float GemsHave => ResourceManager.Instance != null ? ResourceManager.Instance.GetResource(ResourceType.Gems) : 0f;
|
||||
public static float GemsCap => ResourceManager.Instance != null ? ResourceManager.Instance.GetCapacity(ResourceType.Gems) : 0f;
|
||||
|
||||
static int GemsIn(IEnumerable<RewardItem> rewards)
|
||||
{
|
||||
int n = 0;
|
||||
if (rewards != null) foreach (var r in rewards) if (r != null && r.type == ResourceType.Gems) n += Mathf.Max(0, r.amount);
|
||||
return n;
|
||||
}
|
||||
|
||||
static RewardItem[] WithoutGems(IEnumerable<RewardItem> rewards)
|
||||
{
|
||||
var list = new List<RewardItem>();
|
||||
if (rewards != null) foreach (var r in rewards) if (r != null && r.type != ResourceType.Gems) list.Add(r);
|
||||
return list.ToArray();
|
||||
}
|
||||
|
||||
static string QuestKey(QuestDef q, bool daily) => (daily ? "d:" : "m:") + q.id;
|
||||
|
||||
// Кристаллы награды задания, которые ждут места (0 -- не ждут)
|
||||
public int QuestGemsWaiting(QuestDef q, bool daily) => crystals.Left(QuestKey(q, daily));
|
||||
|
||||
// Начислить кристаллы награды key: сколько помещается, остаток ждёт под этим key (сохраняется)
|
||||
int GiveQuestGems(string key, int amount)
|
||||
{
|
||||
int give = crystals.Claim(key, amount, GemsHave, GemsCap);
|
||||
if (give > 0 && ResourceManager.Instance != null)
|
||||
{
|
||||
suppressTracking++;
|
||||
ResourceManager.Instance.AddResource(ResourceType.Gems, give);
|
||||
suppressTracking--;
|
||||
}
|
||||
Data.crystalWaiting = crystals.ToLines();
|
||||
MarkChanged();
|
||||
return give;
|
||||
}
|
||||
|
||||
// =====================================================================
|
||||
// ОТСЛЕЖИВАНИЕ СБОРА РЕСУРСОВ (для заданий и статистики)
|
||||
// =====================================================================
|
||||
@@ -455,13 +509,42 @@ public class GameProgress : MonoBehaviour
|
||||
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; }
|
||||
|
||||
// Забрать награду задания. Кристаллы, которым нет места, ждут в задании («Забрать кристаллы»); пока ждут
|
||||
// CrystalRewards.MaxWaiting наград, новое задание с кристаллами, которые не помещаются, не выдаётся (регламент 05.11)
|
||||
public void ClaimQuest(QuestDef q, bool daily)
|
||||
{
|
||||
if (IsQuestClaimed(q, daily) || !IsQuestDone(q, daily)) return;
|
||||
string key = QuestKey(q, daily);
|
||||
if (IsQuestClaimed(q, daily))
|
||||
{
|
||||
if (crystals.Left(key) <= 0) return;
|
||||
int got = GiveQuestGems(key, 0); // дозабрать остаток
|
||||
int still = crystals.Left(key);
|
||||
UIKit.Toast(got > 0 ? $"Получено: {got} кристаллов" + (still > 0 ? $". Ещё {still} ждут места" : "")
|
||||
: $"Нет места для кристаллов ({GemsHave:0} / {GemsCap:0})");
|
||||
return;
|
||||
}
|
||||
if (!IsQuestDone(q, daily)) return;
|
||||
|
||||
int gems = GemsIn(q.rewards);
|
||||
if (!crystals.CanStart(key, gems, GemsHave, GemsCap))
|
||||
{
|
||||
UIKit.Toast($"Нет места для кристаллов ({GemsHave:0} / {GemsCap:0}), и уже ждут {CrystalRewards.MaxWaiting} награды. " +
|
||||
"Потратьте кристаллы или заберите ждущие -- награда этого задания сохранится");
|
||||
return;
|
||||
}
|
||||
(daily ? Data.claimedDaily : Data.claimedQuests).Add(q.id);
|
||||
Data.questsDone++;
|
||||
AddEventPoints(20);
|
||||
Grant(q.rewards, q.xp);
|
||||
|
||||
var rest = WithoutGems(q.rewards);
|
||||
Grant(rest, q.xp, false);
|
||||
int given = gems > 0 ? GiveQuestGems(key, gems) : 0, left = crystals.Left(key);
|
||||
var shown = new List<RewardItem>(rest);
|
||||
if (given > 0) shown.Add(new RewardItem(ResourceType.Gems, given));
|
||||
string text = GameData.RewardsText(shown.ToArray());
|
||||
if (q.xp > 0) text += (text.Length > 0 ? ", " : "") + q.xp + " опыта";
|
||||
if (left > 0) text += $". Ещё {left} кристаллов ждут места -- «Забрать» в заданиях";
|
||||
UIKit.Toast("Получено: " + text);
|
||||
}
|
||||
|
||||
public int ClaimableQuestsCount
|
||||
@@ -471,6 +554,12 @@ public class GameProgress : MonoBehaviour
|
||||
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++;
|
||||
// кристаллы, которые ждали места, -- когда место появилось
|
||||
if (crystals.WaitingCount > 0 && CrystalRewards.Free(GemsHave, GemsCap) > 0)
|
||||
{
|
||||
foreach (var q in GameData.MainQuests) if (IsQuestClaimed(q, false) && QuestGemsWaiting(q, false) > 0) n++;
|
||||
foreach (var q in GameData.DailyQuests) if (IsQuestClaimed(q, true) && QuestGemsWaiting(q, true) > 0) n++;
|
||||
}
|
||||
return n;
|
||||
}
|
||||
}
|
||||
@@ -525,11 +614,27 @@ public class GameProgress : MonoBehaviour
|
||||
|
||||
public void ReadMail(MailItem m) { if (m != null && !m.read) { m.read = true; MarkChanged(); } }
|
||||
|
||||
public void ClaimMail(MailItem m, bool toast = true)
|
||||
// Забрать подарок письма. Кристаллы -- сколько помещается; остаток остаётся в этом же письме (регламент 05.11).
|
||||
// false -- в письме остались кристаллы, которым нет места
|
||||
public bool 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);
|
||||
if (m == null || m.claimed || !m.HasRewards) return true;
|
||||
m.read = true;
|
||||
int gems = GemsIn(m.rewards), fit = CrystalRewards.Fits(gems, GemsHave, GemsCap), left = gems - fit;
|
||||
var give = new List<RewardItem>(WithoutGems(m.rewards));
|
||||
if (give.Count == 0 && fit == 0)
|
||||
{
|
||||
if (toast) UIKit.Toast($"Нет места для кристаллов ({GemsHave:0} / {GemsCap:0}) -- подарок подождёт в письме");
|
||||
MarkChanged();
|
||||
return false;
|
||||
}
|
||||
if (fit > 0) give.Add(new RewardItem(ResourceType.Gems, fit));
|
||||
m.rewards.Clear();
|
||||
if (left > 0) m.rewards.Add(new RewardItem(ResourceType.Gems, left)); // остаток ждёт в письме
|
||||
else m.claimed = true;
|
||||
Grant(give.ToArray(), 0, toast); // всё помещается: Grant ничего не отложит
|
||||
if (toast && left > 0) UIKit.Toast($"Ещё {left} кристаллов ждут места -- в этом письме");
|
||||
return left == 0;
|
||||
}
|
||||
|
||||
public void DeleteMail(MailItem m)
|
||||
@@ -542,13 +647,16 @@ public class GameProgress : MonoBehaviour
|
||||
|
||||
public void ClaimAllMail()
|
||||
{
|
||||
var all = new List<RewardItem>();
|
||||
foreach (var m in Data.mail)
|
||||
int gifts = 0, waiting = 0;
|
||||
foreach (var m in Data.mail.ToArray())
|
||||
{
|
||||
m.read = true;
|
||||
if (m.HasRewards && !m.claimed) { m.claimed = true; all.AddRange(m.rewards); }
|
||||
if (!m.HasRewards || m.claimed) continue;
|
||||
gifts++;
|
||||
if (!ClaimMail(m, false)) waiting++;
|
||||
}
|
||||
if (all.Count > 0) Grant(all.ToArray()); else UIKit.Toast("Все письма прочитаны");
|
||||
if (gifts == 0) UIKit.Toast("Все письма прочитаны");
|
||||
else UIKit.Toast(waiting == 0 ? "Подарки получены" : $"Подарки получены. В {waiting} письмах кристаллы ждут места");
|
||||
MarkChanged();
|
||||
}
|
||||
|
||||
@@ -658,85 +766,82 @@ public class GameProgress : MonoBehaviour
|
||||
return string.Join(", ", parts.ToArray());
|
||||
}
|
||||
|
||||
// ОЧЕРЕДЬ ОБУЧЕНИЯ ведёт модуль Армии (решения по 8 вопросам Саши, 31.10, п. 8). Здесь -- только то, что знает
|
||||
// слой приложения: есть ли Казармы и открыт ли тип войск, цена (Экономика), длина очереди от числа Казарм, окна.
|
||||
// Время -- часы игры (GameClock.UnixNowMs): перемотка DEV и офлайн досчитывают обучение так же, как марши.
|
||||
|
||||
public IReadOnlyList<TrainingOrderInfo> Training => Army.Training;
|
||||
|
||||
public void Train(int index, int count)
|
||||
{
|
||||
var def = GameData.Troops[index];
|
||||
if (Barracks.Count == 0) { UIKit.Toast("Сначала постройте Казармы"); return; }
|
||||
if (!TroopUnlocked(index)) { UIKit.Toast("Нужно: " + TroopLockText(index)); return; }
|
||||
if (Data.training.Count >= Barracks.QueueSize) { UIKit.Toast("Очередь обучения заполнена. Больше казарм -- длиннее очередь"); return; }
|
||||
// Воин -- житель города (модуль Населения): берутся свободные жители, резерв не отдаётся
|
||||
var population = Population;
|
||||
if (population != null && population.Recruitable < count)
|
||||
// Очередь и жители (резерв не отдаётся) -- проверяет Армия вместе с Населением
|
||||
if (!Army.CanQueueTraining(index, count, Barracks.QueueSize, out string error)) { UIKit.Toast(error); return; }
|
||||
if (!Pay(def.cost, count)) return;
|
||||
|
||||
long durationMs = (long)(def.secondsPerUnit * 1000.0 * count);
|
||||
if (!Army.TryQueueTraining(index, count, durationMs, Barracks.QueueSize, GameClock.UnixNowMs, out error))
|
||||
{
|
||||
UIKit.Toast(population.Recruitable > 0
|
||||
? "Жителей хватит только на " + population.Recruitable + " воинов"
|
||||
: "Некого нанять: свободных жителей нет (резерв " + population.CivilianReserve + "). Снимите рабочих или стройте Дома");
|
||||
// Заказ не встал -- цену возвращаем целиком
|
||||
var refund = new RewardItem[def.cost.Length];
|
||||
for (int i = 0; i < def.cost.Length; i++) refund[i] = new RewardItem(def.cost[i].type, def.cost[i].amount * count);
|
||||
Grant(refund, 0, false);
|
||||
UIKit.Toast(error);
|
||||
return;
|
||||
}
|
||||
if (!Pay(def.cost, count)) return;
|
||||
bool recruited = population != null && population.TryRecruit(count);
|
||||
|
||||
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, recruited = recruited });
|
||||
UIKit.Toast("Обучение начато: " + count + " × " + def.name);
|
||||
MarkChanged();
|
||||
}
|
||||
|
||||
public double TrainingSecondsLeft(TrainingOrder o) { return TimeSpan.FromTicks(Math.Max(0, o.endTicks - Now)).TotalSeconds; }
|
||||
public double TrainingSecondsLeft(TrainingOrderInfo o) { return Math.Max(0, o.EndMs - GameClock.UnixNowMs) / 1000.0; }
|
||||
|
||||
public float TrainingProgress(TrainingOrder o)
|
||||
public float TrainingProgress(TrainingOrderInfo o)
|
||||
{
|
||||
long total = o.endTicks - o.startTicks;
|
||||
long total = o.EndMs - o.StartMs;
|
||||
if (total <= 0) return 1f;
|
||||
return Mathf.Clamp01((float)(Now - o.startTicks) / total);
|
||||
return Mathf.Clamp01((float)(GameClock.UnixNowMs - o.StartMs) / total);
|
||||
}
|
||||
|
||||
public int SpeedUpCost
|
||||
{
|
||||
get
|
||||
{
|
||||
if (Data.training.Count == 0) return 0;
|
||||
double secs = TrainingSecondsLeft(Data.training[Data.training.Count - 1]);
|
||||
var queue = Training;
|
||||
if (queue.Count == 0) return 0;
|
||||
double secs = TrainingSecondsLeft(queue[queue.Count - 1]);
|
||||
return Mathf.Max(1, Mathf.CeilToInt((float)secs / 30f)); // 1 кристалл за 30 сек
|
||||
}
|
||||
}
|
||||
|
||||
public void SpeedUpTraining()
|
||||
{
|
||||
if (Data.training.Count == 0) return;
|
||||
if (Training.Count == 0) return;
|
||||
if (!Pay(new[] { new RewardItem(ResourceType.Gems, SpeedUpCost) })) return;
|
||||
foreach (var o in Data.training) o.endTicks = Now;
|
||||
ProcessTraining();
|
||||
Army.FinishAllTraining(GameClock.UnixNowMs);
|
||||
}
|
||||
|
||||
// Раз в секунду: Армия заканчивает заказы обучения и лечение раненых, срок которых наступил (и пока игра была закрыта)
|
||||
void ProcessTraining()
|
||||
{
|
||||
bool any = false;
|
||||
while (Data.training.Count > 0 && Data.training[0].endTicks <= Now)
|
||||
{
|
||||
var o = Data.training[0];
|
||||
Data.training.RemoveAt(0);
|
||||
if (o.recruited && Population != null)
|
||||
{
|
||||
// Люди из Казарм переходят в Армию одним действием: не теряются и не дублируются
|
||||
if (!Population.CompleteTraining(o.troopIndex, o.count))
|
||||
{
|
||||
Debug.LogWarning($"[ВОЙСКА] Армия не приняла {o.count} × {GameData.Troops[o.troopIndex].name} -- новобранцы остаются в Казармах");
|
||||
continue; // воинов не прибавилось -- и в статистику не идёт
|
||||
}
|
||||
}
|
||||
else Army.AddTroops(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();
|
||||
if (!GameServices.TryGet(out IArmy army)) return;
|
||||
long now = GameClock.UnixNowMs;
|
||||
army.ProcessTraining(now);
|
||||
army.ProcessHealing(now);
|
||||
army.ProcessMissing(now);
|
||||
}
|
||||
|
||||
// Заказ закончен (событие Армии, один раз на заказ): статистика заданий и всплывашка
|
||||
public void OnTroopsTrained(TroopsTrainedEvent e)
|
||||
{
|
||||
Data.troopsTrained += e.Count;
|
||||
Data.dailyTroops += e.Count;
|
||||
Data.eventTroopRemainder += e.Count;
|
||||
AddEventPoints(Data.eventTroopRemainder / 5);
|
||||
Data.eventTroopRemainder %= 5;
|
||||
if (e.Type >= 0 && e.Type < GameData.Troops.Count) UIKit.Toast("Обучено: " + e.Count + " × " + GameData.Troops[e.Type].name);
|
||||
MarkChanged();
|
||||
}
|
||||
|
||||
// Потери в бою: убираем процент от каждого типа войск
|
||||
|
||||
@@ -69,6 +69,8 @@ public class ProgressData
|
||||
public List<string> claimedQuests = new List<string>();
|
||||
public int dailyDay = -1;
|
||||
public List<string> claimedDaily = new List<string>();
|
||||
// Кристаллы наград заданий, которым не хватило места (регламент 05.11): "m:<id задания>|сколько"
|
||||
public List<string> crystalWaiting = new List<string>();
|
||||
public float[] dailyCollected = new float[8];
|
||||
public int dailyBattles;
|
||||
public int dailyTroops;
|
||||
@@ -97,7 +99,7 @@ public class ProgressData
|
||||
// из старых сохранений: при первой загрузке его читает App/GameModules, потом оно обнуляется.
|
||||
public int[] troops = new int[4];
|
||||
public bool armyInModule; // войска уже перенесены в модуль Армии
|
||||
public List<TrainingOrder> training = new List<TrainingOrder>();
|
||||
public List<TrainingOrder> training = new List<TrainingOrder>(); // УСТАРЕЛО: очередь у Армии (save.json, "army"); только для переноса старых сохранений
|
||||
|
||||
// --- Бой (кампания) ---
|
||||
public int battleStage = 1;
|
||||
|
||||
@@ -32,7 +32,9 @@ public class QuestsPanel : BasePanel
|
||||
var shown = new List<QuestDef>();
|
||||
int claimedCount = 0;
|
||||
|
||||
// Сначала готовые к получению, потом в процессе. В основных -- не больше 6 активных.
|
||||
// Сначала кристаллы, которые ждут места (регламент 05.11), потом готовые к получению, потом в процессе.
|
||||
// В основных -- не больше 6 активных.
|
||||
foreach (var q in source) if (GP.IsQuestClaimed(q, daily) && GP.QuestGemsWaiting(q, daily) > 0) shown.Add(q);
|
||||
foreach (var q in source) if (!GP.IsQuestClaimed(q, daily) && GP.IsQuestDone(q, daily)) shown.Add(q);
|
||||
foreach (var q in source)
|
||||
{
|
||||
@@ -62,6 +64,15 @@ public class QuestsPanel : BasePanel
|
||||
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);
|
||||
int waiting = GP.QuestGemsWaiting(q, daily);
|
||||
if (waiting > 0)
|
||||
{
|
||||
// Награда получена, кроме кристаллов: им не было места (хранилище 50). Ждут, не сгорают
|
||||
var wait = UIKit.Text(row, $"Ждут места: {waiting} кристаллов (хранилище {GameProgress.GemsHave:0} / {GameProgress.GemsCap:0})", 24, UIKit.Theme.Gold);
|
||||
UIKit.Size(wait, -1, -1, 1);
|
||||
UIKit.Button(row, "Забрать", UIKit.Theme.Green, () => GP.ClaimQuest(q, daily), 220, 80);
|
||||
return;
|
||||
}
|
||||
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,
|
||||
|
||||
@@ -4,7 +4,8 @@ using UnityEngine.UI;
|
||||
using TMPro;
|
||||
|
||||
// ВОЙСКА: армия, очередь обучения (идёт и когда игра закрыта), найм воинов за ресурсы,
|
||||
// ускорение за кристаллы. Новые типы войск открываются уровнем Ратуши.
|
||||
// ускорение за кристаллы. Найм -- карточки войск в ряд (листаются вбок): иконка, сила, цена,
|
||||
// против кого силён и слаб (из правил боя). Новые типы войск открывают уровни Ратуши и Казарм.
|
||||
public class TroopsPanel : BasePanel
|
||||
{
|
||||
readonly List<Image> queueBars = new List<Image>();
|
||||
@@ -13,6 +14,23 @@ public class TroopsPanel : BasePanel
|
||||
|
||||
static readonly int[] Amounts = { 1, 10, 50 };
|
||||
|
||||
int shownBarracks; // сколько Казарм было, когда окно рисовалось: достроились -- перерисовать
|
||||
TextMeshProUGUI barracksBuildingLeft; // «Осталось: 0:45», пока Казармы строятся
|
||||
|
||||
// Казармы, которые ещё строятся: сколько секунд до ближайшего конца; -1 -- таких нет
|
||||
static double BarracksConstructionSecondsLeft()
|
||||
{
|
||||
double best = -1;
|
||||
foreach (var t in BuildTimer.All)
|
||||
{
|
||||
if (t == null || !t.IsConstruction) continue;
|
||||
var id = t.GetComponent<PlacedBuildingId>();
|
||||
if (id == null || id.buildingName != BarracksBootstrap.BarracksName) continue;
|
||||
if (best < 0 || t.SecondsLeft < best) best = t.SecondsLeft;
|
||||
}
|
||||
return best;
|
||||
}
|
||||
|
||||
protected override void Build(RectTransform root)
|
||||
{
|
||||
var d = GP.Data;
|
||||
@@ -31,6 +49,27 @@ public class TroopsPanel : BasePanel
|
||||
ProfilePanel.KV(army, "Можно нанять из жителей", population.Recruitable.ToString("N0") +
|
||||
" (резерв " + population.CivilianReserve.ToString("N0") + ")");
|
||||
}
|
||||
// Раненые (решения 31.10, п. 7): лечатся в госпитале или ждут места; не воюют, но считаются людьми
|
||||
IArmy hurt = GameServices.Get<IArmy>();
|
||||
if (hurt.WoundedTotal > 0)
|
||||
{
|
||||
int waiting = 0, healingNow = 0;
|
||||
long readyMs = long.MaxValue;
|
||||
for (int t = 0; t < hurt.TypeCount; t++) waiting += hurt.GetWoundedWaiting(t);
|
||||
foreach (var h in hurt.Healing) { healingNow += h.Count; readyMs = System.Math.Min(readyMs, h.EndMs); }
|
||||
string next = readyMs != long.MaxValue
|
||||
? ", ближайшие вылечатся через " + GameData.FormatTime(System.Math.Max(0, readyMs - GameClock.UnixNowMs) / 1000.0)
|
||||
: "";
|
||||
ProfilePanel.KV(army, "Раненые", hurt.WoundedTotal.ToString("N0") + " (лечатся " + healingNow + ", ждут места " + waiting + ")");
|
||||
ProfilePanel.KV(army, "Госпиталь", "мест " + hurt.HospitalBeds + next);
|
||||
}
|
||||
if (hurt.MissingTotal > 0)
|
||||
{
|
||||
long windowEnd = 0;
|
||||
foreach (var m in hurt.Missing) windowEnd = System.Math.Max(windowEnd, m.EndMs);
|
||||
ProfilePanel.KV(army, "Пропали без вести", hurt.MissingTotal.ToString("N0") + " (могут вернуться ещё " +
|
||||
GameData.FormatTime(System.Math.Max(0, windowEnd - GameClock.UnixNowMs) / 1000.0) + ")");
|
||||
}
|
||||
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);
|
||||
@@ -51,31 +90,45 @@ public class TroopsPanel : BasePanel
|
||||
GarrisonButton(gRow, troopsArmy, "Все", troopsArmy.SoldiersAtHome);
|
||||
|
||||
// --- Нет казарм -- нанимать негде ---
|
||||
shownBarracks = Barracks.Count;
|
||||
barracksBuildingLeft = null;
|
||||
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, () =>
|
||||
double left = BarracksConstructionSecondsLeft();
|
||||
if (left >= 0)
|
||||
{
|
||||
if (BuildModeController.Instance != null) BuildModeController.Instance.SetMode(true);
|
||||
else UIKit.Toast("Нажмите кнопку СТРОЙКА");
|
||||
}, -1, 96, 32);
|
||||
// Казармы поставлены, но ещё строятся (05.11: в 3D их было не отличить от готовых)
|
||||
UIKit.Text(warn, "Казармы строятся. Когда стройка закончится, здесь можно будет нанимать войска.", 28, UIKit.Theme.Text);
|
||||
barracksBuildingLeft = UIKit.Text(warn, "Осталось: " + GameData.FormatTime(left), 28, UIKit.Theme.Text);
|
||||
}
|
||||
else
|
||||
{
|
||||
UIKit.Text(warn, "Нанимать войска можно только в Казармах. Постройте их на свободном участке.", 28, UIKit.Theme.Text);
|
||||
UIKit.Button(warn, "ПОСТРОИТЬ КАЗАРМЫ", UIKit.Theme.Green, () =>
|
||||
{
|
||||
if (City3DController.Instance != null) City3DController.Instance.PlaceBuilding("Казармы"); // 3D-город
|
||||
else 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)
|
||||
var training = GP.Training;
|
||||
ProfilePanel.Header(queue, "Обучение (" + training.Count + " / " + Barracks.QueueSize + ") • казарм: " + Barracks.Count);
|
||||
if (training.Count == 0)
|
||||
{
|
||||
UIKit.Text(queue, "Никто не обучается. Выберите войска ниже.", 26, UIKit.Theme.TextDim);
|
||||
}
|
||||
else
|
||||
{
|
||||
foreach (var o in d.training)
|
||||
foreach (var o in training)
|
||||
{
|
||||
var def = GameData.Troops[o.troopIndex];
|
||||
var def = GameData.Troops[o.Type];
|
||||
var r = UIKit.Row(queue, 44, 10);
|
||||
var n = UIKit.Text(r, def.name + " × " + o.count, 28);
|
||||
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);
|
||||
@@ -86,37 +139,133 @@ public class TroopsPanel : BasePanel
|
||||
}
|
||||
Tick();
|
||||
|
||||
// --- Типы войск ---
|
||||
for (int i = 0; i < GameData.Troops.Count; i++)
|
||||
// --- Типы войск: карточки в ряд, листаются вбок ---
|
||||
var troopsCard = UIKit.Card(list, UIKit.Theme.CardDark);
|
||||
ProfilePanel.Header(troopsCard, "Найм войск");
|
||||
UIKit.Text(troopsCard, "Листайте карточки вбок →", 22, UIKit.Theme.TextDim);
|
||||
var strip = HorizontalStrip(troopsCard, CardHeight);
|
||||
for (int i = 0; i < GameData.Troops.Count; i++) TroopCard(strip, i);
|
||||
}
|
||||
|
||||
// ---------------- Карточка войска ----------------
|
||||
// Место под иконку: пока квадрат цвета войска с первой буквой. Картинку поставим в TroopIcon, когда будет арт.
|
||||
// "Силён / слаб против" берётся из правил боя (контры в модуле Боя и BattleConfig), а не пишется руками:
|
||||
// поменяют контры -- поменяется и текст.
|
||||
|
||||
const float CardWidth = 340f;
|
||||
const float CardHeight = 640f;
|
||||
|
||||
void TroopCard(Transform strip, int i)
|
||||
{
|
||||
int idx = i;
|
||||
var def = GameData.Troops[i];
|
||||
bool unlocked = GP.TroopUnlocked(i);
|
||||
|
||||
var card = UIKit.Card(strip, unlocked ? UIKit.Theme.Card : UIKit.Theme.CardDark, 16, 8);
|
||||
UIKit.Size(card, CardWidth, -1);
|
||||
|
||||
var iconRow = UIKit.Row(card, 130, 0);
|
||||
var lpad = UIKit.Make("Pad", iconRow); UIKit.Size(lpad, -1, -1, 1);
|
||||
TroopIcon(iconRow, i, unlocked);
|
||||
var rpad = UIKit.Make("Pad", iconRow); UIKit.Size(rpad, -1, -1, 1);
|
||||
|
||||
UIKit.Text(card, def.name, 32, UIKit.Theme.Text, TextAlignmentOptions.Center).fontStyle = FontStyles.Bold;
|
||||
UIKit.Text(card, "в армии: " + GP.TroopCount(i).ToString("N0"), 22, UIKit.Theme.TextDim, TextAlignmentOptions.Center);
|
||||
UIKit.Text(card, "Сила " + def.power + " • " + def.secondsPerUnit + " сек/воин", 22, UIKit.Theme.Text, TextAlignmentOptions.Center);
|
||||
UIKit.Text(card, GameData.RewardsText(def.cost), 22, UIKit.Theme.TextDim, TextAlignmentOptions.Center);
|
||||
|
||||
string strong, weak;
|
||||
CounterText(i, out strong, out weak);
|
||||
if (strong.Length > 0) UIKit.Text(card, "<color=#9be59b>Силён против:</color> " + strong, 22, UIKit.Theme.Text);
|
||||
if (weak.Length > 0) UIKit.Text(card, "<color=#ff8a80>Слаб против:</color> " + weak, 22, UIKit.Theme.Text);
|
||||
UIKit.Text(card, def.description, 20, UIKit.Theme.TextDim);
|
||||
|
||||
var fill = UIKit.Make("Fill", card); UIKit.Size(fill, -1, -1).flexibleHeight = 1;
|
||||
|
||||
if (!unlocked)
|
||||
{
|
||||
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>в армии: " + GP.TroopCount(i).ToString("N0") + "</size></color>", 32).fontStyle = FontStyles.Bold;
|
||||
UIKit.Text(info, def.description, 22, UIKit.Theme.TextDim);
|
||||
UIKit.Text(info, "Сила " + def.power + " • " + def.secondsPerUnit + " сек/воин • " + GameData.RewardsText(def.cost), 22, UIKit.Theme.TextDim);
|
||||
|
||||
if (!unlocked)
|
||||
{
|
||||
UIKit.Text(card, "Откроется: " + GP.TroopLockText(i), 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 && GP.Recruitable >= a;
|
||||
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);
|
||||
}
|
||||
UIKit.Text(card, "Откроется:\n" + GP.TroopLockText(i), 24, UIKit.Theme.Gold, TextAlignmentOptions.Center);
|
||||
return;
|
||||
}
|
||||
|
||||
var btns = UIKit.Row(card, 70, 8);
|
||||
foreach (int amount in Amounts)
|
||||
{
|
||||
int a = amount;
|
||||
bool can = Barracks.Count > 0 && GP.CanAfford(def.cost, a) && GP.Training.Count < Barracks.QueueSize && GP.Recruitable >= a;
|
||||
var b = UIKit.Button(btns, "+" + a, can ? UIKit.Theme.Green : UIKit.Theme.Gray, () => GP.Train(idx, a), -1, 70, 26);
|
||||
UIKit.Size(b, -1, 70, 1);
|
||||
}
|
||||
}
|
||||
|
||||
// Иконка войска. Сейчас -- заглушка (цветной квадрат с буквой); сюда же потом подставим спрайт.
|
||||
static void TroopIcon(Transform parent, int i, bool unlocked)
|
||||
{
|
||||
var def = GameData.Troops[i];
|
||||
UIKit.Icon(parent, unlocked ? def.color : UIKit.Theme.Gray, def.name.Substring(0, 1), 120);
|
||||
}
|
||||
|
||||
static readonly string[] TroopNamesGenitive = { "пехоты", "лучников", "кавалерии", "осадных" };
|
||||
|
||||
// Контры из правил боя: против кого этот тип сильнее и кто сильнее против него
|
||||
static void CounterText(int type, out string strong, out string weak)
|
||||
{
|
||||
var s = new List<string>();
|
||||
var w = new List<string>();
|
||||
var battle = GameServices.TryGet(out IBattleSystem b) ? b as BattleSystem : null;
|
||||
if (battle != null)
|
||||
for (int j = 0; j < TroopType.Count && j < GameData.Troops.Count; j++)
|
||||
{
|
||||
if (j == type) continue;
|
||||
int mine = battle.Counter(type, j), theirs = battle.Counter(j, type);
|
||||
if (mine > 0) s.Add(Genitive(j) + " (+" + mine + "% силы)");
|
||||
if (theirs > 0) w.Add(Genitive(j));
|
||||
}
|
||||
if (type == TroopType.Siege)
|
||||
{
|
||||
var cfg = BattleConfig.Load();
|
||||
if (cfg.siegeVsWallsPercent > 0) s.Add("стен при штурме (+" + cfg.siegeVsWallsPercent + "%)");
|
||||
if (cfg.siegeInFieldPercent < 0) w.Add("всех в поле и в обороне (" + cfg.siegeInFieldPercent + "%)");
|
||||
}
|
||||
strong = string.Join(", ", s.ToArray());
|
||||
weak = string.Join(", ", w.ToArray());
|
||||
}
|
||||
|
||||
static string Genitive(int type)
|
||||
{
|
||||
return type >= 0 && type < TroopNamesGenitive.Length ? TroopNamesGenitive[type] : GameData.Troops[type].name;
|
||||
}
|
||||
|
||||
// Горизонтальная лента с прокруткой вбок внутри общего вертикального списка.
|
||||
// Вертикальный свайп по ленте прокручивает весь список (NestedScrollRect).
|
||||
static RectTransform HorizontalStrip(Transform parent, float height)
|
||||
{
|
||||
var root = UIKit.Make("TroopStrip", parent);
|
||||
UIKit.Size(root, -1, height);
|
||||
root.gameObject.AddComponent<Image>().color = new Color(0, 0, 0, 0);
|
||||
var scroll = root.gameObject.AddComponent<NestedScrollRect>();
|
||||
scroll.horizontal = true;
|
||||
scroll.vertical = false;
|
||||
scroll.movementType = ScrollRect.MovementType.Elastic;
|
||||
scroll.scrollSensitivity = 40f;
|
||||
|
||||
var viewport = UIKit.Stretch(UIKit.Make("Viewport", root));
|
||||
viewport.gameObject.AddComponent<RectMask2D>();
|
||||
|
||||
var content = UIKit.Make("Content", viewport);
|
||||
content.anchorMin = new Vector2(0, 0); content.anchorMax = new Vector2(0, 1);
|
||||
content.pivot = new Vector2(0, 0.5f);
|
||||
content.offsetMin = Vector2.zero; content.offsetMax = Vector2.zero;
|
||||
var h = content.gameObject.AddComponent<HorizontalLayoutGroup>();
|
||||
h.spacing = 14;
|
||||
h.padding = new RectOffset(4, 4, 4, 4);
|
||||
h.childControlWidth = true; h.childControlHeight = true;
|
||||
h.childForceExpandWidth = false; h.childForceExpandHeight = true;
|
||||
content.gameObject.AddComponent<ContentSizeFitter>().horizontalFit = ContentSizeFitter.FitMode.PreferredSize;
|
||||
|
||||
scroll.viewport = viewport;
|
||||
scroll.content = content;
|
||||
return content;
|
||||
}
|
||||
|
||||
// Окно перестраивается само: Армия публикует TroopsChangedEvent, GameProgress сообщает об изменении
|
||||
@@ -130,14 +279,20 @@ public class TroopsPanel : BasePanel
|
||||
|
||||
protected override void Tick()
|
||||
{
|
||||
var d = GP.Data;
|
||||
for (int i = 0; i < queueBars.Count && i < d.training.Count; i++)
|
||||
if (Barracks.Count != shownBarracks) { RequestRebuild(); return; } // Казармы достроились (или снесены)
|
||||
if (barracksBuildingLeft != null)
|
||||
{
|
||||
var o = d.training[i];
|
||||
double left = BarracksConstructionSecondsLeft();
|
||||
barracksBuildingLeft.text = "Осталось: " + GameData.FormatTime(System.Math.Max(0, left));
|
||||
}
|
||||
var training = GP.Training;
|
||||
for (int i = 0; i < queueBars.Count && i < training.Count; i++)
|
||||
{
|
||||
var o = training[i];
|
||||
UIKit.SetBar(queueBars[i], GP.TrainingProgress(o));
|
||||
queueTimes[i].text = GameData.FormatTime(GP.TrainingSecondsLeft(o));
|
||||
}
|
||||
if (speedUp != null && d.training.Count > 0)
|
||||
if (speedUp != null && training.Count > 0)
|
||||
UIKit.SetButton(speedUp, "Ускорить всё за " + GP.SpeedUpCost + " кристаллов", true);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
using UnityEngine;
|
||||
using UnityEngine.EventSystems;
|
||||
using UnityEngine.UI;
|
||||
|
||||
// Прокрутка внутри прокрутки (например, лента карточек вбок внутри вертикального списка).
|
||||
// Свайп в сторону своей оси листает эту ленту, свайп поперёк и колесо мыши -- родительский список.
|
||||
// Без этого палец на ленте "застревает": общий список не прокручивается.
|
||||
public class NestedScrollRect : ScrollRect
|
||||
{
|
||||
ScrollRect parentScroll;
|
||||
bool routeToParent;
|
||||
|
||||
protected override void Start()
|
||||
{
|
||||
base.Start();
|
||||
if (transform.parent != null) parentScroll = transform.parent.GetComponentInParent<ScrollRect>();
|
||||
}
|
||||
|
||||
bool CrossAxis(PointerEventData e)
|
||||
{
|
||||
float dx = Mathf.Abs(e.delta.x), dy = Mathf.Abs(e.delta.y);
|
||||
return horizontal && !vertical ? dy > dx : vertical && !horizontal ? dx > dy : false;
|
||||
}
|
||||
|
||||
public override void OnInitializePotentialDrag(PointerEventData e)
|
||||
{
|
||||
if (parentScroll != null) parentScroll.OnInitializePotentialDrag(e);
|
||||
base.OnInitializePotentialDrag(e);
|
||||
}
|
||||
|
||||
public override void OnBeginDrag(PointerEventData e)
|
||||
{
|
||||
routeToParent = parentScroll != null && CrossAxis(e);
|
||||
if (routeToParent) parentScroll.OnBeginDrag(e);
|
||||
else base.OnBeginDrag(e);
|
||||
}
|
||||
|
||||
public override void OnDrag(PointerEventData e)
|
||||
{
|
||||
if (routeToParent) parentScroll.OnDrag(e);
|
||||
else base.OnDrag(e);
|
||||
}
|
||||
|
||||
public override void OnEndDrag(PointerEventData e)
|
||||
{
|
||||
if (routeToParent) parentScroll.OnEndDrag(e);
|
||||
else base.OnEndDrag(e);
|
||||
routeToParent = false;
|
||||
}
|
||||
|
||||
public override void OnScroll(PointerEventData e)
|
||||
{
|
||||
if (parentScroll != null) parentScroll.OnScroll(e);
|
||||
else base.OnScroll(e);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 59a54e4603712204d81f167a1c02873f
|
||||
@@ -9,6 +9,38 @@ using System.Text;
|
||||
// Один солдат -- в одном месте: либо в городе (troops), либо в походе (marches).
|
||||
// Поход забирает воинов из города и запоминает состав, возвращение раздаёт выживших по тем же типам.
|
||||
// Гарнизон (garrison) -- часть воинов города: они в troops, но в поход не уходят. garrison[i] <= troops[i] всегда.
|
||||
// Очередь обучения (training) -- тоже здесь (решения по 8 вопросам Саши, 31.10, п. 8): заказы, сроки, завершение.
|
||||
// Раненые (решения 31.10, п. 7): ждут места (woundedWaiting) или лечатся (healing) -- не в troops и не в походах.
|
||||
// Числа госпиталя (регламент 31.10, ТЕСТ). В игре -- из таблицы HospitalConfig.
|
||||
public sealed class HospitalRules
|
||||
{
|
||||
public int TemporaryBeds = 5; // мест, пока здания госпиталя нет (решения 01.11, п. 3); после постройки -- штатные, не вместе
|
||||
public int[] HealGameHours = { 4, 4, 6, 8 }; // лечение одного воина: пехота и лучники 4, кавалерия 6, осадные 8 ИГРОВЫХ часов
|
||||
public long GameHourMs = 5 * 60 * 1000; // 1 игровой час = 5 реальных минут
|
||||
// Цена лечения одного воина по типам (решения 01.11, п. 3: 1 / 1 / 2 / 3 золота -- в таблице HospitalConfig); пусто -- без цены
|
||||
public ResourceAmount[][] HealCostPerSoldier = null;
|
||||
|
||||
public static HospitalRules Default => new HospitalRules();
|
||||
|
||||
public long HealMs(int type)
|
||||
{
|
||||
int hours = HealGameHours != null && type >= 0 && type < HealGameHours.Length ? Math.Max(0, HealGameHours[type]) : 4;
|
||||
return hours * GameHourMs;
|
||||
}
|
||||
}
|
||||
|
||||
// Пропавшие без вести (решения 01.11, п. 5). Доля возвращающихся и формула -- НЕ УТВЕРЖДЕНЫ (ТЕСТ, таблица HospitalConfig)
|
||||
public sealed class MissingRules
|
||||
{
|
||||
public int ReturnPercent = 50; // сколько пропавших вернётся (утверждён диапазон 10-90%, формула открыта)
|
||||
public int WoundedPercent = 30; // из вернувшихся -- раненые (утверждено: 70% здоровыми, 30% ранеными)
|
||||
public int WindowGameHours = 6; // окно возвращения после обратного пути, ИГРОВЫХ часов
|
||||
public long GameHourMs = 5 * 60 * 1000;
|
||||
public int MaxGroups = 4; // возвращаются 2-4 группами (меньше, если пропавших мало)
|
||||
|
||||
public static MissingRules Default => new MissingRules();
|
||||
}
|
||||
|
||||
public sealed class ArmyService : IArmy, ISaveParticipant
|
||||
{
|
||||
public const int Types = 4; // пехота, лучники, кавалерия, осадные -- порядок как в GameData.Troops
|
||||
@@ -23,6 +55,51 @@ public sealed class ArmyService : IArmy, ISaveParticipant
|
||||
private readonly int[] garrison = new int[Types];
|
||||
private readonly List<MarchUnits> marches = new List<MarchUnits>();
|
||||
|
||||
private class TrainingOrder
|
||||
{
|
||||
public int id, type, count;
|
||||
public long startMs, endMs;
|
||||
public bool recruited; // под заказ взяты жители (Население). false -- старый заказ, людей не брали
|
||||
}
|
||||
|
||||
private class HealingBatch
|
||||
{
|
||||
public int id, type, count;
|
||||
public long startMs, endMs;
|
||||
}
|
||||
|
||||
private readonly int[] woundedWaiting = new int[Types];
|
||||
private readonly List<HealingBatch> healing = new List<HealingBatch>();
|
||||
private readonly List<HealingInfo> healingView = new List<HealingInfo>();
|
||||
private int nextHealId = 1;
|
||||
private readonly HospitalRules hospital;
|
||||
|
||||
// Пропавшие без вести одного боя: группы возвращения рассчитаны сразу, при постановке (детерминированно)
|
||||
private class MissingEvent
|
||||
{
|
||||
public int id, marchId;
|
||||
public long windowStartMs, endMs;
|
||||
public int[] remaining = new int[Types]; // ещё не вернулись и не списаны
|
||||
public List<MissingGroup> groups = new List<MissingGroup>();
|
||||
}
|
||||
|
||||
private class MissingGroup
|
||||
{
|
||||
public int index;
|
||||
public long timeMs;
|
||||
public int[] healthy = new int[Types], wounded = new int[Types];
|
||||
}
|
||||
|
||||
private readonly List<MissingEvent> missing = new List<MissingEvent>();
|
||||
private readonly List<MissingInfo> missingView = new List<MissingInfo>();
|
||||
private readonly HashSet<int> missingMarches = new HashSet<int>(); // по каким походам пропавшие уже отмечены
|
||||
private int nextMissingId = 1;
|
||||
private readonly MissingRules missingRules;
|
||||
|
||||
private readonly List<TrainingOrder> training = new List<TrainingOrder>();
|
||||
private readonly List<TrainingOrderInfo> trainingView = new List<TrainingOrderInfo>();
|
||||
private int nextOrderId = 1;
|
||||
|
||||
// Секция "army" была в сохранении -- старые войска из progress.json переносить не нужно
|
||||
public bool RestoredFromSave { get; private set; }
|
||||
|
||||
@@ -30,6 +107,21 @@ public sealed class ArmyService : IArmy, ISaveParticipant
|
||||
|
||||
public int TypeCount => Types;
|
||||
|
||||
// Слоты похода: база из таблицы MarchConfig (регламент 31.10: 2, ТЕСТ). Бонус «Военной логистики» -- позже.
|
||||
private readonly int baseMarchSlots;
|
||||
|
||||
public ArmyService(int baseMarchSlots = 2, HospitalRules hospital = null, MissingRules missingRules = null)
|
||||
{
|
||||
this.baseMarchSlots = Math.Max(1, baseMarchSlots);
|
||||
this.hospital = hospital ?? HospitalRules.Default;
|
||||
this.missingRules = missingRules ?? MissingRules.Default;
|
||||
}
|
||||
|
||||
public HospitalRules Hospital => hospital;
|
||||
|
||||
public int MarchSlots => baseMarchSlots;
|
||||
public int ActiveMarches => marches.Count;
|
||||
|
||||
public int GetTroops(int type)
|
||||
{
|
||||
return type >= 0 && type < Types ? troops[type] : 0;
|
||||
@@ -100,6 +192,8 @@ public sealed class ArmyService : IArmy, ISaveParticipant
|
||||
{
|
||||
// Поход с таким номером уже есть: перезапись потеряла бы его воинов
|
||||
if (marches.Exists(m => m.marchId == marchId)) return false;
|
||||
// Все слоты похода заняты. Походы из старых сохранений сверх лимита дойдут, но новых не будет
|
||||
if (marches.Count >= MarchSlots) return false;
|
||||
|
||||
// В поход -- только свободные воины, гарнизон остаётся в городе
|
||||
var free = new int[Types];
|
||||
@@ -139,17 +233,358 @@ public sealed class ArmyService : IArmy, ISaveParticipant
|
||||
// Выжившие точно по типам: бой посчитал потери каждого типа. Больше, чем ушло, вернуться не может.
|
||||
// Правила те же, что у ReturnFromMarch: только известный поход, только один раз.
|
||||
public bool ReturnUnits(int marchId, int[] survivorUnits)
|
||||
{
|
||||
return ReturnUnits(marchId, survivorUnits, null, 0);
|
||||
}
|
||||
|
||||
// Выжившие -- в город, раненые -- в госпиталь (решения 31.10, п. 7). Остальные из ушедших погибли.
|
||||
// Один солдат в одном месте: выжившие + раненые не больше, чем ушло, по каждому типу.
|
||||
public bool ReturnUnits(int marchId, int[] survivorUnits, int[] woundedUnits, long nowMs)
|
||||
{
|
||||
MarchUnits entry = marches.Find(m => m.marchId == marchId);
|
||||
if (entry == null) return false;
|
||||
|
||||
for (int i = 0; i < Types && survivorUnits != null && i < survivorUnits.Length; i++)
|
||||
troops[i] += Math.Max(0, Math.Min(survivorUnits[i], entry.units[i]));
|
||||
bool anyWounded = false;
|
||||
if (woundedUnits != null) ProcessHealing(nowMs); // сначала освободить места, которые освободились раньше
|
||||
for (int i = 0; i < Types; i++)
|
||||
{
|
||||
int alive = survivorUnits != null && i < survivorUnits.Length ? Math.Max(0, Math.Min(survivorUnits[i], entry.units[i])) : 0;
|
||||
int hurt = woundedUnits != null && i < woundedUnits.Length ? Math.Max(0, Math.Min(woundedUnits[i], entry.units[i] - alive)) : 0;
|
||||
troops[i] += alive;
|
||||
woundedWaiting[i] += hurt;
|
||||
if (hurt > 0) anyWounded = true;
|
||||
}
|
||||
marches.Remove(entry);
|
||||
if (anyWounded) FillBeds(nowMs);
|
||||
Changed();
|
||||
return true;
|
||||
}
|
||||
|
||||
// ---------------- Раненые и лечение ----------------
|
||||
|
||||
public int WoundedTotal
|
||||
{
|
||||
get
|
||||
{
|
||||
int n = 0;
|
||||
foreach (var w in woundedWaiting) n += w;
|
||||
foreach (var b in healing) n += b.count;
|
||||
return n;
|
||||
}
|
||||
}
|
||||
|
||||
public int GetAllTroops(int type)
|
||||
{
|
||||
if (type < 0 || type >= Types) return 0;
|
||||
int n = troops[type] + woundedWaiting[type];
|
||||
foreach (var m in marches) n += m.units[type];
|
||||
foreach (var b in healing) if (b.type == type) n += b.count;
|
||||
return n;
|
||||
}
|
||||
|
||||
// ---------------- Пропавшие без вести ----------------
|
||||
|
||||
public int MissingTotal
|
||||
{
|
||||
get { int n = 0; foreach (var e in missing) foreach (var r in e.remaining) n += r; return n; }
|
||||
}
|
||||
|
||||
public IReadOnlyList<MissingInfo> Missing
|
||||
{
|
||||
get
|
||||
{
|
||||
missingView.Clear();
|
||||
foreach (var e in missing)
|
||||
{
|
||||
int n = 0;
|
||||
foreach (var r in e.remaining) n += r;
|
||||
missingView.Add(new MissingInfo { Id = e.id, Count = n, WindowStartMs = e.windowStartMs, EndMs = e.endMs });
|
||||
}
|
||||
return missingView;
|
||||
}
|
||||
}
|
||||
|
||||
public bool MarkMissing(int marchId, int[] missingUnits, long battleMs, long travelMs)
|
||||
{
|
||||
if (missingUnits == null || missingMarches.Contains(marchId)) return false;
|
||||
MarchUnits entry = marches.Find(m => m.marchId == marchId);
|
||||
if (entry == null) return false;
|
||||
|
||||
var e = new MissingEvent { id = nextMissingId++, marchId = marchId };
|
||||
int total = 0;
|
||||
for (int i = 0; i < Types && i < missingUnits.Length; i++)
|
||||
{
|
||||
int n = Math.Max(0, Math.Min(missingUnits[i], entry.units[i]));
|
||||
entry.units[i] -= n; // из похода -- в пропавшие: один солдат в одном месте
|
||||
e.remaining[i] = n;
|
||||
total += n;
|
||||
}
|
||||
if (total == 0) { nextMissingId--; return false; }
|
||||
|
||||
e.windowStartMs = battleMs + Math.Max(0, travelMs);
|
||||
e.endMs = e.windowStartMs + Math.Max(0, missingRules.WindowGameHours) * missingRules.GameHourMs;
|
||||
PlanGroups(e, battleMs);
|
||||
missing.Add(e);
|
||||
missingMarches.Add(marchId);
|
||||
Changed();
|
||||
return true;
|
||||
}
|
||||
|
||||
// Кто вернётся и когда: доля возвращающихся по типам (дробная часть -- бросок), 2-4 группы в окне,
|
||||
// в каждой -- 70% здоровых, 30% раненых (дробная часть -- бросок). Тип войск сохраняется.
|
||||
void PlanGroups(MissingEvent e, long seedTime)
|
||||
{
|
||||
ulong seed = (ulong)e.marchId * 0x9E3779B97F4A7C15UL ^ (ulong)seedTime;
|
||||
int roll = 0;
|
||||
bool Chance(long num, long den) => den > 0 && (long)(Next(seed, roll++) % (ulong)den) < num;
|
||||
|
||||
var back = new int[Types];
|
||||
int returning = 0;
|
||||
for (int i = 0; i < Types; i++)
|
||||
{
|
||||
long num = (long)e.remaining[i] * Math.Max(0, Math.Min(100, missingRules.ReturnPercent));
|
||||
back[i] = (int)(num / 100) + (Chance(num % 100, 100) ? 1 : 0);
|
||||
returning += back[i];
|
||||
}
|
||||
if (returning == 0) return;
|
||||
|
||||
int groups = Math.Max(1, Math.Min(Math.Max(2, missingRules.MaxGroups), returning)); // мало вернувшихся -- групп меньше
|
||||
if (returning >= 2) groups = Math.Max(2, Math.Min(groups, 2 + (int)(Next(seed, roll++) % (ulong)(Math.Max(2, missingRules.MaxGroups) - 1))));
|
||||
long window = Math.Max(1, e.endMs - e.windowStartMs);
|
||||
for (int g = 0; g < groups; g++)
|
||||
e.groups.Add(new MissingGroup { index = g + 1, timeMs = e.windowStartMs + window * (g + 1) / (groups + 1) });
|
||||
|
||||
// Раздаём вернувшихся по группам по очереди, внутри группы -- здоровые и раненые
|
||||
for (int i = 0; i < Types; i++)
|
||||
for (int k = 0; k < back[i]; k++)
|
||||
{
|
||||
var grp = e.groups[(k + i) % groups];
|
||||
if (Chance(Math.Max(0, Math.Min(100, missingRules.WoundedPercent)), 100)) grp.wounded[i]++;
|
||||
else grp.healthy[i]++;
|
||||
}
|
||||
}
|
||||
|
||||
static ulong Next(ulong seed, int n)
|
||||
{
|
||||
ulong x = seed + 0x9E3779B97F4A7C15UL * (ulong)(n + 1);
|
||||
x = (x ^ (x >> 30)) * 0xBF58476D1CE4E5B9UL;
|
||||
x = (x ^ (x >> 27)) * 0x94D049BB133111EBUL;
|
||||
return x ^ (x >> 31);
|
||||
}
|
||||
|
||||
public int ProcessMissing(long nowMs)
|
||||
{
|
||||
int done = 0;
|
||||
bool changed = false;
|
||||
for (int k = missing.Count - 1; k >= 0; k--)
|
||||
{
|
||||
var e = missing[k];
|
||||
e.groups.Sort((a, b) => a.timeMs.CompareTo(b.timeMs));
|
||||
while (e.groups.Count > 0 && e.groups[0].timeMs <= nowMs && e.groups[0].timeMs <= e.endMs)
|
||||
{
|
||||
var g = e.groups[0];
|
||||
e.groups.RemoveAt(0); // группа возвращается один раз
|
||||
int healthy = 0, wounded = 0;
|
||||
bool anyWounded = false;
|
||||
for (int i = 0; i < Types; i++)
|
||||
{
|
||||
int h = Math.Min(g.healthy[i], e.remaining[i]);
|
||||
int w = Math.Min(g.wounded[i], e.remaining[i] - h);
|
||||
e.remaining[i] -= h + w;
|
||||
troops[i] += h; // исходной армии может уже не быть -- в резерв поселения
|
||||
woundedWaiting[i] += w; // раненые -- в госпиталь по обычным правилам
|
||||
healthy += h; wounded += w;
|
||||
if (w > 0) anyWounded = true;
|
||||
}
|
||||
if (anyWounded) { ProcessHealing(g.timeMs); FillBeds(g.timeMs); }
|
||||
done++;
|
||||
changed = true;
|
||||
EventBus.Publish(new MissingReturnedEvent { MissingId = e.id, Group = g.index, Healthy = healthy, Wounded = wounded, TimeMs = g.timeMs });
|
||||
}
|
||||
if (nowMs >= e.endMs)
|
||||
{
|
||||
int left = 0;
|
||||
foreach (var r in e.remaining) left += r;
|
||||
missing.RemoveAt(k); // окно кончилось: кто не вернулся -- списан, места освобождены
|
||||
changed = true;
|
||||
EventBus.Publish(new MissingWrittenOffEvent { MissingId = e.id, Count = left, TimeMs = e.endMs });
|
||||
}
|
||||
}
|
||||
if (changed) Changed();
|
||||
return done;
|
||||
}
|
||||
|
||||
public int GetWoundedWaiting(int type)
|
||||
{
|
||||
return type >= 0 && type < Types ? woundedWaiting[type] : 0;
|
||||
}
|
||||
|
||||
public IReadOnlyList<HealingInfo> Healing
|
||||
{
|
||||
get
|
||||
{
|
||||
healingView.Clear();
|
||||
foreach (var b in healing)
|
||||
healingView.Add(new HealingInfo { Id = b.id, Type = b.type, Count = b.count, StartMs = b.startMs, EndMs = b.endMs });
|
||||
return healingView;
|
||||
}
|
||||
}
|
||||
|
||||
// Места: от здания госпиталя (Поселение), пока его нет -- временные из таблицы
|
||||
public int HospitalBeds => GameServices.TryGet(out IHospital h) && h.Beds > 0 ? h.Beds : Math.Max(0, hospital.TemporaryBeds);
|
||||
|
||||
public int ProcessHealing(long nowMs)
|
||||
{
|
||||
int done = 0;
|
||||
// По времени: самая ранняя группа заканчивает -- на её место в тот же момент ложатся ждущие
|
||||
while (true)
|
||||
{
|
||||
HealingBatch next = null;
|
||||
foreach (var b in healing)
|
||||
if (b.endMs <= nowMs && (next == null || b.endMs < next.endMs || b.endMs == next.endMs && b.id < next.id)) next = b;
|
||||
if (next == null) break;
|
||||
|
||||
healing.Remove(next); // группа вылечена один раз: повторный расчёт её уже не найдёт
|
||||
troops[next.type] += next.count;
|
||||
done++;
|
||||
EventBus.Publish(new SoldiersHealedEvent { BatchId = next.id, Type = next.type, Count = next.count, TimeMs = next.endMs });
|
||||
FillBeds(next.endMs);
|
||||
}
|
||||
FillBeds(nowMs); // мест могло стать больше (госпиталь улучшили)
|
||||
if (done > 0) Changed();
|
||||
return done;
|
||||
}
|
||||
|
||||
// Положить ждущих на свободные места в момент atMs: по типам по порядку, каждая группа -- свой срок лечения.
|
||||
// Цена лечения (если задана) списывается через Экономику целиком; не хватает -- группа ждёт.
|
||||
void FillBeds(long atMs)
|
||||
{
|
||||
int free = HospitalBeds;
|
||||
foreach (var b in healing) free -= b.count;
|
||||
for (int type = 0; type < Types && free > 0; type++)
|
||||
{
|
||||
int n = Math.Min(free, woundedWaiting[type]);
|
||||
if (n <= 0) continue;
|
||||
if (!PayForHealing(type, n)) continue;
|
||||
woundedWaiting[type] -= n;
|
||||
free -= n;
|
||||
healing.Add(new HealingBatch { id = nextHealId++, type = type, count = n, startMs = atMs, endMs = atMs + hospital.HealMs(type) });
|
||||
}
|
||||
}
|
||||
|
||||
bool PayForHealing(int type, int soldiers)
|
||||
{
|
||||
var perSoldier = hospital.HealCostPerSoldier != null && type < hospital.HealCostPerSoldier.Length ? hospital.HealCostPerSoldier[type] : null;
|
||||
if (perSoldier == null || perSoldier.Length == 0) return true; // цена не утверждена -- без цены
|
||||
if (!GameServices.TryGet(out IEconomy economy)) return false;
|
||||
var cost = new List<ResourceAmount>();
|
||||
foreach (var c in perSoldier) if (c.Amount > 0) cost.Add(new ResourceAmount(c.Type, c.Amount * soldiers));
|
||||
return cost.Count == 0 || economy.TrySpend(cost);
|
||||
}
|
||||
|
||||
// ---------------- Очередь обучения ----------------
|
||||
|
||||
public IReadOnlyList<TrainingOrderInfo> Training
|
||||
{
|
||||
get
|
||||
{
|
||||
trainingView.Clear();
|
||||
foreach (var o in training)
|
||||
trainingView.Add(new TrainingOrderInfo { Id = o.id, Type = o.type, Count = o.count, StartMs = o.startMs, EndMs = o.endMs });
|
||||
return trainingView;
|
||||
}
|
||||
}
|
||||
|
||||
public bool CanQueueTraining(int type, int count, int queueSize, out string error)
|
||||
{
|
||||
error = null;
|
||||
if (type < 0 || type >= Types || count <= 0) error = "Неверный заказ";
|
||||
else if (training.Count >= queueSize) error = "Очередь обучения заполнена. Больше казарм -- длиннее очередь";
|
||||
else if (GameServices.TryGet(out IPopulation population) && population.Recruitable < count)
|
||||
error = population.Recruitable > 0
|
||||
? $"Жителей хватит только на {population.Recruitable} воинов"
|
||||
: $"Некого нанять: свободных жителей нет (резерв {population.CivilianReserve}). Снимите рабочих или стройте Дома";
|
||||
return error == null;
|
||||
}
|
||||
|
||||
public bool TryQueueTraining(int type, int count, long durationMs, int queueSize, long nowMs, out string error)
|
||||
{
|
||||
if (!CanQueueTraining(type, count, queueSize, out error)) return false;
|
||||
|
||||
// Воин -- житель города: люди уходят в Казармы сейчас, в Армию -- когда обучатся
|
||||
bool recruited = false;
|
||||
if (GameServices.TryGet(out IPopulation population))
|
||||
{
|
||||
if (!population.TryRecruit(count)) { error = "Некого нанять: жителей не хватает"; return false; }
|
||||
recruited = true;
|
||||
}
|
||||
|
||||
// Заказы идут друг за другом: новый начинается, когда закончится последний
|
||||
long start = training.Count > 0 ? Math.Max(nowMs, training[training.Count - 1].endMs) : nowMs;
|
||||
training.Add(new TrainingOrder
|
||||
{
|
||||
id = nextOrderId++, type = type, count = count,
|
||||
startMs = start, endMs = start + Math.Max(0, durationMs), recruited = recruited
|
||||
});
|
||||
Changed();
|
||||
return true;
|
||||
}
|
||||
|
||||
public int ProcessTraining(long nowMs)
|
||||
{
|
||||
int done = 0;
|
||||
while (training.Count > 0 && training[0].endMs <= nowMs)
|
||||
{
|
||||
TrainingOrder o = training[0];
|
||||
if (o.recruited)
|
||||
{
|
||||
// Новобранцы переходят из Населения в Армию одним действием (Население само зовёт AddTroops).
|
||||
// Населения ещё нет (запуск) -- подождём: заказ остаётся, никто не теряется.
|
||||
if (!GameServices.TryGet(out IPopulation population)) break;
|
||||
if (!population.CompleteTraining(o.type, o.count))
|
||||
{
|
||||
// Население не нашло этих новобранцев (испорченное сохранение): заказ снимается без воинов --
|
||||
// иначе люди появились бы из ниоткуда, а очередь встала бы навсегда
|
||||
training.RemoveAt(0);
|
||||
Changed();
|
||||
continue;
|
||||
}
|
||||
}
|
||||
else AddTroops(o.type, o.count); // старый заказ: людей под него не брали
|
||||
|
||||
training.RemoveAt(0); // заказ закончен один раз: повторный расчёт его уже не найдёт
|
||||
done++;
|
||||
EventBus.Publish(new TroopsTrainedEvent { OrderId = o.id, Type = o.type, Count = o.count, TimeMs = o.endMs });
|
||||
}
|
||||
return done;
|
||||
}
|
||||
|
||||
public int FinishAllTraining(long nowMs)
|
||||
{
|
||||
foreach (var o in training)
|
||||
{
|
||||
o.endMs = Math.Min(o.endMs, nowMs);
|
||||
o.startMs = Math.Min(o.startMs, o.endMs);
|
||||
}
|
||||
return ProcessTraining(nowMs);
|
||||
}
|
||||
|
||||
// Перенос очереди из старого progress.json (её вёл GameProgress). Время уже переведено в мс часов игры.
|
||||
public void ImportLegacyTraining(IEnumerable<(int type, int count, long startMs, long endMs, bool recruited)> orders)
|
||||
{
|
||||
if (orders == null) return;
|
||||
foreach (var o in orders)
|
||||
{
|
||||
if (o.type < 0 || o.type >= Types || o.count <= 0) continue;
|
||||
training.Add(new TrainingOrder
|
||||
{
|
||||
id = nextOrderId++, type = o.type, count = o.count,
|
||||
startMs = o.startMs, endMs = Math.Max(o.startMs, o.endMs), recruited = o.recruited
|
||||
});
|
||||
}
|
||||
Changed();
|
||||
}
|
||||
|
||||
void ClampGarrison()
|
||||
{
|
||||
for (int i = 0; i < Types; i++) garrison[i] = Math.Max(0, Math.Min(garrison[i], troops[i]));
|
||||
@@ -157,7 +592,8 @@ public sealed class ArmyService : IArmy, ISaveParticipant
|
||||
|
||||
void Changed()
|
||||
{
|
||||
EventBus.Publish(new TroopsChangedEvent { AtHome = SoldiersAtHome, InMarches = TroopsInMarches, Garrison = GarrisonTotal });
|
||||
EventBus.Publish(new TroopsChangedEvent { AtHome = SoldiersAtHome, InMarches = TroopsInMarches, Garrison = GarrisonTotal, Wounded = WoundedTotal,
|
||||
Missing = MissingTotal });
|
||||
}
|
||||
|
||||
// ---------------- Перенос из старого progress.json ----------------
|
||||
@@ -189,6 +625,14 @@ public sealed class ArmyService : IArmy, ISaveParticipant
|
||||
// troops=<пехота>,<лучники>,<кавалерия>,<осадные>
|
||||
// garrison=<пехота>,<лучники>,<кавалерия>,<осадные> (из troops; нет строки -- гарнизона нет)
|
||||
// march=<id похода>:<пехота>,<лучники>,<кавалерия>,<осадные>
|
||||
// train=<id>|<тип>|<сколько>|<начало, мс>|<конец, мс>|<люди взяты 1/0> (по порядку очереди)
|
||||
// trainNext=<номер следующего заказа>
|
||||
// wounded=<пехота>,<лучники>,<кавалерия>,<осадные> (ждут места в госпитале)
|
||||
// heal=<id>|<тип>|<сколько>|<начало, мс>|<конец, мс>
|
||||
// healNext=<номер следующей группы лечения>
|
||||
// missing=<id>|<поход>|<окно с, мс>|<конец, мс>|<осталось по типам через ,>
|
||||
// mgroup=<id пропавших>|<номер группы>|<время, мс>|<здоровые по типам>|<раненые по типам> (ещё не вернувшиеся группы)
|
||||
// missingMarch=<поход> (по нему пропавшие уже отмечены) missingNext=<номер>
|
||||
|
||||
public string SaveKey => "army";
|
||||
|
||||
@@ -199,6 +643,34 @@ public sealed class ArmyService : IArmy, ISaveParticipant
|
||||
sb.Append("garrison=").Append(Join(garrison)).Append('\n');
|
||||
foreach (var m in marches)
|
||||
sb.Append("march=").Append(m.marchId.ToString(CultureInfo.InvariantCulture)).Append(':').Append(Join(m.units)).Append('\n');
|
||||
foreach (var o in training)
|
||||
sb.Append("train=").Append(string.Join("|", new[]
|
||||
{
|
||||
N(o.id), N(o.type), N(o.count),
|
||||
o.startMs.ToString(CultureInfo.InvariantCulture), o.endMs.ToString(CultureInfo.InvariantCulture), o.recruited ? "1" : "0"
|
||||
})).Append('\n');
|
||||
sb.Append("trainNext=").Append(N(nextOrderId)).Append('\n');
|
||||
sb.Append("wounded=").Append(Join(woundedWaiting)).Append('\n');
|
||||
foreach (var b in healing)
|
||||
sb.Append("heal=").Append(string.Join("|", new[]
|
||||
{
|
||||
N(b.id), N(b.type), N(b.count), b.startMs.ToString(CultureInfo.InvariantCulture), b.endMs.ToString(CultureInfo.InvariantCulture)
|
||||
})).Append('\n');
|
||||
sb.Append("healNext=").Append(N(nextHealId)).Append('\n');
|
||||
foreach (var e in missing)
|
||||
{
|
||||
sb.Append("missing=").Append(string.Join("|", new[]
|
||||
{
|
||||
N(e.id), N(e.marchId), e.windowStartMs.ToString(CultureInfo.InvariantCulture), e.endMs.ToString(CultureInfo.InvariantCulture), Join(e.remaining)
|
||||
})).Append('\n');
|
||||
foreach (var g in e.groups)
|
||||
sb.Append("mgroup=").Append(string.Join("|", new[]
|
||||
{
|
||||
N(e.id), N(g.index), g.timeMs.ToString(CultureInfo.InvariantCulture), Join(g.healthy), Join(g.wounded)
|
||||
})).Append('\n');
|
||||
}
|
||||
foreach (var id in missingMarches) sb.Append("missingMarch=").Append(N(id)).Append('\n');
|
||||
sb.Append("missingNext=").Append(N(nextMissingId)).Append('\n');
|
||||
return sb.ToString();
|
||||
}
|
||||
|
||||
@@ -207,6 +679,14 @@ public sealed class ArmyService : IArmy, ISaveParticipant
|
||||
Array.Clear(troops, 0, Types);
|
||||
Array.Clear(garrison, 0, Types);
|
||||
marches.Clear();
|
||||
training.Clear();
|
||||
nextOrderId = 1;
|
||||
Array.Clear(woundedWaiting, 0, Types);
|
||||
healing.Clear();
|
||||
nextHealId = 1;
|
||||
missing.Clear();
|
||||
missingMarches.Clear();
|
||||
nextMissingId = 1;
|
||||
RestoredFromSave = true;
|
||||
|
||||
if (!string.IsNullOrEmpty(data))
|
||||
@@ -231,6 +711,75 @@ public sealed class ArmyService : IArmy, ISaveParticipant
|
||||
&& !marches.Exists(x => x.marchId == id)) // один поход -- одна запись
|
||||
marches.Add(new MarchUnits { marchId = id, units = Parse(parts[1]) });
|
||||
}
|
||||
else if (line.StartsWith("train="))
|
||||
{
|
||||
string[] p = line.Substring("train=".Length).Split('|');
|
||||
if (p.Length == 6 && int.TryParse(p[0], NumberStyles.Integer, CultureInfo.InvariantCulture, out int oid)
|
||||
&& int.TryParse(p[1], NumberStyles.Integer, CultureInfo.InvariantCulture, out int type) && type >= 0 && type < Types
|
||||
&& int.TryParse(p[2], NumberStyles.Integer, CultureInfo.InvariantCulture, out int count) && count > 0
|
||||
&& long.TryParse(p[3], NumberStyles.Integer, CultureInfo.InvariantCulture, out long start)
|
||||
&& long.TryParse(p[4], NumberStyles.Integer, CultureInfo.InvariantCulture, out long end)
|
||||
&& !training.Exists(x => x.id == oid)) // один заказ -- одна запись
|
||||
{
|
||||
training.Add(new TrainingOrder { id = oid, type = type, count = count, startMs = start, endMs = end, recruited = p[5] == "1" });
|
||||
nextOrderId = Math.Max(nextOrderId, oid + 1);
|
||||
}
|
||||
}
|
||||
else if (line.StartsWith("wounded="))
|
||||
{
|
||||
int[] w = Parse(line.Substring("wounded=".Length));
|
||||
Array.Copy(w, woundedWaiting, Types);
|
||||
}
|
||||
else if (line.StartsWith("heal="))
|
||||
{
|
||||
string[] p = line.Substring("heal=".Length).Split('|');
|
||||
if (p.Length == 5 && int.TryParse(p[0], NumberStyles.Integer, CultureInfo.InvariantCulture, out int hid)
|
||||
&& int.TryParse(p[1], NumberStyles.Integer, CultureInfo.InvariantCulture, out int htype) && htype >= 0 && htype < Types
|
||||
&& int.TryParse(p[2], NumberStyles.Integer, CultureInfo.InvariantCulture, out int hcount) && hcount > 0
|
||||
&& long.TryParse(p[3], NumberStyles.Integer, CultureInfo.InvariantCulture, out long hstart)
|
||||
&& long.TryParse(p[4], NumberStyles.Integer, CultureInfo.InvariantCulture, out long hend)
|
||||
&& !healing.Exists(x => x.id == hid))
|
||||
{
|
||||
healing.Add(new HealingBatch { id = hid, type = htype, count = hcount, startMs = hstart, endMs = hend });
|
||||
nextHealId = Math.Max(nextHealId, hid + 1);
|
||||
}
|
||||
}
|
||||
else if (line.StartsWith("missing="))
|
||||
{
|
||||
string[] p = line.Substring("missing=".Length).Split('|');
|
||||
if (p.Length == 5 && int.TryParse(p[0], NumberStyles.Integer, CultureInfo.InvariantCulture, out int mid)
|
||||
&& int.TryParse(p[1], NumberStyles.Integer, CultureInfo.InvariantCulture, out int mmarch)
|
||||
&& long.TryParse(p[2], NumberStyles.Integer, CultureInfo.InvariantCulture, out long mstart)
|
||||
&& long.TryParse(p[3], NumberStyles.Integer, CultureInfo.InvariantCulture, out long mend)
|
||||
&& !missing.Exists(x => x.id == mid))
|
||||
{
|
||||
missing.Add(new MissingEvent { id = mid, marchId = mmarch, windowStartMs = mstart, endMs = mend, remaining = Parse(p[4]) });
|
||||
nextMissingId = Math.Max(nextMissingId, mid + 1);
|
||||
}
|
||||
}
|
||||
else if (line.StartsWith("mgroup="))
|
||||
{
|
||||
string[] p = line.Substring("mgroup=".Length).Split('|');
|
||||
MissingEvent owner = null;
|
||||
if (p.Length == 5 && int.TryParse(p[0], NumberStyles.Integer, CultureInfo.InvariantCulture, out int gid))
|
||||
owner = missing.Find(x => x.id == gid); // строки missing= идут раньше своих групп
|
||||
if (owner != null && int.TryParse(p[1], NumberStyles.Integer, CultureInfo.InvariantCulture, out int gidx)
|
||||
&& long.TryParse(p[2], NumberStyles.Integer, CultureInfo.InvariantCulture, out long gtime)
|
||||
&& !owner.groups.Exists(x => x.index == gidx))
|
||||
owner.groups.Add(new MissingGroup { index = gidx, timeMs = gtime, healthy = Parse(p[3]), wounded = Parse(p[4]) });
|
||||
}
|
||||
else if (line.StartsWith("missingMarch=")
|
||||
&& int.TryParse(line.Substring("missingMarch=".Length), NumberStyles.Integer, CultureInfo.InvariantCulture, out int mm))
|
||||
missingMarches.Add(mm);
|
||||
else if (line.StartsWith("missingNext=")
|
||||
&& int.TryParse(line.Substring("missingNext=".Length), NumberStyles.Integer, CultureInfo.InvariantCulture, out int mnext))
|
||||
nextMissingId = Math.Max(nextMissingId, mnext);
|
||||
else if (line.StartsWith("healNext=")
|
||||
&& int.TryParse(line.Substring("healNext=".Length), NumberStyles.Integer, CultureInfo.InvariantCulture, out int hnext))
|
||||
nextHealId = Math.Max(nextHealId, hnext);
|
||||
else if (line.StartsWith("trainNext=")
|
||||
&& int.TryParse(line.Substring("trainNext=".Length), NumberStyles.Integer, CultureInfo.InvariantCulture, out int next))
|
||||
nextOrderId = Math.Max(nextOrderId, next);
|
||||
}
|
||||
}
|
||||
ClampGarrison(); // гарнизон не больше войск, даже если файл правили руками
|
||||
@@ -243,9 +792,19 @@ public sealed class ArmyService : IArmy, ISaveParticipant
|
||||
Array.Clear(troops, 0, Types);
|
||||
Array.Clear(garrison, 0, Types);
|
||||
marches.Clear();
|
||||
training.Clear();
|
||||
nextOrderId = 1;
|
||||
Array.Clear(woundedWaiting, 0, Types);
|
||||
healing.Clear();
|
||||
nextHealId = 1;
|
||||
missing.Clear();
|
||||
missingMarches.Clear();
|
||||
nextMissingId = 1;
|
||||
Changed();
|
||||
}
|
||||
|
||||
static string N(int v) => v.ToString(CultureInfo.InvariantCulture);
|
||||
|
||||
static string Join(int[] values)
|
||||
{
|
||||
var parts = new string[values.Length];
|
||||
|
||||
@@ -6,7 +6,10 @@
|
||||
// итог боя запоминает версию, по которой посчитан.
|
||||
public sealed class BattleRules
|
||||
{
|
||||
public int Version = 2; // 2 -- добавлены голод и усталость
|
||||
public int Version = 8; // 2 -- голод и усталость; 3 -- сила без округления, потери шансом (№12, №13); 4 -- кривая потерь (№14);
|
||||
// 5 -- разгром дальше 5:1 (решения по 8 вопросам Саши, 31.10, п. 5); 6 -- раненые (п. 7);
|
||||
// 7 -- сильная сторона 5:1 -> 10:1 с 11% до 2% (решения по отчёту Саши, 01.11, п. 1);
|
||||
// 8 -- пропавшие без вести (п. 5)
|
||||
|
||||
// Сила одного воина каждого типа. В игре -- из GameData.Troops (там же цена и время обучения).
|
||||
public int[] UnitPower = { 4, 5, 9, 16 };
|
||||
@@ -15,9 +18,30 @@ public sealed class BattleRules
|
||||
public int WallsPercent = 20; // защитникам за стенами
|
||||
public int SiegeVsWallsPercent = 50; // осадным при штурме стен
|
||||
public int SiegeInFieldPercent = -25; // осадным в поле и в обороне
|
||||
public int LoserLossPercent = 50; // потери проигравшего (не окончательная формула)
|
||||
public int RandomPercent = 5; // случайность каждой стороны: от -5% до +5%
|
||||
|
||||
// Кривая потерь (решение №14, регламент 31.10, ТЕСТ): доля выбывших от каждого типа в зависимости от
|
||||
// соотношения итоговых сил (сильная / слабая), в десятых долях процента. Между опорными точками -- плавно
|
||||
// (линейно), без порогов-переключателей.
|
||||
// Разгром (решения 31.10, п. 5): 7:1 -- 92%, 10:1 -- 95%, дальше слабая сторона плавно стремится к WeakLossMaxPermille (99%):
|
||||
// v = max - (max - v_last) * R_last / R -- при 20:1 97%, при 100:1 98,6%; 100% не достигается.
|
||||
// Сильная сторона (решения по отчёту Саши, 01.11, п. 1): 5:1 -- 11%, плавно до 2% при 10:1, дальше 2%.
|
||||
public int[] LossRatioPermille = { 1000, 3000, 5000, 7000, 10000 }; // 1:1, 3:1, 5:1, 7:1, 10:1
|
||||
public int[] StrongLossPermille = { 450, 160, 110, 74, 20 }; // сильная сторона: 45%, 16%, 11%, 7,4%, 2%
|
||||
public int[] WeakLossPermille = { 450, 740, 880, 920, 950 }; // слабая сторона: 45%, 74%, 88%, 92%, 95%
|
||||
public int WeakLossMaxPermille = 990; // верхний предел ожидаемых потерь слабой стороны: 99%
|
||||
|
||||
// Погибшие и раненые (регламент 31.10, ТЕСТ; решения 31.10, п. 7): из выбывших стороны погибает доля,
|
||||
// зависящая от её соотношения сил (своя / чужая), остальные -- раненые. Ориентиры регламента:
|
||||
// разгром 1:5 -- ~70%, поражение 1:2 -- ~45%, равный бой -- ~30%, уверенная победа 3:1 -- ~20%.
|
||||
// Между точками -- плавно (линейно), за крайними -- значения крайних. Округление -- отдельным броском.
|
||||
public int[] DeadRatioPermille = { 200, 500, 1000, 3000 }; // соотношение сил стороны: 1:5, 1:2, 1:1, 3:1
|
||||
public int[] DeadSharePermille = { 700, 450, 300, 200 }; // доля погибших среди выбывших: 70%, 45%, 30%, 20%
|
||||
|
||||
// Пропавшие без вести (решения 01.11, п. 5): доля выбывших, получающих этот статус, в десятых долях процента.
|
||||
// НЕ УТВЕРЖДЕНА командой -- по умолчанию 0 (механика выключена). Остальные выбывшие -- погибшие и раненые, как выше.
|
||||
public int MissingSharePermille = 0;
|
||||
|
||||
// Состояния (утверждено 27.10.2026): сколько силы остаётся. Применяются последовательно: 50% x 80% = 40%
|
||||
public int FamineStrengthPercent = 50; // голод: -50%
|
||||
public int FatigueStrengthPercent = 80; // усталость: -20%
|
||||
|
||||
@@ -1,21 +1,37 @@
|
||||
using System;
|
||||
using System.Numerics;
|
||||
|
||||
// МОДУЛЬ БОЕВОЙ СИСТЕМЫ: урон, потери, исход. Документ: Docs/Modules/Battle.md
|
||||
// Без Unity и без float: этот же код без изменений сможет считать бои на сервере.
|
||||
//
|
||||
// Правила (стартовые числа -- в BattleRules, утверждает команда):
|
||||
// - сила стороны = сумма по типам: воины x сила типа x (100% + контры + осадный бонус) x (100% + бонусы стороны);
|
||||
// Правила (стартовые числа -- в BattleRules; решения №11-13 комплекта требований от 29.10.2026):
|
||||
// - сила стороны = сумма по типам: воины x сила типа x (100% + контры + осадный бонус)
|
||||
// x (100% + бонусы стороны) x состояния (голод, усталость) x (100% + удача);
|
||||
// - контры: пехота сильнее кавалерии, кавалерия сильнее лучников, лучники сильнее пехоты;
|
||||
// бонус типа против врага взвешивается по составу врага;
|
||||
// - осадные: против стен сильнее, в поле слабее; стены усиливают защитников;
|
||||
// - случайность: каждой стороне от -5% до +5%, считается из Seed -- тот же Seed даёт тот же итог;
|
||||
// - удача: каждой стороне от -5% до +5%, ПОСЛЕ всех модификаторов, из Seed -- тот же Seed даёт тот же итог (№12);
|
||||
// - сила считается точной дробью (BigInteger) и округляется один раз -- только для показа (№12);
|
||||
// - побеждает тот, у кого итоговая сила больше; ничья -- защитнику;
|
||||
// - проигравший теряет половину каждого типа; победитель -- до половины, тем больше, чем ровнее был бой.
|
||||
// Это не окончательная формула потерь (план от 27.10.2026): раненых пока нет, все выбывшие -- погибшие.
|
||||
// - потери (№14): доля выбывших каждого типа -- плавная функция соотношения итоговых сил (сильная / слабая)
|
||||
// по опорным точкам BattleRules: 1:1 -- 45% / 45%, 3:1 -- 16% / 74%, 5:1 -- 11% / 88%. Одинаково для PvE, PvP и Круга;
|
||||
// - округление потерь: целая часть гарантирована, дробная -- шанс ещё одной потери,
|
||||
// из отдельного потока случайности (№13). Раненых пока нет, все выбывшие -- погибшие.
|
||||
public sealed class BattleSystem : IBattleSystem
|
||||
{
|
||||
// Точная неотрицательная дробь: сила x100 = N / D
|
||||
struct Fraction
|
||||
{
|
||||
public BigInteger N, D;
|
||||
public long Floor => (long)(N / D);
|
||||
}
|
||||
|
||||
enum Rounding { Roll, Floor, Ceiling } // бой -- бросок по №13; прогноз -- лучший и худший случай
|
||||
|
||||
private readonly BattleRules rules;
|
||||
private readonly int[] power;
|
||||
private readonly int[] lossRatio, strongLoss, weakLoss; // кривая потерь (проверенная копия из правил)
|
||||
private readonly int[] deadRatio, deadShare; // доля погибших среди выбывших (проверенная копия)
|
||||
|
||||
public BattleRules Rules => rules;
|
||||
|
||||
@@ -29,6 +45,71 @@ public sealed class BattleSystem : IBattleSystem
|
||||
for (int i = 0; i < TroopType.Count; i++)
|
||||
power[i] = this.rules.UnitPower != null && i < this.rules.UnitPower.Length && this.rules.UnitPower[i] > 0
|
||||
? this.rules.UnitPower[i] : defaults[i];
|
||||
|
||||
// Кривая из таблицы, если она корректна, иначе -- значения по умолчанию (ошибка в Inspector не ломает бой)
|
||||
var r = this.rules;
|
||||
bool ok = r.LossRatioPermille != null && r.StrongLossPermille != null && r.WeakLossPermille != null
|
||||
&& r.LossRatioPermille.Length > 0
|
||||
&& r.LossRatioPermille.Length == r.StrongLossPermille.Length
|
||||
&& r.LossRatioPermille.Length == r.WeakLossPermille.Length
|
||||
&& r.LossRatioPermille[0] == 1000;
|
||||
for (int i = 0; ok && i < r.LossRatioPermille.Length; i++)
|
||||
{
|
||||
ok = r.StrongLossPermille[i] >= 0 && r.StrongLossPermille[i] <= 1000 && r.WeakLossPermille[i] >= 0 && r.WeakLossPermille[i] <= 1000
|
||||
&& (i == 0 || r.LossRatioPermille[i] > r.LossRatioPermille[i - 1]);
|
||||
}
|
||||
var src = ok ? r : BattleRules.Default;
|
||||
lossRatio = (int[])src.LossRatioPermille.Clone();
|
||||
strongLoss = (int[])src.StrongLossPermille.Clone();
|
||||
weakLoss = (int[])src.WeakLossPermille.Clone();
|
||||
// Доля погибших: та же проверка -- ошибка в таблице не ломает бой
|
||||
bool deadOk = r.DeadRatioPermille != null && r.DeadSharePermille != null && r.DeadRatioPermille.Length > 0
|
||||
&& r.DeadRatioPermille.Length == r.DeadSharePermille.Length;
|
||||
for (int i = 0; deadOk && i < r.DeadRatioPermille.Length; i++)
|
||||
deadOk = r.DeadRatioPermille[i] > 0 && r.DeadSharePermille[i] >= 0 && r.DeadSharePermille[i] <= 1000
|
||||
&& (i == 0 || r.DeadRatioPermille[i] > r.DeadRatioPermille[i - 1]);
|
||||
var deadSrc = deadOk ? r : BattleRules.Default;
|
||||
deadRatio = (int[])deadSrc.DeadRatioPermille.Clone();
|
||||
deadShare = (int[])deadSrc.DeadSharePermille.Clone();
|
||||
|
||||
// Предел не ниже последней точки и не выше 100%
|
||||
weakLossMax = Math.Min(1000, Math.Max(weakLoss[weakLoss.Length - 1], r.WeakLossMaxPermille));
|
||||
}
|
||||
|
||||
private int weakLossMax;
|
||||
|
||||
// Доля выбывших стороны (в десятых долях процента, дробью) при соотношении сил stronger / weaker (решение №14)
|
||||
void LossShare(Fraction weaker, Fraction stronger, bool isWeak, out BigInteger num, out BigInteger den)
|
||||
{
|
||||
int[] values = isWeak ? weakLoss : strongLoss;
|
||||
int last = lossRatio.Length - 1;
|
||||
if (weaker.N.IsZero)
|
||||
{
|
||||
// У слабой стороны нулевая сила: сильная не теряет никого, слабая -- предел разгрома
|
||||
num = isWeak ? weakLossMax : 0;
|
||||
den = 1;
|
||||
return;
|
||||
}
|
||||
|
||||
// Соотношение сил в десятых долях процента: R = 1000 * stronger / weaker = rn / rd
|
||||
BigInteger rn = 1000 * stronger.N * weaker.D;
|
||||
BigInteger rd = stronger.D * weaker.N;
|
||||
if (rn <= lossRatio[0] * rd) { num = values[0]; den = 1; return; }
|
||||
if (rn >= lossRatio[last] * rd)
|
||||
{
|
||||
if (!isWeak) { num = values[last]; den = 1; return; }
|
||||
// Дальше последней точки слабая сторона плавно стремится к пределу: v = max - (max - v_last) * R_last / R
|
||||
num = (BigInteger)weakLossMax * rn - (BigInteger)(weakLossMax - values[last]) * lossRatio[last] * rd;
|
||||
den = rn;
|
||||
return;
|
||||
}
|
||||
|
||||
int i = 0;
|
||||
while (rn >= lossRatio[i + 1] * rd) i++; // lossRatio[i] <= R < lossRatio[i + 1]
|
||||
long span = lossRatio[i + 1] - lossRatio[i];
|
||||
// v = v_i + (v_{i+1} - v_i) * (R - p_i) / span -- точной дробью
|
||||
num = (BigInteger)values[i] * span * rd + (BigInteger)(values[i + 1] - values[i]) * (rn - lossRatio[i] * rd);
|
||||
den = span * rd;
|
||||
}
|
||||
|
||||
// Контра: насколько тип attackerType сильнее против defenderType, в процентах
|
||||
@@ -42,66 +123,88 @@ public sealed class BattleSystem : IBattleSystem
|
||||
|
||||
public BattleOutcome Resolve(BattleRequest request)
|
||||
{
|
||||
return Fight(request, Luck(request.Seed, 1), Luck(request.Seed, 2));
|
||||
return Fight(request, Luck(request.Seed, 1), Luck(request.Seed, 2), Rounding.Roll);
|
||||
}
|
||||
|
||||
// Прогноз: бой при крайних значениях случайности. Итог конкретного боя не раскрывается.
|
||||
// Прогноз (№11): качественный риск и точный шанс победы. Итог конкретного боя не раскрывается.
|
||||
public BattleForecast Forecast(BattleRequest request)
|
||||
{
|
||||
int r = rules.RandomPercent * 10;
|
||||
int r = Math.Max(0, rules.RandomPercent * 10);
|
||||
var f = new BattleForecast
|
||||
{
|
||||
AttackerPowerMin = long.MaxValue, DefenderPowerMin = long.MaxValue,
|
||||
AttackerLossesMin = int.MaxValue
|
||||
};
|
||||
bool anyWin = false, anyLoss = false;
|
||||
foreach (int attLuck in new[] { -r, r })
|
||||
foreach (int defLuck in new[] { -r, r })
|
||||
{
|
||||
BattleOutcome o = Fight(request, attLuck, defLuck);
|
||||
if (o.Victory) anyWin = true; else anyLoss = true;
|
||||
f.AttackerPowerMin = Math.Min(f.AttackerPowerMin, o.AttackerPower);
|
||||
f.AttackerPowerMax = Math.Max(f.AttackerPowerMax, o.AttackerPower);
|
||||
f.DefenderPowerMin = Math.Min(f.DefenderPowerMin, o.DefenderPower);
|
||||
f.DefenderPowerMax = Math.Max(f.DefenderPowerMax, o.DefenderPower);
|
||||
f.AttackerLossesMin = Math.Min(f.AttackerLossesMin, o.AttackerLosses);
|
||||
f.AttackerLossesMax = Math.Max(f.AttackerLossesMax, o.AttackerLosses);
|
||||
BattleOutcome best = Fight(request, attLuck, defLuck, Rounding.Floor);
|
||||
BattleOutcome worst = Fight(request, attLuck, defLuck, Rounding.Ceiling);
|
||||
f.AttackerPowerMin = Math.Min(f.AttackerPowerMin, best.AttackerPower);
|
||||
f.AttackerPowerMax = Math.Max(f.AttackerPowerMax, best.AttackerPower);
|
||||
f.DefenderPowerMin = Math.Min(f.DefenderPowerMin, best.DefenderPower);
|
||||
f.DefenderPowerMax = Math.Max(f.DefenderPowerMax, best.DefenderPower);
|
||||
f.AttackerLossesMin = Math.Min(f.AttackerLossesMin, best.AttackerLosses);
|
||||
f.AttackerLossesMax = Math.Max(f.AttackerLossesMax, worst.AttackerLosses);
|
||||
}
|
||||
f.Chance = !anyLoss ? BattleChance.Advantage : !anyWin ? BattleChance.HighRisk : BattleChance.Uncertain;
|
||||
|
||||
// Шанс победы: все сочетания удачи сторон равновероятны -- считаем, при скольких нападающий сильнее
|
||||
Sides(request, out Fraction att, out Fraction def, out _, out _, out _, out _);
|
||||
BigInteger x = att.N * def.D, y = def.N * att.D; // нападающий побеждает, если x * (1000 + a) > y * (1000 + d)
|
||||
long wins = 0, total = (long)(2 * r + 1) * (2 * r + 1);
|
||||
for (int a = -r; a <= r; a++)
|
||||
{
|
||||
BigInteger t = x * (1000 + a);
|
||||
if (y.IsZero) { if (t > 0) wins += 2 * r + 1; continue; }
|
||||
// нужно y * (1000 + d) < t, то есть 1000 + d <= ceil(t / y) - 1
|
||||
BigInteger dMax = (t + y - 1) / y - 1 - 1000;
|
||||
if (dMax < -r) continue;
|
||||
wins += dMax >= r ? 2 * r + 1 : (long)dMax + r + 1;
|
||||
}
|
||||
f.WinChancePermille = (int)((wins * 1000 + total / 2) / total);
|
||||
f.Chance = wins == total ? BattleChance.Advantage : wins == 0 ? BattleChance.HighRisk : BattleChance.Uncertain;
|
||||
return f;
|
||||
}
|
||||
|
||||
BattleOutcome Fight(BattleRequest request, int attLuckPermille, int defLuckPermille)
|
||||
BattleOutcome Fight(BattleRequest request, int attLuckPermille, int defLuckPermille, Rounding rounding)
|
||||
{
|
||||
int[] att = Units(request.AttackerUnits, request.Attackers);
|
||||
int[] def = Units(request.DefenderUnits, request.Defenders);
|
||||
Sides(request, out Fraction attBase, out Fraction defBase, out int[] att, out int[] def,
|
||||
out int attCondition, out int defCondition);
|
||||
Fraction attPower = WithLuck(attBase, attLuckPermille);
|
||||
Fraction defPower = WithLuck(defBase, defLuckPermille);
|
||||
|
||||
int attCondition = ConditionPercent(request.AttackerConditions);
|
||||
int defCondition = ConditionPercent(request.DefenderConditions);
|
||||
long attPower = SidePower(att, def, attacking: true, request.DefenderFortified, request.AttackerBonusPercent, attCondition, attLuckPermille);
|
||||
long defPower = SidePower(def, att, attacking: false, request.DefenderFortified,
|
||||
request.DefenderBonusPercent + (request.DefenderFortified ? rules.WallsPercent : 0), defCondition, defLuckPermille);
|
||||
bool victory = attPower.N * defPower.D > defPower.N * attPower.D; // ничья -- в пользу защитника
|
||||
|
||||
bool victory = attPower > defPower; // ничья -- в пользу защитника
|
||||
// Потери по кривой (№14): победитель -- сильная сторона, проигравший -- слабая; при ничьей соотношение 1:1
|
||||
Fraction weaker = victory ? defPower : attPower;
|
||||
Fraction stronger = victory ? attPower : defPower;
|
||||
int[] attOut = Losses(att, weaker, stronger, isWeak: !victory, request.Seed, side: 1, rounding);
|
||||
int[] defOut0 = Losses(def, weaker, stronger, isWeak: victory, request.Seed, side: 2, rounding);
|
||||
|
||||
// Потери в процентах: проигравший -- LoserLossPercent; победитель -- то же x (сила проигравшего / сила победителя)
|
||||
long stronger = Math.Max(attPower, defPower);
|
||||
long weaker = Math.Min(attPower, defPower);
|
||||
int[] attDead = Losses(att, weaker, stronger, isLoser: !victory);
|
||||
int[] defDead = Losses(def, weaker, stronger, isLoser: victory);
|
||||
|
||||
// Раненых пока нет: доля раненых и погибших не утверждена (план от 27.10.2026)
|
||||
// Выбывшие: сначала пропавшие без вести (решения 01.11, п. 5), остальные -- погибшие и раненые (решения 31.10, п. 7):
|
||||
// доля погибших -- от соотношения сил своей стороны. Солдат -- ровно в одном статусе
|
||||
int[] attMissing = Missing(attOut, request.Seed, side: 1, rounding);
|
||||
int[] defMissing = Missing(defOut0, request.Seed, side: 2, rounding);
|
||||
int[] attRest = new int[TroopType.Count], defRest = new int[TroopType.Count];
|
||||
for (int i = 0; i < TroopType.Count; i++) { attRest[i] = attOut[i] - attMissing[i]; defRest[i] = defOut0[i] - defMissing[i]; }
|
||||
int[] attDead = Dead(attRest, attPower, defPower, request.Seed, side: 1, rounding);
|
||||
int[] defDead = Dead(defRest, defPower, attPower, request.Seed, side: 2, rounding);
|
||||
int[] attWounded = new int[TroopType.Count];
|
||||
int[] defWounded = new int[TroopType.Count];
|
||||
for (int i = 0; i < TroopType.Count; i++)
|
||||
{
|
||||
attWounded[i] = attRest[i] - attDead[i];
|
||||
defWounded[i] = defRest[i] - defDead[i];
|
||||
}
|
||||
|
||||
var attAlive = new int[TroopType.Count];
|
||||
var defAlive = new int[TroopType.Count];
|
||||
var defOut = new int[TroopType.Count];
|
||||
for (int i = 0; i < TroopType.Count; i++)
|
||||
{
|
||||
attAlive[i] = att[i] - attDead[i] - attWounded[i];
|
||||
defAlive[i] = def[i] - defDead[i] - defWounded[i];
|
||||
defOut[i] = defDead[i] + defWounded[i];
|
||||
attAlive[i] = att[i] - attOut[i];
|
||||
defAlive[i] = def[i] - defOut0[i];
|
||||
defOut[i] = defOut0[i];
|
||||
}
|
||||
|
||||
return new BattleOutcome
|
||||
@@ -109,11 +212,11 @@ public sealed class BattleSystem : IBattleSystem
|
||||
Victory = victory,
|
||||
BattleId = request.Seed,
|
||||
RulesVersion = rules.Version,
|
||||
AttackerLosses = Sum(attDead) + Sum(attWounded),
|
||||
AttackerLosses = Sum(attOut),
|
||||
DefenderLosses = Sum(defOut),
|
||||
Survivors = Sum(attAlive),
|
||||
AttackerPower = attPower,
|
||||
DefenderPower = defPower,
|
||||
AttackerPower = attPower.Floor, // округление -- только для показа
|
||||
DefenderPower = defPower.Floor,
|
||||
AttackerLuckPermille = attLuckPermille,
|
||||
DefenderLuckPermille = defLuckPermille,
|
||||
AttackerConditionPercent = attCondition,
|
||||
@@ -126,73 +229,178 @@ public sealed class BattleSystem : IBattleSystem
|
||||
DefenderDeadUnits = defDead,
|
||||
AttackerWoundedUnits = attWounded,
|
||||
DefenderWoundedUnits = defWounded,
|
||||
AttackerMissingUnits = attMissing,
|
||||
DefenderMissingUnits = defMissing,
|
||||
DefenderLossUnits = defOut
|
||||
};
|
||||
}
|
||||
|
||||
// Сколько силы оставляют состояния стороны, в процентах. Штрафы -- последовательно: голод и усталость -- 40%
|
||||
public int ConditionPercent(BattleCondition conditions)
|
||||
// Силы сторон без удачи (точные дроби) и состав сторон
|
||||
void Sides(BattleRequest request, out Fraction attPower, out Fraction defPower, out int[] att, out int[] def,
|
||||
out int attCondition, out int defCondition)
|
||||
{
|
||||
int percent = 100;
|
||||
if ((conditions & BattleCondition.Famine) != 0) percent = percent * rules.FamineStrengthPercent / 100;
|
||||
if ((conditions & BattleCondition.Fatigue) != 0) percent = percent * rules.FatigueStrengthPercent / 100;
|
||||
return Math.Max(0, percent);
|
||||
att = Units(request.AttackerUnits, request.Attackers);
|
||||
def = Units(request.DefenderUnits, request.Defenders);
|
||||
attPower = SidePower(att, def, attacking: true, request.DefenderFortified, request.AttackerBonusPercent,
|
||||
request.AttackerConditions, out attCondition);
|
||||
defPower = SidePower(def, att, attacking: false, request.DefenderFortified,
|
||||
request.DefenderBonusPercent + (request.DefenderFortified ? rules.WallsPercent : 0),
|
||||
request.DefenderConditions, out defCondition);
|
||||
}
|
||||
|
||||
// Сила стороны в сотых долях (x100), чтобы проценты считались в целых числах.
|
||||
// Порядок: контры и осадные -> бонусы стороны -> состояния (голод, усталость) -> случайность
|
||||
// (порядок бонусов и случайности -- открытый вопрос плана).
|
||||
long SidePower(int[] units, int[] enemy, bool attacking, bool fortified, int bonusPercent, int conditionPercent, int luckPermille)
|
||||
// Сколько силы оставляют состояния стороны, в процентах (для показа). Штрафы -- последовательно: голод и усталость -- 40%
|
||||
public int ConditionPercent(BattleCondition conditions)
|
||||
{
|
||||
ConditionFactor(conditions, out BigInteger num, out BigInteger den);
|
||||
return (int)(num * 100 / den);
|
||||
}
|
||||
|
||||
void ConditionFactor(BattleCondition conditions, out BigInteger num, out BigInteger den)
|
||||
{
|
||||
num = 1; den = 1;
|
||||
if ((conditions & BattleCondition.Famine) != 0) { num *= Math.Max(0, rules.FamineStrengthPercent); den *= 100; }
|
||||
if ((conditions & BattleCondition.Fatigue) != 0) { num *= Math.Max(0, rules.FatigueStrengthPercent); den *= 100; }
|
||||
}
|
||||
|
||||
// Сила стороны x100 точной дробью. Порядок: контры и осадные -> бонусы стороны -> состояния; удача -- в WithLuck.
|
||||
// Контра по составу врага (counterSum / enemyTotal) остаётся дробью -- без промежуточного округления (№12).
|
||||
Fraction SidePower(int[] units, int[] enemy, bool attacking, bool fortified, int bonusPercent,
|
||||
BattleCondition conditions, out int conditionPercent)
|
||||
{
|
||||
int enemyTotal = Sum(enemy);
|
||||
long total = 0;
|
||||
int e = Math.Max(1, enemyTotal);
|
||||
BigInteger raw = 0;
|
||||
for (int i = 0; i < TroopType.Count; i++)
|
||||
{
|
||||
if (units[i] <= 0) continue;
|
||||
|
||||
long counterSum = 0;
|
||||
for (int j = 0; j < TroopType.Count; j++) counterSum += (long)Counter(i, j) * enemy[j];
|
||||
long counterPercent = enemyTotal > 0 ? counterSum / enemyTotal : 0;
|
||||
if (enemyTotal > 0)
|
||||
for (int j = 0; j < TroopType.Count; j++) counterSum += (long)Counter(i, j) * enemy[j];
|
||||
|
||||
int siegePercent = 0;
|
||||
if (i == TroopType.Siege) siegePercent = attacking && fortified ? rules.SiegeVsWallsPercent : rules.SiegeInFieldPercent;
|
||||
|
||||
total += (long)units[i] * power[i] * (100 + counterPercent + siegePercent);
|
||||
// воины x сила x (100 + осадный% + контра%), где контра% = counterSum / e
|
||||
BigInteger term = (BigInteger)units[i] * power[i] * ((100L + siegePercent) * e + counterSum);
|
||||
if (term > 0) raw += term;
|
||||
}
|
||||
total = Math.Max(0, total * (100 + bonusPercent) / 100);
|
||||
total = total * conditionPercent / 100;
|
||||
return total * (1000 + luckPermille) / 1000;
|
||||
|
||||
ConditionFactor(conditions, out BigInteger condNum, out BigInteger condDen);
|
||||
conditionPercent = (int)(condNum * 100 / condDen);
|
||||
return new Fraction
|
||||
{
|
||||
N = raw * Math.Max(0, 100 + bonusPercent) * condNum,
|
||||
D = (BigInteger)e * 100 * condDen
|
||||
};
|
||||
}
|
||||
|
||||
// Случайность стороны в десятых долях процента: от -RandomPercent*10 до +RandomPercent*10.
|
||||
static Fraction WithLuck(Fraction power, int luckPermille)
|
||||
{
|
||||
return new Fraction { N = power.N * (1000 + luckPermille), D = power.D * 1000 };
|
||||
}
|
||||
|
||||
// Удача стороны в десятых долях процента: от -RandomPercent*10 до +RandomPercent*10.
|
||||
// Считается из Seed (SplitMix64) -- одинаково на любой платформе, System.Random для этого не годится.
|
||||
public int Luck(long seed, int side)
|
||||
{
|
||||
int range = rules.RandomPercent * 10;
|
||||
if (range <= 0) return 0;
|
||||
ulong x = (ulong)seed + 0x9E3779B97F4A7C15UL * (ulong)side;
|
||||
x = (x ^ (x >> 30)) * 0xBF58476D1CE4E5B9UL;
|
||||
x = (x ^ (x >> 27)) * 0x94D049BB133111EBUL;
|
||||
x ^= x >> 31;
|
||||
ulong x = Mix((ulong)seed + 0x9E3779B97F4A7C15UL * (ulong)side);
|
||||
return (int)(x % (ulong)(2 * range + 1)) - range;
|
||||
}
|
||||
|
||||
// Потери каждого типа: проигравший -- LoserLossPercent; победитель -- LoserLossPercent x ratioNum / ratioDen.
|
||||
// Округление вниз: одиночный проигравший не гибнет -- открытый вопрос (Battle.md).
|
||||
int[] Losses(int[] units, long ratioNum, long ratioDen, bool isLoser)
|
||||
// Отдельный поток случайности для округления потерь (№13): своя "соль" на каждую сторону и тип войск,
|
||||
// не пересекается с удачей. Возвращает число от 0 до 2^32 - 1.
|
||||
static ulong LossRoll(long seed, int side, int type)
|
||||
{
|
||||
ulong x = Mix(((ulong)seed ^ 0xD1B54A32D192ED03UL) + 0x9E3779B97F4A7C15UL * (ulong)(16 * side + type + 1));
|
||||
return x >> 32;
|
||||
}
|
||||
|
||||
static ulong Mix(ulong x)
|
||||
{
|
||||
x = (x ^ (x >> 30)) * 0xBF58476D1CE4E5B9UL;
|
||||
x = (x ^ (x >> 27)) * 0x94D049BB133111EBUL;
|
||||
return x ^ (x >> 31);
|
||||
}
|
||||
|
||||
// Потери каждого типа (дробью): units x доля выбывших по кривой (№14).
|
||||
// Целая часть гарантирована, дробная -- шанс ещё одной потери (№13).
|
||||
int[] Losses(int[] units, Fraction weaker, Fraction stronger, bool isWeak, long seed, int side, Rounding rounding)
|
||||
{
|
||||
var lost = new int[TroopType.Count];
|
||||
LossShare(weaker, stronger, isWeak, out BigInteger shareNum, out BigInteger shareDen);
|
||||
for (int i = 0; i < TroopType.Count; i++)
|
||||
{
|
||||
if (units[i] <= 0) continue;
|
||||
long l = isLoser
|
||||
? (long)units[i] * rules.LoserLossPercent / 100
|
||||
: ratioDen > 0 ? (long)units[i] * rules.LoserLossPercent * ratioNum / (100 * ratioDen) : 0;
|
||||
lost[i] = (int)Math.Max(0, Math.Min(units[i], l));
|
||||
|
||||
BigInteger num = (BigInteger)units[i] * shareNum;
|
||||
BigInteger den = 1000 * shareDen;
|
||||
|
||||
BigInteger whole = BigInteger.DivRem(num, den, out BigInteger rest);
|
||||
bool extra = !rest.IsZero && (rounding == Rounding.Ceiling
|
||||
|| rounding == Rounding.Roll && (BigInteger)LossRoll(seed, side, i) * den < rest * (BigInteger.One << 32));
|
||||
BigInteger l = whole + (extra ? 1 : 0);
|
||||
lost[i] = (int)BigInteger.Min(units[i], l);
|
||||
}
|
||||
return lost;
|
||||
}
|
||||
|
||||
// Пропавшие без вести среди выбывших каждого типа: доля из правил (не утверждена; 0 -- никого), дробная часть -- бросок
|
||||
// из своего потока. Прогноз: Floor -- меньше пропавших, Ceiling -- больше
|
||||
int[] Missing(int[] outUnits, long seed, int side, Rounding rounding)
|
||||
{
|
||||
var missing = new int[TroopType.Count];
|
||||
int share = Math.Max(0, Math.Min(1000, rules.MissingSharePermille));
|
||||
if (share == 0) return missing;
|
||||
for (int i = 0; i < TroopType.Count; i++)
|
||||
{
|
||||
if (outUnits[i] <= 0) continue;
|
||||
long num = (long)outUnits[i] * share;
|
||||
long whole = num / 1000, rest = num % 1000;
|
||||
bool extra = rest != 0 && (rounding == Rounding.Ceiling
|
||||
|| rounding == Rounding.Roll && (LossRoll(seed, side + 24, i) * 1000UL >> 32) < (ulong)rest);
|
||||
missing[i] = (int)Math.Min(outUnits[i], whole + (extra ? 1 : 0));
|
||||
}
|
||||
return missing;
|
||||
}
|
||||
|
||||
// Погибшие среди выбывших каждого типа: доля по соотношению сил стороны (own / enemy), дробная часть -- бросок
|
||||
// из своего потока (не тот, что у потерь и удачи). Прогноз: Floor -- меньше погибших, Ceiling -- больше.
|
||||
int[] Dead(int[] outUnits, Fraction own, Fraction enemy, long seed, int side, Rounding rounding)
|
||||
{
|
||||
var dead = new int[TroopType.Count];
|
||||
DeadShare(own, enemy, out BigInteger shareNum, out BigInteger shareDen);
|
||||
for (int i = 0; i < TroopType.Count; i++)
|
||||
{
|
||||
if (outUnits[i] <= 0) continue;
|
||||
BigInteger num = (BigInteger)outUnits[i] * shareNum;
|
||||
BigInteger den = 1000 * shareDen;
|
||||
BigInteger whole = BigInteger.DivRem(num, den, out BigInteger rest);
|
||||
bool extra = !rest.IsZero && (rounding == Rounding.Ceiling
|
||||
|| rounding == Rounding.Roll && (BigInteger)LossRoll(seed, side + 8, i) * den < rest * (BigInteger.One << 32));
|
||||
dead[i] = (int)BigInteger.Min(outUnits[i], whole + (extra ? 1 : 0));
|
||||
}
|
||||
return dead;
|
||||
}
|
||||
|
||||
// Доля погибших (в десятых долях процента, дробью) при соотношении сил стороны R = 1000 * own / enemy
|
||||
void DeadShare(Fraction own, Fraction enemy, out BigInteger num, out BigInteger den)
|
||||
{
|
||||
int last = deadRatio.Length - 1;
|
||||
if (enemy.N.IsZero) { num = deadShare[last]; den = 1; return; } // у врага нет силы -- как при самом большом перевесе
|
||||
BigInteger rn = 1000 * own.N * enemy.D;
|
||||
BigInteger rd = own.D * enemy.N;
|
||||
if (rn <= deadRatio[0] * rd) { num = deadShare[0]; den = 1; return; }
|
||||
if (rn >= deadRatio[last] * rd) { num = deadShare[last]; den = 1; return; }
|
||||
int i = 0;
|
||||
while (rn >= deadRatio[i + 1] * rd) i++;
|
||||
long span = deadRatio[i + 1] - deadRatio[i];
|
||||
num = (BigInteger)deadShare[i] * span * rd + (BigInteger)(deadShare[i + 1] - deadShare[i]) * (rn - deadRatio[i] * rd);
|
||||
den = span * rd;
|
||||
}
|
||||
|
||||
static int[] Units(int[] units, int count)
|
||||
{
|
||||
var result = new int[TroopType.Count];
|
||||
|
||||
@@ -22,6 +22,18 @@ public interface IEconomy
|
||||
float GetCapacity(ResourceType type);
|
||||
}
|
||||
|
||||
// Защита ресурсов от грабежа (01.11, ТЕСТ). Сколько ресурса нельзя отнять в набеге: часть запаса прячут
|
||||
// Склады и Амбары. Добыча набега по ресурсу = Lootable. Реализует слой приложения (App/ResourceProtection)
|
||||
// по зданиям города; модули Боя и Мира берут отсюда, сколько можно унести у города.
|
||||
// Кристаллы (решение 04.11): недоступны нападающему, пока он не изучил технологию «Грабёж».
|
||||
// Технологий в игре ещё нет -- изучена ли она, передаёт тот, кто считает набег (attackerHasPlunder).
|
||||
public interface IResourceProtection
|
||||
{
|
||||
float Protected(ResourceType type); // сколько спрятано от нападающего без «Грабежа»
|
||||
float Lootable(ResourceType type); // сколько можно унести без «Грабежа» (кристаллы -- 0)
|
||||
float Lootable(ResourceType type, bool attackerHasPlunder); // с учётом технологии нападающего
|
||||
}
|
||||
|
||||
// Добыча через рабочих (этап 3). Реализует модуль Экономики (ProductionService).
|
||||
public interface IProduction
|
||||
{
|
||||
@@ -86,6 +98,42 @@ public interface IPopulation
|
||||
}
|
||||
|
||||
// ---------------- Армия: войска ----------------
|
||||
// Пропавшие без вести одного боя (решения 01.11, п. 5). Время -- мс часов игры
|
||||
public struct MissingInfo
|
||||
{
|
||||
public int Id;
|
||||
public int Count; // ещё не вернулись и не списаны
|
||||
public long WindowStartMs; // с какого момента могут начать возвращаться (бой + обратный путь)
|
||||
public long EndMs; // конец окна: кто не вернулся -- списан
|
||||
}
|
||||
|
||||
// Раненые на лечении в госпитале: одна группа одного типа, лечится вместе. Время -- мс часов игры
|
||||
public struct HealingInfo
|
||||
{
|
||||
public int Id;
|
||||
public int Type;
|
||||
public int Count;
|
||||
public long StartMs;
|
||||
public long EndMs;
|
||||
}
|
||||
|
||||
// Госпиталь -- здание Поселения (решения 31.10, п. 7): уровень, вместимость, улучшения.
|
||||
// Пока здания нет, контракт не зарегистрирован -- Армия берёт временное число мест из таблицы HospitalConfig.
|
||||
public interface IHospital
|
||||
{
|
||||
int Beds { get; } // мест для лечения: регламент 31.10 -- 20..60 по уровням I..V
|
||||
}
|
||||
|
||||
// Заказ обучения в Казармах. Время -- миллисекунды часов игры (GameClock.UnixNowMs)
|
||||
public struct TrainingOrderInfo
|
||||
{
|
||||
public int Id; // номер заказа -- не повторяется
|
||||
public int Type; // тип войск (TroopType)
|
||||
public int Count;
|
||||
public long StartMs; // когда начнётся (заказы идут друг за другом)
|
||||
public long EndMs; // когда воины будут готовы
|
||||
}
|
||||
|
||||
public interface IArmy
|
||||
{
|
||||
int TypeCount { get; } // типов войск (пехота, лучники, кавалерия, осадные)
|
||||
@@ -102,8 +150,13 @@ public interface IArmy
|
||||
void AddTroops(int type, int count); // обучены / вернулись
|
||||
int LoseFraction(double fraction); // потери в бою: доля от каждого типа; возвращает, сколько погибло
|
||||
|
||||
// Слоты похода (регламент 31.10, раздел 7): сколько походов игрока может идти одновременно.
|
||||
// База 2 (ТЕСТ), технология «Военная логистика» поднимет до 7 -- уровни и цены ещё не утверждены.
|
||||
int MarchSlots { get; }
|
||||
int ActiveMarches { get; } // походов в пути сейчас (у каждого -- свой слот)
|
||||
|
||||
// Забрать свободных воинов в поход (запоминает состав похода).
|
||||
// false -- ничего не взято: не хватает воинов или поход с таким номером уже есть.
|
||||
// false -- ничего не взято: не хватает воинов, нет свободного слота или поход с таким номером уже есть.
|
||||
bool TakeForMarch(int marchId, int count);
|
||||
// Вернуть выживших из похода по тем же типам войск.
|
||||
// Зачисляет только по известному походу и только один раз; false -- такого похода нет, никто не добавлен.
|
||||
@@ -113,6 +166,45 @@ public interface IArmy
|
||||
int[] GetMarchUnits(int marchId);
|
||||
// Вернуть выживших точно по типам (бой посчитал потери каждого типа). Правила те же, что у ReturnFromMarch.
|
||||
bool ReturnUnits(int marchId, int[] survivorUnits);
|
||||
|
||||
// ---- Раненые и лечение (решения по 8 вопросам Саши, 31.10, п. 7): состояние и очередь лечения -- у Армии ----
|
||||
// Бой считает раненых, Армия хранит их и лечит, места -- от госпиталя (IHospital), цену лечения списывает Экономика.
|
||||
// Раненые -- люди: Население считает их вместе с воинами. Лишние раненые ждут места (ТЕСТ, решение Саши 01.11).
|
||||
int WoundedTotal { get; } // раненых всего: ждут места + лечатся
|
||||
int GetAllTroops(int type); // все воины типа: в городе, в походах и раненые (для еды -- решения 01.11, п. 4)
|
||||
|
||||
// ---- Пропавшие без вести (решения 01.11, п. 5) ----
|
||||
// Не воюют и не едят, но занимают места в населении до возвращения или списания.
|
||||
int MissingTotal { get; }
|
||||
IReadOnlyList<MissingInfo> Missing { get; }
|
||||
// Бой посчитал пропавших: забрать их из похода в группу пропавших. battleMs -- конец боя, travelMs -- обратный путь.
|
||||
// Только известный поход и только один раз на поход; больше, чем ушло, не забрать.
|
||||
bool MarkMissing(int marchId, int[] missingUnits, long battleMs, long travelMs);
|
||||
// Вернуть группы, время которых наступило (и офлайн), и списать тех, чьё окно кончилось. Сколько групп вернулось.
|
||||
int ProcessMissing(long nowMs);
|
||||
int GetWoundedWaiting(int type); // ждут места, по типам
|
||||
IReadOnlyList<HealingInfo> Healing { get; } // лечатся сейчас
|
||||
int HospitalBeds { get; } // мест в госпитале сейчас
|
||||
// Вернуть из похода выживших и раненых точно по типам (раненые -- в госпиталь в момент nowMs).
|
||||
// Только известный поход и только один раз; больше, чем ушло, не вернётся.
|
||||
bool ReturnUnits(int marchId, int[] survivorUnits, int[] woundedUnits, long nowMs);
|
||||
// Закончить лечение, срок которого наступил (и офлайн), и положить ждущих на освободившиеся места.
|
||||
// Вылеченные возвращаются в город (SoldiersHealedEvent, один раз на группу). Возвращает, сколько групп вылечено.
|
||||
int ProcessHealing(long nowMs);
|
||||
|
||||
// ---- Очередь обучения (решения по 8 вопросам Саши, 31.10, п. 8): единственный владелец -- Армия ----
|
||||
// Жители для найма -- у Населения (IPopulation: Recruitable, резерв, TryRecruit, CompleteTraining),
|
||||
// размер очереди -- от Казарм (передаёт вызывающий), цену списывает вызывающий через Экономику.
|
||||
IReadOnlyList<TrainingOrderInfo> Training { get; }
|
||||
// Можно ли поставить заказ: очередь не заполнена, жителей хватает. Ничего не меняет; false -- error для игрока.
|
||||
bool CanQueueTraining(int type, int count, int queueSize, out string error);
|
||||
// Поставить заказ в конец очереди, люди уходят из жителей в Казармы. false -- ничего не изменилось.
|
||||
bool TryQueueTraining(int type, int count, long durationMs, int queueSize, long nowMs, out string error);
|
||||
// Закончить заказы, срок которых наступил к nowMs (и пока игра была закрыта): воины переходят в Армию,
|
||||
// каждый заказ -- один раз (TroopsTrainedEvent). Возвращает, сколько заказов закончено.
|
||||
int ProcessTraining(long nowMs);
|
||||
// Ускорение: все заказы заканчиваются в nowMs
|
||||
int FinishAllTraining(long nowMs);
|
||||
}
|
||||
|
||||
// ---------------- Глобальный мир: карта и перемещения ----------------
|
||||
@@ -120,6 +212,103 @@ public interface IWorld
|
||||
{
|
||||
int ActiveMarches { get; }
|
||||
bool TryStartMarch(string targetId, int soldiers, out string error);
|
||||
// Поход «Разведка» (решения 31.10, п. 2): дойти до цели и вернуться -- без боя, сбора и потерь, занимает слот похода
|
||||
bool TryStartScout(string targetId, int soldiers, out string error);
|
||||
// Разведка с отчётом (Разведка М2): дойти, наблюдать время уровня (II / III), отчёт, вернуться
|
||||
bool TryStartRecon(string targetId, int soldiers, int level, out string error);
|
||||
}
|
||||
|
||||
// ---------------- Мир -> Разведка: что где находится (решение №46) ----------------
|
||||
// Координаты и марши ведёт Мир, разведка только читает их через этот контракт -- без прямых ссылок
|
||||
// World <-> Intelligence. Координаты -- милли-единицы карты (1000 = 1 клетка).
|
||||
public struct ArmyOnMap
|
||||
{
|
||||
public string Id; // устойчивый номер армии на карте ("march_12")
|
||||
public string OwnerId; // владелец (LocalPlayer.Id -- свои войска)
|
||||
public int X, Y; // где сейчас
|
||||
public int ToX, ToY; // куда движется
|
||||
public int Soldiers; // точная численность -- разведка показывает игроку только примерную
|
||||
}
|
||||
|
||||
// Цель разведки (ТЗ разведки, раздел 6): что за объект -- от этого время наблюдения
|
||||
public enum ReconTargetKind { None, Settlement, Castle, Camp }
|
||||
|
||||
// Снимок цели разведки от Мира -- то, что увидят разведчики в момент отчёта
|
||||
public struct ReconTarget
|
||||
{
|
||||
public string Id;
|
||||
public string Name;
|
||||
public ReconTargetKind Kind;
|
||||
public int[] Units; // гарнизон по типам (TroopType)
|
||||
public bool Fortified; // защитники за стенами
|
||||
public bool HasHero;
|
||||
public string[] ResourceNames; // что можно взять (имена ResourceType)
|
||||
public int[] ResourceAmounts;
|
||||
}
|
||||
|
||||
public interface IWorldMap
|
||||
{
|
||||
int HalfWidth { get; } // границы карты от центра, милли-единицы
|
||||
int HalfHeight { get; }
|
||||
bool TryGetSettlement(string ownerId, out int x, out int y); // где поселение игрока
|
||||
// Все армии на карте в момент timeMs (в том числе в прошлом -- для расчёта, пока игра была закрыта)
|
||||
void GetArmies(long timeMs, List<ArmyOnMap> result);
|
||||
// Цель разведки: замок, поселение, лагерь (Разведка М2). false -- такой цели нет или её нельзя разведать
|
||||
bool TryGetReconTarget(string targetId, out ReconTarget target);
|
||||
}
|
||||
|
||||
// ---------------- Разведка (Bogatyri.Intelligence): туман войны, наблюдение ----------------
|
||||
public enum MapVisibility
|
||||
{
|
||||
Unexplored, // неизведано
|
||||
Explored, // исследовано: география известна, текущие движения не видны
|
||||
Observed // наблюдается сейчас
|
||||
}
|
||||
|
||||
// Что игрок знает о чужой армии: только приблизительно и только на момент наблюдения
|
||||
public struct KnownContact
|
||||
{
|
||||
public string ArmyId;
|
||||
public bool Identified; // владелец опознан
|
||||
public string OwnerId; // null, пока не опознан
|
||||
public bool Observed; // видна прямо сейчас; false -- последний контакт (устаревшие данные)
|
||||
public int X, Y, ToX, ToY; // где видели последний раз и куда шла
|
||||
public int ApproxMin, ApproxMax; // примерная численность (±20%), одна на событие наблюдения
|
||||
public long LastSeenMs; // когда видели последний раз
|
||||
}
|
||||
|
||||
// Отчёт разведки (ТЗ разведки, раздел 6): сведения на момент операции, без постоянного слежения.
|
||||
// Уровень II -- точное число и типы, герой, укрепления; III -- плюс модификаторы и оценка ресурсов ±15%
|
||||
public struct ReconReport
|
||||
{
|
||||
public string Id; // устойчивый: "recon_<номер похода>"
|
||||
public string TargetId;
|
||||
public string TargetName;
|
||||
public int Level; // 2 или 3
|
||||
public long TimeMs; // когда закончилось наблюдение (по часам игры)
|
||||
public int[] Units; // гарнизон по типам на момент отчёта
|
||||
public bool Fortified;
|
||||
public bool HasHero;
|
||||
public string Modifiers; // уровень III: основные боевые модификаторы защиты
|
||||
public string[] ResourceNames; // уровень III: оценка ресурсов
|
||||
public int[] ResourceMin, ResourceMax;
|
||||
}
|
||||
|
||||
public interface IIntelligence
|
||||
{
|
||||
int ObservationLevel { get; set; } // технология «Обзор»: 0..3 (+10 / +20 / +30% к радиусам)
|
||||
MapVisibility GetVisibility(int x, int y); // по последнему расчёту
|
||||
IReadOnlyList<KnownContact> Contacts { get; }
|
||||
void ObserveUntil(long nowMs); // досчитать наблюдение до момента (в том числе офлайн)
|
||||
|
||||
// ---- М2: целенаправленная разведка ----
|
||||
// Сколько разведчики наблюдают цель (мс часов игры): поселение 2 / 4, замок 3 / 6 игровых часов (уровень II / III).
|
||||
// 0 -- цель нельзя разведать с отчётом
|
||||
long ReconDurationMs(string targetId, int level);
|
||||
// Наблюдение закончено: составить отчёт по снимку цели на этот момент. Один раз на reportId (повтор -- false)
|
||||
bool CompleteRecon(string reportId, string targetId, int level, long timeMs);
|
||||
IReadOnlyList<ReconReport> Reports { get; }
|
||||
bool TryGetLatestReport(string targetId, out ReconReport report);
|
||||
}
|
||||
|
||||
// ---------------- Боевая система: урон, потери, исход ----------------
|
||||
@@ -187,7 +376,8 @@ public struct BattleOutcome
|
||||
public int[] AttackerUnitsBefore, DefenderUnitsBefore;
|
||||
public int[] AttackerSurvivorUnits, DefenderSurvivorUnits;
|
||||
public int[] AttackerDeadUnits, DefenderDeadUnits;
|
||||
public int[] AttackerWoundedUnits, DefenderWoundedUnits; // пока нули: доля раненых не утверждена командой
|
||||
public int[] AttackerWoundedUnits, DefenderWoundedUnits; // раненые (регламент 31.10, решения 31.10, п. 7)
|
||||
public int[] AttackerMissingUnits, DefenderMissingUnits; // пропавшие без вести (решения 01.11, п. 5): выбывшие = погибшие + раненые + пропавшие
|
||||
public int[] DefenderLossUnits; // погибшие + раненые защитника (для старого кода)
|
||||
}
|
||||
|
||||
@@ -202,9 +392,10 @@ public enum BattleChance
|
||||
public struct BattleForecast
|
||||
{
|
||||
public BattleChance Chance;
|
||||
public int WinChancePermille; // шанс победы нападающего в десятых долях процента: 273 = 27,3% (решение №11)
|
||||
public long AttackerPowerMin, AttackerPowerMax; // x100, крайние значения случайности
|
||||
public long DefenderPowerMin, DefenderPowerMax;
|
||||
public int AttackerLossesMin, AttackerLossesMax;
|
||||
public int AttackerLossesMin, AttackerLossesMax; // с учётом округления потерь (решение №13)
|
||||
}
|
||||
|
||||
public interface IBattleSystem
|
||||
|
||||
@@ -32,6 +32,15 @@ public struct GameTimeAdvancedEvent
|
||||
public long UnixNow;
|
||||
}
|
||||
|
||||
// Расчёт игры (модуль Населения: добыча, еда, прирост) дошёл до момента UnixNow (04.11).
|
||||
// Приходит в начале каждого шага расчёта (раз в 10 игровых минут офлайна) и в конце каждого Tick.
|
||||
// Всё, что должно случиться по времени (стройка закончилась), применяется здесь -- тогда следующий шаг
|
||||
// уже считается с новым городом, и добыча идёт ровно с момента завершения, а не с начала офлайна.
|
||||
public struct GameTimeSimulatedEvent
|
||||
{
|
||||
public long UnixNow;
|
||||
}
|
||||
|
||||
// ---------------- Население ----------------
|
||||
// Число людей изменилось (прирост, загрузка, сброс, изменение войска)
|
||||
public struct PopulationChangedEvent
|
||||
@@ -62,19 +71,63 @@ public struct TroopsChangedEvent
|
||||
public int AtHome;
|
||||
public int InMarches;
|
||||
public int Garrison; // из AtHome -- в гарнизоне
|
||||
public int Wounded; // раненые: в госпитале или ждут места (не в AtHome)
|
||||
public int Missing; // пропавшие без вести: занимают места в населении, но не едят и не воюют
|
||||
}
|
||||
|
||||
// Пропавшие без вести вернулись (часть группы боя): здоровые -- в город, раненые -- в госпиталь. Один раз на группу
|
||||
public struct MissingReturnedEvent
|
||||
{
|
||||
public int MissingId;
|
||||
public int Group;
|
||||
public int Healthy;
|
||||
public int Wounded;
|
||||
public long TimeMs;
|
||||
}
|
||||
|
||||
// Окно возвращения кончилось: кто не вернулся -- списан, места в населении освобождены
|
||||
public struct MissingWrittenOffEvent
|
||||
{
|
||||
public int MissingId;
|
||||
public int Count;
|
||||
public long TimeMs;
|
||||
}
|
||||
|
||||
// Раненые вылечены и вернулись в город. Один раз на группу лечения (BatchId), TimeMs -- когда закончили
|
||||
public struct SoldiersHealedEvent
|
||||
{
|
||||
public int BatchId;
|
||||
public int Type;
|
||||
public int Count;
|
||||
public long TimeMs;
|
||||
}
|
||||
|
||||
// Заказ обучения закончен: воины уже в Армии. Приходит один раз на заказ (OrderId), TimeMs -- когда был готов
|
||||
public struct TroopsTrainedEvent
|
||||
{
|
||||
public int OrderId;
|
||||
public int Type;
|
||||
public int Count;
|
||||
public long TimeMs;
|
||||
}
|
||||
|
||||
// ---------------- Глобальный мир ----------------
|
||||
// У событий похода -- номер похода и точное время по часам игры (документ 02 комплекта от 29.10.2026):
|
||||
// TimeMs -- когда это случилось, а не когда игра это заметила (после офлайна это разные моменты).
|
||||
public struct MarchStartedEvent
|
||||
{
|
||||
public int MarchId;
|
||||
public long TimeMs; // выступление
|
||||
public string TargetId;
|
||||
public int Soldiers;
|
||||
public bool IsScout; // поход «Разведка»: без боя и добычи
|
||||
}
|
||||
|
||||
public struct MarchBattleResolvedEvent
|
||||
{
|
||||
public int MarchId;
|
||||
public long BattleId; // номер боя (= Seed): по нему бой можно перепроверить
|
||||
public long TimeMs; // приход к цели и бой
|
||||
public string TargetId;
|
||||
public bool Victory;
|
||||
public int Soldiers;
|
||||
@@ -84,10 +137,15 @@ public struct MarchBattleResolvedEvent
|
||||
public struct MarchReturnedEvent
|
||||
{
|
||||
public int MarchId;
|
||||
public long TimeMs; // возвращение домой
|
||||
public bool Victory;
|
||||
public int Survivors;
|
||||
public int Sent; // сколько воинов уходило в поход
|
||||
public bool TargetEmpty; // лагерь уже был разбит: боя не было, вернулись все
|
||||
public bool TargetEmpty; // лагерь уже был разбит / ресурсной точки уже не было: боя и сбора не было, вернулись все
|
||||
public bool IsGather; // поход на ресурсную точку (сбор, а не бой)
|
||||
public bool IsScout; // поход «Разведка»: дошли до цели и вернулись, боя и добычи нет
|
||||
public int Wounded; // раненых привезли домой -- они в госпитале (или ждут места)
|
||||
public int Missing; // пропали без вести в бою -- могут вернуться сами
|
||||
public string TargetName; // куда ходили ("Замок Змея Горыныча", имя соседа)
|
||||
public string LootText; // добыча текстом: "+20 золота, +40 еды" или "нет"
|
||||
}
|
||||
@@ -123,3 +181,53 @@ public struct NewGameStartedEvent { }
|
||||
public struct GameSavedEvent { }
|
||||
// Сохранения сброшены, все модули уже в состоянии новой игры
|
||||
public struct GameResetEvent { }
|
||||
|
||||
// ---------------- Мир -> Разведка ----------------
|
||||
// Мир сейчас продвинет время до ToMs: разведка успевает досчитать наблюдение по армиям, которые ещё на карте
|
||||
// (после продвижения вернувшиеся походы исчезают). Публикует MarchManager перед каждым расчётом мира.
|
||||
public struct WorldAdvancingEvent
|
||||
{
|
||||
public long ToMs;
|
||||
}
|
||||
|
||||
// ---------------- Разведка ----------------
|
||||
// У всех событий -- устойчивый номер события (армия + номер наблюдения) и время по часам игры (ТЗ разведки, раздел 10)
|
||||
public struct ArmySpottedEvent // чужая армия вошла в зону обзора
|
||||
{
|
||||
public string EventId;
|
||||
public string ArmyId;
|
||||
public long TimeMs;
|
||||
public int ApproxMin, ApproxMax;
|
||||
}
|
||||
|
||||
public struct ArmyLostContactEvent // чужая армия вышла из зоны обзора -- остаётся метка последнего контакта
|
||||
{
|
||||
public string EventId;
|
||||
public string ArmyId;
|
||||
public long TimeMs;
|
||||
}
|
||||
|
||||
// Разведка М2: отряд вышел на разведку с отчётом / отчёт готов (ТЗ разведки, раздел 10)
|
||||
public struct ReconStartedEvent
|
||||
{
|
||||
public string EventId; // "recon_<номер похода>"
|
||||
public string TargetId;
|
||||
public int Level;
|
||||
public long TimeMs;
|
||||
}
|
||||
|
||||
public struct ReconCompletedEvent
|
||||
{
|
||||
public string EventId; // = id отчёта
|
||||
public string TargetId;
|
||||
public int Level;
|
||||
public long TimeMs;
|
||||
}
|
||||
|
||||
public struct ArmyIdentifiedEvent // владелец чужой армии опознан
|
||||
{
|
||||
public string EventId;
|
||||
public string ArmyId;
|
||||
public string OwnerId;
|
||||
public long TimeMs;
|
||||
}
|
||||
|
||||
@@ -21,6 +21,10 @@ public static class GameClock
|
||||
|
||||
public static DateTime UtcNow => DateTimeOffset.FromUnixTimeSeconds(UnixNow).UtcDateTime;
|
||||
|
||||
// То же в миллисекундах -- для таймеров, которые идут плавно (обучение войск, марши)
|
||||
public static long UnixNowMs => (RealUnixSource != null ? RealUnixSource() * 1000 : DateTimeOffset.UtcNow.ToUnixTimeMilliseconds())
|
||||
+ offsetSeconds * 1000;
|
||||
|
||||
// Промотать время вперёд (отладка). Модули узнают об этом из GameTimeAdvancedEvent и досчитываются.
|
||||
public static void Advance(long seconds)
|
||||
{
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Globalization;
|
||||
|
||||
// СИНИЕ КРИСТАЛЛЫ ПРИ ПРЕДЕЛЕ ХРАНИЛИЩА (Регламент редкости синих кристаллов от 05.11.2026, ред. 1.1, раздел 1).
|
||||
// Документ: Docs/Modules/Economy.md, раздел «Синие кристаллы».
|
||||
//
|
||||
// - Начислить сверх предела нельзя, «тихо потерять» излишек -- тоже нельзя.
|
||||
// - Награда, которая не помещается, выдаётся частично (сколько есть места), остаток ждёт («Можно забрать»)
|
||||
// и выдаётся позже -- не сгорает. Повторное «Забрать», загрузка, перезапуск не выдают кристалл дважды:
|
||||
// каждая награда -- по своему id, остаток хранится вместе с id.
|
||||
// - Одновременно ждут не больше MaxWaiting наград. Пока оба места заняты, новая награда, которая не помещается
|
||||
// целиком, не выдаётся совсем (задание остаётся «Забрать») -- ничего не теряется.
|
||||
// Без Unity: те же правила будут на сервере и в тестах. Сколько кристаллов есть и предел -- даёт вызывающий.
|
||||
public sealed class CrystalRewards
|
||||
{
|
||||
public const int MaxWaiting = 2;
|
||||
|
||||
readonly Dictionary<string, int> waiting = new Dictionary<string, int>();
|
||||
|
||||
public int WaitingCount => waiting.Count;
|
||||
public IEnumerable<KeyValuePair<string, int>> Waiting => waiting;
|
||||
|
||||
// Сколько ещё помещается: целые кристаллы до предела. Предела нет -- сколько угодно
|
||||
public static int Free(float have, float cap)
|
||||
{
|
||||
if (float.IsInfinity(cap) || float.IsNaN(cap)) return int.MaxValue;
|
||||
return Math.Max(0, (int)Math.Floor(cap - have + 0.0001f));
|
||||
}
|
||||
|
||||
public static int Fits(int amount, float have, float cap) => Math.Max(0, Math.Min(amount, Free(have, cap)));
|
||||
|
||||
// Сколько кристаллов этой награды ещё ждут места (0 -- не ждут)
|
||||
public int Left(string id) => id != null && waiting.TryGetValue(id, out int n) ? n : 0;
|
||||
|
||||
// Можно ли начать выдачу новой награды: поместится целиком, уже ждёт или есть свободное место ожидания
|
||||
public bool CanStart(string id, int amount, float have, float cap)
|
||||
{
|
||||
if (amount <= 0 || Fits(amount, have, cap) >= amount) return true;
|
||||
return Left(id) > 0 || waiting.Count < MaxWaiting;
|
||||
}
|
||||
|
||||
// Выдать награду id: возвращает, сколько кристаллов начислить сейчас; остаток ждёт под этим id.
|
||||
// Если награда уже ждёт -- выдаётся её остаток (amount не важен: награда одна, повтор её не увеличит).
|
||||
// Перед новой наградой вызывающий проверяет CanStart
|
||||
public int Claim(string id, int amount, float have, float cap)
|
||||
{
|
||||
if (id == null) id = "";
|
||||
int need = waiting.TryGetValue(id, out int left) ? left : Math.Max(0, amount);
|
||||
if (need <= 0) return 0;
|
||||
int give = Fits(need, have, cap);
|
||||
if (need - give > 0) waiting[id] = need - give;
|
||||
else waiting.Remove(id);
|
||||
return give;
|
||||
}
|
||||
|
||||
// ---------------- Сохранение: строки "id|сколько" ----------------
|
||||
|
||||
public List<string> ToLines()
|
||||
{
|
||||
var lines = new List<string>();
|
||||
foreach (var pair in waiting) lines.Add(pair.Key + "|" + pair.Value.ToString(CultureInfo.InvariantCulture));
|
||||
return lines;
|
||||
}
|
||||
|
||||
public void Load(IEnumerable<string> lines)
|
||||
{
|
||||
waiting.Clear();
|
||||
if (lines == null) return;
|
||||
foreach (var line in lines)
|
||||
{
|
||||
if (string.IsNullOrEmpty(line)) continue;
|
||||
int bar = line.LastIndexOf('|');
|
||||
if (bar <= 0) continue;
|
||||
if (int.TryParse(line.Substring(bar + 1), NumberStyles.Integer, CultureInfo.InvariantCulture, out int n) && n > 0)
|
||||
waiting[line.Substring(0, bar)] = n;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 7c3e9a41b2d84f6e9a0c5d1e8f2b4a67
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,8 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 4b19799fef59420d948583d7bd00e661
|
||||
folderAsset: yes
|
||||
DefaultImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"name": "Bogatyri.Intelligence",
|
||||
"rootNamespace": "",
|
||||
"references": ["Bogatyri.Core"],
|
||||
"includePlatforms": [],
|
||||
"excludePlatforms": [],
|
||||
"allowUnsafeCode": false,
|
||||
"overrideReferences": false,
|
||||
"precompiledReferences": [],
|
||||
"autoReferenced": true,
|
||||
"defineConstraints": [],
|
||||
"versionDefines": [],
|
||||
"noEngineReferences": true
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 263eeba80895448da32cd4c9816b18f3
|
||||
AssemblyDefinitionImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,471 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Globalization;
|
||||
using System.Text;
|
||||
|
||||
// МОДУЛЬ РАЗВЕДКИ, этап М1: туман войны, источники обзора, чужие армии, последний контакт.
|
||||
// Документ: Docs/Modules/Intelligence.md. ТЗ разведки 31.10.2026, решение №46: отдельная сборка Bogatyri.Intelligence.
|
||||
//
|
||||
// Без Unity. Карту и армии не хранит и не двигает -- читает их у Мира через контракт IWorldMap.
|
||||
// Своё: туман (исследованные клетки), известные контакты, номера событий наблюдения. Чужие данные не меняет.
|
||||
//
|
||||
// Числа -- ТЕСТ, в игре из таблицы IntelligenceConfig.
|
||||
public sealed class IntelligenceRules
|
||||
{
|
||||
public int SettlementRadius = 5000; // обзор своего поселения: 5 клеток
|
||||
public int ArmyRadius = 1000; // обзор обычной армии: 1 клетка
|
||||
public int[] ObservationPercent = { 0, 10, 20, 30 }; // технология «Обзор» I/II/III -- не складывается, заменяет
|
||||
public long LastContactMs = 10 * 60_000; // последний контакт живёт 10 РЕАЛЬНЫХ минут
|
||||
public int ApproxPercent = 20; // примерная численность: ±20%
|
||||
public int IdentifyPercent = 50; // опознание чужого владельца: 50% на событие наблюдения
|
||||
public long SampleStepMs = 10_000; // шаг расчёта, пока игра была закрыта: 10 реальных секунд
|
||||
public long MaxCatchUpMs = 48L * 3_600_000; // офлайн досчитывается не больше чем за 48 часов
|
||||
public int CellSize = 1000; // клетка тумана = клетка карты
|
||||
|
||||
// М2, целенаправленная разведка (ТЗ, раздел 6): сколько разведчики наблюдают цель, ИГРОВЫХ часов (уровень II, III)
|
||||
public int[] ReconSettlementGameHours = { 2, 4 };
|
||||
public int[] ReconCastleGameHours = { 3, 6 };
|
||||
public int[] ReconCampGameHours = { 2, 4 }; // лагеря в ТЗ не названы -- как поселение (ТЕСТ)
|
||||
public long GameHourMs = 5 * 60 * 1000; // 1 игровой час = 5 реальных минут
|
||||
public int ResourceEstimatePercent = 15; // уровень III: ресурсы -- оценочный диапазон ±15%
|
||||
public int MaxReports = 30; // храним последние отчёты
|
||||
|
||||
public static IntelligenceRules Default => new IntelligenceRules();
|
||||
}
|
||||
|
||||
public sealed class IntelligenceService : IIntelligence, ISaveParticipant
|
||||
{
|
||||
// Что игрок знает о чужой армии. Номер наблюдения -- для устойчивых событий и воспроизводимых бросков
|
||||
class Contact
|
||||
{
|
||||
public string armyId;
|
||||
public string ownerId; // настоящий владелец (из Мира) -- игроку показывается только после опознания
|
||||
public bool identified;
|
||||
public bool observed;
|
||||
public int x, y, toX, toY;
|
||||
public int approxMin, approxMax;
|
||||
public long lastSeenMs;
|
||||
public int observation; // сколько раз армия входила в обзор (событие наблюдения)
|
||||
}
|
||||
|
||||
private readonly IntelligenceRules rules;
|
||||
private readonly Func<IWorldMap> worldMap;
|
||||
private readonly string localPlayer;
|
||||
private readonly Dictionary<string, Contact> contacts = new Dictionary<string, Contact>();
|
||||
private readonly List<KnownContact> contactView = new List<KnownContact>();
|
||||
private readonly List<ArmyOnMap> armies = new List<ArmyOnMap>();
|
||||
private readonly List<(int x, int y, int r)> sources = new List<(int, int, int)>();
|
||||
private readonly HashSet<string> seen = new HashSet<string>();
|
||||
private bool[] explored = Array.Empty<bool>();
|
||||
private int gridW, gridH, halfW, halfH;
|
||||
private long lastTickMs;
|
||||
private bool viewDirty = true;
|
||||
|
||||
public IntelligenceService(IntelligenceRules rules = null, Func<IWorldMap> worldMap = null, string localPlayerId = LocalPlayer.Id)
|
||||
{
|
||||
this.rules = rules ?? IntelligenceRules.Default;
|
||||
this.worldMap = worldMap ?? (() => GameServices.TryGet(out IWorldMap m) ? m : null);
|
||||
localPlayer = localPlayerId;
|
||||
}
|
||||
|
||||
public IntelligenceRules Rules => rules;
|
||||
|
||||
// ---------------- Контракт IIntelligence ----------------
|
||||
|
||||
private int observationLevel;
|
||||
public int ObservationLevel
|
||||
{
|
||||
get => observationLevel;
|
||||
set => observationLevel = Math.Max(0, Math.Min(rules.ObservationPercent.Length - 1, value));
|
||||
}
|
||||
|
||||
// Радиус с «Обзором»: процент текущего уровня, дробные радиусы без округления до клеток
|
||||
public int Radius(int baseRadius)
|
||||
{
|
||||
return (int)((long)baseRadius * (100 + rules.ObservationPercent[observationLevel]) / 100);
|
||||
}
|
||||
|
||||
public MapVisibility GetVisibility(int x, int y)
|
||||
{
|
||||
foreach (var s in sources)
|
||||
if (Inside(x, y, s.x, s.y, s.r)) return MapVisibility.Observed;
|
||||
int cell = Cell(x, y);
|
||||
return cell >= 0 && explored[cell] ? MapVisibility.Explored : MapVisibility.Unexplored;
|
||||
}
|
||||
|
||||
public IReadOnlyList<KnownContact> Contacts
|
||||
{
|
||||
get
|
||||
{
|
||||
if (viewDirty)
|
||||
{
|
||||
contactView.Clear();
|
||||
foreach (var c in contacts.Values)
|
||||
contactView.Add(new KnownContact
|
||||
{
|
||||
ArmyId = c.armyId,
|
||||
Identified = c.identified,
|
||||
OwnerId = c.identified ? c.ownerId : null,
|
||||
Observed = c.observed,
|
||||
X = c.x, Y = c.y, ToX = c.toX, ToY = c.toY,
|
||||
ApproxMin = c.approxMin, ApproxMax = c.approxMax,
|
||||
LastSeenMs = c.lastSeenMs
|
||||
});
|
||||
viewDirty = false;
|
||||
}
|
||||
return contactView;
|
||||
}
|
||||
}
|
||||
|
||||
// Наблюдение от прошлого расчёта до nowMs шагами SampleStepMs: армия, прошедшая через обзор,
|
||||
// пока игра была закрыта, тоже будет замечена -- в хронологическом порядке (ТЗ, приёмка 9)
|
||||
public void ObserveUntil(long nowMs)
|
||||
{
|
||||
IWorldMap map = worldMap();
|
||||
if (map == null) return;
|
||||
EnsureGrid(map);
|
||||
|
||||
long from = lastTickMs == 0 ? nowMs : Math.Max(lastTickMs, nowMs - rules.MaxCatchUpMs);
|
||||
if (nowMs < from) return; // время не идёт назад: повторный расчёт ничего не меняет
|
||||
for (long t = from + rules.SampleStepMs; t < nowMs; t += rules.SampleStepMs) Sample(map, t);
|
||||
Sample(map, nowMs);
|
||||
lastTickMs = nowMs;
|
||||
|
||||
// Последний контакт стареет и удаляется через LastContactMs (ТЗ, раздел 2)
|
||||
var expired = new List<string>();
|
||||
foreach (var c in contacts.Values)
|
||||
if (!c.observed && nowMs - c.lastSeenMs >= rules.LastContactMs) expired.Add(c.armyId);
|
||||
foreach (var id in expired) contacts.Remove(id);
|
||||
if (expired.Count > 0) viewDirty = true;
|
||||
}
|
||||
|
||||
void Sample(IWorldMap map, long t)
|
||||
{
|
||||
armies.Clear();
|
||||
map.GetArmies(t, armies);
|
||||
|
||||
// Источники обзора: своё поселение и свои армии (башни и герой-разведчик -- позже)
|
||||
sources.Clear();
|
||||
if (map.TryGetSettlement(localPlayer, out int sx, out int sy)) sources.Add((sx, sy, Radius(rules.SettlementRadius)));
|
||||
foreach (var a in armies)
|
||||
if (a.OwnerId == localPlayer) sources.Add((a.X, a.Y, Radius(rules.ArmyRadius)));
|
||||
foreach (var s in sources) Explore(s.x, s.y, s.r);
|
||||
|
||||
seen.Clear();
|
||||
foreach (var a in armies)
|
||||
{
|
||||
if (a.OwnerId == localPlayer) continue; // свои -- не разведка
|
||||
bool inView = false;
|
||||
foreach (var s in sources) if (Inside(a.X, a.Y, s.x, s.y, s.r)) { inView = true; break; }
|
||||
if (!inView) continue;
|
||||
seen.Add(a.Id);
|
||||
Observe(a, t);
|
||||
}
|
||||
|
||||
// Ушла из обзора (или исчезла с карты) -- контакт потерян, данные больше не обновляются
|
||||
foreach (var c in contacts.Values)
|
||||
{
|
||||
if (!c.observed || seen.Contains(c.armyId)) continue;
|
||||
c.observed = false;
|
||||
viewDirty = true;
|
||||
EventBus.Publish(new ArmyLostContactEvent { EventId = EventId(c), ArmyId = c.armyId, TimeMs = t });
|
||||
}
|
||||
}
|
||||
|
||||
void Observe(ArmyOnMap a, long t)
|
||||
{
|
||||
if (!contacts.TryGetValue(a.Id, out Contact c))
|
||||
{
|
||||
c = new Contact { armyId = a.Id, ownerId = a.OwnerId };
|
||||
contacts[a.Id] = c;
|
||||
}
|
||||
|
||||
if (!c.observed)
|
||||
{
|
||||
// Новое событие наблюдения: примерная численность и бросок опознания -- один раз на событие
|
||||
c.observed = true;
|
||||
c.observation++;
|
||||
int width = Math.Max(1, a.Soldiers * 2 * rules.ApproxPercent / 100); // ±20% -> окно 40%
|
||||
int shift = (int)(Roll(a.Id, c.observation, 1) % (ulong)(width + 1));
|
||||
c.approxMin = Math.Max(1, a.Soldiers - shift); // настоящая численность всегда внутри окна
|
||||
c.approxMax = c.approxMin + width;
|
||||
EventBus.Publish(new ArmySpottedEvent
|
||||
{ EventId = EventId(c), ArmyId = a.Id, TimeMs = t, ApproxMin = c.approxMin, ApproxMax = c.approxMax });
|
||||
|
||||
if (!c.identified && Roll(a.Id, c.observation, 2) % 100 < (ulong)rules.IdentifyPercent)
|
||||
{
|
||||
c.identified = true;
|
||||
EventBus.Publish(new ArmyIdentifiedEvent { EventId = EventId(c), ArmyId = a.Id, OwnerId = a.OwnerId, TimeMs = t });
|
||||
}
|
||||
}
|
||||
|
||||
c.x = a.X; c.y = a.Y; c.toX = a.ToX; c.toY = a.ToY;
|
||||
c.lastSeenMs = t;
|
||||
viewDirty = true;
|
||||
}
|
||||
|
||||
static string EventId(Contact c) => c.armyId + "#" + c.observation.ToString(CultureInfo.InvariantCulture);
|
||||
|
||||
// Детерминированный бросок: армия + номер наблюдения + назначение. Повторный расчёт, загрузка или клик
|
||||
// не перебрасывают -- одно событие наблюдения, один результат (ТЗ, приёмка 5)
|
||||
static ulong Roll(string armyId, int observation, int purpose)
|
||||
{
|
||||
ulong h = 14695981039346656037UL;
|
||||
foreach (char ch in armyId) h = (h ^ ch) * 1099511628211UL;
|
||||
h ^= (ulong)observation * 0x9E3779B97F4A7C15UL + (ulong)purpose;
|
||||
h = (h ^ (h >> 30)) * 0xBF58476D1CE4E5B9UL;
|
||||
h = (h ^ (h >> 27)) * 0x94D049BB133111EBUL;
|
||||
return h ^ (h >> 31);
|
||||
}
|
||||
|
||||
// ---------------- Туман: исследованные клетки ----------------
|
||||
|
||||
void EnsureGrid(IWorldMap map)
|
||||
{
|
||||
if (map.HalfWidth == halfW && map.HalfHeight == halfH && explored.Length > 0) return;
|
||||
halfW = map.HalfWidth;
|
||||
halfH = map.HalfHeight;
|
||||
gridW = Math.Max(1, (2 * halfW + rules.CellSize - 1) / rules.CellSize);
|
||||
gridH = Math.Max(1, (2 * halfH + rules.CellSize - 1) / rules.CellSize);
|
||||
var old = explored;
|
||||
explored = new bool[gridW * gridH];
|
||||
if (old.Length == explored.Length) Array.Copy(old, explored, old.Length);
|
||||
}
|
||||
|
||||
int Cell(int x, int y)
|
||||
{
|
||||
if (gridW == 0) return -1;
|
||||
int cx = (x + halfW) / rules.CellSize, cy = (y + halfH) / rules.CellSize;
|
||||
if (cx < 0 || cy < 0 || cx >= gridW || cy >= gridH) return -1;
|
||||
return cy * gridW + cx;
|
||||
}
|
||||
|
||||
// Исследована клетка, центр которой попал в круг обзора
|
||||
void Explore(int x, int y, int r)
|
||||
{
|
||||
int c = rules.CellSize;
|
||||
for (int cy = Math.Max(0, (y - r + halfH) / c); cy <= Math.Min(gridH - 1, (y + r + halfH) / c); cy++)
|
||||
for (int cx = Math.Max(0, (x - r + halfW) / c); cx <= Math.Min(gridW - 1, (x + r + halfW) / c); cx++)
|
||||
{
|
||||
int px = cx * c + c / 2 - halfW, py = cy * c + c / 2 - halfH;
|
||||
if (Inside(px, py, x, y, r)) explored[cy * gridW + cx] = true;
|
||||
}
|
||||
}
|
||||
|
||||
static bool Inside(int x, int y, int cx, int cy, int r)
|
||||
{
|
||||
long dx = x - cx, dy = y - cy;
|
||||
return dx * dx + dy * dy <= (long)r * r;
|
||||
}
|
||||
|
||||
public int ExploredCells
|
||||
{
|
||||
get { int n = 0; foreach (var e in explored) if (e) n++; return n; }
|
||||
}
|
||||
|
||||
// ---------------- М2: целенаправленная разведка и отчёты ----------------
|
||||
|
||||
private readonly List<ReconReport> reports = new List<ReconReport>();
|
||||
public IReadOnlyList<ReconReport> Reports => reports;
|
||||
|
||||
public long ReconDurationMs(string targetId, int level)
|
||||
{
|
||||
IWorldMap map = worldMap();
|
||||
if (map == null || level < 2 || level > 3 || !map.TryGetReconTarget(targetId, out ReconTarget t)) return 0;
|
||||
int[] hours = t.Kind == ReconTargetKind.Castle ? rules.ReconCastleGameHours
|
||||
: t.Kind == ReconTargetKind.Camp ? rules.ReconCampGameHours
|
||||
: t.Kind == ReconTargetKind.Settlement ? rules.ReconSettlementGameHours : null;
|
||||
if (hours == null || hours.Length < level - 1) return 0;
|
||||
return Math.Max(0, hours[level - 2]) * rules.GameHourMs;
|
||||
}
|
||||
|
||||
// Отчёт -- снимок цели на момент конца наблюдения. Один отчёт на операцию: повтор (загрузка, двойное событие) -- false
|
||||
public bool CompleteRecon(string reportId, string targetId, int level, long timeMs)
|
||||
{
|
||||
if (string.IsNullOrEmpty(reportId) || reports.Exists(r => r.Id == reportId)) return false;
|
||||
IWorldMap map = worldMap();
|
||||
if (map == null || !map.TryGetReconTarget(targetId, out ReconTarget t)) return false;
|
||||
level = Math.Max(2, Math.Min(3, level));
|
||||
|
||||
var report = new ReconReport
|
||||
{
|
||||
Id = reportId, TargetId = targetId, TargetName = t.Name, Level = level, TimeMs = timeMs,
|
||||
Units = t.Units != null ? (int[])t.Units.Clone() : new int[4],
|
||||
Fortified = t.Fortified, HasHero = t.HasHero,
|
||||
Modifiers = "", ResourceNames = Array.Empty<string>(), ResourceMin = Array.Empty<int>(), ResourceMax = Array.Empty<int>()
|
||||
};
|
||||
if (level >= 3)
|
||||
{
|
||||
report.Modifiers = (t.Fortified ? "защитники за стенами" : "бой в поле, стен нет") + (t.HasHero ? ", есть герой" : ", героя нет");
|
||||
int n = t.ResourceNames != null ? t.ResourceNames.Length : 0;
|
||||
report.ResourceNames = new string[n];
|
||||
report.ResourceMin = new int[n];
|
||||
report.ResourceMax = new int[n];
|
||||
for (int i = 0; i < n; i++)
|
||||
{
|
||||
int amount = t.ResourceAmounts != null && i < t.ResourceAmounts.Length ? Math.Max(0, t.ResourceAmounts[i]) : 0;
|
||||
int width = amount * 2 * rules.ResourceEstimatePercent / 100; // ±15% -> окно 30%, настоящее значение внутри
|
||||
int shift = width > 0 ? (int)(Roll(reportId, i, 3) % (ulong)(width + 1)) : 0;
|
||||
report.ResourceNames[i] = t.ResourceNames[i];
|
||||
report.ResourceMin[i] = Math.Max(0, amount - shift);
|
||||
report.ResourceMax[i] = report.ResourceMin[i] + width;
|
||||
}
|
||||
}
|
||||
reports.Add(report);
|
||||
while (reports.Count > Math.Max(1, rules.MaxReports)) reports.RemoveAt(0);
|
||||
EventBus.Publish(new ReconCompletedEvent { EventId = reportId, TargetId = targetId, Level = level, TimeMs = timeMs });
|
||||
return true;
|
||||
}
|
||||
|
||||
public bool TryGetLatestReport(string targetId, out ReconReport report)
|
||||
{
|
||||
for (int i = reports.Count - 1; i >= 0; i--)
|
||||
if (reports[i].TargetId == targetId) { report = reports[i]; return true; }
|
||||
report = default;
|
||||
return false;
|
||||
}
|
||||
|
||||
static string Csv(int[] v)
|
||||
{
|
||||
if (v == null) return "";
|
||||
var parts = new string[v.Length];
|
||||
for (int i = 0; i < v.Length; i++) parts[i] = v[i].ToString(CultureInfo.InvariantCulture);
|
||||
return string.Join(",", parts);
|
||||
}
|
||||
|
||||
static int[] ParseCsv(string s)
|
||||
{
|
||||
if (string.IsNullOrEmpty(s)) return Array.Empty<int>();
|
||||
string[] parts = s.Split(',');
|
||||
var v = new int[parts.Length];
|
||||
for (int i = 0; i < parts.Length; i++) v[i] = I(parts[i]);
|
||||
return v;
|
||||
}
|
||||
|
||||
static string Clean(string s) => (s ?? "").Replace("|", "/").Replace("\n", " ");
|
||||
|
||||
void RestoreReport(string[] p)
|
||||
{
|
||||
if (p.Length < 12 || reports.Exists(r => r.Id == p[0])) return;
|
||||
long.TryParse(p[4], NumberStyles.Integer, CultureInfo.InvariantCulture, out long time);
|
||||
reports.Add(new ReconReport
|
||||
{
|
||||
Id = p[0], TargetId = p[1], TargetName = p[2], Level = I(p[3]), TimeMs = time,
|
||||
Units = ParseCsv(p[5]), Fortified = p[6] == "1", HasHero = p[7] == "1", Modifiers = p[8],
|
||||
ResourceNames = string.IsNullOrEmpty(p[9]) ? Array.Empty<string>() : p[9].Split(','),
|
||||
ResourceMin = ParseCsv(p[10]), ResourceMax = ParseCsv(p[11])
|
||||
});
|
||||
}
|
||||
|
||||
// ---------------- Сохранение (секция "intelligence") ----------------
|
||||
// Простые строки (модуль без Unity):
|
||||
// tick=<мс> obzor=<уровень> grid=<ширина>x<высота>:<клетки 0/1 в hex>
|
||||
// contact=<армия>|<владелец>|<опознан>|<видна>|x|y|toX|toY|min|max|<видели, мс>|<номер наблюдения>
|
||||
// report=<id>|<цель>|<название>|<уровень>|<время, мс>|<войска,..>|<стены 1/0>|<герой 1/0>|<модификаторы>|<ресурсы,..>|<мин,..>|<макс,..>
|
||||
|
||||
public string SaveKey => "intelligence";
|
||||
|
||||
public string Capture()
|
||||
{
|
||||
var sb = new StringBuilder();
|
||||
sb.Append("tick=").Append(lastTickMs.ToString(CultureInfo.InvariantCulture)).Append('\n');
|
||||
sb.Append("obzor=").Append(observationLevel.ToString(CultureInfo.InvariantCulture)).Append('\n');
|
||||
sb.Append("grid=").Append(gridW).Append('x').Append(gridH).Append(':').Append(PackBits(explored)).Append('\n');
|
||||
foreach (var c in contacts.Values)
|
||||
sb.Append("contact=").Append(string.Join("|", new[]
|
||||
{
|
||||
c.armyId, c.ownerId ?? "", c.identified ? "1" : "0", c.observed ? "1" : "0",
|
||||
N(c.x), N(c.y), N(c.toX), N(c.toY), N(c.approxMin), N(c.approxMax),
|
||||
c.lastSeenMs.ToString(CultureInfo.InvariantCulture), N(c.observation)
|
||||
})).Append('\n');
|
||||
foreach (var r in reports)
|
||||
sb.Append("report=").Append(string.Join("|", new[]
|
||||
{
|
||||
Clean(r.Id), Clean(r.TargetId), Clean(r.TargetName), N(r.Level), r.TimeMs.ToString(CultureInfo.InvariantCulture),
|
||||
Csv(r.Units), r.Fortified ? "1" : "0", r.HasHero ? "1" : "0", Clean(r.Modifiers),
|
||||
string.Join(",", r.ResourceNames ?? Array.Empty<string>()), Csv(r.ResourceMin), Csv(r.ResourceMax)
|
||||
})).Append('\n');
|
||||
return sb.ToString();
|
||||
}
|
||||
|
||||
public void Restore(string data)
|
||||
{
|
||||
ResetState();
|
||||
if (string.IsNullOrEmpty(data)) return;
|
||||
foreach (var raw in data.Split('\n'))
|
||||
{
|
||||
string line = raw.Trim();
|
||||
if (line.StartsWith("tick=")) long.TryParse(line.Substring(5), NumberStyles.Integer, CultureInfo.InvariantCulture, out lastTickMs);
|
||||
else if (line.StartsWith("obzor=") && int.TryParse(line.Substring(6), NumberStyles.Integer, CultureInfo.InvariantCulture, out int lvl)) ObservationLevel = lvl;
|
||||
else if (line.StartsWith("grid=")) RestoreGrid(line.Substring(5));
|
||||
else if (line.StartsWith("contact=")) RestoreContact(line.Substring(8).Split('|'));
|
||||
else if (line.StartsWith("report=")) RestoreReport(line.Substring(7).Split('|'));
|
||||
}
|
||||
viewDirty = true;
|
||||
}
|
||||
|
||||
public void ResetState()
|
||||
{
|
||||
contacts.Clear();
|
||||
reports.Clear();
|
||||
explored = Array.Empty<bool>();
|
||||
gridW = gridH = halfW = halfH = 0;
|
||||
lastTickMs = 0;
|
||||
observationLevel = 0;
|
||||
sources.Clear();
|
||||
viewDirty = true;
|
||||
}
|
||||
|
||||
void RestoreGrid(string s)
|
||||
{
|
||||
int x = s.IndexOf('x'), colon = s.IndexOf(':');
|
||||
if (x < 0 || colon < x) return;
|
||||
if (!int.TryParse(s.Substring(0, x), out int w) || !int.TryParse(s.Substring(x + 1, colon - x - 1), out int h)) return;
|
||||
if (w <= 0 || h <= 0) return;
|
||||
gridW = w; gridH = h;
|
||||
explored = UnpackBits(s.Substring(colon + 1), w * h);
|
||||
// halfW/halfH уточнит EnsureGrid при первом расчёте; размер сетки совпадёт -- клетки сохранятся
|
||||
}
|
||||
|
||||
void RestoreContact(string[] p)
|
||||
{
|
||||
if (p.Length < 12) return;
|
||||
var c = new Contact
|
||||
{
|
||||
armyId = p[0], ownerId = p[1].Length > 0 ? p[1] : null,
|
||||
identified = p[2] == "1", observed = p[3] == "1",
|
||||
x = I(p[4]), y = I(p[5]), toX = I(p[6]), toY = I(p[7]),
|
||||
approxMin = I(p[8]), approxMax = I(p[9]),
|
||||
observation = I(p[11])
|
||||
};
|
||||
long.TryParse(p[10], NumberStyles.Integer, CultureInfo.InvariantCulture, out c.lastSeenMs);
|
||||
if (!string.IsNullOrEmpty(c.armyId)) contacts[c.armyId] = c;
|
||||
}
|
||||
|
||||
static string N(int v) => v.ToString(CultureInfo.InvariantCulture);
|
||||
static int I(string s) => int.TryParse(s, NumberStyles.Integer, CultureInfo.InvariantCulture, out int v) ? v : 0;
|
||||
|
||||
static string PackBits(bool[] bits)
|
||||
{
|
||||
var sb = new StringBuilder();
|
||||
for (int i = 0; i < bits.Length; i += 4)
|
||||
{
|
||||
int v = 0;
|
||||
for (int k = 0; k < 4 && i + k < bits.Length; k++) if (bits[i + k]) v |= 1 << k;
|
||||
sb.Append("0123456789abcdef"[v]);
|
||||
}
|
||||
return sb.ToString();
|
||||
}
|
||||
|
||||
static bool[] UnpackBits(string hex, int count)
|
||||
{
|
||||
var bits = new bool[count];
|
||||
for (int i = 0; i < hex.Length && i * 4 < count; i++)
|
||||
{
|
||||
int v = "0123456789abcdef".IndexOf(char.ToLowerInvariant(hex[i]));
|
||||
if (v < 0) continue;
|
||||
for (int k = 0; k < 4 && i * 4 + k < count; k++) bits[i * 4 + k] = (v & (1 << k)) != 0;
|
||||
}
|
||||
return bits;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 6be01b8e525a455593482d7159414485
|
||||
@@ -12,6 +12,10 @@ public sealed class PopulationRules
|
||||
// Еда в час
|
||||
public double FoodPerCivilianPerHour = 0.1;
|
||||
public double FoodPerSoldierPerHour = 0.2;
|
||||
// Еда воина по типам войск: пехота, лучники, кавалерия, осадные (решения по отчёту Саши, 01.11, п. 4: 0,15 / 0,20 / 0,15 / 0,15).
|
||||
// Раненые едят полную норму своего типа, даже ожидая койку. null -- все воины едят FoodPerSoldierPerHour (как раньше).
|
||||
// Новобранцы в Казармах -- по FoodPerSoldierPerHour (их норма не задана).
|
||||
public double[] FoodPerTroopTypePerHour = null;
|
||||
|
||||
// Прирост +2–3 человека в час, если есть еда
|
||||
public double GrowthPerHour = 2.5;
|
||||
|
||||
@@ -15,7 +15,7 @@ using System.Text;
|
||||
// свободный житель = Civilians - Workers (Idle)
|
||||
// рабочий = Workers (jobs[], только из жителей: SetWorkers берёт не больше свободных)
|
||||
// новобранец = InTraining (TryRecruit забирает только свободных, рабочих не трогает)
|
||||
// воин = Soldiers (хранит Армия; RecruitsTrained убирает новобранцев до зачисления в Армию)
|
||||
// воин = Soldiers (хранит Армия: в городе, в походах и раненые; RecruitsTrained убирает новобранцев до зачисления в Армию)
|
||||
// Total = Civilians + InTraining + Soldiers -- сумма всех групп.
|
||||
// Рабочие места сами не заполняются: только стартовая расстановка (AutoFillAll, один раз), дальше -- игрок.
|
||||
// Найм воина: свободный житель уходит в Казармы (InTraining), после обучения его считает Армия. Резерв не нанять.
|
||||
@@ -79,7 +79,8 @@ public sealed class PopulationService : IPopulation, ISaveParticipant
|
||||
get
|
||||
{
|
||||
if (!GameServices.TryGet(out IArmy army)) return 0;
|
||||
return army.SoldiersAtHome + army.TroopsInMarches;
|
||||
// раненые -- тоже люди (решения 31.10, п. 7); пропавшие без вести занимают места до возвращения (решения 01.11, п. 5)
|
||||
return army.SoldiersAtHome + army.TroopsInMarches + army.WoundedTotal + army.MissingTotal;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -182,7 +183,19 @@ public sealed class PopulationService : IPopulation, ISaveParticipant
|
||||
double FoodPerHourExact(int soldiers)
|
||||
{
|
||||
// Обучающиеся в Казармах едят как воины
|
||||
return civilians * rules.FoodPerCivilianPerHour + (soldiers + inTraining) * rules.FoodPerSoldierPerHour;
|
||||
return civilians * rules.FoodPerCivilianPerHour + SoldiersFood(soldiers) + inTraining * rules.FoodPerSoldierPerHour;
|
||||
}
|
||||
|
||||
// Воины (в городе, в походах и раненые) едят по норме своего типа, если она задана (решения 01.11, п. 4)
|
||||
double SoldiersFood(int soldiers)
|
||||
{
|
||||
var table = rules.FoodPerTroopTypePerHour;
|
||||
int missingNow = GameServices.TryGet(out IArmy a) ? a.MissingTotal : 0; // пропавшие не едят
|
||||
if (table == null || table.Length == 0 || !GameServices.TryGet(out IArmy army)) return Math.Max(0, soldiers - missingNow) * rules.FoodPerSoldierPerHour;
|
||||
double food = 0;
|
||||
for (int t = 0; t < army.TypeCount; t++)
|
||||
food += army.GetAllTroops(t) * (t < table.Length ? table[t] : rules.FoodPerSoldierPerHour);
|
||||
return food;
|
||||
}
|
||||
|
||||
public int GetWorkers(WorkJob job)
|
||||
@@ -221,17 +234,22 @@ public sealed class PopulationService : IPopulation, ISaveParticipant
|
||||
{
|
||||
lastTickUnix = unixNow; // первый расчёт: отсюда и считаем
|
||||
Changed();
|
||||
SimulatedUntil(unixNow);
|
||||
return;
|
||||
}
|
||||
|
||||
long elapsed = unixNow - lastTickUnix;
|
||||
if (elapsed < 0)
|
||||
{
|
||||
// Часы ушли назад (сброс отладочной перемотки, перевод часов на телефоне) -- просто продолжаем с "сейчас"
|
||||
lastTickUnix = unixNow;
|
||||
// Часы ушли назад (сброс отладочной перемотки, перевод часов на телефоне). Метку НЕ двигаем назад:
|
||||
// это время уже посчитано, и когда часы снова дойдут до него, оно не начислится второй раз (04.11).
|
||||
// Но сообщить «расчёт дошёл до сейчас» нужно: время до сейчас тоже уже посчитано, и стройки, у которых оно
|
||||
// вышло, завершаются. Иначе после «Сбросить перемотку» все стройки висели на 00:00, пока реальное время
|
||||
// не догонит перемотку (05.11). Ресурсы здесь не начисляются
|
||||
SimulatedUntil(unixNow);
|
||||
return;
|
||||
}
|
||||
if (elapsed == 0) return;
|
||||
if (elapsed == 0) { SimulatedUntil(unixNow); return; }
|
||||
|
||||
long max = (long)rules.MaxOfflineHours * SecondsPerHour;
|
||||
if (elapsed > max) elapsed = max;
|
||||
@@ -239,6 +257,13 @@ public sealed class PopulationService : IPopulation, ISaveParticipant
|
||||
Simulate(unixNow - elapsed, elapsed);
|
||||
lastTickUnix = unixNow;
|
||||
PruneFamine(unixNow);
|
||||
SimulatedUntil(unixNow);
|
||||
}
|
||||
|
||||
// Расчёт дошёл до момента: то, что случается по времени (стройка закончилась), применяется сейчас (GameTimeSimulatedEvent)
|
||||
static void SimulatedUntil(long unixNow)
|
||||
{
|
||||
EventBus.Publish(new GameTimeSimulatedEvent { UnixNow = unixNow });
|
||||
}
|
||||
|
||||
// ---------------- Расчёт ----------------
|
||||
@@ -260,6 +285,10 @@ public sealed class PopulationService : IPopulation, ISaveParticipant
|
||||
remaining -= step;
|
||||
double hours = step / (double)SecondsPerHour;
|
||||
|
||||
// Расчёт дошёл до начала шага: стройки, закончившиеся к этому моменту, вступают в силу,
|
||||
// и добыча этого шага считается уже с ними (а не с начала офлайна)
|
||||
SimulatedUntil(stepStart);
|
||||
|
||||
// 0. Добыча: рабочие приносят ресурсы (в том числе еду) -- до того, как город поест
|
||||
production?.Produce(hours);
|
||||
|
||||
|
||||
@@ -34,6 +34,23 @@ public class BuildGrid : MonoBehaviour
|
||||
[Header("Где можно строить")]
|
||||
[Tooltip("Посчитать область самому: старые участки + Ратуша + запас")]
|
||||
public bool autoArea = true;
|
||||
|
||||
// Отладка (05.11, просьба Миши): сетка видна всегда и ярче, каждая 5-я линия жирнее -- чтобы примерять
|
||||
// к ней размеры зданий и референсы. Включается кнопкой "Сетка" в панели DEV.
|
||||
public static bool DebugVisible { get; private set; }
|
||||
|
||||
public static void SetDebugVisible(bool on, bool buildModeActive)
|
||||
{
|
||||
DebugVisible = on;
|
||||
if (BuildManager.Instance == null) return; // 2D-город не открыт -- сетка появится, когда откроется
|
||||
var grid = Instance;
|
||||
if (grid == null) return;
|
||||
grid.lastOverlayKey = null;
|
||||
grid.ShowOverlay(on || buildModeActive);
|
||||
}
|
||||
|
||||
// Здание, которое сейчас переносят: его клетки считаются свободными (режим стройки, перенос)
|
||||
public static GameObject IgnoredBuilding;
|
||||
[Tooltip("Запас вокруг старых участков, клеток")]
|
||||
public int padding = 3;
|
||||
public Vector2 areaMin = new Vector2(-34, -10);
|
||||
@@ -218,6 +235,7 @@ public class BuildGrid : MonoBehaviour
|
||||
foreach (var id in FindObjectsByType<PlacedBuildingId>(FindObjectsSortMode.None))
|
||||
{
|
||||
if (string.IsNullOrEmpty(id.buildingName)) continue; // здание сносится в этом кадре
|
||||
if (IgnoredBuilding != null && id.gameObject == IgnoredBuilding) continue; // его переносят
|
||||
BuildingData data = bm != null ? bm.FindData(id.buildingName) : null;
|
||||
Rect r;
|
||||
if (data != null)
|
||||
@@ -254,6 +272,7 @@ public class BuildGrid : MonoBehaviour
|
||||
public void ShowOverlay(bool show)
|
||||
{
|
||||
EnsureInit();
|
||||
show |= DebugVisible; // отладочная сетка не прячется при выходе из режима стройки
|
||||
if (show && overlay == null) CreateOverlay();
|
||||
if (overlay == null) return;
|
||||
overlay.enabled = show;
|
||||
@@ -279,7 +298,8 @@ public class BuildGrid : MonoBehaviour
|
||||
void RedrawOverlay()
|
||||
{
|
||||
// перерисовываем только если занятость поменялась
|
||||
var key = new System.Text.StringBuilder(Width * Height);
|
||||
var key = new System.Text.StringBuilder(Width * Height + 1);
|
||||
key.Append(DebugVisible ? 'D' : 'n');
|
||||
for (int y = 0; y < Height; y++) for (int x = 0; x < Width; x++) key.Append(busy[x, y] ? '1' : '0');
|
||||
string k = key.ToString();
|
||||
if (k == lastOverlayKey) return;
|
||||
@@ -287,7 +307,8 @@ public class BuildGrid : MonoBehaviour
|
||||
|
||||
int w = overlayTex.width, h = overlayTex.height;
|
||||
var px = new Color32[w * h];
|
||||
Color32 line = lineColor, free = freeColor, bad = busyColor;
|
||||
Color32 line = DebugVisible ? new Color(1f, 1f, 1f, 0.7f) : lineColor, free = freeColor, bad = busyColor;
|
||||
Color32 major = new Color32(255, 230, 80, 230); // каждая 5-я клетка -- жёлтая линия (только в отладке)
|
||||
for (int y = 0; y < h; y++)
|
||||
{
|
||||
int cy = y / PixelsPerCell, iy = y % PixelsPerCell;
|
||||
@@ -296,6 +317,7 @@ public class BuildGrid : MonoBehaviour
|
||||
int cx = x / PixelsPerCell, ix = x % PixelsPerCell;
|
||||
bool edge = ix == 0 || iy == 0 || x == w - 1 || y == h - 1;
|
||||
px[y * w + x] = busy[cx, cy] ? bad : (edge ? line : free);
|
||||
if (DebugVisible && ((ix == 0 && cx % 5 == 0) || (iy == 0 && cy % 5 == 0))) px[y * w + x] = major;
|
||||
if (busy[cx, cy] && edge) px[y * w + x] = new Color32(bad.r, bad.g, bad.b, (byte)Mathf.Min(255, bad.a + 50));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -170,6 +170,8 @@ public class BuildManager : MonoBehaviour
|
||||
var grid = BuildGrid.Instance;
|
||||
grid.Refresh(); // занятость на этот момент, а не 0.3 с назад
|
||||
if (!CanBuildAt(data, cell)) return null;
|
||||
long buildMs = BuildTimer.DurationMs(false, 1);
|
||||
if (buildMs > 0 && BuildTimer.FreeBuilders == 0) return null; // все строители заняты
|
||||
|
||||
data.PayCost();
|
||||
|
||||
@@ -180,6 +182,11 @@ public class BuildManager : MonoBehaviour
|
||||
GameObject built = Instantiate(data.prefab, pos, Quaternion.identity);
|
||||
if (!built.activeSelf) built.SetActive(true); // шаблон казарм хранится выключенным
|
||||
built.AddComponent<PlacedBuildingId>().buildingName = data.buildingName;
|
||||
if (buildMs > 0)
|
||||
{
|
||||
long now = GameClock.UnixNowMs;
|
||||
BuildTimer.Begin(built, BuildTimer.Kind.Build, 1, now, now + buildMs); // стоит, но не работает, пока строится
|
||||
}
|
||||
|
||||
Debug.Log($"[СТРОЙКА] Построено: {data.buildingName}, клетки {cell} размер {fp.size}");
|
||||
EventBus.Publish(new BuildingPlacedEvent { BuildingName = data.buildingName });
|
||||
|
||||
@@ -0,0 +1,177 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using UnityEngine;
|
||||
|
||||
// МОДУЛЬ ПОСЕЛЕНИЯ. Документ: Docs/Modules/Settlement.md
|
||||
// Стройка или улучшение здания, которое ещё идёт (01.11, числа -- ТЕСТ).
|
||||
// - Постройка (Kind.Build): здание стоит полупрозрачным и не работает -- его скрипты выключены
|
||||
// (не добывает, не даёт жильё и места, Казармы не учат). Тап по нему -- окно стройки.
|
||||
// - Улучшение (Kind.Upgrade): здание работает на старом уровне, новый уровень -- когда время выйдет.
|
||||
// - Одновременно идёт не больше Builders строек (строители). Ратуша -- тоже стройка.
|
||||
// - Время -- реальное (GameClock.UnixNowMs): идёт и пока игра закрыта, и при перемотке DEV.
|
||||
// - Офлайн (04.11): метка конца хранится в сохранении; после входа стройка завершается в тот момент расчёта
|
||||
// добычи, когда время дошло до её конца (App/ConstructionClock) -- добыча здания идёт с момента завершения.
|
||||
// - Сохраняет модуль Поселения вместе со зданием (startMs, endMs, вид, целевой уровень).
|
||||
// Сколько длится стройка и сколько строителей -- задаёт слой приложения (EconomyConfig): модуль чисел не знает.
|
||||
public class BuildTimer : MonoBehaviour
|
||||
{
|
||||
public enum Kind { Build = 1, Upgrade = 2 }
|
||||
|
||||
public static readonly List<BuildTimer> All = new List<BuildTimer>();
|
||||
|
||||
// Задаёт слой приложения (App/GameModules) из EconomyConfig
|
||||
public static int Builders = 2;
|
||||
// Можно ли завершить стройку, которая кончилась в этот момент (мс). Слой приложения отвечает "да", только когда
|
||||
// расчёт добычи уже дошёл до этого момента (GameTimeSimulatedEvent): иначе после офлайна здание считалось бы
|
||||
// готовым с самого начала офлайна. null -- завершать сразу.
|
||||
public static Func<long, bool> MayComplete;
|
||||
|
||||
// Длительность в мс: (это Ратуша?, до какого уровня; 1 -- постройка). 0 -- мгновенно
|
||||
public static Func<bool, int, long> DurationMs = (townHall, level) => 0;
|
||||
public static int Busy => All.Count;
|
||||
public static int FreeBuilders => Mathf.Max(0, Builders - Busy);
|
||||
|
||||
// Тап по зданию, которое строится (его собственные скрипты выключены) -- интерфейс откроет окно
|
||||
public static event Action<BuildTimer> Tapped;
|
||||
// Стройка закончилась: интерфейс пересчитает город и покажет сообщение
|
||||
public static event Action<BuildTimer> Completed;
|
||||
|
||||
public Kind kind;
|
||||
public long startMs;
|
||||
public long endMs;
|
||||
public int targetLevel = 1;
|
||||
|
||||
readonly List<MonoBehaviour> paused = new List<MonoBehaviour>();
|
||||
readonly List<KeyValuePair<SpriteRenderer, Color>> tinted = new List<KeyValuePair<SpriteRenderer, Color>>();
|
||||
bool finished;
|
||||
Vector3 downPos;
|
||||
|
||||
public bool IsConstruction => kind == Kind.Build;
|
||||
public double SecondsLeft => Math.Max(0, endMs - GameClock.UnixNowMs) / 1000.0;
|
||||
public float Progress => endMs <= startMs ? 1f : Mathf.Clamp01((float)(GameClock.UnixNowMs - startMs) / (endMs - startMs));
|
||||
|
||||
public static BuildTimer Of(GameObject building) => building != null ? building.GetComponent<BuildTimer>() : null;
|
||||
|
||||
public static bool IsConstructing(Component building)
|
||||
{
|
||||
var t = building != null ? building.GetComponent<BuildTimer>() : null;
|
||||
return t != null && !t.finished && t.kind == Kind.Build;
|
||||
}
|
||||
|
||||
// Начать стройку или улучшение. Цену платит тот, кто вызывает. null -- уже идёт стройка этого здания
|
||||
public static BuildTimer Begin(GameObject building, Kind kind, int targetLevel, long startMs, long endMs)
|
||||
{
|
||||
if (building == null) return null;
|
||||
var old = building.GetComponent<BuildTimer>();
|
||||
if (old != null && !old.finished) return null;
|
||||
if (old != null) DestroyImmediate(old); // закончившийся, ещё не убранный Destroy
|
||||
var t = building.AddComponent<BuildTimer>();
|
||||
t.kind = kind;
|
||||
t.targetLevel = Mathf.Max(1, targetLevel);
|
||||
t.startMs = startMs;
|
||||
t.endMs = Math.Max(startMs, endMs);
|
||||
if (kind == Kind.Build) t.PauseBuilding();
|
||||
if (!All.Contains(t)) All.Add(t);
|
||||
return t;
|
||||
}
|
||||
|
||||
// Закончить сейчас (ускорение за кристаллы или время вышло)
|
||||
public void Complete()
|
||||
{
|
||||
if (finished) return;
|
||||
finished = true;
|
||||
All.Remove(this);
|
||||
ResumeBuilding();
|
||||
|
||||
if (kind == Kind.Upgrade)
|
||||
{
|
||||
var townHall = GetComponent<TownHall>();
|
||||
if (townHall != null)
|
||||
{
|
||||
// уровень только растёт: повторное или устаревшее завершение не понизит и не "докупит" уровни
|
||||
townHall.currentLevel = Mathf.Min(townHall.maxLevel, Mathf.Max(townHall.currentLevel, targetLevel));
|
||||
EventBus.Publish(new TownHallLevelChangedEvent { Level = townHall.currentLevel });
|
||||
}
|
||||
else
|
||||
{
|
||||
var leveled = GetComponent<ILeveledBuilding>();
|
||||
if (leveled != null) leveled.Level = Mathf.Max(leveled.Level, targetLevel);
|
||||
}
|
||||
}
|
||||
Completed?.Invoke(this);
|
||||
Destroy(this);
|
||||
}
|
||||
|
||||
// Убрать без результата (загрузка другого сохранения, новая игра)
|
||||
public void Cancel()
|
||||
{
|
||||
finished = true;
|
||||
All.Remove(this);
|
||||
ResumeBuilding();
|
||||
DestroyImmediate(this);
|
||||
}
|
||||
|
||||
// Здание сносится вместе со стройкой: строитель свободен сразу, скрипты не включаем
|
||||
public void Discard()
|
||||
{
|
||||
finished = true;
|
||||
All.Remove(this);
|
||||
}
|
||||
|
||||
void OnEnable()
|
||||
{
|
||||
if (!finished && !All.Contains(this)) All.Add(this);
|
||||
}
|
||||
|
||||
void OnDestroy()
|
||||
{
|
||||
All.Remove(this);
|
||||
}
|
||||
|
||||
void Update()
|
||||
{
|
||||
if (!finished && GameClock.UnixNowMs >= endMs && (MayComplete == null || MayComplete(endMs))) Complete();
|
||||
}
|
||||
|
||||
// ---------------- Здание, которое строится, не работает ----------------
|
||||
|
||||
void PauseBuilding()
|
||||
{
|
||||
foreach (var mb in GetComponents<MonoBehaviour>())
|
||||
{
|
||||
if (mb == null || mb == this || !mb.enabled) continue;
|
||||
if (mb is PlacedBuildingId || mb is BuildingSlotLink) continue;
|
||||
mb.enabled = false;
|
||||
paused.Add(mb);
|
||||
}
|
||||
foreach (var sr in GetComponentsInChildren<SpriteRenderer>(true))
|
||||
{
|
||||
tinted.Add(new KeyValuePair<SpriteRenderer, Color>(sr, sr.color));
|
||||
var c = sr.color;
|
||||
sr.color = new Color(c.r * 0.7f, c.g * 0.7f, c.b * 0.7f, c.a * 0.55f);
|
||||
}
|
||||
}
|
||||
|
||||
void ResumeBuilding()
|
||||
{
|
||||
foreach (var mb in paused) if (mb != null) mb.enabled = true;
|
||||
paused.Clear();
|
||||
foreach (var kv in tinted) if (kv.Key != null) kv.Key.color = kv.Value;
|
||||
tinted.Clear();
|
||||
}
|
||||
|
||||
// ---------------- Тап по зданию, которое строится ----------------
|
||||
|
||||
void OnMouseDown()
|
||||
{
|
||||
downPos = Input.mousePosition;
|
||||
}
|
||||
|
||||
void OnMouseUpAsButton()
|
||||
{
|
||||
if (kind != Kind.Build || finished) return; // при улучшении окно открывает само здание
|
||||
if (UIBlocker.IsPointerOverUI()) return;
|
||||
if ((Input.mousePosition - downPos).magnitude > 25f) return;
|
||||
Tapped?.Invoke(this);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: dbfaba8641b05a9499eabe36c804fb22
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user