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:
@@ -26,18 +26,15 @@ namespace ProjectM.Client
|
||||
=> s_Pending.Add(new Pending { Op = op, ArgA = argA, ArgB = argB });
|
||||
|
||||
// Convenience wrappers (overlay buttons + execute_code).
|
||||
public static void SpawnWave(int size) => Send(DebugOp.SpawnWave, size);
|
||||
public static void EndSiege() => Send(DebugOp.EndSiege);
|
||||
public static void SpawnWave() => Send(DebugOp.SpawnWave); // re-meant: force the next wave now
|
||||
public static void StopWaves() => Send(DebugOp.EndSiege); // re-meant: quiet the arena (cull + delay waves)
|
||||
public static void ClearEnemies() => Send(DebugOp.ClearEnemies);
|
||||
public static void SetCalm() => Send(DebugOp.SetCalm);
|
||||
public static void GrantResource(byte itemId, int count) => Send(DebugOp.GrantResource, itemId, count);
|
||||
public static void GrantUpgrade() => Send(DebugOp.GrantUpgrade);
|
||||
public static void Teleport(byte region) => Send(DebugOp.Teleport, region);
|
||||
public static void ToggleGod() => Send(DebugOp.ToggleGod);
|
||||
public static void Heal() => Send(DebugOp.Heal);
|
||||
public static void Kill() => Send(DebugOp.KillPlayer);
|
||||
public static void AdvanceGoal(int by) => Send(DebugOp.AdvanceGoal, by);
|
||||
public static void SetHeat(int heat) => Send(DebugOp.SetHeat, heat);
|
||||
/// <summary>Set the <see cref="ProjectM.Simulation.TuningKnob"/> knob to value (server-applied, x1000 fixed-point; MC-0).</summary>
|
||||
public static void SetTuning(byte knob, float value) => Send(DebugOp.SetTuning, knob, Mathf.RoundToInt(value * 1000f));
|
||||
/// <summary>Swap the sender's class to <paramref name="classId"/> (a <see cref="ProjectM.Simulation.CharacterId"/> byte); server-authoritative (class-switch dev tool).</summary>
|
||||
|
||||
@@ -15,7 +15,6 @@ namespace ProjectM.Client
|
||||
public class DebugOverlay : MonoBehaviour
|
||||
{
|
||||
bool _open = true;
|
||||
int _siegeSize = 5;
|
||||
int _grantAmount = 50;
|
||||
bool _tuningOpen;
|
||||
Vector2 _scroll;
|
||||
@@ -37,12 +36,9 @@ namespace ProjectM.Client
|
||||
_scroll = GUILayout.BeginScrollView(_scroll);
|
||||
|
||||
GUILayout.Label("- World -");
|
||||
_siegeSize = IntField("Siege size", _siegeSize);
|
||||
if (GUILayout.Button("Spawn Wave / Force Siege")) DebugCommandSendSystem.SpawnWave(_siegeSize);
|
||||
if (GUILayout.Button("End Siege")) DebugCommandSendSystem.EndSiege();
|
||||
if (GUILayout.Button("Force Next Wave")) DebugCommandSendSystem.SpawnWave();
|
||||
if (GUILayout.Button("Stop Waves")) DebugCommandSendSystem.StopWaves();
|
||||
if (GUILayout.Button("Clear Enemies")) DebugCommandSendSystem.ClearEnemies();
|
||||
if (GUILayout.Button("Force Calm")) DebugCommandSendSystem.SetCalm();
|
||||
if (GUILayout.Button("Advance Goal +1")) DebugCommandSendSystem.AdvanceGoal(1);
|
||||
|
||||
GUILayout.Space(6);
|
||||
GUILayout.Label("- Resources -");
|
||||
@@ -112,9 +108,6 @@ namespace ProjectM.Client
|
||||
TuningRow("Melee combo len", TuningKnob.MeleeComboLength, 1f, "0");
|
||||
GUILayout.Space(4);
|
||||
TuningRow("Struct aggro w", TuningKnob.StructureAggroWeight, 0.1f, "0.00"); // EB-1: <1 prefers structures
|
||||
TuningRow("Core dmg/husk", TuningKnob.CoreDamagePerHusk, 1f, "0"); // END-1: integrity per breaching Husk
|
||||
TuningRow("Core regen int", TuningKnob.CoreRegenIntervalTicks, 1f, "0"); // END-1: ticks between +1 in Calm
|
||||
TuningRow("Core overrun %", TuningKnob.CoreOverrunDrainPct, 0.05f, "0.00"); // END-1: ledger fraction lost on breach
|
||||
|
||||
}
|
||||
|
||||
|
||||
@@ -1,8 +0,0 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 0861914135cacf948ae2adfd7f7d6870
|
||||
folderAsset: yes
|
||||
DefaultImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -1,26 +0,0 @@
|
||||
using UnityEngine;
|
||||
|
||||
namespace ProjectM.Client
|
||||
{
|
||||
/// <summary>
|
||||
/// Tiny static coordination bridge for the first-run onboarding overlay. <see cref="Active"/> is true while a
|
||||
/// coach-mark step is on screen (set each frame by <see cref="OnboardingSystem"/>); <see cref="HudSystem"/>
|
||||
/// reads it to suppress its own ad-hoc location/gate hint so the player ever sees a single prompt voice.
|
||||
/// A presentation-layer static, so it is RESET on play-enter (the CLAUDE.md stale-static rule) to avoid a
|
||||
/// stale flag surviving a fast-enter-playmode domain reload and leaving the HUD hint suppressed.
|
||||
/// </summary>
|
||||
public static class OnboardingState
|
||||
{
|
||||
/// <summary>True while the coach-mark sequence is the active prompt voice (a step is being shown).</summary>
|
||||
public static bool Active;
|
||||
|
||||
/// <summary>True only while an EARLY base-framing step (Welcome/Move/Build/Fabricator/ReadyUp) is showing, so
|
||||
/// HudSystem blanks its own location line for those (the coach-mark owns the voice) but LEAVES the room/siege/
|
||||
/// out-of-ammo cues visible during the combat steps (D4). Set each frame by OnboardingSystem.</summary>
|
||||
public static bool SuppressLocationLine;
|
||||
|
||||
|
||||
[RuntimeInitializeOnLoadMethod(RuntimeInitializeLoadType.SubsystemRegistration)]
|
||||
static void ResetOnPlayEnter() { Active = false; SuppressLocationLine = false; }
|
||||
}
|
||||
}
|
||||
@@ -1,2 +0,0 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 4e9b3bb074eef1c40b90591e85b90e32
|
||||
@@ -1,145 +0,0 @@
|
||||
using ProjectM.Simulation;
|
||||
|
||||
namespace ProjectM.Client
|
||||
{
|
||||
/// <summary>
|
||||
/// Pure, engine-free logic for the first-run onboarding coach-mark sequence — the testable core of
|
||||
/// <see cref="OnboardingSystem"/> (mirrors the project's <c>*Math</c> helper discipline; no UnityEngine /
|
||||
/// Entities types so it unit-tests as plain C#). Defines the ordered step list, a <see cref="Snapshot"/> of the
|
||||
/// observable client state each step reads, the deterministic per-step completion test, prompt copy, the
|
||||
/// spatial-cue kind, and the persisted-mask helpers.
|
||||
///
|
||||
/// RE-AUTHORED for the expedition redesign (demo polish): base nodes are gone (mining happens IN the run),
|
||||
/// so the old at-base Mine step is dead — the first lap is now Build (grubstake Ore) → Fabricator →
|
||||
/// READY UP → fight rooms (mine in-run) → boon → boss/return → defend. Step BITS are re-meant, never
|
||||
/// renamed-in-place semantics: a veteran's full mask stays dormant; a partial first-run mask at worst
|
||||
/// replays one beat.
|
||||
///
|
||||
/// Pacing (operator-locked = soft-gated): a step shows until its action is performed (no per-step timeout),
|
||||
/// EXCEPT two info beats — <see cref="Fabricator"/> and <see cref="Defend"/> — which also auto-advance, plus
|
||||
/// the timed <see cref="Welcome"/> strip. Veteran / co-op auto-suppress falls out for free: the count-based
|
||||
/// steps (<see cref="Build"/>, <see cref="Fabricator"/>) test an ABSOLUTE structure count, so a client joining
|
||||
/// an already-built base satisfies them on entry and skips straight past.
|
||||
/// </summary>
|
||||
public static class OnboardingStepMath
|
||||
{
|
||||
// ---- ordered steps (byte ids; bit i of GameSettings.OnboardingMask = step i complete) ----
|
||||
public const byte Welcome = 0; // tiny win-condition framing strip (timed)
|
||||
public const byte Move = 1;
|
||||
public const byte Build = 2; // open palette + place a Turret (the 50-Ore grubstake covers it)
|
||||
public const byte Fabricator = 3; // Ore -> Charge (soft info beat)
|
||||
public const byte ReadyUp = 4; // press T / click READY — the party launches together
|
||||
public const byte Rooms = 5; // fight the rooms; attack crystal nodes to haul resources
|
||||
public const byte Boon = 6; // clear a room -> pick 1 of 3 boons + choose the path
|
||||
public const byte Return = 7; // fell the boss — the haul + the Engine charge come home
|
||||
public const byte Defend = 8; // survive the retaliation siege (soft info beat)
|
||||
public const byte Done = 9; // closing beat
|
||||
public const byte StepCount = 10;
|
||||
|
||||
// ---- tunable thresholds (public so the EditMode tests pin the contract) ----
|
||||
public const float WelcomeSeconds = 5f;
|
||||
public const float MoveThreshold = 3f; // accumulated player movement (world units)
|
||||
public const float FabricatorSoftSeconds = 14f; // soft beat auto-advance if no Fabricator built
|
||||
public const float DefendNoSiegeSeconds = 20f; // advance if no siege ever materialises
|
||||
public const float DoneSeconds = 6f; // closing beat lingers before going dormant
|
||||
public const float RoomsSeconds = 7f; // D2: keep the mine-the-crystals prompt + node pointer up a beat AFTER teleport (past the ~3s launch countdown -> ~4s in the room)
|
||||
public const float ReturnMaxSeconds = 75f; // D3: soft backstop so a missed homecoming signal can NEVER stall the sequence
|
||||
|
||||
// ---- spatial-cue kinds the System resolves to a live world target ----
|
||||
public const byte PointerNone = 0;
|
||||
public const byte PointerOreNode = 1;
|
||||
public const byte PointerBaseGate = 2; // RETIRED target (walk-in gate died with the redesign); byte kept
|
||||
public const byte PointerExpeditionGate = 3; // RETIRED target; byte kept for mask/step stability
|
||||
|
||||
/// <summary>Observable client state for one evaluation. Built by the System from ECS + input each frame.</summary>
|
||||
public struct Snapshot
|
||||
{
|
||||
public float StepElapsed; // seconds the current step has been shown
|
||||
public float MoveDistance; // accumulated player movement since the Move step began
|
||||
public int TurretCount; // live Turret structures (absolute)
|
||||
public int FabricatorCount; // live Fabricator structures (absolute)
|
||||
public bool LocalReady; // the LOCAL player's replicated PlayerReady flag
|
||||
public byte Lifecycle; // replicated RunInfo.Lifecycle (RunLifecycle.*)
|
||||
public bool OnExpedition; // local player is in the expedition region
|
||||
public byte ObjectiveState; // ExpeditionObjective.State (Idle/Active/Cleared)
|
||||
public bool SawSiege; // a Siege phase was observed while the Defend step was showing
|
||||
public bool WasOnExpedition;// D3: latched true once the player was seen on expedition during the Return step (so a start-at-base Return doesn't instantly satisfy)
|
||||
|
||||
public byte Phase; // CycleState.Phase (Calm/Siege)
|
||||
}
|
||||
|
||||
/// <summary>True when the step's taught action is complete (or its soft timeout has elapsed).</summary>
|
||||
public static bool IsSatisfied(byte step, in Snapshot s)
|
||||
{
|
||||
switch (step)
|
||||
{
|
||||
case Welcome: return s.StepElapsed >= WelcomeSeconds;
|
||||
case Move: return s.MoveDistance >= MoveThreshold;
|
||||
case Build: return s.TurretCount >= 1;
|
||||
case Fabricator: return s.FabricatorCount >= 1 || s.StepElapsed >= FabricatorSoftSeconds;
|
||||
case ReadyUp: return s.LocalReady || s.Lifecycle != RunLifecycle.Staging;
|
||||
case Rooms: return s.OnExpedition && s.StepElapsed >= RoomsSeconds; // D2: show the mine prompt + node pointer IN the room, not the instant we teleport
|
||||
case Boon: return s.ObjectiveState == ExpeditionObjectiveState.Cleared
|
||||
|| s.Lifecycle == RunLifecycle.RoomReward
|
||||
|| s.Lifecycle == RunLifecycle.RouteSelect;
|
||||
case Return: return (s.WasOnExpedition && !s.OnExpedition) || s.StepElapsed >= ReturnMaxSeconds; // D3: home AFTER being on expedition, else a soft timeout (never gate on the 1-tick Returning edge)
|
||||
case Defend: return s.SawSiege ? s.Phase == CyclePhase.Calm : s.StepElapsed >= DefendNoSiegeSeconds;
|
||||
case Done: return s.StepElapsed >= DoneSeconds;
|
||||
default: return true;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Which world target (if any) the prompt should point at this step.</summary>
|
||||
public static byte PointerKind(byte step)
|
||||
{
|
||||
switch (step)
|
||||
{
|
||||
case Rooms: return PointerOreNode; // in-run crystal nodes (base nodes no longer exist)
|
||||
default: return PointerNone;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Ultra-short, verb-first prompt copy with the player's real input glyph (scheme-aware).</summary>
|
||||
public static string Prompt(byte step, bool gamepad)
|
||||
{
|
||||
string move = gamepad ? "Left Stick" : "WASD";
|
||||
string build = gamepad ? "Y" : "Tab"; // matches the existing HUD build-discovery chip glyph
|
||||
switch (step)
|
||||
{
|
||||
case Welcome: return "CLEAR 2 EXPEDITIONS to charge the Engine, then survive the FINAL SIEGE. (Space to continue · Esc → How to Play)";
|
||||
case Move: return move + " — Move " + (gamepad ? "B" : "LShift") + " — Dash (brief invulnerability)";
|
||||
case Build: return build + " — open Build, place a Turret by your Core (your " + ProjectM.Simulation.Tuning.StartingOre + " starting Ore covers it)";
|
||||
case Fabricator: return "Build a Fabricator — turrets need Charge (Ore → ammo)";
|
||||
case ReadyUp: return "Press T (or click READY UP) — when everyone is ready, the party launches";
|
||||
case Rooms: return (gamepad ? "X attack · LT class ability" : "LMB attack · RMB class ability") + " — clear the room; shoot the glowing crystals for your haul";
|
||||
case Boon: return "Clear the room — pick 1 of 3 BOONS, then choose your path on the map";
|
||||
case Return: return "Fell the ALPHA HUSK, then return home — your haul + the Engine charge (+1) come with you";
|
||||
case Defend: return "Defend the Core! — a completed run provokes a retaliation siege";
|
||||
case Done: return "You've got it — clear 2 expeditions to fill the Engine, then hold the final siege to win.";
|
||||
default: return "";
|
||||
}
|
||||
}
|
||||
|
||||
// ---- persisted-mask helpers (GameSettings.OnboardingMask) ----
|
||||
|
||||
/// <summary>D4: the early BASE-framing steps (the coach-mark owns the prompt voice, so the HUD blanks its own
|
||||
/// location line) vs the combat steps (the HUD MUST keep showing room/siege/out-of-ammo cues). Welcome..ReadyUp
|
||||
/// are early. Read each frame by OnboardingSystem to set OnboardingState.SuppressLocationLine.</summary>
|
||||
public static bool IsEarlyStep(byte step) => step <= ReadyUp;
|
||||
|
||||
/// <summary>All steps complete (the sequence is dormant).</summary>
|
||||
public static bool AllComplete(int mask)
|
||||
{
|
||||
int all = (1 << StepCount) - 1;
|
||||
return (mask & all) == all;
|
||||
}
|
||||
|
||||
/// <summary>The lowest not-yet-completed step (resume point); <see cref="Done"/> when all are complete.</summary>
|
||||
public static byte FirstIncomplete(int mask)
|
||||
{
|
||||
for (byte i = 0; i < StepCount; i++)
|
||||
if ((mask & (1 << i)) == 0) return i;
|
||||
return Done;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,2 +0,0 @@
|
||||
fileFormatVersion: 2
|
||||
guid: c264496096436e74ebba163a7a5d2205
|
||||
@@ -1,332 +0,0 @@
|
||||
using ProjectM.Simulation;
|
||||
using Unity.Entities;
|
||||
using Unity.Mathematics;
|
||||
using Unity.NetCode;
|
||||
using Unity.Transforms;
|
||||
using UnityEngine;
|
||||
using UnityEngine.UIElements;
|
||||
|
||||
namespace ProjectM.Client
|
||||
{
|
||||
/// <summary>
|
||||
/// First-run onboarding overlay — a CLIENT-ONLY, observe-only presentation <see cref="SystemBase"/> in
|
||||
/// <see cref="PresentationSystemGroup"/> (same shape/constraints as <see cref="HudSystem"/>: never mutates the
|
||||
/// sim, never destroys a ghost, reads already-replicated state once per frame). Owns its own runtime UIDocument
|
||||
/// (sortingOrder 60 — above the HUD's 50, below the pause overlay's 100) showing a single bottom-center
|
||||
/// coach-mark prompt plus a world-space directional pointer.
|
||||
///
|
||||
/// The sequence is PER-CLIENT and client-local: progress lives in <see cref="GameSettings.OnboardingMask"/>
|
||||
/// (via <see cref="SettingsService"/>), keyed to THIS player's own first-encounter — so a veteran host sees
|
||||
/// nothing, a brand-new join-client is still taught, and a save wipe never re-teaches the host (the mask is in
|
||||
/// settings.json, not the host-only SaveData). Soft-gated pacing: a step shows until its action is done; the
|
||||
/// pure rules + auto-suppress (absolute count checks) live in <see cref="OnboardingStepMath"/>.
|
||||
/// </summary>
|
||||
[WorldSystemFilter(WorldSystemFilterFlags.ClientSimulation)]
|
||||
[UpdateInGroup(typeof(PresentationSystemGroup))]
|
||||
public partial class OnboardingSystem : SystemBase
|
||||
{
|
||||
const float ExpeditionRegionXMin = RegionMath.RegionBoundaryX; // player x past this = the +1000 expedition region (mirrors HudSystem)
|
||||
|
||||
GameObject _go;
|
||||
UIDocument _doc;
|
||||
bool _built;
|
||||
Label _prompt;
|
||||
Label _pointer;
|
||||
|
||||
// step machine (in-memory; persisted to the mask on each completion)
|
||||
bool _maskLoaded;
|
||||
int _mask;
|
||||
byte _step;
|
||||
bool _stepInit;
|
||||
float _stepElapsed;
|
||||
float _moveAccum;
|
||||
float3 _lastPos;
|
||||
bool _sawSiege;
|
||||
bool _wasOnExpedition; // D3: latched true once the player is seen on expedition during the Return step
|
||||
|
||||
|
||||
protected override void OnStartRunning()
|
||||
{
|
||||
if (_go != null) return;
|
||||
_go = new GameObject("~Onboarding");
|
||||
_doc = _go.AddComponent<UIDocument>();
|
||||
_doc.panelSettings = MenuUi.LoadPanelSettings();
|
||||
_doc.sortingOrder = 60; // above HUD (50), below pause (100)
|
||||
}
|
||||
|
||||
protected override void OnDestroy()
|
||||
{
|
||||
OnboardingState.Active = false; // never let the static outlive its owning system (HUD suppression)
|
||||
if (_go != null) Object.Destroy(_go);
|
||||
}
|
||||
|
||||
protected override void OnUpdate()
|
||||
{
|
||||
if (_doc == null) return;
|
||||
var root = _doc.rootVisualElement;
|
||||
if (root == null) return;
|
||||
if (!_built) { BuildTree(root); _built = true; }
|
||||
OnboardingState.SuppressLocationLine = false; // D4: default off each frame; a shown step sets it per-step below
|
||||
|
||||
|
||||
float dt = SystemAPI.Time.DeltaTime; // wall-frame delta — correct in a presentation system
|
||||
|
||||
// ---- local player presence + position ----
|
||||
bool havePlayer = false; float3 playerPos = default;
|
||||
foreach (var lt in SystemAPI.Query<RefRO<LocalTransform>>().WithAll<GhostOwnerIsLocal, PlayerTag>())
|
||||
{ havePlayer = true; playerPos = lt.ValueRO.Position; break; }
|
||||
|
||||
var settings = SettingsService.Current;
|
||||
if (!_maskLoaded)
|
||||
{
|
||||
_mask = settings.OnboardingMask;
|
||||
_step = OnboardingStepMath.FirstIncomplete(_mask);
|
||||
_stepInit = false;
|
||||
_maskLoaded = true;
|
||||
}
|
||||
bool hintsOn = settings.TutorialHints != 0;
|
||||
|
||||
// Dormant (hints off / all steps done) or no local player yet → fully hidden, no voice.
|
||||
if (!hintsOn || OnboardingStepMath.AllComplete(_mask) || !havePlayer)
|
||||
{
|
||||
OnboardingState.Active = false;
|
||||
root.style.display = DisplayStyle.None;
|
||||
return;
|
||||
}
|
||||
|
||||
// ---- remaining observable state ----
|
||||
byte lifecycle = RunLifecycle.Staging;
|
||||
if (SystemAPI.TryGetSingleton<RunInfo>(out var runInfo)) lifecycle = runInfo.Lifecycle;
|
||||
bool localReady = false;
|
||||
foreach (var pr in SystemAPI.Query<RefRO<PlayerReady>>().WithAll<GhostOwnerIsLocal, PlayerTag>())
|
||||
{ localReady = pr.ValueRO.Value != 0; break; }
|
||||
CountStructures(out int turrets, out int fabs);
|
||||
byte phase = CyclePhase.Calm;
|
||||
if (SystemAPI.TryGetSingleton<CycleState>(out var cyc)) phase = cyc.Phase;
|
||||
byte objState = ExpeditionObjectiveState.Idle;
|
||||
if (SystemAPI.TryGetSingleton<ExpeditionObjective>(out var obj)) objState = obj.State;
|
||||
bool onExp = playerPos.x > ExpeditionRegionXMin;
|
||||
|
||||
// ---- per-step entry init (baselines) ----
|
||||
if (!_stepInit)
|
||||
{
|
||||
_stepElapsed = 0f; _moveAccum = 0f; _sawSiege = false; _wasOnExpedition = false;
|
||||
_lastPos = playerPos;
|
||||
_stepInit = true;
|
||||
}
|
||||
|
||||
// ---- advance (FROZEN while the pause overlay is open, so the timed beats — Welcome/Fabricator/
|
||||
// Defend/Done — aren't silently lost behind the pause dim that sits above this overlay) ----
|
||||
if (!PauseMenuController.Open)
|
||||
{
|
||||
_stepElapsed += dt;
|
||||
if (_step == OnboardingStepMath.Move) _moveAccum += math.distance(playerPos, _lastPos);
|
||||
if (_step == OnboardingStepMath.Defend && phase == CyclePhase.Siege) _sawSiege = true;
|
||||
if (_step == OnboardingStepMath.Return && onExp) _wasOnExpedition = true; // D3: latch "was on expedition" so a start-at-base Return doesn't instantly satisfy
|
||||
|
||||
|
||||
var snap = new OnboardingStepMath.Snapshot
|
||||
{
|
||||
StepElapsed = _stepElapsed,
|
||||
MoveDistance = _moveAccum,
|
||||
LocalReady = localReady,
|
||||
Lifecycle = lifecycle,
|
||||
TurretCount = turrets,
|
||||
FabricatorCount = fabs,
|
||||
OnExpedition = onExp,
|
||||
ObjectiveState = objState,
|
||||
SawSiege = _sawSiege,
|
||||
WasOnExpedition = _wasOnExpedition,
|
||||
Phase = phase,
|
||||
};
|
||||
|
||||
// The two pure-message beats can be dismissed with any input EXCEPT Esc (Esc opens Pause; see
|
||||
// AnyInputPressed) so following the "Esc → Pause → How to Play" hint doesn't self-skip the framing.
|
||||
bool skip = (_step == OnboardingStepMath.Welcome && ConfirmPressed()) || (_step == OnboardingStepMath.Done && AnyInputPressed()); // D5: Welcome dismisses ONLY on an explicit confirm (not WASD) so the win-condition strip is actually read
|
||||
if (skip || OnboardingStepMath.IsSatisfied(_step, snap))
|
||||
{
|
||||
_mask |= (1 << _step);
|
||||
Persist(_mask);
|
||||
_step = OnboardingStepMath.FirstIncomplete(_mask); // auto-suppressed steps cascade one/frame
|
||||
_stepInit = false;
|
||||
if (OnboardingStepMath.AllComplete(_mask))
|
||||
{
|
||||
OnboardingState.Active = false;
|
||||
root.style.display = DisplayStyle.None;
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
_lastPos = playerPos;
|
||||
|
||||
// ---- show the current step ----
|
||||
OnboardingState.Active = true;
|
||||
OnboardingState.SuppressLocationLine = OnboardingStepMath.IsEarlyStep(_step); // D4: blank the HUD line only for the early base-framing steps
|
||||
root.style.display = DisplayStyle.Flex;
|
||||
bool gamepad = AimPresentation.Scheme == InputSchemeId.Gamepad;
|
||||
_prompt.text = OnboardingStepMath.Prompt(_step, gamepad);
|
||||
UpdatePointer(_step, playerPos);
|
||||
}
|
||||
|
||||
// ---- state gathering helpers ----
|
||||
|
||||
|
||||
|
||||
void CountStructures(out int turrets, out int fabs)
|
||||
{
|
||||
turrets = 0; fabs = 0;
|
||||
foreach (var ps in SystemAPI.Query<RefRO<PlacedStructure>>())
|
||||
{
|
||||
byte t = ps.ValueRO.Type;
|
||||
if (t == StructureType.Turret) turrets++;
|
||||
else if (t == StructureType.Fabricator) fabs++;
|
||||
}
|
||||
}
|
||||
|
||||
void Persist(int mask)
|
||||
{
|
||||
var s = SettingsService.Current;
|
||||
s.OnboardingMask = mask;
|
||||
SettingsService.Save(s); // atomic write; ~once per completed step
|
||||
}
|
||||
|
||||
static bool AnyInputPressed()
|
||||
{
|
||||
var kb = UnityEngine.InputSystem.Keyboard.current;
|
||||
// any key dismisses a message beat — EXCEPT Esc, which is the pause key (don't self-skip the framing).
|
||||
if (kb != null && kb.anyKey.wasPressedThisFrame && !kb.escapeKey.wasPressedThisFrame) return true;
|
||||
var ms = UnityEngine.InputSystem.Mouse.current;
|
||||
if (ms != null && ms.leftButton.wasPressedThisFrame) return true;
|
||||
var gp = UnityEngine.InputSystem.Gamepad.current;
|
||||
if (gp != null && (gp.buttonSouth.wasPressedThisFrame || gp.startButton.wasPressedThisFrame)) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
// D5: an EXPLICIT confirm only (Space/Enter/click/South) — movement keys must NOT skip the Welcome strip.
|
||||
static bool ConfirmPressed()
|
||||
{
|
||||
var kb = UnityEngine.InputSystem.Keyboard.current;
|
||||
if (kb != null && (kb.spaceKey.wasPressedThisFrame || kb.enterKey.wasPressedThisFrame)) return true;
|
||||
var ms = UnityEngine.InputSystem.Mouse.current;
|
||||
if (ms != null && ms.leftButton.wasPressedThisFrame) return true;
|
||||
var gp = UnityEngine.InputSystem.Gamepad.current;
|
||||
if (gp != null && (gp.buttonSouth.wasPressedThisFrame || gp.startButton.wasPressedThisFrame)) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
|
||||
// ---- world-space pointer ----
|
||||
|
||||
bool ResolveTarget(byte kind, float3 playerPos, out float3 target)
|
||||
{
|
||||
target = default;
|
||||
if (kind == OnboardingStepMath.PointerOreNode)
|
||||
{
|
||||
float best = float.MaxValue; bool found = false;
|
||||
foreach (var lt in SystemAPI.Query<RefRO<LocalTransform>>().WithAll<ResourceNode>())
|
||||
{
|
||||
float d = math.distancesq(lt.ValueRO.Position, playerPos);
|
||||
if (d < best) { best = d; target = lt.ValueRO.Position; found = true; }
|
||||
}
|
||||
return found;
|
||||
}
|
||||
// Step 11: the walk-in ExpeditionGate was RETIRED (the ready-check launches runs), so gate pointers
|
||||
// have no world target — hide the arrow; the step text still teaches. Step 14's HUD pass re-points
|
||||
// onboarding at the READY panel instead.
|
||||
return false;
|
||||
}
|
||||
|
||||
void UpdatePointer(byte step, float3 playerPos)
|
||||
{
|
||||
byte kind = OnboardingStepMath.PointerKind(step);
|
||||
var cam = Camera.main;
|
||||
var root = _doc.rootVisualElement;
|
||||
if (kind == OnboardingStepMath.PointerNone || cam == null || !ResolveTarget(kind, playerPos, out float3 target))
|
||||
{ _pointer.style.display = DisplayStyle.None; return; }
|
||||
|
||||
float pw = root.layout.width, ph = root.layout.height;
|
||||
if (pw <= 1f || ph <= 1f) { _pointer.style.display = DisplayStyle.None; return; }
|
||||
|
||||
Vector3 sp = cam.WorldToScreenPoint((Vector3)target);
|
||||
bool behind = sp.z < 0f;
|
||||
float px = (sp.x / Mathf.Max(1f, Screen.width)) * pw;
|
||||
float py = (1f - sp.y / Mathf.Max(1f, Screen.height)) * ph;
|
||||
if (behind) { px = pw - px; py = ph - py; }
|
||||
|
||||
const float margin = 64f;
|
||||
bool off = behind || px < margin || px > pw - margin || py < margin || py > ph - margin;
|
||||
|
||||
float cx = pw * 0.5f, cy = ph * 0.5f;
|
||||
float dx = px - cx, dy = py - cy;
|
||||
float len = Mathf.Sqrt(dx * dx + dy * dy);
|
||||
if (len < 0.001f) { dx = 1f; dy = 0f; len = 1f; }
|
||||
float ndx = dx / len, ndy = dy / len;
|
||||
|
||||
float ax, ay;
|
||||
if (off)
|
||||
{
|
||||
// intersect the center→target ray with the margin rectangle (edge arrow)
|
||||
float tx = (ndx > 0 ? (pw - margin - cx) : (margin - cx)) / (Mathf.Abs(ndx) < 1e-4f ? (ndx < 0 ? -1e-4f : 1e-4f) : ndx);
|
||||
float ty = (ndy > 0 ? (ph - margin - cy) : (margin - cy)) / (Mathf.Abs(ndy) < 1e-4f ? (ndy < 0 ? -1e-4f : 1e-4f) : ndy);
|
||||
float tt = Mathf.Min(Mathf.Abs(tx), Mathf.Abs(ty));
|
||||
ax = cx + ndx * tt; ay = cy + ndy * tt;
|
||||
}
|
||||
else { ax = px; ay = py - 44f; } // float just above the on-screen target
|
||||
|
||||
float angle = Mathf.Atan2(dy, dx) * Mathf.Rad2Deg; // "▶" art points +x at 0°
|
||||
_pointer.style.left = ax - 15f;
|
||||
_pointer.style.top = ay - 18f;
|
||||
_pointer.style.rotate = new StyleRotate(new Rotate(new Angle(angle)));
|
||||
_pointer.style.display = DisplayStyle.Flex;
|
||||
}
|
||||
|
||||
// ---- UITK construction ----
|
||||
|
||||
void BuildTree(VisualElement root)
|
||||
{
|
||||
root.style.position = Position.Absolute;
|
||||
root.style.left = 0; root.style.right = 0; root.style.top = 0; root.style.bottom = 0;
|
||||
root.pickingMode = PickingMode.Ignore; // never eat world clicks
|
||||
|
||||
var panel = new VisualElement();
|
||||
panel.style.position = Position.Absolute;
|
||||
panel.style.bottom = 210; panel.style.left = 0; panel.style.right = 0;
|
||||
panel.style.flexDirection = FlexDirection.Row;
|
||||
panel.style.justifyContent = Justify.Center;
|
||||
panel.style.alignItems = Align.Center;
|
||||
panel.pickingMode = PickingMode.Ignore;
|
||||
|
||||
var chip = new VisualElement();
|
||||
chip.style.backgroundColor = new Color(0.05f, 0.07f, 0.10f, 0.92f);
|
||||
chip.style.paddingLeft = 22; chip.style.paddingRight = 22;
|
||||
chip.style.paddingTop = 10; chip.style.paddingBottom = 10;
|
||||
chip.style.maxWidth = 920;
|
||||
chip.pickingMode = PickingMode.Ignore;
|
||||
MenuUi.Round(chip, 8);
|
||||
MenuUi.Border(chip, new Color(MenuUi.Accent.r, MenuUi.Accent.g, MenuUi.Accent.b, 0.55f), 1);
|
||||
|
||||
_prompt = new Label(string.Empty);
|
||||
_prompt.style.color = MenuUi.TextCol;
|
||||
_prompt.style.fontSize = 18;
|
||||
_prompt.style.unityFontStyleAndWeight = FontStyle.Bold;
|
||||
_prompt.style.unityTextAlign = TextAnchor.MiddleCenter;
|
||||
_prompt.style.whiteSpace = WhiteSpace.Normal;
|
||||
var theme = HudTheme.Get();
|
||||
if (theme != null) theme.ApplyBody(_prompt.style);
|
||||
chip.Add(_prompt);
|
||||
panel.Add(chip);
|
||||
root.Add(panel);
|
||||
|
||||
_pointer = new Label("▶"); // ▶ right-pointing triangle (rotated toward the target)
|
||||
_pointer.style.position = Position.Absolute;
|
||||
_pointer.style.fontSize = 30;
|
||||
_pointer.style.color = MenuUi.Accent;
|
||||
_pointer.style.unityFontStyleAndWeight = FontStyle.Bold;
|
||||
_pointer.pickingMode = PickingMode.Ignore;
|
||||
_pointer.style.display = DisplayStyle.None;
|
||||
root.Add(_pointer);
|
||||
|
||||
root.style.display = DisplayStyle.None;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,2 +0,0 @@
|
||||
fileFormatVersion: 2
|
||||
guid: b4828f5a68386fa4da379dcddbf629de
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,7 +16,7 @@ namespace ProjectM.Client
|
||||
/// </summary>
|
||||
public static class HowToPlayPanel
|
||||
{
|
||||
static readonly string[] Tabs = { "Controls", "The Loop", "Build & Economy", "Threats", "Win / Lose" };
|
||||
static readonly string[] Tabs = { "Controls", "The Loop", "Build & Economy", "Threats" };
|
||||
|
||||
public static VisualElement Build(Action onClose)
|
||||
{
|
||||
@@ -85,35 +85,24 @@ namespace ProjectM.Client
|
||||
Body(c, "Pause — Esc");
|
||||
break;
|
||||
case 1: // The Loop
|
||||
Head(c, "THE LOOP — expedition RUNS are how you win");
|
||||
Body(c, "1. BASE — pick your CLASS + buy PREP buffs (this run only); build Turrets + a Fabricator; spend Aether on PERMANENT class upgrades.");
|
||||
Head(c, "THE LOOP — descend, fight, bank");
|
||||
Body(c, "1. BASE — pick your suit-frame, buy PREP buffs (this run only), spend Aether on PERMANENT upgrades.");
|
||||
Body(c, "2. READY UP [T] — when everyone is ready, the party launches into a multi-room run together.");
|
||||
Body(c, "3. RUN — clear each room, pick 1 of 3 BOONS (this run only), choose your path on the map, mine the crystals.");
|
||||
Body(c, "4. BOSS — fell it and the party returns with the haul: +1 on the Engine meter. A wipe banks nothing.");
|
||||
Body(c, "5. SIEGE — a completed run provokes retaliation at base. Defend the Core!");
|
||||
Body(c, "6. WIN — fill the Engine meter, then hold the final siege.");
|
||||
Body(c, "3. RUN — clear each room, pick 1 of 3 BOONS (this run only), choose your path, mine the crystals.");
|
||||
Body(c, "4. BOSS — fell it and the party returns with the haul. A wipe banks nothing but the depth record.");
|
||||
break;
|
||||
case 2: // Build & Economy
|
||||
Head(c, "RESOURCES & BUILDING");
|
||||
Body(c, "Ore — main currency. Attack the glowing crystals INSIDE runs and haul them home (they're scarce — spend well).");
|
||||
Body(c, "Turret (40 Ore) — auto-fires at enemies. Needs Charge as ammo.");
|
||||
Body(c, "Fabricator (30 Ore) — converts Ore → Charge so turrets keep firing.");
|
||||
Body(c, "Wall (Biomass) — a cheap barrier that blocks enemies.");
|
||||
Body(c, "Aether — rare; fuels PERMANENT class upgrades at the base between runs.");
|
||||
Body(c, "Aether — rare; fuels PERMANENT upgrades at the base between runs.");
|
||||
Body(c, "Open Build with Tab (Y), pick a piece, click a green tile to place it.");
|
||||
break;
|
||||
case 3: // Threats
|
||||
default: // Threats
|
||||
Head(c, "THREATS");
|
||||
Body(c, "Husks assault the base during a Siege — keep them off the Engine Core.");
|
||||
Body(c, "A Husk that reaches the Core drains it. Lose the Core in the final siege and the run ends.");
|
||||
Body(c, "Runs escalate room by room — elites guard the deep paths, a boss guards the end.");
|
||||
Body(c, "Enemy variety scales the deeper you push.");
|
||||
break;
|
||||
default: // Win / Lose
|
||||
Head(c, "WIN / LOSE");
|
||||
Body(c, "WIN — clear expeditions to fill the Engine meter, then hold the final siege.");
|
||||
Body(c, "LOSE — the Engine Core falls during the final siege.");
|
||||
Body(c, "A Core breach mid-run is only a setback: resources lost, the Core recovers in Calm.");
|
||||
Body(c, "What the light doesn't hold, the dark takes back.");
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -129,17 +129,11 @@ namespace ProjectM.Client
|
||||
static void StagePendingSave(World server)
|
||||
{
|
||||
var data = SaveService.Load();
|
||||
// A terminal (won/lost) save Continues as a FRESH campaign — base+meta kept, outcome/meter/core reset
|
||||
// (else the dead outcome banner re-latches from the first snapshot).
|
||||
SaveService.RollTerminalCampaignForward(data);
|
||||
|
||||
if (data == null) return;
|
||||
var em = server.EntityManager;
|
||||
var e = em.CreateEntity();
|
||||
// v5->v6 migration (operator-approved): an old save's Charge counted boss-cleared runs (DR-042), so a
|
||||
// missing RunsCompleted floors to it — the HUD never shows "Charge 3/4" beside "Runs completed: 0".
|
||||
int runsCompleted = data.RunsCompleted > data.GoalCharge ? data.RunsCompleted : data.GoalCharge;
|
||||
em.AddComponentData(e, new PendingSave { GoalCharge = data.GoalCharge, GoalTarget = data.GoalTarget, CoreCurrent = data.CoreCurrent, RunOutcome = (byte)data.RunOutcome, RunsCompleted = runsCompleted, MaxDepthReached = data.MaxDepthReached, HasData = 1 });
|
||||
em.AddComponentData(e, new PendingSave { RunsCompleted = data.RunsCompleted, MaxDepthReached = data.MaxDepthReached, HasData = 1 });
|
||||
// v6: stage the meta tiers UNCONDITIONALLY (empty OK — the Bursted spawn system GetBuffers it in the
|
||||
// HasData block; a conditional buffer would throw on any v<=5 Continue). Rows verbatim, no clamping.
|
||||
var mbuf = em.AddBuffer<PendingMetaRow>(e);
|
||||
@@ -159,10 +153,6 @@ namespace ProjectM.Client
|
||||
var sbuf = em.AddBuffer<PendingStructure>(se);
|
||||
foreach (var s in data.Structures)
|
||||
sbuf.Add(SaveApply.ToPending(s)); // EB-1: pure mapping (unit-tested, incl. the wounded HP)
|
||||
var iobuf = em.AddBuffer<PendingStructureIo>(se);
|
||||
if (data.StructureIo != null)
|
||||
foreach (var io in data.StructureIo)
|
||||
iobuf.Add(new PendingStructureIo { StructureIndex = io.StructureIndex, Slot = io.Slot, ResourceId = io.ResourceId, Count = io.Count });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -175,9 +165,6 @@ namespace ProjectM.Client
|
||||
using var q = em.CreateEntityQuery(ComponentType.ReadOnly<ResourceLedger>());
|
||||
if (q.IsEmptyIgnoreFilter) return;
|
||||
var dir = q.GetSingletonEntity();
|
||||
var goal = em.HasComponent<GoalProgress>(dir) ? em.GetComponentData<GoalProgress>(dir) : default;
|
||||
var core = em.HasComponent<CoreIntegrity>(dir) ? em.GetComponentData<CoreIntegrity>(dir) : default; // END-1
|
||||
var outcome = em.HasComponent<RunOutcome>(dir) ? em.GetComponentData<RunOutcome>(dir) : default; // END-2
|
||||
|
||||
var buffer = em.GetBuffer<StorageEntry>(dir, true);
|
||||
var rows = new LedgerRow[buffer.Length];
|
||||
@@ -195,21 +182,16 @@ namespace ProjectM.Client
|
||||
// v6: the permanent-meta slice via the ONE shared collector — omitting it HERE (the most common
|
||||
// exit path) would silently WIPE all meta progression on quit (the meta review's top blocker).
|
||||
MetaSaveScan.Collect(em, dir, out var metaRows, out var runsCompleted, out var maxDepth);
|
||||
SaveStructureScan.Collect(em, nowTick, out var structures, out var structureIo);
|
||||
SaveStructureScan.Collect(em, nowTick, out var structures);
|
||||
|
||||
SaveService.Save(new SaveData
|
||||
{
|
||||
GoalCharge = goal.Charge,
|
||||
GoalTarget = goal.Target,
|
||||
CoreCurrent = core.Current,
|
||||
RunsCompleted = runsCompleted,
|
||||
MaxDepthReached = maxDepth,
|
||||
MetaUpgrades = metaRows,
|
||||
RunOutcome = outcome.Value,
|
||||
|
||||
Ledger = rows,
|
||||
Structures = structures,
|
||||
StructureIo = structureIo,
|
||||
SavedAtMs = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(),
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user