This commit is contained in:
2026-07-04 16:57:40 -07:00
parent 16e396841e
commit c6b7890212
47 changed files with 1713 additions and 209 deletions
@@ -27,6 +27,12 @@ namespace ProjectM.Client
byte _lastPhase;
bool _phaseInit;
AudioClip _stingCoreHit;
int _lastCore = -1;
float _coreStingCooldown;
AudioClip _stingBeep, _stingRoar;
int _lastCountdownSec = -1;
bool _bossRoared;
const float AmbientBaseVolume = 0.10f; // low bed; Defend eases up to ~1.7x
@@ -36,6 +42,9 @@ namespace ProjectM.Client
_stingExpedition = MakeSting(520f, 880f, 0.45f, 0.30f); // airy rising "deploy"
_stingDefend = MakeSting(300f, 140f, 0.55f, 0.42f); // tense falling "wave incoming"
_stingBuild = MakeSting(440f, 660f, 0.40f, 0.26f); // soft confirm
_stingCoreHit = MakeSting(240f, 70f, 0.30f, 0.55f); // harsh falling "Core hit" alarm
_stingBeep = MakeSting(880f, 880f, 0.09f, 0.30f); // countdown tick
_stingRoar = MakeSting(90f, 38f, 0.90f, 0.60f); // boss-arrival roar (low falling growl)
}
protected override void OnStartRunning()
@@ -76,6 +85,50 @@ namespace ProjectM.Client
// Ease the drone intensity toward the phase target (tenser during Defend).
float target = phase == CyclePhase.Siege ? AmbientBaseVolume * 1.7f : AmbientBaseVolume;
_ambient.volume = Mathf.MoveTowards(_ambient.volume, target * GameVolume.Music, SystemAPI.Time.DeltaTime * 0.25f);
// Core-under-attack alarm: a falling sting on each CoreIntegrity drop (rate-limited) so a base
// breach is AUDIBLE even when the fight has the player's eyes elsewhere.
_coreStingCooldown -= SystemAPI.Time.DeltaTime;
if (SystemAPI.TryGetSingleton<CoreIntegrity>(out var core))
{
if (_lastCore >= 0 && core.Current < _lastCore && _coreStingCooldown <= 0f)
{
_ambient.PlayOneShot(_stingCoreHit, 0.8f * GameVolume.Sfx);
_coreStingCooldown = 0.7f;
}
_lastCore = core.Current;
}
// Launch countdown beeps (3-2-1) + the boss-arrival roar — replicated-state observations only.
if (SystemAPI.TryGetSingleton<RunInfo>(out var runAudio))
{
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;
}
}
}
}
void PlaySting(byte phase)
@@ -62,6 +62,8 @@ namespace ProjectM.Client
bool _slashActive;
float _slashRange, _slashHalf; // live cone geometry re-sampled each frame for the per-frame sweep rebuild
int _slashSweepSign = 1; // alternate sweep direction per combo step (reads as alternating strikes)
uint _lastConeFireTick; // own latch — the muzzle block owns _lastLocalFireTick and runs first
bool _coneTickInit;
Material _dangerMat;
readonly Dictionary<Entity, GameObject> _dangerZones = new();
readonly HashSet<Entity> _dangerSeen = new();
@@ -367,6 +369,11 @@ namespace ProjectM.Client
bool finisher = step >= comboLen;
float slashRange = tcfg.MeleeRange > 0f ? tcfg.MeleeRange : 2.6f;
float slashHalf = tcfg.MeleeConeHalfAngleRad > 0f ? tcfg.MeleeConeHalfAngleRad : 0.9f;
// Slice-2 deferred reach fix: the SERVER folds per-player StatModifiers into melee range
// (class seed +0.8, boons, meta tiers) — the arc must sweep the REAL reach, not the base.
if (EntityManager.HasBuffer<StatModifier>(_localPlayer))
slashRange = math.max(0f, StatMath.Apply(slashRange, StatTarget.MeleeRange,
EntityManager.GetBuffer<StatModifier>(_localPlayer, true)));
if (finisher) slashRange *= tcfg.MeleeFinisherMult > 0f ? tcfg.MeleeFinisherMult : 1.8f;
// MC-4 connect-vs-whiff: client-side cone overlap over the cached enemy snapshot gives an IMMEDIATE
// "you bit" read (the authoritative server damage spark/number arrives a few ticks later).
@@ -397,6 +404,40 @@ namespace ProjectM.Client
_swingTickInit = true;
}
// Slice-2 deferred: the Warrior CONE ability had no client VFX (server-only cleave). Edge-detect the
// PREDICTED AbilityCooldown (raw uint, like the dash) and reuse the slash-arc sweep with the ability's
// own effective geometry — an aimed cone IS an arc.
if (_localPlayer != Entity.Null
&& EntityManager.HasComponent<AbilityCooldown>(_localPlayer)
&& EntityManager.HasComponent<AbilityRef>(_localPlayer)
&& EntityManager.HasComponent<EffectiveAbilityStats>(_localPlayer))
{
uint nextFire = EntityManager.GetComponentData<AbilityCooldown>(_localPlayer).NextFireTick;
if (_coneTickInit && nextFire != 0 && nextFire != _lastConeFireTick
&& SystemAPI.TryGetSingleton<AbilityDatabase>(out var coneDb) && coneDb.Value.IsCreated)
{
ref var coneAdb = ref coneDb.Value.Value;
if (coneAdb.TryGetAbility(EntityManager.GetComponentData<AbilityRef>(_localPlayer).Id, out var coneDef)
&& coneDef.Archetype == (byte)AbilityArchetype.Cone)
{
var ceff = EntityManager.GetComponentData<EffectiveAbilityStats>(_localPlayer);
Vector3 cface = Vector3.forward;
if (EntityManager.HasComponent<PlayerFacing>(_localPlayer))
{
var cfd = EntityManager.GetComponentData<PlayerFacing>(_localPlayer).Direction;
if (math.lengthsq(cfd) > 1e-6f) cface = new Vector3(cfd.x, 0f, cfd.y).normalized;
}
float coneRange = Mathf.Max(0.1f, ceff.Range);
float coneHalf = Mathf.Clamp(ceff.AutoTargetConeRadians, 0.01f, 3.14159f);
TriggerSlash((Vector3)localPos, new float2(cface.x, cface.z), coneRange, coneHalf, 1, 1, false);
PlayClip(_swingClip, (Vector3)localPos, 0.5f);
PrototypeCameraRig.AddShake(0.06f);
}
}
_lastConeFireTick = nextFire;
_coneTickInit = true;
}
// Footsteps (combat feel): edge-detect local locomotion from the position delta; a soft step at a cadence.
if (_localPlayer != Entity.Null)
{
@@ -820,7 +861,11 @@ namespace ProjectM.Client
{
int step = math.max(1, (int)mc.ValueRO.Step);
bool finisher = step >= comboLen;
rs.Range = finisher ? baseRange * finisherMult : baseRange;
float rRange = baseRange; // same reach fix for teammates' arcs (their buffer is OwnerSendType.All)
if (EntityManager.HasBuffer<StatModifier>(entity))
rRange = math.max(0f, StatMath.Apply(baseRange, StatTarget.MeleeRange,
EntityManager.GetBuffer<StatModifier>(entity, true)));
rs.Range = finisher ? rRange * finisherMult : rRange;
rs.Half = baseHalf;
rs.SweepSign = (step % 2 == 0) ? -1 : 1;
rs.Tint = FeelConfig.RemoteSlashColor * (finisher ? 1.5f : 1f);
@@ -0,0 +1,91 @@
using System.Collections.Generic;
using ProjectM.Simulation;
using Unity.Entities;
using UnityEngine;
using UnityEngine.SceneManagement;
namespace ProjectM.Client
{
/// <summary>
/// The Engine Core's CRYSTAL answers its replicated <see cref="CoreIntegrity"/> (07-01 backlog: the mesh sat
/// static while draining). Client-only observe-only presentation: the cosmetic <c>CoreCrystals</c> /
/// <c>CoreMachine</c> GameObjects in Game.unity (classic URP renderers, not entities) are tinted via
/// MaterialPropertyBlock — darker + blood-shifted as integrity falls, a white-hot flash on each hit (pairs
/// with the AmbientAudioSystem alarm). Property-guarded per the shader-value rule (only materials exposing
/// _BaseColor are touched); per-renderer MPBs never mutate the shared material assets (no bleed).
/// </summary>
[WorldSystemFilter(WorldSystemFilterFlags.ClientSimulation)]
[UpdateInGroup(typeof(PresentationSystemGroup))]
public partial class CoreVisualFeedbackSystem : SystemBase
{
static readonly int BaseColorId = Shader.PropertyToID("_BaseColor");
static readonly string[] TargetNames = { "CoreCrystals", "CoreMachine" };
const float FlashSeconds = 0.35f;
readonly List<Renderer> _renderers = new();
readonly List<Color> _authoredColors = new();
MaterialPropertyBlock _mpb;
bool _resolved;
int _lastCore = -1;
float _flashLeft;
protected override void OnUpdate()
{
if (!SystemAPI.TryGetSingleton<CoreIntegrity>(out var core) || core.Max <= 0)
return;
// One-time renderer resolve, deferred until Game.unity is actually the active scene (the frontend
// path creates this world a frame BEFORE the scene loads — latching early would find nothing).
if (!_resolved)
{
var scene = SceneManager.GetActiveScene();
if (!scene.isLoaded || scene.name != "Game") return;
Resolve();
}
if (_renderers.Count == 0) return;
if (_lastCore >= 0 && core.Current < _lastCore)
_flashLeft = FlashSeconds; // hit edge -> white-hot pop (the audio alarm fires beside it)
_lastCore = core.Current;
_flashLeft -= SystemAPI.Time.DeltaTime;
float frac = Mathf.Clamp01(core.Current / (float)core.Max);
float dim = Mathf.Lerp(0.35f, 1f, frac);
float flash = _flashLeft > 0f ? Mathf.Clamp01(_flashLeft / FlashSeconds) : 0f;
for (int i = 0; i < _renderers.Count; i++)
{
var r = _renderers[i];
if (r == null) continue;
var c0 = _authoredColors[i];
// Wounded shift: keep red-ish energy, drain green/blue with integrity (reads as bleeding light).
var wounded = new Color(
Mathf.Min(1f, c0.r * dim + (1f - frac) * 0.25f),
c0.g * dim * (0.45f + 0.55f * frac),
c0.b * dim * (0.45f + 0.55f * frac),
c0.a);
var col = flash > 0f ? Color.Lerp(wounded, Color.white, flash) : wounded;
r.GetPropertyBlock(_mpb);
_mpb.SetColor(BaseColorId, col);
r.SetPropertyBlock(_mpb);
}
}
void Resolve()
{
_resolved = true;
_mpb = new MaterialPropertyBlock();
foreach (var name in TargetNames)
{
var go = GameObject.Find(name);
if (go == null) continue;
foreach (var r in go.GetComponentsInChildren<Renderer>())
{
var m = r.sharedMaterial;
if (m == null || !m.HasProperty(BaseColorId)) continue;
_renderers.Add(r);
_authoredColors.Add(m.GetColor(BaseColorId));
}
}
}
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 47b2a19145125ac4e98411cab7f9b569
@@ -72,9 +72,9 @@ namespace ProjectM.Client
// observe-only readers of replicated state (BoonOffer via GhostOwnerIsLocal; RunInfo RouteOpt*); clicks
// enqueue through the client send-systems' statics. Built lazily on first show.
VisualElement _boonModal, _boonCardRow;
VisualElement _routePanel, _routeBtnRow;
VisualElement _routePanel;
Label _routeTitle;
byte _boonShownFor; // last (Option0^Option1^Option2 ^ room) signature the modal was built for
int _boonShownFor; // last exact (Option0|Option1<<8|Option2<<16)+1 signature the modal was built for
bool _boonModalBuilt, _routePanelBuilt;
// Step 14 (meta shop): Staging-only permanent-upgrade shop (replicated MetaTierState + ledger Aether;
// row clicks enqueue MetaSpendSendSystem.RequestPurchase — the server re-validates everything).
@@ -82,6 +82,28 @@ namespace ProjectM.Client
Label _metaShopTitle;
bool _metaShopBuilt;
int _metaShownFor; // last (class, tiers, aether) signature the shop rows were built for
// Demo polish: the clickable READY panel (Staging/Launching) + the drawn branching route map
// (RouteSelect) — the map is regenerated client-side from RunInfo.RunSeed for DISPLAY only; the
// clickable next-layer nodes bind to the authoritative RouteOpt* bytes (never the regen).
VisualElement _readyPanel, _readyPipRow;
Button _readyBtn;
Label _readyTitle;
bool _readyPanelBuilt;
int _readyShownFor; // (ready, total, localReady, secs, launching) rebuild signature
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;
// Demo polish round 2: boss presence bar, run-depth dots, outcome flash.
VisualElement _bossPanel, _bossFill;
Label _bossText;
bool _bossBarBuilt;
VisualElement _depthPanel;
int _depthShownFor;
bool _depthBuilt;
VisualElement _outcomeFlash; // one-shot gold/red full-screen flash when the outcome banner first lands
float _outcomeFlashLeft;
byte _outcomeFlashedFor;
readonly List<VisualElement> _pips = new();
@@ -97,6 +119,8 @@ namespace ProjectM.Client
// overlays
VisualElement _vignette, _downed;
Label _downedText; // "RESPAWNING IN N" countdown (client-local: baked DelayTicks + death-edge latch)
float _downedSince = -1f;
float _prevHp, _flash;
bool _haveHp;
// personal inventory panel (read-only; toggled with I)
@@ -194,25 +218,21 @@ namespace ProjectM.Client
switch (runInfo.Lifecycle)
{
case RunLifecycle.Staging:
{
// Client counts the party's replicated ready flags (send-to-all) for the N/M readout.
int total = 0, readyCount = 0;
foreach (var pr in SystemAPI.Query<RefRO<PlayerReady>>().WithAll<PlayerTag>())
{
total++;
if (pr.ValueRO.Value != 0) readyCount++;
}
_locationText.text = "READY UP [T] - " + readyCount + "/" + Mathf.Max(total, 1)
+ " ready - launch a run to advance the Engine";
// 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:
{
uint nowTick = SystemAPI.TryGetSingleton<NetworkTime>(out var ntime) && ntime.ServerTick.IsValid
? ntime.ServerTick.TickIndexForValidTick : 0u;
int secs = runInfo.LaunchTick != 0 && nowTick != 0
? Mathf.Max(0, (int)((runInfo.LaunchTick - nowTick) / 60u) + 1) : 0;
// 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;
@@ -272,9 +292,72 @@ namespace ProjectM.Client
BlobAssetReference<BoonCatalogBlob> boonPool = default;
if (SystemAPI.TryGetSingleton<BoonCatalog>(out var bcat))
boonPool = bcat.Value;
UpdateBoonModal(localOffer, hasOffer && localOffer.Pending == 1, boonPool);
// 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);
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 clickable READY panel (Staging/Launching, hidden once the outcome latched — the banner owns
// the screen then). Counts are the replicated send-to-all PlayerReady flags.
int rTotal = 0, rReady = 0;
bool localReady = false;
int launchSecs = 0;
bool terminal = SystemAPI.TryGetSingleton<RunOutcome>(out var readyOc)
&& readyOc.Value != RunOutcomeId.InProgress;
bool readyShow = haveRun && !terminal
&& (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 — client heuristic: a Boss room spawns exactly ONE enemy (the boss), so any live
// EnemyTag Health in a Boss-type room is it. Replicated state only; no netcode surface.
bool bossAlive = false;
float bossHp = 0f, bossMax = 0f;
if (haveRun && runInfo.Lifecycle == RunLifecycle.InRoom && runInfo.CurrentRoomType == RoomTypeId.Boss)
{
foreach (var bhq in SystemAPI.Query<RefRO<Health>>().WithAll<EnemyTag>())
{
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);
// ---- Goal (hex-pip meter, or a continuous bar for large targets) ----
if (SystemAPI.TryGetSingleton<GoalProgress>(out var goal))
{
@@ -388,6 +471,14 @@ namespace ProjectM.Client
if (SystemAPI.TryGetSingleton<RunOutcome>(out var runOutcome) && runOutcome.Value != RunOutcomeId.InProgress)
{
bool win = runOutcome.Value == RunOutcomeId.Victory;
if (_outcomeFlashedFor != runOutcome.Value)
{
// One-shot landing flourish: full-screen color flash + camera kick — the beat gets a payoff.
_outcomeFlashedFor = runOutcome.Value;
_outcomeFlashLeft = win ? 0.9f : 0.7f;
PrototypeCameraRig.PunchFov(win ? 5f : 3f, win ? 420f : 260f);
PrototypeCameraRig.AddShake(win ? 0.25f : 0.5f);
}
_runBanner.style.display = DisplayStyle.Flex;
_runBannerText.text = win ? "THE ENGINE HOLDS" : "OVERRUN";
_runBannerText.style.color = win ? new Color(0.45f, 0.95f, 1f) : new Color(1f, 0.35f, 0.3f);
@@ -397,6 +488,32 @@ namespace ProjectM.Client
else
{
_runBanner.style.display = DisplayStyle.None;
_outcomeFlashedFor = 0;
}
// Outcome flash decay (lazy element; the banner dim sits above it, the world below).
if (_outcomeFlashLeft > 0f)
{
if (_outcomeFlash == null && _doc != null && _doc.rootVisualElement != null)
{
_outcomeFlash = new VisualElement { pickingMode = PickingMode.Ignore };
_outcomeFlash.style.position = Position.Absolute;
_outcomeFlash.style.left = 0; _outcomeFlash.style.right = 0;
_outcomeFlash.style.top = 0; _outcomeFlash.style.bottom = 0;
_doc.rootVisualElement.Add(_outcomeFlash);
}
_outcomeFlashLeft -= dt;
if (_outcomeFlash != null)
{
bool winFlash = _outcomeFlashedFor == RunOutcomeId.Victory;
var fc = winFlash ? new Color(1f, 0.85f, 0.35f) : new Color(1f, 0.20f, 0.15f);
_outcomeFlash.style.backgroundColor = new Color(fc.r, fc.g, fc.b, Mathf.Clamp01(_outcomeFlashLeft) * 0.35f);
_outcomeFlash.style.display = DisplayStyle.Flex;
}
}
else if (_outcomeFlash != null)
{
_outcomeFlash.style.display = DisplayStyle.None;
}
@@ -487,6 +604,18 @@ namespace ProjectM.Client
HudUi.SetFill(_cooldownFill, cdFrac);
// A READY weapon (full bar) recedes; a CHARGING one is bright — so the inverted-vs-health polarity reads.
if (_cdRow != null) _cdRow.style.opacity = cdFrac >= 1f ? 0.4f : 1f;
if (dead)
{
// Client-local countdown: latch the death edge; the baked (non-replicated) DelayTicks is the
// honest duration — RespawnTick itself is server-only.
if (_downedSince < 0f) _downedSince = (float)SystemAPI.Time.ElapsedTime;
int delayTicks = 180;
foreach (var rs in SystemAPI.Query<RefRO<RespawnState>>().WithAll<PlayerTag, GhostOwnerIsLocal>())
{ delayTicks = Mathf.Max(1, rs.ValueRO.DelayTicks); break; }
float left = delayTicks / 60f - ((float)SystemAPI.Time.ElapsedTime - _downedSince);
_downedText.text = left > 0.05f ? "RESPAWNING IN " + Mathf.CeilToInt(left) : "RESPAWNING...";
}
else _downedSince = -1f;
_downed.style.display = dead ? DisplayStyle.Flex : DisplayStyle.None;
}
else
@@ -1022,7 +1151,12 @@ namespace ProjectM.Client
{
_downed.style.backgroundColor = new Color(0.35f, 0f, 0f, 0.35f);
}
_downed.Add(HudUi.Display("DOWNED - RESPAWNING", 52, new Color(1f, 0.45f, 0.4f), TextAnchor.MiddleCenter));
var downedCol = HudUi.Group(Align.Center);
downedCol.Add(HudUi.Display("DOWNED", 52, new Color(1f, 0.45f, 0.4f), TextAnchor.MiddleCenter));
_downedText = HudUi.Text("RESPAWNING...", 24, new Color(1f, 0.75f, 0.7f), TextAnchor.MiddleCenter);
_downedText.style.marginTop = 6;
downedCol.Add(_downedText);
_downed.Add(downedCol);
_downed.style.display = DisplayStyle.None;
root.Add(_downed);
}
@@ -1042,17 +1176,32 @@ namespace ProjectM.Client
_runBannerSub.style.marginTop = 8;
col.Add(_runBannerSub);
// END-2 (SL-5): the terminal banner offers a clear action so the player isn't hunting for Esc.
// PLAY AGAIN restarts a fresh Single run via the proven menu lifecycle; QUIT TO MENU reuses the teardown.
// Both self-guard on WorldLauncher.Busy. The row picks (Position) even though the banner root Ignores.
// SINGLE: PLAY AGAIN Continues as a fresh campaign (base+meta kept — the terminal save rolls
// forward on stage). CO-OP (operator-locked): the honest exit is a clean teardown for everyone —
// the host ends the session (each joiner's ConnectionWatchdog returns them to the menu with a
// reason), a joiner just leaves. All self-guard on WorldLauncher.Busy. The row picks (Position)
// even though the banner root Ignores.
var btnRow = new VisualElement();
btnRow.style.flexDirection = FlexDirection.Row;
btnRow.style.justifyContent = Justify.Center;
btnRow.style.marginTop = 28;
btnRow.pickingMode = PickingMode.Position;
var again = MenuUi.Button("PLAY AGAIN", () => WorldLauncher.StartSession(SessionMode.Single, null, false));
again.style.marginRight = 12;
btnRow.Add(again);
btnRow.Add(MenuUi.Button("QUIT TO MENU", WorldLauncher.TeardownToMenu));
switch (WorldLauncher.LastMode)
{
case SessionMode.Host:
btnRow.Add(MenuUi.Button("END SESSION — ALL TO MENU", WorldLauncher.TeardownToMenu));
break;
case SessionMode.Join:
btnRow.Add(MenuUi.Button("LEAVE TO MENU", WorldLauncher.TeardownToMenu));
break;
default:
var again = MenuUi.Button("PLAY AGAIN",
() => WorldLauncher.StartSession(SessionMode.Single, null, SaveService.HasSave()));
again.style.marginRight = 12;
btnRow.Add(again);
btnRow.Add(MenuUi.Button("QUIT TO MENU", WorldLauncher.TeardownToMenu));
break;
}
col.Add(btnRow);
_runBanner.Add(col);
_runBanner.style.display = DisplayStyle.None;
@@ -1230,7 +1379,6 @@ namespace ProjectM.Client
// ==== Step 14: boon modal + route panel (lazy-built overlays; clicks -> client send statics) ====
byte _routeShownFor; // last (room ^ options) signature the route buttons were built for
static string RoomTypeLabel(byte roomType) => roomType == RoomTypeId.Boss ? "[BOSS]"
: roomType == RoomTypeId.Elite ? "[ELITE]"
@@ -1253,8 +1401,9 @@ namespace ProjectM.Client
}
// Rebuild the three cards only when the offer actually changes (a new room's deal).
byte sig = (byte)(offer.Option0 ^ (offer.Option1 * 3) ^ (offer.Option2 * 7));
if (sig == 0) sig = 1;
// 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();
@@ -1265,10 +1414,18 @@ namespace ProjectM.Client
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 = 96;
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;
@@ -1312,6 +1469,25 @@ namespace ProjectM.Client
root.Add(_boonModal);
}
// ---- 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).
@@ -1319,7 +1495,7 @@ namespace ProjectM.Client
if (!show)
{
if (_routePanel != null) _routePanel.style.display = DisplayStyle.None;
_routeShownFor = 0;
_routeMapSig = 0;
return;
}
var root = _doc != null ? _doc.rootVisualElement : null;
@@ -1330,23 +1506,15 @@ namespace ProjectM.Client
_routePanelBuilt = true;
}
byte sig = (byte)((runInfo.CurrentRoom + 1) ^ (runInfo.RouteOpt0Type * 3)
^ (runInfo.RouteOpt1Type * 5) ^ (runInfo.RouteOpt2Type * 7) ^ runInfo.RouteOptionCount);
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 (_routeShownFor != sig)
if (_routeMapSig != sig)
{
_routeBtnRow.Clear();
for (byte k = 0; k < runInfo.RouteOptionCount && k < 3; k++)
{
byte type = k == 2 ? runInfo.RouteOpt2Type : k == 1 ? runInfo.RouteOpt1Type : runInfo.RouteOpt0Type;
byte pick = k; // closure copy
var b = MenuUi.Button("→ " + RoomTypeLabel(type), () => RouteSendSystem.PickRoute(pick));
b.style.marginLeft = 6;
b.style.marginRight = 6;
_routeBtnRow.Add(b);
}
RebuildRouteMap(runInfo);
_routeTitle.text = "CHOOSE YOUR PATH — room " + (runInfo.CurrentRoom + 2) + "/" + runInfo.RoomCount;
_routeShownFor = sig;
_routeMapSig = sig;
}
_routePanel.style.display = DisplayStyle.Flex;
}
@@ -1356,33 +1524,299 @@ namespace ProjectM.Client
_routePanel = new VisualElement { pickingMode = PickingMode.Ignore };
_routePanel.style.position = Position.Absolute;
_routePanel.style.left = 0; _routePanel.style.right = 0;
_routePanel.style.bottom = 120;
_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();
box.style.backgroundColor = new Color(0.07f, 0.09f, 0.12f, 0.94f);
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 = 16; box.style.paddingRight = 16;
box.style.paddingTop = 10; box.style.paddingBottom = 12;
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 = 15;
_routeTitle.style.fontSize = 16;
_routeTitle.style.unityFontStyleAndWeight = FontStyle.Bold;
_routeTitle.style.marginBottom = 8;
_routeTitle.style.marginBottom = 10;
box.Add(_routeTitle);
_routeBtnRow = new VisualElement();
_routeBtnRow.style.flexDirection = FlexDirection.Row;
box.Add(_routeBtnRow);
_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;
}
// ---- 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;
}
void UpdateMetaShop(bool show, byte classId, int aether,
BlobAssetReference<MetaUpgradeCatalogBlob> pool, DynamicBuffer<MetaTierState> record)
{
@@ -1417,18 +1851,35 @@ namespace ProjectM.Client
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() + " [" + owned + "/" + defs.Defs[d].MaxTier + "]"
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 ? AetherCyan : new Color(1f, 1f, 1f, 0.14f);
pipRow.Add(tp);
}
row.Add(pipRow);
_metaRowsHost.Add(row);
}
_metaShownFor = sig;
@@ -0,0 +1,285 @@
using ProjectM.Simulation;
using Unity.Entities;
using Unity.NetCode;
using UnityEngine;
namespace ProjectM.Client
{
/// <summary>
/// Client-only PROCEDURAL MUSIC — four synchronized loop layers (bass / chord pad / arpeggio / percussion
/// pulse), all generated over the SAME 4-bar buffer (100 BPM, AmFCG) so they stay phase-locked forever:
/// every AudioSource starts the same frame with loop=true and identical clip length, and per-bar amplitude
/// envelopes reach ~zero at each bar boundary so both the chord changes and the loop point are click-free
/// (the <c>AmbientAudioSystem.Snap</c> trick generalized to enveloped segments). The MIX is the state
/// machine: layer volumes ease toward targets chosen from replicated state only — <see cref="RunInfo"/>
/// lifecycle (staging / combat / boss / reward lull), <see cref="CycleState"/> siege, and the terminal
/// <see cref="RunOutcome"/> (one-shot victory/defeat sting + aftermath bed). Observe-only presentation
/// system: no sim writes, no determinism surface; asset-free per the project convention. Sits under SFX at
/// <see cref="MasterVolume"/> × <see cref="GameVolume.Music"/>; the low <c>AmbientAudioSystem</c> drone
/// (vol 0.10) remains as texture beneath it.
/// </summary>
[WorldSystemFilter(WorldSystemFilterFlags.ClientSimulation)]
[UpdateInGroup(typeof(PresentationSystemGroup))]
public partial class MusicSystem : SystemBase
{
const float MasterVolume = 0.45f;
const float FadePerSecond = 0.30f;
// ---- shared musical grid: 100 BPM, 4/4, one chord per bar, 4 bars ----
const int SampleRate = 44100;
const float BarSeconds = 2.4f; // 4 beats @ 0.6 s
const int Bars = 4;
const float LoopSeconds = BarSeconds * Bars; // 9.6 s
// Am F C G (natural-minor loop; index = bar): root/third/fifth per chord.
static readonly float[][] ChordHz =
{
new[] { 110.00f, 130.81f, 164.81f }, // Am: A2 C3 E3
new[] { 87.31f, 110.00f, 130.81f }, // F: F2 A2 C3
new[] { 130.81f, 164.81f, 196.00f }, // C: C3 E3 G3
new[] { 98.00f, 123.47f, 146.83f }, // G: G2 B2 D3
};
GameObject _root;
AudioSource _bass, _pad, _arp, _pulse;
float _vBass, _vPad, _vArp, _vPulse; // current smoothed volumes (pre-master)
byte _outcomePlayed; // which terminal outcome's sting has fired (0 = none)
protected override void OnStartRunning()
{
if (_root != null) return;
_root = new GameObject("~Music");
_bass = MakeSource(BuildBassLoop());
_pad = MakeSource(BuildPadLoop());
_arp = MakeSource(BuildArpLoop());
_pulse = MakeSource(BuildPulseLoop());
// Same frame, same length, loop=true -> phase-locked for the whole session.
_bass.Play(); _pad.Play(); _arp.Play(); _pulse.Play();
}
protected override void OnDestroy()
{
if (_root != null) Object.Destroy(_root);
}
AudioSource MakeSource(AudioClip clip)
{
var src = _root.AddComponent<AudioSource>();
src.clip = clip;
src.loop = true;
src.playOnAwake = false;
src.spatialBlend = 0f;
src.volume = 0f;
return src;
}
protected override void OnUpdate()
{
if (_bass == null) return;
// ---- pick the mix from replicated state (defaults = quiet staging bed) ----
float tBass = 0.50f, tPad = 0.55f, tArp = 0.12f, tPulse = 0f;
bool haveRun = SystemAPI.TryGetSingleton<RunInfo>(out var run);
SystemAPI.TryGetSingleton<CycleState>(out var cyc);
bool siege = cyc.Phase == CyclePhase.Siege;
bool finalSiege = siege && SystemAPI.TryGetSingleton<GoalProgress>(out var goal)
&& goal.Target > 0 && goal.Charge >= goal.Target;
byte outcome = SystemAPI.TryGetSingleton<RunOutcome>(out var oc) ? oc.Value : RunOutcomeId.InProgress;
if (outcome != RunOutcomeId.InProgress)
{
if (_outcomePlayed != outcome)
{
_outcomePlayed = outcome;
PlayOutcomeSting(outcome == RunOutcomeId.Victory);
}
tBass = 0.20f; tPad = 0.45f; tArp = 0f; tPulse = 0f; // aftermath bed under the banner
}
else if (siege)
{
tBass = 0.70f; tPad = 0.40f; tArp = 0.60f; tPulse = finalSiege ? 0.90f : 0.65f;
}
else if (haveRun)
{
switch (run.Lifecycle)
{
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;
_vBass = Mathf.MoveTowards(_vBass, tBass, dt);
_vPad = Mathf.MoveTowards(_vPad, tPad, dt);
_vArp = Mathf.MoveTowards(_vArp, tArp, dt);
_vPulse = Mathf.MoveTowards(_vPulse, tPulse, dt);
float master = MasterVolume * GameVolume.Music;
_bass.volume = _vBass * master;
_pad.volume = _vPad * master;
_arp.volume = _vArp * master;
_pulse.volume = _vPulse * master;
}
void PlayOutcomeSting(bool victory)
{
// Victory: rising A-minor->major-feel arpeggio; defeat: falling minor third crawl.
float[] notes = victory
? new[] { 220f, 277.18f, 329.63f, 440f } // A3 C#4 E4 A4 (picardy lift)
: new[] { 220f, 207.65f, 174.61f, 146.83f }; // A3 Ab3 F3 D3
var clip = BuildStingClip(notes, victory ? 0.16f : 0.28f, victory ? 0.45f : 0.40f);
if (_pad != null) _pad.PlayOneShot(clip, 0.9f * GameVolume.Music);
}
// ================= clip builders (deterministic, asset-free) =================
static AudioClip NewLoopClip(string name, out float[] data)
{
int len = (int)(LoopSeconds * SampleRate);
data = new float[len];
return AudioClip.Create(name, len, 1, SampleRate, false);
}
/// <summary>Bar-local envelope that is ~0 at both bar edges (click-free chord changes + loop point).</summary>
static float BarEnv(float tInBar, float attack, float release)
{
float up = Mathf.Clamp01(tInBar / attack);
float down = Mathf.Clamp01((BarSeconds - tInBar) / release);
return up * up * down;
}
static AudioClip BuildBassLoop()
{
var clip = NewLoopClip("music_bass", out var data);
for (int i = 0; i < data.Length; i++)
{
float t = i / (float)SampleRate;
int bar = Mathf.Min(Bars - 1, (int)(t / BarSeconds));
float tb = t - bar * BarSeconds;
float f = ChordHz[bar][0] * 0.5f; // root, an octave down
float s = Mathf.Sin(2f * Mathf.PI * f * t) * 0.8f
+ Mathf.Sin(2f * Mathf.PI * f * 2f * t) * 0.2f;
data[i] = s * BarEnv(tb, 0.03f, 0.12f) * 0.50f;
}
clip.SetData(data, 0);
return clip;
}
static AudioClip BuildPadLoop()
{
var clip = NewLoopClip("music_pad", out var data);
for (int i = 0; i < data.Length; i++)
{
float t = i / (float)SampleRate;
int bar = Mathf.Min(Bars - 1, (int)(t / BarSeconds));
float tb = t - bar * BarSeconds;
var chord = ChordHz[bar];
float s = 0f;
for (int n = 0; n < chord.Length; n++)
s += Mathf.Sin(2f * Mathf.PI * chord[n] * t + n * 1.7f);
float trem = 0.85f + 0.15f * Mathf.Sin(2f * Mathf.PI * 0.8f * t);
data[i] = s / 3f * BarEnv(tb, 0.45f, 0.50f) * trem * 0.34f;
}
clip.SetData(data, 0);
return clip;
}
static AudioClip BuildArpLoop()
{
var clip = NewLoopClip("music_arp", out var data);
const float noteLen = BarSeconds / 8f; // 8th notes
for (int i = 0; i < data.Length; i++)
{
float t = i / (float)SampleRate;
int bar = Mathf.Min(Bars - 1, (int)(t / BarSeconds));
float tb = t - bar * BarSeconds;
int step = Mathf.Min(7, (int)(tb / noteLen));
float tn = tb - step * noteLen;
var chord = ChordHz[bar];
// up-and-over pattern: root, fifth, octave, tenth, back down
float f = step switch
{
0 => chord[0] * 2f,
1 => chord[2] * 2f,
2 => chord[0] * 4f,
3 => chord[1] * 4f,
4 => chord[2] * 4f,
5 => chord[0] * 4f,
6 => chord[2] * 2f,
_ => chord[1] * 2f,
};
float env = Mathf.Exp(-9f * tn);
data[i] = Mathf.Sin(2f * Mathf.PI * f * tn) * env * 0.32f;
}
clip.SetData(data, 0);
return clip;
}
static AudioClip BuildPulseLoop()
{
var clip = NewLoopClip("music_pulse", out var data);
const float beat = BarSeconds / 4f;
uint rng = 0x5EED5EEDu; // deterministic hat noise
for (int i = 0; i < data.Length; i++)
{
float t = i / (float)SampleRate;
float tBeat = t % beat;
// Low thump on every beat: 55->38 Hz sweep, tight decay.
float thump = 0f;
if (tBeat < 0.14f)
{
float f = Mathf.Lerp(55f, 38f, tBeat / 0.14f);
thump = Mathf.Sin(2f * Mathf.PI * f * tBeat) * Mathf.Exp(-22f * tBeat) * 0.85f;
}
// Off-beat hat: a 30 ms noise tick halfway through each beat.
float tHat = (t + beat * 0.5f) % beat;
float hat = 0f;
if (tHat < 0.03f)
{
rng ^= rng << 13; rng ^= rng >> 17; rng ^= rng << 5; // xorshift
float n = (rng & 0xFFFF) / 32768f - 1f;
hat = n * Mathf.Exp(-120f * tHat) * 0.22f;
}
data[i] = thump + hat;
}
clip.SetData(data, 0);
return clip;
}
static AudioClip BuildStingClip(float[] notes, float noteLen, float vol)
{
int len = (int)(notes.Length * noteLen * SampleRate) + SampleRate / 2; // + half-second tail
var clip = AudioClip.Create("music_sting", len, 1, SampleRate, false);
var data = new float[len];
for (int n = 0; n < notes.Length; n++)
{
int start = (int)(n * noteLen * SampleRate);
int dur = (int)(SampleRate * (noteLen + (n == notes.Length - 1 ? 0.5f : 0.05f)));
for (int i = 0; i < dur && start + i < len; i++)
{
float tn = i / (float)SampleRate;
data[start + i] += Mathf.Sin(2f * Mathf.PI * notes[n] * tn) * Mathf.Exp(-4.5f * tn) * vol;
}
}
clip.SetData(data, 0);
return clip;
}
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 0674dbc6d7e676f4cb88b18a67cdca99