Compare commits
8
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
61ba46947c | ||
|
|
6c39c8b102 | ||
|
|
cfbdfd9876 | ||
|
|
fae42716a7 | ||
|
|
e69d2bd7db | ||
|
|
9a91926b2c | ||
|
|
9a6e5a2e7b | ||
|
|
a3c4a99a79 |
@@ -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
|
||||
@@ -12,7 +12,7 @@ MonoBehaviour:
|
||||
m_Script: {fileID: 11500000, guid: 7264ce138a844adc81fa69b77a27c8a6, type: 3}
|
||||
m_Name: BattleConfig
|
||||
m_EditorClassIdentifier:
|
||||
version: 7
|
||||
version: 8
|
||||
counterPercent: 25
|
||||
wallsPercent: 20
|
||||
siegeVsWallsPercent: 50
|
||||
@@ -49,3 +49,4 @@ MonoBehaviour:
|
||||
- 450
|
||||
- 300
|
||||
- 200
|
||||
missingSharePermille: 0
|
||||
|
||||
@@ -18,6 +18,11 @@ MonoBehaviour:
|
||||
startSoldiers: 30
|
||||
foodPerCivilianPerHour: 0.1
|
||||
foodPerSoldierPerHour: 0.2
|
||||
foodPerTroopTypePerHour:
|
||||
- 0.15
|
||||
- 0.2
|
||||
- 0.15
|
||||
- 0.15
|
||||
growthPerHour: 2.5
|
||||
housingBase: 300
|
||||
housingPerTownHallLevel: 50
|
||||
|
||||
@@ -20,3 +20,7 @@ MonoBehaviour:
|
||||
healGameHours: 04000000040000000600000008000000
|
||||
healFoodPerSoldier: 00000000000000000000000000000000
|
||||
healGoldPerSoldier: 01000000010000000200000003000000
|
||||
missingReturnPercent: 50
|
||||
missingWoundedPercent: 30
|
||||
missingWindowGameHours: 6
|
||||
missingMaxGroups: 4
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 18e162eeff424f8bb3a71543eafba0c8
|
||||
folderAsset: yes
|
||||
DefaultImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,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
|
||||
@@ -0,0 +1,8 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 0ec4d2749e73404eba2facea709d9f24
|
||||
NativeFormatImporter:
|
||||
externalObjects: {}
|
||||
mainObjectFileID: 2100000
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -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}
|
||||
@@ -0,0 +1,7 @@
|
||||
fileFormatVersion: 2
|
||||
guid: c1c34719f0605c247a3575d077f3d656
|
||||
DefaultImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -42,6 +42,9 @@ public class BattleConfig : ScriptableObject
|
||||
[Tooltip("Доля погибших среди выбывших в каждой точке, десятые доли процента: 700 = 70%. Остальные выбывшие -- раненые")]
|
||||
public int[] deadSharePermille = { 700, 450, 300, 200 };
|
||||
|
||||
[Tooltip("Пропавшие без вести (решения 01.11, п. 5): доля выбывших, десятые доли процента. НЕ УТВЕРЖДЕНА -- 0, механика выключена. Для проверки -- например 150 (15%)")]
|
||||
public int missingSharePermille = 0;
|
||||
|
||||
[Tooltip("Голод: сколько силы остаётся, %. Утверждено 27.10.2026: -50% (остаётся 50). Проверяется на момент боя")]
|
||||
public int famineStrengthPercent = 50;
|
||||
[Tooltip("Усталость: сколько силы остаётся, %. Утверждено 27.10.2026: -20% (остаётся 80). Вместе с голодом -- 40%")]
|
||||
@@ -78,6 +81,7 @@ public class BattleConfig : ScriptableObject
|
||||
WeakLossMaxPermille = weakLossMaxPermille,
|
||||
DeadRatioPermille = deadRatioPermille,
|
||||
DeadSharePermille = deadSharePermille,
|
||||
MissingSharePermille = Mathf.Clamp(missingSharePermille, 0, 1000),
|
||||
RandomPercent = Mathf.Max(0, randomPercent),
|
||||
FamineStrengthPercent = Mathf.Clamp(famineStrengthPercent, 0, 100),
|
||||
FatigueStrengthPercent = Mathf.Clamp(fatigueStrengthPercent, 0, 100)
|
||||
|
||||
@@ -14,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)
|
||||
{
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
{
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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];
|
||||
|
||||
@@ -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
|
||||
@@ -0,0 +1,8 @@
|
||||
fileFormatVersion: 2
|
||||
guid: efa023adb1ce408c971a4f76ced19585
|
||||
folderAsset: yes
|
||||
DefaultImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -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:
|
||||
@@ -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:
|
||||
@@ -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,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);
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -721,6 +721,7 @@ public class GameProgress : MonoBehaviour
|
||||
long now = GameClock.UnixNowMs;
|
||||
army.ProcessTraining(now);
|
||||
army.ProcessHealing(now);
|
||||
army.ProcessMissing(now);
|
||||
}
|
||||
|
||||
// Заказ закончен (событие Армии, один раз на заказ): статистика заданий и всплывашка
|
||||
|
||||
@@ -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
|
||||
@@ -29,6 +29,18 @@ public sealed class HospitalRules
|
||||
}
|
||||
}
|
||||
|
||||
// Пропавшие без вести (решения 01.11, п. 5). Доля возвращающихся и формула -- НЕ УТВЕРЖДЕНЫ (ТЕСТ, таблица HospitalConfig)
|
||||
public sealed class MissingRules
|
||||
{
|
||||
public int ReturnPercent = 50; // сколько пропавших вернётся (утверждён диапазон 10-90%, формула открыта)
|
||||
public int WoundedPercent = 30; // из вернувшихся -- раненые (утверждено: 70% здоровыми, 30% ранеными)
|
||||
public int WindowGameHours = 6; // окно возвращения после обратного пути, ИГРОВЫХ часов
|
||||
public long GameHourMs = 5 * 60 * 1000;
|
||||
public int MaxGroups = 4; // возвращаются 2-4 группами (меньше, если пропавших мало)
|
||||
|
||||
public static MissingRules Default => new MissingRules();
|
||||
}
|
||||
|
||||
public sealed class ArmyService : IArmy, ISaveParticipant
|
||||
{
|
||||
public const int Types = 4; // пехота, лучники, кавалерия, осадные -- порядок как в GameData.Troops
|
||||
@@ -62,6 +74,28 @@ public sealed class ArmyService : IArmy, ISaveParticipant
|
||||
private int nextHealId = 1;
|
||||
private readonly HospitalRules hospital;
|
||||
|
||||
// Пропавшие без вести одного боя: группы возвращения рассчитаны сразу, при постановке (детерминированно)
|
||||
private class MissingEvent
|
||||
{
|
||||
public int id, marchId;
|
||||
public long windowStartMs, endMs;
|
||||
public int[] remaining = new int[Types]; // ещё не вернулись и не списаны
|
||||
public List<MissingGroup> groups = new List<MissingGroup>();
|
||||
}
|
||||
|
||||
private class MissingGroup
|
||||
{
|
||||
public int index;
|
||||
public long timeMs;
|
||||
public int[] healthy = new int[Types], wounded = new int[Types];
|
||||
}
|
||||
|
||||
private readonly List<MissingEvent> missing = new List<MissingEvent>();
|
||||
private readonly List<MissingInfo> missingView = new List<MissingInfo>();
|
||||
private readonly HashSet<int> missingMarches = new HashSet<int>(); // по каким походам пропавшие уже отмечены
|
||||
private int nextMissingId = 1;
|
||||
private readonly MissingRules missingRules;
|
||||
|
||||
private readonly List<TrainingOrder> training = new List<TrainingOrder>();
|
||||
private readonly List<TrainingOrderInfo> trainingView = new List<TrainingOrderInfo>();
|
||||
private int nextOrderId = 1;
|
||||
@@ -76,10 +110,11 @@ public sealed class ArmyService : IArmy, ISaveParticipant
|
||||
// Слоты похода: база из таблицы MarchConfig (регламент 31.10: 2, ТЕСТ). Бонус «Военной логистики» -- позже.
|
||||
private readonly int baseMarchSlots;
|
||||
|
||||
public ArmyService(int baseMarchSlots = 2, HospitalRules hospital = null)
|
||||
public ArmyService(int baseMarchSlots = 2, HospitalRules hospital = null, MissingRules missingRules = null)
|
||||
{
|
||||
this.baseMarchSlots = Math.Max(1, baseMarchSlots);
|
||||
this.hospital = hospital ?? HospitalRules.Default;
|
||||
this.missingRules = missingRules ?? MissingRules.Default;
|
||||
}
|
||||
|
||||
public HospitalRules Hospital => hospital;
|
||||
@@ -238,6 +273,147 @@ public sealed class ArmyService : IArmy, ISaveParticipant
|
||||
}
|
||||
}
|
||||
|
||||
public int GetAllTroops(int type)
|
||||
{
|
||||
if (type < 0 || type >= Types) return 0;
|
||||
int n = troops[type] + woundedWaiting[type];
|
||||
foreach (var m in marches) n += m.units[type];
|
||||
foreach (var b in healing) if (b.type == type) n += b.count;
|
||||
return n;
|
||||
}
|
||||
|
||||
// ---------------- Пропавшие без вести ----------------
|
||||
|
||||
public int MissingTotal
|
||||
{
|
||||
get { int n = 0; foreach (var e in missing) foreach (var r in e.remaining) n += r; return n; }
|
||||
}
|
||||
|
||||
public IReadOnlyList<MissingInfo> Missing
|
||||
{
|
||||
get
|
||||
{
|
||||
missingView.Clear();
|
||||
foreach (var e in missing)
|
||||
{
|
||||
int n = 0;
|
||||
foreach (var r in e.remaining) n += r;
|
||||
missingView.Add(new MissingInfo { Id = e.id, Count = n, WindowStartMs = e.windowStartMs, EndMs = e.endMs });
|
||||
}
|
||||
return missingView;
|
||||
}
|
||||
}
|
||||
|
||||
public bool MarkMissing(int marchId, int[] missingUnits, long battleMs, long travelMs)
|
||||
{
|
||||
if (missingUnits == null || missingMarches.Contains(marchId)) return false;
|
||||
MarchUnits entry = marches.Find(m => m.marchId == marchId);
|
||||
if (entry == null) return false;
|
||||
|
||||
var e = new MissingEvent { id = nextMissingId++, marchId = marchId };
|
||||
int total = 0;
|
||||
for (int i = 0; i < Types && i < missingUnits.Length; i++)
|
||||
{
|
||||
int n = Math.Max(0, Math.Min(missingUnits[i], entry.units[i]));
|
||||
entry.units[i] -= n; // из похода -- в пропавшие: один солдат в одном месте
|
||||
e.remaining[i] = n;
|
||||
total += n;
|
||||
}
|
||||
if (total == 0) { nextMissingId--; return false; }
|
||||
|
||||
e.windowStartMs = battleMs + Math.Max(0, travelMs);
|
||||
e.endMs = e.windowStartMs + Math.Max(0, missingRules.WindowGameHours) * missingRules.GameHourMs;
|
||||
PlanGroups(e, battleMs);
|
||||
missing.Add(e);
|
||||
missingMarches.Add(marchId);
|
||||
Changed();
|
||||
return true;
|
||||
}
|
||||
|
||||
// Кто вернётся и когда: доля возвращающихся по типам (дробная часть -- бросок), 2-4 группы в окне,
|
||||
// в каждой -- 70% здоровых, 30% раненых (дробная часть -- бросок). Тип войск сохраняется.
|
||||
void PlanGroups(MissingEvent e, long seedTime)
|
||||
{
|
||||
ulong seed = (ulong)e.marchId * 0x9E3779B97F4A7C15UL ^ (ulong)seedTime;
|
||||
int roll = 0;
|
||||
bool Chance(long num, long den) => den > 0 && (long)(Next(seed, roll++) % (ulong)den) < num;
|
||||
|
||||
var back = new int[Types];
|
||||
int returning = 0;
|
||||
for (int i = 0; i < Types; i++)
|
||||
{
|
||||
long num = (long)e.remaining[i] * Math.Max(0, Math.Min(100, missingRules.ReturnPercent));
|
||||
back[i] = (int)(num / 100) + (Chance(num % 100, 100) ? 1 : 0);
|
||||
returning += back[i];
|
||||
}
|
||||
if (returning == 0) return;
|
||||
|
||||
int groups = Math.Max(1, Math.Min(Math.Max(2, missingRules.MaxGroups), returning)); // мало вернувшихся -- групп меньше
|
||||
if (returning >= 2) groups = Math.Max(2, Math.Min(groups, 2 + (int)(Next(seed, roll++) % (ulong)(Math.Max(2, missingRules.MaxGroups) - 1))));
|
||||
long window = Math.Max(1, e.endMs - e.windowStartMs);
|
||||
for (int g = 0; g < groups; g++)
|
||||
e.groups.Add(new MissingGroup { index = g + 1, timeMs = e.windowStartMs + window * (g + 1) / (groups + 1) });
|
||||
|
||||
// Раздаём вернувшихся по группам по очереди, внутри группы -- здоровые и раненые
|
||||
for (int i = 0; i < Types; i++)
|
||||
for (int k = 0; k < back[i]; k++)
|
||||
{
|
||||
var grp = e.groups[(k + i) % groups];
|
||||
if (Chance(Math.Max(0, Math.Min(100, missingRules.WoundedPercent)), 100)) grp.wounded[i]++;
|
||||
else grp.healthy[i]++;
|
||||
}
|
||||
}
|
||||
|
||||
static ulong Next(ulong seed, int n)
|
||||
{
|
||||
ulong x = seed + 0x9E3779B97F4A7C15UL * (ulong)(n + 1);
|
||||
x = (x ^ (x >> 30)) * 0xBF58476D1CE4E5B9UL;
|
||||
x = (x ^ (x >> 27)) * 0x94D049BB133111EBUL;
|
||||
return x ^ (x >> 31);
|
||||
}
|
||||
|
||||
public int ProcessMissing(long nowMs)
|
||||
{
|
||||
int done = 0;
|
||||
bool changed = false;
|
||||
for (int k = missing.Count - 1; k >= 0; k--)
|
||||
{
|
||||
var e = missing[k];
|
||||
e.groups.Sort((a, b) => a.timeMs.CompareTo(b.timeMs));
|
||||
while (e.groups.Count > 0 && e.groups[0].timeMs <= nowMs && e.groups[0].timeMs <= e.endMs)
|
||||
{
|
||||
var g = e.groups[0];
|
||||
e.groups.RemoveAt(0); // группа возвращается один раз
|
||||
int healthy = 0, wounded = 0;
|
||||
bool anyWounded = false;
|
||||
for (int i = 0; i < Types; i++)
|
||||
{
|
||||
int h = Math.Min(g.healthy[i], e.remaining[i]);
|
||||
int w = Math.Min(g.wounded[i], e.remaining[i] - h);
|
||||
e.remaining[i] -= h + w;
|
||||
troops[i] += h; // исходной армии может уже не быть -- в резерв поселения
|
||||
woundedWaiting[i] += w; // раненые -- в госпиталь по обычным правилам
|
||||
healthy += h; wounded += w;
|
||||
if (w > 0) anyWounded = true;
|
||||
}
|
||||
if (anyWounded) { ProcessHealing(g.timeMs); FillBeds(g.timeMs); }
|
||||
done++;
|
||||
changed = true;
|
||||
EventBus.Publish(new MissingReturnedEvent { MissingId = e.id, Group = g.index, Healthy = healthy, Wounded = wounded, TimeMs = g.timeMs });
|
||||
}
|
||||
if (nowMs >= e.endMs)
|
||||
{
|
||||
int left = 0;
|
||||
foreach (var r in e.remaining) left += r;
|
||||
missing.RemoveAt(k); // окно кончилось: кто не вернулся -- списан, места освобождены
|
||||
changed = true;
|
||||
EventBus.Publish(new MissingWrittenOffEvent { MissingId = e.id, Count = left, TimeMs = e.endMs });
|
||||
}
|
||||
}
|
||||
if (changed) Changed();
|
||||
return done;
|
||||
}
|
||||
|
||||
public int GetWoundedWaiting(int type)
|
||||
{
|
||||
return type >= 0 && type < Types ? woundedWaiting[type] : 0;
|
||||
@@ -416,7 +592,8 @@ public sealed class ArmyService : IArmy, ISaveParticipant
|
||||
|
||||
void Changed()
|
||||
{
|
||||
EventBus.Publish(new TroopsChangedEvent { AtHome = SoldiersAtHome, InMarches = TroopsInMarches, Garrison = GarrisonTotal, Wounded = WoundedTotal });
|
||||
EventBus.Publish(new TroopsChangedEvent { AtHome = SoldiersAtHome, InMarches = TroopsInMarches, Garrison = GarrisonTotal, Wounded = WoundedTotal,
|
||||
Missing = MissingTotal });
|
||||
}
|
||||
|
||||
// ---------------- Перенос из старого progress.json ----------------
|
||||
@@ -453,6 +630,9 @@ public sealed class ArmyService : IArmy, ISaveParticipant
|
||||
// wounded=<пехота>,<лучники>,<кавалерия>,<осадные> (ждут места в госпитале)
|
||||
// heal=<id>|<тип>|<сколько>|<начало, мс>|<конец, мс>
|
||||
// healNext=<номер следующей группы лечения>
|
||||
// missing=<id>|<поход>|<окно с, мс>|<конец, мс>|<осталось по типам через ,>
|
||||
// mgroup=<id пропавших>|<номер группы>|<время, мс>|<здоровые по типам>|<раненые по типам> (ещё не вернувшиеся группы)
|
||||
// missingMarch=<поход> (по нему пропавшие уже отмечены) missingNext=<номер>
|
||||
|
||||
public string SaveKey => "army";
|
||||
|
||||
@@ -477,6 +657,20 @@ public sealed class ArmyService : IArmy, ISaveParticipant
|
||||
N(b.id), N(b.type), N(b.count), b.startMs.ToString(CultureInfo.InvariantCulture), b.endMs.ToString(CultureInfo.InvariantCulture)
|
||||
})).Append('\n');
|
||||
sb.Append("healNext=").Append(N(nextHealId)).Append('\n');
|
||||
foreach (var e in missing)
|
||||
{
|
||||
sb.Append("missing=").Append(string.Join("|", new[]
|
||||
{
|
||||
N(e.id), N(e.marchId), e.windowStartMs.ToString(CultureInfo.InvariantCulture), e.endMs.ToString(CultureInfo.InvariantCulture), Join(e.remaining)
|
||||
})).Append('\n');
|
||||
foreach (var g in e.groups)
|
||||
sb.Append("mgroup=").Append(string.Join("|", new[]
|
||||
{
|
||||
N(e.id), N(g.index), g.timeMs.ToString(CultureInfo.InvariantCulture), Join(g.healthy), Join(g.wounded)
|
||||
})).Append('\n');
|
||||
}
|
||||
foreach (var id in missingMarches) sb.Append("missingMarch=").Append(N(id)).Append('\n');
|
||||
sb.Append("missingNext=").Append(N(nextMissingId)).Append('\n');
|
||||
return sb.ToString();
|
||||
}
|
||||
|
||||
@@ -490,6 +684,9 @@ public sealed class ArmyService : IArmy, ISaveParticipant
|
||||
Array.Clear(woundedWaiting, 0, Types);
|
||||
healing.Clear();
|
||||
nextHealId = 1;
|
||||
missing.Clear();
|
||||
missingMarches.Clear();
|
||||
nextMissingId = 1;
|
||||
RestoredFromSave = true;
|
||||
|
||||
if (!string.IsNullOrEmpty(data))
|
||||
@@ -547,6 +744,36 @@ public sealed class ArmyService : IArmy, ISaveParticipant
|
||||
nextHealId = Math.Max(nextHealId, hid + 1);
|
||||
}
|
||||
}
|
||||
else if (line.StartsWith("missing="))
|
||||
{
|
||||
string[] p = line.Substring("missing=".Length).Split('|');
|
||||
if (p.Length == 5 && int.TryParse(p[0], NumberStyles.Integer, CultureInfo.InvariantCulture, out int mid)
|
||||
&& int.TryParse(p[1], NumberStyles.Integer, CultureInfo.InvariantCulture, out int mmarch)
|
||||
&& long.TryParse(p[2], NumberStyles.Integer, CultureInfo.InvariantCulture, out long mstart)
|
||||
&& long.TryParse(p[3], NumberStyles.Integer, CultureInfo.InvariantCulture, out long mend)
|
||||
&& !missing.Exists(x => x.id == mid))
|
||||
{
|
||||
missing.Add(new MissingEvent { id = mid, marchId = mmarch, windowStartMs = mstart, endMs = mend, remaining = Parse(p[4]) });
|
||||
nextMissingId = Math.Max(nextMissingId, mid + 1);
|
||||
}
|
||||
}
|
||||
else if (line.StartsWith("mgroup="))
|
||||
{
|
||||
string[] p = line.Substring("mgroup=".Length).Split('|');
|
||||
MissingEvent owner = null;
|
||||
if (p.Length == 5 && int.TryParse(p[0], NumberStyles.Integer, CultureInfo.InvariantCulture, out int gid))
|
||||
owner = missing.Find(x => x.id == gid); // строки missing= идут раньше своих групп
|
||||
if (owner != null && int.TryParse(p[1], NumberStyles.Integer, CultureInfo.InvariantCulture, out int gidx)
|
||||
&& long.TryParse(p[2], NumberStyles.Integer, CultureInfo.InvariantCulture, out long gtime)
|
||||
&& !owner.groups.Exists(x => x.index == gidx))
|
||||
owner.groups.Add(new MissingGroup { index = gidx, timeMs = gtime, healthy = Parse(p[3]), wounded = Parse(p[4]) });
|
||||
}
|
||||
else if (line.StartsWith("missingMarch=")
|
||||
&& int.TryParse(line.Substring("missingMarch=".Length), NumberStyles.Integer, CultureInfo.InvariantCulture, out int mm))
|
||||
missingMarches.Add(mm);
|
||||
else if (line.StartsWith("missingNext=")
|
||||
&& int.TryParse(line.Substring("missingNext=".Length), NumberStyles.Integer, CultureInfo.InvariantCulture, out int mnext))
|
||||
nextMissingId = Math.Max(nextMissingId, mnext);
|
||||
else if (line.StartsWith("healNext=")
|
||||
&& int.TryParse(line.Substring("healNext=".Length), NumberStyles.Integer, CultureInfo.InvariantCulture, out int hnext))
|
||||
nextHealId = Math.Max(nextHealId, hnext);
|
||||
@@ -570,6 +797,9 @@ public sealed class ArmyService : IArmy, ISaveParticipant
|
||||
Array.Clear(woundedWaiting, 0, Types);
|
||||
healing.Clear();
|
||||
nextHealId = 1;
|
||||
missing.Clear();
|
||||
missingMarches.Clear();
|
||||
nextMissingId = 1;
|
||||
Changed();
|
||||
}
|
||||
|
||||
|
||||
@@ -6,9 +6,10 @@
|
||||
// итог боя запоминает версию, по которой посчитан.
|
||||
public sealed class BattleRules
|
||||
{
|
||||
public int Version = 7; // 2 -- голод и усталость; 3 -- сила без округления, потери шансом (№12, №13); 4 -- кривая потерь (№14);
|
||||
public int Version = 8; // 2 -- голод и усталость; 3 -- сила без округления, потери шансом (№12, №13); 4 -- кривая потерь (№14);
|
||||
// 5 -- разгром дальше 5:1 (решения по 8 вопросам Саши, 31.10, п. 5); 6 -- раненые (п. 7);
|
||||
// 7 -- сильная сторона 5:1 -> 10:1 с 11% до 2% (решения по отчёту Саши, 01.11, п. 1)
|
||||
// 7 -- сильная сторона 5:1 -> 10:1 с 11% до 2% (решения по отчёту Саши, 01.11, п. 1);
|
||||
// 8 -- пропавшие без вести (п. 5)
|
||||
|
||||
// Сила одного воина каждого типа. В игре -- из GameData.Troops (там же цена и время обучения).
|
||||
public int[] UnitPower = { 4, 5, 9, 16 };
|
||||
@@ -37,6 +38,10 @@ public sealed class BattleRules
|
||||
public int[] DeadRatioPermille = { 200, 500, 1000, 3000 }; // соотношение сил стороны: 1:5, 1:2, 1:1, 3:1
|
||||
public int[] DeadSharePermille = { 700, 450, 300, 200 }; // доля погибших среди выбывших: 70%, 45%, 30%, 20%
|
||||
|
||||
// Пропавшие без вести (решения 01.11, п. 5): доля выбывших, получающих этот статус, в десятых долях процента.
|
||||
// НЕ УТВЕРЖДЕНА командой -- по умолчанию 0 (механика выключена). Остальные выбывшие -- погибшие и раненые, как выше.
|
||||
public int MissingSharePermille = 0;
|
||||
|
||||
// Состояния (утверждено 27.10.2026): сколько силы остаётся. Применяются последовательно: 50% x 80% = 40%
|
||||
public int FamineStrengthPercent = 50; // голод: -50%
|
||||
public int FatigueStrengthPercent = 80; // усталость: -20%
|
||||
|
||||
@@ -181,15 +181,20 @@ public sealed class BattleSystem : IBattleSystem
|
||||
int[] attOut = Losses(att, weaker, stronger, isWeak: !victory, request.Seed, side: 1, rounding);
|
||||
int[] defOut0 = Losses(def, weaker, stronger, isWeak: victory, request.Seed, side: 2, rounding);
|
||||
|
||||
// Выбывшие -- погибшие и раненые (решения 31.10, п. 7): доля погибших -- от соотношения сил своей стороны
|
||||
int[] attDead = Dead(attOut, attPower, defPower, request.Seed, side: 1, rounding);
|
||||
int[] defDead = Dead(defOut0, defPower, attPower, request.Seed, side: 2, rounding);
|
||||
// Выбывшие: сначала пропавшие без вести (решения 01.11, п. 5), остальные -- погибшие и раненые (решения 31.10, п. 7):
|
||||
// доля погибших -- от соотношения сил своей стороны. Солдат -- ровно в одном статусе
|
||||
int[] attMissing = Missing(attOut, request.Seed, side: 1, rounding);
|
||||
int[] defMissing = Missing(defOut0, request.Seed, side: 2, rounding);
|
||||
int[] attRest = new int[TroopType.Count], defRest = new int[TroopType.Count];
|
||||
for (int i = 0; i < TroopType.Count; i++) { attRest[i] = attOut[i] - attMissing[i]; defRest[i] = defOut0[i] - defMissing[i]; }
|
||||
int[] attDead = Dead(attRest, attPower, defPower, request.Seed, side: 1, rounding);
|
||||
int[] defDead = Dead(defRest, defPower, attPower, request.Seed, side: 2, rounding);
|
||||
int[] attWounded = new int[TroopType.Count];
|
||||
int[] defWounded = new int[TroopType.Count];
|
||||
for (int i = 0; i < TroopType.Count; i++)
|
||||
{
|
||||
attWounded[i] = attOut[i] - attDead[i];
|
||||
defWounded[i] = defOut0[i] - defDead[i];
|
||||
attWounded[i] = attRest[i] - attDead[i];
|
||||
defWounded[i] = defRest[i] - defDead[i];
|
||||
}
|
||||
|
||||
var attAlive = new int[TroopType.Count];
|
||||
@@ -197,9 +202,9 @@ public sealed class BattleSystem : IBattleSystem
|
||||
var defOut = new int[TroopType.Count];
|
||||
for (int i = 0; i < TroopType.Count; i++)
|
||||
{
|
||||
attAlive[i] = att[i] - attDead[i] - attWounded[i];
|
||||
defAlive[i] = def[i] - defDead[i] - defWounded[i];
|
||||
defOut[i] = defDead[i] + defWounded[i];
|
||||
attAlive[i] = att[i] - attOut[i];
|
||||
defAlive[i] = def[i] - defOut0[i];
|
||||
defOut[i] = defOut0[i];
|
||||
}
|
||||
|
||||
return new BattleOutcome
|
||||
@@ -207,7 +212,7 @@ public sealed class BattleSystem : IBattleSystem
|
||||
Victory = victory,
|
||||
BattleId = request.Seed,
|
||||
RulesVersion = rules.Version,
|
||||
AttackerLosses = Sum(attDead) + Sum(attWounded),
|
||||
AttackerLosses = Sum(attOut),
|
||||
DefenderLosses = Sum(defOut),
|
||||
Survivors = Sum(attAlive),
|
||||
AttackerPower = attPower.Floor, // округление -- только для показа
|
||||
@@ -224,6 +229,8 @@ public sealed class BattleSystem : IBattleSystem
|
||||
DefenderDeadUnits = defDead,
|
||||
AttackerWoundedUnits = attWounded,
|
||||
DefenderWoundedUnits = defWounded,
|
||||
AttackerMissingUnits = attMissing,
|
||||
DefenderMissingUnits = defMissing,
|
||||
DefenderLossUnits = defOut
|
||||
};
|
||||
}
|
||||
@@ -340,6 +347,25 @@ public sealed class BattleSystem : IBattleSystem
|
||||
return lost;
|
||||
}
|
||||
|
||||
// Пропавшие без вести среди выбывших каждого типа: доля из правил (не утверждена; 0 -- никого), дробная часть -- бросок
|
||||
// из своего потока. Прогноз: Floor -- меньше пропавших, Ceiling -- больше
|
||||
int[] Missing(int[] outUnits, long seed, int side, Rounding rounding)
|
||||
{
|
||||
var missing = new int[TroopType.Count];
|
||||
int share = Math.Max(0, Math.Min(1000, rules.MissingSharePermille));
|
||||
if (share == 0) return missing;
|
||||
for (int i = 0; i < TroopType.Count; i++)
|
||||
{
|
||||
if (outUnits[i] <= 0) continue;
|
||||
long num = (long)outUnits[i] * share;
|
||||
long whole = num / 1000, rest = num % 1000;
|
||||
bool extra = rest != 0 && (rounding == Rounding.Ceiling
|
||||
|| rounding == Rounding.Roll && (LossRoll(seed, side + 24, i) * 1000UL >> 32) < (ulong)rest);
|
||||
missing[i] = (int)Math.Min(outUnits[i], whole + (extra ? 1 : 0));
|
||||
}
|
||||
return missing;
|
||||
}
|
||||
|
||||
// Погибшие среди выбывших каждого типа: доля по соотношению сил стороны (own / enemy), дробная часть -- бросок
|
||||
// из своего потока (не тот, что у потерь и удачи). Прогноз: Floor -- меньше погибших, Ceiling -- больше.
|
||||
int[] Dead(int[] outUnits, Fraction own, Fraction enemy, long seed, int side, Rounding rounding)
|
||||
|
||||
@@ -22,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"
|
||||
|
||||
@@ -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 минут + конец");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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» -> «Велимир»)
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: c36fcaa4cddf4c76ad06c759afc8301e
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -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()
|
||||
|
||||
@@ -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:
|
||||
@@ -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)
|
||||
|
||||
@@ -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=`. Тесты: места и сроки, однократность, люди в Населении, сохранение и цена. |
|
||||
|
||||
@@ -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. |
|
||||
|
||||
@@ -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). |
|
||||
|
||||
@@ -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` «Люди» на ячейках панели. |
|
||||
|
||||
@@ -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`), Разведка — до каждого из них. Туман темнее. |
|
||||
|
||||
@@ -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`); старые заказы — по-старому. Интеграционные тесты с настоящей Армией: переход без потерь, отказ Армии, повтор, нет Армии, часть заказа. |
|
||||
|
||||
@@ -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
@@ -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`). Улучшение — кнопкой в окне, с ценой и тем, что откроется. |
|
||||
|
||||
@@ -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: армии в пути проходят друг через друга — правило записано, тест на одну точку в один момент. |
|
||||
|
||||
Reference in New Issue
Block a user