Polishes
This commit is contained in:
+41
-27
@@ -1,22 +1,24 @@
|
|||||||
# 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"
|
project_name: "Project-M"
|
||||||
|
|
||||||
|
# list of languages for which language servers are started (LSP backend only); choose from:
|
||||||
# list of languages for which language servers are started; choose from:
|
# ada al angular ansible bash
|
||||||
# al angular ansible bash clojure
|
# bsl clojure cpp cpp_ccls crystal
|
||||||
# cpp cpp_ccls crystal csharp csharp_omnisharp
|
# csharp csharp_omnisharp cue dart elixir
|
||||||
# dart elixir elm erlang fortran
|
# elm erlang fortran fsharp gdscript
|
||||||
# fsharp go groovy haskell haxe
|
# go groovy haskell haxe hlsl
|
||||||
# hlsl html java json julia
|
# html java json julia kotlin
|
||||||
# kotlin lean4 lua luau markdown
|
# latex lean4 lua luau markdown
|
||||||
# matlab msl nix ocaml pascal
|
# matlab msl nix ocaml pascal
|
||||||
# perl php php_phpactor powershell python
|
# perl php php_phpactor php_phpantom powershell
|
||||||
# python_jedi python_ty r rego ruby
|
# python python_jedi python_pyrefly python_ty r
|
||||||
# ruby_solargraph rust scala scss solidity
|
# rego ruby ruby_solargraph rust scala
|
||||||
# svelte swift systemverilog terraform toml
|
# scss solidity svelte swift systemverilog
|
||||||
# typescript typescript_vts vue yaml zig
|
# terraform toml typescript typescript_vts vue
|
||||||
# (This list may be outdated. For the current list, see values of Language enum here:
|
# yaml zig
|
||||||
# https://github.com/oraios/serena/blob/main/src/solidlsp/ls_config.py
|
# (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.)
|
# For some languages, there are alternative language servers, e.g. csharp_omnisharp, ruby_solargraph.)
|
||||||
# Note:
|
# Note:
|
||||||
# - For C, use cpp
|
# - For C, use cpp
|
||||||
@@ -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
|
# See https://oraios.github.io/serena/02-usage/050_configuration.html#language-server-specific-settings
|
||||||
ls_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.
|
# list of additional paths to ignore in this project.
|
||||||
# Same syntax as gitignore, so you can use * and **.
|
# Same syntax as gitignore, so you can use * and **.
|
||||||
# Note: global ignored_paths from serena_config.yml are also applied additively.
|
# 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).
|
# maximum time in seconds to wait for activation_command to complete before killing it (default 180s).
|
||||||
# must be a positive number.
|
# must be a positive number.
|
||||||
activation_command_timeout: 180.0
|
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_MixedLightingSupported: 1
|
||||||
m_SupportsLightCookies: 1
|
m_SupportsLightCookies: 1
|
||||||
m_SupportsLightLayers: 1
|
m_SupportsLightLayers: 1
|
||||||
m_DebugLevel: 0
|
|
||||||
m_StoreActionsOptimization: 0
|
m_StoreActionsOptimization: 0
|
||||||
m_UseAdaptivePerformance: 1
|
m_UseAdaptivePerformance: 1
|
||||||
m_ColorGradingMode: 1
|
m_ColorGradingMode: 1
|
||||||
|
|||||||
@@ -73,7 +73,22 @@ MonoBehaviour:
|
|||||||
- rid: 6670095651985424593
|
- rid: 6670095651985424593
|
||||||
- rid: 6670095651985424594
|
- rid: 6670095651985424594
|
||||||
m_RuntimeSettings:
|
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_AssetVersion: 10
|
||||||
m_ObsoleteDefaultVolumeProfile: {fileID: 0}
|
m_ObsoleteDefaultVolumeProfile: {fileID: 0}
|
||||||
m_RenderingLayerNames:
|
m_RenderingLayerNames:
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ namespace ProjectM.Authoring
|
|||||||
[Min(1)] public int PeriodTicks = 30;
|
[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.")]
|
[Tooltip("EB-2: 1 = ledger-fed (consume the input from the shared ledger; no conveyor). 0 = M7 MachineInput chain.")]
|
||||||
public byte InputFromLedger = 1;
|
public byte InputFromLedger = 1;
|
||||||
|
[Min(1f)] public float MaxHp = 120f;
|
||||||
|
|
||||||
private class FabricatorBaker : Baker<FabricatorAuthoring>
|
private class FabricatorBaker : Baker<FabricatorAuthoring>
|
||||||
{
|
{
|
||||||
@@ -46,6 +47,14 @@ namespace ProjectM.Authoring
|
|||||||
InputFromLedger = authoring.InputFromLedger,
|
InputFromLedger = authoring.InputFromLedger,
|
||||||
});
|
});
|
||||||
AddBuffer<MachineInput>(entity);
|
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
|
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 +
|
// (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
|
#if UNITY_EDITOR
|
||||||
struct PendingBuild { public byte Type; public int CellX; public int CellZ; public byte Direction; }
|
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);
|
byte reason = BuildPreviewMath.Evaluate(anchor, targetCell, occupied, LedgerOre(), cost);
|
||||||
bool valid = reason == BuildPreviewMath.Valid;
|
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).
|
// Place on a left-click (valid, not the selecting click).
|
||||||
if (valid && !justSelected && mouse.leftButton.wasPressedThisFrame)
|
if (valid && !justSelected && mouse.leftButton.wasPressedThisFrame)
|
||||||
|
|||||||
@@ -36,6 +36,8 @@ namespace ProjectM.Client
|
|||||||
if (!NetworkEndpoint.TryParse(address.ToString(), port, out var endpoint, NetworkFamily.Ipv4))
|
if (!NetworkEndpoint.TryParse(address.ToString(), port, out var endpoint, NetworkFamily.Ipv4))
|
||||||
{
|
{
|
||||||
Debug.LogError($"[ClientConnectionControlSystem] Invalid join address '{address}'.");
|
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;
|
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
|
/// observable client state each step reads, the deterministic per-step completion test, prompt copy, the
|
||||||
/// spatial-cue kind, and the persisted-mask helpers.
|
/// 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),
|
/// 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
|
/// 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
|
/// 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) ----
|
// ---- 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 Welcome = 0; // tiny win-condition framing strip (timed)
|
||||||
public const byte Move = 1;
|
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 = 2; // open palette + place a Turret (the 50-Ore grubstake covers it)
|
||||||
public const byte Build = 3; // open palette + place a Turret
|
public const byte Fabricator = 3; // Ore -> Charge (soft info beat)
|
||||||
public const byte Fabricator = 4; // Ore -> Charge (soft info beat)
|
public const byte ReadyUp = 4; // press T / click READY — the party launches together
|
||||||
public const byte Gate = 5; // reach the Expedition Gate
|
public const byte Rooms = 5; // fight the rooms; attack crystal nodes to haul resources
|
||||||
public const byte Clear = 6; // clear the expedition wave (the first real enemies)
|
public const byte Boon = 6; // clear a room -> pick 1 of 3 boons + choose the path
|
||||||
public const byte Return = 7; // walk back to base to bank +1 charge
|
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 Defend = 8; // survive the retaliation siege (soft info beat)
|
||||||
public const byte Done = 9; // closing beat
|
public const byte Done = 9; // closing beat
|
||||||
public const byte StepCount = 10;
|
public const byte StepCount = 10;
|
||||||
@@ -40,18 +46,18 @@ namespace ProjectM.Client
|
|||||||
// ---- spatial-cue kinds the System resolves to a live world target ----
|
// ---- spatial-cue kinds the System resolves to a live world target ----
|
||||||
public const byte PointerNone = 0;
|
public const byte PointerNone = 0;
|
||||||
public const byte PointerOreNode = 1;
|
public const byte PointerOreNode = 1;
|
||||||
public const byte PointerBaseGate = 2; // the base-region gate (go to the expedition)
|
public const byte PointerBaseGate = 2; // RETIRED target (walk-in gate died with the redesign); byte kept
|
||||||
public const byte PointerExpeditionGate = 3; // the expedition-region gate (return home)
|
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>
|
/// <summary>Observable client state for one evaluation. Built by the System from ECS + input each frame.</summary>
|
||||||
public struct Snapshot
|
public struct Snapshot
|
||||||
{
|
{
|
||||||
public float StepElapsed; // seconds the current step has been shown
|
public float StepElapsed; // seconds the current step has been shown
|
||||||
public float MoveDistance; // accumulated player movement since the Move step began
|
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 TurretCount; // live Turret structures (absolute)
|
||||||
public int FabricatorCount; // live Fabricator 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 bool OnExpedition; // local player is in the expedition region
|
||||||
public byte ObjectiveState; // ExpeditionObjective.State (Idle/Active/Cleared)
|
public byte ObjectiveState; // ExpeditionObjective.State (Idle/Active/Cleared)
|
||||||
public bool SawSiege; // a Siege phase was observed while the Defend step was showing
|
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 Welcome: return s.StepElapsed >= WelcomeSeconds;
|
||||||
case Move: return s.MoveDistance >= MoveThreshold;
|
case Move: return s.MoveDistance >= MoveThreshold;
|
||||||
case Mine: return s.OreNow > s.OreBaseline;
|
|
||||||
case Build: return s.TurretCount >= 1;
|
case Build: return s.TurretCount >= 1;
|
||||||
case Fabricator: return s.FabricatorCount >= 1 || s.StepElapsed >= FabricatorSoftSeconds;
|
case Fabricator: return s.FabricatorCount >= 1 || s.StepElapsed >= FabricatorSoftSeconds;
|
||||||
case Gate: return s.OnExpedition || s.ObjectiveState == ExpeditionObjectiveState.Active;
|
case ReadyUp: return s.LocalReady || s.Lifecycle != RunLifecycle.Staging;
|
||||||
case Clear: return s.ObjectiveState == ExpeditionObjectiveState.Cleared;
|
case Rooms: return s.OnExpedition || s.ObjectiveState == ExpeditionObjectiveState.Active;
|
||||||
case Return: return !s.OnExpedition; // entered while on expedition; satisfied on crossing home
|
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 Defend: return s.SawSiege ? s.Phase == CyclePhase.Calm : s.StepElapsed >= DefendNoSiegeSeconds;
|
||||||
case Done: return s.StepElapsed >= DoneSeconds;
|
case Done: return s.StepElapsed >= DoneSeconds;
|
||||||
default: return true;
|
default: return true;
|
||||||
@@ -82,9 +90,7 @@ namespace ProjectM.Client
|
|||||||
{
|
{
|
||||||
switch (step)
|
switch (step)
|
||||||
{
|
{
|
||||||
case Mine: return PointerOreNode;
|
case Rooms: return PointerOreNode; // in-run crystal nodes (base nodes no longer exist)
|
||||||
case Gate: return PointerBaseGate;
|
|
||||||
case Return: return PointerExpeditionGate;
|
|
||||||
default: return PointerNone;
|
default: return PointerNone;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -93,19 +99,18 @@ namespace ProjectM.Client
|
|||||||
public static string Prompt(byte step, bool gamepad)
|
public static string Prompt(byte step, bool gamepad)
|
||||||
{
|
{
|
||||||
string move = gamepad ? "Left Stick" : "WASD";
|
string move = gamepad ? "Left Stick" : "WASD";
|
||||||
string attack = gamepad ? "RT" : "LMB";
|
|
||||||
string build = gamepad ? "Y" : "Tab"; // matches the existing HUD build-discovery chip glyph
|
string build = gamepad ? "Y" : "Tab"; // matches the existing HUD build-discovery chip glyph
|
||||||
switch (step)
|
switch (step)
|
||||||
{
|
{
|
||||||
case Welcome: return "CLEAR EXPEDITIONS to charge the Engine — defend the Core while you do. (Esc → Pause → How to Play)";
|
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 Move: return move + " — Move";
|
||||||
case Mine: return attack + " — Attack the glowing Ore to mine it";
|
case Build: return build + " — open Build, place a Turret by your Core (your 50 starting Ore covers it)";
|
||||||
case Build: return build + " — open Build, then place a Turret by your Core";
|
|
||||||
case Fabricator: return "Build a Fabricator — turrets need Charge (Ore → ammo)";
|
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 ReadyUp: return "Press T (or click READY UP) — when everyone is ready, the party launches";
|
||||||
case Clear: return "Clear each room — pick a boon, choose your path, reach the boss";
|
case Rooms: return "Fight through the rooms — attack the glowing crystals to haul resources home";
|
||||||
case Return: return "Fell the boss — you return home and the Engine charges (+1)";;
|
case Boon: return "Clear the room — pick 1 of 3 BOONS, then choose your path on the map";
|
||||||
case Defend: return "Defend the Core! — hold the line through the siege";
|
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.";
|
case Done: return "You've got it. Clear expeditions to fill the Engine and win.";
|
||||||
default: return "";
|
default: return "";
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -41,7 +41,6 @@ namespace ProjectM.Client
|
|||||||
float _stepElapsed;
|
float _stepElapsed;
|
||||||
float _moveAccum;
|
float _moveAccum;
|
||||||
float3 _lastPos;
|
float3 _lastPos;
|
||||||
int _oreBaseline;
|
|
||||||
bool _sawSiege;
|
bool _sawSiege;
|
||||||
|
|
||||||
protected override void OnStartRunning()
|
protected override void OnStartRunning()
|
||||||
@@ -92,7 +91,11 @@ namespace ProjectM.Client
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ---- remaining observable state ----
|
// ---- 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);
|
CountStructures(out int turrets, out int fabs);
|
||||||
byte phase = CyclePhase.Calm;
|
byte phase = CyclePhase.Calm;
|
||||||
if (SystemAPI.TryGetSingleton<CycleState>(out var cyc)) phase = cyc.Phase;
|
if (SystemAPI.TryGetSingleton<CycleState>(out var cyc)) phase = cyc.Phase;
|
||||||
@@ -104,7 +107,7 @@ namespace ProjectM.Client
|
|||||||
if (!_stepInit)
|
if (!_stepInit)
|
||||||
{
|
{
|
||||||
_stepElapsed = 0f; _moveAccum = 0f; _sawSiege = false;
|
_stepElapsed = 0f; _moveAccum = 0f; _sawSiege = false;
|
||||||
_oreBaseline = ore; _lastPos = playerPos;
|
_lastPos = playerPos;
|
||||||
_stepInit = true;
|
_stepInit = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -120,8 +123,8 @@ namespace ProjectM.Client
|
|||||||
{
|
{
|
||||||
StepElapsed = _stepElapsed,
|
StepElapsed = _stepElapsed,
|
||||||
MoveDistance = _moveAccum,
|
MoveDistance = _moveAccum,
|
||||||
OreNow = ore,
|
LocalReady = localReady,
|
||||||
OreBaseline = _oreBaseline,
|
Lifecycle = lifecycle,
|
||||||
TurretCount = turrets,
|
TurretCount = turrets,
|
||||||
FabricatorCount = fabs,
|
FabricatorCount = fabs,
|
||||||
OnExpedition = onExp,
|
OnExpedition = onExp,
|
||||||
@@ -159,16 +162,7 @@ namespace ProjectM.Client
|
|||||||
|
|
||||||
// ---- state gathering helpers ----
|
// ---- 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)
|
void CountStructures(out int turrets, out int fabs)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -27,6 +27,12 @@ namespace ProjectM.Client
|
|||||||
|
|
||||||
byte _lastPhase;
|
byte _lastPhase;
|
||||||
bool _phaseInit;
|
bool _phaseInit;
|
||||||
|
AudioClip _stingCoreHit;
|
||||||
|
int _lastCore = -1;
|
||||||
|
float _coreStingCooldown;
|
||||||
|
AudioClip _stingBeep, _stingRoar;
|
||||||
|
int _lastCountdownSec = -1;
|
||||||
|
bool _bossRoared;
|
||||||
|
|
||||||
const float AmbientBaseVolume = 0.10f; // low bed; Defend eases up to ~1.7x
|
const float AmbientBaseVolume = 0.10f; // low 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"
|
_stingExpedition = MakeSting(520f, 880f, 0.45f, 0.30f); // airy rising "deploy"
|
||||||
_stingDefend = MakeSting(300f, 140f, 0.55f, 0.42f); // tense falling "wave incoming"
|
_stingDefend = MakeSting(300f, 140f, 0.55f, 0.42f); // tense falling "wave incoming"
|
||||||
_stingBuild = MakeSting(440f, 660f, 0.40f, 0.26f); // soft confirm
|
_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()
|
protected override void OnStartRunning()
|
||||||
@@ -76,6 +85,50 @@ namespace ProjectM.Client
|
|||||||
// Ease the drone intensity toward the phase target (tenser during Defend).
|
// Ease the drone intensity toward the phase target (tenser during Defend).
|
||||||
float target = phase == CyclePhase.Siege ? AmbientBaseVolume * 1.7f : AmbientBaseVolume;
|
float target = phase == CyclePhase.Siege ? AmbientBaseVolume * 1.7f : AmbientBaseVolume;
|
||||||
_ambient.volume = Mathf.MoveTowards(_ambient.volume, target * GameVolume.Music, SystemAPI.Time.DeltaTime * 0.25f);
|
_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)
|
void PlaySting(byte phase)
|
||||||
|
|||||||
@@ -62,6 +62,8 @@ namespace ProjectM.Client
|
|||||||
bool _slashActive;
|
bool _slashActive;
|
||||||
float _slashRange, _slashHalf; // live cone geometry re-sampled each frame for the per-frame sweep rebuild
|
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)
|
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;
|
Material _dangerMat;
|
||||||
readonly Dictionary<Entity, GameObject> _dangerZones = new();
|
readonly Dictionary<Entity, GameObject> _dangerZones = new();
|
||||||
readonly HashSet<Entity> _dangerSeen = new();
|
readonly HashSet<Entity> _dangerSeen = new();
|
||||||
@@ -367,6 +369,11 @@ namespace ProjectM.Client
|
|||||||
bool finisher = step >= comboLen;
|
bool finisher = step >= comboLen;
|
||||||
float slashRange = tcfg.MeleeRange > 0f ? tcfg.MeleeRange : 2.6f;
|
float slashRange = tcfg.MeleeRange > 0f ? tcfg.MeleeRange : 2.6f;
|
||||||
float slashHalf = tcfg.MeleeConeHalfAngleRad > 0f ? tcfg.MeleeConeHalfAngleRad : 0.9f;
|
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;
|
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
|
// 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).
|
// "you bit" read (the authoritative server damage spark/number arrives a few ticks later).
|
||||||
@@ -397,6 +404,40 @@ namespace ProjectM.Client
|
|||||||
_swingTickInit = true;
|
_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.
|
// Footsteps (combat feel): edge-detect local locomotion from the position delta; a soft step at a cadence.
|
||||||
if (_localPlayer != Entity.Null)
|
if (_localPlayer != Entity.Null)
|
||||||
{
|
{
|
||||||
@@ -820,7 +861,11 @@ namespace ProjectM.Client
|
|||||||
{
|
{
|
||||||
int step = math.max(1, (int)mc.ValueRO.Step);
|
int step = math.max(1, (int)mc.ValueRO.Step);
|
||||||
bool finisher = step >= comboLen;
|
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.Half = baseHalf;
|
||||||
rs.SweepSign = (step % 2 == 0) ? -1 : 1;
|
rs.SweepSign = (step % 2 == 0) ? -1 : 1;
|
||||||
rs.Tint = FeelConfig.RemoteSlashColor * (finisher ? 1.5f : 1f);
|
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
|
// observe-only readers of replicated state (BoonOffer via GhostOwnerIsLocal; RunInfo RouteOpt*); clicks
|
||||||
// enqueue through the client send-systems' statics. Built lazily on first show.
|
// enqueue through the client send-systems' statics. Built lazily on first show.
|
||||||
VisualElement _boonModal, _boonCardRow;
|
VisualElement _boonModal, _boonCardRow;
|
||||||
VisualElement _routePanel, _routeBtnRow;
|
VisualElement _routePanel;
|
||||||
Label _routeTitle;
|
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;
|
bool _boonModalBuilt, _routePanelBuilt;
|
||||||
// Step 14 (meta shop): Staging-only permanent-upgrade shop (replicated MetaTierState + ledger Aether;
|
// Step 14 (meta shop): Staging-only permanent-upgrade shop (replicated MetaTierState + ledger Aether;
|
||||||
// row clicks enqueue MetaSpendSendSystem.RequestPurchase — the server re-validates everything).
|
// row clicks enqueue MetaSpendSendSystem.RequestPurchase — the server re-validates everything).
|
||||||
@@ -82,6 +82,28 @@ namespace ProjectM.Client
|
|||||||
Label _metaShopTitle;
|
Label _metaShopTitle;
|
||||||
bool _metaShopBuilt;
|
bool _metaShopBuilt;
|
||||||
int _metaShownFor; // last (class, tiers, aether) signature the shop rows were built for
|
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();
|
readonly List<VisualElement> _pips = new();
|
||||||
@@ -97,6 +119,8 @@ namespace ProjectM.Client
|
|||||||
|
|
||||||
// overlays
|
// overlays
|
||||||
VisualElement _vignette, _downed;
|
VisualElement _vignette, _downed;
|
||||||
|
Label _downedText; // "RESPAWNING IN N" countdown (client-local: baked DelayTicks + death-edge latch)
|
||||||
|
float _downedSince = -1f;
|
||||||
float _prevHp, _flash;
|
float _prevHp, _flash;
|
||||||
bool _haveHp;
|
bool _haveHp;
|
||||||
// personal inventory panel (read-only; toggled with I)
|
// personal inventory panel (read-only; toggled with I)
|
||||||
@@ -194,25 +218,21 @@ namespace ProjectM.Client
|
|||||||
switch (runInfo.Lifecycle)
|
switch (runInfo.Lifecycle)
|
||||||
{
|
{
|
||||||
case RunLifecycle.Staging:
|
case RunLifecycle.Staging:
|
||||||
{
|
// The READY panel (bottom-center) owns the action + N/M count; the top line frames intent.
|
||||||
// Client counts the party's replicated ready flags (send-to-all) for the N/M readout.
|
_locationText.text = "AT THE BASE - build defenses, buy upgrades, READY UP to launch";
|
||||||
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";
|
|
||||||
_locationText.style.color = new Color(0.55f, 0.85f, 1f);
|
_locationText.style.color = new Color(0.55f, 0.85f, 1f);
|
||||||
break;
|
break;
|
||||||
}
|
|
||||||
case RunLifecycle.Launching:
|
case RunLifecycle.Launching:
|
||||||
{
|
{
|
||||||
uint nowTick = SystemAPI.TryGetSingleton<NetworkTime>(out var ntime) && ntime.ServerTick.IsValid
|
// Wrap-safe countdown (post-impl review): the client's PREDICTED tick passes LaunchTick
|
||||||
? ntime.ServerTick.TickIndexForValidTick : 0u;
|
// near zero while Lifecycle is still Launching — signed TicksSince, never raw uint math.
|
||||||
int secs = runInfo.LaunchTick != 0 && nowTick != 0
|
int secs = 0;
|
||||||
? Mathf.Max(0, (int)((runInfo.LaunchTick - nowTick) / 60u) + 1) : 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.text = "LAUNCHING IN " + secs + " - un-ready [T] to abort";
|
||||||
_locationText.style.color = new Color(1f, 0.9f, 0.4f);
|
_locationText.style.color = new Color(1f, 0.9f, 0.4f);
|
||||||
break;
|
break;
|
||||||
@@ -272,9 +292,72 @@ namespace ProjectM.Client
|
|||||||
BlobAssetReference<BoonCatalogBlob> boonPool = default;
|
BlobAssetReference<BoonCatalogBlob> boonPool = default;
|
||||||
if (SystemAPI.TryGetSingleton<BoonCatalog>(out var bcat))
|
if (SystemAPI.TryGetSingleton<BoonCatalog>(out var bcat))
|
||||||
boonPool = bcat.Value;
|
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);
|
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) ----
|
// ---- Goal (hex-pip meter, or a continuous bar for large targets) ----
|
||||||
if (SystemAPI.TryGetSingleton<GoalProgress>(out var goal))
|
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)
|
if (SystemAPI.TryGetSingleton<RunOutcome>(out var runOutcome) && runOutcome.Value != RunOutcomeId.InProgress)
|
||||||
{
|
{
|
||||||
bool win = runOutcome.Value == RunOutcomeId.Victory;
|
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;
|
_runBanner.style.display = DisplayStyle.Flex;
|
||||||
_runBannerText.text = win ? "THE ENGINE HOLDS" : "OVERRUN";
|
_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);
|
_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
|
else
|
||||||
{
|
{
|
||||||
_runBanner.style.display = DisplayStyle.None;
|
_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);
|
HudUi.SetFill(_cooldownFill, cdFrac);
|
||||||
// A READY weapon (full bar) recedes; a CHARGING one is bright — so the inverted-vs-health polarity reads.
|
// 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 (_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;
|
_downed.style.display = dead ? DisplayStyle.Flex : DisplayStyle.None;
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
@@ -1022,7 +1151,12 @@ namespace ProjectM.Client
|
|||||||
{
|
{
|
||||||
_downed.style.backgroundColor = new Color(0.35f, 0f, 0f, 0.35f);
|
_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;
|
_downed.style.display = DisplayStyle.None;
|
||||||
root.Add(_downed);
|
root.Add(_downed);
|
||||||
}
|
}
|
||||||
@@ -1042,17 +1176,32 @@ namespace ProjectM.Client
|
|||||||
_runBannerSub.style.marginTop = 8;
|
_runBannerSub.style.marginTop = 8;
|
||||||
col.Add(_runBannerSub);
|
col.Add(_runBannerSub);
|
||||||
// END-2 (SL-5): the terminal banner offers a clear action so the player isn't hunting for Esc.
|
// 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.
|
// SINGLE: PLAY AGAIN Continues as a fresh campaign (base+meta kept — the terminal save rolls
|
||||||
// Both self-guard on WorldLauncher.Busy. The row picks (Position) even though the banner root Ignores.
|
// 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();
|
var btnRow = new VisualElement();
|
||||||
btnRow.style.flexDirection = FlexDirection.Row;
|
btnRow.style.flexDirection = FlexDirection.Row;
|
||||||
btnRow.style.justifyContent = Justify.Center;
|
btnRow.style.justifyContent = Justify.Center;
|
||||||
btnRow.style.marginTop = 28;
|
btnRow.style.marginTop = 28;
|
||||||
btnRow.pickingMode = PickingMode.Position;
|
btnRow.pickingMode = PickingMode.Position;
|
||||||
var again = MenuUi.Button("PLAY AGAIN", () => WorldLauncher.StartSession(SessionMode.Single, null, false));
|
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;
|
again.style.marginRight = 12;
|
||||||
btnRow.Add(again);
|
btnRow.Add(again);
|
||||||
btnRow.Add(MenuUi.Button("QUIT TO MENU", WorldLauncher.TeardownToMenu));
|
btnRow.Add(MenuUi.Button("QUIT TO MENU", WorldLauncher.TeardownToMenu));
|
||||||
|
break;
|
||||||
|
}
|
||||||
col.Add(btnRow);
|
col.Add(btnRow);
|
||||||
_runBanner.Add(col);
|
_runBanner.Add(col);
|
||||||
_runBanner.style.display = DisplayStyle.None;
|
_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) ====
|
// ==== 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]"
|
static string RoomTypeLabel(byte roomType) => roomType == RoomTypeId.Boss ? "[BOSS]"
|
||||||
: roomType == RoomTypeId.Elite ? "[ELITE]"
|
: 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).
|
// 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));
|
// Exact signature (post-impl review): the old lossy byte XOR could collide across consecutive
|
||||||
if (sig == 0) sig = 1;
|
// 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)
|
if (_boonShownFor != sig)
|
||||||
{
|
{
|
||||||
_boonCardRow.Clear();
|
_boonCardRow.Clear();
|
||||||
@@ -1265,10 +1414,18 @@ namespace ProjectM.Client
|
|||||||
int idx = BoonMath.FindDef(ref defs, id);
|
int idx = BoonMath.FindDef(ref defs, id);
|
||||||
string title = idx >= 0 ? defs.Defs[idx].Name.ToString() : ("BOON " + id);
|
string title = idx >= 0 ? defs.Defs[idx].Name.ToString() : ("BOON " + id);
|
||||||
string desc = idx >= 0 ? defs.Defs[idx].Desc.ToString() : "";
|
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
|
byte pick = k; // capture a COPY into the closure, never the loop variable
|
||||||
var card = MenuUi.Button(title + "\n" + desc, () => BoonSendSystem.PickBoon(pick));
|
var card = MenuUi.Button(title + "\n" + desc, () => BoonSendSystem.PickBoon(pick));
|
||||||
card.style.width = 200;
|
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.marginLeft = 8;
|
||||||
card.style.marginRight = 8;
|
card.style.marginRight = 8;
|
||||||
card.style.whiteSpace = WhiteSpace.Normal;
|
card.style.whiteSpace = WhiteSpace.Normal;
|
||||||
@@ -1312,6 +1469,25 @@ namespace ProjectM.Client
|
|||||||
root.Add(_boonModal);
|
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)
|
void UpdateRoutePanel(RunInfo runInfo)
|
||||||
{
|
{
|
||||||
// Keyed on the LIFECYCLE (never RouteOptionCount alone — the review's D-F6 criterion).
|
// Keyed on the LIFECYCLE (never RouteOptionCount alone — the review's D-F6 criterion).
|
||||||
@@ -1319,7 +1495,7 @@ namespace ProjectM.Client
|
|||||||
if (!show)
|
if (!show)
|
||||||
{
|
{
|
||||||
if (_routePanel != null) _routePanel.style.display = DisplayStyle.None;
|
if (_routePanel != null) _routePanel.style.display = DisplayStyle.None;
|
||||||
_routeShownFor = 0;
|
_routeMapSig = 0;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
var root = _doc != null ? _doc.rootVisualElement : null;
|
var root = _doc != null ? _doc.rootVisualElement : null;
|
||||||
@@ -1330,23 +1506,15 @@ namespace ProjectM.Client
|
|||||||
_routePanelBuilt = true;
|
_routePanelBuilt = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
byte sig = (byte)((runInfo.CurrentRoom + 1) ^ (runInfo.RouteOpt0Type * 3)
|
int sig = (int)runInfo.RunSeed ^ (runInfo.CurrentRoom + 1) * 131 ^ runInfo.CurrentCol * 31
|
||||||
^ (runInfo.RouteOpt1Type * 5) ^ (runInfo.RouteOpt2Type * 7) ^ runInfo.RouteOptionCount);
|
^ (runInfo.RouteOptionCount << 24) ^ (runInfo.RouteOpt0Col << 16)
|
||||||
|
^ (runInfo.RouteOpt1Col << 18) ^ (runInfo.RouteOpt2Col << 20);
|
||||||
if (sig == 0) sig = 1;
|
if (sig == 0) sig = 1;
|
||||||
if (_routeShownFor != sig)
|
if (_routeMapSig != sig)
|
||||||
{
|
{
|
||||||
_routeBtnRow.Clear();
|
RebuildRouteMap(runInfo);
|
||||||
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);
|
|
||||||
}
|
|
||||||
_routeTitle.text = "CHOOSE YOUR PATH — room " + (runInfo.CurrentRoom + 2) + "/" + runInfo.RoomCount;
|
_routeTitle.text = "CHOOSE YOUR PATH — room " + (runInfo.CurrentRoom + 2) + "/" + runInfo.RoomCount;
|
||||||
_routeShownFor = sig;
|
_routeMapSig = sig;
|
||||||
}
|
}
|
||||||
_routePanel.style.display = DisplayStyle.Flex;
|
_routePanel.style.display = DisplayStyle.Flex;
|
||||||
}
|
}
|
||||||
@@ -1356,33 +1524,299 @@ namespace ProjectM.Client
|
|||||||
_routePanel = new VisualElement { pickingMode = PickingMode.Ignore };
|
_routePanel = new VisualElement { pickingMode = PickingMode.Ignore };
|
||||||
_routePanel.style.position = Position.Absolute;
|
_routePanel.style.position = Position.Absolute;
|
||||||
_routePanel.style.left = 0; _routePanel.style.right = 0;
|
_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.alignItems = Align.Center;
|
||||||
|
_routePanel.style.justifyContent = Justify.Center;
|
||||||
_routePanel.style.display = DisplayStyle.None;
|
_routePanel.style.display = DisplayStyle.None;
|
||||||
|
|
||||||
var box = new VisualElement();
|
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.94f);
|
box.style.backgroundColor = new Color(0.07f, 0.09f, 0.12f, 0.95f);
|
||||||
box.style.borderTopLeftRadius = 10; box.style.borderTopRightRadius = 10;
|
box.style.borderTopLeftRadius = 10; box.style.borderTopRightRadius = 10;
|
||||||
box.style.borderBottomLeftRadius = 10; box.style.borderBottomRightRadius = 10;
|
box.style.borderBottomLeftRadius = 10; box.style.borderBottomRightRadius = 10;
|
||||||
box.style.paddingLeft = 16; box.style.paddingRight = 16;
|
box.style.paddingLeft = 18; box.style.paddingRight = 18;
|
||||||
box.style.paddingTop = 10; box.style.paddingBottom = 12;
|
box.style.paddingTop = 12; box.style.paddingBottom = 12;
|
||||||
box.style.alignItems = Align.Center;
|
box.style.alignItems = Align.Center;
|
||||||
|
|
||||||
_routeTitle = new Label("CHOOSE YOUR PATH");
|
_routeTitle = new Label("CHOOSE YOUR PATH");
|
||||||
_routeTitle.style.color = new Color(0.55f, 0.85f, 1f);
|
_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.unityFontStyleAndWeight = FontStyle.Bold;
|
||||||
_routeTitle.style.marginBottom = 8;
|
_routeTitle.style.marginBottom = 10;
|
||||||
box.Add(_routeTitle);
|
box.Add(_routeTitle);
|
||||||
|
|
||||||
_routeBtnRow = new VisualElement();
|
_routeMapHost = new VisualElement { pickingMode = PickingMode.Ignore };
|
||||||
_routeBtnRow.style.flexDirection = FlexDirection.Row;
|
_routeMapHost.style.position = Position.Relative;
|
||||||
box.Add(_routeBtnRow);
|
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);
|
_routePanel.Add(box);
|
||||||
root.Add(_routePanel);
|
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,
|
void UpdateMetaShop(bool show, byte classId, int aether,
|
||||||
BlobAssetReference<MetaUpgradeCatalogBlob> pool, DynamicBuffer<MetaTierState> record)
|
BlobAssetReference<MetaUpgradeCatalogBlob> pool, DynamicBuffer<MetaTierState> record)
|
||||||
{
|
{
|
||||||
@@ -1417,18 +1851,35 @@ namespace ProjectM.Client
|
|||||||
if ((defs.Defs[d].ClassMask & classBit) == 0) continue;
|
if ((defs.Defs[d].ClassMask & classBit) == 0) continue;
|
||||||
byte id = defs.Defs[d].Id;
|
byte id = defs.Defs[d].Id;
|
||||||
byte owned = MetaMath.TierOf(record, classId, 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;
|
bool maxed = owned >= defs.Defs[d].MaxTier;
|
||||||
int cost = MetaMath.CostForTier(in defs.Defs[d], owned);
|
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")
|
+ (maxed ? " MAXED" : " - " + cost + " Aether")
|
||||||
+ "\n" + defs.Defs[d].Desc.ToString();
|
+ "\n" + defs.Defs[d].Desc.ToString();
|
||||||
byte buyId = id; // closure copy, never the loop variable
|
byte buyId = id; // closure copy, never the loop variable
|
||||||
var row = MenuUi.Button(label, () => MetaSpendSendSystem.RequestPurchase(buyId));
|
var row = MenuUi.Button(label, () => MetaSpendSendSystem.RequestPurchase(buyId));
|
||||||
row.style.width = 290;
|
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.marginBottom = 4;
|
||||||
row.style.whiteSpace = WhiteSpace.Normal;
|
row.style.whiteSpace = WhiteSpace.Normal;
|
||||||
row.style.unityTextAlign = TextAnchor.MiddleLeft;
|
row.style.unityTextAlign = TextAnchor.MiddleLeft;
|
||||||
row.SetEnabled(!maxed && aether >= cost); // honest UI; the server re-validates everything anyway
|
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);
|
_metaRowsHost.Add(row);
|
||||||
}
|
}
|
||||||
_metaShownFor = sig;
|
_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, "Build menu — Tab / Y");
|
||||||
Body(c, "Place / Cancel (in build) — LMB / RMB (A / B on gamepad)");
|
Body(c, "Place / Cancel (in build) — LMB / RMB (A / B on gamepad)");
|
||||||
Body(c, "Deposit at base — G");
|
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, "Inventory — I");
|
||||||
Body(c, "Pause — Esc");
|
Body(c, "Pause — Esc");
|
||||||
break;
|
break;
|
||||||
case 1: // The Loop
|
case 1: // The Loop
|
||||||
Head(c, "THE LOOP — expeditions are how you win");
|
Head(c, "THE LOOP — expedition RUNS are how you win");
|
||||||
Body(c, "1. BASE (Calm) — mine Ore, build Turrets + a Fabricator to defend your Engine Core.");
|
Body(c, "1. BASE — build Turrets + a Fabricator around your Engine Core; spend Aether on PERMANENT class upgrades.");
|
||||||
Body(c, "2. EXPEDITION — walk to the Gate and fight the wave. CLEARING it is the progress beat.");
|
Body(c, "2. READY UP [T] — when everyone is ready, the party launches into a multi-room run together.");
|
||||||
Body(c, "3. RETURN — come home to bank the clear: +1 on the Engine meter.");
|
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. SIEGE — returning provokes a retaliation attack. Defend the Core!");
|
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. WIN — fill the Engine meter, then hold the final siege.");
|
Body(c, "5. SIEGE — a completed run provokes retaliation at base. Defend the Core!");
|
||||||
Body(c, "The Engine meter (top of screen) is the goal — base sieges are a consequence, not the win.");
|
Body(c, "6. WIN — fill the Engine meter, then hold the final siege.");
|
||||||
break;
|
break;
|
||||||
case 2: // Build & Economy
|
case 2: // Build & Economy
|
||||||
Head(c, "RESOURCES & BUILDING");
|
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, "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, "Fabricator (30 Ore) — converts Ore → Charge so turrets keep firing.");
|
||||||
Body(c, "Wall (Biomass) — a cheap barrier that blocks enemies.");
|
Body(c, "Wall (Biomass) — a cheap barrier that blocks enemies.");
|
||||||
@@ -101,7 +104,7 @@ namespace ProjectM.Client
|
|||||||
Head(c, "THREATS");
|
Head(c, "THREATS");
|
||||||
Body(c, "Husks assault the base during a Siege — keep them off the Engine Core.");
|
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, "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.");
|
Body(c, "Enemy variety scales the deeper you push.");
|
||||||
break;
|
break;
|
||||||
default: // Win / Lose
|
default: // Win / Lose
|
||||||
|
|||||||
@@ -80,6 +80,17 @@ namespace ProjectM.Client
|
|||||||
_mainPanel = MenuUi.FullScreenRoot(true);
|
_mainPanel = MenuUi.FullScreenRoot(true);
|
||||||
var card = MenuUi.Card("PROJECT M");
|
var card = MenuUi.Card("PROJECT M");
|
||||||
card.Add(MenuUi.Caption("Frontier colony — co-op"));
|
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).
|
// Slice 2: class picker -> sets WorldLauncher.SelectedClass for the next session (Warrior melee / Ranger ranged).
|
||||||
_classLabel = new Label(ClassName(WorldLauncher.SelectedClass));
|
_classLabel = new Label(ClassName(WorldLauncher.SelectedClass));
|
||||||
_classLabel.style.unityTextAlign = TextAnchor.MiddleCenter;
|
_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>
|
/// <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;
|
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)
|
public static void StartSession(SessionMode mode, string joinIp, bool loadSave)
|
||||||
{
|
{
|
||||||
if (Busy) return;
|
if (Busy) return;
|
||||||
Busy = true;
|
Busy = true;
|
||||||
|
LastMode = mode;
|
||||||
SessionRunner.Run(StartRoutine(mode, joinIp, loadSave));
|
SessionRunner.Run(StartRoutine(mode, joinIp, loadSave));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -47,6 +53,12 @@ namespace ProjectM.Client
|
|||||||
|
|
||||||
static IEnumerator StartRoutine(SessionMode mode, string joinIp, bool loadSave)
|
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
|
// 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).
|
// subscene then streams into the netcode worlds, exactly as in the always-on bootstrap flow).
|
||||||
World.DisposeAllWorlds();
|
World.DisposeAllWorlds();
|
||||||
@@ -117,6 +129,10 @@ namespace ProjectM.Client
|
|||||||
static void StagePendingSave(World server)
|
static void StagePendingSave(World server)
|
||||||
{
|
{
|
||||||
var data = SaveService.Load();
|
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;
|
if (data == null) return;
|
||||||
var em = server.EntityManager;
|
var em = server.EntityManager;
|
||||||
var e = em.CreateEntity();
|
var e = em.CreateEntity();
|
||||||
|
|||||||
@@ -56,8 +56,8 @@ namespace ProjectM.Server
|
|||||||
playerRegions.Add(region.ValueRO.Region);
|
playerRegions.Add(region.ValueRO.Region);
|
||||||
}
|
}
|
||||||
|
|
||||||
// EB-1 fortress aggro: also snapshot live structures (Turret/Wall/Pylon carry Health; automation
|
// EB-1 fortress aggro: also snapshot live structures (Turret/Wall/Pylon AND the Fabricator all carry
|
||||||
// machines lack it so the query excludes them). Snapshot ABOVE the early-return so Husks keep razing
|
// 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).
|
// the base even with every player dead/away (the locked 'push for structures' fork).
|
||||||
var structureEntities = new NativeList<Entity>(Allocator.Temp);
|
var structureEntities = new NativeList<Entity>(Allocator.Temp);
|
||||||
var structurePositions = new NativeList<float3>(Allocator.Temp);
|
var structurePositions = new NativeList<float3>(Allocator.Temp);
|
||||||
|
|||||||
@@ -45,7 +45,6 @@ namespace ProjectM.Server
|
|||||||
float3 center = haveAnchor ? BaseGridMath.PlotCenter(baseAnchor) : spawner.SpawnPoint;
|
float3 center = haveAnchor ? BaseGridMath.PlotCenter(baseAnchor) : spawner.SpawnPoint;
|
||||||
float ringRadius = haveSpawner ? spawner.SpawnRingRadius : 2f;
|
float ringRadius = haveSpawner ? spawner.SpawnRingRadius : 2f;
|
||||||
int ringSlots = haveSpawner ? spawner.RingSlots : 4;
|
int ringSlots = haveSpawner ? spawner.RingSlots : 4;
|
||||||
center = BaseGridMath.PlotCenter(baseAnchor);
|
|
||||||
|
|
||||||
foreach (var (health, respawn, invuln, xform, region, owner, eff) in
|
foreach (var (health, respawn, invuln, xform, region, owner, eff) in
|
||||||
SystemAPI.Query<RefRW<Health>, RefRW<RespawnState>, RefRW<RespawnInvuln>, RefRW<LocalTransform>,
|
SystemAPI.Query<RefRW<Health>, RefRW<RespawnState>, RefRW<RespawnInvuln>, RefRW<LocalTransform>,
|
||||||
|
|||||||
@@ -123,7 +123,7 @@ namespace ProjectM.Server
|
|||||||
zs.SeededEpoch = run.RoomEpoch;
|
zs.SeededEpoch = run.RoomEpoch;
|
||||||
zs.SpawnCounter = 0;
|
zs.SpawnCounter = 0;
|
||||||
zs.RemainingToSpawn = bossRoom ? 1 : ZoneEnemyMath.WaveSlots(plan.DifficultyEpoch, bands);
|
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)
|
if (zs.RemainingToSpawn > 0)
|
||||||
|
|||||||
@@ -139,6 +139,16 @@ namespace ProjectM.Server
|
|||||||
bool due = info.LaunchTick == 0u || !new NetworkTick(info.LaunchTick).IsNewerThan(serverTick);
|
bool due = info.LaunchTick == 0u || !new NetworkTick(info.LaunchTick).IsNewerThan(serverTick);
|
||||||
if (due)
|
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).
|
// Enter room 0 (the guaranteed Combat landing at column 0, sub-slot 0).
|
||||||
var map = RunMapMath.Generate(run.RunSeed);
|
var map = RunMapMath.Generate(run.RunSeed);
|
||||||
EnterRoom(ref state, ref info, ref run, in map, layer: 0, col: 0, baseCenter, bumpEpoch: true);
|
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)
|
// 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).
|
// 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;
|
bool anyPending = false;
|
||||||
foreach (var offer in SystemAPI.Query<RefRO<BoonOffer>>().WithAll<PlayerTag>())
|
foreach (var (offer, pregion) in
|
||||||
if (offer.ValueRO.Pending != 0) { anyPending = true; break; }
|
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
|
bool graceElapsed = run.RewardGraceTick == 0u
|
||||||
|| !new NetworkTick(run.RewardGraceTick).IsNewerThan(serverTick);
|
|| !new NetworkTick(run.RewardGraceTick).IsNewerThan(serverTick);
|
||||||
if (anyPending && !graceElapsed)
|
if (anyPending && !graceElapsed)
|
||||||
break;
|
break;
|
||||||
|
|
||||||
run.RewardGraceTick = 0u;
|
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)
|
if (run.LastTerminalCleared != 0)
|
||||||
{
|
{
|
||||||
info.Lifecycle = RunLifecycle.Returning; // boss cleared — go home a winner
|
info.Lifecycle = RunLifecycle.Returning; // boss cleared — go home a winner
|
||||||
@@ -279,18 +298,25 @@ namespace ProjectM.Server
|
|||||||
|
|
||||||
case RunLifecycle.Returning:
|
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;
|
int idx = 0;
|
||||||
foreach (var (region, xform) in
|
var homebound = new EntityCommandBuffer(Allocator.Temp);
|
||||||
SystemAPI.Query<RefRW<RegionTag>, RefRW<LocalTransform>>().WithAll<PlayerTag>())
|
foreach (var (region, xform, playerE) in
|
||||||
|
SystemAPI.Query<RefRW<RegionTag>, RefRW<LocalTransform>>()
|
||||||
|
.WithAll<PlayerTag, RunParticipant>().WithEntityAccess())
|
||||||
{
|
{
|
||||||
region.ValueRW.Region = RegionId.Base;
|
region.ValueRW.Region = RegionId.Base;
|
||||||
var p = baseCenter;
|
var p = baseCenter;
|
||||||
p.x += 1.5f * idx;
|
p.x += 1.5f * idx;
|
||||||
p.y = xform.ValueRO.Position.y;
|
p.y = xform.ValueRO.Position.y;
|
||||||
xform.ValueRW.Position = p;
|
xform.ValueRW.Position = p;
|
||||||
|
homebound.RemoveComponent<RunParticipant>(playerE);
|
||||||
idx++;
|
idx++;
|
||||||
}
|
}
|
||||||
|
homebound.Playback(state.EntityManager);
|
||||||
|
homebound.Dispose();
|
||||||
|
|
||||||
// THE terminal bank — once per RunEpoch (equality latch, F7), CLEAR-GATED (D-F3).
|
// THE terminal bank — once per RunEpoch (equality latch, F7), CLEAR-GATED (D-F3).
|
||||||
if (run.LastBankedRunEpoch != run.RunEpoch)
|
if (run.LastBankedRunEpoch != run.RunEpoch)
|
||||||
@@ -393,7 +419,7 @@ namespace ProjectM.Server
|
|||||||
float3 roomOrigin = RegionMath.ExpeditionRoomOrigin(baseCenter, run.ActiveSubSlot);
|
float3 roomOrigin = RegionMath.ExpeditionRoomOrigin(baseCenter, run.ActiveSubSlot);
|
||||||
int idx = 0;
|
int idx = 0;
|
||||||
foreach (var (region, xform) in
|
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;
|
region.ValueRW.Region = RegionId.Expedition;
|
||||||
var p = roomOrigin;
|
var p = roomOrigin;
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ namespace ProjectM.Server
|
|||||||
/// hand-off is <see cref="ThreatState.PendingSiegeSize"/> (this system sets it; CyclePhaseSystem consumes it).
|
/// 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
|
/// 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
|
/// 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"/>
|
/// <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
|
/// 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
|
/// 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]
|
[Serializable]
|
||||||
public class SaveData
|
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>
|
/// <summary>Oldest save schema the loader accepts (additive); a v2 save loads with structures at full HP.</summary>
|
||||||
public const int MinLoadableVersion = 2;
|
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.
|
// 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;
|
if (data == null || data.Version < SaveData.MinLoadableVersion || data.Version > SaveData.CurrentVersion) return null;
|
||||||
data.Ledger ??= Array.Empty<LedgerRow>();
|
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;
|
return data;
|
||||||
}
|
}
|
||||||
catch (Exception e)
|
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)
|
public static void Save(SaveData data)
|
||||||
{
|
{
|
||||||
if (data == null) return;
|
if (data == null) return;
|
||||||
|
|||||||
@@ -90,6 +90,11 @@ namespace ProjectM.Simulation
|
|||||||
/// at spawn on the baked value, legible at a glance).</summary>
|
/// at spawn on the baked value, legible at a glance).</summary>
|
||||||
public const float BossScaleMultiplier = 1.6f;
|
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) ----
|
// ---- 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>
|
/// <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]
|
[Test]
|
||||||
public void FirstIncomplete_SkipsCompletedPrefix()
|
public void FirstIncomplete_SkipsCompletedPrefix()
|
||||||
{
|
{
|
||||||
int mask = (1 << OnboardingStepMath.Welcome) | (1 << OnboardingStepMath.Move) | (1 << OnboardingStepMath.Mine);
|
int mask = (1 << OnboardingStepMath.Welcome) | (1 << OnboardingStepMath.Move) | (1 << OnboardingStepMath.Build);
|
||||||
Assert.AreEqual(OnboardingStepMath.Build, OnboardingStepMath.FirstIncomplete(mask));
|
Assert.AreEqual(OnboardingStepMath.Fabricator, OnboardingStepMath.FirstIncomplete(mask));
|
||||||
}
|
}
|
||||||
|
|
||||||
[Test]
|
[Test]
|
||||||
@@ -57,12 +57,14 @@ namespace ProjectM.Tests
|
|||||||
}
|
}
|
||||||
|
|
||||||
[Test]
|
[Test]
|
||||||
public void Mine_AdvancesOnlyWhenLedgerOreRises()
|
public void ReadyUp_AdvancesOnLocalReadyOrLaunch()
|
||||||
{
|
{
|
||||||
var s = Empty(); s.OreBaseline = 50; s.OreNow = 50; // starts at the seeded baseline
|
var s = Empty(); s.Lifecycle = RunLifecycle.Staging;
|
||||||
Assert.IsFalse(OnboardingStepMath.IsSatisfied(OnboardingStepMath.Mine, s));
|
Assert.IsFalse(OnboardingStepMath.IsSatisfied(OnboardingStepMath.ReadyUp, s));
|
||||||
s.OreNow = 51;
|
var ready = Empty(); ready.LocalReady = true;
|
||||||
Assert.IsTrue(OnboardingStepMath.IsSatisfied(OnboardingStepMath.Mine, s));
|
Assert.IsTrue(OnboardingStepMath.IsSatisfied(OnboardingStepMath.ReadyUp, ready));
|
||||||
|
var launched = Empty(); launched.Lifecycle = RunLifecycle.Launching;
|
||||||
|
Assert.IsTrue(OnboardingStepMath.IsSatisfied(OnboardingStepMath.ReadyUp, launched));
|
||||||
}
|
}
|
||||||
|
|
||||||
[Test]
|
[Test]
|
||||||
@@ -86,23 +88,27 @@ namespace ProjectM.Tests
|
|||||||
}
|
}
|
||||||
|
|
||||||
[Test]
|
[Test]
|
||||||
public void Gate_AdvancesOnExpeditionEntryOrActiveObjective()
|
public void Rooms_AdvancesOnExpeditionEntryOrActiveObjective()
|
||||||
{
|
{
|
||||||
var s = Empty();
|
var s = Empty();
|
||||||
Assert.IsFalse(OnboardingStepMath.IsSatisfied(OnboardingStepMath.Gate, s));
|
Assert.IsFalse(OnboardingStepMath.IsSatisfied(OnboardingStepMath.Rooms, s));
|
||||||
var onExp = Empty(); onExp.OnExpedition = true;
|
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;
|
var active = Empty(); active.ObjectiveState = ExpeditionObjectiveState.Active;
|
||||||
Assert.IsTrue(OnboardingStepMath.IsSatisfied(OnboardingStepMath.Gate, active));
|
Assert.IsTrue(OnboardingStepMath.IsSatisfied(OnboardingStepMath.Rooms, active));
|
||||||
}
|
}
|
||||||
|
|
||||||
[Test]
|
[Test]
|
||||||
public void Clear_AdvancesOnClearedObjective()
|
public void Boon_AdvancesOnClearedObjectiveOrRewardLifecycle()
|
||||||
{
|
{
|
||||||
var s = Empty(); s.ObjectiveState = ExpeditionObjectiveState.Active;
|
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;
|
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]
|
[Test]
|
||||||
@@ -157,11 +163,11 @@ namespace ProjectM.Tests
|
|||||||
// ---- pointer kinds ----
|
// ---- pointer kinds ----
|
||||||
|
|
||||||
[Test]
|
[Test]
|
||||||
public void PointerKinds_MatchSpatialStepsOnly()
|
public void PointerKinds_OnlyTheRoomsStepPoints()
|
||||||
{
|
{
|
||||||
Assert.AreEqual(OnboardingStepMath.PointerOreNode, OnboardingStepMath.PointerKind(OnboardingStepMath.Mine));
|
Assert.AreEqual(OnboardingStepMath.PointerOreNode, OnboardingStepMath.PointerKind(OnboardingStepMath.Rooms));
|
||||||
Assert.AreEqual(OnboardingStepMath.PointerBaseGate, OnboardingStepMath.PointerKind(OnboardingStepMath.Gate));
|
Assert.AreEqual(OnboardingStepMath.PointerNone, OnboardingStepMath.PointerKind(OnboardingStepMath.ReadyUp));
|
||||||
Assert.AreEqual(OnboardingStepMath.PointerExpeditionGate, OnboardingStepMath.PointerKind(OnboardingStepMath.Return));
|
Assert.AreEqual(OnboardingStepMath.PointerNone, OnboardingStepMath.PointerKind(OnboardingStepMath.Return));
|
||||||
Assert.AreEqual(OnboardingStepMath.PointerNone, OnboardingStepMath.PointerKind(OnboardingStepMath.Move));
|
Assert.AreEqual(OnboardingStepMath.PointerNone, OnboardingStepMath.PointerKind(OnboardingStepMath.Move));
|
||||||
Assert.AreEqual(OnboardingStepMath.PointerNone, OnboardingStepMath.PointerKind(OnboardingStepMath.Defend));
|
Assert.AreEqual(OnboardingStepMath.PointerNone, OnboardingStepMath.PointerKind(OnboardingStepMath.Defend));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -82,6 +82,12 @@ namespace ProjectM.Tests
|
|||||||
return q.CalculateEntityCount();
|
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]
|
[Test]
|
||||||
public void Seeds_ByRoomDifficulty_AndSpawnsTaggedAtActiveSlotOrigin()
|
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);
|
var plan = RoomLayoutMath.Plan(map.NodeAt(RunMap.NodeId(0, 0)), 0, map.LayerCount);
|
||||||
int slots = ZoneEnemyMath.WaveSlots(plan.DifficultyEpoch, Bands());
|
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);
|
var zs = em.GetComponentData<ZoneEnemyState>(zoneDir);
|
||||||
Assert.AreEqual(1, zs.SeededEpoch, "seeded for RoomEpoch 1");
|
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 (world, group, runDir, zoneDir) = MakeWorld(bossLayer, map.BossNodeId, activeSubSlot: (byte)(bossLayer & 1));
|
||||||
var em = world.EntityManager;
|
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);
|
var zs = em.GetComponentData<ZoneEnemyState>(zoneDir);
|
||||||
Assert.AreEqual(0, zs.RemainingToSpawn, "a boss room is a single-slot wave, fully spawned");
|
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 });
|
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, new RegionTag { Region = RegionId.Expedition });
|
||||||
em.SetComponentData(player, LocalTransform.Identity);
|
em.SetComponentData(player, LocalTransform.Identity);
|
||||||
return (world, group, dir, player);
|
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`.
|
- **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-###`).
|
- **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.
|
- **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
|
## 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.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]]. |
|
| `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
|
## 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.
|
- **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]].
|
- **`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.
|
- **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]].
|
- **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-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]].
|
- **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`).
|
- **`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]].
|
- **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]].
|
- **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
|
### 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.
|
- **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
|
### 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`).
|
- **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.
|
||||||
- **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.
|
|
||||||
|
|
||||||
### Animation (Rukhanka) ★
|
### 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).
|
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]].
|
- `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).
|
- **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)
|
## 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)
|
## 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).
|
- **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.
|
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": {
|
"dependencies": {
|
||||||
"com.coplaydev.unity-mcp": "https://github.com/CoplayDev/unity-mcp.git?path=/MCPForUnity#main",
|
"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.inference": "2.6.1",
|
||||||
"com.unity.ai.navigation": "2.0.13",
|
"com.unity.ai.navigation": "2.0.13",
|
||||||
"com.unity.charactercontroller": "1.4.2",
|
"com.unity.charactercontroller": "1.4.2",
|
||||||
|
|||||||
Generated
-20
@@ -39,26 +39,6 @@
|
|||||||
"source": "embedded",
|
"source": "embedded",
|
||||||
"dependencies": {}
|
"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": {
|
"com.unity.ai.inference": {
|
||||||
"version": "2.6.1",
|
"version": "2.6.1",
|
||||||
"depth": 0,
|
"depth": 0,
|
||||||
|
|||||||
@@ -11,12 +11,6 @@ EditorBuildSettings:
|
|||||||
- enabled: 1
|
- enabled: 1
|
||||||
path: Assets/Scenes/Game.unity
|
path: Assets/Scenes/Game.unity
|
||||||
guid: db8738f16e802c8488f9c22aa56e1e60
|
guid: db8738f16e802c8488f9c22aa56e1e60
|
||||||
- enabled: 1
|
|
||||||
path: Assets/Scenes/DevSandbox.unity
|
|
||||||
guid: 1d4b5e92ac7b6ea4880d30545df94704
|
|
||||||
- enabled: 1
|
|
||||||
path: Assets/Scenes/SampleScene.unity
|
|
||||||
guid: 99c9720ab356a0642a771bea13969a05
|
|
||||||
m_configObjects:
|
m_configObjects:
|
||||||
com.unity.dt.app-ui: {fileID: 11400000, guid: 1b1c20d82303e4b5781c3ef50ac1449f, type: 2}
|
com.unity.dt.app-ui: {fileID: 11400000, guid: 1b1c20d82303e4b5781c3ef50ac1449f, type: 2}
|
||||||
com.unity.input.settings.actions: {fileID: -944628639613478452, guid: 5aa62f6ed584c43b791e76f2fd31820f, type: 3}
|
com.unity.input.settings.actions: {fileID: -944628639613478452, guid: 5aa62f6ed584c43b791e76f2fd31820f, type: 3}
|
||||||
|
|||||||
@@ -36,6 +36,15 @@ GraphicsSettings:
|
|||||||
- {fileID: 10783, guid: 0000000000000000f000000000000000, type: 0}
|
- {fileID: 10783, guid: 0000000000000000f000000000000000, type: 0}
|
||||||
m_PreloadedShaders: []
|
m_PreloadedShaders: []
|
||||||
m_PreloadShadersBatchTimeLimit: -1
|
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_SpritesDefaultMaterial: {fileID: 10754, guid: 0000000000000000f000000000000000, type: 0}
|
||||||
m_CustomRenderPipeline: {fileID: 11400000, guid: 4b83569d67af61e458304325a23e5dfd, type: 2}
|
m_CustomRenderPipeline: {fileID: 11400000, guid: 4b83569d67af61e458304325a23e5dfd, type: 2}
|
||||||
m_TransparencySortMode: 0
|
m_TransparencySortMode: 0
|
||||||
@@ -61,6 +70,8 @@ GraphicsSettings:
|
|||||||
UnityEngine.Rendering.Universal.UniversalRenderPipeline: {fileID: 11400000, guid: 18dc0cd2c080841dea60987a38ce93fa, type: 2}
|
UnityEngine.Rendering.Universal.UniversalRenderPipeline: {fileID: 11400000, guid: 18dc0cd2c080841dea60987a38ce93fa, type: 2}
|
||||||
m_ShaderBuildSettings:
|
m_ShaderBuildSettings:
|
||||||
keywordDeclarationOverrides: []
|
keywordDeclarationOverrides: []
|
||||||
|
numInternalDefines: 0
|
||||||
|
defines: []
|
||||||
m_LightsUseLinearIntensity: 1
|
m_LightsUseLinearIntensity: 1
|
||||||
m_LightsUseColorTemperature: 1
|
m_LightsUseColorTemperature: 1
|
||||||
m_LogWhenShaderIsCompiled: 0
|
m_LogWhenShaderIsCompiled: 0
|
||||||
|
|||||||
@@ -149,8 +149,7 @@ PlayerSettings:
|
|||||||
visionOSBundleVersion: 1.0
|
visionOSBundleVersion: 1.0
|
||||||
tvOSBundleVersion: 1.0
|
tvOSBundleVersion: 1.0
|
||||||
bundleVersion: 0.1.0
|
bundleVersion: 0.1.0
|
||||||
preloadedAssets:
|
preloadedAssets: []
|
||||||
- {fileID: 11400000, guid: 44cd3062bcdc07e43aaec59b78f224a6, type: 2}
|
|
||||||
metroInputSource: 0
|
metroInputSource: 0
|
||||||
wsaTransparentSwapchain: 0
|
wsaTransparentSwapchain: 0
|
||||||
xboxOneDisableKinectGpuReservation: 1
|
xboxOneDisableKinectGpuReservation: 1
|
||||||
|
|||||||
Reference in New Issue
Block a user