Polishes
This commit is contained in:
+42
-28
@@ -1,23 +1,25 @@
|
||||
# the name by which the project can be referenced within Serena
|
||||
# the name by which the project can be referenced within Serena/when chatting with the LLM.
|
||||
project_name: "Project-M"
|
||||
|
||||
|
||||
# list of languages for which language servers are started; choose from:
|
||||
# al angular ansible bash clojure
|
||||
# cpp cpp_ccls crystal csharp csharp_omnisharp
|
||||
# dart elixir elm erlang fortran
|
||||
# fsharp go groovy haskell haxe
|
||||
# hlsl html java json julia
|
||||
# kotlin lean4 lua luau markdown
|
||||
# list of languages for which language servers are started (LSP backend only); choose from:
|
||||
# ada al angular ansible bash
|
||||
# bsl clojure cpp cpp_ccls crystal
|
||||
# csharp csharp_omnisharp cue dart elixir
|
||||
# elm erlang fortran fsharp gdscript
|
||||
# go groovy haskell haxe hlsl
|
||||
# html java json julia kotlin
|
||||
# latex lean4 lua luau markdown
|
||||
# matlab msl nix ocaml pascal
|
||||
# perl php php_phpactor powershell python
|
||||
# python_jedi python_ty r rego ruby
|
||||
# ruby_solargraph rust scala scss solidity
|
||||
# svelte swift systemverilog terraform toml
|
||||
# typescript typescript_vts vue yaml zig
|
||||
# (This list may be outdated. For the current list, see values of Language enum here:
|
||||
# https://github.com/oraios/serena/blob/main/src/solidlsp/ls_config.py
|
||||
# For some languages, there are alternative language servers, e.g. csharp_omnisharp, ruby_solargraph.)
|
||||
# perl php php_phpactor php_phpantom powershell
|
||||
# python python_jedi python_pyrefly python_ty r
|
||||
# rego ruby ruby_solargraph rust scala
|
||||
# scss solidity svelte swift systemverilog
|
||||
# terraform toml typescript typescript_vts vue
|
||||
# yaml zig
|
||||
# (This list may be outdated; generated with scripts/print_language_list.py;
|
||||
# For the current list, see values of Language enum here:
|
||||
# https://github.com/oraios/serena/blob/main/src/solidlsp/ls_config.py)
|
||||
# For some languages, there are alternative language servers, e.g. csharp_omnisharp, ruby_solargraph.)
|
||||
# Note:
|
||||
# - For C, use cpp
|
||||
# - For JavaScript, use typescript
|
||||
@@ -59,17 +61,6 @@ ignore_all_files_in_gitignore: true
|
||||
# See https://oraios.github.io/serena/02-usage/050_configuration.html#language-server-specific-settings
|
||||
ls_specific_settings: {}
|
||||
|
||||
# list of additional workspace folder paths for cross-package reference support (e.g. in monorepos).
|
||||
# Paths can be absolute or relative to the project root.
|
||||
# Each folder is registered as an LSP workspace folder, enabling language servers to discover
|
||||
# symbols and references across package boundaries.
|
||||
# Currently supported for: TypeScript.
|
||||
# Example:
|
||||
# additional_workspace_folders:
|
||||
# - ../sibling-package
|
||||
# - ../shared-lib
|
||||
additional_workspace_folders: []
|
||||
|
||||
# list of additional paths to ignore in this project.
|
||||
# Same syntax as gitignore, so you can use * and **.
|
||||
# Note: global ignored_paths from serena_config.yml are also applied additively.
|
||||
@@ -144,3 +135,26 @@ activation_command:
|
||||
# maximum time in seconds to wait for activation_command to complete before killing it (default 180s).
|
||||
# must be a positive number.
|
||||
activation_command_timeout: 180.0
|
||||
|
||||
# list of additional workspace folder paths for cross-package reference support.
|
||||
# Paths can be absolute or relative to the project root.
|
||||
# Each folder is registered as an LSP workspace folder, enabling language servers to discover
|
||||
# symbols and references across package boundaries, but these folders are not indexed by Serena,
|
||||
# i.e. the respective symbols will not be found using Serena's symbol search tools.
|
||||
# Example:
|
||||
# additional_workspace_folders:
|
||||
# - ../sibling-package
|
||||
# - ../shared-lib
|
||||
ls_additional_workspace_folders: []
|
||||
|
||||
# list of workspace folder paths (LSP backend only).
|
||||
# These folders will be used to build up Serena's symbol index.
|
||||
# Paths must be within the project root and should thus be relative to the project root.
|
||||
# Furthermore, the paths should not be filtered by ignore settings.
|
||||
# Default setting: The entire project root folder (".") is considered.
|
||||
# In (large) monorepos, this can be used to index only subfolders of the project root, e.g.
|
||||
# ls_workspace_folders:
|
||||
# - "./subproject1"
|
||||
# - "./subproject2"
|
||||
ls_workspace_folders:
|
||||
- .
|
||||
|
||||
@@ -74,7 +74,6 @@ MonoBehaviour:
|
||||
m_MixedLightingSupported: 1
|
||||
m_SupportsLightCookies: 1
|
||||
m_SupportsLightLayers: 1
|
||||
m_DebugLevel: 0
|
||||
m_StoreActionsOptimization: 0
|
||||
m_UseAdaptivePerformance: 1
|
||||
m_ColorGradingMode: 1
|
||||
|
||||
@@ -73,7 +73,22 @@ MonoBehaviour:
|
||||
- rid: 6670095651985424593
|
||||
- rid: 6670095651985424594
|
||||
m_RuntimeSettings:
|
||||
m_List: []
|
||||
m_List:
|
||||
- rid: 6852985685364965378
|
||||
- rid: 6852985685364965379
|
||||
- rid: 6852985685364965380
|
||||
- rid: 6852985685364965381
|
||||
- rid: 6852985685364965384
|
||||
- rid: 6852985685364965392
|
||||
- rid: 6852985685364965394
|
||||
- rid: 8712630790384254976
|
||||
- rid: 6670095613664165896
|
||||
- rid: 6670095613664165897
|
||||
- rid: 6670095613664165898
|
||||
- rid: 6670095613664165902
|
||||
- rid: 6670095613664165904
|
||||
- rid: 6670095651985424593
|
||||
- rid: 6670095651985424594
|
||||
m_AssetVersion: 10
|
||||
m_ObsoleteDefaultVolumeProfile: {fileID: 0}
|
||||
m_RenderingLayerNames:
|
||||
|
||||
@@ -23,6 +23,7 @@ namespace ProjectM.Authoring
|
||||
[Min(1)] public int PeriodTicks = 30;
|
||||
[Tooltip("EB-2: 1 = ledger-fed (consume the input from the shared ledger; no conveyor). 0 = M7 MachineInput chain.")]
|
||||
public byte InputFromLedger = 1;
|
||||
[Min(1f)] public float MaxHp = 120f;
|
||||
|
||||
private class FabricatorBaker : Baker<FabricatorAuthoring>
|
||||
{
|
||||
@@ -46,6 +47,14 @@ namespace ProjectM.Authoring
|
||||
InputFromLedger = authoring.InputFromLedger,
|
||||
});
|
||||
AddBuffer<MachineInput>(entity);
|
||||
// EB-1 parity (DR-032): machines can die. Without Health this baker left the Fabricator OUT of
|
||||
// the EnemyAISystem fortress-aggro snapshot entirely (its query keys on Health) — an invulnerable
|
||||
// machine, diverging from DR-032. Now that it IS targetable, the DamageEvent buffer must exist
|
||||
// (an AI strike appends into it; absent = ECB-playback throw) + Destructible lets
|
||||
// HealthApplyDamageSystem destroy it at 0 (occupancy auto-frees).
|
||||
AddComponent(entity, new Health { Current = authoring.MaxHp, Max = authoring.MaxHp });
|
||||
AddBuffer<DamageEvent>(entity);
|
||||
AddComponent<Destructible>(entity);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -45,7 +45,7 @@ namespace ProjectM.Client
|
||||
byte _lastSelected; // skip placing on the frame a palette click changes the selection
|
||||
|
||||
// (Step 11: UpgradeAbility/s_PendingUpgrades RETIRED with the AbilityUpgradeRequest wire — in-run boons +
|
||||
// the base meta-shop replaced the Aether damage upgrade.)des++;
|
||||
// the base meta-shop replaced the Aether damage upgrade.)
|
||||
|
||||
#if UNITY_EDITOR
|
||||
struct PendingBuild { public byte Type; public int CellX; public int CellZ; public byte Direction; }
|
||||
@@ -177,7 +177,7 @@ namespace ProjectM.Client
|
||||
byte reason = BuildPreviewMath.Evaluate(anchor, targetCell, occupied, LedgerOre(), cost);
|
||||
bool valid = reason == BuildPreviewMath.Valid;
|
||||
|
||||
ShowGhost(BaseGridMath.CellToWorld(anchor, targetCell), anchor.CellSize, valid, sel);;
|
||||
ShowGhost(BaseGridMath.CellToWorld(anchor, targetCell), anchor.CellSize, valid, sel);
|
||||
|
||||
// Place on a left-click (valid, not the selecting click).
|
||||
if (valid && !justSelected && mouse.leftButton.wasPressedThisFrame)
|
||||
|
||||
@@ -36,6 +36,8 @@ namespace ProjectM.Client
|
||||
if (!NetworkEndpoint.TryParse(address.ToString(), port, out var endpoint, NetworkFamily.Ipv4))
|
||||
{
|
||||
Debug.LogError($"[ClientConnectionControlSystem] Invalid join address '{address}'.");
|
||||
SessionEnd.Reason = "INVALID JOIN ADDRESS — enter a valid IPv4 like 192.168.1.20.";
|
||||
SessionEnd.Abort = true; // ConnectionWatchdogSystem tears down to the menu at a frame boundary
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
using Unity.Entities;
|
||||
using Unity.NetCode;
|
||||
using UnityEngine;
|
||||
|
||||
namespace ProjectM.Client
|
||||
{
|
||||
/// <summary>
|
||||
/// Why the last session ended, surfaced once on the main menu (client-local). Set by
|
||||
/// <see cref="ConnectionWatchdogSystem"/> (host vanished / join never connected) or by the connect path
|
||||
/// (unparseable address); <see cref="MainMenuController"/> shows + clears it. Statics reset on play-enter
|
||||
/// (fast-enter-playmode safe, the VFXConfig precedent).
|
||||
/// </summary>
|
||||
public static class SessionEnd
|
||||
{
|
||||
public static string Reason;
|
||||
|
||||
/// <summary>Force an immediate teardown-to-menu (e.g. a bad join address) without waiting for the
|
||||
/// watchdog's connect timeout. Set <see cref="Reason"/> first.</summary>
|
||||
public static bool Abort;
|
||||
|
||||
[RuntimeInitializeOnLoadMethod(RuntimeInitializeLoadType.SubsystemRegistration)]
|
||||
static void Reset() { Reason = null; Abort = false; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Hands-off co-op fail-safe: watches this client world's connection and returns the player to the main
|
||||
/// menu (reason on <see cref="SessionEnd"/>) instead of leaving them in a dead world with a live HUD.
|
||||
/// Exits: (1) the accepted connection VANISHED on a pure join client — host quit or the network died (the
|
||||
/// host's own in-proc client is excluded: its server outliving user-driven teardown is
|
||||
/// <see cref="WorldLauncher.Busy"/>-guarded); (2) a join was requested but never accepted within
|
||||
/// <see cref="JoinTimeoutSeconds"/> (wrong IP / host not hosting — covers the silent
|
||||
/// NetworkStreamRequestConnect failure modes); (3) <see cref="SessionEnd.Abort"/> was flagged. Managed
|
||||
/// observe-only client system — never writes sim state; world disposal runs on the WorldLauncher /
|
||||
/// SessionRunner frame-boundary path, never here.
|
||||
/// </summary>
|
||||
[WorldSystemFilter(WorldSystemFilterFlags.ClientSimulation)]
|
||||
public partial class ConnectionWatchdogSystem : SystemBase
|
||||
{
|
||||
const float JoinTimeoutSeconds = 12f;
|
||||
|
||||
bool _sawConnection;
|
||||
float _unconnectedElapsed;
|
||||
|
||||
protected override void OnUpdate()
|
||||
{
|
||||
if (WorldLauncher.Busy) return; // a start/teardown routine owns world lifecycle right now
|
||||
|
||||
if (SessionEnd.Abort)
|
||||
{
|
||||
SessionEnd.Abort = false;
|
||||
WorldLauncher.TeardownToMenu();
|
||||
return;
|
||||
}
|
||||
|
||||
// An ACCEPTED connection carries NetworkId (at most one per client world).
|
||||
bool accepted = SystemAPI.HasSingleton<NetworkId>();
|
||||
if (accepted)
|
||||
{
|
||||
_sawConnection = true;
|
||||
_unconnectedElapsed = 0f;
|
||||
return;
|
||||
}
|
||||
|
||||
var serverWorld = ClientServerBootstrap.ServerWorld;
|
||||
bool pureJoiner = serverWorld == null || !serverWorld.IsCreated;
|
||||
|
||||
if (_sawConnection)
|
||||
{
|
||||
_sawConnection = false;
|
||||
if (pureJoiner)
|
||||
{
|
||||
SessionEnd.Reason = "CONNECTION LOST — the host ended the session.";
|
||||
Debug.Log("[ConnectionWatchdog] " + SessionEnd.Reason); // build-log trace for headless smokes
|
||||
WorldLauncher.TeardownToMenu();
|
||||
}
|
||||
// Host/single: losing the loopback connection outside teardown is transient noise; re-arm.
|
||||
return;
|
||||
}
|
||||
|
||||
// Never accepted yet — only a real remote join gets the timeout escape hatch.
|
||||
if (!pureJoiner) return;
|
||||
_unconnectedElapsed += SystemAPI.Time.DeltaTime;
|
||||
if (_unconnectedElapsed >= JoinTimeoutSeconds)
|
||||
{
|
||||
SessionEnd.Reason = "COULD NOT REACH HOST — check the IP and that the host is hosting.";
|
||||
Debug.Log("[ConnectionWatchdog] " + SessionEnd.Reason); // build-log trace for headless smokes
|
||||
WorldLauncher.TeardownToMenu();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: ac9a33fcc24a6e945927c185a16b9e30
|
||||
@@ -9,6 +9,12 @@ namespace ProjectM.Client
|
||||
/// 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
|
||||
@@ -20,12 +26,12 @@ namespace ProjectM.Client
|
||||
// ---- 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 Mine = 2; // attack an Ore node — mining IS combat at the base (Calm = no enemies yet)
|
||||
public const byte Build = 3; // open palette + place a Turret
|
||||
public const byte Fabricator = 4; // Ore -> Charge (soft info beat)
|
||||
public const byte Gate = 5; // reach the Expedition Gate
|
||||
public const byte Clear = 6; // clear the expedition wave (the first real enemies)
|
||||
public const byte Return = 7; // walk back to base to bank +1 charge
|
||||
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;
|
||||
@@ -40,18 +46,18 @@ namespace ProjectM.Client
|
||||
// ---- 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; // the base-region gate (go to the expedition)
|
||||
public const byte PointerExpeditionGate = 3; // the expedition-region gate (return home)
|
||||
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 OreNow; // shared-ledger Ore right now
|
||||
public int OreBaseline; // ledger Ore captured when the Mine 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
|
||||
@@ -65,12 +71,14 @@ namespace ProjectM.Client
|
||||
{
|
||||
case Welcome: return s.StepElapsed >= WelcomeSeconds;
|
||||
case Move: return s.MoveDistance >= MoveThreshold;
|
||||
case Mine: return s.OreNow > s.OreBaseline;
|
||||
case Build: return s.TurretCount >= 1;
|
||||
case Fabricator: return s.FabricatorCount >= 1 || s.StepElapsed >= FabricatorSoftSeconds;
|
||||
case Gate: return s.OnExpedition || s.ObjectiveState == ExpeditionObjectiveState.Active;
|
||||
case Clear: return s.ObjectiveState == ExpeditionObjectiveState.Cleared;
|
||||
case Return: return !s.OnExpedition; // entered while on expedition; satisfied on crossing home
|
||||
case ReadyUp: return s.LocalReady || s.Lifecycle != RunLifecycle.Staging;
|
||||
case Rooms: return s.OnExpedition || s.ObjectiveState == ExpeditionObjectiveState.Active;
|
||||
case Boon: return s.ObjectiveState == ExpeditionObjectiveState.Cleared
|
||||
|| s.Lifecycle == RunLifecycle.RoomReward
|
||||
|| s.Lifecycle == RunLifecycle.RouteSelect;
|
||||
case Return: return !s.OnExpedition; // entered mid-run; satisfied on coming home
|
||||
case Defend: return s.SawSiege ? s.Phase == CyclePhase.Calm : s.StepElapsed >= DefendNoSiegeSeconds;
|
||||
case Done: return s.StepElapsed >= DoneSeconds;
|
||||
default: return true;
|
||||
@@ -82,10 +90,8 @@ namespace ProjectM.Client
|
||||
{
|
||||
switch (step)
|
||||
{
|
||||
case Mine: return PointerOreNode;
|
||||
case Gate: return PointerBaseGate;
|
||||
case Return: return PointerExpeditionGate;
|
||||
default: return PointerNone;
|
||||
case Rooms: return PointerOreNode; // in-run crystal nodes (base nodes no longer exist)
|
||||
default: return PointerNone;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -93,19 +99,18 @@ namespace ProjectM.Client
|
||||
public static string Prompt(byte step, bool gamepad)
|
||||
{
|
||||
string move = gamepad ? "Left Stick" : "WASD";
|
||||
string attack = gamepad ? "RT" : "LMB";
|
||||
string build = gamepad ? "Y" : "Tab"; // matches the existing HUD build-discovery chip glyph
|
||||
switch (step)
|
||||
{
|
||||
case Welcome: return "CLEAR EXPEDITIONS to charge the Engine — defend the Core while you do. (Esc → Pause → How to Play)";
|
||||
case Move: return move + " — Move";
|
||||
case Mine: return attack + " — Attack the glowing Ore to mine it";
|
||||
case Build: return build + " — open Build, then place a Turret by your Core";
|
||||
case Build: return build + " — open Build, place a Turret by your Core (your 50 starting Ore covers it)";
|
||||
case Fabricator: return "Build a Fabricator — turrets need Charge (Ore → ammo)";
|
||||
case Gate: return "Press T to READY UP — when everyone is ready, the run launches";
|
||||
case Clear: return "Clear each room — pick a boon, choose your path, reach the boss";
|
||||
case Return: return "Fell the boss — you return home and the Engine charges (+1)";;
|
||||
case Defend: return "Defend the Core! — hold the line through the siege";
|
||||
case ReadyUp: return "Press T (or click READY UP) — when everyone is ready, the party launches";
|
||||
case Rooms: return "Fight through the rooms — attack the glowing crystals to haul resources home";
|
||||
case Boon: return "Clear the room — pick 1 of 3 BOONS, then choose your path on the map";
|
||||
case Return: return "Reach the boss and fell it — the haul comes home and the Engine charges (+1)";
|
||||
case Defend: return "Defend the Core! — a completed run provokes a retaliation siege";
|
||||
case Done: return "You've got it. Clear expeditions to fill the Engine and win.";
|
||||
default: return "";
|
||||
}
|
||||
|
||||
@@ -41,7 +41,6 @@ namespace ProjectM.Client
|
||||
float _stepElapsed;
|
||||
float _moveAccum;
|
||||
float3 _lastPos;
|
||||
int _oreBaseline;
|
||||
bool _sawSiege;
|
||||
|
||||
protected override void OnStartRunning()
|
||||
@@ -92,7 +91,11 @@ namespace ProjectM.Client
|
||||
}
|
||||
|
||||
// ---- remaining observable state ----
|
||||
int ore = LedgerOre();
|
||||
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;
|
||||
@@ -104,7 +107,7 @@ namespace ProjectM.Client
|
||||
if (!_stepInit)
|
||||
{
|
||||
_stepElapsed = 0f; _moveAccum = 0f; _sawSiege = false;
|
||||
_oreBaseline = ore; _lastPos = playerPos;
|
||||
_lastPos = playerPos;
|
||||
_stepInit = true;
|
||||
}
|
||||
|
||||
@@ -120,8 +123,8 @@ namespace ProjectM.Client
|
||||
{
|
||||
StepElapsed = _stepElapsed,
|
||||
MoveDistance = _moveAccum,
|
||||
OreNow = ore,
|
||||
OreBaseline = _oreBaseline,
|
||||
LocalReady = localReady,
|
||||
Lifecycle = lifecycle,
|
||||
TurretCount = turrets,
|
||||
FabricatorCount = fabs,
|
||||
OnExpedition = onExp,
|
||||
@@ -159,16 +162,7 @@ namespace ProjectM.Client
|
||||
|
||||
// ---- state gathering helpers ----
|
||||
|
||||
int LedgerOre()
|
||||
{
|
||||
if (SystemAPI.TryGetSingletonEntity<ResourceLedger>(out var e))
|
||||
{
|
||||
var buf = SystemAPI.GetBuffer<StorageEntry>(e);
|
||||
for (int i = 0; i < buf.Length; i++)
|
||||
if (buf[i].ItemId == ResourceId.Ore) return buf[i].Count;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
|
||||
void CountStructures(out int turrets, out int fabs)
|
||||
{
|
||||
|
||||
@@ -27,6 +27,12 @@ namespace ProjectM.Client
|
||||
|
||||
byte _lastPhase;
|
||||
bool _phaseInit;
|
||||
AudioClip _stingCoreHit;
|
||||
int _lastCore = -1;
|
||||
float _coreStingCooldown;
|
||||
AudioClip _stingBeep, _stingRoar;
|
||||
int _lastCountdownSec = -1;
|
||||
bool _bossRoared;
|
||||
|
||||
const float AmbientBaseVolume = 0.10f; // low bed; Defend eases up to ~1.7x
|
||||
|
||||
@@ -36,6 +42,9 @@ namespace ProjectM.Client
|
||||
_stingExpedition = MakeSting(520f, 880f, 0.45f, 0.30f); // airy rising "deploy"
|
||||
_stingDefend = MakeSting(300f, 140f, 0.55f, 0.42f); // tense falling "wave incoming"
|
||||
_stingBuild = MakeSting(440f, 660f, 0.40f, 0.26f); // soft confirm
|
||||
_stingCoreHit = MakeSting(240f, 70f, 0.30f, 0.55f); // harsh falling "Core hit" alarm
|
||||
_stingBeep = MakeSting(880f, 880f, 0.09f, 0.30f); // countdown tick
|
||||
_stingRoar = MakeSting(90f, 38f, 0.90f, 0.60f); // boss-arrival roar (low falling growl)
|
||||
}
|
||||
|
||||
protected override void OnStartRunning()
|
||||
@@ -76,6 +85,50 @@ namespace ProjectM.Client
|
||||
// Ease the drone intensity toward the phase target (tenser during Defend).
|
||||
float target = phase == CyclePhase.Siege ? AmbientBaseVolume * 1.7f : AmbientBaseVolume;
|
||||
_ambient.volume = Mathf.MoveTowards(_ambient.volume, target * GameVolume.Music, SystemAPI.Time.DeltaTime * 0.25f);
|
||||
|
||||
// Core-under-attack alarm: a falling sting on each CoreIntegrity drop (rate-limited) so a base
|
||||
// breach is AUDIBLE even when the fight has the player's eyes elsewhere.
|
||||
_coreStingCooldown -= SystemAPI.Time.DeltaTime;
|
||||
if (SystemAPI.TryGetSingleton<CoreIntegrity>(out var core))
|
||||
{
|
||||
if (_lastCore >= 0 && core.Current < _lastCore && _coreStingCooldown <= 0f)
|
||||
{
|
||||
_ambient.PlayOneShot(_stingCoreHit, 0.8f * GameVolume.Sfx);
|
||||
_coreStingCooldown = 0.7f;
|
||||
}
|
||||
_lastCore = core.Current;
|
||||
}
|
||||
|
||||
// Launch countdown beeps (3-2-1) + the boss-arrival roar — replicated-state observations only.
|
||||
if (SystemAPI.TryGetSingleton<RunInfo>(out var runAudio))
|
||||
{
|
||||
int sec = -1;
|
||||
if (runAudio.Lifecycle == RunLifecycle.Launching && runAudio.LaunchTick != 0
|
||||
&& SystemAPI.TryGetSingleton<NetworkTime>(out var antime) && antime.ServerTick.IsValid)
|
||||
{
|
||||
int tl = new NetworkTick(runAudio.LaunchTick).TicksSince(antime.ServerTick);
|
||||
if (tl > 0) sec = tl / 60 + 1;
|
||||
}
|
||||
if (sec > 0 && sec != _lastCountdownSec && sec <= 3)
|
||||
_ambient.PlayOneShot(_stingBeep, 0.5f * GameVolume.Sfx);
|
||||
_lastCountdownSec = sec;
|
||||
|
||||
bool inBossRoom = runAudio.Lifecycle == RunLifecycle.InRoom
|
||||
&& runAudio.CurrentRoomType == RoomTypeId.Boss;
|
||||
if (!inBossRoom)
|
||||
{
|
||||
_bossRoared = false; // re-arm for the next boss room
|
||||
}
|
||||
else if (!_bossRoared)
|
||||
{
|
||||
foreach (var _ in SystemAPI.Query<RefRO<Health>>().WithAll<EnemyTag>())
|
||||
{
|
||||
_ambient.PlayOneShot(_stingRoar, 0.9f * GameVolume.Sfx);
|
||||
_bossRoared = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void PlaySting(byte phase)
|
||||
|
||||
@@ -62,6 +62,8 @@ namespace ProjectM.Client
|
||||
bool _slashActive;
|
||||
float _slashRange, _slashHalf; // live cone geometry re-sampled each frame for the per-frame sweep rebuild
|
||||
int _slashSweepSign = 1; // alternate sweep direction per combo step (reads as alternating strikes)
|
||||
uint _lastConeFireTick; // own latch — the muzzle block owns _lastLocalFireTick and runs first
|
||||
bool _coneTickInit;
|
||||
Material _dangerMat;
|
||||
readonly Dictionary<Entity, GameObject> _dangerZones = new();
|
||||
readonly HashSet<Entity> _dangerSeen = new();
|
||||
@@ -367,6 +369,11 @@ namespace ProjectM.Client
|
||||
bool finisher = step >= comboLen;
|
||||
float slashRange = tcfg.MeleeRange > 0f ? tcfg.MeleeRange : 2.6f;
|
||||
float slashHalf = tcfg.MeleeConeHalfAngleRad > 0f ? tcfg.MeleeConeHalfAngleRad : 0.9f;
|
||||
// Slice-2 deferred reach fix: the SERVER folds per-player StatModifiers into melee range
|
||||
// (class seed +0.8, boons, meta tiers) — the arc must sweep the REAL reach, not the base.
|
||||
if (EntityManager.HasBuffer<StatModifier>(_localPlayer))
|
||||
slashRange = math.max(0f, StatMath.Apply(slashRange, StatTarget.MeleeRange,
|
||||
EntityManager.GetBuffer<StatModifier>(_localPlayer, true)));
|
||||
if (finisher) slashRange *= tcfg.MeleeFinisherMult > 0f ? tcfg.MeleeFinisherMult : 1.8f;
|
||||
// MC-4 connect-vs-whiff: client-side cone overlap over the cached enemy snapshot gives an IMMEDIATE
|
||||
// "you bit" read (the authoritative server damage spark/number arrives a few ticks later).
|
||||
@@ -397,6 +404,40 @@ namespace ProjectM.Client
|
||||
_swingTickInit = true;
|
||||
}
|
||||
|
||||
// Slice-2 deferred: the Warrior CONE ability had no client VFX (server-only cleave). Edge-detect the
|
||||
// PREDICTED AbilityCooldown (raw uint, like the dash) and reuse the slash-arc sweep with the ability's
|
||||
// own effective geometry — an aimed cone IS an arc.
|
||||
if (_localPlayer != Entity.Null
|
||||
&& EntityManager.HasComponent<AbilityCooldown>(_localPlayer)
|
||||
&& EntityManager.HasComponent<AbilityRef>(_localPlayer)
|
||||
&& EntityManager.HasComponent<EffectiveAbilityStats>(_localPlayer))
|
||||
{
|
||||
uint nextFire = EntityManager.GetComponentData<AbilityCooldown>(_localPlayer).NextFireTick;
|
||||
if (_coneTickInit && nextFire != 0 && nextFire != _lastConeFireTick
|
||||
&& SystemAPI.TryGetSingleton<AbilityDatabase>(out var coneDb) && coneDb.Value.IsCreated)
|
||||
{
|
||||
ref var coneAdb = ref coneDb.Value.Value;
|
||||
if (coneAdb.TryGetAbility(EntityManager.GetComponentData<AbilityRef>(_localPlayer).Id, out var coneDef)
|
||||
&& coneDef.Archetype == (byte)AbilityArchetype.Cone)
|
||||
{
|
||||
var ceff = EntityManager.GetComponentData<EffectiveAbilityStats>(_localPlayer);
|
||||
Vector3 cface = Vector3.forward;
|
||||
if (EntityManager.HasComponent<PlayerFacing>(_localPlayer))
|
||||
{
|
||||
var cfd = EntityManager.GetComponentData<PlayerFacing>(_localPlayer).Direction;
|
||||
if (math.lengthsq(cfd) > 1e-6f) cface = new Vector3(cfd.x, 0f, cfd.y).normalized;
|
||||
}
|
||||
float coneRange = Mathf.Max(0.1f, ceff.Range);
|
||||
float coneHalf = Mathf.Clamp(ceff.AutoTargetConeRadians, 0.01f, 3.14159f);
|
||||
TriggerSlash((Vector3)localPos, new float2(cface.x, cface.z), coneRange, coneHalf, 1, 1, false);
|
||||
PlayClip(_swingClip, (Vector3)localPos, 0.5f);
|
||||
PrototypeCameraRig.AddShake(0.06f);
|
||||
}
|
||||
}
|
||||
_lastConeFireTick = nextFire;
|
||||
_coneTickInit = true;
|
||||
}
|
||||
|
||||
// Footsteps (combat feel): edge-detect local locomotion from the position delta; a soft step at a cadence.
|
||||
if (_localPlayer != Entity.Null)
|
||||
{
|
||||
@@ -820,7 +861,11 @@ namespace ProjectM.Client
|
||||
{
|
||||
int step = math.max(1, (int)mc.ValueRO.Step);
|
||||
bool finisher = step >= comboLen;
|
||||
rs.Range = finisher ? baseRange * finisherMult : baseRange;
|
||||
float rRange = baseRange; // same reach fix for teammates' arcs (their buffer is OwnerSendType.All)
|
||||
if (EntityManager.HasBuffer<StatModifier>(entity))
|
||||
rRange = math.max(0f, StatMath.Apply(baseRange, StatTarget.MeleeRange,
|
||||
EntityManager.GetBuffer<StatModifier>(entity, true)));
|
||||
rs.Range = finisher ? rRange * finisherMult : rRange;
|
||||
rs.Half = baseHalf;
|
||||
rs.SweepSign = (step % 2 == 0) ? -1 : 1;
|
||||
rs.Tint = FeelConfig.RemoteSlashColor * (finisher ? 1.5f : 1f);
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
using System.Collections.Generic;
|
||||
using ProjectM.Simulation;
|
||||
using Unity.Entities;
|
||||
using UnityEngine;
|
||||
using UnityEngine.SceneManagement;
|
||||
|
||||
namespace ProjectM.Client
|
||||
{
|
||||
/// <summary>
|
||||
/// The Engine Core's CRYSTAL answers its replicated <see cref="CoreIntegrity"/> (07-01 backlog: the mesh sat
|
||||
/// static while draining). Client-only observe-only presentation: the cosmetic <c>CoreCrystals</c> /
|
||||
/// <c>CoreMachine</c> GameObjects in Game.unity (classic URP renderers, not entities) are tinted via
|
||||
/// MaterialPropertyBlock — darker + blood-shifted as integrity falls, a white-hot flash on each hit (pairs
|
||||
/// with the AmbientAudioSystem alarm). Property-guarded per the shader-value rule (only materials exposing
|
||||
/// _BaseColor are touched); per-renderer MPBs never mutate the shared material assets (no bleed).
|
||||
/// </summary>
|
||||
[WorldSystemFilter(WorldSystemFilterFlags.ClientSimulation)]
|
||||
[UpdateInGroup(typeof(PresentationSystemGroup))]
|
||||
public partial class CoreVisualFeedbackSystem : SystemBase
|
||||
{
|
||||
static readonly int BaseColorId = Shader.PropertyToID("_BaseColor");
|
||||
static readonly string[] TargetNames = { "CoreCrystals", "CoreMachine" };
|
||||
const float FlashSeconds = 0.35f;
|
||||
|
||||
readonly List<Renderer> _renderers = new();
|
||||
readonly List<Color> _authoredColors = new();
|
||||
MaterialPropertyBlock _mpb;
|
||||
bool _resolved;
|
||||
int _lastCore = -1;
|
||||
float _flashLeft;
|
||||
|
||||
protected override void OnUpdate()
|
||||
{
|
||||
if (!SystemAPI.TryGetSingleton<CoreIntegrity>(out var core) || core.Max <= 0)
|
||||
return;
|
||||
|
||||
// One-time renderer resolve, deferred until Game.unity is actually the active scene (the frontend
|
||||
// path creates this world a frame BEFORE the scene loads — latching early would find nothing).
|
||||
if (!_resolved)
|
||||
{
|
||||
var scene = SceneManager.GetActiveScene();
|
||||
if (!scene.isLoaded || scene.name != "Game") return;
|
||||
Resolve();
|
||||
}
|
||||
if (_renderers.Count == 0) return;
|
||||
|
||||
if (_lastCore >= 0 && core.Current < _lastCore)
|
||||
_flashLeft = FlashSeconds; // hit edge -> white-hot pop (the audio alarm fires beside it)
|
||||
_lastCore = core.Current;
|
||||
_flashLeft -= SystemAPI.Time.DeltaTime;
|
||||
|
||||
float frac = Mathf.Clamp01(core.Current / (float)core.Max);
|
||||
float dim = Mathf.Lerp(0.35f, 1f, frac);
|
||||
float flash = _flashLeft > 0f ? Mathf.Clamp01(_flashLeft / FlashSeconds) : 0f;
|
||||
for (int i = 0; i < _renderers.Count; i++)
|
||||
{
|
||||
var r = _renderers[i];
|
||||
if (r == null) continue;
|
||||
var c0 = _authoredColors[i];
|
||||
// Wounded shift: keep red-ish energy, drain green/blue with integrity (reads as bleeding light).
|
||||
var wounded = new Color(
|
||||
Mathf.Min(1f, c0.r * dim + (1f - frac) * 0.25f),
|
||||
c0.g * dim * (0.45f + 0.55f * frac),
|
||||
c0.b * dim * (0.45f + 0.55f * frac),
|
||||
c0.a);
|
||||
var col = flash > 0f ? Color.Lerp(wounded, Color.white, flash) : wounded;
|
||||
r.GetPropertyBlock(_mpb);
|
||||
_mpb.SetColor(BaseColorId, col);
|
||||
r.SetPropertyBlock(_mpb);
|
||||
}
|
||||
}
|
||||
|
||||
void Resolve()
|
||||
{
|
||||
_resolved = true;
|
||||
_mpb = new MaterialPropertyBlock();
|
||||
foreach (var name in TargetNames)
|
||||
{
|
||||
var go = GameObject.Find(name);
|
||||
if (go == null) continue;
|
||||
foreach (var r in go.GetComponentsInChildren<Renderer>())
|
||||
{
|
||||
var m = r.sharedMaterial;
|
||||
if (m == null || !m.HasProperty(BaseColorId)) continue;
|
||||
_renderers.Add(r);
|
||||
_authoredColors.Add(m.GetColor(BaseColorId));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 47b2a19145125ac4e98411cab7f9b569
|
||||
@@ -72,9 +72,9 @@ namespace ProjectM.Client
|
||||
// observe-only readers of replicated state (BoonOffer via GhostOwnerIsLocal; RunInfo RouteOpt*); clicks
|
||||
// enqueue through the client send-systems' statics. Built lazily on first show.
|
||||
VisualElement _boonModal, _boonCardRow;
|
||||
VisualElement _routePanel, _routeBtnRow;
|
||||
VisualElement _routePanel;
|
||||
Label _routeTitle;
|
||||
byte _boonShownFor; // last (Option0^Option1^Option2 ^ room) signature the modal was built for
|
||||
int _boonShownFor; // last exact (Option0|Option1<<8|Option2<<16)+1 signature the modal was built for
|
||||
bool _boonModalBuilt, _routePanelBuilt;
|
||||
// Step 14 (meta shop): Staging-only permanent-upgrade shop (replicated MetaTierState + ledger Aether;
|
||||
// row clicks enqueue MetaSpendSendSystem.RequestPurchase — the server re-validates everything).
|
||||
@@ -82,6 +82,28 @@ namespace ProjectM.Client
|
||||
Label _metaShopTitle;
|
||||
bool _metaShopBuilt;
|
||||
int _metaShownFor; // last (class, tiers, aether) signature the shop rows were built for
|
||||
// Demo polish: the clickable READY panel (Staging/Launching) + the drawn branching route map
|
||||
// (RouteSelect) — the map is regenerated client-side from RunInfo.RunSeed for DISPLAY only; the
|
||||
// clickable next-layer nodes bind to the authoritative RouteOpt* bytes (never the regen).
|
||||
VisualElement _readyPanel, _readyPipRow;
|
||||
Button _readyBtn;
|
||||
Label _readyTitle;
|
||||
bool _readyPanelBuilt;
|
||||
int _readyShownFor; // (ready, total, localReady, secs, launching) rebuild signature
|
||||
VisualElement _routeMapHost; // node circles + Painter2D edges
|
||||
int _routeMapSig; // (seed, room, col, options) signature the map was drawn for
|
||||
readonly List<int> _routeVisited = new(); // client-local path trace (nodeIds), reset per RunSeed
|
||||
uint _routeVisitedSeed;
|
||||
// Demo polish round 2: boss presence bar, run-depth dots, outcome flash.
|
||||
VisualElement _bossPanel, _bossFill;
|
||||
Label _bossText;
|
||||
bool _bossBarBuilt;
|
||||
VisualElement _depthPanel;
|
||||
int _depthShownFor;
|
||||
bool _depthBuilt;
|
||||
VisualElement _outcomeFlash; // one-shot gold/red full-screen flash when the outcome banner first lands
|
||||
float _outcomeFlashLeft;
|
||||
byte _outcomeFlashedFor;
|
||||
|
||||
|
||||
readonly List<VisualElement> _pips = new();
|
||||
@@ -97,6 +119,8 @@ namespace ProjectM.Client
|
||||
|
||||
// overlays
|
||||
VisualElement _vignette, _downed;
|
||||
Label _downedText; // "RESPAWNING IN N" countdown (client-local: baked DelayTicks + death-edge latch)
|
||||
float _downedSince = -1f;
|
||||
float _prevHp, _flash;
|
||||
bool _haveHp;
|
||||
// personal inventory panel (read-only; toggled with I)
|
||||
@@ -194,25 +218,21 @@ namespace ProjectM.Client
|
||||
switch (runInfo.Lifecycle)
|
||||
{
|
||||
case RunLifecycle.Staging:
|
||||
{
|
||||
// Client counts the party's replicated ready flags (send-to-all) for the N/M readout.
|
||||
int total = 0, readyCount = 0;
|
||||
foreach (var pr in SystemAPI.Query<RefRO<PlayerReady>>().WithAll<PlayerTag>())
|
||||
{
|
||||
total++;
|
||||
if (pr.ValueRO.Value != 0) readyCount++;
|
||||
}
|
||||
_locationText.text = "READY UP [T] - " + readyCount + "/" + Mathf.Max(total, 1)
|
||||
+ " ready - launch a run to advance the Engine";
|
||||
// The READY panel (bottom-center) owns the action + N/M count; the top line frames intent.
|
||||
_locationText.text = "AT THE BASE - build defenses, buy upgrades, READY UP to launch";
|
||||
_locationText.style.color = new Color(0.55f, 0.85f, 1f);
|
||||
break;
|
||||
}
|
||||
case RunLifecycle.Launching:
|
||||
{
|
||||
uint nowTick = SystemAPI.TryGetSingleton<NetworkTime>(out var ntime) && ntime.ServerTick.IsValid
|
||||
? ntime.ServerTick.TickIndexForValidTick : 0u;
|
||||
int secs = runInfo.LaunchTick != 0 && nowTick != 0
|
||||
? Mathf.Max(0, (int)((runInfo.LaunchTick - nowTick) / 60u) + 1) : 0;
|
||||
// Wrap-safe countdown (post-impl review): the client's PREDICTED tick passes LaunchTick
|
||||
// near zero while Lifecycle is still Launching — signed TicksSince, never raw uint math.
|
||||
int secs = 0;
|
||||
if (runInfo.LaunchTick != 0 && SystemAPI.TryGetSingleton<NetworkTime>(out var ntime)
|
||||
&& ntime.ServerTick.IsValid)
|
||||
{
|
||||
int ticksLeft = new NetworkTick(runInfo.LaunchTick).TicksSince(ntime.ServerTick);
|
||||
if (ticksLeft > 0) secs = ticksLeft / 60 + 1;
|
||||
}
|
||||
_locationText.text = "LAUNCHING IN " + secs + " - un-ready [T] to abort";
|
||||
_locationText.style.color = new Color(1f, 0.9f, 0.4f);
|
||||
break;
|
||||
@@ -272,9 +292,72 @@ namespace ProjectM.Client
|
||||
BlobAssetReference<BoonCatalogBlob> boonPool = default;
|
||||
if (SystemAPI.TryGetSingleton<BoonCatalog>(out var bcat))
|
||||
boonPool = bcat.Value;
|
||||
UpdateBoonModal(localOffer, hasOffer && localOffer.Pending == 1, boonPool);
|
||||
// Lifecycle gate (post-impl review): even a stale replicated Pending never shows the modal outside
|
||||
// the reward window.
|
||||
UpdateBoonModal(localOffer, hasOffer && localOffer.Pending == 1
|
||||
&& haveRun && runInfo.Lifecycle == RunLifecycle.RoomReward, boonPool);
|
||||
UpdateRoutePanel(haveRun ? runInfo : default);
|
||||
|
||||
// Client-local path trace for the route map (nodeIds visited this run; display-only).
|
||||
if (haveRun && runInfo.RunSeed != _routeVisitedSeed)
|
||||
{
|
||||
_routeVisited.Clear();
|
||||
_routeVisitedSeed = runInfo.RunSeed;
|
||||
}
|
||||
if (haveRun && runInfo.Lifecycle == RunLifecycle.InRoom)
|
||||
{
|
||||
int visitedNode = RunMap.NodeId(runInfo.CurrentRoom, runInfo.CurrentCol);
|
||||
if (_routeVisited.Count == 0 || _routeVisited[^1] != visitedNode) _routeVisited.Add(visitedNode);
|
||||
}
|
||||
|
||||
// The clickable READY panel (Staging/Launching, hidden once the outcome latched — the banner owns
|
||||
// the screen then). Counts are the replicated send-to-all PlayerReady flags.
|
||||
int rTotal = 0, rReady = 0;
|
||||
bool localReady = false;
|
||||
int launchSecs = 0;
|
||||
bool terminal = SystemAPI.TryGetSingleton<RunOutcome>(out var readyOc)
|
||||
&& readyOc.Value != RunOutcomeId.InProgress;
|
||||
bool readyShow = haveRun && !terminal
|
||||
&& (runInfo.Lifecycle == RunLifecycle.Staging || runInfo.Lifecycle == RunLifecycle.Launching);
|
||||
if (readyShow)
|
||||
{
|
||||
foreach (var pr in SystemAPI.Query<RefRO<PlayerReady>>().WithAll<PlayerTag>())
|
||||
{
|
||||
rTotal++;
|
||||
if (pr.ValueRO.Value != 0) rReady++;
|
||||
}
|
||||
foreach (var pr in SystemAPI.Query<RefRO<PlayerReady>>().WithAll<PlayerTag, GhostOwnerIsLocal>())
|
||||
localReady = pr.ValueRO.Value != 0;
|
||||
if (runInfo.Lifecycle == RunLifecycle.Launching && runInfo.LaunchTick != 0
|
||||
&& SystemAPI.TryGetSingleton<NetworkTime>(out var lnt) && lnt.ServerTick.IsValid)
|
||||
{
|
||||
int tl = new NetworkTick(runInfo.LaunchTick).TicksSince(lnt.ServerTick);
|
||||
if (tl > 0) launchSecs = tl / 60 + 1;
|
||||
}
|
||||
}
|
||||
UpdateReadyPanel(readyShow, runInfo, rTotal, rReady, localReady, launchSecs);
|
||||
|
||||
// Boss presence bar — client heuristic: a Boss room spawns exactly ONE enemy (the boss), so any live
|
||||
// EnemyTag Health in a Boss-type room is it. Replicated state only; no netcode surface.
|
||||
bool bossAlive = false;
|
||||
float bossHp = 0f, bossMax = 0f;
|
||||
if (haveRun && runInfo.Lifecycle == RunLifecycle.InRoom && runInfo.CurrentRoomType == RoomTypeId.Boss)
|
||||
{
|
||||
foreach (var bhq in SystemAPI.Query<RefRO<Health>>().WithAll<EnemyTag>())
|
||||
{
|
||||
if (bhq.ValueRO.Max > bossMax)
|
||||
{
|
||||
bossMax = bhq.ValueRO.Max;
|
||||
bossHp = bhq.ValueRO.Current;
|
||||
bossAlive = bhq.ValueRO.Current > 0f;
|
||||
}
|
||||
}
|
||||
}
|
||||
UpdateBossBar(bossAlive, bossHp, bossMax);
|
||||
|
||||
// Run-depth dots — keeps the roguelite spine visible while fighting (the map only shows at gates).
|
||||
UpdateRunDepth(haveRun ? runInfo : default, haveRun);
|
||||
|
||||
// ---- Goal (hex-pip meter, or a continuous bar for large targets) ----
|
||||
if (SystemAPI.TryGetSingleton<GoalProgress>(out var goal))
|
||||
{
|
||||
@@ -388,6 +471,14 @@ namespace ProjectM.Client
|
||||
if (SystemAPI.TryGetSingleton<RunOutcome>(out var runOutcome) && runOutcome.Value != RunOutcomeId.InProgress)
|
||||
{
|
||||
bool win = runOutcome.Value == RunOutcomeId.Victory;
|
||||
if (_outcomeFlashedFor != runOutcome.Value)
|
||||
{
|
||||
// One-shot landing flourish: full-screen color flash + camera kick — the beat gets a payoff.
|
||||
_outcomeFlashedFor = runOutcome.Value;
|
||||
_outcomeFlashLeft = win ? 0.9f : 0.7f;
|
||||
PrototypeCameraRig.PunchFov(win ? 5f : 3f, win ? 420f : 260f);
|
||||
PrototypeCameraRig.AddShake(win ? 0.25f : 0.5f);
|
||||
}
|
||||
_runBanner.style.display = DisplayStyle.Flex;
|
||||
_runBannerText.text = win ? "THE ENGINE HOLDS" : "OVERRUN";
|
||||
_runBannerText.style.color = win ? new Color(0.45f, 0.95f, 1f) : new Color(1f, 0.35f, 0.3f);
|
||||
@@ -397,6 +488,32 @@ namespace ProjectM.Client
|
||||
else
|
||||
{
|
||||
_runBanner.style.display = DisplayStyle.None;
|
||||
_outcomeFlashedFor = 0;
|
||||
}
|
||||
|
||||
// Outcome flash decay (lazy element; the banner dim sits above it, the world below).
|
||||
if (_outcomeFlashLeft > 0f)
|
||||
{
|
||||
if (_outcomeFlash == null && _doc != null && _doc.rootVisualElement != null)
|
||||
{
|
||||
_outcomeFlash = new VisualElement { pickingMode = PickingMode.Ignore };
|
||||
_outcomeFlash.style.position = Position.Absolute;
|
||||
_outcomeFlash.style.left = 0; _outcomeFlash.style.right = 0;
|
||||
_outcomeFlash.style.top = 0; _outcomeFlash.style.bottom = 0;
|
||||
_doc.rootVisualElement.Add(_outcomeFlash);
|
||||
}
|
||||
_outcomeFlashLeft -= dt;
|
||||
if (_outcomeFlash != null)
|
||||
{
|
||||
bool winFlash = _outcomeFlashedFor == RunOutcomeId.Victory;
|
||||
var fc = winFlash ? new Color(1f, 0.85f, 0.35f) : new Color(1f, 0.20f, 0.15f);
|
||||
_outcomeFlash.style.backgroundColor = new Color(fc.r, fc.g, fc.b, Mathf.Clamp01(_outcomeFlashLeft) * 0.35f);
|
||||
_outcomeFlash.style.display = DisplayStyle.Flex;
|
||||
}
|
||||
}
|
||||
else if (_outcomeFlash != null)
|
||||
{
|
||||
_outcomeFlash.style.display = DisplayStyle.None;
|
||||
}
|
||||
|
||||
|
||||
@@ -487,6 +604,18 @@ namespace ProjectM.Client
|
||||
HudUi.SetFill(_cooldownFill, cdFrac);
|
||||
// A READY weapon (full bar) recedes; a CHARGING one is bright — so the inverted-vs-health polarity reads.
|
||||
if (_cdRow != null) _cdRow.style.opacity = cdFrac >= 1f ? 0.4f : 1f;
|
||||
if (dead)
|
||||
{
|
||||
// Client-local countdown: latch the death edge; the baked (non-replicated) DelayTicks is the
|
||||
// honest duration — RespawnTick itself is server-only.
|
||||
if (_downedSince < 0f) _downedSince = (float)SystemAPI.Time.ElapsedTime;
|
||||
int delayTicks = 180;
|
||||
foreach (var rs in SystemAPI.Query<RefRO<RespawnState>>().WithAll<PlayerTag, GhostOwnerIsLocal>())
|
||||
{ delayTicks = Mathf.Max(1, rs.ValueRO.DelayTicks); break; }
|
||||
float left = delayTicks / 60f - ((float)SystemAPI.Time.ElapsedTime - _downedSince);
|
||||
_downedText.text = left > 0.05f ? "RESPAWNING IN " + Mathf.CeilToInt(left) : "RESPAWNING...";
|
||||
}
|
||||
else _downedSince = -1f;
|
||||
_downed.style.display = dead ? DisplayStyle.Flex : DisplayStyle.None;
|
||||
}
|
||||
else
|
||||
@@ -1022,7 +1151,12 @@ namespace ProjectM.Client
|
||||
{
|
||||
_downed.style.backgroundColor = new Color(0.35f, 0f, 0f, 0.35f);
|
||||
}
|
||||
_downed.Add(HudUi.Display("DOWNED - RESPAWNING", 52, new Color(1f, 0.45f, 0.4f), TextAnchor.MiddleCenter));
|
||||
var downedCol = HudUi.Group(Align.Center);
|
||||
downedCol.Add(HudUi.Display("DOWNED", 52, new Color(1f, 0.45f, 0.4f), TextAnchor.MiddleCenter));
|
||||
_downedText = HudUi.Text("RESPAWNING...", 24, new Color(1f, 0.75f, 0.7f), TextAnchor.MiddleCenter);
|
||||
_downedText.style.marginTop = 6;
|
||||
downedCol.Add(_downedText);
|
||||
_downed.Add(downedCol);
|
||||
_downed.style.display = DisplayStyle.None;
|
||||
root.Add(_downed);
|
||||
}
|
||||
@@ -1042,17 +1176,32 @@ namespace ProjectM.Client
|
||||
_runBannerSub.style.marginTop = 8;
|
||||
col.Add(_runBannerSub);
|
||||
// END-2 (SL-5): the terminal banner offers a clear action so the player isn't hunting for Esc.
|
||||
// PLAY AGAIN restarts a fresh Single run via the proven menu lifecycle; QUIT TO MENU reuses the teardown.
|
||||
// Both self-guard on WorldLauncher.Busy. The row picks (Position) even though the banner root Ignores.
|
||||
// SINGLE: PLAY AGAIN Continues as a fresh campaign (base+meta kept — the terminal save rolls
|
||||
// forward on stage). CO-OP (operator-locked): the honest exit is a clean teardown for everyone —
|
||||
// the host ends the session (each joiner's ConnectionWatchdog returns them to the menu with a
|
||||
// reason), a joiner just leaves. All self-guard on WorldLauncher.Busy. The row picks (Position)
|
||||
// even though the banner root Ignores.
|
||||
var btnRow = new VisualElement();
|
||||
btnRow.style.flexDirection = FlexDirection.Row;
|
||||
btnRow.style.justifyContent = Justify.Center;
|
||||
btnRow.style.marginTop = 28;
|
||||
btnRow.pickingMode = PickingMode.Position;
|
||||
var again = MenuUi.Button("PLAY AGAIN", () => WorldLauncher.StartSession(SessionMode.Single, null, false));
|
||||
again.style.marginRight = 12;
|
||||
btnRow.Add(again);
|
||||
btnRow.Add(MenuUi.Button("QUIT TO MENU", WorldLauncher.TeardownToMenu));
|
||||
switch (WorldLauncher.LastMode)
|
||||
{
|
||||
case SessionMode.Host:
|
||||
btnRow.Add(MenuUi.Button("END SESSION — ALL TO MENU", WorldLauncher.TeardownToMenu));
|
||||
break;
|
||||
case SessionMode.Join:
|
||||
btnRow.Add(MenuUi.Button("LEAVE TO MENU", WorldLauncher.TeardownToMenu));
|
||||
break;
|
||||
default:
|
||||
var again = MenuUi.Button("PLAY AGAIN",
|
||||
() => WorldLauncher.StartSession(SessionMode.Single, null, SaveService.HasSave()));
|
||||
again.style.marginRight = 12;
|
||||
btnRow.Add(again);
|
||||
btnRow.Add(MenuUi.Button("QUIT TO MENU", WorldLauncher.TeardownToMenu));
|
||||
break;
|
||||
}
|
||||
col.Add(btnRow);
|
||||
_runBanner.Add(col);
|
||||
_runBanner.style.display = DisplayStyle.None;
|
||||
@@ -1230,7 +1379,6 @@ namespace ProjectM.Client
|
||||
|
||||
// ==== Step 14: boon modal + route panel (lazy-built overlays; clicks -> client send statics) ====
|
||||
|
||||
byte _routeShownFor; // last (room ^ options) signature the route buttons were built for
|
||||
|
||||
static string RoomTypeLabel(byte roomType) => roomType == RoomTypeId.Boss ? "[BOSS]"
|
||||
: roomType == RoomTypeId.Elite ? "[ELITE]"
|
||||
@@ -1253,8 +1401,9 @@ namespace ProjectM.Client
|
||||
}
|
||||
|
||||
// Rebuild the three cards only when the offer actually changes (a new room's deal).
|
||||
byte sig = (byte)(offer.Option0 ^ (offer.Option1 * 3) ^ (offer.Option2 * 7));
|
||||
if (sig == 0) sig = 1;
|
||||
// Exact signature (post-impl review): the old lossy byte XOR could collide across consecutive
|
||||
// rooms and leave stale card labels. +1 keeps 0 as the hidden/reset sentinel.
|
||||
int sig = 1 + (offer.Option0 | (offer.Option1 << 8) | (offer.Option2 << 16));
|
||||
if (_boonShownFor != sig)
|
||||
{
|
||||
_boonCardRow.Clear();
|
||||
@@ -1265,10 +1414,18 @@ namespace ProjectM.Client
|
||||
int idx = BoonMath.FindDef(ref defs, id);
|
||||
string title = idx >= 0 ? defs.Defs[idx].Name.ToString() : ("BOON " + id);
|
||||
string desc = idx >= 0 ? defs.Defs[idx].Desc.ToString() : "";
|
||||
byte weight = idx >= 0 ? defs.Defs[idx].Weight : (byte)100;
|
||||
byte pick = k; // capture a COPY into the closure, never the loop variable
|
||||
var card = MenuUi.Button(title + "\n" + desc, () => BoonSendSystem.PickBoon(pick));
|
||||
card.style.width = 200;
|
||||
card.style.height = 96;
|
||||
card.style.height = StyleKeyword.Auto; // long descs grow the card
|
||||
card.style.minHeight = 96;
|
||||
// Rarity from the draw weight (100 common / 60 uncommon / 30 rare / 10 epic).
|
||||
var rare = weight <= 10 ? new Color(1f, 0.82f, 0.30f)
|
||||
: weight <= 30 ? new Color(0.65f, 0.50f, 1f)
|
||||
: weight <= 60 ? new Color(0.45f, 0.95f, 0.55f)
|
||||
: new Color(1f, 1f, 1f, 0.30f);
|
||||
MenuUi.Border(card, rare, weight <= 30 ? 2.5f : 1.5f);
|
||||
card.style.marginLeft = 8;
|
||||
card.style.marginRight = 8;
|
||||
card.style.whiteSpace = WhiteSpace.Normal;
|
||||
@@ -1312,6 +1469,25 @@ namespace ProjectM.Client
|
||||
root.Add(_boonModal);
|
||||
}
|
||||
|
||||
// ---- the drawn branching route map (Slay-the-Spire style; display regen from RunSeed, clicks bind
|
||||
// to the authoritative RouteOpt* bytes) ----
|
||||
|
||||
const float MapStrideX = 58f, MapStrideY = 46f, MapNodeSize = 34f, MapPad = 14f;
|
||||
|
||||
static Vector2 MapNodePos(int layer, int col, byte layerWidth)
|
||||
{
|
||||
float x = MapPad + layer * MapStrideX;
|
||||
float y = MapPad + MapStrideY + (col - (layerWidth - 1) * 0.5f) * MapStrideY;
|
||||
return new Vector2(x, y);
|
||||
}
|
||||
|
||||
static string RoomGlyph(byte t) => t == RoomTypeId.Boss ? "B"
|
||||
: t == RoomTypeId.Elite ? "E" : t == RoomTypeId.Reward ? "R" : "C";
|
||||
|
||||
static Color RoomColor(byte t) => t == RoomTypeId.Boss ? new Color(0.92f, 0.28f, 0.22f)
|
||||
: t == RoomTypeId.Elite ? new Color(0.80f, 0.45f, 1f)
|
||||
: t == RoomTypeId.Reward ? new Color(0.45f, 0.95f, 0.55f) : new Color(1f, 0.72f, 0.35f);
|
||||
|
||||
void UpdateRoutePanel(RunInfo runInfo)
|
||||
{
|
||||
// Keyed on the LIFECYCLE (never RouteOptionCount alone — the review's D-F6 criterion).
|
||||
@@ -1319,7 +1495,7 @@ namespace ProjectM.Client
|
||||
if (!show)
|
||||
{
|
||||
if (_routePanel != null) _routePanel.style.display = DisplayStyle.None;
|
||||
_routeShownFor = 0;
|
||||
_routeMapSig = 0;
|
||||
return;
|
||||
}
|
||||
var root = _doc != null ? _doc.rootVisualElement : null;
|
||||
@@ -1330,23 +1506,15 @@ namespace ProjectM.Client
|
||||
_routePanelBuilt = true;
|
||||
}
|
||||
|
||||
byte sig = (byte)((runInfo.CurrentRoom + 1) ^ (runInfo.RouteOpt0Type * 3)
|
||||
^ (runInfo.RouteOpt1Type * 5) ^ (runInfo.RouteOpt2Type * 7) ^ runInfo.RouteOptionCount);
|
||||
int sig = (int)runInfo.RunSeed ^ (runInfo.CurrentRoom + 1) * 131 ^ runInfo.CurrentCol * 31
|
||||
^ (runInfo.RouteOptionCount << 24) ^ (runInfo.RouteOpt0Col << 16)
|
||||
^ (runInfo.RouteOpt1Col << 18) ^ (runInfo.RouteOpt2Col << 20);
|
||||
if (sig == 0) sig = 1;
|
||||
if (_routeShownFor != sig)
|
||||
if (_routeMapSig != sig)
|
||||
{
|
||||
_routeBtnRow.Clear();
|
||||
for (byte k = 0; k < runInfo.RouteOptionCount && k < 3; k++)
|
||||
{
|
||||
byte type = k == 2 ? runInfo.RouteOpt2Type : k == 1 ? runInfo.RouteOpt1Type : runInfo.RouteOpt0Type;
|
||||
byte pick = k; // closure copy
|
||||
var b = MenuUi.Button("→ " + RoomTypeLabel(type), () => RouteSendSystem.PickRoute(pick));
|
||||
b.style.marginLeft = 6;
|
||||
b.style.marginRight = 6;
|
||||
_routeBtnRow.Add(b);
|
||||
}
|
||||
RebuildRouteMap(runInfo);
|
||||
_routeTitle.text = "CHOOSE YOUR PATH — room " + (runInfo.CurrentRoom + 2) + "/" + runInfo.RoomCount;
|
||||
_routeShownFor = sig;
|
||||
_routeMapSig = sig;
|
||||
}
|
||||
_routePanel.style.display = DisplayStyle.Flex;
|
||||
}
|
||||
@@ -1356,33 +1524,299 @@ namespace ProjectM.Client
|
||||
_routePanel = new VisualElement { pickingMode = PickingMode.Ignore };
|
||||
_routePanel.style.position = Position.Absolute;
|
||||
_routePanel.style.left = 0; _routePanel.style.right = 0;
|
||||
_routePanel.style.bottom = 120;
|
||||
_routePanel.style.top = 0; _routePanel.style.bottom = 0;
|
||||
_routePanel.style.alignItems = Align.Center;
|
||||
_routePanel.style.justifyContent = Justify.Center;
|
||||
_routePanel.style.display = DisplayStyle.None;
|
||||
|
||||
var box = new VisualElement();
|
||||
box.style.backgroundColor = new Color(0.07f, 0.09f, 0.12f, 0.94f);
|
||||
var box = new VisualElement { pickingMode = PickingMode.Position }; // swallow world clicks under the map
|
||||
box.style.backgroundColor = new Color(0.07f, 0.09f, 0.12f, 0.95f);
|
||||
box.style.borderTopLeftRadius = 10; box.style.borderTopRightRadius = 10;
|
||||
box.style.borderBottomLeftRadius = 10; box.style.borderBottomRightRadius = 10;
|
||||
box.style.paddingLeft = 16; box.style.paddingRight = 16;
|
||||
box.style.paddingTop = 10; box.style.paddingBottom = 12;
|
||||
box.style.paddingLeft = 18; box.style.paddingRight = 18;
|
||||
box.style.paddingTop = 12; box.style.paddingBottom = 12;
|
||||
box.style.alignItems = Align.Center;
|
||||
|
||||
_routeTitle = new Label("CHOOSE YOUR PATH");
|
||||
_routeTitle.style.color = new Color(0.55f, 0.85f, 1f);
|
||||
_routeTitle.style.fontSize = 15;
|
||||
_routeTitle.style.fontSize = 16;
|
||||
_routeTitle.style.unityFontStyleAndWeight = FontStyle.Bold;
|
||||
_routeTitle.style.marginBottom = 8;
|
||||
_routeTitle.style.marginBottom = 10;
|
||||
box.Add(_routeTitle);
|
||||
|
||||
_routeBtnRow = new VisualElement();
|
||||
_routeBtnRow.style.flexDirection = FlexDirection.Row;
|
||||
box.Add(_routeBtnRow);
|
||||
_routeMapHost = new VisualElement { pickingMode = PickingMode.Ignore };
|
||||
_routeMapHost.style.position = Position.Relative;
|
||||
box.Add(_routeMapHost);
|
||||
|
||||
var cap = HudUi.Text("your path is lit — click a highlighted room to commit the party", 13,
|
||||
MenuUi.SubCol, TextAnchor.MiddleCenter);
|
||||
cap.style.marginTop = 10;
|
||||
box.Add(cap);
|
||||
|
||||
_routePanel.Add(box);
|
||||
root.Add(_routePanel);
|
||||
}
|
||||
|
||||
void RebuildRouteMap(RunInfo runInfo)
|
||||
{
|
||||
_routeMapHost.Clear();
|
||||
var map = RunMapMath.Generate(runInfo.RunSeed);
|
||||
_routeMapHost.style.width = MapPad * 2f + (map.LayerCount - 1) * MapStrideX + MapNodeSize;
|
||||
_routeMapHost.style.height = MapPad * 2f + 2f * MapStrideY + MapNodeSize;
|
||||
|
||||
// Edges under the nodes (Painter2D); walked segments glow, the rest are faint.
|
||||
var edges = new VisualElement { pickingMode = PickingMode.Ignore };
|
||||
edges.style.position = Position.Absolute;
|
||||
edges.style.left = 0; edges.style.top = 0; edges.style.right = 0; edges.style.bottom = 0;
|
||||
var mapCopy = map;
|
||||
var visited = new List<int>(_routeVisited);
|
||||
edges.generateVisualContent += ctx =>
|
||||
{
|
||||
var p = ctx.painter2D;
|
||||
p.lineWidth = 2f;
|
||||
var c = new Vector2(MapNodeSize * 0.5f, MapNodeSize * 0.5f);
|
||||
for (int layer = 0; layer < mapCopy.LayerCount - 1; layer++)
|
||||
for (int col = 0; col < mapCopy.LayerWidths[layer]; col++)
|
||||
{
|
||||
var node = mapCopy.Node(layer, col);
|
||||
if (node.NextMask == 0) continue;
|
||||
var a = MapNodePos(layer, col, mapCopy.LayerWidths[layer]);
|
||||
for (int j = 0; j < mapCopy.LayerWidths[layer + 1]; j++)
|
||||
{
|
||||
if ((node.NextMask & (1 << j)) == 0) continue;
|
||||
var b = MapNodePos(layer + 1, j, mapCopy.LayerWidths[layer + 1]);
|
||||
bool walked = visited.Contains(RunMap.NodeId(layer, col))
|
||||
&& visited.Contains(RunMap.NodeId(layer + 1, j));
|
||||
p.strokeColor = walked ? new Color(0.55f, 0.85f, 1f, 0.9f) : new Color(1f, 1f, 1f, 0.16f);
|
||||
p.BeginPath();
|
||||
p.MoveTo(a + c);
|
||||
p.LineTo(b + c);
|
||||
p.Stroke();
|
||||
}
|
||||
}
|
||||
};
|
||||
_routeMapHost.Add(edges);
|
||||
|
||||
int nextLayer = runInfo.CurrentRoom + 1;
|
||||
for (int layer = 0; layer < map.LayerCount; layer++)
|
||||
for (int col = 0; col < map.LayerWidths[layer]; col++)
|
||||
{
|
||||
var node = map.Node(layer, col);
|
||||
bool isCurrent = layer == runInfo.CurrentRoom && col == runInfo.CurrentCol;
|
||||
bool wasVisited = _routeVisited.Contains(RunMap.NodeId(layer, col));
|
||||
byte opt = 255;
|
||||
if (layer == nextLayer)
|
||||
{
|
||||
if (runInfo.RouteOptionCount > 0 && col == runInfo.RouteOpt0Col) opt = 0;
|
||||
else if (runInfo.RouteOptionCount > 1 && col == runInfo.RouteOpt1Col) opt = 1;
|
||||
else if (runInfo.RouteOptionCount > 2 && col == runInfo.RouteOpt2Col) opt = 2;
|
||||
}
|
||||
_routeMapHost.Add(MakeMapNode(node.RoomType,
|
||||
MapNodePos(layer, col, map.LayerWidths[layer]), isCurrent, wasVisited, opt, layer <= runInfo.CurrentRoom));
|
||||
}
|
||||
}
|
||||
|
||||
VisualElement MakeMapNode(byte roomType, Vector2 pos, bool isCurrent, bool visited, byte optionIndex, bool past)
|
||||
{
|
||||
bool clickable = optionIndex != 255;
|
||||
var n = new VisualElement { pickingMode = clickable ? PickingMode.Position : PickingMode.Ignore };
|
||||
n.style.position = Position.Absolute;
|
||||
n.style.left = pos.x; n.style.top = pos.y;
|
||||
n.style.width = MapNodeSize; n.style.height = MapNodeSize;
|
||||
MenuUi.Round(n, MapNodeSize * 0.5f);
|
||||
var c = RoomColor(roomType);
|
||||
float bgA = clickable ? 0.95f : visited || isCurrent ? 0.85f : past ? 0.20f : 0.40f;
|
||||
var restBg = new Color(c.r * 0.35f, c.g * 0.35f, c.b * 0.35f, bgA);
|
||||
n.style.backgroundColor = restBg;
|
||||
MenuUi.Border(n, isCurrent ? new Color(0.55f, 0.85f, 1f) : clickable ? c : new Color(1f, 1f, 1f, 0.18f),
|
||||
isCurrent || clickable ? 2.5f : 1.2f);
|
||||
var lbl = new Label(RoomGlyph(roomType)) { pickingMode = PickingMode.Ignore };
|
||||
lbl.style.unityTextAlign = TextAnchor.MiddleCenter;
|
||||
lbl.style.flexGrow = 1;
|
||||
lbl.style.color = clickable || visited || isCurrent ? c : new Color(1f, 1f, 1f, 0.35f);
|
||||
lbl.style.fontSize = 15;
|
||||
lbl.style.unityFontStyleAndWeight = FontStyle.Bold;
|
||||
n.Add(lbl);
|
||||
if (clickable)
|
||||
{
|
||||
byte pick = optionIndex; // closure copy, never the loop variable
|
||||
n.RegisterCallback<ClickEvent>(_ => RouteSendSystem.PickRoute(pick));
|
||||
n.RegisterCallback<MouseEnterEvent>(_ =>
|
||||
n.style.backgroundColor = new Color(c.r * 0.55f, c.g * 0.55f, c.b * 0.55f, 1f));
|
||||
n.RegisterCallback<MouseLeaveEvent>(_ => n.style.backgroundColor = restBg);
|
||||
}
|
||||
return n;
|
||||
}
|
||||
|
||||
// ---- the clickable READY panel (Staging: toggle + party pips; Launching: countdown + abort) ----
|
||||
|
||||
void UpdateReadyPanel(bool show, RunInfo runInfo, int total, int ready, bool localReady, int launchSecs)
|
||||
{
|
||||
if (!show)
|
||||
{
|
||||
if (_readyPanel != null) _readyPanel.style.display = DisplayStyle.None;
|
||||
_readyShownFor = 0;
|
||||
return;
|
||||
}
|
||||
var root = _doc != null ? _doc.rootVisualElement : null;
|
||||
if (root == null) return;
|
||||
if (!_readyPanelBuilt)
|
||||
{
|
||||
BuildReadyPanel(root);
|
||||
_readyPanelBuilt = true;
|
||||
}
|
||||
|
||||
bool launching = runInfo.Lifecycle == RunLifecycle.Launching;
|
||||
int sig = 1 + ready + (total << 4) + (localReady ? 1 << 8 : 0) + (launchSecs << 9) + (launching ? 1 << 16 : 0);
|
||||
if (_readyShownFor != sig)
|
||||
{
|
||||
_readyTitle.text = launching
|
||||
? "LAUNCHING IN " + launchSecs
|
||||
: "EXPEDITION — " + ready + "/" + Mathf.Max(total, 1) + " READY";
|
||||
_readyTitle.style.color = launching ? new Color(1f, 0.9f, 0.4f) : new Color(0.55f, 0.85f, 1f);
|
||||
_readyBtn.text = launching ? "ABORT [T]" : localReady ? "UNREADY [T]" : "READY UP [T]";
|
||||
_readyPipRow.Clear();
|
||||
for (int i = 0; i < total; i++)
|
||||
{
|
||||
var pip = new VisualElement();
|
||||
pip.style.width = 14; pip.style.height = 14;
|
||||
pip.style.marginLeft = 3; pip.style.marginRight = 3;
|
||||
MenuUi.Round(pip, 7f);
|
||||
pip.style.backgroundColor = i < ready
|
||||
? new Color(0.45f, 0.95f, 0.55f) : new Color(1f, 1f, 1f, 0.15f);
|
||||
_readyPipRow.Add(pip);
|
||||
}
|
||||
_readyShownFor = sig;
|
||||
}
|
||||
_readyPanel.style.display = DisplayStyle.Flex;
|
||||
}
|
||||
|
||||
void BuildReadyPanel(VisualElement root)
|
||||
{
|
||||
_readyPanel = new VisualElement { pickingMode = PickingMode.Ignore };
|
||||
_readyPanel.style.position = Position.Absolute;
|
||||
_readyPanel.style.left = 0; _readyPanel.style.right = 0;
|
||||
_readyPanel.style.bottom = 170; // clear of the build palette row + hint bar
|
||||
_readyPanel.style.alignItems = Align.Center;
|
||||
_readyPanel.style.display = DisplayStyle.None;
|
||||
|
||||
var box = new VisualElement { pickingMode = PickingMode.Position };
|
||||
box.style.backgroundColor = new Color(0.07f, 0.09f, 0.12f, 0.92f);
|
||||
box.style.borderTopLeftRadius = 10; box.style.borderTopRightRadius = 10;
|
||||
box.style.borderBottomLeftRadius = 10; box.style.borderBottomRightRadius = 10;
|
||||
box.style.paddingLeft = 18; box.style.paddingRight = 18;
|
||||
box.style.paddingTop = 10; box.style.paddingBottom = 12;
|
||||
box.style.alignItems = Align.Center;
|
||||
|
||||
_readyTitle = new Label("EXPEDITION");
|
||||
_readyTitle.style.fontSize = 16;
|
||||
_readyTitle.style.unityFontStyleAndWeight = FontStyle.Bold;
|
||||
box.Add(_readyTitle);
|
||||
|
||||
_readyPipRow = new VisualElement();
|
||||
_readyPipRow.style.flexDirection = FlexDirection.Row;
|
||||
_readyPipRow.style.justifyContent = Justify.Center;
|
||||
_readyPipRow.style.marginTop = 6; _readyPipRow.style.marginBottom = 8;
|
||||
box.Add(_readyPipRow);
|
||||
|
||||
_readyBtn = MenuUi.Button("READY UP [T]", ReadySendSystem.ToggleReady);
|
||||
box.Add(_readyBtn);
|
||||
|
||||
_readyPanel.Add(box);
|
||||
root.Add(_readyPanel);
|
||||
}
|
||||
|
||||
// ---- boss presence bar (Boss rooms only; red, top-center, under the macro banner) ----
|
||||
|
||||
void UpdateBossBar(bool alive, float hp, float max)
|
||||
{
|
||||
if (!alive || max <= 0f)
|
||||
{
|
||||
if (_bossPanel != null) _bossPanel.style.display = DisplayStyle.None;
|
||||
return;
|
||||
}
|
||||
var root = _doc != null ? _doc.rootVisualElement : null;
|
||||
if (root == null) return;
|
||||
if (!_bossBarBuilt)
|
||||
{
|
||||
BuildBossBar(root);
|
||||
_bossBarBuilt = true;
|
||||
}
|
||||
HudUi.SetFill(_bossFill, Mathf.Clamp01(hp / max));
|
||||
_bossText.text = "ALPHA HUSK " + Mathf.CeilToInt(Mathf.Max(0f, hp)) + " / " + Mathf.CeilToInt(max);
|
||||
_bossPanel.style.display = DisplayStyle.Flex;
|
||||
}
|
||||
|
||||
void BuildBossBar(VisualElement root)
|
||||
{
|
||||
_bossPanel = new VisualElement { pickingMode = PickingMode.Ignore };
|
||||
_bossPanel.style.position = Position.Absolute;
|
||||
_bossPanel.style.left = 0; _bossPanel.style.right = 0;
|
||||
_bossPanel.style.top = 168;
|
||||
_bossPanel.style.alignItems = Align.Center;
|
||||
_bossPanel.style.display = DisplayStyle.None;
|
||||
|
||||
var col = HudUi.Group(Align.Center);
|
||||
_bossText = HudUi.Display("ALPHA HUSK", 22, new Color(1f, 0.35f, 0.3f), TextAnchor.MiddleCenter);
|
||||
col.Add(_bossText);
|
||||
var bar = HudUi.Bar(420, 12, new Color(0.92f, 0.22f, 0.18f), out _bossFill);
|
||||
bar.style.marginTop = 4;
|
||||
col.Add(bar);
|
||||
_bossPanel.Add(col);
|
||||
root.Add(_bossPanel);
|
||||
}
|
||||
|
||||
// ---- run-depth dots (visible through the whole run; the current room pulses bigger) ----
|
||||
|
||||
void UpdateRunDepth(RunInfo runInfo, bool haveRun)
|
||||
{
|
||||
bool show = haveRun && runInfo.RoomCount > 0
|
||||
&& (runInfo.Lifecycle == RunLifecycle.InRoom
|
||||
|| runInfo.Lifecycle == RunLifecycle.RoomReward
|
||||
|| runInfo.Lifecycle == RunLifecycle.RouteSelect);
|
||||
if (!show)
|
||||
{
|
||||
if (_depthPanel != null) _depthPanel.style.display = DisplayStyle.None;
|
||||
_depthShownFor = 0;
|
||||
return;
|
||||
}
|
||||
var root = _doc != null ? _doc.rootVisualElement : null;
|
||||
if (root == null) return;
|
||||
if (!_depthBuilt)
|
||||
{
|
||||
_depthPanel = new VisualElement { pickingMode = PickingMode.Ignore };
|
||||
_depthPanel.style.position = Position.Absolute;
|
||||
_depthPanel.style.left = 0; _depthPanel.style.right = 0;
|
||||
_depthPanel.style.top = 208; // below the macro cluster (goal/core) AND the boss bar (168)
|
||||
_depthPanel.style.flexDirection = FlexDirection.Row;
|
||||
_depthPanel.style.justifyContent = Justify.Center;
|
||||
root.Add(_depthPanel);
|
||||
_depthBuilt = true;
|
||||
}
|
||||
int sig = 1 + runInfo.CurrentRoom * 37 + runInfo.RoomCount * 3;
|
||||
if (_depthShownFor != sig)
|
||||
{
|
||||
_depthPanel.Clear();
|
||||
for (int i = 0; i < runInfo.RoomCount; i++)
|
||||
{
|
||||
bool current = i == runInfo.CurrentRoom;
|
||||
bool done = i < runInfo.CurrentRoom;
|
||||
var dot = new VisualElement { pickingMode = PickingMode.Ignore };
|
||||
float size = current ? 12f : 8f;
|
||||
dot.style.width = size; dot.style.height = size;
|
||||
dot.style.marginLeft = 3; dot.style.marginRight = 3;
|
||||
dot.style.alignSelf = Align.Center;
|
||||
MenuUi.Round(dot, size * 0.5f);
|
||||
dot.style.backgroundColor = current ? new Color(0.55f, 0.85f, 1f)
|
||||
: done ? new Color(0.55f, 0.85f, 1f, 0.55f)
|
||||
: new Color(1f, 1f, 1f, 0.16f);
|
||||
_depthPanel.Add(dot);
|
||||
}
|
||||
_depthShownFor = sig;
|
||||
}
|
||||
_depthPanel.style.display = DisplayStyle.Flex;
|
||||
}
|
||||
|
||||
void UpdateMetaShop(bool show, byte classId, int aether,
|
||||
BlobAssetReference<MetaUpgradeCatalogBlob> pool, DynamicBuffer<MetaTierState> record)
|
||||
{
|
||||
@@ -1417,18 +1851,35 @@ namespace ProjectM.Client
|
||||
if ((defs.Defs[d].ClassMask & classBit) == 0) continue;
|
||||
byte id = defs.Defs[d].Id;
|
||||
byte owned = MetaMath.TierOf(record, classId, id);
|
||||
if (owned > defs.Defs[d].MaxTier) owned = defs.Defs[d].MaxTier; // D-F5 display clamp (seed AND spend AND shop)
|
||||
bool maxed = owned >= defs.Defs[d].MaxTier;
|
||||
int cost = MetaMath.CostForTier(in defs.Defs[d], owned);
|
||||
string label = defs.Defs[d].Name.ToString() + " [" + owned + "/" + defs.Defs[d].MaxTier + "]"
|
||||
string label = defs.Defs[d].Name.ToString()
|
||||
+ (maxed ? " MAXED" : " - " + cost + " Aether")
|
||||
+ "\n" + defs.Defs[d].Desc.ToString();
|
||||
byte buyId = id; // closure copy, never the loop variable
|
||||
var row = MenuUi.Button(label, () => MetaSpendSendSystem.RequestPurchase(buyId));
|
||||
row.style.width = 290;
|
||||
row.style.height = StyleKeyword.Auto; // two-line labels must grow the row (overlap fix)
|
||||
row.style.paddingTop = 6; row.style.paddingBottom = 6;
|
||||
row.style.marginBottom = 4;
|
||||
row.style.whiteSpace = WhiteSpace.Normal;
|
||||
row.style.unityTextAlign = TextAnchor.MiddleLeft;
|
||||
row.SetEnabled(!maxed && aether >= cost); // honest UI; the server re-validates everything anyway
|
||||
// Owned-tier pips (replaces the "[2/5]" text — reads at a glance).
|
||||
var pipRow = new VisualElement { pickingMode = PickingMode.Ignore };
|
||||
pipRow.style.flexDirection = FlexDirection.Row;
|
||||
pipRow.style.marginTop = 3;
|
||||
for (int p = 0; p < defs.Defs[d].MaxTier; p++)
|
||||
{
|
||||
var tp = new VisualElement { pickingMode = PickingMode.Ignore };
|
||||
tp.style.width = 9; tp.style.height = 9;
|
||||
tp.style.marginRight = 3;
|
||||
MenuUi.Round(tp, 4.5f);
|
||||
tp.style.backgroundColor = p < owned ? AetherCyan : new Color(1f, 1f, 1f, 0.14f);
|
||||
pipRow.Add(tp);
|
||||
}
|
||||
row.Add(pipRow);
|
||||
_metaRowsHost.Add(row);
|
||||
}
|
||||
_metaShownFor = sig;
|
||||
|
||||
@@ -0,0 +1,285 @@
|
||||
using ProjectM.Simulation;
|
||||
using Unity.Entities;
|
||||
using Unity.NetCode;
|
||||
using UnityEngine;
|
||||
|
||||
namespace ProjectM.Client
|
||||
{
|
||||
/// <summary>
|
||||
/// Client-only PROCEDURAL MUSIC — four synchronized loop layers (bass / chord pad / arpeggio / percussion
|
||||
/// pulse), all generated over the SAME 4-bar buffer (100 BPM, Am–F–C–G) so they stay phase-locked forever:
|
||||
/// every AudioSource starts the same frame with loop=true and identical clip length, and per-bar amplitude
|
||||
/// envelopes reach ~zero at each bar boundary so both the chord changes and the loop point are click-free
|
||||
/// (the <c>AmbientAudioSystem.Snap</c> trick generalized to enveloped segments). The MIX is the state
|
||||
/// machine: layer volumes ease toward targets chosen from replicated state only — <see cref="RunInfo"/>
|
||||
/// lifecycle (staging / combat / boss / reward lull), <see cref="CycleState"/> siege, and the terminal
|
||||
/// <see cref="RunOutcome"/> (one-shot victory/defeat sting + aftermath bed). Observe-only presentation
|
||||
/// system: no sim writes, no determinism surface; asset-free per the project convention. Sits under SFX at
|
||||
/// <see cref="MasterVolume"/> × <see cref="GameVolume.Music"/>; the low <c>AmbientAudioSystem</c> drone
|
||||
/// (vol 0.10) remains as texture beneath it.
|
||||
/// </summary>
|
||||
[WorldSystemFilter(WorldSystemFilterFlags.ClientSimulation)]
|
||||
[UpdateInGroup(typeof(PresentationSystemGroup))]
|
||||
public partial class MusicSystem : SystemBase
|
||||
{
|
||||
const float MasterVolume = 0.45f;
|
||||
const float FadePerSecond = 0.30f;
|
||||
|
||||
// ---- shared musical grid: 100 BPM, 4/4, one chord per bar, 4 bars ----
|
||||
const int SampleRate = 44100;
|
||||
const float BarSeconds = 2.4f; // 4 beats @ 0.6 s
|
||||
const int Bars = 4;
|
||||
const float LoopSeconds = BarSeconds * Bars; // 9.6 s
|
||||
|
||||
// Am – F – C – G (natural-minor loop; index = bar): root/third/fifth per chord.
|
||||
static readonly float[][] ChordHz =
|
||||
{
|
||||
new[] { 110.00f, 130.81f, 164.81f }, // Am: A2 C3 E3
|
||||
new[] { 87.31f, 110.00f, 130.81f }, // F: F2 A2 C3
|
||||
new[] { 130.81f, 164.81f, 196.00f }, // C: C3 E3 G3
|
||||
new[] { 98.00f, 123.47f, 146.83f }, // G: G2 B2 D3
|
||||
};
|
||||
|
||||
GameObject _root;
|
||||
AudioSource _bass, _pad, _arp, _pulse;
|
||||
float _vBass, _vPad, _vArp, _vPulse; // current smoothed volumes (pre-master)
|
||||
byte _outcomePlayed; // which terminal outcome's sting has fired (0 = none)
|
||||
|
||||
protected override void OnStartRunning()
|
||||
{
|
||||
if (_root != null) return;
|
||||
_root = new GameObject("~Music");
|
||||
_bass = MakeSource(BuildBassLoop());
|
||||
_pad = MakeSource(BuildPadLoop());
|
||||
_arp = MakeSource(BuildArpLoop());
|
||||
_pulse = MakeSource(BuildPulseLoop());
|
||||
// Same frame, same length, loop=true -> phase-locked for the whole session.
|
||||
_bass.Play(); _pad.Play(); _arp.Play(); _pulse.Play();
|
||||
}
|
||||
|
||||
protected override void OnDestroy()
|
||||
{
|
||||
if (_root != null) Object.Destroy(_root);
|
||||
}
|
||||
|
||||
AudioSource MakeSource(AudioClip clip)
|
||||
{
|
||||
var src = _root.AddComponent<AudioSource>();
|
||||
src.clip = clip;
|
||||
src.loop = true;
|
||||
src.playOnAwake = false;
|
||||
src.spatialBlend = 0f;
|
||||
src.volume = 0f;
|
||||
return src;
|
||||
}
|
||||
|
||||
protected override void OnUpdate()
|
||||
{
|
||||
if (_bass == null) return;
|
||||
|
||||
// ---- pick the mix from replicated state (defaults = quiet staging bed) ----
|
||||
float tBass = 0.50f, tPad = 0.55f, tArp = 0.12f, tPulse = 0f;
|
||||
|
||||
bool haveRun = SystemAPI.TryGetSingleton<RunInfo>(out var run);
|
||||
SystemAPI.TryGetSingleton<CycleState>(out var cyc);
|
||||
bool siege = cyc.Phase == CyclePhase.Siege;
|
||||
bool finalSiege = siege && SystemAPI.TryGetSingleton<GoalProgress>(out var goal)
|
||||
&& goal.Target > 0 && goal.Charge >= goal.Target;
|
||||
|
||||
byte outcome = SystemAPI.TryGetSingleton<RunOutcome>(out var oc) ? oc.Value : RunOutcomeId.InProgress;
|
||||
if (outcome != RunOutcomeId.InProgress)
|
||||
{
|
||||
if (_outcomePlayed != outcome)
|
||||
{
|
||||
_outcomePlayed = outcome;
|
||||
PlayOutcomeSting(outcome == RunOutcomeId.Victory);
|
||||
}
|
||||
tBass = 0.20f; tPad = 0.45f; tArp = 0f; tPulse = 0f; // aftermath bed under the banner
|
||||
}
|
||||
else if (siege)
|
||||
{
|
||||
tBass = 0.70f; tPad = 0.40f; tArp = 0.60f; tPulse = finalSiege ? 0.90f : 0.65f;
|
||||
}
|
||||
else if (haveRun)
|
||||
{
|
||||
switch (run.Lifecycle)
|
||||
{
|
||||
case RunLifecycle.Launching:
|
||||
tBass = 0.55f; tPad = 0.50f; tArp = 0.35f; tPulse = 0.20f; // anticipation swell
|
||||
break;
|
||||
case RunLifecycle.InRoom:
|
||||
bool boss = run.CurrentRoomType == RoomTypeId.Boss;
|
||||
tBass = boss ? 0.75f : 0.65f;
|
||||
tPad = 0.40f;
|
||||
tArp = boss ? 0.80f : 0.65f;
|
||||
tPulse = boss ? 0.75f : 0.35f;
|
||||
break;
|
||||
case RunLifecycle.RoomReward:
|
||||
case RunLifecycle.RouteSelect:
|
||||
tBass = 0.45f; tPad = 0.55f; tArp = 0.25f; tPulse = 0.08f; // between-rooms lull
|
||||
break;
|
||||
case RunLifecycle.Returning:
|
||||
tBass = 0.45f; tPad = 0.60f; tArp = 0.15f; tPulse = 0f; // resolution
|
||||
break;
|
||||
// Staging keeps the defaults.
|
||||
}
|
||||
}
|
||||
|
||||
float dt = SystemAPI.Time.DeltaTime * FadePerSecond;
|
||||
_vBass = Mathf.MoveTowards(_vBass, tBass, dt);
|
||||
_vPad = Mathf.MoveTowards(_vPad, tPad, dt);
|
||||
_vArp = Mathf.MoveTowards(_vArp, tArp, dt);
|
||||
_vPulse = Mathf.MoveTowards(_vPulse, tPulse, dt);
|
||||
|
||||
float master = MasterVolume * GameVolume.Music;
|
||||
_bass.volume = _vBass * master;
|
||||
_pad.volume = _vPad * master;
|
||||
_arp.volume = _vArp * master;
|
||||
_pulse.volume = _vPulse * master;
|
||||
}
|
||||
|
||||
void PlayOutcomeSting(bool victory)
|
||||
{
|
||||
// Victory: rising A-minor->major-feel arpeggio; defeat: falling minor third crawl.
|
||||
float[] notes = victory
|
||||
? new[] { 220f, 277.18f, 329.63f, 440f } // A3 C#4 E4 A4 (picardy lift)
|
||||
: new[] { 220f, 207.65f, 174.61f, 146.83f }; // A3 Ab3 F3 D3
|
||||
var clip = BuildStingClip(notes, victory ? 0.16f : 0.28f, victory ? 0.45f : 0.40f);
|
||||
if (_pad != null) _pad.PlayOneShot(clip, 0.9f * GameVolume.Music);
|
||||
}
|
||||
|
||||
// ================= clip builders (deterministic, asset-free) =================
|
||||
|
||||
static AudioClip NewLoopClip(string name, out float[] data)
|
||||
{
|
||||
int len = (int)(LoopSeconds * SampleRate);
|
||||
data = new float[len];
|
||||
return AudioClip.Create(name, len, 1, SampleRate, false);
|
||||
}
|
||||
|
||||
/// <summary>Bar-local envelope that is ~0 at both bar edges (click-free chord changes + loop point).</summary>
|
||||
static float BarEnv(float tInBar, float attack, float release)
|
||||
{
|
||||
float up = Mathf.Clamp01(tInBar / attack);
|
||||
float down = Mathf.Clamp01((BarSeconds - tInBar) / release);
|
||||
return up * up * down;
|
||||
}
|
||||
|
||||
static AudioClip BuildBassLoop()
|
||||
{
|
||||
var clip = NewLoopClip("music_bass", out var data);
|
||||
for (int i = 0; i < data.Length; i++)
|
||||
{
|
||||
float t = i / (float)SampleRate;
|
||||
int bar = Mathf.Min(Bars - 1, (int)(t / BarSeconds));
|
||||
float tb = t - bar * BarSeconds;
|
||||
float f = ChordHz[bar][0] * 0.5f; // root, an octave down
|
||||
float s = Mathf.Sin(2f * Mathf.PI * f * t) * 0.8f
|
||||
+ Mathf.Sin(2f * Mathf.PI * f * 2f * t) * 0.2f;
|
||||
data[i] = s * BarEnv(tb, 0.03f, 0.12f) * 0.50f;
|
||||
}
|
||||
clip.SetData(data, 0);
|
||||
return clip;
|
||||
}
|
||||
|
||||
static AudioClip BuildPadLoop()
|
||||
{
|
||||
var clip = NewLoopClip("music_pad", out var data);
|
||||
for (int i = 0; i < data.Length; i++)
|
||||
{
|
||||
float t = i / (float)SampleRate;
|
||||
int bar = Mathf.Min(Bars - 1, (int)(t / BarSeconds));
|
||||
float tb = t - bar * BarSeconds;
|
||||
var chord = ChordHz[bar];
|
||||
float s = 0f;
|
||||
for (int n = 0; n < chord.Length; n++)
|
||||
s += Mathf.Sin(2f * Mathf.PI * chord[n] * t + n * 1.7f);
|
||||
float trem = 0.85f + 0.15f * Mathf.Sin(2f * Mathf.PI * 0.8f * t);
|
||||
data[i] = s / 3f * BarEnv(tb, 0.45f, 0.50f) * trem * 0.34f;
|
||||
}
|
||||
clip.SetData(data, 0);
|
||||
return clip;
|
||||
}
|
||||
|
||||
static AudioClip BuildArpLoop()
|
||||
{
|
||||
var clip = NewLoopClip("music_arp", out var data);
|
||||
const float noteLen = BarSeconds / 8f; // 8th notes
|
||||
for (int i = 0; i < data.Length; i++)
|
||||
{
|
||||
float t = i / (float)SampleRate;
|
||||
int bar = Mathf.Min(Bars - 1, (int)(t / BarSeconds));
|
||||
float tb = t - bar * BarSeconds;
|
||||
int step = Mathf.Min(7, (int)(tb / noteLen));
|
||||
float tn = tb - step * noteLen;
|
||||
var chord = ChordHz[bar];
|
||||
// up-and-over pattern: root, fifth, octave, tenth, back down
|
||||
float f = step switch
|
||||
{
|
||||
0 => chord[0] * 2f,
|
||||
1 => chord[2] * 2f,
|
||||
2 => chord[0] * 4f,
|
||||
3 => chord[1] * 4f,
|
||||
4 => chord[2] * 4f,
|
||||
5 => chord[0] * 4f,
|
||||
6 => chord[2] * 2f,
|
||||
_ => chord[1] * 2f,
|
||||
};
|
||||
float env = Mathf.Exp(-9f * tn);
|
||||
data[i] = Mathf.Sin(2f * Mathf.PI * f * tn) * env * 0.32f;
|
||||
}
|
||||
clip.SetData(data, 0);
|
||||
return clip;
|
||||
}
|
||||
|
||||
static AudioClip BuildPulseLoop()
|
||||
{
|
||||
var clip = NewLoopClip("music_pulse", out var data);
|
||||
const float beat = BarSeconds / 4f;
|
||||
uint rng = 0x5EED5EEDu; // deterministic hat noise
|
||||
for (int i = 0; i < data.Length; i++)
|
||||
{
|
||||
float t = i / (float)SampleRate;
|
||||
float tBeat = t % beat;
|
||||
// Low thump on every beat: 55->38 Hz sweep, tight decay.
|
||||
float thump = 0f;
|
||||
if (tBeat < 0.14f)
|
||||
{
|
||||
float f = Mathf.Lerp(55f, 38f, tBeat / 0.14f);
|
||||
thump = Mathf.Sin(2f * Mathf.PI * f * tBeat) * Mathf.Exp(-22f * tBeat) * 0.85f;
|
||||
}
|
||||
// Off-beat hat: a 30 ms noise tick halfway through each beat.
|
||||
float tHat = (t + beat * 0.5f) % beat;
|
||||
float hat = 0f;
|
||||
if (tHat < 0.03f)
|
||||
{
|
||||
rng ^= rng << 13; rng ^= rng >> 17; rng ^= rng << 5; // xorshift
|
||||
float n = (rng & 0xFFFF) / 32768f - 1f;
|
||||
hat = n * Mathf.Exp(-120f * tHat) * 0.22f;
|
||||
}
|
||||
data[i] = thump + hat;
|
||||
}
|
||||
clip.SetData(data, 0);
|
||||
return clip;
|
||||
}
|
||||
|
||||
static AudioClip BuildStingClip(float[] notes, float noteLen, float vol)
|
||||
{
|
||||
int len = (int)(notes.Length * noteLen * SampleRate) + SampleRate / 2; // + half-second tail
|
||||
var clip = AudioClip.Create("music_sting", len, 1, SampleRate, false);
|
||||
var data = new float[len];
|
||||
for (int n = 0; n < notes.Length; n++)
|
||||
{
|
||||
int start = (int)(n * noteLen * SampleRate);
|
||||
int dur = (int)(SampleRate * (noteLen + (n == notes.Length - 1 ? 0.5f : 0.05f)));
|
||||
for (int i = 0; i < dur && start + i < len; i++)
|
||||
{
|
||||
float tn = i / (float)SampleRate;
|
||||
data[start + i] += Mathf.Sin(2f * Mathf.PI * notes[n] * tn) * Mathf.Exp(-4.5f * tn) * vol;
|
||||
}
|
||||
}
|
||||
clip.SetData(data, 0);
|
||||
return clip;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 0674dbc6d7e676f4cb88b18a67cdca99
|
||||
@@ -76,21 +76,24 @@ namespace ProjectM.Client
|
||||
Body(c, "Build menu — Tab / Y");
|
||||
Body(c, "Place / Cancel (in build) — LMB / RMB (A / B on gamepad)");
|
||||
Body(c, "Deposit at base — G");
|
||||
Body(c, "Ready up (launch a run) — T");
|
||||
Body(c, "Boons / route map / shop — mouse click");
|
||||
|
||||
Body(c, "Inventory — I");
|
||||
Body(c, "Pause — Esc");
|
||||
break;
|
||||
case 1: // The Loop
|
||||
Head(c, "THE LOOP — expeditions are how you win");
|
||||
Body(c, "1. BASE (Calm) — mine Ore, build Turrets + a Fabricator to defend your Engine Core.");
|
||||
Body(c, "2. EXPEDITION — walk to the Gate and fight the wave. CLEARING it is the progress beat.");
|
||||
Body(c, "3. RETURN — come home to bank the clear: +1 on the Engine meter.");
|
||||
Body(c, "4. SIEGE — returning provokes a retaliation attack. Defend the Core!");
|
||||
Body(c, "5. WIN — fill the Engine meter, then hold the final siege.");
|
||||
Body(c, "The Engine meter (top of screen) is the goal — base sieges are a consequence, not the win.");
|
||||
Head(c, "THE LOOP — expedition RUNS are how you win");
|
||||
Body(c, "1. BASE — build Turrets + a Fabricator around your Engine Core; spend Aether on PERMANENT class 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.");
|
||||
break;
|
||||
case 2: // Build & Economy
|
||||
Head(c, "RESOURCES & BUILDING");
|
||||
Body(c, "Ore — main currency. Mine it by attacking the glowing nodes at base.");
|
||||
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.");
|
||||
@@ -101,7 +104,7 @@ namespace ProjectM.Client
|
||||
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, "Expeditions throw waves at you — clear them all to bank the win.");
|
||||
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
|
||||
|
||||
@@ -80,6 +80,17 @@ namespace ProjectM.Client
|
||||
_mainPanel = MenuUi.FullScreenRoot(true);
|
||||
var card = MenuUi.Card("PROJECT M");
|
||||
card.Add(MenuUi.Caption("Frontier colony — co-op"));
|
||||
// Why the last session ended (connection watchdog / bad join address) — shown once, then cleared.
|
||||
if (!string.IsNullOrEmpty(SessionEnd.Reason))
|
||||
{
|
||||
var reason = MenuUi.Caption(SessionEnd.Reason);
|
||||
reason.style.color = new Color(1f, 0.55f, 0.35f);
|
||||
reason.style.whiteSpace = WhiteSpace.Normal;
|
||||
reason.style.maxWidth = 340;
|
||||
SessionEnd.Reason = null;
|
||||
card.Add(reason);
|
||||
}
|
||||
|
||||
// Slice 2: class picker -> sets WorldLauncher.SelectedClass for the next session (Warrior melee / Ranger ranged).
|
||||
_classLabel = new Label(ClassName(WorldLauncher.SelectedClass));
|
||||
_classLabel.style.unityTextAlign = TextAnchor.MiddleCenter;
|
||||
|
||||
@@ -31,10 +31,16 @@ namespace ProjectM.Client
|
||||
/// <summary>Slice 2: the class chosen in the menu (a CharacterId byte), seeded into the client world at session start.</summary>
|
||||
public static byte SelectedClass = (byte)CharacterId.Warrior;
|
||||
|
||||
/// <summary>The mode of the most recently started session — the HUD's outcome banner branches on it
|
||||
/// (single: PLAY AGAIN restarts; co-op: the honest exit is a clean teardown for everyone).</summary>
|
||||
public static SessionMode LastMode { get; private set; } = SessionMode.Single;
|
||||
|
||||
|
||||
public static void StartSession(SessionMode mode, string joinIp, bool loadSave)
|
||||
{
|
||||
if (Busy) return;
|
||||
Busy = true;
|
||||
LastMode = mode;
|
||||
SessionRunner.Run(StartRoutine(mode, joinIp, loadSave));
|
||||
}
|
||||
|
||||
@@ -47,6 +53,12 @@ namespace ProjectM.Client
|
||||
|
||||
static IEnumerator StartRoutine(SessionMode mode, string joinIp, bool loadSave)
|
||||
{
|
||||
// A LIVE session restarting in place (the banner's PLAY AGAIN) checkpoints first — the teardown
|
||||
// path saves, so this path must too or the terminal state (outcome, last meta buys) is dropped.
|
||||
var liveServer = ClientServerBootstrap.ServerWorld;
|
||||
if (liveServer is { IsCreated: true })
|
||||
TrySaveFromServer(liveServer);
|
||||
|
||||
// Dispose the idle menu world so a netcode world can own DefaultGameObjectInjectionWorld (the
|
||||
// subscene then streams into the netcode worlds, exactly as in the always-on bootstrap flow).
|
||||
World.DisposeAllWorlds();
|
||||
@@ -117,6 +129,10 @@ 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();
|
||||
|
||||
@@ -56,8 +56,8 @@ namespace ProjectM.Server
|
||||
playerRegions.Add(region.ValueRO.Region);
|
||||
}
|
||||
|
||||
// EB-1 fortress aggro: also snapshot live structures (Turret/Wall/Pylon carry Health; automation
|
||||
// machines lack it so the query excludes them). Snapshot ABOVE the early-return so Husks keep razing
|
||||
// EB-1 fortress aggro: also snapshot live structures (Turret/Wall/Pylon AND the Fabricator all carry
|
||||
// Health since the DR-032 parity fix; the query keys on it). Snapshot ABOVE the early-return so Husks keep razing
|
||||
// the base even with every player dead/away (the locked 'push for structures' fork).
|
||||
var structureEntities = new NativeList<Entity>(Allocator.Temp);
|
||||
var structurePositions = new NativeList<float3>(Allocator.Temp);
|
||||
|
||||
@@ -45,7 +45,6 @@ namespace ProjectM.Server
|
||||
float3 center = haveAnchor ? BaseGridMath.PlotCenter(baseAnchor) : spawner.SpawnPoint;
|
||||
float ringRadius = haveSpawner ? spawner.SpawnRingRadius : 2f;
|
||||
int ringSlots = haveSpawner ? spawner.RingSlots : 4;
|
||||
center = BaseGridMath.PlotCenter(baseAnchor);
|
||||
|
||||
foreach (var (health, respawn, invuln, xform, region, owner, eff) in
|
||||
SystemAPI.Query<RefRW<Health>, RefRW<RespawnState>, RefRW<RespawnInvuln>, RefRW<LocalTransform>,
|
||||
|
||||
@@ -123,7 +123,7 @@ namespace ProjectM.Server
|
||||
zs.SeededEpoch = run.RoomEpoch;
|
||||
zs.SpawnCounter = 0;
|
||||
zs.RemainingToSpawn = bossRoom ? 1 : ZoneEnemyMath.WaveSlots(plan.DifficultyEpoch, bands);
|
||||
zs.NextSpawnTick = TickUtil.NonZero(now); // first slot this tick
|
||||
zs.NextSpawnTick = TickUtil.NonZero(now + Tuning.RoomEntryGraceTicks); // landing grace — let the party orient
|
||||
}
|
||||
|
||||
if (zs.RemainingToSpawn > 0)
|
||||
|
||||
@@ -139,6 +139,16 @@ namespace ProjectM.Server
|
||||
bool due = info.LaunchTick == 0u || !new NetworkTick(info.LaunchTick).IsNewerThan(serverTick);
|
||||
if (due)
|
||||
{
|
||||
// Conscript the party: the launch roster is EVERYONE connected (N7 co-location — all at
|
||||
// base, all ready). Room advances teleport ONLY RunParticipants, so a mid-run late joiner
|
||||
// is never yanked into the fight; the tag is released on the Returning edge.
|
||||
var conscript = new EntityCommandBuffer(Allocator.Temp);
|
||||
foreach (var (_, playerE) in
|
||||
SystemAPI.Query<RefRO<PlayerReady>>().WithAll<PlayerTag>().WithEntityAccess())
|
||||
conscript.AddComponent<RunParticipant>(playerE);
|
||||
conscript.Playback(state.EntityManager);
|
||||
conscript.Dispose();
|
||||
|
||||
// Enter room 0 (the guaranteed Combat landing at column 0, sub-slot 0).
|
||||
var map = RunMapMath.Generate(run.RunSeed);
|
||||
EnterRoom(ref state, ref info, ref run, in map, layer: 0, col: 0, baseCenter, bumpEpoch: true);
|
||||
@@ -187,15 +197,24 @@ namespace ProjectM.Server
|
||||
|
||||
// Exit gate: every SURVIVING player has picked (BoonOffer.Pending==0 — inert until Step 10)
|
||||
// OR the grace elapsed (wrap-safe IsNewerThan, never raw uint — F4).
|
||||
// Only EXPEDITION players hold the gate (BoonOfferSystem's documented contract): a player who
|
||||
// died and respawned to base must not stall the party (post-impl review, confirmed major).
|
||||
bool anyPending = false;
|
||||
foreach (var offer in SystemAPI.Query<RefRO<BoonOffer>>().WithAll<PlayerTag>())
|
||||
if (offer.ValueRO.Pending != 0) { anyPending = true; break; }
|
||||
foreach (var (offer, pregion) in
|
||||
SystemAPI.Query<RefRO<BoonOffer>, RefRO<RegionTag>>().WithAll<PlayerTag>())
|
||||
if (pregion.ValueRO.Region == RegionId.Expedition && offer.ValueRO.Pending != 0)
|
||||
{ anyPending = true; break; }
|
||||
bool graceElapsed = run.RewardGraceTick == 0u
|
||||
|| !new NetworkTick(run.RewardGraceTick).IsNewerThan(serverTick);
|
||||
if (anyPending && !graceElapsed)
|
||||
break;
|
||||
|
||||
run.RewardGraceTick = 0u;
|
||||
// NO offer survives this gate: zero every straggler (a dead-respawned base player the
|
||||
// auto-pick deliberately skips, a grace-expired AFK) so a stale Pending can never wedge the
|
||||
// HUD modal open or stall a later reward gate (post-impl review, confirmed major).
|
||||
foreach (var offer in SystemAPI.Query<RefRW<BoonOffer>>().WithAll<PlayerTag>())
|
||||
offer.ValueRW = default;
|
||||
if (run.LastTerminalCleared != 0)
|
||||
{
|
||||
info.Lifecycle = RunLifecycle.Returning; // boss cleared — go home a winner
|
||||
@@ -279,18 +298,25 @@ namespace ProjectM.Server
|
||||
|
||||
case RunLifecycle.Returning:
|
||||
{
|
||||
// Party teleport HOME + region flip back to Base.
|
||||
// PARTICIPANT teleport home + region flip + roster release. Only the launch roster comes
|
||||
// home (a mid-run joiner already at base keeps its position); the tag removal re-opens the
|
||||
// next run's conscription cleanly (post-impl review, confirmed medium).
|
||||
int idx = 0;
|
||||
foreach (var (region, xform) in
|
||||
SystemAPI.Query<RefRW<RegionTag>, RefRW<LocalTransform>>().WithAll<PlayerTag>())
|
||||
var homebound = new EntityCommandBuffer(Allocator.Temp);
|
||||
foreach (var (region, xform, playerE) in
|
||||
SystemAPI.Query<RefRW<RegionTag>, RefRW<LocalTransform>>()
|
||||
.WithAll<PlayerTag, RunParticipant>().WithEntityAccess())
|
||||
{
|
||||
region.ValueRW.Region = RegionId.Base;
|
||||
var p = baseCenter;
|
||||
p.x += 1.5f * idx;
|
||||
p.y = xform.ValueRO.Position.y;
|
||||
xform.ValueRW.Position = p;
|
||||
homebound.RemoveComponent<RunParticipant>(playerE);
|
||||
idx++;
|
||||
}
|
||||
homebound.Playback(state.EntityManager);
|
||||
homebound.Dispose();
|
||||
|
||||
// THE terminal bank — once per RunEpoch (equality latch, F7), CLEAR-GATED (D-F3).
|
||||
if (run.LastBankedRunEpoch != run.RunEpoch)
|
||||
@@ -393,7 +419,7 @@ namespace ProjectM.Server
|
||||
float3 roomOrigin = RegionMath.ExpeditionRoomOrigin(baseCenter, run.ActiveSubSlot);
|
||||
int idx = 0;
|
||||
foreach (var (region, xform) in
|
||||
SystemAPI.Query<RefRW<RegionTag>, RefRW<LocalTransform>>().WithAll<PlayerTag>())
|
||||
SystemAPI.Query<RefRW<RegionTag>, RefRW<LocalTransform>>().WithAll<PlayerTag, RunParticipant>())
|
||||
{
|
||||
region.ValueRW.Region = RegionId.Expedition;
|
||||
var p = roomOrigin;
|
||||
|
||||
@@ -13,7 +13,7 @@ namespace ProjectM.Server
|
||||
/// hand-off is <see cref="ThreatState.PendingSiegeSize"/> (this system sets it; CyclePhaseSystem consumes it).
|
||||
/// This slice wires ONE source — POST-EXPEDITION retaliation: a completed RUN (banked on the boss-clear
|
||||
/// return by <see cref="RunDirectorSystem"/> into <see cref="ThreatState.PendingReturns"/> — the retired
|
||||
/// walk-in ExpeditionGateSystem's carry, Step 11) arms a siege ofe of
|
||||
/// walk-in ExpeditionGateSystem's carry, Step 11) arms a siege of
|
||||
/// <see cref="ThreatConfig.SizeBase"/> Husks after a <see cref="ThreatConfig.PostExpeditionDelayTicks"/>
|
||||
/// telegraph. The Heat/Schedule sources are reserved (config baked-but-inert) so they drop in additively with
|
||||
/// no re-bake. It also enforces a BOUNDED siege lifetime (<see cref="ThreatConfig.SiegeTimeoutTicks"/>): an
|
||||
|
||||
Binary file not shown.
@@ -63,7 +63,7 @@ namespace ProjectM.Simulation
|
||||
[Serializable]
|
||||
public class SaveData
|
||||
{
|
||||
public const int CurrentVersion = 6; // v6: permanent META (per-class upgrade tiers + run counters); v5 added RunOutcomeoreCurrent
|
||||
public const int CurrentVersion = 6; // v6: permanent META (per-class upgrade tiers + run counters); v5 added RunOutcome; v4 added CoreCurrent
|
||||
|
||||
/// <summary>Oldest save schema the loader accepts (additive); a v2 save loads with structures at full HP.</summary>
|
||||
public const int MinLoadableVersion = 2;
|
||||
|
||||
@@ -27,7 +27,7 @@ namespace ProjectM.Simulation
|
||||
// field 0-defaults and the restore guard maps 0 -> baked Max); v0/v1 garbage is still rejected.
|
||||
if (data == null || data.Version < SaveData.MinLoadableVersion || data.Version > SaveData.CurrentVersion) return null;
|
||||
data.Ledger ??= Array.Empty<LedgerRow>();
|
||||
data.MetaUpgrades ??= Array.Empty<MetaUpgradeSave>(); // v6: null on any v<=5 file();
|
||||
data.MetaUpgrades ??= Array.Empty<MetaUpgradeSave>(); // v6: null on any v<=5 file
|
||||
return data;
|
||||
}
|
||||
catch (Exception e)
|
||||
@@ -37,6 +37,22 @@ namespace ProjectM.Simulation
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A TERMINAL save (Victory/Loss latched) Continues as a FRESH CAMPAIGN that keeps the base + permanent
|
||||
/// meta: the outcome latch, goal meter, and core integrity reset to 0 (the spawn restore guards re-map
|
||||
/// 0 -> InProgress / empty meter / baked-full Core), while meta tiers, run counters, the ledger, and
|
||||
/// placed structures survive untouched. Without this, Continue/PLAY AGAIN after a win re-latches the dead
|
||||
/// outcome banner on the first replicated snapshot. Pure + idempotent; no-op for in-progress saves.
|
||||
/// </summary>
|
||||
public static void RollTerminalCampaignForward(SaveData data)
|
||||
{
|
||||
if (data == null || data.RunOutcome == 0) return;
|
||||
data.RunOutcome = 0;
|
||||
data.GoalCharge = 0;
|
||||
data.CoreCurrent = 0;
|
||||
}
|
||||
|
||||
|
||||
public static void Save(SaveData data)
|
||||
{
|
||||
if (data == null) return;
|
||||
|
||||
@@ -90,6 +90,11 @@ namespace ProjectM.Simulation
|
||||
/// at spawn on the baked value, legible at a glance).</summary>
|
||||
public const float BossScaleMultiplier = 1.6f;
|
||||
|
||||
/// <summary>Room-landing grace before the FIRST enemy slot spawns (~1.7 s @ 60): the party just teleported
|
||||
/// in and needs a beat to orient — without it the wave engages while the camera is still settling (demo
|
||||
/// polish; an idle player died in ~2 s of landing). Subsequent slots keep the normal drip cadence.</summary>
|
||||
public const uint RoomEntryGraceTicks = 100;
|
||||
|
||||
// ---- Inventory (per-player bag; InventoryMath / ResourceHarvestSystem / InventoryDepositSystem) ----
|
||||
|
||||
/// <summary>Max stacks a player can carry; InventoryMath rejects deposits past this and the harvest remainder spills to the global ledger.</summary>
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
using Unity.Entities;
|
||||
|
||||
namespace ProjectM.Simulation
|
||||
{
|
||||
/// <summary>
|
||||
/// SERVER-ONLY roster tag for the players conscripted into the CURRENT run — stamped on every connected
|
||||
/// player at the launch edge (Launching → room 0), removed on the Returning edge. Room advances teleport
|
||||
/// ONLY participants: a dead-respawned member (back at base) is re-conscripted on the next advance (the
|
||||
/// operator-locked default), while a mid-run late JOINER — who never readied — stays safely at base until
|
||||
/// the next Staging (spec §2.2 closed-party rule; post-impl review, confirmed medium). NOT a [GhostField];
|
||||
/// disconnect cleanup is free (the tag dies with the player ghost's LinkedEntityGroup despawn).
|
||||
/// </summary>
|
||||
public struct RunParticipant : IComponentData
|
||||
{
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 92795e88279d04e42af70f8ec1032cc7
|
||||
@@ -23,8 +23,8 @@ namespace ProjectM.Tests
|
||||
[Test]
|
||||
public void FirstIncomplete_SkipsCompletedPrefix()
|
||||
{
|
||||
int mask = (1 << OnboardingStepMath.Welcome) | (1 << OnboardingStepMath.Move) | (1 << OnboardingStepMath.Mine);
|
||||
Assert.AreEqual(OnboardingStepMath.Build, OnboardingStepMath.FirstIncomplete(mask));
|
||||
int mask = (1 << OnboardingStepMath.Welcome) | (1 << OnboardingStepMath.Move) | (1 << OnboardingStepMath.Build);
|
||||
Assert.AreEqual(OnboardingStepMath.Fabricator, OnboardingStepMath.FirstIncomplete(mask));
|
||||
}
|
||||
|
||||
[Test]
|
||||
@@ -57,12 +57,14 @@ namespace ProjectM.Tests
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Mine_AdvancesOnlyWhenLedgerOreRises()
|
||||
public void ReadyUp_AdvancesOnLocalReadyOrLaunch()
|
||||
{
|
||||
var s = Empty(); s.OreBaseline = 50; s.OreNow = 50; // starts at the seeded baseline
|
||||
Assert.IsFalse(OnboardingStepMath.IsSatisfied(OnboardingStepMath.Mine, s));
|
||||
s.OreNow = 51;
|
||||
Assert.IsTrue(OnboardingStepMath.IsSatisfied(OnboardingStepMath.Mine, s));
|
||||
var s = Empty(); s.Lifecycle = RunLifecycle.Staging;
|
||||
Assert.IsFalse(OnboardingStepMath.IsSatisfied(OnboardingStepMath.ReadyUp, s));
|
||||
var ready = Empty(); ready.LocalReady = true;
|
||||
Assert.IsTrue(OnboardingStepMath.IsSatisfied(OnboardingStepMath.ReadyUp, ready));
|
||||
var launched = Empty(); launched.Lifecycle = RunLifecycle.Launching;
|
||||
Assert.IsTrue(OnboardingStepMath.IsSatisfied(OnboardingStepMath.ReadyUp, launched));
|
||||
}
|
||||
|
||||
[Test]
|
||||
@@ -86,23 +88,27 @@ namespace ProjectM.Tests
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Gate_AdvancesOnExpeditionEntryOrActiveObjective()
|
||||
public void Rooms_AdvancesOnExpeditionEntryOrActiveObjective()
|
||||
{
|
||||
var s = Empty();
|
||||
Assert.IsFalse(OnboardingStepMath.IsSatisfied(OnboardingStepMath.Gate, s));
|
||||
Assert.IsFalse(OnboardingStepMath.IsSatisfied(OnboardingStepMath.Rooms, s));
|
||||
var onExp = Empty(); onExp.OnExpedition = true;
|
||||
Assert.IsTrue(OnboardingStepMath.IsSatisfied(OnboardingStepMath.Gate, onExp));
|
||||
Assert.IsTrue(OnboardingStepMath.IsSatisfied(OnboardingStepMath.Rooms, onExp));
|
||||
var active = Empty(); active.ObjectiveState = ExpeditionObjectiveState.Active;
|
||||
Assert.IsTrue(OnboardingStepMath.IsSatisfied(OnboardingStepMath.Gate, active));
|
||||
Assert.IsTrue(OnboardingStepMath.IsSatisfied(OnboardingStepMath.Rooms, active));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Clear_AdvancesOnClearedObjective()
|
||||
public void Boon_AdvancesOnClearedObjectiveOrRewardLifecycle()
|
||||
{
|
||||
var s = Empty(); s.ObjectiveState = ExpeditionObjectiveState.Active;
|
||||
Assert.IsFalse(OnboardingStepMath.IsSatisfied(OnboardingStepMath.Clear, s));
|
||||
Assert.IsFalse(OnboardingStepMath.IsSatisfied(OnboardingStepMath.Boon, s));
|
||||
s.ObjectiveState = ExpeditionObjectiveState.Cleared;
|
||||
Assert.IsTrue(OnboardingStepMath.IsSatisfied(OnboardingStepMath.Clear, s));
|
||||
Assert.IsTrue(OnboardingStepMath.IsSatisfied(OnboardingStepMath.Boon, s));
|
||||
var reward = Empty(); reward.Lifecycle = RunLifecycle.RoomReward;
|
||||
Assert.IsTrue(OnboardingStepMath.IsSatisfied(OnboardingStepMath.Boon, reward));
|
||||
var route = Empty(); route.Lifecycle = RunLifecycle.RouteSelect;
|
||||
Assert.IsTrue(OnboardingStepMath.IsSatisfied(OnboardingStepMath.Boon, route));
|
||||
}
|
||||
|
||||
[Test]
|
||||
@@ -157,11 +163,11 @@ namespace ProjectM.Tests
|
||||
// ---- pointer kinds ----
|
||||
|
||||
[Test]
|
||||
public void PointerKinds_MatchSpatialStepsOnly()
|
||||
public void PointerKinds_OnlyTheRoomsStepPoints()
|
||||
{
|
||||
Assert.AreEqual(OnboardingStepMath.PointerOreNode, OnboardingStepMath.PointerKind(OnboardingStepMath.Mine));
|
||||
Assert.AreEqual(OnboardingStepMath.PointerBaseGate, OnboardingStepMath.PointerKind(OnboardingStepMath.Gate));
|
||||
Assert.AreEqual(OnboardingStepMath.PointerExpeditionGate, OnboardingStepMath.PointerKind(OnboardingStepMath.Return));
|
||||
Assert.AreEqual(OnboardingStepMath.PointerOreNode, OnboardingStepMath.PointerKind(OnboardingStepMath.Rooms));
|
||||
Assert.AreEqual(OnboardingStepMath.PointerNone, OnboardingStepMath.PointerKind(OnboardingStepMath.ReadyUp));
|
||||
Assert.AreEqual(OnboardingStepMath.PointerNone, OnboardingStepMath.PointerKind(OnboardingStepMath.Return));
|
||||
Assert.AreEqual(OnboardingStepMath.PointerNone, OnboardingStepMath.PointerKind(OnboardingStepMath.Move));
|
||||
Assert.AreEqual(OnboardingStepMath.PointerNone, OnboardingStepMath.PointerKind(OnboardingStepMath.Defend));
|
||||
}
|
||||
|
||||
@@ -82,6 +82,12 @@ namespace ProjectM.Tests
|
||||
return q.CalculateEntityCount();
|
||||
}
|
||||
|
||||
static void AdvanceTick(EntityManager em, uint tick)
|
||||
{
|
||||
using var q = em.CreateEntityQuery(typeof(NetworkTime));
|
||||
em.SetComponentData(q.GetSingletonEntity(), new NetworkTime { ServerTick = new NetworkTick(tick) });
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Seeds_ByRoomDifficulty_AndSpawnsTaggedAtActiveSlotOrigin()
|
||||
{
|
||||
@@ -91,7 +97,11 @@ namespace ProjectM.Tests
|
||||
var plan = RoomLayoutMath.Plan(map.NodeAt(RunMap.NodeId(0, 0)), 0, map.LayerCount);
|
||||
int slots = ZoneEnemyMath.WaveSlots(plan.DifficultyEpoch, Bands());
|
||||
|
||||
group.Update(); // seeds + first slot spawns this tick
|
||||
group.Update(); // seeds; the landing grace holds the first slot (demo polish)
|
||||
Assert.AreEqual(slots, em.GetComponentData<ZoneEnemyState>(zoneDir).RemainingToSpawn,
|
||||
"grace: nothing spawns on the seed tick");
|
||||
AdvanceTick(em, T0 + Tuning.RoomEntryGraceTicks + 1);
|
||||
group.Update(); // grace elapsed -> the first slot drips
|
||||
|
||||
var zs = em.GetComponentData<ZoneEnemyState>(zoneDir);
|
||||
Assert.AreEqual(1, zs.SeededEpoch, "seeded for RoomEpoch 1");
|
||||
@@ -121,7 +131,9 @@ namespace ProjectM.Tests
|
||||
var (world, group, runDir, zoneDir) = MakeWorld(bossLayer, map.BossNodeId, activeSubSlot: (byte)(bossLayer & 1));
|
||||
var em = world.EntityManager;
|
||||
|
||||
group.Update();
|
||||
group.Update(); // seed tick (the landing grace holds the boss)
|
||||
AdvanceTick(em, T0 + Tuning.RoomEntryGraceTicks + 1);
|
||||
group.Update(); // grace elapsed -> the boss spawns
|
||||
|
||||
var zs = em.GetComponentData<ZoneEnemyState>(zoneDir);
|
||||
Assert.AreEqual(0, zs.RemainingToSpawn, "a boss room is a single-slot wave, fully spawned");
|
||||
|
||||
@@ -54,7 +54,9 @@ namespace ProjectM.Tests
|
||||
});
|
||||
em.SetComponentData(dir, new GoalProgress { Charge = 0, Target = 4 });
|
||||
|
||||
var player = em.CreateEntity(typeof(PlayerTag), typeof(PlayerReady), typeof(RegionTag), typeof(LocalTransform));
|
||||
// Mid-run fixture: the launch edge would have stamped the roster tag (RunParticipant) — fabricate it.
|
||||
var player = em.CreateEntity(typeof(PlayerTag), typeof(PlayerReady), typeof(RegionTag),
|
||||
typeof(LocalTransform), typeof(RunParticipant));
|
||||
em.SetComponentData(player, new RegionTag { Region = RegionId.Expedition });
|
||||
em.SetComponentData(player, LocalTransform.Identity);
|
||||
return (world, group, dir, player);
|
||||
|
||||
@@ -0,0 +1,136 @@
|
||||
using NUnit.Framework;
|
||||
using ProjectM.Server;
|
||||
using ProjectM.Simulation;
|
||||
using Unity.Core;
|
||||
using Unity.Entities;
|
||||
using Unity.NetCode;
|
||||
using Unity.Transforms;
|
||||
|
||||
namespace ProjectM.Tests
|
||||
{
|
||||
/// <summary>
|
||||
/// Regression pins for the two post-impl-review fixes on <see cref="RunDirectorSystem"/>:
|
||||
/// (1) a dead-respawned (base-region) player's stale <see cref="BoonOffer.Pending"/> neither holds the
|
||||
/// RoomReward exit gate nor survives it (the wedge that stalled every reward 30 s and left the modal
|
||||
/// pickable through the next fight); (2) room advances teleport ONLY <see cref="RunParticipant"/>s —
|
||||
/// a dead-respawned participant is re-conscripted (operator-locked default) while a mid-run late joiner
|
||||
/// stays at base (spec §2.2 closed party).
|
||||
/// </summary>
|
||||
public class RunRosterRegressionTests
|
||||
{
|
||||
const uint Seed = 777u;
|
||||
const uint T0 = 2000;
|
||||
|
||||
static (World world, SimulationSystemGroup group, Entity dir) MakeWorld(out RunMap map)
|
||||
{
|
||||
var world = new World("RosterTest");
|
||||
var group = world.GetOrCreateSystemManaged<SimulationSystemGroup>();
|
||||
group.AddSystemToUpdateList(world.GetOrCreateSystem<RunDirectorSystem>());
|
||||
group.SortSystems();
|
||||
world.SetTime(new TimeData(elapsedTime: 0f, deltaTime: 1f / 60f));
|
||||
var em = world.EntityManager;
|
||||
var nt = em.CreateEntity(typeof(NetworkTime));
|
||||
em.SetComponentData(nt, new NetworkTime { ServerTick = new NetworkTick(T0) });
|
||||
|
||||
map = RunMapMath.Generate(Seed);
|
||||
var dir = em.CreateEntity(typeof(RunInfo), typeof(RunRuntime), typeof(ExpeditionObjective),
|
||||
typeof(RouteCommand), typeof(MetaCounters), typeof(GoalProgress), typeof(ThreatState), typeof(SaveRequest));
|
||||
return (world, group, dir);
|
||||
}
|
||||
|
||||
static Entity MakePlayer(EntityManager em, byte region, bool participant, byte pending)
|
||||
{
|
||||
var player = participant
|
||||
? em.CreateEntity(typeof(PlayerTag), typeof(PlayerReady), typeof(RegionTag),
|
||||
typeof(LocalTransform), typeof(BoonOffer), typeof(RunParticipant))
|
||||
: em.CreateEntity(typeof(PlayerTag), typeof(PlayerReady), typeof(RegionTag),
|
||||
typeof(LocalTransform), typeof(BoonOffer));
|
||||
em.SetComponentData(player, new RegionTag { Region = region });
|
||||
em.SetComponentData(player, LocalTransform.Identity);
|
||||
em.SetComponentData(player, new BoonOffer { Pending = pending, Option0 = 1, Option1 = 2, Option2 = 3 });
|
||||
return player;
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void StalePendingAtBase_DoesNotHoldGate_AndIsStrippedOnExit()
|
||||
{
|
||||
var (world, group, dir) = MakeWorld(out var map);
|
||||
var em = world.EntityManager;
|
||||
em.SetComponentData(dir, new RunInfo
|
||||
{
|
||||
Lifecycle = RunLifecycle.RoomReward,
|
||||
CurrentRoom = 1,
|
||||
CurrentCol = 0,
|
||||
RoomCount = map.LayerCount,
|
||||
RunSeed = Seed,
|
||||
});
|
||||
em.SetComponentData(dir, new RunRuntime
|
||||
{
|
||||
RunSeed = Seed,
|
||||
RunEpoch = 1,
|
||||
RoomEpoch = 2,
|
||||
// Grace far in the future: ONLY the all-picked path can advance this tick.
|
||||
RewardGraceTick = TickUtil.NonZero(T0 + 100000),
|
||||
});
|
||||
|
||||
var alive = MakePlayer(em, RegionId.Expedition, participant: true, pending: 0); // picked already
|
||||
var deadAtBase = MakePlayer(em, RegionId.Base, participant: true, pending: 1); // the wedge
|
||||
|
||||
group.Update();
|
||||
|
||||
var info = em.GetComponentData<RunInfo>(dir);
|
||||
Assert.AreNotEqual(RunLifecycle.RoomReward, info.Lifecycle,
|
||||
"a base-region player's stale Pending must not hold the reward gate");
|
||||
Assert.AreEqual(0, em.GetComponentData<BoonOffer>(deadAtBase).Pending,
|
||||
"the stale offer is stripped on the gate exit");
|
||||
Assert.AreEqual(0, em.GetComponentData<BoonOffer>(alive).Pending,
|
||||
"no offer survives the gate");
|
||||
|
||||
world.Dispose();
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Advance_TeleportsOnlyParticipants_LateJoinerStaysAtBase()
|
||||
{
|
||||
var (world, group, dir) = MakeWorld(out var map);
|
||||
var em = world.EntityManager;
|
||||
em.SetComponentData(dir, new RunInfo
|
||||
{
|
||||
Lifecycle = RunLifecycle.RouteSelect,
|
||||
CurrentRoom = 0,
|
||||
CurrentCol = 0,
|
||||
RoomCount = map.LayerCount,
|
||||
RunSeed = Seed,
|
||||
RouteOptionCount = 1,
|
||||
RouteOpt0Col = 0,
|
||||
});
|
||||
em.SetComponentData(dir, new RunRuntime
|
||||
{
|
||||
RunSeed = Seed,
|
||||
RunEpoch = 1,
|
||||
RoomEpoch = 1,
|
||||
RouteGraceTick = TickUtil.NonZero(T0 + 100000),
|
||||
});
|
||||
em.SetComponentData(dir, new RouteCommand { HasPick = 1, OptionIndex = 0, ForRunEpoch = 1, ForLayer = 0 });
|
||||
|
||||
var inRoom = MakePlayer(em, RegionId.Expedition, participant: true, pending: 0); // fighting on
|
||||
var deadAtBase = MakePlayer(em, RegionId.Base, participant: true, pending: 0); // re-conscripted
|
||||
var lateJoiner = MakePlayer(em, RegionId.Base, participant: false, pending: 0); // stays home
|
||||
|
||||
group.Update();
|
||||
|
||||
var info = em.GetComponentData<RunInfo>(dir);
|
||||
Assert.AreEqual(RunLifecycle.InRoom, info.Lifecycle, "the committed pick advances the run");
|
||||
Assert.GreaterOrEqual(em.GetComponentData<LocalTransform>(inRoom).Position.x, 1000f,
|
||||
"the in-room participant rides the advance");
|
||||
Assert.GreaterOrEqual(em.GetComponentData<LocalTransform>(deadAtBase).Position.x, 1000f,
|
||||
"a dead-respawned participant is re-conscripted onto the new room");
|
||||
Assert.AreEqual(0f, em.GetComponentData<LocalTransform>(lateJoiner).Position.x,
|
||||
"a non-participant late joiner is never yanked into the fight");
|
||||
Assert.AreEqual(RegionId.Base, em.GetComponentData<RegionTag>(lateJoiner).Region,
|
||||
"the late joiner stays in the Base relevancy bucket");
|
||||
|
||||
world.Dispose();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 31b988b0527c7c247ad1976aab05d470
|
||||
@@ -0,0 +1,50 @@
|
||||
using NUnit.Framework;
|
||||
using ProjectM.Simulation;
|
||||
|
||||
namespace ProjectM.Tests
|
||||
{
|
||||
/// <summary>
|
||||
/// Pins <see cref="SaveService.RollTerminalCampaignForward"/>: a TERMINAL save (Victory/Loss latched)
|
||||
/// Continues as a fresh campaign — outcome / goal meter / core reset to 0 (the spawn restore guards re-map
|
||||
/// 0 to InProgress / empty / baked-full) — while the permanent channel (meta tiers, run counters, ledger,
|
||||
/// structures) survives untouched. In-progress saves and null are no-ops.
|
||||
/// </summary>
|
||||
public class TerminalCampaignRollTests
|
||||
{
|
||||
[Test]
|
||||
public void TerminalSave_RollsForward_KeepingBaseAndMeta()
|
||||
{
|
||||
var data = new SaveData
|
||||
{
|
||||
RunOutcome = 1, // Victory latched
|
||||
GoalCharge = 4,
|
||||
GoalTarget = 4,
|
||||
CoreCurrent = 37,
|
||||
RunsCompleted = 4,
|
||||
MaxDepthReached = 7,
|
||||
MetaUpgrades = new[] { new MetaUpgradeSave { ClassId = 0, UpgradeId = 1, Tier = 2 } },
|
||||
Ledger = new[] { new LedgerRow { ItemId = ResourceId.Ore, Count = 123 } },
|
||||
};
|
||||
|
||||
SaveService.RollTerminalCampaignForward(data);
|
||||
|
||||
Assert.AreEqual(0, data.RunOutcome, "outcome latch reset");
|
||||
Assert.AreEqual(0, data.GoalCharge, "goal meter reset");
|
||||
Assert.AreEqual(0, data.CoreCurrent, "core resets to 0 -> restore guard re-maps to baked-full");
|
||||
Assert.AreEqual(4, data.RunsCompleted, "permanent counters survive");
|
||||
Assert.AreEqual(7, data.MaxDepthReached, "permanent counters survive");
|
||||
Assert.AreEqual(2, data.MetaUpgrades[0].Tier, "meta tiers survive");
|
||||
Assert.AreEqual(123, data.Ledger[0].Count, "the ledger survives");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void InProgressSave_AndNull_AreNoOps()
|
||||
{
|
||||
var data = new SaveData { RunOutcome = 0, GoalCharge = 2, CoreCurrent = 50 };
|
||||
SaveService.RollTerminalCampaignForward(data);
|
||||
Assert.AreEqual(2, data.GoalCharge);
|
||||
Assert.AreEqual(50, data.CoreCurrent);
|
||||
Assert.DoesNotThrow(() => SaveService.RollTerminalCampaignForward(null));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 085790610f52ea347aa4ccd208849316
|
||||
@@ -9,7 +9,7 @@ Multiplayer game on **Unity DOTS (Entities) + Netcode for Entities** — server-
|
||||
- **Size check** — bash: `wc -c CLAUDE.md` · PowerShell: `(Get-Item CLAUDE.md).Length`. Must be `< 40960`.
|
||||
- **Archive, don't delete.** When trimming, append the verbose / least-hot detail to the obsidian reference note `Docs/Vault/_Meta/CLAUDE_Build_Gotchas_Archive.md` under a **new dated heading** (never overwrite an older snapshot), and leave a one-line pointer + the relevant `[[DR-###]]` link here. Design rationale already lives in the per-milestone DRs (`Docs/Vault/07_Sessions/_Decisions/DR-###`).
|
||||
- **Net-zero rule:** every addition is paid for by a condensation elsewhere. Keep only the hottest, highest-recurrence operational rules inline (flag them **★**); depth lives in the archive + DRs.
|
||||
- Condensation history: 06-04 → 06-17 (M1–END-2 long-form + 6.5 stack swap → archive).
|
||||
- Condensation history: 06-04 → 06-17 (M1–END-2 long-form + 6.5 stack swap → archive) · 07-04 (base-mining core-loop bullet + stack-history sentence → archive; DR-044 supersedes).
|
||||
|
||||
## Stack — Unity 6.5.1 (`6000.5.1f1`, stable) as of 2026-06-27
|
||||
|
||||
@@ -26,7 +26,7 @@ Multiplayer game on **Unity DOTS (Entities) + Netcode for Entities** — server-
|
||||
| `com.unity.mathematics` | 1.4.0 | (transitive) |
|
||||
| `com.rukhanka.animation` | **2.9.0** | Local pkg (`Packages/com.rukhanka.animation`). ECS skeletal animation (Burst CPU/GPU skinning). Resolves on 6.5.0 via SemVer floor. Netcode replication **OFF** → client-derived. See [[DR-022_Animation_Pipeline_Rukhanka_Synty]]. |
|
||||
|
||||
Values match `packages-lock.json` (reconciled 2026-06-17; URP 17.5.0, test-framework 1.7.0, ugui 2.5.0, multiplayer.center 1.0.1). **History:** 6.4.7→6.5.0 (2026-06-17) put DOTS on unified 6.5 versioning (netcode/physics/transport left their old 1.x/2.x lines) — validated green (342/342 EditMode + a clean netcode Play boot: connect/ghost-sync/player-spawn), so the 6.6.0a6 "invalid wrapped network interface" transport bug does NOT hit 6.5-stable. See [[DR-002_Unity66_Alpha_Netcode_Transport]] + the gotchas archive.
|
||||
Values match `packages-lock.json` (reconciled 2026-06-17; URP 17.5.0, test-framework 1.7.0, ugui 2.5.0, multiplayer.center 1.0.1; `com.unity.ai.assistant` REMOVED 07-04 — console noise). **History:** the 6.4.7→6.5.0 unified-versioning swap (2026-06-17) validated green; the 6.6.0a6 transport bug does NOT hit 6.5-stable — detail in [[DR-002_Unity66_Alpha_Netcode_Transport]] + the gotchas archive.
|
||||
|
||||
## Namespaces & assembly split
|
||||
|
||||
@@ -88,13 +88,13 @@ Long-form originals + the milestone each came from: `Docs/Vault/_Meta/CLAUDE_Bui
|
||||
- **Build-grid math must be deterministic + integer-stable:** corner-origin, center-returning, **half-open** cell bounds, `math.floor`. Lock `CellSize`/`PlotSize` as a coordinate space once (`BaseGridMath`) — changing them invalidates placed structures.
|
||||
- **`PlacedStructure{[GhostField] byte Type; int2 Cell (server-only); uint NextTick; uint LastProcessedTick}`** on an ownerless interpolated ghost. **Bake the tick fields** (catch-up linchpin); only `Type` replicates (client derives `Cell`). **Occupancy is DERIVED** by scanning live ghosts into a Temp `NativeHashSet<int2>`, never baked. See [[DR-014_M6_Build_Structures_Automation_Foundation]].
|
||||
- **Co-op placement atomicity:** commit `StorageMath.Withdraw` + cell-reservation **in-place in the RPC foreach** (only `Instantiate` via ECB) so two same-tick requests for one cell can't both pass.
|
||||
- **EB-1 machines can die ★ (DR-032):** structures bake `Health`(`[GhostField]`)+`DamageEvent`+a `Destructible` tag; `HealthApplyDamageSystem` destroys a `Destructible` at 0 (NOT bare `PlacedStructure`; occupancy auto-frees). `EnemyAISystem` fortress-targets weighted-nearest players+structures (`EnemyAIMath.PickWeightedNearest`; snapshot ABOVE the early-return; `StructureAggroWeight`<1, SQUARED). See [[DR-032_EB1_Machines_Can_Die]].
|
||||
- **EB-1 machines can die ★ (DR-032; Fabricator parity restored 07-04 — a Health-less machine silently drops OUT of the AI aggro snapshot):** structures bake `Health`(`[GhostField]`)+`DamageEvent`+a `Destructible` tag; `HealthApplyDamageSystem` destroys a `Destructible` at 0 (NOT bare `PlacedStructure`; occupancy auto-frees). `EnemyAISystem` fortress-targets weighted-nearest players+structures (`EnemyAIMath.PickWeightedNearest`; snapshot ABOVE the early-return; `StructureAggroWeight`<1, SQUARED). See [[DR-032_EB1_Machines_Can_Die]].
|
||||
- **EB-2 felt spend ★ (DR-033):** turret ammo = shared `Charge`(`ResourceId` **4**) on the `[GhostField] StorageEntry` ledger. `TurretFireSystem` spends from the ONE `GetSingletonEntity<ResourceLedger>` (NEVER `GetSingleton<StorageEntry>`): afford→fire+cooldown, else **SOFT-FAIL** (no cooldown-burn). `Fabricator.InputFromLedger` reads the ledger **LIVE in-loop** (no hoist → machines split a finite pool). See [[DR-033_EB2_Felt_Spend_Charge_Economy]].
|
||||
- **END-1 losable Core ★ (DR-034):** `CoreIntegrity{[GhostField] int Current,Max; uint OverrunTick}` on the GLOBAL CycleDirector ghost. `CoreDamageSystem`/`CoreRestoreSystem` (server): a Husk near `PlotCenter` drains+despawns; regen ONLY in Calm. SOFT-loss edge IN `CyclePhaseSystem` (sole Phase writer): `Current<=0` in Siege → Calm (**NO** reward; drain+despawn; transient `OverrunTick`, NOT latching). Core = `EnemyAISystem` **FALLBACK** target. SaveData **v4**. See [[DR-034_END1_Losable_Core]].
|
||||
- **END-2 win/lose ★ (DR-036):** terminal run on the CycleDirector — server-only `RunPhase` (writer `GoalReachedSystem`, after CyclePhase) + **REPLICATED `RunOutcome{[GhostField] byte}`** (writer `CyclePhaseSystem`; replicate for the banner, do NOT client-derive). `GoalReached` arms a final siege ×`FinalSiegeMultiplier` at `Charge>=Target` (once)+FinalDefense; latch Victory/Loss+halt; **SiegeTimeout OFF in the final**; SaveData **v5**. See [[DR-036_END2_Final_Siege_Win_Lose]].
|
||||
- **`GoalProgress{[GhostField] int Charge,Target}`** (the goal meter — ≠ EB-2 `ResourceId.Charge` ammo) rides the CycleDirector ghost. Resource-gated ability tiers/buffs reuse `StatModifier` (`StatRecomputeSystem`→`EffectiveAbilityStats`).
|
||||
- **M7 Automation (server-only) ★:** `Harvester`/`Conveyor` TRIMMED from the palette (code intact), `Fabricator` LIVE (EB-2); plain server group; catch-up `ProductionMath.CyclesDue` (**lower-bound 0**); `RuntimePlacedTag`=player-built. See [[DR-020_M7_Automation_Production_Chains]].
|
||||
- **Harvest routes by node region (DR-031) ★; inventory/equipment PAUSED:** BASE→shared `ResourceLedger`, Expedition/un-tagged→PERSONAL `InventorySlot` (`[GhostField] OwnerSendType.All`, spill→ledger); `G`=deposit. Items/equip (`ItemDatabase` blob, `EquipSystem`) — **full detail in the gotchas archive (2026-06-12)**. See [[DR-026_Inventory_Equipment_Progression_Foundation]] · [[DR-031_Base_Mining_Loop_Cohesion]].
|
||||
- **Harvest routes by node region ★; inventory/equipment PAUSED:** in-run (Expedition) nodes→PERSONAL `InventorySlot` (`[GhostField] OwnerSendType.All`, spill→ledger); `G`=deposit at base. (Base nodes are GONE — DR-044.) Items/equip — **full detail in the gotchas archive (2026-06-12)**. See [[DR-026_Inventory_Equipment_Progression_Foundation]].
|
||||
- **Disk persistence (`SaveData`, single-slot atomic JSON, versioned/additive) ★:** **born-correct load** — `CycleDirectorSpawnSystem` stages `PendingSave` AT SPAWN; `BaseRestoreSystem` replays structures charge-free + REMAINING-tick cooldowns + per-structure HP. `SaveService.Load` = additive floor `[MinLoadableVersion=2, Current]` (old saves load; missing field 0-defaults). See [[DR-019_Frontend_Menu_Settings_Saves_Build]].
|
||||
|
||||
### Presentation / juice / VFX
|
||||
@@ -115,9 +115,7 @@ Long-form originals + the milestone each came from: `Docs/Vault/_Meta/CLAUDE_Bui
|
||||
- **A GA "projectile" prefab self-propels** (non-kinematic `Rigidbody`+collider+`ProjectileMoveScript`) — strip to particles before `Start` (`CombatFeedbackSystem.StripCosmetic`). Verify *components*, not the name.
|
||||
|
||||
### Aim controls
|
||||
- **Client-derived aim rides the EXISTING `PlayerInput.Aim` `[GhostField]`** — mouse-cursor aim computed in `PlayerInputGatherSystem` (managed `SystemBase`, `GhostInputSystemGroup`): `Mouse.current.position` → `Camera.main.ScreenPointToRay` → `AimMath.PlanarAimFromRay` (pure, unit-tested) → player→cursor direction. Only the direction crosses the wire; strafe-while-aiming is free (`Move` already decoupled from `Aim`).
|
||||
- **Active scheme = last-meaningful-actuation-wins, replicated as `byte`** (`PlayerInput.Scheme`, KBM=0/Gamepad=1 — byte because compared in Bursted `AbilityFireSystem`). Server gates the `AutoTarget` cone to gamepad only → precise mouse, gamepad-only assist.
|
||||
- **Cursor/reticle = client `PresentationSystemGroup` `SystemBase` (`AimReticleSystem`) that OBSERVES.** Re-raycast the KBM ground point INSIDE it (it runs after the follow-cam's LateUpdate; latching from the gather drifts a frame behind). Hardware cursor hidden while aiming + focused, restored on focus-loss.
|
||||
- **Client-derived aim rides the EXISTING `PlayerInput.Aim` `[GhostField]`** (`PlayerInputGatherSystem`, managed, `GhostInputSystemGroup`: cursor ray → `AimMath.PlanarAimFromRay`); only the direction crosses the wire. Scheme = last-meaningful-actuation-wins **`byte`** (`PlayerInput.Scheme`, KBM=0/Gamepad=1 — compared in Bursted `AbilityFireSystem`); server gates `AutoTarget` to gamepad only. `AimReticleSystem` (client presentation, observe-only) RE-raycasts the KBM ground point INSIDE itself (it runs after the follow-cam LateUpdate; latching from the gather drifts a frame); hardware cursor hidden while aiming+focused, restored on focus-loss.
|
||||
|
||||
### Animation (Rukhanka) ★
|
||||
Full rationale: [[DR-022_Animation_Pipeline_Rukhanka_Synty]] · [[DR-023_Enemy_Animation_MonsterMash]] · [[Synty_Asset_Inventory]]. Skeletal animation = **Rukhanka 2.9** (the only maintained Entities-native option on 6.4). **Netcode replication OFF** (`RUKHANKA_WITH_NETCODE` undefined) → **client-derived**: `PlayerAnimationDriveSystem` (client-only `SystemBase`, `[WorldSystemFilter(LocalSimulation|ClientSimulation)]` + `[UpdateBefore(RukhankaAnimationSystemGroup)]`) reads replicated state and writes params via `AnimatorParametersAspect`/`FastAnimatorParameter`. No new `[GhostField]`s; no `DefaultVariant` strip (define off → ghost hash unchanged).
|
||||
@@ -143,7 +141,7 @@ Full rationale: [[DR-022_Animation_Pipeline_Rukhanka_Synty]] · [[DR-023_Enemy_A
|
||||
|
||||
- `ProjectM.Simulation.GameBootstrap : ClientServerBootstrap` overrides `Initialize` with `AutoConnectPort = 0` (M4 — listen/connect is explicit via the `ConnectionConfig` singleton + per-world ConnectionControlSystems). **Editor default = instant-into-game + MPPM** (creates `ServerWorld` (`WorldFlags.GameServer`) + `ClientWorld` (`WorldFlags.GameClient`)); the `ProjectM/Boot Into Menu (Editor)` EditorPref flips the MAIN editor to the frontend path. **Player builds boot the UITK frontend menu** (`return false` → one menu world, no netcode worlds until a menu choice). See [[DR-019_Frontend_Menu_Settings_Saves_Build]].
|
||||
- **Scenes:** `Assets/Scenes/MainMenu.unity` (build index 0) boots the UITK frontend (menu world only); `Assets/Scenes/Game.unity` (index 1) holds gameplay with `Assets/_Project/Subscenes/Gameplay.unity` wired in as the baked subscene (GameObject `GameplaySubScene`). `SampleScene`/`DevSandbox` are kept as reference/dev scenes. The on-demand lifecycle (`WorldLauncher`/`SessionRunner`/`MainMenuController`) creates the right worlds per menu choice (Single/Host/Join), THEN `LoadScene(Game)` (subscene-streaming rule above).
|
||||
- **Core loop is base-local ★ (DR-031 · DR-042):** `BaseFieldSpawnSystem` (server) tops up `RegionTag{Base}` Ore nodes around `BaseGridMath.PlotCenter` (DISTINCT `BaseFieldSpawner`; `SetComponent`-override Region+Ore — Add throws; Ore-only). **Win = expedition CLEARS (DR-042):** `ExpeditionGateSystem` is sole writer of `GoalProgress.Charge` (+1/clear on RETURN); base sieges = **retaliation only**, blind `ScheduleEnabled` baked OFF (★ a serialized prefab bool ignores the C# initializer — flip `CycleDirector.prefab`, not the authoring default). `ExpeditionFieldSystem` teardown Expedition-filtered (else wipes base field); dormant expedition at `base+(1000,0,0)` hidden via `GhostRelevancy`. See [[DR-031_Base_Mining_Loop_Cohesion]] · [[DR-013_M6_Aether_Cycle_Region_Split]] · [[DR-042_Loop_Reshape_Expedition_Driven]].
|
||||
- **Core loop = ready-check multi-room RUNS ★ (DR-044; DR-031/042 base-mining text archived 07-04):** base = pure SPEND hub (no base nodes; `Tuning.StartingOre` grubstake). `RunDirectorSystem` = sole `RunInfo` writer (`[UpdateBefore(CyclePhaseSystem)]`; NOTHING else in the room chain touches CyclePhase); banks Charge+retaliation once-per-`RunEpoch` on boss-clear ONLY; strips the boon band + every `BoonOffer` on exit; teleports ONLY launch-stamped **`RunParticipant`s** (released on Returning — dead-respawned re-conscript, late joiners stay home). Rooms spawn per `RoomEpoch` at ping-pong sub-slots, `RoomTag`-torn-down; first slot waits `Tuning.RoomEntryGraceTicks`. ★ a serialized prefab bool ignores the C# initializer — flip `CycleDirector.prefab`. See [[DR-044_Expedition_Redesign_Shipped_Demo_Polish]].
|
||||
|
||||
## DOTS / ECS conventions (authoritative summary)
|
||||
|
||||
@@ -179,4 +177,4 @@ Full protocol + per-layer detail: [[Documentation_Protocol]] (`Docs/Vault/_Meta/
|
||||
|
||||
## Per-machine setup (NOT in git — redo on each machine)
|
||||
|
||||
`.mcp.json` is committed + portable (`${CLAUDE_PROJECT_DIR}`); the **`dots-dev` skill travels with the repo** (`.claude/skills/dots-dev/`). Each machine still needs: (1) `uv`/`uvx` + Obsidian app + `obsidian-cli` (the `unity-mcp-skill` + native `memory/` are machine-local, don't sync); (2) basic-memory registration — `uvx basic-memory project add gamevault "<repo>/Docs/Vault" --default` then `uvx basic-memory reindex --full --search --embeddings --project gamevault`; (3) Unity 6.4 open + the Unity-MCP bridge connected (`mcpforunity://editor/state` → `ready_for_tools`).
|
||||
`.mcp.json` is committed + portable (`${CLAUDE_PROJECT_DIR}`); the **`dots-dev` skill travels with the repo** (`.claude/skills/dots-dev/`). Each machine still needs: (1) `uv`/`uvx` + Obsidian app + `obsidian-cli` (the `unity-mcp-skill` + native `memory/` are machine-local, don't sync); (2) basic-memory registration — `uvx basic-memory project add gamevault "<repo>/Docs/Vault" --default` then `uvx basic-memory reindex --full --search --embeddings --project gamevault`; (3) Unity 6.5 open + the Unity-MCP bridge connected (`mcpforunity://editor/state` → `ready_for_tools`).
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
---
|
||||
title: Demo-Readiness Polish Pass — redesign closeout review + full four-area polish
|
||||
date: 2026-07-04
|
||||
tags: [session, polish, demo, review, netcode, hud, audio, onboarding, dots-dev]
|
||||
permalink: gamevault/07-sessions/2026/2026-07-04-demo-polish-pass
|
||||
---
|
||||
|
||||
# Demo-Readiness Polish Pass (/dots-dev, ultracode)
|
||||
|
||||
Operator brief: *"overall complete polish pass, ready for a game demo"* → intake locked: hands-off public demo,
|
||||
standalone Windows co-op build, finish-the-redesign baseline, all four polish areas. Full decisions + finding
|
||||
table: [[DR-044_Expedition_Redesign_Shipped_Demo_Polish]].
|
||||
|
||||
## How it went
|
||||
1. **Ground fan-out** (`wf_d3f1890a-8ca`, 3 read-only agents) — headline: the 07-02 log said "steps 1–11" but
|
||||
`Run Re-Do` already contained 12a/12b/13/14. Only the post-impl review + doc bookend were missing.
|
||||
2. **Baseline verify** — 452/452 EditMode + clean netcode Play boot (born-correct Staging on both worlds).
|
||||
3. **Post-impl adversarial review** (`wf_38ef58c2-8bd`, 3 lenses → per-finding critics, 15 agents, 0 failures)
|
||||
— 7 confirmed / 3 refuted. All 7 fixed same-session + pinned (`RunRosterRegressionTests`,
|
||||
`TerminalCampaignRollTests`); the two contradictory critic verdicts on the respawn stray line were
|
||||
adjudicated by reading the code (artifact confirmed, deleted).
|
||||
4. **Stability slice** — ConnectionWatchdog/SessionEnd/menu-reason plumbing; mode-aware outcome banner;
|
||||
terminal-save roll-forward; save-before-restart.
|
||||
5. **UI slice** — READY panel, drawn route map (Painter2D DAG + client path trace), onboarding re-author
|
||||
(the dead at-base Mine step was a first-run soft-lock), How-to-Play rewrite, DOWNED countdown.
|
||||
6. **Feel/audio slice** — 4-layer phase-locked procedural music, Fabricator can-die parity, core-hit alarm,
|
||||
landing grace, slash-arc reach fold (local+remote), Warrior cone VFX, boss bar+roar, rarity borders,
|
||||
depth dots, outcome flourish, shop pips, core-crystal tint system.
|
||||
7. **Sweep** — build scenes → MainMenu+Game only; `com.unity.ai.assistant` removed; corrupt whitespace-only
|
||||
`Simulation/Meta.meta` (committed in Run Re-Do; surfaced on the package re-resolve) deleted → regenerated.
|
||||
|
||||
## Validation
|
||||
456/456 EditMode · repeated Play smokes (launch conscription sampled tick-by-tick; wipe-abort self-validated;
|
||||
zero exceptions) · L3 screenshots: staging READY panel + meta shop, route map with lit path + depth dots,
|
||||
rarity boon modal. The route-map/boon states were forced via the disable-the-driver idiom
|
||||
(`ResolveSystemStateRef(handle).Enabled = false` on `RunDirectorSystem`, then write `RunInfo` server-side).
|
||||
|
||||
## Gotchas worth remembering
|
||||
- **A session log can lag the commit** — ground the working tree, not the log's "Remaining" section.
|
||||
- **Two critics can contradict each other** (one refuted, one confirmed the same stray line) — adjudicate at
|
||||
the source before acting.
|
||||
- **A committed `.meta` can be whitespace-corrupt and stay latent** until a package re-resolve forces a full
|
||||
reimport ("GUID cannot be extracted"); Unity's own advice applies — delete it, let it regenerate (folder
|
||||
GUIDs are unreferenced; script GUIDs live in their own metas).
|
||||
- **`CombatFeedbackSystem` already owns the `AbilityCooldown` fire-edge latch** (muzzle flash) — a second
|
||||
consumer needs its OWN latch field or it never sees the edge (CS0102 caught the collision).
|
||||
- The idle-player death→respawn→wipe-abort arc is the system working, not a bug — but it exposed the missing
|
||||
landing grace.
|
||||
|
||||
## Operator-side next steps
|
||||
Standalone build (`ProjectM/Build/Windows Player`) + 2-instance smoke: `-mhost` / `-mjoin 127.0.0.1`, then kill
|
||||
the host and expect `[ConnectionWatchdog] CONNECTION LOST…` in the joiner log (that line also proves the join
|
||||
connected); a dead-IP join should log the 12 s timeout. Onboarding fun-gate via Settings → Force Each Launch.
|
||||
Explicitly deferred to operator art curation: HudTheme structure icons, projectile/pickup placeholder meshes.
|
||||
@@ -0,0 +1,91 @@
|
||||
---
|
||||
id: DR-044
|
||||
title: Expedition Redesign SHIPPED (steps 1–14) + Demo-Readiness Polish Pass
|
||||
date: 2026-07-04
|
||||
status: locked
|
||||
tags: [decision, expedition, roguelite, netcode, demo, polish, onboarding, audio]
|
||||
supersedes: parts of DR-031/DR-042 (base-mining loop text); completes the [[2026-06-29_Expedition_Redesign_Build_Spec]]
|
||||
---
|
||||
|
||||
# DR-044 — Expedition Redesign SHIPPED + Demo-Readiness Polish
|
||||
|
||||
## 1. The redesign is COMPLETE and reviewed
|
||||
|
||||
All 14 steps of the [[2026-06-29_Expedition_Redesign_Build_Spec]] (§§1–6 + §7 addendum) are built and live in
|
||||
commit `16e396841` "Run Re-Do" — the 2026-07-02 interim log stopped at "steps 1–11" but the commit itself
|
||||
contains 12a/12b/13 (permanent meta: `MetaCatalog` blob + born-correct seeding + SaveData **v6** +
|
||||
`MetaSpendSystem`) and 14 (HUD panels + biome + screenshots). **Lesson recorded: verify the working tree
|
||||
against the session log before planning — logs can lag the commit.**
|
||||
|
||||
The never-run **post-impl adversarial review** (3 lenses + per-finding critics, `wf_38ef58c2-8bd`, 15 agents)
|
||||
confirmed 7 distinct findings — ALL fixed + regression-pinned this session (456/456 EditMode):
|
||||
|
||||
| # | Finding (severity) | Fix |
|
||||
|---|---|---|
|
||||
| 1 | Stale `BoonOffer.Pending` on a dead-respawned (base-region) player stalls every reward gate 30 s and wedges the modal into the next fight (major) | RoomReward gate counts EXPEDITION players only; NO offer survives the gate exit (both paths zero all offers); HUD modal gated on `Lifecycle==RoomReward`. Pinned in `RunRosterRegressionTests`. |
|
||||
| 2 | `EnterRoom` conscripts EVERY `PlayerTag` — a mid-run late joiner is yanked into the fight (medium) | Server-only **`RunParticipant`** roster tag: stamped on all players at the launch edge, gates every advance/return teleport, released on Returning. Dead-respawned members keep the tag → re-conscripted (operator-locked default); late joiners stay home. |
|
||||
| 3 | HUD launch countdown used raw uint tick subtraction → wraps to ~71 M when the client's predicted tick passes `LaunchTick` (medium) | Signed `NetworkTick.TicksSince` with a >0 clamp (the ★ tick rule). |
|
||||
| 4 | `PlayerRespawnSystem` stray line unconditionally overwrote the spawn-center fallback with `PlotCenter(default)` (medium; edit-tool artifact) | Stray line deleted. |
|
||||
| 5 | Boon modal keyed on a lossy byte XOR — 1/256 stale-label collision on the unpicked-offer path (minor) | Exact `(o0\|o1<<8\|o2<<16)+1` int signature. |
|
||||
| 6 | HUD meta shop missed the D-F5 tier display clamp (minor) | Clamped. |
|
||||
| 7 | Edit-tool text artifacts in 5 files (comment mangles, stray `;;`) (minor) | All cleaned. Corrupt whitespace-only `Simulation/Meta.meta` (committed in Run Re-Do) deleted → Unity regenerated. |
|
||||
|
||||
## 2. Demo-readiness decisions (operator-locked this session)
|
||||
|
||||
Demo = **hands-off public play, standalone Windows co-op build**. Baseline = finish redesign, then polish all
|
||||
four areas (stability · combat feel · UI/session flow · audio/atmosphere).
|
||||
|
||||
- **Connection watchdog (`ConnectionWatchdogSystem` + `SessionEnd`):** a pure-join client whose ACCEPTED
|
||||
connection vanishes tears down to the menu with "CONNECTION LOST — the host ended the session."; a join that
|
||||
never connects times out at 12 s; an unparseable IP aborts immediately (`SessionEnd.Abort`, reason set by
|
||||
`ClientConnectionControlSystem`). `MainMenuController` shows the reason once. `[ConnectionWatchdog]` log
|
||||
lines double as join-success evidence in headless build smokes.
|
||||
- **PLAY AGAIN = clean teardown for all (operator fork):** the outcome banner branches on
|
||||
`WorldLauncher.LastMode` — Host: "END SESSION — ALL TO MENU"; Join: "LEAVE TO MENU"; Single: PLAY AGAIN
|
||||
Continues (loadSave). `StartSession` now checkpoints a live server before disposal.
|
||||
- **Terminal-save roll-forward (`SaveService.RollTerminalCampaignForward`):** a Victory/Loss save Continues as
|
||||
a FRESH campaign — outcome/goal/core reset to 0 (restore guards re-map), meta tiers/counters/ledger/
|
||||
structures survive. Without it Continue re-latched the dead banner from the first snapshot. EditMode-pinned.
|
||||
- **Procedural music (operator fork → asset-free):** `MusicSystem` — four phase-locked 9.6 s loop layers
|
||||
(bass/pad/arp/pulse; Am–F–C–G @100 BPM; bar-edge envelopes = click-free loops & chord changes), mixed by
|
||||
replicated state (Staging/launch/combat/boss/lull/siege/final-siege/outcome) + victory/defeat stings.
|
||||
- **Drawn route map (operator fork):** Slay-the-Spire node-map panel in `HudSystem` — client regenerates the
|
||||
full DAG from `RunInfo.RunSeed` (display only; Painter2D edges), walked path traced client-side, clickable
|
||||
next-layer nodes bind ONLY to the authoritative `RouteOpt*` bytes.
|
||||
- **Onboarding re-authored:** the old at-base **Mine step was a first-run SOFT-LOCK** (base nodes were removed
|
||||
in Step 11 — `OreNow > OreBaseline` could never satisfy at base). New cascade: Welcome → Move → Build
|
||||
(grubstake) → Fabricator → **ReadyUp** → Rooms (mine in-run) → Boon → Return → Defend → Done. Same 10 bits
|
||||
(re-meant, never renamed); veteran masks stay dormant. How-to-Play "The Loop"/Threats/Controls/Economy tabs
|
||||
rewritten for the run loop.
|
||||
|
||||
## 3. Polish additions (this session)
|
||||
|
||||
Clickable READY panel (toggle + party pips + 3-2-1 countdown + abort) · run-depth dots (room i/N through the
|
||||
whole run) · boss presence bar ("ALPHA HUSK" + red HP; client heuristic: a Boss room spawns exactly ONE enemy)
|
||||
+ arrival roar · boon rarity borders from draw `Weight` (100/60/30/10 → quiet/green/violet/gold) · meta-shop
|
||||
tier pips + auto-height rows · outcome flash + camera kick on the banner edge · DOWNED respawn countdown
|
||||
(client-local: death-edge latch + baked `DelayTicks`) · core crystal dims/blood-shifts with `CoreIntegrity` +
|
||||
white-hot hit flash (`CoreVisualFeedbackSystem` — the crystals are CLASSIC GameObjects in Game.unity;
|
||||
MaterialPropertyBlock, property-guarded) + core-hit alarm sting · countdown tick beeps ·
|
||||
**`Tuning.RoomEntryGraceTicks` = 100** (landing grace before the first enemy slot — an idle player died in
|
||||
~2 s of teleport-in) · **slash-arc reach fix**: the arc now folds the player's `StatModifier`s
|
||||
(`StatMath.Apply(MeleeRange)`) exactly like the server — class seed/boons/meta were missing from the visual
|
||||
(local AND remote arcs) · **Warrior cone ability VFX** (Slice-2 deferred): predicted `AbilityCooldown` edge +
|
||||
blob archetype gate → slash-arc sweep with the ability's effective geometry · Fabricator **can-die parity**
|
||||
(DR-032: bakes Health 120/DamageEvent/Destructible; it was invulnerable-by-exclusion from the AI snapshot) ·
|
||||
Staging top-line copy → intent framing (the READY panel owns the action) · EditorBuildSettings trimmed to
|
||||
MainMenu+Game · `com.unity.ai.assistant` package removed (console noise).
|
||||
|
||||
## 4. Validation
|
||||
|
||||
456/456 EditMode (from 452 at session start; +4 new pins, 2 traversal/2 room-director tests updated to the new
|
||||
contracts). Live Play: launch conscription arc sampled (Staging→Launching→InRoom, x=1000, participant=True),
|
||||
wipe-abort self-validated twice, L3 screenshots (READY panel · route map with traced path · rarity boon modal ·
|
||||
depth dots · meta shop). Console clean (the only remaining editor noise was the removed AI package).
|
||||
|
||||
## 5. Open / operator-side
|
||||
|
||||
Standalone build + 2-instance LAN smoke (operator runs it; `-mhost`/`-mjoin`, watchdog log lines = evidence) ·
|
||||
onboarding fun-gate playthrough (Force-Each-Launch toggle) · tick-batching perf re-measure in the build ·
|
||||
HudTheme structure icons vs new silhouettes + projectile/pickup placeholder meshes (art curation — needs
|
||||
operator eyes; explicitly deferred) · GoalProgress.Target demo pacing (currently 4 runs).
|
||||
@@ -395,3 +395,17 @@ DR-043's first-run onboarding shipped + Play-validated green (20/20 EditMode; li
|
||||
- **Forcing a specific step in a live Play smoke without input:** the step machine's `_step`/`_mask`/`_stepInit` are private — set them via reflection on `world.GetExistingSystemManaged(typeof(OnboardingSystem))`; remember an MCP `screenshot` can leave the editor **paused** (`EditorApplication.isPaused`), so the field write won't surface until you unpause. Pause + reflection-restyle the `_pointer` Label is also how to get a clean glyph capture (OnUpdate would otherwise overwrite the style next frame).
|
||||
|
||||
Net-zero: archive-only add (no CLAUDE.md bytes changed), so no inline condensation needed.
|
||||
|
||||
## 2026-07-04 — Demo polish pass: superseded CLAUDE.md core-loop bullet (archived verbatim)
|
||||
|
||||
Replaced in CLAUDE.md by the DR-044 ready-check-runs bullet (the expedition redesign retired
|
||||
`BaseFieldSpawnSystem`/`ExpeditionGateSystem`/`ExpeditionFieldSystem`):
|
||||
|
||||
> - **Core loop is base-local ★ (DR-031 · DR-042):** `BaseFieldSpawnSystem` (server) tops up `RegionTag{Base}` Ore nodes around `BaseGridMath.PlotCenter` (DISTINCT `BaseFieldSpawner`; `SetComponent`-override Region+Ore — Add throws; Ore-only). **Win = expedition CLEARS (DR-042):** `ExpeditionGateSystem` is sole writer of `GoalProgress.Charge` (+1/clear on RETURN); base sieges = **retaliation only**, blind `ScheduleEnabled` baked OFF (★ a serialized prefab bool ignores the C# initializer — flip `CycleDirector.prefab`, not the authoring default). `ExpeditionFieldSystem` teardown Expedition-filtered (else wipes base field); dormant expedition at `base+(1000,0,0)` hidden via `GhostRelevancy`. See [[DR-031_Base_Mining_Loop_Cohesion]] · [[DR-013_M6_Aether_Cycle_Region_Split]] · [[DR-042_Loop_Reshape_Expedition_Driven]].
|
||||
|
||||
Also archived from the same pass: the Stack "History" long sentence (6.4.7→6.5.0 validation detail — DR-002 +
|
||||
the 2026-06-17 session log hold it), and the harvest bullet's dead BASE→ledger half (base nodes no longer
|
||||
exist; in-run nodes → personal `InventorySlot`, spill→ledger, `G`=deposit is unchanged). One-off corruption
|
||||
note: `Assets/_Project/Scripts/Simulation/Meta.meta` was committed whitespace-only in `Run Re-Do` and stayed
|
||||
latent until a package re-resolve forced a full reimport ("The GUID … cannot be extracted") — deleted so Unity
|
||||
regenerated it; folder-meta GUIDs are unreferenced, so regeneration is safe.
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
{
|
||||
"dependencies": {
|
||||
"com.coplaydev.unity-mcp": "https://github.com/CoplayDev/unity-mcp.git?path=/MCPForUnity#main",
|
||||
"com.unity.ai.assistant": "2.12.0-pre.2",
|
||||
"com.unity.ai.inference": "2.6.1",
|
||||
"com.unity.ai.navigation": "2.0.13",
|
||||
"com.unity.charactercontroller": "1.4.2",
|
||||
|
||||
Generated
-20
@@ -39,26 +39,6 @@
|
||||
"source": "embedded",
|
||||
"dependencies": {}
|
||||
},
|
||||
"com.unity.2d.sprite": {
|
||||
"version": "1.0.0",
|
||||
"depth": 1,
|
||||
"source": "builtin",
|
||||
"dependencies": {}
|
||||
},
|
||||
"com.unity.ai.assistant": {
|
||||
"version": "2.12.0-pre.2",
|
||||
"depth": 0,
|
||||
"source": "registry",
|
||||
"dependencies": {
|
||||
"com.unity.2d.sprite": "1.0.0",
|
||||
"com.unity.mathematics": "1.3.2",
|
||||
"com.unity.nuget.mono-cecil": "1.11.5",
|
||||
"com.unity.modules.uielements": "1.0.0",
|
||||
"com.unity.nuget.newtonsoft-json": "3.2.1",
|
||||
"com.unity.modules.unitywebrequest": "1.0.0"
|
||||
},
|
||||
"url": "https://packages.unity.com"
|
||||
},
|
||||
"com.unity.ai.inference": {
|
||||
"version": "2.6.1",
|
||||
"depth": 0,
|
||||
|
||||
@@ -11,12 +11,6 @@ EditorBuildSettings:
|
||||
- enabled: 1
|
||||
path: Assets/Scenes/Game.unity
|
||||
guid: db8738f16e802c8488f9c22aa56e1e60
|
||||
- enabled: 1
|
||||
path: Assets/Scenes/DevSandbox.unity
|
||||
guid: 1d4b5e92ac7b6ea4880d30545df94704
|
||||
- enabled: 1
|
||||
path: Assets/Scenes/SampleScene.unity
|
||||
guid: 99c9720ab356a0642a771bea13969a05
|
||||
m_configObjects:
|
||||
com.unity.dt.app-ui: {fileID: 11400000, guid: 1b1c20d82303e4b5781c3ef50ac1449f, type: 2}
|
||||
com.unity.input.settings.actions: {fileID: -944628639613478452, guid: 5aa62f6ed584c43b791e76f2fd31820f, type: 3}
|
||||
|
||||
@@ -36,6 +36,15 @@ GraphicsSettings:
|
||||
- {fileID: 10783, guid: 0000000000000000f000000000000000, type: 0}
|
||||
m_PreloadedShaders: []
|
||||
m_PreloadShadersBatchTimeLimit: -1
|
||||
m_GraphicsStateCollection: {fileID: 0}
|
||||
m_CollectionStartupAction: 0
|
||||
m_TraceSavePath: TracedCollection
|
||||
m_TraceSendToEditor: 1
|
||||
m_AdditionalWarmupCollections: []
|
||||
m_WarmupAsync: 1
|
||||
m_EnableCacheMissTracing: 0
|
||||
m_WarmupProgressivelyLimit: -1
|
||||
m_CacheMissCollectionPath: CacheMissCollection
|
||||
m_SpritesDefaultMaterial: {fileID: 10754, guid: 0000000000000000f000000000000000, type: 0}
|
||||
m_CustomRenderPipeline: {fileID: 11400000, guid: 4b83569d67af61e458304325a23e5dfd, type: 2}
|
||||
m_TransparencySortMode: 0
|
||||
@@ -61,6 +70,8 @@ GraphicsSettings:
|
||||
UnityEngine.Rendering.Universal.UniversalRenderPipeline: {fileID: 11400000, guid: 18dc0cd2c080841dea60987a38ce93fa, type: 2}
|
||||
m_ShaderBuildSettings:
|
||||
keywordDeclarationOverrides: []
|
||||
numInternalDefines: 0
|
||||
defines: []
|
||||
m_LightsUseLinearIntensity: 1
|
||||
m_LightsUseColorTemperature: 1
|
||||
m_LogWhenShaderIsCompiled: 0
|
||||
|
||||
@@ -149,8 +149,7 @@ PlayerSettings:
|
||||
visionOSBundleVersion: 1.0
|
||||
tvOSBundleVersion: 1.0
|
||||
bundleVersion: 0.1.0
|
||||
preloadedAssets:
|
||||
- {fileID: 11400000, guid: 44cd3062bcdc07e43aaec59b78f224a6, type: 2}
|
||||
preloadedAssets: []
|
||||
metroInputSource: 0
|
||||
wsaTransparentSwapchain: 0
|
||||
xboxOneDisableKinectGpuReservation: 1
|
||||
|
||||
Reference in New Issue
Block a user