Compare commits
49
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a72a8941ff | ||
|
|
af7150a006 | ||
|
|
538dbea222 | ||
|
|
6d36c14313 | ||
|
|
157d3ba510 | ||
|
|
b51ad41925 | ||
|
|
277afdf84e | ||
|
|
e05cf87173 | ||
|
|
1ce4082b4c | ||
|
|
e1228e0b3e | ||
|
|
a2d85f56c3 | ||
|
|
98cbe85481 | ||
|
|
fe02e87225 | ||
|
|
de105c4cbe | ||
|
|
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 | ||
|
|
d30fd34810 | ||
|
|
1b7da37c7e |
@@ -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,14 @@ using UnityEditor;
|
||||
|
||||
// Следит, чтобы сцена карты мира (WorldMap, от Саши) была в Build Settings --
|
||||
// иначе переход город -> карта не работает. Срабатывает сам после каждой компиляции.
|
||||
// Стартовая сцена (05.11): 3D-поселение City3D, если оно есть в проекте, иначе 2D-город MVP1.
|
||||
// Выключенный в Build Settings MVP1 (06.11) скрипт не трогает и заново не добавляет.
|
||||
[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 +18,18 @@ 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;
|
||||
// 2D-город (MVP1) больше не развиваем (06.11): при 3D-поселении его в список не добавляем и не включаем
|
||||
var wanted = new[] { start, WorldScene };
|
||||
foreach (var path in wanted)
|
||||
{
|
||||
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,7 +12,7 @@ MonoBehaviour:
|
||||
m_Script: {fileID: 11500000, guid: 7264ce138a844adc81fa69b77a27c8a6, type: 3}
|
||||
m_Name: BattleConfig
|
||||
m_EditorClassIdentifier:
|
||||
version: 7
|
||||
version: 8
|
||||
counterPercent: 25
|
||||
wallsPercent: 20
|
||||
siegeVsWallsPercent: 50
|
||||
@@ -49,3 +49,4 @@ MonoBehaviour:
|
||||
- 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
|
||||
|
||||
@@ -20,3 +20,7 @@ MonoBehaviour:
|
||||
healGameHours: 04000000040000000600000008000000
|
||||
healFoodPerSoldier: 00000000000000000000000000000000
|
||||
healGoldPerSoldier: 01000000010000000200000003000000
|
||||
missingReturnPercent: 50
|
||||
missingWoundedPercent: 30
|
||||
missingWindowGameHours: 6
|
||||
missingMaxGroups: 4
|
||||
|
||||
@@ -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,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:
|
||||
@@ -42,6 +42,9 @@ public class BattleConfig : ScriptableObject
|
||||
[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%")]
|
||||
@@ -78,6 +81,7 @@ public class BattleConfig : ScriptableObject
|
||||
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,25 +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()
|
||||
@@ -162,10 +204,23 @@ public static class BuildingWindow
|
||||
|
||||
// ---------------- Кристалл ----------------
|
||||
|
||||
// Кристальная шахта (ответ 06.11, п. 3): 3 синих кристалла за 7 реальных суток, при полном хранилище -- пауза
|
||||
static void CrystalSection(RectTransform card)
|
||||
{
|
||||
UIKit.Text(card, "Синий кристалл добывается в кристальных разломах на карте мира. Здание не улучшается.", 24, UIKit.Theme.TextDim, TextAlignmentOptions.Center);
|
||||
var gp = GameProgress.Instance;
|
||||
if (gp != null)
|
||||
UIKit.Text(card, $"Кристальная шахта: {gp.CrystalMinePerPeriod} синих кристалла за {gp.CrystalMinePeriodDays} реальных суток, " +
|
||||
"и пока игра закрыта. Не входит в недельный предел. Здание не улучшается.", 24, UIKit.Theme.TextDim, TextAlignmentOptions.Center);
|
||||
if (GameServices.TryGet(out IEconomy economy)) StorageBar(card, economy, ResourceType.Gems);
|
||||
if (gp == null) return;
|
||||
Live(card, () =>
|
||||
{
|
||||
int working = gp.CrystalMinesWorking;
|
||||
if (working == 0) return "Шахта ещё не работает";
|
||||
if (CrystalRewards.Free(GameProgress.GemsHave, GameProgress.GemsCap) <= 0)
|
||||
return "Пауза: хранилище кристаллов заполнено. Освободите место или расширьте его в окне Ратуши";
|
||||
return $"Работает шахт: {working}. Следующий кристалл через {GameData.FormatTime(gp.CrystalMineSecondsToNext)}";
|
||||
}, 24, UIKit.Theme.Text);
|
||||
}
|
||||
|
||||
// ---------------- Склад и Амбар ----------------
|
||||
@@ -192,6 +247,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}",
|
||||
@@ -220,6 +295,8 @@ public static class BuildingWindow
|
||||
Live(card, () => $"Резерв для найма: {population.CivilianReserve} • можно нанять: {population.Recruitable}", 22, UIKit.Theme.TextDim);
|
||||
}
|
||||
|
||||
CrystalStorageBlock(card, level);
|
||||
|
||||
if (level >= townHall.maxLevel)
|
||||
{
|
||||
UIKit.Text(card, "Максимальный уровень", 26, UIKit.Theme.Gold, TextAlignmentOptions.Center);
|
||||
@@ -235,18 +312,93 @@ 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());
|
||||
}
|
||||
|
||||
// Хранилище синих кристаллов (регламент валют 05.11, разд. 8; ответ 06.11, п. 1): вместимость и платное
|
||||
// расширение. Ратуша только разрешает ступень -- игрок покупает её здесь за золото и обычные ресурсы
|
||||
static void CrystalStorageBlock(RectTransform card, int townHallLevel)
|
||||
{
|
||||
var storage = EconomyConfig.CrystalStorageNow();
|
||||
Header(card, "Хранилище синих кристаллов");
|
||||
Live(card, () =>
|
||||
{
|
||||
float have = GameServices.TryGet(out IEconomy e) ? e.Get(ResourceType.Gems) : 0f;
|
||||
return $"Кристаллов {have:0} / {EconomyConfig.CrystalStorageNow().Capacity}";
|
||||
}, 24, UIKit.Theme.Text);
|
||||
// Недельный PvE-предел (регламент валют 05.11, ответ 06.11): сколько новых кристаллов из заданий, событий и боёв
|
||||
Live(card, () =>
|
||||
{
|
||||
var gp = GameProgress.Instance;
|
||||
if (gp == null) return "";
|
||||
string text = $"Новых кристаллов из заданий, событий и боёв за 7 дней: {gp.CrystalQuotaUsed} / {gp.CrystalQuotaLimit}";
|
||||
if (gp.CrystalQuotaLeft <= 0) text += $". Квота освободится через {GameData.FormatTime(gp.CrystalQuotaSecondsToFree)}, до тех пор -- золото";
|
||||
return text;
|
||||
}, 22, UIKit.Theme.TextDim);
|
||||
|
||||
var next = storage.Next;
|
||||
string why = storage.WhyNot(townHallLevel);
|
||||
if (next == null)
|
||||
{
|
||||
UIKit.Text(card, "Хранилище расширено до предела", 22, UIKit.Theme.TextDim, TextAlignmentOptions.Center);
|
||||
return;
|
||||
}
|
||||
var cost = next.Cost ?? new ResourceAmount[0];
|
||||
if (why != null)
|
||||
{
|
||||
UIKit.Text(card, why + ". Цена: " + CostText(cost), 22, UIKit.Theme.TextDim, TextAlignmentOptions.Center);
|
||||
return;
|
||||
}
|
||||
LiveCost(card, cost);
|
||||
bool canPay = GameServices.TryGet(out IEconomy economy) && economy.CanAfford(cost);
|
||||
var b = UIKit.Button(card, $"Расширить до {next.Capacity}", canPay ? UIKit.Theme.Green : UIKit.Theme.Gray, () =>
|
||||
{
|
||||
if (EconomyConfig.TryExpandCrystalStorage(townHallLevel, out string error))
|
||||
{
|
||||
UIKit.Toast($"Хранилище кристаллов: {EconomyConfig.CrystalStorageNow().Capacity}");
|
||||
Rebuild();
|
||||
}
|
||||
else UIKit.Toast(error);
|
||||
}, -1, 80, 26);
|
||||
LiveAfford(b, canPay, () => GameServices.TryGet(out IEconomy now) && now.CanAfford(cost));
|
||||
}
|
||||
|
||||
// Каких зданий на следующем уровне Ратуши можно построить больше: "Лес 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());
|
||||
}
|
||||
|
||||
// ---------------- Казармы ----------------
|
||||
@@ -340,6 +492,72 @@ public static class BuildingWindow
|
||||
() => 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]} — открыт" // без «✓»: его нет в шрифте LiberationSans (06.11)
|
||||
: $"{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)
|
||||
{
|
||||
@@ -372,22 +590,125 @@ public static class BuildingWindow
|
||||
}
|
||||
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);
|
||||
|
||||
// Ускорение -- за золото (06.11, ТЕСТ: EconomyConfig.construction.secondsPerGold)
|
||||
Func<int> gold = () => t != null ? EconomyConfig.SpeedUpGold(t.SecondsLeft) : 0;
|
||||
var speed = UIKit.Button(card, "", UIKit.Theme.Blue, () =>
|
||||
{
|
||||
if (t == null) return;
|
||||
int price = gold();
|
||||
if (!GameServices.TryGet(out IEconomy e) || !e.TrySpend(ResourceType.Gold, price))
|
||||
{
|
||||
UIKit.Toast($"Нужно {price} золота");
|
||||
return;
|
||||
}
|
||||
t.Complete(); // окно перестроит OnBuildCompleted
|
||||
}, -1, 80, 26);
|
||||
live.Add(() => { if (t != null) UIKit.SetButton(speed, $"Ускорить за {gold()} золота", true); });
|
||||
UIKit.SetButton(speed, $"Ускорить за {gold()} золота", 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);
|
||||
@@ -511,6 +832,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,24 @@ 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);
|
||||
|
||||
// Недельный PvE-предел кристаллов: «+1 PvE-кристалл» выдаёт как настоящая награда (квота, место, компенсация)
|
||||
var rowQ = UIKit.Row(card, 70, 10);
|
||||
Btn(rowQ, "+1 PvE-кристалл", UIKit.Theme.Blue, () => { if (GameProgress.Instance != null) GameProgress.Instance.Grant(new[] { new RewardItem(ResourceType.Gems, 1) }); Refresh(); });
|
||||
// Только для проверки торговца (30 000 / 60 000 золота) и Кристальной шахты (12 000 золота, 3200 камня...):
|
||||
// обычные ресурсы ставятся напрямую, мимо лимита склада
|
||||
Btn(rowQ, "Ресурсы 100 000", UIKit.Theme.Green, () =>
|
||||
{
|
||||
var rm = ResourceManager.Instance;
|
||||
if (rm != null)
|
||||
foreach (var t in new[] { ResourceType.Gold, ResourceType.Wood, ResourceType.Stone, ResourceType.Iron, ResourceType.Food })
|
||||
rm.SetResource(t, 100000);
|
||||
Refresh();
|
||||
});
|
||||
Btn(rowQ, "Сбросить квоту", UIKit.Theme.Gray, () => { if (GameProgress.Instance != null) GameProgress.Instance.DevResetCrystalQuota(); UIKit.Toast("Недельная квота кристаллов сброшена"); Refresh(); });
|
||||
|
||||
var row3 = UIKit.Row(card, 70, 10);
|
||||
Btn(row3, "Еда = 0", UIKit.Theme.Red, ZeroFood);
|
||||
@@ -81,11 +98,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 +280,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)
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
using System.Collections.Generic;
|
||||
using System;
|
||||
using UnityEngine;
|
||||
|
||||
@@ -24,8 +25,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;
|
||||
@@ -67,6 +71,20 @@ public class EconomyConfig : ScriptableObject
|
||||
[Range(0f, 1f)] public float famineNonFoodMultiplier = 0.5f;
|
||||
}
|
||||
|
||||
[Serializable]
|
||||
public class CrystalExpansion
|
||||
{
|
||||
public int townHallLevel = 3;
|
||||
public int capacity = 75;
|
||||
public float gold, wood, stone, iron;
|
||||
|
||||
public CrystalExpansion() { }
|
||||
public CrystalExpansion(int townHall, int cap, float gold, float wood, float stone, float iron)
|
||||
{
|
||||
townHallLevel = townHall; capacity = cap; this.gold = gold; this.wood = wood; this.stone = stone; this.iron = iron;
|
||||
}
|
||||
}
|
||||
|
||||
[Serializable]
|
||||
public class StorageSettings // этап 1: лимиты хранения
|
||||
{
|
||||
@@ -78,8 +96,31 @@ public class EconomyConfig : ScriptableObject
|
||||
public float perWarehouseLevel = 1000f;
|
||||
[Tooltip("+ к лимиту еды за каждый уровень Амбара. Не утверждено")]
|
||||
public float perGranaryLevel = 1500f;
|
||||
[Tooltip("Лимит синего кристалла (решение команды от 19.10: небольшой, не бесконечный). Число не утверждено")]
|
||||
[Tooltip("Стартовая вместимость синих кристаллов (регламент валют 05.11, разд. 8). ТЕСТ")]
|
||||
public float crystalCap = 50f;
|
||||
[Tooltip("Недельный PvE-предел: не больше стольких НОВЫХ синих кристаллов за окно из заданий, событий, боёв, PvE-торговца. " +
|
||||
"Шахта, торговля и грабёж -- вне предела (регламент валют 05.11, разд. 5.2; ответ 06.11, п. 2). ТЕСТ")]
|
||||
public int crystalPveLimit = 6;
|
||||
[Tooltip("Окно недельного PvE-предела, реальных суток (скользящее). ТЕСТ")]
|
||||
public int crystalPveWindowDays = 7;
|
||||
[Tooltip("Компенсация золотом за каждый синий кристалл PvE-награды сверх недельного предела (величина не согласована). ТЕСТ")]
|
||||
public float crystalCompensationGold = 1000f;
|
||||
[Tooltip("Кристальная шахта: синих кристаллов за период на одну работающую шахту (ответ 06.11, п. 3). ТЕСТ")]
|
||||
public int crystalMinePerPeriod = 3;
|
||||
[Tooltip("Кристальная шахта: период, РЕАЛЬНЫХ суток (не игровые часы ×12). ТЕСТ")]
|
||||
public int crystalMinePeriodDays = 7;
|
||||
[Tooltip("Редкий PvE-торговец: цена синего кристалла в золоте по порядку покупок за 7 реальных суток; покупок не больше, " +
|
||||
"чем цен (регламент валют 05.11, разд. 3). Входит в недельный PvE-предел. ТЕСТ")]
|
||||
public int[] crystalTraderPrices = { 30000, 60000 };
|
||||
[Tooltip("Платные расширения хранилища кристаллов по порядку: с какой Ратуши, до скольки, цена (золото и обычные ресурсы). " +
|
||||
"Ратуша только разрешает -- само не растёт (регламент валют 05.11, разд. 8; ответ 06.11, п. 1). Числа и цены ТЕСТ")]
|
||||
public CrystalExpansion[] crystalExpansions =
|
||||
{
|
||||
new CrystalExpansion(3, 75, 2000, 1500, 1500, 0),
|
||||
new CrystalExpansion(5, 100, 4000, 3000, 3000, 1000),
|
||||
new CrystalExpansion(7, 150, 7000, 5000, 5000, 2000),
|
||||
new CrystalExpansion(9, 200, 10000, 8000, 8000, 4000),
|
||||
};
|
||||
|
||||
[Tooltip("Цена Склада: дерево / камень. Не утверждено")]
|
||||
public float warehouseWood = 200f;
|
||||
@@ -123,6 +164,51 @@ 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("Устарело (06.11): ускорение теперь за золото -- secondsPerGold")]
|
||||
public int secondsPerGem = 30;
|
||||
[Tooltip("Ускорение стройки, улучшения и обучения войск -- за золото (06.11; регламент валют: золото -- основной ресурс " +
|
||||
"построек и улучшений, синие кристаллы на ускорения не тратятся): секунд за 1 золото. ТЕСТ")]
|
||||
public int secondsPerGold = 10;
|
||||
// Сноса зданий нет (решение 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 +242,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, 2, 2), // Кристальная шахта: вторая -- на Ратуше 9 (ответ 06.11, п. 3; уровень ТЕСТ)
|
||||
};
|
||||
|
||||
[Header("Защита ресурсов от грабежа (ТЕСТ)")]
|
||||
public ProtectionSettings protection = new ProtectionSettings();
|
||||
|
||||
// ---------------- Загрузка ----------------
|
||||
|
||||
static EconomyConfig cached;
|
||||
@@ -184,11 +294,54 @@ public class EconomyConfig : ScriptableObject
|
||||
case ResourceType.Iron:
|
||||
case ResourceType.Gold: return s.baseGoods + s.perWarehouseLevel * warehouseLevels;
|
||||
case ResourceType.Food: return s.baseFood + s.perGranaryLevel * granaryLevels;
|
||||
case ResourceType.Gems: return s.crystalCap;
|
||||
case ResourceType.Gems: return CrystalStorageNow().Capacity; // стартовая + купленные расширения
|
||||
default: return float.PositiveInfinity;
|
||||
}
|
||||
}
|
||||
|
||||
// Сколько зданий с таким именем можно иметь на этом уровне Ратуши. -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)
|
||||
{
|
||||
@@ -214,4 +367,50 @@ public class EconomyConfig : ScriptableObject
|
||||
default: return 0f;
|
||||
}
|
||||
}
|
||||
|
||||
// Цена ускорения в золоте: сколько осталось секунд -> золото (не меньше 1). Стройка, улучшение и обучение войск
|
||||
public static int SpeedUpGold(double secondsLeft)
|
||||
{
|
||||
int perGold = Mathf.Max(1, Load().construction.secondsPerGold);
|
||||
return Mathf.Max(1, Mathf.CeilToInt((float)(secondsLeft / perGold)));
|
||||
}
|
||||
|
||||
// ---------------- Хранилище синих кристаллов (регламент валют 05.11, разд. 8; правила -- CrystalStorage) ----------------
|
||||
|
||||
// Правила с купленной ступенью (её хранит Экономика: ResourceManager.CrystalStorageLevel, секция "economy")
|
||||
public static CrystalStorage CrystalStorageNow()
|
||||
{
|
||||
var c = Load();
|
||||
var tiers = new List<CrystalStorage.Tier>();
|
||||
if (c.storage.crystalExpansions != null)
|
||||
foreach (var e in c.storage.crystalExpansions)
|
||||
{
|
||||
if (e == null) continue;
|
||||
var cost = new List<ResourceAmount>();
|
||||
if (e.gold > 0) cost.Add(new ResourceAmount(ResourceType.Gold, e.gold));
|
||||
if (e.wood > 0) cost.Add(new ResourceAmount(ResourceType.Wood, e.wood));
|
||||
if (e.stone > 0) cost.Add(new ResourceAmount(ResourceType.Stone, e.stone));
|
||||
if (e.iron > 0) cost.Add(new ResourceAmount(ResourceType.Iron, e.iron));
|
||||
tiers.Add(new CrystalStorage.Tier { TownHallLevel = e.townHallLevel, Capacity = e.capacity, Cost = cost.ToArray() });
|
||||
}
|
||||
var storage = new CrystalStorage(Mathf.RoundToInt(c.storage.crystalCap), tiers);
|
||||
if (ResourceManager.Instance != null) storage.SetLevel(ResourceManager.Instance.CrystalStorageLevel);
|
||||
return storage;
|
||||
}
|
||||
|
||||
// Купить следующую ступень: цена списывается один раз (всё или ничего), ступень и ресурсы -- в одной секции
|
||||
// сохранения "economy", сохраняется сразу. error -- почему нельзя
|
||||
public static bool TryExpandCrystalStorage(int townHallLevel, out string error)
|
||||
{
|
||||
var rm = ResourceManager.Instance;
|
||||
var storage = CrystalStorageNow();
|
||||
error = storage.WhyNot(townHallLevel);
|
||||
if (error != null) return false;
|
||||
if (rm == null || !GameServices.TryGet(out IEconomy economy)) { error = "Склад не найден"; return false; }
|
||||
if (!storage.TryExpand(townHallLevel, cost => economy.TrySpend(cost))) { error = "Не хватает ресурсов"; return false; }
|
||||
rm.CrystalStorageLevel = storage.Level;
|
||||
rm.SetCapacity(ResourceType.Gems, storage.Capacity);
|
||||
if (SaveManager.Instance != null) SaveManager.Instance.SaveGame();
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -42,7 +42,7 @@ public static class GameModules
|
||||
EventBus.Subscribe<GameResetEvent>(_ => TestObservation());
|
||||
|
||||
// Армия -- чистый C#, единственное место хранения войск (секция "army")
|
||||
var army = new ArmyService(marchConfig.baseMarchSlots, HospitalConfig.Load().ToRules()); // госпиталь (решения 31.10, п. 7)
|
||||
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(); });
|
||||
@@ -50,6 +50,18 @@ public static class GameModules
|
||||
// Очередь обучения -- у Армии (решения 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 =>
|
||||
{
|
||||
@@ -80,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()); // воины -- тоже люди
|
||||
@@ -103,6 +130,9 @@ public static class GameModules
|
||||
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);
|
||||
@@ -112,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()
|
||||
@@ -209,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
|
||||
@@ -33,6 +33,16 @@ public class HospitalConfig : ScriptableObject
|
||||
[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()
|
||||
@@ -47,6 +57,17 @@ public class HospitalConfig : ScriptableObject
|
||||
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];
|
||||
|
||||
@@ -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,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,278 @@
|
||||
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)
|
||||
|
||||
// Открытая часть поселения (зоны по уровню Ратуши), 06.11: центр кадра из неё не уходит, а при самом дальнем
|
||||
// отдалении она видна целиком -- даже если "Дальше всего" (maxDistance) подобрано меньше. Ширина 0 -- не задана
|
||||
Rect openArea;
|
||||
float fitOpenDistance; // с какого расстояния открытая часть видна целиком (между HUD сверху и снизу)
|
||||
public const float HudTopShare = 0.16f, HudBottomShare = 0.16f; // какую долю экрана закрывает HUD (ТЕСТ)
|
||||
|
||||
// Дальше всего сейчас: подобранное число, но не ближе, чем нужно, чтобы увидеть всё открытое поселение
|
||||
public float MaxDistanceNow => Mathf.Max(maxDistance, fitOpenDistance);
|
||||
|
||||
public void SetOpenArea(Rect area)
|
||||
{
|
||||
openArea = area;
|
||||
fitOpenDistance = FitDistance(area);
|
||||
Clamp();
|
||||
}
|
||||
|
||||
// Плавное приближение кнопками "+ / −" и колесом: камера доезжает до нужного расстояния за доли секунды
|
||||
float zoomTo = -1f;
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
// onlyZoomOut -- не приближать: если камера уже дальше, расстояние остаётся (открытие зоны, 06.11)
|
||||
public void FocusOn(Rect area, bool onlyZoomOut = false)
|
||||
{
|
||||
focusTarget = new Vector3(area.center.x, 0, area.center.y);
|
||||
// отойти так, чтобы область поместилась между HUD сверху и снизу
|
||||
focusDistance = Mathf.Clamp(FitDistance(area), minDistance, MaxDistanceNow);
|
||||
if (onlyZoomOut) focusDistance = Mathf.Max(focusDistance, Mathf.Min(distance, MaxDistanceNow));
|
||||
focusing = true;
|
||||
zoomTo = -1f;
|
||||
}
|
||||
|
||||
// Плавно подъехать к точке, не меняя приближения
|
||||
public void MoveTo(Vector3 point)
|
||||
{
|
||||
focusTarget = new Vector3(point.x, 0, point.z);
|
||||
focusDistance = distance;
|
||||
focusing = true;
|
||||
zoomTo = -1f;
|
||||
}
|
||||
|
||||
// Расстояние, с которого прямоугольник земли (x, z) виден целиком, если смотреть на его центр. Считается по
|
||||
// настоящей проекции камеры (наклон, поле зрения, форма экрана), поэтому верно при любых числах из DEV
|
||||
public float FitDistance(Rect area)
|
||||
{
|
||||
if (cam == null) cam = GetComponent<Camera>();
|
||||
if (cam == null || area.width <= 0 || area.height <= 0) return 0f;
|
||||
Vector3 keepPos = transform.position;
|
||||
Quaternion keepRot = transform.rotation;
|
||||
float keepOrtho = cam.orthographicSize;
|
||||
var center = new Vector3(area.center.x, 0, area.center.y);
|
||||
Vector3[] corners =
|
||||
{
|
||||
new Vector3(area.xMin, 0, area.yMin), new Vector3(area.xMax, 0, area.yMin),
|
||||
new Vector3(area.xMin, 0, area.yMax), new Vector3(area.xMax, 0, area.yMax),
|
||||
};
|
||||
float lo = 1f, hi = 400f;
|
||||
for (int i = 0; i < 24; i++) // двоичный поиск: ближе -- не помещается, дальше -- помещается
|
||||
{
|
||||
float d = (lo + hi) * 0.5f;
|
||||
PlaceAt(center, d);
|
||||
bool fits = true;
|
||||
foreach (var c in corners)
|
||||
{
|
||||
Vector3 v = cam.WorldToViewportPoint(c);
|
||||
if (v.z <= 0 || v.x < 0.02f || v.x > 0.98f || v.y < HudBottomShare || v.y > 1f - HudTopShare) { fits = false; break; }
|
||||
}
|
||||
if (fits) hi = d; else lo = d;
|
||||
}
|
||||
transform.SetPositionAndRotation(keepPos, keepRot);
|
||||
cam.orthographicSize = keepOrtho;
|
||||
return hi;
|
||||
}
|
||||
|
||||
// Начать / продолжить / закончить перемещение камеры. Зовёт контроллер ввода (он решает, что под пальцем)
|
||||
public void BeginPan(Vector3 screen)
|
||||
{
|
||||
panning = AllowPan; lastPointer = screen; focusing = false; zoomTo = -1f;
|
||||
panPlaneY = GroundHeightUnder(screen);
|
||||
}
|
||||
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)
|
||||
}
|
||||
|
||||
// Кнопки "+ / −" и колесо: плавно (06.11). delta > 0 -- ближе
|
||||
public void Zoom(float delta)
|
||||
{
|
||||
focusing = false;
|
||||
float from = zoomTo > 0 ? zoomTo : distance;
|
||||
zoomTo = Mathf.Clamp(from * (1f - delta), minDistance, MaxDistanceNow);
|
||||
}
|
||||
|
||||
float fitPitch, fitFov, fitAspect; // при каких наклоне, поле зрения и форме экрана считали fitOpenDistance
|
||||
|
||||
void Update()
|
||||
{
|
||||
// Наклон или поле зрения поменяли в DEV, повернули экран -- пересчитать, откуда видно всё поселение
|
||||
if (openArea.width > 0 && cam != null && (fitPitch != pitch || fitFov != cam.fieldOfView || fitAspect != cam.aspect))
|
||||
{
|
||||
fitPitch = pitch; fitFov = cam.fieldOfView; fitAspect = cam.aspect;
|
||||
fitOpenDistance = FitDistance(openArea);
|
||||
}
|
||||
|
||||
// Колесо мыши (редактор, ПК)
|
||||
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;
|
||||
zoomTo = -1f;
|
||||
distance = Mathf.Clamp(distance * previous / current, minDistance, MaxDistanceNow);
|
||||
}
|
||||
}
|
||||
|
||||
if (zoomTo > 0)
|
||||
{
|
||||
distance = Mathf.Lerp(distance, zoomTo, 1f - Mathf.Exp(-Time.unscaledDeltaTime * 12f));
|
||||
if (Mathf.Abs(distance - zoomTo) < 0.02f) { distance = zoomTo; zoomTo = -1f; }
|
||||
}
|
||||
distance = Mathf.Clamp(distance, minDistance, MaxDistanceNow);
|
||||
|
||||
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()
|
||||
{
|
||||
Rect r = bounds;
|
||||
if (openArea.width > 0 && openArea.height > 0) // центр кадра -- только над открытой частью поселения
|
||||
r = Rect.MinMaxRect(Mathf.Max(r.xMin, openArea.xMin), Mathf.Max(r.yMin, openArea.yMin),
|
||||
Mathf.Min(r.xMax, openArea.xMax), Mathf.Min(r.yMax, openArea.yMax));
|
||||
target.x = Mathf.Clamp(target.x, r.xMin, r.xMax);
|
||||
target.z = Mathf.Clamp(target.z, r.yMin, r.yMax);
|
||||
target.y = 0;
|
||||
}
|
||||
|
||||
// Дымка начинается за городом, а не на фиксированном расстоянии от камеры: иначе при отдалении весь город
|
||||
// уходил в серую дымку -- казалось, что камера не отдаляется, а сцена сереет (05.11)
|
||||
public float fogStartBehind = 4f, fogEndBehind = 59f;
|
||||
|
||||
public void Apply()
|
||||
{
|
||||
if (cam == null) cam = GetComponent<Camera>();
|
||||
PlaceAt(target, distance);
|
||||
if (RenderSettings.fog && RenderSettings.fogMode == FogMode.Linear)
|
||||
{
|
||||
RenderSettings.fogStartDistance = distance + fogStartBehind;
|
||||
RenderSettings.fogEndDistance = distance + fogEndBehind;
|
||||
}
|
||||
}
|
||||
|
||||
// Поставить камеру: смотрит на точку center с расстояния d.
|
||||
// Камера сцены может быть ортографической (вид 2.5D без перспективы): у неё расстояние не меняет размер города
|
||||
// на экране -- «+ / −» и щипок ничего не делали (05.11). Тогда приближение -- через размер кадра: столько же
|
||||
// земли, сколько видно перспективной камерой с этого расстояния
|
||||
void PlaceAt(Vector3 center, float d)
|
||||
{
|
||||
Quaternion rot = Quaternion.Euler(pitch, yaw, 0);
|
||||
transform.rotation = rot;
|
||||
transform.position = center - rot * Vector3.forward * d;
|
||||
if (cam != null && cam.orthographic)
|
||||
cam.orthographicSize = d * Mathf.Tan(cam.fieldOfView * 0.5f * Mathf.Deg2Rad);
|
||||
}
|
||||
|
||||
// Точка под пальцем на горизонтальной плоскости на высоте земли, где палец коснулся (06.11): на холме карта
|
||||
// идёт за пальцем точно, а не чуть быстрее или медленнее, как было с плоскостью y = 0
|
||||
float panPlaneY;
|
||||
|
||||
Vector3 GroundPoint(Vector3 screen)
|
||||
{
|
||||
Ray ray = cam.ScreenPointToRay(screen);
|
||||
var plane = new Plane(Vector3.up, new Vector3(0, panPlaneY, 0));
|
||||
return plane.Raycast(ray, out float t) ? ray.GetPoint(t) : target;
|
||||
}
|
||||
|
||||
// Высота земли (объект "Ground" 3D-поселения) под экранной точкой; не попали -- 0
|
||||
float GroundHeightUnder(Vector3 screen)
|
||||
{
|
||||
if (cam == null) return 0f;
|
||||
foreach (var hit in Physics.RaycastAll(cam.ScreenPointToRay(screen), 500f))
|
||||
if (hit.collider.gameObject.name == "Ground") return hit.point.y;
|
||||
return 0f;
|
||||
}
|
||||
}
|
||||
@@ -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,263 @@
|
||||
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());
|
||||
}
|
||||
|
||||
// Кристальные шахты: с какого момента каждая добывает (мс; long.MinValue -- давно достроена; конец стройки -- если
|
||||
// ещё строится или достроилась офлайн). Город открыт -- по сцене, иначе -- по сохранению settlement3d.
|
||||
// null -- город ещё не загружен: считать рано (иначе офлайн-время шахты пропало бы)
|
||||
public static List<long> CrystalMinesActiveFrom()
|
||||
{
|
||||
var list = new List<long>();
|
||||
var city = City3DController.Instance;
|
||||
if (city != null)
|
||||
{
|
||||
if (!city.StateApplied) return null;
|
||||
foreach (var id in Object.FindObjectsByType<PlacedBuildingId>(FindObjectsSortMode.None))
|
||||
{
|
||||
if (id.buildingName != "Кристалл") continue;
|
||||
var t = BuildTimer.Of(id.gameObject);
|
||||
list.Add(t != null && BuildTimer.IsConstructing(id.transform) ? t.endMs : long.MinValue);
|
||||
}
|
||||
return list;
|
||||
}
|
||||
if (!City3DSave.Loaded) return null;
|
||||
if (City3DSave.Cached != null)
|
||||
foreach (var e in City3DSave.Cached.buildings)
|
||||
{
|
||||
if (e == null || e.type != "Кристалл") continue;
|
||||
bool building = e.timerKind == (int)BuildTimer.Kind.Build && e.level <= 1 && e.timerEndMs > 0;
|
||||
list.Add(building ? e.timerEndMs : long.MinValue);
|
||||
}
|
||||
return list;
|
||||
}
|
||||
|
||||
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,186 @@
|
||||
using Bogatyri.Settlement.Placement;
|
||||
using UnityEngine;
|
||||
|
||||
// ГЕРОЙ В 3D-ПОСЕЛЕНИИ (перенос из 2D, решение 05.11: всё из 2D-города -- в 3D). Как HeroWalker в 2D-городе:
|
||||
// - живёт у Терема богатыря: появляется у его ворот, когда Терем достроен, и исчезает, если Терема нет;
|
||||
// - гуляет по свободным клеткам 3D-карты (земля, открытая зона, без зданий и деревьев) не дальше WalkRadius от Терема:
|
||||
// шаг -- на соседнюю свободную клетку, поэтому сквозь здания и деревья не проходит; после нескольких шагов стоит 1-3 с;
|
||||
// - тап по герою -- фраза и окно героя (окно Терема); в режиме стройки и переноса прячется;
|
||||
// - пока без арта: фигурка из простых форм (тело, голова, шлем, щит, копьё). Арт подставим в BuildFigure.
|
||||
// Только оживление города: на экономику, бой и сохранение не влияет. Создаёт и убирает City3DController.
|
||||
public class City3DHero : MonoBehaviour
|
||||
{
|
||||
public static City3DHero Instance { get; private set; }
|
||||
|
||||
const float Speed = 1.3f; // клеток в секунду
|
||||
const int WalkRadius = 6; // насколько далеко уходит от Терема
|
||||
|
||||
static readonly string[] Phrases =
|
||||
{
|
||||
"Город под защитой!",
|
||||
"Веди, воевода, — не подведу.",
|
||||
"Силушка богатырская при мне.",
|
||||
"Дозором обхожу владения.",
|
||||
"Есть ли где ворог? Только скажи.",
|
||||
};
|
||||
|
||||
SettlementMap map;
|
||||
System.Func<int> townHallLevel;
|
||||
public string CastleId { get; private set; }
|
||||
public GameObject CastleView { get; private set; }
|
||||
|
||||
Transform figure;
|
||||
Vector2Int cell; // клетка, к которой идёт (или на которой стоит)
|
||||
Vector2Int previous; // откуда пришёл -- чтобы не топтаться туда-обратно
|
||||
int stepsLeft;
|
||||
float waitLeft;
|
||||
|
||||
// Видимость: в режиме стройки и переноса герой прячется, чтобы не мешать ставить здания
|
||||
public bool Hidden
|
||||
{
|
||||
get => figure != null && !figure.gameObject.activeSelf;
|
||||
set { if (figure != null && figure.gameObject.activeSelf == value) figure.gameObject.SetActive(!value); }
|
||||
}
|
||||
|
||||
public static City3DHero Spawn(Transform parent, SettlementMap map, string castleId, GameObject castleView, System.Func<int> townHallLevel)
|
||||
{
|
||||
if (Instance != null) Destroy(Instance.gameObject);
|
||||
var go = new GameObject("Hero3D");
|
||||
go.transform.SetParent(parent, false);
|
||||
var h = go.AddComponent<City3DHero>();
|
||||
h.map = map;
|
||||
h.CastleId = castleId;
|
||||
h.CastleView = castleView;
|
||||
h.townHallLevel = townHallLevel;
|
||||
h.BuildFigure();
|
||||
h.cell = h.previous = h.Gate();
|
||||
go.transform.position = CellWorld(h.cell);
|
||||
h.waitLeft = 1f;
|
||||
Instance = h;
|
||||
return h;
|
||||
}
|
||||
|
||||
void OnDestroy()
|
||||
{
|
||||
if (Instance == this) Instance = null;
|
||||
}
|
||||
|
||||
// ---------------- Внешний вид (заглушка) ----------------
|
||||
|
||||
void BuildFigure()
|
||||
{
|
||||
figure = new GameObject("Figure").transform;
|
||||
figure.SetParent(transform, false);
|
||||
Part(PrimitiveType.Capsule, new Vector3(0, 0.32f, 0), new Vector3(0.28f, 0.3f, 0.22f), new Color(0.55f, 0.4f, 0.2f)); // кафтан
|
||||
Part(PrimitiveType.Sphere, new Vector3(0, 0.72f, 0), new Vector3(0.2f, 0.2f, 0.2f), new Color(1f, 0.85f, 0.7f)); // голова
|
||||
Part(PrimitiveType.Sphere, new Vector3(0, 0.8f, 0), new Vector3(0.22f, 0.14f, 0.22f), new Color(0.75f, 0.75f, 0.8f)); // шлем
|
||||
Part(PrimitiveType.Cylinder, new Vector3(0, 0.9f, 0), new Vector3(0.03f, 0.06f, 0.03f), new Color(0.75f, 0.75f, 0.8f)); // шишак
|
||||
Part(PrimitiveType.Cube, new Vector3(0.17f, 0.36f, 0.04f), new Vector3(0.04f, 0.26f, 0.2f), new Color(0.8f, 0.15f, 0.15f)); // щит
|
||||
Part(PrimitiveType.Cylinder, new Vector3(-0.17f, 0.5f, 0), new Vector3(0.025f, 0.5f, 0.025f), new Color(0.45f, 0.3f, 0.15f)); // копьё
|
||||
Part(PrimitiveType.Cylinder, new Vector3(0, 0.005f, 0), new Vector3(0.36f, 0.005f, 0.36f), new Color(0.18f, 0.22f, 0.14f)); // тень
|
||||
|
||||
// Тап по герою: коллайдер чуть больше фигурки (City3DController ищет City3DHero под пальцем)
|
||||
var hit = gameObject.AddComponent<BoxCollider>();
|
||||
hit.center = new Vector3(0, 0.5f, 0);
|
||||
hit.size = new Vector3(0.7f, 1.1f, 0.7f);
|
||||
}
|
||||
|
||||
void Part(PrimitiveType type, Vector3 pos, Vector3 scale, Color color)
|
||||
{
|
||||
var p = GameObject.CreatePrimitive(type);
|
||||
Destroy(p.GetComponent<Collider>());
|
||||
p.transform.SetParent(figure, false);
|
||||
p.transform.localPosition = pos;
|
||||
p.transform.localScale = scale;
|
||||
p.GetComponent<Renderer>().sharedMaterial = Map3D.Mat(color);
|
||||
}
|
||||
|
||||
// ---------------- Прогулка по клеткам ----------------
|
||||
|
||||
static Vector3 CellWorld(Vector2Int c)
|
||||
{
|
||||
float x = c.x + 0.5f, z = c.y + 0.5f;
|
||||
return new Vector3(x, City3DLayout.HeightAt(x, z), z);
|
||||
}
|
||||
|
||||
bool Walkable(int x, int y)
|
||||
{
|
||||
if (map == null || !map.InMap(x, y)) return false;
|
||||
if (map.KindAt(x, y) != CellKind.Ground || !map.IsOpen(x, y, townHallLevel != null ? townHallLevel() : 1)) return false;
|
||||
return map.BuildingAt(x, y) == null && map.ObstacleAt(x, y) == null;
|
||||
}
|
||||
|
||||
bool CastleRect(out PlacedRect r)
|
||||
{
|
||||
r = default;
|
||||
return map != null && map.TryGetBuilding(CastleId, out r);
|
||||
}
|
||||
|
||||
bool NearHome(int x, int y)
|
||||
{
|
||||
if (!CastleRect(out var r)) return true;
|
||||
float cx = r.X + r.W / 2f, cy = r.Y + r.H / 2f;
|
||||
return Mathf.Abs(x + 0.5f - cx) <= WalkRadius + r.W / 2f && Mathf.Abs(y + 0.5f - cy) <= WalkRadius + r.H / 2f;
|
||||
}
|
||||
|
||||
// Ворота: ближайшая свободная клетка вокруг Терема (с нижней стороны -- первой)
|
||||
Vector2Int Gate()
|
||||
{
|
||||
if (!CastleRect(out var r)) return Vector2Int.zero;
|
||||
for (int ring = 1; ring <= WalkRadius; ring++)
|
||||
for (int x = r.X - ring; x < r.X + r.W + ring; x++)
|
||||
foreach (int y in new[] { r.Y - ring, r.Y + r.H - 1 + ring })
|
||||
if (Walkable(x, y)) return new Vector2Int(x, y);
|
||||
return new Vector2Int(r.X + r.W / 2, r.Y - 1);
|
||||
}
|
||||
|
||||
static readonly Vector2Int[] Dirs = { Vector2Int.up, Vector2Int.down, Vector2Int.left, Vector2Int.right };
|
||||
|
||||
// Следующая клетка: соседняя свободная рядом с Теремом; назад -- только если больше некуда
|
||||
Vector2Int NextCell()
|
||||
{
|
||||
var options = new System.Collections.Generic.List<Vector2Int>();
|
||||
foreach (var d in Dirs)
|
||||
{
|
||||
var c = cell + d;
|
||||
if (c != previous && Walkable(c.x, c.y) && NearHome(c.x, c.y)) options.Add(c);
|
||||
}
|
||||
if (options.Count == 0 && Walkable(previous.x, previous.y)) return previous;
|
||||
return options.Count > 0 ? options[Random.Range(0, options.Count)] : cell;
|
||||
}
|
||||
|
||||
void Update()
|
||||
{
|
||||
if (Hidden) return;
|
||||
if (!Walkable(cell.x, cell.y) && cell != Gate()) { cell = previous = Gate(); transform.position = CellWorld(cell); } // на клетку поставили здание
|
||||
if (waitLeft > 0f) { waitLeft -= Time.deltaTime; figure.localPosition = Vector3.zero; return; }
|
||||
|
||||
Vector3 goal = CellWorld(cell), pos = transform.position;
|
||||
Vector3 flat = new Vector3(goal.x - pos.x, 0, goal.z - pos.z);
|
||||
if (flat.magnitude < 0.03f)
|
||||
{
|
||||
if (stepsLeft <= 0) { stepsLeft = Random.Range(3, 7); waitLeft = Random.Range(1f, 3f); return; }
|
||||
stepsLeft--;
|
||||
var next = NextCell();
|
||||
previous = cell;
|
||||
cell = next;
|
||||
return;
|
||||
}
|
||||
Vector3 step = flat.normalized * Speed * Time.deltaTime;
|
||||
if (step.magnitude > flat.magnitude) step = flat;
|
||||
pos += step;
|
||||
pos.y = City3DLayout.HeightAt(pos.x, pos.z); // по земле, через холмы
|
||||
transform.position = pos;
|
||||
transform.rotation = Quaternion.Slerp(transform.rotation, Quaternion.LookRotation(flat), Time.deltaTime * 10f); // смотрит, куда идёт
|
||||
figure.localPosition = new Vector3(0f, Mathf.Abs(Mathf.Sin(Time.time * 10f)) * 0.04f, 0f); // покачивание при ходьбе
|
||||
}
|
||||
|
||||
// ---------------- Тап ----------------
|
||||
|
||||
public void OnTapped()
|
||||
{
|
||||
UIKit.Toast("Богатырь: " + Phrases[Random.Range(0, Phrases.Length)]);
|
||||
waitLeft = 2f; // остановился поговорить
|
||||
if (CastleView != null) BuildingWindow.Open(CastleView);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 8d4c2a7e1f5b4e93b6a1c0d9e2f73a58
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -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,110 @@
|
||||
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;
|
||||
}
|
||||
|
||||
public static float RiverX(float z) => 37f + 3f * Mathf.Sin(z * 0.17f);
|
||||
public static bool IsRiver(float x, float z) => Mathf.Abs(x - RiverX(z)) < 1.3f;
|
||||
public static bool IsMountain(float x, float z) => x < 9f && z > 34f || x < 4f && z > 24f;
|
||||
|
||||
// Местность за краем участка (только вид, стройки нет): те же холмы, река и горы продолжаются без шва,
|
||||
// к дальнему краю (SkirtCells) плавно опускаются до общей равнины SkirtFloor (06.11: при отдалении край участка
|
||||
// выглядел обрезанным -- холмы и река обрывались уступом)
|
||||
public const int SkirtCells = 60;
|
||||
public const float SkirtFloor = -0.7f;
|
||||
|
||||
public static float SkirtHeight(float x, float z)
|
||||
{
|
||||
float outside = Mathf.Max(0f, Mathf.Max(Mathf.Max(-x, x - Size), Mathf.Max(-z, z - Size)));
|
||||
float fade = 1f - Mathf.SmoothStep(0f, 1f, Mathf.InverseLerp(SkirtCells - 20f, SkirtCells, outside));
|
||||
return Mathf.Lerp(SkirtFloor, HeightAt(x, z), fade);
|
||||
}
|
||||
|
||||
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,93 @@
|
||||
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; }
|
||||
// Сохранение уже прочитано (или новая игра): по Cached можно считать здания вне 3D-сцены (Кристальная шахта на карте мира)
|
||||
public static bool Loaded { 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)
|
||||
{
|
||||
Loaded = true;
|
||||
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()
|
||||
{
|
||||
Loaded = true;
|
||||
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
|
||||
@@ -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
|
||||
@@ -123,6 +123,9 @@ public static class GameData
|
||||
public const int MaxPlayerLevel = 60;
|
||||
|
||||
// ---------- Основные задания (цепочка) ----------
|
||||
// Синие кристаллы (Регламент валют, кристаллов и торговли от 05.11, разд. 5.1): стандартные задания дают золото и
|
||||
// обычные ресурсы, не синие кристаллы (было 20-250, 05.11 -- 1-2). Кристаллы -- только сложная недельная цепочка
|
||||
// (ещё не сделана). Числа ТЕСТ
|
||||
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 +133,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.Gold, 300)),
|
||||
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.Gold, 1000)),
|
||||
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.Gold, 500), 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.Gold, 300)),
|
||||
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.Gold, 500)),
|
||||
new QuestDef("m_battle_5", "Выиграть 5 боёв", QuestKind.BattlesWon, 5, 150, R(ResourceType.Gold, 800)),
|
||||
new QuestDef("m_player_5", "Достигнуть 5 уровня", QuestKind.PlayerLevel, 5, 100, R(ResourceType.Gold, 1000)),
|
||||
new QuestDef("m_th_3", "Ратуша 3 уровня", QuestKind.TownHallLevel, 3, 200, R(ResourceType.Gold, 1500), 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.Gold, 2000)),
|
||||
new QuestDef("m_th_5", "Ратуша 5 уровня", QuestKind.TownHallLevel, 5, 400, R(ResourceType.Gold, 3000)),
|
||||
};
|
||||
|
||||
// ---------- Ежедневные задания (сбрасываются каждый день) ----------
|
||||
@@ -154,8 +157,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)),
|
||||
};
|
||||
|
||||
@@ -212,7 +215,10 @@ public static class GameData
|
||||
};
|
||||
}
|
||||
public static int StageXp(int stage) { return 20 + stage * 6; }
|
||||
public static int StageGems(int stage) { return stage % 5 == 0 ? 25 : 0; } // каждый 5-й этап -- кристаллы
|
||||
// Синие кристаллы за бой -- только за сильных противников, редко (регламент валют 05.11, разд. 5.1): каждый
|
||||
// StageGemsEvery-й этап -- StageGemsAmount (было: каждый 5-й -- 25). ТЕСТ
|
||||
public const int StageGemsEvery = 10, StageGemsAmount = 1;
|
||||
public static int StageGems(int stage) { return stage % StageGemsEvery == 0 ? StageGemsAmount : 0; }
|
||||
|
||||
// ---------- Арена ----------
|
||||
public const int ArenaMaxTickets = 5;
|
||||
@@ -232,43 +238,41 @@ public static class GameData
|
||||
}
|
||||
|
||||
// ---------- Ежедневная награда за вход (7 дней) ----------
|
||||
// Без синих кристаллов (регламент валют 05.11, разд. 5.1; было 50 на 4-й и 200 на 7-й день). ТЕСТ
|
||||
public static readonly RewardItem[][] LoginRewards =
|
||||
{
|
||||
new[] { R(ResourceType.Gold, 500) },
|
||||
new[] { R(ResourceType.Food, 1000) },
|
||||
new[] { R(ResourceType.Wood, 800), R(ResourceType.Stone, 500) },
|
||||
new[] { R(ResourceType.Gems, 50) },
|
||||
new[] { R(ResourceType.Gold, 1000) },
|
||||
new[] { R(ResourceType.Iron, 600) },
|
||||
new[] { R(ResourceType.Gold, 2000) },
|
||||
new[] { R(ResourceType.Gems, 200) },
|
||||
new[] { R(ResourceType.Gold, 3000), R(ResourceType.Stone, 1000) },
|
||||
};
|
||||
|
||||
// ---------- Магазин ----------
|
||||
public static readonly List<ShopItemDef> ShopItems = BuildShop();
|
||||
static List<ShopItemDef> BuildShop()
|
||||
{
|
||||
// Обычный магазин -- за золото: основные ресурсы и хозяйственные товары (регламент валют 05.11, разд. 2).
|
||||
// Убраны: пакеты за 50-80 синих кристаллов (больше хранилища), «Мешок золота» за кристаллы, обмен золота на
|
||||
// кристаллы (будет у редкого PvE-торговца: 30 000 / 60 000 золота, не больше 2 за 7 дней), «Горсть» (500) и
|
||||
// «Сундук» (3000) кристаллов за деньги (за деньги будут космо-кристаллы). Цены ТЕСТ
|
||||
var list = new List<ShopItemDef>
|
||||
{
|
||||
new ShopItemDef("free_chest", "Бесплатный сундук (раз в день)", ResourceType.Gems, 0, 1),
|
||||
new ShopItemDef("wood_1k", "Пакет дерева", ResourceType.Gems, 50, 5, R(ResourceType.Wood, 1000)),
|
||||
new ShopItemDef("stone_1k", "Пакет камня", ResourceType.Gems, 50, 5, R(ResourceType.Stone, 1000)),
|
||||
new ShopItemDef("food_15", "Пакет еды", ResourceType.Gems, 50, 5, R(ResourceType.Food, 1500)),
|
||||
new ShopItemDef("iron_500", "Пакет железа", ResourceType.Gems, 60, 5, R(ResourceType.Iron, 500)),
|
||||
new ShopItemDef("gold_1k", "Мешок золота", ResourceType.Gems, 80, 3, R(ResourceType.Gold, 1000)),
|
||||
new ShopItemDef("gems_10", "Обмен золота на кристаллы", ResourceType.Gold, 1000, 3, R(ResourceType.Gems, 10)),
|
||||
new ShopItemDef("free_chest", "Бесплатный сундук (раз в день)", ResourceType.Gold, 0, 1),
|
||||
new ShopItemDef("wood_1k", "Пакет дерева", ResourceType.Gold, 600, 5, R(ResourceType.Wood, 1000)),
|
||||
new ShopItemDef("stone_1k", "Пакет камня", ResourceType.Gold, 700, 5, R(ResourceType.Stone, 1000)),
|
||||
new ShopItemDef("food_15", "Пакет еды", ResourceType.Gold, 600, 5, R(ResourceType.Food, 1500)),
|
||||
new ShopItemDef("iron_500", "Пакет железа", ResourceType.Gold, 900, 5, R(ResourceType.Iron, 500)),
|
||||
};
|
||||
var p1 = new ShopItemDef("iap_small", "Горсть кристаллов", ResourceType.Gems, 0, 0, R(ResourceType.Gems, 500));
|
||||
p1.comingSoon = true; p1.priceLabel = "99 руб.";
|
||||
var p2 = new ShopItemDef("iap_big", "Сундук кристаллов", ResourceType.Gems, 0, 0, R(ResourceType.Gems, 3000));
|
||||
p2.comingSoon = true; p2.priceLabel = "499 руб.";
|
||||
list.Add(p1); list.Add(p2);
|
||||
return list;
|
||||
}
|
||||
// Содержимое бесплатного сундука (выбирается случайно)
|
||||
public static readonly RewardItem[] FreeChestPool =
|
||||
{
|
||||
R(ResourceType.Wood, 500), R(ResourceType.Stone, 400), R(ResourceType.Food, 600),
|
||||
R(ResourceType.Iron, 250), R(ResourceType.Gold, 400), R(ResourceType.Gems, 15),
|
||||
R(ResourceType.Iron, 250), R(ResourceType.Gold, 400), // без синих кристаллов (было 15; регламент валют 05.11)
|
||||
};
|
||||
|
||||
// ---------- Еженедельное событие ----------
|
||||
@@ -280,9 +284,11 @@ public static class GameData
|
||||
{
|
||||
new[] { R(ResourceType.Gold, 500) },
|
||||
new[] { R(ResourceType.Food, 1500), R(ResourceType.Wood, 1000) },
|
||||
new[] { R(ResourceType.Gems, 50) },
|
||||
new[] { R(ResourceType.Gold, 1500), R(ResourceType.Stone, 1000) },
|
||||
new[] { R(ResourceType.Iron, 1500), R(ResourceType.Gold, 2000) },
|
||||
new[] { R(ResourceType.Gems, 200) },
|
||||
// Верхняя ступень недельного события -- сложная недельная цель: 1 синий кристалл (было 50 и 200;
|
||||
// регламент валют 05.11, разд. 5.1 -- «сложная недельная цепочка: 1»). ТЕСТ
|
||||
new[] { R(ResourceType.Gold, 3000), R(ResourceType.Gems, 1) },
|
||||
};
|
||||
|
||||
// ---------- Текст наград ----------
|
||||
|
||||
@@ -73,6 +73,7 @@ public class GameProgress : MonoBehaviour
|
||||
nextTick = Time.unscaledTime + 1f;
|
||||
EnsureDailyAndWeekly();
|
||||
ProcessTraining();
|
||||
ProcessCrystalMine();
|
||||
UpdateBuildingsCount();
|
||||
UpdatePowerResource();
|
||||
}
|
||||
@@ -144,6 +145,18 @@ 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.crystalQuota == null) Data.crystalQuota = new List<string>();
|
||||
var storageCfg = EconomyConfig.Load().storage;
|
||||
quota = new CrystalQuota(storageCfg.crystalPveLimit, Math.Max(1, storageCfg.crystalPveWindowDays) * CrystalQuota.Day);
|
||||
quota.Load(Data.crystalQuota);
|
||||
if (Data.crystalTrader == null) Data.crystalTrader = new List<string>();
|
||||
if (Data.crystalTraderNextId < 1) Data.crystalTraderNextId = 1;
|
||||
trader = new CrystalTrader(storageCfg.crystalTraderPrices, Math.Max(1, storageCfg.crystalPveWindowDays) * CrystalQuota.Day);
|
||||
trader.Load(Data.crystalTrader);
|
||||
mine = new CrystalMine(storageCfg.crystalMinePerPeriod, Math.Max(1, storageCfg.crystalMinePeriodDays) * CrystalQuota.Day);
|
||||
mine.Load(Data.crystalMine);
|
||||
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,22 +320,210 @@ public class GameProgress : MonoBehaviour
|
||||
return true;
|
||||
}
|
||||
|
||||
public void Grant(RewardItem[] rewards, int xp = 0, bool toast = true)
|
||||
// Выдать награду. Синие кристаллы (регламент валют 05.11, ответ 06.11):
|
||||
// - новые PvE-кристаллы (newPveCrystals) -- в пределах недельной квоты; сверх неё -- золото (компенсация);
|
||||
// - не сверх вместимости хранилища: не поместившиеся приходят письмом «Кристаллы ждут места» и забираются из него,
|
||||
// когда место появится (квота за них уже учтена). Ничего не теряется.
|
||||
// Возвращает, что выдано сейчас (кристаллы -- сколько поместилось, плюс компенсация)
|
||||
public RewardItem[] Grant(RewardItem[] rewards, int xp = 0, bool toast = true, bool newPveCrystals = true)
|
||||
{
|
||||
if (ResourceManager.Instance != null && rewards != null)
|
||||
int gems = GemsIn(rewards), compensated = 0, compGold = 0;
|
||||
if (gems > 0 && newPveCrystals)
|
||||
{
|
||||
int allowed = TakeQuota(null, gems, out compGold);
|
||||
compensated = gems - allowed;
|
||||
gems = allowed;
|
||||
}
|
||||
int 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 (compGold > 0) given.Add(new RewardItem(ResourceType.Gold, compGold));
|
||||
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 (compensated > 0) text += (text.Length > 0 ? ". " : "") + QuotaText(compensated, compGold);
|
||||
if (text.Length > 0) UIKit.Toast("Получено: " + text);
|
||||
}
|
||||
MarkChanged();
|
||||
return given.ToArray();
|
||||
}
|
||||
|
||||
// ---------------- Недельный PvE-предел синих кристаллов (правила -- CrystalQuota) ----------------
|
||||
|
||||
CrystalQuota quota = new CrystalQuota();
|
||||
|
||||
public int CrystalQuotaUsed => quota.Used(GameClock.UnixNowMs);
|
||||
public int CrystalQuotaLimit => quota.Limit;
|
||||
public int CrystalQuotaLeft => quota.Left(GameClock.UnixNowMs);
|
||||
// Через сколько секунд освободится квота (0 -- уже есть)
|
||||
public double CrystalQuotaSecondsToFree
|
||||
{
|
||||
get { long at = quota.NextFreeMs(GameClock.UnixNowMs); return at <= 0 ? 0 : Math.Max(0, at - GameClock.UnixNowMs) / 1000.0; }
|
||||
}
|
||||
|
||||
// Взять квоту: сколько из amount выдать кристаллами; за остальные -- золото (compGold). id -- операция (null -- разовая)
|
||||
int TakeQuota(string id, int amount, out int compGold)
|
||||
{
|
||||
int allowed = quota.Take(id, amount, GameClock.UnixNowMs);
|
||||
compGold = Mathf.RoundToInt((amount - allowed) * EconomyConfig.Load().storage.crystalCompensationGold);
|
||||
Data.crystalQuota = quota.ToLines();
|
||||
MarkChanged();
|
||||
return allowed;
|
||||
}
|
||||
|
||||
// ---------------- Кристальная шахта (правила -- CrystalMine) ----------------
|
||||
|
||||
CrystalMine mine = new CrystalMine();
|
||||
|
||||
// Работающих шахт сейчас (достроенных)
|
||||
public int CrystalMinesWorking
|
||||
{
|
||||
get
|
||||
{
|
||||
var from = City3DGameplay.CrystalMinesActiveFrom();
|
||||
int n = 0;
|
||||
if (from != null) foreach (long t in from) if (t <= GameClock.UnixNowMs) n++;
|
||||
return n;
|
||||
}
|
||||
}
|
||||
public double CrystalMineSecondsToNext { get { long ms = mine.MsToNext(CrystalMinesWorking); return ms < 0 ? -1 : ms / 1000.0; } }
|
||||
public int CrystalMinePerPeriod => mine.PerPeriod;
|
||||
public int CrystalMinePeriodDays => (int)(mine.PeriodMs / CrystalQuota.Day);
|
||||
|
||||
// Раз в секунду: выработка шахт до текущего момента (и офлайн -- при первом расчёте после входа).
|
||||
// Не входит в недельный PvE-предел. Хранилище полное -- пауза
|
||||
void ProcessCrystalMine()
|
||||
{
|
||||
if (ResourceManager.Instance == null) return; // склада нет -- не считать (время не теряется)
|
||||
var from = City3DGameplay.CrystalMinesActiveFrom();
|
||||
if (from == null) return; // город ещё не загружен
|
||||
int got = mine.Advance(GameClock.UnixNowMs, from, CrystalRewards.Free(GemsHave, GemsCap));
|
||||
Data.crystalMine = mine.ToLine();
|
||||
if (got <= 0) return;
|
||||
suppressTracking++;
|
||||
ResourceManager.Instance.AddResource(ResourceType.Gems, got); // место проверено в Advance
|
||||
suppressTracking--;
|
||||
MarkChanged();
|
||||
Save(); // прогресс шахты
|
||||
if (SaveManager.Instance != null) SaveManager.Instance.SaveGame(); // и кристаллы -- сразу, без повторного начисления
|
||||
UIKit.Toast($"Кристальная шахта: +{got} синих кристаллов");
|
||||
}
|
||||
|
||||
// ---------------- Редкий PvE-торговец (правила -- CrystalTrader) ----------------
|
||||
|
||||
CrystalTrader trader = new CrystalTrader();
|
||||
|
||||
public int TraderPrice => trader.PriceNow(GameClock.UnixNowMs); // -1 -- в этом окне больше нельзя
|
||||
public int TraderBought => trader.BoughtInWindow(GameClock.UnixNowMs);
|
||||
public int TraderMaxPurchases => trader.Prices.Length;
|
||||
public double TraderSecondsToSlot
|
||||
{
|
||||
get { long at = trader.NextSlotMs(GameClock.UnixNowMs); return at <= 0 ? 0 : Math.Max(0, at - GameClock.UnixNowMs) / 1000.0; }
|
||||
}
|
||||
|
||||
// Почему сейчас нельзя купить (без списания); null -- можно, если хватит золота
|
||||
public string TraderBlockReason()
|
||||
{
|
||||
switch (trader.Check(GameClock.UnixNowMs, quota, CrystalRewards.Free(GemsHave, GemsCap)))
|
||||
{
|
||||
case CrystalTrader.Result.NoSlots: return $"Куплено {TraderBought} из {TraderMaxPurchases} за 7 дней. Следующая покупка -- через {GameData.FormatTime(TraderSecondsToSlot)}";
|
||||
case CrystalTrader.Result.NoQuota: return $"Недельный предел новых кристаллов исчерпан ({quota.Limit} за 7 дней). Освободится через {GameData.FormatTime(CrystalQuotaSecondsToFree)}";
|
||||
case CrystalTrader.Result.NoSpace: return $"Хранилище кристаллов заполнено ({GemsHave:0} / {GemsCap:0}). Расширить -- в окне Ратуши";
|
||||
default: return null;
|
||||
}
|
||||
}
|
||||
|
||||
// Купить 1 синий кристалл. Проверки (покупки, квота, место) -- до оплаты: при отказе золото не списывается
|
||||
public void BuyCrystalFromTrader()
|
||||
{
|
||||
string id = "trader_" + Data.crystalTraderNextId;
|
||||
var result = trader.TryBuy(id, GameClock.UnixNowMs, quota, CrystalRewards.Free(GemsHave, GemsCap),
|
||||
price => GameServices.TryGet(out IEconomy e) && e.TrySpend(ResourceType.Gold, price));
|
||||
if (result != CrystalTrader.Result.Bought)
|
||||
{
|
||||
UIKit.Toast(result == CrystalTrader.Result.NoGold ? $"Нужно {TraderPrice:N0} золота" : (TraderBlockReason() ?? "Покупка недоступна"));
|
||||
return;
|
||||
}
|
||||
Data.crystalTraderNextId++;
|
||||
if (ResourceManager.Instance != null)
|
||||
{
|
||||
suppressTracking++;
|
||||
ResourceManager.Instance.AddResource(ResourceType.Gems, 1); // место проверено до оплаты
|
||||
suppressTracking--;
|
||||
}
|
||||
Data.crystalTrader = trader.ToLines();
|
||||
Data.crystalQuota = quota.ToLines();
|
||||
MarkChanged();
|
||||
if (SaveManager.Instance != null) SaveManager.Instance.SaveGame(); // золото, кристалл и покупка -- сразу на диск
|
||||
UIKit.Toast("Куплен 1 синий кристалл");
|
||||
}
|
||||
|
||||
// Только для проверки (DEV): забыть недельную квоту
|
||||
public void DevResetCrystalQuota()
|
||||
{
|
||||
quota.Load(null);
|
||||
trader.Load(null);
|
||||
Data.crystalQuota = quota.ToLines();
|
||||
Data.crystalTrader = trader.ToLines();
|
||||
MarkChanged();
|
||||
}
|
||||
|
||||
string QuotaText(int compensated, int compGold) =>
|
||||
$"Недельный предел синих кристаллов ({quota.Limit} за {quota.WindowMs / CrystalQuota.Day} дн.) исчерпан: вместо {compensated} кристаллов -- {compGold} золота";
|
||||
|
||||
// ---------------- Синие кристаллы при пределе 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;
|
||||
}
|
||||
|
||||
// =====================================================================
|
||||
@@ -380,10 +581,10 @@ public class GameProgress : MonoBehaviour
|
||||
{
|
||||
Data.xp -= GameData.XpToNextLevel(Data.level);
|
||||
Data.level++;
|
||||
int gems = 10 * Data.level;
|
||||
// Подарок за уровень -- золото (было ещё 10 × уровень синих кристаллов; регламент валют 05.11, разд. 5.1)
|
||||
SendMail("Новый уровень: " + Data.level + "!",
|
||||
"Поздравляем, командир! Ваша слава растёт. Примите подарок от совета королевства.",
|
||||
new RewardItem(ResourceType.Gems, gems), new RewardItem(ResourceType.Gold, 300 * Data.level));
|
||||
new RewardItem(ResourceType.Gold, 300 * Data.level));
|
||||
UIKit.Toast("Новый уровень: " + Data.level + "! Подарок в почте.");
|
||||
Vibrate();
|
||||
}
|
||||
@@ -455,13 +656,46 @@ 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, Mathf.Min(gems, quota.Left(GameClock.UnixNowMs)), 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);
|
||||
|
||||
// Квота -- один раз на задание (id); сверх неё -- золото. Ждущие места кристаллы квоту второй раз не берут
|
||||
int compGold = 0, allowed = gems > 0 ? TakeQuota(key, gems, out compGold) : 0;
|
||||
var rest = new List<RewardItem>(WithoutGems(q.rewards));
|
||||
if (compGold > 0) rest.Add(new RewardItem(ResourceType.Gold, compGold));
|
||||
Grant(rest.ToArray(), q.xp, false, false);
|
||||
int given = allowed > 0 ? GiveQuestGems(key, allowed) : 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} кристаллов ждут места -- «Забрать» в заданиях";
|
||||
if (gems - allowed > 0) text += ". " + QuotaText(gems - allowed, compGold);
|
||||
UIKit.Toast("Получено: " + text);
|
||||
}
|
||||
|
||||
public int ClaimableQuestsCount
|
||||
@@ -471,6 +705,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;
|
||||
}
|
||||
}
|
||||
@@ -513,7 +753,7 @@ public class GameProgress : MonoBehaviour
|
||||
SendMail("Добро пожаловать, командир!",
|
||||
"Королевство ждёт своего правителя. Примите стартовый подарок, чтобы начать строительство.",
|
||||
new RewardItem(ResourceType.Gold, 500), new RewardItem(ResourceType.Wood, 500),
|
||||
new RewardItem(ResourceType.Food, 500), new RewardItem(ResourceType.Gems, 100));
|
||||
new RewardItem(ResourceType.Food, 500)); // без 100 синих кристаллов (регламент валют 05.11)
|
||||
}
|
||||
|
||||
public int UnreadMailCount
|
||||
@@ -525,11 +765,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, false); // всё помещается; квота за них уже учтена при первой выдаче
|
||||
if (toast && left > 0) UIKit.Toast($"Ещё {left} кристаллов ждут места -- в этом письме");
|
||||
return left == 0;
|
||||
}
|
||||
|
||||
public void DeleteMail(MailItem m)
|
||||
@@ -542,13 +798,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();
|
||||
}
|
||||
|
||||
@@ -679,7 +938,7 @@ public class GameProgress : MonoBehaviour
|
||||
// Заказ не встал -- цену возвращаем целиком
|
||||
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);
|
||||
Grant(refund, 0, false, false); // возврат -- не новые кристаллы
|
||||
UIKit.Toast(error);
|
||||
return;
|
||||
}
|
||||
@@ -703,14 +962,14 @@ public class GameProgress : MonoBehaviour
|
||||
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 сек
|
||||
return EconomyConfig.SpeedUpGold(secs); // за золото (06.11, ТЕСТ; было 1 кристалл за 30 с)
|
||||
}
|
||||
}
|
||||
|
||||
public void SpeedUpTraining()
|
||||
{
|
||||
if (Training.Count == 0) return;
|
||||
if (!Pay(new[] { new RewardItem(ResourceType.Gems, SpeedUpCost) })) return;
|
||||
if (!Pay(new[] { new RewardItem(ResourceType.Gold, SpeedUpCost) })) return;
|
||||
Army.FinishAllTraining(GameClock.UnixNowMs);
|
||||
}
|
||||
|
||||
@@ -721,6 +980,7 @@ public class GameProgress : MonoBehaviour
|
||||
long now = GameClock.UnixNowMs;
|
||||
army.ProcessTraining(now);
|
||||
army.ProcessHealing(now);
|
||||
army.ProcessMissing(now);
|
||||
}
|
||||
|
||||
// Заказ закончен (событие Армии, один раз на заказ): статистика заданий и всплывашка
|
||||
@@ -787,7 +1047,7 @@ public class GameProgress : MonoBehaviour
|
||||
Data.battlesWon++;
|
||||
Data.dailyBattles++;
|
||||
AddEventPoints(10);
|
||||
Grant(res.rewards, res.xp, false);
|
||||
res.rewards = Grant(res.rewards, res.xp, false); // в окне итога -- что выдано на самом деле (квота, место)
|
||||
}
|
||||
else
|
||||
{
|
||||
@@ -881,7 +1141,7 @@ public class GameProgress : MonoBehaviour
|
||||
if (res.win)
|
||||
{
|
||||
res.ratingChange = 15 + Mathf.Clamp((opp.rating - Data.arenaRating) / 10, 0, 15) + UnityEngine.Random.Range(0, 6);
|
||||
res.rewards = new[] { new RewardItem(ResourceType.Gold, 150), new RewardItem(ResourceType.Gems, 5) };
|
||||
res.rewards = new[] { new RewardItem(ResourceType.Gold, 200) }; // без 5 синих кристаллов (регламент валют 05.11)
|
||||
Data.arenaWins++;
|
||||
Grant(res.rewards, 15, false);
|
||||
}
|
||||
|
||||
@@ -69,6 +69,15 @@ 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>();
|
||||
// Недельный PvE-предел синих кристаллов (регламент валют 05.11): "время мс|сколько|id операции"
|
||||
public List<string> crystalQuota = new List<string>();
|
||||
// Покупки у редкого PvE-торговца (регламент валют 05.11, разд. 3): "время мс|id покупки"
|
||||
public List<string> crystalTrader = new List<string>();
|
||||
public int crystalTraderNextId = 1;
|
||||
// Кристальная шахта: "прогресс мс|до какого момента посчитано мс" (ответ 06.11, п. 3)
|
||||
public string crystalMine = "";
|
||||
public float[] dailyCollected = new float[8];
|
||||
public int dailyBattles;
|
||||
public int dailyTroops;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -16,13 +16,21 @@ public class RewardsPanel : BasePanel
|
||||
|
||||
bool canClaim = GP.CanClaimLogin;
|
||||
int total = GameData.LoginRewards.Length;
|
||||
|
||||
// Кнопка и таймер -- сверху: в горизонтальном экране окно низкое, и под календарём кнопка уходила
|
||||
// за нижнюю панель HUD -- её не было видно (06.11)
|
||||
var b = UIKit.Button(list, canClaim ? "ЗАБРАТЬ ПОДАРОК" : "Подарок получен", canClaim ? UIKit.Theme.Green : UIKit.Theme.Gray,
|
||||
() => GP.ClaimLogin(), -1, 90, 32);
|
||||
b.interactable = canClaim;
|
||||
timer = UIKit.Text(list, "", 26, UIKit.Theme.TextDim, TextAlignmentOptions.Center, 40);
|
||||
|
||||
int claimedUpTo = d.loginRewardIndex; // сколько дней цикла уже забрано
|
||||
if (!canClaim && claimedUpTo == 0 && d.lastLoginRewardDay >= 0) claimedUpTo = total; // только что забрали 7-й
|
||||
|
||||
RectTransform row = null;
|
||||
for (int i = 0; i < total; i++)
|
||||
{
|
||||
if (i % 4 == 0) row = UIKit.Row(list, 230, 12);
|
||||
if (i % 4 == 0) row = UIKit.Row(list, 170, 12);
|
||||
bool claimed = i < claimedUpTo;
|
||||
bool current = canClaim && i == d.loginRewardIndex;
|
||||
|
||||
@@ -39,12 +47,6 @@ public class RewardsPanel : BasePanel
|
||||
claimed ? UIKit.Theme.Green : UIKit.Theme.Gold, TextAlignmentOptions.Center);
|
||||
}
|
||||
|
||||
UIKit.Spacer(list, 10);
|
||||
var b = UIKit.Button(list, canClaim ? "ЗАБРАТЬ ПОДАРОК" : "Подарок получен", canClaim ? UIKit.Theme.Green : UIKit.Theme.Gray,
|
||||
() => GP.ClaimLogin(), -1, 110, 36);
|
||||
b.interactable = canClaim;
|
||||
|
||||
timer = UIKit.Text(list, "", 26, UIKit.Theme.TextDim, TextAlignmentOptions.Center, 50);
|
||||
Tick();
|
||||
}
|
||||
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
using UnityEngine;
|
||||
using TMPro;
|
||||
|
||||
// МАГАЗИН: бесплатный сундук раз в день, пакеты ресурсов за кристаллы, обмен золота,
|
||||
// витрина платных наборов (пока "скоро"). Лимиты покупок сбрасываются в полночь.
|
||||
// МАГАЗИН: редкий PvE-торговец (синий кристалл за золото), бесплатный сундук раз в день, пакеты ресурсов за золото.
|
||||
// Лимиты пакетов сбрасываются в полночь; у торговца -- свои: не больше 2 кристаллов за 7 реальных суток.
|
||||
public class ShopPanel : BasePanel
|
||||
{
|
||||
TextMeshProUGUI timer;
|
||||
@@ -15,6 +15,8 @@ public class ShopPanel : BasePanel
|
||||
WalletItem(wallet, ResourceType.Gems);
|
||||
WalletItem(wallet, ResourceType.Gold);
|
||||
|
||||
TraderCard(list);
|
||||
|
||||
timer = UIKit.Text(list, "", 24, UIKit.Theme.TextDim, TextAlignmentOptions.Center, 40);
|
||||
Tick();
|
||||
|
||||
@@ -52,6 +54,30 @@ public class ShopPanel : BasePanel
|
||||
}
|
||||
}
|
||||
|
||||
// Редкий PvE-торговец (регламент валют 05.11, разд. 3): дорогой обмен золота на синий кристалл
|
||||
void TraderCard(Transform list)
|
||||
{
|
||||
var gp = GameProgress.Instance;
|
||||
int price = gp.TraderPrice;
|
||||
string block = gp.TraderBlockReason();
|
||||
bool enough = price >= 0 && gp.Res(ResourceType.Gold) >= price;
|
||||
|
||||
var card = UIKit.Card(list, UIKit.Theme.CardDark);
|
||||
var row = UIKit.Row(card, 150, 16);
|
||||
UIKit.Icon(row, GameData.ResColor(ResourceType.Gems), "", 110);
|
||||
var info = UIKit.Card(row, new Color(0, 0, 0, 0), 0, 4);
|
||||
UIKit.Size(info, -1, -1, 1);
|
||||
var title = UIKit.Text(info, "Редкий торговец: синий кристалл", 30);
|
||||
title.fontStyle = FontStyles.Bold;
|
||||
UIKit.Text(info, $"Куплено за 7 дней: {gp.TraderBought} / {gp.TraderMaxPurchases}. Входит в недельный предел новых кристаллов " +
|
||||
$"({gp.CrystalQuotaUsed} / {gp.CrystalQuotaLimit})", 22, UIKit.Theme.TextDim);
|
||||
if (block != null) UIKit.Text(info, block, 22, UIKit.Theme.Gold);
|
||||
|
||||
string label = price < 0 ? "Недоступно" : price.ToString("N0") + " золота";
|
||||
var b = UIKit.Button(row, label, block == null && enough ? UIKit.Theme.Blue : UIKit.Theme.Gray, () => { gp.BuyCrystalFromTrader(); RequestRebuild(); }, 260, 100, 24);
|
||||
b.interactable = block == null;
|
||||
}
|
||||
|
||||
static void WalletItem(Transform parent, ResourceType t)
|
||||
{
|
||||
UIKit.Icon(parent, GameData.ResColor(t), "", 40);
|
||||
|
||||
@@ -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;
|
||||
@@ -45,6 +63,13 @@ public class TroopsPanel : BasePanel
|
||||
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);
|
||||
@@ -65,15 +90,28 @@ 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);
|
||||
}
|
||||
}
|
||||
|
||||
// --- Очередь обучения ---
|
||||
@@ -101,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) && 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, 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 сообщает об изменении
|
||||
@@ -145,6 +279,12 @@ public class TroopsPanel : BasePanel
|
||||
|
||||
protected override void Tick()
|
||||
{
|
||||
if (Barracks.Count != shownBarracks) { RequestRebuild(); return; } // Казармы достроились (или снесены)
|
||||
if (barracksBuildingLeft != null)
|
||||
{
|
||||
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++)
|
||||
{
|
||||
@@ -153,6 +293,6 @@ public class TroopsPanel : BasePanel
|
||||
queueTimes[i].text = GameData.FormatTime(GP.TrainingSecondsLeft(o));
|
||||
}
|
||||
if (speedUp != null && training.Count > 0)
|
||||
UIKit.SetButton(speedUp, "Ускорить всё за " + GP.SpeedUpCost + " кристаллов", true);
|
||||
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
|
||||
@@ -29,6 +29,18 @@ public sealed class HospitalRules
|
||||
}
|
||||
}
|
||||
|
||||
// Пропавшие без вести (решения 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
|
||||
@@ -62,6 +74,28 @@ public sealed class ArmyService : IArmy, ISaveParticipant
|
||||
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;
|
||||
@@ -76,10 +110,11 @@ public sealed class ArmyService : IArmy, ISaveParticipant
|
||||
// Слоты похода: база из таблицы MarchConfig (регламент 31.10: 2, ТЕСТ). Бонус «Военной логистики» -- позже.
|
||||
private readonly int baseMarchSlots;
|
||||
|
||||
public ArmyService(int baseMarchSlots = 2, HospitalRules hospital = null)
|
||||
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;
|
||||
@@ -238,6 +273,147 @@ public sealed class ArmyService : IArmy, ISaveParticipant
|
||||
}
|
||||
}
|
||||
|
||||
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;
|
||||
@@ -416,7 +592,8 @@ public sealed class ArmyService : IArmy, ISaveParticipant
|
||||
|
||||
void Changed()
|
||||
{
|
||||
EventBus.Publish(new TroopsChangedEvent { AtHome = SoldiersAtHome, InMarches = TroopsInMarches, Garrison = GarrisonTotal, Wounded = WoundedTotal });
|
||||
EventBus.Publish(new TroopsChangedEvent { AtHome = SoldiersAtHome, InMarches = TroopsInMarches, Garrison = GarrisonTotal, Wounded = WoundedTotal,
|
||||
Missing = MissingTotal });
|
||||
}
|
||||
|
||||
// ---------------- Перенос из старого progress.json ----------------
|
||||
@@ -453,6 +630,9 @@ public sealed class ArmyService : IArmy, ISaveParticipant
|
||||
// wounded=<пехота>,<лучники>,<кавалерия>,<осадные> (ждут места в госпитале)
|
||||
// heal=<id>|<тип>|<сколько>|<начало, мс>|<конец, мс>
|
||||
// healNext=<номер следующей группы лечения>
|
||||
// missing=<id>|<поход>|<окно с, мс>|<конец, мс>|<осталось по типам через ,>
|
||||
// mgroup=<id пропавших>|<номер группы>|<время, мс>|<здоровые по типам>|<раненые по типам> (ещё не вернувшиеся группы)
|
||||
// missingMarch=<поход> (по нему пропавшие уже отмечены) missingNext=<номер>
|
||||
|
||||
public string SaveKey => "army";
|
||||
|
||||
@@ -477,6 +657,20 @@ public sealed class ArmyService : IArmy, ISaveParticipant
|
||||
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();
|
||||
}
|
||||
|
||||
@@ -490,6 +684,9 @@ public sealed class ArmyService : IArmy, ISaveParticipant
|
||||
Array.Clear(woundedWaiting, 0, Types);
|
||||
healing.Clear();
|
||||
nextHealId = 1;
|
||||
missing.Clear();
|
||||
missingMarches.Clear();
|
||||
nextMissingId = 1;
|
||||
RestoredFromSave = true;
|
||||
|
||||
if (!string.IsNullOrEmpty(data))
|
||||
@@ -547,6 +744,36 @@ public sealed class ArmyService : IArmy, ISaveParticipant
|
||||
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);
|
||||
@@ -570,6 +797,9 @@ public sealed class ArmyService : IArmy, ISaveParticipant
|
||||
Array.Clear(woundedWaiting, 0, Types);
|
||||
healing.Clear();
|
||||
nextHealId = 1;
|
||||
missing.Clear();
|
||||
missingMarches.Clear();
|
||||
nextMissingId = 1;
|
||||
Changed();
|
||||
}
|
||||
|
||||
|
||||
@@ -6,9 +6,10 @@
|
||||
// итог боя запоминает версию, по которой посчитан.
|
||||
public sealed class BattleRules
|
||||
{
|
||||
public int Version = 7; // 2 -- голод и усталость; 3 -- сила без округления, потери шансом (№12, №13); 4 -- кривая потерь (№14);
|
||||
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)
|
||||
// 7 -- сильная сторона 5:1 -> 10:1 с 11% до 2% (решения по отчёту Саши, 01.11, п. 1);
|
||||
// 8 -- пропавшие без вести (п. 5)
|
||||
|
||||
// Сила одного воина каждого типа. В игре -- из GameData.Troops (там же цена и время обучения).
|
||||
public int[] UnitPower = { 4, 5, 9, 16 };
|
||||
@@ -37,6 +38,10 @@ public sealed class BattleRules
|
||||
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%
|
||||
|
||||
@@ -181,15 +181,20 @@ public sealed class BattleSystem : IBattleSystem
|
||||
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);
|
||||
|
||||
// Выбывшие -- погибшие и раненые (решения 31.10, п. 7): доля погибших -- от соотношения сил своей стороны
|
||||
int[] attDead = Dead(attOut, attPower, defPower, request.Seed, side: 1, rounding);
|
||||
int[] defDead = Dead(defOut0, defPower, attPower, request.Seed, side: 2, rounding);
|
||||
// Выбывшие: сначала пропавшие без вести (решения 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] = attOut[i] - attDead[i];
|
||||
defWounded[i] = defOut0[i] - defDead[i];
|
||||
attWounded[i] = attRest[i] - attDead[i];
|
||||
defWounded[i] = defRest[i] - defDead[i];
|
||||
}
|
||||
|
||||
var attAlive = new int[TroopType.Count];
|
||||
@@ -197,9 +202,9 @@ public sealed class BattleSystem : IBattleSystem
|
||||
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
|
||||
@@ -207,7 +212,7 @@ 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.Floor, // округление -- только для показа
|
||||
@@ -224,6 +229,8 @@ public sealed class BattleSystem : IBattleSystem
|
||||
DefenderDeadUnits = defDead,
|
||||
AttackerWoundedUnits = attWounded,
|
||||
DefenderWoundedUnits = defWounded,
|
||||
AttackerMissingUnits = attMissing,
|
||||
DefenderMissingUnits = defMissing,
|
||||
DefenderLossUnits = defOut
|
||||
};
|
||||
}
|
||||
@@ -340,6 +347,25 @@ public sealed class BattleSystem : IBattleSystem
|
||||
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)
|
||||
|
||||
@@ -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,15 @@ public interface IPopulation
|
||||
}
|
||||
|
||||
// ---------------- Армия: войска ----------------
|
||||
// Пропавшие без вести одного боя (решения 01.11, п. 5). Время -- мс часов игры
|
||||
public struct MissingInfo
|
||||
{
|
||||
public int Id;
|
||||
public int Count; // ещё не вернулись и не списаны
|
||||
public long WindowStartMs; // с какого момента могут начать возвращаться (бой + обратный путь)
|
||||
public long EndMs; // конец окна: кто не вернулся -- списан
|
||||
}
|
||||
|
||||
// Раненые на лечении в госпитале: одна группа одного типа, лечится вместе. Время -- мс часов игры
|
||||
public struct HealingInfo
|
||||
{
|
||||
@@ -150,6 +171,17 @@ public interface IArmy
|
||||
// Бой считает раненых, Армия хранит их и лечит, места -- от госпиталя (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; } // мест в госпитале сейчас
|
||||
@@ -182,6 +214,8 @@ public interface IWorld
|
||||
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) ----------------
|
||||
@@ -196,6 +230,22 @@ public struct ArmyOnMap
|
||||
public int Soldiers; // точная численность -- разведка показывает игроку только примерную
|
||||
}
|
||||
|
||||
// Цель разведки (ТЗ разведки, раздел 6): что за объект -- от этого время наблюдения
|
||||
public enum ReconTargetKind { None, Settlement, Castle, Camp, Army } // Army -- движущаяся армия (id "march_<номер>")
|
||||
|
||||
// Снимок цели разведки от Мира -- то, что увидят разведчики в момент отчёта
|
||||
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; } // границы карты от центра, милли-единицы
|
||||
@@ -203,6 +253,8 @@ public interface IWorldMap
|
||||
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): туман войны, наблюдение ----------------
|
||||
@@ -225,12 +277,38 @@ public struct KnownContact
|
||||
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);
|
||||
}
|
||||
|
||||
// ---------------- Боевая система: урон, потери, исход ----------------
|
||||
@@ -298,7 +376,8 @@ public struct BattleOutcome
|
||||
public int[] AttackerUnitsBefore, DefenderUnitsBefore;
|
||||
public int[] AttackerSurvivorUnits, DefenderSurvivorUnits;
|
||||
public int[] AttackerDeadUnits, DefenderDeadUnits;
|
||||
public int[] AttackerWoundedUnits, DefenderWoundedUnits; // раненые (регламент 31.10, решения 31.10, п. 7): выбывшие минус погибшие
|
||||
public int[] AttackerWoundedUnits, DefenderWoundedUnits; // раненые (регламент 31.10, решения 31.10, п. 7)
|
||||
public int[] AttackerMissingUnits, DefenderMissingUnits; // пропавшие без вести (решения 01.11, п. 5): выбывшие = погибшие + раненые + пропавшие
|
||||
public int[] DefenderLossUnits; // погибшие + раненые защитника (для старого кода)
|
||||
}
|
||||
|
||||
|
||||
@@ -32,6 +32,15 @@ public struct GameTimeAdvancedEvent
|
||||
public long UnixNow;
|
||||
}
|
||||
|
||||
// Расчёт игры (модуль Населения: добыча, еда, прирост) дошёл до момента UnixNow (04.11).
|
||||
// Приходит в начале каждого шага расчёта (раз в 10 игровых минут офлайна) и в конце каждого Tick.
|
||||
// Всё, что должно случиться по времени (стройка закончилась), применяется здесь -- тогда следующий шаг
|
||||
// уже считается с новым городом, и добыча идёт ровно с момента завершения, а не с начала офлайна.
|
||||
public struct GameTimeSimulatedEvent
|
||||
{
|
||||
public long UnixNow;
|
||||
}
|
||||
|
||||
// ---------------- Население ----------------
|
||||
// Число людей изменилось (прирост, загрузка, сброс, изменение войска)
|
||||
public struct PopulationChangedEvent
|
||||
@@ -63,6 +72,25 @@ public struct TroopsChangedEvent
|
||||
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 -- когда закончили
|
||||
@@ -117,6 +145,7 @@ public struct MarchReturnedEvent
|
||||
public bool IsGather; // поход на ресурсную точку (сбор, а не бой)
|
||||
public bool IsScout; // поход «Разведка»: дошли до цели и вернулись, боя и добычи нет
|
||||
public int Wounded; // раненых привезли домой -- они в госпитале (или ждут места)
|
||||
public int Missing; // пропали без вести в бою -- могут вернуться сами
|
||||
public string TargetName; // куда ходили ("Замок Змея Горыныча", имя соседа)
|
||||
public string LootText; // добыча текстом: "+20 золота, +40 еды" или "нет"
|
||||
}
|
||||
@@ -178,6 +207,23 @@ public struct ArmyLostContactEvent // чужая армия вышла из з
|
||||
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;
|
||||
|
||||
@@ -30,6 +30,10 @@ public class ResourceManager : MonoBehaviour, IEconomy, ISaveParticipant
|
||||
// запоминаются в сохранении: на карте мира город не открыт, а лимиты действуют.
|
||||
private Dictionary<ResourceType, float> capacities = new Dictionary<ResourceType, float>();
|
||||
|
||||
// Сколько платных расширений хранилища синих кристаллов куплено (регламент валют 05.11, разд. 8).
|
||||
// Вместимость по ней считает слой приложения (EconomyConfig.CrystalStorageNow); здесь -- только хранится
|
||||
public int CrystalStorageLevel { get; set; }
|
||||
|
||||
// Лимит изменился (для интерфейса: "запас / лимит")
|
||||
public event Action<ResourceType, float> OnCapacityChanged;
|
||||
|
||||
@@ -189,13 +193,14 @@ public class ResourceManager : MonoBehaviour, IEconomy, ISaveParticipant
|
||||
{
|
||||
public List<ResourceSaveEntry> resources = new List<ResourceSaveEntry>();
|
||||
public List<ResourceSaveEntry> capacities = new List<ResourceSaveEntry>(); // лимиты склада (этап 1)
|
||||
public int crystalStorageLevel; // купленные расширения хранилища кристаллов (06.11); нет в старом файле -- 0
|
||||
}
|
||||
|
||||
public string SaveKey => "economy";
|
||||
|
||||
public string Capture()
|
||||
{
|
||||
var data = new EconomySaveData();
|
||||
var data = new EconomySaveData { crystalStorageLevel = CrystalStorageLevel };
|
||||
foreach (var kvp in resources)
|
||||
data.resources.Add(new ResourceSaveEntry { type = kvp.Key, amount = kvp.Value });
|
||||
foreach (var kvp in capacities)
|
||||
@@ -207,6 +212,7 @@ public class ResourceManager : MonoBehaviour, IEconomy, ISaveParticipant
|
||||
{
|
||||
var data = JsonUtility.FromJson<EconomySaveData>(json);
|
||||
if (data?.resources == null) return;
|
||||
CrystalStorageLevel = Mathf.Max(0, data.crystalStorageLevel);
|
||||
if (data.capacities != null)
|
||||
foreach (var entry in data.capacities) SetCapacity(entry.type, entry.amount);
|
||||
foreach (var entry in data.resources) SetResource(entry.type, entry.amount);
|
||||
@@ -215,6 +221,7 @@ public class ResourceManager : MonoBehaviour, IEconomy, ISaveParticipant
|
||||
// Новая игра -- стартовые значения из Inspector
|
||||
public void ResetState()
|
||||
{
|
||||
CrystalStorageLevel = 0;
|
||||
SetStartValues(notify: true);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Globalization;
|
||||
|
||||
// КРИСТАЛЬНАЯ ШАХТА: выработка синих кристаллов (Регламент редкости синих кристаллов от 05.11.2026, разд. 2.1;
|
||||
// Регламент валют от 05.11, разд. 5.2 и 8.1; «Ответ Володе» от 06.11, п. 3). Документ: Docs/Modules/Economy.md.
|
||||
//
|
||||
// - Каждая работающая шахта -- PerPeriod (ТЕСТ 3) кристаллов за PeriodMs (7 РЕАЛЬНЫХ суток; не игровые часы ×12),
|
||||
// равномерно: один кристалл за PeriodMs / PerPeriod. Шахты (до двух) складываются.
|
||||
// - Шахта работает с момента, когда достроена (activeFromMs): стройка, закончившаяся офлайн, добывает с её конца.
|
||||
// - Хранилище полное -- пауза: прогресс не растёт, скрытого запаса нет. Хранилище заполнилось в ходе расчёта --
|
||||
// шахта встаёт сразу после последнего поместившегося кристалла (прогресс 0). Освободилось место -- выработка идёт
|
||||
// дальше с оставшегося прогресса, без начисления за время паузы.
|
||||
// - Офлайн: досчёт по меткам при входе; уже посчитанное время второй раз не начисляется; часы назад (сброс
|
||||
// DEV-перемотки) -- метка не уходит назад, ничего не начисляется.
|
||||
// - В недельный PvE-предел не входит (отдельный учёт).
|
||||
// Без Unity: те же правила будут на сервере и в тестах.
|
||||
public sealed class CrystalMine
|
||||
{
|
||||
public readonly int PerPeriod;
|
||||
public readonly long PeriodMs;
|
||||
|
||||
// Прогресс к следующему кристаллу (мс работы одной шахты) и до какого момента посчитано
|
||||
public long ProgressMs { get; private set; }
|
||||
public long LastMs { get; private set; }
|
||||
|
||||
public CrystalMine(int perPeriod = 3, long periodMs = 7 * CrystalQuota.Day)
|
||||
{
|
||||
PerPeriod = Math.Max(1, perPeriod);
|
||||
PeriodMs = Math.Max(1, periodMs);
|
||||
}
|
||||
|
||||
public long MsPerCrystal => Math.Max(1, PeriodMs / PerPeriod);
|
||||
|
||||
// Досчитать до nowMs. mineActiveFromMs -- по шахте: с какого момента она работает (long.MinValue -- давно).
|
||||
// freeSpace -- сколько кристаллов помещается в хранилище. Возвращает, сколько кристаллов начислить
|
||||
public int Advance(long nowMs, IEnumerable<long> mineActiveFromMs, int freeSpace)
|
||||
{
|
||||
if (LastMs == 0) { LastMs = nowMs; return 0; } // первый расчёт: отсюда и считаем
|
||||
if (nowMs <= LastMs) return 0; // часы назад или то же время -- ничего
|
||||
long from = LastMs;
|
||||
LastMs = nowMs;
|
||||
if (freeSpace <= 0) return 0; // пауза: хранилище полное, прогресс не растёт
|
||||
|
||||
long work = 0;
|
||||
if (mineActiveFromMs != null)
|
||||
foreach (long active in mineActiveFromMs)
|
||||
{
|
||||
long start = Math.Max(from, active);
|
||||
if (start < nowMs) work += nowMs - start;
|
||||
}
|
||||
if (work <= 0) return 0;
|
||||
|
||||
long total = ProgressMs + work;
|
||||
long made = total / MsPerCrystal;
|
||||
if (made <= freeSpace)
|
||||
{
|
||||
ProgressMs = total - made * MsPerCrystal;
|
||||
return (int)made;
|
||||
}
|
||||
ProgressMs = 0; // хранилище заполнилось: шахта встала сразу после последнего кристалла, лишнее не копится
|
||||
return freeSpace;
|
||||
}
|
||||
|
||||
// Через сколько мс следующий кристалл при count работающих шахтах (0 шахт -- -1)
|
||||
public long MsToNext(int count) => count <= 0 ? -1 : Math.Max(0, (MsPerCrystal - ProgressMs + count - 1) / count);
|
||||
|
||||
public void Load(string line)
|
||||
{
|
||||
ProgressMs = 0; LastMs = 0;
|
||||
if (string.IsNullOrEmpty(line)) return;
|
||||
var p = line.Split('|');
|
||||
if (p.Length >= 2
|
||||
&& long.TryParse(p[0], NumberStyles.Integer, CultureInfo.InvariantCulture, out long progress)
|
||||
&& long.TryParse(p[1], NumberStyles.Integer, CultureInfo.InvariantCulture, out long last))
|
||||
{
|
||||
ProgressMs = Math.Max(0, Math.Min(progress, MsPerCrystal - 1));
|
||||
LastMs = Math.Max(0, last);
|
||||
}
|
||||
}
|
||||
|
||||
public string ToLine() => ProgressMs.ToString(CultureInfo.InvariantCulture) + "|" + LastMs.ToString(CultureInfo.InvariantCulture);
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 5f3b9d1e8a2c4e70a6d4b8c1f9e27d36
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,94 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Globalization;
|
||||
|
||||
// НЕДЕЛЬНЫЙ PvE-ПРЕДЕЛ СИНИХ КРИСТАЛЛОВ (Регламент валют, кристаллов и торговли от 05.11.2026, разд. 5.2, 10, 12;
|
||||
// «Ответ Володе» от 06.11.2026, п. 2). Документ: Docs/Modules/Economy.md, раздел «Синие кристаллы».
|
||||
//
|
||||
// - Не больше Limit (ТЕСТ 6) НОВЫХ синих кристаллов за скользящие WindowMs (7 реальных суток) из PvE: задания,
|
||||
// события, бои, PvE-торговец. Кристальная шахта, торговля и грабёж -- вне предела.
|
||||
// - Take: сколько из награды можно выдать кристаллами сейчас; остальное вызывающий заменяет компенсацией
|
||||
// (золото / ресурсы). На следующую неделю излишек не переносится.
|
||||
// - Квота и место в хранилище -- разные условия: взятое по квоте считается сразу, даже если кристаллы потом
|
||||
// ждут места, -- второй раз квота за них не тратится.
|
||||
// - Операция с тем же id второй раз квоту не берёт (повторное «Забрать», загрузка).
|
||||
// - Время -- метки (мс), окно скользящее: запись старше окна больше не считается. На сервере -- доверенное время.
|
||||
// Без Unity: те же правила будут на сервере и в тестах.
|
||||
public sealed class CrystalQuota
|
||||
{
|
||||
public const long Day = 24L * 60 * 60 * 1000;
|
||||
|
||||
public readonly int Limit;
|
||||
public readonly long WindowMs;
|
||||
|
||||
struct Entry { public long TimeMs; public int Amount; public string Id; }
|
||||
readonly List<Entry> entries = new List<Entry>();
|
||||
|
||||
public CrystalQuota(int limit = 6, long windowMs = 7 * Day)
|
||||
{
|
||||
Limit = Math.Max(0, limit);
|
||||
WindowMs = Math.Max(1, windowMs);
|
||||
}
|
||||
|
||||
// Сколько новых PvE-кристаллов уже выдано за окно, кончающееся в nowMs
|
||||
public int Used(long nowMs)
|
||||
{
|
||||
int n = 0;
|
||||
foreach (var e in entries) if (e.TimeMs > nowMs - WindowMs && e.TimeMs <= nowMs) n += e.Amount;
|
||||
return n;
|
||||
}
|
||||
|
||||
public int Left(long nowMs) => Math.Max(0, Limit - Used(nowMs));
|
||||
|
||||
// Когда освободится хотя бы один кристалл квоты (мс); 0 -- уже свободно
|
||||
public long NextFreeMs(long nowMs)
|
||||
{
|
||||
if (Left(nowMs) > 0) return 0;
|
||||
long first = long.MaxValue;
|
||||
foreach (var e in entries) if (e.TimeMs > nowMs - WindowMs && e.TimeMs < first) first = e.TimeMs;
|
||||
return first == long.MaxValue ? 0 : first + WindowMs;
|
||||
}
|
||||
|
||||
public bool Taken(string id) => !string.IsNullOrEmpty(id) && entries.Exists(e => e.Id == id);
|
||||
|
||||
// Взять квоту под награду: сколько из amount выдать кристаллами (остальное -- компенсацией).
|
||||
// Тот же id второй раз -- 0 (уже учтено). Записи старше окна убираются
|
||||
public int Take(string id, int amount, long nowMs)
|
||||
{
|
||||
Prune(nowMs);
|
||||
if (amount <= 0 || Taken(id)) return 0;
|
||||
int give = Math.Min(amount, Left(nowMs));
|
||||
if (give > 0 || !string.IsNullOrEmpty(id)) entries.Add(new Entry { TimeMs = nowMs, Amount = give, Id = id ?? "" });
|
||||
return give;
|
||||
}
|
||||
|
||||
// Можно ли взять amount целиком (покупка у PvE-торговца: при нехватке -- блок, золото не списывается)
|
||||
public bool CanTakeAll(int amount, long nowMs) => amount <= Left(nowMs);
|
||||
|
||||
void Prune(long nowMs) => entries.RemoveAll(e => e.TimeMs <= nowMs - WindowMs);
|
||||
|
||||
// ---------------- Сохранение: строки "время|сколько|id" ----------------
|
||||
|
||||
public List<string> ToLines()
|
||||
{
|
||||
var lines = new List<string>();
|
||||
foreach (var e in entries)
|
||||
lines.Add(e.TimeMs.ToString(CultureInfo.InvariantCulture) + "|" + e.Amount.ToString(CultureInfo.InvariantCulture) + "|" + e.Id);
|
||||
return lines;
|
||||
}
|
||||
|
||||
public void Load(IEnumerable<string> lines)
|
||||
{
|
||||
entries.Clear();
|
||||
if (lines == null) return;
|
||||
foreach (var line in lines)
|
||||
{
|
||||
if (string.IsNullOrEmpty(line)) continue;
|
||||
var p = line.Split(new[] { '|' }, 3);
|
||||
if (p.Length < 2) continue;
|
||||
if (!long.TryParse(p[0], NumberStyles.Integer, CultureInfo.InvariantCulture, out long t)) continue;
|
||||
if (!int.TryParse(p[1], NumberStyles.Integer, CultureInfo.InvariantCulture, out int n) || n < 0) continue;
|
||||
entries.Add(new Entry { TimeMs = t, Amount = n, Id = p.Length > 2 ? p[2] : "" });
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 9e1f4c7a2b6d48a3a8c5f0d2e7b39c14
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -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,63 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
|
||||
// ХРАНИЛИЩЕ СИНИХ КРИСТАЛЛОВ: вместимость и платное расширение
|
||||
// (Регламент валют, кристаллов и торговли от 05.11.2026, разд. 8; «Ответ Володе» от 06.11.2026, п. 1).
|
||||
// Документ: Docs/Modules/Economy.md, раздел «Синие кристаллы».
|
||||
//
|
||||
// - Старт -- BaseCapacity (ТЕСТ 50). Ступени (ТЕСТ): Ратуша 3 -- 75, 5 -- 100, 7 -- 150, 9 -- 200.
|
||||
// - Ступень НЕ открывается сама с уровнем Ратуши: Ратуша только разрешает её, игрок покупает за золото и
|
||||
// обычные ресурсы. Ступени -- по порядку, одна покупка -- одна ступень.
|
||||
// - Цена списывается один раз: ступень растёт, только если списание прошло (всё или ничего). Повторное
|
||||
// нажатие без следующей ступени или без нужной Ратуши ничего не списывает.
|
||||
// - Сохраняется номер купленной ступени (Level), вместимость по нему считается заново -- числа можно менять.
|
||||
// Без Unity: те же правила будут на сервере и в тестах. Числа и цены даёт слой приложения (EconomyConfig).
|
||||
public sealed class CrystalStorage
|
||||
{
|
||||
public sealed class Tier
|
||||
{
|
||||
public int TownHallLevel; // с какого уровня Ратуши можно купить
|
||||
public int Capacity; // вместимость после покупки
|
||||
public ResourceAmount[] Cost; // цена: золото и обычные ресурсы
|
||||
}
|
||||
|
||||
public readonly int BaseCapacity;
|
||||
readonly List<Tier> tiers;
|
||||
|
||||
public CrystalStorage(int baseCapacity, IEnumerable<Tier> tiers)
|
||||
{
|
||||
BaseCapacity = Math.Max(0, baseCapacity);
|
||||
this.tiers = new List<Tier>();
|
||||
if (tiers != null) foreach (var t in tiers) if (t != null) this.tiers.Add(t);
|
||||
}
|
||||
|
||||
// Сколько ступеней куплено (0 -- только стартовая вместимость)
|
||||
public int Level { get; private set; }
|
||||
|
||||
public int TierCount => tiers.Count;
|
||||
public int Capacity => Level <= 0 ? BaseCapacity : Math.Max(BaseCapacity, tiers[Level - 1].Capacity);
|
||||
|
||||
// Следующая ступень; null -- все куплены
|
||||
public Tier Next => Level < tiers.Count ? tiers[Level] : null;
|
||||
|
||||
// Восстановить из сохранения (номер ступени; лишнее обрезается, если ступеней стало меньше)
|
||||
public void SetLevel(int level) => Level = Math.Max(0, Math.Min(level, tiers.Count));
|
||||
|
||||
// Почему сейчас нельзя расширить: null -- можно (если хватит ресурсов -- это проверяет списание)
|
||||
public string WhyNot(int townHallLevel)
|
||||
{
|
||||
var next = Next;
|
||||
if (next == null) return "Хранилище расширено до предела";
|
||||
if (townHallLevel < next.TownHallLevel) return $"Расширение до {next.Capacity} откроется на Ратуше {next.TownHallLevel} ур.";
|
||||
return null;
|
||||
}
|
||||
|
||||
// Купить следующую ступень. trySpend -- списание цены «всё или ничего» (Экономика). true -- ступень куплена
|
||||
public bool TryExpand(int townHallLevel, Func<IReadOnlyList<ResourceAmount>, bool> trySpend)
|
||||
{
|
||||
if (WhyNot(townHallLevel) != null || trySpend == null) return false;
|
||||
if (!trySpend(Next.Cost ?? new ResourceAmount[0])) return false;
|
||||
Level++;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 4b8d2f6a1c9e47d3b5a0e7f3c2d19a85
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,99 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Globalization;
|
||||
|
||||
// РЕДКИЙ PvE-ТОРГОВЕЦ: синий кристалл за золото (Регламент валют, кристаллов и торговли от 05.11.2026, разд. 3, 10, 12;
|
||||
// «Ответ Володе» от 06.11.2026, п. 2). Документ: Docs/Modules/Economy.md, раздел «Синие кристаллы».
|
||||
//
|
||||
// - За скользящие 7 реальных суток: 1-я покупка -- Prices[0] (ТЕСТ 30 000 золота), 2-я -- Prices[1] (60 000),
|
||||
// дальше -- недоступно (не больше Prices.Length покупок).
|
||||
// - Покупка входит в недельный PvE-предел (CrystalQuota). Нет квоты -- покупка заблокирована, золото НЕ списывается,
|
||||
// компенсации за оплаченный товар нет.
|
||||
// - Нет места в хранилище -- тоже блок без списания (вместимость получателя проверяется до оплаты).
|
||||
// - Порядок: все проверки -> списание золота (всё или ничего) -> квота -> запись покупки. Одна операция -- один id,
|
||||
// повтор того же id ничего не делает.
|
||||
// Без Unity: те же правила будут на сервере и в тестах.
|
||||
public sealed class CrystalTrader
|
||||
{
|
||||
public enum Result { Bought, NoSlots, NoQuota, NoSpace, NoGold, Repeated }
|
||||
|
||||
public readonly int[] Prices;
|
||||
public readonly long WindowMs;
|
||||
|
||||
struct Purchase { public long TimeMs; public string Id; }
|
||||
readonly List<Purchase> purchases = new List<Purchase>();
|
||||
|
||||
public CrystalTrader(int[] prices = null, long windowMs = 7 * CrystalQuota.Day)
|
||||
{
|
||||
Prices = prices != null && prices.Length > 0 ? prices : new[] { 30000, 60000 };
|
||||
WindowMs = Math.Max(1, windowMs);
|
||||
}
|
||||
|
||||
public int BoughtInWindow(long nowMs)
|
||||
{
|
||||
int n = 0;
|
||||
foreach (var p in purchases) if (p.TimeMs > nowMs - WindowMs && p.TimeMs <= nowMs) n++;
|
||||
return n;
|
||||
}
|
||||
|
||||
// Цена следующей покупки; -1 -- в этом окне больше нельзя
|
||||
public int PriceNow(long nowMs)
|
||||
{
|
||||
int n = BoughtInWindow(nowMs);
|
||||
return n < Prices.Length ? Prices[n] : -1;
|
||||
}
|
||||
|
||||
// Когда снова можно купить (мс), если покупки кончились; 0 -- можно сейчас
|
||||
public long NextSlotMs(long nowMs)
|
||||
{
|
||||
if (PriceNow(nowMs) >= 0) return 0;
|
||||
long first = long.MaxValue;
|
||||
foreach (var p in purchases) if (p.TimeMs > nowMs - WindowMs && p.TimeMs < first) first = p.TimeMs;
|
||||
return first == long.MaxValue ? 0 : first + WindowMs;
|
||||
}
|
||||
|
||||
// Что помешает купить сейчас (без списания); Bought -- можно
|
||||
public Result Check(long nowMs, CrystalQuota quota, int freeSpace)
|
||||
{
|
||||
if (PriceNow(nowMs) < 0) return Result.NoSlots;
|
||||
if (quota != null && !quota.CanTakeAll(1, nowMs)) return Result.NoQuota;
|
||||
if (freeSpace < 1) return Result.NoSpace;
|
||||
return Result.Bought;
|
||||
}
|
||||
|
||||
// Купить 1 синий кристалл. trySpendGold(цена) -- списание «всё или ничего». При Bought кристалл начисляет вызывающий
|
||||
public Result TryBuy(string id, long nowMs, CrystalQuota quota, int freeSpace, Func<int, bool> trySpendGold)
|
||||
{
|
||||
if (!string.IsNullOrEmpty(id) && purchases.Exists(p => p.Id == id)) return Result.Repeated;
|
||||
var check = Check(nowMs, quota, freeSpace);
|
||||
if (check != Result.Bought) return check;
|
||||
int price = PriceNow(nowMs);
|
||||
if (trySpendGold == null || !trySpendGold(price)) return Result.NoGold;
|
||||
if (quota != null) quota.Take(id, 1, nowMs);
|
||||
purchases.RemoveAll(p => p.TimeMs <= nowMs - WindowMs);
|
||||
purchases.Add(new Purchase { TimeMs = nowMs, Id = id ?? "" });
|
||||
return Result.Bought;
|
||||
}
|
||||
|
||||
// ---------------- Сохранение: строки "время|id" ----------------
|
||||
|
||||
public List<string> ToLines()
|
||||
{
|
||||
var lines = new List<string>();
|
||||
foreach (var p in purchases) lines.Add(p.TimeMs.ToString(CultureInfo.InvariantCulture) + "|" + p.Id);
|
||||
return lines;
|
||||
}
|
||||
|
||||
public void Load(IEnumerable<string> lines)
|
||||
{
|
||||
purchases.Clear();
|
||||
if (lines == null) return;
|
||||
foreach (var line in lines)
|
||||
{
|
||||
if (string.IsNullOrEmpty(line)) continue;
|
||||
var p = line.Split(new[] { '|' }, 2);
|
||||
if (!long.TryParse(p[0], NumberStyles.Integer, CultureInfo.InvariantCulture, out long t)) continue;
|
||||
purchases.Add(new Purchase { TimeMs = t, Id = p.Length > 1 ? p[1] : "" });
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: c25a8e3f7d1b4c69b0e4a6f9d8c37b21
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -22,6 +22,15 @@ public sealed class IntelligenceRules
|
||||
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 int[] ReconArmyGameHours = { 1, 2 }; // вражеская армия (ТЗ, раздел 6): II -- 1, III -- 2 игровых часа
|
||||
public long GameHourMs = 5 * 60 * 1000; // 1 игровой час = 5 реальных минут
|
||||
public int ResourceEstimatePercent = 15; // уровень III: ресурсы -- оценочный диапазон ±15%
|
||||
public int MaxReports = 30; // храним последние отчёты
|
||||
|
||||
public static IntelligenceRules Default => new IntelligenceRules();
|
||||
}
|
||||
|
||||
@@ -255,10 +264,106 @@ public sealed class IntelligenceService : IIntelligence, ISaveParticipant
|
||||
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
|
||||
: t.Kind == ReconTargetKind.Army ? rules.ReconArmyGameHours : 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";
|
||||
|
||||
@@ -275,6 +380,13 @@ public sealed class IntelligenceService : IIntelligence, ISaveParticipant
|
||||
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();
|
||||
}
|
||||
|
||||
@@ -289,6 +401,7 @@ public sealed class IntelligenceService : IIntelligence, ISaveParticipant
|
||||
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;
|
||||
}
|
||||
@@ -296,6 +409,7 @@ public sealed class IntelligenceService : IIntelligence, ISaveParticipant
|
||||
public void ResetState()
|
||||
{
|
||||
contacts.Clear();
|
||||
reports.Clear();
|
||||
explored = Array.Empty<bool>();
|
||||
gridW = gridH = halfW = halfH = 0;
|
||||
lastTickMs = 0;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -79,7 +79,8 @@ public sealed class PopulationService : IPopulation, ISaveParticipant
|
||||
get
|
||||
{
|
||||
if (!GameServices.TryGet(out IArmy army)) return 0;
|
||||
return army.SoldiersAtHome + army.TroopsInMarches + army.WoundedTotal; // раненые -- тоже люди (решения 31.10, п. 7)
|
||||
// раненые -- тоже люди (решения 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
|
||||
@@ -0,0 +1,8 @@
|
||||
fileFormatVersion: 2
|
||||
guid: bd42162aabdf44f5bc5eff56e8ff9ca6
|
||||
folderAsset: yes
|
||||
DefaultImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"name": "Bogatyri.Settlement.Placement",
|
||||
"rootNamespace": "",
|
||||
"references": [],
|
||||
"includePlatforms": [],
|
||||
"excludePlatforms": [],
|
||||
"allowUnsafeCode": false,
|
||||
"overrideReferences": false,
|
||||
"precompiledReferences": [],
|
||||
"autoReferenced": true,
|
||||
"defineConstraints": [],
|
||||
"versionDefines": [],
|
||||
"noEngineReferences": true
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 5a79ac8eed504b9eb36740352ba25b73
|
||||
AssemblyDefinitionImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,272 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
|
||||
// РАЗМЕЩЕНИЕ ЗДАНИЙ В 3D-ПОСЕЛЕНИИ (ТЗ «Система строительства поселения» от 04.11.2026, этап I).
|
||||
// Документ: Docs/Modules/Settlement.md, раздел «3D-поселение».
|
||||
//
|
||||
// Без Unity: только клетки, зоны, препятствия и занятость -- те же правила будут на сервере и в тестах.
|
||||
// Отображение (местность, кубы-здания, призрак) -- в слое приложения (Assets/Scripts/City3D).
|
||||
//
|
||||
// Невидимая логическая сетка: клетка CellSize единиц мира. Здание занимает прямоугольник клеток
|
||||
// (его максимальный габарит -- с первого уровня, апгрейд клетки не расширяет). Поворот 0/90/180/270.
|
||||
// Числа -- ТЕСТ, предложение Саши на согласование (ТЗ, раздел 12).
|
||||
namespace Bogatyri.Settlement.Placement
|
||||
{
|
||||
public enum CellKind
|
||||
{
|
||||
Ground, // обычная земля
|
||||
Water, // река -- постоянное препятствие
|
||||
Mountain // гора, скала -- постоянное препятствие
|
||||
}
|
||||
|
||||
// Почему здание нельзя поставить
|
||||
public enum PlaceError
|
||||
{
|
||||
None,
|
||||
OutOfMap, // за краем карты
|
||||
ZoneLocked, // зона ещё не открыта уровнем Ратуши
|
||||
Water, // река
|
||||
Mountain, // гора
|
||||
Obstacle, // дерево или валун -- можно расчистить
|
||||
Occupied, // другое здание
|
||||
TooClose, // нарушен технический отступ до другого здания
|
||||
TooSteep // слишком большой перепад высоты под зданием
|
||||
}
|
||||
|
||||
public sealed class PlacementRules
|
||||
{
|
||||
public float CellSize = 1f; // размер клетки в единицах мира
|
||||
public int GapCells = 1; // технический отступ между основаниями зданий, клеток
|
||||
public int MaxStepMm = 700; // наибольший перепад высоты под зданием (мм); меньше -- фундамент, больше -- нельзя
|
||||
|
||||
public static PlacementRules Default => new PlacementRules();
|
||||
}
|
||||
|
||||
// Здание на карте: прямоугольник клеток. X, Y -- левый нижний угол уже с учётом поворота
|
||||
public struct PlacedRect
|
||||
{
|
||||
public string Id;
|
||||
public string Type;
|
||||
public int X, Y;
|
||||
public int SizeW, SizeH; // габарит без поворота
|
||||
public int Rotation; // 0, 90, 180, 270
|
||||
public int W => Rotation == 90 || Rotation == 270 ? SizeH : SizeW;
|
||||
public int H => Rotation == 90 || Rotation == 270 ? SizeW : SizeH;
|
||||
}
|
||||
|
||||
public sealed class SettlementMap
|
||||
{
|
||||
public readonly int Width, Height;
|
||||
public readonly PlacementRules Rules;
|
||||
|
||||
private readonly CellKind[] kind;
|
||||
private readonly int[] heightMm; // высота земли в центре клетки, мм
|
||||
private readonly int[] zoneLevel; // с какого уровня Ратуши клетка доступна для стройки (0 -- никогда)
|
||||
private readonly Dictionary<int, string> obstacleAt = new Dictionary<int, string>(); // клетка -> id дерева/валуна
|
||||
private readonly Dictionary<string, List<int>> obstacleCells = new Dictionary<string, List<int>>();
|
||||
private readonly HashSet<string> cleared = new HashSet<string>();
|
||||
private readonly Dictionary<string, PlacedRect> buildings = new Dictionary<string, PlacedRect>();
|
||||
|
||||
public SettlementMap(int width, int height, PlacementRules rules = null)
|
||||
{
|
||||
Width = Math.Max(1, width);
|
||||
Height = Math.Max(1, height);
|
||||
Rules = rules ?? PlacementRules.Default;
|
||||
kind = new CellKind[Width * Height];
|
||||
heightMm = new int[Width * Height];
|
||||
zoneLevel = new int[Width * Height];
|
||||
}
|
||||
|
||||
// ---------------- Описание карты (заполняет генератор местности) ----------------
|
||||
|
||||
public bool InMap(int x, int y) => x >= 0 && y >= 0 && x < Width && y < Height;
|
||||
int Index(int x, int y) => y * Width + x;
|
||||
|
||||
public void SetCell(int x, int y, CellKind k, int heightMillimeters, int openAtTownHall)
|
||||
{
|
||||
if (!InMap(x, y)) return;
|
||||
int i = Index(x, y);
|
||||
kind[i] = k;
|
||||
heightMm[i] = heightMillimeters;
|
||||
zoneLevel[i] = Math.Max(0, openAtTownHall);
|
||||
}
|
||||
|
||||
public CellKind KindAt(int x, int y) => InMap(x, y) ? kind[Index(x, y)] : CellKind.Mountain;
|
||||
public int HeightAt(int x, int y) => InMap(x, y) ? heightMm[Index(x, y)] : 0;
|
||||
public int ZoneAt(int x, int y) => InMap(x, y) ? zoneLevel[Index(x, y)] : 0;
|
||||
public bool IsOpen(int x, int y, int townHallLevel) { int z = ZoneAt(x, y); return z > 0 && z <= townHallLevel; }
|
||||
|
||||
// Дерево или валун: занимает клетки, пока его не расчистили. id -- постоянный в пределах поселения
|
||||
public void AddObstacle(string id, IEnumerable<(int x, int y)> cells)
|
||||
{
|
||||
if (string.IsNullOrEmpty(id) || obstacleCells.ContainsKey(id)) return;
|
||||
var list = new List<int>();
|
||||
foreach (var c in cells)
|
||||
{
|
||||
if (!InMap(c.x, c.y)) continue;
|
||||
int i = Index(c.x, c.y);
|
||||
if (obstacleAt.ContainsKey(i)) continue;
|
||||
obstacleAt[i] = id;
|
||||
list.Add(i);
|
||||
}
|
||||
obstacleCells[id] = list;
|
||||
if (cleared.Contains(id)) foreach (int i in list) obstacleAt.Remove(i); // уже расчищен (из сохранения)
|
||||
}
|
||||
|
||||
public string ObstacleAt(int x, int y) => InMap(x, y) && obstacleAt.TryGetValue(Index(x, y), out string id) ? id : null;
|
||||
public bool IsCleared(string obstacleId) => cleared.Contains(obstacleId);
|
||||
public IReadOnlyCollection<string> Cleared => cleared;
|
||||
|
||||
// Расчистка: один раз на объект. true -- только что расчищен (за это одна награда); false -- нет такого или уже
|
||||
public bool ClearObstacle(string obstacleId)
|
||||
{
|
||||
if (string.IsNullOrEmpty(obstacleId) || !obstacleCells.TryGetValue(obstacleId, out var list) || cleared.Contains(obstacleId)) return false;
|
||||
cleared.Add(obstacleId);
|
||||
foreach (int i in list) obstacleAt.Remove(i);
|
||||
return true;
|
||||
}
|
||||
|
||||
// Расчищенные из сохранения -- до или после AddObstacle, порядок не важен
|
||||
public void RestoreCleared(IEnumerable<string> ids)
|
||||
{
|
||||
if (ids == null) return;
|
||||
foreach (var id in ids)
|
||||
{
|
||||
if (string.IsNullOrEmpty(id) || !cleared.Add(id)) continue;
|
||||
if (obstacleCells.TryGetValue(id, out var list)) foreach (int i in list) obstacleAt.Remove(i);
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------- Здания ----------------
|
||||
|
||||
public IReadOnlyCollection<PlacedRect> Buildings => buildings.Values;
|
||||
public bool TryGetBuilding(string id, out PlacedRect rect) => buildings.TryGetValue(id ?? "", out rect);
|
||||
|
||||
public string BuildingAt(int x, int y)
|
||||
{
|
||||
foreach (var b in buildings.Values)
|
||||
if (x >= b.X && y >= b.Y && x < b.X + b.W && y < b.Y + b.H) return b.Id;
|
||||
return null;
|
||||
}
|
||||
|
||||
// Проверить место: прямоугольник sizeW x sizeH с поворотом rotation, левый нижний угол (x, y).
|
||||
// ignoreId -- здание, которое переносят: его прежний след не мешает. foundationMm -- высота фундамента
|
||||
public PlaceError Validate(int x, int y, int sizeW, int sizeH, int rotation, int townHallLevel, string ignoreId, out int foundationMm)
|
||||
=> ValidateCore(x, y, sizeW, sizeH, rotation, townHallLevel, ignoreId, false, out foundationMm);
|
||||
|
||||
// То же, но деревья и валуны не мешают: можно ли поставить здесь, если расчистить (перенос 2D-города, 05.11)
|
||||
public PlaceError ValidateIfCleared(int x, int y, int sizeW, int sizeH, int rotation, int townHallLevel, string ignoreId, out int foundationMm)
|
||||
=> ValidateCore(x, y, sizeW, sizeH, rotation, townHallLevel, ignoreId, true, out foundationMm);
|
||||
|
||||
// Деревья и валуны (id) под прямоугольником -- что придётся расчистить. Каждый -- один раз
|
||||
public List<string> ObstaclesUnder(int x, int y, int sizeW, int sizeH, int rotation)
|
||||
{
|
||||
rotation = NormalizeRotation(rotation);
|
||||
int w = rotation == 90 || rotation == 270 ? sizeH : sizeW;
|
||||
int h = rotation == 90 || rotation == 270 ? sizeW : sizeH;
|
||||
var ids = new List<string>();
|
||||
for (int cy = y; cy < y + h; cy++)
|
||||
for (int cx = x; cx < x + w; cx++)
|
||||
{
|
||||
string id = ObstacleAt(cx, cy);
|
||||
if (id != null && !ids.Contains(id)) ids.Add(id);
|
||||
}
|
||||
return ids;
|
||||
}
|
||||
|
||||
PlaceError ValidateCore(int x, int y, int sizeW, int sizeH, int rotation, int townHallLevel, string ignoreId, bool ignoreObstacles, out int foundationMm)
|
||||
{
|
||||
foundationMm = 0;
|
||||
rotation = NormalizeRotation(rotation);
|
||||
int w = rotation == 90 || rotation == 270 ? sizeH : sizeW;
|
||||
int h = rotation == 90 || rotation == 270 ? sizeW : sizeH;
|
||||
if (w <= 0 || h <= 0 || x < 0 || y < 0 || x + w > Width || y + h > Height) return PlaceError.OutOfMap;
|
||||
|
||||
int min = int.MaxValue, max = int.MinValue;
|
||||
for (int cy = y; cy < y + h; cy++)
|
||||
for (int cx = x; cx < x + w; cx++)
|
||||
{
|
||||
int i = Index(cx, cy);
|
||||
if (kind[i] == CellKind.Water) return PlaceError.Water;
|
||||
if (kind[i] == CellKind.Mountain) return PlaceError.Mountain;
|
||||
if (!IsOpen(cx, cy, townHallLevel)) return PlaceError.ZoneLocked;
|
||||
if (!ignoreObstacles && obstacleAt.ContainsKey(i)) return PlaceError.Obstacle;
|
||||
min = Math.Min(min, heightMm[i]);
|
||||
max = Math.Max(max, heightMm[i]);
|
||||
}
|
||||
|
||||
int gap = Math.Max(0, Rules.GapCells);
|
||||
foreach (var b in buildings.Values)
|
||||
{
|
||||
if (b.Id == ignoreId) continue;
|
||||
if (Overlap(x, y, w, h, b.X, b.Y, b.W, b.H)) return PlaceError.Occupied;
|
||||
if (Overlap(x - gap, y - gap, w + 2 * gap, h + 2 * gap, b.X, b.Y, b.W, b.H)) return PlaceError.TooClose;
|
||||
}
|
||||
|
||||
if (max - min > Rules.MaxStepMm) return PlaceError.TooSteep;
|
||||
foundationMm = max - min; // небольшой перепад -- площадка под зданием (верх -- по самой высокой клетке)
|
||||
return PlaceError.None;
|
||||
}
|
||||
|
||||
// Поставить новое здание. Место проверяется теми же правилами; false -- ничего не изменилось
|
||||
public PlaceError Place(string id, string type, int x, int y, int sizeW, int sizeH, int rotation, int townHallLevel)
|
||||
{
|
||||
if (string.IsNullOrEmpty(id) || buildings.ContainsKey(id)) return PlaceError.Occupied;
|
||||
PlaceError e = Validate(x, y, sizeW, sizeH, rotation, townHallLevel, null, out _);
|
||||
if (e != PlaceError.None) return e;
|
||||
buildings[id] = new PlacedRect { Id = id, Type = type, X = x, Y = y, SizeW = sizeW, SizeH = sizeH, Rotation = NormalizeRotation(rotation) };
|
||||
return PlaceError.None;
|
||||
}
|
||||
|
||||
// Перенос (ТЗ, раздел 5): то же здание с тем же id -- меняются только позиция и поворот, целиком или никак.
|
||||
// Свой прежний след не мешает. Ошибка -- здание остаётся, где было
|
||||
public PlaceError Move(string id, int x, int y, int rotation, int townHallLevel)
|
||||
{
|
||||
if (!buildings.TryGetValue(id ?? "", out PlacedRect b)) return PlaceError.OutOfMap;
|
||||
PlaceError e = Validate(x, y, b.SizeW, b.SizeH, rotation, townHallLevel, id, out _);
|
||||
if (e != PlaceError.None) return e;
|
||||
b.X = x; b.Y = y; b.Rotation = NormalizeRotation(rotation);
|
||||
buildings[id] = b;
|
||||
return PlaceError.None;
|
||||
}
|
||||
|
||||
// Восстановление из сохранения: без проверки зон (зона могла быть открыта раньше), но без наложений
|
||||
public bool Restore(PlacedRect rect)
|
||||
{
|
||||
if (string.IsNullOrEmpty(rect.Id) || buildings.ContainsKey(rect.Id)) return false;
|
||||
rect.Rotation = NormalizeRotation(rect.Rotation);
|
||||
foreach (var b in buildings.Values)
|
||||
if (Overlap(rect.X, rect.Y, rect.W, rect.H, b.X, b.Y, b.W, b.H)) return false;
|
||||
buildings[rect.Id] = rect;
|
||||
return true;
|
||||
}
|
||||
|
||||
public static int NormalizeRotation(int rotation)
|
||||
{
|
||||
int r = ((rotation % 360) + 360) % 360;
|
||||
return r / 90 * 90;
|
||||
}
|
||||
|
||||
static bool Overlap(int ax, int ay, int aw, int ah, int bx, int by, int bw, int bh)
|
||||
{
|
||||
return ax < bx + bw && bx < ax + aw && ay < by + bh && by < ay + ah;
|
||||
}
|
||||
|
||||
public static string ErrorText(PlaceError e)
|
||||
{
|
||||
switch (e)
|
||||
{
|
||||
case PlaceError.None: return "Можно ставить";
|
||||
case PlaceError.OutOfMap: return "За краем карты";
|
||||
case PlaceError.ZoneLocked: return "Зона не открыта: нужен уровень Ратуши выше";
|
||||
case PlaceError.Water: return "Здесь река";
|
||||
case PlaceError.Mountain: return "Здесь скалы";
|
||||
case PlaceError.Obstacle: return "Мешает дерево или валун -- расчистите";
|
||||
case PlaceError.Occupied: return "Место занято другим зданием";
|
||||
case PlaceError.TooClose: return "Слишком близко к другому зданию";
|
||||
case PlaceError.TooSteep: return "Слишком крутой склон";
|
||||
default: return e.ToString();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 70c26fdf9f764df5977ed79f7457f90e
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -16,6 +16,11 @@ public static class SettlementModule
|
||||
public float posX;
|
||||
public float posY;
|
||||
public int level;
|
||||
// Стройка или улучшение, которое ещё идёт (01.11): 0 -- нет, 1 -- постройка, 2 -- улучшение
|
||||
public int timerKind;
|
||||
public long timerStartMs;
|
||||
public long timerEndMs;
|
||||
public int timerTargetLevel;
|
||||
}
|
||||
|
||||
// Имена полей совпадают со старым save.json -- старые сохранения читаются без переделки
|
||||
@@ -23,10 +28,18 @@ public static class SettlementModule
|
||||
public class SettlementSaveData
|
||||
{
|
||||
public int townHallLevel = 1;
|
||||
// Улучшение Ратуши, которое ещё идёт (01.11): timerEndMs 0 -- нет
|
||||
public long townHallTimerStartMs;
|
||||
public long townHallTimerEndMs;
|
||||
public int townHallTimerTargetLevel;
|
||||
public List<PlacedBuildingSaveEntry> placedBuildings = new List<PlacedBuildingSaveEntry>();
|
||||
}
|
||||
|
||||
static SettlementSaveData cached; // последнее состояние города (из сцены или из файла)
|
||||
|
||||
// 2D-город из сохранения -- только для чтения: по нему 3D-поселение один раз переносит здания игрока (05.11).
|
||||
// null -- 2D-города в сохранении нет. Сами данные 2D не меняются и остаются в файле
|
||||
public static SettlementSaveData Saved2D => cached;
|
||||
static BuildManager appliedToBuildManager; // BuildManager сцены, для которой город уже восстановлен
|
||||
static readonly Service service = new Service();
|
||||
|
||||
@@ -35,29 +48,47 @@ public static class SettlementModule
|
||||
{
|
||||
cached = null;
|
||||
appliedToBuildManager = null;
|
||||
lastTownHallLevel = 0;
|
||||
GameServices.Register<ISettlement>(service);
|
||||
SaveRegistry.Register(service);
|
||||
}
|
||||
|
||||
static bool CityLoaded => BuildManager.Instance != null;
|
||||
static int lastTownHallLevel; // уровень Ратуши последнего открытого города (0 -- ещё не открывали)
|
||||
|
||||
// ---------------- Сцена города <-> данные ----------------
|
||||
|
||||
static SettlementSaveData CaptureScene()
|
||||
{
|
||||
var data = new SettlementSaveData();
|
||||
if (TownHall.Instance != null) data.townHallLevel = TownHall.Instance.currentLevel;
|
||||
if (TownHall.Instance != null)
|
||||
{
|
||||
data.townHallLevel = TownHall.Instance.currentLevel;
|
||||
var thTimer = BuildTimer.Of(TownHall.Instance.gameObject);
|
||||
if (thTimer != null && BuildTimer.All.Contains(thTimer))
|
||||
{
|
||||
data.townHallTimerStartMs = thTimer.startMs;
|
||||
data.townHallTimerEndMs = thTimer.endMs;
|
||||
data.townHallTimerTargetLevel = thTimer.targetLevel;
|
||||
}
|
||||
}
|
||||
|
||||
foreach (var placed in UnityEngine.Object.FindObjectsByType<PlacedBuildingId>(FindObjectsSortMode.None))
|
||||
{
|
||||
if (string.IsNullOrEmpty(placed.buildingName)) continue; // уже убрано, Destroy сработает в конце кадра
|
||||
var leveled = placed.GetComponent<ILeveledBuilding>();
|
||||
var timer = placed.GetComponent<BuildTimer>();
|
||||
bool timerOn = timer != null && BuildTimer.All.Contains(timer);
|
||||
data.placedBuildings.Add(new PlacedBuildingSaveEntry
|
||||
{
|
||||
buildingName = placed.buildingName,
|
||||
posX = placed.transform.position.x,
|
||||
posY = placed.transform.position.y,
|
||||
level = leveled != null ? leveled.Level : 1
|
||||
level = leveled != null ? leveled.Level : 1,
|
||||
timerKind = timerOn ? (int)timer.kind : 0,
|
||||
timerStartMs = timerOn ? timer.startMs : 0,
|
||||
timerEndMs = timerOn ? timer.endMs : 0,
|
||||
timerTargetLevel = timerOn ? timer.targetLevel : 0
|
||||
});
|
||||
}
|
||||
return data;
|
||||
@@ -74,7 +105,16 @@ public static class SettlementModule
|
||||
|
||||
EventBus.Publish(new SettlementRestoringEvent()); // например, Казармы должны попасть в список стройки
|
||||
|
||||
if (TownHall.Instance != null) TownHall.Instance.currentLevel = cached.townHallLevel;
|
||||
if (TownHall.Instance != null)
|
||||
{
|
||||
TownHall.Instance.currentLevel = cached.townHallLevel;
|
||||
var oldTimer = BuildTimer.Of(TownHall.Instance.gameObject);
|
||||
if (oldTimer != null) oldTimer.Cancel();
|
||||
// Проверка таймера из файла: улучшение до уровня выше текущего, конец не раньше начала
|
||||
if (cached.townHallTimerEndMs > 0 && cached.townHallTimerTargetLevel > cached.townHallLevel)
|
||||
BuildTimer.Begin(TownHall.Instance.gameObject, BuildTimer.Kind.Upgrade, cached.townHallTimerTargetLevel,
|
||||
cached.townHallTimerStartMs, cached.townHallTimerEndMs);
|
||||
}
|
||||
|
||||
// Сначала убираем уже заспавненные здания, чтобы не задвоить постройки
|
||||
ClearPlacedBuildings();
|
||||
@@ -98,6 +138,14 @@ public static class SettlementModule
|
||||
var leveled = built.GetComponent<ILeveledBuilding>();
|
||||
if (leveled != null) leveled.Level = entry.level;
|
||||
|
||||
// Стройка или улучшение продолжается (если время уже вышло -- закончится в первом же кадре)
|
||||
// Проверка таймера из файла: постройка -- только для 1 уровня, улучшение -- только до уровня выше текущего
|
||||
bool validBuild = entry.timerKind == (int)BuildTimer.Kind.Build && entry.level <= 1;
|
||||
bool validUpgrade = entry.timerKind == (int)BuildTimer.Kind.Upgrade && entry.timerTargetLevel > Mathf.Max(1, entry.level);
|
||||
if ((validBuild || validUpgrade) && entry.timerEndMs > 0)
|
||||
BuildTimer.Begin(built, (BuildTimer.Kind)entry.timerKind, Mathf.Max(1, entry.timerTargetLevel),
|
||||
entry.timerStartMs, entry.timerEndMs);
|
||||
|
||||
// Помечаем слот занятым, чтобы поверх нельзя было поставить ещё одно здание
|
||||
BuildSlot slot = BuildManager.Instance.FindSlotAtPosition(pos);
|
||||
if (slot != null)
|
||||
@@ -124,6 +172,8 @@ public static class SettlementModule
|
||||
link.linkedSlot = null;
|
||||
}
|
||||
e.buildingName = null; // чтобы сохранение в этом же кадре уже не видело здание
|
||||
var timer = e.GetComponent<BuildTimer>();
|
||||
if (timer != null) timer.Discard(); // строитель свободен сразу, а не в конце кадра
|
||||
UnityEngine.Object.Destroy(e.gameObject);
|
||||
}
|
||||
}
|
||||
@@ -141,8 +191,16 @@ public static class SettlementModule
|
||||
|
||||
class Service : ISettlement, ISaveParticipant
|
||||
{
|
||||
public int TownHallLevel =>
|
||||
TownHall.Instance != null ? TownHall.Instance.currentLevel : (cached?.townHallLevel ?? 1);
|
||||
// Ратуша открытого города -- 2D или 3D. Вне города (карта мира) -- последнего открытого: в 3D-поселении
|
||||
// уровень Ратуши свой (секция settlement3d), и 2D-сохранение показывало бы на карте чужой уровень (05.11)
|
||||
public int TownHallLevel
|
||||
{
|
||||
get
|
||||
{
|
||||
if (TownHall.Instance != null) lastTownHallLevel = TownHall.Instance.currentLevel;
|
||||
return lastTownHallLevel > 0 ? lastTownHallLevel : (cached?.townHallLevel ?? 1);
|
||||
}
|
||||
}
|
||||
|
||||
public int BuildingCount =>
|
||||
CityLoaded
|
||||
@@ -164,10 +222,16 @@ public static class SettlementModule
|
||||
public void ResetState()
|
||||
{
|
||||
cached = new SettlementSaveData();
|
||||
lastTownHallLevel = 0;
|
||||
if (!CityLoaded) return;
|
||||
|
||||
ClearPlacedBuildings();
|
||||
if (TownHall.Instance != null) TownHall.Instance.currentLevel = 1;
|
||||
if (TownHall.Instance != null)
|
||||
{
|
||||
TownHall.Instance.currentLevel = 1;
|
||||
var thTimer = BuildTimer.Of(TownHall.Instance.gameObject);
|
||||
if (thTimer != null) thTimer.Cancel();
|
||||
}
|
||||
appliedToBuildManager = BuildManager.Instance; // город уже в состоянии новой игры
|
||||
EventBus.Publish(new TownHallLevelChangedEvent { Level = 1 });
|
||||
}
|
||||
|
||||
@@ -12,7 +12,8 @@
|
||||
"Bogatyri.World.Simulation",
|
||||
"Bogatyri.Chat",
|
||||
"Bogatyri.Circle",
|
||||
"Bogatyri.Intelligence"
|
||||
"Bogatyri.Intelligence",
|
||||
"Bogatyri.Settlement.Placement"
|
||||
],
|
||||
"includePlatforms": [
|
||||
"Editor"
|
||||
|
||||
@@ -1224,6 +1224,8 @@ public class ModuleTests
|
||||
return ownerId == LocalPlayer.Id;
|
||||
}
|
||||
public void GetArmies(long timeMs, List<ArmyOnMap> result) => result.AddRange(Armies(timeMs));
|
||||
public Dictionary<string, ReconTarget> Targets = new Dictionary<string, ReconTarget>();
|
||||
public bool TryGetReconTarget(string targetId, out ReconTarget target) => Targets.TryGetValue(targetId ?? "", out target);
|
||||
}
|
||||
|
||||
static ArmyOnMap Foreign(string id, int x, int y, int soldiers = 50) =>
|
||||
@@ -1503,6 +1505,21 @@ public class ModuleTests
|
||||
x = home.x; y = home.y;
|
||||
return ownerId == LocalPlayer.Id;
|
||||
}
|
||||
public bool TryGetReconTarget(string targetId, out ReconTarget target)
|
||||
{
|
||||
target = default;
|
||||
MapNode node = World.GetNode(targetId);
|
||||
if (node == null || node.type == MapNodeType.ResourcePoint || node.type == MapNodeType.PlayerCity) return false;
|
||||
target = new ReconTarget
|
||||
{
|
||||
Id = node.id, Name = node.displayName,
|
||||
Kind = node.type == MapNodeType.BossCastle ? ReconTargetKind.Castle : node.type == MapNodeType.Camp ? ReconTargetKind.Camp : ReconTargetKind.Settlement,
|
||||
Units = WorldMapLayout.GarrisonUnits(node), Fortified = WorldMapLayout.IsFortified(node),
|
||||
ResourceNames = new[] { "Gold" }, ResourceAmounts = new[] { 100 }
|
||||
};
|
||||
return true;
|
||||
}
|
||||
|
||||
public void GetArmies(long t, List<ArmyOnMap> result)
|
||||
{
|
||||
foreach (var m in World.Marches)
|
||||
@@ -1949,4 +1966,320 @@ public class ModuleTests
|
||||
Assert.AreEqual(20, army.GetWoundedWaiting(0) + 10, "ещё 20 легли сразу после улучшения: ждут 10");
|
||||
Assert.AreEqual(40, army.Healing[0].Count + army.Healing[1].Count);
|
||||
}
|
||||
|
||||
// Решения 01.11, п. 4: воины и раненые едят по норме своего типа, раненые -- даже ожидая койку
|
||||
[Test]
|
||||
public void Population_FoodByTroopType_IncludesWounded()
|
||||
{
|
||||
var army = new ArmyService(2, new HospitalRules { TemporaryBeds = 0 }); // мест нет -- все раненые ждут
|
||||
GameServices.Register<IArmy>(army);
|
||||
GameServices.Register<IEconomy>(new PopulationTests.FakeEconomy());
|
||||
GameServices.Register<ISettlement>(new PopulationTests.FakeSettlement { TownHallLevel = 1 });
|
||||
var population = new PopulationService(new PopulationRules { FoodPerTroopTypePerHour = new[] { 0.15, 0.2, 0.15, 0.15 } });
|
||||
GameServices.Register<IPopulation>(population);
|
||||
double civilians = population.Civilians * 0.1;
|
||||
|
||||
army.AddTroops(0, 20); // пехота 20 x 0,15 = 3
|
||||
army.AddTroops(1, 10); // лучники 10 x 0,20 = 2
|
||||
army.AddTroops(3, 4); // осадные 4 x 0,15 = 0,6
|
||||
Assert.AreEqual(civilians + 5.6, population.FoodPerHour, 0.001);
|
||||
|
||||
army.TakeForMarch(1, 14); // в походе -- тоже едят
|
||||
Assert.AreEqual(civilians + 5.6, population.FoodPerHour, 0.001, "поход не меняет еду");
|
||||
int[] sent = army.GetMarchUnits(1);
|
||||
var wounded = new int[4];
|
||||
for (int t = 0; t < 4; t++) wounded[t] = sent[t] / 2; // половина ранена, остальные погибли
|
||||
army.ReturnUnits(1, null, wounded, 0);
|
||||
Assert.AreEqual(0, army.Healing.Count, "коек нет -- раненые ждут");
|
||||
double expected = 0;
|
||||
for (int t = 0; t < 4; t++) expected += army.GetAllTroops(t) * new[] { 0.15, 0.2, 0.15, 0.15 }[t];
|
||||
Assert.AreEqual(civilians + expected, population.FoodPerHour, 0.001, "ждущие койку раненые едят полную норму");
|
||||
Assert.Less(expected, 5.6, "погибшие больше не едят");
|
||||
}
|
||||
|
||||
// ---------------- Пропавшие без вести (решения 01.11, п. 5) ----------------
|
||||
|
||||
[Test]
|
||||
public void Battle_Missing_IsSeparateStatus_OffByDefault()
|
||||
{
|
||||
BattleOutcome Fight(int share)
|
||||
{
|
||||
var rules = BattleRules.NoRandom;
|
||||
rules.MissingSharePermille = share;
|
||||
return new BattleSystem(rules).Resolve(new BattleRequest { Attackers = 1000, Defenders = 1000, Seed = 5 });
|
||||
}
|
||||
BattleOutcome off = Fight(0);
|
||||
Assert.AreEqual(0, off.AttackerMissingUnits[0], "доля не утверждена -- по умолчанию пропавших нет");
|
||||
|
||||
BattleOutcome on = Fight(200); // 20% выбывших
|
||||
Assert.AreEqual(90, on.AttackerMissingUnits[0], "20% от 450 выбывших");
|
||||
Assert.AreEqual(450, on.AttackerDeadUnits[0] + on.AttackerWoundedUnits[0] + on.AttackerMissingUnits[0], "каждый -- ровно в одном статусе");
|
||||
Assert.AreEqual(off.Survivors, on.Survivors, "выжившие не меняются");
|
||||
Assert.AreEqual(off.AttackerLosses, on.AttackerLosses);
|
||||
}
|
||||
|
||||
static ArmyService MissingArmy(MissingRules rules = null)
|
||||
{
|
||||
var army = new ArmyService(2, new HospitalRules { TemporaryBeds = 100 }, rules ?? new MissingRules());
|
||||
GameServices.Register<IArmy>(army);
|
||||
army.AddTroops(0, 60);
|
||||
army.AddTroops(1, 40);
|
||||
Assert.IsTrue(army.TakeForMarch(1, 100));
|
||||
return army;
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Army_Missing_ReturnInGroups_ThenWrittenOff()
|
||||
{
|
||||
const long H = 5 * 60_000; // игровой час
|
||||
var army = MissingArmy(new MissingRules { ReturnPercent = 50, WoundedPercent = 30 });
|
||||
var back = new List<MissingReturnedEvent>();
|
||||
var off = new List<MissingWrittenOffEvent>();
|
||||
EventBus.Subscribe<MissingReturnedEvent>(e => back.Add(e));
|
||||
EventBus.Subscribe<MissingWrittenOffEvent>(e => off.Add(e));
|
||||
|
||||
long battle = 1000, travel = 8 * H;
|
||||
Assert.IsTrue(army.MarkMissing(1, new[] { 30, 10, 0, 0 }, battle, travel));
|
||||
Assert.IsFalse(army.MarkMissing(1, new[] { 30, 10, 0, 0 }, battle, travel), "один раз на поход");
|
||||
Assert.AreEqual(40, army.MissingTotal);
|
||||
Assert.AreEqual(60, army.TroopsInMarches, "пропавшие ушли из похода");
|
||||
Assert.AreEqual(battle + travel, army.Missing[0].WindowStartMs, "окно -- после обратного пути");
|
||||
Assert.AreEqual(battle + travel + 6 * H, army.Missing[0].EndMs, "окно -- 6 игровых часов");
|
||||
|
||||
Assert.AreEqual(0, army.ProcessMissing(battle + travel), "до окна никто не вернулся");
|
||||
army.ProcessMissing(battle + travel + 6 * H - 1);
|
||||
Assert.IsTrue(back.Count >= 2 && back.Count <= 4, "2-4 группы: " + back.Count);
|
||||
int healthy = 0, wounded = 0;
|
||||
foreach (var e in back) { healthy += e.Healthy; wounded += e.Wounded; }
|
||||
Assert.AreEqual(20, healthy + wounded, "вернулась половина");
|
||||
Assert.IsTrue(wounded >= 2 && wounded <= 12, "около 30% раненых: " + wounded);
|
||||
Assert.AreEqual(wounded, army.WoundedTotal, "раненые -- в госпиталь");
|
||||
|
||||
army.ProcessMissing(battle + travel + 6 * H);
|
||||
Assert.AreEqual(1, off.Count);
|
||||
Assert.AreEqual(20, off[0].Count, "остальные списаны");
|
||||
Assert.AreEqual(0, army.MissingTotal);
|
||||
int groupsBefore = back.Count;
|
||||
army.ProcessMissing(battle + travel + 100 * H);
|
||||
Assert.AreEqual(groupsBefore, back.Count, "повторно никто не возвращается");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Army_Missing_SaveRestore_NoDoubleReturn_AndPopulationReserve()
|
||||
{
|
||||
const long H = 5 * 60_000;
|
||||
var army = MissingArmy();
|
||||
GameServices.Register<IEconomy>(new PopulationTests.FakeEconomy());
|
||||
GameServices.Register<ISettlement>(new PopulationTests.FakeSettlement { TownHallLevel = 1 });
|
||||
var population = new PopulationService(new PopulationRules());
|
||||
GameServices.Register<IPopulation>(population);
|
||||
int total = population.Total;
|
||||
float food = population.FoodPerHour;
|
||||
|
||||
army.MarkMissing(1, new[] { 20, 0, 0, 0 }, 0, 0);
|
||||
Assert.AreEqual(total, population.Total, "пропавшие занимают места в населении");
|
||||
Assert.AreEqual(food - 20 * 0.2f, population.FoodPerHour, 0.001f, "но не едят");
|
||||
|
||||
army.ProcessMissing(3 * H); // часть групп вернулась
|
||||
string saved = army.Capture();
|
||||
int home = army.GetTroops(0), hurt = army.WoundedTotal, missing = army.MissingTotal;
|
||||
|
||||
var copy = new ArmyService(2, new HospitalRules { TemporaryBeds = 100 });
|
||||
copy.Restore(saved);
|
||||
Assert.AreEqual(saved, copy.Capture(), "пропавшие и их группы сохраняются");
|
||||
Assert.AreEqual(missing, copy.MissingTotal);
|
||||
copy.ProcessMissing(3 * H);
|
||||
Assert.AreEqual(home, copy.GetTroops(0), "после загрузки уже вернувшиеся не возвращаются второй раз");
|
||||
Assert.AreEqual(hurt, copy.WoundedTotal);
|
||||
Assert.IsFalse(copy.MarkMissing(1, new[] { 5, 0, 0, 0 }, 0, 0), "поход уже отмечен -- и после загрузки");
|
||||
}
|
||||
|
||||
// ---------------- Разведка М2: целенаправленная разведка и отчёты (ТЗ разведки, раздел 6) ----------------
|
||||
|
||||
static FakeMap ReconMap()
|
||||
{
|
||||
var map = new FakeMap();
|
||||
map.Targets["city"] = new ReconTarget { Id = "city", Name = "Ратибор", Kind = ReconTargetKind.Settlement,
|
||||
Units = new[] { 10, 5, 3, 0 }, Fortified = true, ResourceNames = new[] { "Gold", "Food" }, ResourceAmounts = new[] { 100, 40 } };
|
||||
map.Targets["castle"] = new ReconTarget { Id = "castle", Name = "Замок", Kind = ReconTargetKind.Castle, Units = new[] { 8, 6, 4, 2 }, Fortified = true };
|
||||
return map;
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Recon_DurationByTargetAndLevel()
|
||||
{
|
||||
var intel = Intel(ReconMap());
|
||||
const long H = 5 * 60_000;
|
||||
Assert.AreEqual(2 * H, intel.ReconDurationMs("city", 2), "поселение, уровень II -- 2 игровых часа");
|
||||
Assert.AreEqual(4 * H, intel.ReconDurationMs("city", 3));
|
||||
Assert.AreEqual(3 * H, intel.ReconDurationMs("castle", 2), "замок -- 3 / 6");
|
||||
Assert.AreEqual(6 * H, intel.ReconDurationMs("castle", 3));
|
||||
Assert.AreEqual(0, intel.ReconDurationMs("nothing", 2), "нет такой цели");
|
||||
Assert.AreEqual(0, intel.ReconDurationMs("city", 1), "уровень I -- без отчёта (это обычное наблюдение)");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Recon_ReportLevels_OncePerOperation_Saved()
|
||||
{
|
||||
var map = ReconMap();
|
||||
var intel = Intel(map);
|
||||
var done = new List<ReconCompletedEvent>();
|
||||
EventBus.Subscribe<ReconCompletedEvent>(e => done.Add(e));
|
||||
|
||||
Assert.IsTrue(intel.CompleteRecon("recon_1", "city", 2, 5000));
|
||||
Assert.IsFalse(intel.CompleteRecon("recon_1", "city", 2, 9000), "одна операция -- один отчёт");
|
||||
Assert.AreEqual(1, done.Count);
|
||||
Assert.IsTrue(intel.TryGetLatestReport("city", out var r2));
|
||||
Assert.AreEqual(new[] { 10, 5, 3, 0 }[0], r2.Units[0], "уровень II -- точные войска по типам");
|
||||
Assert.IsTrue(r2.Fortified);
|
||||
Assert.AreEqual(0, r2.ResourceNames.Length, "уровень II -- без ресурсов");
|
||||
Assert.AreEqual(5000, r2.TimeMs, "время отчёта -- конец наблюдения");
|
||||
|
||||
// Цель изменилась после отчёта -- старый отчёт не обновляется (нет постоянного слежения)
|
||||
var t = map.Targets["city"]; t.Units = new[] { 50, 0, 0, 0 }; map.Targets["city"] = t;
|
||||
Assert.AreEqual(10, intel.Reports[0].Units[0]);
|
||||
|
||||
Assert.IsTrue(intel.CompleteRecon("recon_2", "city", 3, 7000));
|
||||
intel.TryGetLatestReport("city", out var r3);
|
||||
Assert.AreEqual(50, r3.Units[0], "новый отчёт -- на момент своей операции");
|
||||
Assert.AreEqual(2, r3.ResourceNames.Length, "уровень III -- оценка ресурсов");
|
||||
Assert.IsTrue(r3.ResourceMin[0] <= 100 && 100 <= r3.ResourceMax[0], "настоящее значение внутри окна");
|
||||
Assert.AreEqual(30, r3.ResourceMax[0] - r3.ResourceMin[0], "±15% от 100 = окно 30");
|
||||
StringAssertContains(r3.Modifiers, "стен");
|
||||
|
||||
var copy = Intel(map);
|
||||
copy.Restore(intel.Capture());
|
||||
Assert.AreEqual(intel.Capture(), copy.Capture(), "отчёты сохраняются");
|
||||
Assert.IsFalse(copy.CompleteRecon("recon_2", "city", 3, 7000), "после загрузки отчёт не создаётся второй раз");
|
||||
}
|
||||
|
||||
static void StringAssertContains(string text, string part) => Assert.IsTrue(text != null && text.Contains(part), $"«{text}» не содержит «{part}»");
|
||||
|
||||
[Test]
|
||||
public void World_ReconMarch_ObservesThenReturns()
|
||||
{
|
||||
const long H = 5 * 60_000;
|
||||
long t0 = 1_000_000_000_000;
|
||||
var world = new WorldSimulation(WorldMapLayout.CreateDefault(), new BattleSystem());
|
||||
var intel = Intel(new WorldAsMap { World = world });
|
||||
MarchData m = world.StartMarch(LocalPlayer.Id, WorldMapLayout.PlayerCityId, WorldMapLayout.BossCastleId, 5, t0, out _, scout: true);
|
||||
Assert.IsTrue(world.SetRecon(m, 3, intel.ReconDurationMs(WorldMapLayout.BossCastleId, 3)));
|
||||
world.AssignUnits(m, new[] { 5, 0, 0, 0 });
|
||||
|
||||
var events = new List<WorldEvent>();
|
||||
world.Advance(m.arriveMs, events);
|
||||
Assert.AreEqual(WorldEventType.ScoutArrived, events[0].Type);
|
||||
Assert.AreEqual(MarchState.Gathering, m.state, "стоят у цели и наблюдают");
|
||||
Assert.AreEqual(m.arriveMs + 6 * H, m.gatherEndMs, "замок, уровень III -- 6 игровых часов");
|
||||
Assert.IsTrue(world.TryGetPositionAt(m, m.arriveMs + H, out int x, out int y, out _, out _, out _));
|
||||
MapNode boss = world.GetNode(WorldMapLayout.BossCastleId);
|
||||
Assert.AreEqual(boss.x, x, "во время наблюдения -- у цели");
|
||||
|
||||
events.Clear();
|
||||
world.Advance(m.returnMs, events);
|
||||
Assert.AreEqual(WorldEventType.ReconCompleted, events[0].Type, "сначала -- отчёт");
|
||||
Assert.AreEqual(WorldEventType.MarchReturned, events[1].Type, "потом -- домой");
|
||||
Assert.AreEqual(5, m.survivors, "разведка без потерь");
|
||||
Assert.IsTrue(intel.CompleteRecon("recon_" + m.id, m.targetNodeId, m.reconLevel, m.gatherEndMs));
|
||||
}
|
||||
|
||||
// ---------------- Разведка М2: движущаяся армия (ТЗ разведки, раздел 6: II -- 1, III -- 2 игровых часа) ----------------
|
||||
|
||||
// Медленная чужая армия с долгим маршрутом рядом с игроком (настоящие патрули идут ~40 мин -- их часто не догнать,
|
||||
// и это правильно: разведать можно армию, которая проходит рядом). Новые патрули не выходят: место занято ею
|
||||
static WorldSimulation ArmyReconWorld(long t0, out MarchData patrol)
|
||||
{
|
||||
var world = PatrolWorld(true);
|
||||
world.Patrols = new PatrolRules { Enabled = true, MaxActive = 1, IntervalMs = 24 * 3_600_000L };
|
||||
patrol = new MarchData
|
||||
{
|
||||
id = 500, ownerId = "npc_test", fromNodeId = "city_14", targetNodeId = "city_15", soldiers = 20, survivors = 20,
|
||||
departMs = t0, arriveMs = t0 + 10 * 3_600_000L, returnMs = t0 + 20 * 3_600_000L, state = MarchState.Outbound, patrol = true
|
||||
};
|
||||
world.LoadMarches(new List<MarchData> { patrol });
|
||||
return world;
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void ArmyRecon_Duration_1and2GameHours()
|
||||
{
|
||||
var map = new FakeMap();
|
||||
map.Targets["march_7"] = new ReconTarget { Id = "march_7", Name = "Армия", Kind = ReconTargetKind.Army, Units = new[] { 20, 0, 0, 0 } };
|
||||
var intel = Intel(map);
|
||||
const long H = 5 * 60_000;
|
||||
Assert.AreEqual(1 * H, intel.ReconDurationMs("march_7", 2), "вражеская армия, уровень II -- 1 игровой час");
|
||||
Assert.AreEqual(2 * H, intel.ReconDurationMs("march_7", 3), "уровень III -- 2 игровых часа");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void ArmyRecon_Intercepts_FollowsTarget_ReportsThenReturns()
|
||||
{
|
||||
const long H = 5 * 60_000;
|
||||
long t0 = 1_000_000_000_000;
|
||||
var world = ArmyReconWorld(t0, out MarchData patrol);
|
||||
long start = t0 + 60_000;
|
||||
MarchData m = world.StartArmyRecon(LocalPlayer.Id, WorldMapLayout.PlayerCityId, patrol.id, 5, start, 2, H, out string error);
|
||||
Assert.IsNotNull(m, error);
|
||||
Assert.IsTrue(WorldSimulation.IsArmyRecon(m));
|
||||
world.AssignUnits(m, new[] { 5, 0, 0, 0 });
|
||||
Assert.IsTrue(m.arriveMs > start, "путь до перехвата -- физический, не мгновенный");
|
||||
Assert.AreEqual(m.arriveMs + H, m.gatherEndMs, "наблюдение -- 1 игровой час");
|
||||
|
||||
// Точка перехвата -- там, где армия в момент перехвата
|
||||
world.TryGetPositionAt(patrol, m.arriveMs, out int px, out int py, out _, out _, out _);
|
||||
Assert.AreEqual(px, m.meetX); Assert.AreEqual(py, m.meetY);
|
||||
|
||||
var events = new List<WorldEvent>();
|
||||
world.Advance(m.arriveMs, events);
|
||||
Assert.AreEqual(WorldEventType.ScoutArrived, events[events.Count - 1].Type);
|
||||
Assert.AreEqual(MarchState.Gathering, m.state, "догнали -- наблюдают");
|
||||
|
||||
// Во время наблюдения разведчики идут рядом с армией
|
||||
long mid = m.arriveMs + H / 2;
|
||||
world.TryGetPositionAt(patrol, mid, out int ax, out int ay, out _, out _, out _);
|
||||
world.TryGetPositionAt(m, mid, out int sx, out int sy, out _, out _, out _);
|
||||
Assert.AreEqual(ax, sx); Assert.AreEqual(ay, sy);
|
||||
|
||||
events.Clear();
|
||||
world.Advance(m.returnMs, events);
|
||||
var mine = events.FindAll(e => e.March == m);
|
||||
Assert.AreEqual(WorldEventType.ReconCompleted, mine[0].Type, "сначала отчёт");
|
||||
Assert.AreEqual(m.gatherEndMs, mine[0].TimeMs, "отчёт -- на конец наблюдения");
|
||||
Assert.AreEqual(WorldEventType.MarchReturned, mine[1].Type, "потом -- домой");
|
||||
Assert.AreEqual(5, m.survivors, "разведка без боя и потерь");
|
||||
Assert.IsTrue(m.returnMs > m.gatherEndMs, "домой идут пешком от места, где кончилось наблюдение");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void ArmyRecon_Refused_IfArmyLeavesBeforeObservationEnds_OrIsOwn()
|
||||
{
|
||||
const long H = 5 * 60_000;
|
||||
long t0 = 1_000_000_000_000;
|
||||
var world = ArmyReconWorld(t0, out MarchData patrol);
|
||||
int before = world.Marches.Count;
|
||||
// Армия почти дома: наблюдение на 2 часа не успеть
|
||||
long late = patrol.returnMs - 30_000;
|
||||
Assert.IsNull(world.StartArmyRecon(LocalPlayer.Id, WorldMapLayout.PlayerCityId, patrol.id, 5, late, 3, 2 * H, out string err));
|
||||
Assert.IsTrue(err.Contains("не догнать") || err.Contains("раньше"), err);
|
||||
Assert.AreEqual(before, world.Marches.Count, "поход не создан");
|
||||
Assert.IsNull(world.StartArmyRecon(patrol.ownerId, WorldMapLayout.PlayerCityId, patrol.id, 5, t0, 2, H, out err), "свою армию не разведывают");
|
||||
Assert.IsNull(world.StartArmyRecon(LocalPlayer.Id, WorldMapLayout.PlayerCityId, 99999, 5, t0, 2, H, out err), "нет такой армии");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void ArmyRecon_TargetGone_BeforeIntercept_ReturnsWithoutReport()
|
||||
{
|
||||
const long H = 5 * 60_000;
|
||||
long t0 = 1_000_000_000_000;
|
||||
var world = ArmyReconWorld(t0, out MarchData patrol);
|
||||
MarchData m = world.StartArmyRecon(LocalPlayer.Id, WorldMapLayout.PlayerCityId, patrol.id, 5, t0 + 1000, 2, H, out string error);
|
||||
Assert.IsNotNull(m, error);
|
||||
world.Patrols = new PatrolRules { Enabled = false }; // патрули выключили -- цель пропала
|
||||
var events = new List<WorldEvent>();
|
||||
world.SpawnPatrolsUntil(t0 + 2000);
|
||||
world.Advance(m.returnMs + 24 * 3_600_000L, events);
|
||||
Assert.IsTrue(m.targetEmpty, "цели нет -- дошли до места и вернулись");
|
||||
Assert.IsFalse(events.Exists(e => e.March == m && e.Type == WorldEventType.ReconCompleted), "без отчёта");
|
||||
Assert.IsTrue(events.Exists(e => e.March == m && e.Type == WorldEventType.MarchReturned), "вернулись");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,139 @@
|
||||
using System.Collections.Generic;
|
||||
using Bogatyri.Settlement.Placement;
|
||||
using NUnit.Framework;
|
||||
|
||||
// Тесты размещения зданий в 3D-поселении (ТЗ «Система строительства поселения» от 04.11.2026, раздел 11).
|
||||
// Запуск: Window -> General -> Test Runner -> EditMode -> Run All.
|
||||
public class PlacementTests
|
||||
{
|
||||
// Карта 20 x 20: всё открыто с Ратуши 1, кроме правой полосы (x >= 15 -- Ратуша 2);
|
||||
// река по x = 10, скала в (2, 18), дерево "tree_1" в (5, 5), холм справа вверху
|
||||
static SettlementMap Map()
|
||||
{
|
||||
var map = new SettlementMap(20, 20);
|
||||
for (int y = 0; y < 20; y++)
|
||||
for (int x = 0; x < 20; x++)
|
||||
{
|
||||
CellKind k = x == 10 ? CellKind.Water : x == 2 && y == 18 ? CellKind.Mountain : CellKind.Ground;
|
||||
int height = x >= 16 && y >= 16 ? (x - 16) * 500 : 0; // склон: 0,5 м на клетку
|
||||
map.SetCell(x, y, k, height, x >= 15 ? 2 : 1);
|
||||
}
|
||||
map.AddObstacle("tree_1", new[] { (5, 5) });
|
||||
return map;
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Placement_FlatGround_Ok_AndForbiddenPlaces()
|
||||
{
|
||||
var map = Map();
|
||||
Assert.AreEqual(PlaceError.None, map.Place("hall", "Ратуша", 1, 1, 3, 3, 0, 1), "01: ровная земля");
|
||||
Assert.AreEqual(PlaceError.Water, map.Validate(9, 2, 2, 2, 0, 1, null, out _), "02: река");
|
||||
Assert.AreEqual(PlaceError.Mountain, map.Validate(1, 17, 2, 2, 0, 1, null, out _), "02: скала");
|
||||
Assert.AreEqual(PlaceError.Obstacle, map.Validate(4, 4, 2, 2, 0, 1, null, out _), "дерево мешает");
|
||||
Assert.AreEqual(PlaceError.OutOfMap, map.Validate(19, 19, 2, 2, 0, 1, null, out _), "за краем");
|
||||
Assert.AreEqual(PlaceError.ZoneLocked, map.Validate(15, 2, 2, 2, 0, 1, null, out _), "зона Ратуши 2");
|
||||
Assert.AreEqual(PlaceError.None, map.Validate(15, 2, 2, 2, 0, 2, null, out _), "11: Ратуша 2 открыла зону");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Placement_IfCleared_IgnoresOnlyTreesAndListsThem()
|
||||
{
|
||||
var map = Map();
|
||||
Assert.AreEqual(PlaceError.Obstacle, map.Validate(4, 4, 2, 2, 0, 1, null, out _), "дерево мешает");
|
||||
Assert.AreEqual(PlaceError.None, map.ValidateIfCleared(4, 4, 2, 2, 0, 1, null, out _), "если расчистить -- можно");
|
||||
var under = map.ObstaclesUnder(4, 4, 2, 2, 0);
|
||||
Assert.AreEqual(1, under.Count, "под зданием -- одно дерево");
|
||||
Assert.AreEqual("tree_1", under[0]);
|
||||
Assert.AreEqual(0, map.ObstaclesUnder(1, 1, 2, 2, 0).Count, "рядом деревьев нет");
|
||||
Assert.AreEqual(PlaceError.Water, map.ValidateIfCleared(9, 2, 2, 2, 0, 1, null, out _), "река остаётся рекой");
|
||||
Assert.AreEqual(PlaceError.ZoneLocked, map.ValidateIfCleared(15, 2, 2, 2, 0, 1, null, out _), "закрытая зона -- закрыта");
|
||||
map.Place("a", "Дом", 1, 1, 2, 2, 0, 1);
|
||||
Assert.AreEqual(PlaceError.Occupied, map.ValidateIfCleared(1, 1, 2, 2, 0, 1, null, out _), "чужое здание мешает");
|
||||
Assert.IsTrue(map.ClearObstacle("tree_1"));
|
||||
Assert.AreEqual(PlaceError.None, map.Place("b", "Дом", 4, 4, 2, 2, 0, 1), "после расчистки ставится");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Placement_GapBetweenBuildings()
|
||||
{
|
||||
var map = Map();
|
||||
map.Place("a", "Дом", 1, 1, 2, 2, 0, 1);
|
||||
Assert.AreEqual(PlaceError.Occupied, map.Validate(2, 2, 2, 2, 0, 1, null, out _));
|
||||
Assert.AreEqual(PlaceError.TooClose, map.Validate(3, 1, 2, 2, 0, 1, null, out _), "03: вплотную -- нарушен отступ");
|
||||
Assert.AreEqual(PlaceError.None, map.Validate(4, 1, 2, 2, 0, 1, null, out _), "через клетку -- можно");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Placement_Rotation_SwapsFootprint()
|
||||
{
|
||||
var map = Map();
|
||||
// 4 x 1 у реки: повёрнутое на 90 -- 1 x 4 -- помещается вдоль реки, без поворота -- упирается в неё
|
||||
Assert.AreEqual(PlaceError.Water, map.Validate(7, 12, 4, 1, 0, 1, null, out _));
|
||||
foreach (int rot in new[] { 90, 270 })
|
||||
Assert.AreEqual(PlaceError.None, map.Validate(9, 12, 4, 1, rot, 1, null, out _), "04: поворот " + rot);
|
||||
Assert.AreEqual(PlaceError.Water, map.Validate(7, 12, 4, 1, 180, 1, null, out _), "04: 180 -- как 0");
|
||||
map.Place("long", "Склад", 9, 12, 4, 1, 90, 1);
|
||||
Assert.IsTrue(map.TryGetBuilding("long", out var b));
|
||||
Assert.AreEqual(1, b.W);
|
||||
Assert.AreEqual(4, b.H);
|
||||
Assert.AreEqual("long", map.BuildingAt(9, 15));
|
||||
Assert.IsNull(map.BuildingAt(10, 12));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Placement_Slope_FoundationOrTooSteep()
|
||||
{
|
||||
var map = Map();
|
||||
Assert.AreEqual(PlaceError.None, map.Validate(16, 16, 2, 2, 0, 2, null, out int f), "небольшой перепад");
|
||||
Assert.AreEqual(500, f, "фундамент 0,5 м");
|
||||
Assert.AreEqual(PlaceError.TooSteep, map.Validate(16, 16, 3, 2, 0, 2, null, out _), "1 м -- слишком круто");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Placement_Move_IsAtomic_KeepsId()
|
||||
{
|
||||
var map = Map();
|
||||
map.Place("farm", "Ферма", 1, 1, 3, 3, 0, 1);
|
||||
map.Place("mine", "Шахта", 12, 1, 2, 2, 0, 1);
|
||||
|
||||
Assert.AreEqual(PlaceError.None, map.Move("farm", 2, 1, 0, 1), "свой прежний след не мешает");
|
||||
Assert.IsTrue(map.TryGetBuilding("farm", out var moved));
|
||||
Assert.AreEqual(2, moved.X);
|
||||
|
||||
Assert.AreEqual(PlaceError.Water, map.Move("farm", 8, 1, 0, 1), "09: ошибка -- здание на месте");
|
||||
map.TryGetBuilding("farm", out var same);
|
||||
Assert.AreEqual(2, same.X);
|
||||
Assert.AreEqual(2, map.Buildings.Count, "без дублей");
|
||||
|
||||
Assert.AreEqual(PlaceError.None, map.Move("mine", 12, 6, 90, 1), "05: шахту можно переносить и поворачивать");
|
||||
Assert.AreEqual(PlaceError.OutOfMap, map.Move("nope", 1, 1, 0, 1));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Placement_ClearObstacle_OnceOnly_SurvivesReload()
|
||||
{
|
||||
var map = Map();
|
||||
Assert.IsTrue(map.ClearObstacle("tree_1"), "10: расчищено -- одна награда");
|
||||
Assert.IsFalse(map.ClearObstacle("tree_1"), "второй раз -- без награды");
|
||||
Assert.IsNull(map.ObstacleAt(5, 5));
|
||||
Assert.AreEqual(PlaceError.None, map.Validate(4, 4, 2, 2, 0, 1, null, out _));
|
||||
|
||||
// Загрузка: расчищенное не возвращается, даже если отмечено раньше, чем объект добавлен на карту
|
||||
var copy = new SettlementMap(20, 20);
|
||||
for (int y = 0; y < 20; y++) for (int x = 0; x < 20; x++) copy.SetCell(x, y, CellKind.Ground, 0, 1);
|
||||
copy.RestoreCleared(new List<string>(map.Cleared));
|
||||
copy.AddObstacle("tree_1", new[] { (5, 5) });
|
||||
Assert.IsNull(copy.ObstacleAt(5, 5));
|
||||
Assert.IsFalse(copy.ClearObstacle("tree_1"));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Placement_Restore_NoOverlaps()
|
||||
{
|
||||
var map = Map();
|
||||
Assert.IsTrue(map.Restore(new PlacedRect { Id = "a", Type = "Дом", X = 1, Y = 1, SizeW = 2, SizeH = 2, Rotation = 0 }));
|
||||
Assert.IsFalse(map.Restore(new PlacedRect { Id = "b", Type = "Дом", X = 2, Y = 2, SizeW = 2, SizeH = 2, Rotation = 0 }), "12: наложение не восстанавливается");
|
||||
Assert.IsFalse(map.Restore(new PlacedRect { Id = "a", Type = "Дом", X = 6, Y = 6, SizeW = 2, SizeH = 2, Rotation = 0 }), "тот же id -- не дубль");
|
||||
Assert.AreEqual(270, SettlementMap.NormalizeRotation(-90));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 73f551b6ba7242e5bb6a9b8e983be5fe
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -456,7 +456,7 @@ public class PopulationTests
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void ClockGoesBack_NothingHappens_ThenContinues()
|
||||
public void ClockGoesBack_NothingHappens_CountedTimeNotCountedAgain()
|
||||
{
|
||||
economy.Set(ResourceType.Food, 1000);
|
||||
var p = StartCity();
|
||||
@@ -465,8 +465,12 @@ public class PopulationTests
|
||||
Assert.AreEqual(1000, economy.Get(ResourceType.Food), 0.0001, "время назад -- ничего не съедено");
|
||||
Assert.AreEqual(300, p.Total);
|
||||
|
||||
// Решения 04.11: метка последнего расчёта не уходит назад -- уже посчитанное время второй раз не считается
|
||||
p.Tick(T0 - 5000 + Hour);
|
||||
Assert.AreEqual(1000 - 33, economy.Get(ResourceType.Food), 0.01);
|
||||
Assert.AreEqual(1000, economy.Get(ResourceType.Food), 0.0001, "часы ещё не дошли до уже посчитанного -- ничего");
|
||||
|
||||
p.Tick(T0 + Hour);
|
||||
Assert.AreEqual(1000 - 33, economy.Get(ResourceType.Food), 0.01, "дальше метки -- ровно один час");
|
||||
}
|
||||
|
||||
[Test]
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user