Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0876ee9ac6 |
@@ -1,119 +0,0 @@
|
||||
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,11 +0,0 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 37d458150cc84d538a23b4c6612995c7
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,45 @@
|
||||
apply plugin: 'com.android.library'
|
||||
apply from: '../shared/keepUnitySymbols.gradle'
|
||||
apply from: '../shared/common.gradle'
|
||||
**APPLY_PLUGINS**
|
||||
|
||||
dependencies {
|
||||
implementation fileTree(dir: 'libs', include: ['*.jar'])
|
||||
implementation platform('org.jetbrains.kotlin:kotlin-bom:1.8.22')
|
||||
**DEPS**}
|
||||
|
||||
android {
|
||||
namespace "com.unity3d.player"
|
||||
ndkPath "**NDKPATH**"
|
||||
ndkVersion "**NDKVERSION**"
|
||||
compileSdk **APIVERSION**
|
||||
buildToolsVersion "**BUILDTOOLS**"
|
||||
|
||||
compileOptions {
|
||||
sourceCompatibility JavaVersion.VERSION_17
|
||||
targetCompatibility JavaVersion.VERSION_17
|
||||
}
|
||||
|
||||
defaultConfig {
|
||||
minSdk **MINSDK**
|
||||
targetSdk **TARGETSDK**
|
||||
ndk {
|
||||
abiFilters **ABIFILTERS**
|
||||
debugSymbolLevel **DEBUGSYMBOLLEVEL**
|
||||
}
|
||||
consumerProguardFiles 'proguard-unity.txt'**USER_PROGUARD**
|
||||
**DEFAULT_CONFIG_SETUP**
|
||||
}
|
||||
|
||||
lint {
|
||||
abortOnError false
|
||||
}
|
||||
|
||||
androidResources {
|
||||
noCompress = **BUILTIN_NOCOMPRESS** + unityStreamingAssets.tokenize(', ')
|
||||
ignoreAssetsPattern = "!.svn:!.git:!.ds_store:!*.scc:!CVS:!thumbs.db:!picasa.ini:!*~"
|
||||
}**PACKAGING**
|
||||
}
|
||||
**IL_CPP_BUILD_SETUP**
|
||||
**SOURCE_BUILD_SETUP**
|
||||
**EXTERNAL_SOURCES**
|
||||
@@ -1,5 +1,5 @@
|
||||
fileFormatVersion: 2
|
||||
guid: c1c34719f0605c247a3575d077f3d656
|
||||
guid: afabcf797b4c170459251e1f1c3996b6
|
||||
DefaultImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
@@ -1,52 +0,0 @@
|
||||
%YAML 1.1
|
||||
%TAG !u! tag:unity3d.com,2011:
|
||||
--- !u!114 &11400000
|
||||
MonoBehaviour:
|
||||
m_ObjectHideFlags: 0
|
||||
m_CorrespondingSourceObject: {fileID: 0}
|
||||
m_PrefabInstance: {fileID: 0}
|
||||
m_PrefabAsset: {fileID: 0}
|
||||
m_GameObject: {fileID: 0}
|
||||
m_Enabled: 1
|
||||
m_EditorHideFlags: 0
|
||||
m_Script: {fileID: 11500000, guid: 7264ce138a844adc81fa69b77a27c8a6, type: 3}
|
||||
m_Name: BattleConfig
|
||||
m_EditorClassIdentifier:
|
||||
version: 8
|
||||
counterPercent: 25
|
||||
wallsPercent: 20
|
||||
siegeVsWallsPercent: 50
|
||||
siegeInFieldPercent: -25
|
||||
randomPercent: 5
|
||||
famineStrengthPercent: 50
|
||||
fatigueStrengthPercent: 80
|
||||
lossRatioPermille:
|
||||
- 1000
|
||||
- 3000
|
||||
- 5000
|
||||
- 7000
|
||||
- 10000
|
||||
strongLossPermille:
|
||||
- 450
|
||||
- 160
|
||||
- 110
|
||||
- 74
|
||||
- 20
|
||||
weakLossPermille:
|
||||
- 450
|
||||
- 740
|
||||
- 880
|
||||
- 920
|
||||
- 950
|
||||
weakLossMaxPermille: 990
|
||||
deadRatioPermille:
|
||||
- 200
|
||||
- 500
|
||||
- 1000
|
||||
- 3000
|
||||
deadSharePermille:
|
||||
- 700
|
||||
- 450
|
||||
- 300
|
||||
- 200
|
||||
missingSharePermille: 0
|
||||
@@ -1,8 +0,0 @@
|
||||
fileFormatVersion: 2
|
||||
guid: b666973e44a2442b82f2782adc99852f
|
||||
NativeFormatImporter:
|
||||
externalObjects: {}
|
||||
mainObjectFileID: 11400000
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -1,71 +0,0 @@
|
||||
%YAML 1.1
|
||||
%TAG !u! tag:unity3d.com,2011:
|
||||
--- !u!114 &11400000
|
||||
MonoBehaviour:
|
||||
m_ObjectHideFlags: 0
|
||||
m_CorrespondingSourceObject: {fileID: 0}
|
||||
m_PrefabInstance: {fileID: 0}
|
||||
m_PrefabAsset: {fileID: 0}
|
||||
m_GameObject: {fileID: 0}
|
||||
m_Enabled: 1
|
||||
m_EditorHideFlags: 0
|
||||
m_Script: {fileID: 11500000, guid: 4ddb59400c6d4b08b3afaaadc461ec1e, type: 3}
|
||||
m_Name: EconomyConfig
|
||||
m_EditorClassIdentifier:
|
||||
population:
|
||||
startTotal: 300
|
||||
startWorkers: 120
|
||||
startSoldiers: 30
|
||||
foodPerCivilianPerHour: 0.1
|
||||
foodPerSoldierPerHour: 0.2
|
||||
foodPerTroopTypePerHour:
|
||||
- 0.15
|
||||
- 0.2
|
||||
- 0.15
|
||||
- 0.15
|
||||
growthPerHour: 2.5
|
||||
housingBase: 300
|
||||
housingPerTownHallLevel: 50
|
||||
housingPerHouseLevel: 50
|
||||
maxOfflineHours: 48
|
||||
startResources:
|
||||
wood: 500
|
||||
food: 500
|
||||
stone: 200
|
||||
iron: 100
|
||||
gold: 100
|
||||
gems: 0
|
||||
storage:
|
||||
baseGoods: 1000
|
||||
baseFood: 1500
|
||||
perWarehouseLevel: 1000
|
||||
perGranaryLevel: 1500
|
||||
crystalCap: 50
|
||||
warehouseWood: 200
|
||||
warehouseStone: 100
|
||||
granaryWood: 150
|
||||
granaryStone: 50
|
||||
production:
|
||||
woodcutterPerHour: 2
|
||||
farmerPerHour: 2
|
||||
stonecutterPerHour: 1.5
|
||||
minerIronPerHour: 1
|
||||
minerGoldPerHour: 0.4
|
||||
workerSlotsT1: 30
|
||||
workerSlotsPerLevel: 10
|
||||
efficiencyPerLevel: 0.1
|
||||
upgrade:
|
||||
baseWood: 150
|
||||
baseStone: 100
|
||||
multiplierPerLevel: 1.6
|
||||
maxLevel: 10
|
||||
deposits:
|
||||
poor: 0.6
|
||||
normal: 1
|
||||
rich: 1.5
|
||||
soldier:
|
||||
people: 1
|
||||
food: 15
|
||||
iron: 5
|
||||
gold: 5
|
||||
trainSeconds: 60
|
||||
@@ -1,8 +0,0 @@
|
||||
fileFormatVersion: 2
|
||||
guid: db192d63d5ff4504a4624613292cf3ab
|
||||
NativeFormatImporter:
|
||||
externalObjects: {}
|
||||
mainObjectFileID: 11400000
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -1,26 +0,0 @@
|
||||
%YAML 1.1
|
||||
%TAG !u! tag:unity3d.com,2011:
|
||||
--- !u!114 &11400000
|
||||
MonoBehaviour:
|
||||
m_ObjectHideFlags: 0
|
||||
m_CorrespondingSourceObject: {fileID: 0}
|
||||
m_PrefabInstance: {fileID: 0}
|
||||
m_PrefabAsset: {fileID: 0}
|
||||
m_GameObject: {fileID: 0}
|
||||
m_Enabled: 1
|
||||
m_EditorHideFlags: 0
|
||||
m_Script: {fileID: 11500000, guid: c25bbe993b314a15a6d1a8aaf0017d55, type: 3}
|
||||
m_Name: HospitalConfig
|
||||
m_EditorClassIdentifier:
|
||||
bedsByLevel: 140000001e00000028000000320000003c000000
|
||||
temporaryBeds: 5
|
||||
requiredTownHallLevel: 1
|
||||
buildWood: 250
|
||||
buildStone: 150
|
||||
healGameHours: 04000000040000000600000008000000
|
||||
healFoodPerSoldier: 00000000000000000000000000000000
|
||||
healGoldPerSoldier: 01000000010000000200000003000000
|
||||
missingReturnPercent: 50
|
||||
missingWoundedPercent: 30
|
||||
missingWindowGameHours: 6
|
||||
missingMaxGroups: 4
|
||||
@@ -1,8 +0,0 @@
|
||||
fileFormatVersion: 2
|
||||
guid: d92d3f1bd32b432e9a30ee269f436ca0
|
||||
NativeFormatImporter:
|
||||
externalObjects: {}
|
||||
mainObjectFileID: 11400000
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -1,23 +0,0 @@
|
||||
%YAML 1.1
|
||||
%TAG !u! tag:unity3d.com,2011:
|
||||
--- !u!114 &11400000
|
||||
MonoBehaviour:
|
||||
m_ObjectHideFlags: 0
|
||||
m_CorrespondingSourceObject: {fileID: 0}
|
||||
m_PrefabInstance: {fileID: 0}
|
||||
m_PrefabAsset: {fileID: 0}
|
||||
m_GameObject: {fileID: 0}
|
||||
m_Enabled: 1
|
||||
m_EditorHideFlags: 0
|
||||
m_Script: {fileID: 11500000, guid: c249ad34e2f94460b9f99b49c71760c8, type: 3}
|
||||
m_Name: IntelligenceConfig
|
||||
m_EditorClassIdentifier:
|
||||
settlementRadius: 5
|
||||
armyRadius: 1
|
||||
observationPercent: 0a000000140000001e000000
|
||||
testObservationLevel: 0
|
||||
lastContactMinutes: 10
|
||||
approxPercent: 20
|
||||
identifyPercent: 50
|
||||
sampleStepSeconds: 10
|
||||
hideUnexploredObjects: 0
|
||||
@@ -1,8 +0,0 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 4972288700954eda8b97a86b3f7a5492
|
||||
NativeFormatImporter:
|
||||
externalObjects: {}
|
||||
mainObjectFileID: 11400000
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -1,8 +0,0 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 18e162eeff424f8bb3a71543eafba0c8
|
||||
folderAsset: yes
|
||||
DefaultImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -1,58 +0,0 @@
|
||||
%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
|
||||
@@ -1,8 +0,0 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 0ec4d2749e73404eba2facea709d9f24
|
||||
NativeFormatImporter:
|
||||
externalObjects: {}
|
||||
mainObjectFileID: 2100000
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -1,26 +0,0 @@
|
||||
%YAML 1.1
|
||||
%TAG !u! tag:unity3d.com,2011:
|
||||
--- !u!114 &11400000
|
||||
MonoBehaviour:
|
||||
m_ObjectHideFlags: 0
|
||||
m_CorrespondingSourceObject: {fileID: 0}
|
||||
m_PrefabInstance: {fileID: 0}
|
||||
m_PrefabAsset: {fileID: 0}
|
||||
m_GameObject: {fileID: 0}
|
||||
m_Enabled: 1
|
||||
m_EditorHideFlags: 0
|
||||
m_Script: {fileID: 11500000, guid: 0427eef4612041d2b8a359f9bf358779, type: 3}
|
||||
m_Name: MarchConfig
|
||||
m_EditorClassIdentifier:
|
||||
infantry: 8
|
||||
archers: 9
|
||||
cavalry: 12
|
||||
siege: 4
|
||||
typeSpeedBonusPercent: 00000000000000000000000000000000
|
||||
armySpeedBonusPercent: 0
|
||||
baseMarchSlots: 2
|
||||
patrolsEnabled: 1
|
||||
patrolMaxActive: 3
|
||||
patrolIntervalMinutes: 10
|
||||
patrolSoldiersMin: 10
|
||||
patrolSoldiersMax: 40
|
||||
@@ -1,8 +0,0 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 99295aa9eabc4ab6bf8cc687f4d89d96
|
||||
NativeFormatImporter:
|
||||
externalObjects: {}
|
||||
mainObjectFileID: 11400000
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1 @@
|
||||
{"TestSuite":"","Date":0,"Player":{"Development":false,"ScreenWidth":0,"ScreenHeight":0,"ScreenRefreshRate":0,"Fullscreen":false,"Vsync":0,"AntiAliasing":0,"Batchmode":false,"RenderThreadingMode":"MultiThreaded","MtRendering":false,"GraphicsJobs":false,"GpuSkinning":false,"Platform":"","ColorSpace":"","AnisotropicFiltering":"","BlendWeights":"","GraphicsApi":"","ScriptingBackend":"IL2CPP","AndroidTargetSdkVersion":"AndroidApiLevelAuto","AndroidBuildSystem":"Gradle","BuildTarget":"Android","StereoRenderingPath":"MultiPass"},"Hardware":{"OperatingSystem":"","DeviceModel":"","DeviceName":"","ProcessorType":"","ProcessorCount":0,"GraphicsDeviceName":"","SystemMemorySizeMB":0},"Editor":{"Version":"6000.5.0f1","Branch":"6000.5/staging","Changeset":"88b47c5e7076","Date":1780938000},"Dependencies":["com.unity.2d.animation@15.1.0","com.unity.2d.aseprite@5.0.3","com.unity.2d.common@14.0.1","com.unity.2d.psdimporter@14.0.3","com.unity.2d.sprite@1.0.0","com.unity.2d.spriteshape@15.0.3","com.unity.2d.tilemap.extras@8.0.3","com.unity.2d.tilemap@1.0.0","com.unity.2d.tooling@3.0.1","com.unity.burst@1.8.29","com.unity.collab-proxy@2.12.4","com.unity.collections@6.5.0","com.unity.ext.nunit@2.1.0","com.unity.ide.rider@3.0.38","com.unity.ide.visualstudio@2.0.26","com.unity.inputsystem@1.19.0","com.unity.mathematics@1.4.0","com.unity.microsoft.gdk.discovery@1.3.0","com.unity.microsoft.gdk.tools@1.7.0","com.unity.microsoft.gdk@1.7.0","com.unity.modules.accessibility@1.0.0","com.unity.modules.adaptiveperformance@1.0.0","com.unity.modules.ai@1.0.0","com.unity.modules.androidjni@1.0.0","com.unity.modules.animation@1.0.0","com.unity.modules.assetbundle@1.0.0","com.unity.modules.audio@1.0.0","com.unity.modules.cloth@1.0.0","com.unity.modules.director@1.0.0","com.unity.modules.hierarchycore@1.0.0","com.unity.modules.imageconversion@1.0.0","com.unity.modules.imgui@1.0.0","com.unity.modules.jsonserialize@1.0.0","com.unity.modules.particlesystem@1.0.0","com.unity.modules.physics@1.0.0","com.unity.modules.physics2d@1.0.0","com.unity.modules.physicscore2d@1.0.0","com.unity.modules.screencapture@1.0.0","com.unity.modules.subsystems@1.0.0","com.unity.modules.terrain@1.0.0","com.unity.modules.terrainphysics@1.0.0","com.unity.modules.tilemap@1.0.0","com.unity.modules.ui@1.0.0","com.unity.modules.uielements@1.0.0","com.unity.modules.umbra@1.0.0","com.unity.modules.unityanalytics@1.0.0","com.unity.modules.unitywebrequest@1.0.0","com.unity.modules.unitywebrequestassetbundle@1.0.0","com.unity.modules.unitywebrequestaudio@1.0.0","com.unity.modules.unitywebrequesttexture@1.0.0","com.unity.modules.unitywebrequestwww@1.0.0","com.unity.modules.vectorgraphics@1.0.0","com.unity.modules.vehicles@1.0.0","com.unity.modules.video@1.0.0","com.unity.modules.wind@1.0.0","com.unity.modules.xr@1.0.0","com.unity.multiplayer.center@1.0.1","com.unity.nuget.mono-cecil@1.11.6","com.unity.nuget.newtonsoft-json@3.2.2","com.unity.purchasing@5.4.2","com.unity.render-pipelines.core@17.5.0","com.unity.render-pipelines.universal@17.5.0","com.unity.render-pipelines.universal-config@17.5.0","com.unity.searcher@4.9.4","com.unity.services.cloud-build@2.0.8","com.unity.services.core@1.18.0","com.unity.shadergraph@17.5.0","com.unity.test-framework.performance@3.5.0","com.unity.test-framework@1.7.0","com.unity.timeline@1.8.12","com.unity.ugui@2.5.0","com.unity.visualscripting@1.9.11"],"Results":[]}
|
||||
+2
-2
@@ -1,6 +1,6 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 9a5b231268f445349a58134b71372e9b
|
||||
AssemblyDefinitionImporter:
|
||||
guid: 3a842e2b03903434ba52aa026b801445
|
||||
TextScriptImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
@@ -0,0 +1 @@
|
||||
{"MeasurementCount":-1}
|
||||
+2
-2
@@ -1,6 +1,6 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 263eeba80895448da32cd4c9816b18f3
|
||||
AssemblyDefinitionImporter:
|
||||
guid: 2fec63729866dd44483350cddf3c2b84
|
||||
TextScriptImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
@@ -1,58 +0,0 @@
|
||||
%YAML 1.1
|
||||
%TAG !u! tag:unity3d.com,2011:
|
||||
--- !u!114 &11400000
|
||||
MonoBehaviour:
|
||||
m_ObjectHideFlags: 0
|
||||
m_CorrespondingSourceObject: {fileID: 0}
|
||||
m_PrefabInstance: {fileID: 0}
|
||||
m_PrefabAsset: {fileID: 0}
|
||||
m_GameObject: {fileID: 0}
|
||||
m_Enabled: 1
|
||||
m_EditorHideFlags: 0
|
||||
m_Script: {fileID: 11500000, guid: 6ee3759b377140c0b759a4a386327510, type: 3}
|
||||
m_Name: WorldResourceConfig
|
||||
m_EditorClassIdentifier:
|
||||
woodStock:
|
||||
- 200
|
||||
- 600
|
||||
- 1500
|
||||
stoneStock:
|
||||
- 150
|
||||
- 450
|
||||
- 1000
|
||||
metalStock:
|
||||
- 80
|
||||
- 250
|
||||
- 600
|
||||
goldStock:
|
||||
- 30
|
||||
- 100
|
||||
- 250
|
||||
woodPerGameHour: 2
|
||||
stonePerGameHour: 1.5
|
||||
metalPerGameHour: 1
|
||||
goldPerGameHour: 0.5
|
||||
woodCargo: 12
|
||||
stoneCargo: 10
|
||||
metalCargo: 8
|
||||
goldCargo: 5
|
||||
lifetimeRealHours:
|
||||
- 12
|
||||
- 24
|
||||
- 48
|
||||
woodCount:
|
||||
- 4
|
||||
- 3
|
||||
- 1
|
||||
stoneCount:
|
||||
- 4
|
||||
- 2
|
||||
- 1
|
||||
metalCount:
|
||||
- 2
|
||||
- 2
|
||||
- 1
|
||||
goldCount:
|
||||
- 2
|
||||
- 1
|
||||
- 1
|
||||
@@ -1,8 +0,0 @@
|
||||
fileFormatVersion: 2
|
||||
guid: b95775cbe52447a6abff99bfeb488500
|
||||
NativeFormatImporter:
|
||||
externalObjects: {}
|
||||
mainObjectFileID: 11400000
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -1,431 +0,0 @@
|
||||
%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}
|
||||
@@ -1,90 +0,0 @@
|
||||
using UnityEngine;
|
||||
|
||||
// ТАБЛИЦА БАЛАНСА БОЯ. Все числа боевой системы -- здесь, а не в коде модуля (ревью от 27.10.2026, п. 2.5).
|
||||
//
|
||||
// Ассет: Assets/Resources/BattleConfig.asset (выдели его в окне Project и меняй числа в Inspector,
|
||||
// перекомпиляция не нужна; изменения действуют со следующего запуска игры). Создать заново:
|
||||
// правый клик в Project -> Create -> Bogatyri -> Battle Config, имя файла -- BattleConfig, папка -- Resources.
|
||||
//
|
||||
// Модуль Боя таблицу не видит: App/GameModules переводит её в BattleRules.
|
||||
// Сила воинов -- в GameData.Troops (рядом с ценой и временем обучения), здесь её нет.
|
||||
// Числа -- стартовые для тестирования (план от 27.10.2026), утверждает команда.
|
||||
[CreateAssetMenu(fileName = "BattleConfig", menuName = "Bogatyri/Battle Config")]
|
||||
public class BattleConfig : ScriptableObject
|
||||
{
|
||||
[Tooltip("Версия баланса боя. Поменял числа -- подними версию: итог боя запоминает, по какой версии посчитан")]
|
||||
public int version = 4;
|
||||
|
||||
[Tooltip("Контра, %: пехота > кавалерия > лучники > пехота. Взвешивается по доле нужного типа у врага")]
|
||||
public int counterPercent = 25;
|
||||
[Tooltip("Стены, %: усиление защитников замка и городов")]
|
||||
public int wallsPercent = 20;
|
||||
[Tooltip("Осадные при штурме стен, %")]
|
||||
public int siegeVsWallsPercent = 50;
|
||||
[Tooltip("Осадные в поле и в обороне, % (минус)")]
|
||||
public int siegeInFieldPercent = -25;
|
||||
[Tooltip("Случайность каждой стороны: от -N% до +N%. 0 -- без случайности")]
|
||||
public int randomPercent = 5;
|
||||
|
||||
[Header("Кривая потерь (решение №14, регламент 31.10, ТЕСТ)")]
|
||||
[Tooltip("Опорные точки соотношения сил (сильная / слабая) в десятых долях процента: 1000 = 1:1, 3000 = 3:1. Первая -- обязательно 1000, дальше по возрастанию")]
|
||||
public int[] lossRatioPermille = { 1000, 3000, 5000, 7000, 10000 };
|
||||
[Tooltip("Доля выбывших у сильной стороны в каждой точке, десятые доли процента: 450 = 45%")]
|
||||
public int[] strongLossPermille = { 450, 160, 110, 74, 20 };
|
||||
[Tooltip("Доля выбывших у слабой стороны в каждой точке, десятые доли процента: 740 = 74%")]
|
||||
public int[] weakLossPermille = { 450, 740, 880, 920, 950 };
|
||||
[Tooltip("Разгром дальше последней точки: слабая сторона плавно стремится к этому пределу, десятые доли процента. Решения 31.10, п. 5: 990 = 99%")]
|
||||
public int weakLossMaxPermille = 990;
|
||||
|
||||
[Header("Погибшие и раненые (регламент 31.10, решения 31.10, п. 7, ТЕСТ)")]
|
||||
[Tooltip("Соотношение сил стороны (своя / чужая) в десятых долях процента: 200 = 1:5, 1000 = 1:1, 3000 = 3:1. По возрастанию")]
|
||||
public int[] deadRatioPermille = { 200, 500, 1000, 3000 };
|
||||
[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%")]
|
||||
public int fatigueStrengthPercent = 80;
|
||||
|
||||
static BattleConfig cached;
|
||||
|
||||
public static BattleConfig Load()
|
||||
{
|
||||
if (cached != null) return cached;
|
||||
cached = Resources.Load<BattleConfig>("BattleConfig");
|
||||
if (cached == null)
|
||||
{
|
||||
Debug.LogWarning("[БОЙ] Нет Assets/Resources/BattleConfig.asset -- берутся числа по умолчанию из BattleConfig.cs.");
|
||||
cached = CreateInstance<BattleConfig>();
|
||||
}
|
||||
return cached;
|
||||
}
|
||||
|
||||
// Правила для модуля Боя
|
||||
public BattleRules ToRules(int[] unitPower)
|
||||
{
|
||||
return new BattleRules
|
||||
{
|
||||
Version = version,
|
||||
UnitPower = unitPower,
|
||||
CounterPercent = counterPercent,
|
||||
WallsPercent = wallsPercent,
|
||||
SiegeVsWallsPercent = siegeVsWallsPercent,
|
||||
SiegeInFieldPercent = siegeInFieldPercent,
|
||||
LossRatioPermille = lossRatioPermille,
|
||||
StrongLossPermille = strongLossPermille,
|
||||
WeakLossPermille = weakLossPermille,
|
||||
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)
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -1,11 +0,0 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 7264ce138a844adc81fa69b77a27c8a6
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -1,518 +0,0 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using TMPro;
|
||||
using UnityEngine;
|
||||
using UnityEngine.UI;
|
||||
|
||||
// Окно здания. Открывается тапом по зданию (подписей над зданиями больше нет -- всё здесь).
|
||||
// У каждого здания своё окно:
|
||||
// - добывающее (Лесопилка, Ферма, Каменоломня, Шахта, Рудник): рабочие этой профессии и кнопки "−" / "+",
|
||||
// добыча в час, сколько ресурса на складе и когда склад заполнится, что даст улучшение и его цена;
|
||||
// - Склад и Амбар: запас / лимит по их ресурсам, что даст улучшение и цена;
|
||||
// - Дом: сколько жилья даёт, население / предел, улучшение и цена;
|
||||
// - Ратуша: жильё, резерв для найма, что откроется на следующем уровне, цена улучшения;
|
||||
// - Кристалл: где добывается, запас / лимит;
|
||||
// - Казармы: очередь обучения, какие войска открыты и что нужно для остальных, улучшение (открывает войска),
|
||||
// кнопка "Нанять войска" (панель "Войска").
|
||||
// Без здания (кнопка "Рабочие" в панели DEV и "Все рабочие" в окне) -- список рабочих по всем профессиям.
|
||||
// Окно -- только интерфейс: рабочих ставит модуль Населения, добычу считает модуль Экономики.
|
||||
// Пока окно открыто, цифры обновляются сами (раз в полсекунды).
|
||||
public static class BuildingWindow
|
||||
{
|
||||
static GameObject root;
|
||||
static GameObject focus; // здание, по которому тапнули (null -- список рабочих)
|
||||
static readonly List<Action> live = new List<Action>(); // что обновлять, пока окно открыто
|
||||
|
||||
public static void OnWorkplaceTapped(ResourceCollector building) { Open(building != null ? building.gameObject : null); }
|
||||
public static void OnStorageTapped(StorageBuilding building) { Open(building != null ? building.gameObject : null); }
|
||||
public static void OnTownHallTapped(TownHall townHall) { Open(townHall != null ? townHall.gameObject : null); }
|
||||
|
||||
public static void Open(GameObject building)
|
||||
{
|
||||
// В режиме стройки тап -- это выбор места, а не здания
|
||||
if (BuildModeController.Instance != null && BuildModeController.Instance.IsActive) return;
|
||||
focus = building;
|
||||
Rebuild();
|
||||
}
|
||||
|
||||
public static void Close()
|
||||
{
|
||||
if (root != null) UnityEngine.Object.Destroy(root);
|
||||
root = null;
|
||||
focus = null;
|
||||
live.Clear();
|
||||
}
|
||||
|
||||
static void Rebuild()
|
||||
{
|
||||
if (root != null) UnityEngine.Object.Destroy(root);
|
||||
live.Clear();
|
||||
var card = UIKit.Modal(TitleFor(focus), out root);
|
||||
root.AddComponent<BuildingWindowTicker>();
|
||||
|
||||
var rc = focus != null ? focus.GetComponent<ResourceCollector>() : null;
|
||||
var storage = focus != null ? focus.GetComponent<StorageBuilding>() : null;
|
||||
var townHall = focus != null ? focus.GetComponent<TownHall>() : null;
|
||||
var barracks = focus != null ? focus.GetComponent<Barracks>() : null;
|
||||
var hospital = focus != null ? focus.GetComponent<Hospital>() : null;
|
||||
|
||||
if (focus == null) WorkersList(card);
|
||||
else if (townHall != null) TownHallSection(card, townHall);
|
||||
else if (barracks != null) BarracksSection(card, barracks);
|
||||
else if (hospital != null) HospitalSection(card, hospital);
|
||||
else if (rc != null && rc.IsWorkplace) WorkplaceSection(card, rc);
|
||||
else if (rc != null && rc.IsHouse) HouseSection(card, rc);
|
||||
else if (rc != null && rc.resourceType == ResourceType.Gems) CrystalSection(card);
|
||||
else if (storage != null) StorageSection(card, storage);
|
||||
|
||||
UIKit.Button(card, "Закрыть", UIKit.Theme.Gray, Close, -1, 80, 28);
|
||||
}
|
||||
|
||||
internal static void Tick()
|
||||
{
|
||||
if (root == null) return;
|
||||
if (focus == null && FocusWasDestroyed) { Close(); return; }
|
||||
foreach (var update in live) update();
|
||||
}
|
||||
|
||||
static bool focusSet;
|
||||
static bool FocusWasDestroyed => focusSet && focus == null;
|
||||
|
||||
static string TitleFor(GameObject building)
|
||||
{
|
||||
focusSet = building != null;
|
||||
if (building == null) return "РАБОЧИЕ";
|
||||
var townHall = building.GetComponent<TownHall>();
|
||||
if (townHall != null) return $"РАТУША — {townHall.currentLevel} УР.";
|
||||
var id = building.GetComponent<PlacedBuildingId>();
|
||||
var leveled = building.GetComponent<ILeveledBuilding>();
|
||||
string name = id != null && !string.IsNullOrEmpty(id.buildingName) ? id.buildingName : building.name;
|
||||
return leveled != null ? $"{name.ToUpper()} — {leveled.Level} УР." : name.ToUpper();
|
||||
}
|
||||
|
||||
// ---------------- Добывающее здание ----------------
|
||||
|
||||
static void WorkplaceSection(RectTransform card, ResourceCollector rc)
|
||||
{
|
||||
if (!WorkJobs.TryFromResource(rc.resourceType, out WorkJob job)) return;
|
||||
var production = GameModules.Production;
|
||||
if (production == null || !GameServices.TryGet(out IPopulation population) || !GameServices.TryGet(out IEconomy economy))
|
||||
{
|
||||
UIKit.Text(card, "Модули Населения и Экономики не подключены", 26, UIKit.Theme.Red, TextAlignmentOptions.Center);
|
||||
return;
|
||||
}
|
||||
ResourceType res = WorkJobs.Resource(job);
|
||||
string resName = GameData.ResName(res).ToLower();
|
||||
double norm = production.Rules.PerWorkerPerHour[(int)job];
|
||||
|
||||
// Что даёт это здание
|
||||
int slots = production.BuildingSlots(rc.level);
|
||||
double eff = production.BuildingEfficiency(rc.level);
|
||||
UIKit.Text(card, $"Здание даёт {slots} мест, норма ×{eff:0.##} — до {slots * norm * eff:0.#} {resName} в час при полном штате.",
|
||||
24, UIKit.Theme.TextDim, TextAlignmentOptions.Center);
|
||||
|
||||
// Рабочие профессии и кнопки
|
||||
Header(card, $"Рабочие: {WorkJobs.Name(job).ToLower()}");
|
||||
Live(card, () => $"{population.GetWorkers(job)} / {production.Slots(job)} мест в городе • свободных жителей: {population.FreeWorkers}",
|
||||
26, UIKit.Theme.Text);
|
||||
var row = UIKit.Row(card, 72, 8);
|
||||
StepButton(row, "−5", job, -5);
|
||||
StepButton(row, "−1", job, -1);
|
||||
StepButton(row, "+1", job, +1);
|
||||
StepButton(row, "+5", job, +5);
|
||||
|
||||
// Добыча и склад
|
||||
Header(card, "Добыча и склад");
|
||||
Live(card, () =>
|
||||
{
|
||||
float rate = production.RatePerHour(res);
|
||||
string famine = population.IsStarving ? " <color=#ff7a7a>голод: добыча ×0.5</color>" : "";
|
||||
return $"Сейчас: <color=#9be29b>+{rate:0.#} {resName} в час</color>{famine}";
|
||||
}, 26, UIKit.Theme.Text);
|
||||
StorageBar(card, economy, res);
|
||||
Live(card, () => FillTimeText(economy, res, production.RatePerHour(res)), 22, UIKit.Theme.TextDim);
|
||||
|
||||
// Улучшение
|
||||
int nextSlots = production.BuildingSlots(rc.level + 1);
|
||||
double nextEff = production.BuildingEfficiency(rc.level + 1);
|
||||
UpgradeBlock(card, rc.level,
|
||||
$"мест {slots} → {nextSlots}, норма ×{eff:0.##} → ×{nextEff:0.##}",
|
||||
() => rc.Level = rc.level + 1);
|
||||
|
||||
UIKit.Button(card, "Все рабочие", UIKit.Theme.Blue, () => Open(null), -1, 70, 24);
|
||||
}
|
||||
|
||||
// ---------------- Дом ----------------
|
||||
|
||||
static void HouseSection(RectTransform card, ResourceCollector house)
|
||||
{
|
||||
int perLevel = EconomyConfig.Load().population.housingPerHouseLevel;
|
||||
UIKit.Text(card, $"Дом даёт +{perLevel * house.level} к пределу жилья (+{perLevel} за уровень).", 24, UIKit.Theme.TextDim, TextAlignmentOptions.Center);
|
||||
if (GameServices.TryGet(out IPopulation population))
|
||||
{
|
||||
Header(card, "Население");
|
||||
LiveBar(card, () => population.HousingCap > 0 ? (float)population.Total / population.HousingCap : 0f,
|
||||
() => $"{population.Total} / {population.HousingCap} человек", UIKit.Theme.Blue);
|
||||
Live(card, () => population.Total >= population.HousingCap
|
||||
? "Предел жилья: прирост стоит, нужны Дома или Ратуша выше"
|
||||
: $"Прирост: +{population.GrowthPerHour:0.#} человека в час", 22, UIKit.Theme.TextDim);
|
||||
}
|
||||
UpgradeBlock(card, house.level, $"жильё +{perLevel * house.level} → +{perLevel * (house.level + 1)}", () => house.Level = house.level + 1);
|
||||
}
|
||||
|
||||
// ---------------- Кристалл ----------------
|
||||
|
||||
static void CrystalSection(RectTransform card)
|
||||
{
|
||||
UIKit.Text(card, "Синий кристалл добывается в кристальных разломах на карте мира. Здание не улучшается.", 24, UIKit.Theme.TextDim, TextAlignmentOptions.Center);
|
||||
if (GameServices.TryGet(out IEconomy economy)) StorageBar(card, economy, ResourceType.Gems);
|
||||
}
|
||||
|
||||
// ---------------- Склад и Амбар ----------------
|
||||
|
||||
static void StorageSection(RectTransform card, StorageBuilding storage)
|
||||
{
|
||||
var s = EconomyConfig.Load().storage;
|
||||
bool warehouse = storage.kind == StorageBuilding.Kind.Warehouse;
|
||||
float perLevel = warehouse ? s.perWarehouseLevel : s.perGranaryLevel;
|
||||
UIKit.Text(card, warehouse
|
||||
? $"Склад даёт +{perLevel * storage.level:0} к лимиту дерева, камня, металла и золота."
|
||||
: $"Амбар даёт +{perLevel * storage.level:0} к лимиту еды.",
|
||||
24, UIKit.Theme.TextDim, TextAlignmentOptions.Center);
|
||||
|
||||
if (GameServices.TryGet(out IEconomy economy))
|
||||
{
|
||||
Header(card, "Запас / лимит");
|
||||
if (warehouse)
|
||||
{
|
||||
StorageBar(card, economy, ResourceType.Wood);
|
||||
StorageBar(card, economy, ResourceType.Stone);
|
||||
StorageBar(card, economy, ResourceType.Iron);
|
||||
StorageBar(card, economy, ResourceType.Gold);
|
||||
}
|
||||
else StorageBar(card, economy, ResourceType.Food);
|
||||
UIKit.Text(card, "Сверх лимита ресурс не прибавляется — пропадает.", 22, UIKit.Theme.TextDim, TextAlignmentOptions.Center);
|
||||
}
|
||||
|
||||
UpgradeBlock(card, storage.level, $"лимит +{perLevel * storage.level:0} → +{perLevel * (storage.level + 1):0}",
|
||||
() => storage.Level = storage.level + 1);
|
||||
}
|
||||
|
||||
// ---------------- Ратуша ----------------
|
||||
|
||||
static void TownHallSection(RectTransform card, TownHall townHall)
|
||||
{
|
||||
var config = EconomyConfig.Load();
|
||||
int level = townHall.currentLevel;
|
||||
int next = level + 1;
|
||||
var p = config.population;
|
||||
|
||||
UIKit.Text(card, $"Ратуша даёт {p.housingBase + p.housingPerTownHallLevel * (level - 1)} мест жилья. От её уровня зависят резерв для найма и доступные здания и войска.",
|
||||
24, UIKit.Theme.TextDim, TextAlignmentOptions.Center);
|
||||
|
||||
if (GameServices.TryGet(out IPopulation population))
|
||||
{
|
||||
Header(card, "Город");
|
||||
LiveBar(card, () => population.HousingCap > 0 ? (float)population.Total / population.HousingCap : 0f,
|
||||
() => $"Население {population.Total} / {population.HousingCap}", UIKit.Theme.Blue);
|
||||
Live(card, () => $"Жителей {population.Civilians}: рабочих {population.Workers}, свободных {population.Idle} • воинов {population.Soldiers}",
|
||||
22, UIKit.Theme.TextDim);
|
||||
Live(card, () => $"Резерв для найма: {population.CivilianReserve} • можно нанять: {population.Recruitable}", 22, UIKit.Theme.TextDim);
|
||||
}
|
||||
|
||||
if (level >= townHall.maxLevel)
|
||||
{
|
||||
UIKit.Text(card, "Максимальный уровень", 26, UIKit.Theme.Gold, TextAlignmentOptions.Center);
|
||||
return;
|
||||
}
|
||||
|
||||
// Что даст следующий уровень
|
||||
Header(card, $"Уровень {next}");
|
||||
var gains = new List<string> { $"жильё +{p.housingPerTownHallLevel}" };
|
||||
int nowReserve = ReserveAt(level), nextReserve = ReserveAt(next);
|
||||
if (nextReserve != nowReserve) gains.Add($"резерв для найма {nowReserve} → {nextReserve}");
|
||||
UIKit.Text(card, string.Join(", ", gains.ToArray()), 24, UIKit.Theme.Text, TextAlignmentOptions.Center);
|
||||
string unlocks = UnlocksAt(next);
|
||||
if (unlocks.Length > 0) UIKit.Text(card, "Откроется: " + unlocks, 24, UIKit.Theme.Gold, TextAlignmentOptions.Center);
|
||||
|
||||
var cost = BuildingData.ToAmounts(townHall.GetUpgradeCost());
|
||||
UIKit.Text(card, "Цена: " + CostText(cost), 24, UIKit.Theme.Text, TextAlignmentOptions.Center);
|
||||
bool canPay = townHall.CanUpgrade();
|
||||
var b = UIKit.Button(card, $"Улучшить до {next} ур.", canPay ? UIKit.Theme.Green : UIKit.Theme.Gray, () =>
|
||||
{
|
||||
if (!townHall.CanUpgrade()) { UIKit.Toast("Не хватает ресурсов"); return; }
|
||||
townHall.Upgrade();
|
||||
if (CitySync.Instance != null) CitySync.Instance.SyncNow();
|
||||
UIKit.Toast($"Ратуша улучшена до {townHall.currentLevel} уровня");
|
||||
Rebuild();
|
||||
}, -1, 80, 26);
|
||||
b.interactable = canPay;
|
||||
}
|
||||
|
||||
// ---------------- Казармы ----------------
|
||||
|
||||
static void BarracksSection(RectTransform card, Barracks barracks)
|
||||
{
|
||||
var gp = GameProgress.Instance;
|
||||
UIKit.Text(card, $"Казармы обучают войска. Казарм в городе: {Barracks.Count}, мест в очереди: {Barracks.QueueSize}. " +
|
||||
$"Новые войска открывает уровень самых прокачанных Казарм ({Barracks.MaxLevel} ур.) вместе с уровнем Ратуши.",
|
||||
24, UIKit.Theme.TextDim, TextAlignmentOptions.Center);
|
||||
|
||||
if (gp != null)
|
||||
{
|
||||
Header(card, "Обучение");
|
||||
Live(card, () =>
|
||||
{
|
||||
var training = gp.Training;
|
||||
if (training.Count == 0) return "Никто не обучается";
|
||||
var o = training[0];
|
||||
string more = training.Count > 1 ? $" (ещё заказов: {training.Count - 1})" : "";
|
||||
return $"{GameData.Troops[o.Type].name} ×{o.Count} — осталось {GameData.FormatTime(gp.TrainingSecondsLeft(o))}{more}";
|
||||
}, 24, UIKit.Theme.Text);
|
||||
if (GameServices.TryGet(out IPopulation population))
|
||||
Live(card, () => $"Можно нанять из жителей: {population.Recruitable} • в Казармах: {population.InTraining}", 22, UIKit.Theme.TextDim);
|
||||
|
||||
Header(card, "Войска");
|
||||
for (int i = 0; i < GameData.Troops.Count; i++)
|
||||
{
|
||||
string lockText = gp.TroopLockText(i);
|
||||
UIKit.Text(card, lockText.Length == 0
|
||||
? $"<color=#9be59b>{GameData.Troops[i].name} — открыты</color>"
|
||||
: $"{GameData.Troops[i].name} — нужно: {lockText}",
|
||||
24, lockText.Length == 0 ? UIKit.Theme.Text : UIKit.Theme.Gold, TextAlignmentOptions.Center);
|
||||
}
|
||||
}
|
||||
|
||||
// Что откроет следующий уровень этих Казарм
|
||||
var opens = new List<string>();
|
||||
if (gp != null)
|
||||
for (int i = 0; i < GameData.Troops.Count; i++)
|
||||
if (gp.RequiredBarracksLevel(i) == barracks.level + 1 && Barracks.MaxLevel <= barracks.level) opens.Add(GameData.Troops[i].name);
|
||||
string effect = opens.Count > 0
|
||||
? $"откроются: {string.Join(", ", opens.ToArray())} (если хватает уровня Ратуши)"
|
||||
: "ближе к новым войскам";
|
||||
UpgradeBlock(card, barracks.level, effect, () => barracks.Level = barracks.level + 1);
|
||||
|
||||
UIKit.Button(card, "Нанять войска", UIKit.Theme.Blue, () =>
|
||||
{
|
||||
Close();
|
||||
if (UIPanelManager.Instance != null) UIPanelManager.Instance.Open("Troops");
|
||||
}, -1, 80, 26);
|
||||
}
|
||||
|
||||
// ---------------- Госпиталь (решения 31.10, п. 7) ----------------
|
||||
|
||||
static void HospitalSection(RectTransform card, Hospital hospital)
|
||||
{
|
||||
UIKit.Text(card, $"Госпиталь лечит раненых после боёв. Мест: {Hospital.BedsAt(Hospital.MaxLevel)} " +
|
||||
"(считается самый прокачанный госпиталь). Кто не поместился -- ждёт места.",
|
||||
24, UIKit.Theme.TextDim, TextAlignmentOptions.Center);
|
||||
|
||||
if (GameServices.TryGet(out IArmy army))
|
||||
{
|
||||
Header(card, "Раненые");
|
||||
Live(card, () =>
|
||||
{
|
||||
int healingNow = 0, waiting = 0;
|
||||
long readyMs = long.MaxValue;
|
||||
foreach (var h in army.Healing) { healingNow += h.Count; readyMs = Math.Min(readyMs, h.EndMs); }
|
||||
for (int t = 0; t < army.TypeCount; t++) waiting += army.GetWoundedWaiting(t);
|
||||
if (healingNow + waiting == 0) return "Раненых нет";
|
||||
string next = readyMs != long.MaxValue
|
||||
? $", ближайшие вылечатся через {GameData.FormatTime(Math.Max(0, readyMs - GameClock.UnixNowMs) / 1000.0)}"
|
||||
: "";
|
||||
return $"Лечатся {healingNow} из {army.HospitalBeds} мест, ждут места {waiting}{next}";
|
||||
}, 24, UIKit.Theme.Text);
|
||||
Live(card, () =>
|
||||
{
|
||||
var lines = new List<string>();
|
||||
foreach (var h in army.Healing)
|
||||
if (h.Type >= 0 && h.Type < GameData.Troops.Count)
|
||||
lines.Add($"{GameData.Troops[h.Type].name} ×{h.Count} — {GameData.FormatTime(Math.Max(0, h.EndMs - GameClock.UnixNowMs) / 1000.0)}");
|
||||
return lines.Count > 0 ? string.Join("\n", lines.ToArray()) : "";
|
||||
}, 22, UIKit.Theme.TextDim);
|
||||
var rules = HospitalConfig.Load().healGameHours;
|
||||
UIKit.Text(card, $"Лечение одного воина: пехота и лучники {rules[0]}, кавалерия {rules[2]}, осадные {rules[3]} игровых ч " +
|
||||
"(игровой час = 5 мин). Лечатся и пока игра закрыта.", 22, UIKit.Theme.TextDim, TextAlignmentOptions.Center);
|
||||
}
|
||||
|
||||
UpgradeBlock(card, hospital.level, $"мест {Hospital.BedsAt(hospital.level)} → {Hospital.BedsAt(hospital.level + 1)}",
|
||||
() => hospital.Level = hospital.level + 1, Hospital.MaxBuildLevel);
|
||||
}
|
||||
|
||||
// Резерв для найма на уровне Ратуши (те же числа, что у модуля Населения)
|
||||
static int ReserveAt(int townHallLevel)
|
||||
{
|
||||
var table = GameModules.Population != null ? GameModules.Population.Rules.CivilianReserveByTownHallLevel : null;
|
||||
if (table == null || table.Length == 0) return 0;
|
||||
return table[Mathf.Clamp(townHallLevel, 1, table.Length) - 1];
|
||||
}
|
||||
|
||||
// Здания и войска, которые открываются ровно на этом уровне Ратуши
|
||||
static string UnlocksAt(int townHallLevel)
|
||||
{
|
||||
var names = new List<string>();
|
||||
if (BuildManager.Instance != null && BuildManager.Instance.allBuildings != null)
|
||||
foreach (var b in BuildManager.Instance.allBuildings)
|
||||
if (b != null && b.requiredTownHallLevel == townHallLevel) names.Add(b.buildingName);
|
||||
foreach (var t in GameData.Troops)
|
||||
if (t.requiredTownHall == townHallLevel) names.Add(t.name);
|
||||
return string.Join(", ", names.ToArray());
|
||||
}
|
||||
|
||||
// ---------------- Улучшение (общее для зданий с уровнем, кроме Ратуши) ----------------
|
||||
|
||||
static void UpgradeBlock(RectTransform card, int level, string effect, Action raiseLevel, int maxLevel = 0)
|
||||
{
|
||||
var config = EconomyConfig.Load();
|
||||
if (level >= (maxLevel > 0 ? maxLevel : config.upgrade.maxLevel)) // у госпиталя свой предел: I-V
|
||||
{
|
||||
UIKit.Text(card, "Максимальный уровень", 26, UIKit.Theme.Gold, TextAlignmentOptions.Center);
|
||||
return;
|
||||
}
|
||||
Header(card, $"Улучшение до {level + 1} ур.");
|
||||
UIKit.Text(card, effect, 24, UIKit.Theme.Text, TextAlignmentOptions.Center);
|
||||
var cost = config.UpgradeCost(level);
|
||||
UIKit.Text(card, "Цена: " + CostText(cost), 24, UIKit.Theme.Text, TextAlignmentOptions.Center);
|
||||
|
||||
bool canPay = GameServices.TryGet(out IEconomy economy) && economy.CanAfford(cost);
|
||||
var b = UIKit.Button(card, $"Улучшить до {level + 1} ур.", canPay ? UIKit.Theme.Green : UIKit.Theme.Gray, () =>
|
||||
{
|
||||
if (!GameServices.TryGet(out IEconomy e) || !e.TrySpend(cost)) { UIKit.Toast("Не хватает ресурсов"); return; }
|
||||
raiseLevel();
|
||||
if (CitySync.Instance != null) CitySync.Instance.SyncNow();
|
||||
UIKit.Toast($"Улучшено до {level + 1} уровня");
|
||||
Rebuild();
|
||||
}, -1, 80, 26);
|
||||
b.interactable = canPay;
|
||||
}
|
||||
|
||||
// Цена: чего хватает -- зелёным, чего нет -- красным, со сколько есть
|
||||
static string CostText(ResourceAmount[] cost)
|
||||
{
|
||||
GameServices.TryGet(out IEconomy economy);
|
||||
var parts = new string[cost.Length];
|
||||
for (int i = 0; i < cost.Length; i++)
|
||||
{
|
||||
float have = economy != null ? economy.Get(cost[i].Type) : 0f;
|
||||
bool ok = have >= cost[i].Amount;
|
||||
string name = GameData.ResName(cost[i].Type).ToLower();
|
||||
parts[i] = ok
|
||||
? $"<color=#9be59b>{cost[i].Amount:0}</color> {name}"
|
||||
: $"<color=#ff7a7a>{cost[i].Amount:0}</color> {name} (есть {have:0})";
|
||||
}
|
||||
return string.Join(", ", parts);
|
||||
}
|
||||
|
||||
// ---------------- Склад: полоска и время до заполнения ----------------
|
||||
|
||||
static void StorageBar(RectTransform card, IEconomy economy, ResourceType res)
|
||||
{
|
||||
string name = GameData.ResName(res);
|
||||
LiveBar(card, () =>
|
||||
{
|
||||
float cap = economy.GetCapacity(res);
|
||||
return float.IsInfinity(cap) || cap <= 0 ? 0f : economy.Get(res) / cap;
|
||||
},
|
||||
() =>
|
||||
{
|
||||
float cap = economy.GetCapacity(res);
|
||||
return float.IsInfinity(cap) ? $"{name}: {economy.Get(res):0}" : $"{name}: {economy.Get(res):0} / {cap:0}";
|
||||
},
|
||||
new Color(0.85f, 0.65f, 0.25f));
|
||||
}
|
||||
|
||||
static string FillTimeText(IEconomy economy, ResourceType res, float ratePerHour)
|
||||
{
|
||||
float cap = economy.GetCapacity(res), have = economy.Get(res);
|
||||
if (float.IsInfinity(cap)) return "";
|
||||
if (have >= cap) return "<color=#ff7a7a>Склад полон — добыча пропадает. Улучшите Склад или Амбар.</color>";
|
||||
if (ratePerHour <= 0) return "Добычи нет — поставьте рабочих";
|
||||
float hours = (cap - have) / ratePerHour;
|
||||
return hours < 1f ? $"Склад заполнится через {Mathf.CeilToInt(hours * 60)} мин" : $"Склад заполнится через {hours:0.#} ч";
|
||||
}
|
||||
|
||||
// ---------------- Список рабочих по всем профессиям ----------------
|
||||
|
||||
static void WorkersList(RectTransform card)
|
||||
{
|
||||
if (!GameServices.TryGet(out IPopulation pop) || !GameServices.TryGet(out IProduction prod))
|
||||
{
|
||||
UIKit.Text(card, "Модули Населения и Экономики не подключены", 26, UIKit.Theme.Red, TextAlignmentOptions.Center);
|
||||
return;
|
||||
}
|
||||
|
||||
for (int j = 0; j < WorkJobs.Count; j++)
|
||||
{
|
||||
var job = (WorkJob)j;
|
||||
var row = UIKit.Row(card, 72, 8);
|
||||
var label = Live(row, () =>
|
||||
{
|
||||
int slots = prod.Slots(job);
|
||||
if (slots <= 0) return $"{WorkJobs.Name(job)}: нет зданий";
|
||||
return $"{WorkJobs.Name(job)}: {pop.GetWorkers(job)} / {slots} <color=#9be29b>+{prod.RatePerHour(WorkJobs.Resource(job)):0.#}/ч</color>";
|
||||
}, 24, UIKit.Theme.Text);
|
||||
label.enableAutoSizing = true; label.fontSizeMin = 14; label.fontSizeMax = 24;
|
||||
label.alignment = TextAlignmentOptions.Left;
|
||||
UIKit.Size(label, -1, 72, 1);
|
||||
|
||||
StepButton(row, "−5", job, -5);
|
||||
StepButton(row, "−1", job, -1);
|
||||
StepButton(row, "+1", job, +1);
|
||||
StepButton(row, "+5", job, +5);
|
||||
}
|
||||
|
||||
Live(card, () => $"Свободных жителей: <b>{pop.FreeWorkers}</b> из {pop.Civilians}", 24, UIKit.Theme.Gold);
|
||||
}
|
||||
|
||||
// Кнопка "−" / "+": активна, только если есть кого снять или куда и кого поставить
|
||||
static void StepButton(Transform row, string label, WorkJob job, int delta)
|
||||
{
|
||||
Button b = null;
|
||||
b = UIKit.Button(row, label, delta > 0 ? UIKit.Theme.Blue : UIKit.Theme.Gray, () =>
|
||||
{
|
||||
if (GameServices.TryGet(out IPopulation pop)) pop.SetWorkers(job, pop.GetWorkers(job) + delta);
|
||||
Tick();
|
||||
}, 86, 64, 24);
|
||||
live.Add(() =>
|
||||
{
|
||||
if (b == null || !GameServices.TryGet(out IPopulation pop) || !GameServices.TryGet(out IProduction prod)) return;
|
||||
int workers = pop.GetWorkers(job), slots = prod.Slots(job);
|
||||
b.interactable = delta < 0 ? workers > 0 : slots > workers && pop.FreeWorkers > 0;
|
||||
});
|
||||
}
|
||||
|
||||
// ---------------- Мелочи ----------------
|
||||
|
||||
static void Header(RectTransform card, string text)
|
||||
{
|
||||
UIKit.Text(card, text, 28, UIKit.Theme.Gold, TextAlignmentOptions.Center).fontStyle = FontStyles.Bold;
|
||||
}
|
||||
|
||||
// Текст, который обновляется, пока окно открыто
|
||||
static TextMeshProUGUI Live(Transform parent, Func<string> text, float size, Color color)
|
||||
{
|
||||
var t = UIKit.Text(parent, text(), size, color, TextAlignmentOptions.Center);
|
||||
live.Add(() => { if (t != null) t.text = text(); });
|
||||
return t;
|
||||
}
|
||||
|
||||
static void LiveBar(RectTransform card, Func<float> fraction, Func<string> label, Color color)
|
||||
{
|
||||
var bar = UIKit.Bar(card, fraction(), color, 34, label());
|
||||
live.Add(() => { if (bar != null) UIKit.SetBar(bar, fraction(), label()); });
|
||||
}
|
||||
}
|
||||
|
||||
// Обновляет открытое окно здания раз в полсекунды (добыча идёт, склад наполняется)
|
||||
public class BuildingWindowTicker : MonoBehaviour
|
||||
{
|
||||
float next;
|
||||
|
||||
void Update()
|
||||
{
|
||||
if (Time.unscaledTime < next) return;
|
||||
next = Time.unscaledTime + 0.5f;
|
||||
BuildingWindow.Tick();
|
||||
}
|
||||
}
|
||||
@@ -1,11 +0,0 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 50195a725abc4fe9b74871cd8cc4b273
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -1,129 +0,0 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Text;
|
||||
using UnityEngine;
|
||||
|
||||
// Слой приложения: сообщает модулям, какие здания стоят в городе.
|
||||
// - рабочие места (Лес, Еда, Камень, Металл, Золото) и их уровни -> модуль добычи (места и эффективность);
|
||||
// - Дома (здание "Население") -> модуль Населения (предел жилья);
|
||||
// - Склады и Амбары -> лимиты склада в Экономике.
|
||||
// Модули сами здания не ищут: они без Unity и живут и на карте мира, где города нет.
|
||||
//
|
||||
// Проверяет город раз в полсекунды и применяет изменения, только когда картина устоялась
|
||||
// (два одинаковых замера подряд) -- чтобы не поймать город посреди восстановления из сохранения.
|
||||
public class CitySync : MonoBehaviour
|
||||
{
|
||||
const float Interval = 0.5f;
|
||||
|
||||
public static CitySync Instance { get; private set; }
|
||||
|
||||
float next;
|
||||
string pending; // замер, который ждёт подтверждения
|
||||
string applied; // последний применённый замер
|
||||
|
||||
[RuntimeInitializeOnLoadMethod(RuntimeInitializeLoadType.AfterSceneLoad)]
|
||||
static void Create()
|
||||
{
|
||||
if (Instance != null) return;
|
||||
var go = new GameObject("CitySync");
|
||||
DontDestroyOnLoad(go);
|
||||
Instance = go.AddComponent<CitySync>();
|
||||
}
|
||||
|
||||
void OnDestroy()
|
||||
{
|
||||
if (Instance == this) Instance = null;
|
||||
}
|
||||
|
||||
void Update()
|
||||
{
|
||||
if (Time.unscaledTime < next) return;
|
||||
next = Time.unscaledTime + Interval;
|
||||
if (BuildManager.Instance == null) return; // город не открыт -- модули помнят последнее
|
||||
|
||||
var city = Scan();
|
||||
if (city.Signature == applied) { pending = null; return; }
|
||||
if (city.Signature != pending) { pending = city.Signature; return; } // ждём подтверждения
|
||||
Apply(city);
|
||||
}
|
||||
|
||||
// Применить сразу (после улучшения здания в окне -- чтобы окно показало новые места)
|
||||
public void SyncNow()
|
||||
{
|
||||
if (BuildManager.Instance == null) return;
|
||||
Apply(Scan());
|
||||
}
|
||||
|
||||
class City
|
||||
{
|
||||
public readonly List<KeyValuePair<WorkJob, int>> Workplaces = new List<KeyValuePair<WorkJob, int>>();
|
||||
public int HouseLevels, WarehouseLevels, GranaryLevels;
|
||||
public string Signature;
|
||||
}
|
||||
|
||||
static City Scan()
|
||||
{
|
||||
var city = new City();
|
||||
var sig = new List<string>();
|
||||
foreach (var placed in FindObjectsByType<PlacedBuildingId>(FindObjectsSortMode.None))
|
||||
{
|
||||
if (string.IsNullOrEmpty(placed.buildingName)) continue; // убирается в этом кадре
|
||||
|
||||
var rc = placed.GetComponent<ResourceCollector>();
|
||||
if (rc != null)
|
||||
{
|
||||
if (WorkJobs.TryFromResource(rc.resourceType, out WorkJob job))
|
||||
{
|
||||
city.Workplaces.Add(new KeyValuePair<WorkJob, int>(job, rc.level));
|
||||
sig.Add("w" + (int)job + ":" + rc.level);
|
||||
}
|
||||
else if (rc.IsHouse)
|
||||
{
|
||||
city.HouseLevels += Mathf.Max(1, rc.level);
|
||||
sig.Add("h:" + rc.level);
|
||||
}
|
||||
}
|
||||
|
||||
var sb = placed.GetComponent<StorageBuilding>();
|
||||
if (sb != null)
|
||||
{
|
||||
if (sb.kind == StorageBuilding.Kind.Warehouse) city.WarehouseLevels += Mathf.Max(1, sb.level);
|
||||
else city.GranaryLevels += Mathf.Max(1, sb.level);
|
||||
sig.Add("s" + (int)sb.kind + ":" + sb.level);
|
||||
}
|
||||
}
|
||||
sig.Sort(System.StringComparer.Ordinal);
|
||||
var text = new StringBuilder();
|
||||
foreach (var s in sig) text.Append(s).Append(';');
|
||||
city.Signature = text.ToString();
|
||||
return city;
|
||||
}
|
||||
|
||||
void Apply(City city)
|
||||
{
|
||||
applied = city.Signature;
|
||||
pending = null;
|
||||
|
||||
var config = EconomyConfig.Load();
|
||||
|
||||
// Лимиты склада
|
||||
var rm = ResourceManager.Instance;
|
||||
if (rm != null)
|
||||
{
|
||||
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));
|
||||
}
|
||||
|
||||
var production = GameModules.Production;
|
||||
var population = GameModules.Population;
|
||||
if (production == null || population == null) return;
|
||||
|
||||
// Места для рабочих. Новые места (построили, улучшили) остаются пустыми, пока игрок сам
|
||||
// не поставит рабочих (регламент 31.10). Мест стало меньше -- лишних рабочих снимаем.
|
||||
production.SetBuildings(city.Workplaces);
|
||||
population.SetHouseLevels(city.HouseLevels);
|
||||
population.ClampJobs();
|
||||
|
||||
// Единственное автозаполнение -- стартовая расстановка, один раз: новая игра или первая загрузка старого сохранения
|
||||
if (!population.JobsInitialized) population.AutoFillAll();
|
||||
}
|
||||
}
|
||||
@@ -1,11 +0,0 @@
|
||||
fileFormatVersion: 2
|
||||
guid: fbcccbb94e6f46c89d4c49bf589b9f19
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -1,165 +0,0 @@
|
||||
#if UNITY_EDITOR || DEVELOPMENT_BUILD
|
||||
using TMPro;
|
||||
using UnityEngine;
|
||||
using UnityEngine.UI;
|
||||
|
||||
// ОТЛАДОЧНАЯ ПАНЕЛЬ для проверки баланса: кнопка "DEV" слева на экране.
|
||||
//
|
||||
// Есть только в редакторе и в Development Build -- в релизную сборку этот файл не попадает совсем.
|
||||
// "+1 час" не имеет своей логики: он двигает GameClock, и модули досчитывают время по своим обычным правилам,
|
||||
// ровно как при возвращении игрока в игру. Поэтому проверяешь настоящие правила, а не кнопку.
|
||||
//
|
||||
// Перемотка действует на то, что берёт время из GameClock (сейчас -- Население и походы). Обучение
|
||||
// войск пока идёт по реальным часам.
|
||||
public class DebugPanel : MonoBehaviour
|
||||
{
|
||||
static DebugPanel instance;
|
||||
|
||||
GameObject panel;
|
||||
TextMeshProUGUI info;
|
||||
float nextRefresh;
|
||||
|
||||
[RuntimeInitializeOnLoadMethod(RuntimeInitializeLoadType.AfterSceneLoad)]
|
||||
static void Create()
|
||||
{
|
||||
if (instance != null) return;
|
||||
var go = new GameObject("DebugPanel", typeof(RectTransform), typeof(Canvas), typeof(CanvasScaler), typeof(GraphicRaycaster));
|
||||
DontDestroyOnLoad(go);
|
||||
var canvas = go.GetComponent<Canvas>();
|
||||
canvas.renderMode = RenderMode.ScreenSpaceOverlay;
|
||||
canvas.sortingOrder = 1500; // поверх HUD и окон
|
||||
var scaler = go.GetComponent<CanvasScaler>();
|
||||
scaler.uiScaleMode = CanvasScaler.ScaleMode.ScaleWithScreenSize;
|
||||
scaler.referenceResolution = new Vector2(1920, 1080);
|
||||
scaler.matchWidthOrHeight = 0.5f;
|
||||
instance = go.AddComponent<DebugPanel>();
|
||||
instance.Build();
|
||||
}
|
||||
|
||||
void Build()
|
||||
{
|
||||
// Панель -- внутри безопасной зоны, чтобы не уходила под вырез камеры
|
||||
var safe = new GameObject("SafeArea", typeof(RectTransform)).GetComponent<RectTransform>();
|
||||
safe.SetParent(transform, false);
|
||||
safe.anchorMin = Vector2.zero;
|
||||
safe.anchorMax = Vector2.one;
|
||||
safe.offsetMin = safe.offsetMax = Vector2.zero;
|
||||
safe.gameObject.AddComponent<SafeArea>();
|
||||
|
||||
// Кнопка-переключатель слева посередине: там нет кнопок HUD
|
||||
var toggle = UIKit.Button(transform, "DEV", new Color(0.8f, 0.2f, 0.8f, 1f), Toggle, 110, 64, 28);
|
||||
var trt = (RectTransform)toggle.transform;
|
||||
trt.anchorMin = trt.anchorMax = new Vector2(0, 0.5f);
|
||||
trt.pivot = new Vector2(0, 0.5f);
|
||||
trt.anchoredPosition = new Vector2(12, 0);
|
||||
trt.sizeDelta = new Vector2(110, 64);
|
||||
|
||||
var card = UIKit.Card(safe, new Color(0.08f, 0.08f, 0.11f, 0.95f), 20, 10);
|
||||
card.anchorMin = card.anchorMax = new Vector2(0, 0.5f);
|
||||
card.pivot = new Vector2(0, 0.5f);
|
||||
card.anchoredPosition = new Vector2(135, 0);
|
||||
card.sizeDelta = new Vector2(620, 0);
|
||||
card.gameObject.AddComponent<ContentSizeFitter>().verticalFit = ContentSizeFitter.FitMode.PreferredSize;
|
||||
panel = card.gameObject;
|
||||
|
||||
var title = UIKit.Text(card, "ОТЛАДКА БАЛАНСА", 30, UIKit.Theme.Gold, TextAlignmentOptions.Center);
|
||||
title.fontStyle = FontStyles.Bold;
|
||||
|
||||
info = UIKit.Text(card, "", 24, UIKit.Theme.Text);
|
||||
|
||||
var row1 = UIKit.Row(card, 70, 10);
|
||||
Btn(row1, "+1 час", UIKit.Theme.Blue, () => GameClock.Advance(3600));
|
||||
Btn(row1, "+10 часов", UIKit.Theme.Blue, () => GameClock.Advance(36000));
|
||||
|
||||
var row2 = UIKit.Row(card, 70, 10);
|
||||
Btn(row2, "+100 всех ресурсов", UIKit.Theme.Green, AddAllResources);
|
||||
Btn(row2, "+1000 еды", UIKit.Theme.Green, () => AddResource(ResourceType.Food, 1000));
|
||||
|
||||
var row3 = UIKit.Row(card, 70, 10);
|
||||
Btn(row3, "Еда = 0", UIKit.Theme.Red, ZeroFood);
|
||||
Btn(row3, "Сбросить перемотку", UIKit.Theme.Gray, () => { GameClock.ResetOffset(); Refresh(); });
|
||||
|
||||
var row4 = UIKit.Row(card, 70, 10);
|
||||
Btn(row4, "Рабочие", UIKit.Theme.Blue, () => BuildingWindow.Open(null));
|
||||
Btn(row4, "Закрыть", UIKit.Theme.Gray, () => panel.SetActive(false));
|
||||
|
||||
panel.SetActive(false);
|
||||
}
|
||||
|
||||
static void Btn(Transform row, string label, Color color, System.Action onClick)
|
||||
{
|
||||
var b = UIKit.Button(row, label, color, onClick, -1, 70, 24);
|
||||
UIKit.Size(b, -1, 70, 1);
|
||||
}
|
||||
|
||||
void Toggle()
|
||||
{
|
||||
panel.SetActive(!panel.activeSelf);
|
||||
if (panel.activeSelf) Refresh();
|
||||
}
|
||||
|
||||
void Update()
|
||||
{
|
||||
if (panel == null || !panel.activeSelf || Time.unscaledTime < nextRefresh) return;
|
||||
nextRefresh = Time.unscaledTime + 0.5f;
|
||||
Refresh();
|
||||
}
|
||||
|
||||
void Refresh()
|
||||
{
|
||||
if (info == null) return;
|
||||
var sb = new System.Text.StringBuilder();
|
||||
|
||||
if (GameServices.TryGet(out IPopulation p))
|
||||
{
|
||||
sb.Append($"Население: <b>{p.Total}</b> / {p.HousingCap}\n");
|
||||
sb.Append($" жители {p.Civilians}: на работе {p.Workers}, свободно {p.FreeWorkers} из {p.Workforce}; воины {p.Soldiers}\n");
|
||||
sb.Append($"Еда в час: <color=#ff8a80>-{p.FoodPerHour:0.#}</color> прирост в час: +{p.GrowthPerHour:0.#}\n");
|
||||
if (p.IsStarving) sb.Append("<color=#ff5252><b>ГОЛОД: еды не хватает, прирост остановлен</b></color>\n");
|
||||
}
|
||||
else sb.Append("Модуль Населения не зарегистрирован\n");
|
||||
|
||||
if (GameServices.TryGet(out IProduction prod))
|
||||
sb.Append($"Добыча в час: еда +{prod.RatePerHour(ResourceType.Food):0.#}, дерево +{prod.RatePerHour(ResourceType.Wood):0.#}, " +
|
||||
$"камень +{prod.RatePerHour(ResourceType.Stone):0.#}, металл +{prod.RatePerHour(ResourceType.Iron):0.#}, золото +{prod.RatePerHour(ResourceType.Gold):0.#}\n");
|
||||
|
||||
if (GameServices.TryGet(out IEconomy e))
|
||||
sb.Append($"Склад: еда {e.Get(ResourceType.Food):0}/{Cap(e, ResourceType.Food)} дерево {e.Get(ResourceType.Wood):0}/{Cap(e, ResourceType.Wood)} " +
|
||||
$"камень {e.Get(ResourceType.Stone):0}/{Cap(e, ResourceType.Stone)}\n");
|
||||
else sb.Append("Экономика не найдена (нет ResourceManager на сцене)\n");
|
||||
|
||||
long off = GameClock.OffsetSeconds;
|
||||
sb.Append(off > 0 ? $"Перемотано: +{off / 3600f:0.#} ч" : "Время: реальное");
|
||||
info.text = sb.ToString();
|
||||
}
|
||||
|
||||
static string Cap(IEconomy e, ResourceType t)
|
||||
{
|
||||
float c = e.GetCapacity(t);
|
||||
return float.IsInfinity(c) ? "∞" : c.ToString("0");
|
||||
}
|
||||
|
||||
void AddAllResources()
|
||||
{
|
||||
ResourceType[] types = { ResourceType.Wood, ResourceType.Food, ResourceType.Stone, ResourceType.Iron, ResourceType.Gold, ResourceType.Gems };
|
||||
foreach (var t in types) AddResource(t, 100);
|
||||
}
|
||||
|
||||
void AddResource(ResourceType type, float amount)
|
||||
{
|
||||
if (GameServices.TryGet(out IEconomy e)) e.Add(type, amount);
|
||||
Refresh();
|
||||
}
|
||||
|
||||
void ZeroFood()
|
||||
{
|
||||
if (GameServices.TryGet(out IEconomy e)) e.Add(ResourceType.Food, -e.Get(ResourceType.Food));
|
||||
Refresh();
|
||||
}
|
||||
|
||||
void OnDestroy()
|
||||
{
|
||||
if (instance == this) instance = null;
|
||||
}
|
||||
}
|
||||
#endif
|
||||
@@ -1,11 +0,0 @@
|
||||
fileFormatVersion: 2
|
||||
guid: c2cba3db366a45feaaa3e1f4867ae423
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -1,220 +0,0 @@
|
||||
using System;
|
||||
using UnityEngine;
|
||||
|
||||
// ТАБЛИЦА БАЛАНСА ЭКОНОМИКИ. Все числа экономики -- здесь, а не в коде модулей.
|
||||
//
|
||||
// Ассет: Assets/Resources/EconomyConfig.asset (выдели его в окне Project и меняй числа в Inspector,
|
||||
// перекомпиляция не нужна). Создать заново: правый клик в Project -> Create -> Bogatyri -> Economy Config,
|
||||
// имя файла -- EconomyConfig, папка -- Resources.
|
||||
//
|
||||
// Модули конфиг не видят: App/GameModules переводит его в правила модулей (например, правила Населения).
|
||||
// Числа -- из дизайн-документа. Помеченные "не утверждено" -- временные, их решает команда.
|
||||
[CreateAssetMenu(fileName = "EconomyConfig", menuName = "Bogatyri/Economy Config")]
|
||||
public class EconomyConfig : ScriptableObject
|
||||
{
|
||||
[Serializable]
|
||||
public class PopulationSettings
|
||||
{
|
||||
[Tooltip("Стартовый город T1: всего людей")]
|
||||
public int startTotal = 300;
|
||||
[Tooltip("Из них рабочих")]
|
||||
public int startWorkers = 120;
|
||||
[Tooltip("Из них воинов (выдаются в Армию пехотой при новой игре)")]
|
||||
public int startSoldiers = 30;
|
||||
|
||||
[Tooltip("Еды в час на одного жителя")]
|
||||
public float foodPerCivilianPerHour = 0.1f;
|
||||
[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;
|
||||
|
||||
[Tooltip("Предел населения без Домов на 1 уровне Ратуши (решение команды от 19.10: 300 при 300 -- прирост стоит)")]
|
||||
public int housingBase = 300;
|
||||
[Tooltip("Прибавка к пределу за каждый следующий уровень Ратуши. Не утверждено")]
|
||||
public int housingPerTownHallLevel = 50;
|
||||
[Tooltip("Прибавка к пределу за каждый уровень Дома (здание \"Население\"). Не утверждено")]
|
||||
public int housingPerHouseLevel = 50;
|
||||
|
||||
[Tooltip("Офлайн-прогресс считается не больше чем за столько часов. Не утверждено")]
|
||||
public int maxOfflineHours = 48;
|
||||
|
||||
[Tooltip("Гражданский резерв по уровню Ратуши: [0] -- уровень 1, [1] -- уровень 2... Столько свободных жителей нельзя забрать в войско. Регламент 31.10, ТЕСТ: 1-2 -- 10, 3-5 -- 20, 6-10 -- 30")]
|
||||
public int[] civilianReserveByTownHallLevel = { 10, 10, 20, 20, 20, 30, 30, 30, 30, 30 };
|
||||
}
|
||||
|
||||
[Serializable]
|
||||
public class ProductionSettings // этап 3: производство через рабочих
|
||||
{
|
||||
[Tooltip("Лесоруб T1: дерева в час")]
|
||||
public float woodcutterPerHour = 2f;
|
||||
[Tooltip("Фермер T1: еды в час")]
|
||||
public float farmerPerHour = 2f;
|
||||
[Tooltip("Каменотёс T1: камня в час")]
|
||||
public float stonecutterPerHour = 1.5f;
|
||||
[Tooltip("Шахтёр T1: металла в час")]
|
||||
public float minerIronPerHour = 1f;
|
||||
[Tooltip("Шахтёр T1: золота в час")]
|
||||
public float minerGoldPerHour = 0.4f;
|
||||
[Tooltip("Мест для рабочих в здании T1 (Лесопилка и т.п.)")]
|
||||
public int workerSlotsT1 = 30;
|
||||
[Tooltip("+ мест за каждый уровень здания выше первого. Не утверждено")]
|
||||
public int workerSlotsPerLevel = 10;
|
||||
[Tooltip("+ к норме за каждый уровень здания выше первого (0.1 = +10%). Не утверждено")]
|
||||
public float efficiencyPerLevel = 0.1f;
|
||||
[Tooltip("Голод: множитель добычи всех ресурсов, кроме еды (0.5 -- вдвое меньше). Еда добывается без штрафа. Регламент 31.10, ТЕСТ")]
|
||||
[Range(0f, 1f)] public float famineNonFoodMultiplier = 0.5f;
|
||||
}
|
||||
|
||||
[Serializable]
|
||||
public class StorageSettings // этап 1: лимиты хранения
|
||||
{
|
||||
[Tooltip("Лимит без Складов: дерево, камень, металл, золото. Не утверждено")]
|
||||
public float baseGoods = 1000f;
|
||||
[Tooltip("Лимит еды без Амбаров. Не утверждено")]
|
||||
public float baseFood = 1500f;
|
||||
[Tooltip("+ к лимиту дерева, камня, металла, золота за каждый уровень Склада. Не утверждено")]
|
||||
public float perWarehouseLevel = 1000f;
|
||||
[Tooltip("+ к лимиту еды за каждый уровень Амбара. Не утверждено")]
|
||||
public float perGranaryLevel = 1500f;
|
||||
[Tooltip("Лимит синего кристалла (решение команды от 19.10: небольшой, не бесконечный). Число не утверждено")]
|
||||
public float crystalCap = 50f;
|
||||
|
||||
[Tooltip("Цена Склада: дерево / камень. Не утверждено")]
|
||||
public float warehouseWood = 200f;
|
||||
public float warehouseStone = 100f;
|
||||
[Tooltip("Цена Амбара: дерево / камень. Не утверждено")]
|
||||
public float granaryWood = 150f;
|
||||
public float granaryStone = 50f;
|
||||
}
|
||||
|
||||
[Serializable]
|
||||
public class UpgradeSettings // улучшение зданий (рабочие места, Дома, Склады, Амбары)
|
||||
{
|
||||
[Tooltip("Цена улучшения с 1 на 2 уровень: дерево / камень. Не утверждено")]
|
||||
public float baseWood = 150f;
|
||||
public float baseStone = 100f;
|
||||
[Tooltip("Во сколько раз дорожает каждый следующий уровень. Не утверждено")]
|
||||
public float multiplierPerLevel = 1.6f;
|
||||
public int maxLevel = 10;
|
||||
}
|
||||
|
||||
[Serializable]
|
||||
public class DepositSettings // этап 4: качество месторождения (пока не используется)
|
||||
{
|
||||
public float poor = 0.6f;
|
||||
public float normal = 1f;
|
||||
public float rich = 1.5f;
|
||||
}
|
||||
|
||||
[Serializable]
|
||||
public class SoldierSettings // этап 5: цена солдата
|
||||
{
|
||||
[Tooltip("Сколько жителей стоит один воин (берутся из жителей при начале обучения)")]
|
||||
public int people = 1;
|
||||
public float food = 15f;
|
||||
public float iron = 5f;
|
||||
public float gold = 5f;
|
||||
[Tooltip("Время подготовки, секунд. Не утверждено")]
|
||||
public int trainSeconds = 60;
|
||||
[Tooltip("Уровень Казарм, с которого открывается тип войск (порядок как в GameData.Troops: пехота, лучники, кавалерия, осадные). " +
|
||||
"Нужен и уровень Ратуши. Считаются самые прокачанные Казармы. ТЕСТ, не утверждено")]
|
||||
public int[] requiredBarracksLevel = { 1, 2, 3, 5 };
|
||||
}
|
||||
|
||||
[Serializable]
|
||||
public class StartResources
|
||||
{
|
||||
[Tooltip("Ресурсы новой игры. Не утверждено")]
|
||||
public float wood = 500f;
|
||||
public float food = 500f;
|
||||
public float stone = 200f;
|
||||
public float iron = 100f;
|
||||
public float gold = 100f;
|
||||
public float gems = 0f;
|
||||
}
|
||||
|
||||
[Header("Население (этап 2)")]
|
||||
public PopulationSettings population = new PopulationSettings();
|
||||
|
||||
[Header("Стартовые ресурсы новой игры")]
|
||||
public StartResources startResources = new StartResources();
|
||||
|
||||
[Header("Склады и лимиты (этап 1)")]
|
||||
public StorageSettings storage = new StorageSettings();
|
||||
|
||||
[Header("Производство T1 (этап 3)")]
|
||||
public ProductionSettings production = new ProductionSettings();
|
||||
|
||||
[Header("Улучшение зданий")]
|
||||
public UpgradeSettings upgrade = new UpgradeSettings();
|
||||
|
||||
[Header("Месторождения (этап 4)")]
|
||||
public DepositSettings deposits = new DepositSettings();
|
||||
|
||||
[Header("Солдат T1 (этап 5)")]
|
||||
public SoldierSettings soldier = new SoldierSettings();
|
||||
|
||||
// ---------------- Загрузка ----------------
|
||||
|
||||
static EconomyConfig cached;
|
||||
|
||||
// Конфиг из Assets/Resources/EconomyConfig.asset. Нет ассета -- числа по умолчанию (из этого файла) и предупреждение.
|
||||
public static EconomyConfig Load()
|
||||
{
|
||||
if (cached != null) return cached;
|
||||
cached = Resources.Load<EconomyConfig>("EconomyConfig");
|
||||
if (cached == null)
|
||||
{
|
||||
Debug.LogWarning("[ЭКОНОМИКА] Нет Assets/Resources/EconomyConfig.asset -- берутся числа по умолчанию из EconomyConfig.cs.");
|
||||
cached = CreateInstance<EconomyConfig>();
|
||||
}
|
||||
return cached;
|
||||
}
|
||||
|
||||
// Лимит склада для ресурса по уровням Складов и Амбаров. PositiveInfinity -- без лимита (люди, сила)
|
||||
public float GetCapacity(ResourceType type, int warehouseLevels, int granaryLevels)
|
||||
{
|
||||
var s = storage;
|
||||
switch (type)
|
||||
{
|
||||
case ResourceType.Wood:
|
||||
case ResourceType.Stone:
|
||||
case ResourceType.Iron:
|
||||
case ResourceType.Gold: return s.baseGoods + s.perWarehouseLevel * warehouseLevels;
|
||||
case ResourceType.Food: return s.baseFood + s.perGranaryLevel * granaryLevels;
|
||||
case ResourceType.Gems: return s.crystalCap;
|
||||
default: return float.PositiveInfinity;
|
||||
}
|
||||
}
|
||||
|
||||
// Цена улучшения здания с уровня level на level + 1
|
||||
public ResourceAmount[] UpgradeCost(int level)
|
||||
{
|
||||
float m = Mathf.Pow(upgrade.multiplierPerLevel, Mathf.Max(0, level - 1));
|
||||
return new[]
|
||||
{
|
||||
new ResourceAmount(ResourceType.Wood, Mathf.Round(upgrade.baseWood * m)),
|
||||
new ResourceAmount(ResourceType.Stone, Mathf.Round(upgrade.baseStone * m)),
|
||||
};
|
||||
}
|
||||
|
||||
public float GetStartResource(ResourceType type)
|
||||
{
|
||||
var s = startResources;
|
||||
switch (type)
|
||||
{
|
||||
case ResourceType.Wood: return s.wood;
|
||||
case ResourceType.Food: return s.food;
|
||||
case ResourceType.Stone: return s.stone;
|
||||
case ResourceType.Iron: return s.iron;
|
||||
case ResourceType.Gold: return s.gold;
|
||||
case ResourceType.Gems: return s.gems;
|
||||
default: return 0f;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,11 +0,0 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 4ddb59400c6d4b08b3afaaadc461ec1e
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -8,97 +8,19 @@ using UnityEngine;
|
||||
// Модули друг о друге ничего не знают -- всю "проводку" видно в одном файле.
|
||||
public static class GameModules
|
||||
{
|
||||
// Модули экономики для слоя приложения (окна, синхронизация города). Модули друг друга так не берут -- только через контракты.
|
||||
public static PopulationService Population { get; private set; }
|
||||
public static ProductionService Production { get; private set; }
|
||||
|
||||
// После SubsystemRegistration (там ядро очищается) и раньше любых Awake
|
||||
[RuntimeInitializeOnLoadMethod(RuntimeInitializeLoadType.AfterAssembliesLoaded)]
|
||||
static void Register()
|
||||
{
|
||||
// Боевая система -- чистый C#, сама зарегистрироваться не может.
|
||||
// Числа боя -- из таблицы BattleConfig, сила воина каждого типа -- из GameData.Troops
|
||||
var unitPower = new int[TroopType.Count];
|
||||
for (int i = 0; i < TroopType.Count && i < GameData.Troops.Count; i++) unitPower[i] = GameData.Troops[i].power;
|
||||
GameServices.Register<IBattleSystem>(new BattleSystem(BattleConfig.Load().ToRules(unitPower)));
|
||||
|
||||
// Марши: скорости по типам -- в модуль Мира, слоты похода -- в модуль Армии (таблица MarchConfig)
|
||||
var marchConfig = MarchConfig.Load();
|
||||
MarchManager.SpeedRules = marchConfig.ToSpeedRules();
|
||||
MarchManager.PointRules = WorldResourceConfig.Load().ToRules(); // ресурсные точки (регламент 31.10)
|
||||
MarchManager.Patrols = marchConfig.ToPatrolRules(); // патрули-заглушки соседей (Разведка М1)
|
||||
|
||||
// Разведка -- чистый C#: туман, обзор, чужие армии (секция "intelligence"). Карту и армии читает у Мира
|
||||
// через IWorldMap; досчитывает наблюдение перед каждым расчётом мира (WorldAdvancingEvent)
|
||||
var intelConfig = IntelligenceConfig.Load();
|
||||
var intelligence = new IntelligenceService(intelConfig.ToRules());
|
||||
GameServices.Register<IIntelligence>(intelligence);
|
||||
SaveRegistry.Register(intelligence);
|
||||
EventBus.Subscribe<WorldAdvancingEvent>(e => intelligence.ObserveUntil(e.ToMs));
|
||||
// «Обзор» для теста, пока нет дерева технологий (после загрузки и в новой игре)
|
||||
void TestObservation() { if (intelConfig.testObservationLevel > 0) intelligence.ObservationLevel = intelConfig.testObservationLevel; }
|
||||
TestObservation();
|
||||
EventBus.Subscribe<GameLoadedEvent>(_ => TestObservation());
|
||||
EventBus.Subscribe<GameResetEvent>(_ => TestObservation());
|
||||
// Боевая система -- чистый C#, сама зарегистрироваться не может
|
||||
GameServices.Register<IBattleSystem>(new BattleSystem());
|
||||
|
||||
// Армия -- чистый C#, единственное место хранения войск (секция "army")
|
||||
var army = new ArmyService(marchConfig.baseMarchSlots, HospitalConfig.Load().ToRules(), HospitalConfig.Load().ToMissingRules()); // госпиталь, пропавшие
|
||||
var army = new ArmyService();
|
||||
GameServices.Register<IArmy>(army);
|
||||
SaveRegistry.Register(army);
|
||||
EventBus.Subscribe<TroopsChangedEvent>(_ => { if (GameProgress.HasInstance) GameProgress.Instance.MarkChanged(); });
|
||||
EventBus.Subscribe<GameLoadedEvent>(_ => MigrateLegacyArmy(army));
|
||||
// Очередь обучения -- у Армии (решения 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 =>
|
||||
{
|
||||
if (e.Type >= 0 && e.Type < GameData.Troops.Count) UIKit.Toast("Вылечены в госпитале: " + e.Count + " × " + GameData.Troops[e.Type].name);
|
||||
if (GameProgress.HasInstance) GameProgress.Instance.MarkChanged();
|
||||
});
|
||||
|
||||
// Население -- чистый C#: жители, рабочие, еда, прирост (секция "population"). Числа -- из EconomyConfig.
|
||||
var config = EconomyConfig.Load();
|
||||
var population = new PopulationService(ToPopulationRules(config));
|
||||
Population = population;
|
||||
GameServices.Register<IPopulation>(population);
|
||||
SaveRegistry.Register(population);
|
||||
|
||||
// Добыча через рабочих -- чистый C# (секция "production"). Какие здания стоят, сообщает App/CitySync.
|
||||
var production = new ProductionService(ToProductionRules(config));
|
||||
Production = production;
|
||||
GameServices.Register<IProduction>(production);
|
||||
SaveRegistry.Register(production);
|
||||
|
||||
// Склад и Амбар появляются в списке стройки кодом -- до восстановления зданий из сохранения
|
||||
EventBus.Subscribe<SettlementRestoringEvent>(_ => StorageBootstrap.Ensure());
|
||||
|
||||
// Тап по зданию -> окно здания и рабочих (статические события переживают Play в редакторе -- сначала отписка)
|
||||
ResourceCollector.Tapped -= BuildingWindow.OnWorkplaceTapped;
|
||||
ResourceCollector.Tapped += BuildingWindow.OnWorkplaceTapped;
|
||||
StorageBuilding.Tapped -= BuildingWindow.OnStorageTapped;
|
||||
StorageBuilding.Tapped += BuildingWindow.OnStorageTapped;
|
||||
TownHall.Tapped -= BuildingWindow.OnTownHallTapped;
|
||||
TownHall.Tapped += BuildingWindow.OnTownHallTapped;
|
||||
EventBus.Subscribe<GameLoadedEvent>(_ => population.OnGameLoaded(GameClock.UnixNow)); // офлайн-прогресс
|
||||
EventBus.Subscribe<GameTimeAdvancedEvent>(e => population.Tick(e.UnixNow)); // перемотка (отладка)
|
||||
EventBus.Subscribe<TroopsChangedEvent>(_ => population.Changed()); // воины -- тоже люди
|
||||
EventBus.Subscribe<TownHallLevelChangedEvent>(_ => population.Changed()); // предел жилья вырос
|
||||
EventBus.Subscribe<PopulationChangedEvent>(SyncPopulationResource);
|
||||
EventBus.Subscribe<NewGameStartedEvent>(_ => GiveStartingKit(population, army, config));
|
||||
EventBus.Subscribe<GameResetEvent>(_ => GiveStartingKit(population, army, config));
|
||||
|
||||
// Круг -- чистый C#, сам сохраняет, в каком Круге игрок (секция "circle")
|
||||
var circle = new CircleService();
|
||||
@@ -111,10 +33,6 @@ public static class GameModules
|
||||
// Казармы появляются в списке стройки кодом -- до восстановления зданий из сохранения
|
||||
EventBus.Subscribe<SettlementRestoringEvent>(_ => BarracksBootstrap.Ensure());
|
||||
|
||||
// Госпиталь (решения 31.10, п. 7): здание города даёт места для лечения Армии через контракт IHospital
|
||||
GameServices.Register<IHospital>(new HospitalBeds());
|
||||
EventBus.Subscribe<SettlementRestoringEvent>(_ => HospitalBootstrap.Ensure());
|
||||
|
||||
// Статистика для заданий (GameProgress) -- по событиям Мира
|
||||
EventBus.Subscribe<MarchStartedEvent>(OnMarchStarted);
|
||||
EventBus.Subscribe<MarchReturnedEvent>(OnMarchReturned);
|
||||
@@ -179,104 +97,6 @@ public static class GameModules
|
||||
GameProgress.Instance.MarkChanged();
|
||||
}
|
||||
|
||||
// Очередь обучения раньше вёл GameProgress (progress.json, Data.training, время -- тики реальных часов).
|
||||
// Теперь -- Армия (save.json, "army", мс часов игры). Переносим один раз, с тем же оставшимся временем.
|
||||
static void MigrateLegacyTraining(ArmyService army)
|
||||
{
|
||||
var data = GameProgress.Instance.Data;
|
||||
if (data.training == null || data.training.Count == 0) return;
|
||||
|
||||
// В Армии уже есть заказы -- перенос уже был (сохранение записалось, а progress.json -- ещё нет): только убрать старое
|
||||
if (army.Training.Count == 0)
|
||||
{
|
||||
long epochTicks = new System.DateTime(1970, 1, 1, 0, 0, 0, System.DateTimeKind.Utc).Ticks;
|
||||
long offsetMs = GameClock.OffsetSeconds * 1000; // старые тики -- реальное время, Армия -- часы игры
|
||||
var orders = new System.Collections.Generic.List<(int, int, long, long, bool)>();
|
||||
foreach (var o in data.training)
|
||||
orders.Add((o.troopIndex, o.count, (o.startTicks - epochTicks) / 10_000 + offsetMs, (o.endTicks - epochTicks) / 10_000 + offsetMs, o.recruited));
|
||||
army.ImportLegacyTraining(orders);
|
||||
Debug.Log($"[МОДУЛИ] Очередь обучения перенесена из progress.json в модуль Армии: заказов {orders.Count}.");
|
||||
}
|
||||
|
||||
// Из progress.json убираем, только когда очередь уже записана в save.json -- иначе вылет игры её потеряет
|
||||
EventBus.Subscribe<GameSavedEvent>(ClearLegacyTraining);
|
||||
}
|
||||
|
||||
static void ClearLegacyTraining(GameSavedEvent e)
|
||||
{
|
||||
EventBus.Unsubscribe<GameSavedEvent>(ClearLegacyTraining);
|
||||
GameProgress.Instance.Data.training.Clear();
|
||||
GameProgress.Instance.MarkChanged();
|
||||
}
|
||||
|
||||
// ---------------- Население и экономика ----------------
|
||||
|
||||
static PopulationRules ToPopulationRules(EconomyConfig config)
|
||||
{
|
||||
var p = config.population;
|
||||
return new PopulationRules
|
||||
{
|
||||
StartTotal = p.startTotal,
|
||||
StartWorkers = p.startWorkers,
|
||||
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,
|
||||
HousingPerHouseLevel = p.housingPerHouseLevel,
|
||||
MaxOfflineHours = p.maxOfflineHours,
|
||||
CivilianReserveByTownHallLevel = p.civilianReserveByTownHallLevel != null && p.civilianReserveByTownHallLevel.Length > 0
|
||||
? (int[])p.civilianReserveByTownHallLevel.Clone()
|
||||
: new PopulationRules().CivilianReserveByTownHallLevel,
|
||||
PeoplePerSoldier = Mathf.Max(1, config.soldier.people)
|
||||
};
|
||||
}
|
||||
|
||||
static ProductionRules ToProductionRules(EconomyConfig config)
|
||||
{
|
||||
var p = config.production;
|
||||
return new ProductionRules
|
||||
{
|
||||
// порядок как в WorkJob: лесоруб, фермер, каменотёс, шахтёр-металл, шахтёр-золото
|
||||
PerWorkerPerHour = new double[] { p.woodcutterPerHour, p.farmerPerHour, p.stonecutterPerHour, p.minerIronPerHour, p.minerGoldPerHour },
|
||||
SlotsPerBuilding = p.workerSlotsT1,
|
||||
SlotsPerLevel = p.workerSlotsPerLevel,
|
||||
EfficiencyPerLevel = p.efficiencyPerLevel,
|
||||
FamineNonFoodMultiplier = p.famineNonFoodMultiplier
|
||||
};
|
||||
}
|
||||
|
||||
// Новая игра: стартовое войско (пехота) и стартовые ресурсы из EconomyConfig. Один раз на новую игру.
|
||||
static void GiveStartingKit(PopulationService population, ArmyService army, EconomyConfig config)
|
||||
{
|
||||
if (!population.TryTakeStartingKit()) return;
|
||||
|
||||
if (population.Rules.StartSoldiers > 0) army.AddTroops(0, population.Rules.StartSoldiers);
|
||||
|
||||
if (GameServices.TryGet(out IEconomy economy))
|
||||
{
|
||||
ResourceType[] types = { ResourceType.Wood, ResourceType.Food, ResourceType.Stone, ResourceType.Iron, ResourceType.Gold, ResourceType.Gems };
|
||||
foreach (var t in types)
|
||||
{
|
||||
float target = config.GetStartResource(t);
|
||||
float current = economy.Get(t);
|
||||
if (target > current) economy.Add(t, target - current); // не меньше стартового
|
||||
}
|
||||
}
|
||||
Debug.Log($"[НАСЕЛЕНИЕ] Новая игра: {population.Total} человек, воинов {population.Soldiers}.");
|
||||
}
|
||||
|
||||
// Верхняя панель показывает людей как ресурс "Люди" -- держим его равным населению
|
||||
static void SyncPopulationResource(PopulationChangedEvent e)
|
||||
{
|
||||
if (!GameServices.TryGet(out IEconomy economy)) return;
|
||||
float delta = e.Total - economy.Get(ResourceType.Population);
|
||||
if (Mathf.Abs(delta) >= 0.5f) economy.Add(ResourceType.Population, delta);
|
||||
}
|
||||
|
||||
static void OnMarchStarted(MarchStartedEvent e)
|
||||
{
|
||||
var data = GameProgress.Instance.Data;
|
||||
|
||||
@@ -1,89 +0,0 @@
|
||||
using UnityEngine;
|
||||
|
||||
// ТАБЛИЦА ГОСПИТАЛЯ: места и время лечения раненых (регламент 31.10, решения по 8 вопросам Саши от 31.10, п. 7). Числа -- ТЕСТ.
|
||||
//
|
||||
// Ассет: Assets/Resources/HospitalConfig.asset (выдели его в окне Project и меняй числа в Inspector;
|
||||
// изменения действуют со следующего запуска игры). Создать заново: правый клик в Project ->
|
||||
// Create -> Bogatyri -> Hospital Config, имя файла -- HospitalConfig, папка -- Resources.
|
||||
//
|
||||
// Модуль Армии таблицу не видит: App/GameModules передаёт числа в ArmyService.
|
||||
// Места даёт здание «Госпиталь» (Meta/Build/Hospital.cs) через контракт IHospital.
|
||||
[CreateAssetMenu(fileName = "HospitalConfig", menuName = "Bogatyri/Hospital Config")]
|
||||
public class HospitalConfig : ScriptableObject
|
||||
{
|
||||
[Header("Здание «Госпиталь» (ТЕСТ)")]
|
||||
[Tooltip("Мест для лечения по уровням госпиталя I..V. Регламент 31.10: 20..60. Число строк -- число уровней")]
|
||||
public int[] bedsByLevel = { 20, 30, 40, 50, 60 };
|
||||
[Tooltip("Мест, пока в городе нет госпиталя. Решения 01.11, п. 3: 5. После постройки заменяются штатными, не складываются")]
|
||||
public int temporaryBeds = 5;
|
||||
[Tooltip("С какого уровня Ратуши можно строить")]
|
||||
public int requiredTownHallLevel = 1;
|
||||
[Tooltip("Цена постройки: дерево")]
|
||||
public int buildWood = 250;
|
||||
[Tooltip("Цена постройки: камень")]
|
||||
public int buildStone = 150;
|
||||
|
||||
[Header("Лечение одного воина, ИГРОВЫХ часов (1 игровой час = 5 реальных минут)")]
|
||||
[Tooltip("Пехота, лучники, кавалерия, осадные. Регламент 31.10: 4 / 4 / 6 / 8")]
|
||||
public int[] healGameHours = { 4, 4, 6, 8 };
|
||||
|
||||
[Header("Цена лечения одного воина (решения 01.11, п. 3: золото 1 / 1 / 2 / 3)")]
|
||||
[Tooltip("Еды за воина: пехота, лучники, кавалерия, осадные")]
|
||||
public int[] healFoodPerSoldier = { 0, 0, 0, 0 };
|
||||
[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()
|
||||
{
|
||||
if (cached != null) return cached;
|
||||
cached = Resources.Load<HospitalConfig>("HospitalConfig");
|
||||
if (cached == null)
|
||||
{
|
||||
Debug.LogWarning("[ГОСПИТАЛЬ] Нет Assets/Resources/HospitalConfig.asset -- берутся числа по умолчанию из HospitalConfig.cs.");
|
||||
cached = CreateInstance<HospitalConfig>();
|
||||
}
|
||||
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];
|
||||
var cost = new ResourceAmount[TroopType.Count][];
|
||||
for (int i = 0; i < TroopType.Count; i++)
|
||||
{
|
||||
hours[i] = healGameHours != null && i < healGameHours.Length ? Mathf.Max(0, healGameHours[i]) : 4;
|
||||
int food = healFoodPerSoldier != null && i < healFoodPerSoldier.Length ? Mathf.Max(0, healFoodPerSoldier[i]) : 0;
|
||||
int gold = healGoldPerSoldier != null && i < healGoldPerSoldier.Length ? Mathf.Max(0, healGoldPerSoldier[i]) : 0;
|
||||
cost[i] = new[] { new ResourceAmount(ResourceType.Food, food), new ResourceAmount(ResourceType.Gold, gold) };
|
||||
}
|
||||
return new HospitalRules
|
||||
{
|
||||
TemporaryBeds = Mathf.Max(0, temporaryBeds),
|
||||
HealGameHours = hours,
|
||||
HealCostPerSoldier = cost
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -1,11 +0,0 @@
|
||||
fileFormatVersion: 2
|
||||
guid: c25bbe993b314a15a6d1a8aaf0017d55
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -1,73 +0,0 @@
|
||||
using UnityEngine;
|
||||
|
||||
// ТАБЛИЦА РАЗВЕДКИ, этап М1: радиусы обзора, последний контакт, примерная численность, опознание (ТЗ разведки 31.10).
|
||||
// Числа -- ТЕСТ.
|
||||
//
|
||||
// Ассет: Assets/Resources/IntelligenceConfig.asset (выдели его в окне Project и меняй числа в Inspector;
|
||||
// изменения действуют со следующего запуска игры). Создать заново: правый клик в Project ->
|
||||
// Create -> Bogatyri -> Intelligence Config, имя файла -- IntelligenceConfig, папка -- Resources.
|
||||
//
|
||||
// Модуль таблицу не видит: App/GameModules передаёт числа в модуль Разведки.
|
||||
// Патрули-заглушки соседей включаются в таблице MarchConfig (это походы Мира).
|
||||
[CreateAssetMenu(fileName = "IntelligenceConfig", menuName = "Bogatyri/Intelligence Config")]
|
||||
public class IntelligenceConfig : ScriptableObject
|
||||
{
|
||||
[Header("Радиус обзора, клеток карты")]
|
||||
[Tooltip("Своё поселение. ТЗ разведки: 5")]
|
||||
public float settlementRadius = 5f;
|
||||
[Tooltip("Обычная армия в походе. ТЗ разведки: 1")]
|
||||
public float armyRadius = 1f;
|
||||
|
||||
[Header("Технология «Обзор» (не складывается, уровень заменяет)")]
|
||||
[Tooltip("Прибавка к радиусам на уровнях I, II, III, %. ТЗ: 10 / 20 / 30")]
|
||||
public int[] observationPercent = { 10, 20, 30 };
|
||||
[Tooltip("ТОЛЬКО ДЛЯ ТЕСТА: уровень «Обзора» 0..3, пока нет дерева технологий")]
|
||||
public int testObservationLevel = 0;
|
||||
|
||||
[Header("Чужие армии")]
|
||||
[Tooltip("Метка последнего контакта живёт, РЕАЛЬНЫХ минут. ТЗ: 10")]
|
||||
public float lastContactMinutes = 10f;
|
||||
[Tooltip("Примерная численность: ±%. ТЗ: 20")]
|
||||
public int approxPercent = 20;
|
||||
[Tooltip("Шанс опознать владельца за одно событие наблюдения, %. ТЗ: 50")]
|
||||
public int identifyPercent = 50;
|
||||
[Tooltip("Шаг расчёта наблюдения за время, пока игра была закрыта, РЕАЛЬНЫХ секунд")]
|
||||
public float sampleStepSeconds = 10f;
|
||||
|
||||
[Header("Туман на карте")]
|
||||
[Tooltip("Выключено (по умолчанию): города, лагеря и точки видны всегда, туман только затемняет землю. " +
|
||||
"Включено: неисследованная местность закрыта полностью вместе с объектами. Решения 31.10, п. 1: для М1 не требуется, оставлено переключателем")]
|
||||
public bool hideUnexploredObjects = false;
|
||||
|
||||
static IntelligenceConfig cached;
|
||||
|
||||
public static IntelligenceConfig Load()
|
||||
{
|
||||
if (cached != null) return cached;
|
||||
cached = Resources.Load<IntelligenceConfig>("IntelligenceConfig");
|
||||
if (cached == null)
|
||||
{
|
||||
Debug.LogWarning("[РАЗВЕДКА] Нет Assets/Resources/IntelligenceConfig.asset -- берутся числа по умолчанию из IntelligenceConfig.cs.");
|
||||
cached = CreateInstance<IntelligenceConfig>();
|
||||
}
|
||||
return cached;
|
||||
}
|
||||
|
||||
public IntelligenceRules ToRules()
|
||||
{
|
||||
var levels = new int[4];
|
||||
for (int i = 0; i < 3 && observationPercent != null && i < observationPercent.Length; i++) levels[i + 1] = Mathf.Max(0, observationPercent[i]);
|
||||
return new IntelligenceRules
|
||||
{
|
||||
SettlementRadius = Milli(settlementRadius),
|
||||
ArmyRadius = Milli(armyRadius),
|
||||
ObservationPercent = levels,
|
||||
LastContactMs = (long)(Mathf.Max(0f, lastContactMinutes) * 60_000f),
|
||||
ApproxPercent = Mathf.Clamp(approxPercent, 0, 100),
|
||||
IdentifyPercent = Mathf.Clamp(identifyPercent, 0, 100),
|
||||
SampleStepMs = (long)(Mathf.Max(1f, sampleStepSeconds) * 1000f)
|
||||
};
|
||||
}
|
||||
|
||||
static int Milli(float cells) => Mathf.Max(0, Mathf.RoundToInt(cells * 1000f));
|
||||
}
|
||||
@@ -1,11 +0,0 @@
|
||||
fileFormatVersion: 2
|
||||
guid: c249ad34e2f94460b9f99b49c71760c8
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -1,82 +0,0 @@
|
||||
using UnityEngine;
|
||||
|
||||
// ТАБЛИЦА МАРШЕЙ: скорости войск по типам и слоты похода (регламент 31.10, раздел 7). Числа -- ТЕСТ.
|
||||
//
|
||||
// Ассет: Assets/Resources/MarchConfig.asset (выдели его в окне Project и меняй числа в Inspector;
|
||||
// изменения действуют со следующего запуска игры). Создать заново: правый клик в Project ->
|
||||
// Create -> Bogatyri -> March Config, имя файла -- MarchConfig, папка -- Resources.
|
||||
//
|
||||
// Модули таблицу не видят: App/GameModules передаёт скорости в модуль Мира, слоты -- в модуль Армии.
|
||||
// Бонусы героя (+15%) и технологии (+20%) к скорости появятся вместе с героями и технологиями.
|
||||
[CreateAssetMenu(fileName = "MarchConfig", menuName = "Bogatyri/March Config")]
|
||||
public class MarchConfig : ScriptableObject
|
||||
{
|
||||
[Header("Скорость: клеток карты за 1 РЕАЛЬНЫЙ час (марш идёт в реальном времени)")]
|
||||
[Tooltip("Пехота. Регламент 31.10: 8")]
|
||||
public float infantry = 8f;
|
||||
[Tooltip("Лучники. Регламент 31.10: 9")]
|
||||
public float archers = 9f;
|
||||
[Tooltip("Кавалерия. Регламент 31.10: 12")]
|
||||
public float cavalry = 12f;
|
||||
[Tooltip("Осадные. Регламент 31.10: 4")]
|
||||
public float siege = 4f;
|
||||
|
||||
[Header("Модификаторы скорости, % (решения 31.10, п. 4: смешанное войско -- средняя по численности)")]
|
||||
[Tooltip("По типам: пехота, лучники, кавалерия, осадные. +10 -- быстрее на 10%. Позже -- бонусы героев и технологий. ТЕСТ")]
|
||||
public int[] typeSpeedBonusPercent = { 0, 0, 0, 0 };
|
||||
[Tooltip("Общий модификатор всего войска, применяется один раз. ТЕСТ")]
|
||||
public int armySpeedBonusPercent = 0;
|
||||
|
||||
[Header("Слоты похода")]
|
||||
[Tooltip("Сколько походов игрока могут идти одновременно. Регламент 31.10: 2 (технология «Военная логистика» -- до 7)")]
|
||||
public int baseMarchSlots = 2;
|
||||
|
||||
[Header("Патрули-заглушки соседей (Разведка М1, ВРЕМЕННАЯ тестовая механика)")]
|
||||
[Tooltip("Соседние города иногда отправляют отряды к другим городам и лагерям -- проверить обнаружение, численность, опознание и потерю контакта. Выключить, когда появится настоящий PvP")]
|
||||
public bool patrolsEnabled = true;
|
||||
[Tooltip("Сколько патрулей одновременно на карте")]
|
||||
public int patrolMaxActive = 3;
|
||||
[Tooltip("Новый патруль -- раз в столько РЕАЛЬНЫХ минут (если не превышен предел)")]
|
||||
public float patrolIntervalMinutes = 10f;
|
||||
public int patrolSoldiersMin = 10;
|
||||
public int patrolSoldiersMax = 40;
|
||||
|
||||
static MarchConfig cached;
|
||||
|
||||
public static MarchConfig Load()
|
||||
{
|
||||
if (cached != null) return cached;
|
||||
cached = Resources.Load<MarchConfig>("MarchConfig");
|
||||
if (cached == null)
|
||||
{
|
||||
Debug.LogWarning("[МАРШИ] Нет Assets/Resources/MarchConfig.asset -- берутся числа по умолчанию из MarchConfig.cs.");
|
||||
cached = CreateInstance<MarchConfig>();
|
||||
}
|
||||
return cached;
|
||||
}
|
||||
|
||||
// Скорости для модуля Мира: милли-единиц карты (1000 = клетка) за реальный час
|
||||
public Bogatyri.World.MarchSpeedRules ToSpeedRules()
|
||||
{
|
||||
return new Bogatyri.World.MarchSpeedRules
|
||||
{
|
||||
SpeedMilliPerHour = new[] { Milli(infantry), Milli(archers), Milli(cavalry), Milli(siege) },
|
||||
TypeBonusPercent = (int[])(typeSpeedBonusPercent ?? new int[4]).Clone(),
|
||||
ArmyBonusPercent = armySpeedBonusPercent
|
||||
};
|
||||
}
|
||||
|
||||
public Bogatyri.World.PatrolRules ToPatrolRules()
|
||||
{
|
||||
return new Bogatyri.World.PatrolRules
|
||||
{
|
||||
Enabled = patrolsEnabled,
|
||||
MaxActive = Mathf.Max(0, patrolMaxActive),
|
||||
IntervalMs = (long)(Mathf.Max(1f, patrolIntervalMinutes) * 60_000f),
|
||||
SoldiersMin = Mathf.Max(1, patrolSoldiersMin),
|
||||
SoldiersMax = Mathf.Max(1, patrolSoldiersMax)
|
||||
};
|
||||
}
|
||||
|
||||
static int Milli(float cellsPerHour) => Mathf.Max(1, Mathf.RoundToInt(cellsPerHour * 1000f));
|
||||
}
|
||||
@@ -1,11 +0,0 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 0427eef4612041d2b8a359f9bf358779
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -1,38 +0,0 @@
|
||||
using UnityEngine;
|
||||
|
||||
// Слой приложения: "сердцебиение" модуля Населения. Модуль -- чистый C# и не знает про Update,
|
||||
// поэтому раз в несколько секунд его досчитывает этот объект. Живёт между сценами (город <-> карта мира).
|
||||
// Создаётся сам при запуске игры, вешать никуда не нужно.
|
||||
public class PopulationTicker : MonoBehaviour
|
||||
{
|
||||
const float Interval = 2f; // секунд реального времени между расчётами
|
||||
|
||||
static PopulationTicker instance;
|
||||
float next;
|
||||
|
||||
[RuntimeInitializeOnLoadMethod(RuntimeInitializeLoadType.AfterSceneLoad)]
|
||||
static void Create()
|
||||
{
|
||||
if (instance != null) return;
|
||||
var go = new GameObject("PopulationTicker");
|
||||
DontDestroyOnLoad(go);
|
||||
instance = go.AddComponent<PopulationTicker>();
|
||||
}
|
||||
|
||||
void Start()
|
||||
{
|
||||
next = Time.unscaledTime + Interval; // первый расчёт -- после загрузки сохранения (она идёт в Start сцены)
|
||||
}
|
||||
|
||||
void Update()
|
||||
{
|
||||
if (Time.unscaledTime < next) return;
|
||||
next = Time.unscaledTime + Interval;
|
||||
if (GameServices.TryGet(out IPopulation population)) population.Tick(GameClock.UnixNow);
|
||||
}
|
||||
|
||||
void OnDestroy()
|
||||
{
|
||||
if (instance == this) instance = null;
|
||||
}
|
||||
}
|
||||
@@ -1,11 +0,0 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 8d7b4f971ff440eaa5744fbe2a1e834d
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -1,85 +0,0 @@
|
||||
using UnityEngine;
|
||||
|
||||
// Добавляет "Склад" и "Амбар" в список стройки кодом -- как Казармы (Meta/Build/BarracksBootstrap).
|
||||
// Шаблон -- копия обычного здания без добычи; пока нет арта, отличаем цветом.
|
||||
// Вызывается при загрузке сцены и перед восстановлением города из сохранения (SettlementRestoringEvent),
|
||||
// иначе построенные Склады не нашлись бы при загрузке.
|
||||
public static class StorageBootstrap
|
||||
{
|
||||
public const string WarehouseName = "Склад";
|
||||
public const string GranaryName = "Амбар";
|
||||
|
||||
static GameObject warehouseTemplate;
|
||||
static GameObject granaryTemplate;
|
||||
|
||||
[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;
|
||||
|
||||
bool hasWarehouse = false, hasGranary = false;
|
||||
BuildingData template = null;
|
||||
foreach (var b in bm.allBuildings)
|
||||
{
|
||||
if (b == null) continue;
|
||||
if (b.buildingName == WarehouseName) hasWarehouse = true;
|
||||
if (b.buildingName == GranaryName) hasGranary = true;
|
||||
if (template == null && b.prefab != null && b.prefab.GetComponent<ResourceCollector>() != null) template = b;
|
||||
}
|
||||
if (hasWarehouse && hasGranary) return;
|
||||
if (template == null) { Debug.LogWarning("[СКЛАДЫ] Нет здания-шаблона в BuildManager"); return; }
|
||||
|
||||
var cfg = EconomyConfig.Load().storage;
|
||||
if (!hasWarehouse)
|
||||
{
|
||||
if (warehouseTemplate == null) warehouseTemplate = MakeTemplate(template.prefab, "Warehouse", StorageBuilding.Kind.Warehouse, new Color(0.85f, 0.7f, 0.5f));
|
||||
bm.allBuildings.Add(MakeData(WarehouseName, template, warehouseTemplate, cfg.warehouseWood, cfg.warehouseStone));
|
||||
}
|
||||
if (!hasGranary)
|
||||
{
|
||||
if (granaryTemplate == null) granaryTemplate = MakeTemplate(template.prefab, "Granary", StorageBuilding.Kind.Granary, new Color(1f, 0.92f, 0.55f));
|
||||
bm.allBuildings.Add(MakeData(GranaryName, template, granaryTemplate, cfg.granaryWood, cfg.granaryStone));
|
||||
}
|
||||
Debug.Log("[СКЛАДЫ] Склад и Амбар добавлены в список стройки");
|
||||
}
|
||||
|
||||
static GameObject MakeTemplate(GameObject source, string name, StorageBuilding.Kind kind, Color tint)
|
||||
{
|
||||
var t = Object.Instantiate(source);
|
||||
t.name = name;
|
||||
t.SetActive(false);
|
||||
Object.DontDestroyOnLoad(t);
|
||||
foreach (var c in t.GetComponents<ResourceCollector>()) Object.DestroyImmediate(c);
|
||||
foreach (var c in t.GetComponents<ResourceProducer>()) Object.DestroyImmediate(c);
|
||||
foreach (var c in t.GetComponents<BuildingUI>()) Object.DestroyImmediate(c);
|
||||
for (int i = t.transform.childCount - 1; i >= 0; i--)
|
||||
Object.DestroyImmediate(t.transform.GetChild(i).gameObject); // полоска над зданием не нужна
|
||||
var sr = t.GetComponent<SpriteRenderer>();
|
||||
if (sr != null) sr.color = tint;
|
||||
t.AddComponent<StorageBuilding>().kind = kind;
|
||||
return t;
|
||||
}
|
||||
|
||||
static BuildingData MakeData(string title, BuildingData template, GameObject prefab, float wood, float stone)
|
||||
{
|
||||
var data = ScriptableObject.CreateInstance<BuildingData>();
|
||||
data.name = "Data_" + prefab.name + "_Runtime";
|
||||
data.buildingName = title;
|
||||
data.icon = template.icon;
|
||||
data.prefab = prefab;
|
||||
data.requiredTownHallLevel = 1;
|
||||
data.cost = new[]
|
||||
{
|
||||
new ResourceCost { type = ResourceType.Wood, amount = wood },
|
||||
new ResourceCost { type = ResourceType.Stone, amount = stone },
|
||||
};
|
||||
return data;
|
||||
}
|
||||
}
|
||||
@@ -1,11 +0,0 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 87b085cfbef5449b97456bea718c7466
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -1,35 +0,0 @@
|
||||
using System;
|
||||
using UnityEngine;
|
||||
|
||||
// Склад или Амбар (этап 1): каждый уровень поднимает лимит хранения.
|
||||
// Склад -- дерево, камень, металл, золото; Амбар -- еда. Числа -- EconomyConfig, раздел "Склады и лимиты".
|
||||
// Здание появляется в списке стройки кодом (App/StorageBootstrap), отдельных префабов пока нет.
|
||||
// Уровень сохраняет модуль Поселения (ILeveledBuilding), лимиты считает App/CitySync.
|
||||
public class StorageBuilding : MonoBehaviour, ILeveledBuilding
|
||||
{
|
||||
public enum Kind { Warehouse, Granary }
|
||||
|
||||
public static event Action<StorageBuilding> Tapped;
|
||||
|
||||
public Kind kind;
|
||||
public int level = 1;
|
||||
|
||||
public int Level { get => level; set => level = Mathf.Max(1, value); }
|
||||
|
||||
public string Title => kind == Kind.Warehouse ? StorageBootstrap.WarehouseName : StorageBootstrap.GranaryName;
|
||||
|
||||
Vector3 downPos;
|
||||
|
||||
void OnMouseDown()
|
||||
{
|
||||
downPos = Input.mousePosition;
|
||||
}
|
||||
|
||||
// Тап, а не перетаскивание камеры
|
||||
void OnMouseUpAsButton()
|
||||
{
|
||||
if (UIBlocker.IsPointerOverUI()) return;
|
||||
if ((Input.mousePosition - downPos).magnitude > 25f) return;
|
||||
Tapped?.Invoke(this);
|
||||
}
|
||||
}
|
||||
@@ -1,11 +0,0 @@
|
||||
fileFormatVersion: 2
|
||||
guid: bea97fb12da84c7daeb07f65d4947cae
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -1,84 +0,0 @@
|
||||
using UnityEngine;
|
||||
|
||||
// ТАБЛИЦА РЕСУРСНЫХ ТОЧЕК (регламент 31.10, раздел 7: «Изменять числа через WorldResourceConfig.asset»). Числа -- ТЕСТ.
|
||||
//
|
||||
// Ассет: Assets/Resources/WorldResourceConfig.asset (выдели его в окне Project и меняй числа в Inspector;
|
||||
// изменения действуют со следующего запуска игры). Создать заново: правый клик в Project ->
|
||||
// Create -> Bogatyri -> World Resource Config, имя файла -- WorldResourceConfig, папка -- Resources.
|
||||
//
|
||||
// Модуль Мира таблицу не видит: App/GameModules переводит её в ResourcePointRules.
|
||||
// Состояние точек (запас, сроки) ведёт Мир; зачисление добычи и складские лимиты -- Экономика (решение №46).
|
||||
[CreateAssetMenu(fileName = "WorldResourceConfig", menuName = "Bogatyri/World Resource Config")]
|
||||
public class WorldResourceConfig : ScriptableObject
|
||||
{
|
||||
[Header("Запас точки: малая / средняя / крупная")]
|
||||
public int[] woodStock = { 200, 600, 1500 };
|
||||
public int[] stoneStock = { 150, 450, 1000 };
|
||||
public int[] metalStock = { 80, 250, 600 };
|
||||
public int[] goldStock = { 30, 100, 250 };
|
||||
|
||||
[Header("Добыча одного воина за ИГРОВОЙ час (1 игровой час = 5 реальных минут)")]
|
||||
public float woodPerGameHour = 2f;
|
||||
public float stonePerGameHour = 1.5f;
|
||||
public float metalPerGameHour = 1f;
|
||||
public float goldPerGameHour = 0.5f;
|
||||
|
||||
[Header("Груз одного воина (осадные машины груза не несут)")]
|
||||
public int woodCargo = 12;
|
||||
public int stoneCargo = 10;
|
||||
public int metalCargo = 8;
|
||||
public int goldCargo = 5;
|
||||
|
||||
[Header("Срок жизни точки, РЕАЛЬНЫХ часов с момента появления: малая / средняя / крупная")]
|
||||
public int[] lifetimeRealHours = { 12, 24, 48 };
|
||||
|
||||
[Header("Сколько точек на карте: малых / средних / крупных (решения 31.10, п. 6, ТЕСТ)")]
|
||||
[Tooltip("Решения 31.10: всего 24 -- лес 8, камень 7, металл 5, золото 4; по размерам 12 / 8 / 4. Раскладка по ресурсам -- здесь")]
|
||||
public int[] woodCount = { 4, 3, 1 };
|
||||
public int[] stoneCount = { 4, 2, 1 };
|
||||
public int[] metalCount = { 2, 2, 1 };
|
||||
public int[] goldCount = { 2, 1, 1 };
|
||||
|
||||
static WorldResourceConfig cached;
|
||||
|
||||
public static WorldResourceConfig Load()
|
||||
{
|
||||
if (cached != null) return cached;
|
||||
cached = Resources.Load<WorldResourceConfig>("WorldResourceConfig");
|
||||
if (cached == null)
|
||||
{
|
||||
Debug.LogWarning("[РЕСУРСНЫЕ ТОЧКИ] Нет Assets/Resources/WorldResourceConfig.asset -- берутся числа по умолчанию.");
|
||||
cached = CreateInstance<WorldResourceConfig>();
|
||||
}
|
||||
return cached;
|
||||
}
|
||||
|
||||
public Bogatyri.World.ResourcePointRules ToRules()
|
||||
{
|
||||
var d = Bogatyri.World.ResourcePointRules.Default;
|
||||
return new Bogatyri.World.ResourcePointRules
|
||||
{
|
||||
Stock = new[] { Three(woodStock, d.Stock[0]), Three(stoneStock, d.Stock[1]), Three(metalStock, d.Stock[2]), Three(goldStock, d.Stock[3]) },
|
||||
MiningMilliPerGameHour = new[] { Milli(woodPerGameHour), Milli(stonePerGameHour), Milli(metalPerGameHour), Milli(goldPerGameHour) },
|
||||
CargoPerSoldier = new[] { Mathf.Max(0, woodCargo), Mathf.Max(0, stoneCargo), Mathf.Max(0, metalCargo), Mathf.Max(0, goldCargo) },
|
||||
LifetimeRealHours = Three(lifetimeRealHours, d.LifetimeRealHours),
|
||||
Count = new[] { Counts(woodCount, d.Count[0]), Counts(stoneCount, d.Count[1]), Counts(metalCount, d.Count[2]), Counts(goldCount, d.Count[3]) }
|
||||
};
|
||||
}
|
||||
|
||||
static int Milli(float perHour) => Mathf.Max(0, Mathf.RoundToInt(perHour * 1000f));
|
||||
|
||||
// Число точек: ровно три значения, не меньше 0 (0 -- такого размера нет), иначе -- значения по умолчанию
|
||||
static int[] Counts(int[] values, int[] fallback)
|
||||
{
|
||||
if (values == null || values.Length != 3) return (int[])fallback.Clone();
|
||||
return new[] { Mathf.Max(0, values[0]), Mathf.Max(0, values[1]), Mathf.Max(0, values[2]) };
|
||||
}
|
||||
|
||||
// Ровно три значения (малая / средняя / крупная), иначе -- значения по умолчанию
|
||||
static int[] Three(int[] values, int[] fallback)
|
||||
{
|
||||
if (values == null || values.Length != 3) return (int[])fallback.Clone();
|
||||
return new[] { Mathf.Max(1, values[0]), Mathf.Max(1, values[1]), Mathf.Max(1, values[2]) };
|
||||
}
|
||||
}
|
||||
@@ -1,11 +0,0 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 6ee3759b377140c0b759a4a386327510
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -16,9 +16,7 @@ public class CameraController : MonoBehaviour
|
||||
void Update()
|
||||
{
|
||||
// --- Перемещение по зажатой левой кнопке мыши ---
|
||||
if (UIBlocker.WorldInputCaptured) _isDragging = false; // палец тащит призрак здания -- камера стоит
|
||||
|
||||
if (Input.GetMouseButtonDown(0) && !UIBlocker.IsPointerOverUI() && !UIBlocker.WorldInputCaptured) // не тащим камеру, когда листаем панель
|
||||
if (Input.GetMouseButtonDown(0) && !UIBlocker.IsPointerOverUI()) // не тащим камеру, когда листаем панель
|
||||
{
|
||||
_lastMousePosition = Input.mousePosition;
|
||||
_isDragging = true;
|
||||
|
||||
@@ -1,8 +0,0 @@
|
||||
fileFormatVersion: 2
|
||||
guid: efa023adb1ce408c971a4f76ced19585
|
||||
folderAsset: yes
|
||||
DefaultImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -1,106 +0,0 @@
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -1,11 +0,0 @@
|
||||
fileFormatVersion: 2
|
||||
guid: e8645f0028574904969b5802443f100f
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -1,755 +0,0 @@
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -1,11 +0,0 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 44d822c903694362bef912c055adda5b
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -1,97 +0,0 @@
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -1,11 +0,0 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 31e2f86e3dff430ea8a67cc6f1cb4038
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -2,8 +2,6 @@ using UnityEngine;
|
||||
using UnityEngine.UI;
|
||||
using TMPro;
|
||||
|
||||
// Подпись над зданием -- больше не показывается: всё о здании теперь в его окне (App/BuildingWindow, тап по зданию).
|
||||
// Компонент оставлен, чтобы не перенастраивать префабы: при запуске он прячет свою подпись и полоску и выключается.
|
||||
public class BuildingUI : MonoBehaviour
|
||||
{
|
||||
[Header("Настройки")]
|
||||
@@ -11,18 +9,8 @@ public class BuildingUI : MonoBehaviour
|
||||
public TMP_Text stokText;
|
||||
public Slider progressBar;
|
||||
|
||||
float next;
|
||||
|
||||
public static bool ShowLabels = false; // вернуть подписи над зданиями -- поставить true
|
||||
|
||||
void Start()
|
||||
{
|
||||
if (!ShowLabels)
|
||||
{
|
||||
HideLabel();
|
||||
enabled = false;
|
||||
return;
|
||||
}
|
||||
if (building == null)
|
||||
{
|
||||
Debug.LogError("Ошибка BuildingUI: не назначено здание (Building)!");
|
||||
@@ -32,39 +20,21 @@ public class BuildingUI : MonoBehaviour
|
||||
UpdateUI();
|
||||
}
|
||||
|
||||
// Прячем холст с подписью и полоской. Сам объект здания не трогаем: компонент висит на его корне
|
||||
void HideLabel()
|
||||
{
|
||||
foreach (Component c in new Component[] { stokText, progressBar })
|
||||
{
|
||||
if (c == null) continue;
|
||||
var canvas = c.GetComponentInParent<Canvas>(true);
|
||||
GameObject target = canvas != null && canvas.gameObject != gameObject ? canvas.gameObject : c.gameObject;
|
||||
if (target != gameObject) target.SetActive(false);
|
||||
}
|
||||
}
|
||||
|
||||
void Update()
|
||||
{
|
||||
if (Time.unscaledTime < next) return;
|
||||
next = Time.unscaledTime + 0.5f;
|
||||
UpdateUI();
|
||||
}
|
||||
|
||||
void UpdateUI()
|
||||
{
|
||||
bool workplace = building.IsWorkplace;
|
||||
if (progressBar != null && progressBar.gameObject.activeSelf != workplace) progressBar.gameObject.SetActive(workplace);
|
||||
|
||||
if (!workplace)
|
||||
if (stokText != null)
|
||||
{
|
||||
if (stokText != null) stokText.text = building.IsHouse ? $"Дом {building.level} ур." : $"{building.level} ур.";
|
||||
return;
|
||||
stokText.text = $"{building.GetCurrentStock():F1} / {building.GetMaxCapacity()}";
|
||||
}
|
||||
if (progressBar != null)
|
||||
{
|
||||
float fill = building.GetCurrentStock() / building.GetMaxCapacity();
|
||||
progressBar.value = Mathf.Clamp01(fill);
|
||||
}
|
||||
|
||||
float workers = building.GetCurrentStock();
|
||||
float slots = building.GetMaxCapacity();
|
||||
if (stokText != null) stokText.text = $"Рабочие {workers:0} / {slots:0}";
|
||||
if (progressBar != null) progressBar.value = slots > 0 ? Mathf.Clamp01(workers / slots) : 0f;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,92 +1,71 @@
|
||||
using UnityEngine;
|
||||
using TMPro;
|
||||
|
||||
// Ресурс на верхней панели (этап 1): "запас / лимит" и прирост в час.
|
||||
// - Дерево, камень, металл, золото, еда, кристаллы: "820 / 1000" и строкой ниже "+50/ч" (у еды -- добыча минус расход);
|
||||
// - Люди: "300 / 350" (население / предел жилья) и прирост людей в час.
|
||||
// Числа без сокращений 1.2K / 1.5M: по документу нормальный масштаб -- сотни и тысячи.
|
||||
|
||||
[RequireComponent(typeof(TMP_Text))]
|
||||
public class ResourceUIText : MonoBehaviour
|
||||
{
|
||||
public ResourceType resourceType;
|
||||
|
||||
[Tooltip("Показывать лимит склада: \"820 / 1000\"")]
|
||||
public bool showCapacity = true;
|
||||
|
||||
[Tooltip("Показывать прирост в час второй строкой: \"+50/ч\"")]
|
||||
public bool showRate = true;
|
||||
|
||||
[Tooltip("Устарело (этап 1): сокращения 1.2K / 1.5M убраны")]
|
||||
[HideInInspector] public bool useShortFormat;
|
||||
[Tooltip("Сокращать большие числа (1200 -> 1.2K, 1500000 -> 1.5M), как на референсе HUD")]
|
||||
public bool useShortFormat = true;
|
||||
|
||||
private TMP_Text label;
|
||||
private float next;
|
||||
private string lastText;
|
||||
|
||||
void Awake()
|
||||
{
|
||||
label = GetComponent<TMP_Text>();
|
||||
|
||||
// В сцене на ячейках дерева, камня, металла, золота и кристалла по ошибке висит второй такой же компонент
|
||||
// с типом "Люди" -- он перебивал число ресурса. Если рядом есть компонент с настоящим ресурсом -- выключаемся.
|
||||
if (resourceType == ResourceType.Population)
|
||||
{
|
||||
foreach (var other in GetComponents<ResourceUIText>())
|
||||
{
|
||||
if (other != this && other.resourceType != ResourceType.Population)
|
||||
{
|
||||
enabled = false;
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void OnEnable()
|
||||
{
|
||||
next = 0f;
|
||||
}
|
||||
|
||||
void Update()
|
||||
{
|
||||
if (Time.unscaledTime < next) return;
|
||||
next = Time.unscaledTime + 0.5f;
|
||||
Refresh();
|
||||
}
|
||||
|
||||
void Refresh()
|
||||
{
|
||||
string text = BuildText();
|
||||
if (text == lastText) return;
|
||||
lastText = text;
|
||||
label.text = text;
|
||||
}
|
||||
|
||||
string BuildText()
|
||||
{
|
||||
// Люди -- из модуля Населения
|
||||
if (resourceType == ResourceType.Population && GameServices.TryGet(out IPopulation pop))
|
||||
// Ждём, пока ResourceManager точно проинициализируется (Awake порядок не гарантирован)
|
||||
if (ResourceManager.Instance != null)
|
||||
{
|
||||
string value = showCapacity ? $"{pop.Total} / {pop.HousingCap}" : pop.Total.ToString();
|
||||
return showRate ? value + RateLine(pop.GrowthPerHour) : value;
|
||||
Subscribe();
|
||||
}
|
||||
else
|
||||
{
|
||||
Invoke(nameof(TrySubscribeDelayed), 0.1f);
|
||||
}
|
||||
|
||||
if (!GameServices.TryGet(out IEconomy economy)) return "0";
|
||||
int amount = Mathf.FloorToInt(economy.Get(resourceType));
|
||||
float cap = economy.GetCapacity(resourceType);
|
||||
string main = showCapacity && !float.IsInfinity(cap) ? $"{amount} / {Mathf.RoundToInt(cap)}" : amount.ToString();
|
||||
if (!showRate || resourceType == ResourceType.Power) return main;
|
||||
|
||||
float rate = GameServices.TryGet(out IProduction production) ? production.RatePerHour(resourceType) : 0f;
|
||||
if (resourceType == ResourceType.Food && GameServices.TryGet(out IPopulation p)) rate -= p.FoodPerHour;
|
||||
if (resourceType == ResourceType.Gems) return main; // кристалл не добывается рабочими (разломы на карте мира)
|
||||
return main + RateLine(rate);
|
||||
}
|
||||
|
||||
static string RateLine(float rate)
|
||||
void TrySubscribeDelayed()
|
||||
{
|
||||
string color = rate < 0 ? "#ff8a80" : "#9be29b";
|
||||
string sign = rate > 0 ? "+" : "";
|
||||
return $"\n<size=60%><color={color}>{sign}{rate:0.#}/ч</color></size>";
|
||||
if (ResourceManager.Instance != null)
|
||||
Subscribe();
|
||||
}
|
||||
|
||||
void Subscribe()
|
||||
{
|
||||
ResourceManager.Instance.OnResourceChanged += HandleResourceChanged;
|
||||
// сразу показать текущее значение при старте, не дожидаясь первого изменения
|
||||
UpdateText(ResourceManager.Instance.GetResource(resourceType));
|
||||
}
|
||||
|
||||
void OnDisable()
|
||||
{
|
||||
if (ResourceManager.Instance != null)
|
||||
ResourceManager.Instance.OnResourceChanged -= HandleResourceChanged;
|
||||
}
|
||||
|
||||
void HandleResourceChanged(ResourceType type, float newValue)
|
||||
{
|
||||
if (type != resourceType) return; // реагируем только на "свой" ресурс
|
||||
UpdateText(newValue);
|
||||
}
|
||||
|
||||
void UpdateText(float value)
|
||||
{
|
||||
label.text = useShortFormat ? FormatShort(value) : Mathf.FloorToInt(value).ToString();
|
||||
}
|
||||
|
||||
string FormatShort(float value)
|
||||
{
|
||||
if (value >= 1_000_000f)
|
||||
return (value / 1_000_000f).ToString("0.#") + "M";
|
||||
if (value >= 1_000f)
|
||||
return (value / 1_000f).ToString("0.#") + "K";
|
||||
return Mathf.FloorToInt(value).ToString();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,25 +5,13 @@ using TMPro;
|
||||
// КАЗАРМЫ -- здание для найма войск.
|
||||
// - Без казарм нанимать войска нельзя.
|
||||
// - Каждые казармы дают +1 место в очереди обучения (1 казармы = 3 места, максимум 5).
|
||||
// - Уровень Казарм открывает новые типы войск (вместе с уровнем Ратуши; числа -- EconomyConfig, раздел "Солдат", ТЕСТ).
|
||||
// Считаются самые прокачанные Казармы. Уровень сохраняет модуль Поселения (ILeveledBuilding).
|
||||
// - Тап по казармам открывает окно Казарм (App/BuildingWindow): очередь, войска, улучшение, кнопка "Нанять войска".
|
||||
// - Подписи над зданием больше нет -- всё в окне (вернуть: ShowLabel = true).
|
||||
// - Нажатие на казармы открывает панель "Войска" (найм).
|
||||
// - Над зданием подпись: что сейчас обучается и сколько осталось.
|
||||
// Вешается на префаб Barracks (его создаёт Tools -> Build HUD Panels).
|
||||
public class Barracks : MonoBehaviour, ILeveledBuilding
|
||||
public class Barracks : MonoBehaviour
|
||||
{
|
||||
public static readonly List<Barracks> All = new List<Barracks>();
|
||||
public static int Count { get { return All.Count; } }
|
||||
public static bool ShowLabel = false;
|
||||
|
||||
public int level = 1;
|
||||
public int Level { get => level; set => level = Mathf.Max(1, value); }
|
||||
|
||||
// Уровень самых прокачанных Казарм в городе (0 -- казарм нет)
|
||||
public static int MaxLevel
|
||||
{
|
||||
get { int m = 0; foreach (var b in All) if (b != null && b.level > m) m = b.level; return m; }
|
||||
}
|
||||
|
||||
// Размер очереди обучения в зависимости от числа казарм
|
||||
public static int QueueSize { get { return Count == 0 ? 0 : Mathf.Min(5, 2 + Count); } }
|
||||
@@ -36,7 +24,6 @@ public class Barracks : MonoBehaviour, ILeveledBuilding
|
||||
|
||||
void Start()
|
||||
{
|
||||
if (!ShowLabel) return;
|
||||
// подпись над зданием (обычный 3D-текст, не UI)
|
||||
var go = new GameObject("BarracksLabel");
|
||||
go.transform.SetParent(transform, false);
|
||||
@@ -57,7 +44,6 @@ public class Barracks : MonoBehaviour, ILeveledBuilding
|
||||
|
||||
void Update()
|
||||
{
|
||||
if (label == null) return;
|
||||
if (Time.unscaledTime < next) return;
|
||||
next = Time.unscaledTime + 0.5f;
|
||||
Refresh();
|
||||
@@ -66,30 +52,22 @@ public class Barracks : MonoBehaviour, ILeveledBuilding
|
||||
void Refresh()
|
||||
{
|
||||
if (label == null) return;
|
||||
var training = GameProgress.Instance.Training;
|
||||
if (training.Count == 0)
|
||||
var d = GameProgress.Instance.Data;
|
||||
if (d.training.Count == 0)
|
||||
{
|
||||
label.text = "Казармы\n<size=70%>нажмите, чтобы нанять</size>";
|
||||
return;
|
||||
}
|
||||
var o = training[0];
|
||||
label.text = "Казармы\n<size=70%>" + GameData.Troops[o.Type].name + " ×" + o.Count + " " +
|
||||
var o = d.training[0];
|
||||
label.text = "Казармы\n<size=70%>" + GameData.Troops[o.troopIndex].name + " ×" + o.count + " " +
|
||||
GameData.FormatTime(GameProgress.Instance.TrainingSecondsLeft(o)) +
|
||||
(training.Count > 1 ? " (+" + (training.Count - 1) + ")" : "") + "</size>";
|
||||
(d.training.Count > 1 ? " (+" + (d.training.Count - 1) + ")" : "") + "</size>";
|
||||
}
|
||||
|
||||
Vector3 downPos;
|
||||
|
||||
void OnMouseDown()
|
||||
{
|
||||
downPos = Input.mousePosition;
|
||||
}
|
||||
|
||||
// Тап (а не перетаскивание камеры) -- окно Казарм
|
||||
void OnMouseUpAsButton()
|
||||
{
|
||||
if (UIBlocker.IsPointerOverUI()) return;
|
||||
if ((Input.mousePosition - downPos).magnitude > 25f) return;
|
||||
BuildingWindow.Open(gameObject);
|
||||
if (BuildModeController.Instance != null && BuildModeController.Instance.IsActive) return;
|
||||
if (UIPanelManager.Instance != null) UIPanelManager.Instance.Open("Troops");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,18 +3,14 @@ using UnityEngine;
|
||||
using UnityEngine.UI;
|
||||
using TMPro;
|
||||
|
||||
// СВОБОДНАЯ СТРОЙКА НА СЕТКЕ (игрок сам выбирает место):
|
||||
// 1. Кнопка "СТРОЙКА" включает режим: на земле появляется сетка (красные клетки заняты), сверху подсказка,
|
||||
// и сразу открывается список зданий.
|
||||
// 2. Выбрали здание -> появляется призрак: полупрозрачное здание и пятно его клеток.
|
||||
// Зелёное пятно -- можно ставить, красное -- занято или за границей сетки.
|
||||
// 3. Призрак двигают пальцем (тащить за само пятно) или тапом по земле -- он прыгает туда.
|
||||
// Камера в это время не ездит. Размер у зданий разный (BuildingData.footprint).
|
||||
// 4. Внизу панель: "Поставить" (если место свободно и хватает ресурсов), "Другое здание", "Отмена".
|
||||
// 5. После постройки режим остаётся включённым -- можно строить дальше. "Выйти" -- сетка прячется.
|
||||
// НОВАЯ СХЕМА СТРОЙКИ (как в Rise of Kingdoms):
|
||||
// 1. Кнопка "СТРОЙКА" включает режим строительства -- появляются свободные участки и подсказка сверху.
|
||||
// 2. Тап по свободному участку -> окно со зданиями (цена, что доступно по уровню Ратуши).
|
||||
// 3. "Построить" -> здание встаёт на участок. Режим остаётся включённым, можно строить дальше.
|
||||
// 4. Кнопка "ВЫЙТИ" (та же кнопка или в подсказке) -- выключает режим, участки снова прячутся.
|
||||
// Вне режима пустые участки не видны и не мешают, при запуске игры ничего не открывается.
|
||||
//
|
||||
// Вешается билдером (Tools -> Build HUD Panels) на HUD_Canvas, кнопка Nav_Build вызывает ToggleMode().
|
||||
[DefaultExecutionOrder(-50)] // раньше камеры: захват пальца призраком должен быть виден ей в том же кадре
|
||||
public class BuildModeController : MonoBehaviour
|
||||
{
|
||||
public static BuildModeController Instance { get; private set; }
|
||||
@@ -28,27 +24,14 @@ public class BuildModeController : MonoBehaviour
|
||||
|
||||
GameObject banner;
|
||||
GameObject chooser;
|
||||
GameObject placePanel;
|
||||
TextMeshProUGUI placeInfo;
|
||||
Button placeButton;
|
||||
|
||||
BuildGhost ghost;
|
||||
bool dragging;
|
||||
Vector2 grabOffset;
|
||||
Vector2 downPos;
|
||||
bool downOverUI;
|
||||
bool downOnGhost;
|
||||
float nextPanelRefresh;
|
||||
float nextVisualRefresh;
|
||||
|
||||
const float TapThreshold = 25f;
|
||||
static readonly Color SlotColor = new Color(0.3f, 1f, 0.4f, 0.75f);
|
||||
|
||||
void Awake() { Instance = this; }
|
||||
|
||||
void OnDestroy()
|
||||
{
|
||||
if (Instance == this) Instance = null;
|
||||
UIBlocker.WorldInputCaptured = false;
|
||||
}
|
||||
void OnDestroy() { if (Instance == this) Instance = null; }
|
||||
|
||||
void Start()
|
||||
{
|
||||
@@ -62,172 +45,85 @@ public class BuildModeController : MonoBehaviour
|
||||
{
|
||||
IsActive = active;
|
||||
CloseChooser();
|
||||
CancelGhost();
|
||||
ApplySlotVisuals();
|
||||
UpdateButtonLabel();
|
||||
BuildGrid.Instance.ShowOverlay(active);
|
||||
|
||||
if (banner != null) { Destroy(banner); banner = null; }
|
||||
if (active)
|
||||
{
|
||||
if (UIPanelManager.Instance != null) UIPanelManager.Instance.CloseAll();
|
||||
banner = CreateBanner();
|
||||
OpenChooser();
|
||||
if (FreeSlots().Count == 0) UIKit.Toast("Свободных участков нет. Улучшайте Ратушу и сносите старое.");
|
||||
}
|
||||
}
|
||||
|
||||
void Update()
|
||||
{
|
||||
if (Time.unscaledTime >= nextVisualRefresh)
|
||||
{
|
||||
nextVisualRefresh = Time.unscaledTime + 0.3f;
|
||||
ApplySlotVisuals(); // участки могли освободиться/заняться (снос, загрузка сохранения)
|
||||
}
|
||||
if (!IsActive) return;
|
||||
// открыли любую панель HUD -- выходим из режима стройки
|
||||
if (UIPanelManager.Instance != null && UIPanelManager.Instance.CurrentId != "") { SetMode(false); return; }
|
||||
if (chooser != null || ghost == null) { StopDrag(); return; }
|
||||
|
||||
HandlePointer();
|
||||
|
||||
if (Time.unscaledTime >= nextPanelRefresh)
|
||||
{
|
||||
nextPanelRefresh = Time.unscaledTime + 0.3f;
|
||||
ghost.Recheck(); // клетки могли заняться (загрузка сохранения) или ресурсы -- прийти
|
||||
RefreshPlacePanel();
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------- палец: тащим призрак или тапаем по земле ----------------
|
||||
|
||||
void HandlePointer()
|
||||
{
|
||||
Vector2 world = PointerWorld();
|
||||
if (chooser != null) return;
|
||||
|
||||
// Тап = нажали и отпустили почти на том же месте (чтобы перетаскивание камеры не считалось тапом)
|
||||
if (Input.GetMouseButtonDown(0))
|
||||
{
|
||||
downPos = Input.mousePosition;
|
||||
downOverUI = UIBlocker.IsPointerOverUI();
|
||||
downOnGhost = !downOverUI && ghost.Contains(world);
|
||||
if (downOnGhost)
|
||||
{
|
||||
dragging = true;
|
||||
grabOffset = (Vector2)ghost.transform.position - world;
|
||||
UIBlocker.WorldInputCaptured = true; // камера не поедет
|
||||
}
|
||||
}
|
||||
|
||||
if (dragging && Input.GetMouseButton(0))
|
||||
{
|
||||
var before = ghost.Cell;
|
||||
ghost.MoveNear(world + grabOffset);
|
||||
if (ghost.Cell != before) RefreshPlacePanel();
|
||||
}
|
||||
|
||||
if (Input.GetMouseButtonUp(0))
|
||||
{
|
||||
bool wasDragging = dragging;
|
||||
StopDrag();
|
||||
if (wasDragging || downOverUI || UIBlocker.IsPointerOverUI()) return;
|
||||
if (((Vector2)Input.mousePosition - downPos).magnitude > TapThreshold) return; // тащили камеру
|
||||
ghost.MoveNear(world); // тап по земле -- призрак прыгает сюда
|
||||
RefreshPlacePanel();
|
||||
Vector2 up = Input.mousePosition;
|
||||
if (downOverUI || UIBlocker.IsPointerOverUI() || (up - downPos).magnitude > 25f) return;
|
||||
HandleTap(up);
|
||||
}
|
||||
}
|
||||
|
||||
void StopDrag()
|
||||
void HandleTap(Vector2 screenPos)
|
||||
{
|
||||
dragging = false;
|
||||
UIBlocker.WorldInputCaptured = false;
|
||||
}
|
||||
|
||||
static Vector2 PointerWorld()
|
||||
{
|
||||
var cam = Camera.main;
|
||||
if (cam == null) return Vector2.zero;
|
||||
Vector3 w = cam.ScreenToWorldPoint(Input.mousePosition);
|
||||
return new Vector2(w.x, w.y);
|
||||
}
|
||||
|
||||
// ---------------- призрак ----------------
|
||||
|
||||
void StartGhost(BuildingData data)
|
||||
{
|
||||
CancelGhost();
|
||||
CloseChooser();
|
||||
ghost = BuildGhost.Create(data);
|
||||
|
||||
// ставим в центр экрана, а если там занято -- на ближайшее свободное место
|
||||
var cam = Camera.main;
|
||||
Vector2 center = cam != null ? (Vector2)cam.transform.position : Vector2.zero;
|
||||
if (BuildGrid.Instance.FindFreeNear(center, ghost.Footprint.size, out var cell)) ghost.MoveToCell(cell);
|
||||
else
|
||||
if (BuildManager.Instance == null) return;
|
||||
var slot = BuildManager.Instance.SlotAtScreen(screenPos);
|
||||
if (slot == null) return;
|
||||
if (slot.isOccupied)
|
||||
{
|
||||
ghost.MoveNear(center);
|
||||
UIKit.Toast("Свободного места под это здание нет");
|
||||
var id = slot.occupyingBuilding != null ? slot.occupyingBuilding.GetComponent<PlacedBuildingId>() : null;
|
||||
UIKit.Toast("Участок занят" + (id != null ? ": " + id.buildingName : ""));
|
||||
return;
|
||||
}
|
||||
CreatePlacePanel();
|
||||
OpenChooser(slot);
|
||||
}
|
||||
|
||||
void CancelGhost()
|
||||
// ---------------- участки ----------------
|
||||
|
||||
List<BuildSlot> FreeSlots()
|
||||
{
|
||||
StopDrag();
|
||||
if (ghost != null) Destroy(ghost.gameObject);
|
||||
ghost = null;
|
||||
if (placePanel != null) Destroy(placePanel);
|
||||
placePanel = null;
|
||||
var list = new List<BuildSlot>();
|
||||
foreach (var s in FindObjectsByType<BuildSlot>(FindObjectsSortMode.None)) if (!s.isOccupied) list.Add(s);
|
||||
return list;
|
||||
}
|
||||
|
||||
void Place()
|
||||
void ApplySlotVisuals()
|
||||
{
|
||||
if (ghost == null || BuildManager.Instance == null) return;
|
||||
var d = ghost.Data;
|
||||
ghost.Recheck();
|
||||
if (!ghost.CellsFree) { UIKit.Toast("Здесь строить нельзя: клетки заняты"); return; }
|
||||
if (!d.CanAfford()) { UIKit.Toast(GameProgress.Instance.MissingText(ToRewards(d.cost))); return; }
|
||||
|
||||
if (BuildManager.Instance.BuildAt(d, ghost.Cell) != null)
|
||||
foreach (var s in FindObjectsByType<BuildSlot>(FindObjectsSortMode.None))
|
||||
{
|
||||
UIKit.Toast("Построено: " + d.buildingName);
|
||||
CancelGhost(); // режим остаётся: "Здания" в подсказке сверху -- строить дальше
|
||||
var sr = s.GetComponent<SpriteRenderer>();
|
||||
if (sr == null) continue;
|
||||
bool show = IsActive && !s.isOccupied;
|
||||
if (sr.enabled != show) sr.enabled = show;
|
||||
if (show) sr.color = SlotColor;
|
||||
}
|
||||
else UIKit.Toast("Не получилось построить");
|
||||
}
|
||||
|
||||
// ---------------- нижняя панель: Поставить / Другое здание / Отмена ----------------
|
||||
|
||||
void CreatePlacePanel()
|
||||
{
|
||||
if (placePanel != null) Destroy(placePanel);
|
||||
var bar = UIKit.Row(UIKit.Overlay, -1, 14, new Color(0.08f, 0.1f, 0.14f, 0.94f), 14);
|
||||
bar.name = "BuildPlacePanel";
|
||||
bar.anchorMin = new Vector2(0.04f, 0); bar.anchorMax = new Vector2(0.96f, 0);
|
||||
bar.pivot = new Vector2(0.5f, 0);
|
||||
bar.sizeDelta = new Vector2(0, 130); bar.anchoredPosition = new Vector2(0, 40);
|
||||
placePanel = bar.gameObject;
|
||||
|
||||
placeInfo = UIKit.Text(bar, "", 26, Color.white, TextAlignmentOptions.Left);
|
||||
UIKit.Size(placeInfo, -1, -1, 1);
|
||||
placeButton = UIKit.Button(bar, "Поставить", UIKit.Theme.Green, Place, 230, 90, 28);
|
||||
UIKit.Button(bar, "Другое", UIKit.Theme.Blue, OpenChooser, 180, 90, 26);
|
||||
UIKit.Button(bar, "Отмена", UIKit.Theme.Gray, CancelGhost, 180, 90, 26);
|
||||
RefreshPlacePanel();
|
||||
}
|
||||
|
||||
void RefreshPlacePanel()
|
||||
{
|
||||
if (ghost == null || placeInfo == null) return;
|
||||
var d = ghost.Data;
|
||||
bool afford = d.CanAfford();
|
||||
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);
|
||||
bool ok = ghost.CellsFree && afford;
|
||||
UIKit.SetButton(placeButton, ok ? "Поставить" : (ghost.CellsFree ? "Мало ресурсов" : "Занято"), ok);
|
||||
}
|
||||
|
||||
// ---------------- окно выбора здания ----------------
|
||||
|
||||
void OpenChooser()
|
||||
void OpenChooser(BuildSlot slot)
|
||||
{
|
||||
CloseChooser();
|
||||
StopDrag();
|
||||
var bm = BuildManager.Instance;
|
||||
if (bm == null) return;
|
||||
GameObject root;
|
||||
var card = UIKit.Modal("Что построить?", out root);
|
||||
chooser = root;
|
||||
@@ -239,7 +135,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 + " ур. • выберите здание, потом место на сетке", 24, UIKit.Theme.TextDim, TextAlignmentOptions.Center);
|
||||
UIKit.Text(card, "Участок: " + slot.name + " • Ратуша " + th + " ур.", 24, UIKit.Theme.TextDim, TextAlignmentOptions.Center);
|
||||
|
||||
var holder = UIKit.Make("ListHolder", card);
|
||||
UIKit.Size(holder, -1, -1).flexibleHeight = 1;
|
||||
@@ -248,7 +144,7 @@ public class BuildModeController : MonoBehaviour
|
||||
|
||||
// доступные сверху, закрытые по уровню Ратуши -- ниже
|
||||
var all = new List<BuildingData>(bm.allBuildings);
|
||||
all.RemoveAll(b => b == null || b.prefab == null);
|
||||
all.RemoveAll(b => b == null);
|
||||
all.Sort((a, b) => a.requiredTownHallLevel.CompareTo(b.requiredTownHallLevel));
|
||||
|
||||
foreach (var data in all)
|
||||
@@ -256,7 +152,6 @@ public class BuildModeController : MonoBehaviour
|
||||
var d = data;
|
||||
bool locked = d.requiredTownHallLevel > th;
|
||||
bool afford = !locked && d.CanAfford();
|
||||
var size = BuildFootprint.Of(d).size;
|
||||
|
||||
var row = UIKit.Row(list, 120, 14, locked ? UIKit.Theme.CardDark : UIKit.Theme.Card, 10);
|
||||
var icon = UIKit.Box(row, d.icon != null ? Color.white : UIKit.Theme.Gray);
|
||||
@@ -265,18 +160,26 @@ public class BuildModeController : MonoBehaviour
|
||||
|
||||
var info = UIKit.Card(row, new Color(0, 0, 0, 0), 0, 4);
|
||||
UIKit.Size(info, -1, -1, 1);
|
||||
UIKit.Text(info, d.buildingName + " <size=22><color=#aab>" + size.x + "×" + size.y + " кл.</color></size>", 30,
|
||||
locked ? UIKit.Theme.TextDim : UIKit.Theme.Text).fontStyle = FontStyles.Bold;
|
||||
UIKit.Text(info, d.buildingName, 30, locked ? UIKit.Theme.TextDim : UIKit.Theme.Text).fontStyle = FontStyles.Bold;
|
||||
UIKit.Text(info, locked ? "Откроется на Ратуше " + d.requiredTownHallLevel + " уровня" : CostText(d), 24,
|
||||
locked ? UIKit.Theme.Gold : UIKit.Theme.TextDim);
|
||||
|
||||
// выбрать можно и без ресурсов: место присмотреть, а "Поставить" загорится, когда хватит
|
||||
string label = locked ? "Закрыто" : "Выбрать";
|
||||
var b = UIKit.Button(row, label, afford ? UIKit.Theme.Green : UIKit.Theme.Gray, () => StartGhost(d), 230, 90, 26);
|
||||
string label = locked ? "Закрыто" : (afford ? "Построить" : "Мало ресурсов");
|
||||
var b = UIKit.Button(row, label, afford ? UIKit.Theme.Green : UIKit.Theme.Gray, () =>
|
||||
{
|
||||
if (BuildManager.Instance.BuildOnSlot(d, slot))
|
||||
{
|
||||
UIKit.Toast("Построено: " + d.buildingName);
|
||||
CloseChooser();
|
||||
ApplySlotVisuals();
|
||||
if (FreeSlots().Count == 0) SetMode(false);
|
||||
}
|
||||
else UIKit.Toast(GameProgress.Instance.MissingText(ToRewards(d.cost)));
|
||||
}, 230, 90, 26);
|
||||
b.interactable = !locked;
|
||||
}
|
||||
|
||||
UIKit.Button(card, "Закрыть", UIKit.Theme.Gray, CloseChooser, -1, 90, 30);
|
||||
UIKit.Button(card, "Отмена", UIKit.Theme.Gray, CloseChooser, -1, 90, 30);
|
||||
}
|
||||
|
||||
void CloseChooser()
|
||||
@@ -313,13 +216,12 @@ public class BuildModeController : MonoBehaviour
|
||||
// полоска сверху экрана (не модальная: остальной экран остаётся кликабельным)
|
||||
var bar = UIKit.Row(UIKit.Overlay, -1, 16, new Color(0.1f, 0.25f, 0.12f, 0.92f), 14);
|
||||
bar.name = "BuildModeBanner";
|
||||
bar.anchorMin = new Vector2(0.1f, 1); bar.anchorMax = new Vector2(0.9f, 1);
|
||||
bar.anchorMin = new Vector2(0.15f, 1); bar.anchorMax = new Vector2(0.85f, 1);
|
||||
bar.pivot = new Vector2(0.5f, 1);
|
||||
bar.sizeDelta = new Vector2(0, 110); bar.anchoredPosition = new Vector2(0, -150);
|
||||
var t = UIKit.Text(bar, "РЕЖИМ СТРОЙКИ: двигайте здание по сетке, красное -- занято", 26, Color.white, TextAlignmentOptions.Left);
|
||||
var t = UIKit.Text(bar, "РЕЖИМ СТРОЙКИ: нажмите на зелёный участок", 30, Color.white, TextAlignmentOptions.Left);
|
||||
UIKit.Size(t, -1, -1, 1);
|
||||
UIKit.Button(bar, "Здания", UIKit.Theme.Green, OpenChooser, 180, 80, 28);
|
||||
UIKit.Button(bar, "Выйти", UIKit.Theme.Red, () => SetMode(false), 160, 80, 28);
|
||||
UIKit.Button(bar, "Выйти", UIKit.Theme.Red, () => SetMode(false), 180, 80, 28);
|
||||
return bar.gameObject;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,60 +0,0 @@
|
||||
using System.Collections.Generic;
|
||||
using UnityEngine;
|
||||
|
||||
// ГОСПИТАЛЬ -- здание для лечения раненых (регламент 31.10; решения по 8 вопросам Саши от 31.10, п. 7).
|
||||
// - Даёт места для лечения: уровни I-V, 20-60 мест (таблица HospitalConfig, ТЕСТ).
|
||||
// - Считаются самые прокачанные госпитали: второй госпиталь мест не добавляет (регламент про несколько не говорит).
|
||||
// - Раненых хранит и лечит модуль Армии; здание только сообщает число мест через контракт IHospital (HospitalBeds).
|
||||
// - Тап открывает окно госпиталя (App/BuildingWindow): места, кто лечится и ждёт, улучшение.
|
||||
// - Уровень сохраняет модуль Поселения (ILeveledBuilding).
|
||||
// Вешается на копию обычного здания (HospitalBootstrap).
|
||||
public class Hospital : MonoBehaviour, ILeveledBuilding
|
||||
{
|
||||
public static readonly List<Hospital> All = new List<Hospital>();
|
||||
public static int Count => All.Count;
|
||||
|
||||
public int level = 1;
|
||||
public int Level { get => level; set => level = Mathf.Clamp(value, 1, MaxBuildLevel); }
|
||||
|
||||
// Уровней у госпиталя столько, сколько строк мест в таблице (регламент: I-V)
|
||||
public static int MaxBuildLevel => Mathf.Max(1, HospitalConfig.Load().bedsByLevel.Length);
|
||||
|
||||
// Уровень самого прокачанного госпиталя (0 -- госпиталя нет)
|
||||
public static int MaxLevel
|
||||
{
|
||||
get { int m = 0; foreach (var h in All) if (h != null && h.level > m) m = h.level; return m; }
|
||||
}
|
||||
|
||||
// Мест на уровне (1..5) по таблице
|
||||
public static int BedsAt(int lvl)
|
||||
{
|
||||
var beds = HospitalConfig.Load().bedsByLevel;
|
||||
if (lvl <= 0 || beds == null || beds.Length == 0) return 0;
|
||||
return Mathf.Max(0, beds[Mathf.Min(lvl, beds.Length) - 1]);
|
||||
}
|
||||
|
||||
void OnEnable() { if (!All.Contains(this)) All.Add(this); }
|
||||
void OnDisable() { All.Remove(this); }
|
||||
|
||||
Vector3 downPos;
|
||||
|
||||
void OnMouseDown()
|
||||
{
|
||||
downPos = Input.mousePosition;
|
||||
}
|
||||
|
||||
// Тап (а не перетаскивание камеры) -- окно госпиталя
|
||||
void OnMouseUpAsButton()
|
||||
{
|
||||
if (UIBlocker.IsPointerOverUI()) return;
|
||||
if ((Input.mousePosition - downPos).magnitude > 25f) return;
|
||||
BuildingWindow.Open(gameObject);
|
||||
}
|
||||
}
|
||||
|
||||
// Места госпиталя для модуля Армии (контракт IHospital). Регистрирует App/GameModules.
|
||||
// Госпиталя нет -- 0 мест: Армия берёт HospitalConfig.temporaryBeds.
|
||||
public sealed class HospitalBeds : IHospital
|
||||
{
|
||||
public int Beds => Hospital.BedsAt(Hospital.MaxLevel);
|
||||
}
|
||||
@@ -1,11 +0,0 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 2f4c537a26bd44b3ae851af08c652a34
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -1,65 +0,0 @@
|
||||
using UnityEngine;
|
||||
|
||||
// Добавляет «Госпиталь» в список стройки САМ при запуске сцены (как Казармы -- BarracksBootstrap).
|
||||
// Срабатывает до восстановления зданий из сохранения (SettlementRestoringEvent), поэтому построенный
|
||||
// госпиталь и его уровень корректно восстанавливаются из save.json.
|
||||
public static class HospitalBootstrap
|
||||
{
|
||||
public const string HospitalName = "Госпиталь";
|
||||
static GameObject runtimeTemplate;
|
||||
|
||||
[RuntimeInitializeOnLoadMethod(RuntimeInitializeLoadType.AfterSceneLoad)]
|
||||
static void Init()
|
||||
{
|
||||
Ensure();
|
||||
// при каждом новом входе в город (например, возврат с карты мира) -- снова добавить в новый BuildManager
|
||||
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 == HospitalName) return; // уже есть
|
||||
if (template == null && b.prefab != null) template = b;
|
||||
}
|
||||
if (template == null) { Debug.LogWarning("[ГОСПИТАЛЬ] Нет ни одного здания-шаблона в BuildManager"); return; }
|
||||
|
||||
// Префаб-шаблон в памяти: копия обычного здания без сбора ресурсов, с компонентом Hospital
|
||||
if (runtimeTemplate == null)
|
||||
{
|
||||
runtimeTemplate = Object.Instantiate(template.prefab);
|
||||
runtimeTemplate.name = "Hospital";
|
||||
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(0.85f, 0.95f, 1f); // пока нет арта -- отличаем цветом (светло-голубой)
|
||||
runtimeTemplate.AddComponent<Hospital>();
|
||||
}
|
||||
|
||||
var config = HospitalConfig.Load();
|
||||
var data = ScriptableObject.CreateInstance<BuildingData>();
|
||||
data.name = "Data_Hospital_Runtime";
|
||||
data.buildingName = HospitalName;
|
||||
data.icon = template.icon;
|
||||
data.prefab = runtimeTemplate;
|
||||
data.requiredTownHallLevel = Mathf.Max(1, config.requiredTownHallLevel);
|
||||
data.cost = new[]
|
||||
{
|
||||
new ResourceCost { type = ResourceType.Wood, amount = config.buildWood },
|
||||
new ResourceCost { type = ResourceType.Stone, amount = config.buildStone },
|
||||
};
|
||||
bm.allBuildings.Add(data);
|
||||
Debug.Log("[ГОСПИТАЛЬ] Добавлен в список стройки");
|
||||
}
|
||||
}
|
||||
@@ -1,11 +0,0 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 3301bf2246b4444182d1362fe7bffcc5
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -622,12 +622,6 @@ public class GameProgress : MonoBehaviour
|
||||
// Войска хранит модуль Армии (Docs/Modules/Army.md) -- здесь только обращение к нему
|
||||
static IArmy Army => GameServices.Get<IArmy>();
|
||||
|
||||
// Люди города -- модуль Населения. null -- модуль не подключён
|
||||
static IPopulation Population => GameServices.TryGet(out IPopulation p) ? p : null;
|
||||
|
||||
// Сколько воинов можно нанять из жителей прямо сейчас (для кнопок найма)
|
||||
public int Recruitable => Population != null ? Population.Recruitable : int.MaxValue;
|
||||
|
||||
public int TroopCount(int type) { return Army.GetTroops(type); }
|
||||
|
||||
public int TotalTroops
|
||||
@@ -635,105 +629,68 @@ public class GameProgress : MonoBehaviour
|
||||
get { return Army.SoldiersAtHome; }
|
||||
}
|
||||
|
||||
// Тип войск открыт, если выполнены оба условия: уровень Ратуши (GameData.Troops) и уровень Казарм
|
||||
// (EconomyConfig, раздел "Солдат", ТЕСТ). Считаются самые прокачанные Казармы.
|
||||
public bool TroopUnlocked(int index)
|
||||
{
|
||||
return TownHallLevel >= GameData.Troops[index].requiredTownHall && Barracks.MaxLevel >= RequiredBarracksLevel(index);
|
||||
}
|
||||
|
||||
public int RequiredBarracksLevel(int index)
|
||||
{
|
||||
var table = EconomyConfig.Load().soldier.requiredBarracksLevel;
|
||||
return table != null && index >= 0 && index < table.Length ? Mathf.Max(1, table[index]) : 1;
|
||||
}
|
||||
|
||||
// Чего не хватает, чтобы открыть тип войск: "Ратуша 2 ур., Казармы 3 ур." (пусто -- открыт)
|
||||
public string TroopLockText(int index)
|
||||
{
|
||||
var parts = new List<string>();
|
||||
int th = GameData.Troops[index].requiredTownHall, bk = RequiredBarracksLevel(index);
|
||||
if (TownHallLevel < th) parts.Add("Ратуша " + th + " ур.");
|
||||
if (Barracks.MaxLevel < bk) parts.Add("Казармы " + bk + " ур.");
|
||||
return string.Join(", ", parts.ToArray());
|
||||
}
|
||||
|
||||
// ОЧЕРЕДЬ ОБУЧЕНИЯ ведёт модуль Армии (решения по 8 вопросам Саши, 31.10, п. 8). Здесь -- только то, что знает
|
||||
// слой приложения: есть ли Казармы и открыт ли тип войск, цена (Экономика), длина очереди от числа Казарм, окна.
|
||||
// Время -- часы игры (GameClock.UnixNowMs): перемотка DEV и офлайн досчитывают обучение так же, как марши.
|
||||
|
||||
public IReadOnlyList<TrainingOrderInfo> Training => Army.Training;
|
||||
public bool TroopUnlocked(int index) { return TownHallLevel >= GameData.Troops[index].requiredTownHall; }
|
||||
|
||||
public void Train(int index, int count)
|
||||
{
|
||||
var def = GameData.Troops[index];
|
||||
if (!TroopUnlocked(index)) { UIKit.Toast("Нужна Ратуша " + def.requiredTownHall + " уровня"); return; }
|
||||
if (Barracks.Count == 0) { UIKit.Toast("Сначала постройте Казармы"); return; }
|
||||
if (!TroopUnlocked(index)) { UIKit.Toast("Нужно: " + TroopLockText(index)); return; }
|
||||
// Очередь и жители (резерв не отдаётся) -- проверяет Армия вместе с Населением
|
||||
if (!Army.CanQueueTraining(index, count, Barracks.QueueSize, out string error)) { UIKit.Toast(error); return; }
|
||||
if (Data.training.Count >= Barracks.QueueSize) { UIKit.Toast("Очередь обучения заполнена. Больше казарм -- длиннее очередь"); return; }
|
||||
if (!Pay(def.cost, count)) return;
|
||||
|
||||
long durationMs = (long)(def.secondsPerUnit * 1000.0 * count);
|
||||
if (!Army.TryQueueTraining(index, count, durationMs, Barracks.QueueSize, GameClock.UnixNowMs, out error))
|
||||
{
|
||||
// Заказ не встал -- цену возвращаем целиком
|
||||
var refund = new RewardItem[def.cost.Length];
|
||||
for (int i = 0; i < def.cost.Length; i++) refund[i] = new RewardItem(def.cost[i].type, def.cost[i].amount * count);
|
||||
Grant(refund, 0, false);
|
||||
UIKit.Toast(error);
|
||||
return;
|
||||
}
|
||||
long start = Data.training.Count > 0 ? Data.training[Data.training.Count - 1].endTicks : Now;
|
||||
if (start < Now) start = Now;
|
||||
long end = start + TimeSpan.FromSeconds(def.secondsPerUnit * count).Ticks;
|
||||
Data.training.Add(new TrainingOrder { troopIndex = index, count = count, startTicks = start, endTicks = end });
|
||||
UIKit.Toast("Обучение начато: " + count + " × " + def.name);
|
||||
MarkChanged();
|
||||
}
|
||||
|
||||
public double TrainingSecondsLeft(TrainingOrderInfo o) { return Math.Max(0, o.EndMs - GameClock.UnixNowMs) / 1000.0; }
|
||||
public double TrainingSecondsLeft(TrainingOrder o) { return TimeSpan.FromTicks(Math.Max(0, o.endTicks - Now)).TotalSeconds; }
|
||||
|
||||
public float TrainingProgress(TrainingOrderInfo o)
|
||||
public float TrainingProgress(TrainingOrder o)
|
||||
{
|
||||
long total = o.EndMs - o.StartMs;
|
||||
long total = o.endTicks - o.startTicks;
|
||||
if (total <= 0) return 1f;
|
||||
return Mathf.Clamp01((float)(GameClock.UnixNowMs - o.StartMs) / total);
|
||||
return Mathf.Clamp01((float)(Now - o.startTicks) / total);
|
||||
}
|
||||
|
||||
public int SpeedUpCost
|
||||
{
|
||||
get
|
||||
{
|
||||
var queue = Training;
|
||||
if (queue.Count == 0) return 0;
|
||||
double secs = TrainingSecondsLeft(queue[queue.Count - 1]);
|
||||
if (Data.training.Count == 0) return 0;
|
||||
double secs = TrainingSecondsLeft(Data.training[Data.training.Count - 1]);
|
||||
return Mathf.Max(1, Mathf.CeilToInt((float)secs / 30f)); // 1 кристалл за 30 сек
|
||||
}
|
||||
}
|
||||
|
||||
public void SpeedUpTraining()
|
||||
{
|
||||
if (Training.Count == 0) return;
|
||||
if (Data.training.Count == 0) return;
|
||||
if (!Pay(new[] { new RewardItem(ResourceType.Gems, SpeedUpCost) })) return;
|
||||
Army.FinishAllTraining(GameClock.UnixNowMs);
|
||||
foreach (var o in Data.training) o.endTicks = Now;
|
||||
ProcessTraining();
|
||||
}
|
||||
|
||||
// Раз в секунду: Армия заканчивает заказы обучения и лечение раненых, срок которых наступил (и пока игра была закрыта)
|
||||
void ProcessTraining()
|
||||
{
|
||||
if (!GameServices.TryGet(out IArmy army)) return;
|
||||
long now = GameClock.UnixNowMs;
|
||||
army.ProcessTraining(now);
|
||||
army.ProcessHealing(now);
|
||||
army.ProcessMissing(now);
|
||||
}
|
||||
|
||||
// Заказ закончен (событие Армии, один раз на заказ): статистика заданий и всплывашка
|
||||
public void OnTroopsTrained(TroopsTrainedEvent e)
|
||||
{
|
||||
Data.troopsTrained += e.Count;
|
||||
Data.dailyTroops += e.Count;
|
||||
Data.eventTroopRemainder += e.Count;
|
||||
AddEventPoints(Data.eventTroopRemainder / 5);
|
||||
Data.eventTroopRemainder %= 5;
|
||||
if (e.Type >= 0 && e.Type < GameData.Troops.Count) UIKit.Toast("Обучено: " + e.Count + " × " + GameData.Troops[e.Type].name);
|
||||
MarkChanged();
|
||||
bool any = false;
|
||||
while (Data.training.Count > 0 && Data.training[0].endTicks <= Now)
|
||||
{
|
||||
var o = Data.training[0];
|
||||
Data.training.RemoveAt(0);
|
||||
Army.AddTroops(o.troopIndex, o.count);
|
||||
Data.troopsTrained += o.count;
|
||||
Data.dailyTroops += o.count;
|
||||
Data.eventTroopRemainder += o.count;
|
||||
AddEventPoints(Data.eventTroopRemainder / 5);
|
||||
Data.eventTroopRemainder %= 5;
|
||||
UIKit.Toast("Обучено: " + o.count + " × " + GameData.Troops[o.troopIndex].name);
|
||||
any = true;
|
||||
}
|
||||
if (any) MarkChanged();
|
||||
}
|
||||
|
||||
// Потери в бою: убираем процент от каждого типа войск
|
||||
|
||||
@@ -24,7 +24,6 @@ public class TrainingOrder
|
||||
public int count;
|
||||
public long startTicks;
|
||||
public long endTicks;
|
||||
public bool recruited; // под заказ взяты люди из жителей (модуль Населения). false -- старый заказ, людей не брали
|
||||
}
|
||||
|
||||
[Serializable]
|
||||
@@ -97,7 +96,7 @@ public class ProgressData
|
||||
// из старых сохранений: при первой загрузке его читает App/GameModules, потом оно обнуляется.
|
||||
public int[] troops = new int[4];
|
||||
public bool armyInModule; // войска уже перенесены в модуль Армии
|
||||
public List<TrainingOrder> training = new List<TrainingOrder>(); // УСТАРЕЛО: очередь у Армии (save.json, "army"); только для переноса старых сохранений
|
||||
public List<TrainingOrder> training = new List<TrainingOrder>();
|
||||
|
||||
// --- Бой (кампания) ---
|
||||
public int battleStage = 1;
|
||||
|
||||
@@ -24,53 +24,10 @@ public class TroopsPanel : BasePanel
|
||||
ProfilePanel.Header(army, "Армия");
|
||||
ProfilePanel.KV(army, "Всего воинов в городе", GP.TotalTroops.ToString("N0"));
|
||||
ProfilePanel.KV(army, "В походе на карте мира", GameServices.Get<IArmy>().TroopsInMarches.ToString("N0"));
|
||||
if (GameServices.TryGet(out IPopulation population))
|
||||
{
|
||||
// Воины берутся из свободных жителей (модуль Населения), резерв не нанять
|
||||
ProfilePanel.KV(army, "Обучаются в Казармах", population.InTraining.ToString("N0"));
|
||||
ProfilePanel.KV(army, "Можно нанять из жителей", population.Recruitable.ToString("N0") +
|
||||
" (резерв " + population.CivilianReserve.ToString("N0") + ")");
|
||||
}
|
||||
// Раненые (решения 31.10, п. 7): лечатся в госпитале или ждут места; не воюют, но считаются людьми
|
||||
IArmy hurt = GameServices.Get<IArmy>();
|
||||
if (hurt.WoundedTotal > 0)
|
||||
{
|
||||
int waiting = 0, healingNow = 0;
|
||||
long readyMs = long.MaxValue;
|
||||
for (int t = 0; t < hurt.TypeCount; t++) waiting += hurt.GetWoundedWaiting(t);
|
||||
foreach (var h in hurt.Healing) { healingNow += h.Count; readyMs = System.Math.Min(readyMs, h.EndMs); }
|
||||
string next = readyMs != long.MaxValue
|
||||
? ", ближайшие вылечатся через " + GameData.FormatTime(System.Math.Max(0, readyMs - GameClock.UnixNowMs) / 1000.0)
|
||||
: "";
|
||||
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);
|
||||
|
||||
// --- Гарнизон: часть воинов охраняет город и в походы не уходит ---
|
||||
IArmy troopsArmy = GameServices.Get<IArmy>();
|
||||
var garrison = UIKit.Card(list, UIKit.Theme.CardDark);
|
||||
ProfilePanel.Header(garrison, "Гарнизон города");
|
||||
ProfilePanel.KV(garrison, "В гарнизоне", troopsArmy.GarrisonTotal.ToString("N0") + " из " + troopsArmy.SoldiersAtHome.ToString("N0"));
|
||||
ProfilePanel.KV(garrison, "Свободны для походов", troopsArmy.AvailableForMarch.ToString("N0"));
|
||||
UIKit.Text(garrison, "Гарнизон защищает город и не уходит в походы. Воины в нём не пропадают: всего воинов столько же.", 22, UIKit.Theme.TextDim);
|
||||
var gRow = UIKit.Row(garrison, 80, 10);
|
||||
GarrisonButton(gRow, troopsArmy, "0", 0);
|
||||
GarrisonButton(gRow, troopsArmy, "-10", troopsArmy.GarrisonTotal - 10);
|
||||
GarrisonButton(gRow, troopsArmy, "-1", troopsArmy.GarrisonTotal - 1);
|
||||
GarrisonButton(gRow, troopsArmy, "+1", troopsArmy.GarrisonTotal + 1);
|
||||
GarrisonButton(gRow, troopsArmy, "+10", troopsArmy.GarrisonTotal + 10);
|
||||
GarrisonButton(gRow, troopsArmy, "Все", troopsArmy.SoldiersAtHome);
|
||||
|
||||
// --- Нет казарм -- нанимать негде ---
|
||||
if (Barracks.Count == 0)
|
||||
{
|
||||
@@ -85,19 +42,18 @@ public class TroopsPanel : BasePanel
|
||||
|
||||
// --- Очередь обучения ---
|
||||
var queue = UIKit.Card(list);
|
||||
var training = GP.Training;
|
||||
ProfilePanel.Header(queue, "Обучение (" + training.Count + " / " + Barracks.QueueSize + ") • казарм: " + Barracks.Count);
|
||||
if (training.Count == 0)
|
||||
ProfilePanel.Header(queue, "Обучение (" + d.training.Count + " / " + Barracks.QueueSize + ") • казарм: " + Barracks.Count);
|
||||
if (d.training.Count == 0)
|
||||
{
|
||||
UIKit.Text(queue, "Никто не обучается. Выберите войска ниже.", 26, UIKit.Theme.TextDim);
|
||||
}
|
||||
else
|
||||
{
|
||||
foreach (var o in training)
|
||||
foreach (var o in d.training)
|
||||
{
|
||||
var def = GameData.Troops[o.Type];
|
||||
var def = GameData.Troops[o.troopIndex];
|
||||
var r = UIKit.Row(queue, 44, 10);
|
||||
var n = UIKit.Text(r, def.name + " × " + o.Count, 28);
|
||||
var n = UIKit.Text(r, def.name + " × " + o.count, 28);
|
||||
UIKit.Size(n, -1, -1, 1);
|
||||
var t = UIKit.Text(r, "", 26, UIKit.Theme.Gold, TextAlignmentOptions.Right);
|
||||
UIKit.Size(t, 180);
|
||||
@@ -126,7 +82,7 @@ public class TroopsPanel : BasePanel
|
||||
|
||||
if (!unlocked)
|
||||
{
|
||||
UIKit.Text(card, "Откроется: " + GP.TroopLockText(i), 26, UIKit.Theme.Gold, TextAlignmentOptions.Center);
|
||||
UIKit.Text(card, "Откроется на Ратуше " + def.requiredTownHall + " уровня", 26, UIKit.Theme.Gold, TextAlignmentOptions.Center);
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -134,32 +90,23 @@ public class TroopsPanel : BasePanel
|
||||
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;
|
||||
bool can = Barracks.Count > 0 && GP.CanAfford(def.cost, a) && d.training.Count < Barracks.QueueSize;
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Окно перестраивается само: Армия публикует TroopsChangedEvent, GameProgress сообщает об изменении
|
||||
static void GarrisonButton(Transform row, IArmy army, string label, int target)
|
||||
{
|
||||
target = Mathf.Clamp(target, 0, army.SoldiersAtHome);
|
||||
bool can = target != army.GarrisonTotal;
|
||||
var b = UIKit.Button(row, label, can ? UIKit.Theme.Blue : UIKit.Theme.Gray, () => { if (can) army.SetGarrison(target); }, -1, 80, 28);
|
||||
UIKit.Size(b, -1, 80, 1);
|
||||
}
|
||||
|
||||
protected override void Tick()
|
||||
{
|
||||
var training = GP.Training;
|
||||
for (int i = 0; i < queueBars.Count && i < training.Count; i++)
|
||||
var d = GP.Data;
|
||||
for (int i = 0; i < queueBars.Count && i < d.training.Count; i++)
|
||||
{
|
||||
var o = training[i];
|
||||
var o = d.training[i];
|
||||
UIKit.SetBar(queueBars[i], GP.TrainingProgress(o));
|
||||
queueTimes[i].text = GameData.FormatTime(GP.TrainingSecondsLeft(o));
|
||||
}
|
||||
if (speedUp != null && training.Count > 0)
|
||||
if (speedUp != null && d.training.Count > 0)
|
||||
UIKit.SetButton(speedUp, "Ускорить всё за " + GP.SpeedUpCost + " кристаллов", true);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -64,23 +64,11 @@ public abstract class BasePanel : MonoBehaviour
|
||||
t = UIKit.Stretch(UIKit.Make("Content", transform), 24, 130, 24, 24);
|
||||
}
|
||||
content = (RectTransform)t;
|
||||
WrapInSafeArea(content);
|
||||
}
|
||||
return content;
|
||||
}
|
||||
}
|
||||
|
||||
// Фон окна -- на весь экран, а содержимое -- в безопасной зоне: на iPhone левый край уходил под вырез камеры.
|
||||
// Content переезжает в растянутый контейнер с SafeArea и сохраняет свои отступы.
|
||||
void WrapInSafeArea(RectTransform rt)
|
||||
{
|
||||
if (rt.parent != transform) return; // уже обёрнут
|
||||
var safe = UIKit.Stretch(UIKit.Make("SafeArea", transform));
|
||||
safe.SetSiblingIndex(rt.GetSiblingIndex());
|
||||
safe.gameObject.AddComponent<SafeArea>().fitInsideParent = true; // окно -- не на весь экран
|
||||
rt.SetParent(safe, false);
|
||||
}
|
||||
|
||||
void Rebuild()
|
||||
{
|
||||
float pos = (scroll != null && !resetScroll) ? scroll.verticalNormalizedPosition : 1f;
|
||||
|
||||
@@ -8,39 +8,6 @@ using System.Text;
|
||||
//
|
||||
// Один солдат -- в одном месте: либо в городе (troops), либо в походе (marches).
|
||||
// Поход забирает воинов из города и запоминает состав, возвращение раздаёт выживших по тем же типам.
|
||||
// Гарнизон (garrison) -- часть воинов города: они в troops, но в поход не уходят. garrison[i] <= troops[i] всегда.
|
||||
// Очередь обучения (training) -- тоже здесь (решения по 8 вопросам Саши, 31.10, п. 8): заказы, сроки, завершение.
|
||||
// Раненые (решения 31.10, п. 7): ждут места (woundedWaiting) или лечатся (healing) -- не в troops и не в походах.
|
||||
// Числа госпиталя (регламент 31.10, ТЕСТ). В игре -- из таблицы HospitalConfig.
|
||||
public sealed class HospitalRules
|
||||
{
|
||||
public int TemporaryBeds = 5; // мест, пока здания госпиталя нет (решения 01.11, п. 3); после постройки -- штатные, не вместе
|
||||
public int[] HealGameHours = { 4, 4, 6, 8 }; // лечение одного воина: пехота и лучники 4, кавалерия 6, осадные 8 ИГРОВЫХ часов
|
||||
public long GameHourMs = 5 * 60 * 1000; // 1 игровой час = 5 реальных минут
|
||||
// Цена лечения одного воина по типам (решения 01.11, п. 3: 1 / 1 / 2 / 3 золота -- в таблице HospitalConfig); пусто -- без цены
|
||||
public ResourceAmount[][] HealCostPerSoldier = null;
|
||||
|
||||
public static HospitalRules Default => new HospitalRules();
|
||||
|
||||
public long HealMs(int type)
|
||||
{
|
||||
int hours = HealGameHours != null && type >= 0 && type < HealGameHours.Length ? Math.Max(0, HealGameHours[type]) : 4;
|
||||
return hours * GameHourMs;
|
||||
}
|
||||
}
|
||||
|
||||
// Пропавшие без вести (решения 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
|
||||
@@ -52,54 +19,8 @@ public sealed class ArmyService : IArmy, ISaveParticipant
|
||||
}
|
||||
|
||||
private readonly int[] troops = new int[Types];
|
||||
private readonly int[] garrison = new int[Types];
|
||||
private readonly List<MarchUnits> marches = new List<MarchUnits>();
|
||||
|
||||
private class TrainingOrder
|
||||
{
|
||||
public int id, type, count;
|
||||
public long startMs, endMs;
|
||||
public bool recruited; // под заказ взяты жители (Население). false -- старый заказ, людей не брали
|
||||
}
|
||||
|
||||
private class HealingBatch
|
||||
{
|
||||
public int id, type, count;
|
||||
public long startMs, endMs;
|
||||
}
|
||||
|
||||
private readonly int[] woundedWaiting = new int[Types];
|
||||
private readonly List<HealingBatch> healing = new List<HealingBatch>();
|
||||
private readonly List<HealingInfo> healingView = new List<HealingInfo>();
|
||||
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;
|
||||
|
||||
// Секция "army" была в сохранении -- старые войска из progress.json переносить не нужно
|
||||
public bool RestoredFromSave { get; private set; }
|
||||
|
||||
@@ -107,21 +28,6 @@ public sealed class ArmyService : IArmy, ISaveParticipant
|
||||
|
||||
public int TypeCount => Types;
|
||||
|
||||
// Слоты похода: база из таблицы MarchConfig (регламент 31.10: 2, ТЕСТ). Бонус «Военной логистики» -- позже.
|
||||
private readonly int baseMarchSlots;
|
||||
|
||||
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;
|
||||
|
||||
public int MarchSlots => baseMarchSlots;
|
||||
public int ActiveMarches => marches.Count;
|
||||
|
||||
public int GetTroops(int type)
|
||||
{
|
||||
return type >= 0 && type < Types ? troops[type] : 0;
|
||||
@@ -142,28 +48,6 @@ public sealed class ArmyService : IArmy, ISaveParticipant
|
||||
}
|
||||
}
|
||||
|
||||
public int GarrisonTotal
|
||||
{
|
||||
get { int n = 0; foreach (var g in garrison) n += g; return n; }
|
||||
}
|
||||
|
||||
public int GetGarrison(int type)
|
||||
{
|
||||
return type >= 0 && type < Types ? garrison[type] : 0;
|
||||
}
|
||||
|
||||
public int AvailableForMarch => SoldiersAtHome - GarrisonTotal;
|
||||
|
||||
// Гарнизон набирается по составу армии в городе: 30 пехоты и 10 лучников, гарнизон 8 -> 6 пехоты и 2 лучника
|
||||
public int SetGarrison(int count)
|
||||
{
|
||||
count = Math.Max(0, Math.Min(count, SoldiersAtHome));
|
||||
int[] units = count > 0 ? ArmyMath.TakeProportional(troops, count) : null;
|
||||
for (int i = 0; i < Types; i++) garrison[i] = units != null ? units[i] : 0;
|
||||
Changed();
|
||||
return count;
|
||||
}
|
||||
|
||||
public void AddTroops(int type, int count)
|
||||
{
|
||||
if (type < 0 || type >= Types || count <= 0) return;
|
||||
@@ -183,417 +67,41 @@ public sealed class ArmyService : IArmy, ISaveParticipant
|
||||
troops[i] -= l;
|
||||
lost += l;
|
||||
}
|
||||
ClampGarrison();
|
||||
if (lost > 0) Changed();
|
||||
return lost;
|
||||
}
|
||||
|
||||
public bool TakeForMarch(int marchId, int count)
|
||||
{
|
||||
// Поход с таким номером уже есть: перезапись потеряла бы его воинов
|
||||
if (marches.Exists(m => m.marchId == marchId)) return false;
|
||||
// Все слоты похода заняты. Походы из старых сохранений сверх лимита дойдут, но новых не будет
|
||||
if (marches.Count >= MarchSlots) return false;
|
||||
|
||||
// В поход -- только свободные воины, гарнизон остаётся в городе
|
||||
var free = new int[Types];
|
||||
for (int i = 0; i < Types; i++) free[i] = troops[i] - garrison[i];
|
||||
int[] taken = ArmyMath.TakeProportional(free, count);
|
||||
int[] taken = ArmyMath.TakeProportional(troops, count);
|
||||
if (taken == null) return false;
|
||||
|
||||
for (int i = 0; i < Types; i++) troops[i] -= taken[i];
|
||||
marches.RemoveAll(m => m.marchId == marchId);
|
||||
marches.Add(new MarchUnits { marchId = marchId, units = taken });
|
||||
Changed();
|
||||
return true;
|
||||
}
|
||||
|
||||
// Воины возвращаются только из известного похода, и только один раз: запись похода удаляется.
|
||||
// Неизвестный поход (повторное событие, испорченное сохранение) не добавляет никого -- иначе солдаты
|
||||
// появлялись бы из ниоткуда. Перенос старых сохранений -- отдельно, в ImportLegacy.
|
||||
public bool ReturnFromMarch(int marchId, int survivors)
|
||||
public void ReturnFromMarch(int marchId, int survivors)
|
||||
{
|
||||
MarchUnits entry = marches.Find(m => m.marchId == marchId);
|
||||
if (entry == null) return false;
|
||||
|
||||
int sent = 0;
|
||||
foreach (var u in entry.units) sent += u;
|
||||
int[] back = ArmyMath.SplitSurvivors(entry.units, Math.Min(survivors, sent)); // больше, чем ушло, не вернётся
|
||||
for (int i = 0; i < Types; i++) troops[i] += back[i];
|
||||
marches.Remove(entry);
|
||||
Changed();
|
||||
return true;
|
||||
}
|
||||
|
||||
public int[] GetMarchUnits(int marchId)
|
||||
{
|
||||
MarchUnits entry = marches.Find(m => m.marchId == marchId);
|
||||
return entry != null ? (int[])entry.units.Clone() : null;
|
||||
}
|
||||
|
||||
// Выжившие точно по типам: бой посчитал потери каждого типа. Больше, чем ушло, вернуться не может.
|
||||
// Правила те же, что у ReturnFromMarch: только известный поход, только один раз.
|
||||
public bool ReturnUnits(int marchId, int[] survivorUnits)
|
||||
{
|
||||
return ReturnUnits(marchId, survivorUnits, null, 0);
|
||||
}
|
||||
|
||||
// Выжившие -- в город, раненые -- в госпиталь (решения 31.10, п. 7). Остальные из ушедших погибли.
|
||||
// Один солдат в одном месте: выжившие + раненые не больше, чем ушло, по каждому типу.
|
||||
public bool ReturnUnits(int marchId, int[] survivorUnits, int[] woundedUnits, long nowMs)
|
||||
{
|
||||
MarchUnits entry = marches.Find(m => m.marchId == marchId);
|
||||
if (entry == null) return false;
|
||||
|
||||
bool anyWounded = false;
|
||||
if (woundedUnits != null) ProcessHealing(nowMs); // сначала освободить места, которые освободились раньше
|
||||
for (int i = 0; i < Types; i++)
|
||||
if (entry == null)
|
||||
{
|
||||
int alive = survivorUnits != null && i < survivorUnits.Length ? Math.Max(0, Math.Min(survivorUnits[i], entry.units[i])) : 0;
|
||||
int hurt = woundedUnits != null && i < woundedUnits.Length ? Math.Max(0, Math.Min(woundedUnits[i], entry.units[i] - alive)) : 0;
|
||||
troops[i] += alive;
|
||||
woundedWaiting[i] += hurt;
|
||||
if (hurt > 0) anyWounded = true;
|
||||
troops[0] += Math.Max(0, survivors); // состав неизвестен (очень старое сохранение) -- считаем пехотой
|
||||
}
|
||||
marches.Remove(entry);
|
||||
if (anyWounded) FillBeds(nowMs);
|
||||
Changed();
|
||||
return true;
|
||||
}
|
||||
|
||||
// ---------------- Раненые и лечение ----------------
|
||||
|
||||
public int WoundedTotal
|
||||
{
|
||||
get
|
||||
else
|
||||
{
|
||||
int n = 0;
|
||||
foreach (var w in woundedWaiting) n += w;
|
||||
foreach (var b in healing) n += b.count;
|
||||
return n;
|
||||
}
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
public IReadOnlyList<HealingInfo> Healing
|
||||
{
|
||||
get
|
||||
{
|
||||
healingView.Clear();
|
||||
foreach (var b in healing)
|
||||
healingView.Add(new HealingInfo { Id = b.id, Type = b.type, Count = b.count, StartMs = b.startMs, EndMs = b.endMs });
|
||||
return healingView;
|
||||
}
|
||||
}
|
||||
|
||||
// Места: от здания госпиталя (Поселение), пока его нет -- временные из таблицы
|
||||
public int HospitalBeds => GameServices.TryGet(out IHospital h) && h.Beds > 0 ? h.Beds : Math.Max(0, hospital.TemporaryBeds);
|
||||
|
||||
public int ProcessHealing(long nowMs)
|
||||
{
|
||||
int done = 0;
|
||||
// По времени: самая ранняя группа заканчивает -- на её место в тот же момент ложатся ждущие
|
||||
while (true)
|
||||
{
|
||||
HealingBatch next = null;
|
||||
foreach (var b in healing)
|
||||
if (b.endMs <= nowMs && (next == null || b.endMs < next.endMs || b.endMs == next.endMs && b.id < next.id)) next = b;
|
||||
if (next == null) break;
|
||||
|
||||
healing.Remove(next); // группа вылечена один раз: повторный расчёт её уже не найдёт
|
||||
troops[next.type] += next.count;
|
||||
done++;
|
||||
EventBus.Publish(new SoldiersHealedEvent { BatchId = next.id, Type = next.type, Count = next.count, TimeMs = next.endMs });
|
||||
FillBeds(next.endMs);
|
||||
}
|
||||
FillBeds(nowMs); // мест могло стать больше (госпиталь улучшили)
|
||||
if (done > 0) Changed();
|
||||
return done;
|
||||
}
|
||||
|
||||
// Положить ждущих на свободные места в момент atMs: по типам по порядку, каждая группа -- свой срок лечения.
|
||||
// Цена лечения (если задана) списывается через Экономику целиком; не хватает -- группа ждёт.
|
||||
void FillBeds(long atMs)
|
||||
{
|
||||
int free = HospitalBeds;
|
||||
foreach (var b in healing) free -= b.count;
|
||||
for (int type = 0; type < Types && free > 0; type++)
|
||||
{
|
||||
int n = Math.Min(free, woundedWaiting[type]);
|
||||
if (n <= 0) continue;
|
||||
if (!PayForHealing(type, n)) continue;
|
||||
woundedWaiting[type] -= n;
|
||||
free -= n;
|
||||
healing.Add(new HealingBatch { id = nextHealId++, type = type, count = n, startMs = atMs, endMs = atMs + hospital.HealMs(type) });
|
||||
}
|
||||
}
|
||||
|
||||
bool PayForHealing(int type, int soldiers)
|
||||
{
|
||||
var perSoldier = hospital.HealCostPerSoldier != null && type < hospital.HealCostPerSoldier.Length ? hospital.HealCostPerSoldier[type] : null;
|
||||
if (perSoldier == null || perSoldier.Length == 0) return true; // цена не утверждена -- без цены
|
||||
if (!GameServices.TryGet(out IEconomy economy)) return false;
|
||||
var cost = new List<ResourceAmount>();
|
||||
foreach (var c in perSoldier) if (c.Amount > 0) cost.Add(new ResourceAmount(c.Type, c.Amount * soldiers));
|
||||
return cost.Count == 0 || economy.TrySpend(cost);
|
||||
}
|
||||
|
||||
// ---------------- Очередь обучения ----------------
|
||||
|
||||
public IReadOnlyList<TrainingOrderInfo> Training
|
||||
{
|
||||
get
|
||||
{
|
||||
trainingView.Clear();
|
||||
foreach (var o in training)
|
||||
trainingView.Add(new TrainingOrderInfo { Id = o.id, Type = o.type, Count = o.count, StartMs = o.startMs, EndMs = o.endMs });
|
||||
return trainingView;
|
||||
}
|
||||
}
|
||||
|
||||
public bool CanQueueTraining(int type, int count, int queueSize, out string error)
|
||||
{
|
||||
error = null;
|
||||
if (type < 0 || type >= Types || count <= 0) error = "Неверный заказ";
|
||||
else if (training.Count >= queueSize) error = "Очередь обучения заполнена. Больше казарм -- длиннее очередь";
|
||||
else if (GameServices.TryGet(out IPopulation population) && population.Recruitable < count)
|
||||
error = population.Recruitable > 0
|
||||
? $"Жителей хватит только на {population.Recruitable} воинов"
|
||||
: $"Некого нанять: свободных жителей нет (резерв {population.CivilianReserve}). Снимите рабочих или стройте Дома";
|
||||
return error == null;
|
||||
}
|
||||
|
||||
public bool TryQueueTraining(int type, int count, long durationMs, int queueSize, long nowMs, out string error)
|
||||
{
|
||||
if (!CanQueueTraining(type, count, queueSize, out error)) return false;
|
||||
|
||||
// Воин -- житель города: люди уходят в Казармы сейчас, в Армию -- когда обучатся
|
||||
bool recruited = false;
|
||||
if (GameServices.TryGet(out IPopulation population))
|
||||
{
|
||||
if (!population.TryRecruit(count)) { error = "Некого нанять: жителей не хватает"; return false; }
|
||||
recruited = true;
|
||||
}
|
||||
|
||||
// Заказы идут друг за другом: новый начинается, когда закончится последний
|
||||
long start = training.Count > 0 ? Math.Max(nowMs, training[training.Count - 1].endMs) : nowMs;
|
||||
training.Add(new TrainingOrder
|
||||
{
|
||||
id = nextOrderId++, type = type, count = count,
|
||||
startMs = start, endMs = start + Math.Max(0, durationMs), recruited = recruited
|
||||
});
|
||||
Changed();
|
||||
return true;
|
||||
}
|
||||
|
||||
public int ProcessTraining(long nowMs)
|
||||
{
|
||||
int done = 0;
|
||||
while (training.Count > 0 && training[0].endMs <= nowMs)
|
||||
{
|
||||
TrainingOrder o = training[0];
|
||||
if (o.recruited)
|
||||
{
|
||||
// Новобранцы переходят из Населения в Армию одним действием (Население само зовёт AddTroops).
|
||||
// Населения ещё нет (запуск) -- подождём: заказ остаётся, никто не теряется.
|
||||
if (!GameServices.TryGet(out IPopulation population)) break;
|
||||
if (!population.CompleteTraining(o.type, o.count))
|
||||
{
|
||||
// Население не нашло этих новобранцев (испорченное сохранение): заказ снимается без воинов --
|
||||
// иначе люди появились бы из ниоткуда, а очередь встала бы навсегда
|
||||
training.RemoveAt(0);
|
||||
Changed();
|
||||
continue;
|
||||
}
|
||||
}
|
||||
else AddTroops(o.type, o.count); // старый заказ: людей под него не брали
|
||||
|
||||
training.RemoveAt(0); // заказ закончен один раз: повторный расчёт его уже не найдёт
|
||||
done++;
|
||||
EventBus.Publish(new TroopsTrainedEvent { OrderId = o.id, Type = o.type, Count = o.count, TimeMs = o.endMs });
|
||||
}
|
||||
return done;
|
||||
}
|
||||
|
||||
public int FinishAllTraining(long nowMs)
|
||||
{
|
||||
foreach (var o in training)
|
||||
{
|
||||
o.endMs = Math.Min(o.endMs, nowMs);
|
||||
o.startMs = Math.Min(o.startMs, o.endMs);
|
||||
}
|
||||
return ProcessTraining(nowMs);
|
||||
}
|
||||
|
||||
// Перенос очереди из старого progress.json (её вёл GameProgress). Время уже переведено в мс часов игры.
|
||||
public void ImportLegacyTraining(IEnumerable<(int type, int count, long startMs, long endMs, bool recruited)> orders)
|
||||
{
|
||||
if (orders == null) return;
|
||||
foreach (var o in orders)
|
||||
{
|
||||
if (o.type < 0 || o.type >= Types || o.count <= 0) continue;
|
||||
training.Add(new TrainingOrder
|
||||
{
|
||||
id = nextOrderId++, type = o.type, count = o.count,
|
||||
startMs = o.startMs, endMs = Math.Max(o.startMs, o.endMs), recruited = o.recruited
|
||||
});
|
||||
int[] back = ArmyMath.SplitSurvivors(entry.units, survivors);
|
||||
for (int i = 0; i < Types; i++) troops[i] += back[i];
|
||||
marches.Remove(entry);
|
||||
}
|
||||
Changed();
|
||||
}
|
||||
|
||||
void ClampGarrison()
|
||||
{
|
||||
for (int i = 0; i < Types; i++) garrison[i] = Math.Max(0, Math.Min(garrison[i], troops[i]));
|
||||
}
|
||||
|
||||
void Changed()
|
||||
{
|
||||
EventBus.Publish(new TroopsChangedEvent { AtHome = SoldiersAtHome, InMarches = TroopsInMarches, Garrison = GarrisonTotal, Wounded = WoundedTotal,
|
||||
Missing = MissingTotal });
|
||||
EventBus.Publish(new TroopsChangedEvent { AtHome = SoldiersAtHome, InMarches = TroopsInMarches });
|
||||
}
|
||||
|
||||
// ---------------- Перенос из старого progress.json ----------------
|
||||
@@ -604,14 +112,12 @@ public sealed class ArmyService : IArmy, ISaveParticipant
|
||||
{
|
||||
if (legacyTroops != null)
|
||||
for (int i = 0; i < Types && i < legacyTroops.Length; i++) troops[i] = Math.Max(0, legacyTroops[i]);
|
||||
Array.Clear(garrison, 0, Types);
|
||||
|
||||
marches.Clear();
|
||||
if (legacyMarches != null)
|
||||
{
|
||||
foreach (var m in legacyMarches)
|
||||
{
|
||||
if (marches.Exists(x => x.marchId == m.Key)) continue; // один поход -- одна запись
|
||||
var units = new int[Types];
|
||||
for (int i = 0; i < Types && m.Value != null && i < m.Value.Length; i++) units[i] = Math.Max(0, m.Value[i]);
|
||||
marches.Add(new MarchUnits { marchId = m.Key, units = units });
|
||||
@@ -623,16 +129,7 @@ public sealed class ArmyService : IArmy, ISaveParticipant
|
||||
// ---------------- Сохранение (секция "army") ----------------
|
||||
// Модуль без Unity, поэтому формат -- простые строки:
|
||||
// troops=<пехота>,<лучники>,<кавалерия>,<осадные>
|
||||
// garrison=<пехота>,<лучники>,<кавалерия>,<осадные> (из troops; нет строки -- гарнизона нет)
|
||||
// march=<id похода>:<пехота>,<лучники>,<кавалерия>,<осадные>
|
||||
// train=<id>|<тип>|<сколько>|<начало, мс>|<конец, мс>|<люди взяты 1/0> (по порядку очереди)
|
||||
// trainNext=<номер следующего заказа>
|
||||
// wounded=<пехота>,<лучники>,<кавалерия>,<осадные> (ждут места в госпитале)
|
||||
// heal=<id>|<тип>|<сколько>|<начало, мс>|<конец, мс>
|
||||
// healNext=<номер следующей группы лечения>
|
||||
// missing=<id>|<поход>|<окно с, мс>|<конец, мс>|<осталось по типам через ,>
|
||||
// mgroup=<id пропавших>|<номер группы>|<время, мс>|<здоровые по типам>|<раненые по типам> (ещё не вернувшиеся группы)
|
||||
// missingMarch=<поход> (по нему пропавшие уже отмечены) missingNext=<номер>
|
||||
|
||||
public string SaveKey => "army";
|
||||
|
||||
@@ -640,53 +137,15 @@ public sealed class ArmyService : IArmy, ISaveParticipant
|
||||
{
|
||||
var sb = new StringBuilder();
|
||||
sb.Append("troops=").Append(Join(troops)).Append('\n');
|
||||
sb.Append("garrison=").Append(Join(garrison)).Append('\n');
|
||||
foreach (var m in marches)
|
||||
sb.Append("march=").Append(m.marchId.ToString(CultureInfo.InvariantCulture)).Append(':').Append(Join(m.units)).Append('\n');
|
||||
foreach (var o in training)
|
||||
sb.Append("train=").Append(string.Join("|", new[]
|
||||
{
|
||||
N(o.id), N(o.type), N(o.count),
|
||||
o.startMs.ToString(CultureInfo.InvariantCulture), o.endMs.ToString(CultureInfo.InvariantCulture), o.recruited ? "1" : "0"
|
||||
})).Append('\n');
|
||||
sb.Append("trainNext=").Append(N(nextOrderId)).Append('\n');
|
||||
sb.Append("wounded=").Append(Join(woundedWaiting)).Append('\n');
|
||||
foreach (var b in healing)
|
||||
sb.Append("heal=").Append(string.Join("|", new[]
|
||||
{
|
||||
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();
|
||||
}
|
||||
|
||||
public void Restore(string data)
|
||||
{
|
||||
Array.Clear(troops, 0, Types);
|
||||
Array.Clear(garrison, 0, Types);
|
||||
marches.Clear();
|
||||
training.Clear();
|
||||
nextOrderId = 1;
|
||||
Array.Clear(woundedWaiting, 0, Types);
|
||||
healing.Clear();
|
||||
nextHealId = 1;
|
||||
missing.Clear();
|
||||
missingMarches.Clear();
|
||||
nextMissingId = 1;
|
||||
RestoredFromSave = true;
|
||||
|
||||
if (!string.IsNullOrEmpty(data))
|
||||
@@ -699,112 +158,25 @@ public sealed class ArmyService : IArmy, ISaveParticipant
|
||||
int[] t = Parse(line.Substring("troops=".Length));
|
||||
Array.Copy(t, troops, Types);
|
||||
}
|
||||
else if (line.StartsWith("garrison="))
|
||||
{
|
||||
int[] g = Parse(line.Substring("garrison=".Length));
|
||||
Array.Copy(g, garrison, Types);
|
||||
}
|
||||
else if (line.StartsWith("march="))
|
||||
{
|
||||
string[] parts = line.Substring("march=".Length).Split(':');
|
||||
if (parts.Length == 2 && int.TryParse(parts[0], NumberStyles.Integer, CultureInfo.InvariantCulture, out int id)
|
||||
&& !marches.Exists(x => x.marchId == id)) // один поход -- одна запись
|
||||
if (parts.Length == 2 && int.TryParse(parts[0], NumberStyles.Integer, CultureInfo.InvariantCulture, out int id))
|
||||
marches.Add(new MarchUnits { marchId = id, units = Parse(parts[1]) });
|
||||
}
|
||||
else if (line.StartsWith("train="))
|
||||
{
|
||||
string[] p = line.Substring("train=".Length).Split('|');
|
||||
if (p.Length == 6 && int.TryParse(p[0], NumberStyles.Integer, CultureInfo.InvariantCulture, out int oid)
|
||||
&& int.TryParse(p[1], NumberStyles.Integer, CultureInfo.InvariantCulture, out int type) && type >= 0 && type < Types
|
||||
&& int.TryParse(p[2], NumberStyles.Integer, CultureInfo.InvariantCulture, out int count) && count > 0
|
||||
&& long.TryParse(p[3], NumberStyles.Integer, CultureInfo.InvariantCulture, out long start)
|
||||
&& long.TryParse(p[4], NumberStyles.Integer, CultureInfo.InvariantCulture, out long end)
|
||||
&& !training.Exists(x => x.id == oid)) // один заказ -- одна запись
|
||||
{
|
||||
training.Add(new TrainingOrder { id = oid, type = type, count = count, startMs = start, endMs = end, recruited = p[5] == "1" });
|
||||
nextOrderId = Math.Max(nextOrderId, oid + 1);
|
||||
}
|
||||
}
|
||||
else if (line.StartsWith("wounded="))
|
||||
{
|
||||
int[] w = Parse(line.Substring("wounded=".Length));
|
||||
Array.Copy(w, woundedWaiting, Types);
|
||||
}
|
||||
else if (line.StartsWith("heal="))
|
||||
{
|
||||
string[] p = line.Substring("heal=".Length).Split('|');
|
||||
if (p.Length == 5 && int.TryParse(p[0], NumberStyles.Integer, CultureInfo.InvariantCulture, out int hid)
|
||||
&& int.TryParse(p[1], NumberStyles.Integer, CultureInfo.InvariantCulture, out int htype) && htype >= 0 && htype < Types
|
||||
&& int.TryParse(p[2], NumberStyles.Integer, CultureInfo.InvariantCulture, out int hcount) && hcount > 0
|
||||
&& long.TryParse(p[3], NumberStyles.Integer, CultureInfo.InvariantCulture, out long hstart)
|
||||
&& long.TryParse(p[4], NumberStyles.Integer, CultureInfo.InvariantCulture, out long hend)
|
||||
&& !healing.Exists(x => x.id == hid))
|
||||
{
|
||||
healing.Add(new HealingBatch { id = hid, type = htype, count = hcount, startMs = hstart, endMs = hend });
|
||||
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);
|
||||
else if (line.StartsWith("trainNext=")
|
||||
&& int.TryParse(line.Substring("trainNext=".Length), NumberStyles.Integer, CultureInfo.InvariantCulture, out int next))
|
||||
nextOrderId = Math.Max(nextOrderId, next);
|
||||
}
|
||||
}
|
||||
ClampGarrison(); // гарнизон не больше войск, даже если файл правили руками
|
||||
Changed();
|
||||
}
|
||||
|
||||
// Новая игра: войск нет, гарнизона и походов нет
|
||||
// Новая игра: войск нет, походов нет
|
||||
public void ResetState()
|
||||
{
|
||||
Array.Clear(troops, 0, Types);
|
||||
Array.Clear(garrison, 0, Types);
|
||||
marches.Clear();
|
||||
training.Clear();
|
||||
nextOrderId = 1;
|
||||
Array.Clear(woundedWaiting, 0, Types);
|
||||
healing.Clear();
|
||||
nextHealId = 1;
|
||||
missing.Clear();
|
||||
missingMarches.Clear();
|
||||
nextMissingId = 1;
|
||||
Changed();
|
||||
}
|
||||
|
||||
static string N(int v) => v.ToString(CultureInfo.InvariantCulture);
|
||||
|
||||
static string Join(int[] values)
|
||||
{
|
||||
var parts = new string[values.Length];
|
||||
|
||||
@@ -1,53 +0,0 @@
|
||||
// ПРАВИЛА БОЯ: все числа боевой системы в одном месте. Документ: Docs/Modules/Battle.md
|
||||
// Без Unity. В игре значения приходят из таблицы App/BattleConfig (Assets/Resources/BattleConfig.asset),
|
||||
// App/GameModules переводит её в BattleRules. Без таблицы (тесты, сервер) -- значения по умолчанию ниже.
|
||||
//
|
||||
// Числа -- стартовые для тестирования (план от 27.10.2026). Изменил правила -- подними Version:
|
||||
// итог боя запоминает версию, по которой посчитан.
|
||||
public sealed class BattleRules
|
||||
{
|
||||
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);
|
||||
// 8 -- пропавшие без вести (п. 5)
|
||||
|
||||
// Сила одного воина каждого типа. В игре -- из GameData.Troops (там же цена и время обучения).
|
||||
public int[] UnitPower = { 4, 5, 9, 16 };
|
||||
|
||||
public int CounterPercent = 25; // контра: пехота > кавалерия > лучники > пехота
|
||||
public int WallsPercent = 20; // защитникам за стенами
|
||||
public int SiegeVsWallsPercent = 50; // осадным при штурме стен
|
||||
public int SiegeInFieldPercent = -25; // осадным в поле и в обороне
|
||||
public int RandomPercent = 5; // случайность каждой стороны: от -5% до +5%
|
||||
|
||||
// Кривая потерь (решение №14, регламент 31.10, ТЕСТ): доля выбывших от каждого типа в зависимости от
|
||||
// соотношения итоговых сил (сильная / слабая), в десятых долях процента. Между опорными точками -- плавно
|
||||
// (линейно), без порогов-переключателей.
|
||||
// Разгром (решения 31.10, п. 5): 7:1 -- 92%, 10:1 -- 95%, дальше слабая сторона плавно стремится к WeakLossMaxPermille (99%):
|
||||
// v = max - (max - v_last) * R_last / R -- при 20:1 97%, при 100:1 98,6%; 100% не достигается.
|
||||
// Сильная сторона (решения по отчёту Саши, 01.11, п. 1): 5:1 -- 11%, плавно до 2% при 10:1, дальше 2%.
|
||||
public int[] LossRatioPermille = { 1000, 3000, 5000, 7000, 10000 }; // 1:1, 3:1, 5:1, 7:1, 10:1
|
||||
public int[] StrongLossPermille = { 450, 160, 110, 74, 20 }; // сильная сторона: 45%, 16%, 11%, 7,4%, 2%
|
||||
public int[] WeakLossPermille = { 450, 740, 880, 920, 950 }; // слабая сторона: 45%, 74%, 88%, 92%, 95%
|
||||
public int WeakLossMaxPermille = 990; // верхний предел ожидаемых потерь слабой стороны: 99%
|
||||
|
||||
// Погибшие и раненые (регламент 31.10, ТЕСТ; решения 31.10, п. 7): из выбывших стороны погибает доля,
|
||||
// зависящая от её соотношения сил (своя / чужая), остальные -- раненые. Ориентиры регламента:
|
||||
// разгром 1:5 -- ~70%, поражение 1:2 -- ~45%, равный бой -- ~30%, уверенная победа 3:1 -- ~20%.
|
||||
// Между точками -- плавно (линейно), за крайними -- значения крайних. Округление -- отдельным броском.
|
||||
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%
|
||||
|
||||
public static BattleRules Default => new BattleRules();
|
||||
|
||||
// Без случайности: для тестов, где важен точный итог
|
||||
public static BattleRules NoRandom => new BattleRules { RandomPercent = 0 };
|
||||
}
|
||||
@@ -1,11 +0,0 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 6a836fd9caa34954b185ca38b4d89afb
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -1,424 +1,26 @@
|
||||
using System;
|
||||
using System.Numerics;
|
||||
|
||||
// МОДУЛЬ БОЕВОЙ СИСТЕМЫ: урон, потери, исход. Документ: Docs/Modules/Battle.md
|
||||
// Без Unity и без float: этот же код без изменений сможет считать бои на сервере.
|
||||
//
|
||||
// Правила (стартовые числа -- в BattleRules; решения №11-13 комплекта требований от 29.10.2026):
|
||||
// - сила стороны = сумма по типам: воины x сила типа x (100% + контры + осадный бонус)
|
||||
// x (100% + бонусы стороны) x состояния (голод, усталость) x (100% + удача);
|
||||
// - контры: пехота сильнее кавалерии, кавалерия сильнее лучников, лучники сильнее пехоты;
|
||||
// бонус типа против врага взвешивается по составу врага;
|
||||
// - осадные: против стен сильнее, в поле слабее; стены усиливают защитников;
|
||||
// - удача: каждой стороне от -5% до +5%, ПОСЛЕ всех модификаторов, из Seed -- тот же Seed даёт тот же итог (№12);
|
||||
// - сила считается точной дробью (BigInteger) и округляется один раз -- только для показа (№12);
|
||||
// - побеждает тот, у кого итоговая сила больше; ничья -- защитнику;
|
||||
// - потери (№14): доля выбывших каждого типа -- плавная функция соотношения итоговых сил (сильная / слабая)
|
||||
// по опорным точкам BattleRules: 1:1 -- 45% / 45%, 3:1 -- 16% / 74%, 5:1 -- 11% / 88%. Одинаково для PvE, PvP и Круга;
|
||||
// - округление потерь: целая часть гарантирована, дробная -- шанс ещё одной потери,
|
||||
// из отдельного потока случайности (№13). Раненых пока нет, все выбывшие -- погибшие.
|
||||
// Правила пока самые простые: побеждает тот, у кого больше солдат.
|
||||
// Потом сюда добавятся типы войск, герой, технологии -- остальные модули менять не придётся.
|
||||
public sealed class BattleSystem : IBattleSystem
|
||||
{
|
||||
// Точная неотрицательная дробь: сила x100 = N / D
|
||||
struct Fraction
|
||||
{
|
||||
public BigInteger N, D;
|
||||
public long Floor => (long)(N / D);
|
||||
}
|
||||
|
||||
enum Rounding { Roll, Floor, Ceiling } // бой -- бросок по №13; прогноз -- лучший и худший случай
|
||||
|
||||
private readonly BattleRules rules;
|
||||
private readonly int[] power;
|
||||
private readonly int[] lossRatio, strongLoss, weakLoss; // кривая потерь (проверенная копия из правил)
|
||||
private readonly int[] deadRatio, deadShare; // доля погибших среди выбывших (проверенная копия)
|
||||
|
||||
public BattleRules Rules => rules;
|
||||
|
||||
public BattleSystem() : this(BattleRules.Default) { }
|
||||
|
||||
public BattleSystem(BattleRules rules)
|
||||
{
|
||||
this.rules = rules ?? BattleRules.Default;
|
||||
power = new int[TroopType.Count];
|
||||
var defaults = BattleRules.Default.UnitPower;
|
||||
for (int i = 0; i < TroopType.Count; i++)
|
||||
power[i] = this.rules.UnitPower != null && i < this.rules.UnitPower.Length && this.rules.UnitPower[i] > 0
|
||||
? this.rules.UnitPower[i] : defaults[i];
|
||||
|
||||
// Кривая из таблицы, если она корректна, иначе -- значения по умолчанию (ошибка в Inspector не ломает бой)
|
||||
var r = this.rules;
|
||||
bool ok = r.LossRatioPermille != null && r.StrongLossPermille != null && r.WeakLossPermille != null
|
||||
&& r.LossRatioPermille.Length > 0
|
||||
&& r.LossRatioPermille.Length == r.StrongLossPermille.Length
|
||||
&& r.LossRatioPermille.Length == r.WeakLossPermille.Length
|
||||
&& r.LossRatioPermille[0] == 1000;
|
||||
for (int i = 0; ok && i < r.LossRatioPermille.Length; i++)
|
||||
{
|
||||
ok = r.StrongLossPermille[i] >= 0 && r.StrongLossPermille[i] <= 1000 && r.WeakLossPermille[i] >= 0 && r.WeakLossPermille[i] <= 1000
|
||||
&& (i == 0 || r.LossRatioPermille[i] > r.LossRatioPermille[i - 1]);
|
||||
}
|
||||
var src = ok ? r : BattleRules.Default;
|
||||
lossRatio = (int[])src.LossRatioPermille.Clone();
|
||||
strongLoss = (int[])src.StrongLossPermille.Clone();
|
||||
weakLoss = (int[])src.WeakLossPermille.Clone();
|
||||
// Доля погибших: та же проверка -- ошибка в таблице не ломает бой
|
||||
bool deadOk = r.DeadRatioPermille != null && r.DeadSharePermille != null && r.DeadRatioPermille.Length > 0
|
||||
&& r.DeadRatioPermille.Length == r.DeadSharePermille.Length;
|
||||
for (int i = 0; deadOk && i < r.DeadRatioPermille.Length; i++)
|
||||
deadOk = r.DeadRatioPermille[i] > 0 && r.DeadSharePermille[i] >= 0 && r.DeadSharePermille[i] <= 1000
|
||||
&& (i == 0 || r.DeadRatioPermille[i] > r.DeadRatioPermille[i - 1]);
|
||||
var deadSrc = deadOk ? r : BattleRules.Default;
|
||||
deadRatio = (int[])deadSrc.DeadRatioPermille.Clone();
|
||||
deadShare = (int[])deadSrc.DeadSharePermille.Clone();
|
||||
|
||||
// Предел не ниже последней точки и не выше 100%
|
||||
weakLossMax = Math.Min(1000, Math.Max(weakLoss[weakLoss.Length - 1], r.WeakLossMaxPermille));
|
||||
}
|
||||
|
||||
private int weakLossMax;
|
||||
|
||||
// Доля выбывших стороны (в десятых долях процента, дробью) при соотношении сил stronger / weaker (решение №14)
|
||||
void LossShare(Fraction weaker, Fraction stronger, bool isWeak, out BigInteger num, out BigInteger den)
|
||||
{
|
||||
int[] values = isWeak ? weakLoss : strongLoss;
|
||||
int last = lossRatio.Length - 1;
|
||||
if (weaker.N.IsZero)
|
||||
{
|
||||
// У слабой стороны нулевая сила: сильная не теряет никого, слабая -- предел разгрома
|
||||
num = isWeak ? weakLossMax : 0;
|
||||
den = 1;
|
||||
return;
|
||||
}
|
||||
|
||||
// Соотношение сил в десятых долях процента: R = 1000 * stronger / weaker = rn / rd
|
||||
BigInteger rn = 1000 * stronger.N * weaker.D;
|
||||
BigInteger rd = stronger.D * weaker.N;
|
||||
if (rn <= lossRatio[0] * rd) { num = values[0]; den = 1; return; }
|
||||
if (rn >= lossRatio[last] * rd)
|
||||
{
|
||||
if (!isWeak) { num = values[last]; den = 1; return; }
|
||||
// Дальше последней точки слабая сторона плавно стремится к пределу: v = max - (max - v_last) * R_last / R
|
||||
num = (BigInteger)weakLossMax * rn - (BigInteger)(weakLossMax - values[last]) * lossRatio[last] * rd;
|
||||
den = rn;
|
||||
return;
|
||||
}
|
||||
|
||||
int i = 0;
|
||||
while (rn >= lossRatio[i + 1] * rd) i++; // lossRatio[i] <= R < lossRatio[i + 1]
|
||||
long span = lossRatio[i + 1] - lossRatio[i];
|
||||
// v = v_i + (v_{i+1} - v_i) * (R - p_i) / span -- точной дробью
|
||||
num = (BigInteger)values[i] * span * rd + (BigInteger)(values[i + 1] - values[i]) * (rn - lossRatio[i] * rd);
|
||||
den = span * rd;
|
||||
}
|
||||
|
||||
// Контра: насколько тип attackerType сильнее против defenderType, в процентах
|
||||
public int Counter(int attackerType, int defenderType)
|
||||
{
|
||||
if (attackerType == TroopType.Infantry && defenderType == TroopType.Cavalry) return rules.CounterPercent;
|
||||
if (attackerType == TroopType.Cavalry && defenderType == TroopType.Archers) return rules.CounterPercent;
|
||||
if (attackerType == TroopType.Archers && defenderType == TroopType.Infantry) return rules.CounterPercent;
|
||||
return 0;
|
||||
}
|
||||
|
||||
public BattleOutcome Resolve(BattleRequest request)
|
||||
{
|
||||
return Fight(request, Luck(request.Seed, 1), Luck(request.Seed, 2), Rounding.Roll);
|
||||
}
|
||||
int attackers = request.Attackers;
|
||||
int defenders = request.Defenders;
|
||||
bool victory = attackers > defenders; // ничья -- в пользу защитника
|
||||
|
||||
// Прогноз (№11): качественный риск и точный шанс победы. Итог конкретного боя не раскрывается.
|
||||
public BattleForecast Forecast(BattleRequest request)
|
||||
{
|
||||
int r = Math.Max(0, rules.RandomPercent * 10);
|
||||
var f = new BattleForecast
|
||||
{
|
||||
AttackerPowerMin = long.MaxValue, DefenderPowerMin = long.MaxValue,
|
||||
AttackerLossesMin = int.MaxValue
|
||||
};
|
||||
foreach (int attLuck in new[] { -r, r })
|
||||
foreach (int defLuck in new[] { -r, r })
|
||||
{
|
||||
BattleOutcome best = Fight(request, attLuck, defLuck, Rounding.Floor);
|
||||
BattleOutcome worst = Fight(request, attLuck, defLuck, Rounding.Ceiling);
|
||||
f.AttackerPowerMin = Math.Min(f.AttackerPowerMin, best.AttackerPower);
|
||||
f.AttackerPowerMax = Math.Max(f.AttackerPowerMax, best.AttackerPower);
|
||||
f.DefenderPowerMin = Math.Min(f.DefenderPowerMin, best.DefenderPower);
|
||||
f.DefenderPowerMax = Math.Max(f.DefenderPowerMax, best.DefenderPower);
|
||||
f.AttackerLossesMin = Math.Min(f.AttackerLossesMin, best.AttackerLosses);
|
||||
f.AttackerLossesMax = Math.Max(f.AttackerLossesMax, worst.AttackerLosses);
|
||||
}
|
||||
|
||||
// Шанс победы: все сочетания удачи сторон равновероятны -- считаем, при скольких нападающий сильнее
|
||||
Sides(request, out Fraction att, out Fraction def, out _, out _, out _, out _);
|
||||
BigInteger x = att.N * def.D, y = def.N * att.D; // нападающий побеждает, если x * (1000 + a) > y * (1000 + d)
|
||||
long wins = 0, total = (long)(2 * r + 1) * (2 * r + 1);
|
||||
for (int a = -r; a <= r; a++)
|
||||
{
|
||||
BigInteger t = x * (1000 + a);
|
||||
if (y.IsZero) { if (t > 0) wins += 2 * r + 1; continue; }
|
||||
// нужно y * (1000 + d) < t, то есть 1000 + d <= ceil(t / y) - 1
|
||||
BigInteger dMax = (t + y - 1) / y - 1 - 1000;
|
||||
if (dMax < -r) continue;
|
||||
wins += dMax >= r ? 2 * r + 1 : (long)dMax + r + 1;
|
||||
}
|
||||
f.WinChancePermille = (int)((wins * 1000 + total / 2) / total);
|
||||
f.Chance = wins == total ? BattleChance.Advantage : wins == 0 ? BattleChance.HighRisk : BattleChance.Uncertain;
|
||||
return f;
|
||||
}
|
||||
|
||||
BattleOutcome Fight(BattleRequest request, int attLuckPermille, int defLuckPermille, Rounding rounding)
|
||||
{
|
||||
Sides(request, out Fraction attBase, out Fraction defBase, out int[] att, out int[] def,
|
||||
out int attCondition, out int defCondition);
|
||||
Fraction attPower = WithLuck(attBase, attLuckPermille);
|
||||
Fraction defPower = WithLuck(defBase, defLuckPermille);
|
||||
|
||||
bool victory = attPower.N * defPower.D > defPower.N * attPower.D; // ничья -- в пользу защитника
|
||||
|
||||
// Потери по кривой (№14): победитель -- сильная сторона, проигравший -- слабая; при ничьей соотношение 1:1
|
||||
Fraction weaker = victory ? defPower : attPower;
|
||||
Fraction stronger = victory ? attPower : defPower;
|
||||
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);
|
||||
|
||||
// Выбывшие: сначала пропавшие без вести (решения 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] = attRest[i] - attDead[i];
|
||||
defWounded[i] = defRest[i] - defDead[i];
|
||||
}
|
||||
|
||||
var attAlive = new int[TroopType.Count];
|
||||
var defAlive = new int[TroopType.Count];
|
||||
var defOut = new int[TroopType.Count];
|
||||
for (int i = 0; i < TroopType.Count; i++)
|
||||
{
|
||||
attAlive[i] = att[i] - attOut[i];
|
||||
defAlive[i] = def[i] - defOut0[i];
|
||||
defOut[i] = defOut0[i];
|
||||
}
|
||||
// Проигравший теряет половину своего войска, победитель -- столько же
|
||||
int loserArmy = victory ? defenders : attackers;
|
||||
int losses = (loserArmy + 1) / 2;
|
||||
|
||||
return new BattleOutcome
|
||||
{
|
||||
Victory = victory,
|
||||
BattleId = request.Seed,
|
||||
RulesVersion = rules.Version,
|
||||
AttackerLosses = Sum(attOut),
|
||||
DefenderLosses = Sum(defOut),
|
||||
Survivors = Sum(attAlive),
|
||||
AttackerPower = attPower.Floor, // округление -- только для показа
|
||||
DefenderPower = defPower.Floor,
|
||||
AttackerLuckPermille = attLuckPermille,
|
||||
DefenderLuckPermille = defLuckPermille,
|
||||
AttackerConditionPercent = attCondition,
|
||||
DefenderConditionPercent = defCondition,
|
||||
AttackerUnitsBefore = att,
|
||||
DefenderUnitsBefore = def,
|
||||
AttackerSurvivorUnits = attAlive,
|
||||
DefenderSurvivorUnits = defAlive,
|
||||
AttackerDeadUnits = attDead,
|
||||
DefenderDeadUnits = defDead,
|
||||
AttackerWoundedUnits = attWounded,
|
||||
DefenderWoundedUnits = defWounded,
|
||||
AttackerMissingUnits = attMissing,
|
||||
DefenderMissingUnits = defMissing,
|
||||
DefenderLossUnits = defOut
|
||||
AttackerLosses = losses,
|
||||
DefenderLosses = losses,
|
||||
Survivors = attackers - losses
|
||||
};
|
||||
}
|
||||
|
||||
// Силы сторон без удачи (точные дроби) и состав сторон
|
||||
void Sides(BattleRequest request, out Fraction attPower, out Fraction defPower, out int[] att, out int[] def,
|
||||
out int attCondition, out int defCondition)
|
||||
{
|
||||
att = Units(request.AttackerUnits, request.Attackers);
|
||||
def = Units(request.DefenderUnits, request.Defenders);
|
||||
attPower = SidePower(att, def, attacking: true, request.DefenderFortified, request.AttackerBonusPercent,
|
||||
request.AttackerConditions, out attCondition);
|
||||
defPower = SidePower(def, att, attacking: false, request.DefenderFortified,
|
||||
request.DefenderBonusPercent + (request.DefenderFortified ? rules.WallsPercent : 0),
|
||||
request.DefenderConditions, out defCondition);
|
||||
}
|
||||
|
||||
// Сколько силы оставляют состояния стороны, в процентах (для показа). Штрафы -- последовательно: голод и усталость -- 40%
|
||||
public int ConditionPercent(BattleCondition conditions)
|
||||
{
|
||||
ConditionFactor(conditions, out BigInteger num, out BigInteger den);
|
||||
return (int)(num * 100 / den);
|
||||
}
|
||||
|
||||
void ConditionFactor(BattleCondition conditions, out BigInteger num, out BigInteger den)
|
||||
{
|
||||
num = 1; den = 1;
|
||||
if ((conditions & BattleCondition.Famine) != 0) { num *= Math.Max(0, rules.FamineStrengthPercent); den *= 100; }
|
||||
if ((conditions & BattleCondition.Fatigue) != 0) { num *= Math.Max(0, rules.FatigueStrengthPercent); den *= 100; }
|
||||
}
|
||||
|
||||
// Сила стороны x100 точной дробью. Порядок: контры и осадные -> бонусы стороны -> состояния; удача -- в WithLuck.
|
||||
// Контра по составу врага (counterSum / enemyTotal) остаётся дробью -- без промежуточного округления (№12).
|
||||
Fraction SidePower(int[] units, int[] enemy, bool attacking, bool fortified, int bonusPercent,
|
||||
BattleCondition conditions, out int conditionPercent)
|
||||
{
|
||||
int enemyTotal = Sum(enemy);
|
||||
int e = Math.Max(1, enemyTotal);
|
||||
BigInteger raw = 0;
|
||||
for (int i = 0; i < TroopType.Count; i++)
|
||||
{
|
||||
if (units[i] <= 0) continue;
|
||||
|
||||
long counterSum = 0;
|
||||
if (enemyTotal > 0)
|
||||
for (int j = 0; j < TroopType.Count; j++) counterSum += (long)Counter(i, j) * enemy[j];
|
||||
|
||||
int siegePercent = 0;
|
||||
if (i == TroopType.Siege) siegePercent = attacking && fortified ? rules.SiegeVsWallsPercent : rules.SiegeInFieldPercent;
|
||||
|
||||
// воины x сила x (100 + осадный% + контра%), где контра% = counterSum / e
|
||||
BigInteger term = (BigInteger)units[i] * power[i] * ((100L + siegePercent) * e + counterSum);
|
||||
if (term > 0) raw += term;
|
||||
}
|
||||
|
||||
ConditionFactor(conditions, out BigInteger condNum, out BigInteger condDen);
|
||||
conditionPercent = (int)(condNum * 100 / condDen);
|
||||
return new Fraction
|
||||
{
|
||||
N = raw * Math.Max(0, 100 + bonusPercent) * condNum,
|
||||
D = (BigInteger)e * 100 * condDen
|
||||
};
|
||||
}
|
||||
|
||||
static Fraction WithLuck(Fraction power, int luckPermille)
|
||||
{
|
||||
return new Fraction { N = power.N * (1000 + luckPermille), D = power.D * 1000 };
|
||||
}
|
||||
|
||||
// Удача стороны в десятых долях процента: от -RandomPercent*10 до +RandomPercent*10.
|
||||
// Считается из Seed (SplitMix64) -- одинаково на любой платформе, System.Random для этого не годится.
|
||||
public int Luck(long seed, int side)
|
||||
{
|
||||
int range = rules.RandomPercent * 10;
|
||||
if (range <= 0) return 0;
|
||||
ulong x = Mix((ulong)seed + 0x9E3779B97F4A7C15UL * (ulong)side);
|
||||
return (int)(x % (ulong)(2 * range + 1)) - range;
|
||||
}
|
||||
|
||||
// Отдельный поток случайности для округления потерь (№13): своя "соль" на каждую сторону и тип войск,
|
||||
// не пересекается с удачей. Возвращает число от 0 до 2^32 - 1.
|
||||
static ulong LossRoll(long seed, int side, int type)
|
||||
{
|
||||
ulong x = Mix(((ulong)seed ^ 0xD1B54A32D192ED03UL) + 0x9E3779B97F4A7C15UL * (ulong)(16 * side + type + 1));
|
||||
return x >> 32;
|
||||
}
|
||||
|
||||
static ulong Mix(ulong x)
|
||||
{
|
||||
x = (x ^ (x >> 30)) * 0xBF58476D1CE4E5B9UL;
|
||||
x = (x ^ (x >> 27)) * 0x94D049BB133111EBUL;
|
||||
return x ^ (x >> 31);
|
||||
}
|
||||
|
||||
// Потери каждого типа (дробью): units x доля выбывших по кривой (№14).
|
||||
// Целая часть гарантирована, дробная -- шанс ещё одной потери (№13).
|
||||
int[] Losses(int[] units, Fraction weaker, Fraction stronger, bool isWeak, long seed, int side, Rounding rounding)
|
||||
{
|
||||
var lost = new int[TroopType.Count];
|
||||
LossShare(weaker, stronger, isWeak, out BigInteger shareNum, out BigInteger shareDen);
|
||||
for (int i = 0; i < TroopType.Count; i++)
|
||||
{
|
||||
if (units[i] <= 0) continue;
|
||||
|
||||
BigInteger num = (BigInteger)units[i] * shareNum;
|
||||
BigInteger den = 1000 * shareDen;
|
||||
|
||||
BigInteger whole = BigInteger.DivRem(num, den, out BigInteger rest);
|
||||
bool extra = !rest.IsZero && (rounding == Rounding.Ceiling
|
||||
|| rounding == Rounding.Roll && (BigInteger)LossRoll(seed, side, i) * den < rest * (BigInteger.One << 32));
|
||||
BigInteger l = whole + (extra ? 1 : 0);
|
||||
lost[i] = (int)BigInteger.Min(units[i], l);
|
||||
}
|
||||
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)
|
||||
{
|
||||
var dead = new int[TroopType.Count];
|
||||
DeadShare(own, enemy, out BigInteger shareNum, out BigInteger shareDen);
|
||||
for (int i = 0; i < TroopType.Count; i++)
|
||||
{
|
||||
if (outUnits[i] <= 0) continue;
|
||||
BigInteger num = (BigInteger)outUnits[i] * shareNum;
|
||||
BigInteger den = 1000 * shareDen;
|
||||
BigInteger whole = BigInteger.DivRem(num, den, out BigInteger rest);
|
||||
bool extra = !rest.IsZero && (rounding == Rounding.Ceiling
|
||||
|| rounding == Rounding.Roll && (BigInteger)LossRoll(seed, side + 8, i) * den < rest * (BigInteger.One << 32));
|
||||
dead[i] = (int)BigInteger.Min(outUnits[i], whole + (extra ? 1 : 0));
|
||||
}
|
||||
return dead;
|
||||
}
|
||||
|
||||
// Доля погибших (в десятых долях процента, дробью) при соотношении сил стороны R = 1000 * own / enemy
|
||||
void DeadShare(Fraction own, Fraction enemy, out BigInteger num, out BigInteger den)
|
||||
{
|
||||
int last = deadRatio.Length - 1;
|
||||
if (enemy.N.IsZero) { num = deadShare[last]; den = 1; return; } // у врага нет силы -- как при самом большом перевесе
|
||||
BigInteger rn = 1000 * own.N * enemy.D;
|
||||
BigInteger rd = own.D * enemy.N;
|
||||
if (rn <= deadRatio[0] * rd) { num = deadShare[0]; den = 1; return; }
|
||||
if (rn >= deadRatio[last] * rd) { num = deadShare[last]; den = 1; return; }
|
||||
int i = 0;
|
||||
while (rn >= deadRatio[i + 1] * rd) i++;
|
||||
long span = deadRatio[i + 1] - deadRatio[i];
|
||||
num = (BigInteger)deadShare[i] * span * rd + (BigInteger)(deadShare[i + 1] - deadShare[i]) * (rn - deadRatio[i] * rd);
|
||||
den = span * rd;
|
||||
}
|
||||
|
||||
static int[] Units(int[] units, int count)
|
||||
{
|
||||
var result = new int[TroopType.Count];
|
||||
if (units != null)
|
||||
{
|
||||
for (int i = 0; i < TroopType.Count && i < units.Length; i++) result[i] = Math.Max(0, units[i]);
|
||||
}
|
||||
else
|
||||
{
|
||||
result[TroopType.Infantry] = Math.Max(0, count); // состав неизвестен -- считаем пехотой
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
static int Sum(int[] values)
|
||||
{
|
||||
int n = 0;
|
||||
foreach (var v in values) n += v;
|
||||
return n;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,7 +11,6 @@ public static class CoreUnityBootstrap
|
||||
GameServices.Clear();
|
||||
EventBus.Clear();
|
||||
SaveRegistry.Clear();
|
||||
GameClock.ResetOffset(); // отладочная перемотка не переживает перезапуск
|
||||
EventBus.OnHandlerError = Debug.LogException;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,10 +6,6 @@ using UnityEngine.EventSystems;
|
||||
// не собирал ресурсы со здания под панелью, не улучшал Ратушу, не двигал камеру.
|
||||
public static class UIBlocker
|
||||
{
|
||||
// Палец сейчас занят миром (например, игрок тащит призрак здания в режиме стройки) --
|
||||
// камера в это время не двигается. Ставит и снимает тот, кто захватил ввод.
|
||||
public static bool WorldInputCaptured;
|
||||
|
||||
public static bool IsPointerOverUI()
|
||||
{
|
||||
if (EventSystem.current == null) return false;
|
||||
|
||||
@@ -16,20 +16,6 @@ public interface IEconomy
|
||||
bool CanAfford(IReadOnlyList<ResourceAmount> cost);
|
||||
// Списывает всё или ничего
|
||||
bool TrySpend(IReadOnlyList<ResourceAmount> cost);
|
||||
|
||||
// Лимит склада для ресурса. float.PositiveInfinity -- без лимита (люди, сила).
|
||||
// Сверх лимита ресурс не прибавляется (добыча, награды) -- лишнее пропадает.
|
||||
float GetCapacity(ResourceType type);
|
||||
}
|
||||
|
||||
// Добыча через рабочих (этап 3). Реализует модуль Экономики (ProductionService).
|
||||
public interface IProduction
|
||||
{
|
||||
int Slots(WorkJob job); // мест для рабочих этой профессии во всех зданиях города
|
||||
float Efficiency(WorkJob job); // множитель от уровня зданий (1 -- здания 1 уровня)
|
||||
float RatePerHour(ResourceType type); // добыча ресурса в час сейчас
|
||||
// Добыть за hours часов (вызывает модуль Населения в своём расчёте -- сначала добыли, потом съели)
|
||||
void Produce(double hours);
|
||||
}
|
||||
|
||||
// ---------------- Поселение: здания и развитие города ----------------
|
||||
@@ -46,153 +32,21 @@ public interface ILeveledBuilding
|
||||
int Level { get; set; }
|
||||
}
|
||||
|
||||
// ---------------- Население: жители, рабочие, еда, голод, прирост, найм ----------------
|
||||
public interface IPopulation
|
||||
{
|
||||
int Total { get; } // все люди города: жители + обучаются в Казармах + воины (в городе и в походах)
|
||||
int Civilians { get; } // жители (не воины)
|
||||
int Workers { get; } // жители на работе (все профессии вместе)
|
||||
int Idle { get; } // остальные жители: Civilians - Workers
|
||||
int Workforce { get; } // сколько жителей могут работать: все жители (общего ограничения нет, регламент 31.10)
|
||||
int FreeWorkers { get; } // могут работать, но без работы: Workforce - Workers
|
||||
int Soldiers { get; } // воины -- их хранит модуль Армии
|
||||
int HousingCap { get; } // предел населения: выше него прирост не идёт
|
||||
|
||||
float FoodPerHour { get; } // сколько город съедает в час
|
||||
float GrowthPerHour { get; } // прирост в час сейчас (0 -- если голод или упёрлись в предел)
|
||||
bool IsStarving { get; } // еды не хватило на последний расчёт
|
||||
// Голодал ли город в момент unixTime (секунды). Для боёв, прошедших, пока игра была закрыта:
|
||||
// история хранится за последние MaxOfflineHours (48 ч), точность -- 10 минут (шаг расчёта).
|
||||
bool WasStarvingAt(long unixTime);
|
||||
|
||||
// Найм воинов (решение команды от 19.10.2026): воины берутся из свободных жителей, гражданский резерв не отдаётся.
|
||||
int CivilianReserve { get; } // гражданский резерв: столько свободных жителей в войско не взять
|
||||
int Recruitable { get; } // сколько воинов можно нанять прямо сейчас: (свободные - резерв) / жителей на воина
|
||||
int InTraining { get; } // людей, взятых из жителей и ещё обучающихся в Казармах
|
||||
bool TryRecruit(int soldiers); // забрать людей в Казармы (в начале обучения); false -- столько жителей нет
|
||||
void RecruitsTrained(int soldiers); // обучение закончено: этих людей теперь считает Армия (без проверки Армии)
|
||||
// Обучение закончено -- передать новобранцев в Армию одним действием: люди уходят из Населения, только если
|
||||
// Армия их действительно приняла (IArmy.AddTroops). false -- ничего не изменилось: столько новобранцев нет,
|
||||
// Армия не подключена или не приняла (новобранцы остаются в Казармах, никто не теряется и не дублируется).
|
||||
bool CompleteTraining(int troopType, int soldiers);
|
||||
|
||||
// Досчитать население до момента unixNow (вызывается раз в несколько секунд и при перемотке)
|
||||
void Tick(long unixNow);
|
||||
|
||||
// Рабочие по профессиям (этап 3)
|
||||
int GetWorkers(WorkJob job);
|
||||
// Поставить count рабочих на профессию (в пределах мест в зданиях и свободных рабочих). Возвращает, сколько стоит теперь.
|
||||
int SetWorkers(WorkJob job, int count);
|
||||
}
|
||||
|
||||
// ---------------- Армия: войска ----------------
|
||||
// Пропавшие без вести одного боя (решения 01.11, п. 5). Время -- мс часов игры
|
||||
public struct MissingInfo
|
||||
{
|
||||
public int Id;
|
||||
public int Count; // ещё не вернулись и не списаны
|
||||
public long WindowStartMs; // с какого момента могут начать возвращаться (бой + обратный путь)
|
||||
public long EndMs; // конец окна: кто не вернулся -- списан
|
||||
}
|
||||
|
||||
// Раненые на лечении в госпитале: одна группа одного типа, лечится вместе. Время -- мс часов игры
|
||||
public struct HealingInfo
|
||||
{
|
||||
public int Id;
|
||||
public int Type;
|
||||
public int Count;
|
||||
public long StartMs;
|
||||
public long EndMs;
|
||||
}
|
||||
|
||||
// Госпиталь -- здание Поселения (решения 31.10, п. 7): уровень, вместимость, улучшения.
|
||||
// Пока здания нет, контракт не зарегистрирован -- Армия берёт временное число мест из таблицы HospitalConfig.
|
||||
public interface IHospital
|
||||
{
|
||||
int Beds { get; } // мест для лечения: регламент 31.10 -- 20..60 по уровням I..V
|
||||
}
|
||||
|
||||
// Заказ обучения в Казармах. Время -- миллисекунды часов игры (GameClock.UnixNowMs)
|
||||
public struct TrainingOrderInfo
|
||||
{
|
||||
public int Id; // номер заказа -- не повторяется
|
||||
public int Type; // тип войск (TroopType)
|
||||
public int Count;
|
||||
public long StartMs; // когда начнётся (заказы идут друг за другом)
|
||||
public long EndMs; // когда воины будут готовы
|
||||
}
|
||||
|
||||
public interface IArmy
|
||||
{
|
||||
int TypeCount { get; } // типов войск (пехота, лучники, кавалерия, осадные)
|
||||
int GetTroops(int type); // воинов этого типа в городе
|
||||
int SoldiersAtHome { get; } // всего воинов в городе (вместе с гарнизоном)
|
||||
int SoldiersAtHome { get; } // всего воинов в городе
|
||||
int TroopsInMarches { get; } // воинов в походах
|
||||
|
||||
// Гарнизон -- часть воинов в городе: защищает город и в походы не уходит. Всего воинов от этого не меняется.
|
||||
int GarrisonTotal { get; }
|
||||
int GetGarrison(int type);
|
||||
int AvailableForMarch { get; } // можно отправить в поход: в городе минус гарнизон
|
||||
int SetGarrison(int count); // назначить гарнизон (по составу армии); возвращает, сколько назначено
|
||||
|
||||
void AddTroops(int type, int count); // обучены / вернулись
|
||||
int LoseFraction(double fraction); // потери в бою: доля от каждого типа; возвращает, сколько погибло
|
||||
|
||||
// Слоты похода (регламент 31.10, раздел 7): сколько походов игрока может идти одновременно.
|
||||
// База 2 (ТЕСТ), технология «Военная логистика» поднимет до 7 -- уровни и цены ещё не утверждены.
|
||||
int MarchSlots { get; }
|
||||
int ActiveMarches { get; } // походов в пути сейчас (у каждого -- свой слот)
|
||||
|
||||
// Забрать свободных воинов в поход (запоминает состав похода).
|
||||
// false -- ничего не взято: не хватает воинов, нет свободного слота или поход с таким номером уже есть.
|
||||
// Забрать воинов из города в поход (запоминает состав похода)
|
||||
bool TakeForMarch(int marchId, int count);
|
||||
// Вернуть выживших из похода по тем же типам войск.
|
||||
// Зачисляет только по известному походу и только один раз; false -- такого похода нет, никто не добавлен.
|
||||
bool ReturnFromMarch(int marchId, int survivors);
|
||||
|
||||
// Состав похода по типам (копия); null -- такого похода нет
|
||||
int[] GetMarchUnits(int marchId);
|
||||
// Вернуть выживших точно по типам (бой посчитал потери каждого типа). Правила те же, что у ReturnFromMarch.
|
||||
bool ReturnUnits(int marchId, int[] survivorUnits);
|
||||
|
||||
// ---- Раненые и лечение (решения по 8 вопросам Саши, 31.10, п. 7): состояние и очередь лечения -- у Армии ----
|
||||
// Бой считает раненых, Армия хранит их и лечит, места -- от госпиталя (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; } // мест в госпитале сейчас
|
||||
// Вернуть из похода выживших и раненых точно по типам (раненые -- в госпиталь в момент nowMs).
|
||||
// Только известный поход и только один раз; больше, чем ушло, не вернётся.
|
||||
bool ReturnUnits(int marchId, int[] survivorUnits, int[] woundedUnits, long nowMs);
|
||||
// Закончить лечение, срок которого наступил (и офлайн), и положить ждущих на освободившиеся места.
|
||||
// Вылеченные возвращаются в город (SoldiersHealedEvent, один раз на группу). Возвращает, сколько групп вылечено.
|
||||
int ProcessHealing(long nowMs);
|
||||
|
||||
// ---- Очередь обучения (решения по 8 вопросам Саши, 31.10, п. 8): единственный владелец -- Армия ----
|
||||
// Жители для найма -- у Населения (IPopulation: Recruitable, резерв, TryRecruit, CompleteTraining),
|
||||
// размер очереди -- от Казарм (передаёт вызывающий), цену списывает вызывающий через Экономику.
|
||||
IReadOnlyList<TrainingOrderInfo> Training { get; }
|
||||
// Можно ли поставить заказ: очередь не заполнена, жителей хватает. Ничего не меняет; false -- error для игрока.
|
||||
bool CanQueueTraining(int type, int count, int queueSize, out string error);
|
||||
// Поставить заказ в конец очереди, люди уходят из жителей в Казармы. false -- ничего не изменилось.
|
||||
bool TryQueueTraining(int type, int count, long durationMs, int queueSize, long nowMs, out string error);
|
||||
// Закончить заказы, срок которых наступил к nowMs (и пока игра была закрыта): воины переходят в Армию,
|
||||
// каждый заказ -- один раз (TroopsTrainedEvent). Возвращает, сколько заказов закончено.
|
||||
int ProcessTraining(long nowMs);
|
||||
// Ускорение: все заказы заканчиваются в nowMs
|
||||
int FinishAllTraining(long nowMs);
|
||||
// Вернуть выживших из похода по тем же типам войск
|
||||
void ReturnFromMarch(int marchId, int survivors);
|
||||
}
|
||||
|
||||
// ---------------- Глобальный мир: карта и перемещения ----------------
|
||||
@@ -200,196 +54,26 @@ public interface IWorld
|
||||
{
|
||||
int ActiveMarches { get; }
|
||||
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) ----------------
|
||||
// Координаты и марши ведёт Мир, разведка только читает их через этот контракт -- без прямых ссылок
|
||||
// World <-> Intelligence. Координаты -- милли-единицы карты (1000 = 1 клетка).
|
||||
public struct ArmyOnMap
|
||||
{
|
||||
public string Id; // устойчивый номер армии на карте ("march_12")
|
||||
public string OwnerId; // владелец (LocalPlayer.Id -- свои войска)
|
||||
public int X, Y; // где сейчас
|
||||
public int ToX, ToY; // куда движется
|
||||
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; } // границы карты от центра, милли-единицы
|
||||
int HalfHeight { get; }
|
||||
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): туман войны, наблюдение ----------------
|
||||
public enum MapVisibility
|
||||
{
|
||||
Unexplored, // неизведано
|
||||
Explored, // исследовано: география известна, текущие движения не видны
|
||||
Observed // наблюдается сейчас
|
||||
}
|
||||
|
||||
// Что игрок знает о чужой армии: только приблизительно и только на момент наблюдения
|
||||
public struct KnownContact
|
||||
{
|
||||
public string ArmyId;
|
||||
public bool Identified; // владелец опознан
|
||||
public string OwnerId; // null, пока не опознан
|
||||
public bool Observed; // видна прямо сейчас; false -- последний контакт (устаревшие данные)
|
||||
public int X, Y, ToX, ToY; // где видели последний раз и куда шла
|
||||
public int ApproxMin, ApproxMax; // примерная численность (±20%), одна на событие наблюдения
|
||||
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);
|
||||
}
|
||||
|
||||
// ---------------- Боевая система: урон, потери, исход ----------------
|
||||
// Типы войск в бою -- порядок как в GameData.Troops и в модуле Армии
|
||||
public static class TroopType
|
||||
{
|
||||
public const int Infantry = 0; // пехота
|
||||
public const int Archers = 1; // лучники
|
||||
public const int Cavalry = 2; // кавалерия
|
||||
public const int Siege = 3; // осадные
|
||||
public const int Count = 4;
|
||||
}
|
||||
|
||||
// Состояние войск стороны в бою (план от 27.10.2026). Штрафы применяются последовательно:
|
||||
// голод (-50%) и усталость (-20%) вместе оставляют 40% силы, а не 30%. Числа -- в правилах боя.
|
||||
[Flags]
|
||||
public enum BattleCondition
|
||||
{
|
||||
None = 0,
|
||||
Famine = 1, // голод в поселении на момент боя
|
||||
Fatigue = 2 // войско недавно сражалось (пока не выставляется: правила учёта усталости не утверждены)
|
||||
}
|
||||
|
||||
public struct BattleRequest
|
||||
{
|
||||
// Состав сторон по типам войск (TroopType). Если состав не задан (null) --
|
||||
// берётся число Attackers / Defenders, и все считаются пехотой.
|
||||
public int[] AttackerUnits;
|
||||
public int[] DefenderUnits;
|
||||
public int Attackers;
|
||||
public int Defenders;
|
||||
|
||||
public bool DefenderFortified; // защитники за стенами (город, замок)
|
||||
public int AttackerBonusPercent; // герой, технологии, голод (минус) -- +10 = на 10% сильнее
|
||||
public int DefenderBonusPercent;
|
||||
|
||||
// Состояния сторон: голод, усталость. Действуют последовательно (умножаются), см. BattleCondition
|
||||
public BattleCondition AttackerConditions;
|
||||
public BattleCondition DefenderConditions;
|
||||
|
||||
// Номер боя: из него считается случайность сторон (±5%). Один и тот же Seed с теми же войсками --
|
||||
// всегда тот же итог, бой можно перепроверить. В мультиплеере Seed выдаёт сервер.
|
||||
public long Seed;
|
||||
}
|
||||
|
||||
// Итог боя -- одинаково полный для обеих сторон. Все массивы -- по типам войск (TroopType).
|
||||
// По каждому типу: до боя = выжившие + погибшие + раненые.
|
||||
public struct BattleOutcome
|
||||
{
|
||||
public bool Victory; // победил атакующий
|
||||
public long BattleId; // = Seed запроса
|
||||
public int RulesVersion; // версия правил боя (баланса), по которой посчитан итог
|
||||
|
||||
public int AttackerLosses; // выбыли из строя: погибшие + раненые
|
||||
public int AttackerLosses;
|
||||
public int DefenderLosses;
|
||||
public int Survivors; // сколько атакующих осталось в строю
|
||||
|
||||
public long AttackerPower; // итоговая сила сторон x100: с контрами, стенами, бонусами и случайностью
|
||||
public long DefenderPower;
|
||||
public int AttackerLuckPermille; // случайность стороны в десятых долях процента: +23 = +2,3%
|
||||
public int DefenderLuckPermille;
|
||||
public int AttackerConditionPercent; // сколько силы оставили состояния стороны: 100 -- без штрафов, 50 -- голод
|
||||
public int DefenderConditionPercent;
|
||||
|
||||
public int[] AttackerUnitsBefore, DefenderUnitsBefore;
|
||||
public int[] AttackerSurvivorUnits, DefenderSurvivorUnits;
|
||||
public int[] AttackerDeadUnits, DefenderDeadUnits;
|
||||
public int[] AttackerWoundedUnits, DefenderWoundedUnits; // раненые (регламент 31.10, решения 31.10, п. 7)
|
||||
public int[] AttackerMissingUnits, DefenderMissingUnits; // пропавшие без вести (решения 01.11, п. 5): выбывшие = погибшие + раненые + пропавшие
|
||||
public int[] DefenderLossUnits; // погибшие + раненые защитника (для старого кода)
|
||||
}
|
||||
|
||||
// Прогноз до боя: с учётом случайности, без раскрытия итога (план от 27.10.2026)
|
||||
public enum BattleChance
|
||||
{
|
||||
Advantage, // победа при любой случайности
|
||||
Uncertain, // исход зависит от случайности
|
||||
HighRisk // поражение при любой случайности
|
||||
}
|
||||
|
||||
public struct BattleForecast
|
||||
{
|
||||
public BattleChance Chance;
|
||||
public int WinChancePermille; // шанс победы нападающего в десятых долях процента: 273 = 27,3% (решение №11)
|
||||
public long AttackerPowerMin, AttackerPowerMax; // x100, крайние значения случайности
|
||||
public long DefenderPowerMin, DefenderPowerMax;
|
||||
public int AttackerLossesMin, AttackerLossesMax; // с учётом округления потерь (решение №13)
|
||||
public int Survivors; // сколько атакующих осталось
|
||||
}
|
||||
|
||||
public interface IBattleSystem
|
||||
{
|
||||
BattleOutcome Resolve(BattleRequest request);
|
||||
BattleForecast Forecast(BattleRequest request); // Seed не нужен: перебираются крайние значения случайности
|
||||
}
|
||||
|
||||
// ---------------- Чат: общий мир и Круг ----------------
|
||||
|
||||
@@ -24,101 +24,25 @@ public struct TownHallLevelChangedEvent
|
||||
// здания, которые появляются кодом (например, Казармы)
|
||||
public struct SettlementRestoringEvent { }
|
||||
|
||||
// ---------------- Время ----------------
|
||||
// Время игры промотано вперёд (отладочная панель). Модули досчитывают прошедшее время.
|
||||
public struct GameTimeAdvancedEvent
|
||||
{
|
||||
public long Seconds;
|
||||
public long UnixNow;
|
||||
}
|
||||
|
||||
// ---------------- Население ----------------
|
||||
// Число людей изменилось (прирост, загрузка, сброс, изменение войска)
|
||||
public struct PopulationChangedEvent
|
||||
{
|
||||
public int Total;
|
||||
public int Civilians;
|
||||
public int Soldiers;
|
||||
public int HousingCap;
|
||||
}
|
||||
|
||||
// Рабочих переставили (окно "Рабочие", автоматическое заполнение новых мест)
|
||||
public struct WorkersChangedEvent
|
||||
{
|
||||
public int Workers;
|
||||
public int FreeWorkers;
|
||||
}
|
||||
|
||||
// Голод начался (IsStarving = true) или закончился
|
||||
public struct StarvationChangedEvent
|
||||
{
|
||||
public bool IsStarving;
|
||||
}
|
||||
|
||||
// ---------------- Армия ----------------
|
||||
// Число воинов изменилось (обучение, потери, поход, возвращение, загрузка)
|
||||
public struct TroopsChangedEvent
|
||||
{
|
||||
public int AtHome;
|
||||
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 -- когда закончили
|
||||
public struct SoldiersHealedEvent
|
||||
{
|
||||
public int BatchId;
|
||||
public int Type;
|
||||
public int Count;
|
||||
public long TimeMs;
|
||||
}
|
||||
|
||||
// Заказ обучения закончен: воины уже в Армии. Приходит один раз на заказ (OrderId), TimeMs -- когда был готов
|
||||
public struct TroopsTrainedEvent
|
||||
{
|
||||
public int OrderId;
|
||||
public int Type;
|
||||
public int Count;
|
||||
public long TimeMs;
|
||||
}
|
||||
|
||||
// ---------------- Глобальный мир ----------------
|
||||
// У событий похода -- номер похода и точное время по часам игры (документ 02 комплекта от 29.10.2026):
|
||||
// TimeMs -- когда это случилось, а не когда игра это заметила (после офлайна это разные моменты).
|
||||
public struct MarchStartedEvent
|
||||
{
|
||||
public int MarchId;
|
||||
public long TimeMs; // выступление
|
||||
public string TargetId;
|
||||
public int Soldiers;
|
||||
public bool IsScout; // поход «Разведка»: без боя и добычи
|
||||
}
|
||||
|
||||
public struct MarchBattleResolvedEvent
|
||||
{
|
||||
public int MarchId;
|
||||
public long BattleId; // номер боя (= Seed): по нему бой можно перепроверить
|
||||
public long TimeMs; // приход к цели и бой
|
||||
public string TargetId;
|
||||
public bool Victory;
|
||||
public int Soldiers;
|
||||
@@ -128,15 +52,9 @@ public struct MarchBattleResolvedEvent
|
||||
public struct MarchReturnedEvent
|
||||
{
|
||||
public int MarchId;
|
||||
public long TimeMs; // возвращение домой
|
||||
public bool Victory;
|
||||
public int Survivors;
|
||||
public int Sent; // сколько воинов уходило в поход
|
||||
public bool TargetEmpty; // лагерь уже был разбит / ресурсной точки уже не было: боя и сбора не было, вернулись все
|
||||
public bool IsGather; // поход на ресурсную точку (сбор, а не бой)
|
||||
public bool IsScout; // поход «Разведка»: дошли до цели и вернулись, боя и добычи нет
|
||||
public int Wounded; // раненых привезли домой -- они в госпитале (или ждут места)
|
||||
public int Missing; // пропали без вести в бою -- могут вернуться сами
|
||||
public string TargetName; // куда ходили ("Замок Змея Горыныча", имя соседа)
|
||||
public string LootText; // добыча текстом: "+20 золота, +40 еды" или "нет"
|
||||
}
|
||||
@@ -167,58 +85,6 @@ public struct CircleLeftEvent
|
||||
|
||||
// ---------------- Сохранения ----------------
|
||||
public struct GameLoadedEvent { }
|
||||
// Файла сохранения нет -- это первая игра на устройстве (стартовые ресурсы, стартовое войско)
|
||||
public struct NewGameStartedEvent { }
|
||||
public struct GameSavedEvent { }
|
||||
// Сохранения сброшены, все модули уже в состоянии новой игры
|
||||
public struct GameResetEvent { }
|
||||
|
||||
// ---------------- Мир -> Разведка ----------------
|
||||
// Мир сейчас продвинет время до ToMs: разведка успевает досчитать наблюдение по армиям, которые ещё на карте
|
||||
// (после продвижения вернувшиеся походы исчезают). Публикует MarchManager перед каждым расчётом мира.
|
||||
public struct WorldAdvancingEvent
|
||||
{
|
||||
public long ToMs;
|
||||
}
|
||||
|
||||
// ---------------- Разведка ----------------
|
||||
// У всех событий -- устойчивый номер события (армия + номер наблюдения) и время по часам игры (ТЗ разведки, раздел 10)
|
||||
public struct ArmySpottedEvent // чужая армия вошла в зону обзора
|
||||
{
|
||||
public string EventId;
|
||||
public string ArmyId;
|
||||
public long TimeMs;
|
||||
public int ApproxMin, ApproxMax;
|
||||
}
|
||||
|
||||
public struct ArmyLostContactEvent // чужая армия вышла из зоны обзора -- остаётся метка последнего контакта
|
||||
{
|
||||
public string EventId;
|
||||
public string ArmyId;
|
||||
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;
|
||||
public string ArmyId;
|
||||
public string OwnerId;
|
||||
public long TimeMs;
|
||||
}
|
||||
|
||||
@@ -1,46 +0,0 @@
|
||||
using System;
|
||||
|
||||
// ЧАСЫ ИГРЫ: единственный источник "сейчас" для игровых правил (население, а дальше -- производство).
|
||||
//
|
||||
// Время игры = реальное время UTC + сдвиг. Сдвиг нужен только отладочной панели ("+1 час"):
|
||||
// перемотка идёт через те же правила, что и офлайн-прогресс, отдельной логики у неё нет.
|
||||
// Сдвиг НЕ сохраняется -- после перезапуска игры время снова реальное.
|
||||
//
|
||||
// Модули берут время только отсюда: GameClock.UnixNow, а не DateTime.UtcNow.
|
||||
public static class GameClock
|
||||
{
|
||||
static long offsetSeconds;
|
||||
|
||||
// Для тестов: подменить "реальное" время. null -- настоящие часы устройства.
|
||||
public static Func<long> RealUnixSource;
|
||||
|
||||
public static long OffsetSeconds => offsetSeconds;
|
||||
|
||||
// Текущее время игры, секунды Unix
|
||||
public static long UnixNow => RealUnix() + offsetSeconds;
|
||||
|
||||
public static DateTime UtcNow => DateTimeOffset.FromUnixTimeSeconds(UnixNow).UtcDateTime;
|
||||
|
||||
// То же в миллисекундах -- для таймеров, которые идут плавно (обучение войск, марши)
|
||||
public static long UnixNowMs => (RealUnixSource != null ? RealUnixSource() * 1000 : DateTimeOffset.UtcNow.ToUnixTimeMilliseconds())
|
||||
+ offsetSeconds * 1000;
|
||||
|
||||
// Промотать время вперёд (отладка). Модули узнают об этом из GameTimeAdvancedEvent и досчитываются.
|
||||
public static void Advance(long seconds)
|
||||
{
|
||||
if (seconds <= 0) return;
|
||||
offsetSeconds += seconds;
|
||||
EventBus.Publish(new GameTimeAdvancedEvent { Seconds = seconds, UnixNow = UnixNow });
|
||||
}
|
||||
|
||||
// Вернуть реальное время (сдвиг = 0). Модули воспримут это как "часы ушли назад" и просто продолжат с этого момента.
|
||||
public static void ResetOffset()
|
||||
{
|
||||
offsetSeconds = 0;
|
||||
}
|
||||
|
||||
static long RealUnix()
|
||||
{
|
||||
return RealUnixSource != null ? RealUnixSource() : DateTimeOffset.UtcNow.ToUnixTimeSeconds();
|
||||
}
|
||||
}
|
||||
@@ -1,11 +0,0 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 03f995a4fe964f48bfe8d7b2f7215ecc
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -1,53 +0,0 @@
|
||||
// Профессии рабочих. Рабочего держит модуль Населения, добычу считает модуль Экономики.
|
||||
// Числовые значения НЕ менять: они записаны в сохранениях.
|
||||
public enum WorkJob
|
||||
{
|
||||
Woodcutter = 0, // лесоруб -> дерево (Лесопилка)
|
||||
Farmer = 1, // фермер -> еда (Ферма)
|
||||
Stonecutter = 2, // каменотёс -> камень (Каменоломня)
|
||||
MinerIron = 3, // шахтёр -> металл (Шахта)
|
||||
MinerGold = 4 // шахтёр -> золото (Золотой рудник)
|
||||
}
|
||||
|
||||
public static class WorkJobs
|
||||
{
|
||||
public const int Count = 5;
|
||||
|
||||
public static ResourceType Resource(WorkJob job)
|
||||
{
|
||||
switch (job)
|
||||
{
|
||||
case WorkJob.Woodcutter: return ResourceType.Wood;
|
||||
case WorkJob.Farmer: return ResourceType.Food;
|
||||
case WorkJob.Stonecutter: return ResourceType.Stone;
|
||||
case WorkJob.MinerIron: return ResourceType.Iron;
|
||||
default: return ResourceType.Gold;
|
||||
}
|
||||
}
|
||||
|
||||
// Какая профессия добывает этот ресурс. false -- ресурс не добывается рабочими (люди, кристаллы, сила)
|
||||
public static bool TryFromResource(ResourceType type, out WorkJob job)
|
||||
{
|
||||
switch (type)
|
||||
{
|
||||
case ResourceType.Wood: job = WorkJob.Woodcutter; return true;
|
||||
case ResourceType.Food: job = WorkJob.Farmer; return true;
|
||||
case ResourceType.Stone: job = WorkJob.Stonecutter; return true;
|
||||
case ResourceType.Iron: job = WorkJob.MinerIron; return true;
|
||||
case ResourceType.Gold: job = WorkJob.MinerGold; return true;
|
||||
default: job = WorkJob.Woodcutter; return false;
|
||||
}
|
||||
}
|
||||
|
||||
public static string Name(WorkJob job)
|
||||
{
|
||||
switch (job)
|
||||
{
|
||||
case WorkJob.Woodcutter: return "Лесорубы";
|
||||
case WorkJob.Farmer: return "Фермеры";
|
||||
case WorkJob.Stonecutter: return "Каменотёсы";
|
||||
case WorkJob.MinerIron: return "Шахтёры (металл)";
|
||||
default: return "Шахтёры (золото)";
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,11 +0,0 @@
|
||||
fileFormatVersion: 2
|
||||
guid: eb271faca9634ee0b607250e9341b0ce
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -1,71 +1,100 @@
|
||||
using System;
|
||||
using UnityEngine;
|
||||
|
||||
// МОДУЛЬ ЭКОНОМИКИ: здание-рабочее место (этап 3). Висит на всех добывающих зданиях (префабы Лес, Еда, Камень...).
|
||||
//
|
||||
// Раньше это был кликер (тап -- забрать накопленное). Теперь ресурс производят люди:
|
||||
// здание только даёт места для рабочих и эффективность уровня, а добычу считает ProductionService.
|
||||
// Имя класса оставлено старым, чтобы не перенастраивать префабы.
|
||||
//
|
||||
// resourceType = Wood/Food/Stone/Iron/Gold -- рабочее место этой профессии;
|
||||
// resourceType = Population -- Дом: прибавляет места к пределу жилья;
|
||||
// resourceType = Gems -- Кристалл: рабочих нет (синий кристалл добывается в разломах на карте мира).
|
||||
//
|
||||
// Тап по зданию открывает окно рабочих -- его рисует слой приложения (подписка на Tapped в App/GameModules).
|
||||
// Модуль Экономики: временная добыча по клику (уйдёт в этапе 3 -- производство через рабочих).
|
||||
public class ResourceCollector : MonoBehaviour, ILeveledBuilding
|
||||
{
|
||||
// Тап по зданию: интерфейс откроет окно "Рабочие" (модуль про интерфейс не знает)
|
||||
public static event Action<ResourceCollector> Tapped;
|
||||
|
||||
// Уровень здания для Поселения (оно сохраняет уровни, не зная, что это за здание)
|
||||
public int Level { get => level; set => level = Mathf.Max(1, value); }
|
||||
public int Level { get => level; set => level = value; }
|
||||
|
||||
[Header("Что добывают рабочие этого здания")]
|
||||
[Header("Что производит это здание")]
|
||||
public ResourceType resourceType;
|
||||
|
||||
[Header("Уровень здания (больше мест и эффективность)")]
|
||||
[Tooltip("База награды за клик (используется для расчета множителя уровня)")]
|
||||
public float amountPerClick = 10f;
|
||||
|
||||
[Header("Параметры хранилища")]
|
||||
[Tooltip("Максимальная вместимость")]
|
||||
public float maxCapacity = 100f;
|
||||
|
||||
[Tooltip("Текущий запас (меняется автоматически)")]
|
||||
public float currentStock = 0f;
|
||||
|
||||
[Header("Уровень здания")]
|
||||
public int level = 1;
|
||||
public float perLevelMultiplier = 1.2f;
|
||||
|
||||
[Header("Устарело (этап 3): кликер убран, поля оставлены для старых префабов")]
|
||||
[HideInInspector] public float amountPerClick = 10f;
|
||||
[HideInInspector] public float maxCapacity = 100f;
|
||||
[HideInInspector] public float currentStock = 0f;
|
||||
[HideInInspector] public float perLevelMultiplier = 1.2f;
|
||||
private ResourceManager _manager;
|
||||
private bool isRecharging = false;
|
||||
|
||||
public bool IsWorkplace => WorkJobs.TryFromResource(resourceType, out _);
|
||||
public bool IsHouse => resourceType == ResourceType.Population;
|
||||
void Awake()
|
||||
{
|
||||
_manager = ResourceManager.Instance;
|
||||
if (_manager == null)
|
||||
{
|
||||
Debug.LogError($"{gameObject.name}: ResourceManager не найден!");
|
||||
enabled = false;
|
||||
return;
|
||||
}
|
||||
currentStock = 0f;
|
||||
}
|
||||
|
||||
// Для полоски над зданием (BuildingUI): рабочие этой профессии / места
|
||||
public float GetCurrentStock()
|
||||
{
|
||||
if (!WorkJobs.TryFromResource(resourceType, out WorkJob job)) return 0f;
|
||||
return GameServices.TryGet(out IPopulation population) ? population.GetWorkers(job) : 0f;
|
||||
return currentStock;
|
||||
}
|
||||
|
||||
public float GetMaxCapacity()
|
||||
{
|
||||
if (!WorkJobs.TryFromResource(resourceType, out WorkJob job)) return 0f;
|
||||
return GameServices.TryGet(out IProduction production) ? production.Slots(job) : 0f;
|
||||
return maxCapacity;
|
||||
}
|
||||
|
||||
Vector3 downPos;
|
||||
|
||||
void OnMouseDown()
|
||||
{
|
||||
downPos = Input.mousePosition;
|
||||
if (UIBlocker.IsPointerOverUI()) return; // клик по панели HUD не должен собирать ресурс под ней
|
||||
if (isRecharging)
|
||||
{
|
||||
Debug.Log($"[КЛИК] {gameObject.name}: уже идёт восстановление, ждём.");
|
||||
return;
|
||||
}
|
||||
|
||||
if (currentStock > 0)
|
||||
{
|
||||
float reward = currentStock; // берём всё, что накопилось
|
||||
_manager.AddResource(resourceType, reward);
|
||||
|
||||
currentStock = 0f;
|
||||
Debug.Log($"[СБОР] Получено {reward:F1} {resourceType}. Запас сброшен в 0.");
|
||||
|
||||
StartCoroutine(RechargeRoutine());
|
||||
}
|
||||
else
|
||||
{
|
||||
Debug.Log($"[КЛИК] {gameObject.name} пуст, запускаем восстановление.");
|
||||
StartCoroutine(RechargeRoutine());
|
||||
}
|
||||
}
|
||||
|
||||
// Отпустил палец на здании, почти не сдвинув (а не тащил камеру) -- окно рабочих
|
||||
void OnMouseUpAsButton()
|
||||
System.Collections.IEnumerator RechargeRoutine()
|
||||
{
|
||||
if (UIBlocker.IsPointerOverUI()) return; // клик по панели HUD не должен проходить на здание под ней
|
||||
if ((Input.mousePosition - downPos).magnitude > 25f) return;
|
||||
Tapped?.Invoke(this);
|
||||
if (isRecharging) yield break;
|
||||
isRecharging = true;
|
||||
|
||||
float rechargeRate = 5f; // единиц в секунду
|
||||
|
||||
while (currentStock < maxCapacity)
|
||||
{
|
||||
currentStock += rechargeRate * Time.deltaTime;
|
||||
if (currentStock > maxCapacity) currentStock = maxCapacity;
|
||||
yield return null;
|
||||
}
|
||||
|
||||
isRecharging = false;
|
||||
Debug.Log($"[ГОТОВО] {gameObject.name} полностью заполнен!");
|
||||
}
|
||||
|
||||
public void Upgrade()
|
||||
{
|
||||
level++;
|
||||
Debug.Log($"[ЗДАНИЕ] {name}: уровень {level}");
|
||||
Debug.Log($"[АПГРЕЙД] Уровень {level}");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -26,13 +26,6 @@ public class ResourceManager : MonoBehaviour, IEconomy, ISaveParticipant
|
||||
|
||||
private Dictionary<ResourceType, float> resources = new Dictionary<ResourceType, float>();
|
||||
|
||||
// Лимиты склада (этап 1). Нет записи -- лимита нет. Задаёт слой приложения (App/CitySync) по Складам и Амбарам,
|
||||
// запоминаются в сохранении: на карте мира город не открыт, а лимиты действуют.
|
||||
private Dictionary<ResourceType, float> capacities = new Dictionary<ResourceType, float>();
|
||||
|
||||
// Лимит изменился (для интерфейса: "запас / лимит")
|
||||
public event Action<ResourceType, float> OnCapacityChanged;
|
||||
|
||||
void Awake()
|
||||
{
|
||||
// Классический паттерн Singleton: если уже есть экземпляр -- уничтожаем дубликат
|
||||
@@ -57,21 +50,14 @@ public class ResourceManager : MonoBehaviour, IEconomy, ISaveParticipant
|
||||
SaveRegistry.Unregister(this);
|
||||
}
|
||||
|
||||
// Добавить ресурс (можно и отрицательное число -- для трат).
|
||||
// Прибавка не поднимает запас выше лимита склада -- лишнее пропадает. Если запас уже выше лимита
|
||||
// (старое сохранение, лимит уменьшился), он не урезается, просто больше не растёт.
|
||||
// Добавить ресурс (можно и отрицательное число -- для трат)
|
||||
public void AddResource(ResourceType type, float amount)
|
||||
{
|
||||
if (!resources.ContainsKey(type))
|
||||
resources[type] = 0f;
|
||||
|
||||
float current = resources[type];
|
||||
float next = current + amount;
|
||||
if (amount > 0f && capacities.TryGetValue(type, out float cap))
|
||||
next = Mathf.Min(next, Mathf.Max(current, cap));
|
||||
if (next < 0f) next = 0f; // ресурс не уходит в минус
|
||||
if (next == current) return; // ничего не изменилось (например, склад полон) -- не будим интерфейс
|
||||
resources[type] = next;
|
||||
resources[type] += amount;
|
||||
if (resources[type] < 0f) resources[type] = 0f; // ресурс не уходит в минус
|
||||
|
||||
NotifyChanged(type);
|
||||
}
|
||||
@@ -95,19 +81,6 @@ public class ResourceManager : MonoBehaviour, IEconomy, ISaveParticipant
|
||||
return resources.TryGetValue(type, out float value) ? value : 0f;
|
||||
}
|
||||
|
||||
// Лимит склада: float.PositiveInfinity -- без лимита
|
||||
public float GetCapacity(ResourceType type)
|
||||
{
|
||||
return capacities.TryGetValue(type, out float cap) ? cap : float.PositiveInfinity;
|
||||
}
|
||||
|
||||
public void SetCapacity(ResourceType type, float capacity)
|
||||
{
|
||||
if (capacities.TryGetValue(type, out float old) && Mathf.Approximately(old, capacity)) return;
|
||||
capacities[type] = Mathf.Max(0f, capacity);
|
||||
OnCapacityChanged?.Invoke(type, capacities[type]);
|
||||
}
|
||||
|
||||
// Устанавливает значение напрямую, БЕЗ проверок и без учёта прошлого значения.
|
||||
// Используется только при загрузке сохранения -- обычная игровая логика должна
|
||||
// использовать AddResource/TrySpend, а не этот метод.
|
||||
@@ -154,7 +127,6 @@ public class ResourceManager : MonoBehaviour, IEconomy, ISaveParticipant
|
||||
// ---------------- Контракт IEconomy ----------------
|
||||
|
||||
float IEconomy.Get(ResourceType type) => GetResource(type);
|
||||
float IEconomy.GetCapacity(ResourceType type) => GetCapacity(type);
|
||||
bool IEconomy.Has(ResourceType type, float amount) => HasEnough(type, amount);
|
||||
void IEconomy.Add(ResourceType type, float amount) => AddResource(type, amount);
|
||||
|
||||
@@ -188,7 +160,6 @@ public class ResourceManager : MonoBehaviour, IEconomy, ISaveParticipant
|
||||
class EconomySaveData
|
||||
{
|
||||
public List<ResourceSaveEntry> resources = new List<ResourceSaveEntry>();
|
||||
public List<ResourceSaveEntry> capacities = new List<ResourceSaveEntry>(); // лимиты склада (этап 1)
|
||||
}
|
||||
|
||||
public string SaveKey => "economy";
|
||||
@@ -198,8 +169,6 @@ public class ResourceManager : MonoBehaviour, IEconomy, ISaveParticipant
|
||||
var data = new EconomySaveData();
|
||||
foreach (var kvp in resources)
|
||||
data.resources.Add(new ResourceSaveEntry { type = kvp.Key, amount = kvp.Value });
|
||||
foreach (var kvp in capacities)
|
||||
data.capacities.Add(new ResourceSaveEntry { type = kvp.Key, amount = kvp.Value });
|
||||
return JsonUtility.ToJson(data);
|
||||
}
|
||||
|
||||
@@ -207,8 +176,6 @@ public class ResourceManager : MonoBehaviour, IEconomy, ISaveParticipant
|
||||
{
|
||||
var data = JsonUtility.FromJson<EconomySaveData>(json);
|
||||
if (data?.resources == null) return;
|
||||
if (data.capacities != null)
|
||||
foreach (var entry in data.capacities) SetCapacity(entry.type, entry.amount);
|
||||
foreach (var entry in data.resources) SetResource(entry.type, entry.amount);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,21 +1,53 @@
|
||||
using UnityEngine;
|
||||
|
||||
// УСТАРЕЛО (этап 3). Раньше здание само добывало ресурс по таймеру (5 единиц раз в 3 секунды ~ 6000 в час).
|
||||
// Теперь добычу дают только рабочие (ProductionService), этот компонент ничего не делает.
|
||||
// Класс оставлен, чтобы на старых префабах не появилось "Missing Script". Новым зданиям не вешать.
|
||||
// Вешается на ЛЮБОЕ здание (лесопилка, каменоломня, шахта) -- настраиваешь
|
||||
// в Inspector, какой ресурс и сколько оно производит. Один скрипт на все типы построек.
|
||||
//
|
||||
// ПРИМЕР НАСТРОЙКИ:
|
||||
// Лесопилка: resourceType = Wood, amountPerTick = 5, tickInterval = 3
|
||||
// Каменоломня: resourceType = Stone, amountPerTick = 4, tickInterval = 3
|
||||
// Шахта: resourceType = Iron, amountPerTick = 2, tickInterval = 4 (руда добывается медленнее)
|
||||
|
||||
public class ResourceProducer : MonoBehaviour
|
||||
{
|
||||
[Header("Что производит это здание")]
|
||||
public ResourceType resourceType;
|
||||
public float amountPerTick = 1f;
|
||||
public float tickInterval = 3f;
|
||||
public float tickInterval = 3f; // секунд между "тиками" производства
|
||||
|
||||
[Header("Уровень здания (для будущего апгрейда)")]
|
||||
public int level = 1;
|
||||
[Tooltip("Множитель производства за каждый уровень выше первого")]
|
||||
public float perLevelMultiplier = 1.2f;
|
||||
|
||||
void Awake()
|
||||
private float timer;
|
||||
|
||||
void Update()
|
||||
{
|
||||
enabled = false;
|
||||
timer += Time.deltaTime;
|
||||
|
||||
if (timer >= tickInterval)
|
||||
{
|
||||
timer -= tickInterval;
|
||||
Produce();
|
||||
}
|
||||
}
|
||||
|
||||
void Produce()
|
||||
{
|
||||
if (ResourceManager.Instance == null)
|
||||
{
|
||||
Debug.LogWarning($"{gameObject.name}: ResourceManager.Instance не найден. " +
|
||||
"Убедись, что на сцене есть объект с компонентом ResourceManager.");
|
||||
return;
|
||||
}
|
||||
|
||||
float finalAmount = amountPerTick * Mathf.Pow(perLevelMultiplier, level - 1);
|
||||
ResourceManager.Instance.AddResource(resourceType, finalAmount);
|
||||
Debug.Log($"{gameObject.name} произвел {finalAmount} {resourceType}. Итого: {ResourceManager.Instance.GetResource(resourceType)}");
|
||||
}
|
||||
|
||||
// Вызывай при апгрейде здания (кнопка "улучшить" повесишь позже)
|
||||
public void Upgrade()
|
||||
{
|
||||
level++;
|
||||
|
||||
@@ -1,8 +0,0 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 2f9b52d13f334e20be4bad9f8a86c74b
|
||||
folderAsset: yes
|
||||
DefaultImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -1,14 +0,0 @@
|
||||
{
|
||||
"name": "Bogatyri.Economy.Rules",
|
||||
"rootNamespace": "",
|
||||
"references": ["Bogatyri.Core"],
|
||||
"includePlatforms": [],
|
||||
"excludePlatforms": [],
|
||||
"allowUnsafeCode": false,
|
||||
"overrideReferences": false,
|
||||
"precompiledReferences": [],
|
||||
"autoReferenced": true,
|
||||
"defineConstraints": [],
|
||||
"versionDefines": [],
|
||||
"noEngineReferences": true
|
||||
}
|
||||
@@ -1,169 +0,0 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Globalization;
|
||||
using System.Text;
|
||||
|
||||
// Правила добычи -- только числа (заполняются из EconomyConfig в App/GameModules).
|
||||
public sealed class ProductionRules
|
||||
{
|
||||
// Норма T1 в час на одного рабочего, по профессиям (порядок как в WorkJob)
|
||||
public double[] PerWorkerPerHour = { 2.0, 2.0, 1.5, 1.0, 0.4 };
|
||||
|
||||
public int SlotsPerBuilding = 30; // мест в здании 1 уровня (Лесопилка T1 -- 30)
|
||||
public int SlotsPerLevel = 10; // + мест за каждый уровень выше первого. Не утверждено
|
||||
public double EfficiencyPerLevel = 0.1; // + к норме за каждый уровень выше первого (×1.1, ×1.2...). Не утверждено
|
||||
|
||||
// Голод (регламент 31.10, ТЕСТ): добыча всех ресурсов, кроме еды, ×0.5. Еда -- без штрафа.
|
||||
// Штраф снимается сразу, как только город снова сыт (модуль Населения сообщает IsStarving).
|
||||
public double FamineNonFoodMultiplier = 0.5;
|
||||
}
|
||||
|
||||
// МОДУЛЬ ЭКОНОМИКИ: добыча через рабочих (этап 3). Документ: Docs/Modules/Economy.md
|
||||
// Без Unity: правила те же, что будут на сервере.
|
||||
//
|
||||
// Ресурс производят люди, здание даёт только места и эффективность:
|
||||
// добыча в час = min(рабочие, места) × норма × эффективность уровня зданий,
|
||||
// а при голоде всё, кроме еды, -- ещё × FamineNonFoodMultiplier (голод считает модуль Населения, IPopulation.IsStarving).
|
||||
// Какие здания стоят в городе, сообщает слой приложения (App/CitySync) -- модуль их не ищет.
|
||||
// Места запоминаются в сохранении: на карте мира и офлайн город не открыт, а добыча идёт.
|
||||
//
|
||||
// Produce вызывает модуль Населения в своём расчёте, поэтому в игре, офлайн и при перемотке правила одни.
|
||||
// На склад идут только целые единицы, доли копятся здесь (склад хранит float).
|
||||
public sealed class ProductionService : IProduction, ISaveParticipant
|
||||
{
|
||||
const double Epsilon = 1e-9;
|
||||
|
||||
readonly ProductionRules rules;
|
||||
readonly int[] slots = new int[WorkJobs.Count];
|
||||
readonly double[] efficiency = new double[WorkJobs.Count];
|
||||
readonly double[] pending = new double[WorkJobs.Count]; // добыто, но ещё не отдано на склад (< 1)
|
||||
|
||||
public ProductionService(ProductionRules rules)
|
||||
{
|
||||
this.rules = rules ?? new ProductionRules();
|
||||
ResetState();
|
||||
}
|
||||
|
||||
public ProductionRules Rules => rules;
|
||||
|
||||
// ---------------- Здания города ----------------
|
||||
|
||||
// Здания-рабочие места: (профессия, уровень здания). Вызывает App/CitySync, когда город изменился.
|
||||
public void SetBuildings(IEnumerable<KeyValuePair<WorkJob, int>> buildings)
|
||||
{
|
||||
var weighted = new double[WorkJobs.Count];
|
||||
Array.Clear(slots, 0, slots.Length);
|
||||
foreach (var b in buildings)
|
||||
{
|
||||
int j = (int)b.Key;
|
||||
if (j < 0 || j >= WorkJobs.Count) continue;
|
||||
int level = Math.Max(1, b.Value);
|
||||
int s = BuildingSlots(level);
|
||||
slots[j] += s;
|
||||
weighted[j] += s * BuildingEfficiency(level);
|
||||
}
|
||||
for (int j = 0; j < WorkJobs.Count; j++)
|
||||
efficiency[j] = slots[j] > 0 ? weighted[j] / slots[j] : 1.0;
|
||||
}
|
||||
|
||||
public int BuildingSlots(int level) => rules.SlotsPerBuilding + rules.SlotsPerLevel * (Math.Max(1, level) - 1);
|
||||
public double BuildingEfficiency(int level) => 1.0 + rules.EfficiencyPerLevel * (Math.Max(1, level) - 1);
|
||||
|
||||
// ---------------- Контракт IProduction ----------------
|
||||
|
||||
public int Slots(WorkJob job) => slots[(int)job];
|
||||
public float Efficiency(WorkJob job) => (float)efficiency[(int)job];
|
||||
|
||||
public float RatePerHour(ResourceType type)
|
||||
{
|
||||
if (!WorkJobs.TryFromResource(type, out WorkJob job)) return 0f;
|
||||
return (float)JobRate(job);
|
||||
}
|
||||
|
||||
double JobRate(WorkJob job)
|
||||
{
|
||||
int j = (int)job;
|
||||
if (!GameServices.TryGet(out IPopulation population)) return 0;
|
||||
int working = Math.Min(population.GetWorkers(job), slots[j]);
|
||||
double rate = working * rules.PerWorkerPerHour[j] * efficiency[j];
|
||||
if (population.IsStarving && WorkJobs.Resource(job) != ResourceType.Food)
|
||||
rate *= Math.Max(0, Math.Min(1, rules.FamineNonFoodMultiplier));
|
||||
return rate;
|
||||
}
|
||||
|
||||
public void Produce(double hours)
|
||||
{
|
||||
if (hours <= 0 || !GameServices.TryGet(out IEconomy economy)) return;
|
||||
for (int j = 0; j < WorkJobs.Count; j++)
|
||||
{
|
||||
var job = (WorkJob)j;
|
||||
pending[j] += JobRate(job) * hours;
|
||||
int whole = (int)Math.Floor(pending[j] + Epsilon);
|
||||
if (whole <= 0) continue;
|
||||
pending[j] = Math.Max(0, pending[j] - whole);
|
||||
economy.Add(WorkJobs.Resource(job), whole); // сверх лимита склада Экономика не прибавит
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------- Сохранение (секция "production") ----------------
|
||||
// slots=<места по профессиям> eff=<эффективность> pending=<доли>
|
||||
|
||||
public string SaveKey => "production";
|
||||
|
||||
public string Capture()
|
||||
{
|
||||
var sb = new StringBuilder();
|
||||
sb.Append("slots=").Append(Join(slots)).Append('\n');
|
||||
sb.Append("eff=").Append(Join(efficiency)).Append('\n');
|
||||
sb.Append("pending=").Append(Join(pending)).Append('\n');
|
||||
return sb.ToString();
|
||||
}
|
||||
|
||||
public void Restore(string data)
|
||||
{
|
||||
ResetState();
|
||||
if (string.IsNullOrEmpty(data)) return;
|
||||
foreach (var raw in data.Split('\n'))
|
||||
{
|
||||
string line = raw.Trim();
|
||||
if (line.StartsWith("slots=")) ParseInto(line.Substring(6), v => Math.Max(0, (int)v), slots);
|
||||
else if (line.StartsWith("eff=")) ParseInto(line.Substring(4), v => Math.Max(0.1, v), efficiency);
|
||||
else if (line.StartsWith("pending=")) ParseInto(line.Substring(8), v => Math.Max(0, Math.Min(0.999, v)), pending);
|
||||
}
|
||||
}
|
||||
|
||||
public void ResetState()
|
||||
{
|
||||
Array.Clear(slots, 0, slots.Length);
|
||||
Array.Clear(pending, 0, pending.Length);
|
||||
for (int j = 0; j < WorkJobs.Count; j++) efficiency[j] = 1.0;
|
||||
}
|
||||
|
||||
static string Join(int[] a)
|
||||
{
|
||||
var parts = new string[a.Length];
|
||||
for (int i = 0; i < a.Length; i++) parts[i] = a[i].ToString(CultureInfo.InvariantCulture);
|
||||
return string.Join(",", parts);
|
||||
}
|
||||
|
||||
static string Join(double[] a)
|
||||
{
|
||||
var parts = new string[a.Length];
|
||||
for (int i = 0; i < a.Length; i++) parts[i] = a[i].ToString("R", CultureInfo.InvariantCulture);
|
||||
return string.Join(",", parts);
|
||||
}
|
||||
|
||||
static void ParseInto(string csv, Func<double, int> conv, int[] target)
|
||||
{
|
||||
string[] parts = csv.Split(',');
|
||||
for (int i = 0; i < target.Length && i < parts.Length; i++)
|
||||
if (double.TryParse(parts[i], NumberStyles.Float, CultureInfo.InvariantCulture, out double v)) target[i] = conv(v);
|
||||
}
|
||||
|
||||
static void ParseInto(string csv, Func<double, double> conv, double[] target)
|
||||
{
|
||||
string[] parts = csv.Split(',');
|
||||
for (int i = 0; i < target.Length && i < parts.Length; i++)
|
||||
if (double.TryParse(parts[i], NumberStyles.Float, CultureInfo.InvariantCulture, out double v)) target[i] = conv(v);
|
||||
}
|
||||
}
|
||||
@@ -1,11 +0,0 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 040af6a37da54abcad0b6ebf2037adb0
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -1,8 +0,0 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 4b19799fef59420d948583d7bd00e661
|
||||
folderAsset: yes
|
||||
DefaultImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -1,14 +0,0 @@
|
||||
{
|
||||
"name": "Bogatyri.Intelligence",
|
||||
"rootNamespace": "",
|
||||
"references": ["Bogatyri.Core"],
|
||||
"includePlatforms": [],
|
||||
"excludePlatforms": [],
|
||||
"allowUnsafeCode": false,
|
||||
"overrideReferences": false,
|
||||
"precompiledReferences": [],
|
||||
"autoReferenced": true,
|
||||
"defineConstraints": [],
|
||||
"versionDefines": [],
|
||||
"noEngineReferences": true
|
||||
}
|
||||
@@ -1,471 +0,0 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Globalization;
|
||||
using System.Text;
|
||||
|
||||
// МОДУЛЬ РАЗВЕДКИ, этап М1: туман войны, источники обзора, чужие армии, последний контакт.
|
||||
// Документ: Docs/Modules/Intelligence.md. ТЗ разведки 31.10.2026, решение №46: отдельная сборка Bogatyri.Intelligence.
|
||||
//
|
||||
// Без Unity. Карту и армии не хранит и не двигает -- читает их у Мира через контракт IWorldMap.
|
||||
// Своё: туман (исследованные клетки), известные контакты, номера событий наблюдения. Чужие данные не меняет.
|
||||
//
|
||||
// Числа -- ТЕСТ, в игре из таблицы IntelligenceConfig.
|
||||
public sealed class IntelligenceRules
|
||||
{
|
||||
public int SettlementRadius = 5000; // обзор своего поселения: 5 клеток
|
||||
public int ArmyRadius = 1000; // обзор обычной армии: 1 клетка
|
||||
public int[] ObservationPercent = { 0, 10, 20, 30 }; // технология «Обзор» I/II/III -- не складывается, заменяет
|
||||
public long LastContactMs = 10 * 60_000; // последний контакт живёт 10 РЕАЛЬНЫХ минут
|
||||
public int ApproxPercent = 20; // примерная численность: ±20%
|
||||
public int IdentifyPercent = 50; // опознание чужого владельца: 50% на событие наблюдения
|
||||
public long SampleStepMs = 10_000; // шаг расчёта, пока игра была закрыта: 10 реальных секунд
|
||||
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();
|
||||
}
|
||||
|
||||
public sealed class IntelligenceService : IIntelligence, ISaveParticipant
|
||||
{
|
||||
// Что игрок знает о чужой армии. Номер наблюдения -- для устойчивых событий и воспроизводимых бросков
|
||||
class Contact
|
||||
{
|
||||
public string armyId;
|
||||
public string ownerId; // настоящий владелец (из Мира) -- игроку показывается только после опознания
|
||||
public bool identified;
|
||||
public bool observed;
|
||||
public int x, y, toX, toY;
|
||||
public int approxMin, approxMax;
|
||||
public long lastSeenMs;
|
||||
public int observation; // сколько раз армия входила в обзор (событие наблюдения)
|
||||
}
|
||||
|
||||
private readonly IntelligenceRules rules;
|
||||
private readonly Func<IWorldMap> worldMap;
|
||||
private readonly string localPlayer;
|
||||
private readonly Dictionary<string, Contact> contacts = new Dictionary<string, Contact>();
|
||||
private readonly List<KnownContact> contactView = new List<KnownContact>();
|
||||
private readonly List<ArmyOnMap> armies = new List<ArmyOnMap>();
|
||||
private readonly List<(int x, int y, int r)> sources = new List<(int, int, int)>();
|
||||
private readonly HashSet<string> seen = new HashSet<string>();
|
||||
private bool[] explored = Array.Empty<bool>();
|
||||
private int gridW, gridH, halfW, halfH;
|
||||
private long lastTickMs;
|
||||
private bool viewDirty = true;
|
||||
|
||||
public IntelligenceService(IntelligenceRules rules = null, Func<IWorldMap> worldMap = null, string localPlayerId = LocalPlayer.Id)
|
||||
{
|
||||
this.rules = rules ?? IntelligenceRules.Default;
|
||||
this.worldMap = worldMap ?? (() => GameServices.TryGet(out IWorldMap m) ? m : null);
|
||||
localPlayer = localPlayerId;
|
||||
}
|
||||
|
||||
public IntelligenceRules Rules => rules;
|
||||
|
||||
// ---------------- Контракт IIntelligence ----------------
|
||||
|
||||
private int observationLevel;
|
||||
public int ObservationLevel
|
||||
{
|
||||
get => observationLevel;
|
||||
set => observationLevel = Math.Max(0, Math.Min(rules.ObservationPercent.Length - 1, value));
|
||||
}
|
||||
|
||||
// Радиус с «Обзором»: процент текущего уровня, дробные радиусы без округления до клеток
|
||||
public int Radius(int baseRadius)
|
||||
{
|
||||
return (int)((long)baseRadius * (100 + rules.ObservationPercent[observationLevel]) / 100);
|
||||
}
|
||||
|
||||
public MapVisibility GetVisibility(int x, int y)
|
||||
{
|
||||
foreach (var s in sources)
|
||||
if (Inside(x, y, s.x, s.y, s.r)) return MapVisibility.Observed;
|
||||
int cell = Cell(x, y);
|
||||
return cell >= 0 && explored[cell] ? MapVisibility.Explored : MapVisibility.Unexplored;
|
||||
}
|
||||
|
||||
public IReadOnlyList<KnownContact> Contacts
|
||||
{
|
||||
get
|
||||
{
|
||||
if (viewDirty)
|
||||
{
|
||||
contactView.Clear();
|
||||
foreach (var c in contacts.Values)
|
||||
contactView.Add(new KnownContact
|
||||
{
|
||||
ArmyId = c.armyId,
|
||||
Identified = c.identified,
|
||||
OwnerId = c.identified ? c.ownerId : null,
|
||||
Observed = c.observed,
|
||||
X = c.x, Y = c.y, ToX = c.toX, ToY = c.toY,
|
||||
ApproxMin = c.approxMin, ApproxMax = c.approxMax,
|
||||
LastSeenMs = c.lastSeenMs
|
||||
});
|
||||
viewDirty = false;
|
||||
}
|
||||
return contactView;
|
||||
}
|
||||
}
|
||||
|
||||
// Наблюдение от прошлого расчёта до nowMs шагами SampleStepMs: армия, прошедшая через обзор,
|
||||
// пока игра была закрыта, тоже будет замечена -- в хронологическом порядке (ТЗ, приёмка 9)
|
||||
public void ObserveUntil(long nowMs)
|
||||
{
|
||||
IWorldMap map = worldMap();
|
||||
if (map == null) return;
|
||||
EnsureGrid(map);
|
||||
|
||||
long from = lastTickMs == 0 ? nowMs : Math.Max(lastTickMs, nowMs - rules.MaxCatchUpMs);
|
||||
if (nowMs < from) return; // время не идёт назад: повторный расчёт ничего не меняет
|
||||
for (long t = from + rules.SampleStepMs; t < nowMs; t += rules.SampleStepMs) Sample(map, t);
|
||||
Sample(map, nowMs);
|
||||
lastTickMs = nowMs;
|
||||
|
||||
// Последний контакт стареет и удаляется через LastContactMs (ТЗ, раздел 2)
|
||||
var expired = new List<string>();
|
||||
foreach (var c in contacts.Values)
|
||||
if (!c.observed && nowMs - c.lastSeenMs >= rules.LastContactMs) expired.Add(c.armyId);
|
||||
foreach (var id in expired) contacts.Remove(id);
|
||||
if (expired.Count > 0) viewDirty = true;
|
||||
}
|
||||
|
||||
void Sample(IWorldMap map, long t)
|
||||
{
|
||||
armies.Clear();
|
||||
map.GetArmies(t, armies);
|
||||
|
||||
// Источники обзора: своё поселение и свои армии (башни и герой-разведчик -- позже)
|
||||
sources.Clear();
|
||||
if (map.TryGetSettlement(localPlayer, out int sx, out int sy)) sources.Add((sx, sy, Radius(rules.SettlementRadius)));
|
||||
foreach (var a in armies)
|
||||
if (a.OwnerId == localPlayer) sources.Add((a.X, a.Y, Radius(rules.ArmyRadius)));
|
||||
foreach (var s in sources) Explore(s.x, s.y, s.r);
|
||||
|
||||
seen.Clear();
|
||||
foreach (var a in armies)
|
||||
{
|
||||
if (a.OwnerId == localPlayer) continue; // свои -- не разведка
|
||||
bool inView = false;
|
||||
foreach (var s in sources) if (Inside(a.X, a.Y, s.x, s.y, s.r)) { inView = true; break; }
|
||||
if (!inView) continue;
|
||||
seen.Add(a.Id);
|
||||
Observe(a, t);
|
||||
}
|
||||
|
||||
// Ушла из обзора (или исчезла с карты) -- контакт потерян, данные больше не обновляются
|
||||
foreach (var c in contacts.Values)
|
||||
{
|
||||
if (!c.observed || seen.Contains(c.armyId)) continue;
|
||||
c.observed = false;
|
||||
viewDirty = true;
|
||||
EventBus.Publish(new ArmyLostContactEvent { EventId = EventId(c), ArmyId = c.armyId, TimeMs = t });
|
||||
}
|
||||
}
|
||||
|
||||
void Observe(ArmyOnMap a, long t)
|
||||
{
|
||||
if (!contacts.TryGetValue(a.Id, out Contact c))
|
||||
{
|
||||
c = new Contact { armyId = a.Id, ownerId = a.OwnerId };
|
||||
contacts[a.Id] = c;
|
||||
}
|
||||
|
||||
if (!c.observed)
|
||||
{
|
||||
// Новое событие наблюдения: примерная численность и бросок опознания -- один раз на событие
|
||||
c.observed = true;
|
||||
c.observation++;
|
||||
int width = Math.Max(1, a.Soldiers * 2 * rules.ApproxPercent / 100); // ±20% -> окно 40%
|
||||
int shift = (int)(Roll(a.Id, c.observation, 1) % (ulong)(width + 1));
|
||||
c.approxMin = Math.Max(1, a.Soldiers - shift); // настоящая численность всегда внутри окна
|
||||
c.approxMax = c.approxMin + width;
|
||||
EventBus.Publish(new ArmySpottedEvent
|
||||
{ EventId = EventId(c), ArmyId = a.Id, TimeMs = t, ApproxMin = c.approxMin, ApproxMax = c.approxMax });
|
||||
|
||||
if (!c.identified && Roll(a.Id, c.observation, 2) % 100 < (ulong)rules.IdentifyPercent)
|
||||
{
|
||||
c.identified = true;
|
||||
EventBus.Publish(new ArmyIdentifiedEvent { EventId = EventId(c), ArmyId = a.Id, OwnerId = a.OwnerId, TimeMs = t });
|
||||
}
|
||||
}
|
||||
|
||||
c.x = a.X; c.y = a.Y; c.toX = a.ToX; c.toY = a.ToY;
|
||||
c.lastSeenMs = t;
|
||||
viewDirty = true;
|
||||
}
|
||||
|
||||
static string EventId(Contact c) => c.armyId + "#" + c.observation.ToString(CultureInfo.InvariantCulture);
|
||||
|
||||
// Детерминированный бросок: армия + номер наблюдения + назначение. Повторный расчёт, загрузка или клик
|
||||
// не перебрасывают -- одно событие наблюдения, один результат (ТЗ, приёмка 5)
|
||||
static ulong Roll(string armyId, int observation, int purpose)
|
||||
{
|
||||
ulong h = 14695981039346656037UL;
|
||||
foreach (char ch in armyId) h = (h ^ ch) * 1099511628211UL;
|
||||
h ^= (ulong)observation * 0x9E3779B97F4A7C15UL + (ulong)purpose;
|
||||
h = (h ^ (h >> 30)) * 0xBF58476D1CE4E5B9UL;
|
||||
h = (h ^ (h >> 27)) * 0x94D049BB133111EBUL;
|
||||
return h ^ (h >> 31);
|
||||
}
|
||||
|
||||
// ---------------- Туман: исследованные клетки ----------------
|
||||
|
||||
void EnsureGrid(IWorldMap map)
|
||||
{
|
||||
if (map.HalfWidth == halfW && map.HalfHeight == halfH && explored.Length > 0) return;
|
||||
halfW = map.HalfWidth;
|
||||
halfH = map.HalfHeight;
|
||||
gridW = Math.Max(1, (2 * halfW + rules.CellSize - 1) / rules.CellSize);
|
||||
gridH = Math.Max(1, (2 * halfH + rules.CellSize - 1) / rules.CellSize);
|
||||
var old = explored;
|
||||
explored = new bool[gridW * gridH];
|
||||
if (old.Length == explored.Length) Array.Copy(old, explored, old.Length);
|
||||
}
|
||||
|
||||
int Cell(int x, int y)
|
||||
{
|
||||
if (gridW == 0) return -1;
|
||||
int cx = (x + halfW) / rules.CellSize, cy = (y + halfH) / rules.CellSize;
|
||||
if (cx < 0 || cy < 0 || cx >= gridW || cy >= gridH) return -1;
|
||||
return cy * gridW + cx;
|
||||
}
|
||||
|
||||
// Исследована клетка, центр которой попал в круг обзора
|
||||
void Explore(int x, int y, int r)
|
||||
{
|
||||
int c = rules.CellSize;
|
||||
for (int cy = Math.Max(0, (y - r + halfH) / c); cy <= Math.Min(gridH - 1, (y + r + halfH) / c); cy++)
|
||||
for (int cx = Math.Max(0, (x - r + halfW) / c); cx <= Math.Min(gridW - 1, (x + r + halfW) / c); cx++)
|
||||
{
|
||||
int px = cx * c + c / 2 - halfW, py = cy * c + c / 2 - halfH;
|
||||
if (Inside(px, py, x, y, r)) explored[cy * gridW + cx] = true;
|
||||
}
|
||||
}
|
||||
|
||||
static bool Inside(int x, int y, int cx, int cy, int r)
|
||||
{
|
||||
long dx = x - cx, dy = y - cy;
|
||||
return dx * dx + dy * dy <= (long)r * r;
|
||||
}
|
||||
|
||||
public int ExploredCells
|
||||
{
|
||||
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";
|
||||
|
||||
public string Capture()
|
||||
{
|
||||
var sb = new StringBuilder();
|
||||
sb.Append("tick=").Append(lastTickMs.ToString(CultureInfo.InvariantCulture)).Append('\n');
|
||||
sb.Append("obzor=").Append(observationLevel.ToString(CultureInfo.InvariantCulture)).Append('\n');
|
||||
sb.Append("grid=").Append(gridW).Append('x').Append(gridH).Append(':').Append(PackBits(explored)).Append('\n');
|
||||
foreach (var c in contacts.Values)
|
||||
sb.Append("contact=").Append(string.Join("|", new[]
|
||||
{
|
||||
c.armyId, c.ownerId ?? "", c.identified ? "1" : "0", c.observed ? "1" : "0",
|
||||
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();
|
||||
}
|
||||
|
||||
public void Restore(string data)
|
||||
{
|
||||
ResetState();
|
||||
if (string.IsNullOrEmpty(data)) return;
|
||||
foreach (var raw in data.Split('\n'))
|
||||
{
|
||||
string line = raw.Trim();
|
||||
if (line.StartsWith("tick=")) long.TryParse(line.Substring(5), NumberStyles.Integer, CultureInfo.InvariantCulture, out lastTickMs);
|
||||
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;
|
||||
}
|
||||
|
||||
public void ResetState()
|
||||
{
|
||||
contacts.Clear();
|
||||
reports.Clear();
|
||||
explored = Array.Empty<bool>();
|
||||
gridW = gridH = halfW = halfH = 0;
|
||||
lastTickMs = 0;
|
||||
observationLevel = 0;
|
||||
sources.Clear();
|
||||
viewDirty = true;
|
||||
}
|
||||
|
||||
void RestoreGrid(string s)
|
||||
{
|
||||
int x = s.IndexOf('x'), colon = s.IndexOf(':');
|
||||
if (x < 0 || colon < x) return;
|
||||
if (!int.TryParse(s.Substring(0, x), out int w) || !int.TryParse(s.Substring(x + 1, colon - x - 1), out int h)) return;
|
||||
if (w <= 0 || h <= 0) return;
|
||||
gridW = w; gridH = h;
|
||||
explored = UnpackBits(s.Substring(colon + 1), w * h);
|
||||
// halfW/halfH уточнит EnsureGrid при первом расчёте; размер сетки совпадёт -- клетки сохранятся
|
||||
}
|
||||
|
||||
void RestoreContact(string[] p)
|
||||
{
|
||||
if (p.Length < 12) return;
|
||||
var c = new Contact
|
||||
{
|
||||
armyId = p[0], ownerId = p[1].Length > 0 ? p[1] : null,
|
||||
identified = p[2] == "1", observed = p[3] == "1",
|
||||
x = I(p[4]), y = I(p[5]), toX = I(p[6]), toY = I(p[7]),
|
||||
approxMin = I(p[8]), approxMax = I(p[9]),
|
||||
observation = I(p[11])
|
||||
};
|
||||
long.TryParse(p[10], NumberStyles.Integer, CultureInfo.InvariantCulture, out c.lastSeenMs);
|
||||
if (!string.IsNullOrEmpty(c.armyId)) contacts[c.armyId] = c;
|
||||
}
|
||||
|
||||
static string N(int v) => v.ToString(CultureInfo.InvariantCulture);
|
||||
static int I(string s) => int.TryParse(s, NumberStyles.Integer, CultureInfo.InvariantCulture, out int v) ? v : 0;
|
||||
|
||||
static string PackBits(bool[] bits)
|
||||
{
|
||||
var sb = new StringBuilder();
|
||||
for (int i = 0; i < bits.Length; i += 4)
|
||||
{
|
||||
int v = 0;
|
||||
for (int k = 0; k < 4 && i + k < bits.Length; k++) if (bits[i + k]) v |= 1 << k;
|
||||
sb.Append("0123456789abcdef"[v]);
|
||||
}
|
||||
return sb.ToString();
|
||||
}
|
||||
|
||||
static bool[] UnpackBits(string hex, int count)
|
||||
{
|
||||
var bits = new bool[count];
|
||||
for (int i = 0; i < hex.Length && i * 4 < count; i++)
|
||||
{
|
||||
int v = "0123456789abcdef".IndexOf(char.ToLowerInvariant(hex[i]));
|
||||
if (v < 0) continue;
|
||||
for (int k = 0; k < 4 && i * 4 + k < count; k++) bits[i * 4 + k] = (v & (1 << k)) != 0;
|
||||
}
|
||||
return bits;
|
||||
}
|
||||
}
|
||||
@@ -1,2 +0,0 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 6be01b8e525a455593482d7159414485
|
||||
@@ -1,8 +0,0 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 1df99e6793644da19a8df1e9da262cd6
|
||||
folderAsset: yes
|
||||
DefaultImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -1,14 +0,0 @@
|
||||
{
|
||||
"name": "Bogatyri.Population",
|
||||
"rootNamespace": "",
|
||||
"references": ["Bogatyri.Core"],
|
||||
"includePlatforms": [],
|
||||
"excludePlatforms": [],
|
||||
"allowUnsafeCode": false,
|
||||
"overrideReferences": false,
|
||||
"precompiledReferences": [],
|
||||
"autoReferenced": true,
|
||||
"defineConstraints": [],
|
||||
"versionDefines": [],
|
||||
"noEngineReferences": true
|
||||
}
|
||||
@@ -1,7 +0,0 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 253ae5cb48ed4a4a8153210ae03920f0
|
||||
AssemblyDefinitionImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -1,39 +0,0 @@
|
||||
// МОДУЛЬ НАСЕЛЕНИЯ. Документ: Docs/Modules/Population.md
|
||||
// Правила населения -- только числа, без Unity. Заполняются из EconomyConfig (App/GameModules),
|
||||
// в тестах -- напрямую. Значения по умолчанию -- из дизайн-документа (этап 2 плана разработки).
|
||||
public sealed class PopulationRules
|
||||
{
|
||||
// Стартовый город T1: 300 человек = 120 рабочих + 30 воинов + 150 остальных.
|
||||
// Доля способных работать (120 из 270 жителей) сохраняется и при росте города.
|
||||
public int StartTotal = 300;
|
||||
public int StartWorkers = 120;
|
||||
public int StartSoldiers = 30;
|
||||
|
||||
// Еда в час
|
||||
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;
|
||||
|
||||
// Предел населения (жильё). Решение команды от 19.10.2026: 300 жителей при пределе 300 -- прирост стоит,
|
||||
// построен Дом -- прирост идёт. Прибавки за Дом и Ратушу НЕ утверждены командой.
|
||||
public int HousingBase = 300;
|
||||
public int HousingPerTownHallLevel = 50;
|
||||
public int HousingPerHouseLevel = 50; // каждый уровень Дома (здание "Население")
|
||||
|
||||
// Найм воинов: воины набираются из свободных (не работающих) жителей, гражданский резерв не отдаётся.
|
||||
// Резерв зависит от уровня Ратуши (регламент 31.10, ТЕСТ): уровни 1-2 -- 10, 3-5 -- 20, 6-10 -- 30.
|
||||
// Элемент [i] -- резерв на уровне Ратуши i+1; выше последнего уровня -- последнее число.
|
||||
public int[] CivilianReserveByTownHallLevel = { 10, 10, 20, 20, 20, 30, 30, 30, 30, 30 };
|
||||
public int PeoplePerSoldier = 1; // сколько жителей стоит один воин (EconomyConfig, раздел "Солдат")
|
||||
|
||||
// Офлайн-прогресс досчитывается не больше чем за столько часов. Число НЕ утверждено.
|
||||
public int MaxOfflineHours = 48;
|
||||
|
||||
public int StartCivilians => StartTotal - StartSoldiers;
|
||||
}
|
||||
@@ -1,11 +0,0 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 30bbbd3ca6154dda923f86163c3b90a7
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -1,535 +0,0 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Globalization;
|
||||
using System.Text;
|
||||
|
||||
// МОДУЛЬ НАСЕЛЕНИЯ: сколько в городе людей, кто где работает, сколько едят и как прирастают.
|
||||
// Документ: Docs/Modules/Population.md. Без Unity: правила те же, что будут на сервере.
|
||||
//
|
||||
// Люди города = жители (этот модуль) + воины (модуль Армии). Воин -- тоже человек: он ест (0.2 еды в час)
|
||||
// и входит в общее население, но хранится в Армии, чтобы один человек не был записан в двух местах.
|
||||
// Жители: работать может любой житель (общего ограничения нет, регламент 31.10) -- рабочих ограничивают
|
||||
// свободные жители и рабочие места в зданиях. Стартовый город: 120 рабочих из 270 жителей.
|
||||
//
|
||||
// Каждый человек ровно в одной группе (регламент 31.10):
|
||||
// свободный житель = Civilians - Workers (Idle)
|
||||
// рабочий = Workers (jobs[], только из жителей: SetWorkers берёт не больше свободных)
|
||||
// новобранец = InTraining (TryRecruit забирает только свободных, рабочих не трогает)
|
||||
// воин = Soldiers (хранит Армия: в городе, в походах и раненые; RecruitsTrained убирает новобранцев до зачисления в Армию)
|
||||
// Total = Civilians + InTraining + Soldiers -- сумма всех групп.
|
||||
// Рабочие места сами не заполняются: только стартовая расстановка (AutoFillAll, один раз), дальше -- игрок.
|
||||
// Найм воина: свободный житель уходит в Казармы (InTraining), после обучения его считает Армия. Резерв не нанять.
|
||||
//
|
||||
// Время -- только из GameClock. Расчёт идёт кусками по 10 минут: раз в несколько секунд в игре,
|
||||
// при входе в игру (офлайн-прогресс) и при перемотке в отладочной панели -- правила одни и те же.
|
||||
// В каждом куске: сначала добыча (IProduction), потом еда, потом прирост.
|
||||
public sealed class PopulationService : IPopulation, ISaveParticipant
|
||||
{
|
||||
const long SecondsPerHour = 3600;
|
||||
const long StepSeconds = 600; // офлайн-время считаем кусками по 10 минут -- так же, как в игре
|
||||
const double Epsilon = 1e-9;
|
||||
|
||||
readonly PopulationRules rules;
|
||||
|
||||
int civilians;
|
||||
readonly int[] jobs = new int[WorkJobs.Count]; // рабочих по профессиям
|
||||
bool jobsInitialized; // рабочих хоть раз расставляли (старому сохранению расставим сами)
|
||||
int houseLevels; // сумма уровней Домов в городе (сообщает App/CitySync)
|
||||
double growthProgress; // накопленная доля следующего человека (0..1)
|
||||
double foodOwed; // съедено, но ещё не списано со склада (меньше 1 еды)
|
||||
long lastTickUnix; // до какого момента население уже посчитано; 0 -- ещё ни разу
|
||||
bool starving;
|
||||
int inTraining; // люди, взятые из жителей в Казармы и ещё не обученные
|
||||
// История голода: отрезки [начало, конец) в Unix-секундах, конец 0 -- голод идёт сейчас.
|
||||
// Нужна Бою: поход мог дойти до цели, пока игра была закрыта, и голод считается на момент боя.
|
||||
readonly List<long[]> famine = new List<long[]>();
|
||||
bool startingKitGiven; // стартовое войско и ресурсы новой игры уже выданы (или это старое сохранение)
|
||||
|
||||
public PopulationService(PopulationRules rules)
|
||||
{
|
||||
this.rules = rules ?? new PopulationRules();
|
||||
SetStartState();
|
||||
}
|
||||
|
||||
public PopulationRules Rules => rules;
|
||||
public long LastTickUnix => lastTickUnix;
|
||||
public bool RestoredFromSave { get; private set; }
|
||||
public bool JobsInitialized => jobsInitialized;
|
||||
public int HouseLevels => houseLevels;
|
||||
|
||||
// ---------------- Контракт IPopulation ----------------
|
||||
|
||||
public int Civilians => civilians;
|
||||
|
||||
public int Workers
|
||||
{
|
||||
get { int n = 0; foreach (var j in jobs) n += j; return n; }
|
||||
}
|
||||
|
||||
public int Idle => Math.Max(0, civilians - Workers);
|
||||
|
||||
// Работать может любой житель: общего ограничения нет (регламент 31.10).
|
||||
// Рабочих ограничивают свободные жители и рабочие места (IProduction.Slots).
|
||||
public int Workforce => civilians;
|
||||
|
||||
public int FreeWorkers => Math.Max(0, Workforce - Workers);
|
||||
|
||||
public int Soldiers
|
||||
{
|
||||
get
|
||||
{
|
||||
if (!GameServices.TryGet(out IArmy army)) return 0;
|
||||
// раненые -- тоже люди (решения 31.10, п. 7); пропавшие без вести занимают места до возвращения (решения 01.11, п. 5)
|
||||
return army.SoldiersAtHome + army.TroopsInMarches + army.WoundedTotal + army.MissingTotal;
|
||||
}
|
||||
}
|
||||
|
||||
public int Total => civilians + inTraining + Soldiers;
|
||||
|
||||
// ---------------- Найм воинов ----------------
|
||||
|
||||
public int InTraining => inTraining;
|
||||
|
||||
public int CivilianReserve
|
||||
{
|
||||
get
|
||||
{
|
||||
int level = GameServices.TryGet(out ISettlement settlement) ? Math.Max(1, settlement.TownHallLevel) : 1;
|
||||
int[] table = rules.CivilianReserveByTownHallLevel;
|
||||
if (table == null || table.Length == 0) return 0;
|
||||
return Math.Max(0, table[Math.Min(level, table.Length) - 1]);
|
||||
}
|
||||
}
|
||||
|
||||
// Берём только свободных (Idle -- не на работе): рабочих сначала надо снять с профессии
|
||||
public int Recruitable => Math.Max(0, Idle - CivilianReserve) / Math.Max(1, rules.PeoplePerSoldier);
|
||||
|
||||
public bool TryRecruit(int soldiers)
|
||||
{
|
||||
if (soldiers <= 0 || soldiers > Recruitable) return false;
|
||||
int people = soldiers * Math.Max(1, rules.PeoplePerSoldier);
|
||||
civilians -= people;
|
||||
inTraining += people;
|
||||
ClampJobs(); // жителей меньше -- рабочих тоже может стать меньше
|
||||
Changed();
|
||||
return true;
|
||||
}
|
||||
|
||||
public bool CompleteTraining(int troopType, int soldiers)
|
||||
{
|
||||
if (soldiers <= 0) return false;
|
||||
int people = soldiers * Math.Max(1, rules.PeoplePerSoldier);
|
||||
if (people > inTraining) return false; // столько новобранцев нет (повтор или ошибка)
|
||||
if (!GameServices.TryGet(out IArmy army)) return false;
|
||||
|
||||
// Сначала убираем из Казарм, потом отдаём в Армию: событие Армии увидит уже правильную сумму.
|
||||
// Армия не приняла -- возвращаем новобранцев, как было.
|
||||
inTraining -= people;
|
||||
int before = army.SoldiersAtHome;
|
||||
army.AddTroops(troopType, soldiers);
|
||||
if (army.SoldiersAtHome - before != soldiers)
|
||||
{
|
||||
inTraining += people;
|
||||
return false;
|
||||
}
|
||||
Changed();
|
||||
return true;
|
||||
}
|
||||
|
||||
public void RecruitsTrained(int soldiers)
|
||||
{
|
||||
if (soldiers <= 0) return;
|
||||
// Заказы, начатые до найма из жителей (старое сохранение), людей не брали -- уходить в минус нельзя
|
||||
inTraining = Math.Max(0, inTraining - soldiers * Math.Max(1, rules.PeoplePerSoldier));
|
||||
Changed();
|
||||
}
|
||||
|
||||
public int HousingCap
|
||||
{
|
||||
get
|
||||
{
|
||||
int level = GameServices.TryGet(out ISettlement settlement) ? Math.Max(1, settlement.TownHallLevel) : 1;
|
||||
return rules.HousingBase + rules.HousingPerTownHallLevel * (level - 1) + rules.HousingPerHouseLevel * houseLevels;
|
||||
}
|
||||
}
|
||||
|
||||
public float FoodPerHour => (float)FoodPerHourExact(Soldiers);
|
||||
public float GrowthPerHour => starving || Total >= HousingCap ? 0f : (float)rules.GrowthPerHour;
|
||||
public bool IsStarving => starving;
|
||||
|
||||
public bool WasStarvingAt(long unixTime)
|
||||
{
|
||||
if (lastTickUnix == 0 || unixTime >= lastTickUnix) return starving; // ещё не посчитано -- как сейчас
|
||||
foreach (var f in famine)
|
||||
if (unixTime >= f[0] && (f[1] == 0 || unixTime < f[1])) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
// Голод начался или кончился в момент unix (вызывается из расчёта на каждом шаге)
|
||||
void RecordFamine(bool nowStarving, long unix)
|
||||
{
|
||||
long[] open = famine.Count > 0 && famine[famine.Count - 1][1] == 0 ? famine[famine.Count - 1] : null;
|
||||
if (nowStarving && open == null) famine.Add(new[] { unix, 0L });
|
||||
else if (!nowStarving && open != null) open[1] = unix;
|
||||
}
|
||||
|
||||
// Отрезки, закончившиеся раньше, чем 48 часов назад, Бою уже не понадобятся
|
||||
void PruneFamine(long unixNow)
|
||||
{
|
||||
long keepFrom = unixNow - (long)rules.MaxOfflineHours * SecondsPerHour;
|
||||
famine.RemoveAll(f => f[1] != 0 && f[1] <= keepFrom);
|
||||
}
|
||||
|
||||
double FoodPerHourExact(int soldiers)
|
||||
{
|
||||
// Обучающиеся в Казармах едят как воины
|
||||
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)
|
||||
{
|
||||
int j = (int)job;
|
||||
return j >= 0 && j < WorkJobs.Count ? jobs[j] : 0;
|
||||
}
|
||||
|
||||
public int SetWorkers(WorkJob job, int count)
|
||||
{
|
||||
int j = (int)job;
|
||||
if (j < 0 || j >= WorkJobs.Count) return 0;
|
||||
|
||||
int limit = jobs[j] + FreeWorkers; // только свободные жители: не воины, не новобранцы, не уже работающие
|
||||
int slots = SlotsFor(job);
|
||||
if (slots >= 0) limit = Math.Min(limit, slots); // и больше, чем мест в зданиях
|
||||
int value = Math.Max(0, Math.Min(count, limit));
|
||||
if (value != jobs[j])
|
||||
{
|
||||
jobs[j] = value;
|
||||
jobsInitialized = true;
|
||||
WorkersChanged();
|
||||
}
|
||||
return jobs[j];
|
||||
}
|
||||
|
||||
// Мест для профессии: -1 -- модуль добычи не подключён (тесты без него), ограничения нет
|
||||
static int SlotsFor(WorkJob job)
|
||||
{
|
||||
return GameServices.TryGet(out IProduction production) ? production.Slots(job) : -1;
|
||||
}
|
||||
|
||||
public void Tick(long unixNow)
|
||||
{
|
||||
if (lastTickUnix == 0)
|
||||
{
|
||||
lastTickUnix = unixNow; // первый расчёт: отсюда и считаем
|
||||
Changed();
|
||||
return;
|
||||
}
|
||||
|
||||
long elapsed = unixNow - lastTickUnix;
|
||||
if (elapsed < 0)
|
||||
{
|
||||
// Часы ушли назад (сброс отладочной перемотки, перевод часов на телефоне) -- просто продолжаем с "сейчас"
|
||||
lastTickUnix = unixNow;
|
||||
return;
|
||||
}
|
||||
if (elapsed == 0) return;
|
||||
|
||||
long max = (long)rules.MaxOfflineHours * SecondsPerHour;
|
||||
if (elapsed > max) elapsed = max;
|
||||
|
||||
Simulate(unixNow - elapsed, elapsed);
|
||||
lastTickUnix = unixNow;
|
||||
PruneFamine(unixNow);
|
||||
}
|
||||
|
||||
// ---------------- Расчёт ----------------
|
||||
|
||||
void Simulate(long startUnix, long seconds)
|
||||
{
|
||||
if (!GameServices.TryGet(out IEconomy economy)) return; // без склада считать нечего
|
||||
GameServices.TryGet(out IProduction production);
|
||||
|
||||
int totalBefore = Total;
|
||||
bool starvingBefore = starving;
|
||||
int soldiers = Soldiers;
|
||||
|
||||
long remaining = seconds;
|
||||
long stepStart = startUnix;
|
||||
while (remaining > 0)
|
||||
{
|
||||
long step = Math.Min(remaining, StepSeconds);
|
||||
remaining -= step;
|
||||
double hours = step / (double)SecondsPerHour;
|
||||
|
||||
// 0. Добыча: рабочие приносят ресурсы (в том числе еду) -- до того, как город поест
|
||||
production?.Produce(hours);
|
||||
|
||||
// 1. Едим. Со склада списываются только целые единицы, доли копятся здесь (foodOwed):
|
||||
// склад хранит float, и тысячи мелких списаний по 0.02 давали бы ошибку округления.
|
||||
foodOwed += FoodPerHourExact(soldiers) * hours;
|
||||
int portion = (int)Math.Floor(foodOwed + Epsilon);
|
||||
double have = economy.Get(ResourceType.Food);
|
||||
bool fed;
|
||||
if (portion <= 0)
|
||||
{
|
||||
fed = have >= foodOwed - Epsilon; // пока едим из уже взятого -- голод, только если склад пуст
|
||||
}
|
||||
else if (have >= portion)
|
||||
{
|
||||
economy.Add(ResourceType.Food, -portion);
|
||||
foodOwed -= portion;
|
||||
fed = true;
|
||||
}
|
||||
else
|
||||
{
|
||||
int rest = (int)Math.Floor(have);
|
||||
if (rest > 0) economy.Add(ResourceType.Food, -rest); // доедаем остатки
|
||||
foodOwed = 0; // долг за голодное время не копим
|
||||
fed = false;
|
||||
}
|
||||
starving = !fed;
|
||||
RecordFamine(starving, stepStart); // голод начался (кончился) в этом куске -- запоминаем его начало
|
||||
stepStart += step;
|
||||
|
||||
// 2. Прирост -- только сытым и только до предела жилья. Что ещё бывает при голоде -- решит команда.
|
||||
if (fed)
|
||||
{
|
||||
int room = HousingCap - (civilians + inTraining + soldiers);
|
||||
if (room > 0)
|
||||
{
|
||||
growthProgress += rules.GrowthPerHour * hours;
|
||||
int born = (int)Math.Floor(growthProgress + Epsilon);
|
||||
if (born > 0)
|
||||
{
|
||||
growthProgress = Math.Max(0, growthProgress - born);
|
||||
civilians += Math.Min(born, room);
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
growthProgress = 0; // упёрлись в предел -- не копим "запас" людей
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (starving != starvingBefore) EventBus.Publish(new StarvationChangedEvent { IsStarving = starving });
|
||||
if (Total != totalBefore) Changed();
|
||||
}
|
||||
|
||||
// ---------------- Рабочие: расстановка ----------------
|
||||
|
||||
// Стартовая расстановка -- единственное автоматическое заполнение мест (регламент 31.10).
|
||||
// Один раз: в новой игре или при первой загрузке старого сохранения (пока JobsInitialized == false).
|
||||
// Свободных -- поровну по всем местам, всего не больше StartWorkers (120). Новые места после стройки
|
||||
// и улучшения остаются пустыми, пока игрок сам не поставит рабочих.
|
||||
public void AutoFillAll()
|
||||
{
|
||||
bool changed = false;
|
||||
bool progress = true;
|
||||
int cap = Math.Max(Workers, rules.StartWorkers);
|
||||
while (FreeWorkers > 0 && Workers < cap && progress)
|
||||
{
|
||||
progress = false;
|
||||
for (int j = 0; j < WorkJobs.Count && FreeWorkers > 0 && Workers < cap; j++)
|
||||
{
|
||||
int slots = SlotsFor((WorkJob)j);
|
||||
if (slots >= 0 && jobs[j] >= slots) continue;
|
||||
jobs[j]++;
|
||||
progress = changed = true;
|
||||
}
|
||||
}
|
||||
jobsInitialized = true;
|
||||
if (changed) WorkersChanged();
|
||||
}
|
||||
|
||||
// Мест стало меньше или работников меньше (призыв в будущем) -- снять лишних
|
||||
public void ClampJobs()
|
||||
{
|
||||
bool changed = false;
|
||||
for (int j = 0; j < WorkJobs.Count; j++)
|
||||
{
|
||||
int slots = SlotsFor((WorkJob)j);
|
||||
if (slots >= 0 && jobs[j] > slots) { jobs[j] = slots; changed = true; }
|
||||
}
|
||||
int over = Workers - Workforce;
|
||||
for (int j = WorkJobs.Count - 1; j >= 0 && over > 0; j--)
|
||||
{
|
||||
int take = Math.Min(over, jobs[j]);
|
||||
jobs[j] -= take; over -= take;
|
||||
if (take > 0) changed = true;
|
||||
}
|
||||
if (changed) WorkersChanged();
|
||||
}
|
||||
|
||||
// Сумма уровней Домов (каждый уровень прибавляет к пределу жилья)
|
||||
public void SetHouseLevels(int levels)
|
||||
{
|
||||
levels = Math.Max(0, levels);
|
||||
if (levels == houseLevels) return;
|
||||
houseLevels = levels;
|
||||
Changed();
|
||||
}
|
||||
|
||||
void WorkersChanged()
|
||||
{
|
||||
EventBus.Publish(new WorkersChangedEvent { Workers = Workers, FreeWorkers = FreeWorkers });
|
||||
}
|
||||
|
||||
// ---------------- Связь с остальной игрой (вызывает App/GameModules) ----------------
|
||||
|
||||
// Сохранение загружено: досчитать офлайн-время. Старое сохранение без секции населения -- стартовый набор не выдаём.
|
||||
public void OnGameLoaded(long unixNow)
|
||||
{
|
||||
startingKitGiven = true;
|
||||
Tick(unixNow);
|
||||
Changed();
|
||||
}
|
||||
|
||||
// Новая игра (нет файла) или сброс: стартовый набор (войско, ресурсы) выдаётся один раз.
|
||||
// true -- выдать сейчас; false -- уже выдан или это старое сохранение.
|
||||
public bool TryTakeStartingKit()
|
||||
{
|
||||
if (startingKitGiven) return false;
|
||||
startingKitGiven = true;
|
||||
return true;
|
||||
}
|
||||
|
||||
// Изменилась Армия, Ратуша или Дома -- пересчитать общее число людей для интерфейса
|
||||
public void Changed()
|
||||
{
|
||||
EventBus.Publish(new PopulationChangedEvent
|
||||
{
|
||||
Total = Total,
|
||||
Civilians = civilians,
|
||||
Soldiers = Soldiers,
|
||||
HousingCap = HousingCap
|
||||
});
|
||||
}
|
||||
|
||||
void SetStartState()
|
||||
{
|
||||
civilians = Math.Max(0, rules.StartCivilians);
|
||||
Array.Clear(jobs, 0, jobs.Length);
|
||||
jobsInitialized = false;
|
||||
houseLevels = 0;
|
||||
growthProgress = 0;
|
||||
foodOwed = 0;
|
||||
lastTickUnix = 0;
|
||||
starving = false;
|
||||
inTraining = 0;
|
||||
famine.Clear();
|
||||
startingKitGiven = false;
|
||||
}
|
||||
|
||||
// ---------------- Сохранение (секция "population") ----------------
|
||||
// Модуль без Unity, поэтому формат -- простые строки "ключ=значение", как у Армии.
|
||||
|
||||
public string SaveKey => "population";
|
||||
|
||||
public string Capture()
|
||||
{
|
||||
var sb = new StringBuilder();
|
||||
sb.Append("civilians=").Append(civilians.ToString(CultureInfo.InvariantCulture)).Append('\n');
|
||||
var parts = new string[jobs.Length];
|
||||
for (int i = 0; i < jobs.Length; i++) parts[i] = jobs[i].ToString(CultureInfo.InvariantCulture);
|
||||
sb.Append("jobs=").Append(string.Join(",", parts)).Append('\n');
|
||||
sb.Append("jobsInit=").Append(jobsInitialized ? "1" : "0").Append('\n');
|
||||
sb.Append("houses=").Append(houseLevels.ToString(CultureInfo.InvariantCulture)).Append('\n');
|
||||
sb.Append("growth=").Append(growthProgress.ToString("R", CultureInfo.InvariantCulture)).Append('\n');
|
||||
sb.Append("food=").Append(foodOwed.ToString("R", CultureInfo.InvariantCulture)).Append('\n');
|
||||
sb.Append("lastTick=").Append(lastTickUnix.ToString(CultureInfo.InvariantCulture)).Append('\n');
|
||||
sb.Append("starving=").Append(starving ? "1" : "0").Append('\n');
|
||||
sb.Append("training=").Append(inTraining.ToString(CultureInfo.InvariantCulture)).Append('\n');
|
||||
// famine=начало-конец;начало-конец (конец 0 -- голод идёт сейчас)
|
||||
var spans = new List<string>();
|
||||
foreach (var f in famine)
|
||||
spans.Add(f[0].ToString(CultureInfo.InvariantCulture) + "-" + f[1].ToString(CultureInfo.InvariantCulture));
|
||||
sb.Append("famine=").Append(string.Join(";", spans.ToArray())).Append('\n');
|
||||
sb.Append("kit=").Append(startingKitGiven ? "1" : "0").Append('\n');
|
||||
return sb.ToString();
|
||||
}
|
||||
|
||||
public void Restore(string data)
|
||||
{
|
||||
SetStartState();
|
||||
RestoredFromSave = true;
|
||||
if (string.IsNullOrEmpty(data)) return;
|
||||
|
||||
foreach (var raw in data.Split('\n'))
|
||||
{
|
||||
string line = raw.Trim();
|
||||
int eq = line.IndexOf('=');
|
||||
if (eq <= 0) continue;
|
||||
string key = line.Substring(0, eq);
|
||||
string value = line.Substring(eq + 1);
|
||||
|
||||
switch (key)
|
||||
{
|
||||
case "civilians": civilians = Math.Max(0, ParseInt(value, civilians)); break;
|
||||
case "jobs":
|
||||
string[] p = value.Split(',');
|
||||
for (int i = 0; i < jobs.Length && i < p.Length; i++) jobs[i] = Math.Max(0, ParseInt(p[i].Trim(), 0));
|
||||
break;
|
||||
case "jobsInit": jobsInitialized = value == "1"; break;
|
||||
case "houses": houseLevels = Math.Max(0, ParseInt(value, 0)); break;
|
||||
case "growth":
|
||||
if (double.TryParse(value, NumberStyles.Float, CultureInfo.InvariantCulture, out double g)) growthProgress = Math.Max(0, Math.Min(0.999, g));
|
||||
break;
|
||||
case "food":
|
||||
if (double.TryParse(value, NumberStyles.Float, CultureInfo.InvariantCulture, out double f)) foodOwed = Math.Max(0, Math.Min(1, f));
|
||||
break;
|
||||
case "lastTick":
|
||||
if (long.TryParse(value, NumberStyles.Integer, CultureInfo.InvariantCulture, out long t)) lastTickUnix = Math.Max(0, t);
|
||||
break;
|
||||
case "starving": starving = value == "1"; break;
|
||||
case "training": inTraining = Math.Max(0, ParseInt(value, 0)); break;
|
||||
case "famine":
|
||||
foreach (var span in value.Split(';'))
|
||||
{
|
||||
string[] se = span.Split('-');
|
||||
if (se.Length == 2
|
||||
&& long.TryParse(se[0], NumberStyles.Integer, CultureInfo.InvariantCulture, out long fs)
|
||||
&& long.TryParse(se[1], NumberStyles.Integer, CultureInfo.InvariantCulture, out long fe))
|
||||
famine.Add(new[] { fs, fe });
|
||||
}
|
||||
break;
|
||||
case "kit": startingKitGiven = value == "1"; break;
|
||||
}
|
||||
}
|
||||
|
||||
// Рабочих не может быть больше, чем жителей (испорченное или очень старое сохранение).
|
||||
// Места в зданиях здесь не проверяем: секция Экономики может загрузиться позже -- это делает ClampJobs.
|
||||
int over = Workers - civilians;
|
||||
for (int j = WorkJobs.Count - 1; j >= 0 && over > 0; j--)
|
||||
{
|
||||
int take = Math.Min(over, jobs[j]);
|
||||
jobs[j] -= take;
|
||||
over -= take;
|
||||
}
|
||||
}
|
||||
|
||||
// Новая игра: стартовый город. Стартовое войско выдаст App/GameModules после сброса всех модулей.
|
||||
public void ResetState()
|
||||
{
|
||||
SetStartState();
|
||||
Changed();
|
||||
WorkersChanged();
|
||||
}
|
||||
|
||||
static int ParseInt(string s, int fallback)
|
||||
{
|
||||
return int.TryParse(s, NumberStyles.Integer, CultureInfo.InvariantCulture, out int v) ? v : fallback;
|
||||
}
|
||||
}
|
||||
@@ -1,11 +0,0 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 46182f0762894c3aab9763821d2c9c19
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -16,7 +16,6 @@ public class GameBootstrap : MonoBehaviour
|
||||
else
|
||||
{
|
||||
Debug.Log("[СТАРТ] Сохранения нет, начинаем новую игру.");
|
||||
EventBus.Publish(new NewGameStartedEvent()); // стартовые ресурсы и войско -- в App/GameModules
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user