b34945c2d2
Deletes CyclePhaseSystem, GoalReachedSystem, CoreDamage/CoreRestore, ThreatDirector, CoreIntegrity/GoalProgress/RunPhase/RunOutcome/ThreatState components, CoreVisualFeedbackSystem, and the whole Client/Onboarding slice (+6 test files). Keepers reworked: RunDirectorSystem (UpdateBefore attr + launch guard + goal/threat bank removed; sole SaveRequest raiser now), CycleDirectorSpawnSystem (ledger/meta host only), WaveSystem UNGATED (waves run wherever a WaveDirector is baked), EnemyAISystem core-fallback stripped, AmbientAudioSystem reworked (bed + run cues; no CycleState gate), MusicSystem RunInfo-only, HudSystem big trim (goal meter, core bar, siege banner, terminal banner, outcome flash, onboarding hook all gone), MetaShop/ClassPrep/AimReticle siege gates dropped, DebugOverlay/ops re-meant (SpawnWave=force next wave, EndSiege=quiet arena; SetCalm/AdvanceGoal/SetHeat retired, bytes reserved), TuningConfig Core knobs retired (ids 20-23 reserved), StorageMath.DrainFraction deleted, HowToPlay copy rewritten. Save epoch v7 (fresh epoch, operator-approved): SaveData drops goal/core/outcome + conveyor/machine-IO fields; MinLoadableVersion=7; PendingSave/PendingStructure trimmed; RollTerminalCampaignForward deleted; SaveStructureScan signature slimmed. 390 tests green; Play world-creation clean (player + waves live, no exceptions). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
439 lines
25 KiB
C#
439 lines
25 KiB
C#
using ProjectM.Simulation;
|
|
using Unity.Burst;
|
|
using Unity.Collections;
|
|
using Unity.Entities;
|
|
using Unity.Mathematics;
|
|
using Unity.NetCode;
|
|
using Unity.Transforms;
|
|
|
|
namespace ProjectM.Server
|
|
{
|
|
/// <summary>
|
|
/// SOLE writer of the replicated run-lifecycle FSM (<see cref="RunInfo"/>) and its server-only working state
|
|
/// (<see cref="RunRuntime"/>).
|
|
///
|
|
/// Step-7 = the REAL LINEAR traversal: Staging (ready-check) → Launching (3-2-1 telegraph, un-ready aborts) →
|
|
/// InRoom (fight; the clear edge arrives as the replicated <see cref="ExpeditionObjective"/>.State == Cleared,
|
|
/// consumed ONE-TICK-LATE by construction — RoomEnemyDirectorSystem writes it after this system each tick, so no
|
|
/// system-ordering back-edge exists) → RoomReward (cleared room TORN DOWN at entry via <see cref="RoomTeardown"/>;
|
|
/// boon picks gate the exit from Step 10, all-Pending==0 today) → advance (bump room/epoch, flip the ping-pong
|
|
/// sub-slot, teleport — teardown-at-entry + spawn-on-advance guarantees ≥1 empty tick and exactly ONE room alive)
|
|
/// → … → Boss clear → Returning (teleport home + the CLEAR-GATED terminal bank) → Staging. Branching route
|
|
/// choice (RouteSelect) replaces the fixed col-0 advance at Step 8.
|
|
///
|
|
/// The terminal bank (once per RunEpoch, equality-latched): ALWAYS records the honest depth
|
|
/// (max(MaxDepthReached, RoomsClearedThisRun) — never the planned RoomCount) and re-stages; ONLY a genuine
|
|
/// boss-clear terminal (<see cref="RunRuntime.LastTerminalCleared"/>) credits RunsCompleted and requests a
|
|
/// save. An abort/wipe banks NOTHING but the depth high-water (D-F3).
|
|
/// </summary>
|
|
[BurstCompile]
|
|
[WorldSystemFilter(WorldSystemFilterFlags.ServerSimulation)]
|
|
[UpdateInGroup(typeof(SimulationSystemGroup))]
|
|
public partial struct RunDirectorSystem : ISystem
|
|
{
|
|
/// <summary>"All ready → 3-2-1 → go" telegraph (~3 s @ 60). An un-ready during the countdown aborts.</summary>
|
|
const uint LaunchCountdownTicks = 180;
|
|
|
|
/// <summary>Boon-pick grace (~30 s @ 60): RoomReward advances when every survivor picked OR this elapses
|
|
/// (the AFK/disconnect backstop; the un-picked-offer policy lands with the boons at Step 10).</summary>
|
|
const uint RewardGraceTicks = 1800;
|
|
|
|
/// <summary>Route-choice grace (~30 s @ 60): the gate auto-picks the LOWEST-INDEX reachable option when it
|
|
/// elapses (the AFK backstop; an accepted pick always beats a same-tick expiry — review F2).</summary>
|
|
const uint RouteGraceTicks = 1800;
|
|
|
|
EntityQuery m_RoomTagged;
|
|
|
|
[BurstCompile]
|
|
public void OnCreate(ref SystemState state)
|
|
{
|
|
state.RequireForUpdate<NetworkTime>();
|
|
state.RequireForUpdate<RunInfo>();
|
|
state.RequireForUpdate<RunRuntime>();
|
|
m_RoomTagged = state.GetEntityQuery(ComponentType.ReadOnly<RoomTag>());
|
|
}
|
|
|
|
[BurstCompile]
|
|
public void OnUpdate(ref SystemState state)
|
|
{
|
|
var serverTick = SystemAPI.GetSingleton<NetworkTime>().ServerTick;
|
|
if (!serverTick.IsValid)
|
|
return;
|
|
uint now = serverTick.TickIndexForValidTick;
|
|
|
|
var dirEntity = SystemAPI.GetSingletonEntity<RunInfo>();
|
|
var info = SystemAPI.GetComponent<RunInfo>(dirEntity);
|
|
var run = SystemAPI.GetComponent<RunRuntime>(dirEntity);
|
|
|
|
float3 baseCenter = new float3(0f, 1f, 0f);
|
|
if (SystemAPI.TryGetSingleton<BaseAnchor>(out var anchor))
|
|
baseCenter = BaseGridMath.PlotCenter(anchor);
|
|
|
|
// Ready-check + party-presence derivation, shared across the states below. The party is co-located at
|
|
// base while Staging (the N7 co-location invariant), so live PlayerTag ghosts ARE the roster; a
|
|
// disconnect drops the counts (LinkedEntityGroup despawn) and the checks re-derive clean.
|
|
int totalPlayers = 0, readyPlayers = 0, expeditionPlayers = 0;
|
|
foreach (var (ready, region) in
|
|
SystemAPI.Query<RefRO<PlayerReady>, RefRO<RegionTag>>().WithAll<PlayerTag>())
|
|
{
|
|
totalPlayers++;
|
|
if (ready.ValueRO.Value != 0) readyPlayers++;
|
|
if (region.ValueRO.Region == RegionId.Expedition) expeditionPlayers++;
|
|
}
|
|
bool allReady = totalPlayers > 0 && readyPlayers == totalPlayers;
|
|
|
|
switch (info.Lifecycle)
|
|
{
|
|
case RunLifecycle.Staging:
|
|
{
|
|
if (allReady && run.WasAllReady == 0)
|
|
{
|
|
// Rising edge → Launching. Seed the run: monotonic epoch + per-playthrough salt lineage,
|
|
// never a tick, never 0, equality-compared downstream.
|
|
run.RunEpoch += 1;
|
|
run.HostSalt = RunMapMath.Hash(run.HostSalt, (uint)run.RunEpoch);
|
|
run.RunSeed = math.max(1u, RunMapMath.Hash((uint)run.RunEpoch, run.HostSalt));
|
|
run.NodeBudgetRemaining = Tuning.ExpeditionNodeBudget;
|
|
run.RoomsClearedThisRun = 0;
|
|
run.BoonPickCounter = 0; // fresh boon-band provenance per run
|
|
run.LastTerminalCleared = 0;
|
|
|
|
var map = RunMapMath.Generate(run.RunSeed);
|
|
info.RunSeed = run.RunSeed;
|
|
info.RoomCount = map.LayerCount;
|
|
info.LaunchTick = TickUtil.NonZero(now + LaunchCountdownTicks);
|
|
info.Lifecycle = RunLifecycle.Launching;
|
|
}
|
|
run.WasAllReady = (byte)(allReady ? 1 : 0);
|
|
break;
|
|
}
|
|
|
|
case RunLifecycle.Launching:
|
|
{
|
|
// Un-ready during the countdown aborts back to Staging (the telegraph's escape hatch).
|
|
if (!allReady)
|
|
{
|
|
info.LaunchTick = 0u;
|
|
info.Lifecycle = RunLifecycle.Staging;
|
|
run.WasAllReady = 0;
|
|
break;
|
|
}
|
|
|
|
bool due = info.LaunchTick == 0u || !new NetworkTick(info.LaunchTick).IsNewerThan(serverTick);
|
|
if (due)
|
|
{
|
|
// Conscript the party: the launch roster is EVERYONE connected (N7 co-location — all at
|
|
// base, all ready). Room advances teleport ONLY RunParticipants, so a mid-run late joiner
|
|
// is never yanked into the fight; the tag is released on the Returning edge.
|
|
var conscript = new EntityCommandBuffer(Allocator.Temp);
|
|
foreach (var (_, playerE) in
|
|
SystemAPI.Query<RefRO<PlayerReady>>().WithAll<PlayerTag>().WithEntityAccess())
|
|
conscript.AddComponent<RunParticipant>(playerE);
|
|
conscript.Playback(state.EntityManager);
|
|
conscript.Dispose();
|
|
|
|
// Enter room 0 (the guaranteed Combat landing at column 0, sub-slot 0).
|
|
var map = RunMapMath.Generate(run.RunSeed);
|
|
EnterRoom(ref state, ref info, ref run, in map, layer: 0, col: 0, baseCenter, bumpEpoch: true);
|
|
info.LaunchTick = 0u;
|
|
}
|
|
break;
|
|
}
|
|
|
|
case RunLifecycle.InRoom:
|
|
{
|
|
// All expedition players gone (disconnect/death-warp edge) → clean abort, no credit.
|
|
if (expeditionPlayers == 0)
|
|
{
|
|
run.LastTerminalCleared = 0;
|
|
info.Lifecycle = RunLifecycle.Returning;
|
|
break;
|
|
}
|
|
|
|
// The room clear edge — the replicated objective RoomEnemyDirectorSystem computed LAST tick
|
|
// (one-tick-late by construction; no ordering back-edge). Teardown happens AT THIS ENTRY, the
|
|
// next room spawns on the advance tick → ≥1 empty tick, exactly one room alive.
|
|
if (SystemAPI.HasComponent<ExpeditionObjective>(dirEntity)
|
|
&& SystemAPI.GetComponent<ExpeditionObjective>(dirEntity).State == ExpeditionObjectiveState.Cleared)
|
|
{
|
|
// DR-046: teardown MOVED to the RoomExplore exit — the room + resource nodes persist through
|
|
// RoomReward + the loot window so the party can mine after clearing.
|
|
|
|
run.RoomsClearedThisRun += 1;
|
|
if (info.CurrentRoom >= info.RoomCount - 1)
|
|
run.LastTerminalCleared = 1; // the Boss fell — a genuine terminal clear
|
|
run.RewardGraceTick = TickUtil.NonZero(now + RewardGraceTicks);
|
|
info.Lifecycle = RunLifecycle.RoomReward;
|
|
}
|
|
break;
|
|
}
|
|
|
|
case RunLifecycle.RoomReward:
|
|
{
|
|
if (expeditionPlayers == 0 && run.LastTerminalCleared == 0)
|
|
{
|
|
info.Lifecycle = RunLifecycle.Returning;
|
|
break;
|
|
}
|
|
|
|
// Exit gate: every SURVIVING player has picked (BoonOffer.Pending==0 — inert until Step 10)
|
|
// OR the grace elapsed (wrap-safe IsNewerThan, never raw uint — F4).
|
|
// Only EXPEDITION players hold the gate (BoonOfferSystem's documented contract): a player who
|
|
// died and respawned to base must not stall the party (post-impl review, confirmed major).
|
|
bool anyPending = false;
|
|
foreach (var (offer, pregion) in
|
|
SystemAPI.Query<RefRO<BoonOffer>, RefRO<RegionTag>>().WithAll<PlayerTag>())
|
|
if (pregion.ValueRO.Region == RegionId.Expedition && offer.ValueRO.Pending != 0)
|
|
{ anyPending = true; break; }
|
|
bool graceElapsed = run.RewardGraceTick == 0u
|
|
|| !new NetworkTick(run.RewardGraceTick).IsNewerThan(serverTick);
|
|
if (anyPending && !graceElapsed)
|
|
break;
|
|
|
|
run.RewardGraceTick = 0u;
|
|
// NO offer survives this gate: zero every straggler (a dead-respawned base player the
|
|
// auto-pick deliberately skips, a grace-expired AFK) so a stale Pending can never wedge the
|
|
// HUD modal open or stall a later reward gate (post-impl review, confirmed major).
|
|
foreach (var offer in SystemAPI.Query<RefRW<BoonOffer>>().WithAll<PlayerTag>())
|
|
offer.ValueRW = default;
|
|
// DR-046: don't advance yet — open the LOOT WINDOW. The cleared room + its resource nodes persist
|
|
// (teardown moved to the RoomExplore exit); a portal is up. Leave via the portal or a soft timeout.
|
|
run.ExploreGraceTick = TickUtil.NonZero(now + Tuning.ExploreGraceTicks);
|
|
if (SystemAPI.HasComponent<PortalCommand>(dirEntity))
|
|
SystemAPI.SetComponent(dirEntity, default(PortalCommand)); // fresh portal latch for this window
|
|
info.Lifecycle = RunLifecycle.RoomExplore;
|
|
break;
|
|
}
|
|
|
|
case RunLifecycle.RoomExplore:
|
|
{
|
|
// DR-046 LOOT WINDOW: the cleared room + its resource nodes persist; a portal is up. Advance when a
|
|
// participant interacts the portal (PortalCommand, set by PortalInteractReceiveSystem) OR the soft
|
|
// timeout elapses (never a softlock). Abort if the expedition emptied (unless the boss already fell).
|
|
if (expeditionPlayers == 0) // DR-046 fix: an empty expedition advances NOW (boss -> Returning banks the win
|
|
{ // immediately; non-boss -> abort no-credit) — no ~30s ExploreGrace dead-time on the win moment.
|
|
run.ExploreGraceTick = 0u;
|
|
info.Lifecycle = RunLifecycle.Returning;
|
|
break;
|
|
}
|
|
bool portalUsed = SystemAPI.HasComponent<PortalCommand>(dirEntity)
|
|
&& SystemAPI.GetComponent<PortalCommand>(dirEntity).HasInteract != 0;
|
|
bool exploreTimedOut = run.ExploreGraceTick == 0u
|
|
|| !new NetworkTick(run.ExploreGraceTick).IsNewerThan(serverTick);
|
|
if (!portalUsed && !exploreTimedOut)
|
|
break; // still looting
|
|
|
|
run.ExploreGraceTick = 0u;
|
|
if (SystemAPI.HasComponent<PortalCommand>(dirEntity))
|
|
SystemAPI.SetComponent(dirEntity, default(PortalCommand));
|
|
|
|
// The MOVED teardown: NOW destroy the cleared room (nodes + clutter), then advance.
|
|
var exploreEcb = new EntityCommandBuffer(Allocator.Temp);
|
|
RoomTeardown.DestroyRoom(m_RoomTagged, exploreEcb, (byte)(info.CurrentRoom & 0xFF));
|
|
exploreEcb.Playback(state.EntityManager);
|
|
exploreEcb.Dispose();
|
|
|
|
if (run.LastTerminalCleared != 0)
|
|
{
|
|
info.Lifecycle = RunLifecycle.Returning; // boss cleared — go home a winner
|
|
}
|
|
else
|
|
{
|
|
// Open the branching ROUTE GATE (relocated from RoomReward): publish authoritative reachable
|
|
// options; RouteSelect is the teardown gap (the room is gone now).
|
|
var map = RunMapMath.Generate(run.RunSeed);
|
|
int optionCount = RunMapMath.ReachableOptions(in map, info.CurrentRoom, info.CurrentCol, out var cols);
|
|
if (optionCount == 0)
|
|
{
|
|
info.RouteOptionCount = 0;
|
|
info.Lifecycle = RunLifecycle.Returning;
|
|
}
|
|
else
|
|
{
|
|
int nextLayer = info.CurrentRoom + 1;
|
|
info.RouteOptionCount = (byte)math.min(optionCount, 3);
|
|
info.RouteOpt0Col = cols.Length > 0 ? cols[0] : (byte)0;
|
|
info.RouteOpt1Col = cols.Length > 1 ? cols[1] : (byte)0;
|
|
info.RouteOpt2Col = cols.Length > 2 ? cols[2] : (byte)0;
|
|
info.RouteOpt0Type = cols.Length > 0 ? map.Node(nextLayer, cols[0]).RoomType : (byte)0;
|
|
info.RouteOpt1Type = cols.Length > 1 ? map.Node(nextLayer, cols[1]).RoomType : (byte)0;
|
|
info.RouteOpt2Type = cols.Length > 2 ? map.Node(nextLayer, cols[2]).RoomType : (byte)0;
|
|
run.RouteGraceTick = TickUtil.NonZero(now + RouteGraceTicks);
|
|
if (SystemAPI.HasComponent<RouteCommand>(dirEntity))
|
|
SystemAPI.SetComponent(dirEntity, default(RouteCommand));
|
|
info.Lifecycle = RunLifecycle.RouteSelect;
|
|
}
|
|
}
|
|
break;
|
|
}
|
|
|
|
|
|
case RunLifecycle.RouteSelect:
|
|
{
|
|
// Predicate order is LOAD-BEARING (review F2): abort → pick-consume → grace. A same-tick pick
|
|
// from a vanishing party must never resurrect the run (EnterRoom would conscript base players);
|
|
// an accepted pick must beat a same-tick grace expiry (the player was told "committed").
|
|
if (expeditionPlayers == 0)
|
|
{
|
|
info.RouteOptionCount = 0; // close the gate ON the abort edge itself (review F3)
|
|
run.LastTerminalCleared = 0;
|
|
info.Lifecycle = RunLifecycle.Returning;
|
|
break;
|
|
}
|
|
|
|
var cmd = SystemAPI.HasComponent<RouteCommand>(dirEntity)
|
|
? SystemAPI.GetComponent<RouteCommand>(dirEntity)
|
|
: default;
|
|
bool routeGraceElapsed = run.RouteGraceTick == 0u
|
|
|| !new NetworkTick(run.RouteGraceTick).IsNewerThan(serverTick);
|
|
|
|
if (cmd.HasPick != 0)
|
|
{
|
|
// The party's committed choice (first-accepted-wins latch; any-player-first-commits).
|
|
byte chosenCol = cmd.OptionIndex == 2 ? info.RouteOpt2Col
|
|
: cmd.OptionIndex == 1 ? info.RouteOpt1Col : info.RouteOpt0Col;
|
|
if (SystemAPI.HasComponent<RouteCommand>(dirEntity))
|
|
SystemAPI.SetComponent(dirEntity, default(RouteCommand));
|
|
run.RouteGraceTick = 0u;
|
|
var map = RunMapMath.Generate(run.RunSeed);
|
|
EnterRoom(ref state, ref info, ref run, in map, info.CurrentRoom + 1, chosenCol, baseCenter, bumpEpoch: true);
|
|
}
|
|
else if (routeGraceElapsed)
|
|
{
|
|
// AFK backstop: deterministic LOWEST-INDEX reachable option (RouteOpt0 is ascending-first).
|
|
run.RouteGraceTick = 0u;
|
|
var map = RunMapMath.Generate(run.RunSeed);
|
|
EnterRoom(ref state, ref info, ref run, in map, info.CurrentRoom + 1, info.RouteOpt0Col, baseCenter, bumpEpoch: true);
|
|
}
|
|
break;
|
|
}
|
|
|
|
case RunLifecycle.Returning:
|
|
{
|
|
// 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;
|
|
var homebound = new EntityCommandBuffer(Allocator.Temp);
|
|
foreach (var (region, xform, playerE) in
|
|
SystemAPI.Query<RefRW<RegionTag>, RefRW<LocalTransform>>()
|
|
.WithAll<PlayerTag, RunParticipant>().WithEntityAccess())
|
|
{
|
|
region.ValueRW.Region = RegionId.Base;
|
|
var p = baseCenter;
|
|
p.x += 1.5f * idx;
|
|
p.y = xform.ValueRO.Position.y;
|
|
xform.ValueRW.Position = p;
|
|
homebound.RemoveComponent<RunParticipant>(playerE);
|
|
idx++;
|
|
}
|
|
homebound.Playback(state.EntityManager);
|
|
homebound.Dispose();
|
|
|
|
// THE terminal bank — once per RunEpoch (equality latch, F7), CLEAR-GATED (D-F3).
|
|
if (run.LastBankedRunEpoch != run.RunEpoch)
|
|
{
|
|
run.LastBankedRunEpoch = run.RunEpoch;
|
|
|
|
// Always: the honest depth high-water (actual rooms cleared, never the planned count).
|
|
if (SystemAPI.HasComponent<MetaCounters>(dirEntity))
|
|
{
|
|
var meta = SystemAPI.GetComponent<MetaCounters>(dirEntity);
|
|
meta.MaxDepthReached = math.max(meta.MaxDepthReached, run.RoomsClearedThisRun);
|
|
if (run.LastTerminalCleared != 0)
|
|
meta.RunsCompleted += 1;
|
|
SystemAPI.SetComponent(dirEntity, meta);
|
|
info.RunsCompleted = meta.RunsCompleted; // HUD mirror
|
|
info.MaxDepthReached = meta.MaxDepthReached; // HUD mirror
|
|
}
|
|
|
|
// Boss-clear only: a save checkpoint (the win-meter/retaliation credits are retired — LANTERN purge).
|
|
if (run.LastTerminalCleared != 0 && SystemAPI.HasComponent<SaveRequest>(dirEntity))
|
|
SystemAPI.SetComponent(dirEntity, new SaveRequest { Pending = 1 });
|
|
}
|
|
|
|
// TWO-CHANNEL strip (DR-037): run boons EXPIRE at home — one range-strip clears every
|
|
// boon-band StatModifier (replicates via the [GhostField] buffer; StatRecompute reverts the
|
|
// effective stats on both worlds) and zeroes any straggler offer. Class/meta/equip bands are
|
|
// disjoint and survive. Idempotent — safe on every Returning tick.
|
|
foreach (var (mods, timed, fx, offer) in
|
|
SystemAPI.Query<DynamicBuffer<StatModifier>, DynamicBuffer<TimedModifier>, RefRW<BoonEffects>, RefRW<BoonOffer>>().WithAll<PlayerTag>())
|
|
{
|
|
TimedModifierUtil.RemoveBySourceIdRange(mods, Tuning.BoonSourceIdBase,
|
|
Tuning.BoonSourceIdBase + Tuning.BoonSourceIdSpan);
|
|
TimedModifierUtil.RemoveBySourceIdRange(mods, Tuning.PrepSourceIdBase,
|
|
Tuning.PrepSourceIdBase + Tuning.PrepSourceIdSpan); // DR-046: strip the run-scoped prep loadout too
|
|
|
|
// Phase 1.7: zero the mechanic-changer boons + strip the stale Frenzy timed row (its paired
|
|
// StatModifier is already cleared by the boon-band range-strip above).
|
|
fx.ValueRW = default;
|
|
TimedModifierUtil.RemoveBySourceId(timed, Tuning.FrenzySourceId);
|
|
offer.ValueRW = default;
|
|
}
|
|
|
|
// Clear EVERY ready flag — the next run needs a fresh, deliberate ready-check from everyone.
|
|
foreach (var ready in SystemAPI.Query<RefRW<PlayerReady>>().WithAll<PlayerTag>())
|
|
ready.ValueRW.Value = 0;
|
|
|
|
run.RewardGraceTick = 0u;
|
|
run.RouteGraceTick = 0u; // gate hygiene (review F5): no stale grace into the next run
|
|
if (SystemAPI.HasComponent<RouteCommand>(dirEntity))
|
|
SystemAPI.SetComponent(dirEntity, default(RouteCommand)); // no leftover latch either
|
|
run.WasAllReady = 0;
|
|
run.LastTerminalCleared = 0;
|
|
|
|
info.CurrentRoom = 0;
|
|
info.RouteOptionCount = 0;
|
|
info.LaunchTick = 0u;
|
|
info.Lifecycle = RunLifecycle.Staging;
|
|
break;
|
|
}
|
|
}
|
|
|
|
// Single write-back point — RunInfo/RunRuntime are ALWAYS published (F12: the HUD readout can never
|
|
// freeze stale behind a branch's early-break).
|
|
SystemAPI.SetComponent(dirEntity, info);
|
|
SystemAPI.SetComponent(dirEntity, run);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Enter room (<paramref name="layer"/>, <paramref name="col"/>): publish the node as the single plan
|
|
/// authority (<see cref="RunRuntime.CurrentNodeId"/>/<c>CurrentRoomType</c> — the field/enemy directors NEVER
|
|
/// re-derive it), flip the ping-pong sub-slot, bump <see cref="RunRuntime.RoomEpoch"/> so the room systems
|
|
/// reseed, and teleport the party onto the new origin (Position write in place — never FromPosition).
|
|
/// </summary>
|
|
void EnterRoom(ref SystemState state, ref RunInfo info, ref RunRuntime run, in RunMap map,
|
|
int layer, int col, float3 baseCenter, bool bumpEpoch)
|
|
{
|
|
var node = map.Node(layer, col);
|
|
run.ActiveSubSlot = (byte)(layer & 1);
|
|
run.CurrentNodeId = RunMap.NodeId(layer, col);
|
|
run.CurrentCol = (byte)col;
|
|
run.CurrentRoomType = node.RoomType;
|
|
if (bumpEpoch)
|
|
run.RoomEpoch += 1;
|
|
|
|
info.CurrentRoom = layer;
|
|
info.CurrentCol = (byte)col;
|
|
info.CurrentRoomType = node.RoomType;
|
|
info.CurrentBiome = node.Biome;
|
|
info.RouteOptionCount = 0;
|
|
|
|
float3 roomOrigin = RegionMath.ExpeditionRoomOrigin(baseCenter, run.ActiveSubSlot);
|
|
int idx = 0;
|
|
foreach (var (region, xform) in
|
|
SystemAPI.Query<RefRW<RegionTag>, RefRW<LocalTransform>>().WithAll<PlayerTag, RunParticipant>())
|
|
{
|
|
region.ValueRW.Region = RegionId.Expedition;
|
|
var p = roomOrigin;
|
|
p.x += 1.5f * idx; // small spread so kinematic capsules don't stack
|
|
p.y = xform.ValueRO.Position.y;
|
|
xform.ValueRW.Position = p;
|
|
idx++;
|
|
}
|
|
|
|
info.Lifecycle = RunLifecycle.InRoom;
|
|
}
|
|
}
|
|
}
|