Author SHA1 Message Date
AlexanderandClaude Opus 5.5 61ba46947c Разведка М2: целенаправленная разведка поселений, замка и лагерей с отчётами II и III
Отряд идёт маршем, наблюдает 2/4 (поселение, лагерь) или 3/6 (замок) игровых часов, отчёт на конец наблюдения,
потом домой. Уровень II -- точные войска, герой, стены; III -- плюс защита и ресурсы ±15%. Один отчёт на операцию,
сохранение. Карта: кнопки «Разведка II/III», окно отчёта, «Отчёты», прогноз по отчёту вне обзора.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-04 15:21:54 +01:00
AlexanderandClaude Opus 5.5 6c39c8b102 Пропавшие без вести (решения 01.11, п. 5)
Бой: отдельный статус среди выбывших (доля не утверждена -- 0, выключено), версия правил 8.
Армия: пропавшие из похода -- в группу; окно 6 игровых часов после обратного пути, возвращение 2-4 группами,
70% здоровыми / 30% ранеными, остальные списываются; сохранение без повторного возвращения.
Население: занимают места, но не едят. Окна: сколько пропало и сколько ещё может вернуться.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-04 15:16:43 +01:00
AlexanderandClaude Opus 5.5 cfbdfd9876 Еда воинов и раненых по типам (решения 01.11, п. 4): 0,15 / 0,20 / 0,15 / 0,15, раненые -- даже в ожидании койки
IArmy.GetAllTroops; PopulationRules.FoodPerTroopTypePerHour (без таблицы -- как раньше);
EconomyConfig.population.foodPerTroopTypePerHour.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-04 15:13:02 +01:00
AlexanderandClaude Opus 5.5 fae42716a7 3D: земля за краем карты и участка -- вместо пустого неба (на карте тёмная, у города -- лес)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-04 15:07:28 +01:00
AlexanderandClaude Opus 5.5 e69d2bd7db 3D: свой материал на URP Lit (Resources/Map3D/Map3DLit) -- у примитивов в 2D-проекте спрайтовый шейдер без света и цвета
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-04 15:05:12 +01:00
AlexanderandClaude Opus 5.5 9a91926b2c 3D-сцены красивее: цвета, свет, небо, дымка; домики с крышами, замок, шатры, деревья, камни
Цвет -- в _BaseColor (URP), раньше всё было белым. Настройка 3D-рендерера добавляет его в URP Asset
каждого уровня качества (в проекте URP задан только в Quality) + отдельная команда меню.
Карта мира: замок со стенами и башнями, города -- домики, лагеря -- шатры с костром, кристаллы точек, рощи.
3D-поселение: здания с двускатными крышами и приметами, ели и лиственные, песчаные берега, снег на скалах.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-04 15:03:16 +01:00
AlexanderandClaude Opus 5.5 9a6e5a2e7b Карта мира в 2.5D на примитивах
Карта на земле (X/Z): замок, города, лагеря, ресурсные точки, отряды и метки разведки -- примитивы,
подписи смотрят в камеру; наклонная перспективная камера с тем же поведением (перетаскивание, щипок, тап);
туман -- в текстуре земли; солнце и 3D-рендерер URP для камеры (Map3D). Логика и окна не менялись.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-04 14:55:41 +01:00
AlexanderandClaude Opus 5.5 a3c4a99a79 3D-поселение, этап I: прототип на примитивах (ТЗ от 04.11)
Bogatyri.Settlement.Placement (без Unity): сетка, зоны по Ратуше, река/скалы/деревья/валуны,
отступ, уклон и фундамент, поворот 0/90/180/270, атомарный перенос с тем же id, расчистка один раз; 7 тестов.
Сцена City3D (Tools -> Bogatyri -> 3D-город): местность, призрак, перенос, расчистка, камера 2.5D,
второй рендерер URP (3D) только для её камеры -- 2D-сцены не тронуты.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-04 14:50:22 +01:00
83 changed files with 3797 additions and 1641 deletions
+119
View File
@@ -0,0 +1,119 @@
using UnityEditor;
using UnityEditor.SceneManagement;
using UnityEngine;
using UnityEngine.Rendering;
using UnityEngine.Rendering.Universal;
using UnityEngine.SceneManagement;
// Создаёт сцену 3D-поселения (прототип, этап I ТЗ «Система строительства поселения» от 04.11.2026):
// Tools -> Bogatyri -> 3D-город: создать сцену City3D.
//
// Что делает:
// 1. Добавляет в настройки URP второй рендерер -- обычный 3D (Assets/Settings/Renderer3D.asset).
// Старые 2D-сцены продолжают рисоваться 2D-рендерером: он остаётся рендерером по умолчанию.
// 2. Создаёт Assets/Scenes/City3D.unity: камера (3D-рендерер, перспектива), солнце с тенями,
// объект City3D с City3DController -- всё остальное (земля, деревья, здания) он строит сам при запуске.
// 3. Добавляет сцену в Build Settings и открывает её. Дальше -- просто Play.
public static class City3DSetup
{
const string ScenePath = "Assets/Scenes/City3D.unity";
const string RendererPath = "Assets/Settings/Renderer3D.asset";
[MenuItem("Tools/Bogatyri/3D-город: создать сцену City3D")]
static void Create()
{
if (!EditorSceneManager.SaveCurrentModifiedScenesIfUserWantsTo()) return;
int rendererIndex = EnsureRenderer3D();
var scene = EditorSceneManager.NewScene(NewSceneSetup.EmptyScene, NewSceneMode.Single);
var camGo = new GameObject("Main Camera") { tag = "MainCamera" };
var cam = camGo.AddComponent<Camera>();
cam.orthographic = false;
cam.fieldOfView = 40f;
cam.nearClipPlane = 0.3f;
cam.farClipPlane = 300f;
cam.clearFlags = CameraClearFlags.SolidColor;
cam.backgroundColor = new Color(0.55f, 0.72f, 0.9f);
camGo.AddComponent<AudioListener>();
var data = camGo.AddComponent<UniversalAdditionalCameraData>();
if (rendererIndex >= 0) data.SetRenderer(rendererIndex);
camGo.AddComponent<City3DCamera>();
var sunGo = new GameObject("Sun");
var sun = sunGo.AddComponent<Light>();
sun.type = LightType.Directional;
sun.intensity = 1.2f;
sun.shadows = LightShadows.Soft;
sunGo.transform.rotation = Quaternion.Euler(50f, -30f, 0f);
RenderSettings.ambientMode = AmbientMode.Flat;
RenderSettings.ambientLight = new Color(0.55f, 0.58f, 0.62f);
new GameObject("City3D").AddComponent<City3DController>();
EditorSceneManager.SaveScene(scene, ScenePath);
AddToBuildSettings();
Debug.Log($"[3D-ГОРОД] Сцена создана: {ScenePath}. 3D-рендерер: индекс {rendererIndex}. Нажмите Play.");
}
// Второй рендерер URP -- обычный (3D). Добавляется в URP Asset из Graphics Settings и из КАЖДОГО уровня качества
// (в этом проекте URP задан только в Quality). Возвращает индекс рендерера в списке (или -1)
[MenuItem("Tools/Bogatyri/3D: добавить 3D-рендерер в URP")]
static void AddRendererMenu()
{
int index = EnsureRenderer3D();
Debug.Log(index >= 0 ? $"[3D] 3D-рендерер подключён (индекс {index}). Можно запускать карту мира и City3D."
: "[3D] URP Asset не найден ни в Graphics, ни в Quality Settings.");
}
static int EnsureRenderer3D()
{
var pipelines = new System.Collections.Generic.List<UniversalRenderPipelineAsset>();
void Add(RenderPipelineAsset a) { if (a is UniversalRenderPipelineAsset u && !pipelines.Contains(u)) pipelines.Add(u); }
Add(GraphicsSettings.defaultRenderPipeline);
for (int q = 0; q < QualitySettings.names.Length; q++) Add(QualitySettings.GetRenderPipelineAssetAt(q));
if (pipelines.Count == 0)
{
Debug.LogWarning("[3D-ГОРОД] Нет URP Asset ни в Graphics, ни в Quality Settings -- камера будет рисовать рендерером по умолчанию");
return -1;
}
var renderer = AssetDatabase.LoadAssetAtPath<UniversalRendererData>(RendererPath);
if (renderer == null)
{
renderer = ScriptableObject.CreateInstance<UniversalRendererData>();
AssetDatabase.CreateAsset(renderer, RendererPath);
AssetDatabase.SaveAssets();
}
int result = -1;
foreach (var pipeline in pipelines)
{
var so = new SerializedObject(pipeline);
var list = so.FindProperty("m_RendererDataList");
int found = -1;
for (int i = 0; i < list.arraySize; i++)
if (list.GetArrayElementAtIndex(i).objectReferenceValue == renderer) found = i;
if (found < 0)
{
list.arraySize++;
list.GetArrayElementAtIndex(list.arraySize - 1).objectReferenceValue = renderer;
so.ApplyModifiedProperties();
EditorUtility.SetDirty(pipeline);
found = list.arraySize - 1;
}
if (result < 0) result = found;
}
AssetDatabase.SaveAssets();
return result;
}
static void AddToBuildSettings()
{
var scenes = new System.Collections.Generic.List<EditorBuildSettingsScene>(EditorBuildSettings.scenes);
foreach (var s in scenes) if (s.path == ScenePath) return;
scenes.Add(new EditorBuildSettingsScene(ScenePath, true));
EditorBuildSettings.scenes = scenes.ToArray();
}
}
@@ -1,5 +1,5 @@
fileFormatVersion: 2
guid: 6104b5c531ff479eb569880ebd75d68c
guid: 37d458150cc84d538a23b4c6612995c7
MonoImporter:
externalObjects: {}
serializedVersion: 2
+2 -1
View File
@@ -12,7 +12,7 @@ MonoBehaviour:
m_Script: {fileID: 11500000, guid: 7264ce138a844adc81fa69b77a27c8a6, type: 3}
m_Name: BattleConfig
m_EditorClassIdentifier:
version: 7
version: 8
counterPercent: 25
wallsPercent: 20
siegeVsWallsPercent: 50
@@ -49,3 +49,4 @@ MonoBehaviour:
- 450
- 300
- 200
missingSharePermille: 0
+5
View File
@@ -18,6 +18,11 @@ MonoBehaviour:
startSoldiers: 30
foodPerCivilianPerHour: 0.1
foodPerSoldierPerHour: 0.2
foodPerTroopTypePerHour:
- 0.15
- 0.2
- 0.15
- 0.15
growthPerHour: 2.5
housingBase: 300
housingPerTownHallLevel: 50
+4
View File
@@ -20,3 +20,7 @@ MonoBehaviour:
healGameHours: 04000000040000000600000008000000
healFoodPerSoldier: 00000000000000000000000000000000
healGoldPerSoldier: 01000000010000000200000003000000
missingReturnPercent: 50
missingWoundedPercent: 30
missingWindowGameHours: 6
missingMaxGroups: 4
+8
View File
@@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: 18e162eeff424f8bb3a71543eafba0c8
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:
+58
View File
@@ -0,0 +1,58 @@
%YAML 1.1
%TAG !u! tag:unity3d.com,2011:
--- !u!114 &-7493335797097805923
MonoBehaviour:
m_ObjectHideFlags: 11
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 0}
m_Enabled: 1
m_EditorHideFlags: 0
m_Script: {fileID: 11500000, guid: d0353a89b1f911e48b9e16bdc9f2e058, type: 3}
m_Name:
m_EditorClassIdentifier: Unity.RenderPipelines.Universal.Editor::UnityEditor.Rendering.Universal.AssetVersion
version: 10
--- !u!21 &2100000
Material:
serializedVersion: 8
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_Name: Map3DLit
m_Shader: {fileID: 4800000, guid: 933532a4fcc9baf4fa0491de14d08ed7, type: 3}
m_Parent: {fileID: 0}
m_ModifiedSerializedProperties: 0
m_ValidKeywords: []
m_InvalidKeywords: []
m_LightmapFlags: 4
m_EnableInstancingVariants: 1
m_DoubleSidedGI: 0
m_CustomRenderQueue: -1
stringTagMap:
RenderType: Opaque
disabledShaderPasses: []
m_LockedProperties:
m_SavedProperties:
serializedVersion: 3
m_TexEnvs:
- _BaseMap:
m_Texture: {fileID: 0}
m_Scale: {x: 1, y: 1}
m_Offset: {x: 0, y: 0}
- _MainTex:
m_Texture: {fileID: 0}
m_Scale: {x: 1, y: 1}
m_Offset: {x: 0, y: 0}
m_Ints: []
m_Floats:
- _Cull: 2
- _Metallic: 0
- _Smoothness: 0.12
- _Surface: 0
m_Colors:
- _BaseColor: {r: 1, g: 1, b: 1, a: 1}
- _Color: {r: 1, g: 1, b: 1, a: 1}
m_BuildTextureStacks: []
m_AllowLocking: 1
+8
View File
@@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: 0ec4d2749e73404eba2facea709d9f24
NativeFormatImporter:
externalObjects: {}
mainObjectFileID: 2100000
userData:
assetBundleName:
assetBundleVariant:
+431
View File
@@ -0,0 +1,431 @@
%YAML 1.1
%TAG !u! tag:unity3d.com,2011:
--- !u!29 &1
OcclusionCullingSettings:
m_ObjectHideFlags: 0
serializedVersion: 2
m_OcclusionBakeSettings:
smallestOccluder: 5
smallestHole: 0.25
backfaceThreshold: 100
m_SceneGUID: 00000000000000000000000000000000
m_OcclusionCullingData: {fileID: 0}
--- !u!104 &2
RenderSettings:
m_ObjectHideFlags: 0
serializedVersion: 10
m_Fog: 0
m_FogColor: {r: 0.5, g: 0.5, b: 0.5, a: 1}
m_FogMode: 3
m_FogDensity: 0.01
m_LinearFogStart: 0
m_LinearFogEnd: 300
m_AmbientSkyColor: {r: 0.55, g: 0.58, b: 0.62, a: 1}
m_AmbientEquatorColor: {r: 0.114, g: 0.125, b: 0.133, a: 1}
m_AmbientGroundColor: {r: 0.047, g: 0.043, b: 0.035, a: 1}
m_AmbientIntensity: 1
m_AmbientMode: 3
m_SubtractiveShadowColor: {r: 0.42, g: 0.478, b: 0.627, a: 1}
m_SkyboxMaterial: {fileID: 0}
m_HaloStrength: 0.5
m_FlareStrength: 1
m_FlareFadeSpeed: 3
m_HaloTexture: {fileID: 0}
m_SpotCookie: {fileID: 10001, guid: 0000000000000000e000000000000000, type: 0}
m_DefaultReflectionMode: 0
m_DefaultReflectionResolution: 128
m_ReflectionBounces: 1
m_ReflectionIntensity: 1
m_CustomReflection: {fileID: 0}
m_Sun: {fileID: 0}
m_UseRadianceAmbientProbe: 0
--- !u!157 &3
LightmapSettings:
m_ObjectHideFlags: 0
serializedVersion: 13
m_BakeOnSceneLoad: 0
m_GISettings:
serializedVersion: 2
m_BounceScale: 1
m_IndirectOutputScale: 1
m_AlbedoBoost: 1
m_EnvironmentLightingMode: 0
m_EnableBakedLightmaps: 0
m_EnableRealtimeLightmaps: 0
m_LightmapEditorSettings:
serializedVersion: 12
m_Resolution: 2
m_BakeResolution: 40
m_AtlasSize: 1024
m_AO: 0
m_AOMaxDistance: 1
m_CompAOExponent: 1
m_CompAOExponentDirect: 0
m_ExtractAmbientOcclusion: 0
m_Padding: 2
m_LightmapParameters: {fileID: 0}
m_LightmapsBakeMode: 1
m_TextureCompression: 1
m_ReflectionCompression: 2
m_MixedBakeMode: 2
m_BakeBackend: 2
m_PVRSampling: 1
m_PVRDirectSampleCount: 32
m_PVRSampleCount: 512
m_PVRBounces: 2
m_PVREnvironmentSampleCount: 256
m_PVREnvironmentReferencePointCount: 2048
m_PVRFilteringMode: 1
m_PVRDenoiserTypeDirect: 1
m_PVRDenoiserTypeIndirect: 1
m_PVRDenoiserTypeAO: 1
m_PVRFilterTypeDirect: 0
m_PVRFilterTypeIndirect: 0
m_PVRFilterTypeAO: 0
m_PVREnvironmentMIS: 1
m_PVRCulling: 1
m_PVRFilteringGaussRadiusDirect: 1
m_PVRFilteringGaussRadiusIndirect: 1
m_PVRFilteringGaussRadiusAO: 1
m_PVRFilteringAtrousPositionSigmaDirect: 0.5
m_PVRFilteringAtrousPositionSigmaIndirect: 2
m_PVRFilteringAtrousPositionSigmaAO: 1
m_ExportTrainingData: 0
m_TrainingDataDestination: TrainingData
m_LightProbeSampleCountMultiplier: 4
m_LightingDataAsset: {fileID: 20201, guid: 0000000000000000f000000000000000, type: 0}
m_LightingSettings: {fileID: 0}
--- !u!196 &4
NavMeshSettings:
serializedVersion: 2
m_ObjectHideFlags: 0
m_BuildSettings:
serializedVersion: 3
agentTypeID: 0
agentRadius: 0.5
agentHeight: 2
agentSlope: 45
agentClimb: 0.4
ledgeDropHeight: 0
maxJumpAcrossDistance: 0
minRegionArea: 2
manualCellSize: 0
cellSize: 0.16666667
manualTileSize: 0
tileSize: 256
buildHeightMesh: 0
maxJobWorkers: 0
preserveTilesOutsideBounds: 0
debug:
m_Flags: 0
m_NavMeshData: {fileID: 0}
--- !u!1 &110794169
GameObject:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
serializedVersion: 6
m_Component:
- component: {fileID: 110794174}
- component: {fileID: 110794173}
- component: {fileID: 110794172}
- component: {fileID: 110794171}
- component: {fileID: 110794170}
m_Layer: 0
m_Name: Main Camera
m_TagString: MainCamera
m_Icon: {fileID: 0}
m_NavMeshLayer: 0
m_StaticEditorFlags: 0
m_IsActive: 1
--- !u!114 &110794170
MonoBehaviour:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 110794169}
m_Enabled: 1
m_EditorHideFlags: 0
m_Script: {fileID: 11500000, guid: e8645f0028574904969b5802443f100f, type: 3}
m_Name:
m_EditorClassIdentifier: Assembly-CSharp::City3DCamera
pitch: 52
yaw: 45
distance: 26
minDistance: 8
maxDistance: 55
target: {x: 0, y: 0, z: 0}
bounds:
serializedVersion: 2
x: 0
y: 0
width: 48
height: 48
--- !u!114 &110794171
MonoBehaviour:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 110794169}
m_Enabled: 1
m_EditorHideFlags: 0
m_Script: {fileID: 11500000, guid: a79441f348de89743a2939f4d699eac1, type: 3}
m_Name:
m_EditorClassIdentifier: Unity.RenderPipelines.Universal.Runtime::UnityEngine.Rendering.Universal.UniversalAdditionalCameraData
m_RenderShadows: 1
m_RequiresDepthTextureOption: 2
m_RequiresOpaqueTextureOption: 2
m_CameraType: 0
m_Cameras: []
m_RendererIndex: -1
m_VolumeLayerMask:
serializedVersion: 2
m_Bits: 1
m_VolumeTrigger: {fileID: 0}
m_VolumeFrameworkUpdateModeOption: 2
m_RenderPostProcessing: 0
m_Antialiasing: 0
m_AntialiasingQuality: 2
m_StopNaN: 0
m_Dithering: 0
m_ClearDepth: 1
m_AllowXRRendering: 1
m_AllowHDROutput: 1
m_UseScreenCoordOverride: 0
m_ScreenSizeOverride: {x: 0, y: 0, z: 0, w: 0}
m_ScreenCoordScaleBias: {x: 0, y: 0, z: 0, w: 0}
m_RequiresDepthTexture: 0
m_RequiresColorTexture: 0
m_TaaSettings:
m_Quality: 3
m_FrameInfluence: 0.1
m_JitterScale: 1
m_MipBias: 0
m_VarianceClampScale: 0.9
m_ContrastAdaptiveSharpening: 0
m_Version: 2
--- !u!81 &110794172
AudioListener:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 110794169}
m_Enabled: 1
--- !u!20 &110794173
Camera:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 110794169}
m_Enabled: 1
serializedVersion: 2
m_ClearFlags: 2
m_BackGroundColor: {r: 0.55, g: 0.72, b: 0.9, a: 1}
m_projectionMatrixMode: 1
m_GateFitMode: 2
m_FOVAxisMode: 0
m_Iso: 200
m_ShutterSpeed: 0.005
m_Aperture: 16
m_FocusDistance: 10
m_FocalLength: 50
m_BladeCount: 5
m_Curvature: {x: 2, y: 11}
m_BarrelClipping: 0.25
m_Anamorphism: 0
m_SensorSize: {x: 36, y: 24}
m_LensShift: {x: 0, y: 0}
m_NormalizedViewPortRect:
serializedVersion: 2
x: 0
y: 0
width: 1
height: 1
near clip plane: 0.3
far clip plane: 300
field of view: 40
orthographic: 0
orthographic size: 5
m_Depth: 0
m_CullingMask:
serializedVersion: 2
m_Bits: 4294967295
m_RenderingPath: -1
m_TargetTexture: {fileID: 0}
m_TargetDisplay: 0
m_TargetEye: 3
m_HDR: 1
m_AllowMSAA: 1
m_AllowDynamicResolution: 0
m_ForceIntoRT: 0
m_OcclusionCulling: 1
m_StereoConvergence: 10
m_StereoSeparation: 0.022
--- !u!4 &110794174
Transform:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 110794169}
serializedVersion: 2
m_LocalRotation: {x: 0, y: 0, z: 0, w: 1}
m_LocalPosition: {x: 0, y: 0, z: 0}
m_LocalScale: {x: 1, y: 1, z: 1}
m_ConstrainProportionsScale: 0
m_Children: []
m_Father: {fileID: 0}
m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0}
--- !u!1 &1225367365
GameObject:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
serializedVersion: 6
m_Component:
- component: {fileID: 1225367367}
- component: {fileID: 1225367366}
m_Layer: 0
m_Name: City3D
m_TagString: Untagged
m_Icon: {fileID: 0}
m_NavMeshLayer: 0
m_StaticEditorFlags: 0
m_IsActive: 1
--- !u!114 &1225367366
MonoBehaviour:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 1225367365}
m_Enabled: 1
m_EditorHideFlags: 0
m_Script: {fileID: 11500000, guid: 44d822c903694362bef912c055adda5b, type: 3}
m_Name:
m_EditorClassIdentifier: Assembly-CSharp::City3DController
--- !u!4 &1225367367
Transform:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 1225367365}
serializedVersion: 2
m_LocalRotation: {x: 0, y: 0, z: 0, w: 1}
m_LocalPosition: {x: 0, y: 0, z: 0}
m_LocalScale: {x: 1, y: 1, z: 1}
m_ConstrainProportionsScale: 0
m_Children: []
m_Father: {fileID: 0}
m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0}
--- !u!1 &1935466178
GameObject:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
serializedVersion: 6
m_Component:
- component: {fileID: 1935466180}
- component: {fileID: 1935466179}
m_Layer: 0
m_Name: Sun
m_TagString: Untagged
m_Icon: {fileID: 0}
m_NavMeshLayer: 0
m_StaticEditorFlags: 0
m_IsActive: 1
--- !u!108 &1935466179
Light:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 1935466178}
m_Enabled: 1
serializedVersion: 13
m_Type: 1
m_Color: {r: 1, g: 1, b: 1, a: 1}
m_Intensity: 1.2
m_Range: 10
m_SpotAngle: 30
m_InnerSpotAngle: 21.80208
m_CookieSize2D: {x: 0.5, y: 0.5}
m_Shadows:
m_Type: 2
m_Resolution: -1
m_CustomResolution: -1
m_Strength: 1
m_Bias: 0.05
m_NormalBias: 0.4
m_NearPlane: 0.2
m_CullingMatrixOverride:
e00: 1
e01: 0
e02: 0
e03: 0
e10: 0
e11: 1
e12: 0
e13: 0
e20: 0
e21: 0
e22: 1
e23: 0
e30: 0
e31: 0
e32: 0
e33: 1
m_UseCullingMatrixOverride: 0
m_Cookie: {fileID: 0}
m_DrawHalo: 0
m_Flare: {fileID: 0}
m_RenderMode: 0
m_CullingMask:
serializedVersion: 2
m_Bits: 4294967295
m_RenderingLayerMask: 1
m_Lightmapping: 4
m_LightShadowCasterMode: 0
m_AreaSize: {x: 1, y: 1}
m_BounceIntensity: 1
m_ColorTemperature: 6570
m_UseColorTemperature: 0
m_BoundingSphereOverride: {x: 0, y: 0, z: 0, w: 0}
m_UseBoundingSphereOverride: 0
m_UseViewFrustumForShadowCasterCull: 1
m_ForceVisible: 0
m_ShapeRadius: 0.025
m_ShadowAngle: 0
m_LightUnit: 1
m_LuxAtDistance: 1
m_EnableSpotReflector: 1
--- !u!4 &1935466180
Transform:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 1935466178}
serializedVersion: 2
m_LocalRotation: {x: 0.40821794, y: -0.23456973, z: 0.10938166, w: 0.8754261}
m_LocalPosition: {x: 0, y: 0, z: 0}
m_LocalScale: {x: 1, y: 1, z: 1}
m_ConstrainProportionsScale: 0
m_Children: []
m_Father: {fileID: 0}
m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0}
--- !u!1660057539 &9223372036854775807
SceneRoots:
m_ObjectHideFlags: 0
m_Roots:
- {fileID: 110794174}
- {fileID: 1935466180}
- {fileID: 1225367367}
+7
View File
@@ -0,0 +1,7 @@
fileFormatVersion: 2
guid: c1c34719f0605c247a3575d077f3d656
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:
+4
View File
@@ -42,6 +42,9 @@ public class BattleConfig : ScriptableObject
[Tooltip("Доля погибших среди выбывших в каждой точке, десятые доли процента: 700 = 70%. Остальные выбывшие -- раненые")]
public int[] deadSharePermille = { 700, 450, 300, 200 };
[Tooltip("Пропавшие без вести (решения 01.11, п. 5): доля выбывших, десятые доли процента. НЕ УТВЕРЖДЕНА -- 0, механика выключена. Для проверки -- например 150 (15%)")]
public int missingSharePermille = 0;
[Tooltip("Голод: сколько силы остаётся, %. Утверждено 27.10.2026: -50% (остаётся 50). Проверяется на момент боя")]
public int famineStrengthPercent = 50;
[Tooltip("Усталость: сколько силы остаётся, %. Утверждено 27.10.2026: -20% (остаётся 80). Вместе с голодом -- 40%")]
@@ -78,6 +81,7 @@ public class BattleConfig : ScriptableObject
WeakLossMaxPermille = weakLossMaxPermille,
DeadRatioPermille = deadRatioPermille,
DeadSharePermille = deadSharePermille,
MissingSharePermille = Mathf.Clamp(missingSharePermille, 0, 1000),
RandomPercent = Mathf.Max(0, randomPercent),
FamineStrengthPercent = Mathf.Clamp(famineStrengthPercent, 0, 100),
FatigueStrengthPercent = Mathf.Clamp(fatigueStrengthPercent, 0, 100)
+6 -207
View File
@@ -14,9 +14,6 @@ using UnityEngine.UI;
// - Кристалл: где добывается, запас / лимит;
// - Казармы: очередь обучения, какие войска открыты и что нужно для остальных, улучшение (открывает войска),
// кнопка "Нанять войска" (панель "Войска").
// - Стройка и улучшение идут по времени (BuildTimer, 01.11, ТЕСТ): полоска, сколько осталось, ускорение за кристаллы.
// Пока здание строится, в окне только стройка; при улучшении здание работает на старом уровне.
// - У построенных зданий (кроме Ратуши) -- кнопка "Перенести" (бесплатно, уровень и стройка сохраняются). Сноса нет (04.11).
// Без здания (кнопка "Рабочие" в панели DEV и "Все рабочие" в окне) -- список рабочих по всем профессиям.
// Окно -- только интерфейс: рабочих ставит модуль Населения, добычу считает модуль Экономики.
// Пока окно открыто, цифры обновляются сами (раз в полсекунды).
@@ -58,32 +55,19 @@ public static class BuildingWindow
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);
if (focus != null) ActionsBlock(card, focus);
UIKit.Button(card, "Закрыть", UIKit.Theme.Gray, Close, -1, 80, 28);
}
// Стройка закончилась (BuildTimer.Completed): окно этого здания показывает новый уровень
internal static void OnBuildCompleted(BuildTimer timer)
{
if (root != null && timer != null && focus == timer.gameObject) Rebuild();
}
internal static void Tick()
{
if (root == null) return;
@@ -208,26 +192,6 @@ 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}",
@@ -271,47 +235,20 @@ 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);
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; }
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} уровня");
}
townHall.Upgrade();
if (CitySync.Instance != null) CitySync.Instance.SyncNow();
UIKit.Toast($"Ратуша улучшена до {townHall.currentLevel} уровня");
Rebuild();
}, -1, 80, 26);
b.interactable = canPay;
}
// Каких зданий на следующем уровне Ратуши можно построить больше: "Лес 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());
}
// ---------------- Казармы ----------------
static void BarracksSection(RectTransform card, Barracks barracks)
@@ -403,72 +340,6 @@ public static class BuildingWindow
() => hospital.Level = hospital.level + 1, Hospital.MaxBuildLevel);
}
// ---------------- Терем богатыря: окно героя ----------------
// Числа героя -- те же, что в панели "Герой" (GameProgress / GameData), механика не меняется.
static void HeroCastleSection(RectTransform card)
{
var gp = GameProgress.Instance;
UIKit.Text(card, "В Тереме живёт богатырь -- верховный командир. Он гуляет по городу; тапните по нему, чтобы поговорить.",
24, UIKit.Theme.TextDim, TextAlignmentOptions.Center);
if (gp == null) return;
int lvl = gp.Data.heroLevel, maxLvl = gp.HeroMaxLevel;
var head = UIKit.Row(card, 140, 16);
UIKit.Icon(head, new Color(0.55f, 0.4f, 0.2f), "Б", 130); // место под портрет
var info = UIKit.Card(head, new Color(0, 0, 0, 0), 0, 4);
UIKit.Size(info, -1, -1, 1);
UIKit.Text(info, $"Уровень {lvl} / {maxLvl}", 30, UIKit.Theme.Gold).fontStyle = FontStyles.Bold;
UIKit.Text(info, $"Сила героя: {GameData.HeroPower(lvl)}", 24, UIKit.Theme.Text);
UIKit.Text(info, $"Бонус к силе армии: +{Mathf.RoundToInt(gp.ArmyBonus * 100)}%", 24, UIKit.Theme.Text);
Header(card, "Навыки");
for (int i = 0; i < GameData.HeroSkillNames.Length; i++)
{
bool has = gp.HasHeroSkill(i);
UIKit.Text(card, has ? $"<color=#9be59b>✓</color> {GameData.HeroSkillNames[i]}"
: $"{GameData.HeroSkillNames[i]} — с {GameData.HeroSkillLevels[i]} ур.",
22, has ? UIKit.Theme.Text : UIKit.Theme.TextDim);
}
Header(card, "Улучшение героя");
if (lvl >= maxLvl)
{
UIKit.Text(card, $"Предел уровня. Улучшите Ратушу до {gp.TownHallLevel + 1} ур., чтобы поднять предел до " +
$"{GameData.HeroMaxLevel(gp.TownHallLevel + 1)}.", 22, UIKit.Theme.TextDim, TextAlignmentOptions.Center);
}
else
{
var cost = GameData.HeroUpgradeCost(lvl);
Live(card, () =>
{
var parts = new List<string>();
foreach (var c in cost)
{
bool enough = gp.Res(c.type) >= c.amount;
parts.Add($"<color={(enough ? "#9be59b" : "#ff7a7a")}>{GameData.ResName(c.type)} {c.amount:N0}</color>");
}
return "Цена: " + string.Join(", ", parts.ToArray());
}, 22, UIKit.Theme.Text);
UIKit.Text(card, $"Дальше: сила {GameData.HeroPower(lvl)} → {GameData.HeroPower(lvl + 1)}, " +
$"бонус армии +{GameData.HeroArmyBonusPercent(lvl)}% → +{GameData.HeroArmyBonusPercent(lvl + 1)}%",
22, UIKit.Theme.TextDim, TextAlignmentOptions.Center);
var up = UIKit.Button(card, $"Улучшить до {lvl + 1} ур.", UIKit.Theme.Green, () =>
{
if (!gp.CanUpgradeHero) { UIKit.Toast("Не хватает ресурсов"); return; }
gp.UpgradeHero();
Rebuild();
}, -1, 80, 26);
live.Add(() => up.interactable = gp.CanUpgradeHero);
}
UIKit.Button(card, "Подробнее о герое", UIKit.Theme.Blue, () =>
{
Close();
if (UIPanelManager.Instance != null) UIPanelManager.Instance.Open("Hero");
}, -1, 80, 26);
}
// Резерв для найма на уровне Ратуши (те же числа, что у модуля Населения)
static int ReserveAt(int townHallLevel)
{
@@ -501,93 +372,21 @@ 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);
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; }
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} уровня");
}
raiseLevel();
if (CitySync.Instance != null) CitySync.Instance.SyncNow();
UIKit.Toast($"Улучшено до {level + 1} уровня");
Rebuild();
}, -1, 80, 26);
b.interactable = canPay;
}
// ---------------- Стройка по времени (BuildTimer) ----------------
static bool TimerRunning(GameObject building)
{
var t = BuildTimer.Of(building);
return t != null && BuildTimer.All.Contains(t);
}
static void TimeText(RectTransform card, long ms)
{
if (ms <= 0) return;
Live(card, () => $"Время: {GameData.FormatTime(ms / 1000.0)} • строителей свободно: {BuildTimer.FreeBuilders} из {BuildTimer.Builders}",
22, UIKit.Theme.TextDim);
}
// Полоска стройки или улучшения, сколько осталось, ускорение за кристаллы
static void TimerBlock(RectTransform card, GameObject building)
{
var t = BuildTimer.Of(building);
if (t == null || !BuildTimer.All.Contains(t)) return;
Header(card, t.IsConstruction ? "Идёт стройка" : $"Идёт улучшение до {t.targetLevel} ур.");
var bar = UIKit.Bar(card, t.Progress, UIKit.Theme.Gold, 26);
live.Add(() => { if (t != null) UIKit.SetBar(bar, t.Progress); });
Live(card, () => t != null ? "Осталось " + GameData.FormatTime(t.SecondsLeft) : "Готово", 24, UIKit.Theme.Text);
int perGem = Mathf.Max(1, EconomyConfig.Load().construction.secondsPerGem);
Func<int> gems = () => t != null ? Mathf.Max(1, Mathf.CeilToInt((float)t.SecondsLeft / perGem)) : 0;
var speed = UIKit.Button(card, "", UIKit.Theme.Blue, () =>
{
if (t == null) return;
int price = gems();
if (!GameServices.TryGet(out IEconomy e) || !e.TrySpend(ResourceType.Gems, price))
{
UIKit.Toast($"Нужно {price} кристаллов");
return;
}
t.Complete(); // окно перестроит OnBuildCompleted
}, -1, 80, 26);
live.Add(() => { if (t != null) UIKit.SetButton(speed, $"Ускорить за {gems()} кристаллов", true); });
UIKit.SetButton(speed, $"Ускорить за {gems()} кристаллов", true);
}
// ---------------- Перенос (решения 04.11: сноса нет) ----------------
// Здание можно бесплатно переставить в пределах поселения: уровень, стройка или улучшение сохраняются.
// Снести здание и вернуть ресурсы нельзя -- такой кнопки нет.
static void ActionsBlock(RectTransform card, GameObject building)
{
var id = building.GetComponent<PlacedBuildingId>();
if (id == null || string.IsNullOrEmpty(id.buildingName)) return; // Ратуша и стартовые постройки сцены
UIKit.Button(card, "Перенести", UIKit.Theme.Blue, () =>
{
Close();
if (BuildModeController.Instance != null) BuildModeController.Instance.StartMove(building);
}, -1, 80, 26);
}
// Цена: чего хватает -- зелёным, чего нет -- красным, со сколько есть
static string CostText(ResourceAmount[] cost)
{
+1 -4
View File
@@ -5,8 +5,7 @@ using UnityEngine;
// Слой приложения: сообщает модулям, какие здания стоят в городе.
// - рабочие места (Лес, Еда, Камень, Металл, Золото) и их уровни -> модуль добычи (места и эффективность);
// - Дома (здание "Население") -> модуль Населения (предел жилья);
// - Склады и Амбары -> лимиты склада в Экономике и защита ресурсов от грабежа (App/ResourceProtection).
// Здания, которые ещё строятся (BuildTimer), не считаются: они пока не работают.
// - Склады и Амбары -> лимиты склада в Экономике.
// Модули сами здания не ищут: они без Unity и живут и на карте мира, где города нет.
//
// Проверяет город раз в полсекунды и применяет изменения, только когда картина устоялась
@@ -68,7 +67,6 @@ 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)
@@ -114,7 +112,6 @@ 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
@@ -1,40 +0,0 @@
// Стройка и офлайн (решения 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();
}
}
+4 -110
View File
@@ -24,8 +24,11 @@ public class EconomyConfig : ScriptableObject
[Tooltip("Еды в час на одного жителя")]
public float foodPerCivilianPerHour = 0.1f;
[Tooltip("Еды в час на одного воина")]
[Tooltip("Еды в час на одного воина (и новобранца в Казармах), если норма по типам не задана")]
public float foodPerSoldierPerHour = 0.2f;
[Tooltip("Еды в час на воина по типам: пехота, лучники, кавалерия, осадные. Решения 01.11, п. 4: 0,15 / 0,20 / 0,15 / 0,15. " +
"Раненые едят так же, даже ожидая койку. Пусто -- все по foodPerSoldierPerHour")]
public float[] foodPerTroopTypePerHour = { 0.15f, 0.2f, 0.15f, 0.15f };
[Tooltip("Прирост людей в час, если есть еда (документ: 2–3)")]
public float growthPerHour = 2.5f;
@@ -123,48 +126,6 @@ public class EconomyConfig : ScriptableObject
public int[] requiredBarracksLevel = { 1, 2, 3, 5 };
}
[Serializable]
public class ConstructionSettings // время стройки и улучшения, строители (01.11, ТЕСТ)
{
[Tooltip("Сколько строек и улучшений идёт одновременно (строители). ТЕСТ")]
public int builders = 2;
[Tooltip("Постройка нового здания, секунд. ТЕСТ")]
public int buildSeconds = 60;
[Tooltip("Улучшение до уровня 2, 3, 4... (секунд): [0] -- до 2 ур., [1] -- до 3 ур. ... ТЕСТ")]
public int[] upgradeSecondsToLevel = { 60, 120, 300, 600, 1200, 1800, 3600, 5400, 7200 };
[Tooltip("Ратуша строится дольше остальных: множитель времени улучшения. ТЕСТ")]
public float townHallTimeMultiplier = 2f;
[Tooltip("Ускорение: секунд стройки за 1 кристалл. ТЕСТ")]
public int secondsPerGem = 30;
// Сноса зданий нет (решение 04.11): только бесплатный перенос
}
[Serializable]
public class BuildingLimit
{
public string buildingName;
[Tooltip("Сколько таких зданий можно иметь на Ратуше 1, 2, 3 ... уровня. 0 -- нельзя")]
public int[] maxByTownHall;
public BuildingLimit() { }
public BuildingLimit(string name, params int[] max) { buildingName = name; maxByTownHall = max; }
}
[Serializable]
public class ProtectionSettings // защита ресурсов от грабежа (01.11, ТЕСТ)
{
[Tooltip("Защищено без Складов: дерево, камень, металл, золото. ТЕСТ")]
public float baseGoods = 100f;
[Tooltip("+ к защите дерева, камня, металла, золота за каждый уровень Склада. ТЕСТ")]
public float perWarehouseLevel = 200f;
[Tooltip("Защищено без Амбаров: еда. ТЕСТ")]
public float baseFood = 100f;
[Tooltip("+ к защите еды за каждый уровень Амбара. ТЕСТ")]
public float perGranaryLevel = 300f;
[Tooltip("Кристаллы: без технологии «Грабёж» нападающий не уносит ничего (решение 04.11). С ней -- не больше этой доли запаса. ТЕСТ, точные ограничения определят отдельно")]
[Range(0f, 1f)] public float plunderGemsShare = 0.1f;
}
[Serializable]
public class StartResources
{
@@ -198,30 +159,6 @@ public class EconomyConfig : ScriptableObject
[Header("Солдат T1 (этап 5)")]
public SoldierSettings soldier = new SoldierSettings();
[Header("Стройка: время, строители, снос (ТЕСТ)")]
public ConstructionSettings construction = new ConstructionSettings();
[Header("Лимит зданий по уровню Ратуши (ТЕСТ). Здания не из списка -- без лимита")]
public BuildingLimit[] buildingLimits =
{
// Ратуша: 1 2 3 4 5 6 7 8 9 10
new BuildingLimit("Лес", 2, 3, 3, 4, 4, 5, 5, 6, 6, 7),
new BuildingLimit("Еда", 2, 3, 3, 4, 4, 5, 5, 6, 6, 7),
new BuildingLimit("Камень", 2, 2, 3, 3, 4, 4, 5, 5, 6, 6),
new BuildingLimit("Металл", 0, 0, 1, 1, 2, 2, 3, 3, 4, 4),
new BuildingLimit("Золото", 0, 1, 1, 2, 2, 3, 3, 3, 4, 4),
new BuildingLimit("Население", 3, 4, 5, 6, 7, 8, 9, 10, 11, 12),
new BuildingLimit("Склад", 1, 1, 2, 2, 2, 3, 3, 3, 4, 4),
new BuildingLimit("Амбар", 1, 1, 2, 2, 2, 3, 3, 3, 4, 4),
new BuildingLimit("Казармы", 1, 1, 2, 2, 3, 3, 3, 3, 3, 3),
new BuildingLimit("Госпиталь", 1, 1, 1, 1, 1, 1, 1, 1, 1, 1),
new BuildingLimit("Терем богатыря", 1, 1, 1, 1, 1, 1, 1, 1, 1, 1),
new BuildingLimit("Кристалл", 0, 0, 0, 0, 0, 1, 1, 1, 1, 1),
};
[Header("Защита ресурсов от грабежа (ТЕСТ)")]
public ProtectionSettings protection = new ProtectionSettings();
// ---------------- Загрузка ----------------
static EconomyConfig cached;
@@ -255,49 +192,6 @@ public class EconomyConfig : ScriptableObject
}
}
// Сколько зданий с таким именем можно иметь на этом уровне Ратуши. -1 -- без лимита (здания нет в списке)
public int BuildingLimitFor(string buildingName, int townHallLevel)
{
if (buildingLimits == null || string.IsNullOrEmpty(buildingName)) return -1;
foreach (var l in buildingLimits)
{
if (l == null || l.buildingName != buildingName || l.maxByTownHall == null || l.maxByTownHall.Length == 0) continue;
return Mathf.Max(0, l.maxByTownHall[Mathf.Clamp(townHallLevel, 1, l.maxByTownHall.Length) - 1]);
}
return -1;
}
// Время стройки (targetLevel = 1) или улучшения до targetLevel, мс. Ратуша -- с множителем
public long ConstructionMs(bool townHall, int targetLevel)
{
var c = construction;
double seconds;
if (targetLevel <= 1) seconds = c.buildSeconds;
else
{
var t = c.upgradeSecondsToLevel;
seconds = t == null || t.Length == 0 ? c.buildSeconds : t[Mathf.Clamp(targetLevel - 2, 0, t.Length - 1)];
}
if (townHall) seconds *= Mathf.Max(0.01f, c.townHallTimeMultiplier);
return (long)(Math.Max(0, seconds) * 1000.0);
}
// Сколько ресурса не отнять при грабеже: часть запаса, которую прячут Склады и Амбары
public float GetProtected(ResourceType type, int warehouseLevels, int granaryLevels)
{
var p = protection;
switch (type)
{
case ResourceType.Wood:
case ResourceType.Stone:
case ResourceType.Iron:
case ResourceType.Gold: return p.baseGoods + p.perWarehouseLevel * warehouseLevels;
case ResourceType.Food: return p.baseFood + p.perGranaryLevel * granaryLevels;
case ResourceType.Gems: return float.PositiveInfinity; // без «Грабежа» кристаллы не грабятся (с ним -- ResourceProtection)
default: return 0f;
}
}
// Цена улучшения здания с уровня level на level + 1
public ResourceAmount[] UpgradeCost(int level)
{
+15 -31
View File
@@ -42,7 +42,7 @@ public static class GameModules
EventBus.Subscribe<GameResetEvent>(_ => TestObservation());
// Армия -- чистый C#, единственное место хранения войск (секция "army")
var army = new ArmyService(marchConfig.baseMarchSlots, HospitalConfig.Load().ToRules()); // госпиталь (решения 31.10, п. 7)
var army = new ArmyService(marchConfig.baseMarchSlots, HospitalConfig.Load().ToRules(), HospitalConfig.Load().ToMissingRules()); // госпиталь, пропавшие
GameServices.Register<IArmy>(army);
SaveRegistry.Register(army);
EventBus.Subscribe<TroopsChangedEvent>(_ => { if (GameProgress.HasInstance) GameProgress.Instance.MarkChanged(); });
@@ -50,6 +50,18 @@ public static class GameModules
// Очередь обучения -- у Армии (решения 31.10, п. 8): статистика заданий по её событию, старая очередь -- переносится
EventBus.Subscribe<TroopsTrainedEvent>(e => { if (GameProgress.HasInstance) GameProgress.Instance.OnTroopsTrained(e); });
EventBus.Subscribe<GameLoadedEvent>(_ => MigrateLegacyTraining(army));
// Пропавшие без вести вернулись / списаны (решения 01.11, п. 5)
EventBus.Subscribe<MissingReturnedEvent>(e =>
{
if (e.Healthy + e.Wounded > 0)
UIKit.Toast($"Вернулись пропавшие без вести: {e.Healthy + e.Wounded}" + (e.Wounded > 0 ? $" (ранены {e.Wounded} -- в госпиталь)" : ""));
if (GameProgress.HasInstance) GameProgress.Instance.MarkChanged();
});
EventBus.Subscribe<MissingWrittenOffEvent>(e =>
{
if (e.Count > 0) UIKit.Toast($"Не вернулись и списаны пропавшие без вести: {e.Count}");
if (GameProgress.HasInstance) GameProgress.Instance.MarkChanged();
});
// Раненые вылечены -- вернулись в город
EventBus.Subscribe<SoldiersHealedEvent>(e =>
{
@@ -80,18 +92,6 @@ 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;
// Защита ресурсов от грабежа (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,9 +115,6 @@ 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);
@@ -127,21 +124,6 @@ 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()
@@ -239,6 +221,8 @@ public static class GameModules
StartSoldiers = p.startSoldiers,
FoodPerCivilianPerHour = p.foodPerCivilianPerHour,
FoodPerSoldierPerHour = p.foodPerSoldierPerHour,
FoodPerTroopTypePerHour = p.foodPerTroopTypePerHour != null && p.foodPerTroopTypePerHour.Length > 0
? System.Array.ConvertAll(p.foodPerTroopTypePerHour, f => (double)f) : null,
GrowthPerHour = p.growthPerHour,
HousingBase = p.housingBase,
HousingPerTownHallLevel = p.housingPerTownHallLevel,
+21
View File
@@ -33,6 +33,16 @@ public class HospitalConfig : ScriptableObject
[Tooltip("Золота за воина: пехота, лучники, кавалерия, осадные")]
public int[] healGoldPerSoldier = { 1, 1, 2, 3 };
[Header("Пропавшие без вести (решения 01.11, п. 5). Долю пропавших в бою задаёт BattleConfig.missingSharePermille")]
[Tooltip("Сколько пропавших вернётся, %. Утверждён диапазон 10-90%, формула НЕ утверждена -- ТЕСТ")]
public int missingReturnPercent = 50;
[Tooltip("Из вернувшихся ранены, %. Утверждено: 30 (70% здоровыми)")]
public int missingWoundedPercent = 30;
[Tooltip("Окно возвращения после обратного пути, ИГРОВЫХ часов. Утверждено: до 6")]
public int missingWindowGameHours = 6;
[Tooltip("Возвращаются группами: от 2 до стольких. Утверждено: 2-4")]
public int missingMaxGroups = 4;
static HospitalConfig cached;
public static HospitalConfig Load()
@@ -47,6 +57,17 @@ public class HospitalConfig : ScriptableObject
return cached;
}
public MissingRules ToMissingRules()
{
return new MissingRules
{
ReturnPercent = Mathf.Clamp(missingReturnPercent, 0, 100),
WoundedPercent = Mathf.Clamp(missingWoundedPercent, 0, 100),
WindowGameHours = Mathf.Max(0, missingWindowGameHours),
MaxGroups = Mathf.Max(2, missingMaxGroups)
};
}
public HospitalRules ToRules()
{
var hours = new int[TroopType.Count];
-33
View File
@@ -1,33 +0,0 @@
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));
}
}
@@ -1,2 +0,0 @@
fileFormatVersion: 2
guid: cea38890d8f5d8d4f804c095c4c113b4
+8
View File
@@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: efa023adb1ce408c971a4f76ced19585
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:
+106
View File
@@ -0,0 +1,106 @@
using UnityEngine;
// КАМЕРА 3D-ПОСЕЛЕНИЯ (2.5D): наклонный вид под фиксированным углом, без вращения.
// Перемещение -- перетаскиванием (палец / мышь), приближение -- двумя пальцами или колесом.
// Не уходит за пределы полезной карты. FocusOn -- плавно показать область (открытие новой зоны).
public class City3DCamera : MonoBehaviour
{
public float pitch = 52f; // наклон, градусы от горизонта
public float yaw = 45f; // поворот вида вокруг вертикали
public float distance = 26f;
public float minDistance = 8f;
public float maxDistance = 55f;
public Vector3 target; // точка на земле, на которую смотрит камера
public Rect bounds = new Rect(0, 0, 48, 48); // где может быть target (x, z)
Camera cam;
Vector3 focusTarget;
float focusDistance;
bool focusing;
Vector3 lastPointer;
bool panning;
float lastPinch;
// Перемещение камеры пальцем можно выключить на время перетаскивания здания
public bool AllowPan { get; set; } = true;
void Awake()
{
cam = GetComponent<Camera>();
}
public void FocusOn(Rect area)
{
focusTarget = new Vector3(area.center.x, 0, area.center.y);
// отойти так, чтобы область поместилась по высоте экрана (с запасом)
float size = Mathf.Max(area.width, area.height);
focusDistance = Mathf.Clamp(size * 1.15f, minDistance, maxDistance);
focusing = true;
}
// Начать / продолжить / закончить перемещение камеры. Зовёт контроллер ввода (он решает, что под пальцем)
public void BeginPan(Vector3 screen) { panning = AllowPan; lastPointer = screen; focusing = false; }
public void EndPan() { panning = false; }
public void Pan(Vector3 screen)
{
if (!panning || cam == null) return;
Vector3 a = GroundPoint(lastPointer), b = GroundPoint(screen);
target += a - b;
lastPointer = screen;
Clamp();
}
public void Zoom(float delta)
{
focusing = false;
distance = Mathf.Clamp(distance * (1f - delta), minDistance, maxDistance);
}
void Update()
{
// Колесо мыши (редактор, ПК)
float wheel = Input.mouseScrollDelta.y;
if (Mathf.Abs(wheel) > 0.01f) Zoom(wheel * 0.1f);
// Два пальца -- приближение
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; }
}
else lastPinch = 0;
if (focusing)
{
target = Vector3.Lerp(target, focusTarget, Time.unscaledDeltaTime * 2.5f);
distance = Mathf.Lerp(distance, focusDistance, Time.unscaledDeltaTime * 2.5f);
if (Vector3.Distance(target, focusTarget) < 0.05f && Mathf.Abs(distance - focusDistance) < 0.05f) focusing = false;
}
Apply();
}
void Clamp()
{
target.x = Mathf.Clamp(target.x, bounds.xMin, bounds.xMax);
target.z = Mathf.Clamp(target.z, bounds.yMin, bounds.yMax);
target.y = 0;
}
public void Apply()
{
Quaternion rot = Quaternion.Euler(pitch, yaw, 0);
transform.rotation = rot;
transform.position = target - rot * Vector3.forward * distance;
}
// Точка на плоскости земли (y = 0) под экранной точкой
Vector3 GroundPoint(Vector3 screen)
{
Ray ray = cam.ScreenPointToRay(screen);
var plane = new Plane(Vector3.up, Vector3.zero);
return plane.Raycast(ray, out float t) ? ray.GetPoint(t) : target;
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: e8645f0028574904969b5802443f100f
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
+755
View File
@@ -0,0 +1,755 @@
using System.Collections.Generic;
using Bogatyri.Settlement.Placement;
using TMPro;
using UnityEngine;
// 3D-ПОСЕЛЕНИЕ, ЭТАП I -- ПРОТОТИП НА ПРИМИТИВАХ (ТЗ «Система строительства поселения» от 04.11.2026).
// Сцена Assets/Scenes/City3D.unity (создаётся: Tools -> Bogatyri -> 3D-город: создать сцену City3D).
//
// Что умеет:
// - местность с холмами, рекой, скалами, деревьями, валунами и травой (City3DLayout);
// - невидимая сетка: видна только в режиме стройки / редактирования вместе с границей открытой зоны;
// - новое здание: выбрать тип -> призрак-куб тянется пальцем, привязан к клеткам, зелёный / красный с причиной,
// поворот 0/90/180/270, «Поставить» / «Отмена»;
// - перенос: кнопка в меню здания или общий режим редактирования; то же здание (тот же id), подтверждение / отмена;
// - расчистка дерева / валуна (без ожидания и цены); трава прячется под зданием и возвращается;
// - зоны по уровню Ратуши (кнопки «Ратуша -/+» -- ТОЛЬКО ДЛЯ ТЕСТА), камера плавно показывает новую зону.
// Правила места -- в модуле Поселения (Bogatyri.Settlement.Placement, тесты PlacementTests), здесь только показ и ввод.
//
// Пока НЕ подключено (этапы II-III): цена и таймер стройки, Экономика и награда за расчистку, сохранение,
// дороги, настоящие модели. Кнопки -- временные (IMGUI); окна согласуются с Володей.
public class City3DController : MonoBehaviour
{
// Тип здания для прототипа: максимальный габарит (клеток) с первого уровня, высота куба, цвет
struct BuildingType
{
public string Name;
public int W, H;
public float Height;
public Color Color;
}
static readonly BuildingType[] Types =
{
new BuildingType { Name = "Ратуша", W = 4, H = 4, Height = 3.0f, Color = new Color(0.85f, 0.75f, 0.35f) },
new BuildingType { Name = "Дом", W = 2, H = 2, Height = 1.4f, Color = new Color(0.85f, 0.55f, 0.35f) },
new BuildingType { Name = "Ферма", W = 3, H = 3, Height = 1.0f, Color = new Color(0.9f, 0.85f, 0.4f) },
new BuildingType { Name = "Лесопилка", W = 3, H = 2, Height = 1.5f, Color = new Color(0.55f, 0.4f, 0.25f) },
new BuildingType { Name = "Каменоломня", W = 3, H = 2, Height = 1.3f, Color = new Color(0.6f, 0.6f, 0.62f) },
new BuildingType { Name = "Шахта", W = 2, H = 2, Height = 1.5f, Color = new Color(0.4f, 0.45f, 0.55f) },
new BuildingType { Name = "Склад", W = 2, H = 3, Height = 1.8f, Color = new Color(0.65f, 0.5f, 0.3f) },
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) },
};
enum Mode { View, Edit, Place, Move }
City3DLayout layout;
SettlementMap map;
City3DCamera cameraRig;
Camera cam;
Material baseMaterial;
Texture2D groundTexture;
const int PixelsPerCell = 8;
readonly Dictionary<string, GameObject> buildingViews = new Dictionary<string, GameObject>();
readonly Dictionary<string, int> buildingType = new Dictionary<string, int>();
readonly Dictionary<string, GameObject> obstacleViews = new Dictionary<string, GameObject>();
readonly List<GameObject> grassViews = new List<GameObject>();
int nextBuildingId = 1;
int townHallLevel = 1;
Mode mode = Mode.View;
int placingType = -1; // Place: какой тип ставим
string movingId; // Move: какое здание переносим
int ghostX, ghostY, ghostRot;
PlacedRect moveOriginal; // Move: последнее подтверждённое положение
GameObject ghost, footprint;
Material okMaterial, badMaterial;
PlaceError ghostError;
int ghostFoundation;
bool draggingGhost;
Vector3 pointerDown;
bool pointerMoved, pointerOverGui;
string selectedBuilding, selectedObstacle; // меню здания / препятствия (тап в обычном режиме)
string message = "";
float messageUntil;
// ---------------- Построение сцены ----------------
void Start()
{
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);
cameraRig.Apply();
Map3D.SetUpLook(cam, 30f, 85f); // солнце, небо, дымка вдали
layout = City3DLayout.Create();
map = layout.Map;
BuildGround();
BuildObstacles();
BuildGrass();
// Ратуша стоит в центре с самого начала
int c = City3DLayout.Size / 2;
PlaceNew(0, c - 2, c - 2, 0);
RefreshGround();
}
static Material Mat(Color color) => Map3D.Mat(color);
void BuildGround()
{
int n = City3DLayout.Size;
var verts = new Vector3[(n + 1) * (n + 1)];
var uvs = new Vector2[verts.Length];
for (int z = 0; z <= n; z++)
for (int x = 0; x <= n; x++)
{
int i = z * (n + 1) + x;
verts[i] = new Vector3(x, City3DLayout.HeightAt(x, z), z);
uvs[i] = new Vector2(x / (float)n, z / (float)n);
}
var tris = new int[n * n * 6];
int t = 0;
for (int z = 0; z < n; z++)
for (int x = 0; x < n; x++)
{
int i = z * (n + 1) + x;
tris[t++] = i; tris[t++] = i + n + 1; tris[t++] = i + 1;
tris[t++] = i + 1; tris[t++] = i + n + 1; tris[t++] = i + n + 2;
}
var mesh = new Mesh { name = "City3D_Ground", indexFormat = UnityEngine.Rendering.IndexFormat.UInt32 };
mesh.vertices = verts;
mesh.uv = uvs;
mesh.triangles = tris;
mesh.RecalculateNormals();
var go = new GameObject("Ground");
go.transform.SetParent(transform, false);
go.AddComponent<MeshFilter>().sharedMesh = mesh;
groundTexture = new Texture2D(n * PixelsPerCell, n * PixelsPerCell, TextureFormat.RGBA32, false)
{
filterMode = FilterMode.Point,
wrapMode = TextureWrapMode.Clamp
};
go.AddComponent<MeshRenderer>().sharedMaterial = Map3D.TexturedMat(groundTexture);
go.AddComponent<MeshCollider>().sharedMesh = mesh;
// Лес и луга за краем участка: камера не видит пустого неба
Map3D.Block(transform, "OuterLand", PrimitiveType.Cube, new Color(0.24f, 0.4f, 0.22f), new Vector3(n / 2f, -0.7f, n / 2f),
new Vector2(n + 200f, n + 200f), 0.1f);
var forest = new System.Random(7);
for (int i = 0; i < 140; i++)
{
// кольцо деревьев вокруг участка
float angle = (float)forest.NextDouble() * Mathf.PI * 2f, r = n * 0.72f + (float)forest.NextDouble() * 14f;
var p = new Vector3(n / 2f + Mathf.Cos(angle) * r, -0.6f, n / 2f + Mathf.Sin(angle) * r);
if (p.x > 0 && p.x < n && p.z > 0 && p.z < n) continue; // внутри участка -- свои деревья
Map3D.Tree(transform, "ForestTree", p, 1.4f + (float)forest.NextDouble() * 0.8f, forest.Next(6));
}
// Вода -- плоскость над руслом
var water = GameObject.CreatePrimitive(PrimitiveType.Cube);
water.name = "River";
Destroy(water.GetComponent<Collider>());
water.transform.SetParent(transform, false);
water.transform.position = new Vector3(37f, -0.25f, n / 2f);
water.transform.localScale = new Vector3(9f, 0.05f, n);
water.GetComponent<Renderer>().sharedMaterial = Mat(new Color(0.3f, 0.58f, 0.85f));
}
void BuildObstacles()
{
int i = 0;
foreach (var o in layout.Obstacles)
{
Vector2 center = Vector2.zero;
foreach (var c in o.Cells) center += new Vector2(c.x + 0.5f, c.y + 0.5f);
center /= o.Cells.Count;
var ground = new Vector3(center.x, City3DLayout.HeightAt(center.x, center.y), center.y);
i++;
var view = o.IsTree
? Map3D.Tree(transform, o.Id, ground, 1.0f + (i * 37 % 10) * 0.05f, i) // ели и лиственные вперемешку
: Map3D.Rock(transform, o.Id, ground, o.Cells.Count > 1 ? 1.7f : 0.85f, i);
obstacleViews[o.Id] = view.gameObject;
}
}
// Трава кустиками -- только украшение: прячется под зданием и возвращается
void BuildGrass()
{
Color[] greens = { new Color(0.3f, 0.6f, 0.25f), new Color(0.38f, 0.66f, 0.28f), new Color(0.45f, 0.68f, 0.3f) };
for (int i = 0; i < layout.Grass.Count; i++)
{
Vector2 p = layout.Grass[i];
var g = Map3D.Shape(transform, "Grass", Map3D.Cone, greens[i % greens.Length],
new Vector3(p.x, City3DLayout.HeightAt(p.x, p.y), p.y), Vector2.one * 0.28f, 0.22f + (i % 3) * 0.06f).gameObject;
grassViews.Add(g);
}
}
static GameObject Part(GameObject parent, PrimitiveType type, Vector3 localPos, Vector3 scale, Material mat)
{
var p = GameObject.CreatePrimitive(type);
Object.Destroy(p.GetComponent<Collider>());
p.transform.SetParent(parent.transform, false);
p.transform.localPosition = localPos;
p.transform.localScale = scale;
p.GetComponent<Renderer>().sharedMaterial = mat;
return p;
}
// Текстура земли: обычная -- трава, скалы, русло; в режиме стройки -- клетки, открытая зона и следующая
void RefreshGround()
{
int n = City3DLayout.Size, px = PixelsPerCell;
bool grid = mode != Mode.View;
var pixels = new Color32[n * px * n * px];
for (int y = 0; y < n; y++)
for (int x = 0; x < n; x++)
{
CellKind k = map.KindAt(x, y);
bool shore = k == CellKind.Ground && (map.KindAt(x - 1, y) == CellKind.Water || map.KindAt(x + 1, y) == CellKind.Water
|| map.KindAt(x, y - 1) == CellKind.Water || map.KindAt(x, y + 1) == CellKind.Water);
float hm = map.HeightAt(x, y) / 1000f;
Color c = k == CellKind.Water ? new Color(0.32f, 0.5f, 0.72f)
: k == CellKind.Mountain ? Color.Lerp(new Color(0.48f, 0.45f, 0.42f), new Color(0.85f, 0.86f, 0.88f), Mathf.Clamp01((hm - 3.2f) / 1.5f)) // снег на вершинах
: shore ? new Color(0.78f, 0.72f, 0.52f) // песчаный берег
: Color.Lerp(new Color(0.33f, 0.56f, 0.27f), new Color(0.45f, 0.66f, 0.32f), Mathf.PerlinNoise(x * 0.25f, y * 0.25f));
if (grid && k == CellKind.Ground)
{
int zone = map.ZoneAt(x, y);
if (zone > 0 && zone <= townHallLevel) c = Color.Lerp(c, new Color(0.6f, 0.95f, 0.6f), 0.25f); // открыто
else if (zone == townHallLevel + 1) c = Color.Lerp(c, new Color(0.95f, 0.85f, 0.4f), 0.3f); // откроется следующим
else c *= 0.55f; // закрыто
}
bool open = map.IsOpen(x, y, townHallLevel);
for (int iy = 0; iy < px; iy++)
for (int ix = 0; ix < px; ix++)
{
Color p = c;
if (grid && (ix == 0 || iy == 0)) p *= 0.85f; // линии клеток
// граница открытой зоны -- светлая линия
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)))
p = new Color(1f, 1f, 1f);
p.a = 1;
pixels[(y * px + iy) * n * px + x * px + ix] = p;
}
}
groundTexture.SetPixels32(pixels);
groundTexture.Apply(false);
}
// ---------------- Здания ----------------
bool PlaceNew(int type, int x, int y, int rot)
{
var t = Types[type];
string id = "b_" + nextBuildingId;
PlaceError e = map.Place(id, t.Name, x, y, t.W, t.H, rot, townHallLevel);
if (e != PlaceError.None) { Say(SettlementMap.ErrorText(e)); return false; }
nextBuildingId++;
buildingType[id] = type;
buildingViews[id] = CreateBuildingView(id, type);
UpdateBuildingView(id);
UpdateGrass();
return true;
}
// Кровля по типу здания
static Color RoofFor(int type)
{
switch (Types[type].Name)
{
case "Ратуша": return new Color(0.65f, 0.15f, 0.12f);
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);
default: return Color.Lerp(Types[type].Color, new Color(0.3f, 0.15f, 0.1f), 0.55f);
}
}
// Здание на примитивах: стены и двускатная крыша в «единичном» объёме Body (растягивается под габарит и высоту),
// у некоторых -- свои приметы: башня Ратуши, флаг Казарм, крест Госпиталя, тёмный вход Шахты
GameObject CreateBuildingView(string id, int type)
{
var t = Types[type];
var root = new GameObject(id + "_" + t.Name);
root.transform.SetParent(transform, false);
var body = new GameObject("Body").transform;
body.SetParent(root.transform, false);
var hit = body.gameObject.AddComponent<BoxCollider>(); // тап по зданию попадает в него целиком
hit.center = new Vector3(0, 0.5f, 0);
hit.size = Vector3.one;
Color walls = Color.Lerp(t.Color, Color.white, 0.3f), roofColor = RoofFor(type);
Unit(body, PrimitiveType.Cube, new Vector3(0, 0.31f, 0), new Vector3(1f, 0.62f, 1f), walls);
var top = Map3D.Shape(body, "Roof", Map3D.Roof, roofColor, Vector3.zero, new Vector2(1.08f, 1.12f), 0.38f).transform;
top.localPosition = new Vector3(0, 0.62f, 0);
switch (t.Name)
{
case "Ратуша":
Unit(body, PrimitiveType.Cube, new Vector3(0.3f, 0.7f, 0.3f), new Vector3(0.28f, 1.4f, 0.28f), walls);
var spire = Map3D.Shape(body, "Spire", Map3D.Pyramid, roofColor, Vector3.zero, Vector2.one * 0.4f, 0.4f, 45f).transform;
spire.localPosition = new Vector3(0.3f, 1.4f, 0.3f);
break;
case "Казармы":
Unit(body, PrimitiveType.Cylinder, new Vector3(-0.4f, 0.75f, 0.4f), new Vector3(0.03f, 0.75f, 0.03f), new Color(0.35f, 0.25f, 0.15f));
Unit(body, PrimitiveType.Cube, new Vector3(-0.32f, 1.35f, 0.4f), new Vector3(0.16f, 0.12f, 0.01f), new Color(0.8f, 0.15f, 0.15f));
break;
case "Госпиталь":
Unit(body, PrimitiveType.Cube, new Vector3(0, 0.38f, -0.505f), new Vector3(0.08f, 0.28f, 0.01f), new Color(0.85f, 0.1f, 0.1f));
Unit(body, PrimitiveType.Cube, new Vector3(0, 0.38f, -0.505f), new Vector3(0.24f, 0.08f, 0.01f), new Color(0.85f, 0.1f, 0.1f));
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));
break;
case "Ферма":
// грядки перед амбаром
for (int i = 0; i < 3; i++)
Unit(body, PrimitiveType.Cube, new Vector3(-0.3f + i * 0.3f, 0.02f, -0.62f), new Vector3(0.22f, 0.04f, 0.2f), new Color(0.55f, 0.75f, 0.25f));
break;
}
var door = Part(root, PrimitiveType.Cube, Vector3.zero, Vector3.one, Mat(new Color(0.28f, 0.18f, 0.1f)));
door.name = "Door"; // показывает, куда повёрнуто здание
var foundation = Part(root, PrimitiveType.Cube, Vector3.zero, Vector3.one, Mat(new Color(0.62f, 0.58f, 0.52f)));
foundation.name = "Foundation";
var labelGo = new GameObject("Label");
labelGo.transform.SetParent(root.transform, false);
var label = labelGo.AddComponent<TextMeshPro>();
label.text = t.Name;
label.fontSize = 4;
label.alignment = TextAlignmentOptions.Center;
label.color = Color.white;
label.outlineWidth = 0.2f;
label.outlineColor = Color.black;
label.rectTransform.sizeDelta = new Vector2(8, 2);
return root;
}
// Деталь в единичном объёме здания (Body растягивается под габарит и высоту)
static void Unit(Transform body, PrimitiveType type, Vector3 localPos, Vector3 scale, Color color)
{
var p = GameObject.CreatePrimitive(type);
Destroy(p.GetComponent<Collider>());
p.transform.SetParent(body, false);
p.transform.localPosition = localPos;
p.transform.localScale = scale;
p.GetComponent<Renderer>().sharedMaterial = Mat(color);
}
// Положение, поворот, фундамент по данным карты (или призрака при переносе)
void UpdateBuildingView(string id) { if (map.TryGetBuilding(id, out var r)) LayoutView(buildingViews[id], buildingType[id], r.X, r.Y, r.Rotation); }
void LayoutView(GameObject root, int type, int x, int y, int rot)
{
var t = Types[type];
bool swap = rot == 90 || rot == 270;
int w = swap ? t.H : t.W, h = swap ? t.W : t.H;
int min = int.MaxValue, max = int.MinValue;
for (int cy = y; cy < y + h; cy++)
for (int cx = x; cx < x + w; cx++) { int v = map.HeightAt(cx, cy); min = Mathf.Min(min, v); max = Mathf.Max(max, v); }
if (min == int.MaxValue) { min = 0; max = 0; }
float top = max / 1000f, bottom = min / 1000f - 0.3f;
root.transform.position = new Vector3(x + w / 2f, top, y + h / 2f);
// Body -- в размерах без поворота и повёрнут сам: конёк крыши и приметы поворачиваются вместе со зданием
var body = root.transform.Find("Body");
body.localScale = new Vector3(t.W - 0.3f, t.Height, t.H - 0.3f);
body.localPosition = Vector3.zero;
body.localRotation = Quaternion.Euler(0, -rot, 0);
var foundation = root.transform.Find("Foundation");
foundation.localScale = new Vector3(w - 0.1f, top - bottom, h - 0.1f);
foundation.localPosition = new Vector3(0, (bottom - top) / 2f, 0);
// дверь -- на «переднюю» сторону: 0 -- юг, 90 -- запад, 180 -- север, 270 -- восток
var door = root.transform.Find("Door");
Vector3 dir = Quaternion.Euler(0, -rot, 0) * Vector3.back;
float halfDepth = (rot == 0 || rot == 180 ? h : w) / 2f - 0.15f;
door.localPosition = dir * halfDepth + Vector3.up * 0.4f;
door.localScale = rot == 0 || rot == 180 ? new Vector3(0.6f, 0.8f, 0.1f) : new Vector3(0.1f, 0.8f, 0.6f);
var label = root.transform.Find("Label");
label.localPosition = new Vector3(0, t.Height + 0.8f, 0);
}
void UpdateGrass()
{
for (int i = 0; i < grassViews.Count; i++)
{
Vector2 p = layout.Grass[i];
bool hidden = map.BuildingAt((int)p.x, (int)p.y) != null;
// и под призраком при переносе: прежнее место -- трава вернётся только после подтверждения
if (!hidden && mode == Mode.Move && movingId != null && map.TryGetBuilding(movingId, out var r))
hidden = (int)p.x >= r.X && (int)p.x < r.X + r.W && (int)p.y >= r.Y && (int)p.y < r.Y + r.H;
grassViews[i].SetActive(!hidden);
}
}
// ---------------- Призрак: новое здание или перенос ----------------
void StartPlacing(int type)
{
CancelGhost();
mode = Mode.Place;
placingType = type;
var t = Types[type];
ghost = CreateBuildingView("ghost", type);
ghostRot = 0;
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);
EnsureFootprint();
RefreshGround();
UpdateGhost();
}
void StartMoving(string id)
{
if (!map.TryGetBuilding(id, out var r)) return;
CancelGhost();
mode = Mode.Move;
movingId = id;
moveOriginal = r;
ghostX = r.X; ghostY = r.Y; ghostRot = r.Rotation;
ghost = buildingViews[id]; // переносится ТО ЖЕ здание -- новое не создаётся
EnsureFootprint();
RefreshGround();
UpdateGhost();
}
void EnsureFootprint()
{
if (footprint != null) return;
footprint = GameObject.CreatePrimitive(PrimitiveType.Cube);
footprint.name = "Footprint";
Destroy(footprint.GetComponent<Collider>());
footprint.transform.SetParent(transform, false);
}
int GhostType => mode == Mode.Move ? buildingType[movingId] : placingType;
void UpdateGhost()
{
if (ghost == null) return;
var t = Types[GhostType];
ghostError = map.Validate(ghostX, ghostY, t.W, t.H, ghostRot, townHallLevel, mode == Mode.Move ? movingId : null, out ghostFoundation);
LayoutView(ghost, GhostType, ghostX, ghostY, ghostRot);
bool swap = ghostRot == 90 || ghostRot == 270;
int w = swap ? t.H : t.W, h = swap ? t.W : t.H;
float top = ghost.transform.position.y;
footprint.transform.position = new Vector3(ghostX + w / 2f, top + 0.03f, ghostY + h / 2f);
footprint.transform.localScale = new Vector3(w, 0.06f, h);
if (okMaterial == null) { okMaterial = Mat(new Color(0.3f, 0.95f, 0.35f)); badMaterial = Mat(new Color(0.95f, 0.25f, 0.2f)); }
footprint.GetComponent<Renderer>().sharedMaterial = ghostError == PlaceError.None ? okMaterial : badMaterial;
UpdateGrass();
}
void ConfirmGhost()
{
if (ghostError != PlaceError.None) { Say(SettlementMap.ErrorText(ghostError)); return; }
if (mode == Mode.Place)
{
int type = placingType;
int x = ghostX, y = ghostY, rot = ghostRot;
CancelGhost();
if (PlaceNew(type, x, y, rot)) Say(Types[type].Name + " построено (прототип: без цены и таймера)");
}
else if (mode == Mode.Move)
{
// Подтверждение -- одно изменение: позиция и поворот того же здания; ошибка -- всё как было
PlaceError e = map.Move(movingId, ghostX, ghostY, ghostRot, townHallLevel);
string id = movingId;
if (e != PlaceError.None) { Say(SettlementMap.ErrorText(e)); return; }
movingId = null;
ghost = null;
mode = Mode.Edit;
UpdateBuildingView(id);
Say("Перенесено. Здание то же: уровень, рабочие и таймеры не меняются");
FinishGhost();
}
}
// Отмена: новое -- не создаётся; перенос -- здание возвращается на последнее подтверждённое место
void CancelGhost()
{
if (mode == Mode.Place && ghost != null) Destroy(ghost);
if (mode == Mode.Move && movingId != null) UpdateBuildingView(movingId);
bool wasMove = mode == Mode.Move;
ghost = null;
movingId = null;
placingType = -1;
mode = wasMove ? Mode.Edit : Mode.View;
FinishGhost();
}
void FinishGhost()
{
if (footprint != null) { Destroy(footprint); footprint = null; }
draggingGhost = false;
cameraRig.AllowPan = true;
UpdateGrass();
RefreshGround();
}
// ---------------- Ввод ----------------
void Update()
{
if (cam == null) return;
bool down, held, up;
Vector3 pos;
if (Input.touchCount > 0)
{
if (Input.touchCount > 1) { cameraRig.EndPan(); draggingGhost = false; return; } // два пальца -- зум камеры
Touch touch = Input.GetTouch(0);
pos = touch.position;
down = touch.phase == TouchPhase.Began;
up = touch.phase == TouchPhase.Ended || touch.phase == TouchPhase.Canceled;
held = !down && !up;
}
else
{
pos = Input.mousePosition;
down = Input.GetMouseButtonDown(0);
up = Input.GetMouseButtonUp(0);
held = Input.GetMouseButton(0) && !down;
}
if (down)
{
pointerOverGui = OverGui(pos);
if (pointerOverGui) return;
pointerDown = pos;
pointerMoved = false;
draggingGhost = (mode == Mode.Place || mode == Mode.Move) && CellUnder(pos, true, out int cx, out int cy) && InsideGhost(cx, cy);
cameraRig.AllowPan = !draggingGhost;
cameraRig.BeginPan(pos);
}
else if (held && !pointerOverGui)
{
if ((pos - pointerDown).magnitude > Screen.height * 0.015f) pointerMoved = true;
if (draggingGhost) DragGhost(pos);
else cameraRig.Pan(pos);
}
else if (up && !pointerOverGui)
{
cameraRig.EndPan();
if (!pointerMoved && !draggingGhost) Tap(pos);
draggingGhost = false;
cameraRig.AllowPan = true;
}
}
void DragGhost(Vector3 pos)
{
if (!CellUnder(pos, true, out int cx, out int cy)) return;
var t = Types[GhostType];
bool swap = ghostRot == 90 || ghostRot == 270;
int w = swap ? t.H : t.W, h = swap ? t.W : t.H;
int nx = Mathf.Clamp(cx - w / 2, 0, City3DLayout.Size - w), ny = Mathf.Clamp(cy - h / 2, 0, City3DLayout.Size - h);
if (nx == ghostX && ny == ghostY) return;
ghostX = nx; ghostY = ny;
UpdateGhost();
}
bool InsideGhost(int cx, int cy)
{
if (ghost == null) return false;
var t = Types[GhostType];
bool swap = ghostRot == 90 || ghostRot == 270;
int w = swap ? t.H : t.W, h = swap ? t.W : t.H;
return cx >= ghostX - 1 && cx < ghostX + w + 1 && cy >= ghostY - 1 && cy < ghostY + h + 1; // с запасом на палец
}
void Tap(Vector3 pos)
{
if (!CellUnder(pos, mode == Mode.Place || mode == Mode.Move, out int cx, out int cy)) return;
if (mode == Mode.Place || mode == Mode.Move)
{
// тап по земле -- переставить призрак туда
DragGhost(pos);
return;
}
string building = map.BuildingAt(cx, cy);
if (mode == Mode.Edit && building != null) { StartMoving(building); return; }
selectedBuilding = building;
selectedObstacle = building == null ? map.ObstacleAt(cx, cy) : null;
}
// Клетка под пальцем. groundOnly -- только земля (перетаскивание призрака); иначе здание под пальцем -- его клетка
bool CellUnder(Vector3 screen, bool groundOnly, out int x, out int y)
{
x = y = -1;
Ray ray = cam.ScreenPointToRay(screen);
var hits = Physics.RaycastAll(ray, 500f);
System.Array.Sort(hits, (a, b) => a.distance.CompareTo(b.distance));
foreach (var hit in hits)
{
Transform root = hit.collider.transform.parent;
if (!groundOnly && root != null)
foreach (var pair in buildingViews)
if (pair.Value != null && pair.Value.transform == root && map.TryGetBuilding(pair.Key, out var r))
{
x = r.X; y = r.Y;
return true;
}
if (hit.collider.gameObject.name != "Ground") continue;
x = Mathf.FloorToInt(hit.point.x);
y = Mathf.FloorToInt(hit.point.z);
return map.InMap(x, y);
}
return false;
}
// Подписи зданий всегда смотрят в камеру
void LateUpdate()
{
if (cam == null) return;
foreach (var v in buildingViews.Values)
if (v != null) { var l = v.transform.Find("Label"); if (l != null) l.rotation = cam.transform.rotation; }
if (ghost != null) { var l = ghost.transform.Find("Label"); if (l != null) l.rotation = cam.transform.rotation; }
}
void ChangeTownHall(int delta)
{
int before = townHallLevel;
townHallLevel = Mathf.Clamp(townHallLevel + delta, 1, 5);
if (townHallLevel == before) return;
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));
Say($"Ратуша {townHallLevel} ур.: открыта новая зона");
}
}
void Say(string text)
{
message = text;
messageUntil = Time.unscaledTime + 4f;
}
// ---------------- Кнопки (временные, IMGUI) ----------------
readonly List<Rect> guiRects = new List<Rect>();
float guiScale = 1f;
GUIStyle button, label, box;
bool OverGui(Vector3 screen)
{
var p = new Vector2(screen.x / guiScale, (Screen.height - screen.y) / guiScale);
foreach (var r in guiRects) if (r.Contains(p)) return true;
return false;
}
bool Button(Rect r, string text)
{
guiRects.Add(r);
return GUI.Button(r, text, button);
}
void Box(Rect r, string text)
{
guiRects.Add(r);
GUI.Box(r, text, box);
}
void OnGUI()
{
guiScale = Screen.height / 720f;
GUI.matrix = Matrix4x4.Scale(new Vector3(guiScale, guiScale, 1));
float w = Screen.width / guiScale, h = 720f;
if (button == null)
{
button = new GUIStyle(GUI.skin.button) { fontSize = 20, wordWrap = true };
label = new GUIStyle(GUI.skin.label) { fontSize = 20, wordWrap = true, alignment = TextAnchor.MiddleCenter };
label.normal.textColor = Color.white;
box = new GUIStyle(GUI.skin.box) { fontSize = 20, wordWrap = true, alignment = TextAnchor.MiddleCenter };
box.normal.textColor = Color.white;
}
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);
if (mode == Mode.View || mode == Mode.Edit)
{
if (Button(new Rect(w - 250, 10, 240, 44), mode == Mode.Edit ? "Готово" : "Редактировать"))
{
mode = mode == Mode.Edit ? Mode.View : Mode.Edit;
selectedBuilding = selectedObstacle = null;
RefreshGround();
if (mode == Mode.Edit) Say("Режим редактирования: нажмите здание, чтобы перенести");
}
}
// Слева: что построить
if (mode == Mode.View || mode == Mode.Edit)
{
float y = 64;
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);
y += 44;
}
}
// Низ: призрак -- поворот, поставить, отмена
if (mode == Mode.Place || mode == Mode.Move)
{
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();
}
// Меню здания: перенос
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;
}
// Меню дерева / валуна: расчистка без цены и ожидания
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), "Расчистить"))
{
if (map.ClearObstacle(selectedObstacle) && obstacleViews.TryGetValue(selectedObstacle, out var view))
{
Destroy(view);
Say((tree ? "Дерево" : "Валун") + " расчищено. Награда " + (tree ? "деревом" : "камнем") +
" -- после подключения сохранений (этап III)");
}
selectedObstacle = null;
}
if (Button(new Rect(w / 2 + 5, h - 70, 245, 60), "Закрыть")) selectedObstacle = null;
}
if (Time.unscaledTime < messageUntil)
GUI.Label(new Rect(w / 2 - 350, 64, 700, 60), message, label);
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 44d822c903694362bef912c055adda5b
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
+97
View File
@@ -0,0 +1,97 @@
using System;
using System.Collections.Generic;
using Bogatyri.Settlement.Placement;
using UnityEngine;
// ТЕСТОВЫЙ УЧАСТОК 3D-ПОСЕЛЕНИЯ (ТЗ «Система строительства поселения» от 04.11.2026, этап I, прототип).
// Холмы, река, скалы, деревья и валуны строятся из seed -- одинаково при каждом запуске.
// Зоны стройки открываются уровнем Ратуши квадратами от центра (ТЕСТ, число и пороги зон -- на согласование).
// Все числа здесь -- предложение для прототипа, не утверждённый баланс.
public sealed class City3DLayout
{
public const int Size = 48; // клеток по каждой стороне
public const int Seed = 4112026;
public struct Obstacle
{
public string Id;
public bool IsTree; // дерево (иначе валун)
public List<(int x, int y)> Cells;
}
public SettlementMap Map;
public readonly List<Obstacle> Obstacles = new List<Obstacle>();
public readonly List<Vector2> Grass = new List<Vector2>(); // мелкий декор: скрывается под зданиями
// Сколько клеток от центра открывает уровень Ратуши: 1 -- 16 x 16, 2 -- 24 x 24, ... 5 -- весь участок
public static int ZoneHalfSize(int townHallLevel) => Mathf.Min(Size / 2, 8 + 4 * (Mathf.Max(1, townHallLevel) - 1));
// Высота земли в точке (мировые единицы, клетка = 1)
public static float HeightAt(float x, float z)
{
float cx = x - Size / 2f, cz = z - Size / 2f;
float fromCenter = Mathf.Sqrt(cx * cx + cz * cz) / (Size / 2f);
// Холмы: в центре почти ровно, к краям -- выше и круче
float hills = (Mathf.Sin(x * 0.27f) * 0.5f + Mathf.Cos(z * 0.21f) * 0.45f + Mathf.PerlinNoise(x * 0.11f, z * 0.11f) * 0.9f)
* Mathf.Lerp(0.25f, 1.6f, Mathf.Clamp01(fromCenter));
if (IsMountain(x, z)) hills += 2.5f + Mathf.PerlinNoise(x * 0.3f, z * 0.3f) * 2f;
if (IsRiver(x, z)) hills = -0.5f;
return hills;
}
static float RiverX(float z) => 37f + 3f * Mathf.Sin(z * 0.17f);
static bool IsRiver(float x, float z) => Mathf.Abs(x - RiverX(z)) < 1.3f;
static bool IsMountain(float x, float z) => x < 9f && z > 34f || x < 4f && z > 24f;
public static City3DLayout Create(PlacementRules rules = null)
{
var layout = new City3DLayout { Map = new SettlementMap(Size, Size, rules) };
var map = layout.Map;
int c = Size / 2;
for (int y = 0; y < Size; y++)
for (int x = 0; x < Size; x++)
{
float px = x + 0.5f, pz = y + 0.5f;
CellKind kind = IsRiver(px, pz) ? CellKind.Water : IsMountain(px, pz) ? CellKind.Mountain : CellKind.Ground;
float dist = Mathf.Max(Mathf.Abs(px - c), Mathf.Abs(pz - c)); // квадратное кольцо от центра
int zone = 0;
for (int lvl = 1; lvl <= 5; lvl++) if (dist < ZoneHalfSize(lvl)) { zone = lvl; break; }
map.SetCell(x, y, kind, Mathf.RoundToInt(HeightAt(px, pz) * 1000f), zone);
}
// Деревья и валуны: не в центральной поляне 10 x 10 (там Ратуша), не на воде и скалах
var rng = new System.Random(Seed);
var taken = new HashSet<int>();
bool Free(int x, int y) => map.InMap(x, y) && map.KindAt(x, y) == CellKind.Ground && !taken.Contains(y * Size + x)
&& (Math.Abs(x - c) > 5 || Math.Abs(y - c) > 5);
int trees = 0, rocks = 0;
for (int attempt = 0; attempt < 4000 && trees < 90; attempt++)
{
int x = rng.Next(Size), y = rng.Next(Size);
if (!Free(x, y)) continue;
taken.Add(y * Size + x);
layout.Obstacles.Add(new Obstacle { Id = "tree_" + (++trees), IsTree = true, Cells = new List<(int, int)> { (x, y) } });
}
for (int attempt = 0; attempt < 4000 && rocks < 22; attempt++)
{
int x = rng.Next(Size - 1), y = rng.Next(Size - 1);
bool big = rng.Next(3) == 0; // каждый третий валун -- 2 x 2
var cells = big ? new List<(int, int)> { (x, y), (x + 1, y), (x, y + 1), (x + 1, y + 1) } : new List<(int, int)> { (x, y) };
bool ok = true;
foreach (var cell in cells) if (!Free(cell.Item1, cell.Item2)) { ok = false; break; }
if (!ok) continue;
foreach (var cell in cells) taken.Add(cell.Item2 * Size + cell.Item1);
layout.Obstacles.Add(new Obstacle { Id = "rock_" + (++rocks), IsTree = false, Cells = cells });
}
foreach (var o in layout.Obstacles) map.AddObstacle(o.Id, o.Cells);
// Трава -- только украшение: не мешает стройке, прячется под зданием и возвращается, когда здание ушло
for (int i = 0; i < 260; i++)
{
float x = (float)rng.NextDouble() * Size, z = (float)rng.NextDouble() * Size;
if (map.KindAt((int)x, (int)z) == CellKind.Ground) layout.Grass.Add(new Vector2(x, z));
}
return layout;
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 31e2f86e3dff430ea8a67cc6f1cb4038
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
+13 -120
View File
@@ -13,10 +13,6 @@ 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
@@ -37,7 +33,6 @@ public class BuildModeController : MonoBehaviour
Button placeButton;
BuildGhost ghost;
GameObject moving; // здание, которое переносим (null -- строим новое)
bool dragging;
Vector2 grabOffset;
Vector2 downPos;
@@ -150,32 +145,6 @@ public class BuildModeController : MonoBehaviour
// ---------------- призрак ----------------
// Перенос построенного здания: призрак на его месте, "Сюда" -- переезжает бесплатно
public void StartMove(GameObject building)
{
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();
@@ -197,12 +166,6 @@ 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);
@@ -215,28 +178,11 @@ 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(BuildTimer.DurationMs(false, 1) > 0 ? "Стройка началась: " + d.buildingName : "Построено: " + d.buildingName);
UIKit.Toast("Построено: " + d.buildingName);
CancelGhost(); // режим остаётся: "Здания" в подсказке сверху -- строить дальше
}
else UIKit.Toast("Не получилось построить");
@@ -256,8 +202,8 @@ public class BuildModeController : MonoBehaviour
placeInfo = UIKit.Text(bar, "", 26, Color.white, TextAlignmentOptions.Left);
UIKit.Size(placeInfo, -1, -1, 1);
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);
placeButton = UIKit.Button(bar, "Поставить", UIKit.Theme.Green, Place, 230, 90, 28);
UIKit.Button(bar, "Другое", UIKit.Theme.Blue, OpenChooser, 180, 90, 26);
UIKit.Button(bar, "Отмена", UIKit.Theme.Gray, CancelGhost, 180, 90, 26);
RefreshPlacePanel();
}
@@ -266,22 +212,12 @@ public class BuildModeController : MonoBehaviour
{
if (ghost == null || placeInfo == null) return;
var d = ghost.Data;
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;
string where = ghost.CellsFree ? "<color=#9be59b>место свободно</color>" : "<color=#ff7a7a>клетки заняты</color>";
placeInfo.text = "<b>" + d.buildingName + "</b> " + ghost.Footprint.size.x + "×" + ghost.Footprint.size.y +
" кл. • " + 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);
" кл. • " + where + "\n" + CostText(d);
bool ok = ghost.CellsFree && afford;
UIKit.SetButton(placeButton, ok ? "Поставить" : (ghost.CellsFree ? "Мало ресурсов" : "Занято"), ok);
}
// ---------------- окно выбора здания ----------------
@@ -303,8 +239,7 @@ 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 + " ур. • строителей свободно: " + BuildTimer.FreeBuilders + " из " + BuildTimer.Builders +
"\nвыберите здание, потом место на сетке", 24, UIKit.Theme.TextDim, TextAlignmentOptions.Center);
UIKit.Text(card, "Ратуша " + th + " ур. • выберите здание, потом место на сетке", 24, UIKit.Theme.TextDim, TextAlignmentOptions.Center);
var holder = UIKit.Make("ListHolder", card);
UIKit.Size(holder, -1, -1).flexibleHeight = 1;
@@ -319,10 +254,7 @@ public class BuildModeController : MonoBehaviour
foreach (var data in all)
{
var d = data;
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 locked = d.requiredTownHallLevel > th;
bool afford = !locked && d.CanAfford();
var size = BuildFootprint.Of(d).size;
@@ -333,15 +265,13 @@ public class BuildModeController : MonoBehaviour
var info = UIKit.Card(row, new Color(0, 0, 0, 0), 0, 4);
UIKit.Size(info, -1, -1, 1);
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,
UIKit.Text(info, d.buildingName + " <size=22><color=#aab>" + size.x + "×" + size.y + " кл.</color></size>", 30,
locked ? UIKit.Theme.TextDim : UIKit.Theme.Text).fontStyle = FontStyles.Bold;
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);
UIKit.Text(info, locked ? "Откроется на Ратуше " + d.requiredTownHallLevel + " уровня" : CostText(d), 24,
locked ? UIKit.Theme.Gold : UIKit.Theme.TextDim);
// выбрать можно и без ресурсов: место присмотреть, а "Поставить" загорится, когда хватит
string label = d.requiredTownHallLevel > th ? "Закрыто" : full ? "Лимит" : "Выбрать";
string label = locked ? "Закрыто" : "Выбрать";
var b = UIKit.Button(row, label, afford ? UIKit.Theme.Green : UIKit.Theme.Gray, () => StartGhost(d), 230, 90, 26);
b.interactable = !locked;
}
@@ -349,43 +279,6 @@ 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);
-52
View File
@@ -1,52 +0,0 @@
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()
{
if (Main == this) 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);
}
}
@@ -1,2 +0,0 @@
fileFormatVersion: 2
guid: b3edb04b487c1554a91d69b16f7876e4
@@ -1,65 +0,0 @@
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("[ТЕРЕМ] Добавлен в список стройки");
}
}
@@ -1,2 +0,0 @@
fileFormatVersion: 2
guid: b1671b6bc9bd9174f9a8f213d07ef757
-203
View File
@@ -1,203 +0,0 @@
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);
}
}
@@ -1,2 +0,0 @@
fileFormatVersion: 2
guid: caa21d91fd476684ba0fd70c830fe04f
+1
View File
@@ -721,6 +721,7 @@ public class GameProgress : MonoBehaviour
long now = GameClock.UnixNowMs;
army.ProcessTraining(now);
army.ProcessHealing(now);
army.ProcessMissing(now);
}
// Заказ закончен (событие Армии, один раз на заказ): статистика заданий и всплывашка
+33 -123
View File
@@ -4,8 +4,7 @@ using UnityEngine.UI;
using TMPro;
// ВОЙСКА: армия, очередь обучения (идёт и когда игра закрыта), найм воинов за ресурсы,
// ускорение за кристаллы. Найм -- карточки войск в ряд (листаются вбок): иконка, сила, цена,
// против кого силён и слаб (из правил боя). Новые типы войск открывают уровни Ратуши и Казарм.
// ускорение за кристаллы. Новые типы войск открываются уровнем Ратуши.
public class TroopsPanel : BasePanel
{
readonly List<Image> queueBars = new List<Image>();
@@ -46,6 +45,13 @@ public class TroopsPanel : BasePanel
ProfilePanel.KV(army, "Раненые", hurt.WoundedTotal.ToString("N0") + " (лечатся " + healingNow + ", ждут места " + waiting + ")");
ProfilePanel.KV(army, "Госпиталь", "мест " + hurt.HospitalBeds + next);
}
if (hurt.MissingTotal > 0)
{
long windowEnd = 0;
foreach (var m in hurt.Missing) windowEnd = System.Math.Max(windowEnd, m.EndMs);
ProfilePanel.KV(army, "Пропали без вести", hurt.MissingTotal.ToString("N0") + " (могут вернуться ещё " +
GameData.FormatTime(System.Math.Max(0, windowEnd - GameClock.UnixNowMs) / 1000.0) + ")");
}
ProfilePanel.KV(army, "Сила войск", GP.TroopsPower.ToString("N0"));
ProfilePanel.KV(army, "Сила армии с героем", GP.ArmyPower.ToString("N0"));
UIKit.Button(army, "Отправить армию на карте мира", UIKit.Theme.Green, () => OpenPanel("World"), -1, 80, 26);
@@ -102,133 +108,37 @@ public class TroopsPanel : BasePanel
}
Tick();
// --- Типы войск: карточки в ряд, листаются вбок ---
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)
// --- Типы войск ---
for (int i = 0; i < GameData.Troops.Count; i++)
{
UIKit.Text(card, "Откроется:\n" + GP.TroopLockText(i), 24, UIKit.Theme.Gold, TextAlignmentOptions.Center);
return;
}
int idx = i;
var def = GameData.Troops[i];
bool unlocked = GP.TroopUnlocked(i);
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);
}
}
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);
// Иконка войска. Сейчас -- заглушка (цветной квадрат с буквой); сюда же потом подставим спрайт.
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 (!unlocked)
{
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));
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);
}
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 сообщает об изменении
@@ -1,56 +0,0 @@
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);
}
}
@@ -1,2 +0,0 @@
fileFormatVersion: 2
guid: 59a54e4603712204d81f167a1c02873f
+232 -2
View File
@@ -29,6 +29,18 @@ public sealed class HospitalRules
}
}
// Пропавшие без вести (решения 01.11, п. 5). Доля возвращающихся и формула -- НЕ УТВЕРЖДЕНЫ (ТЕСТ, таблица HospitalConfig)
public sealed class MissingRules
{
public int ReturnPercent = 50; // сколько пропавших вернётся (утверждён диапазон 10-90%, формула открыта)
public int WoundedPercent = 30; // из вернувшихся -- раненые (утверждено: 70% здоровыми, 30% ранеными)
public int WindowGameHours = 6; // окно возвращения после обратного пути, ИГРОВЫХ часов
public long GameHourMs = 5 * 60 * 1000;
public int MaxGroups = 4; // возвращаются 2-4 группами (меньше, если пропавших мало)
public static MissingRules Default => new MissingRules();
}
public sealed class ArmyService : IArmy, ISaveParticipant
{
public const int Types = 4; // пехота, лучники, кавалерия, осадные -- порядок как в GameData.Troops
@@ -62,6 +74,28 @@ public sealed class ArmyService : IArmy, ISaveParticipant
private int nextHealId = 1;
private readonly HospitalRules hospital;
// Пропавшие без вести одного боя: группы возвращения рассчитаны сразу, при постановке (детерминированно)
private class MissingEvent
{
public int id, marchId;
public long windowStartMs, endMs;
public int[] remaining = new int[Types]; // ещё не вернулись и не списаны
public List<MissingGroup> groups = new List<MissingGroup>();
}
private class MissingGroup
{
public int index;
public long timeMs;
public int[] healthy = new int[Types], wounded = new int[Types];
}
private readonly List<MissingEvent> missing = new List<MissingEvent>();
private readonly List<MissingInfo> missingView = new List<MissingInfo>();
private readonly HashSet<int> missingMarches = new HashSet<int>(); // по каким походам пропавшие уже отмечены
private int nextMissingId = 1;
private readonly MissingRules missingRules;
private readonly List<TrainingOrder> training = new List<TrainingOrder>();
private readonly List<TrainingOrderInfo> trainingView = new List<TrainingOrderInfo>();
private int nextOrderId = 1;
@@ -76,10 +110,11 @@ public sealed class ArmyService : IArmy, ISaveParticipant
// Слоты похода: база из таблицы MarchConfig (регламент 31.10: 2, ТЕСТ). Бонус «Военной логистики» -- позже.
private readonly int baseMarchSlots;
public ArmyService(int baseMarchSlots = 2, HospitalRules hospital = null)
public ArmyService(int baseMarchSlots = 2, HospitalRules hospital = null, MissingRules missingRules = null)
{
this.baseMarchSlots = Math.Max(1, baseMarchSlots);
this.hospital = hospital ?? HospitalRules.Default;
this.missingRules = missingRules ?? MissingRules.Default;
}
public HospitalRules Hospital => hospital;
@@ -238,6 +273,147 @@ public sealed class ArmyService : IArmy, ISaveParticipant
}
}
public int GetAllTroops(int type)
{
if (type < 0 || type >= Types) return 0;
int n = troops[type] + woundedWaiting[type];
foreach (var m in marches) n += m.units[type];
foreach (var b in healing) if (b.type == type) n += b.count;
return n;
}
// ---------------- Пропавшие без вести ----------------
public int MissingTotal
{
get { int n = 0; foreach (var e in missing) foreach (var r in e.remaining) n += r; return n; }
}
public IReadOnlyList<MissingInfo> Missing
{
get
{
missingView.Clear();
foreach (var e in missing)
{
int n = 0;
foreach (var r in e.remaining) n += r;
missingView.Add(new MissingInfo { Id = e.id, Count = n, WindowStartMs = e.windowStartMs, EndMs = e.endMs });
}
return missingView;
}
}
public bool MarkMissing(int marchId, int[] missingUnits, long battleMs, long travelMs)
{
if (missingUnits == null || missingMarches.Contains(marchId)) return false;
MarchUnits entry = marches.Find(m => m.marchId == marchId);
if (entry == null) return false;
var e = new MissingEvent { id = nextMissingId++, marchId = marchId };
int total = 0;
for (int i = 0; i < Types && i < missingUnits.Length; i++)
{
int n = Math.Max(0, Math.Min(missingUnits[i], entry.units[i]));
entry.units[i] -= n; // из похода -- в пропавшие: один солдат в одном месте
e.remaining[i] = n;
total += n;
}
if (total == 0) { nextMissingId--; return false; }
e.windowStartMs = battleMs + Math.Max(0, travelMs);
e.endMs = e.windowStartMs + Math.Max(0, missingRules.WindowGameHours) * missingRules.GameHourMs;
PlanGroups(e, battleMs);
missing.Add(e);
missingMarches.Add(marchId);
Changed();
return true;
}
// Кто вернётся и когда: доля возвращающихся по типам (дробная часть -- бросок), 2-4 группы в окне,
// в каждой -- 70% здоровых, 30% раненых (дробная часть -- бросок). Тип войск сохраняется.
void PlanGroups(MissingEvent e, long seedTime)
{
ulong seed = (ulong)e.marchId * 0x9E3779B97F4A7C15UL ^ (ulong)seedTime;
int roll = 0;
bool Chance(long num, long den) => den > 0 && (long)(Next(seed, roll++) % (ulong)den) < num;
var back = new int[Types];
int returning = 0;
for (int i = 0; i < Types; i++)
{
long num = (long)e.remaining[i] * Math.Max(0, Math.Min(100, missingRules.ReturnPercent));
back[i] = (int)(num / 100) + (Chance(num % 100, 100) ? 1 : 0);
returning += back[i];
}
if (returning == 0) return;
int groups = Math.Max(1, Math.Min(Math.Max(2, missingRules.MaxGroups), returning)); // мало вернувшихся -- групп меньше
if (returning >= 2) groups = Math.Max(2, Math.Min(groups, 2 + (int)(Next(seed, roll++) % (ulong)(Math.Max(2, missingRules.MaxGroups) - 1))));
long window = Math.Max(1, e.endMs - e.windowStartMs);
for (int g = 0; g < groups; g++)
e.groups.Add(new MissingGroup { index = g + 1, timeMs = e.windowStartMs + window * (g + 1) / (groups + 1) });
// Раздаём вернувшихся по группам по очереди, внутри группы -- здоровые и раненые
for (int i = 0; i < Types; i++)
for (int k = 0; k < back[i]; k++)
{
var grp = e.groups[(k + i) % groups];
if (Chance(Math.Max(0, Math.Min(100, missingRules.WoundedPercent)), 100)) grp.wounded[i]++;
else grp.healthy[i]++;
}
}
static ulong Next(ulong seed, int n)
{
ulong x = seed + 0x9E3779B97F4A7C15UL * (ulong)(n + 1);
x = (x ^ (x >> 30)) * 0xBF58476D1CE4E5B9UL;
x = (x ^ (x >> 27)) * 0x94D049BB133111EBUL;
return x ^ (x >> 31);
}
public int ProcessMissing(long nowMs)
{
int done = 0;
bool changed = false;
for (int k = missing.Count - 1; k >= 0; k--)
{
var e = missing[k];
e.groups.Sort((a, b) => a.timeMs.CompareTo(b.timeMs));
while (e.groups.Count > 0 && e.groups[0].timeMs <= nowMs && e.groups[0].timeMs <= e.endMs)
{
var g = e.groups[0];
e.groups.RemoveAt(0); // группа возвращается один раз
int healthy = 0, wounded = 0;
bool anyWounded = false;
for (int i = 0; i < Types; i++)
{
int h = Math.Min(g.healthy[i], e.remaining[i]);
int w = Math.Min(g.wounded[i], e.remaining[i] - h);
e.remaining[i] -= h + w;
troops[i] += h; // исходной армии может уже не быть -- в резерв поселения
woundedWaiting[i] += w; // раненые -- в госпиталь по обычным правилам
healthy += h; wounded += w;
if (w > 0) anyWounded = true;
}
if (anyWounded) { ProcessHealing(g.timeMs); FillBeds(g.timeMs); }
done++;
changed = true;
EventBus.Publish(new MissingReturnedEvent { MissingId = e.id, Group = g.index, Healthy = healthy, Wounded = wounded, TimeMs = g.timeMs });
}
if (nowMs >= e.endMs)
{
int left = 0;
foreach (var r in e.remaining) left += r;
missing.RemoveAt(k); // окно кончилось: кто не вернулся -- списан, места освобождены
changed = true;
EventBus.Publish(new MissingWrittenOffEvent { MissingId = e.id, Count = left, TimeMs = e.endMs });
}
}
if (changed) Changed();
return done;
}
public int GetWoundedWaiting(int type)
{
return type >= 0 && type < Types ? woundedWaiting[type] : 0;
@@ -416,7 +592,8 @@ public sealed class ArmyService : IArmy, ISaveParticipant
void Changed()
{
EventBus.Publish(new TroopsChangedEvent { AtHome = SoldiersAtHome, InMarches = TroopsInMarches, Garrison = GarrisonTotal, Wounded = WoundedTotal });
EventBus.Publish(new TroopsChangedEvent { AtHome = SoldiersAtHome, InMarches = TroopsInMarches, Garrison = GarrisonTotal, Wounded = WoundedTotal,
Missing = MissingTotal });
}
// ---------------- Перенос из старого progress.json ----------------
@@ -453,6 +630,9 @@ public sealed class ArmyService : IArmy, ISaveParticipant
// wounded=<пехота>,<лучники>,<кавалерия>,<осадные> (ждут места в госпитале)
// heal=<id>|<тип>|<сколько>|<начало, мс>|<конец, мс>
// healNext=<номер следующей группы лечения>
// missing=<id>|<поход>|<окно с, мс>|<конец, мс>|<осталось по типам через ,>
// mgroup=<id пропавших>|<номер группы>|<время, мс>|<здоровые по типам>|<раненые по типам> (ещё не вернувшиеся группы)
// missingMarch=<поход> (по нему пропавшие уже отмечены) missingNext=<номер>
public string SaveKey => "army";
@@ -477,6 +657,20 @@ public sealed class ArmyService : IArmy, ISaveParticipant
N(b.id), N(b.type), N(b.count), b.startMs.ToString(CultureInfo.InvariantCulture), b.endMs.ToString(CultureInfo.InvariantCulture)
})).Append('\n');
sb.Append("healNext=").Append(N(nextHealId)).Append('\n');
foreach (var e in missing)
{
sb.Append("missing=").Append(string.Join("|", new[]
{
N(e.id), N(e.marchId), e.windowStartMs.ToString(CultureInfo.InvariantCulture), e.endMs.ToString(CultureInfo.InvariantCulture), Join(e.remaining)
})).Append('\n');
foreach (var g in e.groups)
sb.Append("mgroup=").Append(string.Join("|", new[]
{
N(e.id), N(g.index), g.timeMs.ToString(CultureInfo.InvariantCulture), Join(g.healthy), Join(g.wounded)
})).Append('\n');
}
foreach (var id in missingMarches) sb.Append("missingMarch=").Append(N(id)).Append('\n');
sb.Append("missingNext=").Append(N(nextMissingId)).Append('\n');
return sb.ToString();
}
@@ -490,6 +684,9 @@ public sealed class ArmyService : IArmy, ISaveParticipant
Array.Clear(woundedWaiting, 0, Types);
healing.Clear();
nextHealId = 1;
missing.Clear();
missingMarches.Clear();
nextMissingId = 1;
RestoredFromSave = true;
if (!string.IsNullOrEmpty(data))
@@ -547,6 +744,36 @@ public sealed class ArmyService : IArmy, ISaveParticipant
nextHealId = Math.Max(nextHealId, hid + 1);
}
}
else if (line.StartsWith("missing="))
{
string[] p = line.Substring("missing=".Length).Split('|');
if (p.Length == 5 && int.TryParse(p[0], NumberStyles.Integer, CultureInfo.InvariantCulture, out int mid)
&& int.TryParse(p[1], NumberStyles.Integer, CultureInfo.InvariantCulture, out int mmarch)
&& long.TryParse(p[2], NumberStyles.Integer, CultureInfo.InvariantCulture, out long mstart)
&& long.TryParse(p[3], NumberStyles.Integer, CultureInfo.InvariantCulture, out long mend)
&& !missing.Exists(x => x.id == mid))
{
missing.Add(new MissingEvent { id = mid, marchId = mmarch, windowStartMs = mstart, endMs = mend, remaining = Parse(p[4]) });
nextMissingId = Math.Max(nextMissingId, mid + 1);
}
}
else if (line.StartsWith("mgroup="))
{
string[] p = line.Substring("mgroup=".Length).Split('|');
MissingEvent owner = null;
if (p.Length == 5 && int.TryParse(p[0], NumberStyles.Integer, CultureInfo.InvariantCulture, out int gid))
owner = missing.Find(x => x.id == gid); // строки missing= идут раньше своих групп
if (owner != null && int.TryParse(p[1], NumberStyles.Integer, CultureInfo.InvariantCulture, out int gidx)
&& long.TryParse(p[2], NumberStyles.Integer, CultureInfo.InvariantCulture, out long gtime)
&& !owner.groups.Exists(x => x.index == gidx))
owner.groups.Add(new MissingGroup { index = gidx, timeMs = gtime, healthy = Parse(p[3]), wounded = Parse(p[4]) });
}
else if (line.StartsWith("missingMarch=")
&& int.TryParse(line.Substring("missingMarch=".Length), NumberStyles.Integer, CultureInfo.InvariantCulture, out int mm))
missingMarches.Add(mm);
else if (line.StartsWith("missingNext=")
&& int.TryParse(line.Substring("missingNext=".Length), NumberStyles.Integer, CultureInfo.InvariantCulture, out int mnext))
nextMissingId = Math.Max(nextMissingId, mnext);
else if (line.StartsWith("healNext=")
&& int.TryParse(line.Substring("healNext=".Length), NumberStyles.Integer, CultureInfo.InvariantCulture, out int hnext))
nextHealId = Math.Max(nextHealId, hnext);
@@ -570,6 +797,9 @@ public sealed class ArmyService : IArmy, ISaveParticipant
Array.Clear(woundedWaiting, 0, Types);
healing.Clear();
nextHealId = 1;
missing.Clear();
missingMarches.Clear();
nextMissingId = 1;
Changed();
}
+7 -2
View File
@@ -6,9 +6,10 @@
// итог боя запоминает версию, по которой посчитан.
public sealed class BattleRules
{
public int Version = 7; // 2 -- голод и усталость; 3 -- сила без округления, потери шансом (№12, №13); 4 -- кривая потерь (№14);
public int Version = 8; // 2 -- голод и усталость; 3 -- сила без округления, потери шансом (№12, №13); 4 -- кривая потерь (№14);
// 5 -- разгром дальше 5:1 (решения по 8 вопросам Саши, 31.10, п. 5); 6 -- раненые (п. 7);
// 7 -- сильная сторона 5:1 -> 10:1 с 11% до 2% (решения по отчёту Саши, 01.11, п. 1)
// 7 -- сильная сторона 5:1 -> 10:1 с 11% до 2% (решения по отчёту Саши, 01.11, п. 1);
// 8 -- пропавшие без вести (п. 5)
// Сила одного воина каждого типа. В игре -- из GameData.Troops (там же цена и время обучения).
public int[] UnitPower = { 4, 5, 9, 16 };
@@ -37,6 +38,10 @@ public sealed class BattleRules
public int[] DeadRatioPermille = { 200, 500, 1000, 3000 }; // соотношение сил стороны: 1:5, 1:2, 1:1, 3:1
public int[] DeadSharePermille = { 700, 450, 300, 200 }; // доля погибших среди выбывших: 70%, 45%, 30%, 20%
// Пропавшие без вести (решения 01.11, п. 5): доля выбывших, получающих этот статус, в десятых долях процента.
// НЕ УТВЕРЖДЕНА командой -- по умолчанию 0 (механика выключена). Остальные выбывшие -- погибшие и раненые, как выше.
public int MissingSharePermille = 0;
// Состояния (утверждено 27.10.2026): сколько силы остаётся. Применяются последовательно: 50% x 80% = 40%
public int FamineStrengthPercent = 50; // голод: -50%
public int FatigueStrengthPercent = 80; // усталость: -20%
+35 -9
View File
@@ -181,15 +181,20 @@ public sealed class BattleSystem : IBattleSystem
int[] attOut = Losses(att, weaker, stronger, isWeak: !victory, request.Seed, side: 1, rounding);
int[] defOut0 = Losses(def, weaker, stronger, isWeak: victory, request.Seed, side: 2, rounding);
// Выбывшие -- погибшие и раненые (решения 31.10, п. 7): доля погибших -- от соотношения сил своей стороны
int[] attDead = Dead(attOut, attPower, defPower, request.Seed, side: 1, rounding);
int[] defDead = Dead(defOut0, defPower, attPower, request.Seed, side: 2, rounding);
// Выбывшие: сначала пропавшие без вести (решения 01.11, п. 5), остальные -- погибшие и раненые (решения 31.10, п. 7):
// доля погибших -- от соотношения сил своей стороны. Солдат -- ровно в одном статусе
int[] attMissing = Missing(attOut, request.Seed, side: 1, rounding);
int[] defMissing = Missing(defOut0, request.Seed, side: 2, rounding);
int[] attRest = new int[TroopType.Count], defRest = new int[TroopType.Count];
for (int i = 0; i < TroopType.Count; i++) { attRest[i] = attOut[i] - attMissing[i]; defRest[i] = defOut0[i] - defMissing[i]; }
int[] attDead = Dead(attRest, attPower, defPower, request.Seed, side: 1, rounding);
int[] defDead = Dead(defRest, defPower, attPower, request.Seed, side: 2, rounding);
int[] attWounded = new int[TroopType.Count];
int[] defWounded = new int[TroopType.Count];
for (int i = 0; i < TroopType.Count; i++)
{
attWounded[i] = attOut[i] - attDead[i];
defWounded[i] = defOut0[i] - defDead[i];
attWounded[i] = attRest[i] - attDead[i];
defWounded[i] = defRest[i] - defDead[i];
}
var attAlive = new int[TroopType.Count];
@@ -197,9 +202,9 @@ public sealed class BattleSystem : IBattleSystem
var defOut = new int[TroopType.Count];
for (int i = 0; i < TroopType.Count; i++)
{
attAlive[i] = att[i] - attDead[i] - attWounded[i];
defAlive[i] = def[i] - defDead[i] - defWounded[i];
defOut[i] = defDead[i] + defWounded[i];
attAlive[i] = att[i] - attOut[i];
defAlive[i] = def[i] - defOut0[i];
defOut[i] = defOut0[i];
}
return new BattleOutcome
@@ -207,7 +212,7 @@ public sealed class BattleSystem : IBattleSystem
Victory = victory,
BattleId = request.Seed,
RulesVersion = rules.Version,
AttackerLosses = Sum(attDead) + Sum(attWounded),
AttackerLosses = Sum(attOut),
DefenderLosses = Sum(defOut),
Survivors = Sum(attAlive),
AttackerPower = attPower.Floor, // округление -- только для показа
@@ -224,6 +229,8 @@ public sealed class BattleSystem : IBattleSystem
DefenderDeadUnits = defDead,
AttackerWoundedUnits = attWounded,
DefenderWoundedUnits = defWounded,
AttackerMissingUnits = attMissing,
DefenderMissingUnits = defMissing,
DefenderLossUnits = defOut
};
}
@@ -340,6 +347,25 @@ public sealed class BattleSystem : IBattleSystem
return lost;
}
// Пропавшие без вести среди выбывших каждого типа: доля из правил (не утверждена; 0 -- никого), дробная часть -- бросок
// из своего потока. Прогноз: Floor -- меньше пропавших, Ceiling -- больше
int[] Missing(int[] outUnits, long seed, int side, Rounding rounding)
{
var missing = new int[TroopType.Count];
int share = Math.Max(0, Math.Min(1000, rules.MissingSharePermille));
if (share == 0) return missing;
for (int i = 0; i < TroopType.Count; i++)
{
if (outUnits[i] <= 0) continue;
long num = (long)outUnits[i] * share;
long whole = num / 1000, rest = num % 1000;
bool extra = rest != 0 && (rounding == Rounding.Ceiling
|| rounding == Rounding.Roll && (LossRoll(seed, side + 24, i) * 1000UL >> 32) < (ulong)rest);
missing[i] = (int)Math.Min(outUnits[i], whole + (extra ? 1 : 0));
}
return missing;
}
// Погибшие среди выбывших каждого типа: доля по соотношению сил стороны (own / enemy), дробная часть -- бросок
// из своего потока (не тот, что у потерь и удачи). Прогноз: Floor -- меньше погибших, Ceiling -- больше.
int[] Dead(int[] outUnits, Fraction own, Fraction enemy, long seed, int side, Rounding rounding)
@@ -22,18 +22,6 @@ 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
{
@@ -98,6 +86,15 @@ public interface IPopulation
}
// ---------------- Армия: войска ----------------
// Пропавшие без вести одного боя (решения 01.11, п. 5). Время -- мс часов игры
public struct MissingInfo
{
public int Id;
public int Count; // ещё не вернулись и не списаны
public long WindowStartMs; // с какого момента могут начать возвращаться (бой + обратный путь)
public long EndMs; // конец окна: кто не вернулся -- списан
}
// Раненые на лечении в госпитале: одна группа одного типа, лечится вместе. Время -- мс часов игры
public struct HealingInfo
{
@@ -162,6 +159,17 @@ public interface IArmy
// Бой считает раненых, Армия хранит их и лечит, места -- от госпиталя (IHospital), цену лечения списывает Экономика.
// Раненые -- люди: Население считает их вместе с воинами. Лишние раненые ждут места (ТЕСТ, решение Саши 01.11).
int WoundedTotal { get; } // раненых всего: ждут места + лечатся
int GetAllTroops(int type); // все воины типа: в городе, в походах и раненые (для еды -- решения 01.11, п. 4)
// ---- Пропавшие без вести (решения 01.11, п. 5) ----
// Не воюют и не едят, но занимают места в населении до возвращения или списания.
int MissingTotal { get; }
IReadOnlyList<MissingInfo> Missing { get; }
// Бой посчитал пропавших: забрать их из похода в группу пропавших. battleMs -- конец боя, travelMs -- обратный путь.
// Только известный поход и только один раз на поход; больше, чем ушло, не забрать.
bool MarkMissing(int marchId, int[] missingUnits, long battleMs, long travelMs);
// Вернуть группы, время которых наступило (и офлайн), и списать тех, чьё окно кончилось. Сколько групп вернулось.
int ProcessMissing(long nowMs);
int GetWoundedWaiting(int type); // ждут места, по типам
IReadOnlyList<HealingInfo> Healing { get; } // лечатся сейчас
int HospitalBeds { get; } // мест в госпитале сейчас
@@ -194,6 +202,8 @@ public interface IWorld
bool TryStartMarch(string targetId, int soldiers, out string error);
// Поход «Разведка» (решения 31.10, п. 2): дойти до цели и вернуться -- без боя, сбора и потерь, занимает слот похода
bool TryStartScout(string targetId, int soldiers, out string error);
// Разведка с отчётом (Разведка М2): дойти, наблюдать время уровня (II / III), отчёт, вернуться
bool TryStartRecon(string targetId, int soldiers, int level, out string error);
}
// ---------------- Мир -> Разведка: что где находится (решение №46) ----------------
@@ -208,6 +218,22 @@ public struct ArmyOnMap
public int Soldiers; // точная численность -- разведка показывает игроку только примерную
}
// Цель разведки (ТЗ разведки, раздел 6): что за объект -- от этого время наблюдения
public enum ReconTargetKind { None, Settlement, Castle, Camp }
// Снимок цели разведки от Мира -- то, что увидят разведчики в момент отчёта
public struct ReconTarget
{
public string Id;
public string Name;
public ReconTargetKind Kind;
public int[] Units; // гарнизон по типам (TroopType)
public bool Fortified; // защитники за стенами
public bool HasHero;
public string[] ResourceNames; // что можно взять (имена ResourceType)
public int[] ResourceAmounts;
}
public interface IWorldMap
{
int HalfWidth { get; } // границы карты от центра, милли-единицы
@@ -215,6 +241,8 @@ public interface IWorldMap
bool TryGetSettlement(string ownerId, out int x, out int y); // где поселение игрока
// Все армии на карте в момент timeMs (в том числе в прошлом -- для расчёта, пока игра была закрыта)
void GetArmies(long timeMs, List<ArmyOnMap> result);
// Цель разведки: замок, поселение, лагерь (Разведка М2). false -- такой цели нет или её нельзя разведать
bool TryGetReconTarget(string targetId, out ReconTarget target);
}
// ---------------- Разведка (Bogatyri.Intelligence): туман войны, наблюдение ----------------
@@ -237,12 +265,38 @@ public struct KnownContact
public long LastSeenMs; // когда видели последний раз
}
// Отчёт разведки (ТЗ разведки, раздел 6): сведения на момент операции, без постоянного слежения.
// Уровень II -- точное число и типы, герой, укрепления; III -- плюс модификаторы и оценка ресурсов ±15%
public struct ReconReport
{
public string Id; // устойчивый: "recon_<номер похода>"
public string TargetId;
public string TargetName;
public int Level; // 2 или 3
public long TimeMs; // когда закончилось наблюдение (по часам игры)
public int[] Units; // гарнизон по типам на момент отчёта
public bool Fortified;
public bool HasHero;
public string Modifiers; // уровень III: основные боевые модификаторы защиты
public string[] ResourceNames; // уровень III: оценка ресурсов
public int[] ResourceMin, ResourceMax;
}
public interface IIntelligence
{
int ObservationLevel { get; set; } // технология «Обзор»: 0..3 (+10 / +20 / +30% к радиусам)
MapVisibility GetVisibility(int x, int y); // по последнему расчёту
IReadOnlyList<KnownContact> Contacts { get; }
void ObserveUntil(long nowMs); // досчитать наблюдение до момента (в том числе офлайн)
// ---- М2: целенаправленная разведка ----
// Сколько разведчики наблюдают цель (мс часов игры): поселение 2 / 4, замок 3 / 6 игровых часов (уровень II / III).
// 0 -- цель нельзя разведать с отчётом
long ReconDurationMs(string targetId, int level);
// Наблюдение закончено: составить отчёт по снимку цели на этот момент. Один раз на reportId (повтор -- false)
bool CompleteRecon(string reportId, string targetId, int level, long timeMs);
IReadOnlyList<ReconReport> Reports { get; }
bool TryGetLatestReport(string targetId, out ReconReport report);
}
// ---------------- Боевая система: урон, потери, исход ----------------
@@ -310,7 +364,8 @@ public struct BattleOutcome
public int[] AttackerUnitsBefore, DefenderUnitsBefore;
public int[] AttackerSurvivorUnits, DefenderSurvivorUnits;
public int[] AttackerDeadUnits, DefenderDeadUnits;
public int[] AttackerWoundedUnits, DefenderWoundedUnits; // раненые (регламент 31.10, решения 31.10, п. 7): выбывшие минус погибшие
public int[] AttackerWoundedUnits, DefenderWoundedUnits; // раненые (регламент 31.10, решения 31.10, п. 7)
public int[] AttackerMissingUnits, DefenderMissingUnits; // пропавшие без вести (решения 01.11, п. 5): выбывшие = погибшие + раненые + пропавшие
public int[] DefenderLossUnits; // погибшие + раненые защитника (для старого кода)
}
@@ -32,15 +32,6 @@ public struct GameTimeAdvancedEvent
public long UnixNow;
}
// Расчёт игры (модуль Населения: добыча, еда, прирост) дошёл до момента UnixNow (04.11).
// Приходит в начале каждого шага расчёта (раз в 10 игровых минут офлайна) и в конце каждого Tick.
// Всё, что должно случиться по времени (стройка закончилась), применяется здесь -- тогда следующий шаг
// уже считается с новым городом, и добыча идёт ровно с момента завершения, а не с начала офлайна.
public struct GameTimeSimulatedEvent
{
public long UnixNow;
}
// ---------------- Население ----------------
// Число людей изменилось (прирост, загрузка, сброс, изменение войска)
public struct PopulationChangedEvent
@@ -72,6 +63,25 @@ public struct TroopsChangedEvent
public int InMarches;
public int Garrison; // из AtHome -- в гарнизоне
public int Wounded; // раненые: в госпитале или ждут места (не в AtHome)
public int Missing; // пропавшие без вести: занимают места в населении, но не едят и не воюют
}
// Пропавшие без вести вернулись (часть группы боя): здоровые -- в город, раненые -- в госпиталь. Один раз на группу
public struct MissingReturnedEvent
{
public int MissingId;
public int Group;
public int Healthy;
public int Wounded;
public long TimeMs;
}
// Окно возвращения кончилось: кто не вернулся -- списан, места в населении освобождены
public struct MissingWrittenOffEvent
{
public int MissingId;
public int Count;
public long TimeMs;
}
// Раненые вылечены и вернулись в город. Один раз на группу лечения (BatchId), TimeMs -- когда закончили
@@ -126,6 +136,7 @@ public struct MarchReturnedEvent
public bool IsGather; // поход на ресурсную точку (сбор, а не бой)
public bool IsScout; // поход «Разведка»: дошли до цели и вернулись, боя и добычи нет
public int Wounded; // раненых привезли домой -- они в госпитале (или ждут места)
public int Missing; // пропали без вести в бою -- могут вернуться сами
public string TargetName; // куда ходили ("Замок Змея Горыныча", имя соседа)
public string LootText; // добыча текстом: "+20 золота, +40 еды" или "нет"
}
@@ -187,6 +198,23 @@ public struct ArmyLostContactEvent // чужая армия вышла из з
public long TimeMs;
}
// Разведка М2: отряд вышел на разведку с отчётом / отчёт готов (ТЗ разведки, раздел 10)
public struct ReconStartedEvent
{
public string EventId; // "recon_<номер похода>"
public string TargetId;
public int Level;
public long TimeMs;
}
public struct ReconCompletedEvent
{
public string EventId; // = id отчёта
public string TargetId;
public int Level;
public long TimeMs;
}
public struct ArmyIdentifiedEvent // владелец чужой армии опознан
{
public string EventId;
@@ -22,6 +22,14 @@ public sealed class IntelligenceRules
public long MaxCatchUpMs = 48L * 3_600_000; // офлайн досчитывается не больше чем за 48 часов
public int CellSize = 1000; // клетка тумана = клетка карты
// М2, целенаправленная разведка (ТЗ, раздел 6): сколько разведчики наблюдают цель, ИГРОВЫХ часов (уровень II, III)
public int[] ReconSettlementGameHours = { 2, 4 };
public int[] ReconCastleGameHours = { 3, 6 };
public int[] ReconCampGameHours = { 2, 4 }; // лагеря в ТЗ не названы -- как поселение (ТЕСТ)
public long GameHourMs = 5 * 60 * 1000; // 1 игровой час = 5 реальных минут
public int ResourceEstimatePercent = 15; // уровень III: ресурсы -- оценочный диапазон ±15%
public int MaxReports = 30; // храним последние отчёты
public static IntelligenceRules Default => new IntelligenceRules();
}
@@ -255,10 +263,105 @@ public sealed class IntelligenceService : IIntelligence, ISaveParticipant
get { int n = 0; foreach (var e in explored) if (e) n++; return n; }
}
// ---------------- М2: целенаправленная разведка и отчёты ----------------
private readonly List<ReconReport> reports = new List<ReconReport>();
public IReadOnlyList<ReconReport> Reports => reports;
public long ReconDurationMs(string targetId, int level)
{
IWorldMap map = worldMap();
if (map == null || level < 2 || level > 3 || !map.TryGetReconTarget(targetId, out ReconTarget t)) return 0;
int[] hours = t.Kind == ReconTargetKind.Castle ? rules.ReconCastleGameHours
: t.Kind == ReconTargetKind.Camp ? rules.ReconCampGameHours
: t.Kind == ReconTargetKind.Settlement ? rules.ReconSettlementGameHours : null;
if (hours == null || hours.Length < level - 1) return 0;
return Math.Max(0, hours[level - 2]) * rules.GameHourMs;
}
// Отчёт -- снимок цели на момент конца наблюдения. Один отчёт на операцию: повтор (загрузка, двойное событие) -- false
public bool CompleteRecon(string reportId, string targetId, int level, long timeMs)
{
if (string.IsNullOrEmpty(reportId) || reports.Exists(r => r.Id == reportId)) return false;
IWorldMap map = worldMap();
if (map == null || !map.TryGetReconTarget(targetId, out ReconTarget t)) return false;
level = Math.Max(2, Math.Min(3, level));
var report = new ReconReport
{
Id = reportId, TargetId = targetId, TargetName = t.Name, Level = level, TimeMs = timeMs,
Units = t.Units != null ? (int[])t.Units.Clone() : new int[4],
Fortified = t.Fortified, HasHero = t.HasHero,
Modifiers = "", ResourceNames = Array.Empty<string>(), ResourceMin = Array.Empty<int>(), ResourceMax = Array.Empty<int>()
};
if (level >= 3)
{
report.Modifiers = (t.Fortified ? "защитники за стенами" : "бой в поле, стен нет") + (t.HasHero ? ", есть герой" : ", героя нет");
int n = t.ResourceNames != null ? t.ResourceNames.Length : 0;
report.ResourceNames = new string[n];
report.ResourceMin = new int[n];
report.ResourceMax = new int[n];
for (int i = 0; i < n; i++)
{
int amount = t.ResourceAmounts != null && i < t.ResourceAmounts.Length ? Math.Max(0, t.ResourceAmounts[i]) : 0;
int width = amount * 2 * rules.ResourceEstimatePercent / 100; // ±15% -> окно 30%, настоящее значение внутри
int shift = width > 0 ? (int)(Roll(reportId, i, 3) % (ulong)(width + 1)) : 0;
report.ResourceNames[i] = t.ResourceNames[i];
report.ResourceMin[i] = Math.Max(0, amount - shift);
report.ResourceMax[i] = report.ResourceMin[i] + width;
}
}
reports.Add(report);
while (reports.Count > Math.Max(1, rules.MaxReports)) reports.RemoveAt(0);
EventBus.Publish(new ReconCompletedEvent { EventId = reportId, TargetId = targetId, Level = level, TimeMs = timeMs });
return true;
}
public bool TryGetLatestReport(string targetId, out ReconReport report)
{
for (int i = reports.Count - 1; i >= 0; i--)
if (reports[i].TargetId == targetId) { report = reports[i]; return true; }
report = default;
return false;
}
static string Csv(int[] v)
{
if (v == null) return "";
var parts = new string[v.Length];
for (int i = 0; i < v.Length; i++) parts[i] = v[i].ToString(CultureInfo.InvariantCulture);
return string.Join(",", parts);
}
static int[] ParseCsv(string s)
{
if (string.IsNullOrEmpty(s)) return Array.Empty<int>();
string[] parts = s.Split(',');
var v = new int[parts.Length];
for (int i = 0; i < parts.Length; i++) v[i] = I(parts[i]);
return v;
}
static string Clean(string s) => (s ?? "").Replace("|", "/").Replace("\n", " ");
void RestoreReport(string[] p)
{
if (p.Length < 12 || reports.Exists(r => r.Id == p[0])) return;
long.TryParse(p[4], NumberStyles.Integer, CultureInfo.InvariantCulture, out long time);
reports.Add(new ReconReport
{
Id = p[0], TargetId = p[1], TargetName = p[2], Level = I(p[3]), TimeMs = time,
Units = ParseCsv(p[5]), Fortified = p[6] == "1", HasHero = p[7] == "1", Modifiers = p[8],
ResourceNames = string.IsNullOrEmpty(p[9]) ? Array.Empty<string>() : p[9].Split(','),
ResourceMin = ParseCsv(p[10]), ResourceMax = ParseCsv(p[11])
});
}
// ---------------- Сохранение (секция "intelligence") ----------------
// Простые строки (модуль без Unity):
// tick=<мс> obzor=<уровень> grid=<ширина>x<высота>:<клетки 0/1 в hex>
// contact=<армия>|<владелец>|<опознан>|<видна>|x|y|toX|toY|min|max|<видели, мс>|<номер наблюдения>
// report=<id>|<цель>|<название>|<уровень>|<время, мс>|<войска,..>|<стены 1/0>|<герой 1/0>|<модификаторы>|<ресурсы,..>|<мин,..>|<макс,..>
public string SaveKey => "intelligence";
@@ -275,6 +378,13 @@ public sealed class IntelligenceService : IIntelligence, ISaveParticipant
N(c.x), N(c.y), N(c.toX), N(c.toY), N(c.approxMin), N(c.approxMax),
c.lastSeenMs.ToString(CultureInfo.InvariantCulture), N(c.observation)
})).Append('\n');
foreach (var r in reports)
sb.Append("report=").Append(string.Join("|", new[]
{
Clean(r.Id), Clean(r.TargetId), Clean(r.TargetName), N(r.Level), r.TimeMs.ToString(CultureInfo.InvariantCulture),
Csv(r.Units), r.Fortified ? "1" : "0", r.HasHero ? "1" : "0", Clean(r.Modifiers),
string.Join(",", r.ResourceNames ?? Array.Empty<string>()), Csv(r.ResourceMin), Csv(r.ResourceMax)
})).Append('\n');
return sb.ToString();
}
@@ -289,6 +399,7 @@ public sealed class IntelligenceService : IIntelligence, ISaveParticipant
else if (line.StartsWith("obzor=") && int.TryParse(line.Substring(6), NumberStyles.Integer, CultureInfo.InvariantCulture, out int lvl)) ObservationLevel = lvl;
else if (line.StartsWith("grid=")) RestoreGrid(line.Substring(5));
else if (line.StartsWith("contact=")) RestoreContact(line.Substring(8).Split('|'));
else if (line.StartsWith("report=")) RestoreReport(line.Substring(7).Split('|'));
}
viewDirty = true;
}
@@ -296,6 +407,7 @@ public sealed class IntelligenceService : IIntelligence, ISaveParticipant
public void ResetState()
{
contacts.Clear();
reports.Clear();
explored = Array.Empty<bool>();
gridW = gridH = halfW = halfH = 0;
lastTickMs = 0;
@@ -12,6 +12,10 @@ public sealed class PopulationRules
// Еда в час
public double FoodPerCivilianPerHour = 0.1;
public double FoodPerSoldierPerHour = 0.2;
// Еда воина по типам войск: пехота, лучники, кавалерия, осадные (решения по отчёту Саши, 01.11, п. 4: 0,15 / 0,20 / 0,15 / 0,15).
// Раненые едят полную норму своего типа, даже ожидая койку. null -- все воины едят FoodPerSoldierPerHour (как раньше).
// Новобранцы в Казармах -- по FoodPerSoldierPerHour (их норма не задана).
public double[] FoodPerTroopTypePerHour = null;
// Прирост +2–3 человека в час, если есть еда
public double GrowthPerHour = 2.5;
@@ -79,7 +79,8 @@ public sealed class PopulationService : IPopulation, ISaveParticipant
get
{
if (!GameServices.TryGet(out IArmy army)) return 0;
return army.SoldiersAtHome + army.TroopsInMarches + army.WoundedTotal; // раненые -- тоже люди (решения 31.10, п. 7)
// раненые -- тоже люди (решения 31.10, п. 7); пропавшие без вести занимают места до возвращения (решения 01.11, п. 5)
return army.SoldiersAtHome + army.TroopsInMarches + army.WoundedTotal + army.MissingTotal;
}
}
@@ -182,7 +183,19 @@ public sealed class PopulationService : IPopulation, ISaveParticipant
double FoodPerHourExact(int soldiers)
{
// Обучающиеся в Казармах едят как воины
return civilians * rules.FoodPerCivilianPerHour + (soldiers + inTraining) * rules.FoodPerSoldierPerHour;
return civilians * rules.FoodPerCivilianPerHour + SoldiersFood(soldiers) + inTraining * rules.FoodPerSoldierPerHour;
}
// Воины (в городе, в походах и раненые) едят по норме своего типа, если она задана (решения 01.11, п. 4)
double SoldiersFood(int soldiers)
{
var table = rules.FoodPerTroopTypePerHour;
int missingNow = GameServices.TryGet(out IArmy a) ? a.MissingTotal : 0; // пропавшие не едят
if (table == null || table.Length == 0 || !GameServices.TryGet(out IArmy army)) return Math.Max(0, soldiers - missingNow) * rules.FoodPerSoldierPerHour;
double food = 0;
for (int t = 0; t < army.TypeCount; t++)
food += army.GetAllTroops(t) * (t < table.Length ? table[t] : rules.FoodPerSoldierPerHour);
return food;
}
public int GetWorkers(WorkJob job)
@@ -221,18 +234,17 @@ public sealed class PopulationService : IPopulation, ISaveParticipant
{
lastTickUnix = unixNow; // первый расчёт: отсюда и считаем
Changed();
SimulatedUntil(unixNow);
return;
}
long elapsed = unixNow - lastTickUnix;
if (elapsed < 0)
{
// Часы ушли назад (сброс отладочной перемотки, перевод часов на телефоне). Метку НЕ двигаем назад:
// это время уже посчитано, и когда часы снова дойдут до него, оно не начислится второй раз (04.11).
// Часы ушли назад (сброс отладочной перемотки, перевод часов на телефоне) -- просто продолжаем с "сейчас"
lastTickUnix = unixNow;
return;
}
if (elapsed == 0) { SimulatedUntil(unixNow); return; }
if (elapsed == 0) return;
long max = (long)rules.MaxOfflineHours * SecondsPerHour;
if (elapsed > max) elapsed = max;
@@ -240,13 +252,6 @@ 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 });
}
// ---------------- Расчёт ----------------
@@ -268,10 +273,6 @@ public sealed class PopulationService : IPopulation, ISaveParticipant
remaining -= step;
double hours = step / (double)SecondsPerHour;
// Расчёт дошёл до начала шага: стройки, закончившиеся к этому моменту, вступают в силу,
// и добыча этого шага считается уже с ними (а не с начала офлайна)
SimulatedUntil(stepStart);
// 0. Добыча: рабочие приносят ресурсы (в том числе еду) -- до того, как город поест
production?.Produce(hours);
@@ -34,9 +34,6 @@ public class BuildGrid : MonoBehaviour
[Header("Где можно строить")]
[Tooltip("Посчитать область самому: старые участки + Ратуша + запас")]
public bool autoArea = true;
// Здание, которое сейчас переносят: его клетки считаются свободными (режим стройки, перенос)
public static GameObject IgnoredBuilding;
[Tooltip("Запас вокруг старых участков, клеток")]
public int padding = 3;
public Vector2 areaMin = new Vector2(-34, -10);
@@ -221,7 +218,6 @@ 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)
@@ -170,8 +170,6 @@ 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();
@@ -182,11 +180,6 @@ 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 });
@@ -1,177 +0,0 @@
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);
}
}
@@ -1,2 +0,0 @@
fileFormatVersion: 2
guid: dbfaba8641b05a9499eabe36c804fb22
@@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: bd42162aabdf44f5bc5eff56e8ff9ca6
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,14 @@
{
"name": "Bogatyri.Settlement.Placement",
"rootNamespace": "",
"references": [],
"includePlatforms": [],
"excludePlatforms": [],
"allowUnsafeCode": false,
"overrideReferences": false,
"precompiledReferences": [],
"autoReferenced": true,
"defineConstraints": [],
"versionDefines": [],
"noEngineReferences": true
}
@@ -0,0 +1,7 @@
fileFormatVersion: 2
guid: 5a79ac8eed504b9eb36740352ba25b73
AssemblyDefinitionImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,249 @@
using System;
using System.Collections.Generic;
// РАЗМЕЩЕНИЕ ЗДАНИЙ В 3D-ПОСЕЛЕНИИ (ТЗ «Система строительства поселения» от 04.11.2026, этап I).
// Документ: Docs/Modules/Settlement.md, раздел «3D-поселение».
//
// Без Unity: только клетки, зоны, препятствия и занятость -- те же правила будут на сервере и в тестах.
// Отображение (местность, кубы-здания, призрак) -- в слое приложения (Assets/Scripts/City3D).
//
// Невидимая логическая сетка: клетка CellSize единиц мира. Здание занимает прямоугольник клеток
// (его максимальный габарит -- с первого уровня, апгрейд клетки не расширяет). Поворот 0/90/180/270.
// Числа -- ТЕСТ, предложение Саши на согласование (ТЗ, раздел 12).
namespace Bogatyri.Settlement.Placement
{
public enum CellKind
{
Ground, // обычная земля
Water, // река -- постоянное препятствие
Mountain // гора, скала -- постоянное препятствие
}
// Почему здание нельзя поставить
public enum PlaceError
{
None,
OutOfMap, // за краем карты
ZoneLocked, // зона ещё не открыта уровнем Ратуши
Water, // река
Mountain, // гора
Obstacle, // дерево или валун -- можно расчистить
Occupied, // другое здание
TooClose, // нарушен технический отступ до другого здания
TooSteep // слишком большой перепад высоты под зданием
}
public sealed class PlacementRules
{
public float CellSize = 1f; // размер клетки в единицах мира
public int GapCells = 1; // технический отступ между основаниями зданий, клеток
public int MaxStepMm = 700; // наибольший перепад высоты под зданием (мм); меньше -- фундамент, больше -- нельзя
public static PlacementRules Default => new PlacementRules();
}
// Здание на карте: прямоугольник клеток. X, Y -- левый нижний угол уже с учётом поворота
public struct PlacedRect
{
public string Id;
public string Type;
public int X, Y;
public int SizeW, SizeH; // габарит без поворота
public int Rotation; // 0, 90, 180, 270
public int W => Rotation == 90 || Rotation == 270 ? SizeH : SizeW;
public int H => Rotation == 90 || Rotation == 270 ? SizeW : SizeH;
}
public sealed class SettlementMap
{
public readonly int Width, Height;
public readonly PlacementRules Rules;
private readonly CellKind[] kind;
private readonly int[] heightMm; // высота земли в центре клетки, мм
private readonly int[] zoneLevel; // с какого уровня Ратуши клетка доступна для стройки (0 -- никогда)
private readonly Dictionary<int, string> obstacleAt = new Dictionary<int, string>(); // клетка -> id дерева/валуна
private readonly Dictionary<string, List<int>> obstacleCells = new Dictionary<string, List<int>>();
private readonly HashSet<string> cleared = new HashSet<string>();
private readonly Dictionary<string, PlacedRect> buildings = new Dictionary<string, PlacedRect>();
public SettlementMap(int width, int height, PlacementRules rules = null)
{
Width = Math.Max(1, width);
Height = Math.Max(1, height);
Rules = rules ?? PlacementRules.Default;
kind = new CellKind[Width * Height];
heightMm = new int[Width * Height];
zoneLevel = new int[Width * Height];
}
// ---------------- Описание карты (заполняет генератор местности) ----------------
public bool InMap(int x, int y) => x >= 0 && y >= 0 && x < Width && y < Height;
int Index(int x, int y) => y * Width + x;
public void SetCell(int x, int y, CellKind k, int heightMillimeters, int openAtTownHall)
{
if (!InMap(x, y)) return;
int i = Index(x, y);
kind[i] = k;
heightMm[i] = heightMillimeters;
zoneLevel[i] = Math.Max(0, openAtTownHall);
}
public CellKind KindAt(int x, int y) => InMap(x, y) ? kind[Index(x, y)] : CellKind.Mountain;
public int HeightAt(int x, int y) => InMap(x, y) ? heightMm[Index(x, y)] : 0;
public int ZoneAt(int x, int y) => InMap(x, y) ? zoneLevel[Index(x, y)] : 0;
public bool IsOpen(int x, int y, int townHallLevel) { int z = ZoneAt(x, y); return z > 0 && z <= townHallLevel; }
// Дерево или валун: занимает клетки, пока его не расчистили. id -- постоянный в пределах поселения
public void AddObstacle(string id, IEnumerable<(int x, int y)> cells)
{
if (string.IsNullOrEmpty(id) || obstacleCells.ContainsKey(id)) return;
var list = new List<int>();
foreach (var c in cells)
{
if (!InMap(c.x, c.y)) continue;
int i = Index(c.x, c.y);
if (obstacleAt.ContainsKey(i)) continue;
obstacleAt[i] = id;
list.Add(i);
}
obstacleCells[id] = list;
if (cleared.Contains(id)) foreach (int i in list) obstacleAt.Remove(i); // уже расчищен (из сохранения)
}
public string ObstacleAt(int x, int y) => InMap(x, y) && obstacleAt.TryGetValue(Index(x, y), out string id) ? id : null;
public bool IsCleared(string obstacleId) => cleared.Contains(obstacleId);
public IReadOnlyCollection<string> Cleared => cleared;
// Расчистка: один раз на объект. true -- только что расчищен (за это одна награда); false -- нет такого или уже
public bool ClearObstacle(string obstacleId)
{
if (string.IsNullOrEmpty(obstacleId) || !obstacleCells.TryGetValue(obstacleId, out var list) || cleared.Contains(obstacleId)) return false;
cleared.Add(obstacleId);
foreach (int i in list) obstacleAt.Remove(i);
return true;
}
// Расчищенные из сохранения -- до или после AddObstacle, порядок не важен
public void RestoreCleared(IEnumerable<string> ids)
{
if (ids == null) return;
foreach (var id in ids)
{
if (string.IsNullOrEmpty(id) || !cleared.Add(id)) continue;
if (obstacleCells.TryGetValue(id, out var list)) foreach (int i in list) obstacleAt.Remove(i);
}
}
// ---------------- Здания ----------------
public IReadOnlyCollection<PlacedRect> Buildings => buildings.Values;
public bool TryGetBuilding(string id, out PlacedRect rect) => buildings.TryGetValue(id ?? "", out rect);
public string BuildingAt(int x, int y)
{
foreach (var b in buildings.Values)
if (x >= b.X && y >= b.Y && x < b.X + b.W && y < b.Y + b.H) return b.Id;
return null;
}
// Проверить место: прямоугольник sizeW x sizeH с поворотом rotation, левый нижний угол (x, y).
// ignoreId -- здание, которое переносят: его прежний след не мешает. foundationMm -- высота фундамента
public PlaceError Validate(int x, int y, int sizeW, int sizeH, int rotation, int townHallLevel, string ignoreId, out int foundationMm)
{
foundationMm = 0;
rotation = NormalizeRotation(rotation);
int w = rotation == 90 || rotation == 270 ? sizeH : sizeW;
int h = rotation == 90 || rotation == 270 ? sizeW : sizeH;
if (w <= 0 || h <= 0 || x < 0 || y < 0 || x + w > Width || y + h > Height) return PlaceError.OutOfMap;
int min = int.MaxValue, max = int.MinValue;
for (int cy = y; cy < y + h; cy++)
for (int cx = x; cx < x + w; cx++)
{
int i = Index(cx, cy);
if (kind[i] == CellKind.Water) return PlaceError.Water;
if (kind[i] == CellKind.Mountain) return PlaceError.Mountain;
if (!IsOpen(cx, cy, townHallLevel)) return PlaceError.ZoneLocked;
if (obstacleAt.ContainsKey(i)) return PlaceError.Obstacle;
min = Math.Min(min, heightMm[i]);
max = Math.Max(max, heightMm[i]);
}
int gap = Math.Max(0, Rules.GapCells);
foreach (var b in buildings.Values)
{
if (b.Id == ignoreId) continue;
if (Overlap(x, y, w, h, b.X, b.Y, b.W, b.H)) return PlaceError.Occupied;
if (Overlap(x - gap, y - gap, w + 2 * gap, h + 2 * gap, b.X, b.Y, b.W, b.H)) return PlaceError.TooClose;
}
if (max - min > Rules.MaxStepMm) return PlaceError.TooSteep;
foundationMm = max - min; // небольшой перепад -- площадка под зданием (верх -- по самой высокой клетке)
return PlaceError.None;
}
// Поставить новое здание. Место проверяется теми же правилами; false -- ничего не изменилось
public PlaceError Place(string id, string type, int x, int y, int sizeW, int sizeH, int rotation, int townHallLevel)
{
if (string.IsNullOrEmpty(id) || buildings.ContainsKey(id)) return PlaceError.Occupied;
PlaceError e = Validate(x, y, sizeW, sizeH, rotation, townHallLevel, null, out _);
if (e != PlaceError.None) return e;
buildings[id] = new PlacedRect { Id = id, Type = type, X = x, Y = y, SizeW = sizeW, SizeH = sizeH, Rotation = NormalizeRotation(rotation) };
return PlaceError.None;
}
// Перенос (ТЗ, раздел 5): то же здание с тем же id -- меняются только позиция и поворот, целиком или никак.
// Свой прежний след не мешает. Ошибка -- здание остаётся, где было
public PlaceError Move(string id, int x, int y, int rotation, int townHallLevel)
{
if (!buildings.TryGetValue(id ?? "", out PlacedRect b)) return PlaceError.OutOfMap;
PlaceError e = Validate(x, y, b.SizeW, b.SizeH, rotation, townHallLevel, id, out _);
if (e != PlaceError.None) return e;
b.X = x; b.Y = y; b.Rotation = NormalizeRotation(rotation);
buildings[id] = b;
return PlaceError.None;
}
// Восстановление из сохранения: без проверки зон (зона могла быть открыта раньше), но без наложений
public bool Restore(PlacedRect rect)
{
if (string.IsNullOrEmpty(rect.Id) || buildings.ContainsKey(rect.Id)) return false;
rect.Rotation = NormalizeRotation(rect.Rotation);
foreach (var b in buildings.Values)
if (Overlap(rect.X, rect.Y, rect.W, rect.H, b.X, b.Y, b.W, b.H)) return false;
buildings[rect.Id] = rect;
return true;
}
public static int NormalizeRotation(int rotation)
{
int r = ((rotation % 360) + 360) % 360;
return r / 90 * 90;
}
static bool Overlap(int ax, int ay, int aw, int ah, int bx, int by, int bw, int bh)
{
return ax < bx + bw && bx < ax + aw && ay < by + bh && by < ay + ah;
}
public static string ErrorText(PlaceError e)
{
switch (e)
{
case PlaceError.None: return "Можно ставить";
case PlaceError.OutOfMap: return "За краем карты";
case PlaceError.ZoneLocked: return "Зона не открыта: нужен уровень Ратуши выше";
case PlaceError.Water: return "Здесь река";
case PlaceError.Mountain: return "Здесь скалы";
case PlaceError.Obstacle: return "Мешает дерево или валун -- расчистите";
case PlaceError.Occupied: return "Место занято другим зданием";
case PlaceError.TooClose: return "Слишком близко к другому зданию";
case PlaceError.TooSteep: return "Слишком крутой склон";
default: return e.ToString();
}
}
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 70c26fdf9f764df5977ed79f7457f90e
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -16,11 +16,6 @@ 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 -- старые сохранения читаются без переделки
@@ -28,10 +23,6 @@ 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>();
}
@@ -55,34 +46,18 @@ public static class SettlementModule
static SettlementSaveData CaptureScene()
{
var data = new SettlementSaveData();
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;
}
}
if (TownHall.Instance != null) data.townHallLevel = TownHall.Instance.currentLevel;
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,
timerKind = timerOn ? (int)timer.kind : 0,
timerStartMs = timerOn ? timer.startMs : 0,
timerEndMs = timerOn ? timer.endMs : 0,
timerTargetLevel = timerOn ? timer.targetLevel : 0
level = leveled != null ? leveled.Level : 1
});
}
return data;
@@ -99,16 +74,7 @@ public static class SettlementModule
EventBus.Publish(new SettlementRestoringEvent()); // например, Казармы должны попасть в список стройки
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);
}
if (TownHall.Instance != null) TownHall.Instance.currentLevel = cached.townHallLevel;
// Сначала убираем уже заспавненные здания, чтобы не задвоить постройки
ClearPlacedBuildings();
@@ -132,14 +98,6 @@ 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)
@@ -166,8 +124,6 @@ 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);
}
}
@@ -211,12 +167,7 @@ public static class SettlementModule
if (!CityLoaded) return;
ClearPlacedBuildings();
if (TownHall.Instance != null)
{
TownHall.Instance.currentLevel = 1;
var thTimer = BuildTimer.Of(TownHall.Instance.gameObject);
if (thTimer != null) thTimer.Cancel();
}
if (TownHall.Instance != null) TownHall.Instance.currentLevel = 1;
appliedToBuildManager = BuildManager.Instance; // город уже в состоянии новой игры
EventBus.Publish(new TownHallLevelChangedEvent { Level = 1 });
}
@@ -12,7 +12,8 @@
"Bogatyri.World.Simulation",
"Bogatyri.Chat",
"Bogatyri.Circle",
"Bogatyri.Intelligence"
"Bogatyri.Intelligence",
"Bogatyri.Settlement.Placement"
],
"includePlatforms": [
"Editor"
+233
View File
@@ -1224,6 +1224,8 @@ public class ModuleTests
return ownerId == LocalPlayer.Id;
}
public void GetArmies(long timeMs, List<ArmyOnMap> result) => result.AddRange(Armies(timeMs));
public Dictionary<string, ReconTarget> Targets = new Dictionary<string, ReconTarget>();
public bool TryGetReconTarget(string targetId, out ReconTarget target) => Targets.TryGetValue(targetId ?? "", out target);
}
static ArmyOnMap Foreign(string id, int x, int y, int soldiers = 50) =>
@@ -1503,6 +1505,21 @@ public class ModuleTests
x = home.x; y = home.y;
return ownerId == LocalPlayer.Id;
}
public bool TryGetReconTarget(string targetId, out ReconTarget target)
{
target = default;
MapNode node = World.GetNode(targetId);
if (node == null || node.type == MapNodeType.ResourcePoint || node.type == MapNodeType.PlayerCity) return false;
target = new ReconTarget
{
Id = node.id, Name = node.displayName,
Kind = node.type == MapNodeType.BossCastle ? ReconTargetKind.Castle : node.type == MapNodeType.Camp ? ReconTargetKind.Camp : ReconTargetKind.Settlement,
Units = WorldMapLayout.GarrisonUnits(node), Fortified = WorldMapLayout.IsFortified(node),
ResourceNames = new[] { "Gold" }, ResourceAmounts = new[] { 100 }
};
return true;
}
public void GetArmies(long t, List<ArmyOnMap> result)
{
foreach (var m in World.Marches)
@@ -1949,4 +1966,220 @@ public class ModuleTests
Assert.AreEqual(20, army.GetWoundedWaiting(0) + 10, "ещё 20 легли сразу после улучшения: ждут 10");
Assert.AreEqual(40, army.Healing[0].Count + army.Healing[1].Count);
}
// Решения 01.11, п. 4: воины и раненые едят по норме своего типа, раненые -- даже ожидая койку
[Test]
public void Population_FoodByTroopType_IncludesWounded()
{
var army = new ArmyService(2, new HospitalRules { TemporaryBeds = 0 }); // мест нет -- все раненые ждут
GameServices.Register<IArmy>(army);
GameServices.Register<IEconomy>(new PopulationTests.FakeEconomy());
GameServices.Register<ISettlement>(new PopulationTests.FakeSettlement { TownHallLevel = 1 });
var population = new PopulationService(new PopulationRules { FoodPerTroopTypePerHour = new[] { 0.15, 0.2, 0.15, 0.15 } });
GameServices.Register<IPopulation>(population);
double civilians = population.Civilians * 0.1;
army.AddTroops(0, 20); // пехота 20 x 0,15 = 3
army.AddTroops(1, 10); // лучники 10 x 0,20 = 2
army.AddTroops(3, 4); // осадные 4 x 0,15 = 0,6
Assert.AreEqual(civilians + 5.6, population.FoodPerHour, 0.001);
army.TakeForMarch(1, 14); // в походе -- тоже едят
Assert.AreEqual(civilians + 5.6, population.FoodPerHour, 0.001, "поход не меняет еду");
int[] sent = army.GetMarchUnits(1);
var wounded = new int[4];
for (int t = 0; t < 4; t++) wounded[t] = sent[t] / 2; // половина ранена, остальные погибли
army.ReturnUnits(1, null, wounded, 0);
Assert.AreEqual(0, army.Healing.Count, "коек нет -- раненые ждут");
double expected = 0;
for (int t = 0; t < 4; t++) expected += army.GetAllTroops(t) * new[] { 0.15, 0.2, 0.15, 0.15 }[t];
Assert.AreEqual(civilians + expected, population.FoodPerHour, 0.001, "ждущие койку раненые едят полную норму");
Assert.Less(expected, 5.6, "погибшие больше не едят");
}
// ---------------- Пропавшие без вести (решения 01.11, п. 5) ----------------
[Test]
public void Battle_Missing_IsSeparateStatus_OffByDefault()
{
BattleOutcome Fight(int share)
{
var rules = BattleRules.NoRandom;
rules.MissingSharePermille = share;
return new BattleSystem(rules).Resolve(new BattleRequest { Attackers = 1000, Defenders = 1000, Seed = 5 });
}
BattleOutcome off = Fight(0);
Assert.AreEqual(0, off.AttackerMissingUnits[0], "доля не утверждена -- по умолчанию пропавших нет");
BattleOutcome on = Fight(200); // 20% выбывших
Assert.AreEqual(90, on.AttackerMissingUnits[0], "20% от 450 выбывших");
Assert.AreEqual(450, on.AttackerDeadUnits[0] + on.AttackerWoundedUnits[0] + on.AttackerMissingUnits[0], "каждый -- ровно в одном статусе");
Assert.AreEqual(off.Survivors, on.Survivors, "выжившие не меняются");
Assert.AreEqual(off.AttackerLosses, on.AttackerLosses);
}
static ArmyService MissingArmy(MissingRules rules = null)
{
var army = new ArmyService(2, new HospitalRules { TemporaryBeds = 100 }, rules ?? new MissingRules());
GameServices.Register<IArmy>(army);
army.AddTroops(0, 60);
army.AddTroops(1, 40);
Assert.IsTrue(army.TakeForMarch(1, 100));
return army;
}
[Test]
public void Army_Missing_ReturnInGroups_ThenWrittenOff()
{
const long H = 5 * 60_000; // игровой час
var army = MissingArmy(new MissingRules { ReturnPercent = 50, WoundedPercent = 30 });
var back = new List<MissingReturnedEvent>();
var off = new List<MissingWrittenOffEvent>();
EventBus.Subscribe<MissingReturnedEvent>(e => back.Add(e));
EventBus.Subscribe<MissingWrittenOffEvent>(e => off.Add(e));
long battle = 1000, travel = 8 * H;
Assert.IsTrue(army.MarkMissing(1, new[] { 30, 10, 0, 0 }, battle, travel));
Assert.IsFalse(army.MarkMissing(1, new[] { 30, 10, 0, 0 }, battle, travel), "один раз на поход");
Assert.AreEqual(40, army.MissingTotal);
Assert.AreEqual(60, army.TroopsInMarches, "пропавшие ушли из похода");
Assert.AreEqual(battle + travel, army.Missing[0].WindowStartMs, "окно -- после обратного пути");
Assert.AreEqual(battle + travel + 6 * H, army.Missing[0].EndMs, "окно -- 6 игровых часов");
Assert.AreEqual(0, army.ProcessMissing(battle + travel), "до окна никто не вернулся");
army.ProcessMissing(battle + travel + 6 * H - 1);
Assert.IsTrue(back.Count >= 2 && back.Count <= 4, "2-4 группы: " + back.Count);
int healthy = 0, wounded = 0;
foreach (var e in back) { healthy += e.Healthy; wounded += e.Wounded; }
Assert.AreEqual(20, healthy + wounded, "вернулась половина");
Assert.IsTrue(wounded >= 2 && wounded <= 12, "около 30% раненых: " + wounded);
Assert.AreEqual(wounded, army.WoundedTotal, "раненые -- в госпиталь");
army.ProcessMissing(battle + travel + 6 * H);
Assert.AreEqual(1, off.Count);
Assert.AreEqual(20, off[0].Count, "остальные списаны");
Assert.AreEqual(0, army.MissingTotal);
int groupsBefore = back.Count;
army.ProcessMissing(battle + travel + 100 * H);
Assert.AreEqual(groupsBefore, back.Count, "повторно никто не возвращается");
}
[Test]
public void Army_Missing_SaveRestore_NoDoubleReturn_AndPopulationReserve()
{
const long H = 5 * 60_000;
var army = MissingArmy();
GameServices.Register<IEconomy>(new PopulationTests.FakeEconomy());
GameServices.Register<ISettlement>(new PopulationTests.FakeSettlement { TownHallLevel = 1 });
var population = new PopulationService(new PopulationRules());
GameServices.Register<IPopulation>(population);
int total = population.Total;
float food = population.FoodPerHour;
army.MarkMissing(1, new[] { 20, 0, 0, 0 }, 0, 0);
Assert.AreEqual(total, population.Total, "пропавшие занимают места в населении");
Assert.AreEqual(food - 20 * 0.2f, population.FoodPerHour, 0.001f, "но не едят");
army.ProcessMissing(3 * H); // часть групп вернулась
string saved = army.Capture();
int home = army.GetTroops(0), hurt = army.WoundedTotal, missing = army.MissingTotal;
var copy = new ArmyService(2, new HospitalRules { TemporaryBeds = 100 });
copy.Restore(saved);
Assert.AreEqual(saved, copy.Capture(), "пропавшие и их группы сохраняются");
Assert.AreEqual(missing, copy.MissingTotal);
copy.ProcessMissing(3 * H);
Assert.AreEqual(home, copy.GetTroops(0), "после загрузки уже вернувшиеся не возвращаются второй раз");
Assert.AreEqual(hurt, copy.WoundedTotal);
Assert.IsFalse(copy.MarkMissing(1, new[] { 5, 0, 0, 0 }, 0, 0), "поход уже отмечен -- и после загрузки");
}
// ---------------- Разведка М2: целенаправленная разведка и отчёты (ТЗ разведки, раздел 6) ----------------
static FakeMap ReconMap()
{
var map = new FakeMap();
map.Targets["city"] = new ReconTarget { Id = "city", Name = "Ратибор", Kind = ReconTargetKind.Settlement,
Units = new[] { 10, 5, 3, 0 }, Fortified = true, ResourceNames = new[] { "Gold", "Food" }, ResourceAmounts = new[] { 100, 40 } };
map.Targets["castle"] = new ReconTarget { Id = "castle", Name = "Замок", Kind = ReconTargetKind.Castle, Units = new[] { 8, 6, 4, 2 }, Fortified = true };
return map;
}
[Test]
public void Recon_DurationByTargetAndLevel()
{
var intel = Intel(ReconMap());
const long H = 5 * 60_000;
Assert.AreEqual(2 * H, intel.ReconDurationMs("city", 2), "поселение, уровень II -- 2 игровых часа");
Assert.AreEqual(4 * H, intel.ReconDurationMs("city", 3));
Assert.AreEqual(3 * H, intel.ReconDurationMs("castle", 2), "замок -- 3 / 6");
Assert.AreEqual(6 * H, intel.ReconDurationMs("castle", 3));
Assert.AreEqual(0, intel.ReconDurationMs("nothing", 2), "нет такой цели");
Assert.AreEqual(0, intel.ReconDurationMs("city", 1), "уровень I -- без отчёта (это обычное наблюдение)");
}
[Test]
public void Recon_ReportLevels_OncePerOperation_Saved()
{
var map = ReconMap();
var intel = Intel(map);
var done = new List<ReconCompletedEvent>();
EventBus.Subscribe<ReconCompletedEvent>(e => done.Add(e));
Assert.IsTrue(intel.CompleteRecon("recon_1", "city", 2, 5000));
Assert.IsFalse(intel.CompleteRecon("recon_1", "city", 2, 9000), "одна операция -- один отчёт");
Assert.AreEqual(1, done.Count);
Assert.IsTrue(intel.TryGetLatestReport("city", out var r2));
Assert.AreEqual(new[] { 10, 5, 3, 0 }[0], r2.Units[0], "уровень II -- точные войска по типам");
Assert.IsTrue(r2.Fortified);
Assert.AreEqual(0, r2.ResourceNames.Length, "уровень II -- без ресурсов");
Assert.AreEqual(5000, r2.TimeMs, "время отчёта -- конец наблюдения");
// Цель изменилась после отчёта -- старый отчёт не обновляется (нет постоянного слежения)
var t = map.Targets["city"]; t.Units = new[] { 50, 0, 0, 0 }; map.Targets["city"] = t;
Assert.AreEqual(10, intel.Reports[0].Units[0]);
Assert.IsTrue(intel.CompleteRecon("recon_2", "city", 3, 7000));
intel.TryGetLatestReport("city", out var r3);
Assert.AreEqual(50, r3.Units[0], "новый отчёт -- на момент своей операции");
Assert.AreEqual(2, r3.ResourceNames.Length, "уровень III -- оценка ресурсов");
Assert.IsTrue(r3.ResourceMin[0] <= 100 && 100 <= r3.ResourceMax[0], "настоящее значение внутри окна");
Assert.AreEqual(30, r3.ResourceMax[0] - r3.ResourceMin[0], "±15% от 100 = окно 30");
StringAssertContains(r3.Modifiers, "стен");
var copy = Intel(map);
copy.Restore(intel.Capture());
Assert.AreEqual(intel.Capture(), copy.Capture(), "отчёты сохраняются");
Assert.IsFalse(copy.CompleteRecon("recon_2", "city", 3, 7000), "после загрузки отчёт не создаётся второй раз");
}
static void StringAssertContains(string text, string part) => Assert.IsTrue(text != null && text.Contains(part), $"«{text}» не содержит «{part}»");
[Test]
public void World_ReconMarch_ObservesThenReturns()
{
const long H = 5 * 60_000;
long t0 = 1_000_000_000_000;
var world = new WorldSimulation(WorldMapLayout.CreateDefault(), new BattleSystem());
var intel = Intel(new WorldAsMap { World = world });
MarchData m = world.StartMarch(LocalPlayer.Id, WorldMapLayout.PlayerCityId, WorldMapLayout.BossCastleId, 5, t0, out _, scout: true);
Assert.IsTrue(world.SetRecon(m, 3, intel.ReconDurationMs(WorldMapLayout.BossCastleId, 3)));
world.AssignUnits(m, new[] { 5, 0, 0, 0 });
var events = new List<WorldEvent>();
world.Advance(m.arriveMs, events);
Assert.AreEqual(WorldEventType.ScoutArrived, events[0].Type);
Assert.AreEqual(MarchState.Gathering, m.state, "стоят у цели и наблюдают");
Assert.AreEqual(m.arriveMs + 6 * H, m.gatherEndMs, "замок, уровень III -- 6 игровых часов");
Assert.IsTrue(world.TryGetPositionAt(m, m.arriveMs + H, out int x, out int y, out _, out _, out _));
MapNode boss = world.GetNode(WorldMapLayout.BossCastleId);
Assert.AreEqual(boss.x, x, "во время наблюдения -- у цели");
events.Clear();
world.Advance(m.returnMs, events);
Assert.AreEqual(WorldEventType.ReconCompleted, events[0].Type, "сначала -- отчёт");
Assert.AreEqual(WorldEventType.MarchReturned, events[1].Type, "потом -- домой");
Assert.AreEqual(5, m.survivors, "разведка без потерь");
Assert.IsTrue(intel.CompleteRecon("recon_" + m.id, m.targetNodeId, m.reconLevel, m.gatherEndMs));
}
}
@@ -0,0 +1,121 @@
using System.Collections.Generic;
using Bogatyri.Settlement.Placement;
using NUnit.Framework;
// Тесты размещения зданий в 3D-поселении (ТЗ «Система строительства поселения» от 04.11.2026, раздел 11).
// Запуск: Window -> General -> Test Runner -> EditMode -> Run All.
public class PlacementTests
{
// Карта 20 x 20: всё открыто с Ратуши 1, кроме правой полосы (x >= 15 -- Ратуша 2);
// река по x = 10, скала в (2, 18), дерево "tree_1" в (5, 5), холм справа вверху
static SettlementMap Map()
{
var map = new SettlementMap(20, 20);
for (int y = 0; y < 20; y++)
for (int x = 0; x < 20; x++)
{
CellKind k = x == 10 ? CellKind.Water : x == 2 && y == 18 ? CellKind.Mountain : CellKind.Ground;
int height = x >= 16 && y >= 16 ? (x - 16) * 500 : 0; // склон: 0,5 м на клетку
map.SetCell(x, y, k, height, x >= 15 ? 2 : 1);
}
map.AddObstacle("tree_1", new[] { (5, 5) });
return map;
}
[Test]
public void Placement_FlatGround_Ok_AndForbiddenPlaces()
{
var map = Map();
Assert.AreEqual(PlaceError.None, map.Place("hall", "Ратуша", 1, 1, 3, 3, 0, 1), "01: ровная земля");
Assert.AreEqual(PlaceError.Water, map.Validate(9, 2, 2, 2, 0, 1, null, out _), "02: река");
Assert.AreEqual(PlaceError.Mountain, map.Validate(1, 17, 2, 2, 0, 1, null, out _), "02: скала");
Assert.AreEqual(PlaceError.Obstacle, map.Validate(4, 4, 2, 2, 0, 1, null, out _), "дерево мешает");
Assert.AreEqual(PlaceError.OutOfMap, map.Validate(19, 19, 2, 2, 0, 1, null, out _), "за краем");
Assert.AreEqual(PlaceError.ZoneLocked, map.Validate(15, 2, 2, 2, 0, 1, null, out _), "зона Ратуши 2");
Assert.AreEqual(PlaceError.None, map.Validate(15, 2, 2, 2, 0, 2, null, out _), "11: Ратуша 2 открыла зону");
}
[Test]
public void Placement_GapBetweenBuildings()
{
var map = Map();
map.Place("a", "Дом", 1, 1, 2, 2, 0, 1);
Assert.AreEqual(PlaceError.Occupied, map.Validate(2, 2, 2, 2, 0, 1, null, out _));
Assert.AreEqual(PlaceError.TooClose, map.Validate(3, 1, 2, 2, 0, 1, null, out _), "03: вплотную -- нарушен отступ");
Assert.AreEqual(PlaceError.None, map.Validate(4, 1, 2, 2, 0, 1, null, out _), "через клетку -- можно");
}
[Test]
public void Placement_Rotation_SwapsFootprint()
{
var map = Map();
// 4 x 1 у реки: повёрнутое на 90 -- 1 x 4 -- помещается вдоль реки, без поворота -- упирается в неё
Assert.AreEqual(PlaceError.Water, map.Validate(7, 12, 4, 1, 0, 1, null, out _));
foreach (int rot in new[] { 90, 270 })
Assert.AreEqual(PlaceError.None, map.Validate(9, 12, 4, 1, rot, 1, null, out _), "04: поворот " + rot);
Assert.AreEqual(PlaceError.Water, map.Validate(7, 12, 4, 1, 180, 1, null, out _), "04: 180 -- как 0");
map.Place("long", "Склад", 9, 12, 4, 1, 90, 1);
Assert.IsTrue(map.TryGetBuilding("long", out var b));
Assert.AreEqual(1, b.W);
Assert.AreEqual(4, b.H);
Assert.AreEqual("long", map.BuildingAt(9, 15));
Assert.IsNull(map.BuildingAt(10, 12));
}
[Test]
public void Placement_Slope_FoundationOrTooSteep()
{
var map = Map();
Assert.AreEqual(PlaceError.None, map.Validate(16, 16, 2, 2, 0, 2, null, out int f), "небольшой перепад");
Assert.AreEqual(500, f, "фундамент 0,5 м");
Assert.AreEqual(PlaceError.TooSteep, map.Validate(16, 16, 3, 2, 0, 2, null, out _), "1 м -- слишком круто");
}
[Test]
public void Placement_Move_IsAtomic_KeepsId()
{
var map = Map();
map.Place("farm", "Ферма", 1, 1, 3, 3, 0, 1);
map.Place("mine", "Шахта", 12, 1, 2, 2, 0, 1);
Assert.AreEqual(PlaceError.None, map.Move("farm", 2, 1, 0, 1), "свой прежний след не мешает");
Assert.IsTrue(map.TryGetBuilding("farm", out var moved));
Assert.AreEqual(2, moved.X);
Assert.AreEqual(PlaceError.Water, map.Move("farm", 8, 1, 0, 1), "09: ошибка -- здание на месте");
map.TryGetBuilding("farm", out var same);
Assert.AreEqual(2, same.X);
Assert.AreEqual(2, map.Buildings.Count, "без дублей");
Assert.AreEqual(PlaceError.None, map.Move("mine", 12, 6, 90, 1), "05: шахту можно переносить и поворачивать");
Assert.AreEqual(PlaceError.OutOfMap, map.Move("nope", 1, 1, 0, 1));
}
[Test]
public void Placement_ClearObstacle_OnceOnly_SurvivesReload()
{
var map = Map();
Assert.IsTrue(map.ClearObstacle("tree_1"), "10: расчищено -- одна награда");
Assert.IsFalse(map.ClearObstacle("tree_1"), "второй раз -- без награды");
Assert.IsNull(map.ObstacleAt(5, 5));
Assert.AreEqual(PlaceError.None, map.Validate(4, 4, 2, 2, 0, 1, null, out _));
// Загрузка: расчищенное не возвращается, даже если отмечено раньше, чем объект добавлен на карту
var copy = new SettlementMap(20, 20);
for (int y = 0; y < 20; y++) for (int x = 0; x < 20; x++) copy.SetCell(x, y, CellKind.Ground, 0, 1);
copy.RestoreCleared(new List<string>(map.Cleared));
copy.AddObstacle("tree_1", new[] { (5, 5) });
Assert.IsNull(copy.ObstacleAt(5, 5));
Assert.IsFalse(copy.ClearObstacle("tree_1"));
}
[Test]
public void Placement_Restore_NoOverlaps()
{
var map = Map();
Assert.IsTrue(map.Restore(new PlacedRect { Id = "a", Type = "Дом", X = 1, Y = 1, SizeW = 2, SizeH = 2, Rotation = 0 }));
Assert.IsFalse(map.Restore(new PlacedRect { Id = "b", Type = "Дом", X = 2, Y = 2, SizeW = 2, SizeH = 2, Rotation = 0 }), "12: наложение не восстанавливается");
Assert.IsFalse(map.Restore(new PlacedRect { Id = "a", Type = "Дом", X = 6, Y = 6, SizeW = 2, SizeH = 2, Rotation = 0 }), "тот же id -- не дубль");
Assert.AreEqual(270, SettlementMap.NormalizeRotation(-90));
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 73f551b6ba7242e5bb6a9b8e983be5fe
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -456,7 +456,7 @@ public class PopulationTests
}
[Test]
public void ClockGoesBack_NothingHappens_CountedTimeNotCountedAgain()
public void ClockGoesBack_NothingHappens_ThenContinues()
{
economy.Set(ResourceType.Food, 1000);
var p = StartCity();
@@ -465,12 +465,8 @@ 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, economy.Get(ResourceType.Food), 0.0001, "часы ещё не дошли до уже посчитанного -- ничего");
p.Tick(T0 + Hour);
Assert.AreEqual(1000 - 33, economy.Get(ResourceType.Food), 0.01, "дальше метки -- ровно один час");
Assert.AreEqual(1000 - 33, economy.Get(ResourceType.Food), 0.01);
}
[Test]
@@ -245,96 +245,4 @@ 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_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 минут + конец");
}
}
+57 -2
View File
@@ -28,7 +28,8 @@ public class MarchManager : MonoBehaviour, IWorld, IWorldMap, ISaveParticipant
public event Action<MarchData> OnBattleResolved;
public event Action<MarchData> OnMarchReturned;
public event Action<MarchData> OnGatherStarted; // дошли до ресурсной точки (targetEmpty -- точки уже нет)
public event Action<MarchData> OnScoutArrived; // разведка дошла до цели и повернула домой
public event Action<MarchData> OnScoutArrived; // разведка дошла до цели и повернула домой (или начала наблюдение)
public event Action<MarchData> OnReconCompleted; // Разведка М2: наблюдение закончено, отчёт готов
// Время мира -- по часам игры (GameClock): перемотка "+1 час" в отладочной панели проматывает и походы.
// Миллисекунды берём от настоящих часов, чтобы отряды на карте двигались плавно. В мультиплеере -- время сервера.
@@ -113,6 +114,7 @@ public class MarchManager : MonoBehaviour, IWorld, IWorldMap, ISaveParticipant
else if (e.Type == WorldEventType.MarchReturned) HandleMarchReturned(e.March);
else if (e.Type == WorldEventType.GatherStarted) HandleGatherStarted(e.March);
else if (e.Type == WorldEventType.ScoutArrived) HandleScoutArrived(e.March);
else if (e.Type == WorldEventType.ReconCompleted) HandleReconCompleted(e.March);
else if (e.Type == WorldEventType.GatherFinished)
Debug.Log($"[СБОР] Сбор окончен: {e.March.cargo} ({World.TargetName(e.March)}), войско идёт домой");
}
@@ -173,6 +175,28 @@ public class MarchManager : MonoBehaviour, IWorld, IWorldMap, ISaveParticipant
return false;
}
// Цель разведки с отчётом (М2): замок, соседний город, лагерь -- гарнизон, стены, что можно взять
public bool TryGetReconTarget(string targetId, out ReconTarget target)
{
target = default;
MapNode node = World.GetNode(targetId);
if (node == null) return false;
ReconTargetKind kind = node.type == MapNodeType.BossCastle ? ReconTargetKind.Castle
: node.type == MapNodeType.NeighborCity ? ReconTargetKind.Settlement
: node.type == MapNodeType.Camp ? ReconTargetKind.Camp : ReconTargetKind.None;
if (kind == ReconTargetKind.None) return false;
var names = new string[node.rewards.Count];
var amounts = new int[node.rewards.Count];
for (int i = 0; i < node.rewards.Count; i++) { names[i] = node.rewards[i].resource; amounts[i] = node.rewards[i].amount; }
target = new ReconTarget
{
Id = node.id, Name = node.displayName, Kind = kind,
Units = WorldMapLayout.GarrisonUnits(node), Fortified = WorldMapLayout.IsFortified(node), HasHero = false, // героев пока нет
ResourceNames = names, ResourceAmounts = amounts
};
return true;
}
// Все армии на карте в момент timeMs: свои походы, патрули соседей (позже -- армии других игроков с сервера)
public void GetArmies(long timeMs, List<ArmyOnMap> result)
{
@@ -194,7 +218,17 @@ public class MarchManager : MonoBehaviour, IWorld, IWorldMap, ISaveParticipant
return TryStart(targetId, soldiers, true, out error);
}
bool TryStart(string targetId, int soldiers, bool scout, out string error)
// Разведка с отчётом (Разведка М2): уровень II или III, наблюдение у цели -- время уровня из модуля Разведки
public bool TryStartRecon(string targetId, int soldiers, int level, out string error)
{
if (level < 2 || level > 3) { error = "Уровень разведки -- II или III"; return false; }
if (!GameServices.TryGet(out IIntelligence intel)) { error = "Модуль Разведки не подключён"; return false; }
long hold = intel.ReconDurationMs(targetId, level);
if (hold <= 0) { error = "Разведку с отчётом можно отправить на поселение, замок или лагерь"; return false; }
return TryStart(targetId, soldiers, true, out error, level, hold);
}
bool TryStart(string targetId, int soldiers, bool scout, out string error, int reconLevel = 0, long reconHoldMs = 0)
{
if (!GameServices.TryGet(out IArmy army))
{
@@ -218,6 +252,7 @@ public class MarchManager : MonoBehaviour, IWorld, IWorldMap, ISaveParticipant
MarchData march = World.StartMarch(LocalPlayerId, WorldMapLayout.PlayerCityId, targetId, soldiers, NowMs, out error, scout);
if (march == null) return false;
if (reconLevel > 0) World.SetRecon(march, reconLevel, reconHoldMs);
// Отправка целиком или никак: поход существует, только если Армия действительно забрала воинов.
// Иначе поход откатывается -- он не должен вернуть домой воинов, которых не забирал.
@@ -234,11 +269,30 @@ public class MarchManager : MonoBehaviour, IWorld, IWorldMap, ISaveParticipant
Debug.Log($"[ПОХОД] {soldiers} солдат выступили {(scout ? "в разведку" : "на")} {World.GetNode(targetId).displayName}");
OnMarchStarted?.Invoke(march);
EventBus.Publish(new MarchStartedEvent { MarchId = march.id, TimeMs = march.departMs, TargetId = targetId, Soldiers = soldiers, IsScout = scout });
if (reconLevel > 0)
EventBus.Publish(new ReconStartedEvent { EventId = ReportId(march), TargetId = targetId, Level = reconLevel, TimeMs = march.departMs });
return true;
}
static string ReportId(MarchData march) => "recon_" + march.id;
// Наблюдение закончено: Разведка составляет отчёт по цели на этот момент (один раз на поход)
void HandleReconCompleted(MarchData march)
{
if (GameServices.TryGet(out IIntelligence intel))
intel.CompleteRecon(ReportId(march), march.targetNodeId, march.reconLevel, march.gatherEndMs);
Debug.Log($"[РАЗВЕДКА] Отчёт уровня {march.reconLevel}: {World.TargetName(march)}");
OnReconCompleted?.Invoke(march);
}
void HandleBattleResolved(MarchData march)
{
// Пропавшие без вести (решения 01.11, п. 5): уходят из похода в Армию отдельной группой -- вернутся сами
if (Sum(march.missingUnits) > 0 && GameServices.TryGet(out IArmy army))
{
army.MarkMissing(march.id, march.missingUnits, march.arriveMs, Math.Max(0, march.returnMs - march.arriveMs));
army.ProcessMissing(NowMs);
}
MapNode target = World.GetNode(march.targetNodeId);
if (march.targetEmpty) Debug.Log($"[ПОХОД] {target.displayName} уже разбит -- войско возвращается без боя");
else Debug.Log($"[ПОХОД] Бой у {target.displayName}: {(march.victory ? "ПОБЕДА" : "поражение")}. " +
@@ -310,6 +364,7 @@ public class MarchManager : MonoBehaviour, IWorld, IWorldMap, ISaveParticipant
IsGather = march.gather,
IsScout = march.scout,
Wounded = Sum(march.woundedUnits),
Missing = Sum(march.missingUnits),
TargetName = World.TargetName(march),
LootText = FormatLoot(march.loot)
});
@@ -169,7 +169,7 @@ namespace Bogatyri.World
return true;
}
long backStart = m.gather ? m.gatherEndMs : m.arriveMs;
long backStart = m.gather || m.reconLevel > 0 && m.gatherEndMs > 0 ? m.gatherEndMs : m.arriveMs; // после сбора / наблюдения
soldiers = m.state == MarchState.Gathering || m.gather && t < backStart ? m.soldiers : m.survivors;
if (soldiers <= 0) return false; // все пали в бою -- армии на карте нет
if (m.state == MarchState.Gathering || t < backStart)
@@ -30,6 +30,8 @@ namespace Bogatyri.World
public bool gather; // поход на ресурсную точку: сбор вместо боя
public bool patrol; // патруль-заглушка соседа (Разведка М1): без боя и добычи, Армии игрока не касается
public bool scout; // поход «Разведка» (решения 31.10, п. 2): дойти до цели и вернуться, без боя и сбора
public int reconLevel; // Разведка М2: 0 -- просто дойти и вернуться; 2 / 3 -- наблюдать reconHoldMs и составить отчёт
public long reconHoldMs; // сколько наблюдать цель (мс часов игры): время уровня из модуля Разведки
public long gatherEndMs; // когда сбор закончится (груз полон, точка истощена или исчезла)
public int cargo; // сколько ресурса несёт войско (известно с начала сбора)
public int defenders; // гарнизон цели в момент боя
@@ -37,6 +39,7 @@ namespace Bogatyri.World
public int[] units; // состав похода по типам (TroopType); пусто -- старый поход, все считаются пехотой
public int[] survivorUnits; // выжившие по типам (известно после боя)
public int[] woundedUnits; // раненые по типам: идут домой с войском и ложатся в госпиталь (решения 31.10, п. 7)
public int[] missingUnits; // пропавшие без вести по типам: возвращаются сами, группами (решения 01.11, п. 5)
public long battleSeed; // номер боя: из него случайность сторон. Задаётся при выступлении и сохраняется
public long attackerPower; // итог боя: силы сторон x100 и выпавшая случайность (десятые доли процента)
@@ -146,7 +149,8 @@ namespace Bogatyri.World
MarchReturned, // войско вернулось домой
GatherStarted, // войско дошло до ресурсной точки и начало сбор (или точка исчезла -- targetEmpty)
GatherFinished, // сбор закончен, войско идёт домой с грузом
ScoutArrived // разведка дошла до цели и повернула домой (targetEmpty -- цели уже нет)
ScoutArrived, // разведка дошла до цели: повернула домой или (М2) начала наблюдение (targetEmpty -- цели уже нет)
ReconCompleted // М2: наблюдение закончено -- пора составить отчёт, разведчики идут домой
}
public struct WorldEvent
@@ -372,6 +376,11 @@ namespace Bogatyri.World
ArriveScout(next);
events.Add(new WorldEvent { Type = WorldEventType.ScoutArrived, March = next, TimeMs = nextMs });
}
else if (nextKind == 1)
{
next.state = MarchState.Returning; // наблюдение закончено, обратный путь уже рассчитан
events.Add(new WorldEvent { Type = WorldEventType.ReconCompleted, March = next, TimeMs = nextMs });
}
else
{
next.state = MarchState.Finished;
@@ -454,6 +463,7 @@ namespace Bogatyri.World
m.survivors = outcome.Survivors;
m.survivorUnits = HasUnits(m) ? outcome.AttackerSurvivorUnits : null;
m.woundedUnits = HasUnits(m) ? outcome.AttackerWoundedUnits : null; // старый поход без состава -- раненые как погибшие
m.missingUnits = HasUnits(m) ? outcome.AttackerMissingUnits : null;
if (m.survivors > 0) // обратно идут выжившие -- со скоростью своего состава
m.returnMs = m.arriveMs + TravelTimeMs(target, from, m.survivorUnits ?? (HasUnits(m) ? m.units : null));
m.defenders = target.garrison;
@@ -485,6 +495,23 @@ namespace Bogatyri.World
m.loot = new List<RewardEntry>();
ResourcePoint p = target.type == MapNodeType.ResourcePoint ? GetPoint(target.id) : null;
m.targetEmpty = target.type == MapNodeType.ResourcePoint && (p == null || !p.active);
// М2: разведчики стоят у цели время наблюдения, потом идут домой (обратный путь -- от конца наблюдения)
if (m.reconLevel > 0 && m.reconHoldMs > 0 && !m.targetEmpty)
{
m.state = MarchState.Gathering;
m.gatherEndMs = m.arriveMs + m.reconHoldMs;
m.returnMs = m.gatherEndMs + TravelTimeMs(target, from, units);
}
}
// Разведка с отчётом (М2): уровень и время наблюдения. Только пока разведчики ещё в пути к цели
public bool SetRecon(MarchData m, int level, long holdMs)
{
if (m == null || !m.scout || m.state != MarchState.Outbound || !marches.Contains(m)) return false;
m.reconLevel = level;
m.reconHoldMs = Math.Max(0, holdMs);
return true;
}
// Название цели похода: запомненное при выступлении (старые походы -- с карты)
@@ -540,7 +567,7 @@ namespace Bogatyri.World
{
segmentFrom = target;
segmentTo = home;
start = m.gather ? m.gatherEndMs : m.arriveMs; // после сбора -- обратно от конца сбора
start = m.gather || m.reconLevel > 0 && m.gatherEndMs > 0 ? m.gatherEndMs : m.arriveMs; // после сбора / наблюдения -- от его конца
end = m.returnMs;
}
+2 -1
View File
@@ -39,11 +39,12 @@ public static class MarchReturnPopup
? (e.TargetEmpty ? "точки уже не было или её заняли, сбора не было" : "<color=#7CD67C>ресурсы собраны</color>")
: e.TargetEmpty ? "лагерь уже был разбит, боя не было"
: e.Victory ? "<color=#7CD67C>победа</color>" : "<color=#E06A5A>поражение</color>";
int dead = Mathf.Max(0, e.Sent - e.Survivors - e.Wounded);
int dead = Mathf.Max(0, e.Sent - e.Survivors - e.Wounded - e.Missing);
string text = $"{target}: {result}\n" +
$"Вернулись {e.Survivors} из {e.Sent} воинов" + (dead > 0 ? $" (погибли {dead})" : "") + "\n" +
(e.Wounded > 0 ? $"Раненых {e.Wounded} -- в госпитале (окно «Войска»)\n" : "") +
(e.Missing > 0 ? $"Пропали без вести {e.Missing} -- часть может вернуться сама\n" : "") +
$"Добыча: {e.LootText}";
UIKit.Message("ВОЙСКО ВЕРНУЛОСЬ", text, "ОК", ShowNext);
+1 -1
View File
@@ -118,7 +118,7 @@ public static class ArmiesWindow
timer = $"до цели {Clock(m.arriveMs - now)}, вернётся примерно через {Clock(ExpectedReturn(m) - now)}";
break;
case MarchState.Gathering:
action = $"собирает: <b>{target}</b>";
action = m.scout ? $"наблюдает за: <b>{target}</b> (разведка {(m.reconLevel == 3 ? "III" : "II")})" : $"собирает: <b>{target}</b>";
timer = $"сбор ещё {Clock(m.gatherEndMs - now)}, вернётся через {Clock(ExpectedReturn(m) - now)}";
break;
default:
+30 -36
View File
@@ -7,14 +7,17 @@ using UnityEngine;
// здесь только рисуем то, что он вернул.
//
// Туман: неисследованное -- тёмное, исследованное -- притушено, в зоне обзора -- чисто.
// По умолчанию туман только затемняет землю: города, лагеря и точки видны (IntelligenceConfig.hideUnexploredObjects).
// 2.5D (04.11.2026): туман рисуется прямо в текстуре земли -- объекты (города, лагеря, точки) стоят над ней и видны всегда,
// как требует решение 31.10, п. 1. hideUnexploredObjects в 3D пока только сильнее затемняет землю.
// Чужие армии -- только те, что знает Разведка: в обзоре -- красные, с примерной численностью «~min–max»
// и владельцем, если опознан; вышли из обзора -- бледная метка последнего контакта.
public class IntelligenceOverlay : MonoBehaviour
{
static readonly Color ObservedColor = new Color(0.9f, 0.25f, 0.2f);
static readonly Color GhostColor = new Color(0.9f, 0.25f, 0.2f, 0.35f);
const float MarkerSize = 0.55f;
static readonly Color GhostColor = new Color(0.75f, 0.55f, 0.55f); // бледная -- последний контакт
static readonly Color GroundColor = new Color(0.3f, 0.42f, 0.25f);
static readonly Color FogColor = new Color(0.04f, 0.055f, 0.08f);
const float MarkerSize = 0.55f, MarkerHeight = 0.5f;
const int CellSize = 1000;
private Transform mapRoot;
@@ -23,17 +26,19 @@ public class IntelligenceOverlay : MonoBehaviour
private Color32[] pixels;
private int gridW, gridH;
private float nextFogRefresh;
private readonly Dictionary<string, (SpriteRenderer sprite, TextMeshPro label)> markers = new Dictionary<string, (SpriteRenderer, TextMeshPro)>();
private Renderer ground;
private readonly Dictionary<string, (Renderer sprite, TextMeshPro label)> markers = new Dictionary<string, (Renderer, TextMeshPro)>();
private readonly HashSet<string> alive = new HashSet<string>();
private readonly List<string> stale = new List<string>();
public static IntelligenceOverlay Create(Transform mapRoot, WorldSimulation world)
public static IntelligenceOverlay Create(Transform mapRoot, WorldSimulation world, Renderer ground)
{
var go = new GameObject("Intelligence");
go.transform.SetParent(mapRoot, false);
var overlay = go.AddComponent<IntelligenceOverlay>();
overlay.mapRoot = mapRoot;
overlay.world = world;
overlay.ground = ground;
overlay.CreateFog();
return overlay;
}
@@ -48,16 +53,12 @@ public class IntelligenceOverlay : MonoBehaviour
wrapMode = TextureWrapMode.Clamp
};
pixels = new Color32[gridW * gridH];
var go = new GameObject("Fog");
go.transform.SetParent(transform, false);
var sr = go.AddComponent<SpriteRenderer>();
// 1 пиксель = 1 клетка карты = 1 единица Unity; центр спрайта -- центр карты
sr.sprite = Sprite.Create(fog, new Rect(0, 0, gridW, gridH), new Vector2(0.5f, 0.5f), 1f);
go.transform.position = new Vector3((gridW * CellSize / 2f - WorldMapLayout.HalfWidth) / 1000f,
(gridH * CellSize / 2f - WorldMapLayout.HalfHeight) / 1000f, 0f);
// Объекты видны -- туман между землёй (-10) и объектами (0+). Скрыты -- поверх объектов, под войсками (30)
sr.sortingOrder = IntelligenceConfig.Load().hideUnexploredObjects ? 20 : -9;
// 1 пиксель = 1 клетка карты; текстура натянута на всю землю (UV 0..1 -- от левого нижнего угла карты)
if (ground != null)
{
if (ground.sharedMaterial.HasProperty("_BaseMap")) ground.sharedMaterial.SetTexture("_BaseMap", fog);
if (ground.sharedMaterial.HasProperty("_MainTex")) ground.sharedMaterial.SetTexture("_MainTex", fog);
}
RefreshFog();
}
@@ -89,7 +90,11 @@ public class IntelligenceOverlay : MonoBehaviour
int y = cy * CellSize + CellSize / 2 - WorldMapLayout.HalfHeight;
MapVisibility v = intel.GetVisibility(x, y);
byte a = v == MapVisibility.Observed ? (byte)0 : v == MapVisibility.Explored ? explored : unexplored;
pixels[cy * gridW + cx] = new Color32(10, 14, 20, a);
// трава разных оттенков (пятнами), поверх -- туман
Color grass = Color.Lerp(GroundColor, new Color(0.42f, 0.55f, 0.3f), Mathf.PerlinNoise(cx * 0.35f, cy * 0.35f));
Color c = Color.Lerp(grass, FogColor, a / 255f);
c.a = 1f;
pixels[cy * gridW + cx] = c;
}
fog.SetPixels32(pixels);
fog.Apply(false);
@@ -107,9 +112,9 @@ public class IntelligenceOverlay : MonoBehaviour
m = CreateMarker(c.ArmyId);
markers[c.ArmyId] = m;
}
m.sprite.transform.position = new Vector3(c.X / 1000f, c.Y / 1000f, 0f);
m.label.transform.position = m.sprite.transform.position + new Vector3(0, 0.75f, 0);
m.sprite.color = c.Observed ? ObservedColor : GhostColor;
m.sprite.transform.position = new Vector3(c.X / 1000f, MarkerHeight / 2f, c.Y / 1000f);
m.label.transform.position = new Vector3(c.X / 1000f, MarkerHeight + 0.8f, c.Y / 1000f);
m.sprite.sharedMaterial = Map3D.Mat(c.Observed ? ObservedColor : GhostColor);
string owner = c.Identified ? OwnerName(c.OwnerId) : "чужая армия";
m.label.text = c.Observed
@@ -128,24 +133,13 @@ public class IntelligenceOverlay : MonoBehaviour
}
}
(SpriteRenderer, TextMeshPro) CreateMarker(string armyId)
(Renderer, TextMeshPro) CreateMarker(string armyId)
{
var go = new GameObject("Contact_" + armyId);
go.transform.SetParent(mapRoot, false);
go.transform.localScale = Vector3.one * MarkerSize;
go.transform.rotation = Quaternion.Euler(0, 0, 45); // ромб-флажок: не путается со своими квадратиками
var sr = go.AddComponent<SpriteRenderer>();
sr.sprite = PrimitiveSprites.Square;
sr.sortingOrder = 32;
var lgo = new GameObject("ContactLabel_" + armyId);
lgo.transform.SetParent(mapRoot, false);
var label = lgo.AddComponent<TextMeshPro>();
label.fontSize = 2.6f;
label.alignment = TextAlignmentOptions.Center;
label.rectTransform.sizeDelta = new Vector2(6f, 1.2f);
label.sortingOrder = 33;
return (sr, label);
var r = Map3D.Block(mapRoot, "Contact_" + armyId, PrimitiveType.Cube, ObservedColor, Vector3.zero, Vector2.one * MarkerSize, MarkerHeight);
r.transform.rotation = Quaternion.Euler(0, 45, 0); // ромб-флажок: не путается со своими кубиками
var label = Map3D.Label(mapRoot, "", Vector3.zero, 2.6f);
label.gameObject.name = "ContactLabel_" + armyId;
return (r, label);
}
// Имя владельца -- по его поселению на карте («npc_3» -> «Велимир»)
+297
View File
@@ -0,0 +1,297 @@
using System.Collections.Generic;
using TMPro;
using UnityEngine;
using UnityEngine.Rendering;
using UnityEngine.Rendering.Universal;
// 3D НА ПРИМИТИВАХ: общие помощники для карты мира и 3D-поселения (переход на 2.5D, 04.11.2026).
// Материал -- свой, на URP Lit (Resources/Map3D/Map3DLit.mat), цвета кешируются.
// Свои простые формы (конус, пирамида, двускатная крыша) -- для деревьев, шатров, крыш.
// Подписи (TextMeshPro) поворачиваются к камере (FaceCamera).
public static class Map3D
{
static Material baseMaterial;
static readonly Dictionary<Color, Material> materials = new Dictionary<Color, Material>();
static Mesh cone, pyramid, roof;
// ---------------- Материалы ----------------
public static Material Mat(Color color)
{
if (baseMaterial == null)
{
// Свой материал на URP Lit (Resources/Map3D/Map3DLit): материал встроенных примитивов в 2D-проекте --
// спрайтовый, без света и без цвета. Нет ассета -- ищем шейдер по имени
var asset = Resources.Load<Material>("Map3D/Map3DLit");
var shader = Shader.Find("Universal Render Pipeline/Lit");
baseMaterial = asset != null ? new Material(asset) : shader != null ? new Material(shader) : null;
if (baseMaterial == null)
{
var probe = GameObject.CreatePrimitive(PrimitiveType.Cube);
baseMaterial = new Material(probe.GetComponent<Renderer>().sharedMaterial);
Object.Destroy(probe);
Debug.LogWarning("[3D] Нет материала Resources/Map3D/Map3DLit и шейдера URP Lit -- 3D будет без света");
}
materials.Clear();
}
if (!materials.TryGetValue(color, out var m) || m == null)
{
m = new Material(baseMaterial);
SetColor(m, color);
if (m.HasProperty("_Smoothness")) m.SetFloat("_Smoothness", 0.12f); // матовые, без пластикового блеска
materials[color] = m;
}
return m;
}
// URP берёт цвет из _BaseColor, встроенный шейдер -- из _Color: ставим оба
public static void SetColor(Material m, Color color)
{
if (m.HasProperty("_BaseColor")) m.SetColor("_BaseColor", color);
if (m.HasProperty("_Color")) m.SetColor("_Color", color);
}
// Материал с текстурой (земля): своя копия, не из кеша
public static Material TexturedMat(Texture texture)
{
var m = new Material(Mat(Color.white));
if (m.HasProperty("_BaseMap")) m.SetTexture("_BaseMap", texture);
if (m.HasProperty("_MainTex")) m.SetTexture("_MainTex", texture);
return m;
}
// ---------------- Простые формы ----------------
// Объект на земле: основание size.x * size.y (по X и Z), высота height, низ -- на уровне ground.y
public static Renderer Block(Transform parent, string name, PrimitiveType type, Color color, Vector3 ground, Vector2 size, float height,
bool collider = false)
{
var go = GameObject.CreatePrimitive(type);
go.name = name;
if (!collider) Object.Destroy(go.GetComponent<Collider>());
go.transform.SetParent(parent, false);
// У цилиндра высота -- 2 единицы при масштабе 1
float scaleY = type == PrimitiveType.Cylinder || type == PrimitiveType.Capsule ? height / 2f : height;
go.transform.localScale = new Vector3(size.x, scaleY, size.y);
go.transform.position = ground + Vector3.up * height / 2f;
var r = go.GetComponent<Renderer>();
r.sharedMaterial = Mat(color);
return r;
}
// Своя форма (конус, пирамида, крыша): основание size.x * size.y, высота height, низ -- на ground
public static Renderer Shape(Transform parent, string name, Mesh mesh, Color color, Vector3 ground, Vector2 size, float height, float yaw = 0f)
{
var go = new GameObject(name);
go.transform.SetParent(parent, false);
go.transform.position = ground;
go.transform.rotation = Quaternion.Euler(0, yaw, 0);
go.transform.localScale = new Vector3(size.x, height, size.y);
go.AddComponent<MeshFilter>().sharedMesh = mesh;
var r = go.AddComponent<MeshRenderer>();
r.sharedMaterial = Mat(color);
return r;
}
// Конус: основание -- круг диаметром 1 на y = 0, вершина -- (0, 1, 0)
public static Mesh Cone => cone != null ? cone : cone = BuildCone(10);
// Пирамида: основание 1 x 1, вершина -- (0, 1, 0)
public static Mesh Pyramid => pyramid != null ? pyramid : pyramid = BuildCone(4, 45f, 0.7071f);
// Двускатная крыша: основание 1 x 1, конёк вдоль X на высоте 1
public static Mesh Roof => roof != null ? roof : roof = BuildRoof();
static Mesh BuildCone(int sides, float startDeg = 0f, float radius = 0.5f)
{
var v = new List<Vector3>();
var t = new List<int>();
for (int i = 0; i < sides; i++)
{
float a0 = (startDeg + 360f * i / sides) * Mathf.Deg2Rad, a1 = (startDeg + 360f * (i + 1) / sides) * Mathf.Deg2Rad;
Vector3 p0 = new Vector3(Mathf.Cos(a0) * radius, 0, Mathf.Sin(a0) * radius);
Vector3 p1 = new Vector3(Mathf.Cos(a1) * radius, 0, Mathf.Sin(a1) * radius);
TwoSided(v, t, p0, Vector3.up, p1); // бок -- свои вершины: грани с резкими нормалями
TwoSided(v, t, p0, p1, Vector3.zero); // дно
}
var mesh = new Mesh { name = "Cone" + sides, vertices = v.ToArray(), triangles = t.ToArray() };
mesh.RecalculateNormals();
mesh.RecalculateBounds();
return mesh;
}
// Треугольник с обеих сторон: у каждой стороны свои вершины и своя нормаль
static void TwoSided(List<Vector3> v, List<int> t, Vector3 p, Vector3 q, Vector3 r)
{
int i = v.Count;
v.Add(p); v.Add(q); v.Add(r);
t.Add(i); t.Add(i + 1); t.Add(i + 2);
i = v.Count;
v.Add(p); v.Add(r); v.Add(q);
t.Add(i); t.Add(i + 1); t.Add(i + 2);
}
static Mesh BuildRoof()
{
Vector3 a = new Vector3(-0.5f, 0, -0.5f), b = new Vector3(0.5f, 0, -0.5f), c = new Vector3(0.5f, 0, 0.5f), d = new Vector3(-0.5f, 0, 0.5f);
Vector3 r0 = new Vector3(-0.5f, 1, 0), r1 = new Vector3(0.5f, 1, 0);
var v = new List<Vector3>();
var t = new List<int>();
void Tri(Vector3 p, Vector3 q, Vector3 r) => TwoSided(v, t, p, q, r);
Tri(a, r0, r1); Tri(a, r1, b); // скат к -Z
Tri(c, r1, r0); Tri(c, r0, d); // скат к +Z
Tri(d, r0, a); Tri(b, r1, c); // торцы
Tri(a, b, c); Tri(a, c, d); // дно
var mesh = new Mesh { name = "Roof", vertices = v.ToArray(), triangles = t.ToArray() };
mesh.RecalculateNormals();
mesh.RecalculateBounds();
return mesh;
}
// ---------------- Готовые фигуры ----------------
// Домик: стены w x d (по X и Z), высота стен h, двускатная крыша; поворот yaw
public static Transform House(Transform parent, string name, Vector3 ground, float w, float d, float h, Color walls, Color roofColor, float yaw = 0f)
{
var root = new GameObject(name).transform;
root.SetParent(parent, false);
root.position = ground;
root.rotation = Quaternion.Euler(0, yaw, 0);
var body = Block(root, "Walls", PrimitiveType.Cube, walls, ground, new Vector2(w, d), h);
body.transform.localPosition = new Vector3(0, h / 2f, 0);
body.transform.localRotation = Quaternion.identity;
var top = Shape(root, "Roof", Roof, roofColor, ground, new Vector2(w * 1.12f, d * 1.15f), Mathf.Max(0.25f, Mathf.Min(w, d) * 0.55f));
top.transform.localPosition = new Vector3(0, h, 0);
top.transform.localRotation = Quaternion.identity;
return root;
}
static readonly Color Trunk = new Color(0.42f, 0.28f, 0.16f);
static readonly Color[] Leaves = { new Color(0.2f, 0.46f, 0.2f), new Color(0.26f, 0.52f, 0.22f), new Color(0.32f, 0.55f, 0.2f) };
static readonly Color[] Pine = { new Color(0.12f, 0.36f, 0.22f), new Color(0.16f, 0.4f, 0.24f) };
// Дерево: ель (конусы) или лиственное (шар). variant -- для разнообразия, scale -- размер
public static Transform Tree(Transform parent, string name, Vector3 ground, float scale, int variant)
{
var root = new GameObject(name).transform;
root.SetParent(parent, false);
root.position = ground;
var trunk = Block(root, "Trunk", PrimitiveType.Cylinder, Trunk, ground, Vector2.one * 0.18f * scale, 0.6f * scale);
if (variant % 3 == 0)
{
var crown = Block(root, "Crown", PrimitiveType.Sphere, Leaves[variant % Leaves.Length], ground + Vector3.up * 0.45f * scale,
new Vector2(1.0f, 0.95f) * scale, 1.05f * scale);
}
else
{
Color c = Pine[variant % Pine.Length];
Shape(root, "Pine1", Cone, c, ground + Vector3.up * 0.35f * scale, Vector2.one * 1.0f * scale, 1.0f * scale);
Shape(root, "Pine2", Cone, c, ground + Vector3.up * 0.8f * scale, Vector2.one * 0.72f * scale, 0.85f * scale);
}
return root;
}
static readonly Color[] Stones = { new Color(0.55f, 0.55f, 0.56f), new Color(0.5f, 0.48f, 0.45f), new Color(0.62f, 0.6f, 0.58f) };
// Валун: сплюснутый шар, повёрнутый случайно по variant
public static Transform Rock(Transform parent, string name, Vector3 ground, float size, int variant)
{
var root = new GameObject(name).transform;
root.SetParent(parent, false);
root.position = ground;
var r = Block(root, "Stone", PrimitiveType.Sphere, Stones[variant % Stones.Length], ground + Vector3.down * 0.1f * size,
new Vector2(size, size * 0.85f), size * 0.65f);
r.transform.rotation = Quaternion.Euler(variant * 7 % 20, variant * 53 % 360, 0);
if (size > 1.2f)
{
var small = Block(root, "Stone2", PrimitiveType.Sphere, Stones[(variant + 1) % Stones.Length],
ground + new Vector3(size * 0.4f, 0, size * 0.3f), Vector2.one * size * 0.45f, size * 0.35f);
}
return root;
}
// Флаг на древке
public static void Flag(Transform parent, Vector3 ground, float height, Color color)
{
Block(parent, "Pole", PrimitiveType.Cylinder, new Color(0.35f, 0.25f, 0.15f), ground, Vector2.one * 0.06f, height);
var f = Block(parent, "Flag", PrimitiveType.Cube, color, ground + new Vector3(0.22f, height - 0.32f, 0), new Vector2(0.42f, 0.03f), 0.28f);
}
// ---------------- Подписи, свет, рендерер ----------------
// Подпись над объектом, всегда лицом к камере
public static TextMeshPro Label(Transform parent, string text, Vector3 position, float fontSize)
{
var go = new GameObject("Label");
go.transform.SetParent(parent, false);
go.transform.position = position;
var tmp = go.AddComponent<TextMeshPro>();
tmp.text = text;
tmp.fontSize = fontSize;
tmp.alignment = TextAlignmentOptions.Center;
tmp.rectTransform.sizeDelta = new Vector2(6f, 1.6f);
tmp.color = Color.white;
tmp.outlineWidth = 0.25f;
tmp.outlineColor = Color.black;
go.AddComponent<FaceCamera>();
return tmp;
}
// Камере -- обычный 3D-рендерер URP (добавляет Tools -> Bogatyri -> 3D-город: создать сцену City3D).
// Нет его -- остаётся рендерер по умолчанию, и в лог пишется, что делать.
public static void UseRenderer3D(Camera cam)
{
var pipeline = (GraphicsSettings.currentRenderPipeline ?? QualitySettings.renderPipeline) as UniversalRenderPipelineAsset;
var data = cam != null ? cam.GetUniversalAdditionalCameraData() : null;
if (pipeline == null || data == null) return;
var list = pipeline.rendererDataList;
for (int i = 0; i < list.Length; i++)
if (list[i] is UniversalRendererData) { data.SetRenderer(i); return; }
Debug.LogWarning("[3D] В URP нет обычного 3D-рендерера: выполните Tools -> Bogatyri -> 3D-город: создать сцену City3D (он добавится). Без него 3D рисуется без света.");
}
// Солнце, общий свет, небо и дымка вдали (2D-сцены света не имели)
public static void SetUpLook(Camera cam, float fogStart, float fogEnd)
{
Light sun = null;
foreach (var l in Object.FindObjectsByType<Light>(FindObjectsSortMode.None))
if (l.type == LightType.Directional) { sun = l; break; }
if (sun == null)
{
sun = new GameObject("Sun").AddComponent<Light>();
sun.type = LightType.Directional;
}
sun.color = new Color(1f, 0.95f, 0.85f); // тёплое солнце
sun.intensity = 1.25f;
sun.shadows = LightShadows.Soft;
sun.shadowStrength = 0.65f;
sun.transform.rotation = Quaternion.Euler(48f, -35f, 0f);
RenderSettings.ambientMode = AmbientMode.Trilight; // небо голубое, горизонт светлый, снизу -- тёплая земля
RenderSettings.ambientSkyColor = new Color(0.62f, 0.72f, 0.86f);
RenderSettings.ambientEquatorColor = new Color(0.55f, 0.6f, 0.58f);
RenderSettings.ambientGroundColor = new Color(0.35f, 0.32f, 0.25f);
Color sky = new Color(0.66f, 0.8f, 0.94f);
RenderSettings.fog = true;
RenderSettings.fogMode = FogMode.Linear;
RenderSettings.fogColor = sky;
RenderSettings.fogStartDistance = fogStart;
RenderSettings.fogEndDistance = fogEnd;
if (cam != null)
{
cam.clearFlags = CameraClearFlags.SolidColor;
cam.backgroundColor = sky;
}
}
}
// Подпись поворачивается к камере (наклонный вид 2.5D)
public class FaceCamera : MonoBehaviour
{
static Camera cam;
void LateUpdate()
{
if (cam == null) cam = Camera.main;
if (cam != null) transform.rotation = cam.transform.rotation;
}
}
+11
View File
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: c36fcaa4cddf4c76ad06c759afc8301e
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
+46 -22
View File
@@ -1,15 +1,22 @@
using UnityEngine;
using UnityEngine.EventSystems;
// Камера карты мира: перетаскивание (палец/мышь), зум (два пальца/колёсико), границы карты.
// Камера карты мира, 2.5D (переход на 3D, 04.11.2026): наклонный вид под фиксированным углом, без вращения.
// Перетаскивание (палец/мышь) двигает карту, два пальца/колёсико -- приближение, границы карты.
// Отличает "тап" от "перетаскивания": тап -- это касание, при котором палец почти не сдвинулся.
// Карта лежит на земле: координаты карты (x, y) -> мир (x, 0, y).
[DefaultExecutionOrder(-10)] // считать тап раньше, чем его прочитает WorldMapController
[RequireComponent(typeof(Camera))]
public class MapCameraController : MonoBehaviour
{
[Header("Вид")]
public float pitch = 55f; // наклон, градусы от горизонта
public float yaw = 0f; // север карты -- вверху экрана, как было в 2D
public float distance = 18f;
[Header("Зум")]
public float minOrthoSize = 4f;
public float maxOrthoSize = 16f;
public float minDistance = 7f;
public float maxDistance = 42f;
public float mouseZoomSpeed = 2f;
[Header("Тап")]
@@ -20,6 +27,7 @@ public class MapCameraController : MonoBehaviour
[HideInInspector] public Vector2 mapHalfSize = new Vector2(20f, 14f);
private Camera cam;
private Vector3 target; // точка на земле, на которую смотрит камера
private Vector3 lastPointerPosition;
private Vector3 pressPosition;
private bool isPressed;
@@ -31,9 +39,15 @@ public class MapCameraController : MonoBehaviour
void Awake()
{
cam = GetComponent<Camera>();
cam.orthographic = false;
cam.fieldOfView = 40f;
cam.nearClipPlane = 0.3f;
cam.farClipPlane = 300f;
Map3D.UseRenderer3D(cam);
Apply();
}
// Было ли за этот кадр нажатие по карте (не по UI и не перетаскивание)
// Было ли за этот кадр нажатие по карте (не по UI и не перетаскивание). Точка -- на земле (x, 0, z)
public bool TryGetTap(out Vector3 worldPosition)
{
worldPosition = tapWorldPosition;
@@ -42,8 +56,9 @@ public class MapCameraController : MonoBehaviour
public void CenterOn(Vector3 worldPosition)
{
transform.position = new Vector3(worldPosition.x, worldPosition.y, transform.position.z);
target = new Vector3(worldPosition.x, 0f, worldPosition.z);
ClampToMap();
Apply();
}
void Update()
@@ -54,12 +69,14 @@ public class MapCameraController : MonoBehaviour
{
HandlePinch();
isPressed = false; // после щипка не засчитываем отпускание как тап
Apply();
return;
}
HandlePointer();
HandleMouseWheel();
ClampToMap();
Apply();
}
void HandlePointer()
@@ -86,10 +103,10 @@ public class MapCameraController : MonoBehaviour
if (isDragging)
{
// Сдвигаем камеру ровно на столько, на сколько сдвинулся палец в мире -- карта "прилипает" к пальцу
Vector3 previous = cam.ScreenToWorldPoint(lastPointerPosition);
Vector3 current = cam.ScreenToWorldPoint(Input.mousePosition);
transform.position += previous - current;
// Сдвигаем камеру ровно на столько, на сколько сдвинулся палец по земле -- карта "прилипает" к пальцу
target += GroundPoint(lastPointerPosition) - GroundPoint(Input.mousePosition);
ClampToMap();
Apply();
}
lastPointerPosition = Input.mousePosition;
}
@@ -99,8 +116,7 @@ public class MapCameraController : MonoBehaviour
if (!isDragging)
{
hasTap = true;
tapWorldPosition = cam.ScreenToWorldPoint(Input.mousePosition);
tapWorldPosition.z = 0f;
tapWorldPosition = GroundPoint(Input.mousePosition);
}
isPressed = false;
isDragging = false;
@@ -116,7 +132,7 @@ public class MapCameraController : MonoBehaviour
float currentDistance = (a.position - b.position).magnitude;
if (currentDistance <= 0.01f) return;
cam.orthographicSize = Mathf.Clamp(cam.orthographicSize * previousDistance / currentDistance, minOrthoSize, maxOrthoSize);
distance = Mathf.Clamp(distance * previousDistance / currentDistance, minDistance, maxDistance);
ClampToMap();
}
@@ -124,22 +140,30 @@ public class MapCameraController : MonoBehaviour
{
float scroll = Input.GetAxis("Mouse ScrollWheel");
if (scroll == 0f || IsPointerOverUI()) return;
cam.orthographicSize = Mathf.Clamp(cam.orthographicSize - scroll * mouseZoomSpeed * 5f, minOrthoSize, maxOrthoSize);
distance = Mathf.Clamp(distance - scroll * mouseZoomSpeed * 10f, minDistance, maxDistance);
}
// Камера не уезжает за край карты (если карта меньше экрана -- центрируем)
// Точка, на которую смотрит камера, не уходит за край карты
void ClampToMap()
{
float halfViewHeight = cam.orthographicSize;
float halfViewWidth = halfViewHeight * cam.aspect;
target.x = Mathf.Clamp(target.x, -mapHalfSize.x, mapHalfSize.x);
target.z = Mathf.Clamp(target.z, -mapHalfSize.y, mapHalfSize.y);
target.y = 0f;
}
float maxX = Mathf.Max(0f, mapHalfSize.x - halfViewWidth);
float maxY = Mathf.Max(0f, mapHalfSize.y - halfViewHeight);
void Apply()
{
Quaternion rot = Quaternion.Euler(pitch, yaw, 0);
transform.rotation = rot;
transform.position = target - rot * Vector3.forward * distance;
}
Vector3 p = transform.position;
p.x = Mathf.Clamp(p.x, -maxX, maxX);
p.y = Mathf.Clamp(p.y, -maxY, maxY);
transform.position = p;
// Точка на земле (y = 0) под экранной точкой
Vector3 GroundPoint(Vector3 screen)
{
Ray ray = cam.ScreenPointToRay(screen);
var plane = new Plane(Vector3.up, Vector3.zero);
return plane.Raycast(ray, out float t) ? ray.GetPoint(t) : target;
}
static bool IsPointerOverUI()
+26 -24
View File
@@ -2,7 +2,7 @@ using Bogatyri.World;
using TMPro;
using UnityEngine;
// Войско в походе на карте: группа маленьких квадратиков + подпись с числом солдат и таймером.
// Войско в походе на карте: группа маленьких кубиков на земле + подпись с числом солдат и таймером (2.5D, 04.11.2026).
// Сам ничего не решает -- позицию и состояние берёт из WorldSimulation.
public class MarchView : MonoBehaviour
{
@@ -16,7 +16,7 @@ public class MarchView : MonoBehaviour
public int MarchId { get; private set; }
private SpriteRenderer[] squares;
private Renderer[] squares;
private TextMeshPro label;
private GameObject routeLine;
private MarchState shownState;
@@ -36,12 +36,15 @@ public class MarchView : MonoBehaviour
{
var go = new GameObject("Label");
go.transform.SetParent(transform, false);
go.transform.localPosition = new Vector3(0, 0.75f, 0);
go.transform.localPosition = new Vector3(0, 1.0f, 0);
label = go.AddComponent<TextMeshPro>();
label.fontSize = 3f;
label.alignment = TextAlignmentOptions.Center;
label.rectTransform.sizeDelta = new Vector2(4f, 0.8f);
label.sortingOrder = 31;
label.rectTransform.sizeDelta = new Vector2(5f, 0.9f);
label.color = Color.white;
label.outlineWidth = 0.25f;
label.outlineColor = Color.black;
go.AddComponent<FaceCamera>();
}
// Перестраиваем квадратики, только когда поменялось число солдат (после боя)
@@ -54,7 +57,7 @@ public class MarchView : MonoBehaviour
int count = Mathf.Clamp(soldiers, 1, MaxSquares);
int rows = Mathf.CeilToInt(count / (float)SquaresPerRow);
squares = new SpriteRenderer[count];
squares = new Renderer[count];
for (int i = 0; i < count; i++)
{
@@ -62,19 +65,19 @@ public class MarchView : MonoBehaviour
int row = i / SquaresPerRow;
int inThisRow = Mathf.Min(SquaresPerRow, count - row * SquaresPerRow);
var go = new GameObject("Soldier");
var go = GameObject.CreatePrimitive(PrimitiveType.Cube);
go.name = "Soldier";
Destroy(go.GetComponent<Collider>());
go.transform.SetParent(transform, false);
go.transform.localPosition = new Vector3(
(col - (inThisRow - 1) / 2f) * SquareSpacing,
((rows - 1) / 2f - row) * SquareSpacing,
0f);
SquareSize / 2f,
((rows - 1) / 2f - row) * SquareSpacing);
go.transform.localScale = Vector3.one * SquareSize;
var sr = go.AddComponent<SpriteRenderer>();
sr.sprite = PrimitiveSprites.Square;
sr.color = color;
sr.sortingOrder = 30;
squares[i] = sr;
var r = go.GetComponent<Renderer>();
r.sharedMaterial = Map3D.Mat(color);
squares[i] = r;
}
}
@@ -85,16 +88,15 @@ public class MarchView : MonoBehaviour
Vector3 target = WorldMapController.ToWorld(world.GetNode(march.targetNodeId));
Vector3 delta = target - home;
routeLine = new GameObject($"Route_{march.id}");
// Тонкая полоса на земле от города до цели
routeLine = GameObject.CreatePrimitive(PrimitiveType.Cube);
routeLine.name = $"Route_{march.id}";
Destroy(routeLine.GetComponent<Collider>());
routeLine.transform.SetParent(transform.parent, false);
routeLine.transform.position = (home + target) / 2f;
routeLine.transform.localScale = new Vector3(delta.magnitude, 0.08f, 1f);
routeLine.transform.rotation = Quaternion.Euler(0, 0, Mathf.Atan2(delta.y, delta.x) * Mathf.Rad2Deg);
var sr = routeLine.AddComponent<SpriteRenderer>();
sr.sprite = PrimitiveSprites.Square;
sr.color = new Color(1f, 1f, 1f, 0.35f);
sr.sortingOrder = -5;
routeLine.transform.position = (home + target) / 2f + Vector3.up * 0.02f;
routeLine.transform.localScale = new Vector3(delta.magnitude, 0.02f, 0.08f);
routeLine.transform.rotation = Quaternion.Euler(0, -Mathf.Atan2(delta.z, delta.x) * Mathf.Rad2Deg, 0);
routeLine.GetComponent<Renderer>().sharedMaterial = Map3D.Mat(new Color(0.85f, 0.85f, 0.8f));
}
void OnDestroy()
@@ -123,7 +125,7 @@ public class MarchView : MonoBehaviour
long endMs = returning ? march.returnMs : gathering ? march.gatherEndMs : march.arriveMs;
long secondsLeft = System.Math.Max(0, (endMs - nowMs + 999) / 1000);
string direction = returning ? (march.cargo > 0 ? $"домой, груз {march.cargo}" : "домой")
: gathering ? $"сбор {march.cargo}"
: gathering ? (march.scout ? "наблюдает" : $"сбор {march.cargo}")
: march.scout ? "разведка" : march.gather ? "на сбор" : "в бой";
// Марши идут часами (регламент 31.10) -- «1:05:30», меньше часа -- «12:30»
string time = secondsLeft >= 3600
@@ -0,0 +1,72 @@
using System.Collections.Generic;
using TMPro;
using UnityEngine;
// Отчёты разведки на экране (Разведка М2): текст отчёта и окно «Отчёты» с последними отчётами.
// Сами отчёты хранит модуль Разведки (IIntelligence.Reports) -- здесь только показ.
// Сведения -- на момент отчёта: сколько прошло с тех пор, видно в каждом отчёте.
public static class ReconReportText
{
static readonly string[] TroopNames = { "пехота", "лучники", "кавалерия", "осадные" };
static readonly Dictionary<string, string> ResourceNames = new Dictionary<string, string>
{
{ "Food", "еда" }, { "Wood", "дерево" }, { "Stone", "камень" }, { "Iron", "металл" }, { "Gold", "золото" }, { "Gems", "кристаллы" }
};
public static string Format(ReconReport r, long nowMs)
{
var lines = new List<string>
{
$"<b>{r.TargetName}</b> — разведка {(r.Level == 3 ? "III" : "II")}, {Ago(nowMs - r.TimeMs)} назад",
};
int total = 0;
var parts = new List<string>();
for (int i = 0; r.Units != null && i < r.Units.Length; i++)
{
total += r.Units[i];
if (r.Units[i] > 0) parts.Add($"{TroopNames[i % TroopNames.Length]} {r.Units[i]}");
}
lines.Add($"Гарнизон: {total}" + (parts.Count > 0 ? $" ({string.Join(", ", parts)})" : ""));
lines.Add((r.Fortified ? "Защитники за стенами" : "Стен нет, бой в поле") + (r.HasHero ? ", есть герой" : ", героя нет"));
if (r.Level >= 3)
{
if (!string.IsNullOrEmpty(r.Modifiers)) lines.Add("Защита: " + r.Modifiers);
var res = new List<string>();
for (int i = 0; r.ResourceNames != null && i < r.ResourceNames.Length; i++)
{
string name = ResourceNames.TryGetValue(r.ResourceNames[i], out var n) ? n : r.ResourceNames[i];
res.Add($"{name} ~{r.ResourceMin[i]}–{r.ResourceMax[i]}");
}
if (res.Count > 0) lines.Add("Ресурсы (оценка ±15%): " + string.Join(", ", res));
}
return string.Join("\n", lines);
}
static string Ago(long ms)
{
long min = System.Math.Max(0, ms) / 60_000;
if (min < 1) return "только что";
return min < 60 ? $"{min} мин" : $"{min / 60} ч {min % 60} мин";
}
// Окно «Отчёты»: последние отчёты, новые сверху
public static void OpenWindow()
{
var card = UIKit.Modal("ОТЧЁТЫ РАЗВЕДКИ", out GameObject root);
if (!GameServices.TryGet(out IIntelligence intel) || intel.Reports.Count == 0)
{
UIKit.Text(card, "Отчётов пока нет. Нажмите на город, лагерь или замок и отправьте «Разведку II» или «Разведку III».",
26, UIKit.Theme.TextDim, TextAlignmentOptions.Center);
}
else
{
long now = MarchManager.NowMs;
int shown = 0;
for (int i = intel.Reports.Count - 1; i >= 0 && shown < 5; i--, shown++)
UIKit.Text(card, Format(intel.Reports[i], now), 24, UIKit.Theme.Text, TextAlignmentOptions.Left);
if (intel.Reports.Count > 5)
UIKit.Text(card, $"И ещё {intel.Reports.Count - 5} более старых", 22, UIKit.Theme.TextDim, TextAlignmentOptions.Center);
}
UIKit.Button(card, "Закрыть", UIKit.Theme.Gray, () => Object.Destroy(root), -1, 80, 28);
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 76599837523e444e87459016c4f367a4
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
+221 -76
View File
@@ -48,7 +48,10 @@ public class WorldMapController : MonoBehaviour
const float CastleSize = 2.2f;
const float CitySize = 1.1f;
const float CampSize = 0.8f;
const float StartZoom = 8f;
const float StartDistance = 18f; // камера 2.5D: расстояние до земли
// Высота объектов над землёй (2.5D на примитивах, 04.11.2026)
const float CastleHeight = 1.4f, CityHeight = 0.9f, CampHeight = 0.5f, PointHeight = 0.45f;
static readonly Color GroundColor = new Color(0.3f, 0.42f, 0.25f);
static readonly Color CastleColor = new Color(0.55f, 0.15f, 0.15f);
@@ -67,10 +70,10 @@ public class WorldMapController : MonoBehaviour
private readonly List<(MapNode node, TextMeshPro label)> garrisonLabels = new List<(MapNode, TextMeshPro)>();
// Лагеря меняют вид: разбит -- серый, с таймером до появления
private readonly List<(MapNode node, SpriteRenderer sprite, TextMeshPro label)> campViews = new List<(MapNode, SpriteRenderer, TextMeshPro)>();
private readonly List<(MapNode node, Renderer sprite, TextMeshPro label)> campViews = new List<(MapNode, Renderer, TextMeshPro)>();
// Ресурсные точки появляются и исчезают -- виды создаются и убираются по ходу игры
private readonly Dictionary<string, (SpriteRenderer sprite, TextMeshPro label)> pointViews = new Dictionary<string, (SpriteRenderer, TextMeshPro)>();
private readonly Dictionary<string, (Renderer sprite, TextMeshPro label)> pointViews = new Dictionary<string, (Renderer, TextMeshPro)>();
private readonly List<string> stalePoints = new List<string>();
const float PointSize = 0.7f;
static readonly Color[] PointColors =
@@ -87,14 +90,15 @@ public class WorldMapController : MonoBehaviour
private string attackInfoBase;
private BattleCondition shownConditions;
private bool shownObserved; // цель была в зоне обзора, когда окно атаки открылось
private ReconReport? shownReport; // цель вне обзора, но есть отчёт разведки -- гарнизон и прогноз по нему (на момент отчёта)
WorldSimulation World => MarchManager.Instance.World;
void Start()
{
mapRoot = new GameObject("Map").transform;
BuildMap();
IntelligenceOverlay.Create(mapRoot, World); // туман и чужие армии (модуль Разведки)
Renderer ground = BuildMap();
IntelligenceOverlay.Create(mapRoot, World, ground); // туман на земле и чужие армии (модуль Разведки)
SetUpCamera();
backButton.onClick.AddListener(SceneNavigator.ReturnToCity);
@@ -126,6 +130,7 @@ public class WorldMapController : MonoBehaviour
MarchManager.Instance.OnBattleResolved += HandleBattleResolved;
MarchManager.Instance.OnGatherStarted += HandleGatherStarted;
MarchManager.Instance.OnScoutArrived += HandleScoutArrived;
MarchManager.Instance.OnReconCompleted += HandleReconCompleted;
MarchManager.Instance.OnMarchReturned += HandleMarchReturned;
}
@@ -135,6 +140,7 @@ public class WorldMapController : MonoBehaviour
MarchManager.Instance.OnBattleResolved -= HandleBattleResolved;
MarchManager.Instance.OnGatherStarted -= HandleGatherStarted;
MarchManager.Instance.OnScoutArrived -= HandleScoutArrived;
MarchManager.Instance.OnReconCompleted -= HandleReconCompleted;
MarchManager.Instance.OnMarchReturned -= HandleMarchReturned;
}
@@ -166,39 +172,68 @@ public class WorldMapController : MonoBehaviour
var button = copy.GetComponent<Button>();
button.onClick.RemoveAllListeners();
button.onClick.AddListener(ArmiesWindow.Open);
// «Отчёты» -- под «Армиями»: последние отчёты разведки (Разведка М2)
var reports = Instantiate(ratingButton.gameObject, ratingButton.transform.parent);
reports.name = "ReportsButton";
var rrt = (RectTransform)reports.transform;
rrt.anchoredPosition = src.anchoredPosition - new Vector2(0, (src.rect.height + 12) * 2);
var rl = reports.GetComponentInChildren<TMP_Text>();
if (rl != null) rl.text = "Отчёты";
var rb = reports.GetComponent<Button>();
rb.onClick.RemoveAllListeners();
rb.onClick.AddListener(ReconReportText.OpenWindow);
}
// Кнопка «Разведка» в окне атаки (решения 31.10, п. 2) -- копия «Отправить». Три кнопки в ряд: Отмена, Разведка, Отправить
private Button scoutButton;
// Кнопки разведки в окне атаки -- копии «Отправить». Четыре в ряд: Отмена, Разведка II, Разведка III, Отправить.
// Разведка II / III (Разведка М2): наблюдать цель и принести отчёт; у ресурсной точки -- просто «Разведка» (дойти и вернуться)
private Button scoutButton, scout3Button;
private TMP_Text scoutLabel;
void CreateScoutButton()
{
var copy = Instantiate(sendButton.gameObject, sendButton.transform.parent);
copy.name = "ScoutButton";
scoutButton = copy.GetComponent<Button>();
scoutButton.onClick.RemoveAllListeners();
scoutButton.onClick.AddListener(OnScoutPressed);
var label = copy.GetComponentInChildren<TMP_Text>();
if (label != null) label.text = "Разведка";
scoutButton = CopyOfSend("ScoutButton", "Разведка II", () => OnScoutPressed(2));
scoutLabel = scoutButton.GetComponentInChildren<TMP_Text>();
scout3Button = CopyOfSend("Scout3Button", "Разведка III", () => OnScoutPressed(3));
Place(cancelButton, -290f);
Place(scoutButton, 0f);
Place(sendButton, 290f);
Place(cancelButton, -330f);
Place(scoutButton, -110f);
Place(scout3Button, 110f);
Place(sendButton, 330f);
}
Button CopyOfSend(string name, string text, UnityEngine.Events.UnityAction onClick)
{
var copy = Instantiate(sendButton.gameObject, sendButton.transform.parent);
copy.name = name;
var b = copy.GetComponent<Button>();
b.onClick.RemoveAllListeners();
b.onClick.AddListener(onClick);
var label = copy.GetComponentInChildren<TMP_Text>();
if (label != null) label.text = text;
return b;
}
static void Place(Button button, float x)
{
var rt = (RectTransform)button.transform;
rt.anchoredPosition = new Vector2(x, rt.anchoredPosition.y);
rt.sizeDelta = new Vector2(Mathf.Min(rt.sizeDelta.x, 260f), rt.sizeDelta.y);
rt.sizeDelta = new Vector2(Mathf.Min(rt.sizeDelta.x, 205f), rt.sizeDelta.y);
}
void OnScoutPressed()
void OnScoutPressed(int level)
{
if (selectedTarget == null) return;
int soldiers = (int)soldiersSlider.value;
hintText.text = MarchManager.Instance.TryStartScout(selectedTarget.id, soldiers, out string error)
? $"Разведка выступила: {soldiers} воинов идут к цели «{selectedTarget.displayName}» и вернутся без боя"
bool point = selectedTarget.type == MapNodeType.ResourcePoint;
string error;
bool ok = point
? MarchManager.Instance.TryStartScout(selectedTarget.id, soldiers, out error)
: MarchManager.Instance.TryStartRecon(selectedTarget.id, soldiers, level, out error);
long hold = !point && GameServices.TryGet(out IIntelligence intel) ? intel.ReconDurationMs(selectedTarget.id, level) : 0;
hintText.text = ok
? point ? $"Разведка выступила: {soldiers} воинов идут к цели «{selectedTarget.displayName}» и вернутся без боя"
: $"Разведка {(level == 3 ? "III" : "II")} выступила к «{selectedTarget.displayName}»: наблюдать {Duration(hold)}, потом отчёт и домой"
: "Разведка не начата: " + error;
CloseAttackPanel();
}
@@ -211,45 +246,144 @@ public class WorldMapController : MonoBehaviour
// ---------------- КАРТА ----------------
void BuildMap()
// Карта лежит на земле (2.5D): замок -- стены с башнями, свой город и соседи -- домики на площадке,
// лагеря -- шатры с костром, ресурсные точки -- кристаллы цвета ресурса. Подписи висят над объектами.
Renderer BuildMap()
{
float width = WorldMapLayout.HalfWidth / 1000f * 2f;
float height = WorldMapLayout.HalfHeight / 1000f * 2f;
CreateSprite("Ground", PrimitiveSprites.Square, GroundColor, Vector3.zero, new Vector3(width, height, 1f), -10);
Renderer ground = CreateGround(width, height);
foreach (var node in World.Nodes)
{
Vector3 pos = ToWorld(node);
int seed = node.id.GetHashCode() & 0x7fff;
switch (node.type)
{
case MapNodeType.BossCastle:
CreateSprite(node.id, PrimitiveSprites.Square, CastleColor, pos, Vector3.one * CastleSize, 0);
CreateSprite(node.id + "_Keep", PrimitiveSprites.Square, new Color(0.3f, 0.08f, 0.08f), pos, Vector3.one * CastleSize * 0.5f, 1);
CreateLabel(node.displayName, pos + new Vector3(0, CastleSize / 2f + 0.4f, 0), 4f);
garrisonLabels.Add((node, CreateLabel("", pos - new Vector3(0, CastleSize / 2f + 0.35f, 0), 3f)));
BuildCastle(node, pos);
Map3D.Label(mapRoot, node.displayName, pos + Vector3.up * (CastleHeight * 1.8f + 1.4f), 4f);
garrisonLabels.Add((node, Map3D.Label(mapRoot, "", pos + Vector3.up * (CastleHeight * 1.8f + 0.75f), 3f)));
break;
case MapNodeType.PlayerCity:
// Белая рамка -- чтобы свой город сразу бросался в глаза
CreateSprite(node.id + "_Frame", PrimitiveSprites.Square, Color.white, pos, Vector3.one * (CitySize + 0.25f), 0);
CreateSprite(node.id, PrimitiveSprites.Square, PlayerCityColor, pos, Vector3.one * CitySize, 1);
CreateLabel(node.displayName, pos - new Vector3(0, CitySize / 2f + 0.35f, 0), 3.2f);
// Белая площадка и синие крыши -- чтобы свой город сразу бросался в глаза
Map3D.Block(mapRoot, node.id + "_Frame", PrimitiveType.Cube, Color.white, pos, Vector2.one * (CitySize + 0.45f), 0.08f);
BuildTown(node.id, pos + Vector3.up * 0.08f, new Color(0.92f, 0.88f, 0.78f), PlayerCityColor, seed, 4);
Map3D.Flag(mapRoot, pos + new Vector3(0.55f, 0.08f, 0.55f), 1.5f, new Color(0.95f, 0.8f, 0.2f));
Map3D.Label(mapRoot, node.displayName, pos + Vector3.up * (CityHeight + 1.0f), 3.2f);
break;
case MapNodeType.NeighborCity:
CreateSprite(node.id, PrimitiveSprites.Square, NeighborCityColor, pos, Vector3.one * CitySize, 0);
garrisonLabels.Add((node, CreateLabel("", pos - new Vector3(0, CitySize / 2f + 0.5f, 0), 2.6f)));
Map3D.Block(mapRoot, node.id + "_Yard", PrimitiveType.Cube, new Color(0.62f, 0.55f, 0.42f), pos, Vector2.one * (CitySize + 0.25f), 0.05f);
BuildTown(node.id, pos + Vector3.up * 0.05f, new Color(0.85f, 0.75f, 0.58f), new Color(0.62f, 0.25f, 0.18f), seed, 3);
garrisonLabels.Add((node, Map3D.Label(mapRoot, "", pos + Vector3.up * (CityHeight + 1.05f), 2.6f)));
break;
case MapNodeType.Camp:
// Круг, а не квадрат: лагерь не путается с городом
var sprite = CreateSprite(node.id, PrimitiveSprites.Circle, CampColor, pos, Vector3.one * CampSize, 0);
var label = CreateLabel("", pos - new Vector3(0, CampSize / 2f + 0.5f, 0), 2.3f);
// Шатры разбойников с костром; цвет главного шатра -- разбит лагерь или нет
var sprite = Map3D.Shape(mapRoot, node.id, Map3D.Cone, CampColor, pos, Vector2.one * CampSize, CampHeight * 1.6f);
Map3D.Shape(mapRoot, node.id + "_Tent2", Map3D.Cone, new Color(0.55f, 0.42f, 0.3f), pos + new Vector3(0.45f, 0, -0.25f), Vector2.one * CampSize * 0.6f, CampHeight * 1.1f);
Map3D.Block(mapRoot, node.id + "_Fire", PrimitiveType.Cube, new Color(1f, 0.55f, 0.15f), pos + new Vector3(-0.35f, 0, -0.35f), Vector2.one * 0.14f, 0.12f);
var label = Map3D.Label(mapRoot, "", pos + Vector3.up * (CampHeight * 1.6f + 0.85f), 2.3f);
campViews.Add((node, sprite, label));
break;
}
}
BuildScenery(width, height);
return ground;
}
// Замок Змея Горыныча: квадрат стен, четыре угловые башни с красными крышами, высокая главная башня
void BuildCastle(MapNode node, Vector3 pos)
{
Color stone = new Color(0.5f, 0.45f, 0.42f), dark = new Color(0.32f, 0.28f, 0.27f), roofRed = new Color(0.6f, 0.12f, 0.1f);
float half = CastleSize / 2f, wall = 0.18f, wallH = CastleHeight * 0.55f;
Map3D.Block(mapRoot, node.id + "_WallN", PrimitiveType.Cube, stone, pos + new Vector3(0, 0, half - wall / 2), new Vector2(CastleSize, wall), wallH);
Map3D.Block(mapRoot, node.id + "_WallS", PrimitiveType.Cube, stone, pos + new Vector3(0, 0, -half + wall / 2), new Vector2(CastleSize, wall), wallH);
Map3D.Block(mapRoot, node.id + "_WallE", PrimitiveType.Cube, stone, pos + new Vector3(half - wall / 2, 0, 0), new Vector2(wall, CastleSize), wallH);
Map3D.Block(mapRoot, node.id + "_WallW", PrimitiveType.Cube, stone, pos + new Vector3(-half + wall / 2, 0, 0), new Vector2(wall, CastleSize), wallH);
foreach (var corner in new[] { new Vector3(1, 0, 1), new Vector3(-1, 0, 1), new Vector3(1, 0, -1), new Vector3(-1, 0, -1) })
{
Vector3 c = pos + corner * (half - 0.12f);
Map3D.Block(mapRoot, node.id + "_Tower", PrimitiveType.Cylinder, stone, c, Vector2.one * 0.42f, CastleHeight);
Map3D.Shape(mapRoot, node.id + "_TowerRoof", Map3D.Cone, roofRed, c + Vector3.up * CastleHeight, Vector2.one * 0.55f, 0.5f);
}
Map3D.Block(mapRoot, node.id + "_Keep", PrimitiveType.Cube, dark, pos, Vector2.one * CastleSize * 0.42f, CastleHeight * 1.8f);
Map3D.Shape(mapRoot, node.id + "_KeepRoof", Map3D.Pyramid, roofRed, pos + Vector3.up * CastleHeight * 1.8f, Vector2.one * CastleSize * 0.5f, 0.7f, 45f);
Map3D.Flag(mapRoot, pos + Vector3.up * (CastleHeight * 1.8f + 0.6f), 0.6f, new Color(0.15f, 0.55f, 0.2f));
}
// Городок: несколько домиков разного размера на площадке города
void BuildTown(string id, Vector3 pos, Color walls, Color roofColor, int seed, int houses)
{
var rng = new System.Random(seed);
Vector3[] spots = { new Vector3(-0.28f, 0, -0.22f), new Vector3(0.28f, 0, -0.2f), new Vector3(-0.2f, 0, 0.3f), new Vector3(0.25f, 0, 0.28f) };
for (int i = 0; i < houses && i < spots.Length; i++)
{
float w = 0.38f + (float)rng.NextDouble() * 0.12f, h = 0.3f + (float)rng.NextDouble() * 0.25f;
Map3D.House(mapRoot, id + "_House" + i, pos + spots[i], w, w * 0.8f, h, walls, roofColor, rng.Next(2) * 90f);
}
}
// Рощи и камни между объектами -- только украшение (не мешают тапам и походам)
void BuildScenery(float width, float height)
{
var rng = new System.Random(WorldMapLayout.DefaultSeed);
int trees = 0;
for (int attempt = 0; attempt < 3000 && trees < 170; attempt++)
{
float x = ((float)rng.NextDouble() - 0.5f) * (width - 1f), z = ((float)rng.NextDouble() - 0.5f) * (height - 1f);
if (!FarFromNodes(x, z, 1.7f)) continue;
// рощами: рядом с деревом -- ещё 1-2
int group = 1 + rng.Next(3);
for (int g = 0; g < group; g++)
{
float gx = x + ((float)rng.NextDouble() - 0.5f) * 1.2f, gz = z + ((float)rng.NextDouble() - 0.5f) * 1.2f;
if (!FarFromNodes(gx, gz, 1.4f)) continue;
Map3D.Tree(mapRoot, "Tree", new Vector3(gx, 0, gz), 0.55f + (float)rng.NextDouble() * 0.35f, rng.Next(6));
trees++;
}
}
for (int i = 0, rocks = 0; i < 600 && rocks < 30; i++)
{
float x = ((float)rng.NextDouble() - 0.5f) * (width - 1f), z = ((float)rng.NextDouble() - 0.5f) * (height - 1f);
if (!FarFromNodes(x, z, 1.5f)) continue;
Map3D.Rock(mapRoot, "Rock", new Vector3(x, 0, z), 0.3f + (float)rng.NextDouble() * 0.35f, rng.Next(9));
rocks++;
}
}
bool FarFromNodes(float x, float z, float distance)
{
foreach (var n in World.Nodes)
{
float dx = n.x / 1000f - x, dz = n.y / 1000f - z;
if (dx * dx + dz * dz < distance * distance) return false;
}
return true;
}
// Земля -- плоскость с текстурой: её раскрашивает туман (IntelligenceOverlay). UV (0,0) -- левый нижний угол карты
Renderer CreateGround(float width, float height)
{
var mesh = new Mesh { name = "WorldMap_Ground" };
float hx = width / 2f, hz = height / 2f;
mesh.vertices = new[] { new Vector3(-hx, 0, -hz), new Vector3(-hx, 0, hz), new Vector3(hx, 0, hz), new Vector3(hx, 0, -hz) };
mesh.uv = new[] { new Vector2(0, 0), new Vector2(0, 1), new Vector2(1, 1), new Vector2(1, 0) };
mesh.triangles = new[] { 0, 1, 2, 0, 2, 3 };
mesh.RecalculateNormals();
// Земля за краем карты -- тёмная, как неисследованное: камера под наклоном не видит пустого неба
Map3D.Block(mapRoot, "OuterLand", PrimitiveType.Cube, new Color(0.12f, 0.16f, 0.13f), new Vector3(0, -0.08f, 0),
new Vector2(width + 160f, height + 160f), 0.06f);
var go = new GameObject("Ground");
go.transform.SetParent(mapRoot, false);
go.AddComponent<MeshFilter>().sharedMesh = mesh;
var r = go.AddComponent<MeshRenderer>();
r.sharedMaterial = Map3D.TexturedMat(Texture2D.whiteTexture); // текстуру с туманом даст IntelligenceOverlay
return r;
}
void SetUpCamera()
@@ -263,7 +397,8 @@ public class WorldMapController : MonoBehaviour
}
mapCamera.mapHalfSize = new Vector2(WorldMapLayout.HalfWidth / 1000f, WorldMapLayout.HalfHeight / 1000f);
mapCamera.GetComponent<Camera>().orthographicSize = StartZoom;
mapCamera.distance = StartDistance;
Map3D.SetUpLook(mapCamera.GetComponent<Camera>(), 22f, 60f); // солнце, небо, дымка вдали (в 2D-сцене света не было)
CenterOnMyCity();
}
@@ -273,33 +408,6 @@ public class WorldMapController : MonoBehaviour
if (home != null) mapCamera.CenterOn(ToWorld(home));
}
SpriteRenderer CreateSprite(string name, Sprite sprite, Color color, Vector3 position, Vector3 scale, int order)
{
var go = new GameObject(name);
go.transform.SetParent(mapRoot, false);
go.transform.position = position;
go.transform.localScale = scale;
var sr = go.AddComponent<SpriteRenderer>();
sr.sprite = sprite;
sr.color = color;
sr.sortingOrder = order;
return sr;
}
TextMeshPro CreateLabel(string text, Vector3 position, float fontSize)
{
var go = new GameObject("Label");
go.transform.SetParent(mapRoot, false);
go.transform.position = position;
var tmp = go.AddComponent<TextMeshPro>();
tmp.text = text;
tmp.fontSize = fontSize;
tmp.alignment = TextAlignmentOptions.Center;
tmp.rectTransform.sizeDelta = new Vector2(6f, 1.2f);
tmp.sortingOrder = 10;
return tmp;
}
void RefreshCamps()
{
if (Time.unscaledTime < nextCampRefresh) return;
@@ -315,7 +423,7 @@ public class WorldMapController : MonoBehaviour
foreach (var (node, sprite, label) in campViews)
{
long left = World.CampRespawnInMs(node.id, now);
sprite.color = left > 0 ? CampClearedColor : CampColor;
sprite.sharedMaterial = Map3D.Mat(left > 0 ? new Color(0.4f, 0.4f, 0.4f) : CampColor);
label.text = left > 0 // разбил его сам игрок -- это он знает
? $"{node.displayName}\n<color=#BBBBBB>разбит, вернётся через {FormatMinutes(left)}</color>"
: $"{node.displayName}, ур. {node.level}\n{GarrisonText(node)}";
@@ -332,9 +440,12 @@ public class WorldMapController : MonoBehaviour
{
MapNode node = World.GetNode(p.id);
Vector3 pos = ToWorld(node);
var sprite = CreateSprite(p.id, PrimitiveSprites.Square, PointColors[p.kind], pos, Vector3.one * PointSize, 0);
sprite.transform.rotation = Quaternion.Euler(0, 0, 45); // ромб -- не путается с городом и лагерем
var label = CreateLabel("", pos - new Vector3(0, PointSize / 2f + 0.5f, 0), 2.2f);
// Кристалл цвета ресурса на камне -- не путается с городом и лагерем
var holder = new GameObject(p.id).transform; // исчезнет точка -- уберём всё вместе
holder.SetParent(mapRoot, false);
Map3D.Rock(holder, "Base", pos, PointSize * 0.9f, p.kind + 3);
var sprite = Map3D.Shape(holder, "Crystal", Map3D.Pyramid, PointColors[p.kind], pos + Vector3.up * 0.1f, Vector2.one * PointSize * 0.75f, PointHeight * 1.8f, 45f);
var label = Map3D.Label(mapRoot, "", pos + Vector3.up * (PointHeight + 0.75f), 2.2f);
view = (sprite, label);
pointViews[p.id] = view;
}
@@ -384,9 +495,10 @@ public class WorldMapController : MonoBehaviour
return sec >= 60 ? $"{(sec + 59) / 60} мин" : $"{sec} сек";
}
// Координаты карты (x, y) -> мир на земле (x, 0, y). 1000 милли-единиц = 1 клетка
public static Vector3 ToWorld(MapNode node)
{
return new Vector3(node.x / 1000f, node.y / 1000f, 0f);
return new Vector3(node.x / 1000f, 0f, node.y / 1000f);
}
// ---------------- ВЫБОР ЦЕЛИ ----------------
@@ -419,7 +531,7 @@ public class WorldMapController : MonoBehaviour
: node.type == MapNodeType.ResourcePoint ? PointSize : CitySize;
float halfSize = size / 2f + 0.2f;
Vector3 pos = ToWorld(node);
if (Mathf.Abs(world.x - pos.x) <= halfSize && Mathf.Abs(world.y - pos.y) <= halfSize) return node;
if (Mathf.Abs(world.x - pos.x) <= halfSize && Mathf.Abs(world.z - pos.z) <= halfSize) return node;
}
return null;
}
@@ -438,10 +550,14 @@ public class WorldMapController : MonoBehaviour
attackInfoText.fontSizeMin = 18;
attackInfoText.fontSizeMax = 32;
shownObserved = IsObserved(target);
shownReport = !shownObserved && GameServices.TryGet(out IIntelligence intelReports) && intelReports.TryGetLatestReport(target.id, out var report)
? report : (ReconReport?)null;
attackInfoBase = target.type == MapNodeType.ResourcePoint ? PointInfo(target)
: (shownObserved
? $"Гарнизон: {target.garrison} ({UnitsText(WorldMapLayout.GarrisonUnits(target))})"
: "Гарнизон неизвестен: цель вне зоны обзора") +
: shownReport.HasValue
? $"Гарнизон по отчёту разведки {Duration(MarchManager.NowMs - shownReport.Value.TimeMs)} назад: {Sum(shownReport.Value.Units)} ({UnitsText(shownReport.Value.Units)})"
: "Гарнизон неизвестен: цель вне зоны обзора. Отправьте разведку") +
(WorldMapLayout.IsFortified(target) ? ", защитники за стенами\n" : ", бой в поле\n") +
$"Награда за победу: {MarchManager.FormatLoot(target.rewards)}";
RefreshAttackConditions();
@@ -453,7 +569,14 @@ public class WorldMapController : MonoBehaviour
soldiersSlider.interactable = available > 0;
bool slotFree = !GameServices.TryGet(out IArmy slotsArmy) || slotsArmy.ActiveMarches < slotsArmy.MarchSlots;
sendButton.interactable = available > 0 && slotFree;
bool pointTarget = target.type == MapNodeType.ResourcePoint;
if (scoutButton != null) scoutButton.interactable = available > 0 && slotFree;
if (scoutLabel != null) scoutLabel.text = pointTarget ? "Разведка" : "Разведка II";
if (scout3Button != null)
{
scout3Button.gameObject.SetActive(!pointTarget); // у точки отчёта нет -- только дойти и вернуться
scout3Button.interactable = available > 0 && slotFree;
}
UpdateSoldiersToSendText();
RefreshAttackConditions(); // путь -- по составу при выбранном числе воинов
@@ -562,14 +685,15 @@ public class WorldMapController : MonoBehaviour
if (selectedTarget.type == MapNodeType.ResourcePoint) return GatherPreview(soldiers);
int[] units = PreviewUnits(soldiers); // гарнизон в поход не идёт
if (units == null) return "";
if (!shownObserved) return "прогноз боя недоступен: гарнизон цели неизвестен";
if (!shownObserved && !shownReport.HasValue) return "прогноз боя недоступен: гарнизон цели неизвестен";
int[] defenders = shownObserved ? WorldMapLayout.GarrisonUnits(selectedTarget) : shownReport.Value.Units; // по отчёту -- на момент отчёта
// Прогноз с учётом случайности ±5%: показывает риск, а не итог конкретного боя (план от 27.10.2026)
BattleForecast f = GameServices.Get<IBattleSystem>().Forecast(new BattleRequest
{
AttackerUnits = units,
DefenderUnits = WorldMapLayout.GarrisonUnits(selectedTarget),
Defenders = selectedTarget.garrison,
DefenderUnits = defenders,
Defenders = Sum(defenders),
DefenderFortified = WorldMapLayout.IsFortified(selectedTarget),
AttackerConditions = MarchManager.PlayerConditions() // голод сейчас; в бою проверится ещё раз
});
@@ -581,6 +705,13 @@ public class WorldMapController : MonoBehaviour
$"{chance}, шанс победы {ChanceText(f.WinChancePermille)}, потери {Range(f.AttackerLossesMin * 100L, f.AttackerLossesMax * 100L)}";
}
static int Sum(int[] units)
{
int n = 0;
if (units != null) foreach (var u in units) n += u;
return n;
}
// 273 десятых процента -> "27%"; около краёв -- с десятыми, чтобы 99,6% не выглядело как 100%
static string ChanceText(int permille)
{
@@ -708,8 +839,19 @@ public class WorldMapController : MonoBehaviour
EnqueuePopup(title, body);
}
void HandleReconCompleted(MarchData march)
{
if (GameServices.TryGet(out IIntelligence intel) && intel.TryGetLatestReport(march.targetNodeId, out var report))
EnqueuePopup("ОТЧЁТ РАЗВЕДКИ", ReconReportText.Format(report, MarchManager.NowMs) + "\nРазведчики возвращаются домой");
}
void HandleScoutArrived(MarchData march)
{
if (march.reconLevel > 0 && !march.targetEmpty)
{
hintText.text = $"Разведка у цели «{World.TargetName(march)}»: наблюдает {Duration(march.gatherEndMs - march.arriveMs)}";
return;
}
string target = World.TargetName(march);
hintText.text = march.targetEmpty
? $"Разведка: «{target}» уже нет, войско возвращается"
@@ -719,9 +861,12 @@ public class WorldMapController : MonoBehaviour
// «(из них ранены 12 -- придут в госпиталь)»: раненые идут домой с войском
static string WoundedText(MarchData march)
{
int w = 0;
int w = 0, missing = 0;
if (march.woundedUnits != null) foreach (var u in march.woundedUnits) w += u;
return w > 0 ? $" (ранены {w} -- придут в госпиталь)" : "";
if (march.missingUnits != null) foreach (var u in march.missingUnits) missing += u;
string text = w > 0 ? $"ранены {w} -- придут в госпиталь" : "";
if (missing > 0) text += (text.Length > 0 ? ", " : "") + $"пропали {missing}";
return text.Length > 0 ? $" ({text})" : "";
}
void HandleGatherStarted(MarchData march)
+13
View File
@@ -58,6 +58,17 @@
- Госпиталь улучшили — ждущие ложатся сразу (в течение секунды).
- **Дальше:** герои — отдельная очередь лечения (регламент), когда появятся герои.
## Пропавшие без вести
Решения по отчёту Саши от 01.11, п. 5.
- Бой отделяет часть выбывших в статус «пропал без вести» (`BattleOutcome.AttackerMissingUnits`): солдат ровно в одном статусе — погиб, ранен или пропал. Доля — `BattleConfig.missingSharePermille`, **не утверждена: 0, механика выключена**. Для проверки — например 150 (15%).
- При бое Мир передаёт пропавших Армии (`MarkMissing`): они уходят из похода в отдельную группу. Один раз на поход.
- Пропавшие не воюют и не едят, но занимают места в населении до возвращения или списания.
- Окно возвращения открывается после обратного пути от места боя (бой + время пути домой) и длится до 6 игровых часов. Возвращаются 2–4 группами (меньше, если пропавших мало); тип войск сохраняется; из вернувшихся 70% здоровы, 30% ранены (раненые — в госпиталь по обычным правилам). Исходной армии может уже не быть — здоровые идут в город.
- Сколько вернётся — `HospitalConfig.missingReturnPercent`, **ТЕСТ 50%** (утверждён диапазон 10–90%, формула не утверждена). Остальные в конце окна списываются (`MissingWrittenOffEvent`), места освобождаются.
- Кто и когда вернётся, решается сразу при бое (детерминированно); группы сохраняются, вернувшаяся группа удаляется — повторная загрузка или расчёт не возвращают её второй раз.
- Сохранение: `missing=`, `mgroup=`, `missingMarch=`, `missingNext=`. Окно «Войска» показывает пропавших и сколько ещё могут вернуться; окна итога боя — сколько пропало.
## Очередь обучения
Решения по 8 вопросам Саши от 31.10, п. 8: **единственный владелец очереди — Армия.** Раньше её вёл `GameProgress` (`progress.json`, время по настоящим часам) — перенесено.
@@ -148,6 +159,8 @@ healNext=<номер следующей группы лечения>
| Дата | Кто | Что |
| --- | --- | --- |
| 04.11.2026 | Claude | Пропавшие без вести (решения 01.11, п. 5): `MissingTotal`, `Missing`, `MarkMissing`, `ProcessMissing`, события `MissingReturnedEvent`, `MissingWrittenOffEvent`; возвращение 2–4 группами в окне 6 игровых часов после обратного пути, 70/30, списание остальных; сохранение. Доля пропавших в бою и процент возврата не утверждены (0 и 50% в таблицах). |
| 04.11.2026 | Claude | `IArmy.GetAllTroops(type)` — все воины типа: в городе, в походах и раненые (для еды по типам, решения 01.11, п. 4). |
| 04.11.2026 | Claude | Решения по отчёту Саши от 01.11, п. 3: 5 временных мест до постройки госпиталя, цена лечения 1 / 1 / 2 / 3 золота за воина. |
| 01.11.2026 | Claude | Здание «Госпиталь» в городе: уровни I–V, 20–60 мест, окно с ранеными и улучшением; места Армии — через `IHospital` (`HospitalBeds`). Без госпиталя мест нет (`temporaryBeds` = 0). Тест `Hospital_BedsFromBuilding_UpgradeFreesWaiting`. |
| 01.11.2026 | Claude | Решения 31.10, п. 7: раненые и лечение у Армии — `WoundedTotal`, `Healing`, `HospitalBeds`, `ReturnUnits(…, woundedUnits, nowMs)`, `ProcessHealing`, `SoldiersHealedEvent`; места от `IHospital` (пока нет здания — `HospitalConfig.temporaryBeds`), лишние ждут, цена через Экономику (не утверждена). Сохранение `wounded=`, `heal=`. Тесты: места и сроки, однократность, люди в Населении, сохранение и цена. |
+1
View File
@@ -121,6 +121,7 @@
| Дата | Кто | Что |
| --- | --- | --- |
| 04.11.2026 | Claude | Пропавшие без вести (решения 01.11, п. 5): из выбывших сначала отделяются пропавшие (`MissingSharePermille`, не утверждено — 0), остальные делятся на погибших и раненых. `BattleOutcome.AttackerMissingUnits` / `DefenderMissingUnits`. Версия правил 8. |
| 04.11.2026 | Claude | Решения по отчёту Саши от 01.11, п. 1: потери сильной стороны 5:1 → 10:1 плавно с 11% до 2%, дальше 2% (`strongLossPermille` 450 / 160 / 110 / 74 / 20). Версия правил 7. |
| 01.11.2026 | Claude | Раненые (решения 31.10, п. 7): выбывшие делятся на погибших и раненых по соотношению сил стороны — 70% / 45% / 30% / 20% погибших при 1:5 / 1:2 / 1:1 / 3:1. Таблица в `BattleConfig`, версия правил 6, тест `Battle_DeadAndWounded_ShareBySideRatio`. |
| 01.11.2026 | Claude | Разгром дальше 5:1 (решения 31.10, п. 5): точки 7:1 — 92% и 10:1 — 95%, дальше слабая сторона плавно стремится к 99% (`weakLossMaxPermille`). Версия правил 5. Тест кривой продлён до 30:1. |
+2 -3
View File
@@ -36,7 +36,6 @@
| `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`) | История каналов, можно ли писать в канал, отправка сообщения от локального игрока. |
@@ -62,8 +61,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`. |
| 01.11.2026 | Claude | `IArmy`: очередь обучения (`Training`, `CanQueueTraining`, `TryQueueTraining`, `ProcessTraining`, `FinishAllTraining`), `TrainingOrderInfo`, событие `TroopsTrainedEvent`; `GameClock.UnixNowMs` (решения 31.10, п. 8). |
| 01.11.2026 | Claude | `IWorld.TryStartScout`; `IsScout` у `MarchStartedEvent` и `MarchReturnedEvent` (решения 31.10, п. 2). |
-2
View File
@@ -109,8 +109,6 @@
| Дата | Кто | Что |
| --- | --- | --- |
| 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` «Люди» на ячейках панели. |
+24
View File
@@ -49,6 +49,28 @@
**Слушает:** ничего напрямую. `WorldAdvancingEvent` (публикует Мир перед каждым расчётом) связан с `ObserveUntil` в `App/GameModules`.
## М2: целенаправленная разведка и отчёты (сделано 04.11.2026)
ТЗ разведки, раздел 6. Отряд физически идёт к цели маршем Мира, стоит и наблюдает, отчёт фиксируется на конец наблюдения, затем отряд идёт домой. Постоянного слежения нет: старый отчёт не обновляется.
| Цель | Уровень II | Уровень III |
| --- | --- | --- |
| Поселение (соседний город) | 2 игровых часа | 4 |
| Замок | 3 | 6 |
| Лагерь (в ТЗ не назван — как поселение, ТЕСТ) | 2 | 4 |
| Вражеская армия (1 / 2 ч) | **ещё нет** — движущаяся цель, позже | |
1 игровой час = 5 реальных минут.
- **Уровень I** — обычное наблюдение (М1): примерная численность в зоне обзора.
- **Уровень II** — точное число и типы войск, есть ли герой, стены.
- **Уровень III** — то же плюс защита (стены, герой) и ресурсы цели: оценка ±15% (окно, в котором точно лежит настоящее значение; бросок — по id отчёта).
- Время наблюдения даёт Разведка (`ReconDurationMs`), Мир держит отряд у цели (`MarchData.reconLevel`, `reconHoldMs`, состояние «наблюдает») и сообщает `WorldEventType.ReconCompleted`; Разведка составляет отчёт по снимку цели от Мира (`IWorldMap.TryGetReconTarget`) — **один раз на операцию** (id `recon_<номер похода>`), повтор после загрузки ничего не создаёт.
- События: `ReconStartedEvent`, `ReconCompletedEvent`. Отчёты сохраняются (`report=`), хранятся последние 30.
- Числа — в `IntelligenceRules` (`ReconSettlementGameHours`, `ReconCastleGameHours`, `ReconCampGameHours`, `ResourceEstimatePercent`).
**На карте:** в окне атаки — «Разведка II» и «Разведка III» (у ресурсной точки — просто «Разведка»: дойти и вернуться). Отряд у цели подписан «наблюдает». По окончании — окно «ОТЧЁТ РАЗВЕДКИ». Кнопка «Отчёты» под «Армиями» — последние отчёты. Если цель вне обзора, но есть отчёт, окно атаки показывает гарнизон «по отчёту N мин назад» и прогноз боя по нему.
## Патрули-заглушки (временно)
Решение команды 31.10.2026: для проверки М1 соседние поселения иногда отправляют тестовые отряды к другим городам и лагерям. **Это временная тестовая механика, а не ИИ соседей.**
@@ -110,6 +132,8 @@
| Дата | Кто | Что |
| --- | --- | --- |
| 04.11.2026 | Claude | М2: целенаправленная разведка поселений, замка и лагерей с отчётами уровней II и III (`ReconDurationMs`, `CompleteRecon`, `Reports`, `TryGetLatestReport`, `ReconStartedEvent` / `ReconCompletedEvent`, сохранение `report=`); на карте — кнопки «Разведка II/III», окно отчёта, «Отчёты», прогноз по отчёту. Разведка армий — позже. |
| 04.11.2026 | Claude | 2.5D: туман рисуется в текстуре земли (объекты стоят над ней и видны всегда), метки чужих армий — ромбы-кубы с подписью к камере. `hideUnexploredObjects` в 3D пока только сильнее затемняет землю. |
| 01.11.2026 | Claude | Поход «Разведка» (решения 31.10, п. 2) — команда Мира; что войско увидит, показывает Разведка. См. World.md. |
| 01.11.2026 | Claude | Решения 31.10, п. 1: объекты видны всегда, а гарнизоны, запас и срок точек, прогноз боя и сбора — только в зоне обзора (`WorldMapController`). |
| 31.10.2026 | Claude | Исправлено: после перезапуска или перемотки обратный путь войска не открывал туман (до расчёта боя Разведка считала, что войско стоит у цели). Мир досчитывается по событиям (`WorldSimulation.AdvanceInSteps`), Разведка — до каждого из них. Туман темнее. |
+4 -3
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`. Если часы ушли назад, ничего не считается, а метка последнего расчёта назад **не** уходит: когда часы снова дойдут до уже посчитанного, второй раз оно не начислится (решения 04.11). В начале каждого шага и в конце `Tick` модуль публикует `GameTimeSimulatedEvent` — до какого момента дошёл расчёт; по нему стройка, закончившаяся офлайн, вступает в силу ровно в свой момент (`App/ConstructionClock`).
- **Время** — только из `GameClock`. Расчёт идёт кусками по 10 минут, и в игре, и офлайн, и при перемотке — правила одни. Офлайн досчитывается не больше чем за `MaxOfflineHours`. Если часы ушли назад, расчёт просто продолжается с «сейчас».
- **Еда списывается целыми единицами,** доли копятся в модуле: склад хранит `float`, и частые мелкие списания давали бы ошибку округления.
## Контракт `IPopulation`
@@ -88,7 +88,7 @@
- упёрлись в предел — прирост стоит, Ратуша выше — идёт; предел не превышается;
- офлайн ограничен 48 часами;
- частые расчёты дают то же, что один большой;
- часы назад — ничего не ломается, уже посчитанное время не начисляется второй раз;
- часы назад — ничего не ломается;
- перемотка идёт через те же правила;
- сохранение;
- стартовый набор — один раз;
@@ -112,7 +112,8 @@
| Дата | Кто | Что |
| --- | --- | --- |
| 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). Погибшие уходят из людей, вылеченные не появляются заново. Еда раненых — как у воинов (нормы четырёх групп — открытый вопрос). |
| 01.11.2026 | Claude (Саша) | Очередь обучения перенесена в Армию (решения 31.10, п. 8). Население по-прежнему отдаёт жителей (`TryRecruit`) и принимает новобранцев в воины (`CompleteTraining`) — его код не менялся, зовёт их теперь Армия. `RecruitsTrained` больше никто не вызывает. |
| 31.10.2026 | Claude | `CompleteTraining`: новобранцы переходят в Армию одним действием с откатом, если Армия не приняла. Заказ обучения помечает, брали ли под него людей (`TrainingOrder.recruited`); старые заказы — по-старому. Интеграционные тесты с настоящей Армией: переход без потерь, отказ Армии, повтор, нет Армии, часть заказа. |
+5 -2
View File
@@ -105,9 +105,12 @@ EditMode-тесты: `Assets/Scripts/Modules/Tests/ModuleTests.cs`. Запуск
| Дата | Кто | Что |
| --- | --- | --- |
| 04.11.2026 | Claude (Валдемар) | Решения Димы 04.11: снос зданий убран (только бесплатный перенос), кристаллы грабятся только с технологией «Грабёж», офлайн по временным меткам — добыча с момента завершения стройки, защита от повторного начисления (метка расчёта не уходит назад). +5 тестов. Обновлены Settlement, Economy, Core. |
| 04.11.2026 | Claude | Разведка М2: разведка поселений, замка и лагерей с отчётами II и III. Обновлены Intelligence, World, Core. |
| 04.11.2026 | Claude | Пропавшие без вести (решения 01.11, п. 5; доля не утверждена — выключено). Обновлены Army, Battle, Population, World, Core. |
| 04.11.2026 | Claude | Еда воинов и раненых по типам (решения 01.11, п. 4). Обновлены Population, Army. |
| 04.11.2026 | Claude | Карта мира в 2.5D на примитивах. Обновлены World, Intelligence. |
| 04.11.2026 | Claude | 3D-поселение, этап I — прототип на примитивах (ТЗ от 04.11). Обновлён Settlement. |
| 04.11.2026 | Claude | Решения по отчёту Саши от 01.11: потери сильной стороны до 2% при 10:1, 5 временных мест госпиталя, цена лечения 1 / 1 / 2 / 3 золота. Обновлены Battle, Army. |
| 01.11.2026 | Claude (Валдемар) | Поселение: стройка и улучшение по времени, 2 строителя, ускорение; лимит зданий по Ратуше; перенос и снос; защита ресурсов от грабежа (`IResourceProtection`). Терем богатыря и герой в городе. Казармы: найм карточками в ряд. Числа — ТЕСТ. Обновлены Settlement, Economy, Core. |
| 01.11.2026 | Claude | Здание «Госпиталь» (решения 31.10, п. 7). Обновлены Army, Core, Settlement. |
| 01.11.2026 | Claude | Решения 31.10, п. 7: раненые и госпиталь (логика; здание — следующий шаг). Обновлены Battle, Army, Population, World, Core, Settlement. |
| 01.11.2026 | Claude | Решения 31.10, п. 8: очередь обучения — у Армии. Обновлены Army, Core, Population. |
+26 -16
View File
@@ -26,19 +26,6 @@
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`
| Свойство | Что значит |
@@ -61,7 +48,7 @@
## Сохранение
Секция `"settlement"`: `{ "townHallLevel", "townHallTimerStartMs", "townHallTimerEndMs", "townHallTimerTargetLevel", "placedBuildings": [ { "buildingName", "posX", "posY", "level", "timerKind", "timerStartMs", "timerEndMs", "timerTargetLevel" } ] }`. Поля таймера (01.11) — стройка или улучшение, которое ещё идёт (`timerKind`: 0 — нет, 1 — постройка, 2 — улучшение). В старых сохранениях их нет — значит, ничего не строится.
Секция `"settlement"`: `{ "townHallLevel", "placedBuildings": [ { "buildingName", "posX", "posY", "level" } ] }`.
Сброс (`ResetState`): Ратуша 1 уровня, постройки убраны, участки освобождены, память модуля очищена. Иначе при следующем входе в город вернулись бы старые здания.
@@ -82,6 +69,30 @@
В слое приложения остаются `BuildMenuUi.cs`, `Meta/Build/*` (Казармы, `BuildModeController`).
## 3D-поселение (ТЗ «Система строительства поселения» от 04.11.2026)
Переход на 3D (2.5D: наклонная камера без вращения) идёт **отдельной сценой** `Assets/Scenes/City3D.unity`. Старый 2D-город (MVP1) работает как раньше, пока 3D его не догонит. Создать сцену: **Tools → Bogatyri → 3D-город: создать сцену City3D**, затем Play.
**Этап I — прототип на примитивах (сделан 04.11.2026):**
- Правила места — без Unity, сборка `Bogatyri.Settlement.Placement` (`SettlementMap`), тесты `PlacementTests` (сценарии ТЗ 01–05, 09–12).
- Невидимая сетка; клетка 1 единица мира.
- Здание — прямоугольник клеток с максимальным габаритом с первого уровня, поворот 0/90/180/270.
- Проверки: край карты, зона по уровню Ратуши, река и скалы (постоянные), деревья и валуны (расчищаются), другие здания, технический отступ 1 клетка, перепад высоты под зданием до 0,7 м (больше — «слишком круто», меньше — фундамент).
- Перенос — то же здание с тем же id, целиком или никак.
- Расчистка — один раз на объект.
- Сцена (`Assets/Scripts/City3D`): местность 48 × 48 с холмами, река, скалы, 90 деревьев, 22 валуна, трава.
- Сетка, открытая зона и следующая зона видны только в режиме стройки и редактирования.
- Новое здание: кнопка типа → куб-призрак тянется пальцем, зелёный или красный с причиной, «Повернуть 90°», «Поставить» / «Отмена».
- Перенос: «Переместить» в меню здания или режим «Редактировать»; «Подтвердить» / «Отмена» возвращает на последнее подтверждённое место.
- Трава прячется под зданием и возвращается, когда его перенесли.
- Камера: перетаскивание, два пальца / колесо, границы карты; при открытии зоны плавно отходит.
- Ратуша −/+ — **только для теста** зон.
- Рендер: в URP добавлен второй рендерер — обычный 3D (`Assets/Settings/Renderer3D.asset`), его использует только камера City3D. 2D-сцены не тронуты.
**Предложения на согласование (ТЗ, раздел 12), ТЕСТ:** клетка 1 м; отступ 1 клетка; перепад до 0,7 м; зоны — квадраты от центра 16 / 24 / 32 / 40 / 48 клеток для Ратуши 1–5; габариты: Ратуша 4×4, Дом 2×2, Ферма 3×3, Лесопилка и Каменоломня 3×2, Шахта 2×2, Склад и Амбар 2×3, Казармы и Госпиталь 3×3.
**Дальше:** этап II — стройка через модуль Поселения (цена, таймер по реальному времени, события, сохранение позиции, поворота и id), перенос живых зданий без сброса производства / обучения / лечения; этап III — награда за расчистку, сохранение расчищенного, автодороги, зоны от настоящей Ратуши; этап IV — телефон. Кнопки — временные (IMGUI), окна — с Володей.
## Решения и план
- **Решение команды от 19.10.2026 (этап 2):** Ратуша даёт базовую вместимость жителей, новое здание «Дом» — дополнительные места.
@@ -95,8 +106,7 @@
| Дата | Кто | Что |
| --- | --- | --- |
| 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 мест). Здания пока нет. |
| 31.10.2026 | Claude | Ратуша: тап больше не улучшает сразу, а открывает окно Ратуши (событие `TownHall.Tapped`, окно — `App/BuildingWindow`). Улучшение — кнопкой в окне, с ценой и тем, что откроется. |
+3
View File
@@ -158,6 +158,9 @@
| Дата | Кто | Что |
| --- | --- | --- |
| 04.11.2026 | Claude | Разведка М2: `IWorld.TryStartRecon`, `MarchData.reconLevel` / `reconHoldMs`, отряд наблюдает у цели (`Gathering`) и идёт домой от конца наблюдения, `WorldEventType.ReconCompleted`; `IWorldMap.TryGetReconTarget` — снимок цели для отчёта. |
| 04.11.2026 | Claude | `MarchData.missingUnits`: при бое пропавшие передаются Армии (`MarkMissing`); `MarchReturnedEvent.Missing`. |
| 04.11.2026 | Claude | Карта мира в 2.5D на примитивах: карта лежит на земле (X/Z), замок — стена с башней, города — кубы, лагеря — цилиндры, точки — ромбы, отряды — кубики с полосой маршрута; подписи смотрят в камеру; наклонная перспективная камера (`MapCameraController`: перетаскивание, щипок, тап, границы); солнце в сцене; камера — 3D-рендерер URP (`Map3D.UseRenderer3D`). Логика, окна и кнопки не менялись. Ветка `feature/3d-settlement`. |
| 01.11.2026 | Claude | Раненые (решения 31.10, п. 7): `MarchData.woundedUnits` — идут домой с войском, при возвращении Армия кладёт их в госпиталь; `MarchReturnedEvent.Wounded`; итог боя на карте показывает раненых. |
| 01.11.2026 | Claude | Решения 31.10, п. 2: поход «Разведка» (`IWorld.TryStartScout`, `MarchData.scout`, `WorldEventType.ScoutArrived`), кнопка в окне атаки, тексты в окне армий, на отряде и в окне возвращения. |
| 01.11.2026 | Claude | Решения 31.10, п. 3: армии в пути проходят друг через друга — правило записано, тест на одну точку в один момент. |