Base<->Expedition ties: portal rooms, class-at-base, prep spend + Health.Max, C3/C4/Spitter (DR-046)
Portal-gated rooms: new RunLifecycle.RoomExplore loot window; room+nodes persist past the last kill; PortalInteractRequest -> server-only PortalCommand advances (client derives the portal pos). RunDirectorSystem moves teardown off the InRoom edge, relocates the boss-branch/route-gate into the RoomExplore exit, and advances an empty expedition immediately (incl. a boss clear). Class-at-base via a shared ClassSwapUtil (meta-band strip + per-class MetaTierState replay, so the base RPC and the dev SetClass can't drift); ClassSelectRequest Staging-gated. Per-run PREP buffs (PrepPurchaseRequest, PrepCatalog): once-per-run == the row's prep StatModifier band is present, TotalOf-before-Withdraw atomic, stripped on Returning beside the boon band. Health.Max promoted to [GhostField] (the one ghost-hash re-bake) so the boss + floating enemy HP bars read it directly. Feel: melee cone connect-thunk (C3), hit-stop throttle so a horde wipe can't stutter (C4), Spitter cornered-hold. 456/456 EditMode; two adversarial reviews (pre-code 13-confirmed folded; post-impl clean bar the RoomExplore boss-clear dead-time, fixed). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -62,7 +62,8 @@ namespace ProjectM.Client
|
||||
bool _slashActive;
|
||||
float _slashRange, _slashHalf; // live cone geometry re-sampled each frame for the per-frame sweep rebuild
|
||||
int _slashSweepSign = 1; // alternate sweep direction per combo step (reads as alternating strikes)
|
||||
uint _lastConeFireTick; // own latch — the muzzle block owns _lastLocalFireTick and runs first
|
||||
uint _lastConeFireTick; // own latch — the muzzle block owns _lastLocalFireTick and runs first
|
||||
double _lastHoldTime; // C4: last hit-stop hold time (throttle so a horde wipe doesn't stutter)
|
||||
bool _coneTickInit;
|
||||
Material _dangerMat;
|
||||
readonly Dictionary<Entity, GameObject> _dangerZones = new();
|
||||
@@ -249,6 +250,8 @@ namespace ProjectM.Client
|
||||
PlayClip(_hitClip, (Vector3)p, FeelConfig.HitSfxVolume);
|
||||
PrototypeCameraRig.AddShake(isLocalPlayer ? FeelConfig.HitShakeLocal : FeelConfig.HitShakeRemote);
|
||||
if (isLocalPlayer) PrototypeCameraRig.PunchFov(FeelConfig.HitStopFovKick, FeelConfig.HitStopDurationMs);
|
||||
if (isLocalPlayer && (prev.Hp - cur) >= 20f) TryHold(); // C4: crunch on a heavy incoming hit (e.g. a boss slam)
|
||||
|
||||
if (isLocalPlayer && FeelConfig.RumbleEnabled && AimPresentation.Scheme == 1)
|
||||
RumbleUtil.Pulse(FeelConfig.RumbleHit * 0.8f, FeelConfig.RumbleHit, FeelConfig.RumbleDurationSec);
|
||||
if (isEnemy)
|
||||
@@ -304,6 +307,8 @@ namespace ProjectM.Client
|
||||
PlayClip(_deathClip, (Vector3)c.Pos, FeelConfig.KillSfxVolume);
|
||||
PrototypeCameraRig.AddShake(FeelConfig.KillShake);
|
||||
PrototypeCameraRig.PunchFov(FeelConfig.KillFovKick, FeelConfig.HitStopDurationMs);
|
||||
TryHold(); // C4: kill crunch (throttled)
|
||||
|
||||
EmitColored(_hitFx, (Vector3)c.Pos + Vector3.up * 0.6f, FeelConfig.KillFlashBurstCount, FeelConfig.HitFlashColor); // kill pop
|
||||
if (FeelConfig.RumbleEnabled && AimPresentation.Scheme == 1)
|
||||
RumbleUtil.Pulse(FeelConfig.RumbleKill * 0.7f, FeelConfig.RumbleKill, FeelConfig.RumbleDurationSec);
|
||||
@@ -413,7 +418,7 @@ namespace ProjectM.Client
|
||||
if (finisher)
|
||||
{
|
||||
PrototypeCameraRig.PunchFov(FeelConfig.DashFovKick * 0.6f, FeelConfig.HitStopDurationMs);
|
||||
if (FeelConfig.HitStopFreezeEnabled) PrototypeCameraRig.Hold(FeelConfig.HitStopMaxFrames); // C4: a beat of crunch on the combo finisher (the deliberate payoff hit)
|
||||
TryHold(); // C4: a beat of crunch on the combo finisher (the deliberate payoff hit)
|
||||
}
|
||||
}
|
||||
_lastLocalSwingTick = mc.SwingStartTick;
|
||||
@@ -445,9 +450,29 @@ namespace ProjectM.Client
|
||||
}
|
||||
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);
|
||||
// C3: client cone-overlap over the cached enemy snapshot -> an immediate "you hit" read + thunk
|
||||
// (the server-only cone damage arrives a few ticks later), mirroring the melee connect path.
|
||||
bool coneConnected = false; Vector3 coneHit = (Vector3)localPos; float cnd = float.MaxValue;
|
||||
float coneCos = Mathf.Cos(coneHalf);
|
||||
float2 cfdir = new float2(cface.x, cface.z);
|
||||
foreach (var kv in _cache)
|
||||
{
|
||||
if (!kv.Value.IsEnemy) continue;
|
||||
if (MeleeConeMath.InCone(localPos, cfdir, coneRange, coneCos, kv.Value.Pos))
|
||||
{
|
||||
float cd2 = math.distancesq(localPos, kv.Value.Pos);
|
||||
if (cd2 < cnd) { cnd = cd2; coneHit = (Vector3)kv.Value.Pos; coneConnected = true; }
|
||||
}
|
||||
}
|
||||
TriggerSlash((Vector3)localPos, new float2(cface.x, cface.z), coneRange, coneHalf, 1, 1, coneConnected);
|
||||
PlayClip(_swingClip, (Vector3)localPos, 0.5f);
|
||||
PrototypeCameraRig.AddShake(0.06f);
|
||||
if (coneConnected)
|
||||
{
|
||||
Burst(_hitFx, cfg != null ? cfg.Hit : null, coneHit + Vector3.up * 0.7f, FeelConfig.HitBurstCount);
|
||||
PlayClip(_meleeConnectClip, coneHit, FeelConfig.MeleeConnectVolume);
|
||||
PrototypeCameraRig.PunchFov(FeelConfig.MeleeConnectFovKick, FeelConfig.HitStopDurationMs);
|
||||
}
|
||||
}
|
||||
}
|
||||
_lastConeFireTick = nextFire;
|
||||
@@ -813,7 +838,18 @@ namespace ProjectM.Client
|
||||
// Trigger a cone-shaped slash matching the LIVE melee range + half-angle, oriented along facing. The arc IS
|
||||
// the range telegraph (MC-4 clarity) AND now SWEEPS across + ramps per combo step so the swing reads as a
|
||||
// directional, escalating cleave rather than a static flash.
|
||||
void TriggerSlash(Vector3 pos, float2 facing, float range, float halfAngle, int step, int comboLen, bool connected)
|
||||
// C4: fire a brief presentation-only hit-stop hold, throttled (never Time.timeScale; the sim keeps ticking).
|
||||
void TryHold()
|
||||
{
|
||||
if (!FeelConfig.HitStopFreezeEnabled) return;
|
||||
double now = SystemAPI.Time.ElapsedTime;
|
||||
if (now - _lastHoldTime < 0.22) return;
|
||||
_lastHoldTime = now;
|
||||
PrototypeCameraRig.Hold(FeelConfig.HitStopMaxFrames);
|
||||
}
|
||||
|
||||
|
||||
void TriggerSlash(Vector3 pos, float2 facing, float range, float halfAngle, int step, int comboLen, bool connected)
|
||||
{
|
||||
if (_slashMr == null || _slashMat == null) return;
|
||||
bool finisher = step >= comboLen;
|
||||
|
||||
@@ -3,6 +3,8 @@ using ProjectM.Simulation;
|
||||
using Unity.Entities;
|
||||
using Unity.NetCode;
|
||||
using Unity.Transforms; // A6: boss-bar query reads LocalTransform (source-gen needs the using in this file)
|
||||
using Unity.Mathematics; // DR-046: portal proximity math (float3/.xz/math.distance)
|
||||
|
||||
|
||||
using UnityEngine;
|
||||
using UnityEngine.UIElements;
|
||||
@@ -341,26 +343,24 @@ namespace ProjectM.Client
|
||||
|
||||
// Boss presence bar. The boss is a scaled Charger (EnemyTelegraph.Kind==KindCharger, baked/client-safe)
|
||||
// in the EXPEDITION region — filtering on both excludes phase-two summoned swarmers AND a base-region
|
||||
// siege enemy a dead teammate can see. Health.Max is NOT replicated, so reconstruct the true max from the
|
||||
// baked Charger Max × the shared BossHealthMultiplier (A6 client fix; zero ghost-hash change).
|
||||
// siege enemy a dead teammate can see. Health.Max is now a [GhostField] (replicated x8 for the boss), so
|
||||
// the fraction reads true directly.
|
||||
bool bossAlive = false;
|
||||
float bossHp = 0f, bossMax = 0f;
|
||||
if (haveRun && runInfo.Lifecycle == RunLifecycle.InRoom && runInfo.CurrentRoomType == RoomTypeId.Boss)
|
||||
{
|
||||
float bossBakedMax = 0f;
|
||||
foreach (var (bhq, tele, blt) in
|
||||
SystemAPI.Query<RefRO<Health>, RefRO<EnemyTelegraph>, RefRO<LocalTransform>>().WithAll<EnemyTag>())
|
||||
{
|
||||
if (tele.ValueRO.Kind != ZoneEnemyMath.KindCharger) continue; // the boss is a Charger; skip summoned swarmers
|
||||
if (blt.ValueRO.Position.x <= ExpeditionRegionXMin) continue; // expedition only (not a base siege enemy)
|
||||
if (bhq.ValueRO.Max > bossBakedMax)
|
||||
if (bhq.ValueRO.Max > bossMax)
|
||||
{
|
||||
bossBakedMax = bhq.ValueRO.Max;
|
||||
bossMax = bhq.ValueRO.Max;
|
||||
bossHp = bhq.ValueRO.Current;
|
||||
bossAlive = bhq.ValueRO.Current > 0f;
|
||||
}
|
||||
}
|
||||
bossMax = bossBakedMax * Tuning.BossHealthMultiplier;
|
||||
}
|
||||
UpdateBossBar(bossAlive, bossHp, bossMax);
|
||||
|
||||
@@ -438,6 +438,10 @@ namespace ProjectM.Client
|
||||
metaShow = true;
|
||||
}
|
||||
UpdateMetaShop(metaShow, localClass, aether, metaPool, metaRecord);
|
||||
UpdateClassPanel(metaShow, localClass); // DR-046: base class pick (Staging)
|
||||
UpdatePrepPanel(metaShow, ore, bio, aether); // DR-046: base prep loadout (Staging)
|
||||
UpdatePortalPrompt(haveRun ? runInfo : default, haveRun); // DR-046: room-exit portal prompt (RoomExplore)
|
||||
|
||||
// DR-042 C6a: dim the Aether upgrade button when it isn't affordable (cost is a compile-time const).
|
||||
// (Step 11: upgrade-button affordability tint retired with the button.)
|
||||
// EB-2 quiet-turret cue (GLOBAL, not per-turret, so the deterministic Charge split never reads as one
|
||||
@@ -1834,7 +1838,150 @@ namespace ProjectM.Client
|
||||
_depthPanel.style.display = DisplayStyle.Flex;
|
||||
}
|
||||
|
||||
void UpdateMetaShop(bool show, byte classId, int aether,
|
||||
// DR-046: base class-select + prep-loadout panels (Staging) + the room-exit portal prompt (RoomExplore).
|
||||
VisualElement _classPanel, _prepPanel, _prepRowsHost;
|
||||
Label _classTitle, _prepTitle, _portalPrompt;
|
||||
Button _classWarBtn, _classRangerBtn;
|
||||
bool _classPanelBuilt, _prepPanelBuilt, _portalBuilt;
|
||||
int _classShownFor, _prepShownFor;
|
||||
|
||||
void UpdateClassPanel(bool show, byte classId)
|
||||
{
|
||||
if (!show) { if (_classPanel != null) _classPanel.style.display = DisplayStyle.None; _classShownFor = 0; return; }
|
||||
var root = _doc != null ? _doc.rootVisualElement : null; if (root == null) return;
|
||||
if (!_classPanelBuilt) { BuildClassPanel(root); _classPanelBuilt = true; }
|
||||
int sig = classId + 1;
|
||||
if (_classShownFor != sig)
|
||||
{
|
||||
bool ranger = classId == ClassTraits.RangerClass;
|
||||
_classWarBtn.text = ranger ? "WARRIOR" : "WARRIOR ✓";
|
||||
_classRangerBtn.text = ranger ? "RANGER ✓" : "RANGER";
|
||||
_classWarBtn.SetEnabled(ranger);
|
||||
_classRangerBtn.SetEnabled(!ranger);
|
||||
_classShownFor = sig;
|
||||
}
|
||||
_classPanel.style.display = DisplayStyle.Flex;
|
||||
}
|
||||
|
||||
void BuildClassPanel(VisualElement root)
|
||||
{
|
||||
_classPanel = new VisualElement { pickingMode = PickingMode.Ignore };
|
||||
_classPanel.style.position = Position.Absolute;
|
||||
_classPanel.style.left = 12; _classPanel.style.top = Length.Percent(22);
|
||||
_classPanel.style.display = DisplayStyle.None;
|
||||
var box = new VisualElement();
|
||||
box.style.backgroundColor = new Color(0.07f, 0.09f, 0.12f, 0.92f);
|
||||
MenuUi.Round(box, 10);
|
||||
box.style.paddingLeft = 12; box.style.paddingRight = 12; box.style.paddingTop = 10; box.style.paddingBottom = 10;
|
||||
_classTitle = new Label("CLASS");
|
||||
_classTitle.style.color = MenuUi.Accent; _classTitle.style.fontSize = 14;
|
||||
_classTitle.style.unityFontStyleAndWeight = FontStyle.Bold; _classTitle.style.marginBottom = 8;
|
||||
box.Add(_classTitle);
|
||||
_classWarBtn = MenuUi.Button("WARRIOR", () => ClassSelectSendSystem.RequestClass(ClassTraits.WarriorClass));
|
||||
_classWarBtn.style.marginBottom = 4; box.Add(_classWarBtn);
|
||||
_classRangerBtn = MenuUi.Button("RANGER", () => ClassSelectSendSystem.RequestClass(ClassTraits.RangerClass));
|
||||
box.Add(_classRangerBtn);
|
||||
_classPanel.Add(box); root.Add(_classPanel);
|
||||
}
|
||||
|
||||
void UpdatePrepPanel(bool show, int ore, int bio, int aether)
|
||||
{
|
||||
if (!show) { if (_prepPanel != null) _prepPanel.style.display = DisplayStyle.None; _prepShownFor = 0; return; }
|
||||
var root = _doc != null ? _doc.rootVisualElement : null; if (root == null) return;
|
||||
if (!_prepPanelBuilt) { BuildPrepPanel(root); _prepPanelBuilt = true; }
|
||||
uint boughtMask = 0;
|
||||
foreach (var mods in SystemAPI.Query<DynamicBuffer<StatModifier>>().WithAll<PlayerTag, GhostOwnerIsLocal>())
|
||||
{
|
||||
for (int m = 0; m < mods.Length; m++)
|
||||
{
|
||||
uint sid = mods[m].SourceId;
|
||||
if (sid >= Tuning.PrepSourceIdBase && sid < Tuning.PrepSourceIdBase + Tuning.PrepSourceIdSpan)
|
||||
boughtMask |= (uint)(1 << (int)(sid - Tuning.PrepSourceIdBase));
|
||||
}
|
||||
break;
|
||||
}
|
||||
int sig = ore * 7 ^ bio * 13 ^ aether * 31 ^ (int)boughtMask * 101;
|
||||
if (sig == 0) sig = 1;
|
||||
if (_prepShownFor != sig)
|
||||
{
|
||||
_prepRowsHost.Clear();
|
||||
for (int i = 0; i < PrepCatalog.Count; i++)
|
||||
{
|
||||
var r = PrepCatalog.Rows[i];
|
||||
int have = r.CostResId == ResourceId.Aether ? aether : r.CostResId == ResourceId.Biomass ? bio : ore;
|
||||
bool bought = (boughtMask & (uint)(1 << r.Id)) != 0;
|
||||
string resName = r.CostResId == ResourceId.Aether ? "Aether" : r.CostResId == ResourceId.Biomass ? "Biomass" : "Ore";
|
||||
string label = PrepLabel(r.Id) + (bought ? " BOUGHT" : " - " + r.Cost + " " + resName);
|
||||
byte buyId = r.Id;
|
||||
var row = MenuUi.Button(label, () => PrepPurchaseSendSystem.RequestPrep(buyId));
|
||||
row.style.width = 240; row.style.marginBottom = 4;
|
||||
row.style.whiteSpace = WhiteSpace.Normal; row.style.unityTextAlign = TextAnchor.MiddleLeft;
|
||||
row.SetEnabled(!bought && have >= r.Cost);
|
||||
_prepRowsHost.Add(row);
|
||||
}
|
||||
_prepShownFor = sig;
|
||||
}
|
||||
_prepPanel.style.display = DisplayStyle.Flex;
|
||||
}
|
||||
|
||||
static string PrepLabel(byte id) => id == 0 ? "+30 Max HP" : id == 1 ? "+12% Move Speed"
|
||||
: id == 2 ? "+20% Melee Damage" : "+20% Ranged Damage";
|
||||
|
||||
void BuildPrepPanel(VisualElement root)
|
||||
{
|
||||
_prepPanel = new VisualElement { pickingMode = PickingMode.Ignore };
|
||||
_prepPanel.style.position = Position.Absolute;
|
||||
_prepPanel.style.left = 12; _prepPanel.style.top = Length.Percent(45);
|
||||
_prepPanel.style.display = DisplayStyle.None;
|
||||
var box = new VisualElement();
|
||||
box.style.backgroundColor = new Color(0.07f, 0.09f, 0.12f, 0.92f);
|
||||
MenuUi.Round(box, 10);
|
||||
box.style.paddingLeft = 12; box.style.paddingRight = 12; box.style.paddingTop = 10; box.style.paddingBottom = 10;
|
||||
_prepTitle = new Label("PREP LOADOUT (lasts the run)");
|
||||
_prepTitle.style.color = MenuUi.Accent; _prepTitle.style.fontSize = 14;
|
||||
_prepTitle.style.unityFontStyleAndWeight = FontStyle.Bold; _prepTitle.style.marginBottom = 8;
|
||||
box.Add(_prepTitle);
|
||||
_prepRowsHost = new VisualElement(); box.Add(_prepRowsHost);
|
||||
_prepPanel.Add(box); root.Add(_prepPanel);
|
||||
}
|
||||
|
||||
void UpdatePortalPrompt(RunInfo runInfo, bool haveRun)
|
||||
{
|
||||
var root = _doc != null ? _doc.rootVisualElement : null; if (root == null) return;
|
||||
if (!_portalBuilt) { BuildPortalPrompt(root); _portalBuilt = true; }
|
||||
bool show = false;
|
||||
if (haveRun && runInfo.Lifecycle == RunLifecycle.RoomExplore
|
||||
&& SystemAPI.TryGetSingleton<BaseAnchor>(out var anchor))
|
||||
{
|
||||
float3 center = BaseGridMath.PlotCenter(anchor);
|
||||
float3 portalPos = RegionMath.ExpeditionRoomOrigin(center, (byte)(runInfo.CurrentRoom & 1));
|
||||
portalPos.z += Tuning.PortalOffsetZ;
|
||||
foreach (var lt in SystemAPI.Query<RefRO<LocalTransform>>().WithAll<PlayerTag, GhostOwnerIsLocal>())
|
||||
{
|
||||
if (math.distance(lt.ValueRO.Position.xz, portalPos.xz) <= Tuning.PortalInteractRange)
|
||||
{
|
||||
show = true;
|
||||
var kb = UnityEngine.InputSystem.Keyboard.current;
|
||||
if (kb != null && kb.eKey.wasPressedThisFrame) PortalInteractSendSystem.Interact();
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
_portalPrompt.style.display = show ? DisplayStyle.Flex : DisplayStyle.None;
|
||||
}
|
||||
|
||||
void BuildPortalPrompt(VisualElement root)
|
||||
{
|
||||
_portalPrompt = HudUi.Display("PRESS E TO LEAVE — the haul comes home", 20, new Color(0.55f, 0.95f, 1f), TextAnchor.MiddleCenter);
|
||||
_portalPrompt.style.position = Position.Absolute;
|
||||
_portalPrompt.style.left = 0; _portalPrompt.style.right = 0; _portalPrompt.style.bottom = 240;
|
||||
_portalPrompt.pickingMode = PickingMode.Ignore;
|
||||
_portalPrompt.style.display = DisplayStyle.None;
|
||||
root.Add(_portalPrompt);
|
||||
}
|
||||
|
||||
|
||||
void UpdateMetaShop(bool show, byte classId, int aether,
|
||||
BlobAssetReference<MetaUpgradeCatalogBlob> pool, DynamicBuffer<MetaTierState> record)
|
||||
{
|
||||
if (!show)
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
using System.Collections.Generic;
|
||||
using ProjectM.Simulation;
|
||||
using Unity.Entities;
|
||||
using Unity.NetCode;
|
||||
using UnityEngine;
|
||||
|
||||
namespace ProjectM.Client
|
||||
{
|
||||
/// <summary>
|
||||
/// Client-side class-pick sender: a static enqueue (the Staging class-select HUD buttons) drained into
|
||||
/// <see cref="ClassSelectRequest"/> RPCs (the MetaSpendSendSystem idiom). Carries only the class id; the server
|
||||
/// re-validates the phase + applies the full swap. Statics reset on play-enter (the stale-bridge hazard).
|
||||
/// </summary>
|
||||
[WorldSystemFilter(WorldSystemFilterFlags.ClientSimulation)]
|
||||
public partial class ClassSelectSendSystem : SystemBase
|
||||
{
|
||||
static readonly Queue<byte> s_Queue = new();
|
||||
|
||||
[RuntimeInitializeOnLoadMethod(RuntimeInitializeLoadType.SubsystemRegistration)]
|
||||
static void ResetStatics() => s_Queue.Clear();
|
||||
|
||||
/// <summary>Queue a class pick (0=Warrior, 1=Ranger). The Staging class buttons drive this.</summary>
|
||||
public static void RequestClass(byte classId) => s_Queue.Enqueue(classId);
|
||||
|
||||
protected override void OnCreate() => RequireForUpdate<NetworkId>();
|
||||
|
||||
protected override void OnUpdate()
|
||||
{
|
||||
while (s_Queue.Count > 0)
|
||||
{
|
||||
var req = EntityManager.CreateEntity(typeof(ClassSelectRequest), typeof(SendRpcCommandRequest));
|
||||
EntityManager.SetComponentData(req, new ClassSelectRequest { ClassId = s_Queue.Dequeue() });
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: e0d67e293cdeb454fbef8414d4aeb813
|
||||
@@ -0,0 +1,33 @@
|
||||
using ProjectM.Simulation;
|
||||
using Unity.Entities;
|
||||
using Unity.NetCode;
|
||||
using UnityEngine;
|
||||
|
||||
namespace ProjectM.Client
|
||||
{
|
||||
/// <summary>
|
||||
/// Client-side portal-interact sender: a static flag (the portal prompt / E-key) drained into a single
|
||||
/// <see cref="PortalInteractRequest"/> RPC. Coalesced (one per drain — repeat E while the server is still in
|
||||
/// RoomExplore is harmless, the server latches once). Statics reset on play-enter.
|
||||
/// </summary>
|
||||
[WorldSystemFilter(WorldSystemFilterFlags.ClientSimulation)]
|
||||
public partial class PortalInteractSendSystem : SystemBase
|
||||
{
|
||||
static bool s_pending;
|
||||
|
||||
[RuntimeInitializeOnLoadMethod(RuntimeInitializeLoadType.SubsystemRegistration)]
|
||||
static void ResetStatics() => s_pending = false;
|
||||
|
||||
/// <summary>Request leaving via the portal (the HUD prompt / E-key near the portal drives this).</summary>
|
||||
public static void Interact() => s_pending = true;
|
||||
|
||||
protected override void OnCreate() => RequireForUpdate<NetworkId>();
|
||||
|
||||
protected override void OnUpdate()
|
||||
{
|
||||
if (!s_pending) return;
|
||||
s_pending = false;
|
||||
EntityManager.CreateEntity(typeof(PortalInteractRequest), typeof(SendRpcCommandRequest));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: eac0f1b2340c0b344b430363311d3be5
|
||||
@@ -0,0 +1,36 @@
|
||||
using System.Collections.Generic;
|
||||
using ProjectM.Simulation;
|
||||
using Unity.Entities;
|
||||
using Unity.NetCode;
|
||||
using UnityEngine;
|
||||
|
||||
namespace ProjectM.Client
|
||||
{
|
||||
/// <summary>
|
||||
/// Client-side prep-loadout sender: a static enqueue (the Staging PREP panel buttons) drained into
|
||||
/// <see cref="PrepPurchaseRequest"/> RPCs (the MetaSpendSendSystem idiom). Carries only the option id; the server
|
||||
/// prices + re-validates (Staging, affordability, once-per-run). Statics reset on play-enter.
|
||||
/// </summary>
|
||||
[WorldSystemFilter(WorldSystemFilterFlags.ClientSimulation)]
|
||||
public partial class PrepPurchaseSendSystem : SystemBase
|
||||
{
|
||||
static readonly Queue<byte> s_Queue = new();
|
||||
|
||||
[RuntimeInitializeOnLoadMethod(RuntimeInitializeLoadType.SubsystemRegistration)]
|
||||
static void ResetStatics() => s_Queue.Clear();
|
||||
|
||||
/// <summary>Queue a prep-loadout purchase by catalog option id. The Staging PREP rows drive this.</summary>
|
||||
public static void RequestPrep(byte optionId) => s_Queue.Enqueue(optionId);
|
||||
|
||||
protected override void OnCreate() => RequireForUpdate<NetworkId>();
|
||||
|
||||
protected override void OnUpdate()
|
||||
{
|
||||
while (s_Queue.Count > 0)
|
||||
{
|
||||
var req = EntityManager.CreateEntity(typeof(PrepPurchaseRequest), typeof(SendRpcCommandRequest));
|
||||
EntityManager.SetComponentData(req, new PrepPurchaseRequest { OptionId = s_Queue.Dequeue() });
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 5b60ac8ed7ee3ce4081c55b2188be142
|
||||
Reference in New Issue
Block a user