Author SHA1 Message Date
AlexanderandClaude Opus 5.5 157d3ba510 Кристальная шахта: 3 синих кристалла за 7 реальных суток на шахту, до двух, пауза при полном хранилище (ответ 06.11, п. 3)
- CrystalMine (без Unity) + 5 тестов: ровно 3 за 7 суток, ×12 не действует, 49/50 -> 1, пауза, перезагрузка и часы назад без двойного начисления, офлайн-стройка
- Работает с конца стройки, офлайн досчитывается; вне недельного PvE-предела
- Вторая шахта -- Ратуша 9 (ТЕСТ); окно здания: следующий кристалл / пауза
- DEV «Ресурсы 100 000» -- обычные ресурсы мимо лимита склада

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-06 16:31:47 +01:00
AlexanderandClaude Opus 5.5 b51ad41925 Редкий PvE-торговец: синий кристалл за 30 000 / 60 000 золота, не больше 2 за 7 суток (регламент валют, разд. 3)
- CrystalTrader (без Unity) + 5 тестов из раздела 12
- Входит в недельный PvE-предел; нет квоты, места или покупок -- блок, золото не списывается
- Магазин: карточка «Редкий торговец»; DEV «Золото 100 000» для проверки

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-06 16:27:27 +01:00
AlexanderandClaude Opus 5.5 277afdf84e Недельный PvE-предел синих кристаллов: 6 за 7 реальных суток, сверх -- золото (регламент валют 05.11, ответ 06.11)
- CrystalQuota (без Unity) + 6 тестов из раздела 12
- Задания, события, бои: в пределах квоты -- кристаллы, сверх -- 1000 золота за кристалл (ТЕСТ)
- Ждущие места кристаллы квоту второй раз не тратят; письмо «ждут места» -- вне квоты
- Окно Ратуши: «Новых кристаллов за 7 дней: N / 6»; DEV: «+1 PvE-кристалл», «Сбросить квоту»

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-06 16:24:16 +01:00
AlexanderandClaude Opus 5.5 e05cf87173 3D: при открытии зоны камера показывает только край новой зоны (старая зона + 2 клетки), а не всю -- не улетает далеко
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-06 16:20:57 +01:00
AlexanderandClaude Opus 5.5 1ce4082b4c Ускорения за золото: стройка, улучшение и обучение войск -- 1 золото за 10 с (ТЕСТ, secondsPerGold), было 1 кристалл за 30 с
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-06 16:18:28 +01:00
AlexanderandClaude Opus 5.5 e1228e0b3e Награды за вход: кнопка «Забрать подарок» сверху -- в горизонтальном экране она уходила под нижнюю панель HUD
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-06 16:17:38 +01:00
AlexanderandClaude Opus 5.5 a2d85f56c3 Хранилище синих кристаллов: платное расширение 50 -> 75/100/150/200 на Ратуше 3/5/7/9 (регламент валют, разд. 8)
- CrystalStorage (без Unity) + 5 тестов из раздела 12
- Ступени и цены -- EconomyConfig.crystalExpansions (ТЕСТ), купленная ступень -- в секции economy
- Окно Ратуши: «Хранилище синих кристаллов», «Расширить до N»; цена списывается один раз

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-06 16:13:43 +01:00
Alexander 98cbe85481 Merge pull request 'Feature/3d hud' (#18) from feature/3d-hud into develop
Reviewed-on: #18
2026-11-06 18:09:32 +03:00
AlexanderandClaude Opus 5.5 fe02e87225 Синие кристаллы по регламенту валют 05.11: убраны старые раздачи, магазин за золото
- Кристаллы только: верхняя ступень недельного события (1), каждый 10-й этап боя (1) -- ТЕСТ
- Задания, вход, письма, подарок за уровень, арена, сундук -- золото и ресурсы
- Магазин: пакеты за золото; убраны пакеты за кристаллы, обмен золота на кристаллы, «Горсть» и «Сундук»

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-06 16:00:31 +01:00
vologdia de105c4cbe 3D: камера в пределах открытых зон, при отдалении видна вся зона, плавный зум, без тапа после щипка; MVP1 не возвращается в сборку; навыки героя без символа, которого нет в шрифте 2026-11-06 13:17:56 +03:00
AlexanderandClaude Opus 5.5 0ebbf56306 Синие кристаллы (регламент 05.11, пп. 1 и 3): не сверх 50 и без потерь, ожидание наград (не больше 2), задания -- 1-2 кристалла
- CrystalRewards (без Unity) + 6 тестов из раздела 6 регламента
- Задания: сколько помещается, остаток ждёт в задании («Забрать»); третья не помещающаяся награда не выдаётся
- Остальные награды: не поместившееся -- письмом «Кристаллы ждут места», из письма -- сколько помещается
- Награды заданий 1-2 вместо 20-250, ежедневные -- без кристаллов

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-05 18:29:31 +01:00
AlexanderandClaude Opus 5.5 f78eb7ea7e Перенос 2D → 3D без потерь (расчистка места, очередь «ждут места»); DEV «Камера» -- ползунки наклона, перспективы и отдаления
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-05 17:56:38 +01:00
AlexanderandClaude Opus 5.5 8a5f7253db DEV: «Кристаллы = макс» -- до предела склада, предел не меняется
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-05 17:51:19 +01:00
AlexanderandClaude Opus 5.5 a6b5926e39 DEV: «+1000 кристаллов» сверх предела (только для теста)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-05 17:50:46 +01:00
AlexanderandClaude Opus 5.5 aa7789dc97 DEV: «+1000 всех ресурсов» вместо +100
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-05 17:49:55 +01:00
AlexanderandClaude Opus 5.5 422f55f22e Стройка не висит на 00:00 после «Сбросить перемотку»: расчёт сообщает текущее время и при часах назад (без начисления)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-05 17:45:12 +01:00
AlexanderandClaude Opus 5.5 65f5039f74 Только 3D-поселение (решение 05.11): Золотой рудник, Кристалл и Терем в 3D, перенос 2D-города, перспективная камера, MVP1 вне сборки
- Золотой рудник, Кристалл, Терем: цены, уровни Ратуши и компоненты как в 2D
- Перенос: нет 3D-города в сохранении, есть 2D -- Ратуша с уровнем и улучшением, здания с уровнями и стройками; секция settlement не меняется
- Камера перспективная (City3DCamera.perspective, поле зрения 40 -- ТЕСТ)
- Город -- City3D; MVP1 выключена в Build Settings, код и сцена 2D остаются
- Меню стройки в два столбца

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-05 17:31:25 +01:00
AlexanderandClaude Opus 5.5 4fe2dced76 Окно здания: строка «Цена» обновляется вместе с кнопкой; тестовые кнопки 3D-сцены не рисуются поверх окон
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-05 17:27:58 +01:00
AlexanderandClaude Opus 5.5 d7348dd23d Чат: на телефоне после «Отправить» клавиатура закрывается полностью, без мигания
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-05 17:17:53 +01:00
AlexanderandClaude Opus 5.5 7d932e3cbc 3D: приближение работает и с ортографической камерой (размер кадра по расстоянию)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-05 17:14:38 +01:00
AlexanderandClaude Opus 5.5 35319be8ca 3D: дымка идёт за камерой (отдаление больше не «сереет»); «Улучшить» оживает, когда ресурсов стало хватать
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-05 17:12:10 +01:00
AlexanderandClaude Opus 5.5 5502489c07 3D: стройка видна (серое здание + «Стройка 0:45»), «Войска» знают о строящихся Казармах, кнопки зума +/−
- BuildTimer затемнял только 2D-спрайты: в 3D строящиеся Казармы выглядели готовыми, а окно «Войска» писало «казарм: 0»
- «Войска»: «Казармы строятся, осталось …»; перерисовка, когда достроились; «Построить Казармы» в 3D ставит призрак
- «+ / −» справа: в Device Simulator колесо мыши не доходит до игры

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-05 17:05:59 +01:00
AlexanderandClaude Opus 5.5 f307c83352 3D: нажатие «по HUD» -- только по кнопкам, прокрутке и небольшим панелям; невидимый слой во весь экран не блокирует карту
Тап, перетаскивание, стройка и зум в 3D-поселении блокировались проверкой UIBlocker. Такой слой и открытая
панель HUD пишутся в Console один раз -- чтобы найти виновника.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-05 16:58:12 +01:00
AlexanderandClaude Opus 5.5 c29a4ae708 3D: призрак нового здания -- на ближайшем свободном месте, а не на Ратуше в центре камеры
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-05 16:56:44 +01:00
AlexanderandClaude Opus 5.5 56b66af0c3 Интеграция 3D: сохранение в первом кадре не затирает 3D-город; Ратуша на карте мира -- из последнего открытого города
CaptureState до ApplySaved отдаёт загруженное состояние (поход, вернувшийся офлайн, сохранял пустой город).
ISettlement.TownHallLevel вне города -- уровень Ратуши последнего открытого города (2D или 3D).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-05 16:22:23 +01:00
AlexanderandClaude Opus 5.5 a03098ffe0 3D: обычный 3D-рендерер URP в репозитории (Renderer3D + список рендереров)
Раньше он создавался только локально командой меню, и в свежем клоне или APK 3D-камеры
рисовали 2D-рендерером -- без света и теней. Map3DLit.mat -- пересохранён Unity.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-05 16:14:48 +01:00
vologdia 413c627d5c 3D: камера как на карте мира, расчистка только в зоне, почта под Наградами, старт с City3D 2026-11-05 17:46:42 +03:00
vologdia e26eeaffaf 3D: версия на экране, отладочная сетка, сборка APK одной кнопкой и починка сборки Android 2026-11-05 15:12:19 +03:00
vologdia 88f460a4a6 3D: общий HUD, здания игры в 3D (окна, рабочие, улучшения, стройка по таймеру, сохранение), окна зданий с прокруткой 2026-11-05 14:18:22 +03:00
vologdia 235ff124ee Merge branch 'feature/troop-cards' into test/3d-together 2026-11-05 12:00:17 +03:00
vologdia bb3baba3b7 Исправление: атрибут RuntimeInitializeOnLoad вернулся к MigrateLegacyMarches 2026-11-05 11:54:20 +03:00
vologdia 1f0676ffc9 Merge remote-tracking branch 'origin/feature/3d-settlement' into test/3d-together
# Conflicts:
#	Docs/Modules/Core.md
#	Docs/Modules/Population.md
#	Docs/Modules/README.md
#	Docs/Modules/Settlement.md
2026-11-05 11:47:41 +03:00
vologdia b3296c7a55 Merge remote-tracking branch 'origin/feature/intelligence-m1' into feature/troop-cards
# Conflicts:
#	Docs/Modules/README.md
2026-11-05 11:44:26 +03:00
vologdia b1bb863807 meta для ConstructionClock 2026-11-05 11:15:41 +03:00
vologdia 8b6a7869a4 Решения 04.11: снос убран (только перенос), кристаллы грабятся с технологией Грабёж, офлайн по временным меткам, защита от повторного начисления, тесты 2026-11-04 23:36:14 +03:00
vologdia d30fd34810 Поселение: стройка по времени и строители, лимит зданий по Ратуше, перенос и снос, защита ресурсов; Терем богатыря; найм карточками 2026-11-02 00:01:51 +03:00
vologdia 1b7da37c7e Казармы: найм войск карточками в ряд, сильные и слабые стороны из правил боя, место под иконки 2026-11-01 23:34:08 +03:00
85 changed files with 20677 additions and 215 deletions
+4
View File
@@ -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/
+61
View File
@@ -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);
}
}
}
+2
View File
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 030e644cb052c0f4d959e499015370fd
+102
View File
@@ -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 ""; }
}
}
+2
View File
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 904233d7647ed024d920bdbbd4058aba
+51
View File
@@ -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;
}
+2
View File
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 1218c67fef1faaf448c6623df897ac12
+9 -3
View File
@@ -3,11 +3,14 @@ using UnityEditor;
// Следит, чтобы сцена карты мира (WorldMap, от Саши) была в Build Settings --
// иначе переход город -> карта не работает. Срабатывает сам после каждой компиляции.
// Стартовая сцена (05.11): 3D-поселение City3D, если оно есть в проекте, иначе 2D-город MVP1.
// Выключенный в Build Settings MVP1 (06.11) скрипт не трогает и заново не добавляет.
[InitializeOnLoad]
public static class SceneListFixer
{
const string CityScene = "Assets/Scenes/MVP1.unity";
const string WorldScene = "Assets/Scenes/WorldMap.unity";
const string City3DScene = "Assets/Scenes/City3D.unity";
static SceneListFixer() { EditorApplication.delayCall += Ensure; }
@@ -15,15 +18,18 @@ public static class SceneListFixer
{
var scenes = new List<EditorBuildSettingsScene>(EditorBuildSettings.scenes);
bool changed = false;
foreach (var path in new[] { CityScene, WorldScene })
string start = AssetDatabase.LoadAssetAtPath<SceneAsset>(City3DScene) != null ? City3DScene : CityScene;
// 2D-город (MVP1) больше не развиваем (06.11): при 3D-поселении его в список не добавляем и не включаем
var wanted = new[] { start, WorldScene };
foreach (var path in wanted)
{
if (AssetDatabase.LoadAssetAtPath<SceneAsset>(path) == null) continue;
if (scenes.Exists(s => s.path == path)) continue;
scenes.Add(new EditorBuildSettingsScene(path, true));
changed = true;
}
// город должен быть первым (с него стартует игра)
int city = scenes.FindIndex(s => s.path == CityScene);
// стартовый город должен быть первым (с него стартует игра)
int city = scenes.FindIndex(s => s.path == start);
if (city > 0) { var c = scenes[city]; scenes.RemoveAt(city); scenes.Insert(0, c); changed = true; }
if (changed)
{
File diff suppressed because it is too large Load Diff
+7
View File
@@ -0,0 +1,7 @@
fileFormatVersion: 2
guid: dd067b5f8139dbc46b51ef157b0f0613
PrefabImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:
+80 -1
View File
@@ -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
View File
@@ -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}
+334 -12
View File
@@ -14,6 +14,9 @@ using UnityEngine.UI;
// - Кристалл: где добывается, запас / лимит;
// - Казармы: очередь обучения, какие войска открыты и что нужно для остальных, улучшение (открывает войска),
// кнопка "Нанять войска" (панель "Войска").
// - Стройка и улучшение идут по времени (BuildTimer, 01.11, ТЕСТ): полоска, сколько осталось, ускорение за кристаллы.
// Пока здание строится, в окне только стройка; при улучшении здание работает на старом уровне.
// - У построенных зданий (кроме Ратуши) -- кнопка "Перенести" (бесплатно, уровень и стройка сохраняются). Сноса нет (04.11).
// Без здания (кнопка "Рабочие" в панели DEV и "Все рабочие" в окне) -- список рабочих по всем профессиям.
// Окно -- только интерфейс: рабочих ставит модуль Населения, добычу считает модуль Экономики.
// Пока окно открыто, цифры обновляются сами (раз в полсекунды).
@@ -47,25 +50,64 @@ public static class BuildingWindow
{
if (root != null) UnityEngine.Object.Destroy(root);
live.Clear();
var card = UIKit.Modal(TitleFor(focus), out root);
var modal = UIKit.Modal(TitleFor(focus), out root);
root.AddComponent<BuildingWindowTicker>();
var card = ScrollBody(modal); // длинное окно прокручивается, "Закрыть" всегда видна внизу (05.11)
var rc = focus != null ? focus.GetComponent<ResourceCollector>() : null;
var storage = focus != null ? focus.GetComponent<StorageBuilding>() : null;
var townHall = focus != null ? focus.GetComponent<TownHall>() : null;
var barracks = focus != null ? focus.GetComponent<Barracks>() : null;
var hospital = focus != null ? focus.GetComponent<Hospital>() : null;
var heroCastle = focus != null ? focus.GetComponent<HeroCastle>() : null;
bool constructing = focus != null && BuildTimer.IsConstructing(focus.transform);
if (focus != null) TimerBlock(card, focus);
if (focus == null) WorkersList(card);
else if (constructing) UIKit.Text(card, "Пока здание строится, оно не работает.", 22, UIKit.Theme.TextDim, TextAlignmentOptions.Center);
else if (townHall != null) TownHallSection(card, townHall);
else if (barracks != null) BarracksSection(card, barracks);
else if (hospital != null) HospitalSection(card, hospital);
else if (heroCastle != null) HeroCastleSection(card);
else if (rc != null && rc.IsWorkplace) WorkplaceSection(card, rc);
else if (rc != null && rc.IsHouse) HouseSection(card, rc);
else if (rc != null && rc.resourceType == ResourceType.Gems) CrystalSection(card);
else if (storage != null) StorageSection(card, storage);
UIKit.Button(card, "Закрыть", UIKit.Theme.Gray, Close, -1, 80, 28);
if (focus != null) ActionsBlock(card, focus);
UIKit.Button(modal, "Закрыть", UIKit.Theme.Gray, Close, -1, 80, 28);
}
// Окно не выше экрана: заголовок сверху, содержимое прокручивается, кнопка "Закрыть" -- внизу, вне прокрутки.
// Закрыть можно и тапом по затемнению вокруг окна, и кнопкой "Назад" (Escape).
static RectTransform ScrollBody(RectTransform modal)
{
var fit = modal.GetComponent<ContentSizeFitter>();
if (fit != null) UnityEngine.Object.DestroyImmediate(fit);
modal.anchorMin = new Vector2(0.06f, 0.05f);
modal.anchorMax = new Vector2(0.94f, 0.95f);
modal.offsetMin = Vector2.zero;
modal.offsetMax = Vector2.zero;
var dim = root.GetComponent<Image>();
if (dim != null)
{
var outside = root.AddComponent<Button>();
outside.transition = Selectable.Transition.None;
outside.onClick.AddListener(Close);
}
var holder = UIKit.Make("Body", modal);
UIKit.Size(holder, -1, -1).flexibleHeight = 1;
ScrollRect scroll;
return UIKit.ScrollList(holder, out scroll, 14);
}
// Стройка закончилась (BuildTimer.Completed): окно этого здания показывает новый уровень
internal static void OnBuildCompleted(BuildTimer timer)
{
if (root != null && timer != null && focus == timer.gameObject) Rebuild();
}
internal static void Tick()
@@ -162,10 +204,23 @@ public static class BuildingWindow
// ---------------- Кристалл ----------------
// Кристальная шахта (ответ 06.11, п. 3): 3 синих кристалла за 7 реальных суток, при полном хранилище -- пауза
static void CrystalSection(RectTransform card)
{
UIKit.Text(card, "Синий кристалл добывается в кристальных разломах на карте мира. Здание не улучшается.", 24, UIKit.Theme.TextDim, TextAlignmentOptions.Center);
var gp = GameProgress.Instance;
if (gp != null)
UIKit.Text(card, $"Кристальная шахта: {gp.CrystalMinePerPeriod} синих кристалла за {gp.CrystalMinePeriodDays} реальных суток, " +
"и пока игра закрыта. Не входит в недельный предел. Здание не улучшается.", 24, UIKit.Theme.TextDim, TextAlignmentOptions.Center);
if (GameServices.TryGet(out IEconomy economy)) StorageBar(card, economy, ResourceType.Gems);
if (gp == null) return;
Live(card, () =>
{
int working = gp.CrystalMinesWorking;
if (working == 0) return "Шахта ещё не работает";
if (CrystalRewards.Free(GameProgress.GemsHave, GameProgress.GemsCap) <= 0)
return "Пауза: хранилище кристаллов заполнено. Освободите место или расширьте его в окне Ратуши";
return $"Работает шахт: {working}. Следующий кристалл через {GameData.FormatTime(gp.CrystalMineSecondsToNext)}";
}, 24, UIKit.Theme.Text);
}
// ---------------- Склад и Амбар ----------------
@@ -192,6 +247,26 @@ public static class BuildingWindow
}
else StorageBar(card, economy, ResourceType.Food);
UIKit.Text(card, "Сверх лимита ресурс не прибавляется — пропадает.", 22, UIKit.Theme.TextDim, TextAlignmentOptions.Center);
if (GameServices.TryGet(out IResourceProtection protection))
{
Header(card, "Защита от грабежа");
var p = EconomyConfig.Load().protection;
ResourceType[] kinds = warehouse
? new[] { ResourceType.Wood, ResourceType.Stone, ResourceType.Iron, ResourceType.Gold }
: new[] { ResourceType.Food };
Live(card, () =>
{
var lines = new List<string>();
foreach (var k in kinds)
lines.Add($"{GameData.ResName(k)}: спрятано {protection.Protected(k):0}, можно унести {protection.Lootable(k):0}");
return string.Join("\n", lines.ToArray());
}, 22, UIKit.Theme.Text);
UIKit.Text(card, warehouse
? $"Каждый уровень Склада прячет ещё {p.perWarehouseLevel:0} каждого ресурса — в набеге их не унесут."
: $"Каждый уровень Амбара прячет ещё {p.perGranaryLevel:0} еды — в набеге её не унесут.",
22, UIKit.Theme.TextDim, TextAlignmentOptions.Center);
}
}
UpgradeBlock(card, storage.level, $"лимит +{perLevel * storage.level:0} → +{perLevel * (storage.level + 1):0}",
@@ -220,6 +295,8 @@ public static class BuildingWindow
Live(card, () => $"Резерв для найма: {population.CivilianReserve} • можно нанять: {population.Recruitable}", 22, UIKit.Theme.TextDim);
}
CrystalStorageBlock(card, level);
if (level >= townHall.maxLevel)
{
UIKit.Text(card, "Максимальный уровень", 26, UIKit.Theme.Gold, TextAlignmentOptions.Center);
@@ -235,18 +312,93 @@ public static class BuildingWindow
string unlocks = UnlocksAt(next);
if (unlocks.Length > 0) UIKit.Text(card, "Откроется: " + unlocks, 24, UIKit.Theme.Gold, TextAlignmentOptions.Center);
string more = MoreBuildingsAt(level, next);
if (more.Length > 0) UIKit.Text(card, "Больше зданий: " + more, 22, UIKit.Theme.TextDim, TextAlignmentOptions.Center);
if (TimerRunning(townHall.gameObject)) return; // улучшение уже идёт -- полоска сверху
var cost = BuildingData.ToAmounts(townHall.GetUpgradeCost());
UIKit.Text(card, "Цена: " + CostText(cost), 24, UIKit.Theme.Text, TextAlignmentOptions.Center);
LiveCost(card, cost);
long ms = config.ConstructionMs(true, next);
TimeText(card, ms);
bool canPay = townHall.CanUpgrade();
var b = UIKit.Button(card, $"Улучшить до {next} ур.", canPay ? UIKit.Theme.Green : UIKit.Theme.Gray, () =>
{
if (!townHall.CanUpgrade()) { UIKit.Toast("Не хватает ресурсов"); return; }
townHall.Upgrade();
if (CitySync.Instance != null) CitySync.Instance.SyncNow();
UIKit.Toast($"Ратуша улучшена до {townHall.currentLevel} уровня");
if (ms > 0 && BuildTimer.FreeBuilders == 0) { UIKit.Toast("Все строители заняты"); return; }
if (!GameServices.TryGet(out IEconomy e) || !e.TrySpend(cost)) { UIKit.Toast("Не хватает ресурсов"); return; }
if (ms <= 0) BuildTimer.Begin(townHall.gameObject, BuildTimer.Kind.Upgrade, next, 0, 0).Complete();
else
{
long now = GameClock.UnixNowMs;
BuildTimer.Begin(townHall.gameObject, BuildTimer.Kind.Upgrade, next, now, now + ms);
UIKit.Toast($"Ратуша улучшается до {next} уровня");
}
Rebuild();
}, -1, 80, 26);
b.interactable = canPay;
LiveAfford(b, canPay, () => townHall != null && townHall.CanUpgrade());
}
// Хранилище синих кристаллов (регламент валют 05.11, разд. 8; ответ 06.11, п. 1): вместимость и платное
// расширение. Ратуша только разрешает ступень -- игрок покупает её здесь за золото и обычные ресурсы
static void CrystalStorageBlock(RectTransform card, int townHallLevel)
{
var storage = EconomyConfig.CrystalStorageNow();
Header(card, "Хранилище синих кристаллов");
Live(card, () =>
{
float have = GameServices.TryGet(out IEconomy e) ? e.Get(ResourceType.Gems) : 0f;
return $"Кристаллов {have:0} / {EconomyConfig.CrystalStorageNow().Capacity}";
}, 24, UIKit.Theme.Text);
// Недельный PvE-предел (регламент валют 05.11, ответ 06.11): сколько новых кристаллов из заданий, событий и боёв
Live(card, () =>
{
var gp = GameProgress.Instance;
if (gp == null) return "";
string text = $"Новых кристаллов из заданий, событий и боёв за 7 дней: {gp.CrystalQuotaUsed} / {gp.CrystalQuotaLimit}";
if (gp.CrystalQuotaLeft <= 0) text += $". Квота освободится через {GameData.FormatTime(gp.CrystalQuotaSecondsToFree)}, до тех пор -- золото";
return text;
}, 22, UIKit.Theme.TextDim);
var next = storage.Next;
string why = storage.WhyNot(townHallLevel);
if (next == null)
{
UIKit.Text(card, "Хранилище расширено до предела", 22, UIKit.Theme.TextDim, TextAlignmentOptions.Center);
return;
}
var cost = next.Cost ?? new ResourceAmount[0];
if (why != null)
{
UIKit.Text(card, why + ". Цена: " + CostText(cost), 22, UIKit.Theme.TextDim, TextAlignmentOptions.Center);
return;
}
LiveCost(card, cost);
bool canPay = GameServices.TryGet(out IEconomy economy) && economy.CanAfford(cost);
var b = UIKit.Button(card, $"Расширить до {next.Capacity}", canPay ? UIKit.Theme.Green : UIKit.Theme.Gray, () =>
{
if (EconomyConfig.TryExpandCrystalStorage(townHallLevel, out string error))
{
UIKit.Toast($"Хранилище кристаллов: {EconomyConfig.CrystalStorageNow().Capacity}");
Rebuild();
}
else UIKit.Toast(error);
}, -1, 80, 26);
LiveAfford(b, canPay, () => GameServices.TryGet(out IEconomy now) && now.CanAfford(cost));
}
// Каких зданий на следующем уровне Ратуши можно построить больше: "Лес 2 → 3, Дом 3 → 4"
static string MoreBuildingsAt(int level, int next)
{
var config = EconomyConfig.Load();
var parts = new List<string>();
if (config.buildingLimits != null)
foreach (var l in config.buildingLimits)
{
if (l == null) continue;
int a = config.BuildingLimitFor(l.buildingName, level), b = config.BuildingLimitFor(l.buildingName, next);
if (b > a) parts.Add($"{l.buildingName} {a} → {b}");
}
return string.Join(", ", parts.ToArray());
}
// ---------------- Казармы ----------------
@@ -340,6 +492,72 @@ public static class BuildingWindow
() => hospital.Level = hospital.level + 1, Hospital.MaxBuildLevel);
}
// ---------------- Терем богатыря: окно героя ----------------
// Числа героя -- те же, что в панели "Герой" (GameProgress / GameData), механика не меняется.
static void HeroCastleSection(RectTransform card)
{
var gp = GameProgress.Instance;
UIKit.Text(card, "В Тереме живёт богатырь -- верховный командир. Он гуляет по городу; тапните по нему, чтобы поговорить.",
24, UIKit.Theme.TextDim, TextAlignmentOptions.Center);
if (gp == null) return;
int lvl = gp.Data.heroLevel, maxLvl = gp.HeroMaxLevel;
var head = UIKit.Row(card, 140, 16);
UIKit.Icon(head, new Color(0.55f, 0.4f, 0.2f), "Б", 130); // место под портрет
var info = UIKit.Card(head, new Color(0, 0, 0, 0), 0, 4);
UIKit.Size(info, -1, -1, 1);
UIKit.Text(info, $"Уровень {lvl} / {maxLvl}", 30, UIKit.Theme.Gold).fontStyle = FontStyles.Bold;
UIKit.Text(info, $"Сила героя: {GameData.HeroPower(lvl)}", 24, UIKit.Theme.Text);
UIKit.Text(info, $"Бонус к силе армии: +{Mathf.RoundToInt(gp.ArmyBonus * 100)}%", 24, UIKit.Theme.Text);
Header(card, "Навыки");
for (int i = 0; i < GameData.HeroSkillNames.Length; i++)
{
bool has = gp.HasHeroSkill(i);
UIKit.Text(card, has ? $"<color=#9be59b>•</color> {GameData.HeroSkillNames[i]} — открыт" // без «✓»: его нет в шрифте LiberationSans (06.11)
: $"{GameData.HeroSkillNames[i]} — с {GameData.HeroSkillLevels[i]} ур.",
22, has ? UIKit.Theme.Text : UIKit.Theme.TextDim);
}
Header(card, "Улучшение героя");
if (lvl >= maxLvl)
{
UIKit.Text(card, $"Предел уровня. Улучшите Ратушу до {gp.TownHallLevel + 1} ур., чтобы поднять предел до " +
$"{GameData.HeroMaxLevel(gp.TownHallLevel + 1)}.", 22, UIKit.Theme.TextDim, TextAlignmentOptions.Center);
}
else
{
var cost = GameData.HeroUpgradeCost(lvl);
Live(card, () =>
{
var parts = new List<string>();
foreach (var c in cost)
{
bool enough = gp.Res(c.type) >= c.amount;
parts.Add($"<color={(enough ? "#9be59b" : "#ff7a7a")}>{GameData.ResName(c.type)} {c.amount:N0}</color>");
}
return "Цена: " + string.Join(", ", parts.ToArray());
}, 22, UIKit.Theme.Text);
UIKit.Text(card, $"Дальше: сила {GameData.HeroPower(lvl)} → {GameData.HeroPower(lvl + 1)}, " +
$"бонус армии +{GameData.HeroArmyBonusPercent(lvl)}% → +{GameData.HeroArmyBonusPercent(lvl + 1)}%",
22, UIKit.Theme.TextDim, TextAlignmentOptions.Center);
var up = UIKit.Button(card, $"Улучшить до {lvl + 1} ур.", UIKit.Theme.Green, () =>
{
if (!gp.CanUpgradeHero) { UIKit.Toast("Не хватает ресурсов"); return; }
gp.UpgradeHero();
Rebuild();
}, -1, 80, 26);
live.Add(() => up.interactable = gp.CanUpgradeHero);
}
UIKit.Button(card, "Подробнее о герое", UIKit.Theme.Blue, () =>
{
Close();
if (UIPanelManager.Instance != null) UIPanelManager.Instance.Open("Hero");
}, -1, 80, 26);
}
// Резерв для найма на уровне Ратуши (те же числа, что у модуля Населения)
static int ReserveAt(int townHallLevel)
{
@@ -372,22 +590,125 @@ public static class BuildingWindow
}
Header(card, $"Улучшение до {level + 1} ур.");
UIKit.Text(card, effect, 24, UIKit.Theme.Text, TextAlignmentOptions.Center);
if (focus != null && TimerRunning(focus)) return; // улучшение уже идёт -- полоска сверху
var cost = config.UpgradeCost(level);
UIKit.Text(card, "Цена: " + CostText(cost), 24, UIKit.Theme.Text, TextAlignmentOptions.Center);
LiveCost(card, cost);
long ms = config.ConstructionMs(false, level + 1);
TimeText(card, ms);
var building = focus;
bool canPay = GameServices.TryGet(out IEconomy economy) && economy.CanAfford(cost);
var b = UIKit.Button(card, $"Улучшить до {level + 1} ур.", canPay ? UIKit.Theme.Green : UIKit.Theme.Gray, () =>
{
bool timed = ms > 0 && building != null;
if (timed && BuildTimer.FreeBuilders == 0) { UIKit.Toast("Все строители заняты"); return; }
if (!GameServices.TryGet(out IEconomy e) || !e.TrySpend(cost)) { UIKit.Toast("Не хватает ресурсов"); return; }
raiseLevel();
if (CitySync.Instance != null) CitySync.Instance.SyncNow();
UIKit.Toast($"Улучшено до {level + 1} уровня");
if (timed)
{
long now = GameClock.UnixNowMs;
BuildTimer.Begin(building, BuildTimer.Kind.Upgrade, level + 1, now, now + ms);
UIKit.Toast($"Улучшение до {level + 1} уровня началось");
}
else
{
raiseLevel();
if (CitySync.Instance != null) CitySync.Instance.SyncNow();
UIKit.Toast($"Улучшено до {level + 1} уровня");
}
Rebuild();
}, -1, 80, 26);
LiveAfford(b, canPay, () => GameServices.TryGet(out IEconomy now) && now.CanAfford(cost));
}
// Кнопка «Улучшить» следит за ресурсами, пока окно открыто: ресурсов стало хватать -- зелёная и нажимается,
// перестало -- серая (05.11: раньше нужно было закрыть и открыть окно)
static void LiveAfford(Button b, bool canPay, Func<bool> can)
{
b.interactable = canPay;
bool shown = canPay;
live.Add(() =>
{
if (b == null) return;
bool c = can();
if (c == shown) return;
shown = c;
b.interactable = c;
var img = b.GetComponent<Image>();
if (img != null) img.color = c ? UIKit.Theme.Green : UIKit.Theme.Gray;
});
}
// ---------------- Стройка по времени (BuildTimer) ----------------
static bool TimerRunning(GameObject building)
{
var t = BuildTimer.Of(building);
return t != null && BuildTimer.All.Contains(t);
}
static void TimeText(RectTransform card, long ms)
{
if (ms <= 0) return;
Live(card, () => $"Время: {GameData.FormatTime(ms / 1000.0)} • строителей свободно: {BuildTimer.FreeBuilders} из {BuildTimer.Builders}",
22, UIKit.Theme.TextDim);
}
// Полоска стройки или улучшения, сколько осталось, ускорение за кристаллы
static void TimerBlock(RectTransform card, GameObject building)
{
var t = BuildTimer.Of(building);
if (t == null || !BuildTimer.All.Contains(t)) return;
Header(card, t.IsConstruction ? "Идёт стройка" : $"Идёт улучшение до {t.targetLevel} ур.");
var bar = UIKit.Bar(card, t.Progress, UIKit.Theme.Gold, 26);
live.Add(() => { if (t != null) UIKit.SetBar(bar, t.Progress); });
Live(card, () => t != null ? "Осталось " + GameData.FormatTime(t.SecondsLeft) : "Готово", 24, UIKit.Theme.Text);
// Ускорение -- за золото (06.11, ТЕСТ: EconomyConfig.construction.secondsPerGold)
Func<int> gold = () => t != null ? EconomyConfig.SpeedUpGold(t.SecondsLeft) : 0;
var speed = UIKit.Button(card, "", UIKit.Theme.Blue, () =>
{
if (t == null) return;
int price = gold();
if (!GameServices.TryGet(out IEconomy e) || !e.TrySpend(ResourceType.Gold, price))
{
UIKit.Toast($"Нужно {price} золота");
return;
}
t.Complete(); // окно перестроит OnBuildCompleted
}, -1, 80, 26);
live.Add(() => { if (t != null) UIKit.SetButton(speed, $"Ускорить за {gold()} золота", true); });
UIKit.SetButton(speed, $"Ускорить за {gold()} золота", true);
}
// ---------------- Перенос (решения 04.11: сноса нет) ----------------
// Здание можно бесплатно переставить в пределах поселения: уровень, стройка или улучшение сохраняются.
// Снести здание и вернуть ресурсы нельзя -- такой кнопки нет.
static void ActionsBlock(RectTransform card, GameObject building)
{
var id = building.GetComponent<PlacedBuildingId>();
if (id == null || string.IsNullOrEmpty(id.buildingName)) return; // Ратуша и стартовые постройки сцены
UIKit.Button(card, "Перенести", UIKit.Theme.Blue, () =>
{
Close();
if (BuildModeController.Instance != null) BuildModeController.Instance.StartMove(building);
}, -1, 80, 26);
}
// Цена: чего хватает -- зелёным, чего нет -- красным, со сколько есть
// Строка «Цена: …» обновляется, пока окно открыто: хватает ресурса -- зелёная цифра, нет -- красная и «есть N» (05.11)
static void LiveCost(Transform card, ResourceAmount[] cost)
{
var text = UIKit.Text(card, "Цена: " + CostText(cost), 24, UIKit.Theme.Text, TextAlignmentOptions.Center);
string shown = text.text;
live.Add(() =>
{
if (text == null) return;
string now = "Цена: " + CostText(cost);
if (now != shown) { shown = now; text.text = now; }
});
}
static string CostText(ResourceAmount[] cost)
{
GameServices.TryGet(out IEconomy economy);
@@ -511,6 +832,7 @@ public class BuildingWindowTicker : MonoBehaviour
void Update()
{
if (Input.GetKeyDown(KeyCode.Escape)) { BuildingWindow.Close(); return; } // "Назад" на телефоне
if (Time.unscaledTime < next) return;
next = Time.unscaledTime + 0.5f;
BuildingWindow.Tick();
+10 -3
View File
@@ -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;
+40
View File
@@ -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:
+152 -3
View File
@@ -17,6 +17,7 @@ public class DebugPanel : MonoBehaviour
GameObject panel;
TextMeshProUGUI info;
UnityEngine.UI.Button gridButton;
float nextRefresh;
[RuntimeInitializeOnLoadMethod(RuntimeInitializeLoadType.AfterSceneLoad)]
@@ -72,8 +73,24 @@ public class DebugPanel : MonoBehaviour
Btn(row1, "+10 часов", UIKit.Theme.Blue, () => GameClock.Advance(36000));
var row2 = UIKit.Row(card, 70, 10);
Btn(row2, "+100 всех ресурсов", UIKit.Theme.Green, AddAllResources);
Btn(row2, "+1000 всех ресурсов", UIKit.Theme.Green, AddAllResources);
Btn(row2, "+1000 еды", UIKit.Theme.Green, () => AddResource(ResourceType.Food, 1000));
Btn(row2, "Кристаллы = макс", UIKit.Theme.Blue, FillGems);
// Недельный PvE-предел кристаллов: «+1 PvE-кристалл» выдаёт как настоящая награда (квота, место, компенсация)
var rowQ = UIKit.Row(card, 70, 10);
Btn(rowQ, "+1 PvE-кристалл", UIKit.Theme.Blue, () => { if (GameProgress.Instance != null) GameProgress.Instance.Grant(new[] { new RewardItem(ResourceType.Gems, 1) }); Refresh(); });
// Только для проверки торговца (30 000 / 60 000 золота) и Кристальной шахты (12 000 золота, 3200 камня...):
// обычные ресурсы ставятся напрямую, мимо лимита склада
Btn(rowQ, "Ресурсы 100 000", UIKit.Theme.Green, () =>
{
var rm = ResourceManager.Instance;
if (rm != null)
foreach (var t in new[] { ResourceType.Gold, ResourceType.Wood, ResourceType.Stone, ResourceType.Iron, ResourceType.Food })
rm.SetResource(t, 100000);
Refresh();
});
Btn(rowQ, "Сбросить квоту", UIKit.Theme.Gray, () => { if (GameProgress.Instance != null) GameProgress.Instance.DevResetCrystalQuota(); UIKit.Toast("Недельная квота кристаллов сброшена"); Refresh(); });
var row3 = UIKit.Row(card, 70, 10);
Btn(row3, "Еда = 0", UIKit.Theme.Red, ZeroFood);
@@ -81,11 +98,132 @@ public class DebugPanel : MonoBehaviour
var row4 = UIKit.Row(card, 70, 10);
Btn(row4, "Рабочие", UIKit.Theme.Blue, () => BuildingWindow.Open(null));
Btn(row4, "Закрыть", UIKit.Theme.Gray, () => panel.SetActive(false));
gridButton = UIKit.Button(row4, GridLabel, UIKit.Theme.Blue, ToggleGrid, -1, 70, 24);
UIKit.Size(gridButton, -1, 70, 1);
Btn(row4, "Камера", UIKit.Theme.Blue, ToggleCameraCard);
Btn(row4, "Закрыть", UIKit.Theme.Gray, () => { panel.SetActive(false); if (cameraCard != null) cameraCard.SetActive(false); });
panel.SetActive(false);
}
// ---------------- Камера 3D-поселения (решение 05.11: перспектива, числа подбираем в Unity и на телефоне) ----------------
GameObject cameraCard;
TextMeshProUGUI cameraInfo;
// Ползунок: подпись, значение, диапазон. get/set -- поле камеры
struct CamSlider { public string Label, Format; public float Min, Max; public System.Func<City3DCamera, float> Get; public System.Action<City3DCamera, float> Set; public string Pref; }
static readonly CamSlider[] CamSliders =
{
new CamSlider { Label = "Наклон", Format = "0°", Min = 25, Max = 85, Pref = City3DCamera.PrefPitch,
Get = c => c.pitch, Set = (c, v) => c.pitch = v },
new CamSlider { Label = "Перспектива", Format = "0°", Min = 15, Max = 75, Pref = City3DCamera.PrefFov,
Get = c => c.fieldOfView, Set = (c, v) => c.SetFieldOfView(v) },
new CamSlider { Label = "Ближе всего", Format = "0", Min = 3, Max = 30, Pref = City3DCamera.PrefMin,
Get = c => c.minDistance, Set = (c, v) => { c.minDistance = Mathf.Min(v, c.maxDistance - 1); c.distance = Mathf.Max(c.distance, c.minDistance); } },
new CamSlider { Label = "Дальше всего", Format = "0", Min = 20, Max = 120, Pref = City3DCamera.PrefMax,
Get = c => c.maxDistance, Set = (c, v) => { c.maxDistance = Mathf.Max(v, c.minDistance + 1); c.distance = Mathf.Min(c.distance, c.maxDistance); } },
new CamSlider { Label = "Сейчас", Format = "0", Min = 3, Max = 120, Pref = null,
Get = c => c.distance, Set = (c, v) => c.distance = Mathf.Clamp(v, c.minDistance, c.maxDistance) },
};
static City3DCamera Cam3D => Object.FindAnyObjectByType<City3DCamera>();
void ToggleCameraCard()
{
if (cameraCard != null) { bool show = !cameraCard.activeSelf; if (show) BuildCameraCard(); else cameraCard.SetActive(false); return; }
BuildCameraCard();
}
void BuildCameraCard()
{
if (cameraCard != null) Destroy(cameraCard);
var cam = Cam3D;
var safe = panel.transform.parent;
var card = UIKit.Card(safe, new Color(0.08f, 0.08f, 0.11f, 0.95f), 20, 10);
card.anchorMin = card.anchorMax = new Vector2(0, 0.5f);
card.pivot = new Vector2(0, 0.5f);
card.anchoredPosition = new Vector2(135 + 640, 0);
card.sizeDelta = new Vector2(640, 0);
card.gameObject.AddComponent<ContentSizeFitter>().verticalFit = ContentSizeFitter.FitMode.PreferredSize;
cameraCard = card.gameObject;
var title = UIKit.Text(card, "КАМЕРА 3D-ПОСЕЛЕНИЯ (ТЕСТ)", 28, UIKit.Theme.Gold, TextAlignmentOptions.Center);
title.fontStyle = FontStyles.Bold;
if (cam == null)
{
UIKit.Text(card, "Откройте 3D-город (сцена City3D)", 24, UIKit.Theme.TextDim, TextAlignmentOptions.Center);
}
else
{
foreach (var def in CamSliders)
{
var d = def;
var row = UIKit.Row(card, 60, 12);
var label = UIKit.Text(row, d.Label, 24);
UIKit.Size(label, 170);
var value = UIKit.Text(row, d.Get(cam).ToString(d.Format), 24, UIKit.Theme.TextDim, TextAlignmentOptions.Right);
UIKit.Slider(row, Mathf.InverseLerp(d.Min, d.Max, d.Get(cam)), t =>
{
var c = Cam3D;
if (c == null) return;
d.Set(c, Mathf.Lerp(d.Min, d.Max, t));
value.text = d.Get(c).ToString(d.Format);
if (d.Pref != null) PlayerPrefs.SetFloat(d.Pref, d.Get(c)); // запомнить на этом устройстве
c.Apply();
UpdateCameraInfo();
});
value.transform.SetAsLastSibling();
UIKit.Size(value, 80);
}
}
cameraInfo = UIKit.Text(card, "", 22, UIKit.Theme.TextDim, TextAlignmentOptions.Center);
UpdateCameraInfo();
var row2 = UIKit.Row(card, 64, 10);
Btn(row2, "Сбросить", UIKit.Theme.Gray, ResetCamera);
Btn(row2, "Закрыть", UIKit.Theme.Gray, () => cameraCard.SetActive(false));
}
// Строка, которую удобно переписать в отчёт / в сцену
void UpdateCameraInfo()
{
var c = Cam3D;
if (cameraInfo == null) return;
cameraInfo.text = c == null ? "" :
$"pitch {c.pitch:0} • fieldOfView {c.fieldOfView:0} • minDistance {c.minDistance:0} • maxDistance {c.maxDistance:0} • distance {c.distance:0}\n" +
"Подобранное запоминается на этом устройстве. В сцену (для всех) -- вписать числа в Main Camera → City3DCamera";
}
void ResetCamera()
{
foreach (var d in CamSliders) if (d.Pref != null) PlayerPrefs.DeleteKey(d.Pref);
var c = Cam3D;
if (c != null)
{
c.pitch = c.DefaultPitch;
c.SetFieldOfView(c.DefaultFov);
c.minDistance = c.DefaultMin;
c.maxDistance = c.DefaultMax;
c.distance = Mathf.Clamp(c.distance, c.minDistance, c.maxDistance);
c.Apply();
}
BuildCameraCard();
}
// Отладочная сетка стройки (3D-поселение и 2D-город): видна всегда, ярче, каждая 5-я линия жёлтая (05.11, просьба Миши)
void ToggleGrid()
{
bool on = !(City3DController.DebugGrid || BuildGrid.DebugVisible);
City3DController.SetDebugGrid(on);
bool building = BuildModeController.Instance != null && BuildModeController.Instance.IsActive;
BuildGrid.SetDebugVisible(on, building);
if (gridButton != null) UIKit.SetButton(gridButton, GridLabel, true);
}
static string GridLabel => City3DController.DebugGrid || BuildGrid.DebugVisible ? "Сетка: вкл" : "Сетка: выкл";
static void Btn(Transform row, string label, Color color, System.Action onClick)
{
var b = UIKit.Button(row, label, color, onClick, -1, 70, 24);
@@ -142,7 +280,18 @@ public class DebugPanel : MonoBehaviour
void AddAllResources()
{
ResourceType[] types = { ResourceType.Wood, ResourceType.Food, ResourceType.Stone, ResourceType.Iron, ResourceType.Gold, ResourceType.Gems };
foreach (var t in types) AddResource(t, 100);
foreach (var t in types) AddResource(t, 1000); // лишнее обрежет лимит склада
}
// Кристаллы до предела склада (лимит 50 -- решение 19.10, предел не меняется)
void FillGems()
{
var rm = ResourceManager.Instance;
if (rm == null) { UIKit.Toast("Нет склада (ResourceManager)"); return; }
float cap = rm.GetCapacity(ResourceType.Gems);
if (float.IsInfinity(cap)) cap = EconomyConfig.Load().storage.crystalCap;
rm.SetResource(ResourceType.Gems, cap);
Refresh();
}
void AddResource(ResourceType type, float amount)
+198 -2
View File
@@ -1,3 +1,4 @@
using System.Collections.Generic;
using System;
using UnityEngine;
@@ -70,6 +71,20 @@ public class EconomyConfig : ScriptableObject
[Range(0f, 1f)] public float famineNonFoodMultiplier = 0.5f;
}
[Serializable]
public class CrystalExpansion
{
public int townHallLevel = 3;
public int capacity = 75;
public float gold, wood, stone, iron;
public CrystalExpansion() { }
public CrystalExpansion(int townHall, int cap, float gold, float wood, float stone, float iron)
{
townHallLevel = townHall; capacity = cap; this.gold = gold; this.wood = wood; this.stone = stone; this.iron = iron;
}
}
[Serializable]
public class StorageSettings // этап 1: лимиты хранения
{
@@ -81,8 +96,31 @@ public class EconomyConfig : ScriptableObject
public float perWarehouseLevel = 1000f;
[Tooltip("+ к лимиту еды за каждый уровень Амбара. Не утверждено")]
public float perGranaryLevel = 1500f;
[Tooltip("Лимит синего кристалла (решение команды от 19.10: небольшой, не бесконечный). Число не утверждено")]
[Tooltip("Стартовая вместимость синих кристаллов (регламент валют 05.11, разд. 8). ТЕСТ")]
public float crystalCap = 50f;
[Tooltip("Недельный PvE-предел: не больше стольких НОВЫХ синих кристаллов за окно из заданий, событий, боёв, PvE-торговца. " +
"Шахта, торговля и грабёж -- вне предела (регламент валют 05.11, разд. 5.2; ответ 06.11, п. 2). ТЕСТ")]
public int crystalPveLimit = 6;
[Tooltip("Окно недельного PvE-предела, реальных суток (скользящее). ТЕСТ")]
public int crystalPveWindowDays = 7;
[Tooltip("Компенсация золотом за каждый синий кристалл PvE-награды сверх недельного предела (величина не согласована). ТЕСТ")]
public float crystalCompensationGold = 1000f;
[Tooltip("Кристальная шахта: синих кристаллов за период на одну работающую шахту (ответ 06.11, п. 3). ТЕСТ")]
public int crystalMinePerPeriod = 3;
[Tooltip("Кристальная шахта: период, РЕАЛЬНЫХ суток (не игровые часы ×12). ТЕСТ")]
public int crystalMinePeriodDays = 7;
[Tooltip("Редкий PvE-торговец: цена синего кристалла в золоте по порядку покупок за 7 реальных суток; покупок не больше, " +
"чем цен (регламент валют 05.11, разд. 3). Входит в недельный PvE-предел. ТЕСТ")]
public int[] crystalTraderPrices = { 30000, 60000 };
[Tooltip("Платные расширения хранилища кристаллов по порядку: с какой Ратуши, до скольки, цена (золото и обычные ресурсы). " +
"Ратуша только разрешает -- само не растёт (регламент валют 05.11, разд. 8; ответ 06.11, п. 1). Числа и цены ТЕСТ")]
public CrystalExpansion[] crystalExpansions =
{
new CrystalExpansion(3, 75, 2000, 1500, 1500, 0),
new CrystalExpansion(5, 100, 4000, 3000, 3000, 1000),
new CrystalExpansion(7, 150, 7000, 5000, 5000, 2000),
new CrystalExpansion(9, 200, 10000, 8000, 8000, 4000),
};
[Tooltip("Цена Склада: дерево / камень. Не утверждено")]
public float warehouseWood = 200f;
@@ -126,6 +164,51 @@ public class EconomyConfig : ScriptableObject
public int[] requiredBarracksLevel = { 1, 2, 3, 5 };
}
[Serializable]
public class ConstructionSettings // время стройки и улучшения, строители (01.11, ТЕСТ)
{
[Tooltip("Сколько строек и улучшений идёт одновременно (строители). ТЕСТ")]
public int builders = 2;
[Tooltip("Постройка нового здания, секунд. ТЕСТ")]
public int buildSeconds = 60;
[Tooltip("Улучшение до уровня 2, 3, 4... (секунд): [0] -- до 2 ур., [1] -- до 3 ур. ... ТЕСТ")]
public int[] upgradeSecondsToLevel = { 60, 120, 300, 600, 1200, 1800, 3600, 5400, 7200 };
[Tooltip("Ратуша строится дольше остальных: множитель времени улучшения. ТЕСТ")]
public float townHallTimeMultiplier = 2f;
[Tooltip("Устарело (06.11): ускорение теперь за золото -- secondsPerGold")]
public int secondsPerGem = 30;
[Tooltip("Ускорение стройки, улучшения и обучения войск -- за золото (06.11; регламент валют: золото -- основной ресурс " +
"построек и улучшений, синие кристаллы на ускорения не тратятся): секунд за 1 золото. ТЕСТ")]
public int secondsPerGold = 10;
// Сноса зданий нет (решение 04.11): только бесплатный перенос
}
[Serializable]
public class BuildingLimit
{
public string buildingName;
[Tooltip("Сколько таких зданий можно иметь на Ратуше 1, 2, 3 ... уровня. 0 -- нельзя")]
public int[] maxByTownHall;
public BuildingLimit() { }
public BuildingLimit(string name, params int[] max) { buildingName = name; maxByTownHall = max; }
}
[Serializable]
public class ProtectionSettings // защита ресурсов от грабежа (01.11, ТЕСТ)
{
[Tooltip("Защищено без Складов: дерево, камень, металл, золото. ТЕСТ")]
public float baseGoods = 100f;
[Tooltip("+ к защите дерева, камня, металла, золота за каждый уровень Склада. ТЕСТ")]
public float perWarehouseLevel = 200f;
[Tooltip("Защищено без Амбаров: еда. ТЕСТ")]
public float baseFood = 100f;
[Tooltip("+ к защите еды за каждый уровень Амбара. ТЕСТ")]
public float perGranaryLevel = 300f;
[Tooltip("Кристаллы: без технологии «Грабёж» нападающий не уносит ничего (решение 04.11). С ней -- не больше этой доли запаса. ТЕСТ, точные ограничения определят отдельно")]
[Range(0f, 1f)] public float plunderGemsShare = 0.1f;
}
[Serializable]
public class StartResources
{
@@ -159,6 +242,30 @@ public class EconomyConfig : ScriptableObject
[Header("Солдат T1 (этап 5)")]
public SoldierSettings soldier = new SoldierSettings();
[Header("Стройка: время, строители, снос (ТЕСТ)")]
public ConstructionSettings construction = new ConstructionSettings();
[Header("Лимит зданий по уровню Ратуши (ТЕСТ). Здания не из списка -- без лимита")]
public BuildingLimit[] buildingLimits =
{
// Ратуша: 1 2 3 4 5 6 7 8 9 10
new BuildingLimit("Лес", 2, 3, 3, 4, 4, 5, 5, 6, 6, 7),
new BuildingLimit("Еда", 2, 3, 3, 4, 4, 5, 5, 6, 6, 7),
new BuildingLimit("Камень", 2, 2, 3, 3, 4, 4, 5, 5, 6, 6),
new BuildingLimit("Металл", 0, 0, 1, 1, 2, 2, 3, 3, 4, 4),
new BuildingLimit("Золото", 0, 1, 1, 2, 2, 3, 3, 3, 4, 4),
new BuildingLimit("Население", 3, 4, 5, 6, 7, 8, 9, 10, 11, 12),
new BuildingLimit("Склад", 1, 1, 2, 2, 2, 3, 3, 3, 4, 4),
new BuildingLimit("Амбар", 1, 1, 2, 2, 2, 3, 3, 3, 4, 4),
new BuildingLimit("Казармы", 1, 1, 2, 2, 3, 3, 3, 3, 3, 3),
new BuildingLimit("Госпиталь", 1, 1, 1, 1, 1, 1, 1, 1, 1, 1),
new BuildingLimit("Терем богатыря", 1, 1, 1, 1, 1, 1, 1, 1, 1, 1),
new BuildingLimit("Кристалл", 0, 0, 0, 0, 0, 1, 1, 1, 2, 2), // Кристальная шахта: вторая -- на Ратуше 9 (ответ 06.11, п. 3; уровень ТЕСТ)
};
[Header("Защита ресурсов от грабежа (ТЕСТ)")]
public ProtectionSettings protection = new ProtectionSettings();
// ---------------- Загрузка ----------------
static EconomyConfig cached;
@@ -187,11 +294,54 @@ public class EconomyConfig : ScriptableObject
case ResourceType.Iron:
case ResourceType.Gold: return s.baseGoods + s.perWarehouseLevel * warehouseLevels;
case ResourceType.Food: return s.baseFood + s.perGranaryLevel * granaryLevels;
case ResourceType.Gems: return s.crystalCap;
case ResourceType.Gems: return CrystalStorageNow().Capacity; // стартовая + купленные расширения
default: return float.PositiveInfinity;
}
}
// Сколько зданий с таким именем можно иметь на этом уровне Ратуши. -1 -- без лимита (здания нет в списке)
public int BuildingLimitFor(string buildingName, int townHallLevel)
{
if (buildingLimits == null || string.IsNullOrEmpty(buildingName)) return -1;
foreach (var l in buildingLimits)
{
if (l == null || l.buildingName != buildingName || l.maxByTownHall == null || l.maxByTownHall.Length == 0) continue;
return Mathf.Max(0, l.maxByTownHall[Mathf.Clamp(townHallLevel, 1, l.maxByTownHall.Length) - 1]);
}
return -1;
}
// Время стройки (targetLevel = 1) или улучшения до targetLevel, мс. Ратуша -- с множителем
public long ConstructionMs(bool townHall, int targetLevel)
{
var c = construction;
double seconds;
if (targetLevel <= 1) seconds = c.buildSeconds;
else
{
var t = c.upgradeSecondsToLevel;
seconds = t == null || t.Length == 0 ? c.buildSeconds : t[Mathf.Clamp(targetLevel - 2, 0, t.Length - 1)];
}
if (townHall) seconds *= Mathf.Max(0.01f, c.townHallTimeMultiplier);
return (long)(Math.Max(0, seconds) * 1000.0);
}
// Сколько ресурса не отнять при грабеже: часть запаса, которую прячут Склады и Амбары
public float GetProtected(ResourceType type, int warehouseLevels, int granaryLevels)
{
var p = protection;
switch (type)
{
case ResourceType.Wood:
case ResourceType.Stone:
case ResourceType.Iron:
case ResourceType.Gold: return p.baseGoods + p.perWarehouseLevel * warehouseLevels;
case ResourceType.Food: return p.baseFood + p.perGranaryLevel * granaryLevels;
case ResourceType.Gems: return float.PositiveInfinity; // без «Грабежа» кристаллы не грабятся (с ним -- ResourceProtection)
default: return 0f;
}
}
// Цена улучшения здания с уровня level на level + 1
public ResourceAmount[] UpgradeCost(int level)
{
@@ -217,4 +367,50 @@ public class EconomyConfig : ScriptableObject
default: return 0f;
}
}
// Цена ускорения в золоте: сколько осталось секунд -> золото (не меньше 1). Стройка, улучшение и обучение войск
public static int SpeedUpGold(double secondsLeft)
{
int perGold = Mathf.Max(1, Load().construction.secondsPerGold);
return Mathf.Max(1, Mathf.CeilToInt((float)(secondsLeft / perGold)));
}
// ---------------- Хранилище синих кристаллов (регламент валют 05.11, разд. 8; правила -- CrystalStorage) ----------------
// Правила с купленной ступенью (её хранит Экономика: ResourceManager.CrystalStorageLevel, секция "economy")
public static CrystalStorage CrystalStorageNow()
{
var c = Load();
var tiers = new List<CrystalStorage.Tier>();
if (c.storage.crystalExpansions != null)
foreach (var e in c.storage.crystalExpansions)
{
if (e == null) continue;
var cost = new List<ResourceAmount>();
if (e.gold > 0) cost.Add(new ResourceAmount(ResourceType.Gold, e.gold));
if (e.wood > 0) cost.Add(new ResourceAmount(ResourceType.Wood, e.wood));
if (e.stone > 0) cost.Add(new ResourceAmount(ResourceType.Stone, e.stone));
if (e.iron > 0) cost.Add(new ResourceAmount(ResourceType.Iron, e.iron));
tiers.Add(new CrystalStorage.Tier { TownHallLevel = e.townHallLevel, Capacity = e.capacity, Cost = cost.ToArray() });
}
var storage = new CrystalStorage(Mathf.RoundToInt(c.storage.crystalCap), tiers);
if (ResourceManager.Instance != null) storage.SetLevel(ResourceManager.Instance.CrystalStorageLevel);
return storage;
}
// Купить следующую ступень: цена списывается один раз (всё или ничего), ступень и ресурсы -- в одной секции
// сохранения "economy", сохраняется сразу. error -- почему нельзя
public static bool TryExpandCrystalStorage(int townHallLevel, out string error)
{
var rm = ResourceManager.Instance;
var storage = CrystalStorageNow();
error = storage.WhyNot(townHallLevel);
if (error != null) return false;
if (rm == null || !GameServices.TryGet(out IEconomy economy)) { error = "Склад не найден"; return false; }
if (!storage.TryExpand(townHallLevel, cost => economy.TrySpend(cost))) { error = "Не хватает ресурсов"; return false; }
rm.CrystalStorageLevel = storage.Level;
rm.SetCapacity(ResourceType.Gems, storage.Capacity);
if (SaveManager.Instance != null) SaveManager.Instance.SaveGame();
return true;
}
}
+33
View File
@@ -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()
+50
View File
@@ -0,0 +1,50 @@
using UnityEngine;
// Версия игры на экране (05.11, требование Миши: в каждом билде видно, что это за версия).
// В углу экрана: версия из Player Settings (Application.version) и сборка -- ветка, коммит, дата.
// Сборку пишет Editor/BuildInfoWriter перед билдом в Resources/BuildInfo.txt; в редакторе -- "редактор".
// Создаётся сам при запуске, живёт между сценами.
public class GameVersionLabel : MonoBehaviour
{
static GameVersionLabel instance;
string text;
GUIStyle style, shadow;
public static string BuildInfo
{
get
{
var asset = Resources.Load<TextAsset>("BuildInfo");
return asset != null ? asset.text.Trim() : "редактор";
}
}
public static string FullVersion => $"v{Application.version} • {BuildInfo}";
[RuntimeInitializeOnLoadMethod(RuntimeInitializeLoadType.AfterSceneLoad)]
static void Create()
{
if (instance != null) return;
var go = new GameObject("GameVersionLabel");
DontDestroyOnLoad(go);
instance = go.AddComponent<GameVersionLabel>();
instance.text = FullVersion;
Debug.Log("[ВЕРСИЯ] " + instance.text);
}
void OnGUI()
{
if (style == null)
{
int size = Mathf.Max(12, Screen.height / 60);
style = new GUIStyle(GUI.skin.label) { fontSize = size, alignment = TextAnchor.LowerLeft };
style.normal.textColor = new Color(1f, 1f, 1f, 0.75f);
shadow = new GUIStyle(style);
shadow.normal.textColor = new Color(0f, 0f, 0f, 0.6f);
}
GUI.depth = -1000; // поверх всего
var r = new Rect(8, Screen.height - style.fontSize * 2 - 4, Screen.width * 0.6f, style.fontSize * 2);
GUI.Label(new Rect(r.x + 1, r.y + 1, r.width, r.height), text, shadow);
GUI.Label(r, text, style);
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 4dae86c365fe55948b878547633d712a
+33
View File
@@ -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
+190 -18
View File
@@ -14,35 +14,144 @@ public class City3DCamera : MonoBehaviour
public Vector3 target; // точка на земле, на которую смотрит камера
public Rect bounds = new Rect(0, 0, 48, 48); // где может быть target (x, z)
// Открытая часть поселения (зоны по уровню Ратуши), 06.11: центр кадра из неё не уходит, а при самом дальнем
// отдалении она видна целиком -- даже если "Дальше всего" (maxDistance) подобрано меньше. Ширина 0 -- не задана
Rect openArea;
float fitOpenDistance; // с какого расстояния открытая часть видна целиком (между HUD сверху и снизу)
public const float HudTopShare = 0.16f, HudBottomShare = 0.16f; // какую долю экрана закрывает HUD (ТЕСТ)
// Дальше всего сейчас: подобранное число, но не ближе, чем нужно, чтобы увидеть всё открытое поселение
public float MaxDistanceNow => Mathf.Max(maxDistance, fitOpenDistance);
public void SetOpenArea(Rect area)
{
openArea = area;
fitOpenDistance = FitDistance(area);
Clamp();
}
// Плавное приближение кнопками "+ / −" и колесом: камера доезжает до нужного расстояния за доли секунды
float zoomTo = -1f;
Camera cam;
Vector3 focusTarget;
float focusDistance;
bool focusing;
Vector3 lastPointer;
bool panning;
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)
// onlyZoomOut -- не приближать: если камера уже дальше, расстояние остаётся (открытие зоны, 06.11)
public void FocusOn(Rect area, bool onlyZoomOut = false)
{
focusTarget = new Vector3(area.center.x, 0, area.center.y);
// отойти так, чтобы область поместилась по высоте экрана (с запасом)
float size = Mathf.Max(area.width, area.height);
focusDistance = Mathf.Clamp(size * 1.15f, minDistance, maxDistance);
// отойти так, чтобы область поместилась между HUD сверху и снизу
focusDistance = Mathf.Clamp(FitDistance(area), minDistance, MaxDistanceNow);
if (onlyZoomOut) focusDistance = Mathf.Max(focusDistance, Mathf.Min(distance, MaxDistanceNow));
focusing = true;
zoomTo = -1f;
}
// Плавно подъехать к точке, не меняя приближения
public void MoveTo(Vector3 point)
{
focusTarget = new Vector3(point.x, 0, point.z);
focusDistance = distance;
focusing = true;
zoomTo = -1f;
}
// Расстояние, с которого прямоугольник земли (x, z) виден целиком, если смотреть на его центр. Считается по
// настоящей проекции камеры (наклон, поле зрения, форма экрана), поэтому верно при любых числах из DEV
public float FitDistance(Rect area)
{
if (cam == null) cam = GetComponent<Camera>();
if (cam == null || area.width <= 0 || area.height <= 0) return 0f;
Vector3 keepPos = transform.position;
Quaternion keepRot = transform.rotation;
float keepOrtho = cam.orthographicSize;
var center = new Vector3(area.center.x, 0, area.center.y);
Vector3[] corners =
{
new Vector3(area.xMin, 0, area.yMin), new Vector3(area.xMax, 0, area.yMin),
new Vector3(area.xMin, 0, area.yMax), new Vector3(area.xMax, 0, area.yMax),
};
float lo = 1f, hi = 400f;
for (int i = 0; i < 24; i++) // двоичный поиск: ближе -- не помещается, дальше -- помещается
{
float d = (lo + hi) * 0.5f;
PlaceAt(center, d);
bool fits = true;
foreach (var c in corners)
{
Vector3 v = cam.WorldToViewportPoint(c);
if (v.z <= 0 || v.x < 0.02f || v.x > 0.98f || v.y < HudBottomShare || v.y > 1f - HudTopShare) { fits = false; break; }
}
if (fits) hi = d; else lo = d;
}
transform.SetPositionAndRotation(keepPos, keepRot);
cam.orthographicSize = keepOrtho;
return hi;
}
// Начать / продолжить / закончить перемещение камеры. Зовёт контроллер ввода (он решает, что под пальцем)
public void BeginPan(Vector3 screen) { panning = AllowPan; lastPointer = screen; focusing = false; }
public void BeginPan(Vector3 screen)
{
panning = AllowPan; lastPointer = screen; focusing = false; zoomTo = -1f;
panPlaneY = GroundHeightUnder(screen);
}
public void EndPan() { panning = false; }
// Палец ещё не сдвинулся настолько, чтобы считать это перетаскиванием: камера стоит, но точка запоминается --
// когда перетаскивание начнётся, карта не прыгнет (как на карте мира, 05.11)
public void Track(Vector3 screen) { lastPointer = screen; }
public void Pan(Vector3 screen)
{
if (!panning || cam == null) return;
@@ -50,28 +159,54 @@ public class City3DCamera : MonoBehaviour
target += a - b;
lastPointer = screen;
Clamp();
Apply(); // сразу: иначе следующий шаг считался бы от старого положения камеры -- камера дёргалась (05.11)
}
// Кнопки "+ / −" и колесо: плавно (06.11). delta > 0 -- ближе
public void Zoom(float delta)
{
focusing = false;
distance = Mathf.Clamp(distance * (1f - delta), minDistance, maxDistance);
float from = zoomTo > 0 ? zoomTo : distance;
zoomTo = Mathf.Clamp(from * (1f - delta), minDistance, MaxDistanceNow);
}
float fitPitch, fitFov, fitAspect; // при каких наклоне, поле зрения и форме экрана считали fitOpenDistance
void Update()
{
// Наклон или поле зрения поменяли в DEV, повернули экран -- пересчитать, откуда видно всё поселение
if (openArea.width > 0 && cam != null && (fitPitch != pitch || fitFov != cam.fieldOfView || fitAspect != cam.aspect))
{
fitPitch = pitch; fitFov = cam.fieldOfView; fitAspect = cam.aspect;
fitOpenDistance = FitDistance(openArea);
}
// Колесо мыши (редактор, ПК)
float wheel = Input.mouseScrollDelta.y;
if (City3DInput.OverHud(Input.mousePosition)) wheel = 0f; // колесо над окнами HUD -- не приближать камеру (05.11)
if (Mathf.Abs(wheel) > 0.01f) Zoom(wheel * 0.1f);
// Два пальца -- приближение
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;
zoomTo = -1f;
distance = Mathf.Clamp(distance * previous / current, minDistance, MaxDistanceNow);
}
}
else lastPinch = 0;
if (zoomTo > 0)
{
distance = Mathf.Lerp(distance, zoomTo, 1f - Mathf.Exp(-Time.unscaledDeltaTime * 12f));
if (Mathf.Abs(distance - zoomTo) < 0.02f) { distance = zoomTo; zoomTo = -1f; }
}
distance = Mathf.Clamp(distance, minDistance, MaxDistanceNow);
if (focusing)
{
@@ -84,23 +219,60 @@ public class City3DCamera : MonoBehaviour
void Clamp()
{
target.x = Mathf.Clamp(target.x, bounds.xMin, bounds.xMax);
target.z = Mathf.Clamp(target.z, bounds.yMin, bounds.yMax);
Rect r = bounds;
if (openArea.width > 0 && openArea.height > 0) // центр кадра -- только над открытой частью поселения
r = Rect.MinMaxRect(Mathf.Max(r.xMin, openArea.xMin), Mathf.Max(r.yMin, openArea.yMin),
Mathf.Min(r.xMax, openArea.xMax), Mathf.Min(r.yMax, openArea.yMax));
target.x = Mathf.Clamp(target.x, r.xMin, r.xMax);
target.z = Mathf.Clamp(target.z, r.yMin, r.yMax);
target.y = 0;
}
// Дымка начинается за городом, а не на фиксированном расстоянии от камеры: иначе при отдалении весь город
// уходил в серую дымку -- казалось, что камера не отдаляется, а сцена сереет (05.11)
public float fogStartBehind = 4f, fogEndBehind = 59f;
public void Apply()
{
if (cam == null) cam = GetComponent<Camera>();
PlaceAt(target, distance);
if (RenderSettings.fog && RenderSettings.fogMode == FogMode.Linear)
{
RenderSettings.fogStartDistance = distance + fogStartBehind;
RenderSettings.fogEndDistance = distance + fogEndBehind;
}
}
// Поставить камеру: смотрит на точку center с расстояния d.
// Камера сцены может быть ортографической (вид 2.5D без перспективы): у неё расстояние не меняет размер города
// на экране -- «+ / −» и щипок ничего не делали (05.11). Тогда приближение -- через размер кадра: столько же
// земли, сколько видно перспективной камерой с этого расстояния
void PlaceAt(Vector3 center, float d)
{
Quaternion rot = Quaternion.Euler(pitch, yaw, 0);
transform.rotation = rot;
transform.position = target - rot * Vector3.forward * distance;
transform.position = center - rot * Vector3.forward * d;
if (cam != null && cam.orthographic)
cam.orthographicSize = d * Mathf.Tan(cam.fieldOfView * 0.5f * Mathf.Deg2Rad);
}
// Точка на плоскости земли (y = 0) под экранной точкой
// Точка под пальцем на горизонтальной плоскости на высоте земли, где палец коснулся (06.11): на холме карта
// идёт за пальцем точно, а не чуть быстрее или медленнее, как было с плоскостью y = 0
float panPlaneY;
Vector3 GroundPoint(Vector3 screen)
{
Ray ray = cam.ScreenPointToRay(screen);
var plane = new Plane(Vector3.up, Vector3.zero);
var plane = new Plane(Vector3.up, new Vector3(0, panPlaneY, 0));
return plane.Raycast(ray, out float t) ? ray.GetPoint(t) : target;
}
// Высота земли (объект "Ground" 3D-поселения) под экранной точкой; не попали -- 0
float GroundHeightUnder(Vector3 screen)
{
if (cam == null) return 0f;
foreach (var hit in Physics.RaycastAll(cam.ScreenPointToRay(screen), 500f))
if (hit.collider.gameObject.name == "Ground") return hit.point.y;
return 0f;
}
}
+490 -37
View File
@@ -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,16 +91,271 @@ 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)
public bool StateApplied => stateApplied;
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, которые ждали места
UpdateCameraArea();
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>();
cameraRig.bounds = new Rect(2, 2, City3DLayout.Size - 4, City3DLayout.Size - 4);
cameraRig.target = new Vector3(City3DLayout.Size / 2f, 0, City3DLayout.Size / 2f);
UpdateCameraArea();
cameraRig.Apply();
Map3D.SetUpLook(cam, 30f, 85f); // солнце, небо, дымка вдали
City3DGameplay.PutHudInFront(cam); // общий HUD -- поверх холмов и деревьев
layout = City3DLayout.Create();
map = layout.Map;
@@ -96,9 +363,8 @@ public class City3DController : MonoBehaviour
BuildObstacles();
BuildGrass();
// Ратуша стоит в центре с самого начала
int c = City3DLayout.Size / 2;
PlaceNew(0, c - 2, c - 2, 0);
// Ратуша и остальные здания -- из сохранения (или новая Ратуша в центре): в первом кадре, когда сохранение
// уже загружено (оно грузится в Start), см. ApplySaved
RefreshGround();
}
@@ -210,7 +476,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 +501,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 +526,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 +541,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 +584,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 +694,37 @@ 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;
// камера плавно подъезжает к месту, не меняя приближения
cameraRig.MoveTo(new Vector3(fx + t.W / 2f, 0, fy + t.H / 2f));
}
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 +774,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,12 +825,16 @@ 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)
{
if (Input.touchCount > 1) { cameraRig.EndPan(); draggingGhost = false; return; } // два пальца -- зум камеры
// два пальца -- зум камеры. Касание считается перетаскиванием: когда пальцы отпустят, это не тап
// (иначе после щипка открывалось окно здания под оставшимся пальцем) (06.11)
if (Input.touchCount > 1) { cameraRig.EndPan(); draggingGhost = false; pointerMoved = true; return; }
Touch touch = Input.GetTouch(0);
pos = touch.position;
down = touch.phase == TouchPhase.Began;
@@ -530,7 +851,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 +863,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 +907,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,22 +959,50 @@ 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;
UpdateCameraArea();
RefreshGround();
if (ghost != null) UpdateGhost();
if (townHallLevel > before)
{
// Открылась зона -- камера плавно отходит и показывает её; здания остаются на местах
int half = City3DLayout.ZoneHalfSize(townHallLevel), c = City3DLayout.Size / 2;
cameraRig.FocusOn(new Rect(c - half, c - half, half * 2, half * 2));
// Открылась зона -- камера плавно отходит и показывает её КРАЙ: старая зона и ZoneEdgeCells клеток новой за её
// границей (06.11, Саша: вся зона целиком -- камера улетала слишком далеко). Ближе, чем была, не подъезжает
int c = City3DLayout.Size / 2;
int show = Mathf.Min(City3DLayout.ZoneHalfSize(townHallLevel), City3DLayout.ZoneHalfSize(before) + ZoneEdgeCells);
cameraRig.FocusOn(new Rect(c - show, c - show, show * 2, show * 2), onlyZoomOut: true);
Say($"Ратуша {townHallLevel} ур.: открыта новая зона");
if (stateApplied && pendingFrom2D.Count > 0) PlacePendingFrom2D(); // здания из 2D, которые ждали места
}
}
const int ZoneEdgeCells = 2; // сколько клеток новой зоны показать при её открытии
// Камера: центр кадра -- только над открытой зоной, при самом дальнем отдалении видна вся зона (06.11)
void UpdateCameraArea()
{
if (cameraRig == null) return;
int half = City3DLayout.ZoneHalfSize(townHallLevel), c = City3DLayout.Size / 2;
cameraRig.SetOpenArea(new Rect(c - half, c - half, half * 2, half * 2));
}
void Say(string text)
{
message = text;
@@ -651,6 +1015,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 +1125,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 +1147,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 +1170,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);
}
}
+263
View File
@@ -0,0 +1,263 @@
using System.Collections.Generic;
using UnityEngine;
// 3D-ПОСЕЛЕНИЕ: здания 3D-сцены становятся зданиями игры (05.11, Валдемар).
// На 3D-здание вешаются те же компоненты, что на префабы 2D-города: рабочее место или Дом (ResourceCollector),
// Склад / Амбар (StorageBuilding), Казармы, Госпиталь, Ратуша (TownHall) и PlacedBuildingId с именем здания.
// Поэтому дальше всё работает так же, как в 2D: CitySync даёт места, жильё и лимиты; окно здания (BuildingWindow) --
// рабочие, добыча, улучшение по таймеру; стройка идёт через BuildTimer (цена, строители, лимиты, ускорение, офлайн).
// Место на карте проверяет SettlementMap Саши, цену, время и лимиты -- этот код и EconomyConfig.
public static class City3DGameplay
{
// Имя типа 3D-сцены -> имя здания игры (как в 2D: по нему лимиты, окна, сохранение)
public static string GameName(string type3d)
{
switch (type3d)
{
case "Дом": return "Население";
case "Ферма": return "Еда";
case "Лесопилка": return "Лес";
case "Каменоломня": return "Камень";
case "Шахта": return "Металл";
case "Склад": return StorageBootstrap.WarehouseName;
case "Амбар": return StorageBootstrap.GranaryName;
case "Казармы": return BarracksBootstrap.BarracksName;
case "Госпиталь": return HospitalBootstrap.HospitalName;
case "Золотой рудник": return "Золото";
case "Кристалл": return "Кристалл";
case "Терем": return HeroCastleBootstrap.CastleName;
default: return type3d; // "Ратуша"
}
}
// Обратно: имя здания игры (как в 2D-сохранении) -> тип 3D-сцены. null -- такого здания в 3D нет
public static string Type3DFor(string gameName)
{
foreach (var t in AllTypes3D)
if (GameName(t) == gameName) return t;
return null;
}
static readonly string[] AllTypes3D =
{
"Ратуша", "Дом", "Ферма", "Лесопилка", "Каменоломня", "Шахта", "Склад", "Амбар", "Казармы", "Госпиталь",
"Золотой рудник", "Кристалл", "Терем",
};
public static bool IsTownHall(string type3d) => type3d == "Ратуша";
// Цена постройки -- та же, что у зданий 2D-города (Data_*.asset, Склады -- EconomyConfig, Госпиталь -- HospitalConfig)
public static ResourceAmount[] BuildCost(string type3d)
{
var e = EconomyConfig.Load().storage;
switch (type3d)
{
case "Дом": return Cost(ResourceType.Wood, 100, ResourceType.Food, 200, ResourceType.Stone, 150);
case "Ферма": return Cost(ResourceType.Wood, 100);
case "Лесопилка": return Cost();
case "Каменоломня": return Cost(ResourceType.Wood, 50);
case "Шахта": return Cost(ResourceType.Wood, 600, ResourceType.Stone, 550);
case "Склад": return Cost(ResourceType.Wood, e.warehouseWood, ResourceType.Stone, e.warehouseStone);
case "Амбар": return Cost(ResourceType.Wood, e.granaryWood, ResourceType.Stone, e.granaryStone);
case "Казармы": return Cost(ResourceType.Wood, 300, ResourceType.Stone, 200);
case "Госпиталь":
var h = HospitalConfig.Load();
return Cost(ResourceType.Wood, h.buildWood, ResourceType.Stone, h.buildStone);
// Перенесены из 2D-города (решение 05.11): цены -- из Data_GoldMine, Data_Kristall и HeroCastleBootstrap
case "Золотой рудник": return Cost(ResourceType.Stone, 50);
case "Кристалл": return Cost(ResourceType.Wood, 2000, ResourceType.Stone, 3200, ResourceType.Gold, 12000, ResourceType.Iron, 1600);
case "Терем": return Cost(ResourceType.Wood, HeroCastleBootstrap.CostWood, ResourceType.Stone, HeroCastleBootstrap.CostStone);
default: return Cost();
}
}
// С какого уровня Ратуши можно строить (как в 2D)
public static int RequiredTownHall(string type3d)
{
switch (type3d)
{
case "Шахта": return 3;
case "Золотой рудник": return 2;
case "Кристалл": return 6;
case "Госпиталь": return Mathf.Max(1, HospitalConfig.Load().requiredTownHallLevel);
default: return 1;
}
}
static ResourceAmount[] Cost(params object[] pairs)
{
var list = new List<ResourceAmount>();
for (int i = 0; i + 1 < pairs.Length; i += 2)
list.Add(new ResourceAmount((ResourceType)pairs[i], System.Convert.ToSingle(pairs[i + 1])));
return list.ToArray();
}
public static int TownHallLevel => GameServices.TryGet(out ISettlement s) ? s.TownHallLevel : 1;
// Можно ли строить: уровень Ратуши, лимит зданий, строители, ресурсы. null -- можно, иначе текст для игрока
public static string CanBuild(string type3d)
{
int th = TownHallLevel;
if (th < RequiredTownHall(type3d)) return $"Откроется на Ратуше {RequiredTownHall(type3d)} ур.";
string name = GameName(type3d);
int max = EconomyConfig.Load().BuildingLimitFor(name, th), have = CountBuilt(name);
if (max >= 0 && have >= max) return $"Лимит: {have} / {max} на Ратуше {th} ур.";
if (BuildTimer.DurationMs(false, 1) > 0 && BuildTimer.FreeBuilders == 0) return "Все строители заняты";
var cost = BuildCost(type3d);
if (cost.Length > 0 && (!GameServices.TryGet(out IEconomy economy) || !economy.CanAfford(cost))) return "Не хватает ресурсов: " + CostText(cost);
return null;
}
public static string CostText(ResourceAmount[] cost)
{
if (cost.Length == 0) return "бесплатно";
var parts = new List<string>();
foreach (var c in cost) parts.Add($"{c.Amount:0} {GameData.ResName(c.Type).ToLower()}");
return string.Join(", ", parts.ToArray());
}
// Кристальные шахты: с какого момента каждая добывает (мс; long.MinValue -- давно достроена; конец стройки -- если
// ещё строится или достроилась офлайн). Город открыт -- по сцене, иначе -- по сохранению settlement3d.
// null -- город ещё не загружен: считать рано (иначе офлайн-время шахты пропало бы)
public static List<long> CrystalMinesActiveFrom()
{
var list = new List<long>();
var city = City3DController.Instance;
if (city != null)
{
if (!city.StateApplied) return null;
foreach (var id in Object.FindObjectsByType<PlacedBuildingId>(FindObjectsSortMode.None))
{
if (id.buildingName != "Кристалл") continue;
var t = BuildTimer.Of(id.gameObject);
list.Add(t != null && BuildTimer.IsConstructing(id.transform) ? t.endMs : long.MinValue);
}
return list;
}
if (!City3DSave.Loaded) return null;
if (City3DSave.Cached != null)
foreach (var e in City3DSave.Cached.buildings)
{
if (e == null || e.type != "Кристалл") continue;
bool building = e.timerKind == (int)BuildTimer.Kind.Build && e.level <= 1 && e.timerEndMs > 0;
list.Add(building ? e.timerEndMs : long.MinValue);
}
return list;
}
public static int CountBuilt(string gameName)
{
int n = 0;
foreach (var id in Object.FindObjectsByType<PlacedBuildingId>(FindObjectsSortMode.None))
if (id.buildingName == gameName) n++;
return n;
}
// Новое здание поставлено на карту: списать цену и начать стройку по времени (BuildTimer)
public static void StartConstruction(GameObject view)
{
var tag = view.GetComponent<City3DBuildingTag>();
if (tag == null) return;
var cost = BuildCost(tag.type3d);
if (cost.Length > 0 && GameServices.TryGet(out IEconomy economy)) economy.TrySpend(cost);
long ms = BuildTimer.DurationMs(false, 1);
if (ms > 0)
{
long now = GameClock.UnixNowMs;
BuildTimer.Begin(view, BuildTimer.Kind.Build, 1, now, now + ms);
}
if (CitySync.Instance != null) CitySync.Instance.SyncNow();
}
// Повесить на 3D-здание компоненты здания игры (как у префабов 2D). Без цены и таймера
public static void Attach(GameObject view, string type3d, int level)
{
var tag = view.GetComponent<City3DBuildingTag>() ?? view.AddComponent<City3DBuildingTag>();
tag.type3d = type3d;
if (IsTownHall(type3d))
{
var th = view.GetComponent<TownHall>() ?? view.AddComponent<TownHall>();
// Та же цена улучшения, что у Ратуши 2D-города (сцена MVP1): 250 дерева, 160 камня, 25 людей, ×1.5 за уровень
th.baseUpgradeCost = new[]
{
new ResourceCost { type = ResourceType.Wood, amount = 250 },
new ResourceCost { type = ResourceType.Stone, amount = 160 },
new ResourceCost { type = ResourceType.Population, amount = 25 },
};
th.costMultiplierPerLevel = 1.5f;
th.maxLevel = 10;
th.currentLevel = Mathf.Max(1, level);
return;
}
view.AddComponent<PlacedBuildingId>().buildingName = GameName(type3d);
switch (type3d)
{
case "Дом": Collector(view, ResourceType.Population, level); break;
case "Ферма": Collector(view, ResourceType.Food, level); break;
case "Лесопилка": Collector(view, ResourceType.Wood, level); break;
case "Каменоломня": Collector(view, ResourceType.Stone, level); break;
case "Шахта": Collector(view, ResourceType.Iron, level); break;
case "Склад": Storage(view, StorageBuilding.Kind.Warehouse, level); break;
case "Амбар": Storage(view, StorageBuilding.Kind.Granary, level); break;
case "Казармы": view.AddComponent<Barracks>().Level = Mathf.Max(1, level); break;
case "Госпиталь": view.AddComponent<Hospital>().Level = Mathf.Max(1, level); break;
case "Золотой рудник": Collector(view, ResourceType.Gold, level); break;
case "Кристалл": Collector(view, ResourceType.Gems, level); break; // рабочих нет, как в 2D
case "Терем": view.AddComponent<HeroCastle>(); break; // окно героя -- BuildingWindow
}
}
static void Collector(GameObject view, ResourceType type, int level)
{
var rc = view.AddComponent<ResourceCollector>();
rc.resourceType = type;
rc.Level = Mathf.Max(1, level);
}
static void Storage(GameObject view, StorageBuilding.Kind kind, int level)
{
var sb = view.AddComponent<StorageBuilding>();
sb.kind = kind;
sb.Level = Mathf.Max(1, level);
}
// Уровень здания (для сохранения и подписи)
public static int LevelOf(GameObject view)
{
var th = view.GetComponent<TownHall>();
if (th != null) return th.currentLevel;
var leveled = view.GetComponent<ILeveledBuilding>();
return leveled != null ? leveled.Level : 1;
}
// HUD 2D-города нарисован на холсте "в камере" (Screen Space - Camera). В 3D-сцене холст стоял бы на расстоянии
// от камеры, и холмы и деревья перекрывали бы кнопки. Ставим холст вплотную к камере -- он всегда поверх 3D.
public static void PutHudInFront(Camera cam)
{
if (cam == null) return;
foreach (var canvas in Object.FindObjectsByType<Canvas>(FindObjectsSortMode.None))
{
if (!canvas.isRootCanvas || canvas.renderMode != RenderMode.ScreenSpaceCamera) continue;
canvas.worldCamera = cam;
canvas.planeDistance = cam.nearClipPlane + 0.05f;
HideWorldSprites(canvas);
}
}
// В HUD есть картинка-спрайт мира (SpriteRenderer) -- плашка с мечом по центру нижней панели. На холсте у
// камеры она рисуется поверх всего HUD, даже поверх открытых окон, а сама картинка без прозрачности
// ("шахматка"). Решение 05.11 (Миша, Володя): в 3D-сцене её убираем, пока не будет новой картинки или кнопки.
static void HideWorldSprites(Canvas canvas)
{
foreach (var sr in canvas.GetComponentsInChildren<SpriteRenderer>(true))
sr.enabled = false;
}
// Награда за расчистку (решение 04.11): +10 дерева за дерево или +10 камня за валун, один раз, в пределах склада
public static void RewardClearing(bool tree)
{
if (GameServices.TryGet(out IEconomy economy)) economy.Add(tree ? ResourceType.Wood : ResourceType.Stone, 10);
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 3b010df83fccefa4aa9c394f84b8214f
+66
View File
@@ -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;
}
}
+11
View File
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: c71f24a7cb164064b98f198eec9665d3
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
+93
View File
@@ -0,0 +1,93 @@
using System;
using System.Collections.Generic;
using UnityEngine;
// Сохранение 3D-поселения: секция "settlement3d" (05.11, Валдемар). 2D-город сохраняется отдельно ("settlement"),
// поэтому 3D-сцена его не трогает. Здания: id, тип, клетка, поворот, уровень, стройка или улучшение, которое идёт;
// Ратуша: уровень и её улучшение; расчищенные деревья и валуны.
// Регистрирует App/GameModules. Пока сцена City3D открыта, данные берутся у неё (Live), иначе -- последние известные.
public sealed class City3DSave : ISaveParticipant
{
[Serializable]
public class Entry
{
public string id;
public string type; // тип 3D-сцены: "Ратуша", "Дом", "Ферма"...
public int x, y, rot;
public int level = 1;
public int timerKind; // 0 -- нет, 1 -- постройка, 2 -- улучшение (BuildTimer.Kind)
public long timerStartMs, timerEndMs;
public int timerTargetLevel;
}
[Serializable]
public class Data
{
public int nextId = 1;
public List<Entry> buildings = new List<Entry>();
public List<string> cleared = new List<string>();
// Здания 2D-города, которым при переносе не нашлось места (зона закрыта, всё занято): ждут и ставятся,
// как только место появится. Не теряются (05.11)
public List<Entry> pendingFrom2D = new List<Entry>();
}
public interface ILive
{
Data CaptureState();
void OnSaveLoaded(); // загрузили другое сохранение или сбросили -- сцена должна перестроиться
}
public static readonly City3DSave Instance = new City3DSave();
public static Data Cached { get; private set; }
// Сохранение уже прочитано (или новая игра): по Cached можно считать здания вне 3D-сцены (Кристальная шахта на карте мира)
public static bool Loaded { get; private set; }
public static ILive Live;
public string SaveKey => "settlement3d";
public string Capture()
{
if (Live != null) Cached = Live.CaptureState();
return Cached != null ? JsonUtility.ToJson(Cached) : null;
}
public void Restore(string json)
{
Loaded = true;
Cached = string.IsNullOrEmpty(json) ? null : JsonUtility.FromJson<Data>(json);
if (Cached != null)
{
if (Cached.buildings == null) Cached.buildings = new List<Entry>();
if (Cached.cleared == null) Cached.cleared = new List<string>();
if (Cached.pendingFrom2D == null) Cached.pendingFrom2D = new List<Entry>();
}
Live?.OnSaveLoaded();
}
public void ResetState()
{
Loaded = true;
Cached = null;
Live?.OnSaveLoaded();
}
// Запись таймера здания в Entry и обратно
public static void WriteTimer(Entry e, GameObject view)
{
var t = BuildTimer.Of(view);
bool on = t != null && BuildTimer.All.Contains(t);
e.timerKind = on ? (int)t.kind : 0;
e.timerStartMs = on ? t.startMs : 0;
e.timerEndMs = on ? t.endMs : 0;
e.timerTargetLevel = on ? t.targetLevel : 0;
}
// Таймер из файла -- только осмысленный: постройка у 1 уровня, улучшение до уровня выше текущего
public static void ReadTimer(Entry e, GameObject view)
{
bool build = e.timerKind == (int)BuildTimer.Kind.Build && e.level <= 1;
bool upgrade = e.timerKind == (int)BuildTimer.Kind.Upgrade && e.timerTargetLevel > Mathf.Max(1, e.level);
if ((build || upgrade) && e.timerEndMs > 0)
BuildTimer.Begin(view, (BuildTimer.Kind)e.timerKind, Mathf.Max(1, e.timerTargetLevel), e.timerStartMs, e.timerEndMs);
}
}
+2
View File
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 037c1ed1fee52f74da1eaaeb4c20e3b1
+135 -15
View File
@@ -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);
+53
View File
@@ -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
+203
View File
@@ -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
+36 -30
View File
@@ -123,6 +123,9 @@ public static class GameData
public const int MaxPlayerLevel = 60;
// ---------- Основные задания (цепочка) ----------
// Синие кристаллы (Регламент валют, кристаллов и торговли от 05.11, разд. 5.1): стандартные задания дают золото и
// обычные ресурсы, не синие кристаллы (было 20-250, 05.11 -- 1-2). Кристаллы -- только сложная недельная цепочка
// (ещё не сделана). Числа ТЕСТ
public static readonly List<QuestDef> MainQuests = new List<QuestDef>
{
new QuestDef("m_collect_wood_1", "Собрать 200 дерева", QuestKind.Collect, 200, 30, R(ResourceType.Gold, 200)).Res(ResourceType.Wood),
@@ -130,21 +133,21 @@ public static class GameData
new QuestDef("m_build_1", "Построить первое здание", QuestKind.BuildingsBuilt, 1, 50, R(ResourceType.Wood, 300), R(ResourceType.Stone, 200)),
new QuestDef("m_barracks", "Построить Казармы", QuestKind.BarracksBuilt, 1, 50, R(ResourceType.Food, 300), R(ResourceType.Wood, 200)),
new QuestDef("m_train_10", "Обучить 10 воинов", QuestKind.TroopsTrained, 10, 50, R(ResourceType.Food, 400)),
new QuestDef("m_battle_1", "Выиграть первый бой", QuestKind.BattlesWon, 1, 60, R(ResourceType.Gems, 30)),
new QuestDef("m_battle_1", "Выиграть первый бой", QuestKind.BattlesWon, 1, 60, R(ResourceType.Gold, 300)),
new QuestDef("m_world_send", "Отправить армию в поход на карте мира", QuestKind.WorldMarches, 1, 60, R(ResourceType.Food, 500)),
new QuestDef("m_world_win", "Разграбить поселение на карте мира", QuestKind.WorldWins, 1, 100, R(ResourceType.Gems, 50), R(ResourceType.Gold, 500)),
new QuestDef("m_world_win", "Разграбить поселение на карте мира", QuestKind.WorldWins, 1, 100, R(ResourceType.Gold, 1000)),
new QuestDef("m_hero_3", "Герой 3 уровня", QuestKind.HeroLevel, 3, 60, R(ResourceType.Gold, 500)),
new QuestDef("m_th_2", "Ратуша 2 уровня", QuestKind.TownHallLevel, 2, 100, R(ResourceType.Gems, 50), R(ResourceType.Stone, 500)),
new QuestDef("m_th_2", "Ратуша 2 уровня", QuestKind.TownHallLevel, 2, 100, R(ResourceType.Gold, 500), R(ResourceType.Stone, 500)),
new QuestDef("m_build_3", "Построить 3 здания", QuestKind.BuildingsBuilt, 3, 80, R(ResourceType.Wood, 600), R(ResourceType.Iron, 200)),
new QuestDef("m_arena_1", "Сразиться на арене", QuestKind.ArenaFights, 1, 60, R(ResourceType.Gems, 20)),
new QuestDef("m_arena_1", "Сразиться на арене", QuestKind.ArenaFights, 1, 60, R(ResourceType.Gold, 300)),
new QuestDef("m_collect_stone_1", "Собрать 1000 камня", QuestKind.Collect, 1000, 80, R(ResourceType.Gold, 800)).Res(ResourceType.Stone),
new QuestDef("m_train_100", "Обучить 100 воинов", QuestKind.TroopsTrained, 100, 120, R(ResourceType.Food, 1500), R(ResourceType.Gems, 30)),
new QuestDef("m_battle_5", "Выиграть 5 боёв", QuestKind.BattlesWon, 5, 150, R(ResourceType.Gems, 60)),
new QuestDef("m_player_5", "Достигнуть 5 уровня", QuestKind.PlayerLevel, 5, 100, R(ResourceType.Gems, 100)),
new QuestDef("m_th_3", "Ратуша 3 уровня", QuestKind.TownHallLevel, 3, 200, R(ResourceType.Gems, 100), R(ResourceType.Iron, 800)),
new QuestDef("m_train_100", "Обучить 100 воинов", QuestKind.TroopsTrained, 100, 120, R(ResourceType.Food, 1500), R(ResourceType.Gold, 500)),
new QuestDef("m_battle_5", "Выиграть 5 боёв", QuestKind.BattlesWon, 5, 150, R(ResourceType.Gold, 800)),
new QuestDef("m_player_5", "Достигнуть 5 уровня", QuestKind.PlayerLevel, 5, 100, R(ResourceType.Gold, 1000)),
new QuestDef("m_th_3", "Ратуша 3 уровня", QuestKind.TownHallLevel, 3, 200, R(ResourceType.Gold, 1500), R(ResourceType.Iron, 800)),
new QuestDef("m_hero_10", "Герой 10 уровня", QuestKind.HeroLevel, 10, 200, R(ResourceType.Gold, 3000)),
new QuestDef("m_battle_15", "Выиграть 15 боёв", QuestKind.BattlesWon, 15, 300, R(ResourceType.Gems, 150)),
new QuestDef("m_th_5", "Ратуша 5 уровня", QuestKind.TownHallLevel, 5, 400, R(ResourceType.Gems, 250)),
new QuestDef("m_battle_15", "Выиграть 15 боёв", QuestKind.BattlesWon, 15, 300, R(ResourceType.Gold, 2000)),
new QuestDef("m_th_5", "Ратуша 5 уровня", QuestKind.TownHallLevel, 5, 400, R(ResourceType.Gold, 3000)),
};
// ---------- Ежедневные задания (сбрасываются каждый день) ----------
@@ -154,8 +157,8 @@ public static class GameData
new QuestDef("d_wood", "Собрать 300 дерева", QuestKind.Collect, 300, 20, R(ResourceType.Food, 200)).Res(ResourceType.Wood),
new QuestDef("d_food", "Собрать 300 еды", QuestKind.Collect, 300, 20, R(ResourceType.Wood, 200)).Res(ResourceType.Food),
new QuestDef("d_train", "Обучить 20 воинов", QuestKind.TroopsTrained, 20, 25, R(ResourceType.Iron, 150)),
new QuestDef("d_battle", "Выиграть 1 бой", QuestKind.BattlesWon, 1, 30, R(ResourceType.Gems, 10)),
new QuestDef("d_arena", "2 боя на арене", QuestKind.ArenaFights, 2, 30, R(ResourceType.Gems, 10)),
new QuestDef("d_battle", "Выиграть 1 бой", QuestKind.BattlesWon, 1, 30, R(ResourceType.Gold, 200)),
new QuestDef("d_arena", "2 боя на арене", QuestKind.ArenaFights, 2, 30, R(ResourceType.Gold, 200)),
new QuestDef("d_world", "Отправить поход на карте мира", QuestKind.WorldMarches, 1, 25, R(ResourceType.Gold, 200)),
};
@@ -212,7 +215,10 @@ public static class GameData
};
}
public static int StageXp(int stage) { return 20 + stage * 6; }
public static int StageGems(int stage) { return stage % 5 == 0 ? 25 : 0; } // каждый 5-й этап -- кристаллы
// Синие кристаллы за бой -- только за сильных противников, редко (регламент валют 05.11, разд. 5.1): каждый
// StageGemsEvery-й этап -- StageGemsAmount (было: каждый 5-й -- 25). ТЕСТ
public const int StageGemsEvery = 10, StageGemsAmount = 1;
public static int StageGems(int stage) { return stage % StageGemsEvery == 0 ? StageGemsAmount : 0; }
// ---------- Арена ----------
public const int ArenaMaxTickets = 5;
@@ -232,43 +238,41 @@ public static class GameData
}
// ---------- Ежедневная награда за вход (7 дней) ----------
// Без синих кристаллов (регламент валют 05.11, разд. 5.1; было 50 на 4-й и 200 на 7-й день). ТЕСТ
public static readonly RewardItem[][] LoginRewards =
{
new[] { R(ResourceType.Gold, 500) },
new[] { R(ResourceType.Food, 1000) },
new[] { R(ResourceType.Wood, 800), R(ResourceType.Stone, 500) },
new[] { R(ResourceType.Gems, 50) },
new[] { R(ResourceType.Gold, 1000) },
new[] { R(ResourceType.Iron, 600) },
new[] { R(ResourceType.Gold, 2000) },
new[] { R(ResourceType.Gems, 200) },
new[] { R(ResourceType.Gold, 3000), R(ResourceType.Stone, 1000) },
};
// ---------- Магазин ----------
public static readonly List<ShopItemDef> ShopItems = BuildShop();
static List<ShopItemDef> BuildShop()
{
// Обычный магазин -- за золото: основные ресурсы и хозяйственные товары (регламент валют 05.11, разд. 2).
// Убраны: пакеты за 50-80 синих кристаллов (больше хранилища), «Мешок золота» за кристаллы, обмен золота на
// кристаллы (будет у редкого PvE-торговца: 30 000 / 60 000 золота, не больше 2 за 7 дней), «Горсть» (500) и
// «Сундук» (3000) кристаллов за деньги (за деньги будут космо-кристаллы). Цены ТЕСТ
var list = new List<ShopItemDef>
{
new ShopItemDef("free_chest", "Бесплатный сундук (раз в день)", ResourceType.Gems, 0, 1),
new ShopItemDef("wood_1k", "Пакет дерева", ResourceType.Gems, 50, 5, R(ResourceType.Wood, 1000)),
new ShopItemDef("stone_1k", "Пакет камня", ResourceType.Gems, 50, 5, R(ResourceType.Stone, 1000)),
new ShopItemDef("food_15", "Пакет еды", ResourceType.Gems, 50, 5, R(ResourceType.Food, 1500)),
new ShopItemDef("iron_500", "Пакет железа", ResourceType.Gems, 60, 5, R(ResourceType.Iron, 500)),
new ShopItemDef("gold_1k", "Мешок золота", ResourceType.Gems, 80, 3, R(ResourceType.Gold, 1000)),
new ShopItemDef("gems_10", "Обмен золота на кристаллы", ResourceType.Gold, 1000, 3, R(ResourceType.Gems, 10)),
new ShopItemDef("free_chest", "Бесплатный сундук (раз в день)", ResourceType.Gold, 0, 1),
new ShopItemDef("wood_1k", "Пакет дерева", ResourceType.Gold, 600, 5, R(ResourceType.Wood, 1000)),
new ShopItemDef("stone_1k", "Пакет камня", ResourceType.Gold, 700, 5, R(ResourceType.Stone, 1000)),
new ShopItemDef("food_15", "Пакет еды", ResourceType.Gold, 600, 5, R(ResourceType.Food, 1500)),
new ShopItemDef("iron_500", "Пакет железа", ResourceType.Gold, 900, 5, R(ResourceType.Iron, 500)),
};
var p1 = new ShopItemDef("iap_small", "Горсть кристаллов", ResourceType.Gems, 0, 0, R(ResourceType.Gems, 500));
p1.comingSoon = true; p1.priceLabel = "99 руб.";
var p2 = new ShopItemDef("iap_big", "Сундук кристаллов", ResourceType.Gems, 0, 0, R(ResourceType.Gems, 3000));
p2.comingSoon = true; p2.priceLabel = "499 руб.";
list.Add(p1); list.Add(p2);
return list;
}
// Содержимое бесплатного сундука (выбирается случайно)
public static readonly RewardItem[] FreeChestPool =
{
R(ResourceType.Wood, 500), R(ResourceType.Stone, 400), R(ResourceType.Food, 600),
R(ResourceType.Iron, 250), R(ResourceType.Gold, 400), R(ResourceType.Gems, 15),
R(ResourceType.Iron, 250), R(ResourceType.Gold, 400), // без синих кристаллов (было 15; регламент валют 05.11)
};
// ---------- Еженедельное событие ----------
@@ -280,9 +284,11 @@ public static class GameData
{
new[] { R(ResourceType.Gold, 500) },
new[] { R(ResourceType.Food, 1500), R(ResourceType.Wood, 1000) },
new[] { R(ResourceType.Gems, 50) },
new[] { R(ResourceType.Gold, 1500), R(ResourceType.Stone, 1000) },
new[] { R(ResourceType.Iron, 1500), R(ResourceType.Gold, 2000) },
new[] { R(ResourceType.Gems, 200) },
// Верхняя ступень недельного события -- сложная недельная цель: 1 синий кристалл (было 50 и 200;
// регламент валют 05.11, разд. 5.1 -- «сложная недельная цепочка: 1»). ТЕСТ
new[] { R(ResourceType.Gold, 3000), R(ResourceType.Gems, 1) },
};
// ---------- Текст наград ----------
+281 -22
View File
@@ -73,6 +73,7 @@ public class GameProgress : MonoBehaviour
nextTick = Time.unscaledTime + 1f;
EnsureDailyAndWeekly();
ProcessTraining();
ProcessCrystalMine();
UpdateBuildingsCount();
UpdatePowerResource();
}
@@ -144,6 +145,18 @@ public class GameProgress : MonoBehaviour
}
if (Data.claimedQuests == null) Data.claimedQuests = new List<string>();
if (Data.claimedDaily == null) Data.claimedDaily = new List<string>();
if (Data.crystalWaiting == null) Data.crystalWaiting = new List<string>();
crystals.Load(Data.crystalWaiting);
if (Data.crystalQuota == null) Data.crystalQuota = new List<string>();
var storageCfg = EconomyConfig.Load().storage;
quota = new CrystalQuota(storageCfg.crystalPveLimit, Math.Max(1, storageCfg.crystalPveWindowDays) * CrystalQuota.Day);
quota.Load(Data.crystalQuota);
if (Data.crystalTrader == null) Data.crystalTrader = new List<string>();
if (Data.crystalTraderNextId < 1) Data.crystalTraderNextId = 1;
trader = new CrystalTrader(storageCfg.crystalTraderPrices, Math.Max(1, storageCfg.crystalPveWindowDays) * CrystalQuota.Day);
trader.Load(Data.crystalTrader);
mine = new CrystalMine(storageCfg.crystalMinePerPeriod, Math.Max(1, storageCfg.crystalMinePeriodDays) * CrystalQuota.Day);
mine.Load(Data.crystalMine);
if (Data.mail == null) Data.mail = new List<MailItem>();
if (Data.shopIds == null) Data.shopIds = new List<string>();
if (Data.shopCounts == null) Data.shopCounts = new List<int>();
@@ -307,22 +320,210 @@ public class GameProgress : MonoBehaviour
return true;
}
public void Grant(RewardItem[] rewards, int xp = 0, bool toast = true)
// Выдать награду. Синие кристаллы (регламент валют 05.11, ответ 06.11):
// - новые PvE-кристаллы (newPveCrystals) -- в пределах недельной квоты; сверх неё -- золото (компенсация);
// - не сверх вместимости хранилища: не поместившиеся приходят письмом «Кристаллы ждут места» и забираются из него,
// когда место появится (квота за них уже учтена). Ничего не теряется.
// Возвращает, что выдано сейчас (кристаллы -- сколько поместилось, плюс компенсация)
public RewardItem[] Grant(RewardItem[] rewards, int xp = 0, bool toast = true, bool newPveCrystals = true)
{
if (ResourceManager.Instance != null && rewards != null)
int gems = GemsIn(rewards), compensated = 0, compGold = 0;
if (gems > 0 && newPveCrystals)
{
int allowed = TakeQuota(null, gems, out compGold);
compensated = gems - allowed;
gems = allowed;
}
int fit = CrystalRewards.Fits(gems, GemsHave, GemsCap), left = gems - fit;
var given = new List<RewardItem>(WithoutGems(rewards));
if (fit > 0) given.Add(new RewardItem(ResourceType.Gems, fit));
if (compGold > 0) given.Add(new RewardItem(ResourceType.Gold, compGold));
if (ResourceManager.Instance != null)
{
suppressTracking++;
foreach (var r in rewards) ResourceManager.Instance.AddResource(r.type, r.amount);
foreach (var r in given) ResourceManager.Instance.AddResource(r.type, r.amount);
suppressTracking--;
}
if (left > 0)
SendMail("Кристаллы ждут места",
$"Хранилище синих кристаллов заполнено ({GemsHave:0} / {GemsCap:0}). {left} кристаллов ждут: потратьте кристаллы и заберите их из этого письма.",
new RewardItem(ResourceType.Gems, left));
if (xp > 0) AddXp(xp);
if (toast)
{
string text = GameData.RewardsText(rewards);
string text = GameData.RewardsText(given.ToArray());
if (xp > 0) text += (text.Length > 0 ? ", " : "") + xp + " опыта";
if (left > 0) text += (text.Length > 0 ? ". " : "") + $"Ещё {left} кристаллов ждут места -- в почте";
if (compensated > 0) text += (text.Length > 0 ? ". " : "") + QuotaText(compensated, compGold);
if (text.Length > 0) UIKit.Toast("Получено: " + text);
}
MarkChanged();
return given.ToArray();
}
// ---------------- Недельный PvE-предел синих кристаллов (правила -- CrystalQuota) ----------------
CrystalQuota quota = new CrystalQuota();
public int CrystalQuotaUsed => quota.Used(GameClock.UnixNowMs);
public int CrystalQuotaLimit => quota.Limit;
public int CrystalQuotaLeft => quota.Left(GameClock.UnixNowMs);
// Через сколько секунд освободится квота (0 -- уже есть)
public double CrystalQuotaSecondsToFree
{
get { long at = quota.NextFreeMs(GameClock.UnixNowMs); return at <= 0 ? 0 : Math.Max(0, at - GameClock.UnixNowMs) / 1000.0; }
}
// Взять квоту: сколько из amount выдать кристаллами; за остальные -- золото (compGold). id -- операция (null -- разовая)
int TakeQuota(string id, int amount, out int compGold)
{
int allowed = quota.Take(id, amount, GameClock.UnixNowMs);
compGold = Mathf.RoundToInt((amount - allowed) * EconomyConfig.Load().storage.crystalCompensationGold);
Data.crystalQuota = quota.ToLines();
MarkChanged();
return allowed;
}
// ---------------- Кристальная шахта (правила -- CrystalMine) ----------------
CrystalMine mine = new CrystalMine();
// Работающих шахт сейчас (достроенных)
public int CrystalMinesWorking
{
get
{
var from = City3DGameplay.CrystalMinesActiveFrom();
int n = 0;
if (from != null) foreach (long t in from) if (t <= GameClock.UnixNowMs) n++;
return n;
}
}
public double CrystalMineSecondsToNext { get { long ms = mine.MsToNext(CrystalMinesWorking); return ms < 0 ? -1 : ms / 1000.0; } }
public int CrystalMinePerPeriod => mine.PerPeriod;
public int CrystalMinePeriodDays => (int)(mine.PeriodMs / CrystalQuota.Day);
// Раз в секунду: выработка шахт до текущего момента (и офлайн -- при первом расчёте после входа).
// Не входит в недельный PvE-предел. Хранилище полное -- пауза
void ProcessCrystalMine()
{
if (ResourceManager.Instance == null) return; // склада нет -- не считать (время не теряется)
var from = City3DGameplay.CrystalMinesActiveFrom();
if (from == null) return; // город ещё не загружен
int got = mine.Advance(GameClock.UnixNowMs, from, CrystalRewards.Free(GemsHave, GemsCap));
Data.crystalMine = mine.ToLine();
if (got <= 0) return;
suppressTracking++;
ResourceManager.Instance.AddResource(ResourceType.Gems, got); // место проверено в Advance
suppressTracking--;
MarkChanged();
Save(); // прогресс шахты
if (SaveManager.Instance != null) SaveManager.Instance.SaveGame(); // и кристаллы -- сразу, без повторного начисления
UIKit.Toast($"Кристальная шахта: +{got} синих кристаллов");
}
// ---------------- Редкий PvE-торговец (правила -- CrystalTrader) ----------------
CrystalTrader trader = new CrystalTrader();
public int TraderPrice => trader.PriceNow(GameClock.UnixNowMs); // -1 -- в этом окне больше нельзя
public int TraderBought => trader.BoughtInWindow(GameClock.UnixNowMs);
public int TraderMaxPurchases => trader.Prices.Length;
public double TraderSecondsToSlot
{
get { long at = trader.NextSlotMs(GameClock.UnixNowMs); return at <= 0 ? 0 : Math.Max(0, at - GameClock.UnixNowMs) / 1000.0; }
}
// Почему сейчас нельзя купить (без списания); null -- можно, если хватит золота
public string TraderBlockReason()
{
switch (trader.Check(GameClock.UnixNowMs, quota, CrystalRewards.Free(GemsHave, GemsCap)))
{
case CrystalTrader.Result.NoSlots: return $"Куплено {TraderBought} из {TraderMaxPurchases} за 7 дней. Следующая покупка -- через {GameData.FormatTime(TraderSecondsToSlot)}";
case CrystalTrader.Result.NoQuota: return $"Недельный предел новых кристаллов исчерпан ({quota.Limit} за 7 дней). Освободится через {GameData.FormatTime(CrystalQuotaSecondsToFree)}";
case CrystalTrader.Result.NoSpace: return $"Хранилище кристаллов заполнено ({GemsHave:0} / {GemsCap:0}). Расширить -- в окне Ратуши";
default: return null;
}
}
// Купить 1 синий кристалл. Проверки (покупки, квота, место) -- до оплаты: при отказе золото не списывается
public void BuyCrystalFromTrader()
{
string id = "trader_" + Data.crystalTraderNextId;
var result = trader.TryBuy(id, GameClock.UnixNowMs, quota, CrystalRewards.Free(GemsHave, GemsCap),
price => GameServices.TryGet(out IEconomy e) && e.TrySpend(ResourceType.Gold, price));
if (result != CrystalTrader.Result.Bought)
{
UIKit.Toast(result == CrystalTrader.Result.NoGold ? $"Нужно {TraderPrice:N0} золота" : (TraderBlockReason() ?? "Покупка недоступна"));
return;
}
Data.crystalTraderNextId++;
if (ResourceManager.Instance != null)
{
suppressTracking++;
ResourceManager.Instance.AddResource(ResourceType.Gems, 1); // место проверено до оплаты
suppressTracking--;
}
Data.crystalTrader = trader.ToLines();
Data.crystalQuota = quota.ToLines();
MarkChanged();
if (SaveManager.Instance != null) SaveManager.Instance.SaveGame(); // золото, кристалл и покупка -- сразу на диск
UIKit.Toast("Куплен 1 синий кристалл");
}
// Только для проверки (DEV): забыть недельную квоту
public void DevResetCrystalQuota()
{
quota.Load(null);
trader.Load(null);
Data.crystalQuota = quota.ToLines();
Data.crystalTrader = trader.ToLines();
MarkChanged();
}
string QuotaText(int compensated, int compGold) =>
$"Недельный предел синих кристаллов ({quota.Limit} за {quota.WindowMs / CrystalQuota.Day} дн.) исчерпан: вместо {compensated} кристаллов -- {compGold} золота";
// ---------------- Синие кристаллы при пределе 50 (регламент 05.11, ред. 1.1; правила -- CrystalRewards) ----------------
readonly CrystalRewards crystals = new CrystalRewards();
// Нет склада (сцена без ResourceManager) -- места нет: кристаллы ждут, а не теряются
public static float GemsHave => ResourceManager.Instance != null ? ResourceManager.Instance.GetResource(ResourceType.Gems) : 0f;
public static float GemsCap => ResourceManager.Instance != null ? ResourceManager.Instance.GetCapacity(ResourceType.Gems) : 0f;
static int GemsIn(IEnumerable<RewardItem> rewards)
{
int n = 0;
if (rewards != null) foreach (var r in rewards) if (r != null && r.type == ResourceType.Gems) n += Mathf.Max(0, r.amount);
return n;
}
static RewardItem[] WithoutGems(IEnumerable<RewardItem> rewards)
{
var list = new List<RewardItem>();
if (rewards != null) foreach (var r in rewards) if (r != null && r.type != ResourceType.Gems) list.Add(r);
return list.ToArray();
}
static string QuestKey(QuestDef q, bool daily) => (daily ? "d:" : "m:") + q.id;
// Кристаллы награды задания, которые ждут места (0 -- не ждут)
public int QuestGemsWaiting(QuestDef q, bool daily) => crystals.Left(QuestKey(q, daily));
// Начислить кристаллы награды key: сколько помещается, остаток ждёт под этим key (сохраняется)
int GiveQuestGems(string key, int amount)
{
int give = crystals.Claim(key, amount, GemsHave, GemsCap);
if (give > 0 && ResourceManager.Instance != null)
{
suppressTracking++;
ResourceManager.Instance.AddResource(ResourceType.Gems, give);
suppressTracking--;
}
Data.crystalWaiting = crystals.ToLines();
MarkChanged();
return give;
}
// =====================================================================
@@ -380,10 +581,10 @@ public class GameProgress : MonoBehaviour
{
Data.xp -= GameData.XpToNextLevel(Data.level);
Data.level++;
int gems = 10 * Data.level;
// Подарок за уровень -- золото (было ещё 10 × уровень синих кристаллов; регламент валют 05.11, разд. 5.1)
SendMail("Новый уровень: " + Data.level + "!",
"Поздравляем, командир! Ваша слава растёт. Примите подарок от совета королевства.",
new RewardItem(ResourceType.Gems, gems), new RewardItem(ResourceType.Gold, 300 * Data.level));
new RewardItem(ResourceType.Gold, 300 * Data.level));
UIKit.Toast("Новый уровень: " + Data.level + "! Подарок в почте.");
Vibrate();
}
@@ -455,13 +656,46 @@ public class GameProgress : MonoBehaviour
public bool IsQuestClaimed(QuestDef q, bool daily) { return (daily ? Data.claimedDaily : Data.claimedQuests).Contains(q.id); }
public bool IsQuestDone(QuestDef q, bool daily) { return QuestProgress(q, daily) >= q.target; }
// Забрать награду задания. Кристаллы, которым нет места, ждут в задании («Забрать кристаллы»); пока ждут
// CrystalRewards.MaxWaiting наград, новое задание с кристаллами, которые не помещаются, не выдаётся (регламент 05.11)
public void ClaimQuest(QuestDef q, bool daily)
{
if (IsQuestClaimed(q, daily) || !IsQuestDone(q, daily)) return;
string key = QuestKey(q, daily);
if (IsQuestClaimed(q, daily))
{
if (crystals.Left(key) <= 0) return;
int got = GiveQuestGems(key, 0); // дозабрать остаток
int still = crystals.Left(key);
UIKit.Toast(got > 0 ? $"Получено: {got} кристаллов" + (still > 0 ? $". Ещё {still} ждут места" : "")
: $"Нет места для кристаллов ({GemsHave:0} / {GemsCap:0})");
return;
}
if (!IsQuestDone(q, daily)) return;
int gems = GemsIn(q.rewards);
if (!crystals.CanStart(key, Mathf.Min(gems, quota.Left(GameClock.UnixNowMs)), GemsHave, GemsCap))
{
UIKit.Toast($"Нет места для кристаллов ({GemsHave:0} / {GemsCap:0}), и уже ждут {CrystalRewards.MaxWaiting} награды. " +
"Потратьте кристаллы или заберите ждущие -- награда этого задания сохранится");
return;
}
(daily ? Data.claimedDaily : Data.claimedQuests).Add(q.id);
Data.questsDone++;
AddEventPoints(20);
Grant(q.rewards, q.xp);
// Квота -- один раз на задание (id); сверх неё -- золото. Ждущие места кристаллы квоту второй раз не берут
int compGold = 0, allowed = gems > 0 ? TakeQuota(key, gems, out compGold) : 0;
var rest = new List<RewardItem>(WithoutGems(q.rewards));
if (compGold > 0) rest.Add(new RewardItem(ResourceType.Gold, compGold));
Grant(rest.ToArray(), q.xp, false, false);
int given = allowed > 0 ? GiveQuestGems(key, allowed) : 0, left = crystals.Left(key);
var shown = new List<RewardItem>(rest);
if (given > 0) shown.Add(new RewardItem(ResourceType.Gems, given));
string text = GameData.RewardsText(shown.ToArray());
if (q.xp > 0) text += (text.Length > 0 ? ", " : "") + q.xp + " опыта";
if (left > 0) text += $". Ещё {left} кристаллов ждут места -- «Забрать» в заданиях";
if (gems - allowed > 0) text += ". " + QuotaText(gems - allowed, compGold);
UIKit.Toast("Получено: " + text);
}
public int ClaimableQuestsCount
@@ -471,6 +705,12 @@ public class GameProgress : MonoBehaviour
int n = 0;
foreach (var q in GameData.MainQuests) if (!IsQuestClaimed(q, false) && IsQuestDone(q, false)) n++;
foreach (var q in GameData.DailyQuests) if (!IsQuestClaimed(q, true) && IsQuestDone(q, true)) n++;
// кристаллы, которые ждали места, -- когда место появилось
if (crystals.WaitingCount > 0 && CrystalRewards.Free(GemsHave, GemsCap) > 0)
{
foreach (var q in GameData.MainQuests) if (IsQuestClaimed(q, false) && QuestGemsWaiting(q, false) > 0) n++;
foreach (var q in GameData.DailyQuests) if (IsQuestClaimed(q, true) && QuestGemsWaiting(q, true) > 0) n++;
}
return n;
}
}
@@ -513,7 +753,7 @@ public class GameProgress : MonoBehaviour
SendMail("Добро пожаловать, командир!",
"Королевство ждёт своего правителя. Примите стартовый подарок, чтобы начать строительство.",
new RewardItem(ResourceType.Gold, 500), new RewardItem(ResourceType.Wood, 500),
new RewardItem(ResourceType.Food, 500), new RewardItem(ResourceType.Gems, 100));
new RewardItem(ResourceType.Food, 500)); // без 100 синих кристаллов (регламент валют 05.11)
}
public int UnreadMailCount
@@ -525,11 +765,27 @@ public class GameProgress : MonoBehaviour
public void ReadMail(MailItem m) { if (m != null && !m.read) { m.read = true; MarkChanged(); } }
public void ClaimMail(MailItem m, bool toast = true)
// Забрать подарок письма. Кристаллы -- сколько помещается; остаток остаётся в этом же письме (регламент 05.11).
// false -- в письме остались кристаллы, которым нет места
public bool ClaimMail(MailItem m, bool toast = true)
{
if (m == null || m.claimed || !m.HasRewards) return;
m.claimed = true; m.read = true;
Grant(m.rewards.ToArray(), 0, toast);
if (m == null || m.claimed || !m.HasRewards) return true;
m.read = true;
int gems = GemsIn(m.rewards), fit = CrystalRewards.Fits(gems, GemsHave, GemsCap), left = gems - fit;
var give = new List<RewardItem>(WithoutGems(m.rewards));
if (give.Count == 0 && fit == 0)
{
if (toast) UIKit.Toast($"Нет места для кристаллов ({GemsHave:0} / {GemsCap:0}) -- подарок подождёт в письме");
MarkChanged();
return false;
}
if (fit > 0) give.Add(new RewardItem(ResourceType.Gems, fit));
m.rewards.Clear();
if (left > 0) m.rewards.Add(new RewardItem(ResourceType.Gems, left)); // остаток ждёт в письме
else m.claimed = true;
Grant(give.ToArray(), 0, toast, false); // всё помещается; квота за них уже учтена при первой выдаче
if (toast && left > 0) UIKit.Toast($"Ещё {left} кристаллов ждут места -- в этом письме");
return left == 0;
}
public void DeleteMail(MailItem m)
@@ -542,13 +798,16 @@ public class GameProgress : MonoBehaviour
public void ClaimAllMail()
{
var all = new List<RewardItem>();
foreach (var m in Data.mail)
int gifts = 0, waiting = 0;
foreach (var m in Data.mail.ToArray())
{
m.read = true;
if (m.HasRewards && !m.claimed) { m.claimed = true; all.AddRange(m.rewards); }
if (!m.HasRewards || m.claimed) continue;
gifts++;
if (!ClaimMail(m, false)) waiting++;
}
if (all.Count > 0) Grant(all.ToArray()); else UIKit.Toast("Все письма прочитаны");
if (gifts == 0) UIKit.Toast("Все письма прочитаны");
else UIKit.Toast(waiting == 0 ? "Подарки получены" : $"Подарки получены. В {waiting} письмах кристаллы ждут места");
MarkChanged();
}
@@ -679,7 +938,7 @@ public class GameProgress : MonoBehaviour
// Заказ не встал -- цену возвращаем целиком
var refund = new RewardItem[def.cost.Length];
for (int i = 0; i < def.cost.Length; i++) refund[i] = new RewardItem(def.cost[i].type, def.cost[i].amount * count);
Grant(refund, 0, false);
Grant(refund, 0, false, false); // возврат -- не новые кристаллы
UIKit.Toast(error);
return;
}
@@ -703,14 +962,14 @@ public class GameProgress : MonoBehaviour
var queue = Training;
if (queue.Count == 0) return 0;
double secs = TrainingSecondsLeft(queue[queue.Count - 1]);
return Mathf.Max(1, Mathf.CeilToInt((float)secs / 30f)); // 1 кристалл за 30 сек
return EconomyConfig.SpeedUpGold(secs); // за золото (06.11, ТЕСТ; было 1 кристалл за 30 с)
}
}
public void SpeedUpTraining()
{
if (Training.Count == 0) return;
if (!Pay(new[] { new RewardItem(ResourceType.Gems, SpeedUpCost) })) return;
if (!Pay(new[] { new RewardItem(ResourceType.Gold, SpeedUpCost) })) return;
Army.FinishAllTraining(GameClock.UnixNowMs);
}
@@ -788,7 +1047,7 @@ public class GameProgress : MonoBehaviour
Data.battlesWon++;
Data.dailyBattles++;
AddEventPoints(10);
Grant(res.rewards, res.xp, false);
res.rewards = Grant(res.rewards, res.xp, false); // в окне итога -- что выдано на самом деле (квота, место)
}
else
{
@@ -882,7 +1141,7 @@ public class GameProgress : MonoBehaviour
if (res.win)
{
res.ratingChange = 15 + Mathf.Clamp((opp.rating - Data.arenaRating) / 10, 0, 15) + UnityEngine.Random.Range(0, 6);
res.rewards = new[] { new RewardItem(ResourceType.Gold, 150), new RewardItem(ResourceType.Gems, 5) };
res.rewards = new[] { new RewardItem(ResourceType.Gold, 200) }; // без 5 синих кристаллов (регламент валют 05.11)
Data.arenaWins++;
Grant(res.rewards, 15, false);
}
+9
View File
@@ -69,6 +69,15 @@ public class ProgressData
public List<string> claimedQuests = new List<string>();
public int dailyDay = -1;
public List<string> claimedDaily = new List<string>();
// Кристаллы наград заданий, которым не хватило места (регламент 05.11): "m:<id задания>|сколько"
public List<string> crystalWaiting = new List<string>();
// Недельный PvE-предел синих кристаллов (регламент валют 05.11): "время мс|сколько|id операции"
public List<string> crystalQuota = new List<string>();
// Покупки у редкого PvE-торговца (регламент валют 05.11, разд. 3): "время мс|id покупки"
public List<string> crystalTrader = new List<string>();
public int crystalTraderNextId = 1;
// Кристальная шахта: "прогресс мс|до какого момента посчитано мс" (ответ 06.11, п. 3)
public string crystalMine = "";
public float[] dailyCollected = new float[8];
public int dailyBattles;
public int dailyTroops;
+12 -1
View File
@@ -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,
+9 -7
View File
@@ -16,13 +16,21 @@ public class RewardsPanel : BasePanel
bool canClaim = GP.CanClaimLogin;
int total = GameData.LoginRewards.Length;
// Кнопка и таймер -- сверху: в горизонтальном экране окно низкое, и под календарём кнопка уходила
// за нижнюю панель HUD -- её не было видно (06.11)
var b = UIKit.Button(list, canClaim ? "ЗАБРАТЬ ПОДАРОК" : "Подарок получен", canClaim ? UIKit.Theme.Green : UIKit.Theme.Gray,
() => GP.ClaimLogin(), -1, 90, 32);
b.interactable = canClaim;
timer = UIKit.Text(list, "", 26, UIKit.Theme.TextDim, TextAlignmentOptions.Center, 40);
int claimedUpTo = d.loginRewardIndex; // сколько дней цикла уже забрано
if (!canClaim && claimedUpTo == 0 && d.lastLoginRewardDay >= 0) claimedUpTo = total; // только что забрали 7-й
RectTransform row = null;
for (int i = 0; i < total; i++)
{
if (i % 4 == 0) row = UIKit.Row(list, 230, 12);
if (i % 4 == 0) row = UIKit.Row(list, 170, 12);
bool claimed = i < claimedUpTo;
bool current = canClaim && i == d.loginRewardIndex;
@@ -39,12 +47,6 @@ public class RewardsPanel : BasePanel
claimed ? UIKit.Theme.Green : UIKit.Theme.Gold, TextAlignmentOptions.Center);
}
UIKit.Spacer(list, 10);
var b = UIKit.Button(list, canClaim ? "ЗАБРАТЬ ПОДАРОК" : "Подарок получен", canClaim ? UIKit.Theme.Green : UIKit.Theme.Gray,
() => GP.ClaimLogin(), -1, 110, 36);
b.interactable = canClaim;
timer = UIKit.Text(list, "", 26, UIKit.Theme.TextDim, TextAlignmentOptions.Center, 50);
Tick();
}
+28 -2
View File
@@ -1,8 +1,8 @@
using UnityEngine;
using TMPro;
// МАГАЗИН: бесплатный сундук раз в день, пакеты ресурсов за кристаллы, обмен золота,
// витрина платных наборов (пока "скоро"). Лимиты покупок сбрасываются в полночь.
// МАГАЗИН: редкий PvE-торговец (синий кристалл за золото), бесплатный сундук раз в день, пакеты ресурсов за золото.
// Лимиты пакетов сбрасываются в полночь; у торговца -- свои: не больше 2 кристаллов за 7 реальных суток.
public class ShopPanel : BasePanel
{
TextMeshProUGUI timer;
@@ -15,6 +15,8 @@ public class ShopPanel : BasePanel
WalletItem(wallet, ResourceType.Gems);
WalletItem(wallet, ResourceType.Gold);
TraderCard(list);
timer = UIKit.Text(list, "", 24, UIKit.Theme.TextDim, TextAlignmentOptions.Center, 40);
Tick();
@@ -52,6 +54,30 @@ public class ShopPanel : BasePanel
}
}
// Редкий PvE-торговец (регламент валют 05.11, разд. 3): дорогой обмен золота на синий кристалл
void TraderCard(Transform list)
{
var gp = GameProgress.Instance;
int price = gp.TraderPrice;
string block = gp.TraderBlockReason();
bool enough = price >= 0 && gp.Res(ResourceType.Gold) >= price;
var card = UIKit.Card(list, UIKit.Theme.CardDark);
var row = UIKit.Row(card, 150, 16);
UIKit.Icon(row, GameData.ResColor(ResourceType.Gems), "", 110);
var info = UIKit.Card(row, new Color(0, 0, 0, 0), 0, 4);
UIKit.Size(info, -1, -1, 1);
var title = UIKit.Text(info, "Редкий торговец: синий кристалл", 30);
title.fontStyle = FontStyles.Bold;
UIKit.Text(info, $"Куплено за 7 дней: {gp.TraderBought} / {gp.TraderMaxPurchases}. Входит в недельный предел новых кристаллов " +
$"({gp.CrystalQuotaUsed} / {gp.CrystalQuotaLimit})", 22, UIKit.Theme.TextDim);
if (block != null) UIKit.Text(info, block, 22, UIKit.Theme.Gold);
string label = price < 0 ? "Недоступно" : price.ToString("N0") + " золота";
var b = UIKit.Button(row, label, block == null && enough ? UIKit.Theme.Blue : UIKit.Theme.Gray, () => { gp.BuyCrystalFromTrader(); RequestRebuild(); }, 260, 100, 24);
b.interactable = block == null;
}
static void WalletItem(Transform parent, ResourceType t)
{
UIKit.Icon(parent, GameData.ResColor(t), "", 40);
+169 -36
View File
@@ -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++)
{
@@ -160,6 +293,6 @@ public class TroopsPanel : BasePanel
queueTimes[i].text = GameData.FormatTime(GP.TrainingSecondsLeft(o));
}
if (speedUp != null && training.Count > 0)
UIKit.SetButton(speedUp, "Ускорить всё за " + GP.SpeedUpCost + " кристаллов", true);
UIKit.SetButton(speedUp, "Ускорить всё за " + GP.SpeedUpCost + " золота", true);
}
}
@@ -0,0 +1,56 @@
using UnityEngine;
using UnityEngine.EventSystems;
using UnityEngine.UI;
// Прокрутка внутри прокрутки (например, лента карточек вбок внутри вертикального списка).
// Свайп в сторону своей оси листает эту ленту, свайп поперёк и колесо мыши -- родительский список.
// Без этого палец на ленте "застревает": общий список не прокручивается.
public class NestedScrollRect : ScrollRect
{
ScrollRect parentScroll;
bool routeToParent;
protected override void Start()
{
base.Start();
if (transform.parent != null) parentScroll = transform.parent.GetComponentInParent<ScrollRect>();
}
bool CrossAxis(PointerEventData e)
{
float dx = Mathf.Abs(e.delta.x), dy = Mathf.Abs(e.delta.y);
return horizontal && !vertical ? dy > dx : vertical && !horizontal ? dx > dy : false;
}
public override void OnInitializePotentialDrag(PointerEventData e)
{
if (parentScroll != null) parentScroll.OnInitializePotentialDrag(e);
base.OnInitializePotentialDrag(e);
}
public override void OnBeginDrag(PointerEventData e)
{
routeToParent = parentScroll != null && CrossAxis(e);
if (routeToParent) parentScroll.OnBeginDrag(e);
else base.OnBeginDrag(e);
}
public override void OnDrag(PointerEventData e)
{
if (routeToParent) parentScroll.OnDrag(e);
else base.OnDrag(e);
}
public override void OnEndDrag(PointerEventData e)
{
if (routeToParent) parentScroll.OnEndDrag(e);
else base.OnEndDrag(e);
routeToParent = false;
}
public override void OnScroll(PointerEventData e)
{
if (parentScroll != null) parentScroll.OnScroll(e);
else base.OnScroll(e);
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 59a54e4603712204d81f167a1c02873f
@@ -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
@@ -30,6 +30,10 @@ public class ResourceManager : MonoBehaviour, IEconomy, ISaveParticipant
// запоминаются в сохранении: на карте мира город не открыт, а лимиты действуют.
private Dictionary<ResourceType, float> capacities = new Dictionary<ResourceType, float>();
// Сколько платных расширений хранилища синих кристаллов куплено (регламент валют 05.11, разд. 8).
// Вместимость по ней считает слой приложения (EconomyConfig.CrystalStorageNow); здесь -- только хранится
public int CrystalStorageLevel { get; set; }
// Лимит изменился (для интерфейса: "запас / лимит")
public event Action<ResourceType, float> OnCapacityChanged;
@@ -189,13 +193,14 @@ public class ResourceManager : MonoBehaviour, IEconomy, ISaveParticipant
{
public List<ResourceSaveEntry> resources = new List<ResourceSaveEntry>();
public List<ResourceSaveEntry> capacities = new List<ResourceSaveEntry>(); // лимиты склада (этап 1)
public int crystalStorageLevel; // купленные расширения хранилища кристаллов (06.11); нет в старом файле -- 0
}
public string SaveKey => "economy";
public string Capture()
{
var data = new EconomySaveData();
var data = new EconomySaveData { crystalStorageLevel = CrystalStorageLevel };
foreach (var kvp in resources)
data.resources.Add(new ResourceSaveEntry { type = kvp.Key, amount = kvp.Value });
foreach (var kvp in capacities)
@@ -207,6 +212,7 @@ public class ResourceManager : MonoBehaviour, IEconomy, ISaveParticipant
{
var data = JsonUtility.FromJson<EconomySaveData>(json);
if (data?.resources == null) return;
CrystalStorageLevel = Mathf.Max(0, data.crystalStorageLevel);
if (data.capacities != null)
foreach (var entry in data.capacities) SetCapacity(entry.type, entry.amount);
foreach (var entry in data.resources) SetResource(entry.type, entry.amount);
@@ -215,6 +221,7 @@ public class ResourceManager : MonoBehaviour, IEconomy, ISaveParticipant
// Новая игра -- стартовые значения из Inspector
public void ResetState()
{
CrystalStorageLevel = 0;
SetStartValues(notify: true);
}
}
@@ -0,0 +1,83 @@
using System;
using System.Collections.Generic;
using System.Globalization;
// КРИСТАЛЬНАЯ ШАХТА: выработка синих кристаллов (Регламент редкости синих кристаллов от 05.11.2026, разд. 2.1;
// Регламент валют от 05.11, разд. 5.2 и 8.1; «Ответ Володе» от 06.11, п. 3). Документ: Docs/Modules/Economy.md.
//
// - Каждая работающая шахта -- PerPeriod (ТЕСТ 3) кристаллов за PeriodMs (7 РЕАЛЬНЫХ суток; не игровые часы ×12),
// равномерно: один кристалл за PeriodMs / PerPeriod. Шахты (до двух) складываются.
// - Шахта работает с момента, когда достроена (activeFromMs): стройка, закончившаяся офлайн, добывает с её конца.
// - Хранилище полное -- пауза: прогресс не растёт, скрытого запаса нет. Хранилище заполнилось в ходе расчёта --
// шахта встаёт сразу после последнего поместившегося кристалла (прогресс 0). Освободилось место -- выработка идёт
// дальше с оставшегося прогресса, без начисления за время паузы.
// - Офлайн: досчёт по меткам при входе; уже посчитанное время второй раз не начисляется; часы назад (сброс
// DEV-перемотки) -- метка не уходит назад, ничего не начисляется.
// - В недельный PvE-предел не входит (отдельный учёт).
// Без Unity: те же правила будут на сервере и в тестах.
public sealed class CrystalMine
{
public readonly int PerPeriod;
public readonly long PeriodMs;
// Прогресс к следующему кристаллу (мс работы одной шахты) и до какого момента посчитано
public long ProgressMs { get; private set; }
public long LastMs { get; private set; }
public CrystalMine(int perPeriod = 3, long periodMs = 7 * CrystalQuota.Day)
{
PerPeriod = Math.Max(1, perPeriod);
PeriodMs = Math.Max(1, periodMs);
}
public long MsPerCrystal => Math.Max(1, PeriodMs / PerPeriod);
// Досчитать до nowMs. mineActiveFromMs -- по шахте: с какого момента она работает (long.MinValue -- давно).
// freeSpace -- сколько кристаллов помещается в хранилище. Возвращает, сколько кристаллов начислить
public int Advance(long nowMs, IEnumerable<long> mineActiveFromMs, int freeSpace)
{
if (LastMs == 0) { LastMs = nowMs; return 0; } // первый расчёт: отсюда и считаем
if (nowMs <= LastMs) return 0; // часы назад или то же время -- ничего
long from = LastMs;
LastMs = nowMs;
if (freeSpace <= 0) return 0; // пауза: хранилище полное, прогресс не растёт
long work = 0;
if (mineActiveFromMs != null)
foreach (long active in mineActiveFromMs)
{
long start = Math.Max(from, active);
if (start < nowMs) work += nowMs - start;
}
if (work <= 0) return 0;
long total = ProgressMs + work;
long made = total / MsPerCrystal;
if (made <= freeSpace)
{
ProgressMs = total - made * MsPerCrystal;
return (int)made;
}
ProgressMs = 0; // хранилище заполнилось: шахта встала сразу после последнего кристалла, лишнее не копится
return freeSpace;
}
// Через сколько мс следующий кристалл при count работающих шахтах (0 шахт -- -1)
public long MsToNext(int count) => count <= 0 ? -1 : Math.Max(0, (MsPerCrystal - ProgressMs + count - 1) / count);
public void Load(string line)
{
ProgressMs = 0; LastMs = 0;
if (string.IsNullOrEmpty(line)) return;
var p = line.Split('|');
if (p.Length >= 2
&& long.TryParse(p[0], NumberStyles.Integer, CultureInfo.InvariantCulture, out long progress)
&& long.TryParse(p[1], NumberStyles.Integer, CultureInfo.InvariantCulture, out long last))
{
ProgressMs = Math.Max(0, Math.Min(progress, MsPerCrystal - 1));
LastMs = Math.Max(0, last);
}
}
public string ToLine() => ProgressMs.ToString(CultureInfo.InvariantCulture) + "|" + LastMs.ToString(CultureInfo.InvariantCulture);
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 5f3b9d1e8a2c4e70a6d4b8c1f9e27d36
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,94 @@
using System;
using System.Collections.Generic;
using System.Globalization;
// НЕДЕЛЬНЫЙ PvE-ПРЕДЕЛ СИНИХ КРИСТАЛЛОВ (Регламент валют, кристаллов и торговли от 05.11.2026, разд. 5.2, 10, 12;
// «Ответ Володе» от 06.11.2026, п. 2). Документ: Docs/Modules/Economy.md, раздел «Синие кристаллы».
//
// - Не больше Limit (ТЕСТ 6) НОВЫХ синих кристаллов за скользящие WindowMs (7 реальных суток) из PvE: задания,
// события, бои, PvE-торговец. Кристальная шахта, торговля и грабёж -- вне предела.
// - Take: сколько из награды можно выдать кристаллами сейчас; остальное вызывающий заменяет компенсацией
// (золото / ресурсы). На следующую неделю излишек не переносится.
// - Квота и место в хранилище -- разные условия: взятое по квоте считается сразу, даже если кристаллы потом
// ждут места, -- второй раз квота за них не тратится.
// - Операция с тем же id второй раз квоту не берёт (повторное «Забрать», загрузка).
// - Время -- метки (мс), окно скользящее: запись старше окна больше не считается. На сервере -- доверенное время.
// Без Unity: те же правила будут на сервере и в тестах.
public sealed class CrystalQuota
{
public const long Day = 24L * 60 * 60 * 1000;
public readonly int Limit;
public readonly long WindowMs;
struct Entry { public long TimeMs; public int Amount; public string Id; }
readonly List<Entry> entries = new List<Entry>();
public CrystalQuota(int limit = 6, long windowMs = 7 * Day)
{
Limit = Math.Max(0, limit);
WindowMs = Math.Max(1, windowMs);
}
// Сколько новых PvE-кристаллов уже выдано за окно, кончающееся в nowMs
public int Used(long nowMs)
{
int n = 0;
foreach (var e in entries) if (e.TimeMs > nowMs - WindowMs && e.TimeMs <= nowMs) n += e.Amount;
return n;
}
public int Left(long nowMs) => Math.Max(0, Limit - Used(nowMs));
// Когда освободится хотя бы один кристалл квоты (мс); 0 -- уже свободно
public long NextFreeMs(long nowMs)
{
if (Left(nowMs) > 0) return 0;
long first = long.MaxValue;
foreach (var e in entries) if (e.TimeMs > nowMs - WindowMs && e.TimeMs < first) first = e.TimeMs;
return first == long.MaxValue ? 0 : first + WindowMs;
}
public bool Taken(string id) => !string.IsNullOrEmpty(id) && entries.Exists(e => e.Id == id);
// Взять квоту под награду: сколько из amount выдать кристаллами (остальное -- компенсацией).
// Тот же id второй раз -- 0 (уже учтено). Записи старше окна убираются
public int Take(string id, int amount, long nowMs)
{
Prune(nowMs);
if (amount <= 0 || Taken(id)) return 0;
int give = Math.Min(amount, Left(nowMs));
if (give > 0 || !string.IsNullOrEmpty(id)) entries.Add(new Entry { TimeMs = nowMs, Amount = give, Id = id ?? "" });
return give;
}
// Можно ли взять amount целиком (покупка у PvE-торговца: при нехватке -- блок, золото не списывается)
public bool CanTakeAll(int amount, long nowMs) => amount <= Left(nowMs);
void Prune(long nowMs) => entries.RemoveAll(e => e.TimeMs <= nowMs - WindowMs);
// ---------------- Сохранение: строки "время|сколько|id" ----------------
public List<string> ToLines()
{
var lines = new List<string>();
foreach (var e in entries)
lines.Add(e.TimeMs.ToString(CultureInfo.InvariantCulture) + "|" + e.Amount.ToString(CultureInfo.InvariantCulture) + "|" + e.Id);
return lines;
}
public void Load(IEnumerable<string> lines)
{
entries.Clear();
if (lines == null) return;
foreach (var line in lines)
{
if (string.IsNullOrEmpty(line)) continue;
var p = line.Split(new[] { '|' }, 3);
if (p.Length < 2) continue;
if (!long.TryParse(p[0], NumberStyles.Integer, CultureInfo.InvariantCulture, out long t)) continue;
if (!int.TryParse(p[1], NumberStyles.Integer, CultureInfo.InvariantCulture, out int n) || n < 0) continue;
entries.Add(new Entry { TimeMs = t, Amount = n, Id = p.Length > 2 ? p[2] : "" });
}
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 9e1f4c7a2b6d48a3a8c5f0d2e7b39c14
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,79 @@
using System;
using System.Collections.Generic;
using System.Globalization;
// СИНИЕ КРИСТАЛЛЫ ПРИ ПРЕДЕЛЕ ХРАНИЛИЩА (Регламент редкости синих кристаллов от 05.11.2026, ред. 1.1, раздел 1).
// Документ: Docs/Modules/Economy.md, раздел «Синие кристаллы».
//
// - Начислить сверх предела нельзя, «тихо потерять» излишек -- тоже нельзя.
// - Награда, которая не помещается, выдаётся частично (сколько есть места), остаток ждёт («Можно забрать»)
// и выдаётся позже -- не сгорает. Повторное «Забрать», загрузка, перезапуск не выдают кристалл дважды:
// каждая награда -- по своему id, остаток хранится вместе с id.
// - Одновременно ждут не больше MaxWaiting наград. Пока оба места заняты, новая награда, которая не помещается
// целиком, не выдаётся совсем (задание остаётся «Забрать») -- ничего не теряется.
// Без Unity: те же правила будут на сервере и в тестах. Сколько кристаллов есть и предел -- даёт вызывающий.
public sealed class CrystalRewards
{
public const int MaxWaiting = 2;
readonly Dictionary<string, int> waiting = new Dictionary<string, int>();
public int WaitingCount => waiting.Count;
public IEnumerable<KeyValuePair<string, int>> Waiting => waiting;
// Сколько ещё помещается: целые кристаллы до предела. Предела нет -- сколько угодно
public static int Free(float have, float cap)
{
if (float.IsInfinity(cap) || float.IsNaN(cap)) return int.MaxValue;
return Math.Max(0, (int)Math.Floor(cap - have + 0.0001f));
}
public static int Fits(int amount, float have, float cap) => Math.Max(0, Math.Min(amount, Free(have, cap)));
// Сколько кристаллов этой награды ещё ждут места (0 -- не ждут)
public int Left(string id) => id != null && waiting.TryGetValue(id, out int n) ? n : 0;
// Можно ли начать выдачу новой награды: поместится целиком, уже ждёт или есть свободное место ожидания
public bool CanStart(string id, int amount, float have, float cap)
{
if (amount <= 0 || Fits(amount, have, cap) >= amount) return true;
return Left(id) > 0 || waiting.Count < MaxWaiting;
}
// Выдать награду id: возвращает, сколько кристаллов начислить сейчас; остаток ждёт под этим id.
// Если награда уже ждёт -- выдаётся её остаток (amount не важен: награда одна, повтор её не увеличит).
// Перед новой наградой вызывающий проверяет CanStart
public int Claim(string id, int amount, float have, float cap)
{
if (id == null) id = "";
int need = waiting.TryGetValue(id, out int left) ? left : Math.Max(0, amount);
if (need <= 0) return 0;
int give = Fits(need, have, cap);
if (need - give > 0) waiting[id] = need - give;
else waiting.Remove(id);
return give;
}
// ---------------- Сохранение: строки "id|сколько" ----------------
public List<string> ToLines()
{
var lines = new List<string>();
foreach (var pair in waiting) lines.Add(pair.Key + "|" + pair.Value.ToString(CultureInfo.InvariantCulture));
return lines;
}
public void Load(IEnumerable<string> lines)
{
waiting.Clear();
if (lines == null) return;
foreach (var line in lines)
{
if (string.IsNullOrEmpty(line)) continue;
int bar = line.LastIndexOf('|');
if (bar <= 0) continue;
if (int.TryParse(line.Substring(bar + 1), NumberStyles.Integer, CultureInfo.InvariantCulture, out int n) && n > 0)
waiting[line.Substring(0, bar)] = n;
}
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 7c3e9a41b2d84f6e9a0c5d1e8f2b4a67
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,63 @@
using System;
using System.Collections.Generic;
// ХРАНИЛИЩЕ СИНИХ КРИСТАЛЛОВ: вместимость и платное расширение
// (Регламент валют, кристаллов и торговли от 05.11.2026, разд. 8; «Ответ Володе» от 06.11.2026, п. 1).
// Документ: Docs/Modules/Economy.md, раздел «Синие кристаллы».
//
// - Старт -- BaseCapacity (ТЕСТ 50). Ступени (ТЕСТ): Ратуша 3 -- 75, 5 -- 100, 7 -- 150, 9 -- 200.
// - Ступень НЕ открывается сама с уровнем Ратуши: Ратуша только разрешает её, игрок покупает за золото и
// обычные ресурсы. Ступени -- по порядку, одна покупка -- одна ступень.
// - Цена списывается один раз: ступень растёт, только если списание прошло (всё или ничего). Повторное
// нажатие без следующей ступени или без нужной Ратуши ничего не списывает.
// - Сохраняется номер купленной ступени (Level), вместимость по нему считается заново -- числа можно менять.
// Без Unity: те же правила будут на сервере и в тестах. Числа и цены даёт слой приложения (EconomyConfig).
public sealed class CrystalStorage
{
public sealed class Tier
{
public int TownHallLevel; // с какого уровня Ратуши можно купить
public int Capacity; // вместимость после покупки
public ResourceAmount[] Cost; // цена: золото и обычные ресурсы
}
public readonly int BaseCapacity;
readonly List<Tier> tiers;
public CrystalStorage(int baseCapacity, IEnumerable<Tier> tiers)
{
BaseCapacity = Math.Max(0, baseCapacity);
this.tiers = new List<Tier>();
if (tiers != null) foreach (var t in tiers) if (t != null) this.tiers.Add(t);
}
// Сколько ступеней куплено (0 -- только стартовая вместимость)
public int Level { get; private set; }
public int TierCount => tiers.Count;
public int Capacity => Level <= 0 ? BaseCapacity : Math.Max(BaseCapacity, tiers[Level - 1].Capacity);
// Следующая ступень; null -- все куплены
public Tier Next => Level < tiers.Count ? tiers[Level] : null;
// Восстановить из сохранения (номер ступени; лишнее обрезается, если ступеней стало меньше)
public void SetLevel(int level) => Level = Math.Max(0, Math.Min(level, tiers.Count));
// Почему сейчас нельзя расширить: null -- можно (если хватит ресурсов -- это проверяет списание)
public string WhyNot(int townHallLevel)
{
var next = Next;
if (next == null) return "Хранилище расширено до предела";
if (townHallLevel < next.TownHallLevel) return $"Расширение до {next.Capacity} откроется на Ратуше {next.TownHallLevel} ур.";
return null;
}
// Купить следующую ступень. trySpend -- списание цены «всё или ничего» (Экономика). true -- ступень куплена
public bool TryExpand(int townHallLevel, Func<IReadOnlyList<ResourceAmount>, bool> trySpend)
{
if (WhyNot(townHallLevel) != null || trySpend == null) return false;
if (!trySpend(Next.Cost ?? new ResourceAmount[0])) return false;
Level++;
return true;
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 4b8d2f6a1c9e47d3b5a0e7f3c2d19a85
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,99 @@
using System;
using System.Collections.Generic;
using System.Globalization;
// РЕДКИЙ PvE-ТОРГОВЕЦ: синий кристалл за золото (Регламент валют, кристаллов и торговли от 05.11.2026, разд. 3, 10, 12;
// «Ответ Володе» от 06.11.2026, п. 2). Документ: Docs/Modules/Economy.md, раздел «Синие кристаллы».
//
// - За скользящие 7 реальных суток: 1-я покупка -- Prices[0] (ТЕСТ 30 000 золота), 2-я -- Prices[1] (60 000),
// дальше -- недоступно (не больше Prices.Length покупок).
// - Покупка входит в недельный PvE-предел (CrystalQuota). Нет квоты -- покупка заблокирована, золото НЕ списывается,
// компенсации за оплаченный товар нет.
// - Нет места в хранилище -- тоже блок без списания (вместимость получателя проверяется до оплаты).
// - Порядок: все проверки -> списание золота (всё или ничего) -> квота -> запись покупки. Одна операция -- один id,
// повтор того же id ничего не делает.
// Без Unity: те же правила будут на сервере и в тестах.
public sealed class CrystalTrader
{
public enum Result { Bought, NoSlots, NoQuota, NoSpace, NoGold, Repeated }
public readonly int[] Prices;
public readonly long WindowMs;
struct Purchase { public long TimeMs; public string Id; }
readonly List<Purchase> purchases = new List<Purchase>();
public CrystalTrader(int[] prices = null, long windowMs = 7 * CrystalQuota.Day)
{
Prices = prices != null && prices.Length > 0 ? prices : new[] { 30000, 60000 };
WindowMs = Math.Max(1, windowMs);
}
public int BoughtInWindow(long nowMs)
{
int n = 0;
foreach (var p in purchases) if (p.TimeMs > nowMs - WindowMs && p.TimeMs <= nowMs) n++;
return n;
}
// Цена следующей покупки; -1 -- в этом окне больше нельзя
public int PriceNow(long nowMs)
{
int n = BoughtInWindow(nowMs);
return n < Prices.Length ? Prices[n] : -1;
}
// Когда снова можно купить (мс), если покупки кончились; 0 -- можно сейчас
public long NextSlotMs(long nowMs)
{
if (PriceNow(nowMs) >= 0) return 0;
long first = long.MaxValue;
foreach (var p in purchases) if (p.TimeMs > nowMs - WindowMs && p.TimeMs < first) first = p.TimeMs;
return first == long.MaxValue ? 0 : first + WindowMs;
}
// Что помешает купить сейчас (без списания); Bought -- можно
public Result Check(long nowMs, CrystalQuota quota, int freeSpace)
{
if (PriceNow(nowMs) < 0) return Result.NoSlots;
if (quota != null && !quota.CanTakeAll(1, nowMs)) return Result.NoQuota;
if (freeSpace < 1) return Result.NoSpace;
return Result.Bought;
}
// Купить 1 синий кристалл. trySpendGold(цена) -- списание «всё или ничего». При Bought кристалл начисляет вызывающий
public Result TryBuy(string id, long nowMs, CrystalQuota quota, int freeSpace, Func<int, bool> trySpendGold)
{
if (!string.IsNullOrEmpty(id) && purchases.Exists(p => p.Id == id)) return Result.Repeated;
var check = Check(nowMs, quota, freeSpace);
if (check != Result.Bought) return check;
int price = PriceNow(nowMs);
if (trySpendGold == null || !trySpendGold(price)) return Result.NoGold;
if (quota != null) quota.Take(id, 1, nowMs);
purchases.RemoveAll(p => p.TimeMs <= nowMs - WindowMs);
purchases.Add(new Purchase { TimeMs = nowMs, Id = id ?? "" });
return Result.Bought;
}
// ---------------- Сохранение: строки "время|id" ----------------
public List<string> ToLines()
{
var lines = new List<string>();
foreach (var p in purchases) lines.Add(p.TimeMs.ToString(CultureInfo.InvariantCulture) + "|" + p.Id);
return lines;
}
public void Load(IEnumerable<string> lines)
{
purchases.Clear();
if (lines == null) return;
foreach (var line in lines)
{
if (string.IsNullOrEmpty(line)) continue;
var p = line.Split(new[] { '|' }, 2);
if (!long.TryParse(p[0], NumberStyles.Integer, CultureInfo.InvariantCulture, out long t)) continue;
purchases.Add(new Purchase { TimeMs = t, Id = p.Length > 1 ? p[1] : "" });
}
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: c25a8e3f7d1b4c69b0e4a6f9d8c37b21
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -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);
+24 -2
View File
@@ -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,491 @@ 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), "без предела -- целиком");
}
}
// Хранилище синих кристаллов: платное расширение по уровню Ратуши (регламент валют 05.11, разд. 8 и 12)
public class CrystalStorageTests
{
static CrystalStorage Storage() => new CrystalStorage(50, new[]
{
new CrystalStorage.Tier { TownHallLevel = 3, Capacity = 75, Cost = new[] { new ResourceAmount(ResourceType.Gold, 2000) } },
new CrystalStorage.Tier { TownHallLevel = 5, Capacity = 100, Cost = new[] { new ResourceAmount(ResourceType.Gold, 4000) } },
new CrystalStorage.Tier { TownHallLevel = 7, Capacity = 150, Cost = new[] { new ResourceAmount(ResourceType.Gold, 7000) } },
new CrystalStorage.Tier { TownHallLevel = 9, Capacity = 200, Cost = new[] { new ResourceAmount(ResourceType.Gold, 10000) } },
});
[Test]
public void Storage_NotAutomatic_TownHallOnlyAllows()
{
var s = Storage();
Assert.AreEqual(50, s.Capacity, "старт 50");
int spent = 0;
Assert.IsFalse(s.TryExpand(2, c => { spent++; return true; }), "Ратуша 2 -- расширения нет");
Assert.AreEqual(0, spent, "ничего не списано");
Assert.AreEqual(50, s.Capacity, "Ратуша 3 сама вместимость не меняет -- только разрешает");
Assert.IsNull(s.WhyNot(3));
}
[Test]
public void Storage_50to75_PaidOnce_AndKeptAfterSaveLoad()
{
var s = Storage();
float gold = 2500;
System.Func<IReadOnlyList<ResourceAmount>, bool> pay = cost =>
{
float need = 0;
foreach (var c in cost) if (c.Type == ResourceType.Gold) need += c.Amount;
if (gold < need) return false;
gold -= need;
return true;
};
Assert.IsTrue(s.TryExpand(3, pay));
Assert.AreEqual(75, s.Capacity, "50 -> 75 после оплаты");
Assert.AreEqual(500, gold, 0.01, "списано один раз");
Assert.IsFalse(s.TryExpand(3, pay), "повторное нажатие на Ратуше 3 -- следующая ступень ещё закрыта");
Assert.AreEqual(500, gold, 0.01, "второй раз не списано");
var loaded = Storage();
loaded.SetLevel(s.Level);
Assert.AreEqual(75, loaded.Capacity, "после сохранения и загрузки -- 75");
}
[Test]
public void Storage_NotEnoughResources_NothingChanges()
{
var s = Storage();
Assert.IsFalse(s.TryExpand(9, c => false), "не хватило -- списание не прошло");
Assert.AreEqual(0, s.Level);
Assert.AreEqual(50, s.Capacity);
}
[Test]
public void Storage_TiersInOrder_UpTo200()
{
var s = Storage();
for (int i = 0; i < 4; i++) Assert.IsTrue(s.TryExpand(10, c => true));
Assert.AreEqual(200, s.Capacity);
Assert.IsNull(s.Next);
Assert.IsFalse(s.TryExpand(10, c => true), "дальше 200 -- нет");
s.SetLevel(99);
Assert.AreEqual(4, s.Level, "лишние ступени из сохранения обрезаются");
}
[Test]
public void Storage_Waiting_ClaimedOnceAfterExpansion()
{
// 49/50 + допустимая награда 2 -> 1 сразу и 1 ждёт; после расширения ждущий забирается один раз
var s = Storage();
var r = new CrystalRewards();
float have = 49;
have += r.Claim("q", 2, have, s.Capacity);
Assert.AreEqual(50, have, 0.01);
Assert.AreEqual(1, r.Left("q"));
s.TryExpand(3, c => true);
have += r.Claim("q", 2, have, s.Capacity);
Assert.AreEqual(51, have, 0.01, "после расширения выдан ждущий 1");
Assert.AreEqual(0, r.Left("q"), "больше ничего не ждёт -- выдан ровно один раз");
}
}
// Недельный PvE-предел синих кристаллов (регламент валют 05.11, разд. 5.2, 10, 12; ответ 06.11, п. 2)
public class CrystalQuotaTests
{
const long T0 = 1_800_000_000_000L;
const long Day = CrystalQuota.Day;
[Test]
public void Quota_5of6_Reward2_OneCrystal_OneCompensated()
{
var q = new CrystalQuota(6, 7 * Day);
Assert.AreEqual(5, q.Take("a", 5, T0));
Assert.AreEqual(1, q.Take("b", 2, T0 + 1000), "5/6 + 2 -> 1 кристалл (второй -- компенсацией)");
Assert.AreEqual(0, q.Left(T0 + 1000));
}
[Test]
public void Quota_6of6_Purchase_Blocked()
{
var q = new CrystalQuota(6, 7 * Day);
q.Take("a", 6, T0);
Assert.IsFalse(q.CanTakeAll(1, T0 + 1), "6/6 -- покупка у торговца блокируется (золото не списывается)");
Assert.AreEqual(0, q.Take("b", 1, T0 + 1));
}
[Test]
public void Quota_SameOperationTwice_CountedOnce()
{
var q = new CrystalQuota(6, 7 * Day);
Assert.AreEqual(2, q.Take("event_5", 2, T0));
Assert.AreEqual(0, q.Take("event_5", 2, T0 + 5), "повторное «Забрать» -- квота не берётся второй раз");
Assert.AreEqual(2, q.Used(T0 + 5));
}
[Test]
public void Quota_RollingWindow_FreesAfter7Days_NoCarryOver()
{
var q = new CrystalQuota(6, 7 * Day);
q.Take("a", 6, T0);
Assert.AreEqual(0, q.Left(T0 + 7 * Day - 1), "ещё не прошло 7 суток");
Assert.AreEqual(T0 + 7 * Day, q.NextFreeMs(T0 + Day));
Assert.AreEqual(6, q.Left(T0 + 7 * Day), "через 7 суток квота снова 6, а не 6 + перенос");
Assert.AreEqual(6, q.Take("b", 9, T0 + 7 * Day), "сверх 6 -- компенсацией, на следующую неделю не переносится");
}
[Test]
public void Quota_SurvivesSaveAndLoad()
{
var q = new CrystalQuota(6, 7 * Day);
q.Take("a", 4, T0);
var copy = new CrystalQuota(6, 7 * Day);
copy.Load(q.ToLines());
Assert.AreEqual(2, copy.Left(T0 + Day), "после перезапуска -- те же 4 из 6");
Assert.IsTrue(copy.Taken("a"));
}
[Test]
public void Quota_SeparateFromStorage_WaitingNotCountedTwice()
{
// Квота есть, а места нет: выданное по квоте ждёт места и второй раз квоту не тратит
var q = new CrystalQuota(6, 7 * Day);
var r = new CrystalRewards();
int byQuota = q.Take("q1", 2, T0);
Assert.AreEqual(2, byQuota);
Assert.AreEqual(0, r.Claim("q1", byQuota, 50, 50), "хранилище 50/50 -- ничего не начислено");
Assert.AreEqual(2, r.Left("q1"), "2 ждут места");
Assert.AreEqual(2, r.Claim("q1", 0, 40, 50), "место появилось -- выданы ждущие");
Assert.AreEqual(2, q.Used(T0 + 10), "квота потрачена один раз: 2, а не 4");
}
}
// Редкий PvE-торговец: синий кристалл за золото (регламент валют 05.11, разд. 3, 10, 12)
public class CrystalTraderTests
{
const long T0 = 1_800_000_000_000L;
const long Day = CrystalQuota.Day;
static System.Func<int, bool> Wallet(float[] gold) => price =>
{
if (gold[0] < price) return false;
gold[0] -= price;
return true;
};
[Test]
public void Trader_Quota4of6_Buys_ChargesGold_CountsQuota_Max2()
{
var quota = new CrystalQuota(6, 7 * Day);
quota.Take("old", 4, T0);
var t = new CrystalTrader(new[] { 30000, 60000 }, 7 * Day);
var gold = new[] { 100000f };
Assert.AreEqual(CrystalTrader.Result.Bought, t.TryBuy("b1", T0 + 1, quota, 10, Wallet(gold)));
Assert.AreEqual(70000f, gold[0], 0.01, "первая -- 30 000");
Assert.AreEqual(5, quota.Used(T0 + 1), "квота +1");
Assert.AreEqual(CrystalTrader.Result.Bought, t.TryBuy("b2", T0 + 2, quota, 10, Wallet(gold)));
Assert.AreEqual(10000f, gold[0], 0.01, "вторая -- 60 000");
Assert.AreEqual(CrystalTrader.Result.NoSlots, t.TryBuy("b3", T0 + 3, quota, 10, Wallet(gold)), "третьей нет");
Assert.AreEqual(10000f, gold[0], 0.01, "золото не списано");
}
[Test]
public void Trader_Quota6of6_Blocked_NoGoldSpent()
{
var quota = new CrystalQuota(6, 7 * Day);
quota.Take("old", 6, T0);
var t = new CrystalTrader(null, 7 * Day);
var gold = new[] { 50000f };
Assert.AreEqual(CrystalTrader.Result.NoQuota, t.TryBuy("b1", T0 + 1, quota, 10, Wallet(gold)));
Assert.AreEqual(50000f, gold[0], 0.01, "6/6 + покупка -> блок без расхода золота");
Assert.AreEqual(0, t.BoughtInWindow(T0 + 1));
}
[Test]
public void Trader_NoSpace_Or_NoGold_NothingChanges()
{
var quota = new CrystalQuota(6, 7 * Day);
var t = new CrystalTrader(null, 7 * Day);
var gold = new[] { 50000f };
Assert.AreEqual(CrystalTrader.Result.NoSpace, t.TryBuy("b1", T0, quota, 0, Wallet(gold)), "хранилище полное");
Assert.AreEqual(50000f, gold[0], 0.01);
var poor = new[] { 1000f };
Assert.AreEqual(CrystalTrader.Result.NoGold, t.TryBuy("b2", T0, quota, 10, Wallet(poor)), "не хватает золота");
Assert.AreEqual(0, quota.Used(T0), "квота не тронута");
Assert.AreEqual(30000, t.PriceNow(T0), "цена по-прежнему первая");
}
[Test]
public void Trader_SameOperationTwice_OnePurchase()
{
var quota = new CrystalQuota(6, 7 * Day);
var t = new CrystalTrader(null, 7 * Day);
var gold = new[] { 100000f };
t.TryBuy("b1", T0, quota, 10, Wallet(gold));
Assert.AreEqual(CrystalTrader.Result.Repeated, t.TryBuy("b1", T0 + 1, quota, 10, Wallet(gold)));
Assert.AreEqual(70000f, gold[0], 0.01, "повтор той же покупки -- ничего не списано");
Assert.AreEqual(1, quota.Used(T0 + 1));
}
[Test]
public void Trader_WindowRolls_AndSurvivesSaveLoad()
{
var quota = new CrystalQuota(6, 7 * Day);
var t = new CrystalTrader(null, 7 * Day);
var gold = new[] { 1000000f };
t.TryBuy("b1", T0, quota, 10, Wallet(gold));
t.TryBuy("b2", T0 + Day, quota, 10, Wallet(gold));
var copy = new CrystalTrader(null, 7 * Day);
copy.Load(t.ToLines());
Assert.AreEqual(-1, copy.PriceNow(T0 + 2 * Day), "после перезапуска -- те же 2 покупки");
Assert.AreEqual(T0 + 7 * Day, copy.NextSlotMs(T0 + 2 * Day));
Assert.AreEqual(60000, copy.PriceNow(T0 + 7 * Day), "первая покупка вышла из окна -- следующая снова вторая по счёту");
Assert.AreEqual(30000, copy.PriceNow(T0 + 8 * Day), "обе вышли из окна -- снова 30 000");
}
}
// Кристальная шахта: 3 синих кристалла за 7 реальных суток (регламент кристаллов 05.11, разд. 2.1 и 6; ответ 06.11, п. 3)
public class CrystalMineTests
{
const long T0 = 1_800_000_000_000L;
const long Day = CrystalQuota.Day;
static readonly long[] OneMine = { long.MinValue };
static CrystalMine Started()
{
var m = new CrystalMine(3, 7 * Day);
m.Advance(T0, OneMine, 50); // первый расчёт -- точка отсчёта
return m;
}
[Test]
public void Mine_7RealDays_FreeStorage_Exactly3()
{
var m = Started();
int got = 0;
for (long t = T0; t < T0 + 7 * Day; t += 10 * 60 * 1000) got += m.Advance(t + 10 * 60 * 1000, OneMine, 50 - got);
Assert.AreEqual(3, got, "за 7 реальных суток -- ровно 3");
}
[Test]
public void Mine_5RealMinutes_NotWeeklyAmount()
{
var m = Started();
Assert.AreEqual(0, m.Advance(T0 + 5 * 60 * 1000, OneMine, 50), "5 реальных минут -- не недельный объём (×12 не действует)");
}
[Test]
public void Mine_49of50_AtMostOne_50of50_Paused_ThenContinues()
{
var m = Started();
Assert.AreEqual(1, m.Advance(T0 + 7 * Day, OneMine, 1), "49/50 -- не больше 1");
Assert.AreEqual(0, m.ProgressMs, "лишнее не копится: шахта встала после последнего поместившегося");
Assert.AreEqual(0, m.Advance(T0 + 14 * Day, OneMine, 0), "50/50 -- пауза");
Assert.AreEqual(0, m.ProgressMs, "за паузу прогресс не вырос");
Assert.AreEqual(1, m.Advance(T0 + 14 * Day + m.MsPerCrystal, OneMine, 10), "место есть -- дальше с оставшегося прогресса, без начисления за паузу");
}
[Test]
public void Mine_Reload_And_ClockBack_NoDouble()
{
var m = Started();
Assert.AreEqual(1, m.Advance(T0 + m.MsPerCrystal, OneMine, 50));
var copy = new CrystalMine(3, 7 * Day);
copy.Load(m.ToLine());
Assert.AreEqual(0, copy.Advance(T0 + m.MsPerCrystal, OneMine, 50), "после загрузки то же время -- ничего");
Assert.AreEqual(0, copy.Advance(T0, OneMine, 50), "часы назад (сброс перемотки) -- ничего");
Assert.AreEqual(T0 + m.MsPerCrystal, copy.LastMs, "метка не ушла назад");
}
[Test]
public void Mine_TwoMines_Double_BuiltOffline_CountsFromCompletion()
{
var m = Started();
Assert.AreEqual(6, m.Advance(T0 + 7 * Day, new[] { long.MinValue, long.MinValue }, 50), "две шахты -- 6 за 7 суток");
var late = Started();
Assert.AreEqual(1, late.Advance(T0 + 3 * Day, new[] { T0 + 3 * Day - late.MsPerCrystal }, 50),
"шахта достроена офлайн -- добывает с конца стройки, а не с начала офлайна");
Assert.AreEqual(0, Started().Advance(T0 + Day, new long[0], 50), "шахт нет -- ничего");
}
}
+5 -1
View File
@@ -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>();
+11 -2
View File
@@ -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()
+56
View File
@@ -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
+8
View File
@@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: 579a321e304bee74a956dcc2b1c02da0
NativeFormatImporter:
externalObjects: {}
mainObjectFileID: 11400000
userData:
assetBundleName:
assetBundleVariant:
+1
View File
@@ -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
+1
View File
@@ -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` напрямую. Добавлены разворот на весь экран и тесты (пустое сообщение, обрезка, событие, предел истории). |
+3
View File
@@ -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`. |
+31
View File
@@ -88,6 +88,28 @@
| `App/BuildingWindow.cs` | Окно здания и рабочих. |
| `App/StorageBuilding.cs`, `App/StorageBootstrap.cs` | Склад и Амбар. |
## Синие кристаллы
Действующие документы: «Регламент валют, кристаллов и торговли» от 05.11.2026 и «Ответ Володе» от 06.11.2026 — они приоритетнее «Регламента редкости синих кристаллов» от 05.11 (ред. 1.1). Правила выдачи — `Modules/Economy/Rules/CrystalRewards.cs` (без Unity), тесты — `CrystalRewardsTests`.
**Сделано:**
- **Не сверх вместимости и без потерь.** Задания: сколько помещается, остаток ждёт в задании (не больше 2 ждущих наград). Остальное — письмом «Кристаллы ждут места», из письма забирается сколько помещается. Сохранение — `progress.json`, `crystalWaiting`.
- **Источники (06.11, разд. 5.1 регламента валют).** Синие кристаллы дают только:
- верхняя ступень недельного события — 1 (`GameData.EventTierRewards`);
- каждый 10-й этап боя — 1 (`StageGemsEvery`, `StageGemsAmount`).
Всё ТЕСТ. Без кристаллов теперь: задания (основные и ежедневные), награды за вход (было 50 и 200), письмо-приветствие (было 100), подарок за уровень (было 10 × уровень), арена (было 5), этапы боя (было 25 на каждом 5-м), бесплатный сундук (было 15). Вместо них — золото и ресурсы.
- **Хранилище — платное расширение (разд. 8 регламента валют, ответ 06.11, п. 1).** Старт 50; ступени (ТЕСТ): Ратуша 3 — 75 (2000 золота, 1500 дерева, 1500 камня), Ратуша 5 — 100 (4000 / 3000 / 3000 + 1000 металла), Ратуша 7 — 150 (7000 / 5000 / 5000 / 2000), Ратуша 9 — 200 (10000 / 8000 / 8000 / 4000). Ратуша только разрешает ступень, покупается в окне Ратуши («Хранилище синих кристаллов» → «Расширить до N»). Цена списывается один раз, всё или ничего. Правила — `Modules/Economy/Rules/CrystalStorage.cs`, тесты — `CrystalStorageTests`; числа — `EconomyConfig` → «Склады и лимиты» → `crystalCap`, `crystalExpansions`. Купленная ступень сохраняется в секции `"economy"` (`crystalStorageLevel`; в старом сохранении её нет — 0), вместимость по ней считается заново.
- **Недельный PvE-предел (разд. 5.2, 10, 12 регламента валют; ответ 06.11, п. 2).** Не больше 6 новых синих кристаллов за скользящие 7 реальных суток из заданий, событий и боёв (позже — PvE-торговец). Сверх квоты — 1000 золота за кристалл (компенсация не согласована, ТЕСТ), на следующую неделю не переносится; при частичной квоте — часть кристаллами, остальное золотом. Квота и место в хранилище — разные условия: взятое по квоте, но не поместившееся ждёт места и квоту второй раз не тратит; кристаллы из письма «Кристаллы ждут места» квоту не берут. Задание берёт квоту один раз (по id). Видно в окне Ратуши: «Новых кристаллов из заданий, событий и боёв за 7 дней: N / 6». Правила — `Modules/Economy/Rules/CrystalQuota.cs`, тесты — `CrystalQuotaTests`; числа — `EconomyConfig` → `crystalPveLimit`, `crystalPveWindowDays`, `crystalCompensationGold`; сохранение — `progress.json`, `crystalQuota`. DEV: «+1 PvE-кристалл», «Сбросить квоту».
- **Редкий PvE-торговец (разд. 3 и 10 регламента валют).** Магазин → «Редкий торговец: синий кристалл»: за скользящие 7 реальных суток первая покупка — 30 000 золота, вторая — 60 000, третьей нет (`EconomyConfig` → `crystalTraderPrices`, ТЕСТ). Покупка входит в недельный PvE-предел. Все проверки — до оплаты: нет покупок в окне, нет квоты или нет места в хранилище — блок, золото не списывается, компенсации нет. Одна покупка — один id, повтор ничего не делает; после покупки игра сразу сохраняется. Правила — `Modules/Economy/Rules/CrystalTrader.cs`, тесты — `CrystalTraderTests`; сохранение — `progress.json`, `crystalTrader`. DEV: «Ресурсы 100 000» (мимо лимита склада, для проверки), «Сбросить квоту» сбрасывает и покупки. **Вопрос:** при лимите золота 1000 + 1000 за уровень Склада цены 30 000 / 60 000 почти недостижимы.
- **Кристальная шахта (разд. 2.1 регламента кристаллов, разд. 8.1 регламента валют, ответ 06.11, п. 3).** Здание «Кристалл» (Ратуша 6; вторая шахта — Ратуша 9, ТЕСТ, лимит в `buildingLimits`). Каждая работающая шахта — 3 синих кристалла за 7 РЕАЛЬНЫХ суток, по одному примерно раз в 56 ч (`crystalMinePerPeriod`, `crystalMinePeriodDays`, ТЕСТ); шахты складываются. Работает с конца стройки (и если стройка закончилась офлайн), досчитывается после входа по меткам; уже посчитанное время второй раз не начисляется, часы назад — ничего. Хранилище полное — пауза без скрытого запаса, после освобождения места — с оставшегося прогресса. В недельный PvE-предел не входит. Окно здания: «Работает шахт: N. Следующий кристалл через …» или «Пауза». Вне 3D-сцены (карта мира) шахты считаются по сохранению `settlement3d`. Правила — `Modules/Economy/Rules/CrystalMine.cs`, тесты — `CrystalMineTests`; расчёт — `GameProgress.ProcessCrystalMine` (раз в секунду), сохранение — `progress.json`, `crystalMine`. DEV «Ресурсы 100 000» — обычные ресурсы мимо лимита склада, для проверки.
- **Ускорения — за золото** (06.11, решение Саши, ТЕСТ): стройка, улучшение и обучение войск — 1 золото за 10 секунд (`EconomyConfig` → «Стройка» → `secondsPerGold`, `EconomyConfig.SpeedUpGold`); было 1 синий кристалл за 30 секунд. Подтвердить у руководителя валюту и курс.
- **Магазин — за золото** (пакеты дерева, камня, еды, железа: 600 / 700 / 600 / 900 золота, ТЕСТ). Убраны пакеты за 50–80 кристаллов, «Мешок золота», обмен золота на кристаллы, «Горсть» (500) и «Сундук» (3000) кристаллов.
**Ещё не сделано:** космо-кристаллы; торговля и военная добыча — этап мультиплеера.
**Открыто:** окончательные цены расширения хранилища, компенсация за кристалл сверх недельного предела, уровень Ратуши для второй шахты (сейчас 9), лимит золота против цен торговца и расширений.
## Таблица баланса `EconomyConfig`
Все числа экономики — в ассете `Assets/Resources/EconomyConfig.asset` (класс `App/EconomyConfig.cs`, слой приложения). Меняются в Inspector без перекомпиляции. Разделы: население, стартовые ресурсы новой игры, производство T1, месторождения, цена солдата. Числа с пометкой «не утверждено» — временные.
@@ -109,6 +131,15 @@
| Дата | Кто | Что |
| --- | --- | --- |
| 06.11.2026 | Claude (Саша) | Кристальная шахта: 3 синих кристалла за 7 реальных суток на шахту, до двух (вторая — Ратуша 9, ТЕСТ), офлайн по меткам, пауза при полном хранилище, вне недельного предела (`CrystalMine` + 5 тестов, `GameProgress.ProcessCrystalMine`, окно здания). |
| 06.11.2026 | Claude (Саша) | Редкий PvE-торговец: синий кристалл за 30 000 / 60 000 золота, не больше 2 за 7 реальных суток, в пределах недельной квоты; блок без списания (`CrystalTrader` + 5 тестов, магазин, DEV «Золото 100 000»). |
| 06.11.2026 | Claude (Саша) | Недельный PvE-предел синих кристаллов: 6 за 7 реальных суток, сверх — 1000 золота за кристалл (ТЕСТ); `CrystalQuota` + 6 тестов, `GameProgress.Grant/ClaimQuest`, окно Ратуши, DEV-кнопки. |
| 06.11.2026 | Claude (Саша) | Ускорения стройки, улучшения и обучения войск — за золото: 1 золото за 10 с (`secondsPerGold`, ТЕСТ), было 1 кристалл за 30 с. |
| 06.11.2026 | Claude (Саша) | Хранилище синих кристаллов: платное расширение 50 → 75 / 100 / 150 / 200 на Ратуше 3 / 5 / 7 / 9 в окне Ратуши (`CrystalStorage`, `EconomyConfig.crystalExpansions`, `ResourceManager.CrystalStorageLevel` в сохранении, 5 тестов). Вместимость кристаллов больше не константа 50. |
| 06.11.2026 | Claude (Саша) | Регламент валют 05.11, разд. 2 и 5.1: синие кристаллы только за верхнюю ступень недельного события (1) и каждый 10-й этап боя (1); задания, вход, письма, уровень, арена, сундук — золото и ресурсы; магазин за золото, убраны пакеты за кристаллы, обмен золота на кристаллы, «Горсть» и «Сундук». |
| 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` «Люди» на ячейках панели. |
+4 -2
View File
@@ -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). Погибшие уходят из людей, вылеченные не появляются заново. Еда раненых — как у воинов (нормы четырёх групп — открытый вопрос). |
+2
View File
@@ -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. |
+24 -1
View File
@@ -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,16 @@
| Дата | Кто | Что |
| --- | --- | --- |
| 06.11.2026 | Claude (Саша) | Открытие зоны Ратушей: камера показывает только край новой зоны — старую зону и 2 клетки новой за её границей (`ZoneEdgeCells`), а не всю зону; ближе, чем была, не подъезжает (`City3DCamera.FocusOn(..., onlyZoomOut)`). Раньше при ускорении улучшения Ратуши камера улетала слишком далеко. |
| 06.11.2026 | Claude (Валдемар) | Этап 1 перехода на 3D (2D-город больше не развиваем). Сверка: все 12 зданий 2D (8 `Data_*.asset` + Склад, Амбар, Госпиталь, Терем) есть в 3D с теми же ценами и уровнем Ратуши; ни одна кнопка не ведёт в MVP1. Камера (`City3DCamera`): центр кадра не уходит за открытые зоны; при самом дальнем отдалении открытая зона видна целиком между HUD сверху и снизу — дальше всего = max(подобранное в DEV, сколько нужно для зоны), считается по проекции камеры (`FitDistance`); «+ / −» и колесо приближают плавно; перетаскивание считается на высоте земли под пальцем (на холмах карта идёт точно за пальцем); подъезд к призраку — `MoveTo`, без смены приближения. Ввод: после щипка отпущенный палец больше не считается тапом (не открывается окно здания). `SceneListFixer` больше не добавляет и не включает MVP1 в Build Settings. Числа камеры — ТЕСТ. |
| 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