LANTERN purge B3+B5: delete the cycle/core/win-lose spine + onboarding; save epoch v7

Deletes CyclePhaseSystem, GoalReachedSystem, CoreDamage/CoreRestore, ThreatDirector,
CoreIntegrity/GoalProgress/RunPhase/RunOutcome/ThreatState components,
CoreVisualFeedbackSystem, and the whole Client/Onboarding slice (+6 test files).

Keepers reworked: RunDirectorSystem (UpdateBefore attr + launch guard + goal/threat
bank removed; sole SaveRequest raiser now), CycleDirectorSpawnSystem (ledger/meta
host only), WaveSystem UNGATED (waves run wherever a WaveDirector is baked),
EnemyAISystem core-fallback stripped, AmbientAudioSystem reworked (bed + run cues;
no CycleState gate), MusicSystem RunInfo-only, HudSystem big trim (goal meter, core
bar, siege banner, terminal banner, outcome flash, onboarding hook all gone),
MetaShop/ClassPrep/AimReticle siege gates dropped, DebugOverlay/ops re-meant
(SpawnWave=force next wave, EndSiege=quiet arena; SetCalm/AdvanceGoal/SetHeat
retired, bytes reserved), TuningConfig Core knobs retired (ids 20-23 reserved),
StorageMath.DrainFraction deleted, HowToPlay copy rewritten.

Save epoch v7 (fresh epoch, operator-approved): SaveData drops goal/core/outcome +
conveyor/machine-IO fields; MinLoadableVersion=7; PendingSave/PendingStructure
trimmed; RollTerminalCampaignForward deleted; SaveStructureScan signature slimmed.

390 tests green; Play world-creation clean (player + waves live, no exceptions).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-15 15:27:12 -07:00
parent 835dace213
commit b34945c2d2
74 changed files with 233 additions and 3955 deletions
@@ -144,10 +144,8 @@ namespace ProjectM.Client
// Hide the OS cursor only while aiming AND focused; restore otherwise (focus loss / pre-spawn) so an
// unfocused editor or a windowed session is never stranded with an invisible pointer.
// END-2: while the run is over (terminal banner up) keep the cursor visible so the player can click the
// Play Again / Quit buttons, regardless of aim state. AimReticleSystem is the sole Cursor.visible writer.
bool runOver = SystemAPI.TryGetSingleton<RunOutcome>(out var ro) && ro.Value != RunOutcomeId.InProgress;
bool wantHidden = haveTarget && Application.isFocused && !AimPresentation.ForceCursorVisible && !runOver;
// AimReticleSystem is the sole Cursor.visible writer.
bool wantHidden = haveTarget && Application.isFocused && !AimPresentation.ForceCursorVisible;
if (wantHidden != _cursorHidden)
{
if (wantHidden) Cursor.lockState = CursorLockMode.None;
@@ -6,13 +6,12 @@ using UnityEngine;
namespace ProjectM.Client
{
/// <summary>
/// Client-only AMBIENT audio + cycle-phase stingers. A managed presentation <see cref="SystemBase"/>
/// (<see cref="PresentationSystemGroup"/>, main thread, no Burst) that OBSERVES the replicated
/// <see cref="CycleState"/> and never touches the simulation. On start it plays a low, seamless-looping
/// procedural drone (asset-free, <c>AudioClip.Create</c> like <c>CombatFeedbackSystem.MakeClip</c>); each
/// time the cycle phase changes it plays a short procedural stinger and eases the drone's intensity by phase
/// (calmer at base, tenser during Defend / "wave incoming"). Lives only in the client world, so the server
/// never creates audio and nothing here affects determinism. Volumes are deliberately conservative + tunable.
/// Client-only AMBIENT audio bed + run cues. A managed presentation <see cref="SystemBase"/>
/// (<see cref="PresentationSystemGroup"/>, main thread, no Burst) that plays a low, seamless-looping
/// procedural drone (asset-free, <c>AudioClip.Create</c> like <c>CombatFeedbackSystem.MakeClip</c>) plus
/// launch-countdown beeps and the boss-arrival roar — replicated-state observations only. Lives only in the
/// client world, so the server never creates audio and nothing here affects determinism. Volumes are
/// deliberately conservative. (The cycle-phase stingers + Core alarm retired with the siege loop — LANTERN purge.)
/// </summary>
[WorldSystemFilter(WorldSystemFilterFlags.ClientSimulation)]
[UpdateInGroup(typeof(PresentationSystemGroup))]
@@ -20,29 +19,17 @@ namespace ProjectM.Client
{
AudioSource _ambient;
AudioClip _ambientClip;
AudioClip _stingExpedition;
AudioClip _stingDefend;
AudioClip _stingBuild;
GameObject _root;
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
const float AmbientBaseVolume = 0.10f; // low ambient bed
protected override void OnCreate()
{
_ambientClip = MakeDrone();
_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)
}
@@ -68,36 +55,8 @@ namespace ProjectM.Client
protected override void OnUpdate()
{
if (_ambient == null) return;
if (!SystemAPI.TryGetSingleton<CycleState>(out var cyc)) return;
byte phase = cyc.Phase;
if (!_phaseInit)
{
_lastPhase = phase; // adopt the current phase silently (no stinger on first observe)
_phaseInit = true;
}
else if (phase != _lastPhase)
{
PlaySting(phase);
_lastPhase = phase;
}
// 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;
}
_ambient.volume = Mathf.MoveTowards(_ambient.volume, AmbientBaseVolume * GameVolume.Music, SystemAPI.Time.DeltaTime * 0.25f);
// Launch countdown beeps (3-2-1) + the boss-arrival roar — replicated-state observations only.
if (SystemAPI.TryGetSingleton<RunInfo>(out var runAudio))
@@ -131,13 +90,6 @@ namespace ProjectM.Client
}
}
void PlaySting(byte phase)
{
AudioClip clip = phase == CyclePhase.Siege ? _stingDefend : _stingBuild;
if (clip != null && _ambient != null)
_ambient.PlayOneShot(clip, 0.6f * GameVolume.Music);
}
// ---- Procedural audio (asset-free; mirrors CombatFeedbackSystem.MakeClip) ----
// A low, seamless-looping pad: each partial completes an integer number of cycles over the buffer
@@ -176,6 +128,6 @@ namespace ProjectM.Client
}
// Short one-shot tone sweeping f0->f1 with an exponential decay envelope.
static AudioClip MakeSting(float f0, float f1, float dur, float vol) => FeedbackFx.MakeClip("sting", f0, f1, dur, vol, decay: 3.5f);
static AudioClip MakeSting(float f0, float f1, float dur, float vol) => FeedbackFx.MakeClip("sting", f0, f1, dur, vol, decay: 3.5f);
}
}
@@ -63,8 +63,6 @@ namespace ProjectM.Client
}
bool haveRun = SystemAPI.TryGetSingleton<RunInfo>(out var runInfo);
bool haveCycle = SystemAPI.TryGetSingleton<CycleState>(out var cyc);
bool siege = haveCycle && cyc.Phase == CyclePhase.Siege;
// Resources from the ledger (last entry per type wins, matching the core loop).
int aether = 0, ore = 0, bio = 0;
@@ -93,7 +91,7 @@ namespace ProjectM.Client
// 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 && !siege
bool metaShow = haveRun && runInfo.Lifecycle == RunLifecycle.Staging && haveLocalPlayer
&& SystemAPI.TryGetSingleton<MetaUpgradeCatalog>(out var metaCat) && metaCat.Value.IsCreated
&& SystemAPI.TryGetSingletonBuffer<MetaTierState>(out metaRecord, true);
@@ -1,91 +0,0 @@
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));
}
}
}
}
}
@@ -1,2 +0,0 @@
fileFormatVersion: 2
guid: 47b2a19145125ac4e98411cab7f9b569
@@ -26,14 +26,12 @@ namespace ProjectM.Client
/// </summary>
[WorldSystemFilter(WorldSystemFilterFlags.ClientSimulation)]
[UpdateInGroup(typeof(PresentationSystemGroup))]
[UpdateAfter(typeof(OnboardingSystem))] // read OnboardingState.Active same-frame (single prompt voice)
public partial class HudSystem : SystemBase
{
// ---- palette (Aether language; Synty white skins are tinted into these) ----
static readonly Color AetherCyan = new(0.30f, 0.85f, 1f);
static readonly Color OreAmber = new(1f, 0.72f, 0.35f);
static readonly Color BioGreen = new(0.55f, 0.85f, 0.45f);
static readonly Color CoreRed = new(1f, 0.40f, 0.32f); // END-1 Engine Core integrity bar
static readonly Color PanelDark = new(0.08f, 0.11f, 0.15f, 0.90f);
static readonly Color PanelWarm = new(0.16f, 0.09f, 0.09f, 0.88f);
@@ -43,7 +41,6 @@ namespace ProjectM.Client
static readonly Color SlotIdleBg = new(0.09f, 0.11f, 0.15f, 0.92f);
static readonly Color SlotSelBg = new(0.16f, 0.26f, 0.32f, 0.95f);
static readonly Color SlotIdleBorder = new(1f, 1f, 1f, 0.08f);
const int MaxPips = 12;
const float ExpeditionRegionXMin = RegionMath.RegionBoundaryX; // camera x past this = the +1000 expedition region (DR-013)
GameObject _hudGo;
@@ -59,18 +56,9 @@ namespace ProjectM.Client
VisualElement _threatPanel, _threatIcon;
Label _threatNum;
// macro: banner + location + goal
VisualElement _banner, _goalContainer, _goalPipsRow, _goalBar, _goalFill;
Label _phaseText, _cycleText, _locationText, _goalText;
// END-1: Engine Core integrity (losable base-heart) + overrun flash edge-detector
VisualElement _coreContainer, _coreBar, _coreFill;
Label _coreText;
uint _lastOverrunTick;
float _overrunFlashLeft;
// END-2: terminal win/loss banner (observes the replicated RunOutcome; latched server-side).
VisualElement _runBanner;
Label _runBannerText, _runBannerSub;
// macro: banner + location line
VisualElement _banner;
Label _phaseText, _locationText;
// Demo polish: the clickable READY panel (Staging/Launching).
VisualElement _readyPanel, _readyPipRow;
Button _readyBtn;
@@ -84,12 +72,8 @@ namespace ProjectM.Client
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();
// resources
Label _aetherNum, _oreNum, _bioNum;
@@ -158,84 +142,34 @@ namespace ProjectM.Client
bool haveTick = SystemAPI.TryGetSingleton<NetworkTime>(out var nt);
int huskCount = _huskQuery.CalculateEntityCount();
// ---- Macro: phase + cycle + countdown (center-top banner) ----
bool haveRun = SystemAPI.TryGetSingleton<RunInfo>(out var runInfo); // hoisted: the phase banner is lifecycle-aware (Phase 0 fix — it read "AT BASE" inside expedition rooms)
// B6: run-failed read (review-confirmed design: NEVER key on Returning — it is a 1-tick transient and
// the Charge bank lands a tick after it; detect the (in-run)->Staging edge with a launch-cached Charge.
// Lifecycle + Charge ride the SAME director ghost snapshot, so at the Staging edge the bank has arrived).
// ---- Macro banner: run-lifecycle header (the siege/cycle machinery is retired — LANTERN purge) ----
bool haveRun = SystemAPI.TryGetSingleton<RunInfo>(out var runInfo);
bool onRun = haveRun && runInfo.Lifecycle != RunLifecycle.Staging;
if (haveRun)
{
bool haveGoalNow = SystemAPI.TryGetSingleton<GoalProgress>(out var goalSnap);
byte lcNow = runInfo.Lifecycle;
if (lcNow == RunLifecycle.Launching && _prevRunLifecycle == RunLifecycle.Staging)
{
_chargeAtLaunch = haveGoalNow ? goalSnap.Charge : 0;
_wentInRun = false;
}
if (lcNow == RunLifecycle.InRoom) _wentInRun = true;
if (lcNow == RunLifecycle.Staging && _prevRunLifecycle != RunLifecycle.Staging && _wentInRun)
{
if (haveGoalNow && goalSnap.Charge <= _chargeAtLaunch)
_runFailedUntil = (float)SystemAPI.Time.ElapsedTime + 6f; // wipe/abort: nothing banked
_wentInRun = false;
}
_prevRunLifecycle = lcNow;
}
bool haveCycle = SystemAPI.TryGetSingleton<CycleState>(out var cyc);
bool siege = haveCycle && cyc.Phase == CyclePhase.Siege;
bool goalFull = SystemAPI.TryGetSingleton<GoalProgress>(out var goalNow) && goalNow.Target > 0 && goalNow.Charge >= goalNow.Target;
bool finalSiege = siege && goalFull; // END-2: the climactic final siege (goal cap reached)
bool onRun = haveRun && !siege && runInfo.Lifecycle != RunLifecycle.Staging; // mid-run: the base's Calm label is wrong
if (haveCycle)
{
var endTick = new NetworkTick(cyc.PhaseEndTick);
bool arming = haveTick && cyc.PhaseEndTick != 0 && endTick.IsValid && endTick.IsNewerThan(nt.ServerTick);
bool finalArming = !siege && goalFull && arming; // the cap-reached arming window before the final wave
int secs = arming ? (endTick.TicksSince(nt.ServerTick) / 60 + 1) : 0;
string detail;
if (siege)
detail = (finalSiege ? "FINAL SIEGE" : "WAVE " + cyc.WaveNumber) + " - " + huskCount + " HUSKS";
else if (arming)
detail = (finalArming ? "FINAL SIEGE INCOMING" : "INCURSION") + " - " + secs + "s";
else
detail = "";
// END-2: the climax reads distinct (intense red), not a normal incursion/wave.
var col = finalSiege || finalArming ? new Color(1f, 0.28f, 0.22f) : PhaseColor(cyc.Phase);
_phaseText.text = (finalSiege ? "HOLD THE ENGINE" : onRun ? "ON EXPEDITION" : PhaseLabel(cyc.Phase)) + (detail.Length > 0 ? " - " + detail : "");
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;
_cycleText.text = "CYCLE " + cyc.CycleNumber;
_banner.style.borderBottomColor = col;
RetintPanel(_banner, siege ? PanelWarm : PanelDark);
RetintPanel(_banner, PanelDark);
}
else
{
_phaseText.text = "";
_cycleText.text = "";
}
// ---- Location line (banner sub-line) — Step 14: driven by the replicated RunInfo lifecycle FSM ----
// (the old camera-X + walk-in-gate copy died with the gate; siege/final overrides below still win).
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 && !siege && !finalSiege)
if (haveRun)
{
switch (runInfo.Lifecycle)
{
case RunLifecycle.Staging:
// The READY panel (bottom-center) owns the action + N/M count; the top line frames intent.
if ((float)SystemAPI.Time.ElapsedTime < _runFailedUntil)
{
// B6: a silent wipe used to land players home with ZERO explanation.
_locationText.text = "EXPEDITION FAILED - the party fell; nothing was banked";
_locationText.style.color = new Color(1f, 0.35f, 0.3f);
}
else
{
_locationText.text = "AT THE BASE - build defenses, buy upgrades, READY UP to launch";
_locationText.style.color = new Color(0.55f, 0.85f, 1f);
}
_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:
{
@@ -280,33 +214,18 @@ namespace ProjectM.Client
break;
}
}
else if (!haveRun)
{
_locationText.text = finalSiege
? "FINAL SIEGE - hold the Engine, this is the last stand"
: siege ? "DEFEND THE BASE - hold the line"
: "MINE THE CRYSTALS - any attack harvests Ore, then BUILD";
_locationText.style.color = finalSiege ? new Color(1f, 0.3f, 0.25f)
: siege ? new Color(1f, 0.55f, 0.4f) : new Color(0.6f, 0.95f, 0.7f);
}
else
{
_locationText.text = finalSiege
? "FINAL SIEGE - hold the Engine, this is the last stand"
: "DEFEND THE BASE - hold the line";
_locationText.style.color = finalSiege ? new Color(1f, 0.3f, 0.25f) : new Color(1f, 0.55f, 0.4f);
_locationText.text = "";
}
// 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.
// 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 terminal = SystemAPI.TryGetSingleton<RunOutcome>(out var readyOc)
&& readyOc.Value != RunOutcomeId.InProgress;
bool readyShow = haveRun && !terminal && !goalFull /* D6: goal full -> final defense armed, launching is refused server-side */
bool readyShow = haveRun
&& (runInfo.Lifecycle == RunLifecycle.Staging || runInfo.Lifecycle == RunLifecycle.Launching);
if (readyShow)
{
@@ -352,35 +271,6 @@ namespace ProjectM.Client
// 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))
{
_goalContainer.style.display = DisplayStyle.Flex;
float gfrac = goal.Target > 0 ? Mathf.Clamp01(goal.Charge / (float)goal.Target) : 0f;
_goalText.text = "GOAL " + goal.Charge + " / " + goal.Target;
if (goal.Target >= 1 && goal.Target <= MaxPips)
{
_goalPipsRow.style.display = DisplayStyle.Flex;
_goalBar.style.display = DisplayStyle.None;
int active = Mathf.Min(goal.Charge, goal.Target); // Charge is the integer pip count; never over-fill
for (int i = 0; i < _pips.Count; i++)
{
bool show = i < goal.Target;
_pips[i].style.display = show ? DisplayStyle.Flex : DisplayStyle.None;
if (show) SetPip(_pips[i], i < active);
}
}
else
{
_goalPipsRow.style.display = DisplayStyle.None;
_goalBar.style.display = DisplayStyle.Flex;
HudUi.SetFill(_goalFill, gfrac);
}
}
else
{
_goalContainer.style.display = DisplayStyle.None;
}
// ---- Resources (feed palette affordability) ----
int aether = 0, ore = 0, bio = 0;
@@ -400,104 +290,21 @@ namespace ProjectM.Client
_bioNum.text = bio.ToString();
// ---- Engine Core integrity (END-1): a red base-heart bar; an overrun stamps a transient pulse we flash ----
if (SystemAPI.TryGetSingleton<CoreIntegrity>(out var core) && core.Max > 0)
{
_coreContainer.style.display = DisplayStyle.Flex;
float cfrac = Mathf.Clamp01(core.Current / (float)core.Max);
HudUi.SetFill(_coreFill, cfrac);
_coreText.text = "CORE " + core.Current + " / " + core.Max;
_coreText.style.color = Color.Lerp(BlightRed, CoreRed, cfrac); // shifts to danger as it drops
if (core.OverrunTick != 0 && core.OverrunTick != _lastOverrunTick)
{
_lastOverrunTick = core.OverrunTick; // edge-detect the replicated breach pulse
_overrunFlashLeft = 3.5f;
}
}
else
{
_coreContainer.style.display = DisplayStyle.None;
}
// Overrun flash overrides the location line (runs AFTER the EB-2 cue so it wins; at a breach Phase is Calm).
if (_overrunFlashLeft > 0f)
{
_overrunFlashLeft -= dt;
_locationText.text = "BASE OVERRUN - resources lost; the Core will recover";
_locationText.style.color = new Color(1f, 0.3f, 0.25f);
}
// First-run onboarding owns the prompt voice: while a coach-mark step is showing, blank the HUD's own
// location/gate hint so the player sees a single prompt (OnboardingSystem drives its own overlay).
if (OnboardingState.SuppressLocationLine) _locationText.text = ""; // D4: blank only for the early base-framing steps; room/siege/charge cues survive
// D6: goal full but the final siege hasn't spawned yet (the arming gap) -> the READY panel is hidden; tell the
// player what's coming instead of a stale base line (goalFull is replicated; RunPhase is server-only).
if (haveRun && goalFull && !terminal && !siege && !finalSiege)
{
_locationText.text = "GOAL REACHED - FINAL DEFENSE INCOMING: hold the Engine!";
_locationText.style.color = new Color(1f, 0.35f, 0.28f);
}
// ---- END-2: terminal run banner (Victory / Loss), observed from the replicated RunOutcome ----
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);
_runBannerSub.text = win ? "VICTORY - the final siege is broken" : "THE FINAL STAND FELL";
_runBannerSub.style.color = win ? new Color(0.7f, 0.95f, 1f) : new Color(1f, 0.6f, 0.5f);
}
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;
}
// ---- Threat readout (top-right) — hidden entirely at base with zero husks; its reappearance is the cue ----
bool showThreat = siege || huskCount > 0;
// ---- Threat readout (top-right) — hidden entirely with zero husks; its reappearance is the cue ----
bool showThreat = huskCount > 0;
_threatPanel.style.display = showThreat ? DisplayStyle.Flex : DisplayStyle.None;
if (showThreat)
{
float intensity = Mathf.Clamp01(huskCount / 30f);
Color tc = siege ? Color.Lerp(ThreatWarm, BlightRed, intensity) : ThreatWarm;
Color tc = Color.Lerp(ThreatWarm, BlightRed, intensity);
_threatNum.text = huskCount.ToString();
_threatNum.style.color = tc;
_threatIcon.style.unityBackgroundImageTintColor = tc;
RetintPanel(_threatPanel, siege ? PanelWarm : PanelDark);
RetintPanel(_threatPanel, PanelDark);
}
// ---- Build palette + control hints (bottom-center) ----
@@ -546,7 +353,7 @@ namespace ProjectM.Client
break;
}
_doc.rootVisualElement.style.display = (found || haveCycle) ? DisplayStyle.Flex : DisplayStyle.None;
_doc.rootVisualElement.style.display = (found || haveRun) ? DisplayStyle.Flex : DisplayStyle.None;
// ---- Low-health vignette + hurt flash (full-screen) ----
_flash = HudVisualMath.DecayFlash(_flash, dt);
@@ -645,22 +452,7 @@ namespace ProjectM.Client
else p.style.backgroundColor = c;
}
void SetPip(VisualElement pip, bool active)
{
var theme = HudTheme.Get();
var spr = active ? theme?.PipActive : theme?.PipInactive;
if (spr != null)
{
pip.style.backgroundImage = new StyleBackground(Background.FromSprite(spr));
pip.style.unityBackgroundImageTintColor = active ? AetherCyan : PipDim;
pip.style.backgroundSize = new StyleBackgroundSize(new BackgroundSize(BackgroundSizeType.Contain));
}
else
{
pip.style.backgroundColor = active ? AetherCyan : PipDim;
MenuUi.Round(pip, 3);
}
}
// LANTERN purge: the automation buildables are deleted; Pylon stays hidden from the build palette (cosmetic-only).
static bool IsPaletteType(byte type) => type != StructureType.Pylon;
@@ -804,7 +596,6 @@ namespace ProjectM.Client
BuildDiscoveryChip(root);
BuildDowned(root);
BuildInventory(root);
BuildRunBanner(root);
}
void BuildVignette(VisualElement root)
@@ -932,69 +723,13 @@ namespace ProjectM.Client
_banner.Add(bIcon);
_phaseText = HudUi.Display("", 30, AetherCyan, TextAnchor.MiddleCenter);
_banner.Add(_phaseText);
_cycleText = HudUi.Text("", 14, MenuUi.SubCol, TextAnchor.MiddleCenter);
_cycleText.style.marginLeft = 14;
_banner.Add(_cycleText);
macro.Add(_banner);
_locationText = HudUi.Text("", 15, new Color(0.6f, 0.85f, 1f), TextAnchor.MiddleCenter);
_locationText.style.marginTop = 5;
macro.Add(_locationText);
// goal: hex-pip meter (or fallback bar) + numeral
_goalContainer = HudUi.Group(Align.Center);
_goalContainer.style.marginTop = 8;
var goalLine = new VisualElement();
goalLine.style.flexDirection = FlexDirection.Row;
goalLine.style.alignItems = Align.Center;
goalLine.pickingMode = PickingMode.Ignore;
_goalPipsRow = new VisualElement();
_goalPipsRow.style.flexDirection = FlexDirection.Row;
_goalPipsRow.style.alignItems = Align.Center;
_goalPipsRow.pickingMode = PickingMode.Ignore;
for (int i = 0; i < MaxPips; i++)
{
var pip = new VisualElement();
pip.style.width = 22; pip.style.height = 22;
pip.style.marginLeft = 2; pip.style.marginRight = 2;
pip.style.flexShrink = 0;
pip.pickingMode = PickingMode.Ignore;
pip.style.display = DisplayStyle.None;
_pips.Add(pip);
_goalPipsRow.Add(pip);
}
goalLine.Add(_goalPipsRow);
_goalText = HudUi.Display("GOAL 0 / 10", 16, AetherCyan, TextAnchor.MiddleCenter);
_goalText.style.marginLeft = 10;
goalLine.Add(_goalText);
_goalContainer.Add(goalLine);
// fallback continuous bar (large targets)
_goalBar = HudUi.Bar(360, 16, new Color(0.8f, 0.6f, 1f), out _goalFill);
_goalBar.style.marginTop = 4;
_goalBar.style.display = DisplayStyle.None;
_goalContainer.Add(_goalBar);
macro.Add(_goalContainer);
// END-1: Engine Core integrity bar (red) — the losable base-heart meter.
_coreContainer = HudUi.Group(Align.Center);
_coreContainer.style.marginTop = 6;
var coreLine = new VisualElement();
coreLine.style.flexDirection = FlexDirection.Row;
coreLine.style.alignItems = Align.Center;
coreLine.pickingMode = PickingMode.Ignore;
_coreBar = HudUi.Bar(360, 14, CoreRed, out _coreFill);
coreLine.Add(_coreBar);
_coreText = HudUi.Text("CORE 100 / 100", 13, CoreRed, TextAnchor.MiddleLeft);
_coreText.style.marginLeft = 10;
coreLine.Add(_coreText);
_coreContainer.Add(coreLine);
_coreContainer.style.display = DisplayStyle.None;
macro.Add(_coreContainer);
root.Add(macro);
}
@@ -1108,53 +843,7 @@ namespace ProjectM.Client
_downed.style.display = DisplayStyle.None;
root.Add(_downed);
}
void BuildRunBanner(VisualElement root)
{
_runBanner = new VisualElement();
_runBanner.style.position = Position.Absolute;
_runBanner.style.left = 0; _runBanner.style.right = 0; _runBanner.style.top = 0; _runBanner.style.bottom = 0;
_runBanner.style.alignItems = Align.Center;
_runBanner.style.justifyContent = Justify.Center;
_runBanner.pickingMode = PickingMode.Ignore;
_runBanner.style.backgroundColor = new Color(0.02f, 0.03f, 0.05f, 0.55f);
var col = HudUi.Group(Align.Center);
_runBannerText = HudUi.Display("", 72, Color.white, TextAnchor.MiddleCenter);
col.Add(_runBannerText);
_runBannerSub = HudUi.Text("", 22, MenuUi.SubCol, TextAnchor.MiddleCenter);
_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.
// 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;
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;
root.Add(_runBanner);
}
void BuildInventory(VisualElement root)
@@ -1280,25 +969,9 @@ namespace ProjectM.Client
}
static Color PhaseColor(byte phase)
{
switch (phase)
{
case CyclePhase.Calm: return new Color(0.45f, 0.9f, 0.7f);
case CyclePhase.Siege: return new Color(1f, 0.45f, 0.3f);
default: return Color.white;
}
}
static string PhaseLabel(byte phase)
{
switch (phase)
{
case CyclePhase.Calm: return "AT BASE";
case CyclePhase.Siege: return "UNDER SIEGE";
default: return "";
}
}
static string StructureName(byte type)
{
@@ -1509,11 +1182,6 @@ namespace ProjectM.Client
}
// B6 run-failed read (client-local edge state; see the tracker near the top of OnUpdate).
byte _prevRunLifecycle;
int _chargeAtLaunch;
bool _wentInRun;
float _runFailedUntil;
}
}
@@ -60,8 +60,6 @@ namespace ProjectM.Client
}
bool haveRun = SystemAPI.TryGetSingleton<RunInfo>(out var runInfo);
bool haveCycle = SystemAPI.TryGetSingleton<CycleState>(out var cyc);
bool siege = haveCycle && cyc.Phase == CyclePhase.Siege;
// Aether from the ledger (the sole meta-shop currency; last entry wins, matching the core loop).
int aether = 0;
@@ -86,7 +84,7 @@ namespace ProjectM.Client
bool metaShow = false;
BlobAssetReference<MetaUpgradeCatalogBlob> metaPool = default;
DynamicBuffer<MetaTierState> metaRecord = default;
if (haveRun && runInfo.Lifecycle == RunLifecycle.Staging && haveLocalPlayer && !siege
if (haveRun && runInfo.Lifecycle == RunLifecycle.Staging && haveLocalPlayer
&& SystemAPI.TryGetSingleton<MetaUpgradeCatalog>(out var metaCat) && metaCat.Value.IsCreated
&& SystemAPI.TryGetSingletonBuffer<MetaTierState>(out metaRecord, true))
{
@@ -11,9 +11,8 @@ namespace ProjectM.Client
/// 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
/// machine: layer volumes ease toward targets chosen from replicated state only — the <see cref="RunInfo"/>
/// lifecycle (staging / launch / combat / boss / reward lull / return). 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.
@@ -43,7 +42,6 @@ namespace ProjectM.Client
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()
{
@@ -80,27 +78,7 @@ namespace ProjectM.Client
// ---- 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)
if (SystemAPI.TryGetSingleton<RunInfo>(out var run))
{
switch (run.Lifecycle)
{
@@ -138,15 +116,7 @@ namespace ProjectM.Client
_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) =================
@@ -263,23 +233,6 @@ namespace ProjectM.Client
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;
}
}
}