Compare commits
15 Commits
03f778085b
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 86575dd5bc | |||
| 29e90a5008 | |||
| 3bb9999173 | |||
| 07b18c8525 | |||
| ec6abb8574 | |||
| 8f96b520d6 | |||
| c3b53cef28 | |||
| ca38c2b16d | |||
| 09183cc139 | |||
| 1b704ca0b9 | |||
| 3c1b5c44cd | |||
| 8596cc74b1 | |||
| bd8458853b | |||
| 419debad74 | |||
| ed65770cc9 |
@@ -127,3 +127,7 @@ InitTestScene*.unity*
|
|||||||
# Scratch / working areas — local screenshots + personal session notes (keep on disk, out of git)
|
# Scratch / working areas — local screenshots + personal session notes (keep on disk, out of git)
|
||||||
/_visual_scratch/
|
/_visual_scratch/
|
||||||
/Docs/Vault/07_Sessions/User Sessions/
|
/Docs/Vault/07_Sessions/User Sessions/
|
||||||
|
|
||||||
|
# Machine-local editor state (regenerated per machine; untracked 2026-06-28)
|
||||||
|
/ProjectSettings/VirtualProjectsConfig.json
|
||||||
|
/ProjectSettings/Packages/com.unity.probuilder/Settings.json
|
||||||
|
|||||||
@@ -1,3 +0,0 @@
|
|||||||
version https://git-lfs.github.com/spec/v1
|
|
||||||
oid sha256:33f3329aa1b6538d7c6ce29cf5ee32240ae9748448063cc3924e79b8cd720b1c
|
|
||||||
size 167807
|
|
||||||
@@ -1,117 +0,0 @@
|
|||||||
fileFormatVersion: 2
|
|
||||||
guid: 04f85d1ea79e74f48945ec9f95fb0f34
|
|
||||||
TextureImporter:
|
|
||||||
internalIDToNameTable: []
|
|
||||||
externalObjects: {}
|
|
||||||
serializedVersion: 13
|
|
||||||
mipmaps:
|
|
||||||
mipMapMode: 0
|
|
||||||
enableMipMap: 1
|
|
||||||
sRGBTexture: 1
|
|
||||||
linearTexture: 0
|
|
||||||
fadeOut: 0
|
|
||||||
borderMipMap: 0
|
|
||||||
mipMapsPreserveCoverage: 0
|
|
||||||
alphaTestReferenceValue: 0.5
|
|
||||||
mipMapFadeDistanceStart: 1
|
|
||||||
mipMapFadeDistanceEnd: 3
|
|
||||||
bumpmap:
|
|
||||||
convertToNormalMap: 0
|
|
||||||
externalNormalMap: 0
|
|
||||||
heightScale: 0.25
|
|
||||||
normalMapFilter: 0
|
|
||||||
flipGreenChannel: 0
|
|
||||||
isReadable: 0
|
|
||||||
streamingMipmaps: 0
|
|
||||||
streamingMipmapsPriority: 0
|
|
||||||
vTOnly: 0
|
|
||||||
ignoreMipmapLimit: 0
|
|
||||||
grayScaleToAlpha: 0
|
|
||||||
generateCubemap: 6
|
|
||||||
cubemapConvolution: 0
|
|
||||||
seamlessCubemap: 0
|
|
||||||
textureFormat: 1
|
|
||||||
maxTextureSize: 2048
|
|
||||||
textureSettings:
|
|
||||||
serializedVersion: 2
|
|
||||||
filterMode: 1
|
|
||||||
aniso: 1
|
|
||||||
mipBias: 0
|
|
||||||
wrapU: 0
|
|
||||||
wrapV: 0
|
|
||||||
wrapW: 0
|
|
||||||
nPOTScale: 1
|
|
||||||
lightmap: 0
|
|
||||||
compressionQuality: 50
|
|
||||||
spriteMode: 0
|
|
||||||
spriteExtrude: 1
|
|
||||||
spriteMeshType: 1
|
|
||||||
alignment: 0
|
|
||||||
spritePivot: {x: 0.5, y: 0.5}
|
|
||||||
spritePixelsToUnits: 100
|
|
||||||
spriteBorder: {x: 0, y: 0, z: 0, w: 0}
|
|
||||||
spriteGenerateFallbackPhysicsShape: 1
|
|
||||||
alphaUsage: 1
|
|
||||||
alphaIsTransparency: 0
|
|
||||||
spriteTessellationDetail: -1
|
|
||||||
textureType: 0
|
|
||||||
textureShape: 1
|
|
||||||
singleChannelComponent: 0
|
|
||||||
flipbookRows: 1
|
|
||||||
flipbookColumns: 1
|
|
||||||
maxTextureSizeSet: 0
|
|
||||||
compressionQualitySet: 0
|
|
||||||
textureFormatSet: 0
|
|
||||||
ignorePngGamma: 0
|
|
||||||
applyGammaDecoding: 0
|
|
||||||
swizzle: 50462976
|
|
||||||
cookieLightType: 0
|
|
||||||
platformSettings:
|
|
||||||
- serializedVersion: 4
|
|
||||||
buildTarget: DefaultTexturePlatform
|
|
||||||
maxTextureSize: 2048
|
|
||||||
resizeAlgorithm: 0
|
|
||||||
textureFormat: -1
|
|
||||||
textureCompression: 1
|
|
||||||
compressionQuality: 50
|
|
||||||
crunchedCompression: 0
|
|
||||||
allowsAlphaSplitting: 0
|
|
||||||
overridden: 0
|
|
||||||
ignorePlatformSupport: 0
|
|
||||||
androidETC2FallbackOverride: 0
|
|
||||||
forceMaximumCompressionQuality_BC6H_BC7: 0
|
|
||||||
- serializedVersion: 4
|
|
||||||
buildTarget: Standalone
|
|
||||||
maxTextureSize: 2048
|
|
||||||
resizeAlgorithm: 0
|
|
||||||
textureFormat: -1
|
|
||||||
textureCompression: 1
|
|
||||||
compressionQuality: 50
|
|
||||||
crunchedCompression: 0
|
|
||||||
allowsAlphaSplitting: 0
|
|
||||||
overridden: 0
|
|
||||||
ignorePlatformSupport: 0
|
|
||||||
androidETC2FallbackOverride: 0
|
|
||||||
forceMaximumCompressionQuality_BC6H_BC7: 0
|
|
||||||
spriteSheet:
|
|
||||||
serializedVersion: 2
|
|
||||||
sprites: []
|
|
||||||
outline: []
|
|
||||||
customData:
|
|
||||||
physicsShape: []
|
|
||||||
bones: []
|
|
||||||
spriteID:
|
|
||||||
internalID: 0
|
|
||||||
vertices: []
|
|
||||||
indices:
|
|
||||||
edges: []
|
|
||||||
weights: []
|
|
||||||
secondaryTextures: []
|
|
||||||
spriteCustomMetadata:
|
|
||||||
entries: []
|
|
||||||
nameFileIdTable: {}
|
|
||||||
mipmapLimitGroupName:
|
|
||||||
pSDRemoveMatte: 0
|
|
||||||
userData:
|
|
||||||
assetBundleName:
|
|
||||||
assetBundleVariant:
|
|
||||||
@@ -89,6 +89,6 @@ MonoBehaviour:
|
|||||||
SiegeSizeBase: 5
|
SiegeSizeBase: 5
|
||||||
SiegeSizePerResource: 0
|
SiegeSizePerResource: 0
|
||||||
SiegeTimeoutTicks: 3600
|
SiegeTimeoutTicks: 3600
|
||||||
ScheduleEnabled: 1
|
ScheduleEnabled: 0
|
||||||
ScheduleIntervalTicks: 2700
|
ScheduleIntervalTicks: 2700
|
||||||
ScheduleSizePerWave: 1
|
ScheduleSizePerWave: 1
|
||||||
|
|||||||
@@ -1816,7 +1816,7 @@ MonoBehaviour:
|
|||||||
m_EditorClassIdentifier: ProjectM.Authoring::ProjectM.Authoring.EnemyAuthoring
|
m_EditorClassIdentifier: ProjectM.Authoring::ProjectM.Authoring.EnemyAuthoring
|
||||||
MaxHealth: 45
|
MaxHealth: 45
|
||||||
HitRadius: 0.8
|
HitRadius: 0.8
|
||||||
MoveSpeed: 2.6
|
MoveSpeed: 3
|
||||||
AttackRange: 1.7
|
AttackRange: 1.7
|
||||||
AttackDamage: 14
|
AttackDamage: 14
|
||||||
AttackCooldownTicks: 48
|
AttackCooldownTicks: 48
|
||||||
|
|||||||
@@ -873,7 +873,7 @@ MonoBehaviour:
|
|||||||
m_EditorClassIdentifier: ProjectM.Authoring::ProjectM.Authoring.EnemyAuthoring
|
m_EditorClassIdentifier: ProjectM.Authoring::ProjectM.Authoring.EnemyAuthoring
|
||||||
MaxHealth: 28
|
MaxHealth: 28
|
||||||
HitRadius: 1
|
HitRadius: 1
|
||||||
MoveSpeed: 2.8
|
MoveSpeed: 3
|
||||||
AttackRange: 1.8
|
AttackRange: 1.8
|
||||||
AttackDamage: 8
|
AttackDamage: 8
|
||||||
AttackCooldownTicks: 66
|
AttackCooldownTicks: 66
|
||||||
|
|||||||
@@ -927,7 +927,7 @@ MonoBehaviour:
|
|||||||
m_EditorClassIdentifier: ProjectM.Authoring::ProjectM.Authoring.EnemyAuthoring
|
m_EditorClassIdentifier: ProjectM.Authoring::ProjectM.Authoring.EnemyAuthoring
|
||||||
MaxHealth: 30
|
MaxHealth: 30
|
||||||
HitRadius: 0.7
|
HitRadius: 0.7
|
||||||
MoveSpeed: 3
|
MoveSpeed: 4.2
|
||||||
AttackRange: 1.6
|
AttackRange: 1.6
|
||||||
AttackDamage: 5
|
AttackDamage: 5
|
||||||
AttackCooldownTicks: 48
|
AttackCooldownTicks: 48
|
||||||
|
|||||||
@@ -102,7 +102,8 @@ GameObject:
|
|||||||
- component: {fileID: 9053853372340598254}
|
- component: {fileID: 9053853372340598254}
|
||||||
- component: {fileID: 6834786618115927220}
|
- component: {fileID: 6834786618115927220}
|
||||||
- component: {fileID: 7685488391646220227}
|
- component: {fileID: 7685488391646220227}
|
||||||
m_Layer: 0
|
- component: {fileID: 1225369404710843925}
|
||||||
|
m_Layer: 9
|
||||||
m_Name: Pylon
|
m_Name: Pylon
|
||||||
m_TagString: Untagged
|
m_TagString: Untagged
|
||||||
m_Icon: {fileID: 0}
|
m_Icon: {fileID: 0}
|
||||||
@@ -177,3 +178,24 @@ MonoBehaviour:
|
|||||||
m_EditorClassIdentifier: ProjectM.Authoring::ProjectM.Authoring.StructureAuthoring
|
m_EditorClassIdentifier: ProjectM.Authoring::ProjectM.Authoring.StructureAuthoring
|
||||||
Kind: 6
|
Kind: 6
|
||||||
MaxHp: 150
|
MaxHp: 150
|
||||||
|
--- !u!65 &1225369404710843925
|
||||||
|
BoxCollider:
|
||||||
|
m_ObjectHideFlags: 0
|
||||||
|
m_CorrespondingSourceObject: {fileID: 0}
|
||||||
|
m_PrefabInstance: {fileID: 0}
|
||||||
|
m_PrefabAsset: {fileID: 0}
|
||||||
|
m_GameObject: {fileID: 3885353946372160549}
|
||||||
|
m_Material: {fileID: 0}
|
||||||
|
m_IncludeLayers:
|
||||||
|
serializedVersion: 2
|
||||||
|
m_Bits: 0
|
||||||
|
m_ExcludeLayers:
|
||||||
|
serializedVersion: 2
|
||||||
|
m_Bits: 0
|
||||||
|
m_LayerOverridePriority: 0
|
||||||
|
m_IsTrigger: 0
|
||||||
|
m_ProvidesContacts: 0
|
||||||
|
m_Enabled: 1
|
||||||
|
serializedVersion: 3
|
||||||
|
m_Size: {x: 1.6, y: 2, z: 1.6}
|
||||||
|
m_Center: {x: 0, y: 1, z: 0}
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ Transform:
|
|||||||
serializedVersion: 2
|
serializedVersion: 2
|
||||||
m_LocalRotation: {x: 0, y: -0, z: -0, w: 1}
|
m_LocalRotation: {x: 0, y: -0, z: -0, w: 1}
|
||||||
m_LocalPosition: {x: 0, y: 0, z: 0}
|
m_LocalPosition: {x: 0, y: 0, z: 0}
|
||||||
m_LocalScale: {x: 1.6, y: 1.6, z: 1.6}
|
m_LocalScale: {x: 0.8, y: 0.8, z: 0.8}
|
||||||
m_ConstrainProportionsScale: 0
|
m_ConstrainProportionsScale: 0
|
||||||
m_Children:
|
m_Children:
|
||||||
- {fileID: 8624793677999475166}
|
- {fileID: 8624793677999475166}
|
||||||
@@ -67,7 +67,7 @@ MeshRenderer:
|
|||||||
m_RenderingLayerMask: 1
|
m_RenderingLayerMask: 1
|
||||||
m_RendererPriority: 0
|
m_RendererPriority: 0
|
||||||
m_Materials:
|
m_Materials:
|
||||||
- {fileID: 2100000, guid: 53c360a5b4f92c04caad3273e9bb5bef, type: 2}
|
- {fileID: 2100000, guid: 71e41e43b459a244abaf5acca76b89ee, type: 2}
|
||||||
m_StaticBatchInfo:
|
m_StaticBatchInfo:
|
||||||
firstSubMesh: 0
|
firstSubMesh: 0
|
||||||
subMeshCount: 0
|
subMeshCount: 0
|
||||||
@@ -157,7 +157,7 @@ MeshRenderer:
|
|||||||
m_RenderingLayerMask: 1
|
m_RenderingLayerMask: 1
|
||||||
m_RendererPriority: 0
|
m_RendererPriority: 0
|
||||||
m_Materials:
|
m_Materials:
|
||||||
- {fileID: 2100000, guid: 53c360a5b4f92c04caad3273e9bb5bef, type: 2}
|
- {fileID: 2100000, guid: 71e41e43b459a244abaf5acca76b89ee, type: 2}
|
||||||
m_StaticBatchInfo:
|
m_StaticBatchInfo:
|
||||||
firstSubMesh: 0
|
firstSubMesh: 0
|
||||||
subMeshCount: 0
|
subMeshCount: 0
|
||||||
@@ -248,7 +248,7 @@ MeshRenderer:
|
|||||||
m_RenderingLayerMask: 1
|
m_RenderingLayerMask: 1
|
||||||
m_RendererPriority: 0
|
m_RendererPriority: 0
|
||||||
m_Materials:
|
m_Materials:
|
||||||
- {fileID: 2100000, guid: 53c360a5b4f92c04caad3273e9bb5bef, type: 2}
|
- {fileID: 2100000, guid: 71e41e43b459a244abaf5acca76b89ee, type: 2}
|
||||||
m_StaticBatchInfo:
|
m_StaticBatchInfo:
|
||||||
firstSubMesh: 0
|
firstSubMesh: 0
|
||||||
subMeshCount: 0
|
subMeshCount: 0
|
||||||
@@ -342,7 +342,7 @@ MeshRenderer:
|
|||||||
m_RenderingLayerMask: 1
|
m_RenderingLayerMask: 1
|
||||||
m_RendererPriority: 0
|
m_RendererPriority: 0
|
||||||
m_Materials:
|
m_Materials:
|
||||||
- {fileID: 2100000, guid: 53c360a5b4f92c04caad3273e9bb5bef, type: 2}
|
- {fileID: 2100000, guid: 71e41e43b459a244abaf5acca76b89ee, type: 2}
|
||||||
m_StaticBatchInfo:
|
m_StaticBatchInfo:
|
||||||
firstSubMesh: 0
|
firstSubMesh: 0
|
||||||
subMeshCount: 0
|
subMeshCount: 0
|
||||||
@@ -378,7 +378,8 @@ GameObject:
|
|||||||
- component: {fileID: 9053853372340598254}
|
- component: {fileID: 9053853372340598254}
|
||||||
- component: {fileID: 6834786618115927220}
|
- component: {fileID: 6834786618115927220}
|
||||||
- component: {fileID: 1794795016809289889}
|
- component: {fileID: 1794795016809289889}
|
||||||
m_Layer: 0
|
- component: {fileID: 9049467567705961987}
|
||||||
|
m_Layer: 9
|
||||||
m_Name: Turret
|
m_Name: Turret
|
||||||
m_TagString: Untagged
|
m_TagString: Untagged
|
||||||
m_Icon: {fileID: 0}
|
m_Icon: {fileID: 0}
|
||||||
@@ -455,6 +456,27 @@ MonoBehaviour:
|
|||||||
CooldownTicks: 30
|
CooldownTicks: 30
|
||||||
Damage: 12
|
Damage: 12
|
||||||
MaxHp: 120
|
MaxHp: 120
|
||||||
|
--- !u!65 &9049467567705961987
|
||||||
|
BoxCollider:
|
||||||
|
m_ObjectHideFlags: 0
|
||||||
|
m_CorrespondingSourceObject: {fileID: 0}
|
||||||
|
m_PrefabInstance: {fileID: 0}
|
||||||
|
m_PrefabAsset: {fileID: 0}
|
||||||
|
m_GameObject: {fileID: 3885353946372160549}
|
||||||
|
m_Material: {fileID: 0}
|
||||||
|
m_IncludeLayers:
|
||||||
|
serializedVersion: 2
|
||||||
|
m_Bits: 0
|
||||||
|
m_ExcludeLayers:
|
||||||
|
serializedVersion: 2
|
||||||
|
m_Bits: 0
|
||||||
|
m_LayerOverridePriority: 0
|
||||||
|
m_IsTrigger: 0
|
||||||
|
m_ProvidesContacts: 0
|
||||||
|
m_Enabled: 1
|
||||||
|
serializedVersion: 3
|
||||||
|
m_Size: {x: 0.8, y: 1.2, z: 0.8}
|
||||||
|
m_Center: {x: 0, y: 0.6, z: 0}
|
||||||
--- !u!1 &4051895978514069616
|
--- !u!1 &4051895978514069616
|
||||||
GameObject:
|
GameObject:
|
||||||
m_ObjectHideFlags: 0
|
m_ObjectHideFlags: 0
|
||||||
@@ -521,7 +543,7 @@ MeshRenderer:
|
|||||||
m_RenderingLayerMask: 1
|
m_RenderingLayerMask: 1
|
||||||
m_RendererPriority: 0
|
m_RendererPriority: 0
|
||||||
m_Materials:
|
m_Materials:
|
||||||
- {fileID: 2100000, guid: 53c360a5b4f92c04caad3273e9bb5bef, type: 2}
|
- {fileID: 2100000, guid: 71e41e43b459a244abaf5acca76b89ee, type: 2}
|
||||||
m_StaticBatchInfo:
|
m_StaticBatchInfo:
|
||||||
firstSubMesh: 0
|
firstSubMesh: 0
|
||||||
subMeshCount: 0
|
subMeshCount: 0
|
||||||
@@ -611,7 +633,7 @@ MeshRenderer:
|
|||||||
m_RenderingLayerMask: 1
|
m_RenderingLayerMask: 1
|
||||||
m_RendererPriority: 0
|
m_RendererPriority: 0
|
||||||
m_Materials:
|
m_Materials:
|
||||||
- {fileID: 2100000, guid: 53c360a5b4f92c04caad3273e9bb5bef, type: 2}
|
- {fileID: 2100000, guid: 71e41e43b459a244abaf5acca76b89ee, type: 2}
|
||||||
m_StaticBatchInfo:
|
m_StaticBatchInfo:
|
||||||
firstSubMesh: 0
|
firstSubMesh: 0
|
||||||
subMeshCount: 0
|
subMeshCount: 0
|
||||||
@@ -701,7 +723,7 @@ MeshRenderer:
|
|||||||
m_RenderingLayerMask: 1
|
m_RenderingLayerMask: 1
|
||||||
m_RendererPriority: 0
|
m_RendererPriority: 0
|
||||||
m_Materials:
|
m_Materials:
|
||||||
- {fileID: 2100000, guid: 53c360a5b4f92c04caad3273e9bb5bef, type: 2}
|
- {fileID: 2100000, guid: 71e41e43b459a244abaf5acca76b89ee, type: 2}
|
||||||
m_StaticBatchInfo:
|
m_StaticBatchInfo:
|
||||||
firstSubMesh: 0
|
firstSubMesh: 0
|
||||||
subMeshCount: 0
|
subMeshCount: 0
|
||||||
|
|||||||
@@ -103,7 +103,7 @@ GameObject:
|
|||||||
- component: {fileID: 6834786618115927220}
|
- component: {fileID: 6834786618115927220}
|
||||||
- component: {fileID: 8793146551006314905}
|
- component: {fileID: 8793146551006314905}
|
||||||
- component: {fileID: 7779358222264100756}
|
- component: {fileID: 7779358222264100756}
|
||||||
m_Layer: 0
|
m_Layer: 9
|
||||||
m_Name: Wall
|
m_Name: Wall
|
||||||
m_TagString: Untagged
|
m_TagString: Untagged
|
||||||
m_Icon: {fileID: 0}
|
m_Icon: {fileID: 0}
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ namespace ProjectM.Authoring
|
|||||||
public GameObject TurretPrefab;
|
public GameObject TurretPrefab;
|
||||||
|
|
||||||
[Tooltip("Ore cost to build a turret.")]
|
[Tooltip("Ore cost to build a turret.")]
|
||||||
[Min(0)] public int TurretCostOre = 10;
|
[Min(0)] public int TurretCostOre = 40; // DR-042 combat pass: was 10 (~1/3 node) -> 40 (~1.3 nodes); a real investment
|
||||||
|
|
||||||
[Tooltip("Wall structure ghost prefab (StructureAuthoring{Wall} + GhostAuthoring).")]
|
[Tooltip("Wall structure ghost prefab (StructureAuthoring{Wall} + GhostAuthoring).")]
|
||||||
public GameObject WallPrefab;
|
public GameObject WallPrefab;
|
||||||
@@ -68,7 +68,7 @@ namespace ProjectM.Authoring
|
|||||||
{
|
{
|
||||||
Type = StructureType.Wall,
|
Type = StructureType.Wall,
|
||||||
Prefab = GetEntity(authoring.WallPrefab, TransformUsageFlags.Dynamic),
|
Prefab = GetEntity(authoring.WallPrefab, TransformUsageFlags.Dynamic),
|
||||||
CostResourceId = ResourceId.Ore,
|
CostResourceId = ResourceId.Biomass, // DR-042 C6b: walls cost Biomass (the dead currency's only sink)
|
||||||
CostAmount = authoring.WallCostOre,
|
CostAmount = authoring.WallCostOre,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -30,9 +30,9 @@ namespace ProjectM.Authoring
|
|||||||
|
|
||||||
[Tooltip("Max server ticks a siege may run before it auto-collapses (no soft-lock). 0 = no cap.")]
|
[Tooltip("Max server ticks a siege may run before it auto-collapses (no soft-lock). 0 = no cap.")]
|
||||||
public uint SiegeTimeoutTicks = 3600;
|
public uint SiegeTimeoutTicks = 3600;
|
||||||
[Header("Threat — scheduled base sieges")]
|
[Header("Threat — scheduled base sieges (DR-042: DISABLED — reserved/inert hook)")]
|
||||||
[Tooltip("A timed cadence arms a base siege even without an expedition trip (keeps the base loop stakeful).")]
|
[Tooltip("DR-042: OFF. A blind timed cadence was the AFK win path (auto-armed sieges the SiegeTimeout auto-cleared). The win-driver is now expedition clears; base sieges are post-expedition retaliation only. Code path kept as a config-inert reserved hook.")]
|
||||||
public bool ScheduleEnabled = true;
|
public bool ScheduleEnabled = false;
|
||||||
|
|
||||||
[Tooltip("Server ticks (@60) between scheduled base sieges. First fire is one interval out (mine/build grace).")]
|
[Tooltip("Server ticks (@60) between scheduled base sieges. First fire is one interval out (mine/build grace).")]
|
||||||
public uint ScheduleIntervalTicks = 2700;
|
public uint ScheduleIntervalTicks = 2700;
|
||||||
@@ -59,7 +59,7 @@ namespace ProjectM.Authoring
|
|||||||
});
|
});
|
||||||
AddComponent<ResourceLedger>(entity);
|
AddComponent<ResourceLedger>(entity);
|
||||||
AddBuffer<StorageEntry>(entity);
|
AddBuffer<StorageEntry>(entity);
|
||||||
AddComponent(entity, new GoalProgress { Charge = 0, Target = 4 }); // END-2: 4 survived sieges -> the final siege (the 5th)
|
AddComponent(entity, new GoalProgress { Charge = 0, Target = 4 }); // DR-042: 4 expedition clears -> the climactic final siege
|
||||||
// END-1: the losable Engine Core rides this GLOBAL ghost (no new ghost / no relevancy). Born full;
|
// END-1: the losable Engine Core rides this GLOBAL ghost (no new ghost / no relevancy). Born full;
|
||||||
// CycleDirectorSpawnSystem overrides Current with a persisted wounded value on Continue.
|
// CycleDirectorSpawnSystem overrides Current with a persisted wounded value on Continue.
|
||||||
AddComponent(entity, new CoreIntegrity
|
AddComponent(entity, new CoreIntegrity
|
||||||
@@ -73,6 +73,11 @@ namespace ProjectM.Authoring
|
|||||||
// CycleDirectorSpawnSystem overrides it with a persisted Victory/Loss on Continue.
|
// CycleDirectorSpawnSystem overrides it with a persisted Victory/Loss on Continue.
|
||||||
AddComponent(entity, new RunOutcome { Value = RunOutcomeId.InProgress });
|
AddComponent(entity, new RunOutcome { Value = RunOutcomeId.InProgress });
|
||||||
|
|
||||||
|
// DR-042 C7b: replicated expedition-objective summary (the HUD 'enemies remaining / cleared' readout).
|
||||||
|
// Born Idle; ZoneEnemyDirectorSystem is the sole writer. New [GhostField] component -> re-hashes the
|
||||||
|
// runtime-spawned director ghost (server + client bake the same prefab -> hash matches), like CoreIntegrity.
|
||||||
|
AddComponent(entity, new ExpeditionObjective { State = ExpeditionObjectiveState.Idle, Remaining = 0 });
|
||||||
|
|
||||||
|
|
||||||
AddComponent(entity, new ThreatConfig
|
AddComponent(entity, new ThreatConfig
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -15,6 +15,9 @@ namespace ProjectM.Authoring
|
|||||||
{
|
{
|
||||||
[Tooltip("Name of the Unity layer carrying the static world colliders (boundary ring + landmarks).")]
|
[Tooltip("Name of the Unity layer carrying the static world colliders (boundary ring + landmarks).")]
|
||||||
public string EnvironmentLayerName = "Environment";
|
public string EnvironmentLayerName = "Environment";
|
||||||
|
[Tooltip("DR-042 C5: Unity layer carrying player-built structure colliders (Wall/Turret/Pylon) that block enemies.")]
|
||||||
|
public string StructureLayerName = "Structure";
|
||||||
|
|
||||||
|
|
||||||
private class WorldCollisionBaker : Baker<WorldCollisionAuthoring>
|
private class WorldCollisionBaker : Baker<WorldCollisionAuthoring>
|
||||||
{
|
{
|
||||||
@@ -23,7 +26,9 @@ namespace ProjectM.Authoring
|
|||||||
int layer = LayerMask.NameToLayer(authoring.EnvironmentLayerName);
|
int layer = LayerMask.NameToLayer(authoring.EnvironmentLayerName);
|
||||||
uint mask = layer >= 0 ? 1u << layer : 0u;
|
uint mask = layer >= 0 ? 1u << layer : 0u;
|
||||||
var entity = GetEntity(TransformUsageFlags.None);
|
var entity = GetEntity(TransformUsageFlags.None);
|
||||||
AddComponent(entity, new WorldCollisionConfig { EnvironmentMask = mask });
|
int structLayer = LayerMask.NameToLayer(authoring.StructureLayerName);
|
||||||
|
uint structMask = structLayer >= 0 ? 1u << structLayer : 0u;
|
||||||
|
AddComponent(entity, new WorldCollisionConfig { EnvironmentMask = mask, StructureMask = structMask });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ namespace ProjectM.Client
|
|||||||
/// rotates a conveyor's facing. Fire is suppressed while build mode is active (PlayerInputGatherSystem reads
|
/// rotates a conveyor's facing. Fire is suppressed while build mode is active (PlayerInputGatherSystem reads
|
||||||
/// <see cref="BuildPaletteState.Active"/>), so the place-click never also fires. Build mode is suspended while
|
/// <see cref="BuildPaletteState.Active"/>), so the place-click never also fires. Build mode is suspended while
|
||||||
/// the pause overlay is open, and the frame a palette button changes the selection never also places.
|
/// the pause overlay is open, and the frame a palette button changes the selection never also places.
|
||||||
/// (2) keyboard hotkeys (fallback, suppressed in palette mode): B/V/N/H/F/C place at the local player's cell.
|
/// (2) keyboard hotkeys (fallback, suppressed in palette mode): B/V/F place at the local player's cell.
|
||||||
/// Editor-only statics (PlaceStructure / PlaceHarvester / ...) drive the same RPC path from execute_code for
|
/// Editor-only statics (PlaceStructure / PlaceHarvester / ...) drive the same RPC path from execute_code for
|
||||||
/// headless validation. Managed SystemBase; UnityEngine.InputSystem types are fully qualified to avoid the
|
/// headless validation. Managed SystemBase; UnityEngine.InputSystem types are fully qualified to avoid the
|
||||||
/// ProjectM.Simulation.PlayerInput name collision. The server re-validates legality + cost authoritatively.
|
/// ProjectM.Simulation.PlayerInput name collision. The server re-validates legality + cost authoritatively.
|
||||||
@@ -30,10 +30,9 @@ namespace ProjectM.Client
|
|||||||
{
|
{
|
||||||
(UnityEngine.InputSystem.Key.B, StructureType.Turret),
|
(UnityEngine.InputSystem.Key.B, StructureType.Turret),
|
||||||
(UnityEngine.InputSystem.Key.V, StructureType.Wall),
|
(UnityEngine.InputSystem.Key.V, StructureType.Wall),
|
||||||
(UnityEngine.InputSystem.Key.N, StructureType.Pylon),
|
|
||||||
(UnityEngine.InputSystem.Key.H, StructureType.Harvester),
|
|
||||||
(UnityEngine.InputSystem.Key.F, StructureType.Fabricator),
|
(UnityEngine.InputSystem.Key.F, StructureType.Fabricator),
|
||||||
(UnityEngine.InputSystem.Key.C, StructureType.Conveyor),
|
// DR-042 C6d: Pylon/Harvester/Conveyor are dead (unwired automation) — dropped from the hotkey fallback
|
||||||
|
// to match the hidden build palette; their PlaceStructure execute_code statics remain for dev.
|
||||||
};
|
};
|
||||||
|
|
||||||
UnityEngine.Camera _camera; // cursor -> ground re-raycast for click-to-place (resolved lazily)
|
UnityEngine.Camera _camera; // cursor -> ground re-raycast for click-to-place (resolved lazily)
|
||||||
@@ -41,11 +40,16 @@ namespace ProjectM.Client
|
|||||||
Material _ghostMat;
|
Material _ghostMat;
|
||||||
byte _lastSelected; // skip placing on the frame a palette click changes the selection
|
byte _lastSelected; // skip placing on the frame a palette click changes the selection
|
||||||
|
|
||||||
|
// DR-042 C6a: the ability-upgrade send is RUNTIME (the HUD Aether button calls UpgradeAbility); only the
|
||||||
|
// execute_code PLACE statics stay editor-gated. Mirrors EquipSendSystem's unconditional queue + drain.
|
||||||
|
static int s_PendingUpgrades = 0;
|
||||||
|
/// <summary>Runtime hook (HUD Aether button) + execute_code: queue an ability-damage upgrade.</summary>
|
||||||
|
public static void UpgradeAbility() => s_PendingUpgrades++;
|
||||||
|
|
||||||
#if UNITY_EDITOR
|
#if UNITY_EDITOR
|
||||||
struct PendingBuild { public byte Type; public int CellX; public int CellZ; public byte Direction; }
|
struct PendingBuild { public byte Type; public int CellX; public int CellZ; public byte Direction; }
|
||||||
static readonly System.Collections.Generic.Queue<PendingBuild> s_PendingBuild =
|
static readonly System.Collections.Generic.Queue<PendingBuild> s_PendingBuild =
|
||||||
new System.Collections.Generic.Queue<PendingBuild>();
|
new System.Collections.Generic.Queue<PendingBuild>();
|
||||||
static int s_PendingUpgrades = 0;
|
|
||||||
|
|
||||||
/// <summary>EDITOR / execute_code hook: queue a structure placement at a specific cell.</summary>
|
/// <summary>EDITOR / execute_code hook: queue a structure placement at a specific cell.</summary>
|
||||||
public static void PlaceStructure(byte type, int cellX, int cellZ, byte direction = 0) =>
|
public static void PlaceStructure(byte type, int cellX, int cellZ, byte direction = 0) =>
|
||||||
@@ -69,8 +73,6 @@ namespace ProjectM.Client
|
|||||||
/// <summary>EDITOR / execute_code hook: queue a conveyor placement facing direction (0=+X,1=-X,2=+Z,3=-Z).</summary>
|
/// <summary>EDITOR / execute_code hook: queue a conveyor placement facing direction (0=+X,1=-X,2=+Z,3=-Z).</summary>
|
||||||
public static void PlaceConveyor(int cellX, int cellZ, byte direction) => PlaceStructure(StructureType.Conveyor, cellX, cellZ, direction);
|
public static void PlaceConveyor(int cellX, int cellZ, byte direction) => PlaceStructure(StructureType.Conveyor, cellX, cellZ, direction);
|
||||||
|
|
||||||
/// <summary>EDITOR / execute_code hook: queue an ability-damage upgrade.</summary>
|
|
||||||
public static void UpgradeAbility() => s_PendingUpgrades++;
|
|
||||||
#endif
|
#endif
|
||||||
|
|
||||||
protected override void OnCreate()
|
protected override void OnCreate()
|
||||||
@@ -115,17 +117,19 @@ namespace ProjectM.Client
|
|||||||
SendUpgrade(connection);
|
SendUpgrade(connection);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// DR-042 C6a: the ability-upgrade drain runs at RUNTIME (the HUD Aether button enqueues via UpgradeAbility);
|
||||||
|
// only the execute_code PLACE drain stays editor-gated.
|
||||||
|
while (s_PendingUpgrades > 0)
|
||||||
|
{
|
||||||
|
s_PendingUpgrades--;
|
||||||
|
SendUpgrade(connection);
|
||||||
|
}
|
||||||
#if UNITY_EDITOR
|
#if UNITY_EDITOR
|
||||||
while (s_PendingBuild.Count > 0)
|
while (s_PendingBuild.Count > 0)
|
||||||
{
|
{
|
||||||
var b = s_PendingBuild.Dequeue();
|
var b = s_PendingBuild.Dequeue();
|
||||||
SendBuild(connection, b.Type, b.CellX, b.CellZ, b.Direction);
|
SendBuild(connection, b.Type, b.CellX, b.CellZ, b.Direction);
|
||||||
}
|
}
|
||||||
while (s_PendingUpgrades > 0)
|
|
||||||
{
|
|
||||||
s_PendingUpgrades--;
|
|
||||||
SendUpgrade(connection);
|
|
||||||
}
|
|
||||||
#endif
|
#endif
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -29,6 +29,17 @@ namespace ProjectM.Client
|
|||||||
[BurstCompile]
|
[BurstCompile]
|
||||||
public void OnUpdate(ref SystemState state)
|
public void OnUpdate(ref SystemState state)
|
||||||
{
|
{
|
||||||
|
// A FULL client must not go in-game until the gameplay subscene's ghost prefabs have streamed in.
|
||||||
|
// Otherwise the server's first ghost snapshot arrives before the client can resolve those prefabs
|
||||||
|
// ("ghost ... ENTITY_NOT_FOUND" -> the server disconnects the connection -> "nothing loads"). On
|
||||||
|
// loopback / fast LAN the connect+go-in-game handshake easily beats the ~0.5s entity-subscene stream.
|
||||||
|
// PlayerSpawner is a subscene-baked singleton that co-loads with the ghost prefabs, so its presence
|
||||||
|
// is a sound "subscene ready" gate. Thin clients never instantiate ghosts (and don't stream the
|
||||||
|
// subscene), so they skip the gate and connect immediately.
|
||||||
|
bool isThinClient = (state.WorldUnmanaged.Flags & WorldFlags.GameThinClient) == WorldFlags.GameThinClient;
|
||||||
|
if (!isThinClient && !SystemAPI.HasSingleton<PlayerSpawner>())
|
||||||
|
return;
|
||||||
|
|
||||||
var ecb = new EntityCommandBuffer(Allocator.Temp);
|
var ecb = new EntityCommandBuffer(Allocator.Temp);
|
||||||
|
|
||||||
foreach (var (_, connection) in
|
foreach (var (_, connection) in
|
||||||
|
|||||||
@@ -0,0 +1,8 @@
|
|||||||
|
fileFormatVersion: 2
|
||||||
|
guid: 0861914135cacf948ae2adfd7f7d6870
|
||||||
|
folderAsset: yes
|
||||||
|
DefaultImporter:
|
||||||
|
externalObjects: {}
|
||||||
|
userData:
|
||||||
|
assetBundleName:
|
||||||
|
assetBundleVariant:
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
using UnityEngine;
|
||||||
|
|
||||||
|
namespace ProjectM.Client
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Tiny static coordination bridge for the first-run onboarding overlay. <see cref="Active"/> is true while a
|
||||||
|
/// coach-mark step is on screen (set each frame by <see cref="OnboardingSystem"/>); <see cref="HudSystem"/>
|
||||||
|
/// reads it to suppress its own ad-hoc location/gate hint so the player ever sees a single prompt voice.
|
||||||
|
/// A presentation-layer static, so it is RESET on play-enter (the CLAUDE.md stale-static rule) to avoid a
|
||||||
|
/// stale flag surviving a fast-enter-playmode domain reload and leaving the HUD hint suppressed.
|
||||||
|
/// </summary>
|
||||||
|
public static class OnboardingState
|
||||||
|
{
|
||||||
|
/// <summary>True while the coach-mark sequence is the active prompt voice (a step is being shown).</summary>
|
||||||
|
public static bool Active;
|
||||||
|
|
||||||
|
[RuntimeInitializeOnLoadMethod(RuntimeInitializeLoadType.SubsystemRegistration)]
|
||||||
|
static void ResetOnPlayEnter() => Active = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
fileFormatVersion: 2
|
||||||
|
guid: 4e9b3bb074eef1c40b90591e85b90e32
|
||||||
@@ -0,0 +1,131 @@
|
|||||||
|
using ProjectM.Simulation;
|
||||||
|
|
||||||
|
namespace ProjectM.Client
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Pure, engine-free logic for the first-run onboarding coach-mark sequence — the testable core of
|
||||||
|
/// <see cref="OnboardingSystem"/> (mirrors the project's <c>*Math</c> helper discipline; no UnityEngine /
|
||||||
|
/// Entities types so it unit-tests as plain C#). Defines the ordered step list, a <see cref="Snapshot"/> of the
|
||||||
|
/// observable client state each step reads, the deterministic per-step completion test, prompt copy, the
|
||||||
|
/// spatial-cue kind, and the persisted-mask helpers.
|
||||||
|
///
|
||||||
|
/// Pacing (operator-locked = soft-gated): a step shows until its action is performed (no per-step timeout),
|
||||||
|
/// EXCEPT two info beats — <see cref="Fabricator"/> and <see cref="Defend"/> — which also auto-advance, plus
|
||||||
|
/// the timed <see cref="Welcome"/> strip. Veteran / co-op auto-suppress falls out for free: the count-based
|
||||||
|
/// steps (<see cref="Build"/>, <see cref="Fabricator"/>) test an ABSOLUTE structure count, so a client joining
|
||||||
|
/// an already-built base satisfies them on entry and skips straight past.
|
||||||
|
/// </summary>
|
||||||
|
public static class OnboardingStepMath
|
||||||
|
{
|
||||||
|
// ---- ordered steps (byte ids; bit i of GameSettings.OnboardingMask = step i complete) ----
|
||||||
|
public const byte Welcome = 0; // tiny win-condition framing strip (timed)
|
||||||
|
public const byte Move = 1;
|
||||||
|
public const byte Mine = 2; // attack an Ore node — mining IS combat at the base (Calm = no enemies yet)
|
||||||
|
public const byte Build = 3; // open palette + place a Turret
|
||||||
|
public const byte Fabricator = 4; // Ore -> Charge (soft info beat)
|
||||||
|
public const byte Gate = 5; // reach the Expedition Gate
|
||||||
|
public const byte Clear = 6; // clear the expedition wave (the first real enemies)
|
||||||
|
public const byte Return = 7; // walk back to base to bank +1 charge
|
||||||
|
public const byte Defend = 8; // survive the retaliation siege (soft info beat)
|
||||||
|
public const byte Done = 9; // closing beat
|
||||||
|
public const byte StepCount = 10;
|
||||||
|
|
||||||
|
// ---- tunable thresholds (public so the EditMode tests pin the contract) ----
|
||||||
|
public const float WelcomeSeconds = 5f;
|
||||||
|
public const float MoveThreshold = 3f; // accumulated player movement (world units)
|
||||||
|
public const float FabricatorSoftSeconds = 14f; // soft beat auto-advance if no Fabricator built
|
||||||
|
public const float DefendNoSiegeSeconds = 20f; // advance if no siege ever materialises
|
||||||
|
public const float DoneSeconds = 6f; // closing beat lingers before going dormant
|
||||||
|
|
||||||
|
// ---- spatial-cue kinds the System resolves to a live world target ----
|
||||||
|
public const byte PointerNone = 0;
|
||||||
|
public const byte PointerOreNode = 1;
|
||||||
|
public const byte PointerBaseGate = 2; // the base-region gate (go to the expedition)
|
||||||
|
public const byte PointerExpeditionGate = 3; // the expedition-region gate (return home)
|
||||||
|
|
||||||
|
/// <summary>Observable client state for one evaluation. Built by the System from ECS + input each frame.</summary>
|
||||||
|
public struct Snapshot
|
||||||
|
{
|
||||||
|
public float StepElapsed; // seconds the current step has been shown
|
||||||
|
public float MoveDistance; // accumulated player movement since the Move step began
|
||||||
|
public int OreNow; // shared-ledger Ore right now
|
||||||
|
public int OreBaseline; // ledger Ore captured when the Mine step began
|
||||||
|
public int TurretCount; // live Turret structures (absolute)
|
||||||
|
public int FabricatorCount; // live Fabricator structures (absolute)
|
||||||
|
public bool OnExpedition; // local player is in the expedition region
|
||||||
|
public byte ObjectiveState; // ExpeditionObjective.State (Idle/Active/Cleared)
|
||||||
|
public bool SawSiege; // a Siege phase was observed while the Defend step was showing
|
||||||
|
public byte Phase; // CycleState.Phase (Calm/Siege)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>True when the step's taught action is complete (or its soft timeout has elapsed).</summary>
|
||||||
|
public static bool IsSatisfied(byte step, in Snapshot s)
|
||||||
|
{
|
||||||
|
switch (step)
|
||||||
|
{
|
||||||
|
case Welcome: return s.StepElapsed >= WelcomeSeconds;
|
||||||
|
case Move: return s.MoveDistance >= MoveThreshold;
|
||||||
|
case Mine: return s.OreNow > s.OreBaseline;
|
||||||
|
case Build: return s.TurretCount >= 1;
|
||||||
|
case Fabricator: return s.FabricatorCount >= 1 || s.StepElapsed >= FabricatorSoftSeconds;
|
||||||
|
case Gate: return s.OnExpedition || s.ObjectiveState == ExpeditionObjectiveState.Active;
|
||||||
|
case Clear: return s.ObjectiveState == ExpeditionObjectiveState.Cleared;
|
||||||
|
case Return: return !s.OnExpedition; // entered while on expedition; satisfied on crossing home
|
||||||
|
case Defend: return s.SawSiege ? s.Phase == CyclePhase.Calm : s.StepElapsed >= DefendNoSiegeSeconds;
|
||||||
|
case Done: return s.StepElapsed >= DoneSeconds;
|
||||||
|
default: return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Which world target (if any) the prompt should point at this step.</summary>
|
||||||
|
public static byte PointerKind(byte step)
|
||||||
|
{
|
||||||
|
switch (step)
|
||||||
|
{
|
||||||
|
case Mine: return PointerOreNode;
|
||||||
|
case Gate: return PointerBaseGate;
|
||||||
|
case Return: return PointerExpeditionGate;
|
||||||
|
default: return PointerNone;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Ultra-short, verb-first prompt copy with the player's real input glyph (scheme-aware).</summary>
|
||||||
|
public static string Prompt(byte step, bool gamepad)
|
||||||
|
{
|
||||||
|
string move = gamepad ? "Left Stick" : "WASD";
|
||||||
|
string attack = gamepad ? "RT" : "LMB";
|
||||||
|
string build = gamepad ? "Y" : "Tab"; // matches the existing HUD build-discovery chip glyph
|
||||||
|
switch (step)
|
||||||
|
{
|
||||||
|
case Welcome: return "CLEAR EXPEDITIONS to charge the Engine — defend the Core while you do. (Esc → Pause → How to Play)";
|
||||||
|
case Move: return move + " — Move";
|
||||||
|
case Mine: return attack + " — Attack the glowing Ore to mine it";
|
||||||
|
case Build: return build + " — open Build, then place a Turret by your Core";
|
||||||
|
case Fabricator: return "Build a Fabricator — turrets need Charge (Ore → ammo)";
|
||||||
|
case Gate: return "Reach the Expedition Gate — clearing it charges the Engine";
|
||||||
|
case Clear: return "Clear the zone — defeat every enemy";
|
||||||
|
case Return: return "Return through the gate — bank your clear (+1 Engine charge)";
|
||||||
|
case Defend: return "Defend the Core! — hold the line through the siege";
|
||||||
|
case Done: return "You've got it. Clear expeditions to fill the Engine and win.";
|
||||||
|
default: return "";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- persisted-mask helpers (GameSettings.OnboardingMask) ----
|
||||||
|
|
||||||
|
/// <summary>All steps complete (the sequence is dormant).</summary>
|
||||||
|
public static bool AllComplete(int mask)
|
||||||
|
{
|
||||||
|
int all = (1 << StepCount) - 1;
|
||||||
|
return (mask & all) == all;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>The lowest not-yet-completed step (resume point); <see cref="Done"/> when all are complete.</summary>
|
||||||
|
public static byte FirstIncomplete(int mask)
|
||||||
|
{
|
||||||
|
for (byte i = 0; i < StepCount; i++)
|
||||||
|
if ((mask & (1 << i)) == 0) return i;
|
||||||
|
return Done;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
fileFormatVersion: 2
|
||||||
|
guid: c264496096436e74ebba163a7a5d2205
|
||||||
@@ -0,0 +1,321 @@
|
|||||||
|
using ProjectM.Simulation;
|
||||||
|
using Unity.Entities;
|
||||||
|
using Unity.Mathematics;
|
||||||
|
using Unity.NetCode;
|
||||||
|
using Unity.Transforms;
|
||||||
|
using UnityEngine;
|
||||||
|
using UnityEngine.UIElements;
|
||||||
|
|
||||||
|
namespace ProjectM.Client
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// First-run onboarding overlay — a CLIENT-ONLY, observe-only presentation <see cref="SystemBase"/> in
|
||||||
|
/// <see cref="PresentationSystemGroup"/> (same shape/constraints as <see cref="HudSystem"/>: never mutates the
|
||||||
|
/// sim, never destroys a ghost, reads already-replicated state once per frame). Owns its own runtime UIDocument
|
||||||
|
/// (sortingOrder 60 — above the HUD's 50, below the pause overlay's 100) showing a single bottom-center
|
||||||
|
/// coach-mark prompt plus a world-space directional pointer.
|
||||||
|
///
|
||||||
|
/// The sequence is PER-CLIENT and client-local: progress lives in <see cref="GameSettings.OnboardingMask"/>
|
||||||
|
/// (via <see cref="SettingsService"/>), keyed to THIS player's own first-encounter — so a veteran host sees
|
||||||
|
/// nothing, a brand-new join-client is still taught, and a save wipe never re-teaches the host (the mask is in
|
||||||
|
/// settings.json, not the host-only SaveData). Soft-gated pacing: a step shows until its action is done; the
|
||||||
|
/// pure rules + auto-suppress (absolute count checks) live in <see cref="OnboardingStepMath"/>.
|
||||||
|
/// </summary>
|
||||||
|
[WorldSystemFilter(WorldSystemFilterFlags.ClientSimulation)]
|
||||||
|
[UpdateInGroup(typeof(PresentationSystemGroup))]
|
||||||
|
public partial class OnboardingSystem : SystemBase
|
||||||
|
{
|
||||||
|
const float ExpeditionRegionXMin = 500f; // player x past this = the +1000 expedition region (mirrors HudSystem)
|
||||||
|
|
||||||
|
GameObject _go;
|
||||||
|
UIDocument _doc;
|
||||||
|
bool _built;
|
||||||
|
Label _prompt;
|
||||||
|
Label _pointer;
|
||||||
|
|
||||||
|
// step machine (in-memory; persisted to the mask on each completion)
|
||||||
|
bool _maskLoaded;
|
||||||
|
int _mask;
|
||||||
|
byte _step;
|
||||||
|
bool _stepInit;
|
||||||
|
float _stepElapsed;
|
||||||
|
float _moveAccum;
|
||||||
|
float3 _lastPos;
|
||||||
|
int _oreBaseline;
|
||||||
|
bool _sawSiege;
|
||||||
|
|
||||||
|
protected override void OnStartRunning()
|
||||||
|
{
|
||||||
|
if (_go != null) return;
|
||||||
|
_go = new GameObject("~Onboarding");
|
||||||
|
_doc = _go.AddComponent<UIDocument>();
|
||||||
|
_doc.panelSettings = MenuUi.LoadPanelSettings();
|
||||||
|
_doc.sortingOrder = 60; // above HUD (50), below pause (100)
|
||||||
|
}
|
||||||
|
|
||||||
|
protected override void OnDestroy()
|
||||||
|
{
|
||||||
|
OnboardingState.Active = false; // never let the static outlive its owning system (HUD suppression)
|
||||||
|
if (_go != null) Object.Destroy(_go);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected override void OnUpdate()
|
||||||
|
{
|
||||||
|
if (_doc == null) return;
|
||||||
|
var root = _doc.rootVisualElement;
|
||||||
|
if (root == null) return;
|
||||||
|
if (!_built) { BuildTree(root); _built = true; }
|
||||||
|
|
||||||
|
float dt = SystemAPI.Time.DeltaTime; // wall-frame delta — correct in a presentation system
|
||||||
|
|
||||||
|
// ---- local player presence + position ----
|
||||||
|
bool havePlayer = false; float3 playerPos = default;
|
||||||
|
foreach (var lt in SystemAPI.Query<RefRO<LocalTransform>>().WithAll<GhostOwnerIsLocal, PlayerTag>())
|
||||||
|
{ havePlayer = true; playerPos = lt.ValueRO.Position; break; }
|
||||||
|
|
||||||
|
var settings = SettingsService.Current;
|
||||||
|
if (!_maskLoaded)
|
||||||
|
{
|
||||||
|
_mask = settings.OnboardingMask;
|
||||||
|
_step = OnboardingStepMath.FirstIncomplete(_mask);
|
||||||
|
_stepInit = false;
|
||||||
|
_maskLoaded = true;
|
||||||
|
}
|
||||||
|
bool hintsOn = settings.TutorialHints != 0;
|
||||||
|
|
||||||
|
// Dormant (hints off / all steps done) or no local player yet → fully hidden, no voice.
|
||||||
|
if (!hintsOn || OnboardingStepMath.AllComplete(_mask) || !havePlayer)
|
||||||
|
{
|
||||||
|
OnboardingState.Active = false;
|
||||||
|
root.style.display = DisplayStyle.None;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- remaining observable state ----
|
||||||
|
int ore = LedgerOre();
|
||||||
|
CountStructures(out int turrets, out int fabs);
|
||||||
|
byte phase = CyclePhase.Calm;
|
||||||
|
if (SystemAPI.TryGetSingleton<CycleState>(out var cyc)) phase = cyc.Phase;
|
||||||
|
byte objState = ExpeditionObjectiveState.Idle;
|
||||||
|
if (SystemAPI.TryGetSingleton<ExpeditionObjective>(out var obj)) objState = obj.State;
|
||||||
|
bool onExp = playerPos.x > ExpeditionRegionXMin;
|
||||||
|
|
||||||
|
// ---- per-step entry init (baselines) ----
|
||||||
|
if (!_stepInit)
|
||||||
|
{
|
||||||
|
_stepElapsed = 0f; _moveAccum = 0f; _sawSiege = false;
|
||||||
|
_oreBaseline = ore; _lastPos = playerPos;
|
||||||
|
_stepInit = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- advance (FROZEN while the pause overlay is open, so the timed beats — Welcome/Fabricator/
|
||||||
|
// Defend/Done — aren't silently lost behind the pause dim that sits above this overlay) ----
|
||||||
|
if (!PauseMenuController.Open)
|
||||||
|
{
|
||||||
|
_stepElapsed += dt;
|
||||||
|
if (_step == OnboardingStepMath.Move) _moveAccum += math.distance(playerPos, _lastPos);
|
||||||
|
if (_step == OnboardingStepMath.Defend && phase == CyclePhase.Siege) _sawSiege = true;
|
||||||
|
|
||||||
|
var snap = new OnboardingStepMath.Snapshot
|
||||||
|
{
|
||||||
|
StepElapsed = _stepElapsed,
|
||||||
|
MoveDistance = _moveAccum,
|
||||||
|
OreNow = ore,
|
||||||
|
OreBaseline = _oreBaseline,
|
||||||
|
TurretCount = turrets,
|
||||||
|
FabricatorCount = fabs,
|
||||||
|
OnExpedition = onExp,
|
||||||
|
ObjectiveState = objState,
|
||||||
|
SawSiege = _sawSiege,
|
||||||
|
Phase = phase,
|
||||||
|
};
|
||||||
|
|
||||||
|
// The two pure-message beats can be dismissed with any input EXCEPT Esc (Esc opens Pause; see
|
||||||
|
// AnyInputPressed) so following the "Esc → Pause → How to Play" hint doesn't self-skip the framing.
|
||||||
|
bool skip = (_step == OnboardingStepMath.Welcome || _step == OnboardingStepMath.Done) && AnyInputPressed();
|
||||||
|
if (skip || OnboardingStepMath.IsSatisfied(_step, snap))
|
||||||
|
{
|
||||||
|
_mask |= (1 << _step);
|
||||||
|
Persist(_mask);
|
||||||
|
_step = OnboardingStepMath.FirstIncomplete(_mask); // auto-suppressed steps cascade one/frame
|
||||||
|
_stepInit = false;
|
||||||
|
if (OnboardingStepMath.AllComplete(_mask))
|
||||||
|
{
|
||||||
|
OnboardingState.Active = false;
|
||||||
|
root.style.display = DisplayStyle.None;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_lastPos = playerPos;
|
||||||
|
|
||||||
|
// ---- show the current step ----
|
||||||
|
OnboardingState.Active = true;
|
||||||
|
root.style.display = DisplayStyle.Flex;
|
||||||
|
bool gamepad = AimPresentation.Scheme == InputSchemeId.Gamepad;
|
||||||
|
_prompt.text = OnboardingStepMath.Prompt(_step, gamepad);
|
||||||
|
UpdatePointer(_step, playerPos);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- state gathering helpers ----
|
||||||
|
|
||||||
|
int LedgerOre()
|
||||||
|
{
|
||||||
|
if (SystemAPI.TryGetSingletonEntity<ResourceLedger>(out var e))
|
||||||
|
{
|
||||||
|
var buf = SystemAPI.GetBuffer<StorageEntry>(e);
|
||||||
|
for (int i = 0; i < buf.Length; i++)
|
||||||
|
if (buf[i].ItemId == ResourceId.Ore) return buf[i].Count;
|
||||||
|
}
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
void CountStructures(out int turrets, out int fabs)
|
||||||
|
{
|
||||||
|
turrets = 0; fabs = 0;
|
||||||
|
foreach (var ps in SystemAPI.Query<RefRO<PlacedStructure>>())
|
||||||
|
{
|
||||||
|
byte t = ps.ValueRO.Type;
|
||||||
|
if (t == StructureType.Turret) turrets++;
|
||||||
|
else if (t == StructureType.Fabricator) fabs++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void Persist(int mask)
|
||||||
|
{
|
||||||
|
var s = SettingsService.Current;
|
||||||
|
s.OnboardingMask = mask;
|
||||||
|
SettingsService.Save(s); // atomic write; ~once per completed step
|
||||||
|
}
|
||||||
|
|
||||||
|
static bool AnyInputPressed()
|
||||||
|
{
|
||||||
|
var kb = UnityEngine.InputSystem.Keyboard.current;
|
||||||
|
// any key dismisses a message beat — EXCEPT Esc, which is the pause key (don't self-skip the framing).
|
||||||
|
if (kb != null && kb.anyKey.wasPressedThisFrame && !kb.escapeKey.wasPressedThisFrame) return true;
|
||||||
|
var ms = UnityEngine.InputSystem.Mouse.current;
|
||||||
|
if (ms != null && ms.leftButton.wasPressedThisFrame) return true;
|
||||||
|
var gp = UnityEngine.InputSystem.Gamepad.current;
|
||||||
|
if (gp != null && (gp.buttonSouth.wasPressedThisFrame || gp.startButton.wasPressedThisFrame)) return true;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- world-space pointer ----
|
||||||
|
|
||||||
|
bool ResolveTarget(byte kind, float3 playerPos, out float3 target)
|
||||||
|
{
|
||||||
|
target = default;
|
||||||
|
if (kind == OnboardingStepMath.PointerOreNode)
|
||||||
|
{
|
||||||
|
float best = float.MaxValue; bool found = false;
|
||||||
|
foreach (var lt in SystemAPI.Query<RefRO<LocalTransform>>().WithAll<ResourceNode>())
|
||||||
|
{
|
||||||
|
float d = math.distancesq(lt.ValueRO.Position, playerPos);
|
||||||
|
if (d < best) { best = d; target = lt.ValueRO.Position; found = true; }
|
||||||
|
}
|
||||||
|
return found;
|
||||||
|
}
|
||||||
|
// base gate (go) lives in the base region; expedition gate (return) lives past the region split.
|
||||||
|
bool wantBase = kind == OnboardingStepMath.PointerBaseGate;
|
||||||
|
foreach (var lt in SystemAPI.Query<RefRO<LocalTransform>>().WithAll<ExpeditionGate>())
|
||||||
|
{
|
||||||
|
var p = lt.ValueRO.Position;
|
||||||
|
if ((p.x < ExpeditionRegionXMin) == wantBase) { target = p; return true; }
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
void UpdatePointer(byte step, float3 playerPos)
|
||||||
|
{
|
||||||
|
byte kind = OnboardingStepMath.PointerKind(step);
|
||||||
|
var cam = Camera.main;
|
||||||
|
var root = _doc.rootVisualElement;
|
||||||
|
if (kind == OnboardingStepMath.PointerNone || cam == null || !ResolveTarget(kind, playerPos, out float3 target))
|
||||||
|
{ _pointer.style.display = DisplayStyle.None; return; }
|
||||||
|
|
||||||
|
float pw = root.layout.width, ph = root.layout.height;
|
||||||
|
if (pw <= 1f || ph <= 1f) { _pointer.style.display = DisplayStyle.None; return; }
|
||||||
|
|
||||||
|
Vector3 sp = cam.WorldToScreenPoint((Vector3)target);
|
||||||
|
bool behind = sp.z < 0f;
|
||||||
|
float px = (sp.x / Mathf.Max(1f, Screen.width)) * pw;
|
||||||
|
float py = (1f - sp.y / Mathf.Max(1f, Screen.height)) * ph;
|
||||||
|
if (behind) { px = pw - px; py = ph - py; }
|
||||||
|
|
||||||
|
const float margin = 64f;
|
||||||
|
bool off = behind || px < margin || px > pw - margin || py < margin || py > ph - margin;
|
||||||
|
|
||||||
|
float cx = pw * 0.5f, cy = ph * 0.5f;
|
||||||
|
float dx = px - cx, dy = py - cy;
|
||||||
|
float len = Mathf.Sqrt(dx * dx + dy * dy);
|
||||||
|
if (len < 0.001f) { dx = 1f; dy = 0f; len = 1f; }
|
||||||
|
float ndx = dx / len, ndy = dy / len;
|
||||||
|
|
||||||
|
float ax, ay;
|
||||||
|
if (off)
|
||||||
|
{
|
||||||
|
// intersect the center→target ray with the margin rectangle (edge arrow)
|
||||||
|
float tx = (ndx > 0 ? (pw - margin - cx) : (margin - cx)) / (Mathf.Abs(ndx) < 1e-4f ? (ndx < 0 ? -1e-4f : 1e-4f) : ndx);
|
||||||
|
float ty = (ndy > 0 ? (ph - margin - cy) : (margin - cy)) / (Mathf.Abs(ndy) < 1e-4f ? (ndy < 0 ? -1e-4f : 1e-4f) : ndy);
|
||||||
|
float tt = Mathf.Min(Mathf.Abs(tx), Mathf.Abs(ty));
|
||||||
|
ax = cx + ndx * tt; ay = cy + ndy * tt;
|
||||||
|
}
|
||||||
|
else { ax = px; ay = py - 44f; } // float just above the on-screen target
|
||||||
|
|
||||||
|
float angle = Mathf.Atan2(dy, dx) * Mathf.Rad2Deg; // "▶" art points +x at 0°
|
||||||
|
_pointer.style.left = ax - 15f;
|
||||||
|
_pointer.style.top = ay - 18f;
|
||||||
|
_pointer.style.rotate = new StyleRotate(new Rotate(new Angle(angle)));
|
||||||
|
_pointer.style.display = DisplayStyle.Flex;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- UITK construction ----
|
||||||
|
|
||||||
|
void BuildTree(VisualElement root)
|
||||||
|
{
|
||||||
|
root.style.position = Position.Absolute;
|
||||||
|
root.style.left = 0; root.style.right = 0; root.style.top = 0; root.style.bottom = 0;
|
||||||
|
root.pickingMode = PickingMode.Ignore; // never eat world clicks
|
||||||
|
|
||||||
|
var panel = new VisualElement();
|
||||||
|
panel.style.position = Position.Absolute;
|
||||||
|
panel.style.bottom = 210; panel.style.left = 0; panel.style.right = 0;
|
||||||
|
panel.style.flexDirection = FlexDirection.Row;
|
||||||
|
panel.style.justifyContent = Justify.Center;
|
||||||
|
panel.style.alignItems = Align.Center;
|
||||||
|
panel.pickingMode = PickingMode.Ignore;
|
||||||
|
|
||||||
|
var chip = new VisualElement();
|
||||||
|
chip.style.backgroundColor = new Color(0.05f, 0.07f, 0.10f, 0.92f);
|
||||||
|
chip.style.paddingLeft = 22; chip.style.paddingRight = 22;
|
||||||
|
chip.style.paddingTop = 10; chip.style.paddingBottom = 10;
|
||||||
|
chip.style.maxWidth = 920;
|
||||||
|
chip.pickingMode = PickingMode.Ignore;
|
||||||
|
MenuUi.Round(chip, 8);
|
||||||
|
MenuUi.Border(chip, new Color(MenuUi.Accent.r, MenuUi.Accent.g, MenuUi.Accent.b, 0.55f), 1);
|
||||||
|
|
||||||
|
_prompt = new Label(string.Empty);
|
||||||
|
_prompt.style.color = MenuUi.TextCol;
|
||||||
|
_prompt.style.fontSize = 18;
|
||||||
|
_prompt.style.unityFontStyleAndWeight = FontStyle.Bold;
|
||||||
|
_prompt.style.unityTextAlign = TextAnchor.MiddleCenter;
|
||||||
|
_prompt.style.whiteSpace = WhiteSpace.Normal;
|
||||||
|
var theme = HudTheme.Get();
|
||||||
|
if (theme != null) theme.ApplyBody(_prompt.style);
|
||||||
|
chip.Add(_prompt);
|
||||||
|
panel.Add(chip);
|
||||||
|
root.Add(panel);
|
||||||
|
|
||||||
|
_pointer = new Label("▶"); // ▶ right-pointing triangle (rotated toward the target)
|
||||||
|
_pointer.style.position = Position.Absolute;
|
||||||
|
_pointer.style.fontSize = 30;
|
||||||
|
_pointer.style.color = MenuUi.Accent;
|
||||||
|
_pointer.style.unityFontStyleAndWeight = FontStyle.Bold;
|
||||||
|
_pointer.pickingMode = PickingMode.Ignore;
|
||||||
|
_pointer.style.display = DisplayStyle.None;
|
||||||
|
root.Add(_pointer);
|
||||||
|
|
||||||
|
root.style.display = DisplayStyle.None;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
fileFormatVersion: 2
|
||||||
|
guid: b4828f5a68386fa4da379dcddbf629de
|
||||||
@@ -60,6 +60,8 @@ namespace ProjectM.Client
|
|||||||
Color _slashTint;
|
Color _slashTint;
|
||||||
float _slashAge, _slashLife;
|
float _slashAge, _slashLife;
|
||||||
bool _slashActive;
|
bool _slashActive;
|
||||||
|
float _slashRange, _slashHalf; // live cone geometry re-sampled each frame for the per-frame sweep rebuild
|
||||||
|
int _slashSweepSign = 1; // alternate sweep direction per combo step (reads as alternating strikes)
|
||||||
Material _dangerMat;
|
Material _dangerMat;
|
||||||
readonly Dictionary<Entity, GameObject> _dangerZones = new();
|
readonly Dictionary<Entity, GameObject> _dangerZones = new();
|
||||||
readonly HashSet<Entity> _dangerSeen = new();
|
readonly HashSet<Entity> _dangerSeen = new();
|
||||||
@@ -77,6 +79,21 @@ namespace ProjectM.Client
|
|||||||
Material _barBgMat, _barFillMat;
|
Material _barBgMat, _barFillMat;
|
||||||
// Telegraph scale-pulse (Slice 1, Feature C): per-enemy windup-onset time, folded into the danger cone.
|
// Telegraph scale-pulse (Slice 1, Feature C): per-enemy windup-onset time, folded into the danger cone.
|
||||||
readonly Dictionary<Entity, float> _pulseStart = new();
|
readonly Dictionary<Entity, float> _pulseStart = new();
|
||||||
|
// Near-impact strike beep (deferred-items pass): entity -> the WindUpUntilTick it last beeped for (once/windup).
|
||||||
|
readonly Dictionary<Entity, uint> _strikeBeeped = new();
|
||||||
|
|
||||||
|
// Remote teammates' melee cleave arcs (deferred-items pass, co-op): one pooled slash renderer per remote
|
||||||
|
// player, edge-detected from the replicated MeleeCombo.SwingStartTick (the local player keeps _slashMr).
|
||||||
|
class RemoteSlash
|
||||||
|
{
|
||||||
|
public GameObject Go; public Mesh Mesh; public MeshRenderer Mr; public Material Mat;
|
||||||
|
public float Age, Life, Range, Half; public int SweepSign; public Color Tint;
|
||||||
|
public bool Active; public uint LastSwingTick; public bool Init;
|
||||||
|
}
|
||||||
|
readonly Dictionary<Entity, RemoteSlash> _remoteSlashes = new();
|
||||||
|
readonly HashSet<Entity> _remoteSeen = new();
|
||||||
|
readonly List<Entity> _remoteStale = new();
|
||||||
|
|
||||||
|
|
||||||
AudioClip _hitClip;
|
AudioClip _hitClip;
|
||||||
AudioClip _deathClip;
|
AudioClip _deathClip;
|
||||||
@@ -84,6 +101,8 @@ namespace ProjectM.Client
|
|||||||
AudioClip _telegraphClip;
|
AudioClip _telegraphClip;
|
||||||
AudioClip _dashClip;
|
AudioClip _dashClip;
|
||||||
AudioClip _swingClip;
|
AudioClip _swingClip;
|
||||||
|
AudioClip _meleeConnectClip, _footstepClip, _strikeBeepClip; // combat feel pass: connect thunk / footstep / strike beep
|
||||||
|
Vector3 _lastFootPos; float _footTimer; bool _footInit; // footstep edge-detect (local player locomotion)
|
||||||
|
|
||||||
Entity _localPlayer = Entity.Null;
|
Entity _localPlayer = Entity.Null;
|
||||||
uint _lastLocalFireTick;
|
uint _lastLocalFireTick;
|
||||||
@@ -106,6 +125,9 @@ namespace ProjectM.Client
|
|||||||
_telegraphClip = MakeClip("telegraph", 680f, 1020f, 0.12f, 0.35f, noise: false);
|
_telegraphClip = MakeClip("telegraph", 680f, 1020f, 0.12f, 0.35f, noise: false);
|
||||||
_dashClip = MakeClip("dash", 950f, 240f, 0.12f, 0.50f, noise: false);
|
_dashClip = MakeClip("dash", 950f, 240f, 0.12f, 0.50f, noise: false);
|
||||||
_swingClip = MakeClip("swing", 720f, 200f, 0.09f, 0.42f, noise: false);
|
_swingClip = MakeClip("swing", 720f, 200f, 0.09f, 0.42f, noise: false);
|
||||||
|
_meleeConnectClip = MakeClip("melee_thunk", 180f, 60f, 0.13f, 0.55f, noise: true); // meaty low connect
|
||||||
|
_footstepClip = MakeClip("step", 200f, 110f, 0.06f, 0.18f, noise: true); // soft footfall
|
||||||
|
_strikeBeepClip = MakeClip("strike", 1150f, 1500f, 0.05f, 0.30f, noise: false); // (reserved) near-impact beep
|
||||||
}
|
}
|
||||||
|
|
||||||
protected override void OnStartRunning()
|
protected override void OnStartRunning()
|
||||||
@@ -145,6 +167,14 @@ namespace ProjectM.Client
|
|||||||
if (kv.Value != null) { var mf = kv.Value.GetComponent<MeshFilter>(); if (mf != null && mf.sharedMesh != null) Object.Destroy(mf.sharedMesh); }
|
if (kv.Value != null) { var mf = kv.Value.GetComponent<MeshFilter>(); if (mf != null && mf.sharedMesh != null) Object.Destroy(mf.sharedMesh); }
|
||||||
foreach (var kv in _healthBars)
|
foreach (var kv in _healthBars)
|
||||||
if (kv.Value.CanvasGo != null) Object.Destroy(kv.Value.CanvasGo);
|
if (kv.Value.CanvasGo != null) Object.Destroy(kv.Value.CanvasGo);
|
||||||
|
foreach (var kv in _remoteSlashes)
|
||||||
|
{
|
||||||
|
if (kv.Value.Mesh != null) Object.Destroy(kv.Value.Mesh);
|
||||||
|
if (kv.Value.Mat != null) Object.Destroy(kv.Value.Mat);
|
||||||
|
if (kv.Value.Go != null) Object.Destroy(kv.Value.Go);
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
protected override void OnUpdate()
|
protected override void OnUpdate()
|
||||||
@@ -217,6 +247,8 @@ namespace ProjectM.Client
|
|||||||
PlayClip(_hitClip, (Vector3)p, FeelConfig.HitSfxVolume);
|
PlayClip(_hitClip, (Vector3)p, FeelConfig.HitSfxVolume);
|
||||||
PrototypeCameraRig.AddShake(isLocalPlayer ? FeelConfig.HitShakeLocal : FeelConfig.HitShakeRemote);
|
PrototypeCameraRig.AddShake(isLocalPlayer ? FeelConfig.HitShakeLocal : FeelConfig.HitShakeRemote);
|
||||||
if (isLocalPlayer) PrototypeCameraRig.PunchFov(FeelConfig.HitStopFovKick, FeelConfig.HitStopDurationMs);
|
if (isLocalPlayer) PrototypeCameraRig.PunchFov(FeelConfig.HitStopFovKick, FeelConfig.HitStopDurationMs);
|
||||||
|
if (isLocalPlayer && FeelConfig.RumbleEnabled && AimPresentation.Scheme == 1)
|
||||||
|
RumbleUtil.Pulse(FeelConfig.RumbleHit * 0.8f, FeelConfig.RumbleHit, FeelConfig.RumbleDurationSec);
|
||||||
if (isEnemy)
|
if (isEnemy)
|
||||||
{
|
{
|
||||||
// MC-3: net-new player-dealt-hit camera punch — scales with the bite size
|
// MC-3: net-new player-dealt-hit camera punch — scales with the bite size
|
||||||
@@ -225,6 +257,10 @@ namespace ProjectM.Client
|
|||||||
float hitMag = math.saturate((prev.Hp - cur) / math.max(1f, FeelConfig.HitStopRefDamage));
|
float hitMag = math.saturate((prev.Hp - cur) / math.max(1f, FeelConfig.HitStopRefDamage));
|
||||||
PrototypeCameraRig.PunchFov(math.lerp(FeelConfig.HitStopFovKickMin, FeelConfig.HitStopFovKickMax, hitMag), FeelConfig.HitStopDurationMs);
|
PrototypeCameraRig.PunchFov(math.lerp(FeelConfig.HitStopFovKickMin, FeelConfig.HitStopFovKickMax, hitMag), FeelConfig.HitStopDurationMs);
|
||||||
ShowHealthBar(entity); // Feature B: arm/refresh this enemy's bar on a damage edge
|
ShowHealthBar(entity); // Feature B: arm/refresh this enemy's bar on a damage edge
|
||||||
|
// Hit-flash: a bright body-scaled puff in FeelConfig.HitFlashColor — the staple "I lit it up" read.
|
||||||
|
EmitColored(_hitFx, (Vector3)p + Vector3.up * 0.7f, FeelConfig.HitFlashBurstCount, FeelConfig.HitFlashColor);
|
||||||
|
if (FeelConfig.RumbleEnabled && AimPresentation.Scheme == 1)
|
||||||
|
RumbleUtil.Pulse(FeelConfig.RumbleHit * 0.6f, FeelConfig.RumbleHit, FeelConfig.RumbleDurationSec);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -266,6 +302,9 @@ namespace ProjectM.Client
|
|||||||
PlayClip(_deathClip, (Vector3)c.Pos, FeelConfig.KillSfxVolume);
|
PlayClip(_deathClip, (Vector3)c.Pos, FeelConfig.KillSfxVolume);
|
||||||
PrototypeCameraRig.AddShake(FeelConfig.KillShake);
|
PrototypeCameraRig.AddShake(FeelConfig.KillShake);
|
||||||
PrototypeCameraRig.PunchFov(FeelConfig.KillFovKick, FeelConfig.HitStopDurationMs);
|
PrototypeCameraRig.PunchFov(FeelConfig.KillFovKick, FeelConfig.HitStopDurationMs);
|
||||||
|
EmitColored(_hitFx, (Vector3)c.Pos + Vector3.up * 0.6f, FeelConfig.KillFlashBurstCount, FeelConfig.HitFlashColor); // kill pop
|
||||||
|
if (FeelConfig.RumbleEnabled && AimPresentation.Scheme == 1)
|
||||||
|
RumbleUtil.Pulse(FeelConfig.RumbleKill * 0.7f, FeelConfig.RumbleKill, FeelConfig.RumbleDurationSec);
|
||||||
}
|
}
|
||||||
_cache.Remove(_stale[i]);
|
_cache.Remove(_stale[i]);
|
||||||
}
|
}
|
||||||
@@ -329,18 +368,58 @@ namespace ProjectM.Client
|
|||||||
float slashRange = tcfg.MeleeRange > 0f ? tcfg.MeleeRange : 2.6f;
|
float slashRange = tcfg.MeleeRange > 0f ? tcfg.MeleeRange : 2.6f;
|
||||||
float slashHalf = tcfg.MeleeConeHalfAngleRad > 0f ? tcfg.MeleeConeHalfAngleRad : 0.9f;
|
float slashHalf = tcfg.MeleeConeHalfAngleRad > 0f ? tcfg.MeleeConeHalfAngleRad : 0.9f;
|
||||||
if (finisher) slashRange *= tcfg.MeleeFinisherMult > 0f ? tcfg.MeleeFinisherMult : 1.8f;
|
if (finisher) slashRange *= tcfg.MeleeFinisherMult > 0f ? tcfg.MeleeFinisherMult : 1.8f;
|
||||||
TriggerSlash((Vector3)localPos, new float2(face.x, face.z), slashRange, slashHalf, finisher); // the arc IS the range telegraph (MC-4 visual clarity)
|
// MC-4 connect-vs-whiff: client-side cone overlap over the cached enemy snapshot gives an IMMEDIATE
|
||||||
|
// "you bit" read (the authoritative server damage spark/number arrives a few ticks later).
|
||||||
|
bool connected = false; Vector3 nearestHit = (Vector3)localPos; float ndist = float.MaxValue;
|
||||||
|
float cosHalf = Mathf.Cos(slashHalf);
|
||||||
|
float2 fdir = new float2(face.x, face.z);
|
||||||
|
foreach (var kv in _cache)
|
||||||
|
{
|
||||||
|
if (!kv.Value.IsEnemy) continue;
|
||||||
|
if (MeleeConeMath.InCone(localPos, fdir, slashRange, cosHalf, kv.Value.Pos))
|
||||||
|
{
|
||||||
|
float d2 = math.distancesq(localPos, kv.Value.Pos);
|
||||||
|
if (d2 < ndist) { ndist = d2; nearestHit = (Vector3)kv.Value.Pos; connected = true; }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
TriggerSlash((Vector3)localPos, new float2(face.x, face.z), slashRange, slashHalf, step, comboLen, connected); // sweeps + ramps + brightens on connect
|
||||||
|
if (connected)
|
||||||
|
{
|
||||||
|
Burst(_hitFx, cfg != null ? cfg.Hit : null, nearestHit + Vector3.up * 0.7f, FeelConfig.HitBurstCount);
|
||||||
|
PlayClip(_meleeConnectClip, nearestHit, FeelConfig.MeleeConnectVolume);
|
||||||
|
PrototypeCameraRig.PunchFov(FeelConfig.MeleeConnectFovKick, FeelConfig.HitStopDurationMs);
|
||||||
|
if (FeelConfig.RumbleEnabled && AimPresentation.Scheme == 1)
|
||||||
|
RumbleUtil.Pulse(FeelConfig.RumbleHit * 0.6f, FeelConfig.RumbleHit, FeelConfig.RumbleDurationSec);
|
||||||
|
}
|
||||||
if (finisher) PrototypeCameraRig.PunchFov(FeelConfig.DashFovKick * 0.6f, FeelConfig.HitStopDurationMs);
|
if (finisher) PrototypeCameraRig.PunchFov(FeelConfig.DashFovKick * 0.6f, FeelConfig.HitStopDurationMs);
|
||||||
}
|
}
|
||||||
_lastLocalSwingTick = mc.SwingStartTick;
|
_lastLocalSwingTick = mc.SwingStartTick;
|
||||||
_swingTickInit = true;
|
_swingTickInit = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Footsteps (combat feel): edge-detect local locomotion from the position delta; a soft step at a cadence.
|
||||||
|
if (_localPlayer != Entity.Null)
|
||||||
|
{
|
||||||
|
Vector3 lp = (Vector3)localPos;
|
||||||
|
if (_footInit)
|
||||||
|
{
|
||||||
|
float sp = dt > 1e-4f ? new Vector2(lp.x - _lastFootPos.x, lp.z - _lastFootPos.z).magnitude / dt : 0f;
|
||||||
|
_footTimer -= dt;
|
||||||
|
if (sp >= FeelConfig.FootstepMinSpeed && _footTimer <= 0f)
|
||||||
|
{
|
||||||
|
PlayClip(_footstepClip, lp, FeelConfig.FootstepVolume);
|
||||||
|
_footTimer = FeelConfig.FootstepIntervalSec;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_lastFootPos = lp; _footInit = true;
|
||||||
|
}
|
||||||
|
RumbleUtil.Tick(); // auto-stop any elapsed gamepad rumble pulse
|
||||||
UpdateProjectileTrails(cfg);
|
UpdateProjectileTrails(cfg);
|
||||||
PruneVfx();
|
PruneVfx();
|
||||||
AnimateNumbers(dt, cam);
|
AnimateNumbers(dt, cam);
|
||||||
UpdateSlash(dt);
|
UpdateSlash(dt);
|
||||||
UpdateEnemyDanger();
|
UpdateEnemyDanger(localPos);
|
||||||
|
UpdateRemoteSwings(dt);
|
||||||
UpdateHealthBars(dt, cam, localPos);
|
UpdateHealthBars(dt, cam, localPos);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -352,6 +431,15 @@ namespace ProjectM.Client
|
|||||||
return cfg.PlayerDeath != null ? cfg.PlayerDeath : cfg.EnemyDeath;
|
return cfg.PlayerDeath != null ? cfg.PlayerDeath : cfg.EnemyDeath;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Emit a colored particle burst at a position (per-emit startColor) — used for the enemy hit-flash + kill pop
|
||||||
|
// without a dedicated particle system (the unused FeelConfig.HitFlashColor finally lights enemies on a hit).
|
||||||
|
void EmitColored(ParticleSystem ps, Vector3 pos, int count, Color color)
|
||||||
|
{
|
||||||
|
if (ps == null || count <= 0) return;
|
||||||
|
var ep = new ParticleSystem.EmitParams { position = pos, startColor = color };
|
||||||
|
ps.Emit(ep, count);
|
||||||
|
}
|
||||||
|
|
||||||
void Burst(ParticleSystem fallback, GameObject prefab, Vector3 pos, int count)
|
void Burst(ParticleSystem fallback, GameObject prefab, Vector3 pos, int count)
|
||||||
{
|
{
|
||||||
if (prefab != null) SpawnVfx(prefab, pos, Quaternion.identity);
|
if (prefab != null) SpawnVfx(prefab, pos, Quaternion.identity);
|
||||||
@@ -495,12 +583,14 @@ namespace ProjectM.Client
|
|||||||
|
|
||||||
fn.Active = true;
|
fn.Active = true;
|
||||||
fn.Age = 0f;
|
fn.Age = 0f;
|
||||||
fn.Life = 0.7f;
|
float mag = Mathf.Clamp01(amount / Mathf.Max(1f, FeelConfig.HitStopRefDamage)); // big hits read bigger
|
||||||
|
fn.Life = Mathf.Lerp(0.6f, 0.95f, mag);
|
||||||
fn.Tm.text = Mathf.Max(1, Mathf.RoundToInt(amount)).ToString();
|
fn.Tm.text = Mathf.Max(1, Mathf.RoundToInt(amount)).ToString();
|
||||||
fn.BaseColor = isLocalPlayer ? new Color(1f, 0.5f, 0.22f) : new Color(0.45f, 0.92f, 1f); // Blight orange (hurt) / Aether cyan (you hit)
|
fn.BaseColor = isLocalPlayer ? new Color(1f, 0.5f, 0.22f) : new Color(0.45f, 0.92f, 1f); // Blight orange (hurt) / Aether cyan (you hit)
|
||||||
fn.Tm.color = fn.BaseColor;
|
fn.Tm.color = fn.BaseColor;
|
||||||
fn.Tr.position = worldPos + Vector3.up * 1.4f + new Vector3(UnityEngine.Random.Range(-0.25f, 0.25f), 0f, 0f);
|
fn.Tr.position = worldPos + Vector3.up * 1.4f + new Vector3(UnityEngine.Random.Range(-0.25f, 0.25f), 0f, 0f);
|
||||||
fn.Vel = new Vector3(0f, 2.2f, 0f);
|
fn.Vel = new Vector3(0f, 2.2f, 0f);
|
||||||
|
fn.Tr.localScale = Vector3.one * Mathf.Lerp(0.85f, 1.5f, mag);
|
||||||
fn.Tr.gameObject.SetActive(true);
|
fn.Tr.gameObject.SetActive(true);
|
||||||
if (cam != null) fn.Tr.rotation = cam.transform.rotation;
|
if (cam != null) fn.Tr.rotation = cam.transform.rotation;
|
||||||
}
|
}
|
||||||
@@ -624,23 +714,27 @@ namespace ProjectM.Client
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Rebuild the crescent (inner->outer arc) for the LIVE cone half-angle + range, in local +Z-forward space.
|
// Rebuild the crescent (inner->outer arc) for the LIVE cone half-angle + range, in local +Z-forward space.
|
||||||
void BuildSlashMesh(float range, float halfAngle)
|
// `reveal` (0..1) sweeps the arc open from one edge (sweepSign) toward the other so the cleave reads directional.
|
||||||
|
void BuildSlashInto(Mesh mesh, float range, float halfAngle, float reveal, int sweepSign)
|
||||||
{
|
{
|
||||||
const int seg = 16;
|
const int seg = 16;
|
||||||
float r1 = Mathf.Max(0.4f, range);
|
float r1 = Mathf.Max(0.4f, range);
|
||||||
float r0 = r1 * 0.45f;
|
float r0 = r1 * 0.45f;
|
||||||
|
float aStart = sweepSign >= 0 ? -halfAngle : halfAngle; // trailing edge
|
||||||
|
float aFull = sweepSign >= 0 ? halfAngle : -halfAngle; // far edge
|
||||||
|
float aEnd = Mathf.Lerp(aStart, aFull, Mathf.Clamp01(reveal)); // current leading edge of the sweep
|
||||||
var verts = new Vector3[(seg + 1) * 2];
|
var verts = new Vector3[(seg + 1) * 2];
|
||||||
var cols = new Color[(seg + 1) * 2];
|
var cols = new Color[(seg + 1) * 2];
|
||||||
var uvs = new Vector2[(seg + 1) * 2];
|
var uvs = new Vector2[(seg + 1) * 2];
|
||||||
var tris = new int[seg * 6];
|
var tris = new int[seg * 6];
|
||||||
for (int i = 0; i <= seg; i++)
|
for (int i = 0; i <= seg; i++)
|
||||||
{
|
{
|
||||||
float a = Mathf.Lerp(-halfAngle, halfAngle, i / (float)seg);
|
float a = Mathf.Lerp(aStart, aEnd, i / (float)seg);
|
||||||
float sx = Mathf.Sin(a), cz = Mathf.Cos(a);
|
float sx = Mathf.Sin(a), cz = Mathf.Cos(a);
|
||||||
verts[i * 2] = new Vector3(sx * r0, 0f, cz * r0);
|
verts[i * 2] = new Vector3(sx * r0, 0f, cz * r0);
|
||||||
verts[i * 2 + 1] = new Vector3(sx * r1, 0f, cz * r1);
|
verts[i * 2 + 1] = new Vector3(sx * r1, 0f, cz * r1);
|
||||||
float across = 1f - Mathf.Abs(i / (float)seg * 2f - 1f); // 0 at edges, 1 at centre
|
float lead = i / (float)seg; // 0 trailing -> 1 leading edge (brightest at the travelling blade)
|
||||||
cols[i * 2] = new Color(1f, 1f, 1f, 0.55f * (0.4f + 0.6f * across)); // inner brighter
|
cols[i * 2] = new Color(1f, 1f, 1f, 0.55f * (0.2f + 0.8f * lead)); // inner, brightest at the leading edge
|
||||||
cols[i * 2 + 1] = new Color(1f, 1f, 1f, 0f); // outer rim fades out
|
cols[i * 2 + 1] = new Color(1f, 1f, 1f, 0f); // outer rim fades out
|
||||||
uvs[i * 2] = new Vector2(0.5f, 0.5f);
|
uvs[i * 2] = new Vector2(0.5f, 0.5f);
|
||||||
uvs[i * 2 + 1] = new Vector2(0.5f, 0.5f);
|
uvs[i * 2 + 1] = new Vector2(0.5f, 0.5f);
|
||||||
@@ -651,27 +745,36 @@ namespace ProjectM.Client
|
|||||||
tris[i * 6 + 0] = b; tris[i * 6 + 1] = b + 1; tris[i * 6 + 2] = b + 2;
|
tris[i * 6 + 0] = b; tris[i * 6 + 1] = b + 1; tris[i * 6 + 2] = b + 2;
|
||||||
tris[i * 6 + 3] = b + 1; tris[i * 6 + 4] = b + 3; tris[i * 6 + 5] = b + 2;
|
tris[i * 6 + 3] = b + 1; tris[i * 6 + 4] = b + 3; tris[i * 6 + 5] = b + 2;
|
||||||
}
|
}
|
||||||
_slashMesh.Clear();
|
mesh.Clear();
|
||||||
_slashMesh.vertices = verts;
|
mesh.vertices = verts;
|
||||||
_slashMesh.colors = cols;
|
mesh.colors = cols;
|
||||||
_slashMesh.uv = uvs;
|
mesh.uv = uvs;
|
||||||
_slashMesh.triangles = tris;
|
mesh.triangles = tris;
|
||||||
_slashMesh.RecalculateBounds();
|
mesh.RecalculateBounds();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Flash a cone-shaped slash matching the LIVE melee range + half-angle, oriented along facing. The arc IS the
|
// Trigger a cone-shaped slash matching the LIVE melee range + half-angle, oriented along facing. The arc IS
|
||||||
// range telegraph (MC-4 visual clarity): the player sees exactly how far + how wide the cleave reaches.
|
// the range telegraph (MC-4 clarity) AND now SWEEPS across + ramps per combo step so the swing reads as a
|
||||||
void TriggerSlash(Vector3 pos, float2 facing, float range, float halfAngle, bool finisher)
|
// directional, escalating cleave rather than a static flash.
|
||||||
|
void TriggerSlash(Vector3 pos, float2 facing, float range, float halfAngle, int step, int comboLen, bool connected)
|
||||||
{
|
{
|
||||||
if (_slashMr == null || _slashMat == null) return;
|
if (_slashMr == null || _slashMat == null) return;
|
||||||
BuildSlashMesh(range, halfAngle);
|
bool finisher = step >= comboLen;
|
||||||
|
_slashRange = range; _slashHalf = halfAngle;
|
||||||
|
_slashSweepSign = (step % 2 == 0) ? -1 : 1; // alternate L->R / R->L per swing -> reads as alternating strikes
|
||||||
|
BuildSlashInto(_slashMesh, range, halfAngle, 0f, _slashSweepSign); // start closed; UpdateSlash sweeps it open
|
||||||
Vector3 f = math.lengthsq(facing) > 1e-6f ? new Vector3(facing.x, 0f, facing.y).normalized : Vector3.forward;
|
Vector3 f = math.lengthsq(facing) > 1e-6f ? new Vector3(facing.x, 0f, facing.y).normalized : Vector3.forward;
|
||||||
var tr = _slashMr.transform;
|
var tr = _slashMr.transform;
|
||||||
tr.position = pos + Vector3.up * 0.12f;
|
tr.position = pos + Vector3.up * 0.12f;
|
||||||
tr.rotation = Quaternion.LookRotation(f, Vector3.up);
|
tr.rotation = Quaternion.LookRotation(f, Vector3.up);
|
||||||
tr.localScale = Vector3.one;
|
tr.localScale = Vector3.one;
|
||||||
_slashTint = finisher ? new Color(3.2f, 2.3f, 0.7f) : new Color(1.6f, 2.4f, 3.2f); // finisher warm / light cool (HDR -> bloom)
|
// Per-step ramp so the chain visibly builds to the finisher (the steps were byte-identical before).
|
||||||
_slashLife = finisher ? 0.26f : 0.17f;
|
float t = comboLen > 1 ? math.saturate((step - 1) / (float)(comboLen - 1)) : 1f;
|
||||||
|
_slashTint = finisher
|
||||||
|
? new Color(3.4f, 2.4f, 0.7f) // finisher: warm HDR flash
|
||||||
|
: Color.Lerp(new Color(1.2f, 1.9f, 2.6f), new Color(1.9f, 2.8f, 3.4f), t); // cool, brighter per step
|
||||||
|
if (connected) _slashTint *= 1.6f; // brighter arc on a confirmed bite (the immediate "you hit" read)
|
||||||
|
_slashLife = finisher ? 0.28f : Mathf.Lerp(0.15f, 0.20f, t);
|
||||||
_slashAge = 0f;
|
_slashAge = 0f;
|
||||||
_slashActive = true;
|
_slashActive = true;
|
||||||
_slashMat.color = _slashTint;
|
_slashMat.color = _slashTint;
|
||||||
@@ -684,14 +787,108 @@ namespace ProjectM.Client
|
|||||||
_slashAge += dt;
|
_slashAge += dt;
|
||||||
float u = _slashAge / Mathf.Max(1e-4f, _slashLife);
|
float u = _slashAge / Mathf.Max(1e-4f, _slashLife);
|
||||||
if (u >= 1f) { _slashActive = false; _slashMr.enabled = false; return; }
|
if (u >= 1f) { _slashActive = false; _slashMr.enabled = false; return; }
|
||||||
var c = _slashTint; c.a = 1f - u; _slashMat.color = c;
|
// MC-4 clarity: SWEEP the crescent open across the arc over the first ~60% of life (reads as a blade
|
||||||
_slashMr.transform.localScale = Vector3.one * (1f + u * 0.12f);
|
// travelling through the cleave), then hold + fade — instead of popping the whole cone at once.
|
||||||
|
float reveal = Mathf.Clamp01(u / 0.6f);
|
||||||
|
BuildSlashInto(_slashMesh, _slashRange, _slashHalf, reveal, _slashSweepSign);
|
||||||
|
var c = _slashTint; c.a = u < 0.6f ? 1f : 1f - (u - 0.6f) / 0.4f; _slashMat.color = c;
|
||||||
|
_slashMr.transform.localScale = Vector3.one * (1f + u * 0.10f);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remote teammates' melee cleave arcs (deferred-items pass, co-op readability): the local player's swing
|
||||||
|
// renders via _slashMr; here each REMOTE player (interpolated, GhostOwnerIsLocal DISABLED) gets a pooled
|
||||||
|
// slash arc edge-detected from its replicated MeleeCombo.SwingStartTick + PlayerFacing. Observe-only client
|
||||||
|
// presentation; no sim, no new [GhostField]. Anchored to the moving teammate while it sweeps open + fades.
|
||||||
|
void UpdateRemoteSwings(float dt)
|
||||||
|
{
|
||||||
|
if (!FeelConfig.RemoteSwingEnabled || _fxRoot == null) return;
|
||||||
|
int comboLen = SystemAPI.TryGetSingleton<TuningConfig>(out var tcfg) ? (int)math.clamp((int)tcfg.MeleeComboLength, 1, 3) : 3;
|
||||||
|
float baseRange = tcfg.MeleeRange > 0f ? tcfg.MeleeRange : 2.6f;
|
||||||
|
float baseHalf = tcfg.MeleeConeHalfAngleRad > 0f ? tcfg.MeleeConeHalfAngleRad : 0.9f;
|
||||||
|
float finisherMult = tcfg.MeleeFinisherMult > 0f ? tcfg.MeleeFinisherMult : 1.8f;
|
||||||
|
|
||||||
|
_remoteSeen.Clear();
|
||||||
|
foreach (var (xf, facing, mc, entity) in
|
||||||
|
SystemAPI.Query<RefRO<LocalTransform>, RefRO<PlayerFacing>, RefRO<MeleeCombo>>()
|
||||||
|
.WithAll<PlayerTag>().WithDisabled<GhostOwnerIsLocal>().WithEntityAccess())
|
||||||
|
{
|
||||||
|
_remoteSeen.Add(entity);
|
||||||
|
if (!_remoteSlashes.TryGetValue(entity, out var rs)) { rs = CreateRemoteSlash(); _remoteSlashes[entity] = rs; }
|
||||||
|
|
||||||
|
uint swing = mc.ValueRO.SwingStartTick;
|
||||||
|
if (rs.Init && swing != 0 && swing != rs.LastSwingTick)
|
||||||
|
{
|
||||||
|
int step = math.max(1, (int)mc.ValueRO.Step);
|
||||||
|
bool finisher = step >= comboLen;
|
||||||
|
rs.Range = finisher ? baseRange * finisherMult : baseRange;
|
||||||
|
rs.Half = baseHalf;
|
||||||
|
rs.SweepSign = (step % 2 == 0) ? -1 : 1;
|
||||||
|
rs.Tint = FeelConfig.RemoteSlashColor * (finisher ? 1.5f : 1f);
|
||||||
|
rs.Life = finisher ? 0.26f : 0.18f;
|
||||||
|
rs.Age = 0f;
|
||||||
|
rs.Active = true;
|
||||||
|
BuildSlashInto(rs.Mesh, rs.Range, rs.Half, 0f, rs.SweepSign);
|
||||||
|
rs.Mat.color = rs.Tint;
|
||||||
|
rs.Mr.enabled = true;
|
||||||
|
}
|
||||||
|
rs.LastSwingTick = swing;
|
||||||
|
rs.Init = true;
|
||||||
|
|
||||||
|
if (rs.Active)
|
||||||
|
{
|
||||||
|
rs.Age += dt;
|
||||||
|
float u = rs.Age / Mathf.Max(1e-4f, rs.Life);
|
||||||
|
if (u >= 1f) { rs.Active = false; rs.Mr.enabled = false; }
|
||||||
|
else
|
||||||
|
{
|
||||||
|
float2 fdir = facing.ValueRO.Direction;
|
||||||
|
Vector3 f = math.lengthsq(fdir) > 1e-6f ? new Vector3(fdir.x, 0f, fdir.y).normalized : Vector3.forward;
|
||||||
|
var tr = rs.Mr.transform;
|
||||||
|
tr.position = (Vector3)xf.ValueRO.Position + Vector3.up * 0.12f;
|
||||||
|
tr.rotation = Quaternion.LookRotation(f, Vector3.up);
|
||||||
|
float reveal = Mathf.Clamp01(u / 0.6f);
|
||||||
|
BuildSlashInto(rs.Mesh, rs.Range, rs.Half, reveal, rs.SweepSign);
|
||||||
|
var c = rs.Tint; c.a = u < 0.6f ? 1f : 1f - (u - 0.6f) / 0.4f; rs.Mat.color = c;
|
||||||
|
tr.localScale = Vector3.one * (1f + u * 0.10f);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (_remoteSlashes.Count != _remoteSeen.Count)
|
||||||
|
{
|
||||||
|
_remoteStale.Clear();
|
||||||
|
foreach (var kv in _remoteSlashes) if (!_remoteSeen.Contains(kv.Key)) _remoteStale.Add(kv.Key);
|
||||||
|
for (int i = 0; i < _remoteStale.Count; i++)
|
||||||
|
{
|
||||||
|
var rs = _remoteSlashes[_remoteStale[i]];
|
||||||
|
if (rs.Mesh != null) Object.Destroy(rs.Mesh);
|
||||||
|
if (rs.Mat != null) Object.Destroy(rs.Mat);
|
||||||
|
if (rs.Go != null) Object.Destroy(rs.Go);
|
||||||
|
_remoteSlashes.Remove(_remoteStale[i]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
RemoteSlash CreateRemoteSlash()
|
||||||
|
{
|
||||||
|
var go = new GameObject("RemoteSlashArc");
|
||||||
|
go.transform.SetParent(_fxRoot, false);
|
||||||
|
var mesh = new Mesh { name = "RemoteSlashArc" };
|
||||||
|
go.AddComponent<MeshFilter>().sharedMesh = mesh;
|
||||||
|
var mr = go.AddComponent<MeshRenderer>();
|
||||||
|
var mat = MakeParticleMaterial();
|
||||||
|
mat.name = "RemoteSlashArc";
|
||||||
|
mr.sharedMaterial = mat;
|
||||||
|
mr.shadowCastingMode = UnityEngine.Rendering.ShadowCastingMode.Off;
|
||||||
|
mr.receiveShadows = false;
|
||||||
|
mr.enabled = false;
|
||||||
|
return new RemoteSlash { Go = go, Mesh = mesh, Mr = mr, Mat = mat, Active = false, Init = false };
|
||||||
}
|
}
|
||||||
|
|
||||||
// Enemy attack TELEGRAPH (MC-4 clarity): while an enemy's AttackWindup counts down, paint a red ground danger
|
// Enemy attack TELEGRAPH (MC-4 clarity): while an enemy's AttackWindup counts down, paint a red ground danger
|
||||||
// cone in its facing out to its reach, brightening + scaling as the strike nears -> the player reads WHERE +
|
// cone in its facing out to its reach, brightening + scaling as the strike nears -> the player reads WHERE +
|
||||||
// WHEN to dodge. Client-only, observe-only; one pooled mesh per winding-up enemy, pruned each frame.
|
// WHEN to dodge. Client-only, observe-only; one pooled mesh per winding-up enemy, pruned each frame.
|
||||||
void UpdateEnemyDanger()
|
void UpdateEnemyDanger(float3 localPos)
|
||||||
{
|
{
|
||||||
if (_fxRoot == null || _dangerMat == null) return;
|
if (_fxRoot == null || _dangerMat == null) return;
|
||||||
Unity.NetCode.NetworkTick serverTick = SystemAPI.TryGetSingleton<NetworkTime>(out var nt) ? nt.ServerTick : default;
|
Unity.NetCode.NetworkTick serverTick = SystemAPI.TryGetSingleton<NetworkTime>(out var nt) ? nt.ServerTick : default;
|
||||||
@@ -721,6 +918,20 @@ namespace ProjectM.Client
|
|||||||
// any windup length (fixes the Charger plateauing early under the old hard-coded 22).
|
// any windup length (fixes the Charger plateauing early under the old hard-coded 22).
|
||||||
float windupDur = math.max(1f, tele.ValueRO.WindupTicks);
|
float windupDur = math.max(1f, tele.ValueRO.WindupTicks);
|
||||||
intensity = math.saturate(1f - remaining / windupDur);
|
intensity = math.saturate(1f - remaining / windupDur);
|
||||||
|
|
||||||
|
// Near-impact strike beep (deferred-items pass): a "dodge NOW" cue once per windup, gated to
|
||||||
|
// enemies near the local player (the danger cone already proves it's winding up to strike).
|
||||||
|
if (FeelConfig.StrikeBeepEnabled && _localPlayer != Entity.Null && remaining <= FeelConfig.StrikeBeepLeadTicks
|
||||||
|
&& (!_strikeBeeped.TryGetValue(entity, out var beepedUntil) || beepedUntil != until))
|
||||||
|
{
|
||||||
|
float3 ep = xf.ValueRO.Position;
|
||||||
|
if (math.distancesq(ep, localPos) <= FeelConfig.StrikeBeepMaxDistSq)
|
||||||
|
{
|
||||||
|
PlayClip(_strikeBeepClip, (Vector3)ep, FeelConfig.StrikeBeepVolume);
|
||||||
|
_strikeBeeped[entity] = until;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Feature C: a short anticipation scale-pulse folded into the client-owned cone (never the ghost).
|
// Feature C: a short anticipation scale-pulse folded into the client-owned cone (never the ghost).
|
||||||
@@ -778,6 +989,8 @@ namespace ProjectM.Client
|
|||||||
if (g != null) { var mf = g.GetComponent<MeshFilter>(); if (mf != null && mf.sharedMesh != null) Object.Destroy(mf.sharedMesh); Object.Destroy(g); }
|
if (g != null) { var mf = g.GetComponent<MeshFilter>(); if (mf != null && mf.sharedMesh != null) Object.Destroy(mf.sharedMesh); Object.Destroy(g); }
|
||||||
_dangerZones.Remove(_dangerStale[i]);
|
_dangerZones.Remove(_dangerStale[i]);
|
||||||
_pulseStart.Remove(_dangerStale[i]);
|
_pulseStart.Remove(_dangerStale[i]);
|
||||||
|
_strikeBeeped.Remove(_dangerStale[i]);
|
||||||
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,125 @@
|
|||||||
|
using System.Collections.Generic;
|
||||||
|
using ProjectM.Simulation;
|
||||||
|
using Unity.Entities;
|
||||||
|
using Unity.Mathematics;
|
||||||
|
using Unity.Rendering; // URPMaterialPropertyBaseColor, MaterialMeshInfo (Entities Graphics)
|
||||||
|
using UnityEngine;
|
||||||
|
|
||||||
|
namespace ProjectM.Client
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Client-only TRUE BODY hit-flash for enemies (the focused follow-up DR-041 deferred as "its own ShaderGraph
|
||||||
|
/// slice"). Enemies render via Rukhanka GPU deformation: the ghost ROOT holds the gameplay components
|
||||||
|
/// (<see cref="Health"/>, <see cref="EnemyTag"/>) while the visible meshes are LinkedEntityGroup CHILD render
|
||||||
|
/// entities (each with a <see cref="MaterialMeshInfo"/> + the AnimatedLitShader material, whose <c>_BaseColor</c>
|
||||||
|
/// is white). <see cref="CombatFeedbackSystem"/>'s colored particle puff is the asset-free stand-in; THIS system
|
||||||
|
/// flashes the actual body by driving the built-in Entities-Graphics per-instance override
|
||||||
|
/// <see cref="URPMaterialPropertyBaseColor"/> (a registered <c>[MaterialProperty("_BaseColor")]</c>) on those
|
||||||
|
/// render children: on an enemy <see cref="Health"/>-decrease edge it lerps <c>_BaseColor</c> toward
|
||||||
|
/// <see cref="FeelConfig.BodyFlashColor"/> and decays back to white. No new component type, NO ShaderGraph edit,
|
||||||
|
/// no server work, no <c>[GhostField]</c> — observe-only client presentation, so it is rollback-irrelevant.
|
||||||
|
/// The render children gain <see cref="URPMaterialPropertyBaseColor"/> lazily (added once per enemy via ECB);
|
||||||
|
/// white is the baked rest value (every enemy uses the same AnimatedLitShader/Synty-atlas convention), so
|
||||||
|
/// <c>Flash==0</c> restores the untouched look and the override is never visible at rest.
|
||||||
|
/// </summary>
|
||||||
|
[WorldSystemFilter(WorldSystemFilterFlags.ClientSimulation)]
|
||||||
|
[UpdateInGroup(typeof(PresentationSystemGroup))]
|
||||||
|
public partial class EnemyHitFlashSystem : SystemBase
|
||||||
|
{
|
||||||
|
class FlashEntry
|
||||||
|
{
|
||||||
|
public readonly List<Entity> RenderKids = new();
|
||||||
|
public float LastHp;
|
||||||
|
public float Flash; // 1 on a fresh hit, decays to 0
|
||||||
|
public bool Settled; // wrote the final white frame after a flash ended (skips per-frame writes at rest)
|
||||||
|
}
|
||||||
|
|
||||||
|
readonly Dictionary<Entity, FlashEntry> _tracked = new();
|
||||||
|
readonly HashSet<Entity> _seen = new();
|
||||||
|
readonly List<Entity> _stale = new();
|
||||||
|
|
||||||
|
static readonly float4 White = new float4(1f, 1f, 1f, 1f);
|
||||||
|
|
||||||
|
protected override void OnUpdate()
|
||||||
|
{
|
||||||
|
if (!FeelConfig.BodyFlashEnabled) { RestoreAllToRest(); return; } // settle any mid-flash body back to white before bailing
|
||||||
|
float dt = SystemAPI.Time.DeltaTime;
|
||||||
|
EntityManager.CompleteDependencyBeforeRO<Health>();
|
||||||
|
|
||||||
|
// Pass 1: discover enemies, ensure each is tracked + its render children carry the override component.
|
||||||
|
_seen.Clear();
|
||||||
|
var ecb = new EntityCommandBuffer(Unity.Collections.Allocator.Temp);
|
||||||
|
foreach (var (health, entity) in
|
||||||
|
SystemAPI.Query<RefRO<Health>>().WithAll<EnemyTag, LinkedEntityGroup>().WithEntityAccess())
|
||||||
|
{
|
||||||
|
_seen.Add(entity);
|
||||||
|
if (_tracked.ContainsKey(entity)) continue;
|
||||||
|
|
||||||
|
var entry = new FlashEntry { LastHp = health.ValueRO.Current, Flash = 0f, Settled = true };
|
||||||
|
var leg = EntityManager.GetBuffer<LinkedEntityGroup>(entity);
|
||||||
|
for (int i = 0; i < leg.Length; i++)
|
||||||
|
{
|
||||||
|
var c = leg[i].Value;
|
||||||
|
if (!EntityManager.Exists(c) || !EntityManager.HasComponent<MaterialMeshInfo>(c)) continue;
|
||||||
|
entry.RenderKids.Add(c);
|
||||||
|
if (!EntityManager.HasComponent<URPMaterialPropertyBaseColor>(c))
|
||||||
|
ecb.AddComponent(c, new URPMaterialPropertyBaseColor { Value = White });
|
||||||
|
}
|
||||||
|
// Render children can lag ghost instantiation a frame; only finalize once we actually found them (else retry next frame).
|
||||||
|
if (entry.RenderKids.Count > 0) _tracked[entity] = entry;
|
||||||
|
}
|
||||||
|
ecb.Playback(EntityManager);
|
||||||
|
ecb.Dispose();
|
||||||
|
|
||||||
|
// Pass 2: edge-detect Health, drive + decay the flash, write _BaseColor to the render children.
|
||||||
|
var bc = FeelConfig.BodyFlashColor;
|
||||||
|
float4 peak = new float4(bc.r, bc.g, bc.b, bc.a);
|
||||||
|
float decay = dt / math.max(0.01f, FeelConfig.BodyFlashDurationSec);
|
||||||
|
foreach (var kv in _tracked)
|
||||||
|
{
|
||||||
|
var entity = kv.Key;
|
||||||
|
var entry = kv.Value;
|
||||||
|
if (!_seen.Contains(entity)) continue; // despawned -> pruned below
|
||||||
|
|
||||||
|
float cur = EntityManager.GetComponentData<Health>(entity).Current;
|
||||||
|
if (cur < entry.LastHp - 0.001f) { entry.Flash = 1f; entry.Settled = false; }
|
||||||
|
entry.LastHp = cur;
|
||||||
|
|
||||||
|
if (entry.Flash <= 0f)
|
||||||
|
{
|
||||||
|
if (!entry.Settled) { WriteColor(entry, White); entry.Settled = true; } // settle to baked white once
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
entry.Flash = math.max(0f, entry.Flash - decay);
|
||||||
|
WriteColor(entry, math.lerp(White, peak, entry.Flash));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Prune despawned enemies (their render children die with them; just drop the managed entry).
|
||||||
|
if (_tracked.Count != _seen.Count)
|
||||||
|
{
|
||||||
|
_stale.Clear();
|
||||||
|
foreach (var kv in _tracked) if (!_seen.Contains(kv.Key)) _stale.Add(kv.Key);
|
||||||
|
for (int i = 0; i < _stale.Count; i++) _tracked.Remove(_stale[i]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Settle every tracked enemy's body back to its baked white rest color and stop tracking — invoked when
|
||||||
|
// BodyFlashEnabled is toggled OFF mid-flash so no body is left frozen at an overdriven tint (re-tracked on re-enable).
|
||||||
|
void RestoreAllToRest()
|
||||||
|
{
|
||||||
|
foreach (var kv in _tracked) WriteColor(kv.Value, White);
|
||||||
|
_tracked.Clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
void WriteColor(FlashEntry entry, float4 col)
|
||||||
|
{
|
||||||
|
for (int i = 0; i < entry.RenderKids.Count; i++)
|
||||||
|
{
|
||||||
|
var c = entry.RenderKids[i];
|
||||||
|
if (EntityManager.Exists(c) && EntityManager.HasComponent<URPMaterialPropertyBaseColor>(c))
|
||||||
|
EntityManager.SetComponentData(c, new URPMaterialPropertyBaseColor { Value = col });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
fileFormatVersion: 2
|
||||||
|
guid: 03cbda5f0bedbe14fbf680d92db148fe
|
||||||
@@ -109,6 +109,53 @@ namespace ProjectM.Client
|
|||||||
/// Health flicker on a clean dodge — the documented acceptable-not-a-bug interaction).</summary>
|
/// Health flicker on a clean dodge — the documented acceptable-not-a-bug interaction).</summary>
|
||||||
public static bool DashHitSuppress;
|
public static bool DashHitSuppress;
|
||||||
|
|
||||||
|
// ---- Combat feel pass (2026-06): connect cue, hit-flash, kill pop, footsteps, rumble, telegraph beep ----
|
||||||
|
/// <summary>Hit-flash puff density (a colored particle burst in HitFlashColor on an enemy damage edge).</summary>
|
||||||
|
public static int HitFlashBurstCount;
|
||||||
|
/// <summary>Extra FOV punch (deg) when the LOCAL melee cleave is confirmed to connect (vs a whiff).</summary>
|
||||||
|
public static float MeleeConnectFovKick;
|
||||||
|
/// <summary>Volume of the meaty melee connect "thunk".</summary>
|
||||||
|
public static float MeleeConnectVolume;
|
||||||
|
/// <summary>Kill-pop colored flash density on an enemy death.</summary>
|
||||||
|
public static int KillFlashBurstCount;
|
||||||
|
/// <summary>Soft footstep SFX volume.</summary>
|
||||||
|
public static float FootstepVolume;
|
||||||
|
/// <summary>Seconds between footsteps while the local player is moving.</summary>
|
||||||
|
public static float FootstepIntervalSec;
|
||||||
|
/// <summary>Local player speed (u/s) above which footsteps play.</summary>
|
||||||
|
public static float FootstepMinSpeed;
|
||||||
|
/// <summary>Master gate for gamepad rumble (no-op on KBM).</summary>
|
||||||
|
public static bool RumbleEnabled;
|
||||||
|
/// <summary>Rumble strength on a local hit taken / dealt.</summary>
|
||||||
|
public static float RumbleHit;
|
||||||
|
/// <summary>Rumble strength on a kill.</summary>
|
||||||
|
public static float RumbleKill;
|
||||||
|
/// <summary>Rumble strength on dash / local death (the heaviest).</summary>
|
||||||
|
public static float RumbleHeavy;
|
||||||
|
/// <summary>Seconds a rumble pulse lasts before it auto-stops.</summary>
|
||||||
|
public static float RumbleDurationSec;
|
||||||
|
|
||||||
|
// ---- Deferred-items pass (2026-06): true body hit-flash, remote co-op swings, near-impact strike beep ----
|
||||||
|
/// <summary>Master gate for the enemy material BODY hit-flash (drives URPMaterialPropertyBaseColor on render children).</summary>
|
||||||
|
public static bool BodyFlashEnabled;
|
||||||
|
/// <summary>Peak _BaseColor the enemy body flashes to on a hit (HDR; lerps from the baked white base and decays back).</summary>
|
||||||
|
public static Color BodyFlashColor;
|
||||||
|
/// <summary>Seconds the body flash decays from peak back to the baked white base.</summary>
|
||||||
|
public static float BodyFlashDurationSec;
|
||||||
|
/// <summary>Master gate for rendering REMOTE teammates' melee cleave arcs (co-op readability).</summary>
|
||||||
|
public static bool RemoteSwingEnabled;
|
||||||
|
/// <summary>Tint of a remote teammate's slash arc (cooler/friendlier than the local warm arc).</summary>
|
||||||
|
public static Color RemoteSlashColor;
|
||||||
|
/// <summary>Master gate for the near-impact \"dodge NOW\" strike beep on a winding-up enemy.</summary>
|
||||||
|
public static bool StrikeBeepEnabled;
|
||||||
|
/// <summary>Volume of the near-impact strike beep.</summary>
|
||||||
|
public static float StrikeBeepVolume;
|
||||||
|
/// <summary>Ticks before the strike lands that the beep fires (the dodge-reaction lead).</summary>
|
||||||
|
public static int StrikeBeepLeadTicks;
|
||||||
|
/// <summary>Squared world-distance from the local player beyond which the strike beep is suppressed (avoids a distant cacophony).</summary>
|
||||||
|
public static float StrikeBeepMaxDistSq;
|
||||||
|
|
||||||
|
|
||||||
[RuntimeInitializeOnLoadMethod(RuntimeInitializeLoadType.SubsystemRegistration)]
|
[RuntimeInitializeOnLoadMethod(RuntimeInitializeLoadType.SubsystemRegistration)]
|
||||||
public static void ResetDefaults()
|
public static void ResetDefaults()
|
||||||
{
|
{
|
||||||
@@ -161,6 +208,32 @@ namespace ProjectM.Client
|
|||||||
DashSfxVolume = 0.55f;
|
DashSfxVolume = 0.55f;
|
||||||
DashShimmerPerFrame = 2;
|
DashShimmerPerFrame = 2;
|
||||||
DashHitSuppress = true;
|
DashHitSuppress = true;
|
||||||
|
|
||||||
|
// Combat feel pass (2026-06)
|
||||||
|
HitFlashBurstCount = 16;
|
||||||
|
MeleeConnectFovKick = 0.8f;
|
||||||
|
MeleeConnectVolume = 0.55f;
|
||||||
|
KillFlashBurstCount = 20;
|
||||||
|
FootstepVolume = 0.16f;
|
||||||
|
FootstepIntervalSec = 0.32f;
|
||||||
|
FootstepMinSpeed = 1.5f;
|
||||||
|
RumbleEnabled = true;
|
||||||
|
RumbleHit = 0.25f;
|
||||||
|
RumbleKill = 0.45f;
|
||||||
|
RumbleHeavy = 0.6f;
|
||||||
|
RumbleDurationSec = 0.12f;
|
||||||
|
|
||||||
|
// Deferred-items pass (2026-06)
|
||||||
|
BodyFlashEnabled = true;
|
||||||
|
BodyFlashColor = new Color(3.2f, 2.8f, 2.2f, 1f); // hot near-white overdrive (multiplies the Synty atlas base map)
|
||||||
|
BodyFlashDurationSec = 0.16f;
|
||||||
|
RemoteSwingEnabled = true;
|
||||||
|
RemoteSlashColor = new Color(1.4f, 2.2f, 2.8f, 1f); // cool teammate arc
|
||||||
|
StrikeBeepEnabled = true;
|
||||||
|
StrikeBeepVolume = 0.40f;
|
||||||
|
StrikeBeepLeadTicks = 8;
|
||||||
|
StrikeBeepMaxDistSq = 225f; // 15 m
|
||||||
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ namespace ProjectM.Client
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
[WorldSystemFilter(WorldSystemFilterFlags.ClientSimulation)]
|
[WorldSystemFilter(WorldSystemFilterFlags.ClientSimulation)]
|
||||||
[UpdateInGroup(typeof(PresentationSystemGroup))]
|
[UpdateInGroup(typeof(PresentationSystemGroup))]
|
||||||
|
[UpdateAfter(typeof(OnboardingSystem))] // read OnboardingState.Active same-frame (single prompt voice)
|
||||||
public partial class HudSystem : SystemBase
|
public partial class HudSystem : SystemBase
|
||||||
{
|
{
|
||||||
// ---- palette (Aether language; Synty white skins are tinted into these) ----
|
// ---- palette (Aether language; Synty white skins are tinted into these) ----
|
||||||
@@ -67,6 +68,8 @@ namespace ProjectM.Client
|
|||||||
// END-2: terminal win/loss banner (observes the replicated RunOutcome; latched server-side).
|
// END-2: terminal win/loss banner (observes the replicated RunOutcome; latched server-side).
|
||||||
VisualElement _runBanner;
|
VisualElement _runBanner;
|
||||||
Label _runBannerText, _runBannerSub;
|
Label _runBannerText, _runBannerSub;
|
||||||
|
// DR-042 C6a: the Aether ability-upgrade button (was U-key only) + its live affordability tint.
|
||||||
|
Button _upgradeBtn;
|
||||||
|
|
||||||
|
|
||||||
readonly List<VisualElement> _pips = new();
|
readonly List<VisualElement> _pips = new();
|
||||||
@@ -181,6 +184,28 @@ namespace ProjectM.Client
|
|||||||
_locationText.style.color = onExpedition ? new Color(1f, 0.8f, 0.4f)
|
_locationText.style.color = onExpedition ? new Color(1f, 0.8f, 0.4f)
|
||||||
: finalSiege ? new Color(1f, 0.3f, 0.25f)
|
: finalSiege ? new Color(1f, 0.3f, 0.25f)
|
||||||
: siege ? new Color(1f, 0.55f, 0.4f) : new Color(0.6f, 0.95f, 0.7f);
|
: siege ? new Color(1f, 0.55f, 0.4f) : new Color(0.6f, 0.95f, 0.7f);
|
||||||
|
// DR-042 C7 (gate prompt) + C7b (objective readout): the expedition is the win-driver, so signpost it.
|
||||||
|
// Reads the REPLICATED ExpeditionObjective summary (cross-region safe). Lower priority than the siege /
|
||||||
|
// cold-turret / overrun overrides below, which still win.
|
||||||
|
if (SystemAPI.TryGetSingleton<ExpeditionObjective>(out var obj))
|
||||||
|
{
|
||||||
|
if (onExpedition)
|
||||||
|
{
|
||||||
|
if (obj.State == ExpeditionObjectiveState.Cleared)
|
||||||
|
{ _locationText.text = "ZONE CLEARED - return to base to claim"; _locationText.style.color = new Color(0.5f, 1f, 0.6f); }
|
||||||
|
else if (obj.State == ExpeditionObjectiveState.Active)
|
||||||
|
{ _locationText.text = "CLEAR THE ZONE - " + obj.Remaining + " enemies remaining"; _locationText.style.color = new Color(1f, 0.8f, 0.4f); }
|
||||||
|
}
|
||||||
|
else if (!siege)
|
||||||
|
{
|
||||||
|
if (obj.State == ExpeditionObjectiveState.Cleared)
|
||||||
|
{ _locationText.text = "EXPEDITION CLEARED - return to claim your reward"; _locationText.style.color = new Color(0.5f, 1f, 0.6f); }
|
||||||
|
else if (obj.State == ExpeditionObjectiveState.Active)
|
||||||
|
{ _locationText.text = "EXPEDITION IN PROGRESS - " + obj.Remaining + " enemies remaining"; _locationText.style.color = new Color(1f, 0.8f, 0.4f); }
|
||||||
|
else
|
||||||
|
{ _locationText.text = "GO TO THE EXPEDITION GATE - clear a sortie to advance the Engine"; _locationText.style.color = new Color(0.55f, 0.85f, 1f); }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ---- Goal (hex-pip meter, or a continuous bar for large targets) ----
|
// ---- Goal (hex-pip meter, or a continuous bar for large targets) ----
|
||||||
if (SystemAPI.TryGetSingleton<GoalProgress>(out var goal))
|
if (SystemAPI.TryGetSingleton<GoalProgress>(out var goal))
|
||||||
@@ -230,6 +255,9 @@ namespace ProjectM.Client
|
|||||||
_oreNum.text = ore.ToString();
|
_oreNum.text = ore.ToString();
|
||||||
_bioNum.text = bio.ToString();
|
_bioNum.text = bio.ToString();
|
||||||
_chargeNum.text = charge.ToString();
|
_chargeNum.text = charge.ToString();
|
||||||
|
// DR-042 C6a: dim the Aether upgrade button when it isn't affordable (cost is a compile-time const).
|
||||||
|
if (_upgradeBtn != null)
|
||||||
|
_upgradeBtn.style.opacity = aether >= Tuning.AbilityUpgradeCostAmount ? 1f : 0.5f;
|
||||||
// EB-2 quiet-turret cue (GLOBAL, not per-turret, so the deterministic Charge split never reads as one
|
// EB-2 quiet-turret cue (GLOBAL, not per-turret, so the deterministic Charge split never reads as one
|
||||||
// broken turret): a dry base during a siege tells the player to build a Fabricator.
|
// broken turret): a dry base during a siege tells the player to build a Fabricator.
|
||||||
if (siege && charge == 0 && !onExpedition)
|
if (siege && charge == 0 && !onExpedition)
|
||||||
@@ -263,6 +291,9 @@ namespace ProjectM.Client
|
|||||||
_locationText.text = "BASE OVERRUN - resources lost; the Core will recover";
|
_locationText.text = "BASE OVERRUN - resources lost; the Core will recover";
|
||||||
_locationText.style.color = new Color(1f, 0.3f, 0.25f);
|
_locationText.style.color = new Color(1f, 0.3f, 0.25f);
|
||||||
}
|
}
|
||||||
|
// First-run onboarding owns the prompt voice: while a coach-mark step is showing, blank the HUD's own
|
||||||
|
// location/gate hint so the player sees a single prompt (OnboardingSystem drives its own overlay).
|
||||||
|
if (OnboardingState.Active) _locationText.text = "";
|
||||||
// ---- END-2: terminal run banner (Victory / Loss), observed from the replicated RunOutcome ----
|
// ---- END-2: terminal run banner (Victory / Loss), observed from the replicated RunOutcome ----
|
||||||
if (SystemAPI.TryGetSingleton<RunOutcome>(out var runOutcome) && runOutcome.Value != RunOutcomeId.InProgress)
|
if (SystemAPI.TryGetSingleton<RunOutcome>(out var runOutcome) && runOutcome.Value != RunOutcomeId.InProgress)
|
||||||
{
|
{
|
||||||
@@ -446,12 +477,18 @@ namespace ProjectM.Client
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// DR-042 C6d: Harvester/Conveyor/Pylon are dead (unwired automation) -> hidden from the build palette
|
||||||
|
// (catalog + prefabs stay baked, code-intact per DR-020). Only Turret/Wall/Fabricator are buildable in the UI.
|
||||||
|
static bool IsPaletteType(byte type) =>
|
||||||
|
type != StructureType.Pylon && type != StructureType.Harvester && type != StructureType.Conveyor;
|
||||||
|
|
||||||
void UpdatePalette(int aether, int ore, int bio, bool onExpedition)
|
void UpdatePalette(int aether, int ore, int bio, bool onExpedition)
|
||||||
{
|
{
|
||||||
if (!_paletteBuilt && SystemAPI.TryGetSingletonEntity<StructureCatalog>(out var catE))
|
if (!_paletteBuilt && SystemAPI.TryGetSingletonEntity<StructureCatalog>(out var catE))
|
||||||
{
|
{
|
||||||
var cat = SystemAPI.GetBuffer<StructureCatalogEntry>(catE);
|
var cat = SystemAPI.GetBuffer<StructureCatalogEntry>(catE);
|
||||||
for (int i = 0; i < cat.Length; i++)
|
for (int i = 0; i < cat.Length; i++)
|
||||||
|
if (IsPaletteType(cat[i].Type))
|
||||||
AddPaletteItem(cat[i].Type, cat[i].CostAmount, cat[i].CostResourceId);
|
AddPaletteItem(cat[i].Type, cat[i].CostAmount, cat[i].CostResourceId);
|
||||||
_paletteBuilt = true;
|
_paletteBuilt = true;
|
||||||
}
|
}
|
||||||
@@ -809,6 +846,11 @@ namespace ProjectM.Client
|
|||||||
strip.Add(ResourceChip(theme != null ? theme.OreIcon : null, OreAmber, "0", out _oreNum, 30, 22));
|
strip.Add(ResourceChip(theme != null ? theme.OreIcon : null, OreAmber, "0", out _oreNum, 30, 22));
|
||||||
strip.Add(ResourceChip(theme != null ? theme.BioIcon : null, BioGreen, "0", out _bioNum, 26, 20));
|
strip.Add(ResourceChip(theme != null ? theme.BioIcon : null, BioGreen, "0", out _bioNum, 26, 20));
|
||||||
strip.Add(ResourceChip(null, ChargeViolet, "0", out _chargeNum, 26, 20)); // EB-2 turret ammo (flat violet, no icon)
|
strip.Add(ResourceChip(null, ChargeViolet, "0", out _chargeNum, 26, 20)); // EB-2 turret ammo (flat violet, no icon)
|
||||||
|
// DR-042 C6a: the only Aether sink (ability-damage upgrade) gets a visible, clickable button (was U-key
|
||||||
|
// only). The Button element handles its own picking even though the HUD root Ignores clicks.
|
||||||
|
_upgradeBtn = MenuUi.Button("UPGRADE DMG (" + Tuning.AbilityUpgradeCostAmount + " AETHER)", BuildSendSystem.UpgradeAbility);
|
||||||
|
_upgradeBtn.style.marginLeft = 18;
|
||||||
|
strip.Add(_upgradeBtn);
|
||||||
|
|
||||||
root.Add(strip);
|
root.Add(strip);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,54 @@
|
|||||||
|
using UnityEngine;
|
||||||
|
using UnityEngine.InputSystem;
|
||||||
|
|
||||||
|
namespace ProjectM.Client
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Gamepad rumble for combat feel — a static bridge (mirrors <see cref="FeelConfig"/> / <see cref="AimPresentation"/>).
|
||||||
|
/// <see cref="Pulse"/> sets the motors and stamps a stop time; <see cref="Tick"/> (called once per frame from
|
||||||
|
/// <c>CombatFeedbackSystem</c>) stops them when the pulse elapses OR the app loses focus, so a rumble never sticks.
|
||||||
|
/// A no-op when no pad is connected; the CALLER gates to the Gamepad scheme. Statics survive fast-enter-playmode
|
||||||
|
/// reloads, so <see cref="ResetState"/> re-arms clean on play-enter and stops any leaked motor (the AimPresentation
|
||||||
|
/// reset idiom). Presentation-only, main-thread, never touches the simulation.
|
||||||
|
/// </summary>
|
||||||
|
public static class RumbleUtil
|
||||||
|
{
|
||||||
|
static float s_StopTime;
|
||||||
|
static bool s_Active;
|
||||||
|
|
||||||
|
[RuntimeInitializeOnLoadMethod(RuntimeInitializeLoadType.SubsystemRegistration)]
|
||||||
|
static void ResetState()
|
||||||
|
{
|
||||||
|
s_Active = false;
|
||||||
|
s_StopTime = 0f;
|
||||||
|
Stop();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Pulse both motors at the given low/high strengths for durSec, then auto-stop. No-op without a pad.</summary>
|
||||||
|
public static void Pulse(float low, float high, float durSec)
|
||||||
|
{
|
||||||
|
var pad = Gamepad.current;
|
||||||
|
if (pad == null) return;
|
||||||
|
pad.SetMotorSpeeds(Mathf.Clamp01(low), Mathf.Clamp01(high));
|
||||||
|
s_StopTime = Time.unscaledTime + Mathf.Max(0.02f, durSec);
|
||||||
|
s_Active = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Call once per frame: stops the motors when the pulse elapses or focus is lost.</summary>
|
||||||
|
public static void Tick()
|
||||||
|
{
|
||||||
|
if (!s_Active) return;
|
||||||
|
if (!Application.isFocused || Time.unscaledTime >= s_StopTime)
|
||||||
|
{
|
||||||
|
Stop();
|
||||||
|
s_Active = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static void Stop()
|
||||||
|
{
|
||||||
|
var pad = Gamepad.current;
|
||||||
|
if (pad != null) pad.ResetHaptics();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
fileFormatVersion: 2
|
||||||
|
guid: 2f7005e66cfc2ce4d825ebad3cdc9eac
|
||||||
@@ -12,7 +12,7 @@ namespace ProjectM.Client
|
|||||||
[Serializable]
|
[Serializable]
|
||||||
public struct GameSettings
|
public struct GameSettings
|
||||||
{
|
{
|
||||||
public const int CurrentVersion = 1;
|
public const int CurrentVersion = 2;
|
||||||
|
|
||||||
public int Version;
|
public int Version;
|
||||||
|
|
||||||
@@ -30,6 +30,13 @@ namespace ProjectM.Client
|
|||||||
public float Music;
|
public float Music;
|
||||||
public float Sfx;
|
public float Sfx;
|
||||||
|
|
||||||
|
// ---- Onboarding (client-local first-run state; NEVER replicated — a Join client keeps its own,
|
||||||
|
// unlike the host-only SaveData) ----
|
||||||
|
public int TutorialHints; // 0 = first-run coach-marks off, 1 = on
|
||||||
|
public int OnboardingMask; // bitmask of completed coach-mark steps (0 = nothing seen; bit i = step i done)
|
||||||
|
public int ForceOnboardingEachLaunch; // DEV: 1 = wipe OnboardingMask + force hints on at every launch so the
|
||||||
|
// first-run coach-marks always replay fresh (additive; 0-default off)
|
||||||
|
|
||||||
/// <summary>Sensible defaults derived from the current display + active quality level.</summary>
|
/// <summary>Sensible defaults derived from the current display + active quality level.</summary>
|
||||||
public static GameSettings Defaults()
|
public static GameSettings Defaults()
|
||||||
{
|
{
|
||||||
@@ -47,6 +54,9 @@ namespace ProjectM.Client
|
|||||||
Master = 1f,
|
Master = 1f,
|
||||||
Music = 1f,
|
Music = 1f,
|
||||||
Sfx = 1f,
|
Sfx = 1f,
|
||||||
|
TutorialHints = 1,
|
||||||
|
OnboardingMask = 0,
|
||||||
|
ForceOnboardingEachLaunch = 0,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -65,6 +75,9 @@ namespace ProjectM.Client
|
|||||||
s.Master = Mathf.Clamp01(s.Master);
|
s.Master = Mathf.Clamp01(s.Master);
|
||||||
s.Music = Mathf.Clamp01(s.Music);
|
s.Music = Mathf.Clamp01(s.Music);
|
||||||
s.Sfx = Mathf.Clamp01(s.Sfx);
|
s.Sfx = Mathf.Clamp01(s.Sfx);
|
||||||
|
s.TutorialHints = s.TutorialHints != 0 ? 1 : 0;
|
||||||
|
s.ForceOnboardingEachLaunch = s.ForceOnboardingEachLaunch != 0 ? 1 : 0;
|
||||||
|
// OnboardingMask is an opaque bitmask — deliberately NOT clamped.
|
||||||
return s;
|
return s;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -22,6 +22,18 @@ namespace ProjectM.Client
|
|||||||
static void Boot()
|
static void Boot()
|
||||||
{
|
{
|
||||||
Load();
|
Load();
|
||||||
|
// DEV convenience ("Force Each Launch", Settings → Onboarding): wipe the persisted completed-step mask at
|
||||||
|
// EVERY boot — each editor Play-enter / each built-player launch runs this hook — so the first-run
|
||||||
|
// coach-marks always replay from the top, and force hints on so they actually show. In-memory only (the
|
||||||
|
// wipe is NOT written back to disk); OnboardingSystem re-persists progress as the player advances, and the
|
||||||
|
// next launch wipes it again. Toggle it off to return to normal once-only first-run behaviour.
|
||||||
|
if (Current.ForceOnboardingEachLaunch != 0)
|
||||||
|
{
|
||||||
|
var s = Current;
|
||||||
|
s.OnboardingMask = 0;
|
||||||
|
s.TutorialHints = 1;
|
||||||
|
Current = s;
|
||||||
|
}
|
||||||
Apply(Current);
|
Apply(Current);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -103,14 +115,21 @@ namespace ProjectM.Client
|
|||||||
// Additive-only as the schema grows (never throws on an unknown version).
|
// Additive-only as the schema grows (never throws on an unknown version).
|
||||||
static GameSettings Migrate(GameSettings old)
|
static GameSettings Migrate(GameSettings old)
|
||||||
{
|
{
|
||||||
var def = GameSettings.Defaults();
|
// Preserve EVERY recognized field from the old save (Load() Clamps the result, fixing any 0/garbage),
|
||||||
if (old.ResWidth > 0) def.ResWidth = old.ResWidth;
|
// and seed ONLY the genuinely-new fields — a v1 file deserializes those to 0. Migrating from a fresh
|
||||||
if (old.ResHeight > 0) def.ResHeight = old.ResHeight;
|
// Defaults() instead would silently reset graphics fields (display mode / quality / v-sync / fps cap /
|
||||||
if (old.Master > 0f) def.Master = old.Master;
|
// refresh rate) that v1 already carried — a regression the version bump would otherwise surface.
|
||||||
if (old.Music > 0f) def.Music = old.Music;
|
var s = old;
|
||||||
if (old.Sfx > 0f) def.Sfx = old.Sfx;
|
if (old.Version < 2)
|
||||||
def.Version = GameSettings.CurrentVersion;
|
{
|
||||||
return def;
|
// v1 had no onboarding fields. An existing settings.json ⇒ a returning player who already played a
|
||||||
|
// pre-onboarding build, so mark every coach-mark step complete (dormant); hints stay on so
|
||||||
|
// "Replay Tutorial" (which clears OnboardingMask) still re-arms.
|
||||||
|
s.TutorialHints = 1;
|
||||||
|
s.OnboardingMask = int.MaxValue;
|
||||||
|
}
|
||||||
|
s.Version = GameSettings.CurrentVersion;
|
||||||
|
return s;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,141 @@
|
|||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using ProjectM.Simulation;
|
||||||
|
using UnityEngine;
|
||||||
|
using UnityEngine.UIElements;
|
||||||
|
|
||||||
|
namespace ProjectM.Client
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// The replayable "How to Play" reference card (UI Toolkit), built on <see cref="MenuUi"/> like
|
||||||
|
/// <see cref="SettingsScreen"/> and reachable from BOTH the main menu and the in-game pause overlay. Tabbed,
|
||||||
|
/// one glanceable page each: Controls (for the chosen class), The Loop (the annotated win-condition diagram —
|
||||||
|
/// the single highest-value page, since the inverted goal "clear expeditions to win" is the #1 new-player
|
||||||
|
/// confusion), Build & Economy, Threats, Win/Lose. Static + stateless: <see cref="Build"/> returns a fresh
|
||||||
|
/// full-screen panel each call; the caller owns its lifetime (RemoveFromHierarchy on close).
|
||||||
|
/// </summary>
|
||||||
|
public static class HowToPlayPanel
|
||||||
|
{
|
||||||
|
static readonly string[] Tabs = { "Controls", "The Loop", "Build & Economy", "Threats", "Win / Lose" };
|
||||||
|
|
||||||
|
public static VisualElement Build(Action onClose)
|
||||||
|
{
|
||||||
|
var root = MenuUi.FullScreenRoot(true);
|
||||||
|
var card = MenuUi.Card("HOW TO PLAY");
|
||||||
|
card.style.minWidth = 640;
|
||||||
|
card.style.maxWidth = 780;
|
||||||
|
root.Add(card);
|
||||||
|
|
||||||
|
var tabBar = new VisualElement();
|
||||||
|
tabBar.style.flexDirection = FlexDirection.Row;
|
||||||
|
tabBar.style.justifyContent = Justify.Center;
|
||||||
|
tabBar.style.marginBottom = 12;
|
||||||
|
card.Add(tabBar);
|
||||||
|
|
||||||
|
var content = new VisualElement();
|
||||||
|
content.style.minHeight = 230;
|
||||||
|
card.Add(content);
|
||||||
|
|
||||||
|
var tabButtons = new List<Button>();
|
||||||
|
void Show(int idx)
|
||||||
|
{
|
||||||
|
content.Clear();
|
||||||
|
BuildTab(content, idx);
|
||||||
|
for (int i = 0; i < tabButtons.Count; i++)
|
||||||
|
tabButtons[i].style.backgroundColor = i == idx
|
||||||
|
? new Color(0.16f, 0.34f, 0.42f, 1f)
|
||||||
|
: new Color(0.16f, 0.20f, 0.27f, 1f);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (int i = 0; i < Tabs.Length; i++)
|
||||||
|
{
|
||||||
|
int idx = i;
|
||||||
|
var b = MenuUi.Button(Tabs[i], () => Show(idx));
|
||||||
|
b.style.height = 32; b.style.fontSize = 13;
|
||||||
|
b.style.marginLeft = 3; b.style.marginRight = 3;
|
||||||
|
b.style.flexGrow = 1;
|
||||||
|
tabButtons.Add(b);
|
||||||
|
tabBar.Add(b);
|
||||||
|
}
|
||||||
|
|
||||||
|
card.Add(MenuUi.Button("Back", () => onClose?.Invoke()));
|
||||||
|
Show(0);
|
||||||
|
return root;
|
||||||
|
}
|
||||||
|
|
||||||
|
static void BuildTab(VisualElement c, int idx)
|
||||||
|
{
|
||||||
|
switch (idx)
|
||||||
|
{
|
||||||
|
case 0: // Controls (chosen class)
|
||||||
|
bool ranger = WorldLauncher.SelectedClass == (byte)CharacterId.Ranger;
|
||||||
|
Head(c, ranger ? "CLASS: Ranger (ranged anchor)" : "CLASS: Warrior (melee anchor)");
|
||||||
|
Body(c, "Move — WASD / Left Stick");
|
||||||
|
Body(c, "Aim — Mouse cursor / Right Stick");
|
||||||
|
Body(c, "Attack — LMB / RT (your primary verb)");
|
||||||
|
Body(c, "Build menu — Tab / Y");
|
||||||
|
Body(c, "Place / Cancel (in build) — LMB / RMB (A / B on gamepad)");
|
||||||
|
Body(c, "Deposit at base — G");
|
||||||
|
Body(c, "Inventory — I");
|
||||||
|
Body(c, "Pause — Esc");
|
||||||
|
break;
|
||||||
|
case 1: // The Loop
|
||||||
|
Head(c, "THE LOOP — expeditions are how you win");
|
||||||
|
Body(c, "1. BASE (Calm) — mine Ore, build Turrets + a Fabricator to defend your Engine Core.");
|
||||||
|
Body(c, "2. EXPEDITION — walk to the Gate and fight the wave. CLEARING it is the progress beat.");
|
||||||
|
Body(c, "3. RETURN — come home to bank the clear: +1 on the Engine meter.");
|
||||||
|
Body(c, "4. SIEGE — returning provokes a retaliation attack. Defend the Core!");
|
||||||
|
Body(c, "5. WIN — fill the Engine meter, then hold the final siege.");
|
||||||
|
Body(c, "The Engine meter (top of screen) is the goal — base sieges are a consequence, not the win.");
|
||||||
|
break;
|
||||||
|
case 2: // Build & Economy
|
||||||
|
Head(c, "RESOURCES & BUILDING");
|
||||||
|
Body(c, "Ore — main currency. Mine it by attacking the glowing nodes at base.");
|
||||||
|
Body(c, "Turret (40 Ore) — auto-fires at enemies. Needs Charge as ammo.");
|
||||||
|
Body(c, "Fabricator (30 Ore) — converts Ore → Charge so turrets keep firing.");
|
||||||
|
Body(c, "Wall (Biomass) — a cheap barrier that blocks enemies.");
|
||||||
|
Body(c, "Aether — spend it on UPGRADE DMG to boost your damage.");
|
||||||
|
Body(c, "Open Build with Tab (Y), pick a piece, click a green tile to place it.");
|
||||||
|
break;
|
||||||
|
case 3: // Threats
|
||||||
|
Head(c, "THREATS");
|
||||||
|
Body(c, "Husks assault the base during a Siege — keep them off the Engine Core.");
|
||||||
|
Body(c, "A Husk that reaches the Core drains it. Lose the Core in the final siege and the run ends.");
|
||||||
|
Body(c, "Expeditions throw waves at you — clear them all to bank the win.");
|
||||||
|
Body(c, "Enemy variety scales the deeper you push.");
|
||||||
|
break;
|
||||||
|
default: // Win / Lose
|
||||||
|
Head(c, "WIN / LOSE");
|
||||||
|
Body(c, "WIN — clear expeditions to fill the Engine meter, then hold the final siege.");
|
||||||
|
Body(c, "LOSE — the Engine Core falls during the final siege.");
|
||||||
|
Body(c, "A Core breach mid-run is only a setback: resources lost, the Core recovers in Calm.");
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static void Head(VisualElement c, string t)
|
||||||
|
{
|
||||||
|
var l = new Label(t);
|
||||||
|
l.style.color = MenuUi.Accent;
|
||||||
|
l.style.fontSize = 16;
|
||||||
|
l.style.unityFontStyleAndWeight = FontStyle.Bold;
|
||||||
|
l.style.marginBottom = 8;
|
||||||
|
l.style.whiteSpace = WhiteSpace.Normal;
|
||||||
|
var th = HudTheme.Get();
|
||||||
|
if (th != null) th.ApplyDisplay(l.style);
|
||||||
|
c.Add(l);
|
||||||
|
}
|
||||||
|
|
||||||
|
static void Body(VisualElement c, string t)
|
||||||
|
{
|
||||||
|
var l = new Label(t);
|
||||||
|
l.style.color = MenuUi.TextCol;
|
||||||
|
l.style.fontSize = 14;
|
||||||
|
l.style.marginBottom = 5;
|
||||||
|
l.style.whiteSpace = WhiteSpace.Normal;
|
||||||
|
var th = HudTheme.Get();
|
||||||
|
if (th != null) th.ApplyBody(l.style);
|
||||||
|
c.Add(l);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
fileFormatVersion: 2
|
||||||
|
guid: fec1f60cab08999419a195c68923634e
|
||||||
@@ -18,8 +18,10 @@ namespace ProjectM.Client
|
|||||||
UIDocument _doc;
|
UIDocument _doc;
|
||||||
VisualElement _mainPanel;
|
VisualElement _mainPanel;
|
||||||
VisualElement _settingsPanel;
|
VisualElement _settingsPanel;
|
||||||
|
VisualElement _howToPanel;
|
||||||
TextField _ipField;
|
TextField _ipField;
|
||||||
Label _classLabel;
|
Label _classLabel;
|
||||||
|
bool _autoStarted;
|
||||||
|
|
||||||
void Awake()
|
void Awake()
|
||||||
{
|
{
|
||||||
@@ -31,10 +33,12 @@ namespace ProjectM.Client
|
|||||||
// The menu owns the cursor.
|
// The menu owns the cursor.
|
||||||
UnityEngine.Cursor.lockState = CursorLockMode.None;
|
UnityEngine.Cursor.lockState = CursorLockMode.None;
|
||||||
UnityEngine.Cursor.visible = true;
|
UnityEngine.Cursor.visible = true;
|
||||||
|
_autoStarted = TryAutoStartFromCommandLine();
|
||||||
}
|
}
|
||||||
|
|
||||||
void OnEnable()
|
void OnEnable()
|
||||||
{
|
{
|
||||||
|
if (_autoStarted) return; // headless CLI co-op session; don't build the menu UI
|
||||||
if (_doc == null) _doc = GetComponent<UIDocument>();
|
if (_doc == null) _doc = GetComponent<UIDocument>();
|
||||||
var root = _doc.rootVisualElement;
|
var root = _doc.rootVisualElement;
|
||||||
if (root == null) return;
|
if (root == null) return;
|
||||||
@@ -42,6 +46,28 @@ namespace ProjectM.Client
|
|||||||
BuildMain(root);
|
BuildMain(root);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Dev/automation hook: a player launched with <c>-mhost</c> auto-hosts; <c>-mjoin <ip></c> auto-joins
|
||||||
|
/// (ip optional, defaults to loopback). Lets two standalone builds form a co-op session headlessly for
|
||||||
|
/// testing — the same <see cref="WorldLauncher.StartSession"/> path the menu buttons use, no UI clicks.
|
||||||
|
/// No effect when neither arg is present. Returns true if a session was started.
|
||||||
|
/// </summary>
|
||||||
|
static bool TryAutoStartFromCommandLine()
|
||||||
|
{
|
||||||
|
var args = System.Environment.GetCommandLineArgs();
|
||||||
|
for (int i = 0; i < args.Length; i++)
|
||||||
|
{
|
||||||
|
if (args[i] == "-mhost") { WorldLauncher.StartSession(SessionMode.Host, "", false); return true; }
|
||||||
|
if (args[i] == "-mjoin")
|
||||||
|
{
|
||||||
|
string ip = (i + 1 < args.Length && !args[i + 1].StartsWith("-")) ? args[i + 1] : "127.0.0.1";
|
||||||
|
WorldLauncher.StartSession(SessionMode.Join, ip, false);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
static void EnsureMenuWorld()
|
static void EnsureMenuWorld()
|
||||||
{
|
{
|
||||||
var w = World.DefaultGameObjectInjectionWorld;
|
var w = World.DefaultGameObjectInjectionWorld;
|
||||||
@@ -79,6 +105,21 @@ namespace ProjectM.Client
|
|||||||
card.Add(MenuUi.Button("Join", () => Launch(SessionMode.Join, false)));
|
card.Add(MenuUi.Button("Join", () => Launch(SessionMode.Join, false)));
|
||||||
|
|
||||||
card.Add(MenuUi.Button("Settings", ShowSettings));
|
card.Add(MenuUi.Button("Settings", ShowSettings));
|
||||||
|
card.Add(MenuUi.Button("How to Play", ShowHowToPlay));
|
||||||
|
|
||||||
|
// Re-arm the first-run coach-marks (clears the client-local completed-step mask). The next session
|
||||||
|
// replays them; the How-to-Play card stays available regardless.
|
||||||
|
Button replayBtn = null;
|
||||||
|
replayBtn = MenuUi.Button("Replay Tutorial", () =>
|
||||||
|
{
|
||||||
|
var s = SettingsService.Current;
|
||||||
|
s.OnboardingMask = 0;
|
||||||
|
s.TutorialHints = 1;
|
||||||
|
SettingsService.Save(s);
|
||||||
|
if (replayBtn != null) replayBtn.text = "Tutorial armed ✓";
|
||||||
|
});
|
||||||
|
card.Add(replayBtn);
|
||||||
|
|
||||||
card.Add(MenuUi.Button("Quit", Quit));
|
card.Add(MenuUi.Button("Quit", Quit));
|
||||||
|
|
||||||
_mainPanel.Add(card);
|
_mainPanel.Add(card);
|
||||||
@@ -112,6 +153,19 @@ namespace ProjectM.Client
|
|||||||
_mainPanel.style.display = DisplayStyle.Flex;
|
_mainPanel.style.display = DisplayStyle.Flex;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void ShowHowToPlay()
|
||||||
|
{
|
||||||
|
_mainPanel.style.display = DisplayStyle.None;
|
||||||
|
_howToPanel = HowToPlayPanel.Build(HideHowToPlay);
|
||||||
|
_doc.rootVisualElement.Add(_howToPanel);
|
||||||
|
}
|
||||||
|
|
||||||
|
void HideHowToPlay()
|
||||||
|
{
|
||||||
|
if (_howToPanel != null) { _howToPanel.RemoveFromHierarchy(); _howToPanel = null; }
|
||||||
|
_mainPanel.style.display = DisplayStyle.Flex;
|
||||||
|
}
|
||||||
|
|
||||||
static void Quit()
|
static void Quit()
|
||||||
{
|
{
|
||||||
#if UNITY_EDITOR
|
#if UNITY_EDITOR
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ namespace ProjectM.Client
|
|||||||
VisualElement _root;
|
VisualElement _root;
|
||||||
VisualElement _pausePanel;
|
VisualElement _pausePanel;
|
||||||
VisualElement _settingsPanel;
|
VisualElement _settingsPanel;
|
||||||
|
VisualElement _howToPanel;
|
||||||
bool _open;
|
bool _open;
|
||||||
/// <summary>True while the pause overlay is shown (BuildSendSystem suspends build-clicks while paused).</summary>
|
/// <summary>True while the pause overlay is shown (BuildSendSystem suspends build-clicks while paused).</summary>
|
||||||
public static bool Open;
|
public static bool Open;
|
||||||
@@ -49,6 +50,7 @@ namespace ProjectM.Client
|
|||||||
var card = MenuUi.Card("PAUSED");
|
var card = MenuUi.Card("PAUSED");
|
||||||
card.Add(MenuUi.Button("Resume", () => SetOpen(false)));
|
card.Add(MenuUi.Button("Resume", () => SetOpen(false)));
|
||||||
card.Add(MenuUi.Button("Settings", ShowSettings));
|
card.Add(MenuUi.Button("Settings", ShowSettings));
|
||||||
|
card.Add(MenuUi.Button("How to Play", ShowHowToPlay));
|
||||||
card.Add(MenuUi.Button("Quit to Menu", WorldLauncher.TeardownToMenu));
|
card.Add(MenuUi.Button("Quit to Menu", WorldLauncher.TeardownToMenu));
|
||||||
card.Add(MenuUi.Button("Quit to Desktop", Quit));
|
card.Add(MenuUi.Button("Quit to Desktop", Quit));
|
||||||
_pausePanel.Add(card);
|
_pausePanel.Add(card);
|
||||||
@@ -61,6 +63,7 @@ namespace ProjectM.Client
|
|||||||
Open = open;
|
Open = open;
|
||||||
if (_pausePanel != null) _pausePanel.style.display = open ? DisplayStyle.Flex : DisplayStyle.None;
|
if (_pausePanel != null) _pausePanel.style.display = open ? DisplayStyle.Flex : DisplayStyle.None;
|
||||||
if (!open && _settingsPanel != null) { _settingsPanel.RemoveFromHierarchy(); _settingsPanel = null; }
|
if (!open && _settingsPanel != null) { _settingsPanel.RemoveFromHierarchy(); _settingsPanel = null; }
|
||||||
|
if (!open && _howToPanel != null) { _howToPanel.RemoveFromHierarchy(); _howToPanel = null; }
|
||||||
if (open) { UnityEngine.Cursor.lockState = CursorLockMode.None; UnityEngine.Cursor.visible = true; }
|
if (open) { UnityEngine.Cursor.lockState = CursorLockMode.None; UnityEngine.Cursor.visible = true; }
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -75,6 +78,17 @@ namespace ProjectM.Client
|
|||||||
_root.Add(_settingsPanel);
|
_root.Add(_settingsPanel);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void ShowHowToPlay()
|
||||||
|
{
|
||||||
|
_pausePanel.style.display = DisplayStyle.None;
|
||||||
|
_howToPanel = HowToPlayPanel.Build(() =>
|
||||||
|
{
|
||||||
|
if (_howToPanel != null) { _howToPanel.RemoveFromHierarchy(); _howToPanel = null; }
|
||||||
|
_pausePanel.style.display = DisplayStyle.Flex;
|
||||||
|
});
|
||||||
|
_root.Add(_howToPanel);
|
||||||
|
}
|
||||||
|
|
||||||
static void Quit()
|
static void Quit()
|
||||||
{
|
{
|
||||||
#if UNITY_EDITOR
|
#if UNITY_EDITOR
|
||||||
|
|||||||
@@ -59,6 +59,19 @@ namespace ProjectM.Client
|
|||||||
card.Add(VolumeRow("Music", working.Music, v => { working.Music = v; GameVolume.Music = v; }));
|
card.Add(VolumeRow("Music", working.Music, v => { working.Music = v; GameVolume.Music = v; }));
|
||||||
card.Add(VolumeRow("SFX", working.Sfx, v => { working.Sfx = v; GameVolume.Sfx = v; }));
|
card.Add(VolumeRow("SFX", working.Sfx, v => { working.Sfx = v; GameVolume.Sfx = v; }));
|
||||||
|
|
||||||
|
// ---------------- Onboarding ----------------
|
||||||
|
card.Add(MenuUi.Caption("— ONBOARDING —"));
|
||||||
|
string[] onoff = { "Off", "On" };
|
||||||
|
card.Add(CycleRow("Tutorial Hints",
|
||||||
|
() => onoff[Mathf.Clamp(working.TutorialHints, 0, 1)],
|
||||||
|
dir => working.TutorialHints = Wrap(working.TutorialHints + dir, 2)));
|
||||||
|
|
||||||
|
// DEV: forces the first-run coach-marks to replay fresh on every launch (wipes the completed-step mask at
|
||||||
|
// each boot — see SettingsService.Boot). Off = normal once-only first-run behaviour.
|
||||||
|
card.Add(CycleRow("Force Each Launch (Dev)",
|
||||||
|
() => onoff[Mathf.Clamp(working.ForceOnboardingEachLaunch, 0, 1)],
|
||||||
|
dir => working.ForceOnboardingEachLaunch = Wrap(working.ForceOnboardingEachLaunch + dir, 2)));
|
||||||
|
|
||||||
// ---------------- Buttons ----------------
|
// ---------------- Buttons ----------------
|
||||||
var apply = MenuUi.Button("Apply", () => SettingsService.SaveAndApply(working));
|
var apply = MenuUi.Button("Apply", () => SettingsService.SaveAndApply(working));
|
||||||
apply.style.backgroundColor = new Color(0.12f, 0.30f, 0.22f, 1f);
|
apply.style.backgroundColor = new Color(0.12f, 0.30f, 0.22f, 1f);
|
||||||
|
|||||||
@@ -50,10 +50,14 @@ namespace ProjectM.Server
|
|||||||
var catalog = SystemAPI.GetBuffer<StructureCatalogEntry>(SystemAPI.GetSingletonEntity<StructureCatalog>());
|
var catalog = SystemAPI.GetBuffer<StructureCatalogEntry>(SystemAPI.GetSingletonEntity<StructureCatalog>());
|
||||||
var ledger = SystemAPI.GetBuffer<StorageEntry>(SystemAPI.GetSingletonEntity<ResourceLedger>());
|
var ledger = SystemAPI.GetBuffer<StorageEntry>(SystemAPI.GetSingletonEntity<ResourceLedger>());
|
||||||
|
|
||||||
// Derive occupancy from the live structure set (authoritative source of truth).
|
// Derive occupancy from the live structure set (authoritative); also count turrets for the per-base cap.
|
||||||
var occupied = new NativeHashSet<int2>(64, Allocator.Temp);
|
var occupied = new NativeHashSet<int2>(64, Allocator.Temp);
|
||||||
|
int turretCount = 0;
|
||||||
foreach (var ps in SystemAPI.Query<RefRO<PlacedStructure>>())
|
foreach (var ps in SystemAPI.Query<RefRO<PlacedStructure>>())
|
||||||
|
{
|
||||||
occupied.Add(ps.ValueRO.Cell);
|
occupied.Add(ps.ValueRO.Cell);
|
||||||
|
if (ps.ValueRO.Type == StructureType.Turret) turretCount++;
|
||||||
|
}
|
||||||
|
|
||||||
var ecb = new EntityCommandBuffer(Allocator.Temp);
|
var ecb = new EntityCommandBuffer(Allocator.Temp);
|
||||||
|
|
||||||
@@ -67,7 +71,9 @@ namespace ProjectM.Server
|
|||||||
for (int i = 0; i < catalog.Length; i++)
|
for (int i = 0; i < catalog.Length; i++)
|
||||||
if (catalog[i].Type == req.StructureType) { entryIdx = i; break; }
|
if (catalog[i].Type == req.StructureType) { entryIdx = i; break; }
|
||||||
|
|
||||||
if (entryIdx >= 0 && catalog[entryIdx].Prefab != Entity.Null
|
// DR-042 combat pass: cap turrets per base (server-authoritative) so they can't be spammed.
|
||||||
|
bool turretCapOk = req.StructureType != StructureType.Turret || turretCount < Tuning.TurretCap;
|
||||||
|
if (entryIdx >= 0 && catalog[entryIdx].Prefab != Entity.Null && turretCapOk
|
||||||
&& BuildPlacementMath.CanPlace(anchor, occupied, cell))
|
&& BuildPlacementMath.CanPlace(anchor, occupied, cell))
|
||||||
{
|
{
|
||||||
var entry = catalog[entryIdx];
|
var entry = catalog[entryIdx];
|
||||||
@@ -81,6 +87,7 @@ namespace ProjectM.Server
|
|||||||
// Commit IN-PLACE so a second same-tick request sees the spend + reservation.
|
// Commit IN-PLACE so a second same-tick request sees the spend + reservation.
|
||||||
StorageMath.Withdraw(ledger, entry.CostResourceId, entry.CostAmount);
|
StorageMath.Withdraw(ledger, entry.CostResourceId, entry.CostAmount);
|
||||||
occupied.Add(cell);
|
occupied.Add(cell);
|
||||||
|
if (req.StructureType == StructureType.Turret) turretCount++; // keep same-tick turret requests under the cap
|
||||||
|
|
||||||
var structure = ecb.Instantiate(entry.Prefab);
|
var structure = ecb.Instantiate(entry.Prefab);
|
||||||
var xform = m_TransformLookup[entry.Prefab];
|
var xform = m_TransformLookup[entry.Prefab];
|
||||||
|
|||||||
@@ -100,8 +100,9 @@ namespace ProjectM.Server
|
|||||||
var ecb = new EntityCommandBuffer(Allocator.Temp);
|
var ecb = new EntityCommandBuffer(Allocator.Temp);
|
||||||
bool havePhysics = SystemAPI.TryGetSingleton<PhysicsWorldSingleton>(out var physics);
|
bool havePhysics = SystemAPI.TryGetSingleton<PhysicsWorldSingleton>(out var physics);
|
||||||
uint envMask = SystemAPI.TryGetSingleton<WorldCollisionConfig>(out var worldCol) ? worldCol.EnvironmentMask : 0u;
|
uint envMask = SystemAPI.TryGetSingleton<WorldCollisionConfig>(out var worldCol) ? worldCol.EnvironmentMask : 0u;
|
||||||
var envFilter = new CollisionFilter { BelongsTo = ~0u, CollidesWith = envMask, GroupIndex = 0 };
|
uint sweepMask = envMask | worldCol.StructureMask; // DR-042 C5: also collide enemies against player-built walls
|
||||||
bool sweep = havePhysics && envMask != 0u;
|
var envFilter = new CollisionFilter { BelongsTo = ~0u, CollidesWith = sweepMask, GroupIndex = 0 };
|
||||||
|
bool sweep = havePhysics && sweepMask != 0u;
|
||||||
const float SweepRadius = 0.5f; // collide-and-slide sphere radius for Husk movement
|
const float SweepRadius = 0.5f; // collide-and-slide sphere radius for Husk movement
|
||||||
|
|
||||||
foreach (var (xform, stats, cooldown, knockback, windup, region) in
|
foreach (var (xform, stats, cooldown, knockback, windup, region) in
|
||||||
|
|||||||
@@ -26,7 +26,6 @@ namespace ProjectM.Server
|
|||||||
public void OnCreate(ref SystemState state)
|
public void OnCreate(ref SystemState state)
|
||||||
{
|
{
|
||||||
state.RequireForUpdate<NetworkTime>();
|
state.RequireForUpdate<NetworkTime>();
|
||||||
state.RequireForUpdate<PlayerSpawner>();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
[BurstCompile]
|
[BurstCompile]
|
||||||
@@ -37,14 +36,20 @@ namespace ProjectM.Server
|
|||||||
return;
|
return;
|
||||||
uint now = serverTick.TickIndexForValidTick;
|
uint now = serverTick.TickIndexForValidTick;
|
||||||
|
|
||||||
var spawner = SystemAPI.GetSingleton<PlayerSpawner>();
|
// Resilient spawn reference: prefer the BaseAnchor plot center, fall back to the PlayerSpawner. NEVER
|
||||||
float3 center = spawner.SpawnPoint;
|
// hard-require PlayerSpawner (a transiently-missing singleton must not strand dead players downed forever).
|
||||||
if (SystemAPI.TryGetSingleton<BaseAnchor>(out var baseAnchor))
|
bool haveSpawner = SystemAPI.TryGetSingleton<PlayerSpawner>(out var spawner);
|
||||||
|
bool haveAnchor = SystemAPI.TryGetSingleton<BaseAnchor>(out var baseAnchor);
|
||||||
|
if (!haveSpawner && !haveAnchor)
|
||||||
|
return; // no spawn reference at all this tick — re-try next tick rather than mis-place
|
||||||
|
float3 center = haveAnchor ? BaseGridMath.PlotCenter(baseAnchor) : spawner.SpawnPoint;
|
||||||
|
float ringRadius = haveSpawner ? spawner.SpawnRingRadius : 2f;
|
||||||
|
int ringSlots = haveSpawner ? spawner.RingSlots : 4;
|
||||||
center = BaseGridMath.PlotCenter(baseAnchor);
|
center = BaseGridMath.PlotCenter(baseAnchor);
|
||||||
|
|
||||||
foreach (var (health, respawn, invuln, xform, owner, eff) in
|
foreach (var (health, respawn, invuln, xform, region, owner, eff) in
|
||||||
SystemAPI.Query<RefRW<Health>, RefRW<RespawnState>, RefRW<RespawnInvuln>, RefRW<LocalTransform>,
|
SystemAPI.Query<RefRW<Health>, RefRW<RespawnState>, RefRW<RespawnInvuln>, RefRW<LocalTransform>,
|
||||||
RefRO<GhostOwner>, RefRO<EffectiveCharacterStats>>()
|
RefRW<RegionTag>, RefRO<GhostOwner>, RefRO<EffectiveCharacterStats>>()
|
||||||
.WithAll<PlayerTag>())
|
.WithAll<PlayerTag>())
|
||||||
{
|
{
|
||||||
if (health.ValueRO.Current > 0f)
|
if (health.ValueRO.Current > 0f)
|
||||||
@@ -66,8 +71,12 @@ namespace ProjectM.Server
|
|||||||
health.ValueRW.Current = maxHealth;
|
health.ValueRW.Current = maxHealth;
|
||||||
|
|
||||||
float3 pos = center + PlayerSpawnMath.SpawnOffset(
|
float3 pos = center + PlayerSpawnMath.SpawnOffset(
|
||||||
owner.ValueRO.NetworkId, spawner.SpawnRingRadius, spawner.RingSlots);
|
owner.ValueRO.NetworkId, ringRadius, ringSlots);
|
||||||
xform.ValueRW.Position = pos;
|
xform.ValueRW.Position = pos;
|
||||||
|
// Death fix: respawn is at BASE, so the player's server-only RegionTag MUST return to Base too
|
||||||
|
// (every other mover flips RegionTag + Position together). Dying on an expedition otherwise leaves
|
||||||
|
// you at base coords still tagged Expedition -> RegionRelevancy hides all base ghosts (soft-brick).
|
||||||
|
region.ValueRW.Region = RegionId.Base;
|
||||||
|
|
||||||
// Grant brief post-respawn damage immunity so the swarm can't instantly re-kill.
|
// Grant brief post-respawn damage immunity so the swarm can't instantly re-kill.
|
||||||
invuln.ValueRW.UntilTick = TickUtil.NonZero(now + (uint)math.max(0, respawn.ValueRO.InvulnTicks));
|
invuln.ValueRW.UntilTick = TickUtil.NonZero(now + (uint)math.max(0, respawn.ValueRO.InvulnTicks));
|
||||||
|
|||||||
@@ -20,6 +20,10 @@ namespace ProjectM.Server
|
|||||||
/// wave is fully spawned and every zone enemy is dead, it marks <see cref="CycleRuntime.ClearedThisEpoch"/> once —
|
/// wave is fully spawned and every zone enemy is dead, it marks <see cref="CycleRuntime.ClearedThisEpoch"/> once —
|
||||||
/// the gate's once-per-epoch Ore reward reads that on the player's return.
|
/// the gate's once-per-epoch Ore reward reads that on the player's return.
|
||||||
///
|
///
|
||||||
|
/// DR-042 C7b: it ALSO writes the replicated <see cref="ExpeditionObjective"/> summary every tick, ABOVE the
|
||||||
|
/// presence early-return (snapshot-above-early-return), so the client HUD's "enemies remaining / cleared" readout
|
||||||
|
/// never freezes stale even when nobody is out.
|
||||||
|
///
|
||||||
/// Ordering: <c>[UpdateAfter(ExpeditionFieldSystem)]</c> ONLY. ExpeditionFieldSystem is itself
|
/// Ordering: <c>[UpdateAfter(ExpeditionFieldSystem)]</c> ONLY. ExpeditionFieldSystem is itself
|
||||||
/// <c>[UpdateAfter(CyclePhaseSystem)]</c>, so ALSO declaring <c>[UpdateBefore(CyclePhaseSystem)]</c> here (as the
|
/// <c>[UpdateAfter(CyclePhaseSystem)]</c>, so ALSO declaring <c>[UpdateBefore(CyclePhaseSystem)]</c> here (as the
|
||||||
/// v1 plan first sketched) would close a CyclePhase->Field->Zone->CyclePhase sort cycle that throws at Play
|
/// v1 plan first sketched) would close a CyclePhase->Field->Zone->CyclePhase sort cycle that throws at Play
|
||||||
@@ -51,26 +55,55 @@ namespace ProjectM.Server
|
|||||||
return;
|
return;
|
||||||
uint now = serverTick.TickIndexForValidTick;
|
uint now = serverTick.TickIndexForValidTick;
|
||||||
|
|
||||||
// Per-player presence: only run while someone is OUT in the expedition (mirrors ExpeditionFieldSystem).
|
// Per-player presence: the SPAWNER only runs while someone is OUT in the expedition (mirrors
|
||||||
|
// ExpeditionFieldSystem). The objective readout below is written FIRST, every tick, even when nobody's out.
|
||||||
int expeditionPlayers = 0;
|
int expeditionPlayers = 0;
|
||||||
foreach (var region in SystemAPI.Query<RefRO<RegionTag>>().WithAll<PlayerTag>())
|
foreach (var region in SystemAPI.Query<RefRO<RegionTag>>().WithAll<PlayerTag>())
|
||||||
if (region.ValueRO.Region == RegionId.Expedition)
|
if (region.ValueRO.Region == RegionId.Expedition)
|
||||||
expeditionPlayers++;
|
expeditionPlayers++;
|
||||||
if (expeditionPlayers == 0)
|
|
||||||
return; // nobody out there: the field manager owns teardown, we do nothing
|
|
||||||
|
|
||||||
var directorEntity = SystemAPI.GetSingletonEntity<ZoneEnemyDirector>();
|
var directorEntity = SystemAPI.GetSingletonEntity<ZoneEnemyDirector>();
|
||||||
var dir = SystemAPI.GetComponent<ZoneEnemyDirector>(directorEntity);
|
var dir = SystemAPI.GetComponent<ZoneEnemyDirector>(directorEntity);
|
||||||
var zs = SystemAPI.GetComponent<ZoneEnemyState>(directorEntity);
|
var zs = SystemAPI.GetComponent<ZoneEnemyState>(directorEntity);
|
||||||
var prefabs = SystemAPI.GetBuffer<ZoneEnemyPrefab>(directorEntity);
|
|
||||||
if (prefabs.Length == 0)
|
|
||||||
return;
|
|
||||||
|
|
||||||
var cycleEntity = SystemAPI.GetSingletonEntity<CycleState>();
|
var cycleEntity = SystemAPI.GetSingletonEntity<CycleState>();
|
||||||
var cycle = SystemAPI.GetComponent<CycleState>(cycleEntity);
|
var cycle = SystemAPI.GetComponent<CycleState>(cycleEntity);
|
||||||
var runtime = SystemAPI.GetComponent<CycleRuntime>(cycleEntity);
|
var runtime = SystemAPI.GetComponent<CycleRuntime>(cycleEntity);
|
||||||
int epoch = runtime.ExpeditionEpoch;
|
int epoch = runtime.ExpeditionEpoch;
|
||||||
|
|
||||||
|
int aliveZone = m_ZoneEnemies.CalculateEntityCount();
|
||||||
|
|
||||||
|
// DR-042 C7b: write the REPLICATED objective summary FIRST, above the early-returns (snapshot-above-
|
||||||
|
// early-return) so the HUD never freezes stale. Rides the untagged CycleDirector ghost (cross-region safe).
|
||||||
|
if (SystemAPI.HasComponent<ExpeditionObjective>(cycleEntity))
|
||||||
|
{
|
||||||
|
byte objState;
|
||||||
|
short objRemaining;
|
||||||
|
if (runtime.ClearedThisEpoch != 0 && runtime.LastRewardedEpoch != runtime.ExpeditionEpoch)
|
||||||
|
{
|
||||||
|
objState = ExpeditionObjectiveState.Cleared; // cleared but not yet claimed -> "return to claim"
|
||||||
|
objRemaining = 0;
|
||||||
|
}
|
||||||
|
else if (expeditionPlayers > 0 && (aliveZone > 0 || zs.RemainingToSpawn > 0))
|
||||||
|
{
|
||||||
|
objState = ExpeditionObjectiveState.Active;
|
||||||
|
objRemaining = (short)math.min(aliveZone + zs.RemainingToSpawn, short.MaxValue);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
objState = ExpeditionObjectiveState.Idle;
|
||||||
|
objRemaining = 0;
|
||||||
|
}
|
||||||
|
SystemAPI.SetComponent(cycleEntity, new ExpeditionObjective { State = objState, Remaining = objRemaining });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (expeditionPlayers == 0)
|
||||||
|
return; // nobody out there: the field manager owns teardown, the spawner does nothing
|
||||||
|
|
||||||
|
var prefabs = SystemAPI.GetBuffer<ZoneEnemyPrefab>(directorEntity);
|
||||||
|
if (prefabs.Length == 0)
|
||||||
|
return;
|
||||||
|
|
||||||
// MC-2: build the 4-type weighted mix band from the director's baked weights (shared math with the base
|
// MC-2: build the 4-type weighted mix band from the director's baked weights (shared math with the base
|
||||||
// siege). GruntsPerWave/ChargersPerWave are the Grunt/Charger base counts.
|
// siege). GruntsPerWave/ChargersPerWave are the Grunt/Charger base counts.
|
||||||
var bands = new MixBands
|
var bands = new MixBands
|
||||||
@@ -94,8 +127,6 @@ namespace ProjectM.Server
|
|||||||
zs.NextSpawnTick = TickUtil.NonZero(now); // first slot this tick
|
zs.NextSpawnTick = TickUtil.NonZero(now); // first slot this tick
|
||||||
}
|
}
|
||||||
|
|
||||||
int aliveZone = m_ZoneEnemies.CalculateEntityCount();
|
|
||||||
|
|
||||||
if (zs.RemainingToSpawn > 0)
|
if (zs.RemainingToSpawn > 0)
|
||||||
{
|
{
|
||||||
// Spawn only in Calm (a base Siege pauses the expedition wave), one SLOT per cadence, under the cap.
|
// Spawn only in Calm (a base Siege pauses the expedition wave), one SLOT per cadence, under the cap.
|
||||||
|
|||||||
@@ -63,6 +63,8 @@ namespace ProjectM.Server
|
|||||||
ecb.AddComponent(director, new RunPhase { Value = RunPhaseId.Normal });
|
ecb.AddComponent(director, new RunPhase { Value = RunPhaseId.Normal });
|
||||||
|
|
||||||
// Born-correct load: if the menu staged a save (Continue), apply it AT SPAWN so the director
|
// Born-correct load: if the menu staged a save (Continue), apply it AT SPAWN so the director
|
||||||
|
// DR-042 C6c: a NEW game seeds starting Ore below; a restored save (Continue) keeps its ledger.
|
||||||
|
bool restoredLedger = false;
|
||||||
// ghost never serializes a default GoalProgress / empty ledger to clients (no replication flicker).
|
// ghost never serializes a default GoalProgress / empty ledger to clients (no replication flicker).
|
||||||
if (SystemAPI.TryGetSingletonEntity<PendingSave>(out var pendingEntity))
|
if (SystemAPI.TryGetSingletonEntity<PendingSave>(out var pendingEntity))
|
||||||
{
|
{
|
||||||
@@ -79,6 +81,7 @@ namespace ProjectM.Server
|
|||||||
var srcLedger = SystemAPI.GetBuffer<PendingSaveLedgerRow>(pendingEntity);
|
var srcLedger = SystemAPI.GetBuffer<PendingSaveLedgerRow>(pendingEntity);
|
||||||
var destLedger = ecb.SetBuffer<StorageEntry>(director);
|
var destLedger = ecb.SetBuffer<StorageEntry>(director);
|
||||||
SaveApply.WriteLedger(srcLedger, destLedger);
|
SaveApply.WriteLedger(srcLedger, destLedger);
|
||||||
|
restoredLedger = true; // a save restored the ledger -> do NOT seed starting Ore (C6c)
|
||||||
|
|
||||||
// END-1: born-correct the Engine Core. Max comes from the BAKED prefab (never the save); a
|
// END-1: born-correct the Engine Core. Max comes from the BAKED prefab (never the save); a
|
||||||
// persisted wounded Current (>0) restores clamped to Max, else (0 = pre-v4 save) born full.
|
// persisted wounded Current (>0) restores clamped to Max, else (0 = pre-v4 save) born full.
|
||||||
@@ -99,6 +102,12 @@ namespace ProjectM.Server
|
|||||||
ecb.DestroyEntity(pendingEntity);
|
ecb.DestroyEntity(pendingEntity);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// DR-042 C6c: NEW game only (no restored ledger) -> seed a little Ore so the build loop isn't a cold
|
||||||
|
// deadlock (a turret needs Charge from a Fabricator that costs Ore you haven't mined yet). Appended
|
||||||
|
// BEFORE Playback so the ghost first-serializes WITH the seed (no empty-ledger replication flicker).
|
||||||
|
if (!restoredLedger)
|
||||||
|
ecb.AppendToBuffer(director, new StorageEntry { ItemId = ResourceId.Ore, Count = Tuning.StartingOre });
|
||||||
|
|
||||||
// Host-only autosave flag; SaveWriteSystem consumes it on the Siege->Calm checkpoint.
|
// Host-only autosave flag; SaveWriteSystem consumes it on the Siege->Calm checkpoint.
|
||||||
ecb.AddComponent(director, new SaveRequest { Pending = 0 });
|
ecb.AddComponent(director, new SaveRequest { Pending = 0 });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -159,18 +159,12 @@ namespace ProjectM.Server
|
|||||||
if (SystemAPI.HasComponent<SaveRequest>(cycleEntity))
|
if (SystemAPI.HasComponent<SaveRequest>(cycleEntity))
|
||||||
SystemAPI.SetComponent(cycleEntity, new SaveRequest { Pending = 1 });
|
SystemAPI.SetComponent(cycleEntity, new SaveRequest { Pending = 1 });
|
||||||
}
|
}
|
||||||
else if (SystemAPI.HasComponent<GoalProgress>(cycleEntity))
|
// DR-042: a SURVIVED base siege no longer advances the win meter — that was the AFK/passive win
|
||||||
{
|
// path (scheduled sieges auto-armed + auto-collapsed on timeout, so standing still won). The win-
|
||||||
// Long-arc goal: +1 per siege survived, CLAMPED to Target (single writer). Clamping at the
|
// driver moved to EXPEDITION CLEARS: GoalProgress.Charge is now credited per cleared expedition by
|
||||||
// increment site keeps the persisted Charge bounded regardless of system order; GoalReachedSystem
|
// ExpeditionGateSystem on the player's RETURN. Surviving a normal siege is still its own reward
|
||||||
// only READS this edge to arm the final siege.
|
// (resources kept, Core intact) but is not progress toward Victory. The final-siege Victory latch
|
||||||
var goal = SystemAPI.GetComponent<GoalProgress>(cycleEntity);
|
// above is unchanged — GoalReachedSystem still arms the climactic final siege once Charge hits Target.
|
||||||
goal.Charge = math.min(goal.Charge + 1, goal.Target);
|
|
||||||
SystemAPI.SetComponent(cycleEntity, goal);
|
|
||||||
// Autosave checkpoint: a survived siege is a natural save point (host-only writer consumes the flag).
|
|
||||||
if (SystemAPI.HasComponent<SaveRequest>(cycleEntity))
|
|
||||||
SystemAPI.SetComponent(cycleEntity, new SaveRequest { Pending = 1 });
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -83,19 +83,35 @@ namespace ProjectM.Server
|
|||||||
SystemAPI.SetComponent(threatEntity, threat);
|
SystemAPI.SetComponent(threatEntity, threat);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Once-per-epoch zone-clear reward: a returner banks flat Ore IFF this epoch's expedition wave was
|
// Once-per-epoch zone-clear reward: a returner BANKS flat Ore to the shared ledger AND advances the
|
||||||
// actually cleared and not yet rewarded. Resolved ONCE here (not per-returner) so two same-tick co-op
|
// long-arc win meter (DR-042 — EXPEDITION CLEARS, not survived base sieges, are the win-driver:
|
||||||
// returns pay exactly once (DR-040 BLOCKER 4) and gate re-entry before a clear can't farm (MINOR 2).
|
// CyclePhaseSystem no longer credits Charge, so this is the sole PRODUCTION writer of GoalProgress.Charge).
|
||||||
if (SystemAPI.HasSingleton<CycleState>()
|
// Resolved ONCE here (not per-returner) so two same-tick co-op returns pay exactly once (DR-040 BLOCKER 4)
|
||||||
&& SystemAPI.TryGetSingleton<ZoneEnemyDirector>(out var zoneDir)
|
// and gate re-entry before a clear can't farm (MINOR 2). Ore + Charge share the SAME LastRewardedEpoch
|
||||||
&& SystemAPI.HasSingleton<ResourceLedger>())
|
// latch so they always share fate (never one without the other). The Charge credit is guarded
|
||||||
|
// independently of the ledger so it still lands in ledger-less worlds.
|
||||||
|
if (SystemAPI.HasSingleton<CycleState>())
|
||||||
{
|
{
|
||||||
var cycleEntity = SystemAPI.GetSingletonEntity<CycleState>();
|
var cycleEntity = SystemAPI.GetSingletonEntity<CycleState>();
|
||||||
var runtime = SystemAPI.GetComponent<CycleRuntime>(cycleEntity);
|
var runtime = SystemAPI.GetComponent<CycleRuntime>(cycleEntity);
|
||||||
if (runtime.ClearedThisEpoch != 0 && runtime.LastRewardedEpoch != runtime.ExpeditionEpoch)
|
if (runtime.ClearedThisEpoch != 0 && runtime.LastRewardedEpoch != runtime.ExpeditionEpoch)
|
||||||
|
{
|
||||||
|
if (SystemAPI.TryGetSingleton<ZoneEnemyDirector>(out var zoneDir)
|
||||||
|
&& SystemAPI.HasSingleton<ResourceLedger>())
|
||||||
{
|
{
|
||||||
var ledger = SystemAPI.GetBuffer<StorageEntry>(SystemAPI.GetSingletonEntity<ResourceLedger>());
|
var ledger = SystemAPI.GetBuffer<StorageEntry>(SystemAPI.GetSingletonEntity<ResourceLedger>());
|
||||||
StorageMath.Deposit(ledger, (ushort)ResourceId.Ore, zoneDir.RewardOre);
|
StorageMath.Deposit(ledger, (ushort)ResourceId.Ore, zoneDir.RewardOre);
|
||||||
|
}
|
||||||
|
if (SystemAPI.HasComponent<GoalProgress>(cycleEntity))
|
||||||
|
{
|
||||||
|
// +1 toward the goal per cleared expedition, CLAMPED to Target (single production writer).
|
||||||
|
var goal = SystemAPI.GetComponent<GoalProgress>(cycleEntity);
|
||||||
|
goal.Charge = math.min(goal.Charge + 1, goal.Target);
|
||||||
|
SystemAPI.SetComponent(cycleEntity, goal);
|
||||||
|
}
|
||||||
|
// Checkpoint the hard-won clear (replaces the deleted survived-siege autosave in CyclePhaseSystem).
|
||||||
|
if (SystemAPI.HasComponent<SaveRequest>(cycleEntity))
|
||||||
|
SystemAPI.SetComponent(cycleEntity, new SaveRequest { Pending = 1 });
|
||||||
runtime.LastRewardedEpoch = runtime.ExpeditionEpoch;
|
runtime.LastRewardedEpoch = runtime.ExpeditionEpoch;
|
||||||
SystemAPI.SetComponent(cycleEntity, runtime);
|
SystemAPI.SetComponent(cycleEntity, runtime);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -65,9 +65,9 @@ namespace ProjectM.Simulation
|
|||||||
public static TuningConfig Defaults() => new TuningConfig
|
public static TuningConfig Defaults() => new TuningConfig
|
||||||
{
|
{
|
||||||
DashDistance = 4.0f,
|
DashDistance = 4.0f,
|
||||||
IFrameWindowTicks = 12f,
|
IFrameWindowTicks = 14f, // tune: was 12 (0.20s) -> 0.23s, i-frames better cover a reacted telegraph
|
||||||
RecoverTailTicks = 9f,
|
RecoverTailTicks = 9f,
|
||||||
DashCooldownTicks = 45f,
|
DashCooldownTicks = 36f, // tune: was 45 (0.75s) -> 0.60s, snappier horde-kiter cadence
|
||||||
DashSharpness = 200f,
|
DashSharpness = 200f,
|
||||||
ChargerWindupTicks = 30f,
|
ChargerWindupTicks = 30f,
|
||||||
ChargerLungeSpeed = 16f,
|
ChargerLungeSpeed = 16f,
|
||||||
|
|||||||
@@ -63,6 +63,17 @@ namespace ProjectM.Simulation
|
|||||||
/// Ore. Operator feel-fork: keep generous so turrets stay fed while you keep mining.</summary>
|
/// Ore. Operator feel-fork: keep generous so turrets stay fed while you keep mining.</summary>
|
||||||
public const int TurretChargeCostPerShot = 1;
|
public const int TurretChargeCostPerShot = 1;
|
||||||
|
|
||||||
|
/// <summary>Max turrets buildable per base (server-enforced in BuildPlaceSystem; the client preview goes red at
|
||||||
|
/// the cap). Turrets are a deliberate fortress investment, not spammable — paired with the 40-Ore build cost.</summary>
|
||||||
|
public const int TurretCap = 6;
|
||||||
|
|
||||||
|
// ---- Cold start (CycleDirectorSpawnSystem seeds the shared ledger on a NEW game) ----
|
||||||
|
|
||||||
|
/// <summary>DR-042 C6c: Ore deposited into the shared ledger at spawn on a NEW game ONLY (a restored save keeps
|
||||||
|
/// its persisted ledger). Bootstraps the Fabricator(30)->Charge->Turret(10) chain so a turret placed before any
|
||||||
|
/// mining isn't a silent cold deadlock. Ore-only so the 'build a Fabricator to arm turrets' lesson survives.</summary>
|
||||||
|
public const int StartingOre = 50;
|
||||||
|
|
||||||
// ---- Inventory (per-player bag; InventoryMath / ResourceHarvestSystem / InventoryDepositSystem) ----
|
// ---- Inventory (per-player bag; InventoryMath / ResourceHarvestSystem / InventoryDepositSystem) ----
|
||||||
|
|
||||||
/// <summary>Max stacks a player can carry; InventoryMath rejects deposits past this and the harvest remainder spills to the global ledger.</summary>
|
/// <summary>Max stacks a player can carry; InventoryMath rejects deposits past this and the harvest remainder spills to the global ledger.</summary>
|
||||||
|
|||||||
@@ -83,4 +83,29 @@ namespace ProjectM.Simulation
|
|||||||
/// <summary>1 once the current epoch's expedition wave has FULLY spawned and been cleared to zero live zone enemies; reset to 0 on the empty->occupied epoch bump. The reward fires only on a REAL clear.</summary>
|
/// <summary>1 once the current epoch's expedition wave has FULLY spawned and been cleared to zero live zone enemies; reset to 0 on the empty->occupied epoch bump. The reward fires only on a REAL clear.</summary>
|
||||||
public byte ClearedThisEpoch;
|
public byte ClearedThisEpoch;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// DR-042 C7b — a SMALL replicated summary of the current expedition objective so the client HUD can show an
|
||||||
|
/// "enemies remaining / cleared — return to claim" readout. Rides the GLOBAL UNTAGGED CycleDirector ghost
|
||||||
|
/// (alongside <see cref="CycleState"/> / GoalProgress) so GhostRelevancy.SetIsIrrelevant never hides it
|
||||||
|
/// cross-region — a base teammate can't see the expedition's own (region-tagged, relevancy-hidden) enemy
|
||||||
|
/// ghosts. SOLE writer: ZoneEnemyDirectorSystem (server, plain group), written ABOVE its early-returns
|
||||||
|
/// (snapshot-above-early-return) so the readout never freezes stale. byte/short, never enum (writer is [BurstCompile]).
|
||||||
|
/// </summary>
|
||||||
|
public struct ExpeditionObjective : IComponentData
|
||||||
|
{
|
||||||
|
/// <summary>0 = Idle (no sortie active), 1 = Active (wave in progress), 2 = Cleared (return to claim).</summary>
|
||||||
|
[GhostField] public byte State;
|
||||||
|
|
||||||
|
/// <summary>Live zone enemies remaining (alive + not-yet-spawned) while Active; 0 when Idle/Cleared.</summary>
|
||||||
|
[GhostField] public short Remaining;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>State constants for <see cref="ExpeditionObjective.State"/> (byte, not enum — Burst/serialization).</summary>
|
||||||
|
public static class ExpeditionObjectiveState
|
||||||
|
{
|
||||||
|
public const byte Idle = 0;
|
||||||
|
public const byte Active = 1;
|
||||||
|
public const byte Cleared = 2;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,8 +6,10 @@ namespace ProjectM.Simulation
|
|||||||
/// <summary>
|
/// <summary>
|
||||||
/// Long-arc progress toward the goal ("reach THEM"). Lives on the GLOBAL CycleDirector ghost (relevant in
|
/// Long-arc progress toward the goal ("reach THEM"). Lives on the GLOBAL CycleDirector ghost (relevant in
|
||||||
/// every region, alongside CycleState + the resource ledger), so it is visible to all players regardless
|
/// every region, alongside CycleState + the resource ledger), so it is visible to all players regardless
|
||||||
/// of region. SINGLE writer: <c>CyclePhaseSystem</c> increments <see cref="Charge"/> on each completed
|
/// of region. Sole PRODUCTION writer (DR-042): <c>ExpeditionGateSystem</c> increments <see cref="Charge"/> by
|
||||||
/// cycle (Build -> Expedition). The HUD observes it for a progress bar.
|
/// one per cleared EXPEDITION (on the player's return). <c>GoalReachedSystem</c> only READS the Charge==Target
|
||||||
|
/// edge to arm the climactic final siege. (<c>DebugCommandReceiveSystem</c> is a manual dev-op writer.) The HUD
|
||||||
|
/// observes it for a progress bar.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public struct GoalProgress : IComponentData
|
public struct GoalProgress : IComponentData
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -11,7 +11,14 @@ namespace ProjectM.Simulation
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
public struct WorldCollisionConfig : IComponentData
|
public struct WorldCollisionConfig : IComponentData
|
||||||
{
|
{
|
||||||
|
/// <summary>BelongsTo bitmask of the Environment physics layer (<c>1u << layerIndex</c>).</summary>
|
||||||
/// <summary>BelongsTo bitmask of the Environment physics layer (<c>1u << layerIndex</c>).</summary>
|
/// <summary>BelongsTo bitmask of the Environment physics layer (<c>1u << layerIndex</c>).</summary>
|
||||||
public uint EnvironmentMask;
|
public uint EnvironmentMask;
|
||||||
|
|
||||||
|
/// <summary>DR-042 C5: BelongsTo bitmask of the "Structure" physics layer (player-built Wall/Turret/Pylon
|
||||||
|
/// colliders). OR'd into the enemy-movement sweep filter so husks collide-and-slide against walls. 0 if the
|
||||||
|
/// layer is absent (feature inert). The layer matrix excludes Structure×the player layer so the player CC
|
||||||
|
/// passes through its own walls.</summary>
|
||||||
|
public uint StructureMask;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -894,7 +894,7 @@ MonoBehaviour:
|
|||||||
m_Name:
|
m_Name:
|
||||||
m_EditorClassIdentifier: ProjectM.Authoring::ProjectM.Authoring.StructureCatalogAuthoring
|
m_EditorClassIdentifier: ProjectM.Authoring::ProjectM.Authoring.StructureCatalogAuthoring
|
||||||
TurretPrefab: {fileID: 3885353946372160549, guid: 5459c9edea89bd94fa6f5043ae00eb40, type: 3}
|
TurretPrefab: {fileID: 3885353946372160549, guid: 5459c9edea89bd94fa6f5043ae00eb40, type: 3}
|
||||||
TurretCostOre: 10
|
TurretCostOre: 40
|
||||||
WallPrefab: {fileID: 3885353946372160549, guid: 1e321aea244cc484f99c1cdd68cb01c4, type: 3}
|
WallPrefab: {fileID: 3885353946372160549, guid: 1e321aea244cc484f99c1cdd68cb01c4, type: 3}
|
||||||
WallCostOre: 4
|
WallCostOre: 4
|
||||||
PylonPrefab: {fileID: 3885353946372160549, guid: 7d0637ef90f120a4c9e2ba637dfc00af, type: 3}
|
PylonPrefab: {fileID: 3885353946372160549, guid: 7d0637ef90f120a4c9e2ba637dfc00af, type: 3}
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ namespace ProjectM.Tests
|
|||||||
/// carrying CycleState + CycleRuntime (+ optionally ThreatState / WaveState / GoalProgress). The global phase
|
/// carrying CycleState + CycleRuntime (+ optionally ThreatState / WaveState / GoalProgress). The global phase
|
||||||
/// is only ever Calm or Siege — being out on an expedition is per-player presence, NOT a global phase — so
|
/// is only ever Calm or Siege — being out on an expedition is per-player presence, NOT a global phase — so
|
||||||
/// these pin: Calm holds with no pending siege; an armed ThreatState.PendingSiegeSize enters Siege and seeds
|
/// these pin: Calm holds with no pending siege; an armed ThreatState.PendingSiegeSize enters Siege and seeds
|
||||||
/// WaveState's Spawning entry at the EXACT size; a cleared Siege returns to Calm and charges the goal once;
|
/// WaveState's Spawning entry at the EXACT size; a cleared Siege returns to Calm WITHOUT charging the goal (DR-042: expedition clears drive the win);
|
||||||
/// and split co-op presence never produces a non-Calm phase. All timing is wrap-safe NetworkTick math.
|
/// and split co-op presence never produces a non-Calm phase. All timing is wrap-safe NetworkTick math.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public class CyclePhaseSystemTests
|
public class CyclePhaseSystemTests
|
||||||
@@ -99,7 +99,7 @@ namespace ProjectM.Tests
|
|||||||
}
|
}
|
||||||
|
|
||||||
[Test]
|
[Test]
|
||||||
public void Siege_Exits_To_Calm_On_DefendCleared_And_Charges_Goal_Once()
|
public void Siege_Exits_To_Calm_On_DefendCleared_Does_Not_Charge_Goal()
|
||||||
{
|
{
|
||||||
var (world, group) = MakeWorld("SiegeClears", serverTick: 200);
|
var (world, group) = MakeWorld("SiegeClears", serverTick: 200);
|
||||||
using (world)
|
using (world)
|
||||||
@@ -114,8 +114,8 @@ namespace ProjectM.Tests
|
|||||||
|
|
||||||
Assert.AreEqual(CyclePhase.Calm, em.GetComponentData<CycleState>(cycle).Phase,
|
Assert.AreEqual(CyclePhase.Calm, em.GetComponentData<CycleState>(cycle).Phase,
|
||||||
"A cleared siege returns to Calm.");
|
"A cleared siege returns to Calm.");
|
||||||
Assert.AreEqual(1, em.GetComponentData<GoalProgress>(cycle).Charge,
|
Assert.AreEqual(0, em.GetComponentData<GoalProgress>(cycle).Charge,
|
||||||
"One goal charge accrues per siege survived (single writer).");
|
"DR-042: surviving a base siege does NOT charge the goal (the AFK win path is closed).");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -35,8 +35,8 @@ namespace ProjectM.Tests
|
|||||||
return (world, group);
|
return (world, group);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Dash speed derived from the baked knobs: 4.0 units / (12 ticks / 60) = 20 units/s.
|
// Dash speed derived from the live knobs: DashDistance / (IFrameWindowTicks/60). Tracks TuningConfig.Defaults().
|
||||||
const float ExpectedDashSpeed = 20f;
|
static readonly float ExpectedDashSpeed = TuningConfig.Defaults().DashDistance / (TuningConfig.Defaults().IFrameWindowTicks / 60f);
|
||||||
|
|
||||||
static Entity MakeDasher(EntityManager em, float2 facing)
|
static Entity MakeDasher(EntityManager em, float2 facing)
|
||||||
{
|
{
|
||||||
@@ -69,7 +69,7 @@ namespace ProjectM.Tests
|
|||||||
var ctrl = em.GetComponentData<CharacterControl>(e).MoveVelocity;
|
var ctrl = em.GetComponentData<CharacterControl>(e).MoveVelocity;
|
||||||
Assert.AreEqual(0f, ctrl.x, 1e-3f, "Dash heading (0,1) overrides X to 0.");
|
Assert.AreEqual(0f, ctrl.x, 1e-3f, "Dash heading (0,1) overrides X to 0.");
|
||||||
Assert.AreEqual(0f, ctrl.y, 1e-3f, "Planar dash keeps Y at 0.");
|
Assert.AreEqual(0f, ctrl.y, 1e-3f, "Planar dash keeps Y at 0.");
|
||||||
Assert.AreEqual(ExpectedDashSpeed, ctrl.z, 1e-3f, "i-frame window overrides velocity to Dir*dashSpeed (20).");
|
Assert.AreEqual(ExpectedDashSpeed, ctrl.z, 1e-3f, "i-frame window overrides velocity to Dir*dashSpeed (derived from the dash knobs).");
|
||||||
Assert.AreEqual(200f, em.GetComponentData<CharacterComponent>(e).GroundedMovementSharpness, 1e-3f,
|
Assert.AreEqual(200f, em.GetComponentData<CharacterComponent>(e).GroundedMovementSharpness, 1e-3f,
|
||||||
"i-frame window raises GroundedMovementSharpness to ~200 (the blink).");
|
"i-frame window raises GroundedMovementSharpness to ~200 (the blink).");
|
||||||
}
|
}
|
||||||
@@ -133,9 +133,9 @@ namespace ProjectM.Tests
|
|||||||
|
|
||||||
var ds = em.GetComponentData<DashState>(e);
|
var ds = em.GetComponentData<DashState>(e);
|
||||||
Assert.AreEqual(100u, ds.StartTick, "StartTick = now.");
|
Assert.AreEqual(100u, ds.StartTick, "StartTick = now.");
|
||||||
Assert.AreEqual(112u, ds.IFrameUntilTick, "IFrameUntilTick = now + 12.");
|
Assert.AreEqual(114u, ds.IFrameUntilTick, "IFrameUntilTick = now + 14.");
|
||||||
Assert.AreEqual(121u, ds.RecoverUntilTick, "RecoverUntilTick = now + 12 + 9.");
|
Assert.AreEqual(123u, ds.RecoverUntilTick, "RecoverUntilTick = now + 14 + 9.");
|
||||||
Assert.AreEqual(145u, em.GetComponentData<DashCooldown>(e).NextTick, "Cooldown = now + 45.");
|
Assert.AreEqual(136u, em.GetComponentData<DashCooldown>(e).NextTick, "Cooldown = now + 36.");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ namespace ProjectM.Tests
|
|||||||
/// <see cref="ThreatDirectorSystem"/> SiegeTimeout guard. A bare world is seeded with a NetworkTime singleton and a
|
/// <see cref="ThreatDirectorSystem"/> SiegeTimeout guard. A bare world is seeded with a NetworkTime singleton and a
|
||||||
/// CycleDirector entity carrying the full run-state set (CycleState/CycleRuntime/ThreatState/ThreatConfig/
|
/// CycleDirector entity carrying the full run-state set (CycleState/CycleRuntime/ThreatState/ThreatConfig/
|
||||||
/// GoalProgress/CoreIntegrity/RunPhase/RunOutcome/SaveRequest + a ledger). These pin: the goal cap arms a bigger
|
/// GoalProgress/CoreIntegrity/RunPhase/RunOutcome/SaveRequest + a ledger). These pin: the goal cap arms a bigger
|
||||||
/// final siege EXACTLY once and CyclePhaseSystem enters it once; Charge clamps to Target; a survived final siege
|
/// final siege EXACTLY once and CyclePhaseSystem enters it once; a survived NORMAL siege no longer charges the goal (DR-042); a survived final siege
|
||||||
/// latches Victory (no extra charge); a Core breach during the final siege latches Loss with NONE of the END-1
|
/// latches Victory (no extra charge); a Core breach during the final siege latches Loss with NONE of the END-1
|
||||||
/// soft-loss side effects (no ledger drain, no OverrunTick); a NORMAL-phase overrun STILL takes the END-1 soft
|
/// soft-loss side effects (no ledger drain, no OverrunTick); a NORMAL-phase overrun STILL takes the END-1 soft
|
||||||
/// path (the key regression); a restored Victory does not re-arm; and the SiegeTimeout cull is disabled during the
|
/// path (the key regression); a restored Victory does not re-arm; and the SiegeTimeout cull is disabled during the
|
||||||
@@ -127,21 +127,25 @@ namespace ProjectM.Tests
|
|||||||
}
|
}
|
||||||
|
|
||||||
[Test]
|
[Test]
|
||||||
public void Charge_Clamps_To_Target_On_Survived_Siege()
|
public void Survived_Normal_Siege_Neither_Charges_Goal_Nor_Arms_Final()
|
||||||
{
|
{
|
||||||
var (world, group) = MakeWorld("End2Clamp", serverTick: 200);
|
var (world, group) = MakeWorld("End2Clamp", serverTick: 200);
|
||||||
using (world)
|
using (world)
|
||||||
{
|
{
|
||||||
var em = world.EntityManager;
|
var em = world.EntityManager;
|
||||||
// Charge already AT Target, a NORMAL (non-final) siege is survived -> Charge must not exceed Target.
|
// DR-042: surviving a NORMAL siege one short of the cap must neither charge the goal nor arm the final.
|
||||||
var dir = MakeDirector(em, CyclePhase.Siege, defendStartWave: 5, charge: 4, target: 4,
|
var dir = MakeDirector(em, CyclePhase.Siege, defendStartWave: 5, charge: 3, target: 4,
|
||||||
core: 100, RunPhaseId.Normal, RunOutcomeId.InProgress);
|
core: 100, RunPhaseId.Normal, RunOutcomeId.InProgress);
|
||||||
MakeWave(em, waveNumber: 6, phase: WavePhase.Spawning, remaining: 0); // DefendCleared
|
MakeWave(em, waveNumber: 6, phase: WavePhase.Spawning, remaining: 0); // DefendCleared
|
||||||
|
|
||||||
group.Update();
|
group.Update();
|
||||||
|
|
||||||
Assert.AreEqual(4, em.GetComponentData<GoalProgress>(dir).Charge,
|
Assert.AreEqual(3, em.GetComponentData<GoalProgress>(dir).Charge,
|
||||||
"Charge clamps at Target on a survived siege (min(Charge+1, Target)); it never runs away.");
|
"a survived normal siege does NOT charge the goal (DR-042: base-siege survival is not win-progress).");
|
||||||
|
Assert.AreEqual(RunPhaseId.Normal, em.GetComponentData<RunPhase>(dir).Value,
|
||||||
|
"the final siege is NOT armed by a survived siege near the cap.");
|
||||||
|
Assert.AreEqual(0, em.GetComponentData<ThreatState>(dir).PendingSiegeSize,
|
||||||
|
"nothing is armed (the cap is only crossed by an expedition clear).");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -314,13 +318,13 @@ namespace ProjectM.Tests
|
|||||||
public void Final_Siege_Arms_On_Goal_Edge_Through_Pipeline_Not_Stomped_By_Scheduler()
|
public void Final_Siege_Arms_On_Goal_Edge_Through_Pipeline_Not_Stomped_By_Scheduler()
|
||||||
{
|
{
|
||||||
// M-3 + N-4: drive the REAL cross-system handoff (ThreatDirector -> CyclePhase -> GoalReached) over the
|
// M-3 + N-4: drive the REAL cross-system handoff (ThreatDirector -> CyclePhase -> GoalReached) over the
|
||||||
// Charge edge (3 -> 4 via a survived siege), then prove a DUE scheduled source can't stomp the armed final
|
// Charge edge (now crossed by an EXPEDITION CLEAR in production; PRE-SEEDED at Target here), then prove a DUE scheduled source can't stomp the armed final
|
||||||
// siege (the FinalDefense + PendingSiegeSize!=0 guards) and the FINAL size flows through to the wave.
|
// siege (the FinalDefense + PendingSiegeSize!=0 guards) and the FINAL size flows through to the wave.
|
||||||
var (world, group) = MakeFullWorld("End2Pipeline", serverTick: 200);
|
var (world, group) = MakeFullWorld("End2Pipeline", serverTick: 200);
|
||||||
using (world)
|
using (world)
|
||||||
{
|
{
|
||||||
var em = world.EntityManager;
|
var em = world.EntityManager;
|
||||||
var dir = MakeDirector(em, CyclePhase.Siege, defendStartWave: 5, charge: 3, target: 4,
|
var dir = MakeDirector(em, CyclePhase.Siege, defendStartWave: 5, charge: 4, target: 4,
|
||||||
core: 100, RunPhaseId.Normal, RunOutcomeId.InProgress);
|
core: 100, RunPhaseId.Normal, RunOutcomeId.InProgress);
|
||||||
var cfg = Cfg(); cfg.ScheduleEnabled = 1; cfg.ScheduleIntervalTicks = 100;
|
var cfg = Cfg(); cfg.ScheduleEnabled = 1; cfg.ScheduleIntervalTicks = 100;
|
||||||
em.SetComponentData(dir, cfg);
|
em.SetComponentData(dir, cfg);
|
||||||
@@ -328,9 +332,10 @@ namespace ProjectM.Tests
|
|||||||
var wave = MakeWave(em, waveNumber: 6, phase: WavePhase.Spawning, remaining: 0); // DefendCleared this tick
|
var wave = MakeWave(em, waveNumber: 6, phase: WavePhase.Spawning, remaining: 0); // DefendCleared this tick
|
||||||
int expected = ExpectedFinalSize(5, 1, 6); // (5 + 6) * 2.5 = 27
|
int expected = ExpectedFinalSize(5, 1, 6); // (5 + 6) * 2.5 = 27
|
||||||
|
|
||||||
// Tick 1: ThreatDirector (Siege -> no arm) -> CyclePhase (survive -> Charge 3->4, Calm) -> GoalReached (arm).
|
// Tick 1: ThreatDirector (Siege -> no arm) -> CyclePhase (survive -> Calm, Charge stays at cap) -> GoalReached (arm).
|
||||||
group.Update();
|
group.Update();
|
||||||
Assert.AreEqual(4, em.GetComponentData<GoalProgress>(dir).Charge, "the survived siege reaches the cap.");
|
Assert.AreEqual(4, em.GetComponentData<GoalProgress>(dir).Charge,
|
||||||
|
"Charge sits at the cap (crossed by an expedition clear in production; survived sieges no longer credit — DR-042).");
|
||||||
Assert.AreEqual(RunPhaseId.FinalDefense, em.GetComponentData<RunPhase>(dir).Value,
|
Assert.AreEqual(RunPhaseId.FinalDefense, em.GetComponentData<RunPhase>(dir).Value,
|
||||||
"GoalReached flips FinalDefense the same tick the Charge edge is crossed.");
|
"GoalReached flips FinalDefense the same tick the Charge edge is crossed.");
|
||||||
Assert.AreEqual(expected, em.GetComponentData<ThreatState>(dir).PendingSiegeSize,
|
Assert.AreEqual(expected, em.GetComponentData<ThreatState>(dir).PendingSiegeSize,
|
||||||
|
|||||||
@@ -10,9 +10,11 @@ namespace ProjectM.Tests
|
|||||||
{
|
{
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Plain-Entities EditMode tests for the once-per-epoch zone-clear reward folded into
|
/// Plain-Entities EditMode tests for the once-per-epoch zone-clear reward folded into
|
||||||
/// <see cref="ExpeditionGateSystem"/> (DR-040 BLOCKER 4). A returning player banks flat Ore to the shared ledger
|
/// <see cref="ExpeditionGateSystem"/> (DR-040 BLOCKER 4 + DR-042). A returning player banks flat Ore to the
|
||||||
/// IFF this epoch's expedition wave was actually cleared and not yet rewarded — and never twice for the same
|
/// shared ledger AND advances the long-arc win meter (GoalProgress.Charge — DR-042: EXPEDITION CLEARS, not
|
||||||
/// epoch (the co-op same-tick / gate-re-entry de-dup).
|
/// survived sieges, are the win-driver) IFF this epoch's expedition wave was actually cleared and not yet
|
||||||
|
/// rewarded — and never twice for the same epoch (the co-op same-tick / gate-re-entry de-dup; Ore + Charge
|
||||||
|
/// share the one LastRewardedEpoch latch so they always share fate).
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public class ExpeditionGateRewardTests
|
public class ExpeditionGateRewardTests
|
||||||
{
|
{
|
||||||
@@ -26,13 +28,15 @@ namespace ProjectM.Tests
|
|||||||
world.SetTime(new TimeData(elapsedTime: 0f, deltaTime: 1f / 60f));
|
world.SetTime(new TimeData(elapsedTime: 0f, deltaTime: 1f / 60f));
|
||||||
var em = world.EntityManager;
|
var em = world.EntityManager;
|
||||||
|
|
||||||
// CycleDirector-like entity: cycle state/runtime + the shared resource ledger + threat state.
|
// CycleDirector-like entity: cycle state/runtime + the shared resource ledger + threat state + goal meter.
|
||||||
var cyc = em.CreateEntity(typeof(CycleState), typeof(CycleRuntime), typeof(ResourceLedger), typeof(ThreatState));
|
var cyc = em.CreateEntity(typeof(CycleState), typeof(CycleRuntime), typeof(ResourceLedger),
|
||||||
|
typeof(ThreatState), typeof(GoalProgress));
|
||||||
em.SetComponentData(cyc, new CycleState { Phase = CyclePhase.Calm });
|
em.SetComponentData(cyc, new CycleState { Phase = CyclePhase.Calm });
|
||||||
em.SetComponentData(cyc, new CycleRuntime
|
em.SetComponentData(cyc, new CycleRuntime
|
||||||
{
|
{
|
||||||
ExpeditionEpoch = epoch, ClearedThisEpoch = clearedThisEpoch, LastRewardedEpoch = lastRewardedEpoch,
|
ExpeditionEpoch = epoch, ClearedThisEpoch = clearedThisEpoch, LastRewardedEpoch = lastRewardedEpoch,
|
||||||
});
|
});
|
||||||
|
em.SetComponentData(cyc, new GoalProgress { Charge = 0, Target = 4 });
|
||||||
em.AddBuffer<StorageEntry>(cyc);
|
em.AddBuffer<StorageEntry>(cyc);
|
||||||
|
|
||||||
// Zone-enemy director singleton (only RewardOre matters to the reward fold).
|
// Zone-enemy director singleton (only RewardOre matters to the reward fold).
|
||||||
@@ -68,7 +72,7 @@ namespace ProjectM.Tests
|
|||||||
}
|
}
|
||||||
|
|
||||||
[Test]
|
[Test]
|
||||||
public void Cleared_Return_Banks_Ore_Once()
|
public void Cleared_Return_Banks_Ore_And_Charge_Once()
|
||||||
{
|
{
|
||||||
var (world, group, cyc) = MakeWorld("GateRewardOnce", epoch: 1, clearedThisEpoch: 1, lastRewardedEpoch: 0);
|
var (world, group, cyc) = MakeWorld("GateRewardOnce", epoch: 1, clearedThisEpoch: 1, lastRewardedEpoch: 0);
|
||||||
using (world)
|
using (world)
|
||||||
@@ -79,6 +83,8 @@ namespace ProjectM.Tests
|
|||||||
group.Update(); // player walks the gate back to base -> reward
|
group.Update(); // player walks the gate back to base -> reward
|
||||||
|
|
||||||
Assert.AreEqual(25, OreInLedger(em, cyc), "a cleared return banks RewardOre to the shared ledger");
|
Assert.AreEqual(25, OreInLedger(em, cyc), "a cleared return banks RewardOre to the shared ledger");
|
||||||
|
Assert.AreEqual(1, em.GetComponentData<GoalProgress>(cyc).Charge,
|
||||||
|
"DR-042: a cleared return also advances the win meter by one (the new win-driver).");
|
||||||
Assert.AreEqual(1, em.GetComponentData<CycleRuntime>(cyc).LastRewardedEpoch, "the epoch is marked rewarded");
|
Assert.AreEqual(1, em.GetComponentData<CycleRuntime>(cyc).LastRewardedEpoch, "the epoch is marked rewarded");
|
||||||
|
|
||||||
// Force a second same-epoch return (the player is back in the expedition at the gate).
|
// Force a second same-epoch return (the player is back in the expedition at the gate).
|
||||||
@@ -88,6 +94,26 @@ namespace ProjectM.Tests
|
|||||||
group.Update(); // returns again, but the epoch was already rewarded
|
group.Update(); // returns again, but the epoch was already rewarded
|
||||||
|
|
||||||
Assert.AreEqual(25, OreInLedger(em, cyc), "the same epoch never pays twice (co-op / re-entry de-dup)");
|
Assert.AreEqual(25, OreInLedger(em, cyc), "the same epoch never pays twice (co-op / re-entry de-dup)");
|
||||||
|
Assert.AreEqual(1, em.GetComponentData<GoalProgress>(cyc).Charge,
|
||||||
|
"the same epoch never double-credits the win meter either (shared LastRewardedEpoch latch).");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public void Cleared_Return_Clamps_Charge_At_Target()
|
||||||
|
{
|
||||||
|
// DR-042: the win credit clamps at Target (min(Charge+1, Target)) — a cleared return at the cap never overshoots.
|
||||||
|
var (world, group, cyc) = MakeWorld("GateRewardClamp", epoch: 1, clearedThisEpoch: 1, lastRewardedEpoch: 0);
|
||||||
|
using (world)
|
||||||
|
{
|
||||||
|
var em = world.EntityManager;
|
||||||
|
em.SetComponentData(cyc, new GoalProgress { Charge = 4, Target = 4 }); // already at the cap
|
||||||
|
MakeExpeditionPlayerAtGate(em);
|
||||||
|
|
||||||
|
group.Update();
|
||||||
|
|
||||||
|
Assert.AreEqual(4, em.GetComponentData<GoalProgress>(cyc).Charge,
|
||||||
|
"a cleared return at the cap clamps at Target (never overshoots).");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -103,6 +129,8 @@ namespace ProjectM.Tests
|
|||||||
group.Update();
|
group.Update();
|
||||||
|
|
||||||
Assert.AreEqual(0, OreInLedger(em, cyc), "returning without clearing the wave banks nothing (no farming)");
|
Assert.AreEqual(0, OreInLedger(em, cyc), "returning without clearing the wave banks nothing (no farming)");
|
||||||
|
Assert.AreEqual(0, em.GetComponentData<GoalProgress>(cyc).Charge,
|
||||||
|
"an uncleared return advances neither Ore nor the win meter.");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,211 @@
|
|||||||
|
using NUnit.Framework;
|
||||||
|
using ProjectM.Client;
|
||||||
|
using ProjectM.Simulation;
|
||||||
|
|
||||||
|
namespace ProjectM.Tests
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Pure-logic coverage for the first-run onboarding step machine (<see cref="OnboardingStepMath"/>) — the
|
||||||
|
/// testable core of the client-only <c>OnboardingSystem</c>. No World/ECS needed: each case builds a
|
||||||
|
/// <see cref="OnboardingStepMath.Snapshot"/> and asserts the deterministic advance rule, the mask helpers,
|
||||||
|
/// the scheme-aware prompts, and the pointer kinds.
|
||||||
|
/// </summary>
|
||||||
|
public class OnboardingStepTests
|
||||||
|
{
|
||||||
|
static OnboardingStepMath.Snapshot Empty() => new OnboardingStepMath.Snapshot();
|
||||||
|
|
||||||
|
// ---- mask helpers (resume point + dormant detection) ----
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public void FirstIncomplete_EmptyMask_IsWelcome()
|
||||||
|
=> Assert.AreEqual(OnboardingStepMath.Welcome, OnboardingStepMath.FirstIncomplete(0));
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public void FirstIncomplete_SkipsCompletedPrefix()
|
||||||
|
{
|
||||||
|
int mask = (1 << OnboardingStepMath.Welcome) | (1 << OnboardingStepMath.Move) | (1 << OnboardingStepMath.Mine);
|
||||||
|
Assert.AreEqual(OnboardingStepMath.Build, OnboardingStepMath.FirstIncomplete(mask));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public void AllComplete_TrueForFullMaskAndMigrationSentinel()
|
||||||
|
{
|
||||||
|
Assert.IsFalse(OnboardingStepMath.AllComplete(0));
|
||||||
|
int full = (1 << OnboardingStepMath.StepCount) - 1;
|
||||||
|
Assert.IsTrue(OnboardingStepMath.AllComplete(full));
|
||||||
|
Assert.IsTrue(OnboardingStepMath.AllComplete(int.MaxValue)); // the v1->v2 migration sentinel reads as done
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- per-step completion rules ----
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public void Welcome_AdvancesOnTimer()
|
||||||
|
{
|
||||||
|
var s = Empty(); s.StepElapsed = OnboardingStepMath.WelcomeSeconds - 0.1f;
|
||||||
|
Assert.IsFalse(OnboardingStepMath.IsSatisfied(OnboardingStepMath.Welcome, s));
|
||||||
|
s.StepElapsed = OnboardingStepMath.WelcomeSeconds;
|
||||||
|
Assert.IsTrue(OnboardingStepMath.IsSatisfied(OnboardingStepMath.Welcome, s));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public void Move_AdvancesAfterThreshold()
|
||||||
|
{
|
||||||
|
var s = Empty(); s.MoveDistance = OnboardingStepMath.MoveThreshold - 0.1f;
|
||||||
|
Assert.IsFalse(OnboardingStepMath.IsSatisfied(OnboardingStepMath.Move, s));
|
||||||
|
s.MoveDistance = OnboardingStepMath.MoveThreshold;
|
||||||
|
Assert.IsTrue(OnboardingStepMath.IsSatisfied(OnboardingStepMath.Move, s));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public void Mine_AdvancesOnlyWhenLedgerOreRises()
|
||||||
|
{
|
||||||
|
var s = Empty(); s.OreBaseline = 50; s.OreNow = 50; // starts at the seeded baseline
|
||||||
|
Assert.IsFalse(OnboardingStepMath.IsSatisfied(OnboardingStepMath.Mine, s));
|
||||||
|
s.OreNow = 51;
|
||||||
|
Assert.IsTrue(OnboardingStepMath.IsSatisfied(OnboardingStepMath.Mine, s));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public void Build_AbsoluteTurretCount_AutoSuppressesAtBuiltBase()
|
||||||
|
{
|
||||||
|
var s = Empty(); s.TurretCount = 0;
|
||||||
|
Assert.IsFalse(OnboardingStepMath.IsSatisfied(OnboardingStepMath.Build, s));
|
||||||
|
s.TurretCount = 1; // a join-client landing at an already-built base satisfies it on entry
|
||||||
|
Assert.IsTrue(OnboardingStepMath.IsSatisfied(OnboardingStepMath.Build, s));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public void Fabricator_SoftBeat_AdvancesOnBuildOrTimeout()
|
||||||
|
{
|
||||||
|
var none = Empty();
|
||||||
|
Assert.IsFalse(OnboardingStepMath.IsSatisfied(OnboardingStepMath.Fabricator, none));
|
||||||
|
var built = Empty(); built.FabricatorCount = 1;
|
||||||
|
Assert.IsTrue(OnboardingStepMath.IsSatisfied(OnboardingStepMath.Fabricator, built));
|
||||||
|
var timedOut = Empty(); timedOut.StepElapsed = OnboardingStepMath.FabricatorSoftSeconds;
|
||||||
|
Assert.IsTrue(OnboardingStepMath.IsSatisfied(OnboardingStepMath.Fabricator, timedOut));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public void Gate_AdvancesOnExpeditionEntryOrActiveObjective()
|
||||||
|
{
|
||||||
|
var s = Empty();
|
||||||
|
Assert.IsFalse(OnboardingStepMath.IsSatisfied(OnboardingStepMath.Gate, s));
|
||||||
|
var onExp = Empty(); onExp.OnExpedition = true;
|
||||||
|
Assert.IsTrue(OnboardingStepMath.IsSatisfied(OnboardingStepMath.Gate, onExp));
|
||||||
|
var active = Empty(); active.ObjectiveState = ExpeditionObjectiveState.Active;
|
||||||
|
Assert.IsTrue(OnboardingStepMath.IsSatisfied(OnboardingStepMath.Gate, active));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public void Clear_AdvancesOnClearedObjective()
|
||||||
|
{
|
||||||
|
var s = Empty(); s.ObjectiveState = ExpeditionObjectiveState.Active;
|
||||||
|
Assert.IsFalse(OnboardingStepMath.IsSatisfied(OnboardingStepMath.Clear, s));
|
||||||
|
s.ObjectiveState = ExpeditionObjectiveState.Cleared;
|
||||||
|
Assert.IsTrue(OnboardingStepMath.IsSatisfied(OnboardingStepMath.Clear, s));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public void Return_AdvancesOnLeavingExpedition()
|
||||||
|
{
|
||||||
|
var s = Empty(); s.OnExpedition = true;
|
||||||
|
Assert.IsFalse(OnboardingStepMath.IsSatisfied(OnboardingStepMath.Return, s));
|
||||||
|
s.OnExpedition = false;
|
||||||
|
Assert.IsTrue(OnboardingStepMath.IsSatisfied(OnboardingStepMath.Return, s));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public void Defend_WaitsForSiegeEndButTimesOutWithoutOne()
|
||||||
|
{
|
||||||
|
var mid = Empty(); mid.SawSiege = true; mid.Phase = CyclePhase.Siege;
|
||||||
|
Assert.IsFalse(OnboardingStepMath.IsSatisfied(OnboardingStepMath.Defend, mid));
|
||||||
|
var survived = Empty(); survived.SawSiege = true; survived.Phase = CyclePhase.Calm;
|
||||||
|
Assert.IsTrue(OnboardingStepMath.IsSatisfied(OnboardingStepMath.Defend, survived));
|
||||||
|
var noSiege = Empty(); noSiege.StepElapsed = OnboardingStepMath.DefendNoSiegeSeconds;
|
||||||
|
Assert.IsTrue(OnboardingStepMath.IsSatisfied(OnboardingStepMath.Defend, noSiege));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public void Done_LingersThenCompletes()
|
||||||
|
{
|
||||||
|
var s = Empty(); s.StepElapsed = OnboardingStepMath.DoneSeconds - 0.1f;
|
||||||
|
Assert.IsFalse(OnboardingStepMath.IsSatisfied(OnboardingStepMath.Done, s));
|
||||||
|
s.StepElapsed = OnboardingStepMath.DoneSeconds;
|
||||||
|
Assert.IsTrue(OnboardingStepMath.IsSatisfied(OnboardingStepMath.Done, s));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- prompts (scheme-aware, never empty) ----
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public void Prompts_NonEmptyForEveryStep()
|
||||||
|
{
|
||||||
|
for (byte i = 0; i < OnboardingStepMath.StepCount; i++)
|
||||||
|
{
|
||||||
|
Assert.IsNotEmpty(OnboardingStepMath.Prompt(i, false), "kbm step " + i);
|
||||||
|
Assert.IsNotEmpty(OnboardingStepMath.Prompt(i, true), "pad step " + i);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public void Prompts_AreSchemeAware()
|
||||||
|
{
|
||||||
|
StringAssert.Contains("WASD", OnboardingStepMath.Prompt(OnboardingStepMath.Move, false));
|
||||||
|
StringAssert.Contains("Tab", OnboardingStepMath.Prompt(OnboardingStepMath.Build, false));
|
||||||
|
StringAssert.Contains("Y", OnboardingStepMath.Prompt(OnboardingStepMath.Build, true));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- pointer kinds ----
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public void PointerKinds_MatchSpatialStepsOnly()
|
||||||
|
{
|
||||||
|
Assert.AreEqual(OnboardingStepMath.PointerOreNode, OnboardingStepMath.PointerKind(OnboardingStepMath.Mine));
|
||||||
|
Assert.AreEqual(OnboardingStepMath.PointerBaseGate, OnboardingStepMath.PointerKind(OnboardingStepMath.Gate));
|
||||||
|
Assert.AreEqual(OnboardingStepMath.PointerExpeditionGate, OnboardingStepMath.PointerKind(OnboardingStepMath.Return));
|
||||||
|
Assert.AreEqual(OnboardingStepMath.PointerNone, OnboardingStepMath.PointerKind(OnboardingStepMath.Move));
|
||||||
|
Assert.AreEqual(OnboardingStepMath.PointerNone, OnboardingStepMath.PointerKind(OnboardingStepMath.Defend));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Public-surface coverage of the onboarding settings fields + their interaction with the dormant check
|
||||||
|
/// (the v1->v2 migration itself runs through the private SettingsService.Migrate at load — its EFFECT is
|
||||||
|
/// pinned here via the all-done sentinel + Defaults/Clamped, and end-to-end in the Play smoke).
|
||||||
|
/// </summary>
|
||||||
|
public class OnboardingSettingsTests
|
||||||
|
{
|
||||||
|
[Test]
|
||||||
|
public void Defaults_TutorialOn_MaskEmpty()
|
||||||
|
{
|
||||||
|
var d = GameSettings.Defaults();
|
||||||
|
Assert.AreEqual(1, d.TutorialHints);
|
||||||
|
Assert.AreEqual(0, d.OnboardingMask);
|
||||||
|
Assert.AreEqual(GameSettings.CurrentVersion, d.Version);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public void Clamped_NormalizesHints_PreservesMask()
|
||||||
|
{
|
||||||
|
var s = GameSettings.Defaults();
|
||||||
|
s.TutorialHints = 5; // out of the 0/1 range
|
||||||
|
s.OnboardingMask = 0x55; // an arbitrary bitmask must survive untouched
|
||||||
|
var c = s.Clamped();
|
||||||
|
Assert.AreEqual(1, c.TutorialHints);
|
||||||
|
Assert.AreEqual(0x55, c.OnboardingMask);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public void Defaults_ForceEachLaunch_Off()
|
||||||
|
=> Assert.AreEqual(0, GameSettings.Defaults().ForceOnboardingEachLaunch);
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public void Clamped_NormalizesForceEachLaunchToBool()
|
||||||
|
{
|
||||||
|
var s = GameSettings.Defaults();
|
||||||
|
s.ForceOnboardingEachLaunch = 7; // any non-zero collapses to the 0/1 dev flag
|
||||||
|
Assert.AreEqual(1, s.Clamped().ForceOnboardingEachLaunch);
|
||||||
|
s.ForceOnboardingEachLaunch = 0;
|
||||||
|
Assert.AreEqual(0, s.Clamped().ForceOnboardingEachLaunch);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
fileFormatVersion: 2
|
||||||
|
guid: 6b7226d166f601d43add545e1532c3e1
|
||||||
@@ -44,6 +44,7 @@ namespace ProjectM.Tests
|
|||||||
em.AddComponentData(e, new GhostOwner { NetworkId = networkId });
|
em.AddComponentData(e, new GhostOwner { NetworkId = networkId });
|
||||||
em.AddComponentData(e, new EffectiveCharacterStats { MaxHealth = maxHealth });
|
em.AddComponentData(e, new EffectiveCharacterStats { MaxHealth = maxHealth });
|
||||||
em.AddComponent<PlayerTag>(e);
|
em.AddComponent<PlayerTag>(e);
|
||||||
|
em.AddComponentData(e, new RegionTag { Region = RegionId.Base });
|
||||||
return e;
|
return e;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -103,5 +104,27 @@ namespace ProjectM.Tests
|
|||||||
Assert.AreEqual(50f, em.GetComponentData<Health>(player).Current, 1e-4f, "Alive health is untouched.");
|
Assert.AreEqual(50f, em.GetComponentData<Health>(player).Current, 1e-4f, "Alive health is untouched.");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public void Expedition_Death_Respawns_At_Base_And_Resets_RegionTag()
|
||||||
|
{
|
||||||
|
// Death-fix regression: a player who dies ON an expedition (RegionTag=Expedition) must respawn at base
|
||||||
|
// AND have its RegionTag reset to Base, or it soft-bricks (RegionRelevancy hides all base ghosts).
|
||||||
|
var (world, group) = MakeWorld("RespawnExpedition", serverTick: 200);
|
||||||
|
using (world)
|
||||||
|
{
|
||||||
|
var em = world.EntityManager;
|
||||||
|
var player = MakePlayer(em, health: 0f, maxHealth: 100f, respawnTick: 160,
|
||||||
|
delayTicks: 60, invulnTicks: 120, pos: new float3(1005, 1, 3), networkId: 1);
|
||||||
|
em.SetComponentData(player, new RegionTag { Region = RegionId.Expedition }); // died out on the sortie
|
||||||
|
|
||||||
|
group.Update();
|
||||||
|
|
||||||
|
Assert.AreEqual(RegionId.Base, em.GetComponentData<RegionTag>(player).Region,
|
||||||
|
"Death fix: respawn resets RegionTag to Base (else the player is stranded tagged Expedition).");
|
||||||
|
Assert.Less(em.GetComponentData<LocalTransform>(player).Position.x, 100f,
|
||||||
|
"And is repositioned from the expedition (x~1005) back to the base ring.");
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -24,9 +24,9 @@ namespace ProjectM.Tests
|
|||||||
{
|
{
|
||||||
var d = TuningConfig.Defaults();
|
var d = TuningConfig.Defaults();
|
||||||
Assert.AreEqual(4.0f, d.DashDistance, 1e-6f, "DashDistance");
|
Assert.AreEqual(4.0f, d.DashDistance, 1e-6f, "DashDistance");
|
||||||
Assert.AreEqual(12f, d.IFrameWindowTicks, 1e-6f, "IFrameWindowTicks");
|
Assert.AreEqual(14f, d.IFrameWindowTicks, 1e-6f, "IFrameWindowTicks");
|
||||||
Assert.AreEqual(9f, d.RecoverTailTicks, 1e-6f, "RecoverTailTicks");
|
Assert.AreEqual(9f, d.RecoverTailTicks, 1e-6f, "RecoverTailTicks");
|
||||||
Assert.AreEqual(45f, d.DashCooldownTicks, 1e-6f, "DashCooldownTicks");
|
Assert.AreEqual(36f, d.DashCooldownTicks, 1e-6f, "DashCooldownTicks");
|
||||||
Assert.AreEqual(200f, d.DashSharpness, 1e-6f, "DashSharpness");
|
Assert.AreEqual(200f, d.DashSharpness, 1e-6f, "DashSharpness");
|
||||||
Assert.AreEqual(30f, d.ChargerWindupTicks, 1e-6f, "ChargerWindupTicks");
|
Assert.AreEqual(30f, d.ChargerWindupTicks, 1e-6f, "ChargerWindupTicks");
|
||||||
Assert.AreEqual(16f, d.ChargerLungeSpeed, 1e-6f, "ChargerLungeSpeed");
|
Assert.AreEqual(16f, d.ChargerLungeSpeed, 1e-6f, "ChargerLungeSpeed");
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ Multiplayer game on **Unity DOTS (Entities) + Netcode for Entities** — server-
|
|||||||
- **Net-zero rule:** every addition is paid for by a condensation elsewhere. Keep only the hottest, highest-recurrence operational rules inline (flag them **★**); depth lives in the archive + DRs.
|
- **Net-zero rule:** every addition is paid for by a condensation elsewhere. Keep only the hottest, highest-recurrence operational rules inline (flag them **★**); depth lives in the archive + DRs.
|
||||||
- Condensation history: 06-04 → 06-17 (M1–END-2 long-form + 6.5 stack swap → archive).
|
- Condensation history: 06-04 → 06-17 (M1–END-2 long-form + 6.5 stack swap → archive).
|
||||||
|
|
||||||
## Stack — Unity 6.5.0 (`6000.5.0f1`, stable) as of 2026-06-17
|
## Stack — Unity 6.5.1 (`6000.5.1f1`, stable) as of 2026-06-27
|
||||||
|
|
||||||
| Package | Version | Notes |
|
| Package | Version | Notes |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
@@ -143,7 +143,7 @@ Full rationale: [[DR-022_Animation_Pipeline_Rukhanka_Synty]] · [[DR-023_Enemy_A
|
|||||||
|
|
||||||
- `ProjectM.Simulation.GameBootstrap : ClientServerBootstrap` overrides `Initialize` with `AutoConnectPort = 0` (M4 — listen/connect is explicit via the `ConnectionConfig` singleton + per-world ConnectionControlSystems). **Editor default = instant-into-game + MPPM** (creates `ServerWorld` (`WorldFlags.GameServer`) + `ClientWorld` (`WorldFlags.GameClient`)); the `ProjectM/Boot Into Menu (Editor)` EditorPref flips the MAIN editor to the frontend path. **Player builds boot the UITK frontend menu** (`return false` → one menu world, no netcode worlds until a menu choice). See [[DR-019_Frontend_Menu_Settings_Saves_Build]].
|
- `ProjectM.Simulation.GameBootstrap : ClientServerBootstrap` overrides `Initialize` with `AutoConnectPort = 0` (M4 — listen/connect is explicit via the `ConnectionConfig` singleton + per-world ConnectionControlSystems). **Editor default = instant-into-game + MPPM** (creates `ServerWorld` (`WorldFlags.GameServer`) + `ClientWorld` (`WorldFlags.GameClient`)); the `ProjectM/Boot Into Menu (Editor)` EditorPref flips the MAIN editor to the frontend path. **Player builds boot the UITK frontend menu** (`return false` → one menu world, no netcode worlds until a menu choice). See [[DR-019_Frontend_Menu_Settings_Saves_Build]].
|
||||||
- **Scenes:** `Assets/Scenes/MainMenu.unity` (build index 0) boots the UITK frontend (menu world only); `Assets/Scenes/Game.unity` (index 1) holds gameplay with `Assets/_Project/Subscenes/Gameplay.unity` wired in as the baked subscene (GameObject `GameplaySubScene`). `SampleScene`/`DevSandbox` are kept as reference/dev scenes. The on-demand lifecycle (`WorldLauncher`/`SessionRunner`/`MainMenuController`) creates the right worlds per menu choice (Single/Host/Join), THEN `LoadScene(Game)` (subscene-streaming rule above).
|
- **Scenes:** `Assets/Scenes/MainMenu.unity` (build index 0) boots the UITK frontend (menu world only); `Assets/Scenes/Game.unity` (index 1) holds gameplay with `Assets/_Project/Subscenes/Gameplay.unity` wired in as the baked subscene (GameObject `GameplaySubScene`). `SampleScene`/`DevSandbox` are kept as reference/dev scenes. The on-demand lifecycle (`WorldLauncher`/`SessionRunner`/`MainMenuController`) creates the right worlds per menu choice (Single/Host/Join), THEN `LoadScene(Game)` (subscene-streaming rule above).
|
||||||
- **Core loop is base-local ★ (DR-031):** `BaseFieldSpawnSystem` (server) tops up `RegionTag{Base}` Ore nodes around `BaseGridMath.PlotCenter` (DISTINCT `BaseFieldSpawner` singleton; `SetComponent`-override Region+Ore — Add throws; Ore-only). Scheduled base sieges via `ThreatDirectorSystem`'s reserved **Schedule** source (`ScheduleEnabled`/`Interval`/`SizePerWave` on `CycleDirectorAuthoring`) need NO expedition trip; `ExpeditionFieldSystem` teardown region-filtered Expedition-only (else it wipes the base field). The now-dormant expedition still lives at `base+(1000,0,0)`, hidden per-connection via `GhostRelevancy`. See [[DR-031_Base_Mining_Loop_Cohesion]] · [[DR-013_M6_Aether_Cycle_Region_Split]].
|
- **Core loop is base-local ★ (DR-031 · DR-042):** `BaseFieldSpawnSystem` (server) tops up `RegionTag{Base}` Ore nodes around `BaseGridMath.PlotCenter` (DISTINCT `BaseFieldSpawner`; `SetComponent`-override Region+Ore — Add throws; Ore-only). **Win = expedition CLEARS (DR-042):** `ExpeditionGateSystem` is sole writer of `GoalProgress.Charge` (+1/clear on RETURN); base sieges = **retaliation only**, blind `ScheduleEnabled` baked OFF (★ a serialized prefab bool ignores the C# initializer — flip `CycleDirector.prefab`, not the authoring default). `ExpeditionFieldSystem` teardown Expedition-filtered (else wipes base field); dormant expedition at `base+(1000,0,0)` hidden via `GhostRelevancy`. See [[DR-031_Base_Mining_Loop_Cohesion]] · [[DR-013_M6_Aether_Cycle_Region_Split]] · [[DR-042_Loop_Reshape_Expedition_Driven]].
|
||||||
|
|
||||||
## DOTS / ECS conventions (authoritative summary)
|
## DOTS / ECS conventions (authoritative summary)
|
||||||
|
|
||||||
|
|||||||
@@ -29,9 +29,9 @@ Last decluttered 2026-06-08 (removed all shipped `[x]` items; their context is p
|
|||||||
|
|
||||||
A 5-subsystem loop evaluation found the loop has **two conflicting win-models bolted together**: the only path to victory is "survive 4 base sieges" (passively/AFK-reachable — scheduled sieges auto-arm + a 60 s timeout auto-clears them), while the **expedition (the stated combat spine, where all the new enemy variety lives) advances nothing toward winning.** Root cause: the END-1/END-2 base-siege win is a leftover from the superseded jam slice ([[DR-035_End_Of_Month_Slice_Adoption]]/[[DR-036_END2_Final_Siege_Win_Lose]]) that DR-037 never retired. **Operator chose (2026-06-24): commit to the expedition-driven vision.** Build order:
|
A 5-subsystem loop evaluation found the loop has **two conflicting win-models bolted together**: the only path to victory is "survive 4 base sieges" (passively/AFK-reachable — scheduled sieges auto-arm + a 60 s timeout auto-clears them), while the **expedition (the stated combat spine, where all the new enemy variety lives) advances nothing toward winning.** Root cause: the END-1/END-2 base-siege win is a leftover from the superseded jam slice ([[DR-035_End_Of_Month_Slice_Adoption]]/[[DR-036_END2_Final_Siege_Win_Lose]]) that DR-037 never retired. **Operator chose (2026-06-24): commit to the expedition-driven vision.** Build order:
|
||||||
|
|
||||||
- **A — Coherence core (first):** move the win-driver from *base sieges* → *expedition clears* (`GoalProgress.Charge` +1 on a cleared sortie); **kill the AFK win** (disable the blind scheduled siege as a progression source); final beat reached through the spine. *Netcode-touching → design-review first.*
|
- **A — Coherence core — ✅ BUILT + validated (2026-06-25):** win-driver moved *base sieges → expedition clears* — `ExpeditionGateSystem` is now the sole production writer of `GoalProgress.Charge` (+1/clear, credited **on RETURN** — the review overturned credit-on-clear, which would arm the undefended final siege → uncontestable Loss; credit-on-return keeps the player home). **AFK win killed** (`ScheduleEnabled` baked OFF, retaliation-only; survived sieges no longer credit). Final beat = the END-2 final **base** siege (operator-locked). 389/389 EditMode + clean Play smoke (no sort-cycle, `ScheduleEnabled=0`). Design-review-gated (`wf_ebef4e81-dba`). Fun-gate playtest pending. *Next: **C**.*
|
||||||
- **B — Retaliation connect:** post-expedition siege becomes THE base-siege source (fix the interference) — defending what you built becomes a *consequence* of sortieing, not the goal. *Netcode-touching → design-review.*
|
- **B — Retaliation connect:** post-expedition siege becomes THE base-siege source (fix the interference) — defending what you built becomes a *consequence* of sortieing, not the goal. *Netcode-touching → design-review.*
|
||||||
- **C — Legibility fixes:** walls actually block (structures on the enemy collision filter); Aether-upgrade HUD button + cost; Biomass sink (or cut); cold-start ledger seed; hide dead Harvester/Conveyor/Pylon; expedition objective UI + gate prompt; reward scales with depth.
|
- **C — Legibility fixes — ✅ BUILT + validated (2026-06-25):** walls now block enemies (dedicated `Structure` physics layer; player passes own walls — Play-verified baked filters); Aether-upgrade HUD button + affordability tint; Biomass sink (Wall cost → Biomass, operator fork); cold-start Ore seed (kills the turret-before-fabricator deadlock); dead Harvester/Conveyor/Pylon hidden from palette + hotkeys; **expedition objective readout** (new replicated `ExpeditionObjective` GhostField) + gate prompt. Reward-depth scaling deferred (operator fork). Scoping/design-gated (`wf_7c5a555e-136`); 389/389 EditMode + Play smokes. *Visual fun-gate pending. Next: B (retaliation polish) → D.*
|
||||||
- **D — Persistent meta (= Slice 4):** SaveData v6 + between-runs growth (above).
|
- **D — Persistent meta (= Slice 4):** SaveData v6 + between-runs growth (above).
|
||||||
|
|
||||||
**Consolidation:** [[DR-036_END2_Final_Siege_Win_Lose]]'s "survive-4-base-sieges" win is **superseded** (win-driver → expedition clears; the charge-cadence "siege-survived-only" lock is reversed); [[DR-034_END1_Losable_Core]]'s Core stays but as a **consequence of the retaliation siege**, not the win-gate. The health-bar fill bug-fix (sprite-less `Image.Filled` ignores `fillAmount` → size the RectTransform) rides the first commit.
|
**Consolidation:** [[DR-036_END2_Final_Siege_Win_Lose]]'s "survive-4-base-sieges" win is **superseded** (win-driver → expedition clears; the charge-cadence "siege-survived-only" lock is reversed); [[DR-034_END1_Losable_Core]]'s Core stays but as a **consequence of the retaliation siege**, not the win-gate. The health-bar fill bug-fix (sprite-less `Image.Filled` ignores `fillAmount` → size the RectTransform) rides the first commit.
|
||||||
|
|||||||
+35
@@ -0,0 +1,35 @@
|
|||||||
|
---
|
||||||
|
title: Deferred Combat-Feel Items — Body Hit-Flash + Remote Co-op Swings + Strike Beep — Build
|
||||||
|
date: 2026-06-27
|
||||||
|
tags: [session, combat, juice, presentation, rukhanka, entities-graphics, netcode, co-op]
|
||||||
|
permalink: gamevault/07-sessions/2026/2026-06-27-deferred-feel-items
|
||||||
|
---
|
||||||
|
|
||||||
|
# Deferred combat-feel items — Build session
|
||||||
|
|
||||||
|
Cleared the three focused follow-ups that the combat-overhaul pass had explicitly deferred (named in commit `c3b53cef2` + the [[DR-041_Slice_Combat_Depth_Enemy_Variety_Impact]] "needs its own ShaderGraph slice" note). All three are **client-only, observe-only presentation** (PresentationSystemGroup; no sim mutation, no `[GhostField]`, no server work, rollback-irrelevant).
|
||||||
|
|
||||||
|
## What shipped
|
||||||
|
- **Item 1 — TRUE body hit-flash (resolves the DR-041 deferral).** New `EnemyHitFlashSystem` (client `SystemBase`, PresentationSystemGroup). Enemies render via Rukhanka GPU deformation: the ghost ROOT holds gameplay components (`Health`, `EnemyTag`); the visible meshes are `LinkedEntityGroup` CHILD render entities (each with `Unity.Rendering.MaterialMeshInfo` + the `Shader Graphs/AnimatedLitShader` material, `_BaseColor` baked white). The system drives the **built-in Entities-Graphics per-instance override `Unity.Rendering.URPMaterialPropertyBaseColor`** (`[MaterialProperty("_BaseColor")]`) on those children: a `Health`-decrease edge lerps `_BaseColor` toward `FeelConfig.BodyFlashColor` (HDR near-white) and decays back to white. **No ShaderGraph edit, no new component type** — the original deferral assumed a custom `_Flash*` graph property was required; the reusable shortcut is the stock EG `URP*MaterialProperty*` components, which the deformation shader already honors.
|
||||||
|
- **Item 2 — co-op REMOTE swing arcs.** `CombatFeedbackSystem` now renders each remote teammate's melee cleave: a per-remote-player pooled `RemoteSlash` (Mesh+Material+GO), edge-detected from the **replicated `MeleeCombo.SwingStartTick`** on interpolated teammates (`.WithAll<PlayerTag>().WithDisabled<GhostOwnerIsLocal>()`), reusing the refactored `BuildSlashInto(mesh, …)` sweep. The local player keeps its dedicated `_slashMr`. (`MeleeCombo` replicates to non-owners — `PlayerAnimationDriveSystem.RemoteDriveJob` already relies on this for teammate attack anim.)
|
||||||
|
- **Item 3 — near-impact strike beep.** Folded into the existing enemy danger-cone loop (which already computes `remaining` ticks to impact): a once-per-windup `_strikeBeepClip` "dodge NOW" cue at `StrikeBeepLeadTicks` (default 8 ≈ 130 ms) before the strike lands, distance-gated to the local player.
|
||||||
|
- 9 new `FeelConfig` knobs (+ `ResetDefaults`) covering all three.
|
||||||
|
|
||||||
|
## How it went (verify ladder)
|
||||||
|
- Drove the whole thing off an **empirical Play probe** of a live Husk: confirmed render entities are LEG children (not the root), shader = `AnimatedLitShader`, `_BaseColor` = white, and the children do **not** ship `URPMaterialPropertyBaseColor` until added.
|
||||||
|
- **Override-works proof:** the unfocused editor kept disposing the netcode worlds mid-Play (known hazard) and server-spawned test husks were culled (spawned outside the director's bookkeeping), so I proved the mechanism on the **local player** (same `AnimatedLitShader`, persistent, centered): tinted its render children via the override → captured the Game view → **body rendered red**. Same shader ⇒ the enemy flash works. Separately confirmed `EnemyHitFlashSystem` attaches the override to all 4 enemy render children at runtime.
|
||||||
|
- **390/390 EditMode**, clean compile, zero Play exceptions with all three paths live.
|
||||||
|
|
||||||
|
## Post-impl adversarial review (`wf_8a998c6c-af9`)
|
||||||
|
3 lenses (ECS/Entities-Graphics correctness · lifecycle/leaks/rollback · netcode-read edge cases). **No critical/major.** Fixed 4 real minors:
|
||||||
|
- **[FIXED] Strike beep could fire spuriously at base origin** — the "no local player ⇒ `localPos`=`float3.zero` ⇒ distance gate suppresses" assumption is FALSE: origin IS the base, so base-siege enemies within 15 m would beep before the local player ghost resolves (co-op join / save-load mid-siege). Gated the beep on `_localPlayer != Entity.Null`.
|
||||||
|
- **[FIXED] `BodyFlashEnabled` toggled off mid-flash froze an enemy tinted** (reachable via the FeelConfig tuning toggle). Added `RestoreAllToRest()` on the disable edge (settle white + clear; re-tracked on re-enable).
|
||||||
|
- **[FIXED] `RenderKids` captured once with no recovery** — don't finalize tracking until render children exist (retry next frame if Rukhanka child setup lags ghost instantiation).
|
||||||
|
- **[FIXED] `OnDestroy` symmetry** — also destroy the pooled remote-slash `GO`.
|
||||||
|
- **[NOTED, no change]** hardcoded white-restore (moot — every enemy uses the Synty-atlas white-`_BaseColor` convention; documented in the system); runtime `AddComponent` fragmentation (bounded, once/child); committed-Charger lunge relies on the cone not the beep (intended).
|
||||||
|
|
||||||
|
## Gotchas worth remembering
|
||||||
|
- **Material-driven body flash on Rukhanka/EG = drive a stock `URP*MaterialProperty*` component on the render-entity CHILDREN, not a custom ShaderGraph property.** The render entities are `LinkedEntityGroup` children with `MaterialMeshInfo` (the root has none); `_BaseColor` bakes white, so flash-toward-color / decay-to-white needs no per-material rest capture. Add the override at runtime (once/child) from a client observe-only system; settle to white at rest so the override is invisible when idle.
|
||||||
|
- **To prove an EG per-instance override is honored by a deformation shader without a stable enemy:** tint the **local player** (same material) and screenshot — the player is persistent + centered, unlike server-spawned test enemies which get culled and unlike the worlds which the unfocused editor disposes.
|
||||||
|
|
||||||
|
See [[DR-041_Slice_Combat_Depth_Enemy_Variety_Impact]] (item-1 origin) · [[DR-022_Animation_Pipeline_Rukhanka_Synty]] (render-entity structure) · [[DR-038_Slice1_Combat_Readability_HUD_Declutter]] (danger-cone the beep folds into).
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
---
|
||||||
|
title: First-Run Onboarding — design decision-tree + offline build (Unity GPU-crash session)
|
||||||
|
date: 2026-06-28
|
||||||
|
tags: [session, onboarding, ux, hud, client-only, presentation, dots-dev]
|
||||||
|
permalink: gamevault/07-sessions/2026/2026-06-28-first-run-onboarding
|
||||||
|
---
|
||||||
|
|
||||||
|
# First-run onboarding — session
|
||||||
|
|
||||||
|
`/dots-dev` session on the operator's brief: *"This game needs an onboarding style type of thing, plan something that makes sense."* Full decision: [[DR-043_First_Run_Onboarding]].
|
||||||
|
|
||||||
|
## How it went
|
||||||
|
1. **Ground** — 5-agent read-only fan-out (`wf_670a0cdf-832`) mapped the onboarding-relevant surfaces (HUD/UITK, controls, the macro loop, economy/build, frontend lifecycle) + an exhaustive search confirming **no onboarding/tutorial/help exists anywhere**. (2 of 5 mappers returned degenerate stubs; the 3 working ones triangulated the rest, so no re-run was needed.)
|
||||||
|
2. **Forks** — operator asked to drive it **decision-tree style**. Two rounds of `AskUserQuestion` (3 + 4 forks) locked the 7 design decisions (table in DR-043). A genre-precedent research pass (`wf_f41c8423-68b`, NN/g + CHI-2012 + DRG/Riftbreaker/CotL/Hades/Helldivers/Remnant) backed every recommendation; the operator chose the recommended option on all 7.
|
||||||
|
3. **Build** — mid-session the editor began **crashing randomly**. Diagnosed from `Editor.log`: empty managed stack + faulting `dxgi.dll`, GPU = **RTX 5060 Ti, driver 32.0.16.1062** → a **GPU/driver (TDR) fault, not project code** (recurring `Unity.exe.*.dmp`). Operator chose "peek at the crash, then code." Pivoted to building the whole feature **via the filesystem** (decoupled from the unstable bridge), with a single `refresh_unity force` deferred to when the editor is back.
|
||||||
|
4. **Static review in lieu of a compiler** — 3-lens adversarial review (`wf_d804925a-f7b`) verified every symbol against source: **Lens 1 compile/API = clean PASS**. Fixed 1 major (the v1→v2 migration dropped returning players' graphics settings) + 4 minors (Esc/pause copy + self-skip, pause-freeze for timed beats, static reset on teardown, same-frame HUD suppression ordering).
|
||||||
|
|
||||||
|
## What shipped (code-complete, NOT yet validated)
|
||||||
|
Contextual coach-marks (`OnboardingSystem` — client-only observe-only, own UIDocument @ sortingOrder 60) running the full first lap soft-gated, a world-space `▶` pointer, a tiny welcome strip naming the inverted win goal, a tabbed **How-to-Play** card (menu + pause), a Settings **Tutorial Hints** toggle + **Replay Tutorial**, all per-client via a client-local `GameSettings.OnboardingMask` (v1→v2 additive). Pure logic in `OnboardingStepMath` with `OnboardingStepTests`. **Zero netcode/replication/bake surface.** Files in DR-043.
|
||||||
|
|
||||||
|
## Validation — DONE (2026-06-29, editor stable)
|
||||||
|
**Green.** `refresh_unity force` → console clean → **20/20 EditMode** (incl. 2 new `ForceOnboardingEachLaunch` cases) → Play smoke proved the new dev toggle (seeded a veteran `int.MaxValue` mask + `force=1` → Boot wiped it → tutorial replayed from Welcome), confirmed **no system sort-cycle** from `HudSystem`'s `[UpdateAfter(OnboardingSystem)]`, and verified the `▶` pointer renders as a clean **U+25B6 triangle** (not tofu) + HUD hint suppression. Also shipped this session: a dev **Force Each Launch** onboarding toggle (`GameSettings.ForceOnboardingEachLaunch` + `SettingsService.Boot` per-launch wipe + a Settings cycle row). The deferred CLAUDE.md gotcha was parked in `_Meta/CLAUDE_Build_Gotchas_Archive.md` (file at cap; one-time pattern doesn't earn inline space). Full detail in [[DR-043_First_Run_Onboarding]].
|
||||||
|
|
||||||
|
### Original plan (for the record — now executed)
|
||||||
|
Not compiled/tested/Play-run yet. When the editor is stable: `refresh_unity scope=all mode=force` (the 5 new `.cs` have no `.meta`) → `read_console` clean → `run_tests` EditMode `ProjectM.Tests.EditMode` → Play smoke (welcome+step1 on a fresh client; dormant when hints off / mask full; per-client; no sort-cycle from the new `[UpdateAfter]`) → L3 screenshots (strip / pointer / card; confirm the `▶` glyph renders). Then add the deferred **CLAUDE.md** gotcha line (first-run flags → client-local `GameSettings`, not host `SaveData`) once green.
|
||||||
|
|
||||||
|
## Gotchas worth remembering
|
||||||
|
- **Unity random/idle crashes on this machine = the RTX 5060 Ti driver (`dxgi.dll`), not code.** Fix path: DDU + NVIDIA Studio driver / disable HAGS / `-force-d3d11`. (Native memory: `gpu-crash-dxgi-driver`.)
|
||||||
|
- **When the editor is unstable, write Assets `.cs` via the filesystem + one `refresh_unity force` later** instead of per-edit MCP `create_script` — the bridge dies with the editor; a static adversarial review can stand in for the compiler.
|
||||||
|
- **A version bump on an additive settings/save struct re-activates the migration path for every existing file** — migrate by carrying forward the old value and seeding only new fields, never by rebuilding from `Defaults()` (else you silently reset untouched fields). Caught by the post-impl review.
|
||||||
|
|
||||||
|
## Next-session intent
|
||||||
|
~~Get the editor stable (driver), run the verify ladder for DR-043~~ — **done 2026-06-29** (green). Remaining: the **operator fun-gate** — a real first-run playthrough to feel whether the welcome framing lands, the pointers read, and it stays un-naggy (and to confirm the Welcome→Move→Mine→Build→Fabricator→Gate→Clear→Return→Defend→Done cascade paces well with actual input). The dev **Force Each Launch** toggle (Settings → Onboarding) makes that repeatable.
|
||||||
@@ -75,10 +75,34 @@ The game has **two disconnected win-models that fight each other**:
|
|||||||
- **[[DR-037_Procedural_Expedition_Spine_Two_Classes_Persistent_Meta]]:** this DR is the concrete loop the redirect implied. Slice 4 (Persistent Meta) is **re-framed**: the loop-coherence work (A–C) comes first; the meta/SaveData-v6 (D) is the final phase.
|
- **[[DR-037_Procedural_Expedition_Spine_Two_Classes_Persistent_Meta]]:** this DR is the concrete loop the redirect implied. Slice 4 (Persistent Meta) is **re-framed**: the loop-coherence work (A–C) comes first; the meta/SaveData-v6 (D) is the final phase.
|
||||||
- **Roadmap:** [[Path_to_Fun]] (Path A/B) was already historical post-DR-037; this is the current operative loop plan. The committed list is in [[Backlog]] under the Co-op Roguelite Redirect.
|
- **Roadmap:** [[Path_to_Fun]] (Path A/B) was already historical post-DR-037; this is the current operative loop plan. The committed list is in [[Backlog]] under the Co-op Roguelite Redirect.
|
||||||
|
|
||||||
## Open forks (operator, lock before/at the design review)
|
## Open forks — RESOLVED (operator, 2026-06-25)
|
||||||
- **Final beat:** a **final expedition** (the climax IS a deep sortie — purest expression of "expedition = spine") vs a **final base siege** (a defense climax, reuses END-2's final-siege machinery). *Recommend present both at the A-phase review.*
|
- **Final beat:** **final base siege** (defense climax, reuses END-2's final-siege machinery) — *operator-chosen*. With credit-on-RETURN the capping return arms the climactic siege while the player is freshly home, so the build pillar pays off at the finish. (A *final expedition* was the alternative; deferred.)
|
||||||
- **Do base sieges stay in v1 at all,** or does retaliation become a later layer (B) so the first coherent build is purely sortie→reward→escalate? *(The recommendation keeps a light retaliation siege so the build pillar isn't orphaned.)*
|
- **Base sieges stay in v1** as **post-expedition retaliation only** (the build pillar isn't orphaned). The blind scheduled source is disabled.
|
||||||
- Expedition reward shape + depth scaling; whether a soft-loss should cost `GoalProgress` (give the run real downside).
|
- Expedition reward shape + depth scaling, and whether a soft-loss costs `GoalProgress` — deferred to phase C/tuning.
|
||||||
|
|
||||||
## Status
|
## Status
|
||||||
Accepted + locked (direction). Build pending — phase A first, design-review-gated. Loop evaluation transcript: `wf_4cebbc74-216`. Combat substrate it gives purpose to: [[DR-041_Slice_Combat_Depth_Enemy_Variety_Impact]].
|
**Phase A — BUILT + validated (2026-06-25).** Design-review-gated (`wf_ebef4e81-dba`, GREEN-WITH-CHANGES). Loop evaluation transcript: `wf_4cebbc74-216`. Combat substrate it gives purpose to: [[DR-041_Slice_Combat_Depth_Enemy_Variety_Impact]].
|
||||||
|
|
||||||
|
### Phase A build record — what shipped (and the design correction)
|
||||||
|
The review overturned the literal A1 sketch. **The win credit fires on the player's RETURN, not at the expedition-clear edge.** Crediting at the clear edge (while the player is still out in the expedition) would arm the climactic final *base* siege with nobody home — and `SiegeTimeout` is disabled in the final — so the undefended Core would breach into an **uncontestable terminal Loss**. Credit-on-return guarantees the player is teleported home the same tick the final arms.
|
||||||
|
|
||||||
|
The implementation **relocates** the single writer of `GoalProgress.Charge` (it does not add a second writer):
|
||||||
|
- **`ExpeditionGateSystem`** is now the sole *production* writer of `GoalProgress.Charge`: folded a clamped `+1` (`math.min(Charge+1, Target)`) + a `SaveRequest` checkpoint into the **existing** once-per-epoch reward block, reusing the same `LastRewardedEpoch` latch that gates the Ore reward (Ore + Charge share fate; co-op/re-entry de-dup is free). No new latch field, **no new `[GhostField]`**, no ghost re-hash. The credit is guarded independently of the ledger so it still lands in ledger-less worlds.
|
||||||
|
- **`CyclePhaseSystem`** no longer credits Charge on a survived base siege (the AFK win path deleted); the final-siege Victory latch is unchanged. `GoalReachedSystem` still arms the climactic final siege at `Charge>=Target`.
|
||||||
|
- **`ScheduleEnabled` baked OFF** (both the `CycleDirectorAuthoring` code default *and* the serialized `CycleDirector.prefab` value — the code default alone does not flip an already-serialized prefab field; Play-verified `ScheduleEnabled=0` at runtime). The schedule code path is kept as a config-inert reserved hook; `ScheduleSizePerWave` left non-zero (the final-siege size formula still reads it). Retaliation (`PostExpeditionEnabled`) stays on.
|
||||||
|
- **No system-ordering change** → no sort-cycle risk (Gate is already `[UpdateBefore(CyclePhaseSystem)]`, so the credit lands before `GoalReachedSystem` reads the edge same-tick). SaveData stays **v5**; legacy v5 saves load as-is (their old siege-era Charge reads as clears — accepted for single-slot dev saves).
|
||||||
|
- **Validation:** 389/389 EditMode (re-pointed `CyclePhaseSystemTests` + `EndgameWinLoseTests` survived-siege assertions; extended `ExpeditionGateRewardTests` with the +1, no-double-credit, and clamp cases) + a clean netcode Play smoke (world boots, no sort-cycle, `ScheduleEnabled=0`, no exceptions). **Fun-gate playtest still pending** (the base reads as inert until you walk to the gate — the gate-prompt UI is phase C, accepted caveat).
|
||||||
|
|
||||||
|
### Phase C build record — BUILT + validated (2026-06-25)
|
||||||
|
Scoping/design-gated (`wf_7c5a555e-136`). Legibility cleanup that makes the loop self-explain. Shipped in two commits:
|
||||||
|
- **C7b objective readout** — new replicated `ExpeditionObjective{[GhostField] byte State, short Remaining}` on the untagged CycleDirector ghost (cross-region safe). Sole writer `ZoneEnemyDirectorSystem`, written ABOVE its early-returns (snapshot-above-early-return). Play-verified it replicates server→client.
|
||||||
|
- **C7a gate prompt + HUD readout** — `HudSystem` shows "GO TO THE EXPEDITION GATE" / "EXPEDITION IN PROGRESS — N remaining" / "CLEARED — return to claim" (below the siege/overrun overrides).
|
||||||
|
- **C6a Aether upgrade button** — un-gated `BuildSendSystem.UpgradeAbility`; `HudSystem` button + affordability tint (was U-key only).
|
||||||
|
- **C6c cold-start seed** — `CycleDirectorSpawnSystem` seeds `Tuning.StartingOre`(50) on a NEW game only (born-correct, pre-Playback). Kills the silent turret-before-fabricator deadlock. Play-verified `seededOre=50`.
|
||||||
|
- **C6b Biomass sink** — Wall cost Ore→Biomass (the dead currency now has a home). Play-verified.
|
||||||
|
- **C6d palette declutter** — dead Pylon/Harvester/Conveyor hidden from the palette + dev hotkeys (code-intact).
|
||||||
|
- **C5 walls block enemies** — dedicated `Structure` physics layer (slot 9) + `WorldCollisionConfig.StructureMask`; `EnemyAISystem` ORs it into the sweep filter; Wall/Turret/Pylon get cell-sized colliders on the Structure layer; matrix `Default×Structure` unchecked so the player CC passes its own walls while enemies are stopped. **Play-verified baked filters:** `StructMask=512`, structure `BelongsTo=512 CollidesWith=0xFFFFFFFE` (excludes the player/Default bit). Server-only/static → deterministic, despawn frees collision for free.
|
||||||
|
|
||||||
|
389/389 EditMode + clean Play smokes throughout. SaveData stays **v5**. The Biomass-for-walls choice + defer-reward-scaling were operator forks (2026-06-25). Open: a **visual fun-gate** (husk stops at wall / player walks through; objective readout reads clearly) and the optional gate-direction arrow (deferred).
|
||||||
|
|
||||||
|
### Next phases: **B** (retaliation polish — mostly satisfied by A2 already; tune the post-expedition siege feel) → **D** (Slice 4 persistent meta, SaveData v6).
|
||||||
|
|||||||
@@ -0,0 +1,71 @@
|
|||||||
|
---
|
||||||
|
id: DR-043
|
||||||
|
title: First-Run Onboarding — contextual coach-marks + replayable How-to-Play card
|
||||||
|
status: accepted
|
||||||
|
date: 2026-06-28
|
||||||
|
tags:
|
||||||
|
- decision
|
||||||
|
- design
|
||||||
|
- onboarding
|
||||||
|
- ux
|
||||||
|
- hud
|
||||||
|
- client-only
|
||||||
|
- presentation
|
||||||
|
permalink: gamevault/07-sessions/decisions/dr-043-first-run-onboarding
|
||||||
|
---
|
||||||
|
|
||||||
|
# DR-043 — First-Run Onboarding
|
||||||
|
|
||||||
|
> Operator (2026-06-28): *"This game needs an onboarding style type of thing, plan something that makes sense."* The game teaches NOTHING today (only a "Tab/Y — BUILD" discovery chip + a one-line expedition objective readout) yet has a deep, interlocking stack to learn: twin-stick combat → mine/economy → build palette → defend-the-Core sieges → walk-the-gate expeditions → an **inverted win condition** (you win by CLEARING EXPEDITIONS — [[DR-042_Loop_Reshape_Expedition_Driven]] — not by surviving base sieges). Designed via a 7-fork decision-tree (operator-locked) backed by a genre-precedent research pass, then built through the `/dots-dev` ladder.
|
||||||
|
|
||||||
|
## The problem
|
||||||
|
A new player is dropped cold into the full loop with no scaffolding, and the win condition is **counter-intuitive** — base defense *feels* like the goal, so without explicit framing players read it as tower-defense and never discover that expeditions are the win (the "Don't Starve / Hades II — no framing" failure the research flagged). Nothing in the build addresses this.
|
||||||
|
|
||||||
|
## Locked design — 7 forks (decision-tree, operator-chosen)
|
||||||
|
| Fork | Decision |
|
||||||
|
|---|---|
|
||||||
|
| **Style** | Contextual just-in-time coach-marks **+** a replayable How-to-Play reference card (not a guided rail, not a static-only screen) |
|
||||||
|
| **Scope** | The **full first lap** — through one expedition clear AND the retaliation siege (stopping earlier hides the win) |
|
||||||
|
| **Pacing** | **Soft-gated** objectives (a step shows until its action is done; never physically blocks) + auto-suppress |
|
||||||
|
| **Guidance** | Text prompt **+ one world-space pointer** per step (off-screen edge-arrow for navigation; text for conceptual beats) |
|
||||||
|
| **Welcome** | A **tiny non-modal welcome strip** on first spawn (names the inverted goal) → then silent coach-marks |
|
||||||
|
| **Reference card** | **Tabbed** (Controls · The Loop · Build & Economy · Threats · Win/Lose), reachable from **menu + pause** |
|
||||||
|
| **Opt-out** | Settings **toggle** + **auto-suppress** (a taught action already done never fires) + **replayable** from the menu + a dev **Force Each Launch** toggle (wipes the mask every boot — see below) |
|
||||||
|
| **Co-op** | **Per-client**, keyed to each player's own first-encounter; flags in **client-local `GameSettings`**, NOT the host-only `SaveData` |
|
||||||
|
|
||||||
|
Research backing (3-agent genre-precedent pass, run `wf_f41c8423-68b`): NN/g pull-not-push / one-thing-at-a-time / advance-by-doing; CHI-2012 (context-sensitive > forced); shipped analogs Riftbreaker / Cult of the Lamb / DRG / Hades / Helldivers 2 / Remnant 2 all teach **one complete lap then release**, optional+replayable, per-client. The annotated loop diagram is the single highest-value asset (answers the #1 confusion "how do the pillars connect").
|
||||||
|
|
||||||
|
## Architecture — pure client-side presentation, ZERO netcode surface
|
||||||
|
- **`OnboardingSystem`** — client-only observe-only `SystemBase` in `PresentationSystemGroup` (same constraints as `HudSystem`: never mutates sim, never destroys a ghost, reads replicated state once/frame). Owns its own runtime UIDocument (sortingOrder **60** — above HUD 50, below pause 100, root `pickingMode=Ignore`). A bottom-center prompt chip + a world-space `▶` pointer.
|
||||||
|
- **`OnboardingStepMath`** — pure, engine-free step list + `Snapshot` + `IsSatisfied` + prompt copy + pointer kind + mask helpers (the unit-testable core; mirrors the `*Math` discipline).
|
||||||
|
- **`OnboardingState`** — static `Active` flag (HudSystem reads it to blank its own location hint → single prompt voice) + `[RuntimeInitializeOnLoadMethod]` reset-on-play-enter (stale-static rule).
|
||||||
|
- Progress persists per-client in **`GameSettings.OnboardingMask`** (a completed-step bitmask) + `TutorialHints` toggle (v1→v2 **additive**). Client-local JSON — a Join client keeps its own; a save wipe never re-teaches the host.
|
||||||
|
- **Dev replay-each-launch** (`GameSettings.ForceOnboardingEachLaunch`, added 2026-06-29): an additive **0-default** int (no version bump — a v2 file deserializes it to 0/off). When set, `SettingsService.Boot` (a `RuntimeInitializeOnLoadMethod` that runs on **every** editor Play-enter / built-player launch) wipes the mask + forces hints on **in-memory** (not written back — the system re-persists as the player advances, and the next launch wipes again). Surfaced as a "Force Each Launch (Dev)" cycle row in Settings → Onboarding. The menu "Replay Tutorial" remains the one-shot equivalent.
|
||||||
|
|
||||||
|
### The lap (10 beats; soft-gated; scheme-aware glyphs; auto-suppress via absolute counts)
|
||||||
|
Welcome(timed, names the goal) → Move → Mine (attack an Ore node — mining IS combat at the base since Calm has no enemies; pointer→nearest node) → Build a Turret → Fabricator (soft) → Reach the Gate (edge-arrow) → Clear the zone → Return (leave expedition) → Defend the siege (soft) → Done. Each step gated on the **local** player's own first-encounter; count-based steps (Build/Fabricator) test an **absolute** count so a join-client at a built base auto-skips.
|
||||||
|
|
||||||
|
## Wire/bake classification — **LOW blast radius**
|
||||||
|
No `[GhostField]`, no ghost prefab/hash change, **no subscene re-bake**, no RPC, no `GhostRelevancy`, no server-system ordering. Only client-local `GameSettings` v1→v2 (additive JSON). ⇒ no pre-code netcode design review needed; relied on the verify ladder + a post-impl static review instead.
|
||||||
|
|
||||||
|
## Build status — shipped + validated green (built 2026-06-28, validated 2026-06-29)
|
||||||
|
Built **2026-06-28**, written via the **filesystem** (not MCP `create_script`) because the Unity editor was crashing — a **GPU/driver fault** (`dxgi.dll`, RTX 5060 Ti, driver 32.0.16.1062; recurring `Unity.exe.*.dmp` — NOT project code; see the session log + native memory). The feature has **NOT yet been compiled / tested / Play-validated**.
|
||||||
|
|
||||||
|
In lieu of a compiler, a **3-lens adversarial static review** (run `wf_d804925a-f7b`) verified every symbol against the codebase: **Lens 1 (compile/API) = clean PASS** (SystemAPI-in-helpers pattern confirmed valid, the `PlayerInput`/InputSystem CS8377 gotcha respected, asmdef refs + UITK APIs all resolve). Findings fixed:
|
||||||
|
- **[MAJOR, fixed]** the v1→v2 bump activated the lossy `SettingsService.Migrate` (rebuilt from `Defaults()`, dropping returning players' display-mode/quality/v-sync/fps/refresh) → rewrote Migrate to carry forward all old fields and seed only the new ones (Load already Clamps).
|
||||||
|
- **[minor, fixed]** Welcome copy said "Esc: How to Play" but Esc opens Pause, and Esc (via `anyKey`) self-dismissed the framing → reworded to "Esc → Pause → How to Play" + excluded `escapeKey` from the message-beat skip.
|
||||||
|
- **[minor, fixed]** timed beats advanced behind the pause overlay → freeze accumulation/eval on `PauseMenuController.Open`.
|
||||||
|
- **[minor, fixed]** `OnboardingState.Active` not reset on system teardown → reset in `OnDestroy`; HudSystem now `[UpdateAfter(OnboardingSystem)]` for same-frame suppression.
|
||||||
|
- **[L3 watch]** the `▶` pointer glyph's font coverage — verify it renders (swap to a font-independent shape if it's a missing-glyph box).
|
||||||
|
|
||||||
|
**Validation (2026-06-29, editor stable):** `refresh_unity scope=all mode=force` imported the 5 new `.cs` + recompiled → `read_console` clean (1 unrelated AI-assistant warning) → **20/20 EditMode pass** (`OnboardingStepTests` + `OnboardingSettingsTests`, incl. 2 new `ForceOnboardingEachLaunch` cases). Play smoke: seeded a **veteran** mask (`int.MaxValue`) + `force=1` → Boot wiped it → the overlay replayed from Welcome (live `mask` observed back at 0, then 1 as Welcome auto-completed) — proving the dev toggle AND that `HudSystem`'s new `[UpdateAfter(OnboardingSystem)]` introduces **no system sort-cycle** (world booted clean). Captured the Move + Build + Gate prompt chips and the `▶` pointer — the glyph renders as a **clean U+25B6 triangle, not a tofu box** (the flagged L3 watch), and the HUD location hint is correctly suppressed during onboarding. (The `▶` step was forced via reflection on the live `OnboardingSystem` since input can't be injected in an MCP smoke.)
|
||||||
|
|
||||||
|
## Files
|
||||||
|
**New:** `Client/Onboarding/{OnboardingState, OnboardingStepMath, OnboardingSystem}.cs`, `Client/UI/HowToPlayPanel.cs`, `Tests/EditMode/OnboardingStepTests.cs`.
|
||||||
|
**Edited:** `Client/Settings/{GameSettings, SettingsService}.cs`, `Client/UI/{MainMenuController, PauseMenuController, SettingsScreen}.cs`, `Client/Presentation/HudSystem.cs`.
|
||||||
|
|
||||||
|
## Consequences / open
|
||||||
|
- **CLAUDE.md gotcha — parked in the archive, NOT inlined** (resolved 2026-06-29): the reusable lesson (*first-run / has-played / per-player UI flags → client-local `GameSettings`, never host `SaveData`* + the client-onboarding-overlay pattern + the dev force-each-launch wipe) is captured under a dated heading in `_Meta/CLAUDE_Build_Gotchas_Archive.md`. It stayed out of CLAUDE.md by design: the file is at **40884/40960 B** (76 B headroom) and this is a one-time client-overlay pattern, not a high-recurrence hazard, so by CLAUDE.md's own "hottest rules only" budget rule it doesn't earn inline space — evicting a hot rule to make room for a cold one would be a net loss.
|
||||||
|
- Tuning knobs (live): `OnboardingStepMath.{WelcomeSeconds, MoveThreshold, FabricatorSoftSeconds, DefendNoSiegeSeconds, DoneSeconds}`.
|
||||||
|
- Deferred (not built): contextual `?` deep-link from a coach-mark to the card page; target/HUD highlights (operator chose pointers only); a structured guided-tutorial variant.
|
||||||
|
- See [[2026-06-28_First_Run_Onboarding]] · [[DR-042_Loop_Reshape_Expedition_Driven]] · [[DR-038_Slice1_Combat_Readability_HUD_Declutter]] (the discovery-chip/build-mode precedent) · [[DR-019_Frontend_Menu_Settings_Saves_Build]] (settings/save lifecycle).
|
||||||
@@ -384,3 +384,14 @@ Added the **EB-2 felt spend ★** bullet ([[DR-033_EB2_Felt_Spend_Charge_Economy
|
|||||||
- **Resource-gated ability tiers / buffs ([[DR-026_Inventory_Equipment_Progression_Foundation]]):** dropped the "(replace/clear-by-SourceId → bounded buffer; folds into … both worlds)" mechanism phrasing → kept "reuse `StatModifier` … `StatRecomputeSystem`→`EffectiveAbilityStats`". (Note: this bullet's `GoalProgress.Charge` is the **goal-meter** charge, unrelated to EB-2's `ResourceId.Charge` turret ammo.)
|
- **Resource-gated ability tiers / buffs ([[DR-026_Inventory_Equipment_Progression_Foundation]]):** dropped the "(replace/clear-by-SourceId → bounded buffer; folds into … both worlds)" mechanism phrasing → kept "reuse `StatModifier` … `StatRecomputeSystem`→`EffectiveAbilityStats`". (Note: this bullet's `GoalProgress.Charge` is the **goal-meter** charge, unrelated to EB-2's `ResourceId.Charge` turret ammo.)
|
||||||
- **PlacedStructure ([[DR-014_M6_Build_Structures_Automation_Foundation]]):** dropped the "(turret reuses `NextTick` as fire cooldown; they're the offline-catch-up linchpin)" + "Data-driven `StructureCatalog` buffer" asides → kept the field layout + the bake-the-tick-fields rule + the DERIVED-occupancy rule.
|
- **PlacedStructure ([[DR-014_M6_Build_Structures_Automation_Foundation]]):** dropped the "(turret reuses `NextTick` as fire cooldown; they're the offline-catch-up linchpin)" + "Data-driven `StructureCatalog` buffer" asides → kept the field layout + the bake-the-tick-fields rule + the DERIVED-occupancy rule.
|
||||||
- **M7 Automation ([[DR-020_M7_Automation_Production_Chains]]):** dropped the "server-only `MachineInput`/`MachineOutput`" restatement and noted `Fabricator` is now LIVE on the palette via EB-2 while `Harvester`/`Conveyor` stay trimmed (code intact).
|
- **M7 Automation ([[DR-020_M7_Automation_Production_Chains]]):** dropped the "server-only `MachineInput`/`MachineOutput`" restatement and noted `Fabricator` is now LIVE on the palette via EB-2 while `Harvester`/`Conveyor` stay trimmed (code intact).
|
||||||
|
|
||||||
|
## 2026-06-29 — First-run onboarding validated (DR-043); CLAUDE.md line kept archive-only (file at cap)
|
||||||
|
|
||||||
|
DR-043's first-run onboarding shipped + Play-validated green (20/20 EditMode; live Play: a "veteran" full mask wiped by the dev toggle → tutorial replayed from Welcome, the `▶` pointer = a clean U+25B6 triangle (not tofu), no system sort-cycle from `HudSystem`'s new `[UpdateAfter(OnboardingSystem)]`). The deferred CLAUDE.md gotcha is parked HERE, not inline — the file sits at 40884/40960 B (76 B headroom) and the lesson is a one-time client-overlay pattern, not a high-recurrence hazard, so by CLAUDE.md's own "hottest rules only" rule it doesn't earn inline space. The lesson:
|
||||||
|
|
||||||
|
- **First-run / "has-played" / per-player UI flags belong in client-local `GameSettings` (settings.json), NEVER the host-only `SaveData`** — in co-op a Join client never sees the host save, and a host save-wipe must not re-teach. Progress = a completed-step **bitmask** in settings. Additive field, **0-default → no version bump** (a missing field deserializes to 0 = the safe off/fresh default; bumping `CurrentVersion` instead re-activates the migration path for every existing file — see the 2026-06-28 migration regression below/in DR-043).
|
||||||
|
- **Onboarding overlay = a client-only observe-only `PresentationSystemGroup` `SystemBase`** (same constraints as `HudSystem`: never mutates sim, never destroys a ghost, reads replicated state once/frame) owning its own runtime `UIDocument` (sortingOrder 60 — above HUD 50, below pause 100, root `pickingMode=Ignore`). A static `OnboardingState.Active` (reset on `SubsystemRegistration`) lets `HudSystem` (`[UpdateAfter(OnboardingSystem)]`) blank its own location hint → a single prompt voice. Auto-suppress for veterans/co-op falls out of **ABSOLUTE count checks** (turret/fabricator count ≥1 satisfies on entry at an already-built base).
|
||||||
|
- **Dev "Force Each Launch" toggle:** `GameSettings.ForceOnboardingEachLaunch` → `SettingsService.Boot` (a `RuntimeInitializeOnLoadMethod` that runs each editor Play-enter / built-player launch) wipes the mask + forces hints on **IN-MEMORY** (NOT written back; the system re-persists progress as the player advances, and the next launch wipes again). Validated by seeding `OnboardingMask=int.MaxValue` (veteran) + `force=1` then Play → mask observed back at 0/fresh.
|
||||||
|
- **Forcing a specific step in a live Play smoke without input:** the step machine's `_step`/`_mask`/`_stepInit` are private — set them via reflection on `world.GetExistingSystemManaged(typeof(OnboardingSystem))`; remember an MCP `screenshot` can leave the editor **paused** (`EditorApplication.isPaused`), so the field write won't surface until you unpause. Pause + reflection-restyle the `_pointer` Label is also how to get a clean glyph capture (OnUpdate would otherwise overwrite the style next frame).
|
||||||
|
|
||||||
|
Net-zero: archive-only add (no CLAUDE.md bytes changed), so no inline condensation needed.
|
||||||
|
|||||||
@@ -1,3 +0,0 @@
|
|||||||
version https://git-lfs.github.com/spec/v1
|
|
||||||
oid sha256:0b07c4f9006927403f888bfd692e356f5c9f108dc7a0854ab7f4c4aeb1778173
|
|
||||||
size 34272
|
|
||||||
-121
@@ -1,121 +0,0 @@
|
|||||||
fileFormatVersion: 2
|
|
||||||
guid: 9152f2aee957bdc488befd900ff896e4
|
|
||||||
TextureImporter:
|
|
||||||
internalIDToNameTable: []
|
|
||||||
externalObjects: {}
|
|
||||||
serializedVersion: 13
|
|
||||||
mipmaps:
|
|
||||||
mipMapMode: 0
|
|
||||||
enableMipMap: 1
|
|
||||||
sRGBTexture: 1
|
|
||||||
linearTexture: 0
|
|
||||||
fadeOut: 0
|
|
||||||
borderMipMap: 0
|
|
||||||
mipMapsPreserveCoverage: 0
|
|
||||||
alphaTestReferenceValue: 0.5
|
|
||||||
mipMapFadeDistanceStart: 1
|
|
||||||
mipMapFadeDistanceEnd: 3
|
|
||||||
bumpmap:
|
|
||||||
convertToNormalMap: 0
|
|
||||||
externalNormalMap: 0
|
|
||||||
heightScale: 0.25
|
|
||||||
normalMapFilter: 0
|
|
||||||
flipGreenChannel: 0
|
|
||||||
isReadable: 0
|
|
||||||
streamingMipmaps: 0
|
|
||||||
streamingMipmapsPriority: 0
|
|
||||||
vTOnly: 0
|
|
||||||
ignoreMipmapLimit: 0
|
|
||||||
grayScaleToAlpha: 0
|
|
||||||
generateCubemap: 6
|
|
||||||
cubemapConvolution: 0
|
|
||||||
seamlessCubemap: 0
|
|
||||||
textureFormat: 1
|
|
||||||
maxTextureSize: 2048
|
|
||||||
textureSettings:
|
|
||||||
serializedVersion: 2
|
|
||||||
filterMode: 1
|
|
||||||
aniso: 1
|
|
||||||
mipBias: 0
|
|
||||||
wrapU: 0
|
|
||||||
wrapV: 0
|
|
||||||
wrapW: 0
|
|
||||||
nPOTScale: 1
|
|
||||||
lightmap: 0
|
|
||||||
compressionQuality: 50
|
|
||||||
spriteMode: 0
|
|
||||||
spriteExtrude: 1
|
|
||||||
spriteMeshType: 1
|
|
||||||
alignment: 0
|
|
||||||
spritePivot: {x: 0.5, y: 0.5}
|
|
||||||
spritePixelsToUnits: 100
|
|
||||||
spriteBorder: {x: 0, y: 0, z: 0, w: 0}
|
|
||||||
spriteGenerateFallbackPhysicsShape: 1
|
|
||||||
alphaUsage: 1
|
|
||||||
alphaIsTransparency: 0
|
|
||||||
spriteTessellationDetail: -1
|
|
||||||
textureType: 0
|
|
||||||
textureShape: 1
|
|
||||||
singleChannelComponent: 0
|
|
||||||
flipbookRows: 1
|
|
||||||
flipbookColumns: 1
|
|
||||||
maxTextureSizeSet: 0
|
|
||||||
compressionQualitySet: 0
|
|
||||||
textureFormatSet: 0
|
|
||||||
ignorePngGamma: 0
|
|
||||||
applyGammaDecoding: 0
|
|
||||||
swizzle: 50462976
|
|
||||||
cookieLightType: 0
|
|
||||||
platformSettings:
|
|
||||||
- serializedVersion: 3
|
|
||||||
buildTarget: DefaultTexturePlatform
|
|
||||||
maxTextureSize: 2048
|
|
||||||
resizeAlgorithm: 0
|
|
||||||
textureFormat: -1
|
|
||||||
textureCompression: 1
|
|
||||||
compressionQuality: 50
|
|
||||||
crunchedCompression: 0
|
|
||||||
allowsAlphaSplitting: 0
|
|
||||||
overridden: 0
|
|
||||||
ignorePlatformSupport: 0
|
|
||||||
androidETC2FallbackOverride: 0
|
|
||||||
forceMaximumCompressionQuality_BC6H_BC7: 0
|
|
||||||
- serializedVersion: 3
|
|
||||||
buildTarget: Standalone
|
|
||||||
maxTextureSize: 2048
|
|
||||||
resizeAlgorithm: 0
|
|
||||||
textureFormat: -1
|
|
||||||
textureCompression: 1
|
|
||||||
compressionQuality: 50
|
|
||||||
crunchedCompression: 0
|
|
||||||
allowsAlphaSplitting: 0
|
|
||||||
overridden: 0
|
|
||||||
ignorePlatformSupport: 0
|
|
||||||
androidETC2FallbackOverride: 0
|
|
||||||
forceMaximumCompressionQuality_BC6H_BC7: 0
|
|
||||||
spriteSheet:
|
|
||||||
serializedVersion: 2
|
|
||||||
sprites: []
|
|
||||||
outline: []
|
|
||||||
physicsShape: []
|
|
||||||
bones: []
|
|
||||||
spriteID:
|
|
||||||
internalID: 0
|
|
||||||
vertices: []
|
|
||||||
indices:
|
|
||||||
edges: []
|
|
||||||
weights: []
|
|
||||||
secondaryTextures: []
|
|
||||||
nameFileIdTable: {}
|
|
||||||
mipmapLimitGroupName:
|
|
||||||
pSDRemoveMatte: 0
|
|
||||||
userData:
|
|
||||||
assetBundleName:
|
|
||||||
assetBundleVariant:
|
|
||||||
AssetOrigin:
|
|
||||||
serializedVersion: 1
|
|
||||||
productId: 298480
|
|
||||||
packageName: Rukhanka Animation System 2
|
|
||||||
packageVersion: 2.9.0
|
|
||||||
assetPath: Packages/com.rukhanka.animation/Rukhanka.Editor/BlobInspector/RukhankaLogoSmall.png
|
|
||||||
uploadId: 897522
|
|
||||||
-3
@@ -1,3 +0,0 @@
|
|||||||
version https://git-lfs.github.com/spec/v1
|
|
||||||
oid sha256:dc93292b6018d1829c24285452ec78679a867efd9d6b40324c444b72297dced5
|
|
||||||
size 12205
|
|
||||||
-121
@@ -1,121 +0,0 @@
|
|||||||
fileFormatVersion: 2
|
|
||||||
guid: 656caeb2c35ed38498a80781843a5012
|
|
||||||
TextureImporter:
|
|
||||||
internalIDToNameTable: []
|
|
||||||
externalObjects: {}
|
|
||||||
serializedVersion: 13
|
|
||||||
mipmaps:
|
|
||||||
mipMapMode: 0
|
|
||||||
enableMipMap: 1
|
|
||||||
sRGBTexture: 1
|
|
||||||
linearTexture: 0
|
|
||||||
fadeOut: 0
|
|
||||||
borderMipMap: 0
|
|
||||||
mipMapsPreserveCoverage: 0
|
|
||||||
alphaTestReferenceValue: 0.5
|
|
||||||
mipMapFadeDistanceStart: 1
|
|
||||||
mipMapFadeDistanceEnd: 3
|
|
||||||
bumpmap:
|
|
||||||
convertToNormalMap: 0
|
|
||||||
externalNormalMap: 0
|
|
||||||
heightScale: 0.25
|
|
||||||
normalMapFilter: 0
|
|
||||||
flipGreenChannel: 0
|
|
||||||
isReadable: 0
|
|
||||||
streamingMipmaps: 0
|
|
||||||
streamingMipmapsPriority: 0
|
|
||||||
vTOnly: 0
|
|
||||||
ignoreMipmapLimit: 0
|
|
||||||
grayScaleToAlpha: 0
|
|
||||||
generateCubemap: 6
|
|
||||||
cubemapConvolution: 0
|
|
||||||
seamlessCubemap: 0
|
|
||||||
textureFormat: 1
|
|
||||||
maxTextureSize: 2048
|
|
||||||
textureSettings:
|
|
||||||
serializedVersion: 2
|
|
||||||
filterMode: 1
|
|
||||||
aniso: 1
|
|
||||||
mipBias: 0
|
|
||||||
wrapU: 0
|
|
||||||
wrapV: 0
|
|
||||||
wrapW: 0
|
|
||||||
nPOTScale: 1
|
|
||||||
lightmap: 0
|
|
||||||
compressionQuality: 50
|
|
||||||
spriteMode: 0
|
|
||||||
spriteExtrude: 1
|
|
||||||
spriteMeshType: 1
|
|
||||||
alignment: 0
|
|
||||||
spritePivot: {x: 0.5, y: 0.5}
|
|
||||||
spritePixelsToUnits: 100
|
|
||||||
spriteBorder: {x: 0, y: 0, z: 0, w: 0}
|
|
||||||
spriteGenerateFallbackPhysicsShape: 1
|
|
||||||
alphaUsage: 1
|
|
||||||
alphaIsTransparency: 0
|
|
||||||
spriteTessellationDetail: -1
|
|
||||||
textureType: 0
|
|
||||||
textureShape: 1
|
|
||||||
singleChannelComponent: 0
|
|
||||||
flipbookRows: 1
|
|
||||||
flipbookColumns: 1
|
|
||||||
maxTextureSizeSet: 0
|
|
||||||
compressionQualitySet: 0
|
|
||||||
textureFormatSet: 0
|
|
||||||
ignorePngGamma: 0
|
|
||||||
applyGammaDecoding: 0
|
|
||||||
swizzle: 50462976
|
|
||||||
cookieLightType: 0
|
|
||||||
platformSettings:
|
|
||||||
- serializedVersion: 3
|
|
||||||
buildTarget: DefaultTexturePlatform
|
|
||||||
maxTextureSize: 2048
|
|
||||||
resizeAlgorithm: 0
|
|
||||||
textureFormat: -1
|
|
||||||
textureCompression: 1
|
|
||||||
compressionQuality: 50
|
|
||||||
crunchedCompression: 0
|
|
||||||
allowsAlphaSplitting: 0
|
|
||||||
overridden: 0
|
|
||||||
ignorePlatformSupport: 0
|
|
||||||
androidETC2FallbackOverride: 0
|
|
||||||
forceMaximumCompressionQuality_BC6H_BC7: 0
|
|
||||||
- serializedVersion: 3
|
|
||||||
buildTarget: Standalone
|
|
||||||
maxTextureSize: 2048
|
|
||||||
resizeAlgorithm: 0
|
|
||||||
textureFormat: -1
|
|
||||||
textureCompression: 1
|
|
||||||
compressionQuality: 50
|
|
||||||
crunchedCompression: 0
|
|
||||||
allowsAlphaSplitting: 0
|
|
||||||
overridden: 0
|
|
||||||
ignorePlatformSupport: 0
|
|
||||||
androidETC2FallbackOverride: 0
|
|
||||||
forceMaximumCompressionQuality_BC6H_BC7: 0
|
|
||||||
spriteSheet:
|
|
||||||
serializedVersion: 2
|
|
||||||
sprites: []
|
|
||||||
outline: []
|
|
||||||
physicsShape: []
|
|
||||||
bones: []
|
|
||||||
spriteID:
|
|
||||||
internalID: 0
|
|
||||||
vertices: []
|
|
||||||
indices:
|
|
||||||
edges: []
|
|
||||||
weights: []
|
|
||||||
secondaryTextures: []
|
|
||||||
nameFileIdTable: {}
|
|
||||||
mipmapLimitGroupName:
|
|
||||||
pSDRemoveMatte: 0
|
|
||||||
userData:
|
|
||||||
assetBundleName:
|
|
||||||
assetBundleVariant:
|
|
||||||
AssetOrigin:
|
|
||||||
serializedVersion: 1
|
|
||||||
productId: 298480
|
|
||||||
packageName: Rukhanka Animation System 2
|
|
||||||
packageVersion: 2.9.0
|
|
||||||
assetPath: Packages/com.rukhanka.animation/Rukhanka.Editor/Editor Default Resources/Icons/Icon@64.png
|
|
||||||
uploadId: 897522
|
|
||||||
-3
@@ -1,3 +0,0 @@
|
|||||||
version https://git-lfs.github.com/spec/v1
|
|
||||||
oid sha256:780d80089b9b292d0ca85f75c026a100d875bc6d0e8f30effbf538e0a2a4857d
|
|
||||||
size 454
|
|
||||||
-121
@@ -1,121 +0,0 @@
|
|||||||
fileFormatVersion: 2
|
|
||||||
guid: ba20972f4867ba540850834b8c8d5917
|
|
||||||
TextureImporter:
|
|
||||||
internalIDToNameTable: []
|
|
||||||
externalObjects: {}
|
|
||||||
serializedVersion: 13
|
|
||||||
mipmaps:
|
|
||||||
mipMapMode: 0
|
|
||||||
enableMipMap: 0
|
|
||||||
sRGBTexture: 1
|
|
||||||
linearTexture: 0
|
|
||||||
fadeOut: 0
|
|
||||||
borderMipMap: 0
|
|
||||||
mipMapsPreserveCoverage: 0
|
|
||||||
alphaTestReferenceValue: 0.5
|
|
||||||
mipMapFadeDistanceStart: 1
|
|
||||||
mipMapFadeDistanceEnd: 3
|
|
||||||
bumpmap:
|
|
||||||
convertToNormalMap: 0
|
|
||||||
externalNormalMap: 0
|
|
||||||
heightScale: 0.25
|
|
||||||
normalMapFilter: 0
|
|
||||||
flipGreenChannel: 0
|
|
||||||
isReadable: 0
|
|
||||||
streamingMipmaps: 0
|
|
||||||
streamingMipmapsPriority: 0
|
|
||||||
vTOnly: 0
|
|
||||||
ignoreMipmapLimit: 0
|
|
||||||
grayScaleToAlpha: 0
|
|
||||||
generateCubemap: 6
|
|
||||||
cubemapConvolution: 0
|
|
||||||
seamlessCubemap: 0
|
|
||||||
textureFormat: 1
|
|
||||||
maxTextureSize: 2048
|
|
||||||
textureSettings:
|
|
||||||
serializedVersion: 2
|
|
||||||
filterMode: 1
|
|
||||||
aniso: 1
|
|
||||||
mipBias: 0
|
|
||||||
wrapU: 0
|
|
||||||
wrapV: 0
|
|
||||||
wrapW: 0
|
|
||||||
nPOTScale: 1
|
|
||||||
lightmap: 0
|
|
||||||
compressionQuality: 50
|
|
||||||
spriteMode: 0
|
|
||||||
spriteExtrude: 1
|
|
||||||
spriteMeshType: 1
|
|
||||||
alignment: 0
|
|
||||||
spritePivot: {x: 0.5, y: 0.5}
|
|
||||||
spritePixelsToUnits: 100
|
|
||||||
spriteBorder: {x: 0, y: 0, z: 0, w: 0}
|
|
||||||
spriteGenerateFallbackPhysicsShape: 1
|
|
||||||
alphaUsage: 1
|
|
||||||
alphaIsTransparency: 0
|
|
||||||
spriteTessellationDetail: -1
|
|
||||||
textureType: 0
|
|
||||||
textureShape: 1
|
|
||||||
singleChannelComponent: 0
|
|
||||||
flipbookRows: 1
|
|
||||||
flipbookColumns: 1
|
|
||||||
maxTextureSizeSet: 0
|
|
||||||
compressionQualitySet: 0
|
|
||||||
textureFormatSet: 0
|
|
||||||
ignorePngGamma: 0
|
|
||||||
applyGammaDecoding: 0
|
|
||||||
swizzle: 50462976
|
|
||||||
cookieLightType: 0
|
|
||||||
platformSettings:
|
|
||||||
- serializedVersion: 3
|
|
||||||
buildTarget: DefaultTexturePlatform
|
|
||||||
maxTextureSize: 2048
|
|
||||||
resizeAlgorithm: 0
|
|
||||||
textureFormat: 4
|
|
||||||
textureCompression: 1
|
|
||||||
compressionQuality: 50
|
|
||||||
crunchedCompression: 0
|
|
||||||
allowsAlphaSplitting: 0
|
|
||||||
overridden: 0
|
|
||||||
ignorePlatformSupport: 0
|
|
||||||
androidETC2FallbackOverride: 0
|
|
||||||
forceMaximumCompressionQuality_BC6H_BC7: 0
|
|
||||||
- serializedVersion: 3
|
|
||||||
buildTarget: Standalone
|
|
||||||
maxTextureSize: 2048
|
|
||||||
resizeAlgorithm: 0
|
|
||||||
textureFormat: -1
|
|
||||||
textureCompression: 1
|
|
||||||
compressionQuality: 50
|
|
||||||
crunchedCompression: 0
|
|
||||||
allowsAlphaSplitting: 0
|
|
||||||
overridden: 0
|
|
||||||
ignorePlatformSupport: 0
|
|
||||||
androidETC2FallbackOverride: 0
|
|
||||||
forceMaximumCompressionQuality_BC6H_BC7: 0
|
|
||||||
spriteSheet:
|
|
||||||
serializedVersion: 2
|
|
||||||
sprites: []
|
|
||||||
outline: []
|
|
||||||
physicsShape: []
|
|
||||||
bones: []
|
|
||||||
spriteID:
|
|
||||||
internalID: 0
|
|
||||||
vertices: []
|
|
||||||
indices:
|
|
||||||
edges: []
|
|
||||||
weights: []
|
|
||||||
secondaryTextures: []
|
|
||||||
nameFileIdTable: {}
|
|
||||||
mipmapLimitGroupName:
|
|
||||||
pSDRemoveMatte: 0
|
|
||||||
userData:
|
|
||||||
assetBundleName:
|
|
||||||
assetBundleVariant:
|
|
||||||
AssetOrigin:
|
|
||||||
serializedVersion: 1
|
|
||||||
productId: 298480
|
|
||||||
packageName: Rukhanka Animation System 2
|
|
||||||
packageVersion: 2.9.0
|
|
||||||
assetPath: Packages/com.rukhanka.animation/Rukhanka.Editor/Editor Default Resources/Icons/RukhankaWaybackMachine@16.png
|
|
||||||
uploadId: 897522
|
|
||||||
@@ -1,3 +0,0 @@
|
|||||||
version https://git-lfs.github.com/spec/v1
|
|
||||||
oid sha256:9358080bc4a17744843f74b1a80ad823da9c03e1a7940721e20bd7ce57dc4167
|
|
||||||
size 8544454
|
|
||||||
@@ -1,14 +0,0 @@
|
|||||||
fileFormatVersion: 2
|
|
||||||
guid: 5fc783efb879658498ef7e482d82d827
|
|
||||||
DefaultImporter:
|
|
||||||
externalObjects: {}
|
|
||||||
userData:
|
|
||||||
assetBundleName:
|
|
||||||
assetBundleVariant:
|
|
||||||
AssetOrigin:
|
|
||||||
serializedVersion: 1
|
|
||||||
productId: 298480
|
|
||||||
packageName: Rukhanka Animation System 2
|
|
||||||
packageVersion: 2.9.0
|
|
||||||
assetPath: Packages/com.rukhanka.animation/RukhankaAnimation.pdf
|
|
||||||
uploadId: 897522
|
|
||||||
-3
@@ -1,3 +0,0 @@
|
|||||||
version https://git-lfs.github.com/spec/v1
|
|
||||||
oid sha256:d96647a6ccd2481ef10e55a558f37445cf201d08ebf2407871bfda16ce6400f5
|
|
||||||
size 188717
|
|
||||||
@@ -1,3 +0,0 @@
|
|||||||
version https://git-lfs.github.com/spec/v1
|
|
||||||
oid sha256:23b485716a0c01e5916fd249cff3cada64455207ea7ebafe3507b1ccd1368e7d
|
|
||||||
size 1189806
|
|
||||||
-3
@@ -1,3 +0,0 @@
|
|||||||
version https://git-lfs.github.com/spec/v1
|
|
||||||
oid sha256:c199f0ba39a87b5d04706defb9cab8fb41d7c4c83590302ab38a2de6c246343f
|
|
||||||
size 1307632
|
|
||||||
-3
@@ -1,3 +0,0 @@
|
|||||||
version https://git-lfs.github.com/spec/v1
|
|
||||||
oid sha256:7332531fa42e4dcfefecbdfc3a785e1d438d0dcb02cc81c0be5fca8b3c7fcc0c
|
|
||||||
size 24783
|
|
||||||
-3
@@ -1,3 +0,0 @@
|
|||||||
version https://git-lfs.github.com/spec/v1
|
|
||||||
oid sha256:fc4705fb590d10cd7a90ffda8b5e36b47f51bc091cccc0fcbd5ea307003b1058
|
|
||||||
size 850120
|
|
||||||
-3
@@ -1,3 +0,0 @@
|
|||||||
version https://git-lfs.github.com/spec/v1
|
|
||||||
oid sha256:5c6b9d2fb65a9cab9036ac15ee8d544a00872c7d555510e7453104ce995477fb
|
|
||||||
size 358453
|
|
||||||
-3
@@ -1,3 +0,0 @@
|
|||||||
version https://git-lfs.github.com/spec/v1
|
|
||||||
oid sha256:ab015b180e89496180a3e81a75160c9fe2b27095e6590d1f0125fcfa85bd787c
|
|
||||||
size 605520
|
|
||||||
-3
@@ -1,3 +0,0 @@
|
|||||||
version https://git-lfs.github.com/spec/v1
|
|
||||||
oid sha256:855e05c72da761ccd2bfb2ec8e77599677d327d627ee51c0bf6a818ce958b7f1
|
|
||||||
size 5021936
|
|
||||||
-3
@@ -1,3 +0,0 @@
|
|||||||
version https://git-lfs.github.com/spec/v1
|
|
||||||
oid sha256:855e05c72da761ccd2bfb2ec8e77599677d327d627ee51c0bf6a818ce958b7f1
|
|
||||||
size 5021936
|
|
||||||
-3
@@ -1,3 +0,0 @@
|
|||||||
version https://git-lfs.github.com/spec/v1
|
|
||||||
oid sha256:6fd9ca4e12a5c87b995151b1214bac19186ddc69ff7465b072b471c22a47183c
|
|
||||||
size 1511158
|
|
||||||
-3
@@ -1,3 +0,0 @@
|
|||||||
version https://git-lfs.github.com/spec/v1
|
|
||||||
oid sha256:70cc94914096603b9402d22318f437da5f6445764645c5d83e21f8d88a9a1a01
|
|
||||||
size 304186
|
|
||||||
-3
@@ -1,3 +0,0 @@
|
|||||||
version https://git-lfs.github.com/spec/v1
|
|
||||||
oid sha256:7333ca62e193315221bd6036c6c3ff4ed9cae9f48df4cb97283544e4ab1a6fff
|
|
||||||
size 5638820
|
|
||||||
-3
@@ -1,3 +0,0 @@
|
|||||||
version https://git-lfs.github.com/spec/v1
|
|
||||||
oid sha256:119ef128b0b072af817fa40f3760a42f3e24567e1628365f0bd871a29932f8ec
|
|
||||||
size 1728684
|
|
||||||
-3
@@ -1,3 +0,0 @@
|
|||||||
version https://git-lfs.github.com/spec/v1
|
|
||||||
oid sha256:fc2525420bc521c82a07888edd98e71ce7a69e7ed9ee9f848ef0193ea8c148ed
|
|
||||||
size 3648211
|
|
||||||
-3
@@ -1,3 +0,0 @@
|
|||||||
version https://git-lfs.github.com/spec/v1
|
|
||||||
oid sha256:93a9b6681bbdc16fce9ab132fb64a7f762ef042db44decc33ecf9008a3b3d4cd
|
|
||||||
size 447944
|
|
||||||
-3
@@ -1,3 +0,0 @@
|
|||||||
version https://git-lfs.github.com/spec/v1
|
|
||||||
oid sha256:791002915b96180ffdff610fb2ea1ce29ceb94c65d876fa113d44e4e9446e578
|
|
||||||
size 5379186
|
|
||||||
-3
@@ -1,3 +0,0 @@
|
|||||||
version https://git-lfs.github.com/spec/v1
|
|
||||||
oid sha256:0b9e945ce8a75e3ee72c2a4c4746f22ace3124cb79e17e38ea76d1a32bb0b2bc
|
|
||||||
size 1394972
|
|
||||||
-3
@@ -1,3 +0,0 @@
|
|||||||
version https://git-lfs.github.com/spec/v1
|
|
||||||
oid sha256:71dfa0d4e362d140946097330800f5c71cb271b73f7eff4ac8fbe803e5821d76
|
|
||||||
size 157930
|
|
||||||
-3
@@ -1,3 +0,0 @@
|
|||||||
version https://git-lfs.github.com/spec/v1
|
|
||||||
oid sha256:9637854833f9ade38bc287cb0deced884e815fb4a441a9f13bcefd8437b2974c
|
|
||||||
size 5006166
|
|
||||||
-3
@@ -1,3 +0,0 @@
|
|||||||
version https://git-lfs.github.com/spec/v1
|
|
||||||
oid sha256:46aa70383b0466b33e44046c5d12ab12b78263771dadc42cda7efeeedfbe0966
|
|
||||||
size 24284
|
|
||||||
-3
@@ -1,3 +0,0 @@
|
|||||||
version https://git-lfs.github.com/spec/v1
|
|
||||||
oid sha256:7da79d81c036cf09b706d773bfcb8826a88652a16f8a49ab2915be2d4464b6f8
|
|
||||||
size 1731728
|
|
||||||
-3
@@ -1,3 +0,0 @@
|
|||||||
version https://git-lfs.github.com/spec/v1
|
|
||||||
oid sha256:d2ce7f7d19d7a4b6ce0d582b35aee201268c28eb1791b8b7d0ce027e8ac97f29
|
|
||||||
size 4852912
|
|
||||||
-3
@@ -1,3 +0,0 @@
|
|||||||
version https://git-lfs.github.com/spec/v1
|
|
||||||
oid sha256:4cf429c935bc983e910d23a2340c4caa46462f89fce5fbfa5e7e1421eec8910b
|
|
||||||
size 1445344
|
|
||||||
-3
@@ -1,3 +0,0 @@
|
|||||||
version https://git-lfs.github.com/spec/v1
|
|
||||||
oid sha256:5b97be7e143bf8356b7df4fb0a6d148991e6304e27d791d870d6afd0449869f9
|
|
||||||
size 965952
|
|
||||||
-3
@@ -1,3 +0,0 @@
|
|||||||
version https://git-lfs.github.com/spec/v1
|
|
||||||
oid sha256:1b0fa0a29eda83665f2e3336b9e3690275a213724e251d6651292a6646aa732c
|
|
||||||
size 3179124
|
|
||||||
-3
@@ -1,3 +0,0 @@
|
|||||||
version https://git-lfs.github.com/spec/v1
|
|
||||||
oid sha256:d34261057263e698c4686a9cd8a38f54f9e5e891af8e142139fab4671b38fa16
|
|
||||||
size 250247
|
|
||||||
-3
@@ -1,3 +0,0 @@
|
|||||||
version https://git-lfs.github.com/spec/v1
|
|
||||||
oid sha256:806c1312c9128a2d2b9c2fcd6aceb25785992a59c94ff2b0e2d4299cc0772ca3
|
|
||||||
size 3231404
|
|
||||||
-3
@@ -1,3 +0,0 @@
|
|||||||
version https://git-lfs.github.com/spec/v1
|
|
||||||
oid sha256:0e7852510545bbae51964f12407e2f11ae4f9228acfc3394704d7370c8461cb7
|
|
||||||
size 270211
|
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user