Compare commits
27
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0ebbf56306 | ||
|
|
f78eb7ea7e | ||
|
|
8a5f7253db | ||
|
|
a6b5926e39 | ||
|
|
aa7789dc97 | ||
|
|
422f55f22e | ||
|
|
65f5039f74 | ||
|
|
4fe2dced76 | ||
|
|
d7348dd23d | ||
|
|
7d932e3cbc | ||
|
|
35319be8ca | ||
|
|
5502489c07 | ||
|
|
f307c83352 | ||
|
|
c29a4ae708 | ||
|
|
56b66af0c3 | ||
|
|
a03098ffe0 | ||
|
|
413c627d5c | ||
|
|
e26eeaffaf | ||
|
|
88f460a4a6 | ||
|
|
235ff124ee | ||
|
|
bb3baba3b7 | ||
|
|
1f0676ffc9 | ||
|
|
b3296c7a55 | ||
|
|
b1bb863807 | ||
|
|
8b6a7869a4 | ||
|
|
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
|
||||
@@ -3,11 +3,13 @@ using UnityEditor;
|
||||
|
||||
// Следит, чтобы сцена карты мира (WorldMap, от Саши) была в Build Settings --
|
||||
// иначе переход город -> карта не работает. Срабатывает сам после каждой компиляции.
|
||||
// Стартовая сцена (05.11): 3D-поселение City3D, если оно есть в проекте, иначе 2D-город MVP1.
|
||||
[InitializeOnLoad]
|
||||
public static class SceneListFixer
|
||||
{
|
||||
const string CityScene = "Assets/Scenes/MVP1.unity";
|
||||
const string WorldScene = "Assets/Scenes/WorldMap.unity";
|
||||
const string City3DScene = "Assets/Scenes/City3D.unity";
|
||||
|
||||
static SceneListFixer() { EditorApplication.delayCall += Ensure; }
|
||||
|
||||
@@ -15,15 +17,16 @@ public static class SceneListFixer
|
||||
{
|
||||
var scenes = new List<EditorBuildSettingsScene>(EditorBuildSettings.scenes);
|
||||
bool changed = false;
|
||||
foreach (var path in new[] { CityScene, WorldScene })
|
||||
string start = AssetDatabase.LoadAssetAtPath<SceneAsset>(City3DScene) != null ? City3DScene : CityScene;
|
||||
foreach (var path in new[] { start, CityScene, WorldScene })
|
||||
{
|
||||
if (AssetDatabase.LoadAssetAtPath<SceneAsset>(path) == null) continue;
|
||||
if (scenes.Exists(s => s.path == path)) continue;
|
||||
scenes.Add(new EditorBuildSettingsScene(path, true));
|
||||
changed = true;
|
||||
}
|
||||
// город должен быть первым (с него стартует игра)
|
||||
int city = scenes.FindIndex(s => s.path == CityScene);
|
||||
// стартовый город должен быть первым (с него стартует игра)
|
||||
int city = scenes.FindIndex(s => s.path == start);
|
||||
if (city > 0) { var c = scenes[city]; scenes.RemoveAt(city); scenes.Insert(0, c); changed = true; }
|
||||
if (changed)
|
||||
{
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,7 @@
|
||||
fileFormatVersion: 2
|
||||
guid: dd067b5f8139dbc46b51ef157b0f0613
|
||||
PrefabImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -32,7 +32,8 @@ Material:
|
||||
m_CustomRenderQueue: -1
|
||||
stringTagMap:
|
||||
RenderType: Opaque
|
||||
disabledShaderPasses: []
|
||||
disabledShaderPasses:
|
||||
- MOTIONVECTORS
|
||||
m_LockedProperties:
|
||||
m_SavedProperties:
|
||||
serializedVersion: 3
|
||||
@@ -41,18 +42,96 @@ Material:
|
||||
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
|
||||
|
||||
+369
-1
@@ -132,6 +132,7 @@ GameObject:
|
||||
- component: {fileID: 110794172}
|
||||
- component: {fileID: 110794171}
|
||||
- component: {fileID: 110794170}
|
||||
- component: {fileID: 110794175}
|
||||
m_Layer: 0
|
||||
m_Name: Main Camera
|
||||
m_TagString: MainCamera
|
||||
@@ -249,7 +250,7 @@ Camera:
|
||||
near clip plane: 0.3
|
||||
far clip plane: 300
|
||||
field of view: 40
|
||||
orthographic: 0
|
||||
orthographic: 1
|
||||
orthographic size: 5
|
||||
m_Depth: 0
|
||||
m_CullingMask:
|
||||
@@ -281,6 +282,369 @@ Transform:
|
||||
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
|
||||
@@ -429,3 +793,7 @@ SceneRoots:
|
||||
- {fileID: 110794174}
|
||||
- {fileID: 1935466180}
|
||||
- {fileID: 1225367367}
|
||||
- {fileID: 429953708}
|
||||
- {fileID: 1080091921}
|
||||
- {fileID: 842753595}
|
||||
- {fileID: 695231209}
|
||||
|
||||
@@ -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()
|
||||
@@ -192,6 +234,26 @@ public static class BuildingWindow
|
||||
}
|
||||
else StorageBar(card, economy, ResourceType.Food);
|
||||
UIKit.Text(card, "Сверх лимита ресурс не прибавляется — пропадает.", 22, UIKit.Theme.TextDim, TextAlignmentOptions.Center);
|
||||
|
||||
if (GameServices.TryGet(out IResourceProtection protection))
|
||||
{
|
||||
Header(card, "Защита от грабежа");
|
||||
var p = EconomyConfig.Load().protection;
|
||||
ResourceType[] kinds = warehouse
|
||||
? new[] { ResourceType.Wood, ResourceType.Stone, ResourceType.Iron, ResourceType.Gold }
|
||||
: new[] { ResourceType.Food };
|
||||
Live(card, () =>
|
||||
{
|
||||
var lines = new List<string>();
|
||||
foreach (var k in kinds)
|
||||
lines.Add($"{GameData.ResName(k)}: спрятано {protection.Protected(k):0}, можно унести {protection.Lootable(k):0}");
|
||||
return string.Join("\n", lines.ToArray());
|
||||
}, 22, UIKit.Theme.Text);
|
||||
UIKit.Text(card, warehouse
|
||||
? $"Каждый уровень Склада прячет ещё {p.perWarehouseLevel:0} каждого ресурса — в набеге их не унесут."
|
||||
: $"Каждый уровень Амбара прячет ещё {p.perGranaryLevel:0} еды — в набеге её не унесут.",
|
||||
22, UIKit.Theme.TextDim, TextAlignmentOptions.Center);
|
||||
}
|
||||
}
|
||||
|
||||
UpgradeBlock(card, storage.level, $"лимит +{perLevel * storage.level:0} → +{perLevel * (storage.level + 1):0}",
|
||||
@@ -235,18 +297,45 @@ public static class BuildingWindow
|
||||
string unlocks = UnlocksAt(next);
|
||||
if (unlocks.Length > 0) UIKit.Text(card, "Откроется: " + unlocks, 24, UIKit.Theme.Gold, TextAlignmentOptions.Center);
|
||||
|
||||
string more = MoreBuildingsAt(level, next);
|
||||
if (more.Length > 0) UIKit.Text(card, "Больше зданий: " + more, 22, UIKit.Theme.TextDim, TextAlignmentOptions.Center);
|
||||
if (TimerRunning(townHall.gameObject)) return; // улучшение уже идёт -- полоска сверху
|
||||
|
||||
var cost = BuildingData.ToAmounts(townHall.GetUpgradeCost());
|
||||
UIKit.Text(card, "Цена: " + CostText(cost), 24, UIKit.Theme.Text, TextAlignmentOptions.Center);
|
||||
LiveCost(card, cost);
|
||||
long ms = config.ConstructionMs(true, next);
|
||||
TimeText(card, ms);
|
||||
bool canPay = townHall.CanUpgrade();
|
||||
var b = UIKit.Button(card, $"Улучшить до {next} ур.", canPay ? UIKit.Theme.Green : UIKit.Theme.Gray, () =>
|
||||
{
|
||||
if (!townHall.CanUpgrade()) { UIKit.Toast("Не хватает ресурсов"); return; }
|
||||
townHall.Upgrade();
|
||||
if (CitySync.Instance != null) CitySync.Instance.SyncNow();
|
||||
UIKit.Toast($"Ратуша улучшена до {townHall.currentLevel} уровня");
|
||||
if (ms > 0 && BuildTimer.FreeBuilders == 0) { UIKit.Toast("Все строители заняты"); return; }
|
||||
if (!GameServices.TryGet(out IEconomy e) || !e.TrySpend(cost)) { UIKit.Toast("Не хватает ресурсов"); return; }
|
||||
if (ms <= 0) BuildTimer.Begin(townHall.gameObject, BuildTimer.Kind.Upgrade, next, 0, 0).Complete();
|
||||
else
|
||||
{
|
||||
long now = GameClock.UnixNowMs;
|
||||
BuildTimer.Begin(townHall.gameObject, BuildTimer.Kind.Upgrade, next, now, now + ms);
|
||||
UIKit.Toast($"Ратуша улучшается до {next} уровня");
|
||||
}
|
||||
Rebuild();
|
||||
}, -1, 80, 26);
|
||||
b.interactable = canPay;
|
||||
LiveAfford(b, canPay, () => townHall != null && townHall.CanUpgrade());
|
||||
}
|
||||
|
||||
// Каких зданий на следующем уровне Ратуши можно построить больше: "Лес 2 → 3, Дом 3 → 4"
|
||||
static string MoreBuildingsAt(int level, int next)
|
||||
{
|
||||
var config = EconomyConfig.Load();
|
||||
var parts = new List<string>();
|
||||
if (config.buildingLimits != null)
|
||||
foreach (var l in config.buildingLimits)
|
||||
{
|
||||
if (l == null) continue;
|
||||
int a = config.BuildingLimitFor(l.buildingName, level), b = config.BuildingLimitFor(l.buildingName, next);
|
||||
if (b > a) parts.Add($"{l.buildingName} {a} → {b}");
|
||||
}
|
||||
return string.Join(", ", parts.ToArray());
|
||||
}
|
||||
|
||||
// ---------------- Казармы ----------------
|
||||
@@ -340,6 +429,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]}"
|
||||
: $"{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 +527,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);
|
||||
|
||||
int perGem = Mathf.Max(1, EconomyConfig.Load().construction.secondsPerGem);
|
||||
Func<int> gems = () => t != null ? Mathf.Max(1, Mathf.CeilToInt((float)t.SecondsLeft / perGem)) : 0;
|
||||
var speed = UIKit.Button(card, "", UIKit.Theme.Blue, () =>
|
||||
{
|
||||
if (t == null) return;
|
||||
int price = gems();
|
||||
if (!GameServices.TryGet(out IEconomy e) || !e.TrySpend(ResourceType.Gems, price))
|
||||
{
|
||||
UIKit.Toast($"Нужно {price} кристаллов");
|
||||
return;
|
||||
}
|
||||
t.Complete(); // окно перестроит OnBuildCompleted
|
||||
}, -1, 80, 26);
|
||||
live.Add(() => { if (t != null) UIKit.SetButton(speed, $"Ускорить за {gems()} кристаллов", true); });
|
||||
UIKit.SetButton(speed, $"Ускорить за {gems()} кристаллов", true);
|
||||
}
|
||||
|
||||
// ---------------- Перенос (решения 04.11: сноса нет) ----------------
|
||||
// Здание можно бесплатно переставить в пределах поселения: уровень, стройка или улучшение сохраняются.
|
||||
// Снести здание и вернуть ресурсы нельзя -- такой кнопки нет.
|
||||
|
||||
static void ActionsBlock(RectTransform card, GameObject building)
|
||||
{
|
||||
var id = building.GetComponent<PlacedBuildingId>();
|
||||
if (id == null || string.IsNullOrEmpty(id.buildingName)) return; // Ратуша и стартовые постройки сцены
|
||||
UIKit.Button(card, "Перенести", UIKit.Theme.Blue, () =>
|
||||
{
|
||||
Close();
|
||||
if (BuildModeController.Instance != null) BuildModeController.Instance.StartMove(building);
|
||||
}, -1, 80, 26);
|
||||
}
|
||||
|
||||
// Цена: чего хватает -- зелёным, чего нет -- красным, со сколько есть
|
||||
// Строка «Цена: …» обновляется, пока окно открыто: хватает ресурса -- зелёная цифра, нет -- красная и «есть N» (05.11)
|
||||
static void LiveCost(Transform card, ResourceAmount[] cost)
|
||||
{
|
||||
var text = UIKit.Text(card, "Цена: " + CostText(cost), 24, UIKit.Theme.Text, TextAlignmentOptions.Center);
|
||||
string shown = text.text;
|
||||
live.Add(() =>
|
||||
{
|
||||
if (text == null) return;
|
||||
string now = "Цена: " + CostText(cost);
|
||||
if (now != shown) { shown = now; text.text = now; }
|
||||
});
|
||||
}
|
||||
|
||||
static string CostText(ResourceAmount[] cost)
|
||||
{
|
||||
GameServices.TryGet(out IEconomy economy);
|
||||
@@ -511,6 +769,7 @@ public class BuildingWindowTicker : MonoBehaviour
|
||||
|
||||
void Update()
|
||||
{
|
||||
if (Input.GetKeyDown(KeyCode.Escape)) { BuildingWindow.Close(); return; } // "Назад" на телефоне
|
||||
if (Time.unscaledTime < next) return;
|
||||
next = Time.unscaledTime + 0.5f;
|
||||
BuildingWindow.Tick();
|
||||
|
||||
@@ -5,7 +5,8 @@ using UnityEngine;
|
||||
// Слой приложения: сообщает модулям, какие здания стоят в городе.
|
||||
// - рабочие места (Лес, Еда, Камень, Металл, Золото) и их уровни -> модуль добычи (места и эффективность);
|
||||
// - Дома (здание "Население") -> модуль Населения (предел жилья);
|
||||
// - Склады и Амбары -> лимиты склада в Экономике.
|
||||
// - Склады и Амбары -> лимиты склада в Экономике и защита ресурсов от грабежа (App/ResourceProtection).
|
||||
// Здания, которые ещё строятся (BuildTimer), не считаются: они пока не работают.
|
||||
// Модули сами здания не ищут: они без Unity и живут и на карте мира, где города нет.
|
||||
//
|
||||
// Проверяет город раз в полсекунды и применяет изменения, только когда картина устоялась
|
||||
@@ -16,6 +17,10 @@ public class CitySync : MonoBehaviour
|
||||
|
||||
public static CitySync Instance { get; private set; }
|
||||
|
||||
// Город открыт не в 2D-сцене, а в 3D (City3D): здания с PlacedBuildingId там тоже есть (05.11)
|
||||
public static bool ExternalCity;
|
||||
static bool CityOpen => BuildManager.Instance != null || ExternalCity;
|
||||
|
||||
float next;
|
||||
string pending; // замер, который ждёт подтверждения
|
||||
string applied; // последний применённый замер
|
||||
@@ -38,7 +43,7 @@ public class CitySync : MonoBehaviour
|
||||
{
|
||||
if (Time.unscaledTime < next) return;
|
||||
next = Time.unscaledTime + Interval;
|
||||
if (BuildManager.Instance == null) return; // город не открыт -- модули помнят последнее
|
||||
if (!CityOpen) return; // город не открыт -- модули помнят последнее
|
||||
|
||||
var city = Scan();
|
||||
if (city.Signature == applied) { pending = null; return; }
|
||||
@@ -49,7 +54,7 @@ public class CitySync : MonoBehaviour
|
||||
// Применить сразу (после улучшения здания в окне -- чтобы окно показало новые места)
|
||||
public void SyncNow()
|
||||
{
|
||||
if (BuildManager.Instance == null) return;
|
||||
if (!CityOpen) return;
|
||||
Apply(Scan());
|
||||
}
|
||||
|
||||
@@ -67,6 +72,7 @@ public class CitySync : MonoBehaviour
|
||||
foreach (var placed in FindObjectsByType<PlacedBuildingId>(FindObjectsSortMode.None))
|
||||
{
|
||||
if (string.IsNullOrEmpty(placed.buildingName)) continue; // убирается в этом кадре
|
||||
if (BuildTimer.IsConstructing(placed)) continue; // ещё строится -- не работает
|
||||
|
||||
var rc = placed.GetComponent<ResourceCollector>();
|
||||
if (rc != null)
|
||||
@@ -112,6 +118,7 @@ public class CitySync : MonoBehaviour
|
||||
ResourceType[] stored = { ResourceType.Wood, ResourceType.Stone, ResourceType.Iron, ResourceType.Gold, ResourceType.Food, ResourceType.Gems };
|
||||
foreach (var t in stored) rm.SetCapacity(t, config.GetCapacity(t, city.WarehouseLevels, city.GranaryLevels));
|
||||
}
|
||||
ResourceProtection.SetLevels(city.WarehouseLevels, city.GranaryLevels); // защита от грабежа
|
||||
|
||||
var production = GameModules.Production;
|
||||
var population = GameModules.Population;
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
// Стройка и офлайн (решения 04.11): стройка, которая закончилась, пока игра была закрыта, вступает в силу
|
||||
// в тот момент офлайн-расчёта, когда время дошло до её конца. Тогда добыча здания (и его новый уровень)
|
||||
// считается ровно с момента завершения, а не с начала офлайна и не с момента входа в игру.
|
||||
//
|
||||
// Как: модуль Населения на каждом шаге расчёта сообщает, до какого момента дошёл (GameTimeSimulatedEvent).
|
||||
// Здесь завершаются стройки с концом не позже этого момента, и город сразу пересчитывается (CitySync) --
|
||||
// следующий шаг добычи идёт уже с новым зданием. Пока расчёт не дошёл до конца стройки, BuildTimer сам её не
|
||||
// завершает (BuildTimer.MayComplete). Шаг расчёта -- 10 минут, поэтому добыча начинается не раньше конца
|
||||
// стройки и не позже чем через 10 минут после него: лишнего не начисляется никогда.
|
||||
public static class ConstructionClock
|
||||
{
|
||||
// До какого момента (мс) расчёт добычи уже дошёл. 0 -- ещё ни разу не считал (сразу после запуска)
|
||||
public static long SimulatedUntilMs { get; private set; }
|
||||
|
||||
static bool subscribed;
|
||||
|
||||
public static void Init()
|
||||
{
|
||||
SimulatedUntilMs = 0;
|
||||
BuildTimer.MayComplete = endMs => endMs <= SimulatedUntilMs;
|
||||
if (subscribed) return;
|
||||
subscribed = true;
|
||||
EventBus.Subscribe<GameTimeSimulatedEvent>(OnSimulated);
|
||||
}
|
||||
|
||||
static void OnSimulated(GameTimeSimulatedEvent e)
|
||||
{
|
||||
long ms = e.UnixNow * 1000;
|
||||
if (ms > SimulatedUntilMs) SimulatedUntilMs = ms; // время расчёта только растёт
|
||||
|
||||
bool any = false;
|
||||
foreach (var timer in BuildTimer.All.ToArray())
|
||||
{
|
||||
if (timer == null || timer.endMs > SimulatedUntilMs) continue;
|
||||
timer.Complete(); // уровень только растёт, второй раз не завершается
|
||||
any = true;
|
||||
}
|
||||
if (any && CitySync.Instance != null) CitySync.Instance.SyncNow();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 6104b5c531ff479eb569880ebd75d68c
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -17,6 +17,7 @@ public class DebugPanel : MonoBehaviour
|
||||
|
||||
GameObject panel;
|
||||
TextMeshProUGUI info;
|
||||
UnityEngine.UI.Button gridButton;
|
||||
float nextRefresh;
|
||||
|
||||
[RuntimeInitializeOnLoadMethod(RuntimeInitializeLoadType.AfterSceneLoad)]
|
||||
@@ -72,8 +73,9 @@ public class DebugPanel : MonoBehaviour
|
||||
Btn(row1, "+10 часов", UIKit.Theme.Blue, () => GameClock.Advance(36000));
|
||||
|
||||
var row2 = UIKit.Row(card, 70, 10);
|
||||
Btn(row2, "+100 всех ресурсов", UIKit.Theme.Green, AddAllResources);
|
||||
Btn(row2, "+1000 всех ресурсов", UIKit.Theme.Green, AddAllResources);
|
||||
Btn(row2, "+1000 еды", UIKit.Theme.Green, () => AddResource(ResourceType.Food, 1000));
|
||||
Btn(row2, "Кристаллы = макс", UIKit.Theme.Blue, FillGems);
|
||||
|
||||
var row3 = UIKit.Row(card, 70, 10);
|
||||
Btn(row3, "Еда = 0", UIKit.Theme.Red, ZeroFood);
|
||||
@@ -81,11 +83,132 @@ public class DebugPanel : MonoBehaviour
|
||||
|
||||
var row4 = UIKit.Row(card, 70, 10);
|
||||
Btn(row4, "Рабочие", UIKit.Theme.Blue, () => BuildingWindow.Open(null));
|
||||
Btn(row4, "Закрыть", UIKit.Theme.Gray, () => panel.SetActive(false));
|
||||
gridButton = UIKit.Button(row4, GridLabel, UIKit.Theme.Blue, ToggleGrid, -1, 70, 24);
|
||||
UIKit.Size(gridButton, -1, 70, 1);
|
||||
Btn(row4, "Камера", UIKit.Theme.Blue, ToggleCameraCard);
|
||||
Btn(row4, "Закрыть", UIKit.Theme.Gray, () => { panel.SetActive(false); if (cameraCard != null) cameraCard.SetActive(false); });
|
||||
|
||||
panel.SetActive(false);
|
||||
}
|
||||
|
||||
// ---------------- Камера 3D-поселения (решение 05.11: перспектива, числа подбираем в Unity и на телефоне) ----------------
|
||||
|
||||
GameObject cameraCard;
|
||||
TextMeshProUGUI cameraInfo;
|
||||
|
||||
// Ползунок: подпись, значение, диапазон. get/set -- поле камеры
|
||||
struct CamSlider { public string Label, Format; public float Min, Max; public System.Func<City3DCamera, float> Get; public System.Action<City3DCamera, float> Set; public string Pref; }
|
||||
|
||||
static readonly CamSlider[] CamSliders =
|
||||
{
|
||||
new CamSlider { Label = "Наклон", Format = "0°", Min = 25, Max = 85, Pref = City3DCamera.PrefPitch,
|
||||
Get = c => c.pitch, Set = (c, v) => c.pitch = v },
|
||||
new CamSlider { Label = "Перспектива", Format = "0°", Min = 15, Max = 75, Pref = City3DCamera.PrefFov,
|
||||
Get = c => c.fieldOfView, Set = (c, v) => c.SetFieldOfView(v) },
|
||||
new CamSlider { Label = "Ближе всего", Format = "0", Min = 3, Max = 30, Pref = City3DCamera.PrefMin,
|
||||
Get = c => c.minDistance, Set = (c, v) => { c.minDistance = Mathf.Min(v, c.maxDistance - 1); c.distance = Mathf.Max(c.distance, c.minDistance); } },
|
||||
new CamSlider { Label = "Дальше всего", Format = "0", Min = 20, Max = 120, Pref = City3DCamera.PrefMax,
|
||||
Get = c => c.maxDistance, Set = (c, v) => { c.maxDistance = Mathf.Max(v, c.minDistance + 1); c.distance = Mathf.Min(c.distance, c.maxDistance); } },
|
||||
new CamSlider { Label = "Сейчас", Format = "0", Min = 3, Max = 120, Pref = null,
|
||||
Get = c => c.distance, Set = (c, v) => c.distance = Mathf.Clamp(v, c.minDistance, c.maxDistance) },
|
||||
};
|
||||
|
||||
static City3DCamera Cam3D => Object.FindAnyObjectByType<City3DCamera>();
|
||||
|
||||
void ToggleCameraCard()
|
||||
{
|
||||
if (cameraCard != null) { bool show = !cameraCard.activeSelf; if (show) BuildCameraCard(); else cameraCard.SetActive(false); return; }
|
||||
BuildCameraCard();
|
||||
}
|
||||
|
||||
void BuildCameraCard()
|
||||
{
|
||||
if (cameraCard != null) Destroy(cameraCard);
|
||||
var cam = Cam3D;
|
||||
var safe = panel.transform.parent;
|
||||
var card = UIKit.Card(safe, new Color(0.08f, 0.08f, 0.11f, 0.95f), 20, 10);
|
||||
card.anchorMin = card.anchorMax = new Vector2(0, 0.5f);
|
||||
card.pivot = new Vector2(0, 0.5f);
|
||||
card.anchoredPosition = new Vector2(135 + 640, 0);
|
||||
card.sizeDelta = new Vector2(640, 0);
|
||||
card.gameObject.AddComponent<ContentSizeFitter>().verticalFit = ContentSizeFitter.FitMode.PreferredSize;
|
||||
cameraCard = card.gameObject;
|
||||
|
||||
var title = UIKit.Text(card, "КАМЕРА 3D-ПОСЕЛЕНИЯ (ТЕСТ)", 28, UIKit.Theme.Gold, TextAlignmentOptions.Center);
|
||||
title.fontStyle = FontStyles.Bold;
|
||||
if (cam == null)
|
||||
{
|
||||
UIKit.Text(card, "Откройте 3D-город (сцена City3D)", 24, UIKit.Theme.TextDim, TextAlignmentOptions.Center);
|
||||
}
|
||||
else
|
||||
{
|
||||
foreach (var def in CamSliders)
|
||||
{
|
||||
var d = def;
|
||||
var row = UIKit.Row(card, 60, 12);
|
||||
var label = UIKit.Text(row, d.Label, 24);
|
||||
UIKit.Size(label, 170);
|
||||
var value = UIKit.Text(row, d.Get(cam).ToString(d.Format), 24, UIKit.Theme.TextDim, TextAlignmentOptions.Right);
|
||||
UIKit.Slider(row, Mathf.InverseLerp(d.Min, d.Max, d.Get(cam)), t =>
|
||||
{
|
||||
var c = Cam3D;
|
||||
if (c == null) return;
|
||||
d.Set(c, Mathf.Lerp(d.Min, d.Max, t));
|
||||
value.text = d.Get(c).ToString(d.Format);
|
||||
if (d.Pref != null) PlayerPrefs.SetFloat(d.Pref, d.Get(c)); // запомнить на этом устройстве
|
||||
c.Apply();
|
||||
UpdateCameraInfo();
|
||||
});
|
||||
value.transform.SetAsLastSibling();
|
||||
UIKit.Size(value, 80);
|
||||
}
|
||||
}
|
||||
cameraInfo = UIKit.Text(card, "", 22, UIKit.Theme.TextDim, TextAlignmentOptions.Center);
|
||||
UpdateCameraInfo();
|
||||
|
||||
var row2 = UIKit.Row(card, 64, 10);
|
||||
Btn(row2, "Сбросить", UIKit.Theme.Gray, ResetCamera);
|
||||
Btn(row2, "Закрыть", UIKit.Theme.Gray, () => cameraCard.SetActive(false));
|
||||
}
|
||||
|
||||
// Строка, которую удобно переписать в отчёт / в сцену
|
||||
void UpdateCameraInfo()
|
||||
{
|
||||
var c = Cam3D;
|
||||
if (cameraInfo == null) return;
|
||||
cameraInfo.text = c == null ? "" :
|
||||
$"pitch {c.pitch:0} • fieldOfView {c.fieldOfView:0} • minDistance {c.minDistance:0} • maxDistance {c.maxDistance:0} • distance {c.distance:0}\n" +
|
||||
"Подобранное запоминается на этом устройстве. В сцену (для всех) -- вписать числа в Main Camera → City3DCamera";
|
||||
}
|
||||
|
||||
void ResetCamera()
|
||||
{
|
||||
foreach (var d in CamSliders) if (d.Pref != null) PlayerPrefs.DeleteKey(d.Pref);
|
||||
var c = Cam3D;
|
||||
if (c != null)
|
||||
{
|
||||
c.pitch = c.DefaultPitch;
|
||||
c.SetFieldOfView(c.DefaultFov);
|
||||
c.minDistance = c.DefaultMin;
|
||||
c.maxDistance = c.DefaultMax;
|
||||
c.distance = Mathf.Clamp(c.distance, c.minDistance, c.maxDistance);
|
||||
c.Apply();
|
||||
}
|
||||
BuildCameraCard();
|
||||
}
|
||||
|
||||
// Отладочная сетка стройки (3D-поселение и 2D-город): видна всегда, ярче, каждая 5-я линия жёлтая (05.11, просьба Миши)
|
||||
void ToggleGrid()
|
||||
{
|
||||
bool on = !(City3DController.DebugGrid || BuildGrid.DebugVisible);
|
||||
City3DController.SetDebugGrid(on);
|
||||
bool building = BuildModeController.Instance != null && BuildModeController.Instance.IsActive;
|
||||
BuildGrid.SetDebugVisible(on, building);
|
||||
if (gridButton != null) UIKit.SetButton(gridButton, GridLabel, true);
|
||||
}
|
||||
|
||||
static string GridLabel => City3DController.DebugGrid || BuildGrid.DebugVisible ? "Сетка: вкл" : "Сетка: выкл";
|
||||
|
||||
static void Btn(Transform row, string label, Color color, System.Action onClick)
|
||||
{
|
||||
var b = UIKit.Button(row, label, color, onClick, -1, 70, 24);
|
||||
@@ -142,7 +265,18 @@ public class DebugPanel : MonoBehaviour
|
||||
void AddAllResources()
|
||||
{
|
||||
ResourceType[] types = { ResourceType.Wood, ResourceType.Food, ResourceType.Stone, ResourceType.Iron, ResourceType.Gold, ResourceType.Gems };
|
||||
foreach (var t in types) AddResource(t, 100);
|
||||
foreach (var t in types) AddResource(t, 1000); // лишнее обрежет лимит склада
|
||||
}
|
||||
|
||||
// Кристаллы до предела склада (лимит 50 -- решение 19.10, предел не меняется)
|
||||
void FillGems()
|
||||
{
|
||||
var rm = ResourceManager.Instance;
|
||||
if (rm == null) { UIKit.Toast("Нет склада (ResourceManager)"); return; }
|
||||
float cap = rm.GetCapacity(ResourceType.Gems);
|
||||
if (float.IsInfinity(cap)) cap = EconomyConfig.Load().storage.crystalCap;
|
||||
rm.SetResource(ResourceType.Gems, cap);
|
||||
Refresh();
|
||||
}
|
||||
|
||||
void AddResource(ResourceType type, float amount)
|
||||
|
||||
@@ -126,6 +126,48 @@ public class EconomyConfig : ScriptableObject
|
||||
public int[] requiredBarracksLevel = { 1, 2, 3, 5 };
|
||||
}
|
||||
|
||||
[Serializable]
|
||||
public class ConstructionSettings // время стройки и улучшения, строители (01.11, ТЕСТ)
|
||||
{
|
||||
[Tooltip("Сколько строек и улучшений идёт одновременно (строители). ТЕСТ")]
|
||||
public int builders = 2;
|
||||
[Tooltip("Постройка нового здания, секунд. ТЕСТ")]
|
||||
public int buildSeconds = 60;
|
||||
[Tooltip("Улучшение до уровня 2, 3, 4... (секунд): [0] -- до 2 ур., [1] -- до 3 ур. ... ТЕСТ")]
|
||||
public int[] upgradeSecondsToLevel = { 60, 120, 300, 600, 1200, 1800, 3600, 5400, 7200 };
|
||||
[Tooltip("Ратуша строится дольше остальных: множитель времени улучшения. ТЕСТ")]
|
||||
public float townHallTimeMultiplier = 2f;
|
||||
[Tooltip("Ускорение: секунд стройки за 1 кристалл. ТЕСТ")]
|
||||
public int secondsPerGem = 30;
|
||||
// Сноса зданий нет (решение 04.11): только бесплатный перенос
|
||||
}
|
||||
|
||||
[Serializable]
|
||||
public class BuildingLimit
|
||||
{
|
||||
public string buildingName;
|
||||
[Tooltip("Сколько таких зданий можно иметь на Ратуше 1, 2, 3 ... уровня. 0 -- нельзя")]
|
||||
public int[] maxByTownHall;
|
||||
|
||||
public BuildingLimit() { }
|
||||
public BuildingLimit(string name, params int[] max) { buildingName = name; maxByTownHall = max; }
|
||||
}
|
||||
|
||||
[Serializable]
|
||||
public class ProtectionSettings // защита ресурсов от грабежа (01.11, ТЕСТ)
|
||||
{
|
||||
[Tooltip("Защищено без Складов: дерево, камень, металл, золото. ТЕСТ")]
|
||||
public float baseGoods = 100f;
|
||||
[Tooltip("+ к защите дерева, камня, металла, золота за каждый уровень Склада. ТЕСТ")]
|
||||
public float perWarehouseLevel = 200f;
|
||||
[Tooltip("Защищено без Амбаров: еда. ТЕСТ")]
|
||||
public float baseFood = 100f;
|
||||
[Tooltip("+ к защите еды за каждый уровень Амбара. ТЕСТ")]
|
||||
public float perGranaryLevel = 300f;
|
||||
[Tooltip("Кристаллы: без технологии «Грабёж» нападающий не уносит ничего (решение 04.11). С ней -- не больше этой доли запаса. ТЕСТ, точные ограничения определят отдельно")]
|
||||
[Range(0f, 1f)] public float plunderGemsShare = 0.1f;
|
||||
}
|
||||
|
||||
[Serializable]
|
||||
public class StartResources
|
||||
{
|
||||
@@ -159,6 +201,30 @@ public class EconomyConfig : ScriptableObject
|
||||
[Header("Солдат T1 (этап 5)")]
|
||||
public SoldierSettings soldier = new SoldierSettings();
|
||||
|
||||
[Header("Стройка: время, строители, снос (ТЕСТ)")]
|
||||
public ConstructionSettings construction = new ConstructionSettings();
|
||||
|
||||
[Header("Лимит зданий по уровню Ратуши (ТЕСТ). Здания не из списка -- без лимита")]
|
||||
public BuildingLimit[] buildingLimits =
|
||||
{
|
||||
// Ратуша: 1 2 3 4 5 6 7 8 9 10
|
||||
new BuildingLimit("Лес", 2, 3, 3, 4, 4, 5, 5, 6, 6, 7),
|
||||
new BuildingLimit("Еда", 2, 3, 3, 4, 4, 5, 5, 6, 6, 7),
|
||||
new BuildingLimit("Камень", 2, 2, 3, 3, 4, 4, 5, 5, 6, 6),
|
||||
new BuildingLimit("Металл", 0, 0, 1, 1, 2, 2, 3, 3, 4, 4),
|
||||
new BuildingLimit("Золото", 0, 1, 1, 2, 2, 3, 3, 3, 4, 4),
|
||||
new BuildingLimit("Население", 3, 4, 5, 6, 7, 8, 9, 10, 11, 12),
|
||||
new BuildingLimit("Склад", 1, 1, 2, 2, 2, 3, 3, 3, 4, 4),
|
||||
new BuildingLimit("Амбар", 1, 1, 2, 2, 2, 3, 3, 3, 4, 4),
|
||||
new BuildingLimit("Казармы", 1, 1, 2, 2, 3, 3, 3, 3, 3, 3),
|
||||
new BuildingLimit("Госпиталь", 1, 1, 1, 1, 1, 1, 1, 1, 1, 1),
|
||||
new BuildingLimit("Терем богатыря", 1, 1, 1, 1, 1, 1, 1, 1, 1, 1),
|
||||
new BuildingLimit("Кристалл", 0, 0, 0, 0, 0, 1, 1, 1, 1, 1),
|
||||
};
|
||||
|
||||
[Header("Защита ресурсов от грабежа (ТЕСТ)")]
|
||||
public ProtectionSettings protection = new ProtectionSettings();
|
||||
|
||||
// ---------------- Загрузка ----------------
|
||||
|
||||
static EconomyConfig cached;
|
||||
@@ -192,6 +258,49 @@ public class EconomyConfig : ScriptableObject
|
||||
}
|
||||
}
|
||||
|
||||
// Сколько зданий с таким именем можно иметь на этом уровне Ратуши. -1 -- без лимита (здания нет в списке)
|
||||
public int BuildingLimitFor(string buildingName, int townHallLevel)
|
||||
{
|
||||
if (buildingLimits == null || string.IsNullOrEmpty(buildingName)) return -1;
|
||||
foreach (var l in buildingLimits)
|
||||
{
|
||||
if (l == null || l.buildingName != buildingName || l.maxByTownHall == null || l.maxByTownHall.Length == 0) continue;
|
||||
return Mathf.Max(0, l.maxByTownHall[Mathf.Clamp(townHallLevel, 1, l.maxByTownHall.Length) - 1]);
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
// Время стройки (targetLevel = 1) или улучшения до targetLevel, мс. Ратуша -- с множителем
|
||||
public long ConstructionMs(bool townHall, int targetLevel)
|
||||
{
|
||||
var c = construction;
|
||||
double seconds;
|
||||
if (targetLevel <= 1) seconds = c.buildSeconds;
|
||||
else
|
||||
{
|
||||
var t = c.upgradeSecondsToLevel;
|
||||
seconds = t == null || t.Length == 0 ? c.buildSeconds : t[Mathf.Clamp(targetLevel - 2, 0, t.Length - 1)];
|
||||
}
|
||||
if (townHall) seconds *= Mathf.Max(0.01f, c.townHallTimeMultiplier);
|
||||
return (long)(Math.Max(0, seconds) * 1000.0);
|
||||
}
|
||||
|
||||
// Сколько ресурса не отнять при грабеже: часть запаса, которую прячут Склады и Амбары
|
||||
public float GetProtected(ResourceType type, int warehouseLevels, int granaryLevels)
|
||||
{
|
||||
var p = protection;
|
||||
switch (type)
|
||||
{
|
||||
case ResourceType.Wood:
|
||||
case ResourceType.Stone:
|
||||
case ResourceType.Iron:
|
||||
case ResourceType.Gold: return p.baseGoods + p.perWarehouseLevel * warehouseLevels;
|
||||
case ResourceType.Food: return p.baseFood + p.perGranaryLevel * granaryLevels;
|
||||
case ResourceType.Gems: return float.PositiveInfinity; // без «Грабежа» кристаллы не грабятся (с ним -- ResourceProtection)
|
||||
default: return 0f;
|
||||
}
|
||||
}
|
||||
|
||||
// Цена улучшения здания с уровня level на level + 1
|
||||
public ResourceAmount[] UpgradeCost(int level)
|
||||
{
|
||||
|
||||
@@ -92,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()); // воины -- тоже люди
|
||||
@@ -115,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);
|
||||
@@ -124,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()
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
using UnityEngine;
|
||||
|
||||
// Версия игры на экране (05.11, требование Миши: в каждом билде видно, что это за версия).
|
||||
// В углу экрана: версия из Player Settings (Application.version) и сборка -- ветка, коммит, дата.
|
||||
// Сборку пишет Editor/BuildInfoWriter перед билдом в Resources/BuildInfo.txt; в редакторе -- "редактор".
|
||||
// Создаётся сам при запуске, живёт между сценами.
|
||||
public class GameVersionLabel : MonoBehaviour
|
||||
{
|
||||
static GameVersionLabel instance;
|
||||
string text;
|
||||
GUIStyle style, shadow;
|
||||
|
||||
public static string BuildInfo
|
||||
{
|
||||
get
|
||||
{
|
||||
var asset = Resources.Load<TextAsset>("BuildInfo");
|
||||
return asset != null ? asset.text.Trim() : "редактор";
|
||||
}
|
||||
}
|
||||
|
||||
public static string FullVersion => $"v{Application.version} • {BuildInfo}";
|
||||
|
||||
[RuntimeInitializeOnLoadMethod(RuntimeInitializeLoadType.AfterSceneLoad)]
|
||||
static void Create()
|
||||
{
|
||||
if (instance != null) return;
|
||||
var go = new GameObject("GameVersionLabel");
|
||||
DontDestroyOnLoad(go);
|
||||
instance = go.AddComponent<GameVersionLabel>();
|
||||
instance.text = FullVersion;
|
||||
Debug.Log("[ВЕРСИЯ] " + instance.text);
|
||||
}
|
||||
|
||||
void OnGUI()
|
||||
{
|
||||
if (style == null)
|
||||
{
|
||||
int size = Mathf.Max(12, Screen.height / 60);
|
||||
style = new GUIStyle(GUI.skin.label) { fontSize = size, alignment = TextAnchor.LowerLeft };
|
||||
style.normal.textColor = new Color(1f, 1f, 1f, 0.75f);
|
||||
shadow = new GUIStyle(style);
|
||||
shadow.normal.textColor = new Color(0f, 0f, 0f, 0.6f);
|
||||
}
|
||||
GUI.depth = -1000; // поверх всего
|
||||
var r = new Rect(8, Screen.height - style.fontSize * 2 - 4, Screen.width * 0.6f, style.fontSize * 2);
|
||||
GUI.Label(new Rect(r.x + 1, r.y + 1, r.width, r.height), text, shadow);
|
||||
GUI.Label(r, text, style);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 4dae86c365fe55948b878547633d712a
|
||||
@@ -0,0 +1,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,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
|
||||
@@ -20,14 +20,52 @@ public class City3DCamera : MonoBehaviour
|
||||
bool focusing;
|
||||
Vector3 lastPointer;
|
||||
bool panning;
|
||||
float lastPinch;
|
||||
|
||||
// Перемещение камеры пальцем можно выключить на время перетаскивания здания
|
||||
public bool AllowPan { get; set; } = true;
|
||||
|
||||
// Камера перспективная (решение 05.11): поселение объёмнее, есть глубина. Угол, высота и поле зрения -- ТЕСТ,
|
||||
// подбираются в Unity и на телефоне. Ортографическую можно вернуть галочкой (тогда зум -- размер кадра, см. Apply)
|
||||
public bool perspective = true;
|
||||
public float fieldOfView = 40f;
|
||||
|
||||
// Подбор камеры в панели DEV (окно «Камера»): наклон, поле зрения, ближе / дальше всего. Запоминается на этом
|
||||
// устройстве -- только в редакторе и отладочной сборке; в обычной сборке -- числа из сцены (05.11)
|
||||
public const string PrefPitch = "dev_cam3d_pitch", PrefFov = "dev_cam3d_fov", PrefMin = "dev_cam3d_min", PrefMax = "dev_cam3d_max";
|
||||
|
||||
// Числа по умолчанию (из сцены) -- для кнопки «Сбросить» в DEV
|
||||
public float DefaultPitch { get; private set; }
|
||||
public float DefaultFov { get; private set; }
|
||||
public float DefaultMin { get; private set; }
|
||||
public float DefaultMax { get; private set; }
|
||||
|
||||
public void SetFieldOfView(float fov)
|
||||
{
|
||||
fieldOfView = fov;
|
||||
if (cam != null && !cam.orthographic) cam.fieldOfView = fov;
|
||||
}
|
||||
|
||||
void LoadDevTuning()
|
||||
{
|
||||
DefaultPitch = pitch; DefaultFov = fieldOfView; DefaultMin = minDistance; DefaultMax = maxDistance;
|
||||
#if UNITY_EDITOR || DEVELOPMENT_BUILD
|
||||
pitch = PlayerPrefs.GetFloat(PrefPitch, pitch);
|
||||
fieldOfView = PlayerPrefs.GetFloat(PrefFov, fieldOfView);
|
||||
minDistance = PlayerPrefs.GetFloat(PrefMin, minDistance);
|
||||
maxDistance = Mathf.Max(minDistance + 1f, PlayerPrefs.GetFloat(PrefMax, maxDistance));
|
||||
distance = Mathf.Clamp(distance, minDistance, maxDistance);
|
||||
#endif
|
||||
}
|
||||
|
||||
void Awake()
|
||||
{
|
||||
cam = GetComponent<Camera>();
|
||||
LoadDevTuning();
|
||||
if (cam != null && perspective)
|
||||
{
|
||||
cam.orthographic = false;
|
||||
cam.fieldOfView = fieldOfView;
|
||||
}
|
||||
}
|
||||
|
||||
public void FocusOn(Rect area)
|
||||
@@ -43,6 +81,10 @@ public class City3DCamera : MonoBehaviour
|
||||
public void BeginPan(Vector3 screen) { panning = AllowPan; lastPointer = screen; focusing = false; }
|
||||
public void EndPan() { panning = false; }
|
||||
|
||||
// Палец ещё не сдвинулся настолько, чтобы считать это перетаскиванием: камера стоит, но точка запоминается --
|
||||
// когда перетаскивание начнётся, карта не прыгнет (как на карте мира, 05.11)
|
||||
public void Track(Vector3 screen) { lastPointer = screen; }
|
||||
|
||||
public void Pan(Vector3 screen)
|
||||
{
|
||||
if (!panning || cam == null) return;
|
||||
@@ -50,6 +92,7 @@ public class City3DCamera : MonoBehaviour
|
||||
target += a - b;
|
||||
lastPointer = screen;
|
||||
Clamp();
|
||||
Apply(); // сразу: иначе следующий шаг считался бы от старого положения камеры -- камера дёргалась (05.11)
|
||||
}
|
||||
|
||||
public void Zoom(float delta)
|
||||
@@ -62,16 +105,22 @@ public class City3DCamera : MonoBehaviour
|
||||
{
|
||||
// Колесо мыши (редактор, ПК)
|
||||
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);
|
||||
|
||||
// Два пальца -- приближение
|
||||
if (Input.touchCount == 2)
|
||||
// Два пальца -- приближение, как на карте мира: расстояние камеры меняется во столько раз,
|
||||
// во сколько изменилось расстояние между пальцами за кадр (не зависит от того, увидели ли касание) (05.11)
|
||||
if (Input.touchCount >= 2)
|
||||
{
|
||||
float d = Vector2.Distance(Input.GetTouch(0).position, Input.GetTouch(1).position);
|
||||
if (Input.GetTouch(1).phase == TouchPhase.Began) lastPinch = d;
|
||||
else if (lastPinch > 0) { Zoom((d - lastPinch) / Mathf.Max(200f, Screen.height)); lastPinch = d; }
|
||||
Touch a = Input.GetTouch(0), b = Input.GetTouch(1);
|
||||
float previous = ((a.position - a.deltaPosition) - (b.position - b.deltaPosition)).magnitude;
|
||||
float current = (a.position - b.position).magnitude;
|
||||
if (previous > 0.01f && current > 0.01f)
|
||||
{
|
||||
focusing = false;
|
||||
distance = Mathf.Clamp(distance * previous / current, minDistance, maxDistance);
|
||||
}
|
||||
}
|
||||
else lastPinch = 0;
|
||||
|
||||
if (focusing)
|
||||
{
|
||||
@@ -89,11 +138,26 @@ public class City3DCamera : MonoBehaviour
|
||||
target.y = 0;
|
||||
}
|
||||
|
||||
// Дымка начинается за городом, а не на фиксированном расстоянии от камеры: иначе при отдалении весь город
|
||||
// уходил в серую дымку -- казалось, что камера не отдаляется, а сцена сереет (05.11)
|
||||
public float fogStartBehind = 4f, fogEndBehind = 59f;
|
||||
|
||||
public void Apply()
|
||||
{
|
||||
Quaternion rot = Quaternion.Euler(pitch, yaw, 0);
|
||||
transform.rotation = rot;
|
||||
transform.position = target - rot * Vector3.forward * distance;
|
||||
// Камера сцены может быть ортографической (вид 2.5D без перспективы): у неё расстояние не меняет размер города
|
||||
// на экране -- «+ / −» и щипок ничего не делали (05.11). Тогда приближение -- через размер кадра: столько же
|
||||
// земли, сколько видно перспективной камерой с этого расстояния
|
||||
if (cam == null) cam = GetComponent<Camera>();
|
||||
if (cam != null && cam.orthographic)
|
||||
cam.orthographicSize = distance * Mathf.Tan(cam.fieldOfView * 0.5f * Mathf.Deg2Rad);
|
||||
if (RenderSettings.fog && RenderSettings.fogMode == FogMode.Linear)
|
||||
{
|
||||
RenderSettings.fogStartDistance = distance + fogStartBehind;
|
||||
RenderSettings.fogEndDistance = distance + fogEndBehind;
|
||||
}
|
||||
}
|
||||
|
||||
// Точка на плоскости земли (y = 0) под экранной точкой
|
||||
|
||||
@@ -16,9 +16,17 @@ using UnityEngine;
|
||||
// - зоны по уровню Ратуши (кнопки «Ратуша -/+» -- ТОЛЬКО ДЛЯ ТЕСТА), камера плавно показывает новую зону.
|
||||
// Правила места -- в модуле Поселения (Bogatyri.Settlement.Placement, тесты PlacementTests), здесь только показ и ввод.
|
||||
//
|
||||
// Общий HUD (05.11, Валдемар): в сцене стоит префаб HUD_Canvas -- тот же, что в 2D-городе. Нажатия по HUD
|
||||
// в сцену не проходят; кнопка "СТРОЙКА" HUD открывает список зданий (BuildModeController.ExternalToggle);
|
||||
// временные кнопки этой сцены сдвинуты, чтобы не налезать на HUD.
|
||||
//
|
||||
// Здания игры (05.11, Валдемар, City3DGameplay): у каждого 3D-здания те же компоненты, что у 2D-префабов --
|
||||
// тап открывает окно здания (рабочие, добыча, улучшение по таймеру), стройка -- с ценой, лимитом, строителями и
|
||||
// таймером (BuildTimer), Ратуша -- настоящая (TownHall, её уровень открывает зоны). Город сохраняется (City3DSave).
|
||||
//
|
||||
// Пока НЕ подключено (этапы II-III): цена и таймер стройки, Экономика и награда за расчистку, сохранение,
|
||||
// дороги, настоящие модели. Кнопки -- временные (IMGUI); окна согласуются с Володей.
|
||||
public class City3DController : MonoBehaviour
|
||||
public class City3DController : MonoBehaviour, City3DSave.ILive
|
||||
{
|
||||
// Тип здания для прототипа: максимальный габарит (клеток) с первого уровня, высота куба, цвет
|
||||
struct BuildingType
|
||||
@@ -41,6 +49,10 @@ public class City3DController : MonoBehaviour
|
||||
new BuildingType { Name = "Амбар", W = 2, H = 3, Height = 1.8f, Color = new Color(0.8f, 0.65f, 0.3f) },
|
||||
new BuildingType { Name = "Казармы", W = 3, H = 3, Height = 2.0f, Color = new Color(0.75f, 0.3f, 0.3f) },
|
||||
new BuildingType { Name = "Госпиталь", W = 3, H = 3, Height = 1.8f, Color = new Color(0.85f, 0.95f, 1f) },
|
||||
// Из 2D-города (решение 05.11): золото, кристаллы, дом героя. Новые типы -- только в конец: сохранение хранит имя
|
||||
new BuildingType { Name = "Золотой рудник", W = 2, H = 2, Height = 1.5f, Color = new Color(0.95f, 0.8f, 0.3f) },
|
||||
new BuildingType { Name = "Кристалл", W = 2, H = 2, Height = 1.2f, Color = new Color(0.5f, 0.8f, 0.95f) },
|
||||
new BuildingType { Name = "Терем", W = 3, H = 3, Height = 2.4f, Color = new Color(0.9f, 0.75f, 0.45f) },
|
||||
};
|
||||
|
||||
enum Mode { View, Edit, Place, Move }
|
||||
@@ -79,8 +91,259 @@ public class City3DController : MonoBehaviour
|
||||
|
||||
// ---------------- Построение сцены ----------------
|
||||
|
||||
bool buildMenuOpen; // список "что построить" открывается кнопкой "СТРОЙКА" общего HUD
|
||||
|
||||
// HUD закрывает верх (ресурсы) и низ (кнопки) экрана -- временные кнопки сцены ставим между ними (в единицах 720)
|
||||
const float HudTop = 150f, HudBottom = 150f, HudLeft = 160f;
|
||||
|
||||
void ToggleBuildMenu()
|
||||
{
|
||||
if (mode == Mode.Place || mode == Mode.Move) { CancelGhost(); buildMenuOpen = false; return; }
|
||||
buildMenuOpen = !buildMenuOpen;
|
||||
if (UIPanelManager.Instance != null) UIPanelManager.Instance.CloseAll();
|
||||
}
|
||||
|
||||
// Отладочная сетка (05.11, просьба Миши): видна всегда, ярче, каждая 5-я линия жёлтая -- примерять референсы.
|
||||
// Включается кнопкой "Сетка" в панели DEV.
|
||||
public static bool DebugGrid { get; private set; }
|
||||
|
||||
public static void SetDebugGrid(bool on)
|
||||
{
|
||||
DebugGrid = on;
|
||||
var city = FindAnyObjectByType<City3DController>();
|
||||
if (city != null && city.map != null) city.RefreshGround();
|
||||
}
|
||||
|
||||
bool stateApplied; // сохранённый город уже поставлен (в первом кадре, после загрузки сохранения в Start)
|
||||
|
||||
void OnEnable()
|
||||
{
|
||||
BuildModeController.ExternalToggle = ToggleBuildMenu;
|
||||
BuildModeController.ExternalMove = MoveFromWindow;
|
||||
CitySync.ExternalCity = true;
|
||||
City3DSave.Live = this;
|
||||
EventBus.Subscribe<TownHallLevelChangedEvent>(OnTownHallLevel);
|
||||
}
|
||||
|
||||
void OnDisable()
|
||||
{
|
||||
if (BuildModeController.ExternalToggle == (System.Action)ToggleBuildMenu) BuildModeController.ExternalToggle = null;
|
||||
if (BuildModeController.ExternalMove == (System.Action<GameObject>)MoveFromWindow) BuildModeController.ExternalMove = null;
|
||||
CitySync.ExternalCity = false;
|
||||
// Сохраняемся до ухода из сцены (SceneNavigator, выход из игры) -- здесь здания могут быть уже убраны
|
||||
if (City3DSave.Live == (City3DSave.ILive)this) City3DSave.Live = null;
|
||||
EventBus.Unsubscribe<TownHallLevelChangedEvent>(OnTownHallLevel);
|
||||
}
|
||||
|
||||
// ---------------- Сохранение (City3DSave) ----------------
|
||||
|
||||
public City3DSave.Data CaptureState()
|
||||
{
|
||||
// Сохранённый город ещё не поставлен (сохранение в первом кадре -- например, поход вернулся офлайн):
|
||||
// отдаём то, что было загружено, иначе пустая сцена затёрла бы весь 3D-город
|
||||
if (!stateApplied || map == null) return City3DSave.Cached;
|
||||
var d = new City3DSave.Data { nextId = nextBuildingId };
|
||||
foreach (var b in map.Buildings)
|
||||
{
|
||||
if (!buildingViews.TryGetValue(b.Id, out var view) || view == null) continue;
|
||||
var e = new City3DSave.Entry { id = b.Id, type = b.Type, x = b.X, y = b.Y, rot = b.Rotation, level = City3DGameplay.LevelOf(view) };
|
||||
City3DSave.WriteTimer(e, view);
|
||||
d.buildings.Add(e);
|
||||
}
|
||||
d.cleared.AddRange(map.Cleared);
|
||||
d.pendingFrom2D.AddRange(pendingFrom2D);
|
||||
return d;
|
||||
}
|
||||
|
||||
// Загрузили другое сохранение или сбросили -- город уже стоит, перестраиваем сцену целиком
|
||||
public void OnSaveLoaded()
|
||||
{
|
||||
if (stateApplied) UnityEngine.SceneManagement.SceneManager.LoadScene(gameObject.scene.name);
|
||||
}
|
||||
|
||||
void ApplySaved()
|
||||
{
|
||||
stateApplied = true;
|
||||
var d = City3DSave.Cached;
|
||||
int c = City3DLayout.Size / 2;
|
||||
bool hasTownHall = false;
|
||||
if (d != null)
|
||||
{
|
||||
nextBuildingId = Mathf.Max(1, d.nextId);
|
||||
pendingFrom2D.Clear();
|
||||
if (d.pendingFrom2D != null) pendingFrom2D.AddRange(d.pendingFrom2D);
|
||||
map.RestoreCleared(d.cleared);
|
||||
foreach (var id in d.cleared)
|
||||
if (obstacleViews.TryGetValue(id, out var ov)) { Destroy(ov); obstacleViews.Remove(id); }
|
||||
// Ратуша первой: её уровень открывает зоны
|
||||
var list = new List<City3DSave.Entry>(d.buildings);
|
||||
list.Sort((a, b) => (b.type == "Ратуша").CompareTo(a.type == "Ратуша"));
|
||||
foreach (var e in list)
|
||||
{
|
||||
int type = System.Array.FindIndex(Types, t => t.Name == e.type);
|
||||
if (type < 0) continue;
|
||||
var t = Types[type];
|
||||
var rect = new PlacedRect { Id = e.id, Type = t.Name, X = e.x, Y = e.y, SizeW = t.W, SizeH = t.H, Rotation = e.rot };
|
||||
if (!map.Restore(rect)) continue;
|
||||
buildingType[e.id] = type;
|
||||
buildingViews[e.id] = CreateBuildingView(e.id, type);
|
||||
City3DGameplay.Attach(buildingViews[e.id], t.Name, e.level);
|
||||
City3DSave.ReadTimer(e, buildingViews[e.id]);
|
||||
UpdateBuildingView(e.id);
|
||||
if (type == 0) { hasTownHall = true; townHallLevel = Mathf.Clamp(e.level, 1, 5); }
|
||||
}
|
||||
}
|
||||
if (!hasTownHall)
|
||||
{
|
||||
PlaceNew(0, c - 2, c - 2, 0); // новый город: Ратуша в центре
|
||||
if (d == null) MoveFrom2D(); // 3D-города в сохранении ещё нет, а 2D есть -- перенести постройки игрока
|
||||
}
|
||||
else if (pendingFrom2D.Count > 0) PlacePendingFrom2D(); // здания из 2D, которые ждали места
|
||||
UpdateGrass();
|
||||
RefreshGround();
|
||||
if (CitySync.Instance != null) CitySync.Instance.SyncNow();
|
||||
}
|
||||
|
||||
// ---------------- Перенос 2D-города (решение 05.11: 2D-город больше не развиваем) ----------------
|
||||
// Один раз, когда в сохранении нет 3D-города: Ратуша получает уровень (и улучшение, которое идёт) из 2D,
|
||||
// остальные здания встают на ближайшие свободные места от центра с теми же уровнем и стройкой.
|
||||
// 2D-данные в файле не меняются. Здание, которому не нашлось места или типа, пишется в Console
|
||||
void MoveFrom2D()
|
||||
{
|
||||
var old = SettlementModule.Saved2D;
|
||||
if (old == null || (old.townHallLevel <= 1 && (old.placedBuildings == null || old.placedBuildings.Count == 0))) return;
|
||||
|
||||
string hallId = null;
|
||||
foreach (var pair in buildingType) if (pair.Value == 0) hallId = pair.Key;
|
||||
var th = hallId != null ? buildingViews[hallId].GetComponent<TownHall>() : null;
|
||||
if (th != null)
|
||||
{
|
||||
th.currentLevel = Mathf.Clamp(old.townHallLevel, 1, th.maxLevel);
|
||||
ApplyTownHallLevel(th.currentLevel); // зоны -- до расстановки, чтобы здания поместились
|
||||
if (old.townHallTimerEndMs > 0 && old.townHallTimerTargetLevel > th.currentLevel)
|
||||
BuildTimer.Begin(th.gameObject, BuildTimer.Kind.Upgrade, old.townHallTimerTargetLevel, old.townHallTimerStartMs, old.townHallTimerEndMs);
|
||||
}
|
||||
|
||||
int unknown = 0;
|
||||
pendingFrom2D.Clear();
|
||||
foreach (var e in old.placedBuildings ?? new List<SettlementModule.PlacedBuildingSaveEntry>())
|
||||
{
|
||||
string type3d = City3DGameplay.Type3DFor(e.buildingName);
|
||||
if (type3d == null || System.Array.FindIndex(Types, t => t.Name == type3d) <= 0)
|
||||
{
|
||||
Debug.LogWarning($"[3D] Перенос 2D-города: здания «{e.buildingName}» в 3D нет -- пропущено");
|
||||
unknown++;
|
||||
continue;
|
||||
}
|
||||
pendingFrom2D.Add(new City3DSave.Entry
|
||||
{
|
||||
type = type3d, level = Mathf.Max(1, e.level), timerKind = e.timerKind,
|
||||
timerStartMs = e.timerStartMs, timerEndMs = e.timerEndMs, timerTargetLevel = e.timerTargetLevel,
|
||||
});
|
||||
}
|
||||
int total = pendingFrom2D.Count;
|
||||
int placed = PlacePendingFrom2D(false);
|
||||
Debug.Log($"[3D] Перенос 2D-города: Ратуша {old.townHallLevel} ур., зданий {total}: поставлено {placed}, ждут места {pendingFrom2D.Count}" +
|
||||
(unknown > 0 ? $", нет в 3D {unknown}" : ""));
|
||||
if (total > 0)
|
||||
Say(pendingFrom2D.Count == 0 ? $"Город перенесён в 3D: {placed} зданий"
|
||||
: $"Город перенесён в 3D: {placed} зданий, {pendingFrom2D.Count} ждут места -- встанут, когда оно появится");
|
||||
}
|
||||
|
||||
// Здания из 2D, которые ещё не стоят: ближайшее к центру место, сначала свободное, потом -- с расчисткой деревьев
|
||||
// и валунов (бесплатно, без награды: это не стройка игрока). Нет места -- ждут (сохраняются в settlement3d).
|
||||
// Зовётся при переносе, при входе в город и когда Ратуша открыла зону. Возвращает, сколько поставлено
|
||||
int PlacePendingFrom2D(bool announce = true)
|
||||
{
|
||||
int placed = 0, c = City3DLayout.Size / 2;
|
||||
for (int i = 0; i < pendingFrom2D.Count; i++)
|
||||
{
|
||||
var e = pendingFrom2D[i];
|
||||
int type = System.Array.FindIndex(Types, t => t.Name == e.type);
|
||||
if (type <= 0) continue;
|
||||
var t = Types[type];
|
||||
int x, y, rot;
|
||||
bool found = FindFreeSpot(t, c - t.W / 2, c - t.H / 2, out x, out y, out rot)
|
||||
|| FindSpotIfCleared(t, c - t.W / 2, c - t.H / 2, out x, out y, out rot);
|
||||
if (!found) continue;
|
||||
foreach (var obstacle in map.ObstaclesUnder(x, y, t.W, t.H, rot))
|
||||
if (map.ClearObstacle(obstacle) && obstacleViews.TryGetValue(obstacle, out var ov)) { Destroy(ov); obstacleViews.Remove(obstacle); }
|
||||
if (!PlaceNew(type, x, y, rot)) continue;
|
||||
|
||||
var view = buildingViews["b_" + (nextBuildingId - 1)];
|
||||
var leveled = view.GetComponent<ILeveledBuilding>();
|
||||
if (leveled != null) leveled.Level = e.level;
|
||||
City3DSave.ReadTimer(e, view);
|
||||
pendingFrom2D.RemoveAt(i--);
|
||||
placed++;
|
||||
}
|
||||
if (placed > 0)
|
||||
{
|
||||
if (CitySync.Instance != null) CitySync.Instance.SyncNow();
|
||||
if (announce) Say($"Из старого города поставлено зданий: {placed}" + (pendingFrom2D.Count > 0 ? $", ещё ждут места: {pendingFrom2D.Count}" : ""));
|
||||
}
|
||||
return placed;
|
||||
}
|
||||
|
||||
bool FindSpotIfCleared(BuildingType t, int x0, int y0, out int x, out int y, out int rot)
|
||||
{
|
||||
for (int r = 0; r < City3DLayout.Size; r++)
|
||||
for (int dy = -r; dy <= r; dy++)
|
||||
for (int dx = -r; dx <= r; dx++)
|
||||
{
|
||||
if (Mathf.Max(Mathf.Abs(dx), Mathf.Abs(dy)) != r) continue;
|
||||
foreach (int tryRot in new[] { 0, 90 })
|
||||
if (map.ValidateIfCleared(x0 + dx, y0 + dy, t.W, t.H, tryRot, townHallLevel, null, out _) == PlaceError.None)
|
||||
{
|
||||
x = x0 + dx; y = y0 + dy; rot = tryRot;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
x = x0; y = y0; rot = 0;
|
||||
return false;
|
||||
}
|
||||
|
||||
readonly List<City3DSave.Entry> pendingFrom2D = new List<City3DSave.Entry>();
|
||||
|
||||
// Кнопка "Перенести" в окне здания
|
||||
void MoveFromWindow(GameObject view)
|
||||
{
|
||||
foreach (var pair in buildingViews)
|
||||
if (pair.Value == view)
|
||||
{
|
||||
if (mode == Mode.Place || mode == Mode.Move) CancelGhost();
|
||||
buildMenuOpen = false;
|
||||
StartMoving(pair.Key);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
void OnTownHallLevel(TownHallLevelChangedEvent e) => ApplyTownHallLevel(e.Level);
|
||||
|
||||
// Сцена 3D-города открыта (для окон HUD: «Построить Казармы» ставит призрак здесь, а не в 2D-режиме стройки)
|
||||
public static City3DController Instance { get; private set; }
|
||||
|
||||
void OnDestroy()
|
||||
{
|
||||
if (Instance == this) Instance = null;
|
||||
}
|
||||
|
||||
// Начать ставить здание по имени 3D-типа ("Казармы"). false -- такого типа нет
|
||||
public bool PlaceBuilding(string type3d)
|
||||
{
|
||||
int type = System.Array.FindIndex(Types, t => t.Name == type3d);
|
||||
if (type <= 0) return false;
|
||||
buildMenuOpen = false;
|
||||
if (UIPanelManager.Instance != null) UIPanelManager.Instance.CloseAll();
|
||||
string error = City3DGameplay.CanBuild(type3d);
|
||||
if (error != null) Say(error);
|
||||
StartPlacing(type);
|
||||
return true;
|
||||
}
|
||||
|
||||
void Start()
|
||||
{
|
||||
Instance = this;
|
||||
cam = Camera.main;
|
||||
Map3D.UseRenderer3D(cam); // обычный 3D-рендерер URP (на случай, если сцену собрали вручную)
|
||||
cameraRig = cam.GetComponent<City3DCamera>() ?? cam.gameObject.AddComponent<City3DCamera>();
|
||||
@@ -89,6 +352,7 @@ public class City3DController : MonoBehaviour
|
||||
cameraRig.Apply();
|
||||
|
||||
Map3D.SetUpLook(cam, 30f, 85f); // солнце, небо, дымка вдали
|
||||
City3DGameplay.PutHudInFront(cam); // общий HUD -- поверх холмов и деревьев
|
||||
|
||||
layout = City3DLayout.Create();
|
||||
map = layout.Map;
|
||||
@@ -96,9 +360,8 @@ public class City3DController : MonoBehaviour
|
||||
BuildObstacles();
|
||||
BuildGrass();
|
||||
|
||||
// Ратуша стоит в центре с самого начала
|
||||
int c = City3DLayout.Size / 2;
|
||||
PlaceNew(0, c - 2, c - 2, 0);
|
||||
// Ратуша и остальные здания -- из сохранения (или новая Ратуша в центре): в первом кадре, когда сохранение
|
||||
// уже загружено (оно грузится в Start), см. ApplySaved
|
||||
RefreshGround();
|
||||
}
|
||||
|
||||
@@ -210,7 +473,7 @@ public class City3DController : MonoBehaviour
|
||||
void RefreshGround()
|
||||
{
|
||||
int n = City3DLayout.Size, px = PixelsPerCell;
|
||||
bool grid = mode != Mode.View;
|
||||
bool grid = mode != Mode.View || DebugGrid;
|
||||
var pixels = new Color32[n * px * n * px];
|
||||
for (int y = 0; y < n; y++)
|
||||
for (int x = 0; x < n; x++)
|
||||
@@ -235,7 +498,8 @@ public class City3DController : MonoBehaviour
|
||||
for (int ix = 0; ix < px; ix++)
|
||||
{
|
||||
Color p = c;
|
||||
if (grid && (ix == 0 || iy == 0)) p *= 0.85f; // линии клеток
|
||||
if (grid && (ix == 0 || iy == 0)) p *= DebugGrid ? 0.55f : 0.85f; // линии клеток
|
||||
if (DebugGrid && ((ix == 0 && x % 5 == 0) || (iy == 0 && y % 5 == 0))) p = new Color(1f, 0.85f, 0.2f); // каждая 5-я
|
||||
// граница открытой зоны -- светлая линия
|
||||
if (grid && open && (ix == 0 && !map.IsOpen(x - 1, y, townHallLevel) || ix == px - 1 && !map.IsOpen(x + 1, y, townHallLevel)
|
||||
|| iy == 0 && !map.IsOpen(x, y - 1, townHallLevel) || iy == px - 1 && !map.IsOpen(x, y + 1, townHallLevel)))
|
||||
@@ -259,6 +523,7 @@ public class City3DController : MonoBehaviour
|
||||
nextBuildingId++;
|
||||
buildingType[id] = type;
|
||||
buildingViews[id] = CreateBuildingView(id, type);
|
||||
City3DGameplay.Attach(buildingViews[id], t.Name, 1); // здание игры: окно, рабочие, уровни (без цены и таймера)
|
||||
UpdateBuildingView(id);
|
||||
UpdateGrass();
|
||||
return true;
|
||||
@@ -273,7 +538,10 @@ public class City3DController : MonoBehaviour
|
||||
case "Госпиталь": return new Color(0.45f, 0.55f, 0.65f);
|
||||
case "Казармы": return new Color(0.4f, 0.15f, 0.12f);
|
||||
case "Каменоломня":
|
||||
case "Шахта": return new Color(0.35f, 0.35f, 0.38f);
|
||||
case "Шахта":
|
||||
case "Золотой рудник": return new Color(0.35f, 0.35f, 0.38f);
|
||||
case "Кристалл": return new Color(0.25f, 0.4f, 0.6f);
|
||||
case "Терем": return new Color(0.75f, 0.2f, 0.15f);
|
||||
default: return Color.Lerp(Types[type].Color, new Color(0.3f, 0.15f, 0.1f), 0.55f);
|
||||
}
|
||||
}
|
||||
@@ -313,6 +581,19 @@ public class City3DController : MonoBehaviour
|
||||
case "Шахта":
|
||||
Unit(body, PrimitiveType.Cube, new Vector3(0, 0.2f, -0.505f), new Vector3(0.35f, 0.4f, 0.02f), new Color(0.1f, 0.1f, 0.12f));
|
||||
break;
|
||||
case "Золотой рудник":
|
||||
Unit(body, PrimitiveType.Cube, new Vector3(0, 0.2f, -0.505f), new Vector3(0.35f, 0.4f, 0.02f), new Color(0.1f, 0.1f, 0.12f));
|
||||
Unit(body, PrimitiveType.Cube, new Vector3(0.32f, 0.08f, -0.62f), new Vector3(0.18f, 0.16f, 0.18f), new Color(1f, 0.82f, 0.2f)); // самородок у входа
|
||||
break;
|
||||
case "Кристалл":
|
||||
var gem = Map3D.Shape(body, "Gem", Map3D.Pyramid, new Color(0.35f, 0.85f, 1f), Vector3.zero, Vector2.one * 0.45f, 0.7f, 45f).transform;
|
||||
gem.localPosition = new Vector3(0, 1.0f, 0);
|
||||
break;
|
||||
case "Терем":
|
||||
Unit(body, PrimitiveType.Cube, new Vector3(-0.3f, 0.75f, -0.3f), new Vector3(0.3f, 1.5f, 0.3f), walls);
|
||||
var dome = Map3D.Shape(body, "Dome", Map3D.Pyramid, new Color(0.95f, 0.75f, 0.2f), Vector3.zero, Vector2.one * 0.42f, 0.45f, 45f).transform;
|
||||
dome.localPosition = new Vector3(-0.3f, 1.5f, -0.3f);
|
||||
break;
|
||||
case "Ферма":
|
||||
// грядки перед амбаром
|
||||
for (int i = 0; i < 3; i++)
|
||||
@@ -410,11 +691,38 @@ public class City3DController : MonoBehaviour
|
||||
Vector3 c = cameraRig.target;
|
||||
ghostX = Mathf.Clamp(Mathf.RoundToInt(c.x - t.W / 2f), 0, City3DLayout.Size - t.W);
|
||||
ghostY = Mathf.Clamp(Mathf.RoundToInt(c.z - t.H / 2f), 0, City3DLayout.Size - t.H);
|
||||
// Не на Ратуше и не в чужом здании: ближайшее к центру экрана место, где здание можно поставить
|
||||
if (FindFreeSpot(t, ghostX, ghostY, out int fx, out int fy, out int frot))
|
||||
{
|
||||
ghostX = fx; ghostY = fy; ghostRot = frot;
|
||||
// камера плавно подъезжает к месту, не меняя приближения
|
||||
float size = cameraRig.distance / 1.15f;
|
||||
cameraRig.FocusOn(new Rect(fx + t.W / 2f - size / 2f, fy + t.H / 2f - size / 2f, size, size));
|
||||
}
|
||||
EnsureFootprint();
|
||||
RefreshGround();
|
||||
UpdateGhost();
|
||||
}
|
||||
|
||||
// Ближайшее свободное место (по кольцам от x0, y0), сначала без поворота, потом с поворотом на 90
|
||||
bool FindFreeSpot(BuildingType t, int x0, int y0, out int x, out int y, out int rot)
|
||||
{
|
||||
for (int r = 0; r < City3DLayout.Size; r++)
|
||||
for (int dy = -r; dy <= r; dy++)
|
||||
for (int dx = -r; dx <= r; dx++)
|
||||
{
|
||||
if (Mathf.Max(Mathf.Abs(dx), Mathf.Abs(dy)) != r) continue; // только кольцо радиуса r
|
||||
foreach (int tryRot in new[] { 0, 90 })
|
||||
if (map.Validate(x0 + dx, y0 + dy, t.W, t.H, tryRot, townHallLevel, null, out _) == PlaceError.None)
|
||||
{
|
||||
x = x0 + dx; y = y0 + dy; rot = tryRot;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
x = x0; y = y0; rot = 0;
|
||||
return false; // места нет -- призрак там, где камера, красный с причиной
|
||||
}
|
||||
|
||||
void StartMoving(string id)
|
||||
{
|
||||
if (!map.TryGetBuilding(id, out var r)) return;
|
||||
@@ -464,8 +772,15 @@ public class City3DController : MonoBehaviour
|
||||
{
|
||||
int type = placingType;
|
||||
int x = ghostX, y = ghostY, rot = ghostRot;
|
||||
string problem = City3DGameplay.CanBuild(Types[type].Name); // уровень Ратуши, лимит, строители, ресурсы
|
||||
if (problem != null) { Say(problem); return; }
|
||||
string id = "b_" + nextBuildingId;
|
||||
CancelGhost();
|
||||
if (PlaceNew(type, x, y, rot)) Say(Types[type].Name + " построено (прототип: без цены и таймера)");
|
||||
if (PlaceNew(type, x, y, rot))
|
||||
{
|
||||
City3DGameplay.StartConstruction(buildingViews[id]); // цена и стройка по времени (BuildTimer)
|
||||
Say(Types[type].Name + ": стройка началась");
|
||||
}
|
||||
}
|
||||
else if (mode == Mode.Move)
|
||||
{
|
||||
@@ -508,7 +823,9 @@ public class City3DController : MonoBehaviour
|
||||
|
||||
void Update()
|
||||
{
|
||||
if (!stateApplied && map != null) ApplySaved();
|
||||
if (cam == null) return;
|
||||
UpdateConstructionViews();
|
||||
bool down, held, up;
|
||||
Vector3 pos;
|
||||
if (Input.touchCount > 0)
|
||||
@@ -530,7 +847,7 @@ public class City3DController : MonoBehaviour
|
||||
|
||||
if (down)
|
||||
{
|
||||
pointerOverGui = OverGui(pos);
|
||||
pointerOverGui = OverGui(pos) || City3DInput.OverHud(pos); // и кнопки/окна общего HUD
|
||||
if (pointerOverGui) return;
|
||||
pointerDown = pos;
|
||||
pointerMoved = false;
|
||||
@@ -542,7 +859,8 @@ public class City3DController : MonoBehaviour
|
||||
{
|
||||
if ((pos - pointerDown).magnitude > Screen.height * 0.015f) pointerMoved = true;
|
||||
if (draggingGhost) DragGhost(pos);
|
||||
else cameraRig.Pan(pos);
|
||||
else if (pointerMoved) cameraRig.Pan(pos); // карта едет только при настоящем перетаскивании
|
||||
else cameraRig.Track(pos); // а мелкое дрожание пальца при тапе её не трогает
|
||||
}
|
||||
else if (up && !pointerOverGui)
|
||||
{
|
||||
@@ -585,8 +903,22 @@ public class City3DController : MonoBehaviour
|
||||
}
|
||||
string building = map.BuildingAt(cx, cy);
|
||||
if (mode == Mode.Edit && building != null) { StartMoving(building); return; }
|
||||
// Тап по зданию -- окно здания игры (как в 2D): рабочие, добыча, улучшение, перенос
|
||||
if (building != null && buildingViews.TryGetValue(building, out var tapped) && tapped != null)
|
||||
{
|
||||
selectedBuilding = selectedObstacle = null;
|
||||
buildMenuOpen = false;
|
||||
BuildingWindow.Open(tapped);
|
||||
return;
|
||||
}
|
||||
selectedBuilding = building;
|
||||
selectedObstacle = building == null ? map.ObstacleAt(cx, cy) : null;
|
||||
// Расчищать можно только в открытой зоне стройки (за её пределами -- после улучшения Ратуши) (05.11)
|
||||
if (selectedObstacle != null && !map.IsOpen(cx, cy, townHallLevel))
|
||||
{
|
||||
selectedObstacle = null;
|
||||
Say("Это за пределами зоны стройки -- откроется после улучшения Ратуши");
|
||||
}
|
||||
}
|
||||
|
||||
// Клетка под пальцем. groundOnly -- только земля (перетаскивание призрака); иначе здание под пальцем -- его клетка
|
||||
@@ -623,10 +955,24 @@ public class City3DController : MonoBehaviour
|
||||
if (ghost != null) { var l = ghost.transform.Find("Label"); if (l != null) l.rotation = cam.transform.rotation; }
|
||||
}
|
||||
|
||||
// Кнопки "Ратуша −/+" -- только для теста: меняют уровень настоящей Ратуши (TownHall)
|
||||
void ChangeTownHall(int delta)
|
||||
{
|
||||
var th = TownHall.Instance;
|
||||
int level = Mathf.Clamp((th != null ? th.currentLevel : townHallLevel) + delta, 1, th != null ? th.maxLevel : 5);
|
||||
if (th != null)
|
||||
{
|
||||
th.currentLevel = level;
|
||||
EventBus.Publish(new TownHallLevelChangedEvent { Level = level }); // зоны обновит OnTownHallLevel
|
||||
}
|
||||
else ApplyTownHallLevel(level);
|
||||
}
|
||||
|
||||
// Уровень Ратуши изменился (улучшение в окне Ратуши или тест): открыть зону
|
||||
void ApplyTownHallLevel(int level)
|
||||
{
|
||||
int before = townHallLevel;
|
||||
townHallLevel = Mathf.Clamp(townHallLevel + delta, 1, 5);
|
||||
townHallLevel = Mathf.Clamp(level, 1, 5);
|
||||
if (townHallLevel == before) return;
|
||||
RefreshGround();
|
||||
if (ghost != null) UpdateGhost();
|
||||
@@ -636,6 +982,7 @@ public class City3DController : MonoBehaviour
|
||||
int half = City3DLayout.ZoneHalfSize(townHallLevel), c = City3DLayout.Size / 2;
|
||||
cameraRig.FocusOn(new Rect(c - half, c - half, half * 2, half * 2));
|
||||
Say($"Ратуша {townHallLevel} ур.: открыта новая зона");
|
||||
if (stateApplied && pendingFrom2D.Count > 0) PlacePendingFrom2D(); // здания из 2D, которые ждали места
|
||||
}
|
||||
}
|
||||
|
||||
@@ -651,6 +998,82 @@ public class City3DController : MonoBehaviour
|
||||
float guiScale = 1f;
|
||||
GUIStyle button, label, box;
|
||||
|
||||
// ---------------- Стройка по времени в 3D ----------------
|
||||
// BuildTimer затемняет только 2D-спрайты, поэтому в 3D здание, которое ещё строится, выглядело готовым
|
||||
// (Казармы «построены», а нанимать нельзя). Здесь: здание темнее и над ним -- «Стройка 0:45» (05.11)
|
||||
|
||||
readonly Dictionary<string, TextMeshPro> constructionLabels = new Dictionary<string, TextMeshPro>();
|
||||
static readonly int BaseColorId = Shader.PropertyToID("_BaseColor"), ColorId = Shader.PropertyToID("_Color");
|
||||
MaterialPropertyBlock constructionBlock;
|
||||
|
||||
void UpdateConstructionViews()
|
||||
{
|
||||
foreach (var pair in buildingViews)
|
||||
{
|
||||
var view = pair.Value;
|
||||
if (view == null) continue;
|
||||
var timer = BuildTimer.Of(view);
|
||||
bool building = timer != null && BuildTimer.IsConstructing(view.transform);
|
||||
constructionLabels.TryGetValue(pair.Key, out var label);
|
||||
if (building)
|
||||
{
|
||||
if (label == null) label = constructionLabels[pair.Key] = StartConstructionLook(view);
|
||||
label.text = "Стройка " + GameData.FormatTime(timer.SecondsLeft);
|
||||
label.transform.rotation = cam.transform.rotation;
|
||||
}
|
||||
else if (label != null)
|
||||
{
|
||||
Destroy(label.gameObject);
|
||||
constructionLabels.Remove(pair.Key);
|
||||
foreach (var r in view.GetComponentsInChildren<Renderer>()) if (!(r is TMPro.TMP_SubMesh)) r.SetPropertyBlock(null);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
TextMeshPro StartConstructionLook(GameObject view)
|
||||
{
|
||||
if (constructionBlock == null) constructionBlock = new MaterialPropertyBlock();
|
||||
Bounds bounds = new Bounds(view.transform.position, Vector3.zero);
|
||||
bool first = true;
|
||||
foreach (var r in view.GetComponentsInChildren<Renderer>())
|
||||
{
|
||||
if (first) { bounds = r.bounds; first = false; } else bounds.Encapsulate(r.bounds);
|
||||
var m = r.sharedMaterial;
|
||||
if (m == null) continue;
|
||||
Color c = m.HasProperty(BaseColorId) ? m.GetColor(BaseColorId) : m.HasProperty(ColorId) ? m.GetColor(ColorId) : Color.white;
|
||||
Color grey = Color.Lerp(c, new Color(0.45f, 0.42f, 0.38f), 0.6f) * 0.75f;
|
||||
grey.a = 1f;
|
||||
r.GetPropertyBlock(constructionBlock);
|
||||
constructionBlock.SetColor(BaseColorId, grey);
|
||||
constructionBlock.SetColor(ColorId, grey);
|
||||
r.SetPropertyBlock(constructionBlock);
|
||||
}
|
||||
var go = new GameObject("ConstructionLabel");
|
||||
go.transform.SetParent(view.transform, false);
|
||||
go.transform.position = new Vector3(bounds.center.x, bounds.max.y + 0.7f, bounds.center.z);
|
||||
var label = go.AddComponent<TextMeshPro>();
|
||||
label.fontSize = 7f;
|
||||
label.alignment = TextAlignmentOptions.Center;
|
||||
label.rectTransform.sizeDelta = new Vector2(8f, 1.5f);
|
||||
label.color = new Color(1f, 0.85f, 0.4f);
|
||||
label.outlineWidth = 0.25f;
|
||||
label.outlineColor = Color.black;
|
||||
return label;
|
||||
}
|
||||
|
||||
// Открыто модальное окно UIKit (окно здания, отчёт, подтверждение) -- проверка раз в кадр
|
||||
int modalCheckedFrame = -1;
|
||||
bool modalOpen;
|
||||
bool ModalOpen()
|
||||
{
|
||||
if (modalCheckedFrame != Time.frameCount)
|
||||
{
|
||||
modalCheckedFrame = Time.frameCount;
|
||||
modalOpen = GameObject.Find("Modal") != null;
|
||||
}
|
||||
return modalOpen;
|
||||
}
|
||||
|
||||
bool OverGui(Vector3 screen)
|
||||
{
|
||||
var p = new Vector2(screen.x / guiScale, (Screen.height - screen.y) / guiScale);
|
||||
@@ -685,13 +1108,20 @@ public class City3DController : MonoBehaviour
|
||||
}
|
||||
if (Event.current.type == EventType.Layout) guiRects.Clear();
|
||||
|
||||
// Верх: режим, Ратуша (тест), редактирование
|
||||
Box(new Rect(10, 10, 330, 44), $"3D-поселение (прототип) • Ратуша {townHallLevel} ур.");
|
||||
if (Button(new Rect(350, 10, 60, 44), "−")) ChangeTownHall(-1);
|
||||
if (Button(new Rect(415, 10, 60, 44), "+")) ChangeTownHall(+1);
|
||||
// Окно HUD открыто -- временные кнопки сцены не показываем (и не ловим нажатия)
|
||||
if (UIPanelManager.Instance != null && UIPanelManager.Instance.CurrentId != "") return;
|
||||
if (ModalOpen()) return; // и окно здания / сообщение (UIKit.Modal): тестовые кнопки сцены не рисуются поверх него (05.11)
|
||||
|
||||
// Под верхней панелью HUD, справа: Ратуша −/+ -- только в редакторе и отладочной сборке (тест зон), редактирование
|
||||
if (Application.isEditor || Debug.isDebugBuild)
|
||||
{
|
||||
Box(new Rect(w - 480, HudTop, 290, 44), $"3D (тест) • Ратуша {townHallLevel} ур.");
|
||||
if (Button(new Rect(w - 185, HudTop, 55, 44), "−")) ChangeTownHall(-1);
|
||||
if (Button(new Rect(w - 125, HudTop, 55, 44), "+")) ChangeTownHall(+1);
|
||||
}
|
||||
if (mode == Mode.View || mode == Mode.Edit)
|
||||
{
|
||||
if (Button(new Rect(w - 250, 10, 240, 44), mode == Mode.Edit ? "Готово" : "Редактировать"))
|
||||
if (Button(new Rect(w - 250, HudTop + 50, 240, 44), mode == Mode.Edit ? "Готово" : "Редактировать"))
|
||||
{
|
||||
mode = mode == Mode.Edit ? Mode.View : Mode.Edit;
|
||||
selectedBuilding = selectedObstacle = null;
|
||||
@@ -700,13 +1130,19 @@ public class City3DController : MonoBehaviour
|
||||
}
|
||||
}
|
||||
|
||||
// Слева: что построить
|
||||
if (mode == Mode.View || mode == Mode.Edit)
|
||||
// Справа по центру: приближение / отдаление. На телефоне есть щипок двумя пальцами, но в редакторе
|
||||
// (Device Simulator) колесо мыши не доходит до игры, а щипок мышью не сделать -- без кнопок карту не отдалить (05.11)
|
||||
if (Button(new Rect(w - 70, h / 2 - 62, 60, 56), "+")) cameraRig.Zoom(0.2f);
|
||||
if (Button(new Rect(w - 70, h / 2 + 2, 60, 56), "−")) cameraRig.Zoom(-0.25f);
|
||||
|
||||
// Слева, правее кнопок HUD: что построить -- открывается кнопкой "СТРОЙКА" общего HUD
|
||||
if (buildMenuOpen && (mode == Mode.View || mode == Mode.Edit))
|
||||
{
|
||||
float y = 64;
|
||||
float y = HudTop, x = HudLeft;
|
||||
for (int i = 1; i < Types.Length; i++)
|
||||
{
|
||||
if (Button(new Rect(10, y, 200, 40), $"{Types[i].Name} {Types[i].W}×{Types[i].H}")) StartPlacing(i);
|
||||
if (y + 40 > h - HudBottom) { y = HudTop; x += 206; } // не помещается до нижней панели -- следующий столбец
|
||||
if (Button(new Rect(x, y, 200, 40), $"{Types[i].Name} {Types[i].W}×{Types[i].H}")) { buildMenuOpen = false; StartPlacing(i); }
|
||||
y += 44;
|
||||
}
|
||||
}
|
||||
@@ -717,39 +1153,39 @@ public class City3DController : MonoBehaviour
|
||||
string status = ghostError == PlaceError.None
|
||||
? (ghostFoundation > 0 ? $"Можно ставить (фундамент {ghostFoundation / 1000f:0.0} м)" : "Можно ставить")
|
||||
: SettlementMap.ErrorText(ghostError);
|
||||
Box(new Rect(w / 2 - 300, h - 120, 600, 44), (mode == Mode.Move ? "Перенос: " : Types[GhostType].Name + ": ") + status);
|
||||
if (Button(new Rect(w / 2 - 300, h - 70, 190, 60), "Повернуть 90°")) { ghostRot = (ghostRot + 90) % 360; UpdateGhost(); }
|
||||
if (Button(new Rect(w / 2 - 95, h - 70, 190, 60), mode == Mode.Move ? "Подтвердить" : "Поставить")) ConfirmGhost();
|
||||
if (Button(new Rect(w / 2 + 110, h - 70, 190, 60), "Отмена")) CancelGhost();
|
||||
Box(new Rect(w / 2 - 300, h - HudBottom - 120, 600, 44), (mode == Mode.Move ? "Перенос: " : Types[GhostType].Name + ": ") + status);
|
||||
if (Button(new Rect(w / 2 - 300, h - HudBottom - 70, 190, 60), "Повернуть 90°")) { ghostRot = (ghostRot + 90) % 360; UpdateGhost(); }
|
||||
if (Button(new Rect(w / 2 - 95, h - HudBottom - 70, 190, 60), mode == Mode.Move ? "Подтвердить" : "Поставить")) ConfirmGhost();
|
||||
if (Button(new Rect(w / 2 + 110, h - HudBottom - 70, 190, 60), "Отмена")) CancelGhost();
|
||||
}
|
||||
|
||||
// Меню здания: перенос
|
||||
if (mode == Mode.View && selectedBuilding != null && map.TryGetBuilding(selectedBuilding, out var b))
|
||||
{
|
||||
Box(new Rect(w / 2 - 250, h - 120, 500, 44), $"{b.Type} • {b.W}×{b.H} клеток • поворот {b.Rotation}°");
|
||||
if (Button(new Rect(w / 2 - 250, h - 70, 245, 60), "Переместить")) { string id = selectedBuilding; selectedBuilding = null; StartMoving(id); }
|
||||
if (Button(new Rect(w / 2 + 5, h - 70, 245, 60), "Закрыть")) selectedBuilding = null;
|
||||
Box(new Rect(w / 2 - 250, h - HudBottom - 120, 500, 44), $"{b.Type} • {b.W}×{b.H} клеток • поворот {b.Rotation}°");
|
||||
if (Button(new Rect(w / 2 - 250, h - HudBottom - 70, 245, 60), "Переместить")) { string id = selectedBuilding; selectedBuilding = null; StartMoving(id); }
|
||||
if (Button(new Rect(w / 2 + 5, h - HudBottom - 70, 245, 60), "Закрыть")) selectedBuilding = null;
|
||||
}
|
||||
|
||||
// Меню дерева / валуна: расчистка без цены и ожидания
|
||||
if (mode == Mode.View && selectedObstacle != null)
|
||||
{
|
||||
bool tree = selectedObstacle.StartsWith("tree");
|
||||
Box(new Rect(w / 2 - 250, h - 120, 500, 44), tree ? "Дерево" : "Валун");
|
||||
if (Button(new Rect(w / 2 - 250, h - 70, 245, 60), "Расчистить"))
|
||||
Box(new Rect(w / 2 - 250, h - HudBottom - 120, 500, 44), tree ? "Дерево" : "Валун");
|
||||
if (Button(new Rect(w / 2 - 250, h - HudBottom - 70, 245, 60), "Расчистить"))
|
||||
{
|
||||
if (map.ClearObstacle(selectedObstacle) && obstacleViews.TryGetValue(selectedObstacle, out var view))
|
||||
{
|
||||
Destroy(view);
|
||||
Say((tree ? "Дерево" : "Валун") + " расчищено. Награда " + (tree ? "деревом" : "камнем") +
|
||||
" -- после подключения сохранений (этап III)");
|
||||
City3DGameplay.RewardClearing(tree); // +10 дерева или камня, один раз (решение 04.11)
|
||||
Say((tree ? "Дерево" : "Валун") + " расчищено: +10 " + (tree ? "дерева" : "камня"));
|
||||
}
|
||||
selectedObstacle = null;
|
||||
}
|
||||
if (Button(new Rect(w / 2 + 5, h - 70, 245, 60), "Закрыть")) selectedObstacle = null;
|
||||
if (Button(new Rect(w / 2 + 5, h - HudBottom - 70, 245, 60), "Закрыть")) selectedObstacle = null;
|
||||
}
|
||||
|
||||
if (Time.unscaledTime < messageUntil)
|
||||
GUI.Label(new Rect(w / 2 - 350, 64, 700, 60), message, label);
|
||||
GUI.Label(new Rect(w / 2 - 350, HudTop, 700, 60), message, label);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,234 @@
|
||||
using System.Collections.Generic;
|
||||
using UnityEngine;
|
||||
|
||||
// 3D-ПОСЕЛЕНИЕ: здания 3D-сцены становятся зданиями игры (05.11, Валдемар).
|
||||
// На 3D-здание вешаются те же компоненты, что на префабы 2D-города: рабочее место или Дом (ResourceCollector),
|
||||
// Склад / Амбар (StorageBuilding), Казармы, Госпиталь, Ратуша (TownHall) и PlacedBuildingId с именем здания.
|
||||
// Поэтому дальше всё работает так же, как в 2D: CitySync даёт места, жильё и лимиты; окно здания (BuildingWindow) --
|
||||
// рабочие, добыча, улучшение по таймеру; стройка идёт через BuildTimer (цена, строители, лимиты, ускорение, офлайн).
|
||||
// Место на карте проверяет SettlementMap Саши, цену, время и лимиты -- этот код и EconomyConfig.
|
||||
public static class City3DGameplay
|
||||
{
|
||||
// Имя типа 3D-сцены -> имя здания игры (как в 2D: по нему лимиты, окна, сохранение)
|
||||
public static string GameName(string type3d)
|
||||
{
|
||||
switch (type3d)
|
||||
{
|
||||
case "Дом": return "Население";
|
||||
case "Ферма": return "Еда";
|
||||
case "Лесопилка": return "Лес";
|
||||
case "Каменоломня": return "Камень";
|
||||
case "Шахта": return "Металл";
|
||||
case "Склад": return StorageBootstrap.WarehouseName;
|
||||
case "Амбар": return StorageBootstrap.GranaryName;
|
||||
case "Казармы": return BarracksBootstrap.BarracksName;
|
||||
case "Госпиталь": return HospitalBootstrap.HospitalName;
|
||||
case "Золотой рудник": return "Золото";
|
||||
case "Кристалл": return "Кристалл";
|
||||
case "Терем": return HeroCastleBootstrap.CastleName;
|
||||
default: return type3d; // "Ратуша"
|
||||
}
|
||||
}
|
||||
|
||||
// Обратно: имя здания игры (как в 2D-сохранении) -> тип 3D-сцены. null -- такого здания в 3D нет
|
||||
public static string Type3DFor(string gameName)
|
||||
{
|
||||
foreach (var t in AllTypes3D)
|
||||
if (GameName(t) == gameName) return t;
|
||||
return null;
|
||||
}
|
||||
|
||||
static readonly string[] AllTypes3D =
|
||||
{
|
||||
"Ратуша", "Дом", "Ферма", "Лесопилка", "Каменоломня", "Шахта", "Склад", "Амбар", "Казармы", "Госпиталь",
|
||||
"Золотой рудник", "Кристалл", "Терем",
|
||||
};
|
||||
|
||||
public static bool IsTownHall(string type3d) => type3d == "Ратуша";
|
||||
|
||||
// Цена постройки -- та же, что у зданий 2D-города (Data_*.asset, Склады -- EconomyConfig, Госпиталь -- HospitalConfig)
|
||||
public static ResourceAmount[] BuildCost(string type3d)
|
||||
{
|
||||
var e = EconomyConfig.Load().storage;
|
||||
switch (type3d)
|
||||
{
|
||||
case "Дом": return Cost(ResourceType.Wood, 100, ResourceType.Food, 200, ResourceType.Stone, 150);
|
||||
case "Ферма": return Cost(ResourceType.Wood, 100);
|
||||
case "Лесопилка": return Cost();
|
||||
case "Каменоломня": return Cost(ResourceType.Wood, 50);
|
||||
case "Шахта": return Cost(ResourceType.Wood, 600, ResourceType.Stone, 550);
|
||||
case "Склад": return Cost(ResourceType.Wood, e.warehouseWood, ResourceType.Stone, e.warehouseStone);
|
||||
case "Амбар": return Cost(ResourceType.Wood, e.granaryWood, ResourceType.Stone, e.granaryStone);
|
||||
case "Казармы": return Cost(ResourceType.Wood, 300, ResourceType.Stone, 200);
|
||||
case "Госпиталь":
|
||||
var h = HospitalConfig.Load();
|
||||
return Cost(ResourceType.Wood, h.buildWood, ResourceType.Stone, h.buildStone);
|
||||
// Перенесены из 2D-города (решение 05.11): цены -- из Data_GoldMine, Data_Kristall и HeroCastleBootstrap
|
||||
case "Золотой рудник": return Cost(ResourceType.Stone, 50);
|
||||
case "Кристалл": return Cost(ResourceType.Wood, 2000, ResourceType.Stone, 3200, ResourceType.Gold, 12000, ResourceType.Iron, 1600);
|
||||
case "Терем": return Cost(ResourceType.Wood, HeroCastleBootstrap.CostWood, ResourceType.Stone, HeroCastleBootstrap.CostStone);
|
||||
default: return Cost();
|
||||
}
|
||||
}
|
||||
|
||||
// С какого уровня Ратуши можно строить (как в 2D)
|
||||
public static int RequiredTownHall(string type3d)
|
||||
{
|
||||
switch (type3d)
|
||||
{
|
||||
case "Шахта": return 3;
|
||||
case "Золотой рудник": return 2;
|
||||
case "Кристалл": return 6;
|
||||
case "Госпиталь": return Mathf.Max(1, HospitalConfig.Load().requiredTownHallLevel);
|
||||
default: return 1;
|
||||
}
|
||||
}
|
||||
|
||||
static ResourceAmount[] Cost(params object[] pairs)
|
||||
{
|
||||
var list = new List<ResourceAmount>();
|
||||
for (int i = 0; i + 1 < pairs.Length; i += 2)
|
||||
list.Add(new ResourceAmount((ResourceType)pairs[i], System.Convert.ToSingle(pairs[i + 1])));
|
||||
return list.ToArray();
|
||||
}
|
||||
|
||||
public static int TownHallLevel => GameServices.TryGet(out ISettlement s) ? s.TownHallLevel : 1;
|
||||
|
||||
// Можно ли строить: уровень Ратуши, лимит зданий, строители, ресурсы. null -- можно, иначе текст для игрока
|
||||
public static string CanBuild(string type3d)
|
||||
{
|
||||
int th = TownHallLevel;
|
||||
if (th < RequiredTownHall(type3d)) return $"Откроется на Ратуше {RequiredTownHall(type3d)} ур.";
|
||||
string name = GameName(type3d);
|
||||
int max = EconomyConfig.Load().BuildingLimitFor(name, th), have = CountBuilt(name);
|
||||
if (max >= 0 && have >= max) return $"Лимит: {have} / {max} на Ратуше {th} ур.";
|
||||
if (BuildTimer.DurationMs(false, 1) > 0 && BuildTimer.FreeBuilders == 0) return "Все строители заняты";
|
||||
var cost = BuildCost(type3d);
|
||||
if (cost.Length > 0 && (!GameServices.TryGet(out IEconomy economy) || !economy.CanAfford(cost))) return "Не хватает ресурсов: " + CostText(cost);
|
||||
return null;
|
||||
}
|
||||
|
||||
public static string CostText(ResourceAmount[] cost)
|
||||
{
|
||||
if (cost.Length == 0) return "бесплатно";
|
||||
var parts = new List<string>();
|
||||
foreach (var c in cost) parts.Add($"{c.Amount:0} {GameData.ResName(c.Type).ToLower()}");
|
||||
return string.Join(", ", parts.ToArray());
|
||||
}
|
||||
|
||||
public static int CountBuilt(string gameName)
|
||||
{
|
||||
int n = 0;
|
||||
foreach (var id in Object.FindObjectsByType<PlacedBuildingId>(FindObjectsSortMode.None))
|
||||
if (id.buildingName == gameName) n++;
|
||||
return n;
|
||||
}
|
||||
|
||||
// Новое здание поставлено на карту: списать цену и начать стройку по времени (BuildTimer)
|
||||
public static void StartConstruction(GameObject view)
|
||||
{
|
||||
var tag = view.GetComponent<City3DBuildingTag>();
|
||||
if (tag == null) return;
|
||||
var cost = BuildCost(tag.type3d);
|
||||
if (cost.Length > 0 && GameServices.TryGet(out IEconomy economy)) economy.TrySpend(cost);
|
||||
long ms = BuildTimer.DurationMs(false, 1);
|
||||
if (ms > 0)
|
||||
{
|
||||
long now = GameClock.UnixNowMs;
|
||||
BuildTimer.Begin(view, BuildTimer.Kind.Build, 1, now, now + ms);
|
||||
}
|
||||
if (CitySync.Instance != null) CitySync.Instance.SyncNow();
|
||||
}
|
||||
|
||||
// Повесить на 3D-здание компоненты здания игры (как у префабов 2D). Без цены и таймера
|
||||
public static void Attach(GameObject view, string type3d, int level)
|
||||
{
|
||||
var tag = view.GetComponent<City3DBuildingTag>() ?? view.AddComponent<City3DBuildingTag>();
|
||||
tag.type3d = type3d;
|
||||
|
||||
if (IsTownHall(type3d))
|
||||
{
|
||||
var th = view.GetComponent<TownHall>() ?? view.AddComponent<TownHall>();
|
||||
// Та же цена улучшения, что у Ратуши 2D-города (сцена MVP1): 250 дерева, 160 камня, 25 людей, ×1.5 за уровень
|
||||
th.baseUpgradeCost = new[]
|
||||
{
|
||||
new ResourceCost { type = ResourceType.Wood, amount = 250 },
|
||||
new ResourceCost { type = ResourceType.Stone, amount = 160 },
|
||||
new ResourceCost { type = ResourceType.Population, amount = 25 },
|
||||
};
|
||||
th.costMultiplierPerLevel = 1.5f;
|
||||
th.maxLevel = 10;
|
||||
th.currentLevel = Mathf.Max(1, level);
|
||||
return;
|
||||
}
|
||||
|
||||
view.AddComponent<PlacedBuildingId>().buildingName = GameName(type3d);
|
||||
switch (type3d)
|
||||
{
|
||||
case "Дом": Collector(view, ResourceType.Population, level); break;
|
||||
case "Ферма": Collector(view, ResourceType.Food, level); break;
|
||||
case "Лесопилка": Collector(view, ResourceType.Wood, level); break;
|
||||
case "Каменоломня": Collector(view, ResourceType.Stone, level); break;
|
||||
case "Шахта": Collector(view, ResourceType.Iron, level); break;
|
||||
case "Склад": Storage(view, StorageBuilding.Kind.Warehouse, level); break;
|
||||
case "Амбар": Storage(view, StorageBuilding.Kind.Granary, level); break;
|
||||
case "Казармы": view.AddComponent<Barracks>().Level = Mathf.Max(1, level); break;
|
||||
case "Госпиталь": view.AddComponent<Hospital>().Level = Mathf.Max(1, level); break;
|
||||
case "Золотой рудник": Collector(view, ResourceType.Gold, level); break;
|
||||
case "Кристалл": Collector(view, ResourceType.Gems, level); break; // рабочих нет, как в 2D
|
||||
case "Терем": view.AddComponent<HeroCastle>(); break; // окно героя -- BuildingWindow
|
||||
}
|
||||
}
|
||||
|
||||
static void Collector(GameObject view, ResourceType type, int level)
|
||||
{
|
||||
var rc = view.AddComponent<ResourceCollector>();
|
||||
rc.resourceType = type;
|
||||
rc.Level = Mathf.Max(1, level);
|
||||
}
|
||||
|
||||
static void Storage(GameObject view, StorageBuilding.Kind kind, int level)
|
||||
{
|
||||
var sb = view.AddComponent<StorageBuilding>();
|
||||
sb.kind = kind;
|
||||
sb.Level = Mathf.Max(1, level);
|
||||
}
|
||||
|
||||
// Уровень здания (для сохранения и подписи)
|
||||
public static int LevelOf(GameObject view)
|
||||
{
|
||||
var th = view.GetComponent<TownHall>();
|
||||
if (th != null) return th.currentLevel;
|
||||
var leveled = view.GetComponent<ILeveledBuilding>();
|
||||
return leveled != null ? leveled.Level : 1;
|
||||
}
|
||||
|
||||
// HUD 2D-города нарисован на холсте "в камере" (Screen Space - Camera). В 3D-сцене холст стоял бы на расстоянии
|
||||
// от камеры, и холмы и деревья перекрывали бы кнопки. Ставим холст вплотную к камере -- он всегда поверх 3D.
|
||||
public static void PutHudInFront(Camera cam)
|
||||
{
|
||||
if (cam == null) return;
|
||||
foreach (var canvas in Object.FindObjectsByType<Canvas>(FindObjectsSortMode.None))
|
||||
{
|
||||
if (!canvas.isRootCanvas || canvas.renderMode != RenderMode.ScreenSpaceCamera) continue;
|
||||
canvas.worldCamera = cam;
|
||||
canvas.planeDistance = cam.nearClipPlane + 0.05f;
|
||||
HideWorldSprites(canvas);
|
||||
}
|
||||
}
|
||||
|
||||
// В HUD есть картинка-спрайт мира (SpriteRenderer) -- плашка с мечом по центру нижней панели. На холсте у
|
||||
// камеры она рисуется поверх всего HUD, даже поверх открытых окон, а сама картинка без прозрачности
|
||||
// ("шахматка"). Решение 05.11 (Миша, Володя): в 3D-сцене её убираем, пока не будет новой картинки или кнопки.
|
||||
static void HideWorldSprites(Canvas canvas)
|
||||
{
|
||||
foreach (var sr in canvas.GetComponentsInChildren<SpriteRenderer>(true))
|
||||
sr.enabled = false;
|
||||
}
|
||||
|
||||
// Награда за расчистку (решение 04.11): +10 дерева за дерево или +10 камня за валун, один раз, в пределах склада
|
||||
public static void RewardClearing(bool tree)
|
||||
{
|
||||
if (GameServices.TryGet(out IEconomy economy)) economy.Add(tree ? ResourceType.Wood : ResourceType.Stone, 10);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 3b010df83fccefa4aa9c394f84b8214f
|
||||
@@ -0,0 +1,66 @@
|
||||
using System.Collections.Generic;
|
||||
using UnityEngine;
|
||||
using UnityEngine.EventSystems;
|
||||
using UnityEngine.UI;
|
||||
|
||||
// Палец над HUD в 3D-поселении (05.11): «над интерфейсом» -- только если под пальцем то, что нажимают или крутят
|
||||
// (кнопка, поле, ползунок, прокручиваемое окно) или небольшая панель. Невидимый слой во весь экран
|
||||
// (затемнение, контейнер безопасной зоны, фон) карту не блокирует -- иначе не работают ни тап, ни зум, ни стройка.
|
||||
// Такой слой пишется в Console один раз, чтобы его можно было найти и убрать.
|
||||
public static class City3DInput
|
||||
{
|
||||
static readonly List<RaycastResult> hits = new List<RaycastResult>();
|
||||
static readonly HashSet<string> reported = new HashSet<string>();
|
||||
|
||||
public static bool OverHud(Vector2 screen)
|
||||
{
|
||||
var es = EventSystem.current;
|
||||
if (es == null) return false;
|
||||
// Открыто окно HUD (панель) -- весь ввод его, карта не трогается
|
||||
if (UIPanelManager.Instance != null && UIPanelManager.Instance.CurrentId != "")
|
||||
{
|
||||
if (reported.Add("panel:" + UIPanelManager.Instance.CurrentId))
|
||||
Debug.Log($"[3D] Открыта панель HUD «{UIPanelManager.Instance.CurrentId}» -- 3D-карта не принимает нажатия, пока её не закрыть");
|
||||
return true;
|
||||
}
|
||||
hits.Clear();
|
||||
es.RaycastAll(new PointerEventData(es) { position = screen }, hits);
|
||||
foreach (var h in hits)
|
||||
{
|
||||
if (h.gameObject == null) continue;
|
||||
if (IsInteractive(h.gameObject) || !CoversScreen(h.gameObject)) return true;
|
||||
if (reported.Add(h.gameObject.name))
|
||||
Debug.Log($"[3D] Слой интерфейса во весь экран ловит нажатия, 3D-карта его пропускает: «{Path(h.gameObject.transform)}». " +
|
||||
"Если он не нужен -- выключить у него Raycast Target");
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
static bool IsInteractive(GameObject go)
|
||||
{
|
||||
// кнопки, прокрутка, поля, а также всё, что само ловит нажатие (затемнение модального окна закрывает его тапом)
|
||||
return go.GetComponentInParent<Selectable>() != null || go.GetComponentInParent<ScrollRect>() != null
|
||||
|| go.GetComponentInParent<IPointerClickHandler>() != null || go.GetComponentInParent<IPointerDownHandler>() != null
|
||||
|| go.name == "Modal" || go.GetComponentInParent<TMPro.TMP_InputField>() != null;
|
||||
}
|
||||
|
||||
// Занимает почти весь экран (больше 80% по ширине и высоте)
|
||||
static bool CoversScreen(GameObject go)
|
||||
{
|
||||
if (!(go.transform is RectTransform rt)) return false;
|
||||
var corners = new Vector3[4];
|
||||
rt.GetWorldCorners(corners);
|
||||
var canvas = go.GetComponentInParent<Canvas>();
|
||||
Camera cam = canvas != null && canvas.renderMode != RenderMode.ScreenSpaceOverlay ? canvas.worldCamera : null;
|
||||
Vector2 a = RectTransformUtility.WorldToScreenPoint(cam, corners[0]);
|
||||
Vector2 b = RectTransformUtility.WorldToScreenPoint(cam, corners[2]);
|
||||
return Mathf.Abs(b.x - a.x) > Screen.width * 0.8f && Mathf.Abs(b.y - a.y) > Screen.height * 0.8f;
|
||||
}
|
||||
|
||||
static string Path(Transform t)
|
||||
{
|
||||
string p = t.name;
|
||||
for (var x = t.parent; x != null; x = x.parent) p = x.name + "/" + p;
|
||||
return p;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: c71f24a7cb164064b98f198eec9665d3
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,89 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using UnityEngine;
|
||||
|
||||
// Сохранение 3D-поселения: секция "settlement3d" (05.11, Валдемар). 2D-город сохраняется отдельно ("settlement"),
|
||||
// поэтому 3D-сцена его не трогает. Здания: id, тип, клетка, поворот, уровень, стройка или улучшение, которое идёт;
|
||||
// Ратуша: уровень и её улучшение; расчищенные деревья и валуны.
|
||||
// Регистрирует App/GameModules. Пока сцена City3D открыта, данные берутся у неё (Live), иначе -- последние известные.
|
||||
public sealed class City3DSave : ISaveParticipant
|
||||
{
|
||||
[Serializable]
|
||||
public class Entry
|
||||
{
|
||||
public string id;
|
||||
public string type; // тип 3D-сцены: "Ратуша", "Дом", "Ферма"...
|
||||
public int x, y, rot;
|
||||
public int level = 1;
|
||||
public int timerKind; // 0 -- нет, 1 -- постройка, 2 -- улучшение (BuildTimer.Kind)
|
||||
public long timerStartMs, timerEndMs;
|
||||
public int timerTargetLevel;
|
||||
}
|
||||
|
||||
[Serializable]
|
||||
public class Data
|
||||
{
|
||||
public int nextId = 1;
|
||||
public List<Entry> buildings = new List<Entry>();
|
||||
public List<string> cleared = new List<string>();
|
||||
// Здания 2D-города, которым при переносе не нашлось места (зона закрыта, всё занято): ждут и ставятся,
|
||||
// как только место появится. Не теряются (05.11)
|
||||
public List<Entry> pendingFrom2D = new List<Entry>();
|
||||
}
|
||||
|
||||
public interface ILive
|
||||
{
|
||||
Data CaptureState();
|
||||
void OnSaveLoaded(); // загрузили другое сохранение или сбросили -- сцена должна перестроиться
|
||||
}
|
||||
|
||||
public static readonly City3DSave Instance = new City3DSave();
|
||||
public static Data Cached { get; private set; }
|
||||
public static ILive Live;
|
||||
|
||||
public string SaveKey => "settlement3d";
|
||||
|
||||
public string Capture()
|
||||
{
|
||||
if (Live != null) Cached = Live.CaptureState();
|
||||
return Cached != null ? JsonUtility.ToJson(Cached) : null;
|
||||
}
|
||||
|
||||
public void Restore(string json)
|
||||
{
|
||||
Cached = string.IsNullOrEmpty(json) ? null : JsonUtility.FromJson<Data>(json);
|
||||
if (Cached != null)
|
||||
{
|
||||
if (Cached.buildings == null) Cached.buildings = new List<Entry>();
|
||||
if (Cached.cleared == null) Cached.cleared = new List<string>();
|
||||
if (Cached.pendingFrom2D == null) Cached.pendingFrom2D = new List<Entry>();
|
||||
}
|
||||
Live?.OnSaveLoaded();
|
||||
}
|
||||
|
||||
public void ResetState()
|
||||
{
|
||||
Cached = null;
|
||||
Live?.OnSaveLoaded();
|
||||
}
|
||||
|
||||
// Запись таймера здания в Entry и обратно
|
||||
public static void WriteTimer(Entry e, GameObject view)
|
||||
{
|
||||
var t = BuildTimer.Of(view);
|
||||
bool on = t != null && BuildTimer.All.Contains(t);
|
||||
e.timerKind = on ? (int)t.kind : 0;
|
||||
e.timerStartMs = on ? t.startMs : 0;
|
||||
e.timerEndMs = on ? t.endMs : 0;
|
||||
e.timerTargetLevel = on ? t.targetLevel : 0;
|
||||
}
|
||||
|
||||
// Таймер из файла -- только осмысленный: постройка у 1 уровня, улучшение до уровня выше текущего
|
||||
public static void ReadTimer(Entry e, GameObject view)
|
||||
{
|
||||
bool build = e.timerKind == (int)BuildTimer.Kind.Build && e.level <= 1;
|
||||
bool upgrade = e.timerKind == (int)BuildTimer.Kind.Upgrade && e.timerTargetLevel > Mathf.Max(1, e.level);
|
||||
if ((build || upgrade) && e.timerEndMs > 0)
|
||||
BuildTimer.Begin(view, (BuildTimer.Kind)e.timerKind, Mathf.Max(1, e.timerTargetLevel), e.timerStartMs, e.timerEndMs);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 037c1ed1fee52f74da1eaaeb4c20e3b1
|
||||
@@ -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,8 @@ public static class GameData
|
||||
public const int MaxPlayerLevel = 60;
|
||||
|
||||
// ---------- Основные задания (цепочка) ----------
|
||||
// Синие кристаллы (регламент 05.11, разд. 3): простое задание -- 1, сложное -- 2 (было 20-250). В ежедневных
|
||||
// кристаллов нет: кристальное задание -- не чаще раза в 7-14 дней. Числа ТЕСТ
|
||||
public static readonly List<QuestDef> MainQuests = new List<QuestDef>
|
||||
{
|
||||
new QuestDef("m_collect_wood_1", "Собрать 200 дерева", QuestKind.Collect, 200, 30, R(ResourceType.Gold, 200)).Res(ResourceType.Wood),
|
||||
@@ -130,21 +132,21 @@ public static class GameData
|
||||
new QuestDef("m_build_1", "Построить первое здание", QuestKind.BuildingsBuilt, 1, 50, R(ResourceType.Wood, 300), R(ResourceType.Stone, 200)),
|
||||
new QuestDef("m_barracks", "Построить Казармы", QuestKind.BarracksBuilt, 1, 50, R(ResourceType.Food, 300), R(ResourceType.Wood, 200)),
|
||||
new QuestDef("m_train_10", "Обучить 10 воинов", QuestKind.TroopsTrained, 10, 50, R(ResourceType.Food, 400)),
|
||||
new QuestDef("m_battle_1", "Выиграть первый бой", QuestKind.BattlesWon, 1, 60, R(ResourceType.Gems, 30)),
|
||||
new QuestDef("m_battle_1", "Выиграть первый бой", QuestKind.BattlesWon, 1, 60, R(ResourceType.Gems, 1)),
|
||||
new QuestDef("m_world_send", "Отправить армию в поход на карте мира", QuestKind.WorldMarches, 1, 60, R(ResourceType.Food, 500)),
|
||||
new QuestDef("m_world_win", "Разграбить поселение на карте мира", QuestKind.WorldWins, 1, 100, R(ResourceType.Gems, 50), R(ResourceType.Gold, 500)),
|
||||
new QuestDef("m_world_win", "Разграбить поселение на карте мира", QuestKind.WorldWins, 1, 100, R(ResourceType.Gems, 2), R(ResourceType.Gold, 500)),
|
||||
new QuestDef("m_hero_3", "Герой 3 уровня", QuestKind.HeroLevel, 3, 60, R(ResourceType.Gold, 500)),
|
||||
new QuestDef("m_th_2", "Ратуша 2 уровня", QuestKind.TownHallLevel, 2, 100, R(ResourceType.Gems, 50), R(ResourceType.Stone, 500)),
|
||||
new QuestDef("m_th_2", "Ратуша 2 уровня", QuestKind.TownHallLevel, 2, 100, R(ResourceType.Gems, 1), R(ResourceType.Stone, 500)),
|
||||
new QuestDef("m_build_3", "Построить 3 здания", QuestKind.BuildingsBuilt, 3, 80, R(ResourceType.Wood, 600), R(ResourceType.Iron, 200)),
|
||||
new QuestDef("m_arena_1", "Сразиться на арене", QuestKind.ArenaFights, 1, 60, R(ResourceType.Gems, 20)),
|
||||
new QuestDef("m_arena_1", "Сразиться на арене", QuestKind.ArenaFights, 1, 60, R(ResourceType.Gems, 1)),
|
||||
new QuestDef("m_collect_stone_1", "Собрать 1000 камня", QuestKind.Collect, 1000, 80, R(ResourceType.Gold, 800)).Res(ResourceType.Stone),
|
||||
new QuestDef("m_train_100", "Обучить 100 воинов", QuestKind.TroopsTrained, 100, 120, R(ResourceType.Food, 1500), R(ResourceType.Gems, 30)),
|
||||
new QuestDef("m_battle_5", "Выиграть 5 боёв", QuestKind.BattlesWon, 5, 150, R(ResourceType.Gems, 60)),
|
||||
new QuestDef("m_player_5", "Достигнуть 5 уровня", QuestKind.PlayerLevel, 5, 100, R(ResourceType.Gems, 100)),
|
||||
new QuestDef("m_th_3", "Ратуша 3 уровня", QuestKind.TownHallLevel, 3, 200, R(ResourceType.Gems, 100), R(ResourceType.Iron, 800)),
|
||||
new QuestDef("m_train_100", "Обучить 100 воинов", QuestKind.TroopsTrained, 100, 120, R(ResourceType.Food, 1500), R(ResourceType.Gems, 1)),
|
||||
new QuestDef("m_battle_5", "Выиграть 5 боёв", QuestKind.BattlesWon, 5, 150, R(ResourceType.Gems, 1)),
|
||||
new QuestDef("m_player_5", "Достигнуть 5 уровня", QuestKind.PlayerLevel, 5, 100, R(ResourceType.Gems, 1)),
|
||||
new QuestDef("m_th_3", "Ратуша 3 уровня", QuestKind.TownHallLevel, 3, 200, R(ResourceType.Gems, 2), R(ResourceType.Iron, 800)),
|
||||
new QuestDef("m_hero_10", "Герой 10 уровня", QuestKind.HeroLevel, 10, 200, R(ResourceType.Gold, 3000)),
|
||||
new QuestDef("m_battle_15", "Выиграть 15 боёв", QuestKind.BattlesWon, 15, 300, R(ResourceType.Gems, 150)),
|
||||
new QuestDef("m_th_5", "Ратуша 5 уровня", QuestKind.TownHallLevel, 5, 400, R(ResourceType.Gems, 250)),
|
||||
new QuestDef("m_battle_15", "Выиграть 15 боёв", QuestKind.BattlesWon, 15, 300, R(ResourceType.Gems, 2)),
|
||||
new QuestDef("m_th_5", "Ратуша 5 уровня", QuestKind.TownHallLevel, 5, 400, R(ResourceType.Gems, 2)),
|
||||
};
|
||||
|
||||
// ---------- Ежедневные задания (сбрасываются каждый день) ----------
|
||||
@@ -154,8 +156,8 @@ public static class GameData
|
||||
new QuestDef("d_wood", "Собрать 300 дерева", QuestKind.Collect, 300, 20, R(ResourceType.Food, 200)).Res(ResourceType.Wood),
|
||||
new QuestDef("d_food", "Собрать 300 еды", QuestKind.Collect, 300, 20, R(ResourceType.Wood, 200)).Res(ResourceType.Food),
|
||||
new QuestDef("d_train", "Обучить 20 воинов", QuestKind.TroopsTrained, 20, 25, R(ResourceType.Iron, 150)),
|
||||
new QuestDef("d_battle", "Выиграть 1 бой", QuestKind.BattlesWon, 1, 30, R(ResourceType.Gems, 10)),
|
||||
new QuestDef("d_arena", "2 боя на арене", QuestKind.ArenaFights, 2, 30, R(ResourceType.Gems, 10)),
|
||||
new QuestDef("d_battle", "Выиграть 1 бой", QuestKind.BattlesWon, 1, 30, R(ResourceType.Gold, 200)),
|
||||
new QuestDef("d_arena", "2 боя на арене", QuestKind.ArenaFights, 2, 30, R(ResourceType.Gold, 200)),
|
||||
new QuestDef("d_world", "Отправить поход на карте мира", QuestKind.WorldMarches, 1, 25, R(ResourceType.Gold, 200)),
|
||||
};
|
||||
|
||||
|
||||
@@ -144,6 +144,8 @@ public class GameProgress : MonoBehaviour
|
||||
}
|
||||
if (Data.claimedQuests == null) Data.claimedQuests = new List<string>();
|
||||
if (Data.claimedDaily == null) Data.claimedDaily = new List<string>();
|
||||
if (Data.crystalWaiting == null) Data.crystalWaiting = new List<string>();
|
||||
crystals.Load(Data.crystalWaiting);
|
||||
if (Data.mail == null) Data.mail = new List<MailItem>();
|
||||
if (Data.shopIds == null) Data.shopIds = new List<string>();
|
||||
if (Data.shopCounts == null) Data.shopCounts = new List<int>();
|
||||
@@ -307,24 +309,76 @@ public class GameProgress : MonoBehaviour
|
||||
return true;
|
||||
}
|
||||
|
||||
// Выдать награду. Синие кристаллы -- не сверх предела хранилища (регламент 05.11): не поместившиеся приходят
|
||||
// письмом «Кристаллы ждут места» и забираются из него, когда место появится. Ничего не теряется
|
||||
public void Grant(RewardItem[] rewards, int xp = 0, bool toast = true)
|
||||
{
|
||||
if (ResourceManager.Instance != null && rewards != null)
|
||||
int gems = GemsIn(rewards), fit = CrystalRewards.Fits(gems, GemsHave, GemsCap), left = gems - fit;
|
||||
var given = new List<RewardItem>(WithoutGems(rewards));
|
||||
if (fit > 0) given.Add(new RewardItem(ResourceType.Gems, fit));
|
||||
if (ResourceManager.Instance != null)
|
||||
{
|
||||
suppressTracking++;
|
||||
foreach (var r in rewards) ResourceManager.Instance.AddResource(r.type, r.amount);
|
||||
foreach (var r in given) ResourceManager.Instance.AddResource(r.type, r.amount);
|
||||
suppressTracking--;
|
||||
}
|
||||
if (left > 0)
|
||||
SendMail("Кристаллы ждут места",
|
||||
$"Хранилище синих кристаллов заполнено ({GemsHave:0} / {GemsCap:0}). {left} кристаллов ждут: потратьте кристаллы и заберите их из этого письма.",
|
||||
new RewardItem(ResourceType.Gems, left));
|
||||
if (xp > 0) AddXp(xp);
|
||||
if (toast)
|
||||
{
|
||||
string text = GameData.RewardsText(rewards);
|
||||
string text = GameData.RewardsText(given.ToArray());
|
||||
if (xp > 0) text += (text.Length > 0 ? ", " : "") + xp + " опыта";
|
||||
if (left > 0) text += (text.Length > 0 ? ". " : "") + $"Ещё {left} кристаллов ждут места -- в почте";
|
||||
if (text.Length > 0) UIKit.Toast("Получено: " + text);
|
||||
}
|
||||
MarkChanged();
|
||||
}
|
||||
|
||||
// ---------------- Синие кристаллы при пределе 50 (регламент 05.11, ред. 1.1; правила -- CrystalRewards) ----------------
|
||||
|
||||
readonly CrystalRewards crystals = new CrystalRewards();
|
||||
|
||||
// Нет склада (сцена без ResourceManager) -- места нет: кристаллы ждут, а не теряются
|
||||
public static float GemsHave => ResourceManager.Instance != null ? ResourceManager.Instance.GetResource(ResourceType.Gems) : 0f;
|
||||
public static float GemsCap => ResourceManager.Instance != null ? ResourceManager.Instance.GetCapacity(ResourceType.Gems) : 0f;
|
||||
|
||||
static int GemsIn(IEnumerable<RewardItem> rewards)
|
||||
{
|
||||
int n = 0;
|
||||
if (rewards != null) foreach (var r in rewards) if (r != null && r.type == ResourceType.Gems) n += Mathf.Max(0, r.amount);
|
||||
return n;
|
||||
}
|
||||
|
||||
static RewardItem[] WithoutGems(IEnumerable<RewardItem> rewards)
|
||||
{
|
||||
var list = new List<RewardItem>();
|
||||
if (rewards != null) foreach (var r in rewards) if (r != null && r.type != ResourceType.Gems) list.Add(r);
|
||||
return list.ToArray();
|
||||
}
|
||||
|
||||
static string QuestKey(QuestDef q, bool daily) => (daily ? "d:" : "m:") + q.id;
|
||||
|
||||
// Кристаллы награды задания, которые ждут места (0 -- не ждут)
|
||||
public int QuestGemsWaiting(QuestDef q, bool daily) => crystals.Left(QuestKey(q, daily));
|
||||
|
||||
// Начислить кристаллы награды key: сколько помещается, остаток ждёт под этим key (сохраняется)
|
||||
int GiveQuestGems(string key, int amount)
|
||||
{
|
||||
int give = crystals.Claim(key, amount, GemsHave, GemsCap);
|
||||
if (give > 0 && ResourceManager.Instance != null)
|
||||
{
|
||||
suppressTracking++;
|
||||
ResourceManager.Instance.AddResource(ResourceType.Gems, give);
|
||||
suppressTracking--;
|
||||
}
|
||||
Data.crystalWaiting = crystals.ToLines();
|
||||
MarkChanged();
|
||||
return give;
|
||||
}
|
||||
|
||||
// =====================================================================
|
||||
// ОТСЛЕЖИВАНИЕ СБОРА РЕСУРСОВ (для заданий и статистики)
|
||||
// =====================================================================
|
||||
@@ -455,13 +509,42 @@ public class GameProgress : MonoBehaviour
|
||||
public bool IsQuestClaimed(QuestDef q, bool daily) { return (daily ? Data.claimedDaily : Data.claimedQuests).Contains(q.id); }
|
||||
public bool IsQuestDone(QuestDef q, bool daily) { return QuestProgress(q, daily) >= q.target; }
|
||||
|
||||
// Забрать награду задания. Кристаллы, которым нет места, ждут в задании («Забрать кристаллы»); пока ждут
|
||||
// CrystalRewards.MaxWaiting наград, новое задание с кристаллами, которые не помещаются, не выдаётся (регламент 05.11)
|
||||
public void ClaimQuest(QuestDef q, bool daily)
|
||||
{
|
||||
if (IsQuestClaimed(q, daily) || !IsQuestDone(q, daily)) return;
|
||||
string key = QuestKey(q, daily);
|
||||
if (IsQuestClaimed(q, daily))
|
||||
{
|
||||
if (crystals.Left(key) <= 0) return;
|
||||
int got = GiveQuestGems(key, 0); // дозабрать остаток
|
||||
int still = crystals.Left(key);
|
||||
UIKit.Toast(got > 0 ? $"Получено: {got} кристаллов" + (still > 0 ? $". Ещё {still} ждут места" : "")
|
||||
: $"Нет места для кристаллов ({GemsHave:0} / {GemsCap:0})");
|
||||
return;
|
||||
}
|
||||
if (!IsQuestDone(q, daily)) return;
|
||||
|
||||
int gems = GemsIn(q.rewards);
|
||||
if (!crystals.CanStart(key, gems, GemsHave, GemsCap))
|
||||
{
|
||||
UIKit.Toast($"Нет места для кристаллов ({GemsHave:0} / {GemsCap:0}), и уже ждут {CrystalRewards.MaxWaiting} награды. " +
|
||||
"Потратьте кристаллы или заберите ждущие -- награда этого задания сохранится");
|
||||
return;
|
||||
}
|
||||
(daily ? Data.claimedDaily : Data.claimedQuests).Add(q.id);
|
||||
Data.questsDone++;
|
||||
AddEventPoints(20);
|
||||
Grant(q.rewards, q.xp);
|
||||
|
||||
var rest = WithoutGems(q.rewards);
|
||||
Grant(rest, q.xp, false);
|
||||
int given = gems > 0 ? GiveQuestGems(key, gems) : 0, left = crystals.Left(key);
|
||||
var shown = new List<RewardItem>(rest);
|
||||
if (given > 0) shown.Add(new RewardItem(ResourceType.Gems, given));
|
||||
string text = GameData.RewardsText(shown.ToArray());
|
||||
if (q.xp > 0) text += (text.Length > 0 ? ", " : "") + q.xp + " опыта";
|
||||
if (left > 0) text += $". Ещё {left} кристаллов ждут места -- «Забрать» в заданиях";
|
||||
UIKit.Toast("Получено: " + text);
|
||||
}
|
||||
|
||||
public int ClaimableQuestsCount
|
||||
@@ -471,6 +554,12 @@ public class GameProgress : MonoBehaviour
|
||||
int n = 0;
|
||||
foreach (var q in GameData.MainQuests) if (!IsQuestClaimed(q, false) && IsQuestDone(q, false)) n++;
|
||||
foreach (var q in GameData.DailyQuests) if (!IsQuestClaimed(q, true) && IsQuestDone(q, true)) n++;
|
||||
// кристаллы, которые ждали места, -- когда место появилось
|
||||
if (crystals.WaitingCount > 0 && CrystalRewards.Free(GemsHave, GemsCap) > 0)
|
||||
{
|
||||
foreach (var q in GameData.MainQuests) if (IsQuestClaimed(q, false) && QuestGemsWaiting(q, false) > 0) n++;
|
||||
foreach (var q in GameData.DailyQuests) if (IsQuestClaimed(q, true) && QuestGemsWaiting(q, true) > 0) n++;
|
||||
}
|
||||
return n;
|
||||
}
|
||||
}
|
||||
@@ -525,11 +614,27 @@ public class GameProgress : MonoBehaviour
|
||||
|
||||
public void ReadMail(MailItem m) { if (m != null && !m.read) { m.read = true; MarkChanged(); } }
|
||||
|
||||
public void ClaimMail(MailItem m, bool toast = true)
|
||||
// Забрать подарок письма. Кристаллы -- сколько помещается; остаток остаётся в этом же письме (регламент 05.11).
|
||||
// false -- в письме остались кристаллы, которым нет места
|
||||
public bool ClaimMail(MailItem m, bool toast = true)
|
||||
{
|
||||
if (m == null || m.claimed || !m.HasRewards) return;
|
||||
m.claimed = true; m.read = true;
|
||||
Grant(m.rewards.ToArray(), 0, toast);
|
||||
if (m == null || m.claimed || !m.HasRewards) return true;
|
||||
m.read = true;
|
||||
int gems = GemsIn(m.rewards), fit = CrystalRewards.Fits(gems, GemsHave, GemsCap), left = gems - fit;
|
||||
var give = new List<RewardItem>(WithoutGems(m.rewards));
|
||||
if (give.Count == 0 && fit == 0)
|
||||
{
|
||||
if (toast) UIKit.Toast($"Нет места для кристаллов ({GemsHave:0} / {GemsCap:0}) -- подарок подождёт в письме");
|
||||
MarkChanged();
|
||||
return false;
|
||||
}
|
||||
if (fit > 0) give.Add(new RewardItem(ResourceType.Gems, fit));
|
||||
m.rewards.Clear();
|
||||
if (left > 0) m.rewards.Add(new RewardItem(ResourceType.Gems, left)); // остаток ждёт в письме
|
||||
else m.claimed = true;
|
||||
Grant(give.ToArray(), 0, toast); // всё помещается: Grant ничего не отложит
|
||||
if (toast && left > 0) UIKit.Toast($"Ещё {left} кристаллов ждут места -- в этом письме");
|
||||
return left == 0;
|
||||
}
|
||||
|
||||
public void DeleteMail(MailItem m)
|
||||
@@ -542,13 +647,16 @@ public class GameProgress : MonoBehaviour
|
||||
|
||||
public void ClaimAllMail()
|
||||
{
|
||||
var all = new List<RewardItem>();
|
||||
foreach (var m in Data.mail)
|
||||
int gifts = 0, waiting = 0;
|
||||
foreach (var m in Data.mail.ToArray())
|
||||
{
|
||||
m.read = true;
|
||||
if (m.HasRewards && !m.claimed) { m.claimed = true; all.AddRange(m.rewards); }
|
||||
if (!m.HasRewards || m.claimed) continue;
|
||||
gifts++;
|
||||
if (!ClaimMail(m, false)) waiting++;
|
||||
}
|
||||
if (all.Count > 0) Grant(all.ToArray()); else UIKit.Toast("Все письма прочитаны");
|
||||
if (gifts == 0) UIKit.Toast("Все письма прочитаны");
|
||||
else UIKit.Toast(waiting == 0 ? "Подарки получены" : $"Подарки получены. В {waiting} письмах кристаллы ждут места");
|
||||
MarkChanged();
|
||||
}
|
||||
|
||||
|
||||
@@ -69,6 +69,8 @@ public class ProgressData
|
||||
public List<string> claimedQuests = new List<string>();
|
||||
public int dailyDay = -1;
|
||||
public List<string> claimedDaily = new List<string>();
|
||||
// Кристаллы наград заданий, которым не хватило места (регламент 05.11): "m:<id задания>|сколько"
|
||||
public List<string> crystalWaiting = new List<string>();
|
||||
public float[] dailyCollected = new float[8];
|
||||
public int dailyBattles;
|
||||
public int dailyTroops;
|
||||
|
||||
@@ -32,7 +32,9 @@ public class QuestsPanel : BasePanel
|
||||
var shown = new List<QuestDef>();
|
||||
int claimedCount = 0;
|
||||
|
||||
// Сначала готовые к получению, потом в процессе. В основных -- не больше 6 активных.
|
||||
// Сначала кристаллы, которые ждут места (регламент 05.11), потом готовые к получению, потом в процессе.
|
||||
// В основных -- не больше 6 активных.
|
||||
foreach (var q in source) if (GP.IsQuestClaimed(q, daily) && GP.QuestGemsWaiting(q, daily) > 0) shown.Add(q);
|
||||
foreach (var q in source) if (!GP.IsQuestClaimed(q, daily) && GP.IsQuestDone(q, daily)) shown.Add(q);
|
||||
foreach (var q in source)
|
||||
{
|
||||
@@ -62,6 +64,15 @@ public class QuestsPanel : BasePanel
|
||||
UIKit.Bar(card, (float)progress / q.target, done ? UIKit.Theme.Green : UIKit.Theme.Gold, 30, progress + " / " + q.target);
|
||||
|
||||
var row = UIKit.Row(card, 80, 12);
|
||||
int waiting = GP.QuestGemsWaiting(q, daily);
|
||||
if (waiting > 0)
|
||||
{
|
||||
// Награда получена, кроме кристаллов: им не было места (хранилище 50). Ждут, не сгорают
|
||||
var wait = UIKit.Text(row, $"Ждут места: {waiting} кристаллов (хранилище {GameProgress.GemsHave:0} / {GameProgress.GemsCap:0})", 24, UIKit.Theme.Gold);
|
||||
UIKit.Size(wait, -1, -1, 1);
|
||||
UIKit.Button(row, "Забрать", UIKit.Theme.Green, () => GP.ClaimQuest(q, daily), 220, 80);
|
||||
return;
|
||||
}
|
||||
var reward = UIKit.Text(row, "Награда: " + GameData.RewardsText(q.rewards) + ", " + q.xp + " опыта", 24, UIKit.Theme.TextDim);
|
||||
UIKit.Size(reward, -1, -1, 1);
|
||||
var b = UIKit.Button(row, done ? "Забрать" : "В процессе", done ? UIKit.Theme.Green : UIKit.Theme.Gray,
|
||||
|
||||
@@ -4,7 +4,8 @@ using UnityEngine.UI;
|
||||
using TMPro;
|
||||
|
||||
// ВОЙСКА: армия, очередь обучения (идёт и когда игра закрыта), найм воинов за ресурсы,
|
||||
// ускорение за кристаллы. Новые типы войск открываются уровнем Ратуши.
|
||||
// ускорение за кристаллы. Найм -- карточки войск в ряд (листаются вбок): иконка, сила, цена,
|
||||
// против кого силён и слаб (из правил боя). Новые типы войск открывают уровни Ратуши и Казарм.
|
||||
public class TroopsPanel : BasePanel
|
||||
{
|
||||
readonly List<Image> queueBars = new List<Image>();
|
||||
@@ -13,6 +14,23 @@ public class TroopsPanel : BasePanel
|
||||
|
||||
static readonly int[] Amounts = { 1, 10, 50 };
|
||||
|
||||
int shownBarracks; // сколько Казарм было, когда окно рисовалось: достроились -- перерисовать
|
||||
TextMeshProUGUI barracksBuildingLeft; // «Осталось: 0:45», пока Казармы строятся
|
||||
|
||||
// Казармы, которые ещё строятся: сколько секунд до ближайшего конца; -1 -- таких нет
|
||||
static double BarracksConstructionSecondsLeft()
|
||||
{
|
||||
double best = -1;
|
||||
foreach (var t in BuildTimer.All)
|
||||
{
|
||||
if (t == null || !t.IsConstruction) continue;
|
||||
var id = t.GetComponent<PlacedBuildingId>();
|
||||
if (id == null || id.buildingName != BarracksBootstrap.BarracksName) continue;
|
||||
if (best < 0 || t.SecondsLeft < best) best = t.SecondsLeft;
|
||||
}
|
||||
return best;
|
||||
}
|
||||
|
||||
protected override void Build(RectTransform root)
|
||||
{
|
||||
var d = GP.Data;
|
||||
@@ -72,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);
|
||||
}
|
||||
}
|
||||
|
||||
// --- Очередь обучения ---
|
||||
@@ -108,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 сообщает об изменении
|
||||
@@ -152,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++)
|
||||
{
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
{
|
||||
|
||||
@@ -32,6 +32,15 @@ public struct GameTimeAdvancedEvent
|
||||
public long UnixNow;
|
||||
}
|
||||
|
||||
// Расчёт игры (модуль Населения: добыча, еда, прирост) дошёл до момента UnixNow (04.11).
|
||||
// Приходит в начале каждого шага расчёта (раз в 10 игровых минут офлайна) и в конце каждого Tick.
|
||||
// Всё, что должно случиться по времени (стройка закончилась), применяется здесь -- тогда следующий шаг
|
||||
// уже считается с новым городом, и добыча идёт ровно с момента завершения, а не с начала офлайна.
|
||||
public struct GameTimeSimulatedEvent
|
||||
{
|
||||
public long UnixNow;
|
||||
}
|
||||
|
||||
// ---------------- Население ----------------
|
||||
// Число людей изменилось (прирост, загрузка, сброс, изменение войска)
|
||||
public struct PopulationChangedEvent
|
||||
|
||||
@@ -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:
|
||||
@@ -234,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;
|
||||
@@ -252,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 });
|
||||
}
|
||||
|
||||
// ---------------- Расчёт ----------------
|
||||
@@ -273,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
|
||||
@@ -152,6 +152,29 @@ namespace Bogatyri.Settlement.Placement
|
||||
// Проверить место: прямоугольник 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);
|
||||
@@ -167,7 +190,7 @@ namespace Bogatyri.Settlement.Placement
|
||||
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 (obstacleAt.ContainsKey(i)) return PlaceError.Obstacle;
|
||||
if (!ignoreObstacles && obstacleAt.ContainsKey(i)) return PlaceError.Obstacle;
|
||||
min = Math.Min(min, heightMm[i]);
|
||||
max = Math.Max(max, heightMm[i]);
|
||||
}
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
|
||||
@@ -35,6 +35,24 @@ public class PlacementTests
|
||||
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()
|
||||
{
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -245,4 +245,187 @@ public class ProductionTests
|
||||
Assert.AreEqual(40, copy.Slots(WorkJob.Stonecutter));
|
||||
Assert.AreEqual(1.1f, copy.Efficiency(WorkJob.Stonecutter), 0.0001f);
|
||||
}
|
||||
|
||||
// ---------------- Офлайн по временным меткам (решения 04.11) ----------------
|
||||
// Стройка, закончившаяся в офлайне, вступает в силу в момент расчёта, когда время дошло до её конца
|
||||
// (GameTimeSimulatedEvent). В игре это делает App/ConstructionClock; здесь -- подписка в тесте.
|
||||
|
||||
void CompleteUpgradeAt(long atUnix, List<KeyValuePair<WorkJob, int>> after)
|
||||
{
|
||||
bool done = false;
|
||||
EventBus.Subscribe<GameTimeSimulatedEvent>(e =>
|
||||
{
|
||||
if (done || e.UnixNow < atUnix) return;
|
||||
done = true;
|
||||
production.SetBuildings(after);
|
||||
});
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Offline_UpgradeFinishedMidway_ProducesNewRateOnlyFromCompletion()
|
||||
{
|
||||
economy.Set(ResourceType.Food, 100000);
|
||||
economy.Set(ResourceType.Wood, 0);
|
||||
production.SetBuildings(Buildings((WorkJob.Woodcutter, 1)));
|
||||
population.SetWorkers(WorkJob.Woodcutter, 30); // 60 дерева в час, на 2 уровне -- 66
|
||||
CompleteUpgradeAt(T0 + 2 * Hour, Buildings((WorkJob.Woodcutter, 2)));
|
||||
|
||||
population.Tick(T0 + 4 * Hour); // 4 часа офлайна, улучшение закончилось через 2 часа
|
||||
|
||||
Assert.AreEqual(2 * 60 + 2 * 66, economy.Get(ResourceType.Wood), 0.01f,
|
||||
"2 часа по 60 + 2 часа по 66, а не все 4 часа по новой норме");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Offline_FinishedInsideStep_CountsFromNextStep_NeverEarlier()
|
||||
{
|
||||
economy.Set(ResourceType.Food, 100000);
|
||||
economy.Set(ResourceType.Wood, 0);
|
||||
production.SetBuildings(Buildings((WorkJob.Woodcutter, 1)));
|
||||
population.SetWorkers(WorkJob.Woodcutter, 30);
|
||||
CompleteUpgradeAt(T0 + 2 * Hour + 5 * 60, Buildings((WorkJob.Woodcutter, 2))); // посреди 10-минутного шага
|
||||
|
||||
population.Tick(T0 + 4 * Hour);
|
||||
|
||||
float wood = economy.Get(ResourceType.Wood);
|
||||
Assert.LessOrEqual(wood, 125 / 60f * 60 + 115 / 60f * 66 + 0.01f, "не больше, чем если бы новая норма шла ровно с конца стройки");
|
||||
Assert.AreEqual(130 / 60f * 60 + 110 / 60f * 66, wood, 0.01f, "с ближайшего шага расчёта после конца стройки");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Tick_ClockWentBack_StillReportsSimulatedTime_NoResources()
|
||||
{
|
||||
// «+1 час», потом «Сбросить перемотку»: часы ушли назад. Стройка, у которой время вышло, должна завершиться
|
||||
// (событие о расчёте приходит), а ресурсы за уже посчитанный час второй раз не начисляются (05.11)
|
||||
economy.Set(ResourceType.Food, 100000);
|
||||
economy.Set(ResourceType.Wood, 0);
|
||||
production.SetBuildings(Buildings((WorkJob.Woodcutter, 1)));
|
||||
population.SetWorkers(WorkJob.Woodcutter, 30);
|
||||
population.Tick(T0);
|
||||
population.Tick(T0 + Hour);
|
||||
float wood = economy.Get(ResourceType.Wood);
|
||||
|
||||
long reported = 0;
|
||||
EventBus.Subscribe<GameTimeSimulatedEvent>(e => reported = e.UnixNow);
|
||||
population.Tick(T0 + 60);
|
||||
|
||||
Assert.AreEqual(T0 + 60, reported, "расчёт сообщает, до какого момента дошло время -- стройки завершаются");
|
||||
Assert.AreEqual(wood, economy.Get(ResourceType.Wood), 0.01f, "за уже посчитанное время ресурсов не прибавилось");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Tick_SameMomentTwice_NoExtraResources()
|
||||
{
|
||||
economy.Set(ResourceType.Food, 100000);
|
||||
production.SetBuildings(Buildings((WorkJob.Woodcutter, 1)));
|
||||
population.SetWorkers(WorkJob.Woodcutter, 30);
|
||||
population.Tick(T0 + Hour);
|
||||
float wood = economy.Get(ResourceType.Wood);
|
||||
|
||||
population.Tick(T0 + Hour);
|
||||
population.Tick(T0 + Hour - 60); // часы ушли назад -- ничего не начисляется
|
||||
population.Tick(T0 + Hour);
|
||||
Assert.AreEqual(wood, economy.Get(ResourceType.Wood), 0.001f);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Restore_SameMoment_NoSecondCredit()
|
||||
{
|
||||
economy.Set(ResourceType.Food, 100000);
|
||||
production.SetBuildings(Buildings((WorkJob.Woodcutter, 1)));
|
||||
population.SetWorkers(WorkJob.Woodcutter, 30);
|
||||
population.Tick(T0 + Hour);
|
||||
float wood = economy.Get(ResourceType.Wood);
|
||||
|
||||
var copy = new PopulationService(new PopulationRules());
|
||||
copy.Restore(population.Capture()); // как загрузка сохранения: метка последнего расчёта та же
|
||||
GameServices.Register<IPopulation>(copy);
|
||||
copy.Tick(T0 + Hour);
|
||||
Assert.AreEqual(wood, economy.Get(ResourceType.Wood), 0.001f, "загрузка не начисляет то же время второй раз");
|
||||
|
||||
copy.Tick(T0 + 2 * Hour);
|
||||
Assert.AreEqual(wood + 60, economy.Get(ResourceType.Wood), 0.01f, "а следующий час -- ровно один раз");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void SimulatedEvents_GoForwardStepByStep_EndAtNow()
|
||||
{
|
||||
var times = new List<long>();
|
||||
EventBus.Subscribe<GameTimeSimulatedEvent>(e => times.Add(e.UnixNow));
|
||||
population.Tick(T0 + Hour);
|
||||
|
||||
Assert.AreEqual(T0 + Hour, times[times.Count - 1], "последнее событие -- момент Tick");
|
||||
for (int i = 1; i < times.Count; i++) Assert.GreaterOrEqual(times[i], times[i - 1], "время расчёта не идёт назад");
|
||||
Assert.AreEqual(7, times.Count, "6 шагов по 10 минут + конец");
|
||||
}
|
||||
}
|
||||
|
||||
// Синие кристаллы при пределе 50 (Регламент редкости синих кристаллов от 05.11.2026, ред. 1.1, раздел 6)
|
||||
public class CrystalRewardsTests
|
||||
{
|
||||
const float Cap = 50f;
|
||||
|
||||
[Test]
|
||||
public void Crystals_49of50_Reward2_OneGiven_OneWaits()
|
||||
{
|
||||
var r = new CrystalRewards();
|
||||
Assert.AreEqual(1, r.Claim("q1", 2, 49, Cap), "49/50 + 2 -> выдан 1");
|
||||
Assert.AreEqual(1, r.Left("q1"), "1 ждёт места");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Crystals_Full_OldReward15_NothingGiven_NothingLost()
|
||||
{
|
||||
var r = new CrystalRewards();
|
||||
Assert.AreEqual(0, r.Claim("old", 15, 50, Cap), "50/50 + 15 из старых данных -> 0 выдано");
|
||||
Assert.AreEqual(15, r.Left("old"), "награда ждёт целиком, ничего не исчезло");
|
||||
Assert.AreEqual(10, r.Claim("old", 15, 40, Cap), "освободили 10 -- выдано 10");
|
||||
Assert.AreEqual(5, r.Left("old"));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Crystals_RepeatClaim_NeverGivesTwice()
|
||||
{
|
||||
var r = new CrystalRewards();
|
||||
r.Claim("q", 2, 49, Cap); // выдан 1, ждёт 1
|
||||
Assert.AreEqual(0, r.Claim("q", 2, 50, Cap), "повторное «Забрать» при 50/50 -- ничего");
|
||||
Assert.AreEqual(1, r.Left("q"), "остаток не вырос: награда одна");
|
||||
Assert.AreEqual(1, r.Claim("q", 2, 30, Cap), "место есть -- выдан только остаток 1, а не 2");
|
||||
Assert.AreEqual(0, r.Left("q"));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Crystals_AtMostTwoWaiting_ThirdNotStarted_FittingAlwaysOk()
|
||||
{
|
||||
var r = new CrystalRewards();
|
||||
r.Claim("a", 2, 50, Cap);
|
||||
r.Claim("b", 2, 50, Cap);
|
||||
Assert.AreEqual(2, r.WaitingCount);
|
||||
Assert.IsFalse(r.CanStart("c", 2, 50, Cap), "третья награда при 50/50 не выдаётся -- задание остаётся «Забрать»");
|
||||
Assert.IsTrue(r.CanStart("a", 2, 50, Cap), "ждущую можно дозабирать");
|
||||
Assert.IsTrue(r.CanStart("c", 2, 40, Cap), "помещается целиком -- место ожидания не нужно");
|
||||
Assert.IsTrue(r.CanStart("d", 0, 50, Cap), "награда без кристаллов -- всегда");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Crystals_Waiting_SurvivesSaveAndLoad()
|
||||
{
|
||||
var r = new CrystalRewards();
|
||||
r.Claim("m:m_th_3", 2, 50, Cap);
|
||||
r.Claim("mail_7", 5, 48, Cap);
|
||||
var copy = new CrystalRewards();
|
||||
copy.Load(r.ToLines());
|
||||
Assert.AreEqual(2, copy.Left("m:m_th_3"));
|
||||
Assert.AreEqual(3, copy.Left("mail_7"));
|
||||
Assert.AreEqual(2, copy.WaitingCount);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Crystals_NeverAboveCap()
|
||||
{
|
||||
Assert.AreEqual(0, CrystalRewards.Free(50, Cap));
|
||||
Assert.AreEqual(0, CrystalRewards.Free(51, Cap), "выше предела (старые данные) -- места нет, а не минус");
|
||||
Assert.AreEqual(3, CrystalRewards.Fits(10, 47, Cap));
|
||||
Assert.AreEqual(10, CrystalRewards.Fits(10, 0, float.PositiveInfinity), "без предела -- целиком");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -171,7 +171,11 @@ public class ChatPanel : MonoBehaviour
|
||||
{
|
||||
inputField.text = "";
|
||||
}
|
||||
inputField.ActivateInputField(); // курсор остаётся в поле -- можно писать дальше
|
||||
// ПК: курсор остаётся в поле -- можно писать дальше.
|
||||
// Телефон: экранная клавиатура при «Отправить» закрывается сама, и повторная активация тут же открывала её
|
||||
// снова -- клавиатура «мигала» (05.11). Теперь она закрывается полностью, до следующего касания поля
|
||||
if (TouchScreenKeyboard.isSupported) inputField.DeactivateInputField();
|
||||
else inputField.ActivateInputField();
|
||||
}
|
||||
|
||||
static IChat Chat => GameServices.Get<IChat>();
|
||||
|
||||
@@ -4,9 +4,15 @@ using UnityEngine.SceneManagement;
|
||||
// Переходы город <-> карта мира
|
||||
public static class SceneNavigator
|
||||
{
|
||||
public const string CitySceneName = "MVP1";
|
||||
// Город -- 3D-поселение (решение 05.11). Старый 2D-город (MVP1) исключён из сборки; если его открыть в редакторе,
|
||||
// с карты мира вернёт в него же (lastCityScene)
|
||||
public const string CitySceneName = "City3D";
|
||||
public const string OldCitySceneName = "MVP1";
|
||||
public const string WorldMapSceneName = "WorldMap";
|
||||
|
||||
// Из какого города ушли на карту мира: 2D (MVP1) или 3D (City3D) -- туда и возвращаемся (05.11)
|
||||
static string lastCityScene = CitySceneName;
|
||||
|
||||
public static void OpenWorldMap()
|
||||
{
|
||||
if (!Application.CanStreamedLevelBeLoaded(WorldMapSceneName))
|
||||
@@ -20,12 +26,15 @@ public static class SceneNavigator
|
||||
// и SaveManager берёт их из последнего сохранения
|
||||
if (SaveManager.Instance != null) SaveManager.Instance.SaveGame();
|
||||
|
||||
lastCityScene = SceneManager.GetActiveScene().name;
|
||||
SceneManager.LoadScene(WorldMapSceneName);
|
||||
}
|
||||
|
||||
public static void ReturnToCity()
|
||||
{
|
||||
if (SaveManager.Instance != null) SaveManager.Instance.SaveGame();
|
||||
SceneManager.LoadScene(CitySceneName);
|
||||
string city = Application.CanStreamedLevelBeLoaded(lastCityScene) ? lastCityScene
|
||||
: Application.CanStreamedLevelBeLoaded(CitySceneName) ? CitySceneName : OldCitySceneName;
|
||||
SceneManager.LoadScene(city);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -121,7 +121,7 @@ public class WorldMapController : MonoBehaviour
|
||||
hintText.text = DefaultHint;
|
||||
|
||||
if (ResourceManager.Instance == null)
|
||||
hintText.text = "Запусти игру из сцены города (MVP1), иначе войско неоткуда брать";
|
||||
hintText.text = "Запусти игру из сцены города (City3D), иначе войско неоткуда брать";
|
||||
}
|
||||
|
||||
void OnEnable()
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
%YAML 1.1
|
||||
%TAG !u! tag:unity3d.com,2011:
|
||||
--- !u!114 &11400000
|
||||
MonoBehaviour:
|
||||
m_ObjectHideFlags: 0
|
||||
m_CorrespondingSourceObject: {fileID: 0}
|
||||
m_PrefabInstance: {fileID: 0}
|
||||
m_PrefabAsset: {fileID: 0}
|
||||
m_GameObject: {fileID: 0}
|
||||
m_Enabled: 1
|
||||
m_EditorHideFlags: 0
|
||||
m_Script: {fileID: 11500000, guid: de640fe3d0db1804a85f9fc8f5cadab6, type: 3}
|
||||
m_Name: Renderer3D
|
||||
m_EditorClassIdentifier: Unity.RenderPipelines.Universal.Runtime::UnityEngine.Rendering.Universal.UniversalRendererData
|
||||
debugShaders:
|
||||
debugReplacementPS: {fileID: 4800000, guid: cf852408f2e174538bcd9b7fda1c5ae7, type: 3}
|
||||
hdrDebugViewPS: {fileID: 4800000, guid: 573620ae32aec764abd4d728906d2587, type: 3}
|
||||
probeVolumeSamplingDebugComputeShader: {fileID: 7200000, guid: 53626a513ea68ce47b59dc1299fe3959, type: 3}
|
||||
probeVolumeResources:
|
||||
probeVolumeDebugShader: {fileID: 0}
|
||||
probeVolumeFragmentationDebugShader: {fileID: 0}
|
||||
probeVolumeOffsetDebugShader: {fileID: 0}
|
||||
probeVolumeSamplingDebugShader: {fileID: 0}
|
||||
probeSamplingDebugMesh: {fileID: 0}
|
||||
probeSamplingDebugTexture: {fileID: 0}
|
||||
probeVolumeBlendStatesCS: {fileID: 0}
|
||||
m_RendererFeatures: []
|
||||
m_RendererFeatureMap:
|
||||
xrSystemData: {fileID: 0}
|
||||
postProcessData: {fileID: 0}
|
||||
m_AssetVersion: 3
|
||||
m_PrepassLayerMask:
|
||||
serializedVersion: 2
|
||||
m_Bits: 4294967295
|
||||
m_OpaqueLayerMask:
|
||||
serializedVersion: 2
|
||||
m_Bits: 4294967295
|
||||
m_TransparentLayerMask:
|
||||
serializedVersion: 2
|
||||
m_Bits: 4294967295
|
||||
m_DefaultStencilState:
|
||||
overrideStencilState: 0
|
||||
stencilReference: 0
|
||||
stencilCompareFunction: 8
|
||||
passOperation: 2
|
||||
failOperation: 0
|
||||
zFailOperation: 0
|
||||
m_ShadowTransparentReceive: 1
|
||||
m_RenderingMode: 0
|
||||
m_DepthPrimingMode: 0
|
||||
m_CopyDepthMode: 1
|
||||
m_DepthAttachmentFormat: 0
|
||||
m_DepthTextureFormat: 0
|
||||
m_AccurateGbufferNormals: 0
|
||||
m_IntermediateTextureMode: 1
|
||||
m_TileOnlyMode: 0
|
||||
@@ -0,0 +1,8 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 579a321e304bee74a956dcc2b1c02da0
|
||||
NativeFormatImporter:
|
||||
externalObjects: {}
|
||||
mainObjectFileID: 11400000
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -18,6 +18,7 @@ MonoBehaviour:
|
||||
m_RendererData: {fileID: 0}
|
||||
m_RendererDataList:
|
||||
- {fileID: 11400000, guid: 424799608f7334c24bf367e4bbfa7f9a, type: 2}
|
||||
- {fileID: 11400000, guid: 579a321e304bee74a956dcc2b1c02da0, type: 2}
|
||||
m_DefaultRendererIndex: 0
|
||||
m_RequireDepthTexture: 0
|
||||
m_RequireOpaqueTexture: 0
|
||||
|
||||
@@ -65,5 +65,6 @@
|
||||
|
||||
| Дата | Кто | Что |
|
||||
| --- | --- | --- |
|
||||
| 05.11.2026 | Claude (Саша) | Телефон: после «Отправить» экранная клавиатура закрывается полностью (раньше поле сразу активировалось снова, и клавиатура исчезала и тут же появлялась). На ПК курсор остаётся в поле, как раньше. |
|
||||
| 19.10.2026 | Claude | Канал «Круг» привязан к модулю Круга: `IChat.CanWrite`, системные сообщения о вступлении и выходе, подсказка и выключенный ввод без Круга. Демо-сообщения в канале «Круг» убраны. |
|
||||
| 19.10.2026 | Claude | Чат вынесен в отдельный модуль `Bogatyri.Chat`: контракт `IChat`, событие `ChatMessageReceivedEvent`, `LocalPlayer` в ядре. Панель чата больше не обращается к `MarchManager` и `ChatService` напрямую. Добавлены разворот на весь экран и тесты (пустое сообщение, обрезка, событие, предел истории). |
|
||||
|
||||
@@ -36,6 +36,7 @@
|
||||
| `IWorld` | Мир (`MarchManager`) | Сколько походов в пути; начать поход; начать поход «Разведка» (`TryStartScout`). |
|
||||
| `IWorldMap` | Мир (`MarchManager`) | Для Разведки: границы карты, где поселение, все армии в момент времени (`ArmyOnMap`). |
|
||||
| `IIntelligence` | Разведка (`IntelligenceService`) | Туман (`GetVisibility`), «Обзор», известные чужие армии (`KnownContact`), `ObserveUntil`. |
|
||||
| `IResourceProtection` | Город — Склады и Амбары (`App/ResourceProtection`, регистрирует `App/GameModules`) | Сколько ресурса спрятано от грабежа (`Protected`) и сколько можно унести в набеге (`Lootable`; кристаллы — только с технологией «Грабёж»: `Lootable(type, attackerHasPlunder)`). 01.11 / 04.11, ТЕСТ. |
|
||||
| `IHospital` | Город — здание «Госпиталь» (`HospitalBeds`, регистрирует `App/GameModules`) | Мест для лечения (`Beds`) по уровню самого прокачанного госпиталя; 0 — госпиталя нет. |
|
||||
| `IBattleSystem` | Бой (`BattleSystem`) | Посчитать исход боя по `BattleRequest`: состав сторон по типам, стены, бонусы. |
|
||||
| `IChat` | Чат (`ChatService`) | История каналов, можно ли писать в канал, отправка сообщения от локального игрока. |
|
||||
@@ -61,6 +62,8 @@
|
||||
|
||||
| Дата | Кто | Что |
|
||||
| --- | --- | --- |
|
||||
| 04.11.2026 | Claude | Событие `GameTimeSimulatedEvent` (расчёт дошёл до момента — для стройки в офлайне). `IResourceProtection.Lootable(type, attackerHasPlunder)` — кристаллы и технология «Грабёж». Существующие контракты не менялись. |
|
||||
| 01.11.2026 | Claude | Контракт `IResourceProtection` (защита ресурсов от грабежа): для будущих набегов на города — Бой и Мир берут отсюда, сколько можно унести. |
|
||||
| 04.11.2026 | Claude | Разведка М2: `ReconTargetKind`, `ReconTarget`, `ReconReport`; `IWorldMap.TryGetReconTarget`; `IIntelligence.ReconDurationMs/CompleteRecon/Reports/TryGetLatestReport`; `IWorld.TryStartRecon`; события `ReconStartedEvent`, `ReconCompletedEvent`. |
|
||||
| 04.11.2026 | Claude | Пропавшие без вести: `MissingInfo`, `IArmy.MissingTotal/Missing/MarkMissing/ProcessMissing`, `BattleOutcome.*MissingUnits`, события `MissingReturnedEvent`, `MissingWrittenOffEvent`, поля `Missing` у `TroopsChangedEvent` и `MarchReturnedEvent`. |
|
||||
| 01.11.2026 | Claude | Раненые (решения 31.10, п. 7): `IHospital`, `HealingInfo`; `IArmy`: `WoundedTotal`, `GetWoundedWaiting`, `Healing`, `HospitalBeds`, `ReturnUnits(…, woundedUnits, nowMs)`, `ProcessHealing`; события `SoldiersHealedEvent`, `TroopsChangedEvent.Wounded`, `MarchReturnedEvent.Wounded`. |
|
||||
|
||||
@@ -88,6 +88,17 @@
|
||||
| `App/BuildingWindow.cs` | Окно здания и рабочих. |
|
||||
| `App/StorageBuilding.cs`, `App/StorageBootstrap.cs` | Склад и Амбар. |
|
||||
|
||||
## Синие кристаллы при пределе 50 (регламент 05.11.2026, ред. 1.1)
|
||||
|
||||
Предел — строго 50, не повышается. Правила — `Modules/Economy/Rules/CrystalRewards.cs` (без Unity), тесты — `CrystalRewardsTests`.
|
||||
|
||||
- **Не сверх предела и без потерь.** Кристаллы, которым нет места, не начисляются и не пропадают.
|
||||
- **Задания.** Выдаётся, сколько помещается; остаток ждёт в задании (в окне заданий — «Ждут места: N кристаллов», кнопка «Забрать»), не сгорает. Одновременно ждут не больше **2** наград; пока оба места заняты, задание, кристаллы которого не помещаются целиком, не выдаётся совсем — остаётся «Забрать». Сохраняется в `progress.json`: `crystalWaiting` («m:<задание>|сколько»).
|
||||
- **Остальные награды** (вход, события, бои, почта уровня): то, что не поместилось, приходит письмом «Кристаллы ждут места». Из письма забирается, сколько помещается, остаток остаётся в письме.
|
||||
- **Числа заданий (разд. 3):** простое — 1 кристалл, сложное — 2 (было 20–250). В ежедневных заданиях кристаллов нет (было 10) — вместо них 200 золота. ТЕСТ.
|
||||
- **Ещё не сделано:** Кристальная шахта (3 за 7 реальных суток), недельный PvE-предел 6, точки на карте, торговля, грабёж с пределом получателя.
|
||||
- **Вопросы команде:** источники, которых нет в регламенте, — награды за вход (50 и 200), письмо-приветствие (100), подарок за уровень (10 × уровень), события, обмен золота на кристаллы и покупки в магазине. Сейчас они не теряют кристаллы, но дают их много.
|
||||
|
||||
## Таблица баланса `EconomyConfig`
|
||||
|
||||
Все числа экономики — в ассете `Assets/Resources/EconomyConfig.asset` (класс `App/EconomyConfig.cs`, слой приложения). Меняются в Inspector без перекомпиляции. Разделы: население, стартовые ресурсы новой игры, производство T1, месторождения, цена солдата. Числа с пометкой «не утверждено» — временные.
|
||||
@@ -109,6 +120,9 @@
|
||||
|
||||
| Дата | Кто | Что |
|
||||
| --- | --- | --- |
|
||||
| 05.11.2026 | Claude (Саша) | Регламент синих кристаллов, пп. 1 и 3: кристаллы не начисляются сверх 50 и не теряются — награды заданий ждут (не больше 2), остальные — письмом «Кристаллы ждут места», из письма забирается сколько помещается (`CrystalRewards`, `GameProgress.Grant/ClaimQuest/ClaimMail`, 6 тестов). Задания — 1–2 кристалла вместо 20–250, ежедневные — без кристаллов. |
|
||||
| 04.11.2026 | Claude | Решения 04.11: кристаллы грабятся только нападающим с технологией «Грабёж» (`IResourceProtection.Lootable(type, attackerHasPlunder)`; с ней — не больше `plunderGemsShare` запаса, ТЕСТ 10%, точные ограничения — позже). Защита остальных ресурсов без изменений. Из `EconomyConfig` убран возврат при сносе. Офлайн: добыча здания, достроенного в офлайне, — с момента завершения; уже посчитанное время второй раз не начисляется (тесты в `ProductionTests`). |
|
||||
| 01.11.2026 | Claude | `EconomyConfig`: разделы «Стройка» (время, строители, ускорение, возврат при сносе), «Лимит зданий по уровню Ратуши», «Защита ресурсов от грабежа» — всё ТЕСТ. Защита: без Складов 100 каждого ресурса, +200 за уровень Склада; еда 100, +300 за уровень Амбара; кристаллы не грабятся. Контракт `IResourceProtection` (`App/ResourceProtection`), в окне Склада и Амбара — «спрятано / можно унести». |
|
||||
| 31.10.2026 | Claude | Новые места после стройки и улучшения больше не заполняются сами (`App/CitySync`), только стартовая расстановка один раз. |
|
||||
| 31.10.2026 | Claude | Голод: добыча всего, кроме еды, ×0.5 (`ProductionRules.FamineNonFoodMultiplier`, ТЕСТ). Тест `Famine_HalvesNonFoodProduction_…`. |
|
||||
| 27.10.2026 | Claude | Этапы 1 и 3: лимиты склада (`GetCapacity`, Склад, Амбар), «запас / лимит» и прирост на панели; добыча через рабочих (`Bogatyri.Economy.Rules`, `IProduction`), кликер убран, окно здания и рабочих, улучшение зданий. Исправлены лишние `ResourceUIText` «Люди» на ячейках панели. |
|
||||
|
||||
@@ -20,7 +20,7 @@
|
||||
- **История голода:** модуль помнит отрезки голода за последние `MaxOfflineHours` (48 ч) с точностью до шага расчёта (10 минут). Нужна Бою: поход мог дойти до цели, пока игра была закрыта, и голод берётся на момент боя (`WasStarvingAt`), а не на момент входа в игру.
|
||||
- **Предел жилья:** `HousingBase + HousingPerTownHallLevel × (уровень Ратуши − 1) + HousingPerHouseLevel × уровни Домов` = 300 + 50 + 50. По решению команды от 19.10 стартовый город (300) упирается в предел: прирост пойдёт после постройки Дома. Прибавки не утверждены.
|
||||
- **Добыча** идёт внутри расчёта населения: в каждом куске сначала `IProduction.Produce`, потом еда, потом прирост. Иначе офлайн город «голодал» бы, хотя фермы работали.
|
||||
- **Время** — только из `GameClock`. Расчёт идёт кусками по 10 минут, и в игре, и офлайн, и при перемотке — правила одни. Офлайн досчитывается не больше чем за `MaxOfflineHours`. Если часы ушли назад, расчёт просто продолжается с «сейчас».
|
||||
- **Время** — только из `GameClock`. Расчёт идёт кусками по 10 минут, и в игре, и офлайн, и при перемотке — правила одни. Офлайн досчитывается не больше чем за `MaxOfflineHours`. Если часы ушли назад, ничего не считается, а метка последнего расчёта назад **не** уходит: когда часы снова дойдут до уже посчитанного, второй раз оно не начислится (решения 04.11). В начале каждого шага и в конце `Tick` модуль публикует `GameTimeSimulatedEvent` — до какого момента дошёл расчёт; по нему стройка, закончившаяся офлайн, вступает в силу ровно в свой момент (`App/ConstructionClock`).
|
||||
- **Еда списывается целыми единицами,** доли копятся в модуле: склад хранит `float`, и частые мелкие списания давали бы ошибку округления.
|
||||
|
||||
## Контракт `IPopulation`
|
||||
@@ -88,7 +88,7 @@
|
||||
- упёрлись в предел — прирост стоит, Ратуша выше — идёт; предел не превышается;
|
||||
- офлайн ограничен 48 часами;
|
||||
- частые расчёты дают то же, что один большой;
|
||||
- часы назад — ничего не ломается;
|
||||
- часы назад — ничего не ломается, уже посчитанное время не начисляется второй раз;
|
||||
- перемотка идёт через те же правила;
|
||||
- сохранение;
|
||||
- стартовый набор — один раз;
|
||||
@@ -112,6 +112,8 @@
|
||||
|
||||
| Дата | Кто | Что |
|
||||
| --- | --- | --- |
|
||||
| 05.11.2026 | Claude (Саша) | Часы назад (после «+1 час» и «Сбросить перемотку»): `Tick` всё равно публикует `GameTimeSimulatedEvent` на текущий момент — стройки, у которых время вышло, завершаются (раньше висели на «Стройка 00:00», пока реальное время не догонит перемотку). Ресурсы за уже посчитанное время по-прежнему не начисляются. Тест `Tick_ClockWentBack_StillReportsSimulatedTime_NoResources`. |
|
||||
| 04.11.2026 | Claude (Валдемар) | Решения 04.11 (офлайн по временным меткам): событие `GameTimeSimulatedEvent` на каждом шаге расчёта и в конце `Tick` — стройка, закончившаяся офлайн, вступает в силу в свой момент, добыча с момента завершения. Часы назад: метка последнего расчёта не уходит назад — закрыто повторное начисление. Тесты в `ProductionTests` (офлайн-стройка, повторный Tick, загрузка), обновлён тест про часы назад. Побочный эффект: после «DEV +10 часов» и перезапуска расчёт стоит, пока реальное время не догонит перемотку. |
|
||||
| 04.11.2026 | Claude (Саша) | Пропавшие без вести (решения 01.11, п. 5) занимают места в населении (`Soldiers` включает `IArmy.MissingTotal`), но не едят. |
|
||||
| 04.11.2026 | Claude (Саша) | Решения по отчёту Саши от 01.11, п. 4: воины едят по норме типа — пехота 0,15, лучники 0,20, кавалерия 0,15, осадные 0,15 в час (`PopulationRules.FoodPerTroopTypePerHour`, `EconomyConfig.population.foodPerTroopTypePerHour`); раненые — полную норму своего типа, даже ожидая койку (`IArmy.GetAllTroops`). Новобранцы — по `foodPerSoldierPerHour`. Без таблицы — как раньше (тесты Населения не менялись). Час — тот же, что у остальной еды (переход еды на игровые часы — открытый вопрос). |
|
||||
| 01.11.2026 | Claude (Саша) | Раненые считаются людьми: `Soldiers` = в городе + в походах + `IArmy.WoundedTotal` (решения 31.10, п. 7). Погибшие уходят из людей, вылеченные не появляются заново. Еда раненых — как у воинов (нормы четырёх групп — открытый вопрос). |
|
||||
|
||||
@@ -105,12 +105,14 @@ EditMode-тесты: `Assets/Scripts/Modules/Tests/ModuleTests.cs`. Запуск
|
||||
|
||||
| Дата | Кто | Что |
|
||||
| --- | --- | --- |
|
||||
| 04.11.2026 | Claude (Валдемар) | Решения Димы 04.11: снос зданий убран (только бесплатный перенос), кристаллы грабятся только с технологией «Грабёж», офлайн по временным меткам — добыча с момента завершения стройки, защита от повторного начисления (метка расчёта не уходит назад). +5 тестов. Обновлены Settlement, Economy, Core. |
|
||||
| 04.11.2026 | Claude | Разведка М2: разведка поселений, замка и лагерей с отчётами II и III. Обновлены Intelligence, World, Core. |
|
||||
| 04.11.2026 | Claude | Пропавшие без вести (решения 01.11, п. 5; доля не утверждена — выключено). Обновлены Army, Battle, Population, World, Core. |
|
||||
| 04.11.2026 | Claude | Еда воинов и раненых по типам (решения 01.11, п. 4). Обновлены Population, Army. |
|
||||
| 04.11.2026 | Claude | Карта мира в 2.5D на примитивах. Обновлены World, Intelligence. |
|
||||
| 04.11.2026 | Claude | 3D-поселение, этап I — прототип на примитивах (ТЗ от 04.11). Обновлён Settlement. |
|
||||
| 04.11.2026 | Claude | Решения по отчёту Саши от 01.11: потери сильной стороны до 2% при 10:1, 5 временных мест госпиталя, цена лечения 1 / 1 / 2 / 3 золота. Обновлены Battle, Army. |
|
||||
| 01.11.2026 | Claude (Валдемар) | Поселение: стройка и улучшение по времени, 2 строителя, ускорение; лимит зданий по Ратуше; перенос и снос; защита ресурсов от грабежа (`IResourceProtection`). Терем богатыря и герой в городе. Казармы: найм карточками в ряд. Числа — ТЕСТ. Обновлены Settlement, Economy, Core. |
|
||||
| 01.11.2026 | Claude | Здание «Госпиталь» (решения 31.10, п. 7). Обновлены Army, Core, Settlement. |
|
||||
| 01.11.2026 | Claude | Решения 31.10, п. 7: раненые и госпиталь (логика; здание — следующий шаг). Обновлены Battle, Army, Population, World, Core, Settlement. |
|
||||
| 01.11.2026 | Claude | Решения 31.10, п. 8: очередь обучения — у Армии. Обновлены Army, Core, Population. |
|
||||
|
||||
@@ -26,6 +26,19 @@
|
||||
4. Нижняя панель: «Поставить» (место свободно и хватает ресурсов), «Другое», «Отмена».
|
||||
5. После постройки режим остаётся; «Здания» сверху — строить дальше, «Выйти» — сетка прячется.
|
||||
|
||||
## Стройка по времени, лимиты, перенос (01.11.2026, решения 04.11; числа — ТЕСТ)
|
||||
|
||||
- **Время реальное** (решение 04.11): 60 с стройки — 60 реальных секунд, «игровой час» к стройке не относится. Развитие долгосрочное: позже — тиры T1/T2/T3 и внутри них уровни с растущим временем (шкалу согласуют отдельно).
|
||||
- **Стройка и улучшение идут по времени** (`BuildTimer`). Постройка — `buildSeconds` (60 с), улучшение до уровня N — `upgradeSecondsToLevel` (60 с … 2 ч), Ратуша ×2. Время — часы игры (`GameClock.UnixNowMs`): идёт и в офлайне, и при перемотке DEV.
|
||||
- **Пока здание строится,** оно полупрозрачное и не работает: его скрипты выключены, `App/CitySync` его не считает (нет мест, жилья, лимитов; Казармы не учат, Терем без героя). При улучшении здание работает на старом уровне, новый — когда время выйдет.
|
||||
- **Строители:** одновременно не больше `builders` (2) строек и улучшений, Ратуша тоже занимает строителя.
|
||||
- **Ускорение:** 1 кристалл за `secondsPerGem` (30) секунд оставшегося времени — кнопка в окне здания.
|
||||
- **Лимит зданий по уровню Ратуши** (`EconomyConfig.buildingLimits`): в списке стройки «построено 2 / 3», сверх лимита — «Лимит: больше на Ратуше N ур.». Здания не из таблицы — без лимита. В окне Ратуши — каких зданий станет больше на следующем уровне.
|
||||
- **Перенос:** кнопка «Перенести» в окне здания — режим стройки с призраком на месте здания, «Сюда» — здание переезжает бесплатно (`BuildModeController.StartMove`, `BuildGrid.IgnoredBuilding`). Стройка или улучшение продолжаются.
|
||||
- **Сноса нет** (решение 04.11): удалить здание и вернуть ресурсы нельзя. Перенос — бесплатно, в пределах поселения, уровень и текущие стройка или улучшение сохраняются. Ратушу не переносят.
|
||||
- **Офлайн по временным меткам** (решение 04.11): в сохранении хранится момент конца стройки. После входа стройка вступает в силу в тот момент офлайн-расчёта добычи, когда время дошло до её конца (`App/ConstructionClock`, событие `GameTimeSimulatedEvent` от Населения, `BuildTimer.MayComplete`). Добыча нового здания или уровня идёт с момента завершения (точность — шаг расчёта 10 минут, никогда не раньше конца стройки).
|
||||
- **Проверки:** таймер из файла принимается, только если он осмыслен (постройка — у здания 1 уровня, улучшение — до уровня выше текущего); завершение только повышает уровень, повторное ничего не даёт; перенос не тратит и не возвращает ресурсы.
|
||||
|
||||
## Контракт `ISettlement`
|
||||
|
||||
| Свойство | Что значит |
|
||||
@@ -48,7 +61,7 @@
|
||||
|
||||
## Сохранение
|
||||
|
||||
Секция `"settlement"`: `{ "townHallLevel", "placedBuildings": [ { "buildingName", "posX", "posY", "level" } ] }`.
|
||||
Секция `"settlement"`: `{ "townHallLevel", "townHallTimerStartMs", "townHallTimerEndMs", "townHallTimerTargetLevel", "placedBuildings": [ { "buildingName", "posX", "posY", "level", "timerKind", "timerStartMs", "timerEndMs", "timerTargetLevel" } ] }`. Поля таймера (01.11) — стройка или улучшение, которое ещё идёт (`timerKind`: 0 — нет, 1 — постройка, 2 — улучшение). В старых сохранениях их нет — значит, ничего не строится.
|
||||
|
||||
Сброс (`ResetState`): Ратуша 1 уровня, постройки убраны, участки освобождены, память модуля очищена. Иначе при следующем входе в город вернулись бы старые здания.
|
||||
|
||||
@@ -106,6 +119,14 @@
|
||||
|
||||
| Дата | Кто | Что |
|
||||
| --- | --- | --- |
|
||||
| 05.11.2026 | Claude (Саша) | Перенос 2D-города надёжнее: если места нет, деревья и валуны под зданием расчищаются (бесплатно, без награды — `SettlementMap.ValidateIfCleared`, `ObstaclesUnder`, тест `Placement_IfCleared_IgnoresOnlyTreesAndListsThem`); если места нет и так — здание не теряется, а ждёт в `settlement3d.pendingFrom2D` и ставится при входе в город или когда Ратуша открывает зону. DEV → «Камера»: ползунки наклона, перспективы (поле зрения), ближе / дальше всего и текущего отдаления; подобранное запоминается на устройстве (только редактор и отладочная сборка), «Сбросить» — числа из сцены. |
|
||||
| 05.11.2026 | Claude (Саша) | **Решение команды: только 3D-поселение, камера перспективная.** (1) В 3D перенесены Золотой рудник (золото, Ратуша 2, 50 камня), Кристалл (Ратуша 6, цена из `Data_Kristall`) и Терем (`HeroCastle`, окно героя; фигурка героя гуляет только по 2D-сетке, в 3D — нет). (2) Перенос 2D-города: если в сохранении нет `settlement3d`, а есть 2D-город, при первом входе в 3D Ратуша получает уровень и улучшение из 2D, остальные здания встают на ближайшие свободные места с теми же уровнями и стройками (`City3DController.MoveFrom2D`, итог — в Console «[3D] Перенос 2D-города»). Секция `settlement` в файле не меняется. (3) `SceneNavigator.CitySceneName` = City3D; MVP1 выключена в Build Settings (сцена и код 2D остаются в репозитории до конца проверки). (4) `City3DCamera.perspective` (по умолчанию да, поле зрения 40°) — угол, высота и поле зрения ТЕСТ. Меню стройки — в два столбца. |
|
||||
| 05.11.2026 | Claude (Саша) | 3D: камера сцены City3D ортографическая — у неё расстояние не меняет размер города, поэтому «+ / −» и щипок не приближали. `City3DCamera.Apply` для ортографической камеры ставит `orthographicSize` по расстоянию (как видно перспективной камерой с этого расстояния). |
|
||||
| 05.11.2026 | Claude (Саша) | 3D: дымка идёт за камерой (`City3DCamera.Apply`: начало и конец — от расстояния камеры), поэтому при отдалении город больше не уходит в серую дымку. Окно здания: кнопка «Улучшить» (Ратуша и уровни зданий) сама становится зелёной, когда ресурсов стало хватать (`BuildingWindow.LiveAfford`) — не нужно закрывать и открывать окно. |
|
||||
| 05.11.2026 | Claude (Саша) | 3D: здание, которое ещё строится, теперь видно — оно серее, над ним «Стройка 0:45» (`BuildTimer` затемнял только 2D-спрайты, поэтому Казармы выглядели готовыми, а «Войска» писали «казарм: 0»). Окно «Войска»: пока Казармы строятся — «Казармы строятся, осталось …», после стройки окно перерисовывается само; «Построить Казармы» в 3D ставит призрак Казарм (`City3DController.PlaceBuilding`). Кнопки «+ / −» справа — приближение камеры (в Device Simulator колесо мыши не доходит до игры). |
|
||||
| 05.11.2026 | Claude (Саша) | Интеграция 3D: (1) снимок 3D-города для сохранения до расстановки сохранённых зданий отдаёт загруженное состояние — сохранение в первом кадре (поход вернулся офлайн) больше не затирает 3D-город пустым; (2) `ISettlement.TownHallLevel` вне города — уровень Ратуши последнего открытого города (2D или 3D), а не всегда 2D-сохранения: на карте мира предел жилья, резерв и открытие войск считаются по 3D-Ратуше. |
|
||||
| 04.11.2026 | Claude | Решения 04.11: снос убран, остался бесплатный перенос; офлайн по временным меткам — стройка, закончившаяся офлайн, вступает в силу в момент завершения внутри расчёта добычи (`ConstructionClock`, `GameTimeSimulatedEvent`, `BuildTimer.MayComplete`); проверки таймеров из сохранения; уровень при завершении только растёт. |
|
||||
| 01.11.2026 | Claude | Стройка и улучшение по времени (`BuildTimer`), 2 строителя, ускорение за кристаллы, офлайн; лимит зданий по уровню Ратуши; перенос и снос зданий из окна. Сохранение: поля таймера у зданий и Ратуши (старые сохранения читаются). Терем богатыря (`HeroCastle`) и гуляющий герой. Числа — ТЕСТ, `EconomyConfig`. |
|
||||
| 04.11.2026 | Claude (Саша) | 3D-поселение, этап I (ТЗ от 04.11): правила места `Bogatyri.Settlement.Placement` + тесты, сцена City3D на примитивах (местность, зоны, призрак, поворот, перенос, расчистка, камера), 3D-рендерер для её камеры. Ветка `feature/3d-settlement`. |
|
||||
| 01.11.2026 | Claude (Саша) | Новое здание «Госпиталь» в списке стройки (`Meta/Build/HospitalBootstrap.cs`, как Казармы): уровни I–V сохраняются как у остальных (`ILeveledBuilding`), тап — окно госпиталя. Места — в `HospitalConfig`. Арта нет: копия обычного здания светло-голубого цвета. |
|
||||
| 01.11.2026 | Claude (Саша) | Для госпиталя (решения 31.10, п. 7) в ядре есть контракт `IHospital.Beds` — его будет регистрировать здание госпиталя (уровни I–V, 20–60 мест). Здания пока нет. |
|
||||
|
||||
@@ -6,6 +6,9 @@ EditorBuildSettings:
|
||||
serializedVersion: 2
|
||||
m_Scenes:
|
||||
- enabled: 1
|
||||
path: Assets/Scenes/City3D.unity
|
||||
guid: c1c34719f0605c247a3575d077f3d656
|
||||
- enabled: 0
|
||||
path: Assets/Scenes/MVP1.unity
|
||||
guid: beae4025d64976c4f93af3b9233183da
|
||||
- enabled: 1
|
||||
|
||||
Reference in New Issue
Block a user