This commit is contained in:
2026-06-07 13:28:25 -07:00
parent 0df0b45163
commit 25b53cb062
25 changed files with 1573 additions and 125 deletions
Binary file not shown.
@@ -0,0 +1,117 @@
fileFormatVersion: 2
guid: afca5e6e55c487f409a970ba901dad9e
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:
Binary file not shown.
@@ -0,0 +1,117 @@
fileFormatVersion: 2
guid: f9a065d41a38802498de6e0afcba10f0
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:
Binary file not shown.
@@ -0,0 +1,117 @@
fileFormatVersion: 2
guid: ccc39a1f3a2fc4d4aaa6e9ecc6926317
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:
Binary file not shown.
@@ -0,0 +1,117 @@
fileFormatVersion: 2
guid: 4df1a2477053b6e4fb590f9f31587ad8
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:
@@ -43,7 +43,7 @@ TextureImporter:
nPOTScale: 0
lightmap: 0
compressionQuality: 50
spriteMode: 2
spriteMode: 1
spriteExtrude: 0
spriteMeshType: 1
alignment: 0
@@ -67,7 +67,7 @@ TextureImporter:
swizzle: 50462976
cookieLightType: 1
platformSettings:
- serializedVersion: 3
- serializedVersion: 4
buildTarget: DefaultTexturePlatform
maxTextureSize: 2048
resizeAlgorithm: 0
@@ -80,7 +80,7 @@ TextureImporter:
ignorePlatformSupport: 0
androidETC2FallbackOverride: 0
forceMaximumCompressionQuality_BC6H_BC7: 0
- serializedVersion: 3
- serializedVersion: 4
buildTarget: Standalone
maxTextureSize: 2048
resizeAlgorithm: 0
@@ -93,7 +93,7 @@ TextureImporter:
ignorePlatformSupport: 0
androidETC2FallbackOverride: 0
forceMaximumCompressionQuality_BC6H_BC7: 0
- serializedVersion: 3
- serializedVersion: 4
buildTarget: Server
maxTextureSize: 2048
resizeAlgorithm: 0
@@ -110,6 +110,7 @@ TextureImporter:
serializedVersion: 2
sprites: []
outline: []
customData:
physicsShape: []
bones: []
spriteID: 5e97eb03825dee720800000000000000
@@ -119,6 +120,8 @@ TextureImporter:
edges: []
weights: []
secondaryTextures: []
spriteCustomMetadata:
entries: []
nameFileIdTable: {}
mipmapLimitGroupName:
pSDRemoveMatte: 0
@@ -76,6 +76,7 @@ Material:
- _Cutoff: 0.5
- _DetailNormalMapScale: 1
- _DstBlend: 10
- _DstBlendAlpha: 10
- _Enable_Emission: 0
- _GlossMapScale: 1
- _Glossiness: 0.608
@@ -92,6 +93,7 @@ Material:
- _SmoothnessTextureChannel: 0
- _SpecularHighlights: 1
- _SrcBlend: 1
- _SrcBlendAlpha: 1
- _Surface: 1
- _UVSec: 0
- _WorkflowMode: 1
+45
View File
@@ -0,0 +1,45 @@
%YAML 1.1
%TAG !u! tag:unity3d.com,2011:
--- !u!114 &11400000
MonoBehaviour:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 0}
m_Enabled: 1
m_EditorHideFlags: 0
m_Script: {fileID: 11500000, guid: 5b0ba3a2a48e57547b89018d8aba1134, type: 3}
m_Name: HudTheme
m_EditorClassIdentifier: ProjectM.Client::ProjectM.Client.HudTheme
DisplayFont: {fileID: 12800000, guid: 319f905d228f1954287b03ca7f765339, type: 3}
BodyFont: {fileID: 12800000, guid: 6aba851fb3214a44db734640c86770d8, type: 3}
BodyLightFont: {fileID: 12800000, guid: d3a2f510bc359a746b7dbf70ee67a4e2, type: 3}
PanelBox: {fileID: 21300000, guid: dae3c84a5c66df34dbbaa61259992522, type: 3}
BarTrack: {fileID: 21300000, guid: 68cc419df845fee45b1f03838b9db588, type: 3}
Vignette: {fileID: 21300000, guid: b884942c7c315d445aab18a35ec0ded5, type: 3}
Glow: {fileID: 21300000, guid: 3df67136bd1b3034e8a05b816d8f4925, type: 3}
PipActive: {fileID: 21300000, guid: e1ab7424b5c11bd4f99e8f3ebc195b80, type: 3}
PipInactive: {fileID: 21300000, guid: 1bbdc2e30de1b8a438a5bd8779b8906a, type: 3}
HealthIcon: {fileID: 21300000, guid: bcfc86f084f11084aa3ea0818f67122a, type: 3}
ShieldIcon: {fileID: 21300000, guid: cdb36b97147bf0a47acdab7757676fd1, type: 3}
ThreatIcon: {fileID: 21300000, guid: 01355905a4556544cab941de4a8c6286, type: 3}
GoalIcon: {fileID: 21300000, guid: bb2be08a1b4f7fb4ab0253875a76672c, type: 3}
CooldownIcon: {fileID: 21300000, guid: 0f29a99d278aaa649a4a9712799550fb, type: 3}
LocationBaseIcon: {fileID: 21300000, guid: 660c70bdd241e334fba13240a702fe5f, type: 3}
LocationExpeditionIcon: {fileID: 21300000, guid: 5089ba11ce996474eb180d6cff1861c0, type: 3}
AetherIcon: {fileID: 21300000, guid: 5ec17eed1a417b944b4494db0a88e645, type: 3}
OreIcon: {fileID: 21300000, guid: 5a8c6e8575552cb4d96a8fe09227c6e2, type: 3}
BioIcon: {fileID: 21300000, guid: ca8f53ae67b997e489ba530a7c0ea190, type: 3}
TurretIcon: {fileID: 21300000, guid: 0f29a99d278aaa649a4a9712799550fb, type: 3}
WallIcon: {fileID: 21300000, guid: 977995b1071c6c044b8725d231a428a8, type: 3}
PylonIcon: {fileID: 21300000, guid: 5ec17eed1a417b944b4494db0a88e645, type: 3}
HarvesterIcon: {fileID: 21300000, guid: 5a8c6e8575552cb4d96a8fe09227c6e2, type: 3}
FabricatorIcon: {fileID: 21300000, guid: ca43072da0d43f44fbdbf57c997414ba, type: 3}
ConveyorIcon: {fileID: 21300000, guid: ba4939cd85537454a93777a4cc9e8b38, type: 3}
KbmPlace: {fileID: 21300000, guid: 3fc063021eaa5eb4cb710e15a38ceb6c, type: 3}
KbmCancel: {fileID: 21300000, guid: 78d618b10af7f9b4db2600f77fdce06c, type: 3}
PadPlace: {fileID: 21300000, guid: 15a4f1d900c35fd4091f6970c5250a44, type: 3}
PadCancel: {fileID: 21300000, guid: e311023e5bfd1434d9701f9d89d81e40, type: 3}
PadRotate: {fileID: 21300000, guid: 28c34b18ad3c2cf4dacc46ef515a44aa, type: 3}
PadExit: {fileID: 21300000, guid: ecf39456e341c8640a5c4aec5de3f5aa, type: 3}
@@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: 96bc82b4127f03242aa7e1c35e36a82e
NativeFormatImporter:
externalObjects: {}
mainObjectFileID: 11400000
userData:
assetBundleName:
assetBundleVariant:
@@ -8,33 +8,72 @@ using UnityEngine.UIElements;
namespace ProjectM.Client
{
/// <summary>
/// Client-only screen HUD, rebuilt on UI Toolkit so it reads in the same Aether-cyan visual language as the
/// menu / pause / settings screens (<see cref="MenuUi"/> / <see cref="HudUi"/>). A managed presentation
/// SystemBase (<see cref="PresentationSystemGroup"/>) that OBSERVES the local player ghost + the global
/// cycle / ledger / goal each frame and pushes values into a runtime UIDocument (shared PanelSettings,
/// sortingOrder 50 so it sits BEHIND the pause overlay's 100). The root is <c>pickingMode = Ignore</c> so the
/// HUD never eats clicks meant for the game world — only the build-palette buttons pick. Presentation only
/// (client world, no simulation). The palette buttons set <see cref="BuildPaletteState"/> (client-local UI
/// state), which BuildSendSystem turns into a ground ghost + click-to-place. The visual tree is built on the
/// first OnUpdate where the UIDocument's root exists (giving the panel a frame to initialise its PanelSettings).
/// Client-only screen HUD on UI Toolkit, skinned with the curated Synty sci-fi-soldier kit
/// (<see cref="HudTheme"/>) over <see cref="MenuUi"/>'s Aether palette so it reads in one visual language with
/// the menu / pause / settings. A managed presentation <see cref="SystemBase"/> (<see cref="PresentationSystemGroup"/>)
/// that OBSERVES the local player ghost + the global cycle / ledger / goal each frame and pushes values into a
/// runtime UIDocument (shared PanelSettings, sortingOrder 50 so it sits behind the pause overlay's 100). The
/// root is <c>pickingMode = Ignore</c> so the HUD never eats world clicks — only the build-palette slots pick.
/// Layout (good-HUD spatial convention): persistent self-state hugs the corners (health bottom-left, resources
/// top-left, threat top-right, build deck bottom-center); transient mission state (phase / countdown / wave /
/// goal) lives center-top; a low-health vignette + hurt-flash + a scheme-aware build-mode control-hint bar give
/// just-in-time feedback. EVERY skinned element is null-safe: with no <see cref="HudTheme"/> it falls back to
/// the flat-colour HUD. Presentation only (client world, no simulation, no rollback double-fire).
/// </summary>
[WorldSystemFilter(WorldSystemFilterFlags.ClientSimulation)]
[UpdateInGroup(typeof(PresentationSystemGroup))]
public partial class HudSystem : SystemBase
{
// ---- palette (Aether language; Synty white skins are tinted into these) ----
static readonly Color AetherCyan = new(0.30f, 0.85f, 1f);
static readonly Color OreAmber = new(1f, 0.72f, 0.35f);
static readonly Color BioGreen = new(0.55f, 0.85f, 0.45f);
static readonly Color PanelDark = new(0.08f, 0.11f, 0.15f, 0.90f);
static readonly Color PanelWarm = new(0.16f, 0.09f, 0.09f, 0.88f);
static readonly Color PipDim = new(0.25f, 0.30f, 0.36f, 0.9f);
static readonly Color BlightRed = new(0.85f, 0.10f, 0.08f);
static readonly Color ThreatWarm = new(1f, 0.62f, 0.4f);
static readonly Color SlotIdleBg = new(0.09f, 0.11f, 0.15f, 0.92f);
static readonly Color SlotSelBg = new(0.16f, 0.26f, 0.32f, 0.95f);
static readonly Color SlotIdleBorder = new(1f, 1f, 1f, 0.08f);
const int MaxPips = 12;
const float ExpeditionRegionXMin = 500f; // camera x past this = the +1000 expedition region (DR-013)
GameObject _hudGo;
UIDocument _doc;
bool _built;
bool _themed; // HudTheme + PanelBox present (drives sprite-tint vs flat-colour retint)
VisualElement _healthFill, _cooldownFill, _goalFill, _downed, _paletteRow;
Label _healthText, _threatText, _phaseText, _resourceText, _locationText, _goalText;
// vitals
VisualElement _healthFill, _cooldownFill, _shieldRow, _cdRow;
Label _healthText;
bool _paletteBuilt;
// threat
VisualElement _threatPanel, _threatIcon;
Label _threatNum;
// macro: banner + location + goal
VisualElement _banner, _goalContainer, _goalPipsRow, _goalBar, _goalFill;
Label _phaseText, _cycleText, _locationText, _goalText;
readonly List<VisualElement> _pips = new();
// resources
Label _aetherNum, _oreNum, _bioNum;
// build palette + hints
VisualElement _paletteRow, _hintBar, _facingArrow;
bool _paletteBuilt, _hintBuilt, _hintConveyor;
byte _hintScheme = 255;
readonly Dictionary<byte, PaletteItem> _palette = new();
// overlays
VisualElement _vignette, _downed;
float _prevHp, _flash;
bool _haveHp;
EntityQuery _huskQuery;
struct PaletteItem { public VisualElement Root; public Label Cost; public int CostAmount; }
struct PaletteItem { public VisualElement Root; public Label Cost; public int CostAmount; public byte CostRes; public VisualElement Glow; public VisualElement Icon; }
protected override void OnCreate()
{
@@ -67,46 +106,84 @@ namespace ProjectM.Client
_built = true;
}
// Job-safety insurance (matches the sibling presentation systems + CLAUDE.md): finish any jobs writing
// the components we read on the main thread before reading them. No job writes these today, but this
// stays correct the day a Health/stats writer is parallelised.
EntityManager.CompleteDependencyBeforeRO<Health>();
EntityManager.CompleteDependencyBeforeRO<EffectiveCharacterStats>();
EntityManager.CompleteDependencyBeforeRO<EffectiveAbilityStats>();
EntityManager.CompleteDependencyBeforeRO<AbilityCooldown>();
EntityManager.CompleteDependencyBeforeRO<RespawnInvuln>();
float dt = SystemAPI.Time.DeltaTime; // wall-frame delta — correct in a presentation system
bool haveTick = SystemAPI.TryGetSingleton<NetworkTime>(out var nt);
int huskCount = _huskQuery.CalculateEntityCount();
// ---- Macro loop: phase + cycle + countdown ----
// ---- Macro: phase + cycle + countdown (center-top banner) ----
bool haveCycle = SystemAPI.TryGetSingleton<CycleState>(out var cyc);
bool siege = haveCycle && cyc.Phase == CyclePhase.Siege;
if (haveCycle)
{
var endTick = new NetworkTick(cyc.PhaseEndTick);
string detail;
if (cyc.Phase == CyclePhase.Siege)
if (siege)
detail = "WAVE " + cyc.WaveNumber + " - " + huskCount + " HUSKS";
else if (haveTick && cyc.PhaseEndTick != 0 && endTick.IsValid && endTick.IsNewerThan(nt.ServerTick))
detail = "INCURSION IN " + (endTick.TicksSince(nt.ServerTick) / 60 + 1) + "s";
else
detail = "";
var col = PhaseColor(cyc.Phase);
_phaseText.text = PhaseLabel(cyc.Phase) + (detail.Length > 0 ? " - " + detail : "");
_phaseText.style.color = PhaseColor(cyc.Phase);
_phaseText.style.color = col;
_cycleText.text = "CYCLE " + cyc.CycleNumber;
_banner.style.borderBottomColor = col;
RetintPanel(_banner, siege ? PanelWarm : PanelDark);
}
else
{
_phaseText.text = "";
_cycleText.text = "";
}
// ---- Location + gate hint ----
// ---- Location + gate hint (banner sub-line) ----
var cam = Camera.main;
bool onExpedition = cam != null && cam.transform.position.x > 500f;
bool onExpedition = cam != null && cam.transform.position.x > ExpeditionRegionXMin;
_locationText.text = onExpedition
? "ON EXPEDITION - return through the gate"
: "AT BASE - deploy through the gate when you're ready";
_locationText.style.color = onExpedition ? new Color(1f, 0.8f, 0.4f) : new Color(0.6f, 0.85f, 1f);
// ---- Goal ----
// ---- Goal (hex-pip meter, or a continuous bar for large targets) ----
if (SystemAPI.TryGetSingleton<GoalProgress>(out var goal))
{
_goalContainer.style.display = DisplayStyle.Flex;
float gfrac = goal.Target > 0 ? Mathf.Clamp01(goal.Charge / (float)goal.Target) : 0f;
HudUi.SetFill(_goalFill, gfrac);
_goalText.text = "GOAL " + goal.Charge + " / " + goal.Target;
if (goal.Target >= 1 && goal.Target <= MaxPips)
{
_goalPipsRow.style.display = DisplayStyle.Flex;
_goalBar.style.display = DisplayStyle.None;
int active = Mathf.Min(goal.Charge, goal.Target); // Charge is the integer pip count; never over-fill
for (int i = 0; i < _pips.Count; i++)
{
bool show = i < goal.Target;
_pips[i].style.display = show ? DisplayStyle.Flex : DisplayStyle.None;
if (show) SetPip(_pips[i], i < active);
}
}
else
{
_goalPipsRow.style.display = DisplayStyle.None;
_goalBar.style.display = DisplayStyle.Flex;
HudUi.SetFill(_goalFill, gfrac);
}
}
else
{
_goalContainer.style.display = DisplayStyle.None;
}
// ---- Resources (Ore feeds the palette affordability) ----
// ---- Resources (feed palette affordability) ----
int aether = 0, ore = 0, bio = 0;
if (SystemAPI.TryGetSingletonEntity<ResourceLedger>(out var ledgerE))
{
@@ -119,10 +196,39 @@ namespace ProjectM.Client
else if (en.ItemId == ResourceId.Biomass) bio = en.Count;
}
}
_resourceText.text = "AETHER " + aether + " ORE " + ore + " BIO " + bio;
_aetherNum.text = aether.ToString();
_oreNum.text = ore.ToString();
_bioNum.text = bio.ToString();
// ---- Build palette (lazy-built once the catalog has streamed; hidden off-base) ----
UpdatePalette(ore, onExpedition);
// ---- Threat readout (top-right) — hidden entirely at base with zero husks; its reappearance is the cue ----
bool showThreat = siege || huskCount > 0;
_threatPanel.style.display = showThreat ? DisplayStyle.Flex : DisplayStyle.None;
if (showThreat)
{
float intensity = Mathf.Clamp01(huskCount / 30f);
Color tc = siege ? Color.Lerp(ThreatWarm, BlightRed, intensity) : ThreatWarm;
_threatNum.text = huskCount.ToString();
_threatNum.style.color = tc;
_threatIcon.style.unityBackgroundImageTintColor = tc;
RetintPanel(_threatPanel, siege ? PanelWarm : PanelDark);
}
// ---- Build palette + control hints (bottom-center) ----
UpdatePalette(aether, ore, bio, onExpedition);
bool buildActive = BuildPaletteState.Active && !onExpedition && _paletteBuilt;
if (buildActive)
{
byte scheme = AimPresentation.Scheme;
bool conv = BuildPaletteState.Selected == StructureType.Conveyor;
if (!_hintBuilt || _hintScheme != scheme || _hintConveyor != conv) RebuildHints(scheme, conv);
if (conv && _facingArrow != null)
_facingArrow.style.rotate = new StyleRotate(new Rotate(new Angle(FacingDegrees(BuildPaletteState.Direction))));
_hintBar.style.display = DisplayStyle.Flex;
}
else
{
_hintBar.style.display = DisplayStyle.None;
}
// ---- Per-player vitals ----
bool found = false;
@@ -153,27 +259,71 @@ namespace ProjectM.Client
}
_doc.rootVisualElement.style.display = (found || haveCycle) ? DisplayStyle.Flex : DisplayStyle.None;
if (!found) { _downed.style.display = DisplayStyle.None; return; }
float frac = maxHp > 0f ? Mathf.Clamp01(hp / maxHp) : 0f;
HudUi.SetFill(_healthFill, frac);
_healthFill.style.backgroundColor = shielded
? new Color(0.45f, 0.85f, 1f)
: Color.Lerp(new Color(0.92f, 0.16f, 0.16f), new Color(0.25f, 0.9f, 0.5f), frac);
_healthText.text = Mathf.CeilToInt(Mathf.Max(0f, hp)) + " / " + Mathf.CeilToInt(maxHp) + (shielded ? " SHIELDED" : "");
// ---- Low-health vignette + hurt flash (full-screen) ----
_flash = HudVisualMath.DecayFlash(_flash, dt);
if (found)
{
float frac = maxHp > 0f ? Mathf.Clamp01(hp / maxHp) : 0f;
if (_haveHp && hp < _prevHp - 1f) _flash = HudVisualMath.HurtFlashKick;
_prevHp = hp; _haveHp = true;
HudUi.SetFill(_cooldownFill, cdFrac);
_threatText.text = "HUSKS " + huskCount;
_downed.style.display = dead ? DisplayStyle.Flex : DisplayStyle.None;
float vigOp = dead ? 0f : HudVisualMath.CombinedOpacity(frac, _flash);
_vignette.style.opacity = vigOp;
_vignette.style.display = vigOp > 0.001f ? DisplayStyle.Flex : DisplayStyle.None;
HudUi.SetFill(_healthFill, frac);
_healthFill.style.backgroundColor = shielded
? new Color(0.45f, 0.85f, 1f)
: Color.Lerp(new Color(0.92f, 0.16f, 0.16f), new Color(0.25f, 0.9f, 0.5f), frac);
_healthText.text = Mathf.CeilToInt(Mathf.Max(0f, hp)) + " / " + Mathf.CeilToInt(maxHp);
_shieldRow.style.display = shielded ? DisplayStyle.Flex : DisplayStyle.None;
HudUi.SetFill(_cooldownFill, cdFrac);
// A READY weapon (full bar) recedes; a CHARGING one is bright — so the inverted-vs-health polarity reads.
if (_cdRow != null) _cdRow.style.opacity = cdFrac >= 1f ? 0.4f : 1f;
_downed.style.display = dead ? DisplayStyle.Flex : DisplayStyle.None;
}
else
{
_haveHp = false; _flash = 0f;
_vignette.style.display = DisplayStyle.None;
_downed.style.display = DisplayStyle.None;
}
}
void UpdatePalette(int ore, bool onExpedition)
// ---- per-frame helpers ----
void RetintPanel(VisualElement p, Color c)
{
if (_themed) p.style.unityBackgroundImageTintColor = c;
else p.style.backgroundColor = c;
}
void SetPip(VisualElement pip, bool active)
{
var theme = HudTheme.Get();
var spr = active ? theme?.PipActive : theme?.PipInactive;
if (spr != null)
{
pip.style.backgroundImage = new StyleBackground(Background.FromSprite(spr));
pip.style.unityBackgroundImageTintColor = active ? AetherCyan : PipDim;
pip.style.backgroundSize = new StyleBackgroundSize(new BackgroundSize(BackgroundSizeType.Contain));
}
else
{
pip.style.backgroundColor = active ? AetherCyan : PipDim;
MenuUi.Round(pip, 3);
}
}
void UpdatePalette(int aether, int ore, int bio, bool onExpedition)
{
if (!_paletteBuilt && SystemAPI.TryGetSingletonEntity<StructureCatalog>(out var catE))
{
var cat = SystemAPI.GetBuffer<StructureCatalogEntry>(catE);
for (int i = 0; i < cat.Length; i++)
AddPaletteItem(cat[i].Type, cat[i].CostAmount);
AddPaletteItem(cat[i].Type, cat[i].CostAmount, cat[i].CostResourceId);
_paletteBuilt = true;
}
if (!_paletteBuilt) { _paletteRow.style.display = DisplayStyle.None; return; }
@@ -182,116 +332,419 @@ namespace ProjectM.Client
foreach (var kv in _palette)
{
var item = kv.Value;
bool affordable = ore >= item.CostAmount;
int have = item.CostRes == ResourceId.Aether ? aether : item.CostRes == ResourceId.Biomass ? bio : ore;
bool affordable = have >= item.CostAmount;
bool selected = BuildPaletteState.Selected == kv.Key;
item.Root.style.opacity = affordable ? 1f : 0.45f;
item.Root.style.opacity = affordable ? 1f : 0.5f;
item.Cost.style.color = affordable ? new Color(0.7f, 0.95f, 0.8f) : new Color(1f, 0.5f, 0.4f);
MenuUi.Border(item.Root, selected ? MenuUi.Accent : new Color(1f, 1f, 1f, 0.08f), selected ? 2 : 1);
item.Root.style.backgroundColor = selected
? new Color(0.16f, 0.26f, 0.32f, 0.95f)
: new Color(0.09f, 0.11f, 0.15f, 0.92f);
if (item.Icon != null)
item.Icon.style.unityBackgroundImageTintColor = affordable ? AetherCyan : new Color(0.5f, 0.55f, 0.6f);
MenuUi.Border(item.Root, selected ? MenuUi.Accent : SlotIdleBorder, selected ? 2 : 1);
item.Root.style.backgroundColor = selected ? SlotSelBg : SlotIdleBg;
if (item.Glow != null) item.Glow.style.opacity = selected ? 0.6f : 0f;
}
}
void AddPaletteItem(byte type, int cost)
void AddPaletteItem(byte type, int cost, byte costRes)
{
if (type == 0 || _palette.ContainsKey(type)) return;
var theme = HudTheme.Get();
var root = new VisualElement();
root.style.width = 92;
root.style.width = 86;
root.style.marginLeft = 4; root.style.marginRight = 4;
root.style.paddingTop = 6; root.style.paddingBottom = 6;
root.style.paddingTop = 8; root.style.paddingBottom = 6;
root.style.alignItems = Align.Center;
root.style.backgroundColor = new Color(0.09f, 0.11f, 0.15f, 0.92f);
root.style.backgroundColor = SlotIdleBg;
root.pickingMode = PickingMode.Position;
MenuUi.Round(root, 6);
MenuUi.Border(root, new Color(1f, 1f, 1f, 0.08f), 1);
MenuUi.Border(root, SlotIdleBorder, 1);
var nameLabel = HudUi.Text(StructureName(type), 13, MenuUi.TextCol, TextAnchor.MiddleCenter);
var costLabel = HudUi.Text(cost + " ORE", 12, new Color(0.7f, 0.95f, 0.8f), TextAnchor.MiddleCenter);
costLabel.style.marginTop = 2;
// selection glow: a soft Synty glow filling the slot behind everything, opacity toggled on select.
var glow = new VisualElement();
glow.style.position = Position.Absolute;
glow.style.left = 3; glow.style.right = 3; glow.style.top = 4; glow.style.bottom = 4;
glow.pickingMode = PickingMode.Ignore;
glow.style.opacity = 0f;
if (theme != null && theme.Glow != null)
{
glow.style.backgroundImage = new StyleBackground(Background.FromSprite(theme.Glow));
glow.style.unityBackgroundImageTintColor = AetherCyan;
glow.style.backgroundSize = new StyleBackgroundSize(new BackgroundSize(BackgroundSizeType.Cover));
}
root.Add(glow);
var iconEl = HudUi.Icon(theme != null ? theme.StructureIcon(type) : null, 44, AetherCyan);
root.Add(iconEl);
var nameLabel = HudUi.Text(StructureName(type), 12, MenuUi.TextCol, TextAnchor.MiddleCenter);
nameLabel.style.marginTop = 2;
root.Add(nameLabel);
root.Add(costLabel);
var costRow = new VisualElement();
costRow.style.flexDirection = FlexDirection.Row;
costRow.style.alignItems = Align.Center;
costRow.style.marginTop = 2;
costRow.pickingMode = PickingMode.Ignore;
var costIcon = HudUi.Icon(ResourceSprite(theme, costRes), 14, ResourceTint(costRes));
costIcon.style.marginRight = 3;
costRow.Add(costIcon);
var costLabel = HudUi.Display(cost.ToString(), 13, new Color(0.7f, 0.95f, 0.8f), TextAnchor.MiddleCenter);
costRow.Add(costLabel);
root.Add(costRow);
byte t = type;
root.RegisterCallback<ClickEvent>(_ =>
BuildPaletteState.Select(BuildPaletteState.Selected == t ? (byte)0 : t));
_paletteRow.Add(root);
_palette[type] = new PaletteItem { Root = root, Cost = costLabel, CostAmount = cost };
_palette[type] = new PaletteItem { Root = root, Cost = costLabel, CostAmount = cost, CostRes = costRes, Glow = glow, Icon = iconEl };
}
void RebuildHints(byte scheme, bool conveyor)
{
_hintBar.Clear();
_facingArrow = null;
var theme = HudTheme.Get();
bool pad = scheme == InputSchemeId.Gamepad;
AddHint(pad ? theme?.PadPlace : theme?.KbmPlace, pad ? "A" : "LMB", "PLACE");
AddHint(pad ? theme?.PadCancel : theme?.KbmCancel, pad ? "B" : "RMB", "CANCEL");
if (conveyor)
{
// Rotate hint + a LIVE facing arrow (resolves the DR-021 conveyor-facing indicator). Only conveyors
// rotate, so this chip is gated to them — the other buildables don't show a meaningless ROTATE.
var chip = MakeChip();
chip.Add(HudUi.Glyph(pad ? theme?.PadRotate : null, pad ? "LB" : "R", 26));
var lbl = HudUi.Text("FACING", 12, MenuUi.SubCol, TextAnchor.MiddleLeft);
lbl.style.marginLeft = 5; lbl.style.marginRight = 6;
chip.Add(lbl);
_facingArrow = HudUi.Icon(theme != null ? theme.ConveyorIcon : null, 24, AetherCyan);
chip.Add(_facingArrow);
_hintBar.Add(chip);
}
AddHint(pad ? theme?.PadExit : null, pad ? "MENU" : "ESC", "EXIT");
_hintScheme = scheme;
_hintConveyor = conveyor;
_hintBuilt = true;
}
VisualElement MakeChip()
{
var chip = new VisualElement();
chip.style.flexDirection = FlexDirection.Row;
chip.style.alignItems = Align.Center;
chip.style.marginLeft = 8; chip.style.marginRight = 8;
chip.pickingMode = PickingMode.Ignore;
return chip;
}
void AddHint(Sprite glyph, string fallback, string action)
{
var chip = MakeChip();
chip.Add(HudUi.Glyph(glyph, fallback, 26));
var lbl = HudUi.Text(action, 12, MenuUi.SubCol, TextAnchor.MiddleLeft);
lbl.style.marginLeft = 5;
chip.Add(lbl);
_hintBar.Add(chip);
}
// ---- UITK construction ----
void BuildTree(VisualElement root)
{
var theme = HudTheme.Get();
_themed = theme != null && theme.PanelBox != null;
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 game-world clicks
// Health + cooldown (bottom-left).
var vitals = HudUi.Group();
vitals.style.position = Position.Absolute;
vitals.style.left = 40; vitals.style.bottom = 40;
var cdBar = HudUi.Bar(440, 12, new Color(0.4f, 0.8f, 1f), out _cooldownFill);
cdBar.style.marginBottom = 6;
var hpBar = HudUi.Bar(440, 40, new Color(0.25f, 0.9f, 0.5f), out _healthFill);
_healthText = HudUi.Text("100 / 100", 22, Color.white, TextAnchor.MiddleCenter);
BuildVignette(root);
BuildVitals(root);
BuildThreat(root);
BuildMacro(root);
BuildResources(root);
BuildPaletteRow(root);
BuildHintBar(root);
BuildDowned(root);
}
void BuildVignette(VisualElement root)
{
_vignette = new VisualElement();
_vignette.style.position = Position.Absolute;
_vignette.style.left = 0; _vignette.style.right = 0; _vignette.style.top = 0; _vignette.style.bottom = 0;
_vignette.pickingMode = PickingMode.Ignore;
var theme = HudTheme.Get();
if (theme != null && theme.Vignette != null)
{
_vignette.style.backgroundImage = new StyleBackground(Background.FromSprite(theme.Vignette));
_vignette.style.unityBackgroundImageTintColor = BlightRed;
_vignette.style.backgroundSize = new StyleBackgroundSize(new BackgroundSize(BackgroundSizeType.Cover));
}
else
{
_vignette.style.backgroundColor = new Color(BlightRed.r, BlightRed.g, BlightRed.b, 1f);
}
_vignette.style.display = DisplayStyle.None;
root.Add(_vignette);
}
void BuildVitals(VisualElement root)
{
var panel = HudUi.Panel(PanelDark);
panel.style.position = Position.Absolute;
panel.style.left = 40; panel.style.bottom = 40;
panel.style.paddingLeft = 14; panel.style.paddingRight = 14;
panel.style.paddingTop = 12; panel.style.paddingBottom = 12;
panel.style.alignItems = Align.FlexStart;
var theme = HudTheme.Get();
// shield chip (shown only while the respawn shield is active)
_shieldRow = new VisualElement();
_shieldRow.style.flexDirection = FlexDirection.Row;
_shieldRow.style.alignItems = Align.Center;
_shieldRow.style.marginBottom = 6;
_shieldRow.pickingMode = PickingMode.Ignore;
var shieldIcon = HudUi.Icon(theme != null ? theme.ShieldIcon : null, 22, AetherCyan);
shieldIcon.style.marginRight = 6;
_shieldRow.Add(shieldIcon);
_shieldRow.Add(HudUi.Text("SHIELDED", 13, new Color(0.45f, 0.85f, 1f), TextAnchor.MiddleLeft));
_shieldRow.style.display = DisplayStyle.None;
panel.Add(_shieldRow);
// cooldown row: weapon icon + thin bar
_cdRow = new VisualElement();
_cdRow.style.flexDirection = FlexDirection.Row;
_cdRow.style.alignItems = Align.Center;
_cdRow.style.marginBottom = 6;
_cdRow.pickingMode = PickingMode.Ignore;
var cdIcon = HudUi.Icon(theme != null ? theme.CooldownIcon : null, 22, AetherCyan);
cdIcon.style.marginRight = 8;
_cdRow.Add(cdIcon);
var cdBar = HudUi.Bar(420, 12, new Color(0.4f, 0.8f, 1f), out _cooldownFill);
_cdRow.Add(cdBar);
panel.Add(_cdRow);
// health row: health icon + big bar with numeric overlay
var hpRow = new VisualElement();
hpRow.style.flexDirection = FlexDirection.Row;
hpRow.style.alignItems = Align.Center;
hpRow.pickingMode = PickingMode.Ignore;
var hpIcon = HudUi.Icon(theme != null ? theme.HealthIcon : null, 34, new Color(0.95f, 0.4f, 0.4f));
hpIcon.style.marginRight = 8;
hpRow.Add(hpIcon);
var hpBar = HudUi.Bar(420, 40, new Color(0.25f, 0.9f, 0.5f), out _healthFill);
_healthText = HudUi.Display("100 / 100", 24, Color.white, TextAnchor.MiddleCenter);
_healthText.style.position = Position.Absolute;
_healthText.style.left = 0; _healthText.style.right = 0; _healthText.style.top = 0; _healthText.style.bottom = 0;
hpBar.Add(_healthText);
vitals.Add(cdBar);
vitals.Add(hpBar);
root.Add(vitals);
hpRow.Add(hpBar);
panel.Add(hpRow);
// Threat (top-right).
_threatText = HudUi.Text("HUSKS 0", 30, new Color(1f, 0.62f, 0.4f), TextAnchor.UpperRight);
_threatText.style.position = Position.Absolute;
_threatText.style.right = 40; _threatText.style.top = 28; _threatText.style.width = 380;
root.Add(_threatText);
root.Add(panel);
}
// Macro stack (top-center).
void BuildThreat(VisualElement root)
{
_threatPanel = HudUi.Panel(PanelDark);
_threatPanel.style.position = Position.Absolute;
_threatPanel.style.right = 40; _threatPanel.style.top = 28;
_threatPanel.style.paddingLeft = 16; _threatPanel.style.paddingRight = 16;
_threatPanel.style.paddingTop = 8; _threatPanel.style.paddingBottom = 8;
_threatPanel.style.alignItems = Align.FlexEnd;
var theme = HudTheme.Get();
var row = new VisualElement();
row.style.flexDirection = FlexDirection.Row;
row.style.alignItems = Align.Center;
row.pickingMode = PickingMode.Ignore;
_threatIcon = HudUi.Icon(theme != null ? theme.ThreatIcon : null, 36, ThreatWarm);
_threatIcon.style.marginRight = 8;
row.Add(_threatIcon);
_threatNum = HudUi.Display("0", 34, ThreatWarm, TextAnchor.MiddleRight);
row.Add(_threatNum);
_threatPanel.Add(row);
var caption = HudUi.Text("HUSKS", 13, MenuUi.SubCol, TextAnchor.MiddleRight);
caption.style.letterSpacing = 4;
_threatPanel.Add(caption);
_threatPanel.style.display = DisplayStyle.None;
root.Add(_threatPanel);
}
void BuildMacro(VisualElement root)
{
var macro = HudUi.Group(Align.Center);
macro.style.position = Position.Absolute;
macro.style.top = 22; macro.style.left = 0; macro.style.right = 0;
_phaseText = HudUi.Text("", 30, new Color(0.55f, 0.9f, 1f), TextAnchor.MiddleCenter);
_resourceText = HudUi.Text("", 22, new Color(0.7f, 0.95f, 0.8f), TextAnchor.MiddleCenter);
_resourceText.style.marginTop = 4;
_locationText = HudUi.Text("", 18, new Color(0.6f, 0.85f, 1f), TextAnchor.MiddleCenter);
_locationText.style.marginTop = 4;
var goalBar = HudUi.Bar(360, 16, new Color(0.8f, 0.6f, 1f), out _goalFill);
goalBar.style.marginTop = 8;
_goalText = HudUi.Text("GOAL 0 / 10", 13, Color.white, TextAnchor.MiddleCenter);
_goalText.style.position = Position.Absolute;
_goalText.style.left = 0; _goalText.style.right = 0; _goalText.style.top = 0; _goalText.style.bottom = 0;
goalBar.Add(_goalText);
macro.Add(_phaseText);
macro.Add(_resourceText);
macro.Add(_locationText);
macro.Add(goalBar);
root.Add(macro);
macro.style.top = 16; macro.style.left = 0; macro.style.right = 0;
var theme = HudTheme.Get();
// Build palette row (bottom-center). The row passes clicks through; its buttons pick.
// banner: objective icon + phase line + cycle, phase-coloured underline
_banner = HudUi.Panel(PanelDark);
_banner.style.flexDirection = FlexDirection.Row;
_banner.style.alignItems = Align.Center;
_banner.style.paddingLeft = 22; _banner.style.paddingRight = 22;
_banner.style.paddingTop = 8; _banner.style.paddingBottom = 8;
_banner.style.borderBottomWidth = 2;
_banner.style.borderBottomColor = AetherCyan;
var bIcon = HudUi.Icon(theme != null ? theme.GoalIcon : null, 26, AetherCyan);
bIcon.style.marginRight = 10;
_banner.Add(bIcon);
_phaseText = HudUi.Display("", 30, AetherCyan, TextAnchor.MiddleCenter);
_banner.Add(_phaseText);
_cycleText = HudUi.Text("", 14, MenuUi.SubCol, TextAnchor.MiddleCenter);
_cycleText.style.marginLeft = 14;
_banner.Add(_cycleText);
macro.Add(_banner);
_locationText = HudUi.Text("", 15, new Color(0.6f, 0.85f, 1f), TextAnchor.MiddleCenter);
_locationText.style.marginTop = 5;
macro.Add(_locationText);
// goal: hex-pip meter (or fallback bar) + numeral
_goalContainer = HudUi.Group(Align.Center);
_goalContainer.style.marginTop = 8;
var goalLine = new VisualElement();
goalLine.style.flexDirection = FlexDirection.Row;
goalLine.style.alignItems = Align.Center;
goalLine.pickingMode = PickingMode.Ignore;
_goalPipsRow = new VisualElement();
_goalPipsRow.style.flexDirection = FlexDirection.Row;
_goalPipsRow.style.alignItems = Align.Center;
_goalPipsRow.pickingMode = PickingMode.Ignore;
for (int i = 0; i < MaxPips; i++)
{
var pip = new VisualElement();
pip.style.width = 22; pip.style.height = 22;
pip.style.marginLeft = 2; pip.style.marginRight = 2;
pip.style.flexShrink = 0;
pip.pickingMode = PickingMode.Ignore;
pip.style.display = DisplayStyle.None;
_pips.Add(pip);
_goalPipsRow.Add(pip);
}
goalLine.Add(_goalPipsRow);
_goalText = HudUi.Display("GOAL 0 / 10", 16, AetherCyan, TextAnchor.MiddleCenter);
_goalText.style.marginLeft = 10;
goalLine.Add(_goalText);
_goalContainer.Add(goalLine);
// fallback continuous bar (large targets)
_goalBar = HudUi.Bar(360, 16, new Color(0.8f, 0.6f, 1f), out _goalFill);
_goalBar.style.marginTop = 4;
_goalBar.style.display = DisplayStyle.None;
_goalContainer.Add(_goalBar);
macro.Add(_goalContainer);
root.Add(macro);
}
void BuildResources(VisualElement root)
{
var strip = HudUi.Panel(PanelDark);
strip.style.position = Position.Absolute;
strip.style.left = 40; strip.style.top = 28;
strip.style.flexDirection = FlexDirection.Row;
strip.style.alignItems = Align.Center;
strip.style.paddingLeft = 14; strip.style.paddingRight = 14;
strip.style.paddingTop = 8; strip.style.paddingBottom = 8;
var theme = HudTheme.Get();
strip.Add(ResourceChip(theme != null ? theme.AetherIcon : null, AetherCyan, "0", out _aetherNum, 26, 20));
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));
root.Add(strip);
}
VisualElement ResourceChip(Sprite icon, Color tint, string initial, out Label num, float iconSize, int fontSize)
{
var chip = new VisualElement();
chip.style.flexDirection = FlexDirection.Row;
chip.style.alignItems = Align.Center;
chip.style.marginLeft = 9; chip.style.marginRight = 9;
chip.pickingMode = PickingMode.Ignore;
var ic = HudUi.Icon(icon, iconSize, tint);
ic.style.marginRight = 6;
chip.Add(ic);
num = HudUi.Display(initial, fontSize, tint, TextAnchor.MiddleLeft);
chip.Add(num);
return chip;
}
void BuildPaletteRow(VisualElement root)
{
_paletteRow = new VisualElement();
_paletteRow.style.position = Position.Absolute;
_paletteRow.style.bottom = 24; _paletteRow.style.left = 0; _paletteRow.style.right = 0;
_paletteRow.style.flexDirection = FlexDirection.Row;
_paletteRow.style.justifyContent = Justify.Center;
_paletteRow.pickingMode = PickingMode.Ignore;
_paletteRow.pickingMode = PickingMode.Ignore; // the row passes clicks through; its slots pick
root.Add(_paletteRow);
}
// Downed overlay.
void BuildHintBar(VisualElement root)
{
_hintBar = new VisualElement();
_hintBar.style.position = Position.Absolute;
_hintBar.style.bottom = 138; _hintBar.style.left = 0; _hintBar.style.right = 0;
_hintBar.style.flexDirection = FlexDirection.Row;
_hintBar.style.justifyContent = Justify.Center;
_hintBar.pickingMode = PickingMode.Ignore;
_hintBar.style.display = DisplayStyle.None;
root.Add(_hintBar);
}
void BuildDowned(VisualElement root)
{
_downed = new VisualElement();
_downed.style.position = Position.Absolute;
_downed.style.left = 0; _downed.style.right = 0; _downed.style.top = 0; _downed.style.bottom = 0;
_downed.style.backgroundColor = new Color(0.35f, 0f, 0f, 0.35f);
_downed.style.alignItems = Align.Center;
_downed.style.justifyContent = Justify.Center;
_downed.pickingMode = PickingMode.Ignore;
_downed.Add(HudUi.Text("DOWNED - RESPAWNING", 52, new Color(1f, 0.45f, 0.4f), TextAnchor.MiddleCenter));
var theme = HudTheme.Get();
if (theme != null && theme.Vignette != null)
{
_downed.style.backgroundImage = new StyleBackground(Background.FromSprite(theme.Vignette));
_downed.style.unityBackgroundImageTintColor = new Color(0.45f, 0f, 0f, 0.6f);
_downed.style.backgroundSize = new StyleBackgroundSize(new BackgroundSize(BackgroundSizeType.Cover));
}
else
{
_downed.style.backgroundColor = new Color(0.35f, 0f, 0f, 0.35f);
}
_downed.Add(HudUi.Display("DOWNED - RESPAWNING", 52, new Color(1f, 0.45f, 0.4f), TextAnchor.MiddleCenter));
_downed.style.display = DisplayStyle.None;
root.Add(_downed);
}
static Color ResourceTint(byte resId)
=> resId == ResourceId.Aether ? AetherCyan : resId == ResourceId.Biomass ? BioGreen : OreAmber;
static Sprite ResourceSprite(HudTheme t, byte resId)
{
if (t == null) return null;
return resId == ResourceId.Aether ? t.AetherIcon : resId == ResourceId.Biomass ? t.BioIcon : t.OreIcon;
}
// Conveyor facing (BuildPaletteState.Direction 0=+X,1=-X,2=+Z,3=-Z) → arrow rotation; the arrow art points up (+Z).
static float FacingDegrees(byte dir)
{
switch (dir)
{
case 0: return 90f; // +X
case 1: return 270f; // -X
case 3: return 180f; // -Z
default: return 0f; // +Z
}
}
static Color PhaseColor(byte phase)
{
switch (phase)
@@ -0,0 +1,128 @@
using ProjectM.Simulation;
using UnityEngine;
using UnityEngine.TextCore.Text;
using UnityEngine.UIElements;
namespace ProjectM.Client
{
/// <summary>
/// Curated Synty sci-fi-soldier HUD skin — serialized <see cref="Sprite"/> + <see cref="Font"/> references
/// harvested from the InterfaceSciFiSoldierHUD / InterfaceCore packs and authored into
/// <c>Assets/_Project/Resources/HudTheme.asset</c>. Those source sprites/fonts live under
/// <c>Assets/Synty/…</c> (NOT a Resources folder), so a serialized asset like this is the build-safe way to
/// pull a curated subset into the player build (the dependency walker follows the refs). Loaded null-safe via
/// <see cref="Get"/> (mirrors <see cref="MenuUi.LoadPanelSettings"/>); EVERY consumer must null-check the theme
/// AND each field and fall back to the flat-color HUD, so a missing asset/ref never breaks (or magenta-s) the
/// HUD. Fonts are applied as cached SDF font definitions: a <see cref="FontAsset"/> is built once per font from
/// the serialized <see cref="Font"/> (crisp at any size, supports outlines) and re-built per play session (the
/// static cache is reset on play-enter, like the project's other static presentation bridges).
/// </summary>
[CreateAssetMenu(menuName = "ProjectM/HUD Theme", fileName = "HudTheme")]
public class HudTheme : ScriptableObject
{
[Header("Fonts")]
public Font DisplayFont; // Orbitron-ExtraBold — numerics + phase words (the HUD's authoritative voice)
public Font BodyFont; // Exo 2.0 SemiBold — labels
public Font BodyLightFont; // Exo 2.0 Regular — captions / hints
[Header("Panels & meters")]
public Sprite PanelBox; // 9-sliced card background (tinted per cluster)
public Sprite BarTrack; // bar fill skin
public Sprite Vignette; // radial edge gradient (low-HP / hurt flash / downed)
public Sprite Glow; // soft glow accent (selection / pulse)
public Sprite PipActive; // filled hex pip (goal meter)
public Sprite PipInactive; // empty hex pip
[Header("Status / info icons")]
public Sprite HealthIcon;
public Sprite ShieldIcon;
public Sprite ThreatIcon;
public Sprite GoalIcon;
public Sprite CooldownIcon;
public Sprite LocationBaseIcon;
public Sprite LocationExpeditionIcon;
[Header("Resource icons")]
public Sprite AetherIcon;
public Sprite OreIcon;
public Sprite BioIcon;
[Header("Structure icons")]
public Sprite TurretIcon;
public Sprite WallIcon;
public Sprite PylonIcon;
public Sprite HarvesterIcon;
public Sprite FabricatorIcon;
public Sprite ConveyorIcon;
[Header("Build-mode control glyphs")]
public Sprite KbmPlace; // LMB
public Sprite KbmCancel; // RMB
public Sprite PadPlace; // gamepad A / south
public Sprite PadCancel; // gamepad B / east
public Sprite PadRotate; // gamepad LB
public Sprite PadExit; // gamepad Menu / start
// ---- null-safe load + cache (mirrors MenuUi.LoadPanelSettings Resources idiom) ----
static HudTheme _cached;
static bool _tried;
/// <summary>The loaded theme, or null if the asset is missing. Callers MUST null-check this and each field.</summary>
public static HudTheme Get()
{
if (_tried) return _cached;
_tried = true;
_cached = Resources.Load<HudTheme>("HudTheme");
return _cached;
}
/// <summary>Icon for a <see cref="StructureType"/> byte (null → caller falls back to the structure name text).</summary>
public Sprite StructureIcon(byte type)
{
switch (type)
{
case StructureType.Turret: return TurretIcon;
case StructureType.Wall: return WallIcon;
case StructureType.Pylon: return PylonIcon;
case StructureType.Harvester: return HarvesterIcon;
case StructureType.Fabricator: return FabricatorIcon;
case StructureType.Conveyor: return ConveyorIcon;
default: return null;
}
}
// ---- cached SDF font definitions (one FontAsset per font, built once, reset per play session) ----
static FontAsset _displayFa, _bodyFa, _bodyLightFa;
static bool _displayTried, _bodyTried, _bodyLightTried;
/// <summary>Apply the display font (Orbitron) to a style, if available. No-op otherwise (stock font).</summary>
public void ApplyDisplay(IStyle style) => Apply(style, DisplayFont, ref _displayFa, ref _displayTried);
/// <summary>Apply the body font (Exo 2.0 SemiBold) to a style, if available.</summary>
public void ApplyBody(IStyle style) => Apply(style, BodyFont, ref _bodyFa, ref _bodyTried);
/// <summary>Apply the light body font (Exo 2.0 Regular) to a style, if available.</summary>
public void ApplyBodyLight(IStyle style) => Apply(style, BodyLightFont, ref _bodyLightFa, ref _bodyLightTried);
static void Apply(IStyle style, Font font, ref FontAsset fa, ref bool tried)
{
if (!tried)
{
tried = true;
if (font != null) fa = FontAsset.CreateFontAsset(font); // dynamic SDF atlas, built once per session
}
if (fa != null)
style.unityFontDefinition = new StyleFontDefinition(FontDefinition.FromSDFFont(fa));
}
// Fast-enter-playmode keeps statics alive across sessions; the runtime FontAssets are destroyed on play-exit,
// so a stale cache would reference a destroyed atlas. Reset everything so it re-loads/re-builds per session.
[RuntimeInitializeOnLoadMethod(RuntimeInitializeLoadType.SubsystemRegistration)]
static void ResetStatics()
{
_cached = null; _tried = false;
_displayFa = _bodyFa = _bodyLightFa = null;
_displayTried = _bodyTried = _bodyLightTried = false;
}
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 5b0ba3a2a48e57547b89018d8aba1134
+136 -28
View File
@@ -4,41 +4,39 @@ using UnityEngine.UIElements;
namespace ProjectM.Client
{
/// <summary>
/// UI Toolkit factories for the in-game HUD — a thin extension of <see cref="MenuUi"/>'s Aether palette so the
/// HUD reads in the same visual language as the menu / pause / settings screens. Bars are a dark rounded track
/// + a percent-width fill; labels use the shared text weights/colours. Every element is built
/// <c>pickingMode = Ignore</c> by default so the HUD never eats clicks meant for the game world (only the
/// interactive build-palette buttons opt back into picking).
/// UI Toolkit factories for the in-game HUD — a thin extension of <see cref="MenuUi"/>'s Aether palette,
/// now skinned with the curated Synty sci-fi-soldier kit (<see cref="HudTheme"/>). Panels are 9-sliced Synty
/// boxes tinted into the Aether palette; bars are a skinned track + a percent-width fill; numerics use the
/// Orbitron display font and labels the Exo 2.0 body font. EVERYTHING is null-safe: if <see cref="HudTheme"/>
/// (or a given sprite/font) is missing, each factory falls back to the original flat-colour look, so the HUD
/// is shippable with or without the theme asset. Every element is <c>pickingMode = Ignore</c> by default so
/// the HUD never eats clicks meant for the game world (only the interactive build-palette slots opt back in).
/// </summary>
public static class HudUi
{
public static readonly Color Track = new(0f, 0f, 0f, 0.55f);
/// <summary>A dark rounded bar track with a percent-width fill child (returned via <paramref name="fill"/>).</summary>
public static VisualElement Bar(float width, float height, Color fillColor, out VisualElement fill)
{
var track = new VisualElement();
track.style.width = width;
track.style.height = height;
track.style.backgroundColor = Track;
track.style.paddingLeft = 2; track.style.paddingRight = 2;
track.style.paddingTop = 2; track.style.paddingBottom = 2;
track.style.flexDirection = FlexDirection.Row;
track.pickingMode = PickingMode.Ignore;
MenuUi.Round(track, 4);
// ---- text (Orbitron display vs Exo body, theme-driven with a bold fallback) ----
fill = new VisualElement();
fill.style.height = Length.Percent(100);
fill.style.width = Length.Percent(100);
fill.style.backgroundColor = fillColor;
fill.pickingMode = PickingMode.Ignore;
MenuUi.Round(fill, 3);
track.Add(fill);
return track;
/// <summary>A body label (Exo 2.0 when themed) — labels, captions, hints.</summary>
public static Label Text(string text, int size, Color color, TextAnchor align)
{
var l = MakeLabel(text, size, color, align);
var theme = HudTheme.Get();
if (theme != null) theme.ApplyBody(l.style);
return l;
}
/// <summary>A bold HUD label (non-interactive).</summary>
public static Label Text(string text, int size, Color color, TextAnchor align)
/// <summary>A display label (Orbitron when themed) — numerics + phase words, the authoritative HUD voice.</summary>
public static Label Display(string text, int size, Color color, TextAnchor align)
{
var l = MakeLabel(text, size, color, align);
var theme = HudTheme.Get();
if (theme != null) theme.ApplyDisplay(l.style);
return l;
}
static Label MakeLabel(string text, int size, Color color, TextAnchor align)
{
var l = new Label(text);
l.style.fontSize = size;
@@ -49,13 +47,54 @@ namespace ProjectM.Client
return l;
}
// ---- bars ----
/// <summary>A dark rounded bar track with a percent-width fill child (returned via <paramref name="fill"/>).</summary>
public static VisualElement Bar(float width, float height, Color fillColor, out VisualElement fill)
{
var theme = HudTheme.Get();
var trackSpr = theme != null ? theme.BarTrack : null;
var track = new VisualElement();
track.style.width = width;
track.style.height = height;
track.style.paddingLeft = 2; track.style.paddingRight = 2;
track.style.paddingTop = 2; track.style.paddingBottom = 2;
track.style.flexDirection = FlexDirection.Row;
track.pickingMode = PickingMode.Ignore;
fill = new VisualElement();
fill.style.height = Length.Percent(100);
fill.style.width = Length.Percent(100);
fill.style.backgroundColor = fillColor;
fill.pickingMode = PickingMode.Ignore;
MenuUi.Round(fill, 3);
if (trackSpr != null)
{
// Bar_Angled ships an 80/0 border → UITK 9-slices it horizontally from the art; no style override.
track.style.backgroundImage = new StyleBackground(Background.FromSprite(trackSpr));
track.style.unityBackgroundImageTintColor = new Color(0.04f, 0.06f, 0.09f, 0.92f);
}
else
{
track.style.backgroundColor = Track;
MenuUi.Round(track, 4);
}
track.Add(fill);
return track;
}
/// <summary>Set a fill's width to a 0..1 fraction of its track.</summary>
public static void SetFill(VisualElement fill, float frac)
{
if (fill != null) fill.style.width = Length.Percent(Mathf.Clamp01(frac) * 100f);
}
/// <summary>A semi-transparent rounded panel for grouping a cluster of HUD elements.</summary>
// ---- panels / icons / glyphs ----
/// <summary>A grouping container (no background). Transparent, click-through.</summary>
public static VisualElement Group(Align items = Align.FlexStart)
{
var g = new VisualElement();
@@ -64,5 +103,74 @@ namespace ProjectM.Client
return g;
}
/// <summary>
/// A 9-sliced Synty panel tinted into the Aether palette; falls back to a flat translucent rounded panel
/// when the theme/sprite is missing. <paramref name="tint"/> multiplies the (light-grey) skin, so a dark
/// tint reads as panel-dark while preserving the printed bevels.
/// </summary>
public static VisualElement Panel(Color tint)
{
var p = new VisualElement();
p.pickingMode = PickingMode.Ignore;
var theme = HudTheme.Get();
var box = theme != null ? theme.PanelBox : null;
if (box != null)
{
p.style.backgroundImage = new StyleBackground(Background.FromSprite(box));
p.style.unityBackgroundImageTintColor = tint;
// 9-slice uses the sprite's AUTHORED border (Box_Glass ships 25px); no style override → no
// "borders overridden by style slices" log, and the art's intended corners are preserved.
}
else
{
p.style.backgroundColor = tint;
MenuUi.Round(p, 8);
MenuUi.Border(p, new Color(1f, 1f, 1f, 0.08f), 1);
}
return p;
}
/// <summary>A fixed-size icon element backed by a Synty sprite (tinted). Returns an empty element if null.</summary>
public static VisualElement Icon(Sprite sprite, float size, Color tint)
{
var e = new VisualElement();
e.style.width = size; e.style.height = size;
e.style.flexShrink = 0;
e.pickingMode = PickingMode.Ignore;
if (sprite != null)
{
e.style.backgroundImage = new StyleBackground(Background.FromSprite(sprite));
e.style.unityBackgroundImageTintColor = tint;
e.style.backgroundSize = new StyleBackgroundSize(new BackgroundSize(BackgroundSizeType.Contain));
}
return e;
}
/// <summary>True when the theme is loaded and a sprite is present (callers choose icon vs text fallback).</summary>
/// <summary>
/// An input-prompt chip: the Synty key/button glyph (left untinted to keep its printed face) when present,
/// else a bordered text keycap with <paramref name="fallbackText"/> (e.g. "R", "Esc").
/// </summary>
public static VisualElement Glyph(Sprite sprite, string fallbackText, float size)
{
if (sprite != null) return Icon(sprite, size, Color.white);
var cap = new VisualElement();
cap.style.minWidth = size; cap.style.height = size;
cap.style.paddingLeft = 7; cap.style.paddingRight = 7;
cap.style.alignItems = Align.Center; cap.style.justifyContent = Justify.Center;
cap.style.backgroundColor = new Color(0.14f, 0.17f, 0.22f, 0.95f);
cap.style.flexShrink = 0;
cap.pickingMode = PickingMode.Ignore;
MenuUi.Round(cap, 4);
MenuUi.Border(cap, new Color(1f, 1f, 1f, 0.25f), 1);
cap.Add(Text(fallbackText, Mathf.Max(10, (int)(size * 0.5f)), MenuUi.TextCol, TextAnchor.MiddleCenter));
return cap;
}
/// <summary>Apply a uniform 9-slice (horizontal / vertical source-texture px) to a skinned element.</summary>
}
}
@@ -0,0 +1,45 @@
using UnityEngine;
namespace ProjectM.Client
{
/// <summary>
/// Pure HUD presentation math (unit-tested, no ECS / no UnityEngine.Object). Drives the low-health screen
/// vignette + the transient hurt-flash so the damage feedback is deterministic and testable. Used by the
/// client-only observe-only <see cref="HudSystem"/> in PresentationSystemGroup; the flash decay runs on the
/// wall-frame <c>SystemAPI.Time.DeltaTime</c>, which is correct in a presentation system (the dt-trap only
/// applies to plain simulation systems).
/// </summary>
public static class HudVisualMath
{
/// <summary>Below this health fraction the low-health vignette starts to ramp in.</summary>
public const float LowHealthThreshold = 0.35f;
/// <summary>Maximum steady vignette opacity at 0 HP.</summary>
public const float MaxVignetteOpacity = 0.55f;
/// <summary>Opacity boost added by a fresh hit (a hurt flash), decaying back to the steady vignette.</summary>
public const float HurtFlashKick = 0.40f;
/// <summary>Seconds for a full hurt-flash kick to decay to zero.</summary>
public const float HurtFlashDuration = 0.40f;
/// <summary>0 at/above the low-health threshold, ramping to 1 as the health fraction falls to 0.</summary>
public static float VignetteIntensity(float healthFrac)
{
float f = Mathf.Clamp01(healthFrac);
if (f >= LowHealthThreshold) return 0f;
return Mathf.Clamp01((LowHealthThreshold - f) / LowHealthThreshold);
}
/// <summary>Steady low-health vignette opacity (no flash) from the current health fraction.</summary>
public static float VignetteOpacity(float healthFrac) => VignetteIntensity(healthFrac) * MaxVignetteOpacity;
/// <summary>Decay an active hurt-flash boost toward 0 over <paramref name="dt"/> seconds (clamped, never negative).</summary>
public static float DecayFlash(float flash, float dt)
=> Mathf.Max(0f, flash - (HurtFlashKick / HurtFlashDuration) * Mathf.Max(0f, dt));
/// <summary>Final overlay opacity: the steady low-health vignette plus the current flash boost, clamped to 1.</summary>
public static float CombinedOpacity(float healthFrac, float flash)
=> Mathf.Clamp01(VignetteOpacity(healthFrac) + Mathf.Clamp01(flash));
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 01204e46e6631634fa1007f4b3640035
@@ -66,6 +66,8 @@ namespace ProjectM.Client
l.style.color = Accent;
l.style.unityTextAlign = TextAnchor.MiddleCenter;
l.style.marginBottom = 16;
var theme = HudTheme.Get();
if (theme != null) theme.ApplyDisplay(l.style);
return l;
}
@@ -76,6 +78,8 @@ namespace ProjectM.Client
l.style.color = SubCol;
l.style.unityTextAlign = TextAnchor.MiddleCenter;
l.style.marginTop = 8; l.style.marginBottom = 6;
var theme = HudTheme.Get();
if (theme != null) theme.ApplyBodyLight(l.style);
return l;
}
@@ -90,6 +94,8 @@ namespace ProjectM.Client
b.style.unityFontStyleAndWeight = FontStyle.Bold;
Round(b, 6);
Border(b, new Color(0f, 0f, 0f, 0f), 0);
var theme = HudTheme.Get();
if (theme != null) theme.ApplyBody(b.style);
return b;
}
@@ -0,0 +1,70 @@
using NUnit.Framework;
using ProjectM.Client;
namespace ProjectM.Tests
{
/// <summary>
/// Pure tests for <see cref="HudVisualMath"/> — the low-health screen vignette ramp + hurt-flash decay that
/// drive the HUD damage overlay. Verifies the curve is 0 above the threshold, 1 at empty, monotonic between,
/// and that the flash decays to zero without going negative.
/// </summary>
public class HudVisualMathTests
{
const float Eps = 1e-4f;
[Test]
public void Vignette_ZeroAtFullHealth_AndAtThreshold()
{
Assert.AreEqual(0f, HudVisualMath.VignetteIntensity(1f), Eps);
Assert.AreEqual(0f, HudVisualMath.VignetteIntensity(HudVisualMath.LowHealthThreshold), Eps);
Assert.AreEqual(0f, HudVisualMath.VignetteIntensity(0.9f), Eps);
}
[Test]
public void Vignette_OneAtEmpty_HalfAtMidband()
{
Assert.AreEqual(1f, HudVisualMath.VignetteIntensity(0f), Eps);
// Half of the [0, threshold] band → ~0.5 intensity.
Assert.AreEqual(0.5f, HudVisualMath.VignetteIntensity(HudVisualMath.LowHealthThreshold * 0.5f), Eps);
}
[Test]
public void Vignette_IsMonotonicDecreasingInHealth()
{
float prev = HudVisualMath.VignetteIntensity(0f);
for (float f = 0.05f; f <= 0.4f; f += 0.05f)
{
float cur = HudVisualMath.VignetteIntensity(f);
Assert.LessOrEqual(cur, prev + Eps, "intensity must not rise as health rises");
prev = cur;
}
}
[Test]
public void VignetteOpacity_ScalesByMax()
{
Assert.AreEqual(HudVisualMath.MaxVignetteOpacity, HudVisualMath.VignetteOpacity(0f), Eps);
Assert.AreEqual(0f, HudVisualMath.VignetteOpacity(1f), Eps);
}
[Test]
public void Flash_DecaysToZero_NeverNegative()
{
Assert.AreEqual(0f, HudVisualMath.DecayFlash(HudVisualMath.HurtFlashKick, HudVisualMath.HurtFlashDuration), Eps);
// Half the duration → roughly half the kick remains.
Assert.AreEqual(HudVisualMath.HurtFlashKick * 0.5f,
HudVisualMath.DecayFlash(HudVisualMath.HurtFlashKick, HudVisualMath.HurtFlashDuration * 0.5f), Eps);
Assert.AreEqual(0f, HudVisualMath.DecayFlash(0.1f, 10f), Eps, "over-long dt clamps to 0, not negative");
}
[Test]
public void CombinedOpacity_ClampsToOne()
{
Assert.AreEqual(1f, HudVisualMath.CombinedOpacity(0f, 1f), Eps);
Assert.AreEqual(0f, HudVisualMath.CombinedOpacity(1f, 0f), Eps);
float mid = HudVisualMath.CombinedOpacity(0.175f, 0.2f);
Assert.Greater(mid, 0f);
Assert.LessOrEqual(mid, 1f);
}
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: c15dadef04ec9f543a9fc9926bdf2b0f