Portal made visible: cyan beacon pillar + always-on RoomExplore steer prompt (DR-046 follow-up)

The room-exit portal was invisible in the first playtest - players loitered
out the 30s ExploreGrace timeout. Adds RegionMath.ExpeditionPortalPos as the
single client-derivable portal-position authority (HUD prompt + beacon can't
drift), a pooled breathing HDR pillar in CombatFeedbackSystem shown only
during RoomExplore, and an in-range/out-of-range two-stage prompt.

Also repairs the comment-swallowed _portalMat initialization (NRE in
OnStartRunning killed ALL combat feedback each session and auto-paused the
editor via Error Pause). 456/456 EditMode.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-06 12:20:04 -07:00
parent e6cbba5e25
commit 274cf3b791
5 changed files with 92 additions and 13 deletions
@@ -66,7 +66,9 @@ namespace ProjectM.Client
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();
readonly Dictionary<Entity, GameObject> _dangerZones = new(); Material _portalMat; // DR-046: room-exit portal beacon glow (mutated for the pulse; beacon-only mat)
GameObject _portalBeacon; // DR-046: pooled world-space "go here" pillar, shown only during RoomExplore
readonly HashSet<Entity> _dangerSeen = new();
readonly List<Entity> _dangerStale = new();
// ---- Enemy health bars (Slice 1, Feature B) — one pooled world-space Canvas per live Husk ----
@@ -148,6 +150,10 @@ namespace ProjectM.Client
_dangerMat = MakeParticleMaterial();
_dangerMat.name = "EnemyDanger";
_dangerMat.color = new Color(3.2f, 0.28f, 0.18f, 1f); // HDR red (per-zone intensity carried in vertex alpha)
_portalMat = MakeParticleMaterial();
_portalMat.name = "RoomPortal";
_portalMat.color = new Color(0.5f, 2.6f, 3.4f, 0.85f); // DR-046: HDR cyan portal glow (pushes past bloom)
// Health-bar materials (UI/Default = always-included URP-compatible UI shader; per-instance Image.color carries alpha).
Shader uiShader = Shader.Find("UI/Default") ?? Shader.Find("Sprites/Default");
_barBgMat = new Material(uiShader) { name = "HealthBarBg" };
@@ -163,7 +169,8 @@ namespace ProjectM.Client
Object.Destroy(_fxRoot.gameObject);
if (_slashMesh != null) Object.Destroy(_slashMesh);
if (_slashMat != null) Object.Destroy(_slashMat);
if (_dangerMat != null) Object.Destroy(_dangerMat);
if (_dangerMat != null) Object.Destroy(_dangerMat); if (_portalMat != null) Object.Destroy(_portalMat);
if (_barBgMat != null) Object.Destroy(_barBgMat);
if (_barFillMat != null) Object.Destroy(_barFillMat);
foreach (var kv in _dangerZones)
@@ -500,7 +507,8 @@ namespace ProjectM.Client
PruneVfx();
AnimateNumbers(dt, cam);
UpdateSlash(dt);
UpdateEnemyDanger(localPos);
UpdateEnemyDanger(localPos); UpdatePortalBeacon();
UpdateRemoteSwings(dt);
UpdateHealthBars(dt, cam, localPos);
}
@@ -982,7 +990,45 @@ void TriggerSlash(Vector3 pos, float2 facing, float range, float halfAngle, int
return new RemoteSlash { Go = go, Mesh = mesh, Mr = mr, Mat = mat, Active = false, Init = false };
}
// Enemy attack TELEGRAPH (MC-4 clarity): while an enemy's AttackWindup counts down, paint a red ground danger
// DR-046: the room-exit PORTAL made VISIBLE. During the RoomExplore loot window a glowing cyan pillar marks the
// client-derived portal position so the player has an unmistakable "go here to continue" target — the HUD prompt
// alone left the exit invisible, so players waited out the ~30s grace timeout ("nothing happens for a while").
// Client-only, observe-only; one pooled GameObject, hidden whenever the run isn't in RoomExplore. Position
// resolves through the SAME RegionMath.ExpeditionPortalPos authority the HUD prompt uses -> beacon + "PRESS E"
// range always agree.
void UpdatePortalBeacon()
{
if (_fxRoot == null || _portalMat == null) return;
bool inExplore = SystemAPI.TryGetSingleton<RunInfo>(out var ri) && ri.Lifecycle == RunLifecycle.RoomExplore;
if (!inExplore || !SystemAPI.TryGetSingleton<BaseAnchor>(out var anchor))
{
if (_portalBeacon != null && _portalBeacon.activeSelf) _portalBeacon.SetActive(false);
return;
}
float3 pos = RegionMath.ExpeditionPortalPos(BaseGridMath.PlotCenter(anchor), (byte)(ri.CurrentRoom & 1));
if (_portalBeacon == null)
{
_portalBeacon = GameObject.CreatePrimitive(PrimitiveType.Cylinder);
_portalBeacon.name = "~RoomPortalBeacon";
var col = _portalBeacon.GetComponent<Collider>(); if (col != null) Object.Destroy(col); // cosmetic only
_portalBeacon.transform.SetParent(_fxRoot, false);
var mr = _portalBeacon.GetComponent<MeshRenderer>();
mr.sharedMaterial = _portalMat;
mr.shadowCastingMode = UnityEngine.Rendering.ShadowCastingMode.Off;
mr.receiveShadows = false;
}
if (!_portalBeacon.activeSelf) _portalBeacon.SetActive(true);
float t = (float)SystemAPI.Time.ElapsedTime;
float breathe = 0.5f + 0.5f * math.sin(t * 3.5f);
var tr = _portalBeacon.transform;
// Cylinder is 2u tall in local space -> scale.y=3 gives a 6u pillar; lift the centre so the base sits on the ground.
tr.position = new Vector3(pos.x, pos.y + 3f, pos.z);
tr.localScale = new Vector3(1.4f + 0.15f * breathe, 3f, 1.4f + 0.15f * breathe);
_portalMat.color = new Color(0.5f, 2.6f, 3.4f, 0.55f + 0.35f * breathe); // glow throb (beacon-only mat, safe to mutate)
}
// Enemy attack TELEGRAPH (MC-4 clarity): while an enemy's AttackWindup counts down, paint a red ground danger
// cone in its facing out to its reach, brightening + scaling as the strike nears -> the player reads WHERE +
// WHEN to dodge. Client-only, observe-only; one pooled mesh per winding-up enemy, pruned each frame.
void UpdateEnemyDanger(float3 localPos)
@@ -1949,23 +1949,25 @@ namespace ProjectM.Client
{
var root = _doc != null ? _doc.rootVisualElement : null; if (root == null) return;
if (!_portalBuilt) { BuildPortalPrompt(root); _portalBuilt = true; }
bool show = false;
bool show = false, inRange = 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;
show = true; // room cleared -> ALWAYS steer the player to the (now visible) portal, not only when in range
float3 portalPos = RegionMath.ExpeditionPortalPos(BaseGridMath.PlotCenter(anchor), (byte)(runInfo.CurrentRoom & 1));
foreach (var lt in SystemAPI.Query<RefRO<LocalTransform>>().WithAll<PlayerTag, GhostOwnerIsLocal>())
{
if (math.distance(lt.ValueRO.Position.xz, portalPos.xz) <= Tuning.PortalInteractRange)
inRange = math.distance(lt.ValueRO.Position.xz, portalPos.xz) <= Tuning.PortalInteractRange;
if (inRange)
{
show = true;
var kb = UnityEngine.InputSystem.Keyboard.current;
if (kb != null && kb.eKey.wasPressedThisFrame) PortalInteractSendSystem.Interact();
}
break;
}
_portalPrompt.text = inRange
? "PRESS E TO LEAVE — the haul comes home"
: "ROOM CLEAR — reach the glowing portal to move on";
}
_portalPrompt.style.display = show ? DisplayStyle.Flex : DisplayStyle.None;
}
@@ -53,7 +53,18 @@ namespace ProjectM.Simulation
return baseCenter + new float3(ExpeditionOffsetX + subSlot * RoomStrideX, 0f, 0f);
}
/// <summary>World-space origin of <paramref name="region"/>, given the base center (BaseGridMath.PlotCenter).
/// <summary>World-space position of the room-exit PORTAL for sub-slot <paramref name="subSlot"/> — the single
/// client-derivable authority the HUD prompt AND the presentation beacon both resolve through (DR-046), so they
/// can't drift. = the room origin nudged by <see cref="Tuning.PortalOffsetZ"/> in Z.</summary>
public static float3 ExpeditionPortalPos(float3 baseCenter, byte subSlot)
{
float3 p = ExpeditionRoomOrigin(baseCenter, subSlot);
p.z += Tuning.PortalOffsetZ;
return p;
}
/// <summary>World-space origin of <paramref name="region"/>, given the base center (BaseGridMath.PlotCenter).
/// The expedition resolves to room sub-slot 0 (legacy call sites; room-aware systems pass the ACTIVE
/// sub-slot to <see cref="ExpeditionRoomOrigin"/> directly).</summary>
public static float3 RegionOrigin(byte region, float3 baseCenter)