LANTERN purge: delete the superseded base/expedition shell (audit H1/H3/M5)

The 2026-08-06 audit found the shipping scene was still the abandoned
co-op-Hades game with LANTERN combat bolted on, and that a third of the
codebase was live code for a direction abandoned on 2026-07-13. Operator
chose deletion over freezing: "everything is saved in source control if
needed. I want the project to be clean."

DELETED (~140 source files, Scripts 335->231, Tests 77->43):
- Enemy variants + boss (H3). ChargerAuthoring / SpitterAuthoring /
  SwarmerAuthoring were attached to ZERO prefabs, so LungeState /
  SpitterState / SwarmerTag were never baked: ~272 lines of Bursted AI
  passes, BossAISystem (261 lines) and the whole MixBands escalation
  curve could not match a single chunk at runtime, while 734 lines of
  green tests certified them. Both shipping enemy prefabs were already
  byte-identical in stats.
- Run/room lifecycle: RunDirector FSM, RunInfo/RunMap/RoomPlan/RoomTag,
  route select, portal interact, ready-check, room field/teardown.
- Meta shop, prep loadout, boons (incl. KillRewardSystem and
  DashTrailDamageSystem, which existed only to serve boon flags).
- Build palette + structures, shared storage, inventory/equipment
  (already recorded PAUSED in CLAUDE.md).
- The HUD panels driving all of the above (HudSystem 1168 -> 610).

KEPT deliberately: BaseGridMath + BaseAnchor (8 systems use PlotCenter
for spawn rings, respawn and dynamic light), the resource ledger +
StorageMath, the save system, region/relevancy. Three of these were in
the delete set until I checked their consumers — worth remembering that
the file-level manifest was wrong about them.

Also folds in audit finding M5: PlayerClass was a second, server-only
copy of the byte FrameId already replicates. It existed for the meta
shop; with that gone, FrameId is the single frame identity.

Harvest is now single-sink (ledger). HarvestMath keeps its shape so
LANTERN's carried-vs-banked cargo split lands in one place, not two.

295/295 EditMode green, zero compile errors. Subscene re-bake and Play
validation follow in the next commit.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-08-07 12:59:39 -07:00
parent 6a412fe3e7
commit 62e48a3b0b
304 changed files with 260 additions and 14591 deletions
@@ -1,39 +0,0 @@
using ProjectM.Simulation;
using Unity.Entities;
using UnityEngine;
namespace ProjectM.Authoring
{
/// <summary>
/// Baker helpers shared by the build-structure / automation-machine bakers. These emit the SAME component data
/// the bakers wrote inline before (no serialized authoring field or baked component change) — they only
/// deduplicate the repeated <see cref="PlacedStructure"/> stamp and the damageable triad
/// (<see cref="Health"/> + <see cref="DamageEvent"/> buffer + <see cref="Destructible"/>). Extension methods on
/// the concrete <see cref="Baker{TAuthoringType}"/> (not IBaker, whose AddComponent/AddBuffer are obsolete) so
/// the non-obsolete public Baker API resolves.
/// </summary>
public static class BakerStructureExt
{
/// <summary>Stamp PlacedStructure{Type=type} with the standard baked defaults (Cell/NextTick/LastProcessedTick set at placement).</summary>
public static void AddPlacedStructure<TAuthoring>(this Baker<TAuthoring> baker, Entity e, byte type)
where TAuthoring : Component
{
baker.AddComponent(e, new PlacedStructure
{
Type = type,
Cell = default,
NextTick = 0u,
LastProcessedTick = 0u,
});
}
/// <summary>Make an entity damageable/destructible: Health{Current=Max=maxHp} + the required DamageEvent buffer + Destructible tag.</summary>
public static void AddDamageable<TAuthoring>(this Baker<TAuthoring> baker, Entity e, float maxHp)
where TAuthoring : Component
{
baker.AddComponent(e, new Health { Current = maxHp, Max = maxHp });
baker.AddBuffer<DamageEvent>(e);
baker.AddComponent<Destructible>(e);
}
}
}
@@ -1,2 +0,0 @@
fileFormatVersion: 2
guid: a54e2710fbe8cca4a9e84e9bbca4f7b9
@@ -1,35 +0,0 @@
using ProjectM.Simulation;
using Unity.Entities;
using UnityEngine;
namespace ProjectM.Authoring
{
/// <summary>
/// Generic authoring for a non-functional build-structure ghost prefab (Wall / Pylon) — duplicate
/// Turret.prefab so the ownerless interpolated GhostAuthoringComponent on PlacedStructure.Type comes free,
/// then swap TurretAuthoring for this. Bakes ONLY <see cref="PlacedStructure"/>{Type=<see cref="Kind"/>}
/// (no <see cref="Turret"/> stats, so TurretFireSystem ignores it). BuildPlaceSystem overrides Cell +
/// LastProcessedTick and adds RegionTag{Base} at placement. <see cref="Kind"/> is a byte (StructureType.*) to
/// dodge the cross-assembly enum-in-Burst hazard and the MCP enum-drop gotcha.
/// </summary>
public class StructureAuthoring : MonoBehaviour
{
[Tooltip("StructureType byte: 5 = Wall, 6 = Pylon (do NOT use 1-4: Turret + reserved M7 automation).")]
public byte Kind = StructureType.Wall;
[Min(1f)] public float MaxHp = 150f;
private class StructureBaker : Baker<StructureAuthoring>
{
public override void Bake(StructureAuthoring authoring)
{
var entity = GetEntity(authoring, TransformUsageFlags.Dynamic);
this.AddPlacedStructure(entity, authoring.Kind);
// EB-1: Wall/Pylon are damageable + destructible AI targets (a wall soaks Husk strikes that would
// otherwise hit a turret). DamageEvent buffer MUST exist or an AI strike crashes at ECB playback.
// No HitRadius -> ProjectileDamageSystem ignores them (no friendly projectile fire).
this.AddDamageable(entity, authoring.MaxHp);
}
}
}
}
@@ -1,2 +0,0 @@
fileFormatVersion: 2
guid: 3f03349205fb1fe43bf6aaff14fce0b7
@@ -1,60 +0,0 @@
using ProjectM.Simulation;
using Unity.Entities;
using UnityEngine;
using UnityEngine.Serialization;
namespace ProjectM.Authoring
{
/// <summary>
/// Authoring for the baked <see cref="StructureCatalog"/> singleton (the build cost/prefab table). Flat
/// prefab + cost fields per buildable type (the type + cost-resource are byte consts in the baker —
/// enum-via-MCP is unreliable, and bytes dodge the cross-assembly enum-in-Burst hazard); the runtime
/// <see cref="StructureCatalogEntry"/> buffer is the data-driven shape. Place once in the gameplay subscene.
/// </summary>
public class StructureCatalogAuthoring : MonoBehaviour
{
[Tooltip("Wall structure ghost prefab (StructureAuthoring{Wall} + GhostAuthoring).")]
public GameObject WallPrefab;
[Tooltip("Biomass cost to build a wall.")]
[Min(0)] [FormerlySerializedAs("WallCostOre")] public int WallCostBiomass = 4;
[Tooltip("Pylon cosmetic-beacon ghost prefab (StructureAuthoring{Pylon} + GhostAuthoring).")]
public GameObject PylonPrefab;
[Tooltip("Ore cost to build a pylon.")]
[Min(0)] public int PylonCostOre = 2;
private class StructureCatalogBaker : Baker<StructureCatalogAuthoring>
{
public override void Bake(StructureCatalogAuthoring authoring)
{
var entity = GetEntity(authoring, TransformUsageFlags.None);
AddComponent<StructureCatalog>(entity);
var buf = AddBuffer<StructureCatalogEntry>(entity);
if (authoring.WallPrefab != null)
{
buf.Add(new StructureCatalogEntry
{
Type = StructureType.Wall,
Prefab = GetEntity(authoring.WallPrefab, TransformUsageFlags.Dynamic),
CostResourceId = ResourceId.Biomass, // DR-042 C6b: walls cost Biomass (the dead currency's only sink)
CostAmount = authoring.WallCostBiomass,
});
}
if (authoring.PylonPrefab != null)
{
buf.Add(new StructureCatalogEntry
{
Type = StructureType.Pylon,
Prefab = GetEntity(authoring.PylonPrefab, TransformUsageFlags.Dynamic),
CostResourceId = ResourceId.Ore,
CostAmount = authoring.PylonCostOre,
});
}
}
}
}
}
@@ -1,2 +0,0 @@
fileFormatVersion: 2
guid: 40093ed42072f5a4889f5f62f510aa27
@@ -1,77 +0,0 @@
using System;
using System.Collections.Generic;
using ProjectM.Simulation;
using Unity.Collections;
using Unity.Entities;
using UnityEngine;
namespace ProjectM.Authoring
{
/// <summary>
/// Authoring for the boon-catalog config singleton (place ONE in the gameplay subscene). Designers can author
/// rows in the inspector; an EMPTY list bakes the code-default v1 table (<see cref="BoonCatalogData.BuildDefault"/>)
/// verbatim — so the subscene object needs zero property assignment through the tooling (the
/// component_properties enum/array-drop hazard). Ids are APPEND-ONLY (they ride the replicated BoonOffer bytes
/// and, later, per-run analytics).
/// </summary>
public class BoonCatalogAuthoring : MonoBehaviour
{
[Serializable]
public struct BoonRow
{
public byte Id;
public StatTarget Target;
public ModOp Op;
public float Value;
[Tooltip("Rarity draw weight: common 100 / rare 30 / epic 10. 0 removes the boon from the pool.")]
public byte Weight;
[Tooltip("bit0 = Warrior, bit1 = Ranger, 3 = both.")]
public byte ClassMask;
public string Name;
public string Desc;
}
[Tooltip("Leave EMPTY to bake the code-default v1 table; fill to fully replace it.")]
public List<BoonRow> Rows = new List<BoonRow>();
private class BoonCatalogBaker : Baker<BoonCatalogAuthoring>
{
public override void Bake(BoonCatalogAuthoring authoring)
{
var entity = GetEntity(authoring, TransformUsageFlags.None);
BlobAssetReference<BoonCatalogBlob> blob;
if (authoring.Rows == null || authoring.Rows.Count == 0)
{
blob = BoonCatalogData.BuildDefault();
}
else
{
var builder = new BlobBuilder(Allocator.Temp);
ref var root = ref builder.ConstructRoot<BoonCatalogBlob>();
var defs = builder.Allocate(ref root.Defs, authoring.Rows.Count);
for (int i = 0; i < authoring.Rows.Count; i++)
{
var r = authoring.Rows[i];
defs[i] = new BoonDefBlob
{
Id = r.Id,
Target = (byte)r.Target,
Op = (byte)r.Op,
Value = r.Value,
Weight = r.Weight,
ClassMask = r.ClassMask,
Name = new FixedString64Bytes(r.Name ?? string.Empty),
Desc = new FixedString128Bytes(r.Desc ?? string.Empty),
};
}
blob = builder.CreateBlobAssetReference<BoonCatalogBlob>(Allocator.Persistent);
builder.Dispose();
}
AddBlobAsset(ref blob, out _);
AddComponent(entity, new BoonCatalog { Value = blob });
}
}
}
}
@@ -1,2 +0,0 @@
fileFormatVersion: 2
guid: 075a570afb8ef9541b6307280b53f4e7
@@ -1,32 +0,0 @@
using ProjectM.Simulation;
using Unity.Entities;
using UnityEngine;
namespace ProjectM.Authoring
{
/// <summary>
/// MC-1 — marks a Husk prefab as a CHARGER variant. Compose this WITH <see cref="EnemyAuthoring"/> on the
/// prefab root (both bakers share the primary entity): EnemyAuthoring bakes the common Husk components and
/// Charger-tuned stats; this bakes the server-only <see cref="LungeState"/> (zeroed = not lunging).
/// Component-PRESENCE is the discriminator <c>EnemyAISystem</c> branches on — no enum/brain byte (the Burst
/// cross-assembly-enum hazard) — routing the Charger to the commit→lunge→whiff-stagger pass while the Grunt
/// pass excludes it via <c>.WithNone&lt;LungeState&gt;()</c>. NOT a <c>[GhostField]</c>: the lunged position
/// replicates via stock LocalTransform like every Husk.
/// </summary>
public class ChargerAuthoring : MonoBehaviour
{
private class ChargerBaker : Baker<ChargerAuthoring>
{
public override void Bake(ChargerAuthoring authoring)
{
var entity = GetEntity(authoring, TransformUsageFlags.Dynamic);
AddComponent<LungeState>(entity);
// Slice 1 (Feature D): the replicated mid-lunge cue, baked DISABLED (a Charger spawns not-lunging).
// EnemyAISystem derives the bit each tick from LungeState.UntilTick (visiting disabled entities via
// .WithPresent<IsLunging>()). Adding this [GhostEnabledBit] changes the Charger ghost hash -> RE-BAKE.
AddComponent<IsLunging>(entity);
SetComponentEnabled<IsLunging>(entity, false);
}
}
}
}
@@ -1,2 +0,0 @@
fileFormatVersion: 2
guid: 9565191e0ea7fc94db934ae91a43a4cf
@@ -60,16 +60,11 @@ namespace ProjectM.Authoring
// denominator per variant; IsCharger lets the client pick the Charger look (LungeState is server-only). // denominator per variant; IsCharger lets the client pick the Charger look (LungeState is server-only).
// Kind byte (client telegraph look) — derived from the sibling variant authoring (EnemyBaker is the // Kind byte (client telegraph look) — derived from the sibling variant authoring (EnemyBaker is the
// SOLE EnemyTelegraph writer). Grunt=0 / Charger=1 / Spitter=2 / Swarmer=3 (ZoneEnemyMath.Kind*). // SOLE EnemyTelegraph writer). Grunt=0 / Charger=1 / Spitter=2 / Swarmer=3 (ZoneEnemyMath.Kind*).
byte kind = ZoneEnemyMath.KindGrunt; // 2026-08-07 audit purge: the Charger/Spitter/Swarmer variant authoring is gone — it was attached
// to ZERO prefabs, so every enemy already baked Kind=Grunt and the variant AI passes matched
// nothing. One kind, one windup, until the LANTERN bestiary reintroduces variety via CreatureKit.
const byte kind = ZoneEnemyMath.KindGrunt;
byte windup = (byte)Tuning.AttackWindupTicks; byte windup = (byte)Tuning.AttackWindupTicks;
var spitter = GetComponent<SpitterAuthoring>();
// Bake-time guard (DR-041 sole-Position-writer invariant): a prefab must carry at most ONE of
// {ChargerAuthoring(LungeState), SpitterAuthoring(SpitterState)} — both would match ZERO AI passes.
if (GetComponent<ChargerAuthoring>() != null && spitter != null)
Debug.LogError($"Enemy '{authoring.name}' has BOTH ChargerAuthoring and SpitterAuthoring; it would match no AI pass and never move. Remove one.", authoring);
if (GetComponent<ChargerAuthoring>() != null) { kind = ZoneEnemyMath.KindCharger; windup = (byte)Tuning.ChargerWindupTicks; }
else if (spitter != null) { kind = ZoneEnemyMath.KindSpitter; windup = (byte)Mathf.Clamp(spitter.WindupTicks, 1, 255); }
else if (GetComponent<SwarmerAuthoring>() != null) { kind = ZoneEnemyMath.KindSwarmer; windup = (byte)Tuning.AttackWindupTicks; /* B4: match the server windup (grunt-path swarmers use GruntWindupTicks); baked 6 was a snap-ramp lie */ }
AddComponent(entity, new EnemyTelegraph { WindupTicks = windup, Kind = kind }); AddComponent(entity, new EnemyTelegraph { WindupTicks = windup, Kind = kind });
} }
} }
@@ -1,44 +0,0 @@
using ProjectM.Simulation;
using Unity.Entities;
using Unity.Mathematics;
using UnityEngine;
namespace ProjectM.Authoring
{
/// <summary>
/// MC-2 — authoring for the hostile Spitter projectile prefab (an ownerless INTERPOLATED ghost, duplicated from an
/// existing interpolated ghost so the GhostAuthoringComponent comes free). Bakes <see cref="EnemyProjectile"/> with
/// the spit's default Speed/Damage/Range; the firing Spitter OVERRIDES Direction + Speed + Damage + Region at spawn
/// and ADDS the <c>RegionTag</c> (so this prefab MUST NOT bake RegionTag — AddComponent would throw on a baked one).
/// NO Health (so it is invisible to every player hit-test) and NO <c>[GhostField]</c> beyond the stock LocalTransform.
/// </summary>
public class EnemyProjectileAuthoring : MonoBehaviour
{
[Min(0f), Tooltip("Default muzzle speed (the firing Spitter overrides this per-variant).")]
public float Speed = 11f;
[Min(0f), Tooltip("Default damage (the firing Spitter overrides this from its AttackDamage).")]
public float Damage = 8f;
[Min(0f), Tooltip("Max travel distance before the spit expires (world units).")]
public float Range = 16f;
private class EnemyProjectileBaker : Baker<EnemyProjectileAuthoring>
{
public override void Bake(EnemyProjectileAuthoring authoring)
{
var entity = GetEntity(authoring, TransformUsageFlags.Dynamic);
AddComponent(entity, new EnemyProjectile
{
Speed = authoring.Speed,
Damage = authoring.Damage,
Range = authoring.Range,
Direction = new float2(0f, 1f),
DistanceTravelled = 0f,
LastStep = 0f,
Region = 0,
});
}
}
}
}
@@ -1,2 +0,0 @@
fileFormatVersion: 2
guid: ff79c8fbcacb8c34faad37d59836b5ac
@@ -1,49 +0,0 @@
using ProjectM.Simulation;
using Unity.Entities;
using UnityEngine;
namespace ProjectM.Authoring
{
/// <summary>
/// MC-2 — marks a Husk prefab as a SPITTER variant (the ranged "reposition" question). Compose WITH
/// <see cref="EnemyAuthoring"/> on the prefab root: EnemyAuthoring bakes the common Husk components + the spit's
/// damage/cooldown (EnemyStats.AttackDamage / AttackCooldownTicks), this bakes the server-only
/// <see cref="SpitterState"/> (zeroed NextShotTick = ready). Component-PRESENCE is the discriminator EnemyAISystem
/// branches on (no enum); the Grunt + Charger passes exclude it via <c>.WithNone&lt;SpitterState&gt;()</c>. The
/// actual spit projectile is a SEPARATE ghost configured by the SpitterProjectilePrefab subscene singleton.
/// </summary>
public class SpitterAuthoring : MonoBehaviour
{
[Min(0f), Tooltip("Distance the Spitter tries to hold from its target (band centre).")]
public float PreferredRange = 9f;
[Min(0f), Tooltip("Half-width dead-zone around PreferredRange where it holds and fires.")]
public float RangeTolerance = 1.5f;
[Min(0f), Tooltip("Muzzle speed of the spit (world units/second). Slow enough to be dodgeable at range.")]
public float ProjectileSpeed = 11f;
[Min(0f), Tooltip("If the target closes within this AND the Spitter can't retreat, it fires point-blank.")]
public float CorneredRange = 3f;
[Min(1), Tooltip("Telegraph wind-up before the spit fires (ticks). Keep >= ~24 (> interp delay) to stay dodgeable.")]
public int WindupTicks = 26;
private class SpitterBaker : Baker<SpitterAuthoring>
{
public override void Bake(SpitterAuthoring authoring)
{
var entity = GetEntity(authoring, TransformUsageFlags.Dynamic);
AddComponent(entity, new SpitterState
{
PreferredRange = authoring.PreferredRange,
RangeTolerance = authoring.RangeTolerance,
ProjectileSpeed = authoring.ProjectileSpeed,
CorneredRange = authoring.CorneredRange,
WindupTicks = authoring.WindupTicks,
NextShotTick = 0,
});
}
}
}
}
@@ -1,2 +0,0 @@
fileFormatVersion: 2
guid: 55fe00810b31aa54abd577b6a07192e2
@@ -1,36 +0,0 @@
using ProjectM.Simulation;
using Unity.Entities;
using UnityEngine;
namespace ProjectM.Authoring
{
/// <summary>
/// MC-2 — authoring for the <see cref="SpitterProjectilePrefab"/> subscene singleton. Place ONE on a GameObject in
/// the gameplay subscene; the server EnemyAISystem Spitter pass reads it via <c>GetSingleton</c> to know which spit
/// ghost to instantiate and the concurrent soft-cap. The referenced prefab is the EnemyProjectile ghost
/// (EnemyProjectileAuthoring); MaxLiveProjectiles bounds the relevancy loop — a Spitter at/over the cap soft-fails
/// its shot (no cooldown burn). The carrying entity has no transform; only the referenced prefab needs one.
/// </summary>
public class SpitterProjectilePrefabAuthoring : MonoBehaviour
{
[Tooltip("The EnemyProjectile ghost prefab that Spitters fire (must carry EnemyProjectileAuthoring + an interpolated GhostAuthoringComponent).")]
public GameObject ProjectilePrefab;
[Min(1), Tooltip("Max concurrent live spit projectiles across all Spitters (soft-cap; over it a Spitter soft-fails its shot).")]
public int MaxLiveProjectiles = 24;
private class SpitterProjectilePrefabBaker : Baker<SpitterProjectilePrefabAuthoring>
{
public override void Bake(SpitterProjectilePrefabAuthoring authoring)
{
var entity = GetEntity(authoring, TransformUsageFlags.None);
AddComponent(entity, new SpitterProjectilePrefab
{
Prefab = authoring.ProjectilePrefab != null
? GetEntity(authoring.ProjectilePrefab, TransformUsageFlags.Dynamic) : Entity.Null,
MaxLiveProjectiles = authoring.MaxLiveProjectiles,
});
}
}
}
}
@@ -1,2 +0,0 @@
fileFormatVersion: 2
guid: 4ce5223c5fd56694c81991e1bb1232de
@@ -1,25 +0,0 @@
using ProjectM.Simulation;
using Unity.Entities;
using UnityEngine;
namespace ProjectM.Authoring
{
/// <summary>
/// MC-2 — marks a Husk prefab as a SWARMER variant (the "surround" question). Compose WITH
/// <see cref="EnemyAuthoring"/> on the prefab root, tuned fast + low-HP + fast frequent low-chip bites (via the
/// EnemyAuthoring fields). This bakes only the <see cref="SwarmerTag"/> marker: a Swarmer has NO AI branch (it
/// falls through the Grunt seek+strike pass); the tag drives the director's CLUSTER spawn (a pack per slot) + a
/// client tint. Keeps EnemyTag + RegionTag like every Husk.
/// </summary>
public class SwarmerAuthoring : MonoBehaviour
{
private class SwarmerBaker : Baker<SwarmerAuthoring>
{
public override void Bake(SwarmerAuthoring authoring)
{
var entity = GetEntity(authoring, TransformUsageFlags.Dynamic);
AddComponent<SwarmerTag>(entity);
}
}
}
}
@@ -1,2 +0,0 @@
fileFormatVersion: 2
guid: b6a84b442d0535642abc303c01546a15
@@ -1,32 +0,0 @@
using ProjectM.Simulation;
using Unity.Entities;
using UnityEngine;
namespace ProjectM.Authoring
{
/// <summary>
/// Authoring for the shared storage-container ghost prefab: an ownerless INTERPOLATED ghost whose
/// replicated <see cref="StorageEntry"/> buffer is the shared inventory any player deposits into /
/// withdraws from (server-authoritative, applied by StorageOpReceiveSystem). Add a
/// GhostAuthoringComponent (Interpolated) to the prefab so clients see its contents replicate.
/// <c>GetEntity(TransformUsageFlags.Dynamic)</c> gives it a runtime world transform, set at spawn to
/// the base cell center.
/// </summary>
public class SharedStorageContainerAuthoring : MonoBehaviour
{
[Min(0f)]
[Tooltip("Interaction radius (world units) for the deposit/withdraw test; reserved for proximity gating.")]
public float InteractRadius = 2f;
private class SharedStorageContainerBaker : Baker<SharedStorageContainerAuthoring>
{
public override void Bake(SharedStorageContainerAuthoring authoring)
{
var entity = GetEntity(authoring, TransformUsageFlags.Dynamic);
AddComponent<SharedStorageContainer>(entity);
AddComponent(entity, new HitRadius { Value = authoring.InteractRadius });
AddBuffer<StorageEntry>(entity);
}
}
}
}
@@ -1,2 +0,0 @@
fileFormatVersion: 2
guid: 8cc6285a9b8958a47a61a07550b7f792
@@ -1,38 +0,0 @@
using ProjectM.Simulation;
using Unity.Entities;
using Unity.Mathematics;
using UnityEngine;
namespace ProjectM.Authoring
{
/// <summary>
/// Authoring for the baked <see cref="StorageSpawner"/> singleton (mirrors UpgradePickupSpawnerAuthoring).
/// Place once in the gameplay subscene; the server-only SharedStorageSpawnSystem reads it, instantiates
/// the storage-container ghost at the base-grid cell center (BaseGridMath.CellToWorld), then destroys
/// the singleton so it fires exactly once. The entity carries no transform; only the prefab needs one.
/// </summary>
public class StorageSpawnerAuthoring : MonoBehaviour
{
[Tooltip("Storage-container ghost prefab to instantiate. Must carry SharedStorageContainerAuthoring + a GhostAuthoringComponent.")]
public GameObject ContainerPrefab;
[Tooltip("Build-grid cell at which to place the container (cell center, on the base plane).")]
public Vector2Int Cell = new Vector2Int(16, 22);
private class StorageSpawnerBaker : Baker<StorageSpawnerAuthoring>
{
public override void Bake(StorageSpawnerAuthoring authoring)
{
var entity = GetEntity(authoring, TransformUsageFlags.None);
AddComponent(entity, new StorageSpawner
{
Prefab = authoring.ContainerPrefab != null
? GetEntity(authoring.ContainerPrefab, TransformUsageFlags.Dynamic)
: Entity.Null,
Cell = new int2(authoring.Cell.x, authoring.Cell.y),
});
}
}
}
}
@@ -1,2 +0,0 @@
fileFormatVersion: 2
guid: 968b8c85b6f69ae438e56cb1f19a2450
@@ -1,69 +0,0 @@
using System.Collections.Generic;
using ProjectM.Simulation;
using Unity.Collections;
using Unity.Entities;
using UnityEngine;
namespace ProjectM.Authoring
{
/// <summary>
/// Bakes the designer-authored item definitions into a single ItemDatabase blob singleton (immutable,
/// shared, Burst-fast), mirroring AbilityDatabaseAuthoring. Place ONE in the gameplay subscene; it streams
/// identically into the client and server worlds (config, not replicated). DependsOn each definition so a
/// value change re-bakes the blob. Runtime lookup is ID-keyed (ItemDatabaseBlob.TryGetItem), so the list
/// order here does not matter and inserting an item never renumbers existing ids.
/// </summary>
public class ItemDatabaseAuthoring : MonoBehaviour
{
[Tooltip("All item definitions in the game (resources + tools/gear). Looked up at runtime by ItemId.")]
public List<ItemDefinition> Items = new List<ItemDefinition>();
private class DatabaseBaker : Baker<ItemDatabaseAuthoring>
{
public override void Bake(ItemDatabaseAuthoring authoring)
{
var entity = GetEntity(TransformUsageFlags.None);
int count = authoring.Items != null ? authoring.Items.Count : 0;
var builder = new BlobBuilder(Allocator.Temp);
ref var root = ref builder.ConstructRoot<ItemDatabaseBlob>();
var arr = builder.Allocate(ref root.Items, count);
for (int i = 0; i < count; i++)
{
var def = authoring.Items[i];
if (def == null) { arr[i] = default; continue; }
DependsOn(def);
arr[i] = new ItemDefBlob
{
ItemId = (ushort)def.ItemId,
Category = def.Category,
Tier = def.Tier,
StackMax = def.StackMax,
EquipSlot = def.EquipSlot,
Mod0 = ModAt(def, 0),
Mod1 = ModAt(def, 1),
Mod2 = ModAt(def, 2),
Mod3 = ModAt(def, 3),
Name = def.DisplayName,
};
}
var blob = builder.CreateBlobAssetReference<ItemDatabaseBlob>(Allocator.Persistent);
builder.Dispose();
AddBlobAsset(ref blob, out _);
AddComponent(entity, new ItemDatabase { Value = blob });
static ItemModSpec ModAt(ItemDefinition def, int i)
{
if (def.Mods != null && i < def.Mods.Count)
{
var m = def.Mods[i];
return new ItemModSpec { Target = m.Target, Op = m.Op, Value = m.Value };
}
return new ItemModSpec { Target = 255 };
}
}
}
}
}
@@ -1,2 +0,0 @@
fileFormatVersion: 2
guid: 5ee44dc3bc9f3164592195d4068be8d1
@@ -1,50 +0,0 @@
using System.Collections.Generic;
using ProjectM.Simulation;
using UnityEngine;
namespace ProjectM.Authoring
{
/// <summary>
/// Designer-facing definition of one item (resource, tool, weapon, gear, consumable). Numeric/byte fields
/// are baked into the ItemDatabase blob (immutable, Burst-fast runtime config). Category and Tier are
/// BYTES, not enums, to dodge BOTH the MCP enum-drop hazard (manage_* silently drop enum fields when set
/// via tooling) AND the cross-assembly enum-in-Burst hazard. UI icon/description are deferred to a later
/// managed lookup keyed by id, exactly like AbilityDefinition.
/// </summary>
[CreateAssetMenu(menuName = "Project M/Item Definition", fileName = "Item_")]
public class ItemDefinition : ScriptableObject
{
[Tooltip("Stable item id (ushort range). Keep 1=Aether, 2=Ore, 3=Biomass; reserve >3 for new items; 0 = none.")]
public int ItemId = 4;
public string DisplayName = "Item";
[Tooltip("ItemCategory byte: 0=Resource, 1=Tool, 2=Weapon, 3=Gear, 4=Consumable.")]
public byte Category = ItemCategory.Resource;
[Tooltip("Progression tier (0 = base). Higher-tier tools harvest higher-tier nodes / hit harder.")]
public byte Tier = 0;
[Min(1)]
[Tooltip("Max units that stack in one inventory slot (1 for non-stacking equipment).")]
public int StackMax = 999;
[Header("Equipment (Phase 1)")]
[Tooltip("EquipSlotId byte: 0=Weapon, 1=Armor, 2=Trinket, 3=Tool, 255=not equippable.")]
public byte EquipSlot = 255;
[Tooltip("Stat modifiers granted while equipped (first 4 used).")]
public List<ItemModAuthoring> Mods = new List<ItemModAuthoring>();
}
/// <summary>Designer-facing stat-mod grant on an equippable item; the baker writes the first 4 into ItemDefBlob's inline mod slots.</summary>
[System.Serializable]
public struct ItemModAuthoring
{
[Tooltip("StatTarget byte: 0=Damage,1=CooldownTicks,2=Range,3=ProjectileSpeed,4=AutoTargetRange,5=AutoTargetCone,6=MoveSpeed,7=TurnRate,8=MaxHealth.")]
public byte Target;
[Tooltip("ModOp byte: 0=Flat, 1=PercentAdd, 2=PercentMult.")]
public byte Op;
public float Value;
}
}
@@ -1,2 +0,0 @@
fileFormatVersion: 2
guid: 84295e2f852afac4fa4b7384857281d9
@@ -1,85 +0,0 @@
using System;
using System.Collections.Generic;
using ProjectM.Simulation;
using Unity.Collections;
using Unity.Entities;
using UnityEngine;
namespace ProjectM.Authoring
{
/// <summary>
/// Authoring for the PERMANENT meta-upgrade catalog singleton (place ONE in the gameplay subscene — the
/// BoonCatalogAuthoring pattern). An EMPTY row list bakes the code-default v1 table
/// (<see cref="MetaCatalogData.BuildDefault"/>) verbatim, so the subscene object needs zero property assignment
/// through the tooling. Ids are APPEND-ONLY (persisted in SaveData v6; a removed id's saved rows are preserved
/// and skipped, never crashed on).
/// </summary>
public class MetaCatalogAuthoring : MonoBehaviour
{
[Serializable]
public struct MetaRow
{
public byte Id;
[Tooltip("bit0 = Warrior, bit1 = Ranger, 3 = both.")]
public byte ClassMask;
public StatTarget Target;
public ModOp Op;
public byte MaxTier;
public float ValuePerTier;
public int BaseCost;
public int CostGrowth;
[Tooltip("0xFF (255) = no prerequisite.")]
public byte PrereqId;
public byte PrereqTier;
public string Name;
public string Desc;
}
[Tooltip("Leave EMPTY to bake the code-default v1 table; fill to fully replace it.")]
public List<MetaRow> Rows = new List<MetaRow>();
private class MetaCatalogBaker : Baker<MetaCatalogAuthoring>
{
public override void Bake(MetaCatalogAuthoring authoring)
{
var entity = GetEntity(authoring, TransformUsageFlags.None);
BlobAssetReference<MetaUpgradeCatalogBlob> blob;
if (authoring.Rows == null || authoring.Rows.Count == 0)
{
blob = MetaCatalogData.BuildDefault();
}
else
{
var builder = new BlobBuilder(Allocator.Temp);
ref var root = ref builder.ConstructRoot<MetaUpgradeCatalogBlob>();
var defs = builder.Allocate(ref root.Defs, authoring.Rows.Count);
for (int i = 0; i < authoring.Rows.Count; i++)
{
var r = authoring.Rows[i];
defs[i] = new MetaUpgradeDefBlob
{
Id = r.Id,
ClassMask = r.ClassMask,
Target = (byte)r.Target,
Op = (byte)r.Op,
MaxTier = r.MaxTier,
ValuePerTier = r.ValuePerTier,
BaseCost = r.BaseCost,
CostGrowth = r.CostGrowth,
PrereqId = r.PrereqId,
PrereqTier = r.PrereqTier,
Name = new FixedString64Bytes(r.Name ?? string.Empty),
Desc = new FixedString128Bytes(r.Desc ?? string.Empty),
};
}
blob = builder.CreateBlobAssetReference<MetaUpgradeCatalogBlob>(Allocator.Persistent);
builder.Dispose();
}
AddBlobAsset(ref blob, out _);
AddComponent(entity, new MetaUpgradeCatalog { Value = blob });
}
}
}
}
@@ -1,2 +0,0 @@
fileFormatVersion: 2
guid: 4f007f7870c0afd4e93ea5b86fd21c8b
@@ -60,12 +60,9 @@ namespace ProjectM.Authoring
// Empty replicated modifier stack (grown by upgrades/pickups/debug hook, server-authoritative). // Empty replicated modifier stack (grown by upgrades/pickups/debug hook, server-authoritative).
AddBuffer<StatModifier>(entity); AddBuffer<StatModifier>(entity);
// Empty replicated personal inventory (server-authoritative; harvest yield + deposit RPC land here). // 2026-08-07 audit purge: the replicated personal InventorySlot bag and the EquipmentSlot loadout
AddBuffer<InventorySlot>(entity); // went with the shell (CLAUDE.md already recorded inventory/equipment as PAUSED). Harvest now
// Equipment loadout: one replicated row per slot in FIXED order (buffer index = EquipSlotId), empty. // credits the shared ledger directly.
var equip = AddBuffer<EquipmentSlot>(entity);
for (int s = 0; s < EquipSlotId.Count; s++)
equip.Add(new EquipmentSlot { ItemId = 0 });
// Server-only expiry tracker for timed buffs (paired with a StatModifier by SourceId; not replicated). // Server-only expiry tracker for timed buffs (paired with a StatModifier by SourceId; not replicated).
AddBuffer<TimedModifier>(entity); AddBuffer<TimedModifier>(entity);
@@ -94,14 +91,9 @@ namespace ProjectM.Authoring
AddComponent(entity, new RespawnState { RespawnTick = 0, DelayTicks = authoring.RespawnDelayTicks, InvulnTicks = authoring.RespawnInvulnTicks }); AddComponent(entity, new RespawnState { RespawnTick = 0, DelayTicks = authoring.RespawnDelayTicks, InvulnTicks = authoring.RespawnInvulnTicks });
AddComponent(entity, new RespawnInvuln { UntilTick = 0 }); AddComponent(entity, new RespawnInvuln { UntilTick = 0 });
// Expedition redesign (the ONE player-ghost re-bake, front-loaded): the send-to-all ready-check // 2026-08-07 audit purge: PlayerReady (ready-check), BoonOffer/BoonEffects (Phase-1.7 boons) and
// flag + the owner-only choice-of-3 boon offer (inert until Step 9's BoonOfferSystem lights it up). // DashTrailState (Blade Dash) were baked onto every player ghost for the superseded base/expedition
AddComponent<PlayerReady>(entity); // loop. All four are gone; the ghost archetype shrinks accordingly.
AddComponent<BoonOffer>(entity);
// Phase 1.7 boon overhaul: the mechanic-changer state (replicated SendToOwner, baked inert, zeroed on
// the Returning edge) + the server-only Blade-Dash per-dash dedup accumulator (non-replicated).
AddComponent<BoonEffects>(entity);
AddComponent<DashTrailState>(entity);
// LANTERN Phase 1 (Step 1): 4-socket kit data model — THE ability model (the legacy single // LANTERN Phase 1 (Step 1): 4-socket kit data model — THE ability model (the legacy single
// AbilityRef/AbilityCooldown path is deleted). AbilitySocket = cold per-socket loadout // AbilityRef/AbilityCooldown path is deleted). AbilitySocket = cold per-socket loadout
// (EquipmentSlot-modelled, 4 empty rows; GoInGameServerSystem seeds the frame loadout at spawn); // (EquipmentSlot-modelled, 4 empty rows; GoInGameServerSystem seeds the frame loadout at spawn);
@@ -23,15 +23,10 @@ namespace ProjectM.Authoring
AddComponent<ResourceLedger>(entity); AddComponent<ResourceLedger>(entity);
AddBuffer<StorageEntry>(entity); AddBuffer<StorageEntry>(entity);
// DR-042 C7b: replicated expedition-objective summary (the HUD 'enemies remaining / cleared' readout). // 2026-08-07 audit purge: ExpeditionObjective (the 'enemies remaining / cleared' readout),
// Born Idle; RoomEnemyDirectorSystem is the sole writer. // RunInfo (the run-lifecycle FSM) and MetaTierState (per-frame permanent upgrades) were all baked
AddComponent(entity, new ExpeditionObjective { State = ExpeditionObjectiveState.Idle, Remaining = 0 }); // here for the superseded base/expedition loop. Their sole writers are deleted; the director now
// carries only the global resource ledger.
// Expedition redesign: the replicated run-lifecycle FSM (RunInfo) + the per-class permanent-meta
// tier buffer (MetaTierState). Born Staging/empty; server RunDirectorSystem / MetaSpendSystem are
// the sole writers.
AddComponent(entity, new RunInfo { Lifecycle = RunLifecycle.Staging });
AddBuffer<MetaTierState>(entity);
} }
} }
} }
@@ -1,41 +0,0 @@
namespace ProjectM.Client
{
/// <summary>
/// Client-local build-mode state shared between the HUD build palette (sets the selected buildable), the
/// build placement input (ground ghost preview + click-to-place + conveyor rotation), and the input gather
/// (suppresses Fire while a build is selected, so a left-click places instead of firing). Pure UI state —
/// never replicated, never touches the sim. Reset on play-enter (statics survive fast-enter domain reloads).
/// </summary>
public static class BuildPaletteState
{
/// <summary>Selected structure type (StructureType.*); 0 = none / no slot selected.</summary>
public static byte Selected;
/// <summary>Pending conveyor facing (0=+X,1=-X,2=+Z,3=-Z); rotated by [ / ] or R.</summary>
public static byte Direction;
/// <summary>True while the build PALETTE panel is open (toggled by Tab / gamepad Y). Slice 1 HUD declutter:
/// the palette is hidden by default; this gates its visibility, orthogonal to <see cref="Active"/>.</summary>
public static bool PaletteOpen;
/// <summary>True while a buildable SLOT is selected (placement is armed). The palette must also be open.</summary>
public static bool Active => Selected != 0;
/// <summary>Toggle the palette panel open/closed; closing also cancels any active slot selection.</summary>
public static void TogglePalette()
{
PaletteOpen = !PaletteOpen;
if (!PaletteOpen) { Selected = 0; Direction = 0; }
}
/// <summary>Select a type (or 0 to deselect), resetting the pending conveyor facing; auto-opens the palette
/// so a slot click never leaves the panel hidden.</summary>
public static void Select(byte type) { Selected = type; Direction = 0; if (type != 0) PaletteOpen = true; }
/// <summary>Cancel the current selection and close the palette.</summary>
public static void Clear() { Selected = 0; Direction = 0; PaletteOpen = false; }
[UnityEngine.RuntimeInitializeOnLoadMethod(UnityEngine.RuntimeInitializeLoadType.SubsystemRegistration)]
static void ResetStatics() { Selected = 0; Direction = 0; PaletteOpen = false; }
}
}
@@ -1,2 +0,0 @@
fileFormatVersion: 2
guid: b0f12fac7a937bf418aaf47eba57cc74
@@ -1,267 +0,0 @@
using ProjectM.Simulation;
using Unity.Entities;
using Unity.Mathematics;
using Unity.NetCode;
using Unity.Transforms;
using UnityEngine;
namespace ProjectM.Client
{
/// <summary>
/// Client-only build input + RPC sender. Two ways to build:
/// (1) the HUD build PALETTE (primary): a selected buildable (<see cref="BuildPaletteState"/>) drives a ground
/// GHOST preview at the cursor cell — green when valid (in-plot, unoccupied, affordable; the same legality the
/// server re-validates), red otherwise — and a LEFT-CLICK places it; right-click / Esc cancels; [ / ] or R
/// 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
/// 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/F place at the local player's cell.
/// Editor-only statics (PlaceStructure / PlaceTurret / ...) drive the same RPC path from execute_code for
/// 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.
/// </summary>
[WorldSystemFilter(WorldSystemFilterFlags.ClientSimulation)]
public partial class BuildSendSystem : SystemBase
{
// Dev hotkey fallback: key -> buildable.
static readonly (UnityEngine.InputSystem.Key Key, byte Type)[] s_BuildHotkeys =
{
(UnityEngine.InputSystem.Key.V, StructureType.Wall),
// LANTERN purge: the automation buildables (Fabricator/Harvester/Conveyor) are deleted; Pylon stays
// hidden from the hotkey fallback to match the build palette. PlaceStructure execute_code statics remain.
};
UnityEngine.Camera _camera; // cursor -> ground re-raycast for click-to-place (resolved lazily)
GameObject _ghost; // translucent ground preview cube
Material _ghostMat;
MeshFilter _ghostMf; // ghost mesh swaps to the selected structure's preview mesh (HudTheme)
MeshRenderer _ghostMr;
Mesh _cubeMesh; // procedural fallback (the original preview cube)
byte _ghostType = 255; // last-applied preview type (255 = unset)
byte _lastSelected; // skip placing on the frame a palette click changes the selection
// (Step 11: UpgradeAbility/s_PendingUpgrades RETIRED with the AbilityUpgradeRequest wire — in-run boons +
// the base meta-shop replaced the Aether damage upgrade.)
#if UNITY_EDITOR
struct PendingBuild { public byte Type; public int CellX; public int CellZ; public byte Direction; }
static readonly System.Collections.Generic.Queue<PendingBuild> s_PendingBuild =
new System.Collections.Generic.Queue<PendingBuild>();
/// <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) =>
s_PendingBuild.Enqueue(new PendingBuild { Type = type, CellX = cellX, CellZ = cellZ, Direction = direction });
/// <summary>EDITOR / execute_code hook: queue a wall placement at a specific cell.</summary>
public static void PlaceWall(int cellX, int cellZ) => PlaceStructure(StructureType.Wall, cellX, cellZ);
#endif
protected override void OnCreate()
{
RequireForUpdate<NetworkId>();
}
protected override void OnDestroy()
{
if (_ghost != null) Object.Destroy(_ghost);
if (_ghostMat != null) Object.Destroy(_ghostMat);
}
protected override void OnUpdate()
{
if (!SystemAPI.TryGetSingletonEntity<NetworkId>(out var connection))
return;
HandleBuildMode(connection);
// --- Build-palette toggle (Tab / gamepad Y): Slice 1 HUD declutter — the palette is hidden by default ---
var keyboard = UnityEngine.InputSystem.Keyboard.current;
var gamepad = UnityEngine.InputSystem.Gamepad.current;
bool togglePressed =
(keyboard != null && keyboard.tabKey.wasPressedThisFrame) ||
(gamepad != null && gamepad.buttonNorth.wasPressedThisFrame);
if (togglePressed && !PauseMenuController.Open)
BuildPaletteState.TogglePalette();
// Hotkey fallback (suppressed while the palette build mode is active).
if (keyboard != null && !BuildPaletteState.Active)
{
foreach (var (key, type) in s_BuildHotkeys)
if (keyboard[key].wasPressedThisFrame && TryGetLocalPlayerCell(out int2 cell))
SendBuild(connection, type, cell.x, cell.y, (byte)0);
}
#if UNITY_EDITOR
while (s_PendingBuild.Count > 0)
{
var b = s_PendingBuild.Dequeue();
SendBuild(connection, b.Type, b.CellX, b.CellZ, b.Direction);
}
#endif
}
// ---- Palette-driven build mode: ground ghost preview + click-to-place ----
void HandleBuildMode(Entity connection)
{
byte sel = BuildPaletteState.Selected;
bool justSelected = sel != _lastSelected; // the selecting click must not also place
_lastSelected = sel;
bool active = BuildPaletteState.Active && !PauseMenuController.Open;
AimPresentation.ForceCursorVisible = active;
if (!active) { HideGhost(); return; }
var keyboard = UnityEngine.InputSystem.Keyboard.current;
var mouse = UnityEngine.InputSystem.Mouse.current;
// Cancel build mode (right-click / Esc).
if ((mouse != null && mouse.rightButton.wasPressedThisFrame) ||
(keyboard != null && keyboard.escapeKey.wasPressedThisFrame))
{
BuildPaletteState.Clear();
HideGhost();
return;
}
// Rotate a conveyor's facing ([ / ] or R).
if (keyboard != null)
{
if (keyboard.leftBracketKey.wasPressedThisFrame)
BuildPaletteState.Direction = (byte)((BuildPaletteState.Direction + 3) % 4);
if (keyboard.rightBracketKey.wasPressedThisFrame || keyboard.rKey.wasPressedThisFrame)
BuildPaletteState.Direction = (byte)((BuildPaletteState.Direction + 1) % 4);
}
if (!SystemAPI.TryGetSingleton<BaseAnchor>(out var anchor)) { HideGhost(); return; }
if (_camera == null) _camera = CameraResolver.Resolve();
if (_camera == null || mouse == null) { HideGhost(); return; }
// Cursor -> ground -> cell.
UnityEngine.Vector2 sp = mouse.position.ReadValue();
UnityEngine.Ray ray = _camera.ScreenPointToRay(new UnityEngine.Vector3(sp.x, sp.y, 0f));
if (!AimMath.TryGroundHit((float3)ray.origin, (float3)ray.direction, anchor.GridOrigin.y, out var groundPoint))
{ HideGhost(); return; }
int2 targetCell = BaseGridMath.WorldToCell(anchor, groundPoint);
// Validity — client mirror of the server check (in-plot, unoccupied, affordable).
bool occupied = false;
foreach (var (ps, xf) in SystemAPI.Query<RefRO<PlacedStructure>, RefRO<LocalTransform>>())
if (math.all(BaseGridMath.WorldToCell(anchor, xf.ValueRO.Position) == targetCell)) { occupied = true; break; }
int cost = CatalogCost(sel);
byte reason = BuildPreviewMath.Evaluate(anchor, targetCell, occupied, LedgerOre(), cost);
bool valid = reason == BuildPreviewMath.Valid;
ShowGhost(BaseGridMath.CellToWorld(anchor, targetCell), anchor.CellSize, valid, sel);
// Place on a left-click (valid, not the selecting click).
if (valid && !justSelected && mouse.leftButton.wasPressedThisFrame)
SendBuild(connection, sel, targetCell.x, targetCell.y, BuildPaletteState.Direction);
}
int LedgerOre()
{
if (!SystemAPI.TryGetSingletonEntity<ResourceLedger>(out var le)) return 0;
var buf = SystemAPI.GetBuffer<StorageEntry>(le);
for (int i = 0; i < buf.Length; i++) if (buf[i].ItemId == ResourceId.Ore) return buf[i].Count;
return 0;
}
int CatalogCost(byte type)
{
if (type == 0 || !SystemAPI.TryGetSingletonEntity<StructureCatalog>(out var ce)) return int.MaxValue;
var cat = SystemAPI.GetBuffer<StructureCatalogEntry>(ce);
for (int i = 0; i < cat.Length; i++) if (cat[i].Type == type) return cat[i].CostAmount;
return int.MaxValue;
}
// ---- Ground ghost preview: the selected structure's REAL mesh (HudTheme, build-safe serialized refs)
// tinted translucent green/red; falls back to the original procedural cube when no mesh is authored. ----
void ShowGhost(float3 center, float cellSize, bool valid, byte type)
{
EnsureGhost();
ApplyGhostMesh(type);
if (_ghostMf.sharedMesh == _cubeMesh)
{
_ghost.transform.position = (Vector3)center + Vector3.up * 0.5f;
_ghost.transform.localScale = new Vector3(cellSize * 0.9f, 1f, cellSize * 0.9f);
}
else
{
// preview meshes are authored real-size with a ground pivot (SM_Turret_01 / SM_Wall_01 / SM_Fabricator_01)
_ghost.transform.position = (Vector3)center;
_ghost.transform.localScale = Vector3.one;
}
_ghostMat.color = valid ? new Color(0.3f, 1f, 0.45f, 0.35f) : new Color(1f, 0.32f, 0.26f, 0.35f);
if (!_ghost.activeSelf) _ghost.SetActive(true);
}
// Swap the ghost's mesh when the palette selection changes (255 = unset sentinel; no selection is 0 -> cube).
void ApplyGhostMesh(byte type)
{
if (type == _ghostType) return;
_ghostType = type;
var theme = HudTheme.Get();
Mesh mesh = theme != null ? theme.StructureGhostMesh(type) : null;
if (mesh == null) mesh = _cubeMesh;
_ghostMf.sharedMesh = mesh;
if (_ghostMr.sharedMaterials.Length != mesh.subMeshCount)
{
var mats = new Material[mesh.subMeshCount]; // one translucent mat per submesh so the whole preview tints
for (int i = 0; i < mats.Length; i++) mats[i] = _ghostMat;
_ghostMr.sharedMaterials = mats;
}
}
void HideGhost()
{
if (_ghost != null && _ghost.activeSelf) _ghost.SetActive(false);
}
void EnsureGhost()
{
if (_ghost != null) return;
var shader = Shader.Find("Sprites/Default");
if (shader == null) shader = Shader.Find("Universal Render Pipeline/Unlit");
_ghostMat = new Material(shader) { color = new Color(0.3f, 1f, 0.45f, 0.35f) };
_ghost = GameObject.CreatePrimitive(PrimitiveType.Cube);
_ghost.name = "~BuildGhost";
var col = _ghost.GetComponent<Collider>();
if (col != null) Object.Destroy(col);
_ghostMf = _ghost.GetComponent<MeshFilter>();
_cubeMesh = _ghostMf.sharedMesh;
_ghostType = 255;
_ghostMr = _ghost.GetComponent<MeshRenderer>();
_ghostMr.sharedMaterial = _ghostMat;
_ghostMr.shadowCastingMode = UnityEngine.Rendering.ShadowCastingMode.Off;
_ghostMr.receiveShadows = false;
_ghost.SetActive(false);
}
bool TryGetLocalPlayerCell(out int2 cell)
{
cell = default;
if (!SystemAPI.TryGetSingleton<BaseAnchor>(out var anchor))
return false;
foreach (var xform in SystemAPI.Query<RefRO<LocalTransform>>().WithAll<GhostOwnerIsLocal, PlayerTag>())
{
cell = BaseGridMath.WorldToCell(anchor, xform.ValueRO.Position);
return true;
}
return false;
}
void SendBuild(Entity connection, byte type, int cellX, int cellZ, byte direction)
{
var e = EntityManager.CreateEntity();
EntityManager.AddComponentData(e, new BuildPlaceRequest { StructureType = type, CellX = cellX, CellZ = cellZ, Direction = direction });
EntityManager.AddComponentData(e, new SendRpcCommandRequest { TargetConnection = connection });
}
// (Step 11: the Aether ability-upgrade sender was RETIRED with AbilityUpgradeRequest — boons replaced it.)
}
}
@@ -1,2 +0,0 @@
fileFormatVersion: 2
guid: 765356fd6c5e64c4e9ab588f99d3388f
@@ -1,49 +0,0 @@
using ProjectM.Simulation;
using Unity.Entities;
using Unity.NetCode;
using UnityEngine;
namespace ProjectM.Client
{
/// <summary>
/// Client-side boon-pick sender: a static enqueue (the Step-14 3-card modal / execute_code) drained into
/// <see cref="BoonPickRequest"/> RPCs. Carries only the option INDEX — the server resolves it against the
/// sender's own authoritative <c>BoonOffer</c> and validates lifecycle/pending, so a stale or forged pick is
/// simply dropped. Statics reset on play-enter (the stale-bridge hazard).
/// </summary>
[WorldSystemFilter(WorldSystemFilterFlags.ClientSimulation)]
public partial class BoonSendSystem : SystemBase
{
static int s_Pending;
static byte s_PendingIndex;
[RuntimeInitializeOnLoadMethod(RuntimeInitializeLoadType.SubsystemRegistration)]
static void ResetStatics()
{
s_Pending = 0;
s_PendingIndex = 0;
}
/// <summary>Queue a boon pick (0/1/2). The HUD card click + execute_code drive this.</summary>
public static void PickBoon(byte optionIndex)
{
s_PendingIndex = optionIndex;
s_Pending++;
}
protected override void OnCreate()
{
RequireForUpdate<NetworkId>();
}
protected override void OnUpdate()
{
while (s_Pending > 0)
{
s_Pending--;
var req = EntityManager.CreateEntity(typeof(BoonPickRequest), typeof(SendRpcCommandRequest));
EntityManager.SetComponentData(req, new BoonPickRequest { Index = s_PendingIndex });
}
}
}
}
@@ -1,2 +0,0 @@
fileFormatVersion: 2
guid: d7e90bc7230614845817b81f0f7b70f0
@@ -1,116 +0,0 @@
using ProjectM.Simulation;
using Unity.Entities;
using Unity.NetCode;
namespace ProjectM.Client
{
/// <summary>
/// Client-only sender for <see cref="EquipRequest"/> / <see cref="UnequipRequest"/> RPCs. One-off actions, so
/// RPCs (not per-tick input); the server applies them authoritatively in
/// <see cref="ProjectM.Server.EquipSystem"/>. Number keys 1-9 equip the Nth EQUIPPABLE item in the local bag
/// (resources are skipped via the catalog); U unequips the weapon. Managed SystemBase because it reads the
/// managed Input System; Input System types are fully qualified and <c>using UnityEngine.InputSystem;</c> is
/// omitted (it defines a colliding PlayerInput type). An <c>#if UNITY_EDITOR</c> static hook drives the same
/// path from execute_code for headless validation. The HUD click-to-equip (HudSystem) is the primary UX;
/// these keys are the reachable fallback. Wire types are unconditional; only this send SYSTEM is gated.
/// </summary>
[WorldSystemFilter(WorldSystemFilterFlags.ClientSimulation)]
public partial class EquipSendSystem : SystemBase
{
struct PendingEquip { public bool Unequip; public ushort ItemId; public byte Slot; }
static readonly System.Collections.Generic.Queue<PendingEquip> s_Pending =
new System.Collections.Generic.Queue<PendingEquip>();
/// <summary>EDITOR / execute_code hook: queue an equip of <paramref name="itemId"/> from the bag.</summary>
public static void Equip(ushort itemId) =>
s_Pending.Enqueue(new PendingEquip { Unequip = false, ItemId = itemId });
/// <summary>EDITOR / execute_code hook: queue an unequip of <paramref name="slot"/> (an EquipSlotId).</summary>
public static void Unequip(byte slot) =>
s_Pending.Enqueue(new PendingEquip { Unequip = true, Slot = slot });
protected override void OnCreate()
{
RequireForUpdate<NetworkId>();
}
protected override void OnUpdate()
{
if (!SystemAPI.TryGetSingletonEntity<NetworkId>(out var connection))
return;
var keyboard = UnityEngine.InputSystem.Keyboard.current;
if (keyboard != null)
{
int n = NumberKeyPressed(keyboard);
if (n >= 0)
{
ushort itemId = NthEquippableBagItem(n);
if (itemId != 0) SendEquip(connection, itemId);
}
if (keyboard.uKey.wasPressedThisFrame)
SendUnequip(connection, EquipSlotId.Weapon);
}
while (s_Pending.Count > 0)
{
var p = s_Pending.Dequeue();
if (p.Unequip) SendUnequip(connection, p.Slot);
else SendEquip(connection, p.ItemId);
}
}
static int NumberKeyPressed(UnityEngine.InputSystem.Keyboard kb)
{
if (kb.digit1Key.wasPressedThisFrame) return 0;
if (kb.digit2Key.wasPressedThisFrame) return 1;
if (kb.digit3Key.wasPressedThisFrame) return 2;
if (kb.digit4Key.wasPressedThisFrame) return 3;
if (kb.digit5Key.wasPressedThisFrame) return 4;
if (kb.digit6Key.wasPressedThisFrame) return 5;
if (kb.digit7Key.wasPressedThisFrame) return 6;
if (kb.digit8Key.wasPressedThisFrame) return 7;
if (kb.digit9Key.wasPressedThisFrame) return 8;
return -1;
}
/// <summary>Resolve the Nth EQUIPPABLE distinct item in the local player's bag (skips resources via the catalog).</summary>
ushort NthEquippableBagItem(int n)
{
bool haveDb = SystemAPI.TryGetSingleton<ItemDatabase>(out var db);
foreach (var bag in SystemAPI.Query<DynamicBuffer<InventorySlot>>().WithAll<GhostOwnerIsLocal, PlayerTag>())
{
int idx = 0;
for (int i = 0; i < bag.Length; i++)
{
ushort id = bag[i].ItemId;
if (id == 0 || bag[i].Count <= 0) continue;
if (haveDb && db.Value.IsCreated)
{
ref var b = ref db.Value.Value;
if (!b.TryGetItem(id, out var def) || def.EquipSlot >= EquipSlotId.Count) continue;
}
if (idx == n) return id;
idx++;
}
break;
}
return 0;
}
void SendEquip(Entity connection, ushort itemId)
{
var req = EntityManager.CreateEntity();
EntityManager.AddComponentData(req, new EquipRequest { ItemId = itemId });
EntityManager.AddComponentData(req, new SendRpcCommandRequest { TargetConnection = connection });
}
void SendUnequip(Entity connection, byte slot)
{
var req = EntityManager.CreateEntity();
EntityManager.AddComponentData(req, new UnequipRequest { Slot = slot });
EntityManager.AddComponentData(req, new SendRpcCommandRequest { TargetConnection = connection });
}
}
}
@@ -1,2 +0,0 @@
fileFormatVersion: 2
guid: 7a9bec24b84553746aec834c1b56dfd6
@@ -1,64 +0,0 @@
using ProjectM.Simulation;
using Unity.Entities;
using Unity.NetCode;
namespace ProjectM.Client
{
/// <summary>
/// Client-only sender for <see cref="InventoryDepositRequest"/> RPCs (move the local player's PERSONAL
/// inventory into the shared base stockpile / global ledger). A one-off action (not per-tick predicted
/// input), so it is an RPC: on the deposit key edge (G = deposit ALL) it creates the request entity
/// targeted at the server connection, and the server applies it authoritatively in
/// <see cref="ProjectM.Server.InventoryDepositSystem"/>. Managed SystemBase because it reads the managed
/// Input System; Input System types are fully qualified and <c>using UnityEngine.InputSystem;</c> is
/// intentionally omitted (that namespace defines a PlayerInput type that collides with
/// <see cref="ProjectM.Simulation.PlayerInput"/>). An editor-only static hook (<see cref="Deposit"/>)
/// drives the same path from execute_code for headless validation without a focused Game view. The wire
/// type is unconditional; only this send SYSTEM is build-time managed.
/// </summary>
[WorldSystemFilter(WorldSystemFilterFlags.ClientSimulation)]
public partial class InventoryDepositSendSystem : SystemBase
{
#if UNITY_EDITOR
struct PendingDeposit { public ushort ItemId; public int Count; }
static readonly System.Collections.Generic.Queue<PendingDeposit> s_Pending =
new System.Collections.Generic.Queue<PendingDeposit>();
/// <summary>EDITOR / execute_code hook: queue a deposit (ItemId 0 = deposit all; Count &lt;= 0 = all of that item).</summary>
public static void Deposit(ushort itemId = 0, int count = 0) =>
s_Pending.Enqueue(new PendingDeposit { ItemId = itemId, Count = count });
#endif
protected override void OnCreate()
{
RequireForUpdate<NetworkId>();
}
protected override void OnUpdate()
{
// Need the server connection to target the RPC; bail (keeping any queued ops) until connected.
if (!SystemAPI.TryGetSingletonEntity<NetworkId>(out var connection))
return;
var keyboard = UnityEngine.InputSystem.Keyboard.current;
if (keyboard != null && keyboard.gKey.wasPressedThisFrame)
Send(connection, 0, 0); // G -> deposit everything to the base stockpile
#if UNITY_EDITOR
while (s_Pending.Count > 0)
{
var d = s_Pending.Dequeue();
Send(connection, d.ItemId, d.Count);
}
#endif
}
void Send(Entity connection, ushort itemId, int count)
{
var request = EntityManager.CreateEntity();
EntityManager.AddComponentData(request, new InventoryDepositRequest { ItemId = itemId, Count = count });
EntityManager.AddComponentData(request, new SendRpcCommandRequest { TargetConnection = connection });
}
}
}
@@ -1,2 +0,0 @@
fileFormatVersion: 2
guid: 9298a5924b4920b4db8ff2f48732a662
@@ -1,76 +0,0 @@
using ProjectM.Simulation;
using Unity.Entities;
using Unity.NetCode;
namespace ProjectM.Client
{
/// <summary>
/// Client-only sender for shared-storage deposit/withdraw <see cref="StorageOpRequest"/> RPCs. A
/// one-off action (not per-tick predicted input), so it is an RPC: on an interact key edge (E =
/// deposit, Q = withdraw a default test item) it creates the request entity targeted at the server
/// connection, and the server applies it authoritatively in StorageOpReceiveSystem. Managed
/// SystemBase because it reads the managed Input System. Input System types are fully qualified and
/// <c>using UnityEngine.InputSystem;</c> is intentionally omitted (that namespace defines a
/// PlayerInput type that collides with <see cref="ProjectM.Simulation.PlayerInput"/>). An editor-only
/// static hook (Deposit/Withdraw) drives the same path from execute_code for headless validation
/// without a focused Game view.
/// </summary>
[WorldSystemFilter(WorldSystemFilterFlags.ClientSimulation)]
public partial class StorageOpSendSystem : SystemBase
{
// Default test item used by the keyboard interact and the parameterless debug hooks.
const ushort DefaultItemId = 1;
const int DefaultCount = 1;
#if UNITY_EDITOR
struct PendingStorageOp { public byte Op; public ushort ItemId; public int Count; }
static readonly System.Collections.Generic.Queue<PendingStorageOp> s_Pending =
new System.Collections.Generic.Queue<PendingStorageOp>();
/// <summary>EDITOR / execute_code hook: queue a deposit of <paramref name="count"/> of <paramref name="itemId"/>.</summary>
public static void Deposit(ushort itemId = DefaultItemId, int count = DefaultCount) =>
s_Pending.Enqueue(new PendingStorageOp { Op = StorageOp.Deposit, ItemId = itemId, Count = count });
/// <summary>EDITOR / execute_code hook: queue a withdraw of <paramref name="count"/> of <paramref name="itemId"/>.</summary>
public static void Withdraw(ushort itemId = DefaultItemId, int count = DefaultCount) =>
s_Pending.Enqueue(new PendingStorageOp { Op = StorageOp.Withdraw, ItemId = itemId, Count = count });
#endif
protected override void OnCreate()
{
RequireForUpdate<NetworkId>();
}
protected override void OnUpdate()
{
// Need the server connection to target the RPC; bail (keeping any queued ops) until connected.
if (!SystemAPI.TryGetSingletonEntity<NetworkId>(out var connection))
return;
var keyboard = UnityEngine.InputSystem.Keyboard.current;
if (keyboard != null)
{
if (keyboard.eKey.wasPressedThisFrame)
Send(connection, StorageOp.Deposit, DefaultItemId, DefaultCount);
if (keyboard.qKey.wasPressedThisFrame)
Send(connection, StorageOp.Withdraw, DefaultItemId, DefaultCount);
}
#if UNITY_EDITOR
while (s_Pending.Count > 0)
{
var op = s_Pending.Dequeue();
Send(connection, op.Op, op.ItemId, op.Count);
}
#endif
}
void Send(Entity connection, byte op, ushort itemId, int count)
{
var request = EntityManager.CreateEntity();
EntityManager.AddComponentData(request, new StorageOpRequest { Op = op, ItemId = itemId, Count = count });
EntityManager.AddComponentData(request, new SendRpcCommandRequest { TargetConnection = connection });
}
}
}
@@ -1,2 +0,0 @@
fileFormatVersion: 2
guid: d59f540925fe24a439bad6f7a77907fe
@@ -77,16 +77,16 @@ namespace ProjectM.Client
var gamepad = UnityEngine.InputSystem.Gamepad.current; var gamepad = UnityEngine.InputSystem.Gamepad.current;
var mouse = UnityEngine.InputSystem.Mouse.current; var mouse = UnityEngine.InputSystem.Mouse.current;
var keyboard = UnityEngine.InputSystem.Keyboard.current; var keyboard = UnityEngine.InputSystem.Keyboard.current;
bool dashPressed = ((keyboard != null && keyboard.leftShiftKey.wasPressedThisFrame) || (gamepad != null && gamepad.buttonEast.wasPressedThisFrame)) && !BuildPaletteState.Active; bool dashPressed = ((keyboard != null && keyboard.leftShiftKey.wasPressedThisFrame) || (gamepad != null && gamepad.buttonEast.wasPressedThisFrame));
// MC-4 offense rebind: melee combo = PRIMARY (left-click / pad West); ranged projectile demoted to right-click / pad left-trigger. Both suppressed while placing a build (like dash/old fire). // MC-4 offense rebind: melee combo = PRIMARY (left-click / pad West); ranged demoted to right-click / pad LT.
bool attackPressed = ((mouse != null && mouse.leftButton.wasPressedThisFrame) || (gamepad != null && gamepad.buttonWest.wasPressedThisFrame)) && !BuildPaletteState.Active; bool attackPressed = ((mouse != null && mouse.leftButton.wasPressedThisFrame) || (gamepad != null && gamepad.buttonWest.wasPressedThisFrame));
bool firePressed = ((mouse != null && mouse.rightButton.wasPressedThisFrame) || (gamepad != null && gamepad.leftTrigger.wasPressedThisFrame)) && !BuildPaletteState.Active; bool firePressed = ((mouse != null && mouse.rightButton.wasPressedThisFrame) || (gamepad != null && gamepad.leftTrigger.wasPressedThisFrame));
// LANTERN 4-socket kit bindings: keyboard 1..4 (+ gamepad RT/LB/RB); socket 0 also fires on the // LANTERN 4-socket kit bindings: keyboard 1..4 (+ gamepad RT/LB/RB); socket 0 also fires on the
// legacy primary (right-click / pad LT). Suppressed while placing a build. // legacy primary (right-click / pad LT). Build-palette suppression removed 2026-08-07.
bool socket0Pressed = firePressed || ((keyboard != null && keyboard.digit1Key.wasPressedThisFrame) && !BuildPaletteState.Active); bool socket0Pressed = firePressed || ((keyboard != null && keyboard.digit1Key.wasPressedThisFrame));
bool socket1Pressed = ((keyboard != null && keyboard.digit2Key.wasPressedThisFrame) || (gamepad != null && gamepad.rightTrigger.wasPressedThisFrame)) && !BuildPaletteState.Active; bool socket1Pressed = ((keyboard != null && keyboard.digit2Key.wasPressedThisFrame) || (gamepad != null && gamepad.rightTrigger.wasPressedThisFrame));
bool socket2Pressed = ((keyboard != null && keyboard.digit3Key.wasPressedThisFrame) || (gamepad != null && gamepad.leftShoulder.wasPressedThisFrame)) && !BuildPaletteState.Active; bool socket2Pressed = ((keyboard != null && keyboard.digit3Key.wasPressedThisFrame) || (gamepad != null && gamepad.leftShoulder.wasPressedThisFrame));
bool socket3Pressed = ((keyboard != null && keyboard.digit4Key.wasPressedThisFrame) || (gamepad != null && gamepad.rightShoulder.wasPressedThisFrame)) && !BuildPaletteState.Active; bool socket3Pressed = ((keyboard != null && keyboard.digit4Key.wasPressedThisFrame) || (gamepad != null && gamepad.rightShoulder.wasPressedThisFrame));
float2 rightStick = float2.zero; float2 rightStick = float2.zero;
bool gamepadActive = false; bool gamepadActive = false;
@@ -1,41 +0,0 @@
using System.Collections.Generic;
using ProjectM.Simulation;
using Unity.Entities;
using Unity.NetCode;
using UnityEngine;
namespace ProjectM.Client
{
/// <summary>
/// Client-side meta-purchase sender: a static enqueue (the Step-14 base meta-shop panel / execute_code) drained
/// into <see cref="MetaSpendRequest"/> RPCs. Carries only the upgrade ID — the tier is server-computed and the
/// server re-validates everything (Staging gate, class mask, MaxTier, prereq, Aether affordability), so a stale
/// or forged request is simply dropped. A real QUEUE (unlike the single-slot boon pick) — two rapid clicks on
/// two different shop rows must both arrive. Statics reset on play-enter (the stale-bridge hazard).
/// </summary>
[WorldSystemFilter(WorldSystemFilterFlags.ClientSimulation)]
public partial class MetaSpendSendSystem : SystemBase
{
static readonly Queue<byte> s_Queue = new();
[RuntimeInitializeOnLoadMethod(RuntimeInitializeLoadType.SubsystemRegistration)]
static void ResetStatics() => s_Queue.Clear();
/// <summary>Queue a permanent-upgrade purchase by catalog id. The shop row click + execute_code drive this.</summary>
public static void RequestPurchase(byte upgradeId) => s_Queue.Enqueue(upgradeId);
protected override void OnCreate()
{
RequireForUpdate<NetworkId>();
}
protected override void OnUpdate()
{
while (s_Queue.Count > 0)
{
var req = EntityManager.CreateEntity(typeof(MetaSpendRequest), typeof(SendRpcCommandRequest));
EntityManager.SetComponentData(req, new MetaSpendRequest { UpgradeId = s_Queue.Dequeue() });
}
}
}
}
@@ -1,2 +0,0 @@
fileFormatVersion: 2
guid: 9f3197b4ede6dea41a6c4c93ffa51b42
@@ -80,36 +80,8 @@ namespace ProjectM.Client
} }
} }
// Launch countdown beeps (3-2-1) + the boss-arrival roar — replicated-state observations only. // 2026-08-07 audit purge: the 3-2-1 launch countdown beeps and the boss-arrival roar keyed off
if (SystemAPI.TryGetSingleton<RunInfo>(out var runAudio)) // RunInfo.Lifecycle / RoomTypeId. Both went with the run FSM and the boss.
{
int sec = -1;
if (runAudio.Lifecycle == RunLifecycle.Launching && runAudio.LaunchTick != 0
&& SystemAPI.TryGetSingleton<NetworkTime>(out var antime) && antime.ServerTick.IsValid)
{
int tl = new NetworkTick(runAudio.LaunchTick).TicksSince(antime.ServerTick);
if (tl > 0) sec = tl / 60 + 1;
}
if (sec > 0 && sec != _lastCountdownSec && sec <= 3)
_ambient.PlayOneShot(_stingBeep, 0.5f * GameVolume.Sfx);
_lastCountdownSec = sec;
bool inBossRoom = runAudio.Lifecycle == RunLifecycle.InRoom
&& runAudio.CurrentRoomType == RoomTypeId.Boss;
if (!inBossRoom)
{
_bossRoared = false; // re-arm for the next boss room
}
else if (!_bossRoared)
{
foreach (var _ in SystemAPI.Query<RefRO<Health>>().WithAll<EnemyTag>())
{
_ambient.PlayOneShot(_stingRoar, 0.9f * GameVolume.Sfx);
_bossRoared = true;
break;
}
}
}
} }
// ---- Procedural audio (asset-free; mirrors CombatFeedbackSystem.MakeClip) ---- // ---- Procedural audio (asset-free; mirrors CombatFeedbackSystem.MakeClip) ----
@@ -109,13 +109,8 @@ namespace ProjectM.Client
if (_cam.transform.position.x > 500f) if (_cam.transform.position.x > 500f)
{ {
key = 1; // arid default in the expedition region key = 1; // arid default in the expedition region
if (SystemAPI.TryGetSingleton<RunInfo>(out var ri) && ri.Lifecycle != RunLifecycle.Staging) // 2026-08-07 audit purge: per-room biome selection keyed off RunInfo.CurrentBiome. The room
switch (ri.CurrentBiome) // biomes went with the run FSM; the expedition region keeps its single ambient set.
{
case RoomBiomeId.Meadow: key = 0; break;
case RoomBiomeId.Cavern: key = 2; break;
case RoomBiomeId.Blight: key = 3; break;
}
} }
return key; return key;
} }
@@ -152,15 +152,8 @@ namespace ProjectM.Client
if (_cam.transform.position.x > 500f) if (_cam.transform.position.x > 500f)
{ {
key = 1; // arid default key = 1; // arid default
if (SystemAPI.TryGetSingleton<RunInfo>(out var ri) && ri.Lifecycle != RunLifecycle.Staging) // 2026-08-07 audit purge: per-room biome selection keyed off RunInfo.CurrentBiome, which went
{ // with the run FSM. The expedition region keeps its single motion set.
switch (ri.CurrentBiome)
{
case RoomBiomeId.Meadow: key = 0; break;
case RoomBiomeId.Cavern: key = 2; break;
case RoomBiomeId.Blight: key = 3; break;
}
}
} }
if (key != _biomeKey) if (key != _biomeKey)
{ {
@@ -1,164 +0,0 @@
using System.Collections.Generic;
using ProjectM.Simulation;
using Unity.Entities;
using Unity.NetCode;
using Unity.Transforms;
using Unity.Mathematics;
using UnityEngine;
using UnityEngine.UIElements;
namespace ProjectM.Client
{
/// <summary>
/// The choice-of-3 boon modal (RoomReward) — extracted from <see cref="HudSystem"/> into its own client-only,
/// observe-only presentation <see cref="SystemBase"/> in <see cref="PresentationSystemGroup"/>. Owns its own
/// runtime UIDocument sharing <see cref="MenuUi.LoadPanelSettings"/> (sortingOrder 55) so it composes into the
/// same UITK panel + event dispatcher as the HUD (50) / markers (48) / onboarding (60). Reads the local player's
/// replicated <see cref="BoonOffer"/> + the <see cref="BoonCatalog"/> blob; card clicks enqueue through
/// <see cref="BoonSendSystem.PickBoon"/>. Built lazily on first show.
/// </summary>
[WorldSystemFilter(WorldSystemFilterFlags.ClientSimulation)]
[UpdateInGroup(typeof(PresentationSystemGroup))]
public partial class BoonModalHudSystem : SystemBase
{
GameObject _go;
UIDocument _doc;
bool _built;
VisualElement _boonModal, _boonCardRow;
int _boonShownFor; // last exact (Option0|Option1<<8|Option2<<16)+1 signature the modal was built for
bool _boonModalBuilt;
protected override void OnStartRunning()
{
if (_go != null) return;
MenuUi.EnsureEventSystem();
_go = new GameObject("~HUDBoonModal");
_doc = _go.AddComponent<UIDocument>();
_doc.panelSettings = MenuUi.LoadPanelSettings();
_doc.sortingOrder = 55;
}
protected override void OnDestroy()
{
if (_go != null) Object.Destroy(_go);
}
protected override void OnUpdate()
{
if (_doc == null) return;
var root = _doc.rootVisualElement;
if (root == null) return; // panel not initialised yet (next frame)
if (!_built)
{
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
_built = true;
}
bool haveRun = SystemAPI.TryGetSingleton<RunInfo>(out var runInfo);
BoonOffer localOffer = default;
bool hasOffer = false;
foreach (var off in SystemAPI.Query<RefRO<BoonOffer>>().WithAll<PlayerTag, GhostOwnerIsLocal>())
{
localOffer = off.ValueRO;
hasOffer = true;
break;
}
BlobAssetReference<BoonCatalogBlob> boonPool = default;
if (SystemAPI.TryGetSingleton<BoonCatalog>(out var bcat))
boonPool = bcat.Value;
// Lifecycle gate (post-impl review): even a stale replicated Pending never shows the modal outside
// the reward window.
UpdateBoonModal(localOffer, hasOffer && localOffer.Pending == 1
&& haveRun && runInfo.Lifecycle == RunLifecycle.RoomReward, boonPool);
}
void UpdateBoonModal(BoonOffer offer, bool show, BlobAssetReference<BoonCatalogBlob> pool)
{
if (!show || !pool.IsCreated)
{
if (_boonModal != null) _boonModal.style.display = DisplayStyle.None;
_boonShownFor = 0;
return;
}
var root = _doc != null ? _doc.rootVisualElement : null;
if (root == null) return;
if (!_boonModalBuilt)
{
BuildBoonModal(root);
_boonModalBuilt = true;
}
// Rebuild the three cards only when the offer actually changes (a new room's deal).
// Exact signature (post-impl review): the old lossy byte XOR could collide across consecutive
// rooms and leave stale card labels. +1 keeps 0 as the hidden/reset sentinel.
int sig = 1 + (offer.Option0 | (offer.Option1 << 8) | (offer.Option2 << 16));
if (_boonShownFor != sig)
{
_boonCardRow.Clear();
ref var defs = ref pool.Value;
for (byte k = 0; k < 3; k++)
{
byte id = k == 2 ? offer.Option2 : k == 1 ? offer.Option1 : offer.Option0;
int idx = BoonMath.FindDef(ref defs, id);
string title = idx >= 0 ? defs.Defs[idx].Name.ToString() : ("BOON " + id);
string desc = idx >= 0 ? defs.Defs[idx].Desc.ToString() : "";
byte weight = idx >= 0 ? defs.Defs[idx].Weight : (byte)100;
byte pick = k; // capture a COPY into the closure, never the loop variable
var card = MenuUi.Button(title + "\n" + desc, () => BoonSendSystem.PickBoon(pick));
card.style.width = 200;
card.style.height = StyleKeyword.Auto; // long descs grow the card
card.style.minHeight = 96;
// Rarity from the draw weight (100 common / 60 uncommon / 30 rare / 10 epic).
var rare = weight <= 10 ? new Color(1f, 0.82f, 0.30f)
: weight <= 30 ? new Color(0.65f, 0.50f, 1f)
: weight <= 60 ? new Color(0.45f, 0.95f, 0.55f)
: new Color(1f, 1f, 1f, 0.30f);
MenuUi.Border(card, rare, weight <= 30 ? 2.5f : 1.5f);
card.style.marginLeft = 8;
card.style.marginRight = 8;
card.style.whiteSpace = WhiteSpace.Normal;
_boonCardRow.Add(card);
}
_boonShownFor = sig;
}
_boonModal.style.display = DisplayStyle.Flex;
}
void BuildBoonModal(VisualElement root)
{
_boonModal = new VisualElement { pickingMode = PickingMode.Ignore };
_boonModal.style.position = Position.Absolute;
_boonModal.style.left = 0; _boonModal.style.right = 0;
_boonModal.style.top = 0; _boonModal.style.bottom = 0;
_boonModal.style.alignItems = Align.Center;
_boonModal.style.justifyContent = Justify.Center;
_boonModal.style.display = DisplayStyle.None;
var box = new VisualElement();
box.style.backgroundColor = new Color(0.07f, 0.09f, 0.12f, 0.96f);
box.style.borderTopLeftRadius = 10; box.style.borderTopRightRadius = 10;
box.style.borderBottomLeftRadius = 10; box.style.borderBottomRightRadius = 10;
box.style.paddingLeft = 18; box.style.paddingRight = 18;
box.style.paddingTop = 14; box.style.paddingBottom = 16;
box.style.alignItems = Align.Center;
var title = new Label("ROOM CLEARED — CHOOSE A BOON");
title.style.color = new Color(0.6f, 1f, 0.7f);
title.style.fontSize = 18;
title.style.unityFontStyleAndWeight = FontStyle.Bold;
title.style.marginBottom = 12;
box.Add(title);
_boonCardRow = new VisualElement();
_boonCardRow.style.flexDirection = FlexDirection.Row;
box.Add(_boonCardRow);
_boonModal.Add(box);
root.Add(_boonModal);
}
}
}
@@ -1,2 +0,0 @@
fileFormatVersion: 2
guid: 90c2dc6391260794dbdf466b2f8887e5
@@ -1,240 +0,0 @@
using System.Collections.Generic;
using ProjectM.Simulation;
using Unity.Entities;
using Unity.NetCode;
using Unity.Transforms;
using Unity.Mathematics;
using UnityEngine;
using UnityEngine.UIElements;
namespace ProjectM.Client
{
/// <summary>
/// DR-046 base class-select + prep-loadout panels (Staging) + the room-exit portal prompt (RoomExplore) —
/// extracted from <see cref="HudSystem"/> into their own client-only, observe-only presentation
/// <see cref="SystemBase"/> in <see cref="PresentationSystemGroup"/>. Owns its own runtime UIDocument sharing
/// <see cref="MenuUi.LoadPanelSettings"/> (sortingOrder 54). Recomputes the local class + ore/bio/aether + the
/// Staging gate + the RoomExplore portal proximity locally; clicks enqueue through
/// <see cref="ClassSelectSendSystem"/> / <see cref="PrepPurchaseSendSystem"/> / <see cref="PortalInteractSendSystem"/>.
/// NOTE (behavior-preserving): the class/prep Staging gate reproduces the original's FULL condition — it also
/// requires the <see cref="MetaUpgradeCatalog"/> + <see cref="MetaTierState"/> buffer to be present (the panels
/// were gated on the same <c>metaShow</c> boolean as the meta shop).
/// </summary>
[WorldSystemFilter(WorldSystemFilterFlags.ClientSimulation)]
[UpdateInGroup(typeof(PresentationSystemGroup))]
public partial class ClassPrepPortalHudSystem : SystemBase
{
GameObject _go;
UIDocument _doc;
bool _built;
VisualElement _classPanel, _prepPanel, _prepRowsHost;
Label _classTitle, _prepTitle, _portalPrompt;
Button _classWarBtn, _classRangerBtn;
bool _classPanelBuilt, _prepPanelBuilt, _portalBuilt;
int _classShownFor, _prepShownFor;
protected override void OnStartRunning()
{
if (_go != null) return;
MenuUi.EnsureEventSystem();
_go = new GameObject("~HUDClassPrepPortal");
_doc = _go.AddComponent<UIDocument>();
_doc.panelSettings = MenuUi.LoadPanelSettings();
_doc.sortingOrder = 54;
}
protected override void OnDestroy()
{
if (_go != null) Object.Destroy(_go);
}
protected override void OnUpdate()
{
if (_doc == null) return;
var root = _doc.rootVisualElement;
if (root == null) return; // panel not initialised yet (next frame)
if (!_built)
{
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
_built = true;
}
bool haveRun = SystemAPI.TryGetSingleton<RunInfo>(out var runInfo);
// Resources from the ledger (last entry per type wins, matching the core loop).
int aether = 0, ore = 0, bio = 0;
if (SystemAPI.TryGetSingletonEntity<ResourceLedger>(out var ledgerE))
{
var buf = SystemAPI.GetBuffer<StorageEntry>(ledgerE);
for (int i = 0; i < buf.Length; i++)
{
var en = buf[i];
if (en.ItemId == ResourceId.Aether) aether = en.Count;
else if (en.ItemId == ResourceId.Ore) ore = en.Count;
else if (en.ItemId == ResourceId.Biomass) bio = en.Count;
}
}
// Local class from the replicated AbilityRef (tracks the dev class-switch; PlayerClass is server-only).
byte localClass = ClassTraits.WarriorClass;
bool haveLocalPlayer = false;
foreach (var fr in SystemAPI.Query<RefRO<FrameId>>().WithAll<PlayerTag, GhostOwnerIsLocal>())
{
localClass = ClassTraits.Normalize(fr.ValueRO.Value); // FrameId is the sole class signal (legacy AbilityRef deleted)
haveLocalPlayer = true;
break;
}
// Faithful reproduction of the original `metaShow` gate: class/prep were shown on the SAME condition as
// the meta shop, which requires the meta catalog + tier buffer to exist.
DynamicBuffer<MetaTierState> metaRecord = default;
bool metaShow = haveRun && runInfo.Lifecycle == RunLifecycle.Staging && haveLocalPlayer
&& SystemAPI.TryGetSingleton<MetaUpgradeCatalog>(out var metaCat) && metaCat.Value.IsCreated
&& SystemAPI.TryGetSingletonBuffer<MetaTierState>(out metaRecord, true);
UpdateClassPanel(metaShow, localClass); // DR-046: base class pick (Staging)
UpdatePrepPanel(metaShow, ore, bio, aether); // DR-046: base prep loadout (Staging)
UpdatePortalPrompt(haveRun ? runInfo : default, haveRun); // DR-046: room-exit portal prompt (RoomExplore)
}
void UpdateClassPanel(bool show, byte classId)
{
if (!show) { if (_classPanel != null) _classPanel.style.display = DisplayStyle.None; _classShownFor = 0; return; }
var root = _doc != null ? _doc.rootVisualElement : null; if (root == null) return;
if (!_classPanelBuilt) { BuildClassPanel(root); _classPanelBuilt = true; }
int sig = classId + 1;
if (_classShownFor != sig)
{
bool ranger = classId == ClassTraits.RangerClass;
_classWarBtn.text = ranger ? "WARRIOR" : "WARRIOR ✓";
_classRangerBtn.text = ranger ? "RANGER ✓" : "RANGER";
_classWarBtn.SetEnabled(ranger);
_classRangerBtn.SetEnabled(!ranger);
_classShownFor = sig;
}
_classPanel.style.display = DisplayStyle.Flex;
}
void BuildClassPanel(VisualElement root)
{
_classPanel = new VisualElement { pickingMode = PickingMode.Ignore };
_classPanel.style.position = Position.Absolute;
_classPanel.style.left = 12; _classPanel.style.top = Length.Percent(22);
_classPanel.style.display = DisplayStyle.None;
var box = new VisualElement();
box.style.backgroundColor = new Color(0.07f, 0.09f, 0.12f, 0.92f);
MenuUi.Round(box, 10);
box.style.paddingLeft = 12; box.style.paddingRight = 12; box.style.paddingTop = 10; box.style.paddingBottom = 10;
_classTitle = new Label("CLASS");
_classTitle.style.color = MenuUi.Accent; _classTitle.style.fontSize = 14;
_classTitle.style.unityFontStyleAndWeight = FontStyle.Bold; _classTitle.style.marginBottom = 8;
box.Add(_classTitle);
_classWarBtn = MenuUi.Button("WARRIOR", () => ClassSelectSendSystem.RequestClass(ClassTraits.WarriorClass));
_classWarBtn.style.marginBottom = 4; box.Add(_classWarBtn);
_classRangerBtn = MenuUi.Button("RANGER", () => ClassSelectSendSystem.RequestClass(ClassTraits.RangerClass));
box.Add(_classRangerBtn);
_classPanel.Add(box); root.Add(_classPanel);
}
void UpdatePrepPanel(bool show, int ore, int bio, int aether)
{
if (!show) { if (_prepPanel != null) _prepPanel.style.display = DisplayStyle.None; _prepShownFor = 0; return; }
var root = _doc != null ? _doc.rootVisualElement : null; if (root == null) return;
if (!_prepPanelBuilt) { BuildPrepPanel(root); _prepPanelBuilt = true; }
uint boughtMask = 0;
foreach (var mods in SystemAPI.Query<DynamicBuffer<StatModifier>>().WithAll<PlayerTag, GhostOwnerIsLocal>())
{
for (int m = 0; m < mods.Length; m++)
{
uint sid = mods[m].SourceId;
if (sid >= Tuning.PrepSourceIdBase && sid < Tuning.PrepSourceIdBase + Tuning.PrepSourceIdSpan)
boughtMask |= (uint)(1 << (int)(sid - Tuning.PrepSourceIdBase));
}
break;
}
int sig = ore * 7 ^ bio * 13 ^ aether * 31 ^ (int)boughtMask * 101;
if (sig == 0) sig = 1;
if (_prepShownFor != sig)
{
_prepRowsHost.Clear();
for (int i = 0; i < PrepCatalog.Count; i++)
{
var r = PrepCatalog.Rows[i];
int have = r.CostResId == ResourceId.Aether ? aether : r.CostResId == ResourceId.Biomass ? bio : ore;
bool bought = (boughtMask & (uint)(1 << r.Id)) != 0;
string resName = r.CostResId == ResourceId.Aether ? "Aether" : r.CostResId == ResourceId.Biomass ? "Biomass" : "Ore";
string label = PrepLabel(r.Id) + (bought ? " BOUGHT" : " - " + r.Cost + " " + resName);
byte buyId = r.Id;
var row = MenuUi.Button(label, () => PrepPurchaseSendSystem.RequestPrep(buyId));
row.style.width = 240; row.style.marginBottom = 4;
row.style.whiteSpace = WhiteSpace.Normal; row.style.unityTextAlign = TextAnchor.MiddleLeft;
row.SetEnabled(!bought && have >= r.Cost);
_prepRowsHost.Add(row);
}
_prepShownFor = sig;
}
_prepPanel.style.display = DisplayStyle.Flex;
}
static string PrepLabel(byte id) => id == 0 ? "+30 Max HP" : id == 1 ? "+12% Move Speed"
: id == 2 ? "+20% Melee Damage" : "+20% Ranged Damage";
void BuildPrepPanel(VisualElement root)
{
_prepPanel = new VisualElement { pickingMode = PickingMode.Ignore };
_prepPanel.style.position = Position.Absolute;
_prepPanel.style.left = 12; _prepPanel.style.top = Length.Percent(45);
_prepPanel.style.display = DisplayStyle.None;
var box = new VisualElement();
box.style.backgroundColor = new Color(0.07f, 0.09f, 0.12f, 0.92f);
MenuUi.Round(box, 10);
box.style.paddingLeft = 12; box.style.paddingRight = 12; box.style.paddingTop = 10; box.style.paddingBottom = 10;
_prepTitle = new Label("PREP LOADOUT (lasts the run)");
_prepTitle.style.color = MenuUi.Accent; _prepTitle.style.fontSize = 14;
_prepTitle.style.unityFontStyleAndWeight = FontStyle.Bold; _prepTitle.style.marginBottom = 8;
box.Add(_prepTitle);
_prepRowsHost = new VisualElement(); box.Add(_prepRowsHost);
_prepPanel.Add(box); root.Add(_prepPanel);
}
void UpdatePortalPrompt(RunInfo runInfo, bool haveRun)
{
var root = _doc != null ? _doc.rootVisualElement : null; if (root == null) return;
if (!_portalBuilt) { BuildPortalPrompt(root); _portalBuilt = true; }
bool show = false, inRange = false;
if (haveRun && runInfo.Lifecycle == RunLifecycle.RoomExplore
&& SystemAPI.TryGetSingleton<BaseAnchor>(out var anchor))
{
show = true; // room cleared -> ALWAYS steer the player to the (now visible) portal, not only when in range
float3 portalPos = RegionMath.ExpeditionPortalPos(BaseGridMath.PlotCenter(anchor), (byte)(runInfo.CurrentRoom & 1));
foreach (var lt in SystemAPI.Query<RefRO<LocalTransform>>().WithAll<PlayerTag, GhostOwnerIsLocal>())
{
inRange = math.distance(lt.ValueRO.Position.xz, portalPos.xz) <= Tuning.PortalInteractRange;
if (inRange)
{
var kb = UnityEngine.InputSystem.Keyboard.current;
if (kb != null && kb.eKey.wasPressedThisFrame) PortalInteractSendSystem.Interact();
}
break;
}
_portalPrompt.text = inRange
? "PRESS E TO LEAVE — the haul comes home"
: "ROOM CLEAR — reach the glowing portal to move on";
}
_portalPrompt.style.display = show ? DisplayStyle.Flex : DisplayStyle.None;
}
void BuildPortalPrompt(VisualElement root)
{
_portalPrompt = HudUi.Display("PRESS E TO LEAVE — the haul comes home", 20, new Color(0.55f, 0.95f, 1f), TextAnchor.MiddleCenter);
_portalPrompt.style.position = Position.Absolute;
_portalPrompt.style.left = 0; _portalPrompt.style.right = 0; _portalPrompt.style.bottom = 240;
_portalPrompt.pickingMode = PickingMode.Ignore;
_portalPrompt.style.display = DisplayStyle.None;
root.Add(_portalPrompt);
}
}
}
@@ -1,2 +0,0 @@
fileFormatVersion: 2
guid: 32e011d68689e89488879377a7fb4c3a
@@ -225,7 +225,7 @@ namespace ProjectM.Client
bool isEnemy = SystemAPI.HasComponent<EnemyTag>(entity); bool isEnemy = SystemAPI.HasComponent<EnemyTag>(entity);
uint windup = isEnemy && SystemAPI.HasComponent<AttackWindup>(entity) ? SystemAPI.GetComponent<AttackWindup>(entity).WindUpUntilTick : 0u; uint windup = isEnemy && SystemAPI.HasComponent<AttackWindup>(entity) ? SystemAPI.GetComponent<AttackWindup>(entity).WindUpUntilTick : 0u;
bool isLocalPlayer = entity == _localPlayer; bool isLocalPlayer = entity == _localPlayer;
bool isStructure = SystemAPI.HasComponent<PlacedStructure>(entity); // EB-1: suppress combat cues -> StructureFeedbackSystem const bool isStructure = false; // structures deleted 2026-08-07 (audit purge)
if (_cache.TryGetValue(entity, out var prev)) if (_cache.TryGetValue(entity, out var prev))
{ {
@@ -148,23 +148,10 @@ namespace ProjectM.Client
} }
PruneUnseen(_barrelLights); PruneUnseen(_barrelLights);
// ---- portal light (RoomExplore only) ---- // 2026-08-07 audit purge: the room-exit portal light keyed off RunInfo.Lifecycle == RoomExplore.
bool portalOn = false; // Portals went with the run FSM; release any pooled light so nothing leaks.
if (SystemAPI.TryGetSingleton<RunInfo>(out var runInfo) if (_portalLight != null) { Return(_portalLight); _portalLight = null; }
&& runInfo.Lifecycle == RunLifecycle.RoomExplore
&& SystemAPI.TryGetSingleton<BaseAnchor>(out var anchor))
{
portalOn = true;
var pos = RegionMath.ExpeditionPortalPos(BaseGridMath.PlotCenter(anchor), (byte)(runInfo.CurrentRoom & 1));
if (_portalLight == null) _portalLight = Rent();
float baseI = cfg != null ? cfg.PortalIntensity : 3.5f;
float amp = cfg != null ? cfg.PortalPulseAmp : 1.2f;
_portalLight.color = cfg != null ? cfg.PortalColor : new Color(0.55f, 0.95f, 1f, 1f);
_portalLight.range = cfg != null ? cfg.PortalRange : 13f;
_portalLight.intensity = baseI + amp * Mathf.Sin(UnityEngine.Time.time * 4f);
_portalLight.transform.position = new Vector3(pos.x, pos.y + 2.2f, pos.z);
}
if (!portalOn && _portalLight != null) { Return(_portalLight); _portalLight = null; }
// ---- impact flashes ---- // ---- impact flashes ----
float now = UnityEngine.Time.time; float now = UnityEngine.Time.time;
@@ -85,7 +85,6 @@ namespace ProjectM.Client
dt = dt, dt = dt,
prevPos = _prevPos, prevPos = _prevPos,
seen = seen, seen = seen,
isLunging = SystemAPI.GetComponentLookup<IsLunging>(true),
}; };
Dependency = job.Schedule(Dependency); // .Schedule (not parallel): mutates _prevPos Dependency = job.Schedule(Dependency); // .Schedule (not parallel): mutates _prevPos
@@ -111,7 +110,7 @@ namespace ProjectM.Client
{ {
public FastAnimatorParameter moveX, moveZ, speed, isAttacking, isDead, isHit, isHitHeavy; public FastAnimatorParameter moveX, moveZ, speed, isAttacking, isDead, isHit, isHitHeavy;
public float el, reactSeconds, staggerSeconds, staggerDamage; // 07-21 hit-react (reactSeconds 0 = off) public float el, reactSeconds, staggerSeconds, staggerDamage; // 07-21 hit-react (reactSeconds 0 = off)
[Unity.Collections.ReadOnly] public ComponentLookup<IsLunging> isLunging; // A7: a lunge has no AttackWindup; OR it in so the boss/Charger attack anim plays during the committed lunge
public float dt; public float dt;
public NativeParallelHashMap<Entity, EnemyAnimCache> prevPos; public NativeParallelHashMap<Entity, EnemyAnimCache> prevPos;
@@ -149,7 +148,7 @@ namespace ProjectM.Client
float2 facing = AnimParamMath.PlanarForward(xform.Rotation); float2 facing = AnimParamMath.PlanarForward(xform.Rotation);
float3 p = AnimParamMath.LocomotionParams(vel, facing, stats.MoveSpeed); float3 p = AnimParamMath.LocomotionParams(vel, facing, stats.MoveSpeed);
bool attacking = windup.WindUpUntilTick != 0 || (isLunging.HasComponent(e) && isLunging.IsComponentEnabled(e)); // A7: the committed lunge (which zeroes AttackWindup) still animates as an attack bool attacking = windup.WindUpUntilTick != 0; // the IsLunging OR went with the Charger purge (2026-08-07)
// B3: the corpse window — Health.Current is replicated, so <=0 IS the death read. A corpse // B3: the corpse window — Health.Current is replicated, so <=0 IS the death read. A corpse
// plays the Death state and nothing else (no jog, no frozen attack). // plays the Death state and nothing else (no jog, no frozen attack).
@@ -98,7 +98,6 @@ namespace ProjectM.Client
EntityManager.CompleteDependencyBeforeRO<AttackWindup>(); EntityManager.CompleteDependencyBeforeRO<AttackWindup>();
EntityManager.CompleteDependencyBeforeRO<EnemyStats>(); EntityManager.CompleteDependencyBeforeRO<EnemyStats>();
EntityManager.CompleteDependencyBeforeRO<EnemyTelegraph>(); EntityManager.CompleteDependencyBeforeRO<EnemyTelegraph>();
EntityManager.CompleteDependencyBeforeRO<IsLunging>();
// Local player (strike-beep proximity gate). // Local player (strike-beep proximity gate).
_localPlayer = Entity.Null; _localPlayer = Entity.Null;
@@ -122,7 +121,6 @@ namespace ProjectM.Client
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;
_dangerSeen.Clear(); _dangerSeen.Clear();
_enemySeen.Clear(); _enemySeen.Clear();
bool bossRoom = SystemAPI.TryGetSingleton<RunInfo>(out var dangerRi) && dangerRi.Lifecycle == RunLifecycle.InRoom && dangerRi.CurrentRoomType == RoomTypeId.Boss; // A7: in a Boss room the Charger-kind enemy IS the boss (adds are swarmers)
if (serverTick.IsValid) if (serverTick.IsValid)
{ {
@@ -151,26 +149,16 @@ namespace ProjectM.Client
} }
_prevWindup[entity] = until; _prevWindup[entity] = until;
// Feature D: a committed Charger lunge keeps the cue ALIVE past windup (AttackWindup zeroes at commit). if (until == 0u) continue;
bool lunging = SystemAPI.HasComponent<IsLunging>(entity) && SystemAPI.IsComponentEnabled<IsLunging>(entity);
bool isBoss = bossRoom && tele.ValueRO.Kind == ZoneEnemyMath.KindCharger; // A7: boss radial SLAM telegraph
if (until == 0u && !lunging) continue; var untilTick = new Unity.NetCode.NetworkTick(until);
if (!untilTick.IsValid || !untilTick.IsNewerThan(serverTick)) continue; // windup already elapsed
float intensity; int remaining = untilTick.TicksSince(serverTick);
if (lunging) // Feature C: per-enemy windup duration (baked, client-safe) -> ramps 0->1 ending AT impact for
{ // any windup length.
intensity = 1f; // mid-lunge: max danger, persistent until IsLunging clears float windupDur = math.max(1f, tele.ValueRO.WindupTicks);
} float intensity = math.saturate(1f - remaining / windupDur);
else {;
{
var untilTick = new Unity.NetCode.NetworkTick(until);
if (!untilTick.IsValid || !untilTick.IsNewerThan(serverTick)) continue; // windup already elapsed
int remaining = untilTick.TicksSince(serverTick);
// Feature C: per-enemy windup duration (baked, client-safe) -> ramps 0->1 ending AT impact for
// any windup length (fixes the Charger plateauing early under the old hard-coded 22).
float windupDur = isBoss ? Tuning.BossSlamWindupTicks : math.max(1f, tele.ValueRO.WindupTicks); // A7: ramp over the boss's real slam wind-up
intensity = math.saturate(1f - remaining / windupDur);
// Near-impact strike beep (deferred-items pass): a "dodge NOW" cue once per windup, gated to // 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). // enemies near the local player (the danger cone already proves it's winding up to strike).
@@ -211,33 +199,9 @@ namespace ProjectM.Client
_dangerZones[entity] = go; _dangerZones[entity] = go;
} }
float coneRange = math.max(1f, stats.ValueRO.AttackRange + 0.6f); float coneRange = math.max(1f, stats.ValueRO.AttackRange + 0.6f);
if (lunging) coneRange += 1.5f; // forward-stretch the wedge to read the committed travel // 2026-08-07 audit purge: the boss radial-slam ring, the boss lunge wedge and the Spitter aim
if (isBoss && !lunging) // lane are gone with BossState / IsLunging / SpitterState. One enemy kind, one melee wedge.
{ BuildDangerMesh(go.GetComponent<MeshFilter>().sharedMesh, coneRange, 0.7f, intensity);
// A7: the boss SLAM is RADIAL (Tuning.BossSlamRadius) -> paint a FULL ground ring so the tell
// matches the hit area (a forward wedge sized to melee reach would lie about a radial AoE).
BuildDangerMesh(go.GetComponent<MeshFilter>().sharedMesh, Tuning.BossSlamRadius, 3.14159f, intensity);
}
else if (isBoss)
{
// B4: the boss LUNGE is a committed forward gap-closer (IsLunging bit on through windup +
// travel) - a radial ring would lie about the threat shape; paint a long narrow travel wedge.
BuildDangerMesh(go.GetComponent<MeshFilter>().sharedMesh, math.max(coneRange, 8f), 0.45f, intensity);
}
else if (tele.ValueRO.Kind == ZoneEnemyMath.KindSpitter)
{
// MC-3: a Spitter is a RANGED threat — a melee wedge at its feet is useless. Paint a thin aim
// LANE along its (face-locked) facing out to projectile reach during wind-up, brightening as the
// shot nears so the player reads the line to dodge/dash across it.
float laneLen = 12f;
if (SystemAPI.HasComponent<SpitterState>(entity))
{
var ss = SystemAPI.GetComponent<SpitterState>(entity);
laneLen = math.max(4f, ss.PreferredRange + ss.RangeTolerance + 2f);
}
BuildLaneMesh(go.GetComponent<MeshFilter>().sharedMesh, laneLen, 0.28f, intensity);
}
else BuildDangerMesh(go.GetComponent<MeshFilter>().sharedMesh, coneRange, 0.7f, intensity);
float2 fwd = AnimParamMath.PlanarForward(xf.ValueRO.Rotation); float2 fwd = AnimParamMath.PlanarForward(xf.ValueRO.Rotation);
var tr = go.transform; var tr = go.transform;
tr.position = (Vector3)xf.ValueRO.Position + Vector3.up * 0.06f; tr.position = (Vector3)xf.ValueRO.Position + Vector3.up * 0.06f;
@@ -76,7 +76,7 @@ namespace ProjectM.Client
// Collect living enemies within range (nearest-capped by MaxMarkers). // Collect living enemies within range (nearest-capped by MaxMarkers).
_positions.Clear(); _positions.Clear();
float rangeSq = FeelConfig.EnemyMarkerRange * FeelConfig.EnemyMarkerRange; float rangeSq = FeelConfig.EnemyMarkerRange * FeelConfig.EnemyMarkerRange;
foreach (var lt in SystemAPI.Query<RefRO<LocalTransform>>().WithAll<EnemyTag>().WithNone<Dying, BossState>()) foreach (var lt in SystemAPI.Query<RefRO<LocalTransform>>().WithAll<EnemyTag>().WithNone<Dying>())
{ {
float3 p = lt.ValueRO.Position; float3 p = lt.ValueRO.Position;
if (haveLocal && math.distancesq(p, localPos) > rangeSq) continue; if (haveLocal && math.distancesq(p, localPos) > rangeSq) continue;
@@ -142,134 +142,10 @@ namespace ProjectM.Client
bool haveTick = SystemAPI.TryGetSingleton<NetworkTime>(out var nt); bool haveTick = SystemAPI.TryGetSingleton<NetworkTime>(out var nt);
int huskCount = _huskQuery.CalculateEntityCount(); int huskCount = _huskQuery.CalculateEntityCount();
// ---- Macro banner: run-lifecycle header (the siege/cycle machinery is retired — LANTERN purge) ---- // 2026-08-07 audit purge: the macro run banner, the location sub-line, the ready-check panel, the
bool haveRun = SystemAPI.TryGetSingleton<RunInfo>(out var runInfo); // boss bar and the run-depth dots were all driven by RunInfo / ExpeditionObjective / PlayerReady —
bool onRun = haveRun && runInfo.Lifecycle != RunLifecycle.Staging; // the superseded base/expedition FSM. All deleted; the HUD is now vitals + threat + resources + the
if (haveRun) // ability bar (its own system).
{
var col = onRun ? new Color(1f, 0.8f, 0.4f) : new Color(0.45f, 0.9f, 0.7f);
_phaseText.text = onRun ? "ON EXPEDITION" : "AT BASE";
_phaseText.style.color = col;
_banner.style.borderBottomColor = col;
RetintPanel(_banner, PanelDark);
}
else
{
_phaseText.text = "";
}
// ---- Location line (banner sub-line) — Step 14: driven by the replicated RunInfo lifecycle FSM ----
var cam = Camera.main; // camera-X region signal still feeds downstream panels (atmosphere/threat)
bool onExpedition = cam != null && cam.transform.position.x > ExpeditionRegionXMin;
SystemAPI.TryGetSingleton<ExpeditionObjective>(out var obj);
if (haveRun)
{
switch (runInfo.Lifecycle)
{
case RunLifecycle.Staging:
// The READY panel (bottom-center) owns the action + N/M count; the top line frames intent.
_locationText.text = "AT THE BASE - build defenses, buy upgrades, READY UP to launch";
_locationText.style.color = new Color(0.55f, 0.85f, 1f);
break;
case RunLifecycle.Launching:
{
// Wrap-safe countdown (post-impl review): the client's PREDICTED tick passes LaunchTick
// near zero while Lifecycle is still Launching — signed TicksSince, never raw uint math.
int secs = 0;
if (runInfo.LaunchTick != 0 && SystemAPI.TryGetSingleton<NetworkTime>(out var ntime)
&& ntime.ServerTick.IsValid)
{
int ticksLeft = new NetworkTick(runInfo.LaunchTick).TicksSince(ntime.ServerTick);
if (ticksLeft > 0) secs = ticksLeft / 60 + 1;
}
_locationText.text = "LAUNCHING IN " + secs + " - un-ready [T] to abort";
_locationText.style.color = new Color(1f, 0.9f, 0.4f);
break;
}
case RunLifecycle.InRoom:
{
string room = "ROOM " + (runInfo.CurrentRoom + 1) + "/" + runInfo.RoomCount
+ " " + RoomTypeLabel(runInfo.CurrentRoomType);
_locationText.text = obj.State == ExpeditionObjectiveState.Active
? room + " - " + obj.Remaining + " enemies remaining"
: room + " - clear it to advance";
_locationText.style.color = new Color(1f, 0.8f, 0.4f);
break;
}
case RunLifecycle.RoomReward:
_locationText.text = "ROOM CLEARED - choose your boon";
_locationText.style.color = new Color(0.5f, 1f, 0.6f);
break;
case RunLifecycle.RoomExplore: // Phase 0: without this case the RoomReward text stuck through the loot window
_locationText.text = "ROOM CLEAR - grab the loot, take the portal to move on";
_locationText.style.color = new Color(0.5f, 1f, 0.6f);
break;
case RunLifecycle.RouteSelect:
_locationText.text = "CHOOSE YOUR PATH";
_locationText.style.color = new Color(0.55f, 0.85f, 1f);
break;
case RunLifecycle.Returning:
_locationText.text = "RETURNING HOME...";
_locationText.style.color = new Color(0.7f, 0.9f, 1f);
break;
}
}
else
{
_locationText.text = "";
}
// The clickable READY panel (Staging/Launching). Counts are the replicated send-to-all PlayerReady flags.
int rTotal = 0, rReady = 0;
bool localReady = false;
int launchSecs = 0;
bool readyShow = haveRun
&& (runInfo.Lifecycle == RunLifecycle.Staging || runInfo.Lifecycle == RunLifecycle.Launching);
if (readyShow)
{
foreach (var pr in SystemAPI.Query<RefRO<PlayerReady>>().WithAll<PlayerTag>())
{
rTotal++;
if (pr.ValueRO.Value != 0) rReady++;
}
foreach (var pr in SystemAPI.Query<RefRO<PlayerReady>>().WithAll<PlayerTag, GhostOwnerIsLocal>())
localReady = pr.ValueRO.Value != 0;
if (runInfo.Lifecycle == RunLifecycle.Launching && runInfo.LaunchTick != 0
&& SystemAPI.TryGetSingleton<NetworkTime>(out var lnt) && lnt.ServerTick.IsValid)
{
int tl = new NetworkTick(runInfo.LaunchTick).TicksSince(lnt.ServerTick);
if (tl > 0) launchSecs = tl / 60 + 1;
}
}
UpdateReadyPanel(readyShow, runInfo, rTotal, rReady, localReady, launchSecs);
// Boss presence bar. The boss is a scaled Charger (EnemyTelegraph.Kind==KindCharger, baked/client-safe)
// in the EXPEDITION region — filtering on both excludes phase-two summoned swarmers AND a base-region
// siege enemy a dead teammate can see. Health.Max is now a [GhostField] (replicated x8 for the boss), so
// the fraction reads true directly.
bool bossAlive = false;
float bossHp = 0f, bossMax = 0f;
if (haveRun && runInfo.Lifecycle == RunLifecycle.InRoom && runInfo.CurrentRoomType == RoomTypeId.Boss)
{
foreach (var (bhq, tele, blt) in
SystemAPI.Query<RefRO<Health>, RefRO<EnemyTelegraph>, RefRO<LocalTransform>>().WithAll<EnemyTag>())
{
if (tele.ValueRO.Kind != ZoneEnemyMath.KindCharger) continue; // the boss is a Charger; skip summoned swarmers
if (blt.ValueRO.Position.x <= ExpeditionRegionXMin) continue; // expedition only (not a base siege enemy)
if (bhq.ValueRO.Max > bossMax)
{
bossMax = bhq.ValueRO.Max;
bossHp = bhq.ValueRO.Current;
bossAlive = bhq.ValueRO.Current > 0f;
}
}
}
UpdateBossBar(bossAlive, bossHp, bossMax);
// Run-depth dots — keeps the roguelite spine visible while fighting (the map only shows at gates).
UpdateRunDepth(haveRun ? runInfo : default, haveRun);
// ---- Resources (feed palette affordability) ---- // ---- Resources (feed palette affordability) ----
@@ -307,23 +183,6 @@ namespace ProjectM.Client
RetintPanel(_threatPanel, PanelDark); RetintPanel(_threatPanel, PanelDark);
} }
// ---- Build palette + control hints (bottom-center) ----
UpdatePalette(aether, ore, bio, onExpedition);
bool paletteOpen = BuildPaletteState.PaletteOpen && !onExpedition && _paletteBuilt;
bool buildActive = paletteOpen && BuildPaletteState.Active;
if (buildActive)
{
byte scheme = AimPresentation.Scheme;
if (!_hintBuilt || _hintScheme != scheme) RebuildHints(scheme);
_hintBar.style.display = DisplayStyle.Flex;
}
else
{
_hintBar.style.display = DisplayStyle.None;
}
// Build-mode discovery chip: a subtle "Tab/Y — BUILD" hint when the palette is hidden at base (Slice 1).
_buildDiscoveryChip.style.display = (!onExpedition && !BuildPaletteState.PaletteOpen)
? DisplayStyle.Flex : DisplayStyle.None;
// ---- Per-player vitals ---- // ---- Per-player vitals ----
bool found = false; bool found = false;
@@ -348,7 +207,7 @@ namespace ProjectM.Client
break; break;
} }
_doc.rootVisualElement.style.display = (found || haveRun) ? DisplayStyle.Flex : DisplayStyle.None; _doc.rootVisualElement.style.display = found ? DisplayStyle.Flex : DisplayStyle.None;
// ---- Low-health vignette + hurt flash (full-screen) ---- // ---- Low-health vignette + hurt flash (full-screen) ----
_flash = HudVisualMath.DecayFlash(_flash, dt); _flash = HudVisualMath.DecayFlash(_flash, dt);
@@ -389,51 +248,6 @@ namespace ProjectM.Client
_vignette.style.display = DisplayStyle.None; _vignette.style.display = DisplayStyle.None;
_downed.style.display = DisplayStyle.None; _downed.style.display = DisplayStyle.None;
} }
// ---- Personal inventory (read-only; toggle with I, deposit-all with G via InventoryDepositSendSystem) ----
var invKb = UnityEngine.InputSystem.Keyboard.current;
if (invKb != null && invKb.iKey.wasPressedThisFrame) _invOpen = !_invOpen;
if (_invOpen && found)
{
EntityManager.CompleteDependencyBeforeRO<InventorySlot>();
EntityManager.CompleteDependencyBeforeRO<EquipmentSlot>();
bool haveItemDb = SystemAPI.TryGetSingleton<ItemDatabase>(out var itemDb);
_invPanel.style.display = DisplayStyle.Flex;
_invList.Clear();
int shown = 0;
foreach (var bag in SystemAPI.Query<DynamicBuffer<InventorySlot>>()
.WithAll<GhostOwnerIsLocal, PlayerTag>())
{
for (int i = 0; i < bag.Length; i++)
{
var slot = bag[i];
if (slot.ItemId == 0 || slot.Count <= 0) continue;
AddInvRow(slot.ItemId, ItemName(haveItemDb, itemDb, slot.ItemId), ItemTint(slot.ItemId), slot.Count,
IsEquippable(haveItemDb, itemDb, slot.ItemId));
shown++;
}
break;
}
if (shown == 0)
_invList.Add(HudUi.Text("(empty)", 13, MenuUi.SubCol, TextAnchor.MiddleLeft));
_equipList.Clear();
foreach (var slots in SystemAPI.Query<DynamicBuffer<EquipmentSlot>>()
.WithAll<GhostOwnerIsLocal, PlayerTag>())
{
for (byte s = 0; s < EquipSlotId.Count && s < slots.Length; s++)
{
ushort id = slots[s].ItemId;
string label = SlotName(s) + ": " + (id == 0 ? "-" : ItemName(haveItemDb, itemDb, id));
AddEquipRow(s, label, id != 0);
}
break;
}
}
else
{
_invPanel.style.display = DisplayStyle.None;
}
} }
// ---- per-frame helpers ---- // ---- per-frame helpers ----
@@ -446,94 +260,9 @@ namespace ProjectM.Client
// LANTERN purge: the automation buildables are deleted; Pylon stays hidden from the build palette (cosmetic-only).
static bool IsPaletteType(byte type) => type != StructureType.Pylon;
void UpdatePalette(int aether, int ore, int bio, bool onExpedition) // 2026-08-07 audit purge: AddPaletteItem built one build-palette slot (icon, cost row, selection glow,
{ // click-to-select). The build palette went with the structures layer.
if (!_paletteBuilt && SystemAPI.TryGetSingletonEntity<StructureCatalog>(out var catE))
{
var cat = SystemAPI.GetBuffer<StructureCatalogEntry>(catE);
for (int i = 0; i < cat.Length; i++)
if (IsPaletteType(cat[i].Type))
AddPaletteItem(cat[i].Type, cat[i].CostAmount, cat[i].CostResourceId);
_paletteBuilt = true;
}
if (!_paletteBuilt) { _paletteRow.style.display = DisplayStyle.None; return; }
bool showPalette = !onExpedition && BuildPaletteState.PaletteOpen;
_paletteRow.style.display = showPalette ? DisplayStyle.Flex : DisplayStyle.None;
foreach (var kv in _palette)
{
var item = kv.Value;
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.5f;
item.Cost.style.color = affordable ? new Color(0.7f, 0.95f, 0.8f) : new Color(1f, 0.5f, 0.4f);
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, byte costRes)
{
if (type == 0 || _palette.ContainsKey(type)) return;
var theme = HudTheme.Get();
var root = new VisualElement();
root.style.width = 86;
root.style.marginLeft = 4; root.style.marginRight = 4;
root.style.paddingTop = 8; root.style.paddingBottom = 6;
root.style.alignItems = Align.Center;
root.style.backgroundColor = SlotIdleBg;
root.pickingMode = PickingMode.Position;
MenuUi.Round(root, 6);
MenuUi.Border(root, SlotIdleBorder, 1);
// 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);
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, CostRes = costRes, Glow = glow, Icon = iconEl };
}
void RebuildHints(byte scheme) void RebuildHints(byte scheme)
{ {
@@ -583,11 +312,7 @@ namespace ProjectM.Client
BuildThreat(root); BuildThreat(root);
BuildMacro(root); BuildMacro(root);
BuildResources(root); BuildResources(root);
BuildPaletteRow(root);
BuildHintBar(root);
BuildDiscoveryChip(root);
BuildDowned(root); BuildDowned(root);
BuildInventory(root);
} }
void BuildVignette(VisualElement root) void BuildVignette(VisualElement root)
@@ -827,119 +552,9 @@ namespace ProjectM.Client
void BuildInventory(VisualElement root) // 2026-08-07 audit purge: BuildInventory / AddInvRow / ItemName / ItemTint / IsEquippable / SlotName /
{ // AddEquipRow drove the personal-inventory + equipment strip. That layer was already PAUSED in CLAUDE.md
_invPanel = HudUi.Panel(PanelDark); // and went with the shell.
_invPanel.style.position = Position.Absolute;
_invPanel.style.right = 40; _invPanel.style.bottom = 40;
_invPanel.style.minWidth = 224;
_invPanel.style.paddingLeft = 14; _invPanel.style.paddingRight = 14;
_invPanel.style.paddingTop = 10; _invPanel.style.paddingBottom = 10;
_invPanel.style.alignItems = Align.FlexStart;
_invPanel.pickingMode = PickingMode.Ignore;
var header = HudUi.Display("INVENTORY", 16, AetherCyan, TextAnchor.MiddleLeft);
header.style.marginBottom = 6;
_invPanel.Add(header);
_invList = new VisualElement();
_invList.pickingMode = PickingMode.Ignore;
_invPanel.Add(_invList);
var equipHeader = HudUi.Display("EQUIPMENT", 14, AetherCyan, TextAnchor.MiddleLeft);
equipHeader.style.marginTop = 8; equipHeader.style.marginBottom = 4;
_invPanel.Add(equipHeader);
_equipList = new VisualElement();
_equipList.pickingMode = PickingMode.Ignore;
_invPanel.Add(_equipList);
var hint = HudUi.Text("I close - click item=equip / slot=unequip - G deposit", 11, MenuUi.SubCol, TextAnchor.MiddleLeft);
hint.style.marginTop = 8;
_invPanel.Add(hint);
_invPanel.style.display = DisplayStyle.None;
root.Add(_invPanel);
}
void AddInvRow(ushort itemId, string name, Color tint, int count, bool equippable)
{
var row = new VisualElement();
row.style.flexDirection = FlexDirection.Row;
row.style.justifyContent = Justify.SpaceBetween;
row.style.minWidth = 196;
row.style.marginTop = 2;
row.Add(HudUi.Text(name + (equippable ? " (equip)" : ""), 13, tint, TextAnchor.MiddleLeft));
row.Add(HudUi.Display("x" + count, 13, Color.white, TextAnchor.MiddleRight));
if (equippable)
{
row.pickingMode = PickingMode.Position;
ushort id = itemId;
row.RegisterCallback<ClickEvent>(_ => EquipSendSystem.Equip(id));
}
else row.pickingMode = PickingMode.Ignore;
_invList.Add(row);
}
static string ItemName(bool haveDb, ItemDatabase db, ushort id)
{
if (haveDb && db.Value.IsCreated)
{
ref var blob = ref db.Value.Value;
if (blob.TryGetItem(id, out var def)) return def.Name.ToString();
}
if (id == ResourceId.Aether) return "Aether";
if (id == ResourceId.Ore) return "Ore";
if (id == ResourceId.Biomass) return "Biomass";
return "Item " + id;
}
static Color ItemTint(ushort id)
{
if (id == ResourceId.Aether) return AetherCyan;
if (id == ResourceId.Ore) return OreAmber;
if (id == ResourceId.Biomass) return BioGreen;
return new Color(0.85f, 0.85f, 0.9f);
}
static bool IsEquippable(bool haveDb, ItemDatabase db, ushort id)
{
if (!haveDb || !db.Value.IsCreated) return false;
ref var b = ref db.Value.Value;
return b.TryGetItem(id, out var def) && def.EquipSlot < EquipSlotId.Count;
}
static string SlotName(byte slot)
{
switch (slot)
{
case EquipSlotId.Weapon: return "Weapon";
case EquipSlotId.Armor: return "Armor";
case EquipSlotId.Trinket: return "Trinket";
case EquipSlotId.Tool: return "Tool";
default: return "Slot " + slot;
}
}
void AddEquipRow(byte slot, string label, bool occupied)
{
var row = new VisualElement();
row.style.flexDirection = FlexDirection.Row;
row.style.justifyContent = Justify.SpaceBetween;
row.style.minWidth = 196;
row.style.marginTop = 2;
row.Add(HudUi.Text(label, 13, occupied ? AetherCyan : MenuUi.SubCol, TextAnchor.MiddleLeft));
if (occupied)
{
row.pickingMode = PickingMode.Position;
byte s = slot;
row.RegisterCallback<ClickEvent>(_ => EquipSendSystem.Unequip(s));
row.Add(HudUi.Text("unequip", 11, new Color(1f, 0.6f, 0.5f), TextAnchor.MiddleRight));
}
else row.pickingMode = PickingMode.Ignore;
_equipList.Add(row);
}
static Color ResourceTint(byte resId) static Color ResourceTint(byte resId)
=> resId == ResourceId.Aether ? AetherCyan : resId == ResourceId.Biomass ? BioGreen : OreAmber; => resId == ResourceId.Aether ? AetherCyan : resId == ResourceId.Biomass ? BioGreen : OreAmber;
@@ -954,213 +569,10 @@ namespace ProjectM.Client
static string StructureName(byte type) // 2026-08-07 audit purge: StructureName, RoomTypeLabel, the branching route map, the ready panel, the
{ // boss presence bar and the run-depth dots all belonged to the superseded base/expedition loop and are
switch (type) // deleted along with RunInfo / PlayerReady / BossState / StructureCatalog. Recover from git if the
{ // roguelite spine returns.
case StructureType.Wall: return "Wall";
case StructureType.Pylon: return "Pylon";
default: return "?";
}
}
// ==== Step 14: boon modal + route panel (lazy-built overlays; clicks -> client send statics) ====
static string RoomTypeLabel(byte roomType) => roomType == RoomTypeId.Boss ? "[BOSS]"
: roomType == RoomTypeId.Elite ? "[ELITE]"
: roomType == RoomTypeId.Reward ? "[REWARD]" : "[COMBAT]";
// ---- the drawn branching route map (Slay-the-Spire style; display regen from RunSeed, clicks bind
// to the authoritative RouteOpt* bytes) ----
// ---- the clickable READY panel (Staging: toggle + party pips; Launching: countdown + abort) ----
void UpdateReadyPanel(bool show, RunInfo runInfo, int total, int ready, bool localReady, int launchSecs)
{
if (!show)
{
if (_readyPanel != null) _readyPanel.style.display = DisplayStyle.None;
_readyShownFor = 0;
return;
}
var root = _doc != null ? _doc.rootVisualElement : null;
if (root == null) return;
if (!_readyPanelBuilt)
{
BuildReadyPanel(root);
_readyPanelBuilt = true;
}
bool launching = runInfo.Lifecycle == RunLifecycle.Launching;
int sig = 1 + ready + (total << 4) + (localReady ? 1 << 8 : 0) + (launchSecs << 9) + (launching ? 1 << 16 : 0);
if (_readyShownFor != sig)
{
_readyTitle.text = launching
? "LAUNCHING IN " + launchSecs
: "EXPEDITION — " + ready + "/" + Mathf.Max(total, 1) + " READY";
_readyTitle.style.color = launching ? new Color(1f, 0.9f, 0.4f) : new Color(0.55f, 0.85f, 1f);
_readyBtn.text = launching ? "ABORT [T]" : localReady ? "UNREADY [T]" : "READY UP [T]";
_readyPipRow.Clear();
for (int i = 0; i < total; i++)
{
var pip = new VisualElement();
pip.style.width = 14; pip.style.height = 14;
pip.style.marginLeft = 3; pip.style.marginRight = 3;
MenuUi.Round(pip, 7f);
pip.style.backgroundColor = i < ready
? new Color(0.45f, 0.95f, 0.55f) : new Color(1f, 1f, 1f, 0.15f);
_readyPipRow.Add(pip);
}
_readyShownFor = sig;
}
_readyPanel.style.display = DisplayStyle.Flex;
}
void BuildReadyPanel(VisualElement root)
{
_readyPanel = new VisualElement { pickingMode = PickingMode.Ignore };
_readyPanel.style.position = Position.Absolute;
_readyPanel.style.left = 0; _readyPanel.style.right = 0;
_readyPanel.style.bottom = 170; // clear of the build palette row + hint bar
_readyPanel.style.alignItems = Align.Center;
_readyPanel.style.display = DisplayStyle.None;
var box = new VisualElement { pickingMode = PickingMode.Position };
box.style.backgroundColor = new Color(0.07f, 0.09f, 0.12f, 0.92f);
box.style.borderTopLeftRadius = 10; box.style.borderTopRightRadius = 10;
box.style.borderBottomLeftRadius = 10; box.style.borderBottomRightRadius = 10;
box.style.paddingLeft = 18; box.style.paddingRight = 18;
box.style.paddingTop = 10; box.style.paddingBottom = 12;
box.style.alignItems = Align.Center;
_readyTitle = new Label("EXPEDITION");
_readyTitle.style.fontSize = 16;
_readyTitle.style.unityFontStyleAndWeight = FontStyle.Bold;
box.Add(_readyTitle);
_readyPipRow = new VisualElement();
_readyPipRow.style.flexDirection = FlexDirection.Row;
_readyPipRow.style.justifyContent = Justify.Center;
_readyPipRow.style.marginTop = 6; _readyPipRow.style.marginBottom = 8;
box.Add(_readyPipRow);
_readyBtn = MenuUi.Button("READY UP [T]", ReadySendSystem.ToggleReady);
box.Add(_readyBtn);
_readyPanel.Add(box);
root.Add(_readyPanel);
}
// ---- boss presence bar (Boss rooms only; red, top-center, under the macro banner) ----
void UpdateBossBar(bool alive, float hp, float max)
{
if (!alive || max <= 0f)
{
if (_bossPanel != null) _bossPanel.style.display = DisplayStyle.None;
return;
}
var root = _doc != null ? _doc.rootVisualElement : null;
if (root == null) return;
if (!_bossBarBuilt)
{
BuildBossBar(root);
_bossBarBuilt = true;
}
HudUi.SetFill(_bossFill, Mathf.Clamp01(hp / max));
_bossText.text = "ALPHA HUSK " + Mathf.CeilToInt(Mathf.Max(0f, hp)) + " / " + Mathf.CeilToInt(max);
_bossPanel.style.display = DisplayStyle.Flex;
}
void BuildBossBar(VisualElement root)
{
_bossPanel = new VisualElement { pickingMode = PickingMode.Ignore };
_bossPanel.style.position = Position.Absolute;
_bossPanel.style.left = 0; _bossPanel.style.right = 0;
_bossPanel.style.top = 168;
_bossPanel.style.alignItems = Align.Center;
_bossPanel.style.display = DisplayStyle.None;
var col = HudUi.Group(Align.Center);
_bossText = HudUi.Display("ALPHA HUSK", 22, new Color(1f, 0.35f, 0.3f), TextAnchor.MiddleCenter);
col.Add(_bossText);
var bar = HudUi.Bar(420, 12, new Color(0.92f, 0.22f, 0.18f), out _bossFill);
bar.style.marginTop = 4;
col.Add(bar);
_bossPanel.Add(col);
root.Add(_bossPanel);
}
// ---- run-depth dots (visible through the whole run; the current room pulses bigger) ----
void UpdateRunDepth(RunInfo runInfo, bool haveRun)
{
bool show = haveRun && runInfo.RoomCount > 0
&& (runInfo.Lifecycle == RunLifecycle.InRoom
|| runInfo.Lifecycle == RunLifecycle.RoomReward
|| runInfo.Lifecycle == RunLifecycle.RouteSelect);
if (!show)
{
if (_depthPanel != null) _depthPanel.style.display = DisplayStyle.None;
_depthShownFor = 0;
return;
}
var root = _doc != null ? _doc.rootVisualElement : null;
if (root == null) return;
if (!_depthBuilt)
{
_depthPanel = new VisualElement { pickingMode = PickingMode.Ignore };
_depthPanel.style.position = Position.Absolute;
_depthPanel.style.left = 0; _depthPanel.style.right = 0;
_depthPanel.style.top = 208; // below the macro cluster (goal/core) AND the boss bar (168)
_depthPanel.style.flexDirection = FlexDirection.Row;
_depthPanel.style.justifyContent = Justify.Center;
root.Add(_depthPanel);
_depthBuilt = true;
}
int sig = 1 + runInfo.CurrentRoom * 37 + runInfo.RoomCount * 3;
if (_depthShownFor != sig)
{
_depthPanel.Clear();
for (int i = 0; i < runInfo.RoomCount; i++)
{
bool current = i == runInfo.CurrentRoom;
bool done = i < runInfo.CurrentRoom;
var dot = new VisualElement { pickingMode = PickingMode.Ignore };
float size = current ? 12f : 8f;
dot.style.width = size; dot.style.height = size;
dot.style.marginLeft = 3; dot.style.marginRight = 3;
dot.style.alignSelf = Align.Center;
MenuUi.Round(dot, size * 0.5f);
dot.style.backgroundColor = current ? new Color(0.55f, 0.85f, 1f)
: done ? new Color(0.55f, 0.85f, 1f, 0.55f)
: new Color(1f, 1f, 1f, 0.16f);
_depthPanel.Add(dot);
}
_depthShownFor = sig;
}
_depthPanel.style.display = DisplayStyle.Flex;
}
@@ -1,197 +0,0 @@
using System.Collections.Generic;
using ProjectM.Simulation;
using Unity.Entities;
using Unity.NetCode;
using Unity.Transforms;
using Unity.Mathematics;
using UnityEngine;
using UnityEngine.UIElements;
namespace ProjectM.Client
{
/// <summary>
/// The Staging-only permanent-upgrade shop (meta shop) — extracted from <see cref="HudSystem"/> into its own
/// client-only, observe-only presentation <see cref="SystemBase"/> in <see cref="PresentationSystemGroup"/>.
/// Owns its own runtime UIDocument sharing <see cref="MenuUi.LoadPanelSettings"/> (sortingOrder 51). Recomputes
/// its inputs locally: the local class from the replicated <see cref="AbilityRef"/>
/// (<see cref="ClassTraits.ClassForAbility"/>), Aether from the <see cref="ResourceLedger"/> buffer, siege from
/// <see cref="CycleState"/>, and the Staging gate from <see cref="RunInfo"/>. Row clicks enqueue through
/// <see cref="MetaSpendSendSystem.RequestPurchase"/> — the server re-validates everything.
/// </summary>
[WorldSystemFilter(WorldSystemFilterFlags.ClientSimulation)]
[UpdateInGroup(typeof(PresentationSystemGroup))]
public partial class MetaShopHudSystem : SystemBase
{
GameObject _go;
UIDocument _doc;
bool _built;
VisualElement _metaPanel, _metaRowsHost;
Label _metaShopTitle;
bool _metaShopBuilt;
int _metaShownFor; // last (class, tiers, aether) signature the shop rows were built for
protected override void OnStartRunning()
{
if (_go != null) return;
MenuUi.EnsureEventSystem();
_go = new GameObject("~HUDMetaShop");
_doc = _go.AddComponent<UIDocument>();
_doc.panelSettings = MenuUi.LoadPanelSettings();
_doc.sortingOrder = 51;
}
protected override void OnDestroy()
{
if (_go != null) Object.Destroy(_go);
}
protected override void OnUpdate()
{
if (_doc == null) return;
var root = _doc.rootVisualElement;
if (root == null) return; // panel not initialised yet (next frame)
if (!_built)
{
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
_built = true;
}
bool haveRun = SystemAPI.TryGetSingleton<RunInfo>(out var runInfo);
// Aether from the ledger (the sole meta-shop currency; last entry wins, matching the core loop).
int aether = 0;
if (SystemAPI.TryGetSingletonEntity<ResourceLedger>(out var ledgerE))
{
var buf = SystemAPI.GetBuffer<StorageEntry>(ledgerE);
for (int i = 0; i < buf.Length; i++)
if (buf[i].ItemId == ResourceId.Aether) aether = buf[i].Count;
}
// Local class derives from the replicated AbilityRef (tracks the dev class-switch; PlayerClass is
// server-only); tiers from the replicated MetaTierState record on the director ghost.
byte localClass = ClassTraits.WarriorClass;
bool haveLocalPlayer = false;
foreach (var fr in SystemAPI.Query<RefRO<FrameId>>().WithAll<PlayerTag, GhostOwnerIsLocal>())
{
localClass = ClassTraits.Normalize(fr.ValueRO.Value); // FrameId is the sole class signal (legacy AbilityRef deleted)
haveLocalPlayer = true;
break;
}
bool metaShow = false;
BlobAssetReference<MetaUpgradeCatalogBlob> metaPool = default;
DynamicBuffer<MetaTierState> metaRecord = default;
if (haveRun && runInfo.Lifecycle == RunLifecycle.Staging && haveLocalPlayer
&& SystemAPI.TryGetSingleton<MetaUpgradeCatalog>(out var metaCat) && metaCat.Value.IsCreated
&& SystemAPI.TryGetSingletonBuffer<MetaTierState>(out metaRecord, true))
{
metaPool = metaCat.Value;
metaShow = true;
}
UpdateMetaShop(metaShow, localClass, aether, metaPool, metaRecord);
}
void UpdateMetaShop(bool show, byte classId, int aether,
BlobAssetReference<MetaUpgradeCatalogBlob> pool, DynamicBuffer<MetaTierState> record)
{
if (!show)
{
if (_metaPanel != null) _metaPanel.style.display = DisplayStyle.None;
_metaShownFor = 0;
return;
}
var root = _doc != null ? _doc.rootVisualElement : null;
if (root == null) return;
if (!_metaShopBuilt)
{
BuildMetaShop(root);
_metaShopBuilt = true;
}
// Rebuild the rows only when class / owned tiers / affordability actually change (Staging-only, <=8 rows).
int sig = classId * 131 ^ aether * 31;
for (int i = 0; i < record.Length; i++)
sig ^= (record[i].ClassId * 7 + record[i].UpgradeId * 13 + record[i].Tier) * (i + 3);
if (sig == 0) sig = 1;
if (_metaShownFor != sig)
{
_metaRowsHost.Clear();
_metaShopTitle.text = (classId == ClassTraits.RangerClass ? "RANGER" : "WARRIOR")
+ " PERMANENT UPGRADES - AETHER " + aether;
ref var defs = ref pool.Value;
byte classBit = BoonMath.MaskFor(classId);
for (int d = 0; d < defs.Defs.Length; d++)
{
if ((defs.Defs[d].ClassMask & classBit) == 0) continue;
byte id = defs.Defs[d].Id;
byte owned = MetaMath.TierOf(record, classId, id);
if (owned > defs.Defs[d].MaxTier) owned = defs.Defs[d].MaxTier; // D-F5 display clamp (seed AND spend AND shop)
bool maxed = owned >= defs.Defs[d].MaxTier;
int cost = MetaMath.CostForTier(in defs.Defs[d], owned);
string label = defs.Defs[d].Name.ToString()
+ (maxed ? " MAXED" : " - " + cost + " Aether")
+ "\n" + defs.Defs[d].Desc.ToString();
byte buyId = id; // closure copy, never the loop variable
var row = MenuUi.Button(label, () => MetaSpendSendSystem.RequestPurchase(buyId));
row.style.width = 290;
row.style.height = StyleKeyword.Auto; // two-line labels must grow the row (overlap fix)
row.style.paddingTop = 6; row.style.paddingBottom = 6;
row.style.marginBottom = 4;
row.style.whiteSpace = WhiteSpace.Normal;
row.style.unityTextAlign = TextAnchor.MiddleLeft;
row.SetEnabled(!maxed && aether >= cost); // honest UI; the server re-validates everything anyway
// Owned-tier pips (replaces the "[2/5]" text — reads at a glance).
var pipRow = new VisualElement { pickingMode = PickingMode.Ignore };
pipRow.style.flexDirection = FlexDirection.Row;
pipRow.style.marginTop = 3;
for (int p = 0; p < defs.Defs[d].MaxTier; p++)
{
var tp = new VisualElement { pickingMode = PickingMode.Ignore };
tp.style.width = 9; tp.style.height = 9;
tp.style.marginRight = 3;
MenuUi.Round(tp, 4.5f);
tp.style.backgroundColor = p < owned ? MenuUi.Accent : new Color(1f, 1f, 1f, 0.14f);
pipRow.Add(tp);
}
row.Add(pipRow);
_metaRowsHost.Add(row);
}
_metaShownFor = sig;
}
_metaPanel.style.display = DisplayStyle.Flex;
}
void BuildMetaShop(VisualElement root)
{
_metaPanel = new VisualElement { pickingMode = PickingMode.Ignore };
_metaPanel.style.position = Position.Absolute;
_metaPanel.style.right = 12;
_metaPanel.style.top = Length.Percent(22);
_metaPanel.style.alignItems = Align.FlexEnd;
_metaPanel.style.display = DisplayStyle.None;
var box = new VisualElement();
box.style.backgroundColor = new Color(0.07f, 0.09f, 0.12f, 0.92f);
box.style.borderTopLeftRadius = 10; box.style.borderTopRightRadius = 10;
box.style.borderBottomLeftRadius = 10; box.style.borderBottomRightRadius = 10;
box.style.paddingLeft = 12; box.style.paddingRight = 12;
box.style.paddingTop = 10; box.style.paddingBottom = 10;
_metaShopTitle = new Label("PERMANENT UPGRADES");
_metaShopTitle.style.color = MenuUi.Accent;
_metaShopTitle.style.fontSize = 14;
_metaShopTitle.style.unityFontStyleAndWeight = FontStyle.Bold;
_metaShopTitle.style.marginBottom = 8;
box.Add(_metaShopTitle);
_metaRowsHost = new VisualElement();
box.Add(_metaRowsHost);
_metaPanel.Add(box);
root.Add(_metaPanel);
}
}
}
@@ -1,2 +0,0 @@
fileFormatVersion: 2
guid: 2551683f48286ae41980c5bb4e48d6e1
@@ -78,30 +78,9 @@ namespace ProjectM.Client
// ---- pick the mix from replicated state (defaults = quiet staging bed) ---- // ---- pick the mix from replicated state (defaults = quiet staging bed) ----
float tBass = 0.50f, tPad = 0.55f, tArp = 0.12f, tPulse = 0f; float tBass = 0.50f, tPad = 0.55f, tArp = 0.12f, tPulse = 0f;
if (SystemAPI.TryGetSingleton<RunInfo>(out var run)) // 2026-08-07 audit purge: the mix used to key off RunInfo.Lifecycle (staging / launching / in-room /
{ // boss / returning). With the run FSM deleted the bed holds its defaults; re-key it off LANTERN's
switch (run.Lifecycle) // descent state when Phase 2 lands.
{
case RunLifecycle.Launching:
tBass = 0.55f; tPad = 0.50f; tArp = 0.35f; tPulse = 0.20f; // anticipation swell
break;
case RunLifecycle.InRoom:
bool boss = run.CurrentRoomType == RoomTypeId.Boss;
tBass = boss ? 0.75f : 0.65f;
tPad = 0.40f;
tArp = boss ? 0.80f : 0.65f;
tPulse = boss ? 0.75f : 0.35f;
break;
case RunLifecycle.RoomReward:
case RunLifecycle.RouteSelect:
tBass = 0.45f; tPad = 0.55f; tArp = 0.25f; tPulse = 0.08f; // between-rooms lull
break;
case RunLifecycle.Returning:
tBass = 0.45f; tPad = 0.60f; tArp = 0.15f; tPulse = 0f; // resolution
break;
// Staging keeps the defaults.
}
}
float dt = SystemAPI.Time.DeltaTime * FadePerSecond; float dt = SystemAPI.Time.DeltaTime * FadePerSecond;
_vBass = Mathf.MoveTowards(_vBass, tBass, dt); _vBass = Mathf.MoveTowards(_vBass, tBass, dt);
@@ -1,34 +0,0 @@
using UnityEngine;
namespace ProjectM.Client
{
/// <summary>
/// Live-tunable knobs + build-safe prefab references for <see cref="RoomDressingSystem"/> — the Phase 1.5b
/// per-room cosmetic dressing scatter. Mirrors the <see cref="AmbientMotionConfig"/>/<see cref="VFXConfig"/>
/// bridge idiom: a MonoBehaviour in the gameplay scene with a static <see cref="Instance"/> the client
/// presentation system reads. Prefab arrays are serialized scene references (never Resources.Load — that is
/// build-stripped). Absent config or an empty set = no dressing (the feature is soft-off).
/// </summary>
public sealed class RoomDressingConfig : MonoBehaviour
{
public static RoomDressingConfig Instance;
[Header("Master")]
public bool Enabled = true;
[Min(0)] public int CountPerRoom = 26;
[Min(0.1f)] public float ScaleMin = 0.85f;
[Min(0.1f)] public float ScaleMax = 1.35f;
[Header("Per-biome cosmetic prop sets (RoomBiomeId: Meadow/Arid/Cavern/Blight)")]
public GameObject[] Meadow;
public GameObject[] Arid;
public GameObject[] Cavern;
public GameObject[] Blight;
void OnEnable() { Instance = this; }
void OnDisable() { if (Instance == this) Instance = null; }
[RuntimeInitializeOnLoadMethod(RuntimeInitializeLoadType.SubsystemRegistration)]
static void ResetStatics() { Instance = null; }
}
}
@@ -1,2 +0,0 @@
fileFormatVersion: 2
guid: 4ef612b567d6f6746bc03f764274740e
@@ -1,172 +0,0 @@
using ProjectM.Simulation;
using Unity.Entities;
using Unity.Mathematics;
using UnityEngine;
namespace ProjectM.Client
{
/// <summary>
/// Phase 1.5b (ground bundle) — client-only, observe-only PER-ROOM DRESSING SCATTER. A managed
/// <see cref="SystemBase"/> in <see cref="PresentationSystemGroup"/> that OBSERVES replicated
/// <see cref="RunInfo"/> and instantiates biome-flavoured cosmetic ground props (pebbles, tufts, bones,
/// mushrooms) inside the room's ACTUAL shape — the same <see cref="RoomLayoutMath"/> authority the server
/// scatter uses, on a DISTINCT hash stream so dressing never co-locates with nodes (0x0DE) or clutter
/// (0xC17). Deterministic from the replicated run seed: every co-op client (and a late-joiner) sees the
/// same floor. Torn down when the room changes or the run ends. Colliders + rigidbodies are stripped on
/// spawn — the aim-reticle raycast and the physics world must never see dressing. Never mutates the sim.
/// <para>
/// Bundle 2 (decals): also lays a few persistent feathered crack/scorch DETAIL scars at the arena centre
/// (higher-frequency detail the baked ground splat can't resolve), on its own hash sub-stream, parented to
/// the same dressing root so they tear down together. Base POIs are skipped — Part O baked their wear in.
/// </para>
/// </summary>
[WorldSystemFilter(WorldSystemFilterFlags.ClientSimulation)]
[UpdateInGroup(typeof(PresentationSystemGroup))]
public partial class RoomDressingSystem : SystemBase
{
Transform _root;
uint _activeKey;
// Bundle 2 arena-scar decal assets (reused across rebuilds; the scar GameObjects are children of _root and
// torn down each room change, these shared assets persist until OnDestroy).
Material _decalMat;
Mesh[] _decalMeshes;
MaterialPropertyBlock _decalMpb;
static readonly int DecalColorId = Shader.PropertyToID("_Color");
protected override void OnStartRunning()
{
if (_root == null) _root = new GameObject("~RoomDressing").transform;
}
protected override void OnDestroy()
{
if (_root != null) Object.Destroy(_root.gameObject);
if (_decalMat != null) Object.Destroy(_decalMat);
if (_decalMeshes != null)
for (int i = 0; i < _decalMeshes.Length; i++)
if (_decalMeshes[i] != null) Object.Destroy(_decalMeshes[i]);
}
protected override void OnUpdate()
{
if (!ScenePolicy.IsGameplayScene()) return;
if (_root == null) return;
var cfg = RoomDressingConfig.Instance;
bool inRoom = SystemAPI.TryGetSingleton<RunInfo>(out var ri)
&& (ri.Lifecycle == RunLifecycle.InRoom
|| ri.Lifecycle == RunLifecycle.RoomReward
|| ri.Lifecycle == RunLifecycle.RoomExplore);
bool on = cfg != null && cfg.Enabled && cfg.CountPerRoom > 0;
if (!inRoom || !on || !SystemAPI.TryGetSingleton<BaseAnchor>(out var anchor))
{
Clear();
return;
}
uint key = RunMapMath.Hash(ri.RunSeed, (uint)(ri.CurrentRoom + 1), 0xD2E5u);
if (key == _activeKey) return;
Clear();
Build(in ri, in anchor, cfg);
_activeKey = key;
}
void Clear()
{
if (_activeKey == 0 || _root == null) { _activeKey = 0; return; }
for (int i = _root.childCount - 1; i >= 0; i--)
Object.Destroy(_root.GetChild(i).gameObject);
_activeKey = 0;
}
void Build(in RunInfo ri, in BaseAnchor anchor, RoomDressingConfig cfg)
{
GameObject[] set = ri.CurrentBiome switch
{
RoomBiomeId.Meadow => cfg.Meadow,
RoomBiomeId.Cavern => cfg.Cavern,
RoomBiomeId.Blight => cfg.Blight,
_ => cfg.Arid,
};
if (set == null || set.Length == 0) return;
float3 baseCenter = BaseGridMath.PlotCenter(anchor);
byte subSlot = (byte)(ri.CurrentRoom & 1);
float3 origin = RegionMath.ExpeditionRoomOrigin(baseCenter, subSlot);
float3 portal = RegionMath.ExpeditionPortalPos(baseCenter, subSlot);
var map = RunMapMath.Generate(ri.RunSeed);
var node = map.NodeAt(RunMap.NodeId(ri.CurrentRoom, ri.CurrentCol));
var plan = RoomLayoutMath.Plan(node, ri.CurrentRoom, ri.RoomCount);
var rng = new Unity.Mathematics.Random(RunMapMath.Hash(ri.RunSeed, (uint)(ri.CurrentRoom + 1), 0xD2E55u) | 1u);
int count = cfg.CountPerRoom;
for (int i = 0; i < count; i++)
{
float3 pos = RoomLayoutMath.ScatterInShape(plan.ShapeId, origin, i, count, ref rng);
for (int attempt = 0; attempt < 4; attempt++)
{
bool nearLanding = math.distancesq(pos.xz, origin.xz) < 9f; // party lands at the origin
bool nearPortal = math.distancesq(pos.xz, portal.xz) < 6.25f; // keep the exit beacon clear
if (!nearLanding && !nearPortal) break;
pos = RoomLayoutMath.ScatterInShape(plan.ShapeId, origin, i, count, ref rng);
}
var prefab = set[rng.NextInt(0, set.Length)];
if (prefab == null) continue;
var go = Object.Instantiate(prefab, _root, false);
go.transform.SetPositionAndRotation(
new Vector3(pos.x, 0f, pos.z), // terrain y=0 (origin.y is the CC capsule plane, 1 u up)
Quaternion.Euler(0f, rng.NextFloat(0f, 360f), 0f));
go.transform.localScale = Vector3.one * rng.NextFloat(cfg.ScaleMin, cfg.ScaleMax);
foreach (var col in go.GetComponentsInChildren<Collider>(true)) Object.Destroy(col);
foreach (var rb in go.GetComponentsInChildren<Rigidbody>(true)) Object.Destroy(rb);
}
// Bundle 2 arena scars: distinct hash sub-stream (0xD2E56) so they never co-locate with the prop
// scatter (0xD2E55); clustered around the arena centre. Persistent (no fade); torn down with dressing.
if (DecalConfig.Enabled && DecalConfig.RoomArenaDecalCount > 0)
{
EnsureDecalAssets();
var srng = new Unity.Mathematics.Random(RunMapMath.Hash(ri.RunSeed, (uint)(ri.CurrentRoom + 1), 0xD2E56u) | 1u);
for (int i = 0; i < DecalConfig.RoomArenaDecalCount; i++)
SpawnArenaScar(origin, i, ref srng);
}
}
void EnsureDecalAssets()
{
if (_decalMat == null) _decalMat = FeedbackFx.MakeDecalMaterial("RoomScarDecal");
if (_decalMpb == null) _decalMpb = new MaterialPropertyBlock();
if (_decalMeshes == null)
{
_decalMeshes = new Mesh[4];
_decalMeshes[0] = FeedbackFx.BuildScorchMesh(20, 0.32f, 11);
_decalMeshes[1] = FeedbackFx.BuildScorchMesh(20, 0.34f, 23);
_decalMeshes[2] = FeedbackFx.BuildCrackMesh(3, 31);
_decalMeshes[3] = FeedbackFx.BuildCrackMesh(2, 47);
}
}
void SpawnArenaScar(float3 origin, int index, ref Unity.Mathematics.Random rng)
{
var go = new GameObject("ArenaScar");
go.transform.SetParent(_root, false);
go.AddComponent<MeshFilter>().sharedMesh = _decalMeshes[rng.NextInt(0, _decalMeshes.Length)];
var mr = go.AddComponent<MeshRenderer>();
mr.sharedMaterial = _decalMat;
mr.shadowCastingMode = UnityEngine.Rendering.ShadowCastingMode.Off;
mr.receiveShadows = false;
mr.lightProbeUsage = UnityEngine.Rendering.LightProbeUsage.Off;
_decalMpb.SetColor(DecalColorId, DecalConfig.RoomDecalColor);
mr.SetPropertyBlock(_decalMpb);
float ang = rng.NextFloat(0f, math.PI * 2f);
float rad = rng.NextFloat(0f, 4.5f); // clustered around the arena centre where the fight happens
float scale = rng.NextFloat(1.4f, 3.0f);
go.transform.SetPositionAndRotation(
new Vector3(origin.x + math.cos(ang) * rad, 0.05f + 0.003f * index, origin.z + math.sin(ang) * rad),
Quaternion.Euler(0f, rng.NextFloat(0f, 360f), 0f));
go.transform.localScale = new Vector3(scale, 1f, scale);
}
}
}
@@ -1,2 +0,0 @@
fileFormatVersion: 2
guid: 6eb0d1fcbd0a69244842b4eb2fbcd49f
@@ -1,102 +0,0 @@
using ProjectM.Simulation;
using Unity.Entities;
using Unity.Mathematics;
using UnityEngine;
using static ProjectM.Client.FeedbackFx;
namespace ProjectM.Client
{
/// <summary>
/// DR-046 — client-only, observe-only presentation of the room-exit PORTAL made visible. A managed
/// <see cref="SystemBase"/> in <see cref="PresentationSystemGroup"/> that OBSERVES replicated <see cref="RunInfo"/>
/// and never mutates the sim. During the <see cref="RunLifecycle.RoomExplore"/> loot window it shows a glowing cyan
/// pillar (or the authored <see cref="VFXConfig.Portal"/> effect when wired) at the client-derived portal position
/// so the player has an unmistakable "go here to continue" target; hidden whenever the run isn't in RoomExplore.
/// Position resolves through the SAME <see cref="RegionMath.ExpeditionPortalPos"/> authority the HUD prompt uses, so
/// the beacon and the "PRESS E" range always agree. Extracted from CombatFeedbackSystem (owns its own FX-root +
/// beacon material); no Entity-keyed cache.
/// </summary>
[WorldSystemFilter(WorldSystemFilterFlags.ClientSimulation)]
[UpdateInGroup(typeof(PresentationSystemGroup))]
public partial class RoomPortalBeaconSystem : SystemBase
{
Transform _fxRoot;
GameObject _portalFx; // Phase 1: authored portal effect (VFXConfig.Portal) replacing the procedural pillar when wired
Material _portalMat; // DR-046: room-exit portal beacon glow (mutated for the pulse; beacon-only mat)
GameObject _portalBeacon; // DR-046: pooled world-space "go here" pillar, shown only during RoomExplore
protected override void OnStartRunning()
{
if (_fxRoot != null) return;
_fxRoot = new GameObject("~RoomPortalBeaconFX").transform;
_portalMat = MakeParticleMaterial();
_portalMat.name = "RoomPortal";
_portalMat.color = new Color(0.25f, 1.2f, 1.55f, 0.85f); // DR-046: HDR cyan portal glow (Phase 0: tamed — 2.6/3.4 bloomed to a white blob)
}
protected override void OnDestroy()
{
if (_fxRoot != null) Object.Destroy(_fxRoot.gameObject);
if (_portalMat != null) Object.Destroy(_portalMat);
}
protected override void OnUpdate()
{
UpdatePortalBeacon();
}
// DR-046: the room-exit PORTAL made VISIBLE. During the RoomExplore loot window a glowing cyan pillar marks the
// client-derived portal position so the player has an unmistakable "go here to continue" target — the HUD prompt
// alone left the exit invisible, so players waited out the ~30s grace timeout ("nothing happens for a while").
// Client-only, observe-only; one pooled GameObject, hidden whenever the run isn't in RoomExplore. Position
// resolves through the SAME RegionMath.ExpeditionPortalPos authority the HUD prompt uses -> beacon + "PRESS E"
// range always agree.
void UpdatePortalBeacon()
{
if (_fxRoot == null || _portalMat == null) return;
bool inExplore = SystemAPI.TryGetSingleton<RunInfo>(out var ri) && ri.Lifecycle == RunLifecycle.RoomExplore;
if (!inExplore || !SystemAPI.TryGetSingleton<BaseAnchor>(out var anchor))
{
if (_portalBeacon != null && _portalBeacon.activeSelf) _portalBeacon.SetActive(false);
if (_portalFx != null && _portalFx.activeSelf) _portalFx.SetActive(false);
return;
}
float3 pos = RegionMath.ExpeditionPortalPos(BaseGridMath.PlotCenter(anchor), (byte)(ri.CurrentRoom & 1));
// Phase 1: prefer the authored portal effect (VFXConfig.Portal, PolygonParticleFX) over the
// procedural pillar; the pillar remains the asset-free fallback.
var vfx = VFXConfig.Instance;
if (vfx != null && vfx.Portal != null)
{
if (_portalFx == null)
{
_portalFx = Object.Instantiate(vfx.Portal, _fxRoot, false);
_portalFx.name = "~RoomPortalFx";
}
_portalFx.transform.position = new Vector3(pos.x, 0f, pos.z); // terrain y=0 (pos.y is the capsule plane)
if (!_portalFx.activeSelf) _portalFx.SetActive(true);
if (_portalBeacon != null && _portalBeacon.activeSelf) _portalBeacon.SetActive(false);
return;
}
if (_portalBeacon == null)
{
_portalBeacon = GameObject.CreatePrimitive(PrimitiveType.Cylinder);
_portalBeacon.name = "~RoomPortalBeacon";
var col = _portalBeacon.GetComponent<Collider>(); if (col != null) Object.Destroy(col); // cosmetic only
_portalBeacon.transform.SetParent(_fxRoot, false);
var mr = _portalBeacon.GetComponent<MeshRenderer>();
mr.sharedMaterial = _portalMat;
mr.shadowCastingMode = UnityEngine.Rendering.ShadowCastingMode.Off;
mr.receiveShadows = false;
}
if (!_portalBeacon.activeSelf) _portalBeacon.SetActive(true);
float t = (float)SystemAPI.Time.ElapsedTime;
float breathe = 0.5f + 0.5f * math.sin(t * 3.5f);
var tr = _portalBeacon.transform;
// Cylinder is 2u tall in local space -> scale.y=2.2 gives a 4.4u pillar; lift the centre so the base sits
// on the TERRAIN (y=0) — pos.y is the CC capsule-center plane (GridOrigin.y=1), 1 u above the ground.
tr.position = new Vector3(pos.x, 2.2f, pos.z);
tr.localScale = new Vector3(0.9f + 0.12f * breathe, 2.2f, 0.9f + 0.12f * breathe);
_portalMat.color = new Color(0.25f, 1.2f, 1.55f, 0.45f + 0.3f * breathe); // glow throb (beacon-only mat; Phase 0: tamed + slimmed — the fat 6u pillar bloomed to a white egg swallowing the prompt)
}
}
}
@@ -1,2 +0,0 @@
fileFormatVersion: 2
guid: 587c50c122f52954da052232f28a6052
@@ -1,259 +0,0 @@
using System.Collections.Generic;
using ProjectM.Simulation;
using Unity.Entities;
using Unity.NetCode;
using Unity.Transforms;
using Unity.Mathematics;
using UnityEngine;
using UnityEngine.UIElements;
namespace ProjectM.Client
{
/// <summary>
/// The drawn branching route map (RouteSelect) — extracted from <see cref="HudSystem"/> into its own client-only,
/// observe-only presentation <see cref="SystemBase"/> in <see cref="PresentationSystemGroup"/>. Owns its own
/// runtime UIDocument sharing <see cref="MenuUi.LoadPanelSettings"/> (sortingOrder 55). The map is regenerated
/// client-side from <c>RunInfo.RunSeed</c> for DISPLAY only; the clickable next-layer nodes bind to the
/// authoritative RouteOpt* bytes (never the regen) via <see cref="RouteSendSystem.PickRoute"/>. Also owns the
/// client-local visited-path trace (nodeIds; reset per RunSeed) that lights walked edges.
/// </summary>
[WorldSystemFilter(WorldSystemFilterFlags.ClientSimulation)]
[UpdateInGroup(typeof(PresentationSystemGroup))]
public partial class RouteMapHudSystem : SystemBase
{
GameObject _go;
UIDocument _doc;
bool _built;
VisualElement _routePanel;
Label _routeTitle;
bool _routePanelBuilt;
VisualElement _routeMapHost; // node circles + Painter2D edges
int _routeMapSig; // (seed, room, col, options) signature the map was drawn for
readonly List<int> _routeVisited = new(); // client-local path trace (nodeIds), reset per RunSeed
uint _routeVisitedSeed;
protected override void OnStartRunning()
{
if (_go != null) return;
MenuUi.EnsureEventSystem();
_go = new GameObject("~HUDRouteMap");
_doc = _go.AddComponent<UIDocument>();
_doc.panelSettings = MenuUi.LoadPanelSettings();
_doc.sortingOrder = 55;
}
protected override void OnDestroy()
{
if (_go != null) Object.Destroy(_go);
}
protected override void OnUpdate()
{
if (_doc == null) return;
var root = _doc.rootVisualElement;
if (root == null) return; // panel not initialised yet (next frame)
if (!_built)
{
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
_built = true;
}
bool haveRun = SystemAPI.TryGetSingleton<RunInfo>(out var runInfo);
UpdateRoutePanel(haveRun ? runInfo : default);
// Client-local path trace for the route map (nodeIds visited this run; display-only).
if (haveRun && runInfo.RunSeed != _routeVisitedSeed)
{
_routeVisited.Clear();
_routeVisitedSeed = runInfo.RunSeed;
}
if (haveRun && runInfo.Lifecycle == RunLifecycle.InRoom)
{
int visitedNode = RunMap.NodeId(runInfo.CurrentRoom, runInfo.CurrentCol);
if (_routeVisited.Count == 0 || _routeVisited[^1] != visitedNode) _routeVisited.Add(visitedNode);
}
}
// ---- the drawn branching route map (Slay-the-Spire style; display regen from RunSeed, clicks bind
// to the authoritative RouteOpt* bytes) ----
const float MapStrideX = 58f, MapStrideY = 46f, MapNodeSize = 34f, MapPad = 14f;
static Vector2 MapNodePos(int layer, int col, byte layerWidth)
{
float x = MapPad + layer * MapStrideX;
float y = MapPad + MapStrideY + (col - (layerWidth - 1) * 0.5f) * MapStrideY;
return new Vector2(x, y);
}
static string RoomGlyph(byte t) => t == RoomTypeId.Boss ? "B"
: t == RoomTypeId.Elite ? "E" : t == RoomTypeId.Reward ? "R" : "C";
static Color RoomColor(byte t) => t == RoomTypeId.Boss ? new Color(0.92f, 0.28f, 0.22f)
: t == RoomTypeId.Elite ? new Color(0.80f, 0.45f, 1f)
: t == RoomTypeId.Reward ? new Color(0.45f, 0.95f, 0.55f) : new Color(1f, 0.72f, 0.35f);
void UpdateRoutePanel(RunInfo runInfo)
{
// Keyed on the LIFECYCLE (never RouteOptionCount alone — the review's D-F6 criterion).
bool show = runInfo.Lifecycle == RunLifecycle.RouteSelect && runInfo.RouteOptionCount > 0;
if (!show)
{
if (_routePanel != null) _routePanel.style.display = DisplayStyle.None;
_routeMapSig = 0;
return;
}
var root = _doc != null ? _doc.rootVisualElement : null;
if (root == null) return;
if (!_routePanelBuilt)
{
BuildRoutePanel(root);
_routePanelBuilt = true;
}
int sig = (int)runInfo.RunSeed ^ (runInfo.CurrentRoom + 1) * 131 ^ runInfo.CurrentCol * 31
^ (runInfo.RouteOptionCount << 24) ^ (runInfo.RouteOpt0Col << 16)
^ (runInfo.RouteOpt1Col << 18) ^ (runInfo.RouteOpt2Col << 20);
if (sig == 0) sig = 1;
if (_routeMapSig != sig)
{
RebuildRouteMap(runInfo);
_routeTitle.text = "CHOOSE YOUR PATH — room " + (runInfo.CurrentRoom + 2) + "/" + runInfo.RoomCount;
_routeMapSig = sig;
}
_routePanel.style.display = DisplayStyle.Flex;
}
void BuildRoutePanel(VisualElement root)
{
_routePanel = new VisualElement { pickingMode = PickingMode.Ignore };
_routePanel.style.position = Position.Absolute;
_routePanel.style.left = 0; _routePanel.style.right = 0;
_routePanel.style.top = 0; _routePanel.style.bottom = 0;
_routePanel.style.alignItems = Align.Center;
_routePanel.style.justifyContent = Justify.Center;
_routePanel.style.display = DisplayStyle.None;
var box = new VisualElement { pickingMode = PickingMode.Position }; // swallow world clicks under the map
box.style.backgroundColor = new Color(0.07f, 0.09f, 0.12f, 0.95f);
box.style.borderTopLeftRadius = 10; box.style.borderTopRightRadius = 10;
box.style.borderBottomLeftRadius = 10; box.style.borderBottomRightRadius = 10;
box.style.paddingLeft = 18; box.style.paddingRight = 18;
box.style.paddingTop = 12; box.style.paddingBottom = 12;
box.style.alignItems = Align.Center;
_routeTitle = new Label("CHOOSE YOUR PATH");
_routeTitle.style.color = new Color(0.55f, 0.85f, 1f);
_routeTitle.style.fontSize = 16;
_routeTitle.style.unityFontStyleAndWeight = FontStyle.Bold;
_routeTitle.style.marginBottom = 10;
box.Add(_routeTitle);
_routeMapHost = new VisualElement { pickingMode = PickingMode.Ignore };
_routeMapHost.style.position = Position.Relative;
box.Add(_routeMapHost);
var cap = HudUi.Text("your path is lit — click a highlighted room to commit the party", 13,
MenuUi.SubCol, TextAnchor.MiddleCenter);
cap.style.marginTop = 10;
box.Add(cap);
_routePanel.Add(box);
root.Add(_routePanel);
}
void RebuildRouteMap(RunInfo runInfo)
{
_routeMapHost.Clear();
var map = RunMapMath.Generate(runInfo.RunSeed);
_routeMapHost.style.width = MapPad * 2f + (map.LayerCount - 1) * MapStrideX + MapNodeSize;
_routeMapHost.style.height = MapPad * 2f + 2f * MapStrideY + MapNodeSize;
// Edges under the nodes (Painter2D); walked segments glow, the rest are faint.
var edges = new VisualElement { pickingMode = PickingMode.Ignore };
edges.style.position = Position.Absolute;
edges.style.left = 0; edges.style.top = 0; edges.style.right = 0; edges.style.bottom = 0;
var mapCopy = map;
var visited = new List<int>(_routeVisited);
edges.generateVisualContent += ctx =>
{
var p = ctx.painter2D;
p.lineWidth = 2f;
var c = new Vector2(MapNodeSize * 0.5f, MapNodeSize * 0.5f);
for (int layer = 0; layer < mapCopy.LayerCount - 1; layer++)
for (int col = 0; col < mapCopy.LayerWidths[layer]; col++)
{
var node = mapCopy.Node(layer, col);
if (node.NextMask == 0) continue;
var a = MapNodePos(layer, col, mapCopy.LayerWidths[layer]);
for (int j = 0; j < mapCopy.LayerWidths[layer + 1]; j++)
{
if ((node.NextMask & (1 << j)) == 0) continue;
var b = MapNodePos(layer + 1, j, mapCopy.LayerWidths[layer + 1]);
bool walked = visited.Contains(RunMap.NodeId(layer, col))
&& visited.Contains(RunMap.NodeId(layer + 1, j));
p.strokeColor = walked ? new Color(0.55f, 0.85f, 1f, 0.9f) : new Color(1f, 1f, 1f, 0.16f);
p.BeginPath();
p.MoveTo(a + c);
p.LineTo(b + c);
p.Stroke();
}
}
};
_routeMapHost.Add(edges);
int nextLayer = runInfo.CurrentRoom + 1;
for (int layer = 0; layer < map.LayerCount; layer++)
for (int col = 0; col < map.LayerWidths[layer]; col++)
{
var node = map.Node(layer, col);
bool isCurrent = layer == runInfo.CurrentRoom && col == runInfo.CurrentCol;
bool wasVisited = _routeVisited.Contains(RunMap.NodeId(layer, col));
byte opt = 255;
if (layer == nextLayer)
{
if (runInfo.RouteOptionCount > 0 && col == runInfo.RouteOpt0Col) opt = 0;
else if (runInfo.RouteOptionCount > 1 && col == runInfo.RouteOpt1Col) opt = 1;
else if (runInfo.RouteOptionCount > 2 && col == runInfo.RouteOpt2Col) opt = 2;
}
_routeMapHost.Add(MakeMapNode(node.RoomType,
MapNodePos(layer, col, map.LayerWidths[layer]), isCurrent, wasVisited, opt, layer <= runInfo.CurrentRoom));
}
}
VisualElement MakeMapNode(byte roomType, Vector2 pos, bool isCurrent, bool visited, byte optionIndex, bool past)
{
bool clickable = optionIndex != 255;
var n = new VisualElement { pickingMode = clickable ? PickingMode.Position : PickingMode.Ignore };
n.style.position = Position.Absolute;
n.style.left = pos.x; n.style.top = pos.y;
n.style.width = MapNodeSize; n.style.height = MapNodeSize;
MenuUi.Round(n, MapNodeSize * 0.5f);
var c = RoomColor(roomType);
float bgA = clickable ? 0.95f : visited || isCurrent ? 0.85f : past ? 0.20f : 0.40f;
var restBg = new Color(c.r * 0.35f, c.g * 0.35f, c.b * 0.35f, bgA);
n.style.backgroundColor = restBg;
MenuUi.Border(n, isCurrent ? new Color(0.55f, 0.85f, 1f) : clickable ? c : new Color(1f, 1f, 1f, 0.18f),
isCurrent || clickable ? 2.5f : 1.2f);
var lbl = new Label(RoomGlyph(roomType)) { pickingMode = PickingMode.Ignore };
lbl.style.unityTextAlign = TextAnchor.MiddleCenter;
lbl.style.flexGrow = 1;
lbl.style.color = clickable || visited || isCurrent ? c : new Color(1f, 1f, 1f, 0.35f);
lbl.style.fontSize = 15;
lbl.style.unityFontStyleAndWeight = FontStyle.Bold;
n.Add(lbl);
if (clickable)
{
byte pick = optionIndex; // closure copy, never the loop variable
n.RegisterCallback<ClickEvent>(_ => RouteSendSystem.PickRoute(pick));
n.RegisterCallback<MouseEnterEvent>(_ =>
n.style.backgroundColor = new Color(c.r * 0.55f, c.g * 0.55f, c.b * 0.55f, 1f));
n.RegisterCallback<MouseLeaveEvent>(_ => n.style.backgroundColor = restBg);
}
return n;
}
}
}
@@ -1,2 +0,0 @@
fileFormatVersion: 2
guid: e2330bd90a6294442959883258d476b4
@@ -1,142 +0,0 @@
using System.Collections.Generic;
using ProjectM.Simulation;
using Unity.Entities;
using Unity.Mathematics;
using Unity.NetCode;
using Unity.Transforms;
using UnityEngine;
using static ProjectM.Client.FeedbackFx;
namespace ProjectM.Client
{
/// <summary>
/// EB-1 — client-only WORLD JUICE for player-built structures taking damage + dying ("loses have weight"). A
/// managed <see cref="SystemBase"/> in <see cref="PresentationSystemGroup"/> that OBSERVES replicated state and
/// never mutates the sim: it edge-detects each structure ghost's [GhostField] <c>Health.Current</c> — a decrease
/// spawns a small amber chip (camera-SILENT so a siege's many hits never clamp the shake), and a destruction
/// (an HP&lt;=0 edge OR a despawn) spawns a LOUD red-orange burst + camera punch. A PROXIMITY GATE suppresses the
/// destruction burst unless the structure was near the local player, so the base->expedition RegionRelevancy
/// despawn (every base structure drops from this client at once) stays SILENT. De-duped: a structure fires its
/// death burst AT MOST once (the HP&lt;=0 edge sets DeathFired so the prune-cleanup skips it; the server destroys
/// a structure the same tick it hits 0, so the prune is usually the path that fires). CombatFeedbackSystem
/// suppresses structures, so this is the SOLE structure cue. Procedural particles + SFX (mirrors
/// WorldFeedbackSystem; self-contained). Never destroys a ghost (GhostDespawnSystem owns despawn); prunes the
/// cache EVERY frame (no <c>[RequireMatchingQueriesForUpdate]</c> — else a cache entry leaks per kill).
/// </summary>
[WorldSystemFilter(WorldSystemFilterFlags.ClientSimulation)]
[UpdateInGroup(typeof(PresentationSystemGroup))]
public partial class StructureFeedbackSystem : SystemBase
{
struct Cache { public float Hp; public float3 Pos; public bool DeathFired; }
readonly Dictionary<Entity, Cache> _cache = new();
readonly HashSet<Entity> _seen = new();
readonly List<Entity> _stale = new();
Transform _fxRoot;
ParticleSystem _chipFx;
ParticleSystem _deathFx;
AudioClip _chipClip;
AudioClip _deathClip;
protected override void OnCreate()
{
_chipClip = MakeClip("struct_chip", 700f, 500f, 0.05f, 0.30f);
_deathClip = MakeClip("struct_death", 220f, 60f, 0.35f, 0.55f);
}
protected override void OnStartRunning()
{
if (_fxRoot != null) return;
_fxRoot = new GameObject("~StructureFeedbackFX").transform;
var mat = MakeParticleMaterial();
_chipFx = MakeBurst(_fxRoot, "StructChips", mat, StructureFeelConfig.DamageTint, 0.12f, 5f, 0.30f, 256, 0.3f, 0.18f, 0.15f);
_deathFx = MakeBurst(_fxRoot, "StructDeath", mat, StructureFeelConfig.DeathTint, 0.20f, 8f, 0.55f, 512, 0.3f, 0.18f, 0.15f);
}
protected override void OnDestroy()
{
if (_fxRoot != null) Object.Destroy(_fxRoot.gameObject);
}
protected override void OnUpdate()
{
if (!StructureFeelConfig.Enabled) { _cache.Clear(); return; }
EntityManager.CompleteDependencyBeforeRO<Health>();
EntityManager.CompleteDependencyBeforeRO<PlacedStructure>();
EntityManager.CompleteDependencyBeforeRO<LocalTransform>();
bool haveLocal = false;
float3 localPos = default;
foreach (var xf in SystemAPI.Query<RefRO<LocalTransform>>().WithAll<GhostOwnerIsLocal, PlayerTag>())
{
localPos = xf.ValueRO.Position;
haveLocal = true;
}
float rangeSq = StructureFeelConfig.ProximityRange * StructureFeelConfig.ProximityRange;
_seen.Clear();
foreach (var (health, xf, e) in
SystemAPI.Query<RefRO<Health>, RefRO<LocalTransform>>().WithAll<PlacedStructure>().WithEntityAccess())
{
_seen.Add(e);
float cur = health.ValueRO.Current;
float3 pos = xf.ValueRO.Position;
bool nearby = haveLocal && math.distancesq(pos, localPos) <= rangeSq;
if (_cache.TryGetValue(e, out var prev))
{
if (cur <= 0f && prev.Hp > 0f && !prev.DeathFired)
{
if (nearby) FireDeath(pos);
_cache[e] = new Cache { Hp = cur, Pos = pos, DeathFired = true };
continue;
}
if (cur < prev.Hp - 0.001f && cur > 0f && nearby)
{
EmitTinted(_chipFx, (Vector3)pos + Vector3.up * 0.7f, StructureFeelConfig.ChipBurstCount, StructureFeelConfig.DamageTint);
PlayClip(_chipClip, (Vector3)pos, StructureFeelConfig.ChipSfxVolume);
}
}
_cache[e] = new Cache { Hp = cur, Pos = pos, DeathFired = _cache.TryGetValue(e, out var c2) && c2.DeathFired };
}
// Prune: a despawn = destroyed (or a region-transit drop). Proximity-gated so the +1000 base->expedition
// despawn stays silent; de-duped against an HP<=0 edge that already fired this structure's death.
if (_cache.Count != _seen.Count)
{
_stale.Clear();
foreach (var kv in _cache)
if (!_seen.Contains(kv.Key)) _stale.Add(kv.Key);
for (int i = 0; i < _stale.Count; i++)
{
var c = _cache[_stale[i]];
if (!c.DeathFired && haveLocal && math.distancesq(c.Pos, localPos) <= rangeSq)
FireDeath(c.Pos);
_cache.Remove(_stale[i]);
}
}
}
void FireDeath(float3 pos)
{
EmitTinted(_deathFx, (Vector3)pos + Vector3.up * 0.6f, StructureFeelConfig.DeathBurstCount, StructureFeelConfig.DeathTint);
PlayClip(_deathClip, (Vector3)pos, StructureFeelConfig.DeathSfxVolume);
PrototypeCameraRig.PunchFov(StructureFeelConfig.DeathFovKick, 110f);
PrototypeCameraRig.AddShake(StructureFeelConfig.DeathShake);
}
// ---- procedural particles + SFX (mirrors WorldFeedbackSystem; self-contained) ----
}
}
@@ -1,2 +0,0 @@
fileFormatVersion: 2
guid: 61153a58a80eb0542bbdc62085cce81b
@@ -1,47 +0,0 @@
using UnityEngine;
namespace ProjectM.Client
{
/// <summary>
/// EB-1 — static live-tunable knobs for <see cref="StructureFeedbackSystem"/> (structure damage chips +
/// destruction bursts). A presentation-only bridge (mirrors <c>WorldFeelConfig</c>); reset on play-enter via
/// <see cref="RuntimeInitializeOnLoadMethod"/> so poked values never leak across fast-enter-playmode sessions.
/// Read only on the main thread by the managed feedback system, never from Burst.
/// </summary>
public static class StructureFeelConfig
{
public static bool Enabled = true;
/// <summary>A despawn farther than this from the local player does NOT fire a death burst — so the
/// base->expedition RegionRelevancy despawn (all base structures drop at once) stays silent.</summary>
public static float ProximityRange = 45f;
public static int ChipBurstCount = 8;
public static int DeathBurstCount = 40;
public static float ChipSfxVolume = 0.25f;
public static float DeathSfxVolume = 0.6f;
// A LOUD, low-frequency punch is reserved for a structure DEATH only; per-chip feedback is camera-silent so
// a wave of hits never sustains a nauseating shake (AddShake clamps cumulatively, PunchFov takes a max).
public static float DeathFovKick = 5.5f;
public static float DeathShake = 0.35f;
public static Color DamageTint = new Color(2.4f, 1.4f, 0.4f); // amber HDR spark on a hit
public static Color DeathTint = new Color(3.0f, 0.7f, 0.25f); // red-orange HDR loss burst
[RuntimeInitializeOnLoadMethod(RuntimeInitializeLoadType.SubsystemRegistration)]
static void ResetDefaults()
{
Enabled = true;
ProximityRange = 45f;
ChipBurstCount = 8;
DeathBurstCount = 40;
ChipSfxVolume = 0.25f;
DeathSfxVolume = 0.6f;
DeathFovKick = 5.5f;
DeathShake = 0.35f;
DamageTint = new Color(2.4f, 1.4f, 0.4f);
DeathTint = new Color(3.0f, 0.7f, 0.25f);
}
}
}
@@ -1,2 +0,0 @@
fileFormatVersion: 2
guid: c868d6648bec9fd4199c44fcf8330326
@@ -39,28 +39,11 @@ namespace ProjectM.Client
float expDen = cfg != null ? cfg.ExpeditionFogDensity : 0.04f; float expDen = cfg != null ? cfg.ExpeditionFogDensity : 0.04f;
Color expAmb = cfg != null ? cfg.ExpeditionAmbientSky : new Color(0.02f, 0.045f, 0.07f, 1f); Color expAmb = cfg != null ? cfg.ExpeditionAmbientSky : new Color(0.02f, 0.045f, 0.07f, 1f);
// Per-room murk flavors (cosmetic code consts; all stay inside the deep-sea band — hue shifts are // 2026-08-07 audit purge: per-room murk flavors (kelp shallows / crush-dark trench / gloam bloom)
// subtle, value stays dark so dynamic lights keep carrying readability). // keyed off RunInfo.CurrentBiome. The run FSM is deleted, so the out-shelf murk config above is the
bool haveRun = SystemAPI.TryGetSingleton<ProjectM.Simulation.RunInfo>(out var runInfo); // whole look. Re-key this off LANTERN's pocket type when Phase 2 lands — the colour constants are in
if (haveRun && runInfo.Lifecycle != ProjectM.Simulation.RunLifecycle.Staging) // git if the three flavors are wanted back.
{
switch (runInfo.CurrentBiome)
{
case ProjectM.Simulation.RoomBiomeId.Meadow: // kelp shallows: a touch greener
expFog = new Color(0.02f, 0.10f, 0.09f, 1f); expDen = 0.035f;
expAmb = new Color(0.025f, 0.06f, 0.06f, 1f);
break;
case ProjectM.Simulation.RoomBiomeId.Cavern: // crush-dark trench: colder + denser
expFog = new Color(0.01f, 0.05f, 0.08f, 1f); expDen = 0.05f;
expAmb = new Color(0.015f, 0.035f, 0.06f, 1f);
break;
case ProjectM.Simulation.RoomBiomeId.Blight: // gloam bloom: a sick green-violet cast
expFog = new Color(0.045f, 0.075f, 0.09f, 1f); expDen = 0.045f;
expAmb = new Color(0.045f, 0.05f, 0.075f, 1f);
break;
// default keeps the config/fallback out-shelf murk above.
}
}
float x = _cam.transform.position.x; float x = _cam.transform.position.x;
float t = Mathf.Clamp01((x - (boundary - half)) / (2f * half)); float t = Mathf.Clamp01((x - (boundary - half)) / (2f * half));
+2 -17
View File
@@ -76,25 +76,10 @@ namespace ProjectM.Client
} }
/// <summary>Icon for a <see cref="StructureType"/> byte (null → caller falls back to the structure name text).</summary> /// <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.Wall: return WallIcon;
case StructureType.Pylon: return PylonIcon;
default: return null;
}
}
/// <summary>Placement-ghost preview mesh for a <see cref="StructureType"/> byte (null → the cube fallback).</summary> /// <summary>Placement-ghost preview mesh for a <see cref="StructureType"/> byte (null → the cube fallback).</summary>
public Mesh StructureGhostMesh(byte type)
{
switch (type)
{
case StructureType.Wall: return WallGhostMesh;
default: return null;
}
}
// ---- cached SDF font definitions (one FontAsset per font, built once, reset per play session) ---- // ---- cached SDF font definitions (one FontAsset per font, built once, reset per play session) ----
static FontAsset _displayFa, _bodyFa, _bodyLightFa; static FontAsset _displayFa, _bodyFa, _bodyLightFa;
@@ -179,19 +179,11 @@ namespace ProjectM.Client
var st = tq.GetSingleton<NetworkTime>().ServerTick; var st = tq.GetSingleton<NetworkTime>().ServerTick;
if (st.IsValid) nowTick = st.TickIndexForValidTick; if (st.IsValid) nowTick = st.TickIndexForValidTick;
} }
// v6: the permanent-meta slice via the ONE shared collector — omitting it HERE (the most common // 2026-08-07 audit purge: the quit-to-menu save used to collect the permanent-meta slice and the
// exit path) would silently WIPE all meta progression on quit (the meta review's top blocker). // placed structures too. Both layers are deleted; the ledger is the whole save now.
MetaSaveScan.Collect(em, dir, out var metaRows, out var runsCompleted, out var maxDepth);
SaveStructureScan.Collect(em, nowTick, out var structures);
SaveService.Save(new SaveData SaveService.Save(new SaveData
{ {
RunsCompleted = runsCompleted,
MaxDepthReached = maxDepth,
MetaUpgrades = metaRows,
Ledger = rows, Ledger = rows,
Structures = structures,
SavedAtMs = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(), SavedAtMs = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(),
}); });
} }
@@ -1,36 +0,0 @@
using System.Collections.Generic;
using ProjectM.Simulation;
using Unity.Entities;
using Unity.NetCode;
using UnityEngine;
namespace ProjectM.Client
{
/// <summary>
/// Client-side class-pick sender: a static enqueue (the Staging class-select HUD buttons) drained into
/// <see cref="ClassSelectRequest"/> RPCs (the MetaSpendSendSystem idiom). Carries only the class id; the server
/// re-validates the phase + applies the full swap. Statics reset on play-enter (the stale-bridge hazard).
/// </summary>
[WorldSystemFilter(WorldSystemFilterFlags.ClientSimulation)]
public partial class ClassSelectSendSystem : SystemBase
{
static readonly Queue<byte> s_Queue = new();
[RuntimeInitializeOnLoadMethod(RuntimeInitializeLoadType.SubsystemRegistration)]
static void ResetStatics() => s_Queue.Clear();
/// <summary>Queue a class pick (0=Warrior, 1=Ranger). The Staging class buttons drive this.</summary>
public static void RequestClass(byte classId) => s_Queue.Enqueue(classId);
protected override void OnCreate() => RequireForUpdate<NetworkId>();
protected override void OnUpdate()
{
while (s_Queue.Count > 0)
{
var req = EntityManager.CreateEntity(typeof(ClassSelectRequest), typeof(SendRpcCommandRequest));
EntityManager.SetComponentData(req, new ClassSelectRequest { ClassId = s_Queue.Dequeue() });
}
}
}
}
@@ -1,2 +0,0 @@
fileFormatVersion: 2
guid: e0d67e293cdeb454fbef8414d4aeb813
@@ -1,33 +0,0 @@
using ProjectM.Simulation;
using Unity.Entities;
using Unity.NetCode;
using UnityEngine;
namespace ProjectM.Client
{
/// <summary>
/// Client-side portal-interact sender: a static flag (the portal prompt / E-key) drained into a single
/// <see cref="PortalInteractRequest"/> RPC. Coalesced (one per drain — repeat E while the server is still in
/// RoomExplore is harmless, the server latches once). Statics reset on play-enter.
/// </summary>
[WorldSystemFilter(WorldSystemFilterFlags.ClientSimulation)]
public partial class PortalInteractSendSystem : SystemBase
{
static bool s_pending;
[RuntimeInitializeOnLoadMethod(RuntimeInitializeLoadType.SubsystemRegistration)]
static void ResetStatics() => s_pending = false;
/// <summary>Request leaving via the portal (the HUD prompt / E-key near the portal drives this).</summary>
public static void Interact() => s_pending = true;
protected override void OnCreate() => RequireForUpdate<NetworkId>();
protected override void OnUpdate()
{
if (!s_pending) return;
s_pending = false;
EntityManager.CreateEntity(typeof(PortalInteractRequest), typeof(SendRpcCommandRequest));
}
}
}
@@ -1,2 +0,0 @@
fileFormatVersion: 2
guid: eac0f1b2340c0b344b430363311d3be5
@@ -1,36 +0,0 @@
using System.Collections.Generic;
using ProjectM.Simulation;
using Unity.Entities;
using Unity.NetCode;
using UnityEngine;
namespace ProjectM.Client
{
/// <summary>
/// Client-side prep-loadout sender: a static enqueue (the Staging PREP panel buttons) drained into
/// <see cref="PrepPurchaseRequest"/> RPCs (the MetaSpendSendSystem idiom). Carries only the option id; the server
/// prices + re-validates (Staging, affordability, once-per-run). Statics reset on play-enter.
/// </summary>
[WorldSystemFilter(WorldSystemFilterFlags.ClientSimulation)]
public partial class PrepPurchaseSendSystem : SystemBase
{
static readonly Queue<byte> s_Queue = new();
[RuntimeInitializeOnLoadMethod(RuntimeInitializeLoadType.SubsystemRegistration)]
static void ResetStatics() => s_Queue.Clear();
/// <summary>Queue a prep-loadout purchase by catalog option id. The Staging PREP rows drive this.</summary>
public static void RequestPrep(byte optionId) => s_Queue.Enqueue(optionId);
protected override void OnCreate() => RequireForUpdate<NetworkId>();
protected override void OnUpdate()
{
while (s_Queue.Count > 0)
{
var req = EntityManager.CreateEntity(typeof(PrepPurchaseRequest), typeof(SendRpcCommandRequest));
EntityManager.SetComponentData(req, new PrepPurchaseRequest { OptionId = s_Queue.Dequeue() });
}
}
}
}
@@ -1,2 +0,0 @@
fileFormatVersion: 2
guid: 5b60ac8ed7ee3ce4081c55b2188be142
@@ -1,59 +0,0 @@
using ProjectM.Simulation;
using Unity.Entities;
using Unity.NetCode;
using UnityEngine;
namespace ProjectM.Client
{
/// <summary>
/// Client-side ready-toggle sender: a static enqueue (HUD button at Step 14 / the T dev key / execute_code)
/// drained into <see cref="ReadyToggleRequest"/> RPC entities — the BuildSendSystem queue+drain idiom. The local
/// bool tracks only the toggle DIRECTION; the server-replicated <see cref="PlayerReady"/> is the truth the HUD
/// renders. Statics reset on play-enter (statics survive fast-enter-playmode reloads — the stale-bridge hazard).
/// </summary>
[WorldSystemFilter(WorldSystemFilterFlags.ClientSimulation)]
public partial class ReadySendSystem : SystemBase
{
static int s_Pending; // queued explicit sets
static byte s_PendingValue;
static bool s_LocalReady; // last requested state (toggle direction only, not authority)
[RuntimeInitializeOnLoadMethod(RuntimeInitializeLoadType.SubsystemRegistration)]
static void ResetStatics()
{
s_Pending = 0;
s_PendingValue = 0;
s_LocalReady = false;
}
/// <summary>Queue an explicit ready set (HUD button / execute_code).</summary>
public static void SetReady(bool ready)
{
s_PendingValue = (byte)(ready ? 1 : 0);
s_Pending++;
s_LocalReady = ready;
}
/// <summary>Queue a toggle of the last requested state (the T dev key; HUD replaces this at Step 14).</summary>
public static void ToggleReady() => SetReady(!s_LocalReady);
protected override void OnCreate()
{
RequireForUpdate<NetworkId>();
}
protected override void OnUpdate()
{
var keyboard = UnityEngine.InputSystem.Keyboard.current;
if (keyboard != null && keyboard.tKey.wasPressedThisFrame && !PauseMenuController.Open)
ToggleReady();
while (s_Pending > 0)
{
s_Pending--;
var req = EntityManager.CreateEntity(typeof(ReadyToggleRequest), typeof(SendRpcCommandRequest));
EntityManager.SetComponentData(req, new ReadyToggleRequest { Ready = s_PendingValue });
}
}
}
}
@@ -1,2 +0,0 @@
fileFormatVersion: 2
guid: 7047cb8f6861ba8498f33698c9948dc8
@@ -1,60 +0,0 @@
using ProjectM.Simulation;
using Unity.Entities;
using Unity.NetCode;
using UnityEngine;
namespace ProjectM.Client
{
/// <summary>
/// Client-side route-pick sender: a static enqueue (the Step-14 map panel's option buttons / execute_code)
/// drained into <see cref="RouteSelectRequest"/> RPCs. The request is stamped from the CLIENT's replicated
/// <see cref="RunInfo"/>: <c>ForRunEpoch = (int)RunSeed</c> (the re-meaned run-identity token — the server-only
/// RunEpoch is not client-knowable) and <c>ForLayer = CurrentRoom</c> VERBATIM (during a gate that is still the
/// just-cleared layer — never +1). The server re-validates everything; a stale/possessed pick is simply dropped.
/// Statics reset on play-enter (the stale-bridge hazard).
/// </summary>
[WorldSystemFilter(WorldSystemFilterFlags.ClientSimulation)]
public partial class RouteSendSystem : SystemBase
{
static int s_Pending;
static byte s_PendingIndex;
[RuntimeInitializeOnLoadMethod(RuntimeInitializeLoadType.SubsystemRegistration)]
static void ResetStatics()
{
s_Pending = 0;
s_PendingIndex = 0;
}
/// <summary>Queue a route pick (0..RouteOptionCount-1). HUD map panel + execute_code drive this.</summary>
public static void PickRoute(byte optionIndex)
{
s_PendingIndex = optionIndex;
s_Pending++;
}
protected override void OnCreate()
{
RequireForUpdate<NetworkId>();
RequireForUpdate<RunInfo>();
}
protected override void OnUpdate()
{
if (s_Pending == 0)
return;
var runInfo = SystemAPI.GetSingleton<RunInfo>();
while (s_Pending > 0)
{
s_Pending--;
var req = EntityManager.CreateEntity(typeof(RouteSelectRequest), typeof(SendRpcCommandRequest));
EntityManager.SetComponentData(req, new RouteSelectRequest
{
OptionIndex = s_PendingIndex,
ForRunEpoch = (int)runInfo.RunSeed, // the re-meaned replicated run token
ForLayer = runInfo.CurrentRoom, // the gate's un-incremented cleared layer
});
}
}
}
}
@@ -1,2 +0,0 @@
fileFormatVersion: 2
guid: c22921cccedc3564b86d12491d88488e
@@ -1,101 +0,0 @@
using ProjectM.Simulation;
using Unity.Burst;
using Unity.Collections;
using Unity.Entities;
using Unity.Mathematics;
using Unity.NetCode;
using Unity.Transforms;
namespace ProjectM.Server
{
/// <summary>
/// One-shot server restore of player-built structures for a "Continue" session. The menu (WorldLauncher) stages a
/// <see cref="PendingStructure"/> carrier in the fresh ServerWorld BEFORE the gameplay subscene streams; this
/// system waits (RequireForUpdate) for the streamed <see cref="StructureCatalog"/> + <see cref="BaseAnchor"/> +
/// a valid NetworkTime, then replays each saved structure CHARGE-FREE: Instantiate the catalog prefab at the
/// saved cell (preserving the baked Scale), restore the wounded HP born-correct, re-tag RegionTag{Base} +
/// RuntimePlacedTag, then DESTROY the carrier so it never runs again. The ledger restores separately +
/// absolutely via CycleDirectorSpawnSystem's born-correct load (no double-spend, no Withdraw here).
/// </summary>
[BurstCompile]
[WorldSystemFilter(WorldSystemFilterFlags.ServerSimulation)]
public partial struct BaseRestoreSystem : ISystem
{
ComponentLookup<LocalTransform> m_TransformLookup;
ComponentLookup<Health> m_HealthLookup;
[BurstCompile]
public void OnCreate(ref SystemState state)
{
m_TransformLookup = state.GetComponentLookup<LocalTransform>(isReadOnly: true);
m_HealthLookup = state.GetComponentLookup<Health>(isReadOnly: true);
state.RequireForUpdate<StructureCatalog>();
state.RequireForUpdate<BaseAnchor>();
state.RequireForUpdate<NetworkTime>();
state.RequireForUpdate(state.GetEntityQuery(ComponentType.ReadOnly<PendingStructure>()));
}
[BurstCompile]
public void OnUpdate(ref SystemState state)
{
var serverTick = SystemAPI.GetSingleton<NetworkTime>().ServerTick;
if (!serverTick.IsValid)
return;
uint now = serverTick.TickIndexForValidTick;
m_TransformLookup.Update(ref state);
m_HealthLookup.Update(ref state);
var anchor = SystemAPI.GetSingleton<BaseAnchor>();
var catalog = SystemAPI.GetBuffer<StructureCatalogEntry>(SystemAPI.GetSingletonEntity<StructureCatalog>());
var ecb = new EntityCommandBuffer(Allocator.Temp);
foreach (var (pending, carrier) in
SystemAPI.Query<DynamicBuffer<PendingStructure>>().WithEntityAccess())
{
for (int s = 0; s < pending.Length; s++)
{
var p = pending[s];
int entryIdx = -1;
for (int i = 0; i < catalog.Length; i++)
if (catalog[i].Type == p.Type) { entryIdx = i; break; }
if (entryIdx < 0 || catalog[entryIdx].Prefab == Entity.Null)
continue; // type not in the catalog (e.g. a save from a newer build) -> skip, don't crash
var prefab = catalog[entryIdx].Prefab;
var structure = ecb.Instantiate(prefab);
int2 cell = new int2(p.CellX, p.CellZ);
var xform = m_TransformLookup[prefab];
xform.Position = BaseGridMath.CellToWorld(anchor, cell); // preserve baked Scale (FromPosition would reset it)
ecb.SetComponent(structure, xform);
ecb.SetComponent(structure, new PlacedStructure
{
Type = p.Type,
Cell = cell,
NextTick = 0u, // cooldown restore retired with the automation chain (LANTERN purge)
LastProcessedTick = TickUtil.NonZero(now),
});
// EB-1: restore the wounded HP born-correct in the SAME ecb as Instantiate (Health.Current is a
// [GhostField]; a deferred set would leak baked Max to clients for one snapshot). Max + the
// 0->full fallback come from the BAKED prefab, never the save.
if (m_HealthLookup.HasComponent(prefab))
{
var hm = m_HealthLookup[prefab];
ecb.SetComponent(structure, new Health { Current = p.HP > 0f ? p.HP : hm.Max, Max = hm.Max });
}
ecb.AddComponent(structure, new RegionTag { Region = RegionId.Base });
ecb.AddComponent<RuntimePlacedTag>(structure);
}
ecb.DestroyEntity(carrier);
}
ecb.Playback(state.EntityManager);
ecb.Dispose();
}
}
}
@@ -1,2 +0,0 @@
fileFormatVersion: 2
guid: 4003027ade5ccd5418e300d87e5c5e14
@@ -1,106 +0,0 @@
using ProjectM.Simulation;
using Unity.Burst;
using Unity.Collections;
using Unity.Entities;
using Unity.Mathematics;
using Unity.NetCode;
using Unity.Transforms;
namespace ProjectM.Server
{
/// <summary>
/// Server-authoritative structure placement (handles <see cref="BuildPlaceRequest"/> RPCs). Derives
/// occupancy by scanning live <see cref="PlacedStructure"/> ghosts into a Temp NativeHashSet (structures
/// are the source of truth — no cached buffer on the immutable BaseAnchor). For each request it validates
/// catalog/legality/occupancy/cost, and on success commits IN-PLACE (StorageMath.Withdraw on the global
/// ledger + reserve the cell in the set) so two same-tick requests for one cell can't both pass — the
/// StorageOpReceiveSystem in-place idiom — then instantiates the catalog prefab at the cell center
/// (RegionTag{Base}, world-owned, NextTick=0, LastProcessedTick stamped). Plain server SimulationSystemGroup
/// (not predicted → applied once). Rejects invalid requests silently.
/// </summary>
[BurstCompile]
[WorldSystemFilter(WorldSystemFilterFlags.ServerSimulation)]
public partial struct BuildPlaceSystem : ISystem
{
ComponentLookup<LocalTransform> m_TransformLookup;
[BurstCompile]
public void OnCreate(ref SystemState state)
{
m_TransformLookup = state.GetComponentLookup<LocalTransform>(isReadOnly: true);
state.RequireForUpdate<StructureCatalog>();
state.RequireForUpdate<BaseAnchor>();
state.RequireForUpdate<ResourceLedger>();
state.RequireForUpdate<NetworkTime>();
var builder = new EntityQueryBuilder(Allocator.Temp)
.WithAll<BuildPlaceRequest, ReceiveRpcCommandRequest>();
state.RequireForUpdate(state.GetEntityQuery(builder));
}
[BurstCompile]
public void OnUpdate(ref SystemState state)
{
m_TransformLookup.Update(ref state);
uint now = SystemAPI.GetSingleton<NetworkTime>().ServerTick.TickIndexForValidTick;
var anchor = SystemAPI.GetSingleton<BaseAnchor>();
var catalog = SystemAPI.GetBuffer<StructureCatalogEntry>(SystemAPI.GetSingletonEntity<StructureCatalog>());
var ledger = SystemAPI.GetBuffer<StorageEntry>(SystemAPI.GetSingletonEntity<ResourceLedger>());
// Derive occupancy from the live structure set (authoritative).
var occupied = new NativeHashSet<int2>(64, Allocator.Temp);
foreach (var ps in SystemAPI.Query<RefRO<PlacedStructure>>())
occupied.Add(ps.ValueRO.Cell);
var ecb = new EntityCommandBuffer(Allocator.Temp);
foreach (var (request, receive, requestEntity) in
SystemAPI.Query<RefRO<BuildPlaceRequest>, RefRO<ReceiveRpcCommandRequest>>().WithEntityAccess())
{
var req = request.ValueRO;
int2 cell = new int2(req.CellX, req.CellZ);
int entryIdx = -1;
for (int i = 0; i < catalog.Length; i++)
if (catalog[i].Type == req.StructureType) { entryIdx = i; break; }
if (entryIdx >= 0 && catalog[entryIdx].Prefab != Entity.Null
&& BuildPlacementMath.CanPlace(anchor, occupied, cell))
{
var entry = catalog[entryIdx];
int have = 0;
for (int i = 0; i < ledger.Length; i++)
if (ledger[i].ItemId == entry.CostResourceId) { have = ledger[i].Count; break; }
if (have >= entry.CostAmount)
{
// Commit IN-PLACE so a second same-tick request sees the spend + reservation.
StorageMath.Withdraw(ledger, entry.CostResourceId, entry.CostAmount);
occupied.Add(cell);
var structure = ecb.Instantiate(entry.Prefab);
var xform = m_TransformLookup[entry.Prefab];
xform.Position = BaseGridMath.CellToWorld(anchor, cell); // preserve baked Scale
ecb.SetComponent(structure, xform);
ecb.SetComponent(structure, new PlacedStructure
{
Type = req.StructureType,
Cell = cell,
NextTick = 0u,
LastProcessedTick = 0u, // 0 = uninitialized; the production systems set the baseline on first encounter (turret ignores it)
});
ecb.AddComponent(structure, new RegionTag { Region = RegionId.Base });
ecb.AddComponent<RuntimePlacedTag>(structure); // player-built -> persisted by SaveStructureScan
}
}
ecb.DestroyEntity(requestEntity);
}
ecb.Playback(state.EntityManager);
ecb.Dispose();
occupied.Dispose();
}
}
}
@@ -1,2 +0,0 @@
fileFormatVersion: 2
guid: d1886c7056b315e42b7754f50c43c59e
@@ -1,157 +0,0 @@
using ProjectM.Simulation;
using Unity.Burst;
using Unity.Collections;
using Unity.Entities;
using Unity.NetCode;
namespace ProjectM.Server
{
/// <summary>
/// Server receiver for <see cref="BoonPickRequest"/> + the reward-grace AUTO-PICK backstop. A valid pick
/// (sender resolved, <c>RunInfo.Lifecycle == RoomReward</c> — the D-F4 gate — <c>Pending == 1</c>, index in
/// range, option id known to the catalog) appends ONE <see cref="StatModifier"/> in the run-scoped BOON band
/// (<c>Tuning.BoonSourceIdBase + BoonPickCounter++</c> — distinct rows, one range-strip clears the run) and
/// clears <c>Pending</c>; the buffer mutation is non-structural and folds through the unchanged
/// StatRecomputeSystem on both worlds (rollback-correct). When the reward grace elapses, every still-pending
/// EXPEDITION player is auto-dealt <c>Option0</c> (the operator's default un-picked policy — a player always
/// gets something) so the run never stalls on an AFK picker.
///
/// Ordering: <c>[UpdateBefore(RunDirectorSystem)]</c> — ALL RPC receivers sit before the director (the
/// ReadyToggle/RouteSelect symmetry). This closes the D-F4 straggler race STRUCTURALLY: on the tick the
/// director strips (Returning), a straggler pick is rejected here FIRST (lifecycle is already past RoomReward),
/// so nothing can append after the strip; and the auto-pick lands before the director's exit gate reads
/// Pending. Requests are ALWAYS destroyed. No CyclePhase edge (the room-chain hard rule).
/// </summary>
[BurstCompile]
[WorldSystemFilter(WorldSystemFilterFlags.ServerSimulation)]
[UpdateInGroup(typeof(SimulationSystemGroup))]
[UpdateBefore(typeof(RunDirectorSystem))]
public partial struct BoonApplySystem : ISystem
{
[BurstCompile]
public void OnCreate(ref SystemState state)
{
state.RequireForUpdate<BoonCatalog>();
state.RequireForUpdate<RunInfo>();
state.RequireForUpdate<RunRuntime>();
state.RequireForUpdate<NetworkTime>();
}
[BurstCompile]
public void OnUpdate(ref SystemState state)
{
var dirEntity = SystemAPI.GetSingletonEntity<RunInfo>();
var info = SystemAPI.GetComponent<RunInfo>(dirEntity);
var run = SystemAPI.GetComponent<RunRuntime>(dirEntity);
bool rewarding = info.Lifecycle == RunLifecycle.RoomReward;
var catalog = SystemAPI.GetComponent<BoonCatalog>(SystemAPI.GetSingletonEntity<BoonCatalog>());
if (!catalog.Value.IsCreated)
return;
ref var pool = ref catalog.Value.Value;
bool runDirty = false;
// ---- explicit picks (drained every tick so stale requests die even outside RoomReward) ----
var playerByConn = new NativeHashMap<int, Entity>(8, Allocator.Temp);
foreach (var (owner, entity) in
SystemAPI.Query<RefRO<GhostOwner>>().WithAll<PlayerTag, BoonOffer, StatModifier>().WithEntityAccess())
playerByConn[owner.ValueRO.NetworkId] = entity;
var ecb = new EntityCommandBuffer(Allocator.Temp);
foreach (var (receive, req, requestEntity) in
SystemAPI.Query<RefRO<ReceiveRpcCommandRequest>, RefRO<BoonPickRequest>>().WithEntityAccess())
{
var conn = receive.ValueRO.SourceConnection;
if (rewarding
&& req.ValueRO.Index < 3
&& SystemAPI.HasComponent<NetworkId>(conn)
&& playerByConn.TryGetValue(SystemAPI.GetComponent<NetworkId>(conn).Value, out var player))
{
var offer = SystemAPI.GetComponent<BoonOffer>(player);
if (offer.Pending == 1)
{
byte id = req.ValueRO.Index == 2 ? offer.Option2
: req.ValueRO.Index == 1 ? offer.Option1 : offer.Option0;
if (Apply(ref state, player, id, ref pool, ref run))
{
offer.Pending = 0;
SystemAPI.SetComponent(player, offer);
runDirty = true;
}
}
}
ecb.DestroyEntity(requestEntity);
}
ecb.Playback(state.EntityManager);
playerByConn.Dispose();
// ---- reward-grace auto-pick backstop (Option0 — the player always gets something) ----
if (rewarding && run.RewardGraceTick != 0u)
{
var serverTick = SystemAPI.GetSingleton<NetworkTime>().ServerTick;
if (serverTick.IsValid && !new NetworkTick(run.RewardGraceTick).IsNewerThan(serverTick))
{
foreach (var (offer, region, entity) in
SystemAPI.Query<RefRW<BoonOffer>, RefRO<RegionTag>>()
.WithAll<PlayerTag, StatModifier>().WithEntityAccess())
{
if (offer.ValueRO.Pending != 1 || region.ValueRO.Region != RegionId.Expedition)
continue;
if (Apply(ref state, entity, offer.ValueRO.Option0, ref pool, ref run))
runDirty = true;
offer.ValueRW.Pending = 0; // cleared even if the id was unknown — never wedge the gate
}
}
}
if (runDirty)
SystemAPI.SetComponent(dirEntity, run); // the documented BoonPickCounter co-write (band provenance)
}
/// <summary>Append the boon's StatModifier in the run-scoped band. False iff the id is unknown/zero.</summary>
static bool Apply(ref SystemState state, Entity player, byte boonId, ref BoonCatalogBlob pool, ref RunRuntime run)
{
if (boonId == 0)
return false;
int idx = BoonMath.FindDef(ref pool, boonId);
if (idx < 0)
return false; // unknown id (catalog drift) — preserve-and-skip, never throw
if (pool.Defs[idx].Kind == 1)
{
// Phase 1.7 mechanic-changer: mutate the baked-present BoonEffects (non-structural) instead of
// appending a StatModifier. Bytes only (Burst-safe switch). No BoonPickCounter bump (no band row).
if (!state.EntityManager.HasComponent<BoonEffects>(player))
return false; // real players are baked with it; skip defensively otherwise
var fx = state.EntityManager.GetComponentData<BoonEffects>(player);
byte delta = (byte)pool.Defs[idx].Value;
switch (pool.Defs[idx].EffectKind)
{
case BoonEffectKind.Pierce: fx.Pierce = (byte)(fx.Pierce + delta); break;
case BoonEffectKind.Fork: fx.Fork = (byte)(fx.Fork + delta); break;
case BoonEffectKind.Chain: fx.Chain = (byte)(fx.Chain + delta); break;
case BoonEffectKind.DashTrail: fx.Flags |= BoonFlag.DashTrail; break;
case BoonEffectKind.FinisherDetonate: fx.Flags |= BoonFlag.FinisherDetonate; break;
case BoonEffectKind.KnockToPull: fx.Flags |= BoonFlag.KnockToPull; break;
case BoonEffectKind.Siphon: fx.Flags |= BoonFlag.Siphon; break;
case BoonEffectKind.Frenzy: fx.Flags |= BoonFlag.Frenzy; break;
default: return false; // unknown effect kind — preserve-and-skip
}
state.EntityManager.SetComponentData(player, fx);
return true;
}
var mods = state.EntityManager.GetBuffer<StatModifier>(player);
mods.Add(new StatModifier
{
Target = pool.Defs[idx].Target,
Op = pool.Defs[idx].Op,
Value = pool.Defs[idx].Value,
SourceId = Tuning.BoonSourceIdBase + (run.BoonPickCounter % Tuning.BoonSourceIdSpan),
});
run.BoonPickCounter += 1;
return true;
}
}
}
@@ -1,2 +0,0 @@
fileFormatVersion: 2
guid: 5749745bedc86ca4396b9a3911ef8773
@@ -1,78 +0,0 @@
using ProjectM.Simulation;
using Unity.Burst;
using Unity.Entities;
using Unity.NetCode;
namespace ProjectM.Server
{
/// <summary>
/// Server-only choice-of-3 boon dealer: once per <see cref="RunRuntime.RoomEpoch"/> (int-equality latch on
/// <see cref="BoonOfferState"/>, attached beside the catalog singleton), when the run FSM enters RoomReward it
/// draws each EXPEDITION player's 3 distinct, rarity-weighted, class-filtered options via
/// <see cref="BoonMath.PickBoons"/> — deterministically seeded from Hash(RunSeed, room, NetworkId) — and writes
/// the player's owner-only replicated <see cref="BoonOffer"/> (Pending=1). A base-region player (dead-respawned,
/// late joiner) gets NO offer and never holds the gate (RunDirector counts only Pending!=0). BoonApplySystem
/// (Step 10) consumes picks; the Returning-edge strip zeroes stragglers.
///
/// Ordering: <c>[UpdateAfter(RunDirectorSystem)]</c> — on the RoomReward ENTRY tick this runs after the
/// transition, so offers exist BEFORE RunDirector's exit gate first evaluates (next tick). No CyclePhase edge.
/// </summary>
[BurstCompile]
[WorldSystemFilter(WorldSystemFilterFlags.ServerSimulation)]
[UpdateInGroup(typeof(SimulationSystemGroup))]
[UpdateAfter(typeof(RunDirectorSystem))]
public partial struct BoonOfferSystem : ISystem
{
[BurstCompile]
public void OnCreate(ref SystemState state)
{
state.RequireForUpdate<BoonCatalog>();
state.RequireForUpdate<RunInfo>();
state.RequireForUpdate<RunRuntime>();
}
[BurstCompile]
public void OnUpdate(ref SystemState state)
{
var catalogEntity = SystemAPI.GetSingletonEntity<BoonCatalog>();
// One-shot: attach this system's latch beside the catalog singleton (the RoomFieldState idiom).
if (!SystemAPI.HasComponent<BoonOfferState>(catalogEntity))
{
state.EntityManager.AddComponentData(catalogEntity, new BoonOfferState());
return; // structural change — clean re-read next tick
}
var dirEntity = SystemAPI.GetSingletonEntity<RunInfo>();
var info = SystemAPI.GetComponent<RunInfo>(dirEntity);
if (info.Lifecycle != RunLifecycle.RoomReward)
return;
var run = SystemAPI.GetComponent<RunRuntime>(dirEntity);
var offered = SystemAPI.GetComponent<BoonOfferState>(catalogEntity);
if (offered.OfferedRoomEpoch == run.RoomEpoch)
return; // this room's offers are already dealt
var catalog = SystemAPI.GetComponent<BoonCatalog>(catalogEntity);
if (!catalog.Value.IsCreated)
return;
ref var pool = ref catalog.Value.Value;
foreach (var (offer, owner, region, cls, fx) in
SystemAPI.Query<RefRW<BoonOffer>, RefRO<GhostOwner>, RefRO<RegionTag>, RefRO<PlayerClass>, RefRO<BoonEffects>>()
.WithAll<PlayerTag>())
{
if (region.ValueRO.Region != RegionId.Expedition)
continue; // home-bound players (dead-respawned, joiners) are dealt nothing
// Deterministic per-player draw: reconnect-stable per session, replay-reproducible per (seed, room, player, owned-effects-at-draw).
uint offerSeed = RunMapMath.Hash(run.RunSeed, (uint)info.CurrentRoom, (uint)owner.ValueRO.NetworkId) | 1u;
BoonMath.PickBoons(offerSeed, cls.ValueRO.ClassId, fx.ValueRO, ref pool, out byte o0, out byte o1, out byte o2);
offer.ValueRW = new BoonOffer { Pending = 1, Option0 = o0, Option1 = o1, Option2 = o2 };
}
offered.OfferedRoomEpoch = run.RoomEpoch;
SystemAPI.SetComponent(catalogEntity, offered);
}
}
}
@@ -1,2 +0,0 @@
fileFormatVersion: 2
guid: 2d3715c60d2cc2348ac4ff7600006d23
@@ -1,261 +0,0 @@
using ProjectM.Simulation;
using Unity.Burst;
using Unity.Collections;
using Unity.Entities;
using Unity.Mathematics;
using Unity.NetCode;
using Unity.Physics;
using Unity.Transforms;
namespace ProjectM.Server
{
/// <summary>
/// Server-authoritative EXPEDITION BOSS brain — the SOLE mover/attacker of <c>.WithAll&lt;EnemyTag, BossState&gt;()</c>
/// (EnemyAISystem's Charger MOVE pass excludes it via <c>.WithNone&lt;BossState&gt;()</c>, so exactly one system
/// writes the boss's Position/Rotation/AttackWindup — the sole-writer invariant). Runs SERVER-ONLY in the plain
/// <see cref="SimulationSystemGroup"/> <c>[UpdateAfter(EnemyAISystem)]</c> (a linear chain, no sort cycle), once per
/// tick (interpolated ghost, no rollback → no Simulate filter, no IsFirstTimeFullyPredictingTick).
///
/// v2 boss = a real fight (operator-locked): chase the nearest living expedition player, then a telegraphed radial
/// SLAM — the client danger cue rides the replicated <see cref="AttackWindup"/> [GhostField] (CombatFeedbackSystem
/// draws a boss-scale ring). At/below <see cref="Tuning.BossPhase2HealthFraction"/> HP it enters phase two: faster,
/// slams more often, and periodically summons swarmer adds. B4 (Phase 1): the boss ALSO lunges - a telegraphed
/// gap-closer on its own cooldown when the target sits outside slam reach; LungeState.UntilTick spans the
/// windup+travel so EnemyAISystem's IsLunging derive replicates the tell (the client suppresses the slam ring
/// off that bit), and BossState.PendingAttack (server-only byte) tells the shared windup-elapse branch WHICH
/// attack fires. Knockback-immune (the stamp
/// sites skip BossState; this system also clears any residual so nothing else can shove it). Summoned adds go
/// through <see cref="ZoneEnemySpawnUtil"/> so they carry the SAME ZoneEnemyTag/RoomTag/RegionTag stack the
/// room-clear gate + teardown depend on (dropping one would leak adds or clear the room early). All ticks route
/// through <c>TickUtil.NonZero</c> and compare with <see cref="NetworkTick"/> only (never raw uint).
/// </summary>
[BurstCompile]
[WorldSystemFilter(WorldSystemFilterFlags.ServerSimulation)]
[UpdateInGroup(typeof(SimulationSystemGroup))]
[UpdateAfter(typeof(EnemyAISystem))]
public partial struct BossAISystem : ISystem
{
EntityQuery m_Bosses;
EntityQuery m_ZoneEnemies;
[BurstCompile]
public void OnCreate(ref SystemState state)
{
state.RequireForUpdate<NetworkTime>();
m_Bosses = state.GetEntityQuery(ComponentType.ReadOnly<EnemyTag>(), ComponentType.ReadOnly<BossState>(), ComponentType.Exclude<Dying>());
state.RequireForUpdate(m_Bosses);
m_ZoneEnemies = state.GetEntityQuery(ComponentType.ReadOnly<ZoneEnemyTag>(), ComponentType.Exclude<Dying>()); // summon cap counts LIVING only (B3)
}
[BurstCompile]
public void OnUpdate(ref SystemState state)
{
var serverTick = SystemAPI.GetSingleton<NetworkTime>().ServerTick;
if (!serverTick.IsValid)
return;
uint now = serverTick.TickIndexForValidTick;
float dt = SystemAPI.Time.DeltaTime;
// Living EXPEDITION players — the boss's only valid targets. Snapshot once (stable query order).
var playerEntities = new NativeList<Entity>(Allocator.Temp);
var playerPositions = new NativeList<float3>(Allocator.Temp);
foreach (var (xform, health, region, entity) in
SystemAPI.Query<RefRO<LocalTransform>, RefRO<Health>, RefRO<RegionTag>>()
.WithAll<PlayerTag>().WithEntityAccess())
{
if (health.ValueRO.Current <= 0f || region.ValueRO.Region != RegionId.Expedition)
continue;
playerEntities.Add(entity);
playerPositions.Add(xform.ValueRO.Position);
}
// Collide-and-slide setup (mirrors EnemyAISystem).
bool havePhysics = SystemAPI.TryGetSingleton<PhysicsWorldSingleton>(out var physics);
uint envMask = SystemAPI.TryGetSingleton<WorldCollisionConfig>(out var worldCol) ? worldCol.EnvironmentMask : 0u;
uint sweepMask = envMask | worldCol.StructureMask;
var envFilter = new CollisionFilter { BelongsTo = ~0u, CollidesWith = sweepMask, GroupIndex = 0 };
bool sweep = havePhysics && sweepMask != 0u;
const float SweepRadius = 0.8f; // the boss is a big body
int liveZone = m_ZoneEnemies.CalculateEntityCount();
// Summon resources (phase two): the swarmer prefab + baked transform + the current room byte.
bool haveDirector = SystemAPI.TryGetSingletonEntity<ZoneEnemyDirector>(out var directorEntity);
Entity swarmerPrefab = Entity.Null;
LocalTransform swarmerBaked = default;
if (haveDirector)
{
var prefabs = SystemAPI.GetBuffer<ZoneEnemyPrefab>(directorEntity);
if (prefabs.Length > ZoneEnemyMath.KindSwarmer)
{
swarmerPrefab = prefabs[ZoneEnemyMath.KindSwarmer].Prefab;
if (swarmerPrefab != Entity.Null)
swarmerBaked = state.EntityManager.GetComponentData<LocalTransform>(swarmerPrefab);
}
}
byte roomByte = SystemAPI.TryGetSingleton<RunInfo>(out var runInfo) ? (byte)(runInfo.CurrentRoom & 0xFF) : (byte)0;
var ecb = new EntityCommandBuffer(Allocator.Temp);
foreach (var (xform, stats, health, boss, windup, knockback, lunge) in
SystemAPI.Query<RefRW<LocalTransform>, RefRO<EnemyStats>, RefRO<Health>, RefRW<BossState>,
RefRW<AttackWindup>, RefRW<KnockbackState>, RefRW<LungeState>>()
.WithAll<EnemyTag, BossState>().WithNone<Dying>())
{
float3 pos = xform.ValueRO.Position;
// Knockback-immune: never recoil (A4). Zero any residual so a competing stamp can't shove the boss.
if (knockback.ValueRO.UntilTick != 0u) knockback.ValueRW.UntilTick = 0u;
// Phase from the boss's own Current vs (server-side, real ×BossHealthMultiplier) Max.
float maxHp = math.max(1f, health.ValueRO.Max);
byte phase = health.ValueRO.Current <= maxHp * Tuning.BossPhase2HealthFraction ? (byte)2 : (byte)1;
boss.ValueRW.Phase = phase;
// Target: nearest living expedition player.
int tgt = -1; float bestSq = float.MaxValue;
for (int i = 0; i < playerPositions.Length; i++)
{
float d = math.distancesq(pos, playerPositions[i]);
if (d < bestSq) { bestSq = d; tgt = i; }
}
if (tgt < 0)
continue; // no valid target -> idle (InRoom-abort handles a fully-empty expedition)
float3 targetPos = playerPositions[tgt];
// Face the target (planar) at all times, incl. while telegraphing.
float3 toTarget = targetPos - pos; toTarget.y = 0f;
if (math.lengthsq(toTarget) > 1e-6f)
xform.ValueRW.Rotation = quaternion.LookRotationSafe(math.normalize(toTarget), math.up());
// --- SLAM in progress: root (the telegraph) until it lands, then AoE all players in the ring. ---
uint windRaw = windup.ValueRO.WindUpUntilTick;
if (windRaw != 0u)
{
var wt = new NetworkTick(windRaw);
if (!(wt.IsValid && wt.IsNewerThan(serverTick)))
{
// B4: the windup elapse fires whichever attack was PENDING - the shared AttackWindup field
// alone cannot tell them apart (review-confirmed: the naive reuse slams on a lunge elapse).
if (boss.ValueRO.PendingAttack == 1)
{
// Lunge commit: lock direction at travel start (the Charger contract - dodge DURING
// travel with dash i-frames). No unique damage: arriving re-opens the slam threat.
lunge.ValueRW.Dir = math.normalizesafe(toTarget.xz, new float2(0f, 1f));
lunge.ValueRW.Speed = Tuning.BossLungeSpeed;
lunge.ValueRW.UntilTick = TickUtil.NonZero(now + Tuning.BossLungeDurationTicks);
windup.ValueRW.WindUpUntilTick = 0u;
continue;
}
float slamSq = Tuning.BossSlamRadius * Tuning.BossSlamRadius;
for (int i = 0; i < playerEntities.Length; i++)
{
if (math.distancesq(pos, playerPositions[i]) > slamSq)
continue;
ecb.AppendToBuffer(playerEntities[i], new DamageEvent
{
Amount = Tuning.BossSlamDamage,
SourceNetworkId = -1, // environment / boss, not a player
SourceTick = TickUtil.NonZero(now),
});
}
windup.ValueRW.WindUpUntilTick = 0u;
uint baseCd = Tuning.BossSlamCooldownTicks;
uint cd = phase == 2
? (uint)math.max(1f, baseCd * Tuning.BossPhase2SlamCooldownMult)
: baseCd;
boss.ValueRW.SlamReadyTick = TickUtil.NonZero(now + cd);
}
continue; // rooted while winding up (the tell); rotation already written above
}
// --- B4 LUNGE travel in progress: committed movement along the locked direction. Wall-stop or
// timer ends it (the Charger contract); the replicated IsLunging bit rides LungeState.UntilTick. ---
if (lunge.ValueRO.UntilTick != 0u)
{
var blt = new NetworkTick(lunge.ValueRO.UntilTick);
if (blt.IsValid && blt.IsNewerThan(serverTick))
{
float3 intended = pos + new float3(lunge.ValueRO.Dir.x, 0f, lunge.ValueRO.Dir.y) * (lunge.ValueRO.Speed * dt);
intended.y = pos.y;
float3 moved = sweep ? EnemyMoveUtil.SweptMove(in physics, pos, intended, SweepRadius, envFilter) : intended;
xform.ValueRW.Position = moved;
if (math.lengthsq(lunge.ValueRO.Dir) > 1e-6f)
xform.ValueRW.Rotation = quaternion.LookRotationSafe(new float3(lunge.ValueRO.Dir.x, 0f, lunge.ValueRO.Dir.y), math.up());
float intendedDist = math.distance(pos.xz, intended.xz);
float actualDist = math.distance(pos.xz, moved.xz);
if (intendedDist > 1e-4f && actualDist < intendedDist * 0.5f)
{
lunge.ValueRW.UntilTick = 0u; // wall-stop -> end the travel early
boss.ValueRW.PendingAttack = 0;
boss.ValueRW.LungeReadyTick = TickUtil.NonZero(now + Tuning.BossLungeCooldownTicks);
}
continue; // committed this tick
}
lunge.ValueRW.UntilTick = 0u; // travel done
boss.ValueRW.PendingAttack = 0;
boss.ValueRW.LungeReadyTick = TickUtil.NonZero(now + Tuning.BossLungeCooldownTicks);
}
// --- Chase (no active slam). ---
float speed = stats.ValueRO.MoveSpeed * (phase == 2 ? Tuning.BossPhase2SpeedMult : 1f);
float stopDist = stats.ValueRO.AttackRange * 0.9f;
float3 vel = EnemyAIMath.SeekVelocity(pos, targetPos, speed, stopDist);
float3 newPos = pos + vel * dt; newPos.y = pos.y;
if (sweep) newPos = EnemyMoveUtil.SweptMove(in physics, pos, newPos, SweepRadius, envFilter);
xform.ValueRW.Position = newPos;
// Slam gate: ready + a player inside (ring + a small lead) -> commit a telegraphed slam.
bool slamReady = boss.ValueRO.SlamReadyTick == 0u
|| !new NetworkTick(boss.ValueRO.SlamReadyTick).IsNewerThan(serverTick);
float lead = Tuning.BossSlamRadius + 1.5f;
float tgtDistSq = math.distancesq(newPos, targetPos);
if (slamReady && tgtDistSq <= lead * lead)
{
windup.ValueRW.WindUpUntilTick = TickUtil.NonZero(now + Tuning.BossSlamWindupTicks);
boss.ValueRW.PendingAttack = 0;
}
else
{
// B4 lunge gate: target out of slam reach but within lunge range -> telegraphed gap-closer.
// LungeState.UntilTick spans windup+travel so the IsLunging ghost bit (derived by EnemyAISystem
// from LungeState) is ON for the whole move - the client suppresses the slam ring off that bit.
bool lungeReady = boss.ValueRO.LungeReadyTick == 0u
|| !new NetworkTick(boss.ValueRO.LungeReadyTick).IsNewerThan(serverTick);
if (lungeReady
&& tgtDistSq >= Tuning.BossLungeMinRange * Tuning.BossLungeMinRange
&& tgtDistSq <= Tuning.BossLungeMaxRange * Tuning.BossLungeMaxRange)
{
windup.ValueRW.WindUpUntilTick = TickUtil.NonZero(now + Tuning.BossLungeWindupTicks);
boss.ValueRW.PendingAttack = 1;
lunge.ValueRW.UntilTick = TickUtil.NonZero(now + Tuning.BossLungeWindupTicks + Tuning.BossLungeDurationTicks);
}
}
// Summon (phase two only): ready + under the live cap + a swarmer prefab wired.
if (phase == 2 && swarmerPrefab != Entity.Null && liveZone < Tuning.BossSummonLiveCap)
{
bool summonReady = boss.ValueRO.SummonReadyTick == 0u
|| !new NetworkTick(boss.ValueRO.SummonReadyTick).IsNewerThan(serverTick);
if (summonReady)
{
int toSpawn = math.min(Tuning.BossSummonCount, Tuning.BossSummonLiveCap - liveZone);
for (int k = 0; k < toSpawn; k++)
{
float3 spawnPos = EnemyAIMath.ClusterOffset(newPos, k, math.max(1, toSpawn), 2.5f);
spawnPos.y = newPos.y;
ZoneEnemySpawnUtil.Spawn(ecb, swarmerPrefab, in swarmerBaked, spawnPos, RegionId.Expedition, roomByte);
liveZone++;
}
boss.ValueRW.SummonReadyTick = TickUtil.NonZero(now + Tuning.BossSummonCooldownTicks);
}
}
}
ecb.Playback(state.EntityManager);
ecb.Dispose();
playerEntities.Dispose();
playerPositions.Dispose();
}
}
}
@@ -1,2 +0,0 @@
fileFormatVersion: 2
guid: 105d73021b780c449a16ea72bcc29b69
@@ -9,36 +9,33 @@ namespace ProjectM.Server
/// Server receiver for <see cref="ClassSelectRequest"/> — the player picks their frame at base. Honored ONLY in /// Server receiver for <see cref="ClassSelectRequest"/> — the player picks their frame at base. Honored ONLY in
/// Staging (frame = a between-runs choice; mid-run it would desync the fight). Resolves sender → player (the /// Staging (frame = a between-runs choice; mid-run it would desync the fight). Resolves sender → player (the
/// MetaSpend/ReadyToggle idiom), then applies the FULL in-place swap via <see cref="ClassSwapUtil"/> (class seeds + /// MetaSpend/ReadyToggle idiom), then applies the FULL in-place swap via <see cref="ClassSwapUtil"/> (class seeds +
/// permanent-meta re-sync), writes FrameId / PlayerClass, re-seeds the 4-socket Spark loadout, and calls /// permanent-meta re-sync), writes FrameId, re-seeds the 4-socket Spark loadout, and calls
/// <see cref="ClassSwapUtil.HealClamp"/>. Plain server group, before RunDirectorSystem (the receiver convention); /// <see cref="ClassSwapUtil.HealClamp"/>. Plain server group, before RunDirectorSystem (the receiver convention);
/// requests are ALWAYS destroyed. NOT Burst-compiled (a cross-assembly blob+buffer helper on a low-frequency RPC). /// requests are ALWAYS destroyed. NOT Burst-compiled (a cross-assembly blob+buffer helper on a low-frequency RPC).
/// </summary> /// </summary>
/// </summary>
[WorldSystemFilter(WorldSystemFilterFlags.ServerSimulation)] [WorldSystemFilter(WorldSystemFilterFlags.ServerSimulation)]
[UpdateInGroup(typeof(SimulationSystemGroup))] [UpdateInGroup(typeof(SimulationSystemGroup))]
[UpdateBefore(typeof(RunDirectorSystem))]
public partial struct ClassSelectReceiveSystem : ISystem public partial struct ClassSelectReceiveSystem : ISystem
{ {
public void OnCreate(ref SystemState state) public void OnCreate(ref SystemState state)
{ {
var b = new EntityQueryBuilder(Allocator.Temp).WithAll<ClassSelectRequest, ReceiveRpcCommandRequest>(); var b = new EntityQueryBuilder(Allocator.Temp).WithAll<ClassSelectRequest, ReceiveRpcCommandRequest>();
state.RequireForUpdate(state.GetEntityQuery(b)); state.RequireForUpdate(state.GetEntityQuery(b));
state.RequireForUpdate<RunInfo>();
} }
public void OnUpdate(ref SystemState state) public void OnUpdate(ref SystemState state)
{ {
bool accept = SystemAPI.GetSingleton<RunInfo>().Lifecycle == RunLifecycle.Staging; // 2026-08-07 audit purge: this used to accept a frame swap only during RunInfo Lifecycle==Staging
// (the base-phase gate). With the run FSM gone the gym accepts a swap at any time.
const bool accept = true;
var playerByConn = new NativeHashMap<int, Entity>(8, Allocator.Temp); var playerByConn = new NativeHashMap<int, Entity>(8, Allocator.Temp);
foreach (var (owner, e) in foreach (var (owner, e) in
SystemAPI.Query<RefRO<GhostOwner>>().WithAll<PlayerTag, StatModifier>().WithEntityAccess()) SystemAPI.Query<RefRO<GhostOwner>>().WithAll<PlayerTag, StatModifier>().WithEntityAccess())
playerByConn[owner.ValueRO.NetworkId] = e; playerByConn[owner.ValueRO.NetworkId] = e;
// Meta re-sync inputs (on the director/ledger ghost). dir stays Null if the catalog is absent (guarded). // 2026-08-07 audit purge: the permanent-meta re-sync (MetaUpgradeCatalog + MetaTierState) went
Entity dir = Entity.Null; // with the meta shop; a frame swap now re-seeds only the frame stat band.
bool haveMeta = SystemAPI.TryGetSingleton<MetaUpgradeCatalog>(out var metaCat)
&& SystemAPI.TryGetSingletonEntity<ResourceLedger>(out dir) && SystemAPI.HasBuffer<MetaTierState>(dir);
bool haveDb = SystemAPI.TryGetSingleton<AbilityDatabase>(out var abilityDb); bool haveDb = SystemAPI.TryGetSingleton<AbilityDatabase>(out var abilityDb);
var ecb = new EntityCommandBuffer(Allocator.Temp); var ecb = new EntityCommandBuffer(Allocator.Temp);
@@ -54,13 +51,12 @@ namespace ProjectM.Server
if (!SystemAPI.HasBuffer<AbilitySocket>(player)) continue; if (!SystemAPI.HasBuffer<AbilitySocket>(player)) continue;
var mods = SystemAPI.GetBuffer<StatModifier>(player); var mods = SystemAPI.GetBuffer<StatModifier>(player);
var metaRecord = haveMeta ? SystemAPI.GetBuffer<MetaTierState>(dir) : default; ClassSwapUtil.Apply(req.ValueRO.ClassId, mods, out byte newClass);
ClassSwapUtil.Apply(req.ValueRO.ClassId, mods, haveMeta, metaCat, metaRecord, out byte newClass);
if (SystemAPI.HasComponent<FrameId>(player)) if (SystemAPI.HasComponent<FrameId>(player))
SystemAPI.SetComponent(player, new FrameId { Value = newClass }); SystemAPI.SetComponent(player, new FrameId { Value = newClass });
if (SystemAPI.HasComponent<PlayerClass>(player))
SystemAPI.SetComponent(player, new PlayerClass { ClassId = newClass });
// Re-seed the 4-socket Spark loadout for the new frame + clear its cooldowns (fires now). // Re-seed the 4-socket Spark loadout for the new frame + clear its cooldowns (fires now).
ClassTraits.FrameLoadout(newClass, out byte f0, out byte f1, out byte f2, out byte f3); ClassTraits.FrameLoadout(newClass, out byte f0, out byte f1, out byte f2, out byte f3);
var sockets = SystemAPI.GetBuffer<AbilitySocket>(player); var sockets = SystemAPI.GetBuffer<AbilitySocket>(player);
@@ -1,139 +0,0 @@
using ProjectM.Simulation;
using Unity.Burst;
using Unity.Collections;
using Unity.Entities;
using Unity.Mathematics;
using Unity.NetCode;
using Unity.Transforms;
namespace ProjectM.Server
{
/// <summary>
/// Phase 1.7 "Blade Dash" boon (<see cref="BoonFlag.DashTrail"/>): while a player is inside its dash blink window,
/// living enemies within <see cref="k_Radius"/> of the player take damage — one hit per enemy per dash. SERVER-ONLY
/// (enemies are interpolated ghosts the client never predicts — mirrors the melee cleave / cone / projectile-damage
/// pattern), inside the predicted group after <see cref="DashSystem"/> (dash state committed) and before
/// <c>HealthApplyDamageSystem</c> (the DamageEvent drains the same tick). Enemies carry no <c>DashState</c>, so the
/// dash-i-frame negation branch in HealthApplyDamageSystem is skipped — harmless.
///
/// Dedup is keyed to <see cref="DashState.StartTick"/> (which is <c>TickUtil.NonZero(now)</c> on every dash and has
/// NO reliable clear edge on a release server): <see cref="DashTrailState.Hit"/> is cleared whenever the current
/// StartTick differs from <see cref="DashTrailState.LastStartTick"/>. Server-only ⇒ no rollback, so persisting the
/// accumulator across ticks is safe. A per-tick radius test (run every blink tick) approximates the swept path; the
/// per-tick dash step (&lt;~0.6u) is well inside the radius, so a thin enemy is not tunnelled. Hit-set overflow stops
/// adding (a possible re-hit on a very crowded dash — accepted v1 cap).
/// </summary>
[BurstCompile]
[WorldSystemFilter(WorldSystemFilterFlags.ServerSimulation)]
[UpdateInGroup(typeof(PredictedSimulationSystemGroup))]
[UpdateAfter(typeof(DashSystem))]
[UpdateBefore(typeof(HealthApplyDamageSystem))]
public partial struct DashTrailDamageSystem : ISystem
{
const float k_Radius = 1.6f; // planar hit radius around the dashing player (tunable)
const float k_Damage = 12f; // per-enemy damage for a dash pass (tunable)
[BurstCompile]
public void OnCreate(ref SystemState state)
{
state.RequireForUpdate<NetworkTime>();
state.RequireForUpdate<DashTrailState>();
}
[BurstCompile]
public void OnUpdate(ref SystemState state)
{
var nt = SystemAPI.GetSingleton<NetworkTime>();
var serverTick = nt.ServerTick;
if (!serverTick.IsValid)
return;
// Snapshot living enemies once (positions + radii + entities), stable query order.
var enemyEntities = new NativeList<Entity>(Allocator.Temp);
var enemyPositions = new NativeList<float3>(Allocator.Temp);
var enemyRadii = new NativeList<float>(Allocator.Temp);
foreach (var (tx, hr, hp, te) in
SystemAPI.Query<RefRO<LocalTransform>, RefRO<HitRadius>, RefRO<Health>>()
.WithAll<EnemyTag>().WithNone<Dying>().WithEntityAccess())
{
if (hp.ValueRO.Current <= 0f) continue;
enemyEntities.Add(te);
enemyPositions.Add(tx.ValueRO.Position);
enemyRadii.Add(hr.ValueRO.Value);
}
if (enemyEntities.Length == 0)
{
enemyEntities.Dispose(); enemyPositions.Dispose(); enemyRadii.Dispose();
return;
}
uint stamp = TickUtil.NonZero(serverTick.TickIndexForValidTick);
var ecb = new EntityCommandBuffer(Allocator.Temp);
foreach (var (xform, dash, trail, owner, fx) in
SystemAPI.Query<RefRO<LocalTransform>, RefRO<DashState>, RefRW<DashTrailState>,
RefRO<GhostOwner>, RefRO<BoonEffects>>()
.WithAll<PlayerTag, Simulate>())
{
if ((fx.ValueRO.Flags & BoonFlag.DashTrail) == 0)
continue;
uint startRaw = dash.ValueRO.StartTick;
if (startRaw == 0u)
continue; // never dashed
// Inside the blink (i-frame) window [StartTick, IFrameUntilTick)?
var startTick = new NetworkTick(startRaw);
var untilTick = new NetworkTick(dash.ValueRO.IFrameUntilTick);
bool dashing = startTick.IsValid && untilTick.IsValid
&& !startTick.IsNewerThan(serverTick) && untilTick.IsNewerThan(serverTick);
if (!dashing)
continue;
// New dash → reset the per-dash hit set (StartTick changes every dash; no reliable DashState clear).
if (trail.ValueRO.LastStartTick != startRaw)
{
trail.ValueRW.Hit.Clear();
trail.ValueRW.LastStartTick = startRaw;
}
float3 p = xform.ValueRO.Position;
int ownerId = owner.ValueRO.NetworkId;
for (int i = 0; i < enemyEntities.Length; i++)
{
var enemy = enemyEntities[i];
if (HitContains(trail.ValueRO, enemy))
continue;
float2 d = new float2(enemyPositions[i].x - p.x, enemyPositions[i].z - p.z);
float reach = k_Radius + enemyRadii[i];
if (math.lengthsq(d) > reach * reach)
continue;
if (trail.ValueRO.Hit.Length >= trail.ValueRO.Hit.Capacity) break; // hit-cap: never damage an enemy we can't record (else re-hit every tick)
ecb.AppendToBuffer(enemy, new DamageEvent
{
Amount = k_Damage,
SourceNetworkId = ownerId, // a real player id (legit Charger whiff-punish credit)
SourceTick = stamp,
});
if (trail.ValueRO.Hit.Length < trail.ValueRO.Hit.Capacity)
trail.ValueRW.Hit.Add(enemy);
}
}
ecb.Playback(state.EntityManager);
ecb.Dispose();
enemyEntities.Dispose();
enemyPositions.Dispose();
enemyRadii.Dispose();
}
static bool HitContains(in DashTrailState trail, Entity e)
{
for (int i = 0; i < trail.Hit.Length; i++)
if (trail.Hit[i] == e) return true;
return false;
}
}
}
@@ -1,2 +0,0 @@
fileFormatVersion: 2
guid: f2a00802a81103745a1d20475a3c7b7b

Some files were not shown because too many files have changed in this diff Show More