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:
@@ -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;
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user