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:
@@ -66,7 +66,9 @@ namespace ProjectM.Client
|
|||||||
double _lastHoldTime; // C4: last hit-stop hold time (throttle so a horde wipe doesn't stutter)
|
double _lastHoldTime; // C4: last hit-stop hold time (throttle so a horde wipe doesn't stutter)
|
||||||
bool _coneTickInit;
|
bool _coneTickInit;
|
||||||
Material _dangerMat;
|
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 HashSet<Entity> _dangerSeen = new();
|
||||||
readonly List<Entity> _dangerStale = new();
|
readonly List<Entity> _dangerStale = new();
|
||||||
// ---- Enemy health bars (Slice 1, Feature B) — one pooled world-space Canvas per live Husk ----
|
// ---- 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 = MakeParticleMaterial();
|
||||||
_dangerMat.name = "EnemyDanger";
|
_dangerMat.name = "EnemyDanger";
|
||||||
_dangerMat.color = new Color(3.2f, 0.28f, 0.18f, 1f); // HDR red (per-zone intensity carried in vertex alpha)
|
_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).
|
// 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");
|
Shader uiShader = Shader.Find("UI/Default") ?? Shader.Find("Sprites/Default");
|
||||||
_barBgMat = new Material(uiShader) { name = "HealthBarBg" };
|
_barBgMat = new Material(uiShader) { name = "HealthBarBg" };
|
||||||
@@ -163,7 +169,8 @@ namespace ProjectM.Client
|
|||||||
Object.Destroy(_fxRoot.gameObject);
|
Object.Destroy(_fxRoot.gameObject);
|
||||||
if (_slashMesh != null) Object.Destroy(_slashMesh);
|
if (_slashMesh != null) Object.Destroy(_slashMesh);
|
||||||
if (_slashMat != null) Object.Destroy(_slashMat);
|
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 (_barBgMat != null) Object.Destroy(_barBgMat);
|
||||||
if (_barFillMat != null) Object.Destroy(_barFillMat);
|
if (_barFillMat != null) Object.Destroy(_barFillMat);
|
||||||
foreach (var kv in _dangerZones)
|
foreach (var kv in _dangerZones)
|
||||||
@@ -500,7 +507,8 @@ namespace ProjectM.Client
|
|||||||
PruneVfx();
|
PruneVfx();
|
||||||
AnimateNumbers(dt, cam);
|
AnimateNumbers(dt, cam);
|
||||||
UpdateSlash(dt);
|
UpdateSlash(dt);
|
||||||
UpdateEnemyDanger(localPos);
|
UpdateEnemyDanger(localPos); UpdatePortalBeacon();
|
||||||
|
|
||||||
UpdateRemoteSwings(dt);
|
UpdateRemoteSwings(dt);
|
||||||
UpdateHealthBars(dt, cam, localPos);
|
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 };
|
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 +
|
// 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.
|
// WHEN to dodge. Client-only, observe-only; one pooled mesh per winding-up enemy, pruned each frame.
|
||||||
void UpdateEnemyDanger(float3 localPos)
|
void UpdateEnemyDanger(float3 localPos)
|
||||||
|
|||||||
@@ -1949,23 +1949,25 @@ namespace ProjectM.Client
|
|||||||
{
|
{
|
||||||
var root = _doc != null ? _doc.rootVisualElement : null; if (root == null) return;
|
var root = _doc != null ? _doc.rootVisualElement : null; if (root == null) return;
|
||||||
if (!_portalBuilt) { BuildPortalPrompt(root); _portalBuilt = true; }
|
if (!_portalBuilt) { BuildPortalPrompt(root); _portalBuilt = true; }
|
||||||
bool show = false;
|
bool show = false, inRange = false;
|
||||||
if (haveRun && runInfo.Lifecycle == RunLifecycle.RoomExplore
|
if (haveRun && runInfo.Lifecycle == RunLifecycle.RoomExplore
|
||||||
&& SystemAPI.TryGetSingleton<BaseAnchor>(out var anchor))
|
&& SystemAPI.TryGetSingleton<BaseAnchor>(out var anchor))
|
||||||
{
|
{
|
||||||
float3 center = BaseGridMath.PlotCenter(anchor);
|
show = true; // room cleared -> ALWAYS steer the player to the (now visible) portal, not only when in range
|
||||||
float3 portalPos = RegionMath.ExpeditionRoomOrigin(center, (byte)(runInfo.CurrentRoom & 1));
|
float3 portalPos = RegionMath.ExpeditionPortalPos(BaseGridMath.PlotCenter(anchor), (byte)(runInfo.CurrentRoom & 1));
|
||||||
portalPos.z += Tuning.PortalOffsetZ;
|
|
||||||
foreach (var lt in SystemAPI.Query<RefRO<LocalTransform>>().WithAll<PlayerTag, GhostOwnerIsLocal>())
|
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;
|
var kb = UnityEngine.InputSystem.Keyboard.current;
|
||||||
if (kb != null && kb.eKey.wasPressedThisFrame) PortalInteractSendSystem.Interact();
|
if (kb != null && kb.eKey.wasPressedThisFrame) PortalInteractSendSystem.Interact();
|
||||||
}
|
}
|
||||||
break;
|
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;
|
_portalPrompt.style.display = show ? DisplayStyle.Flex : DisplayStyle.None;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -53,7 +53,18 @@ namespace ProjectM.Simulation
|
|||||||
return baseCenter + new float3(ExpeditionOffsetX + subSlot * RoomStrideX, 0f, 0f);
|
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
|
/// 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>
|
/// sub-slot to <see cref="ExpeditionRoomOrigin"/> directly).</summary>
|
||||||
public static float3 RegionOrigin(byte region, float3 baseCenter)
|
public static float3 RegionOrigin(byte region, float3 baseCenter)
|
||||||
|
|||||||
+10
-1
@@ -3,8 +3,17 @@ id: DR-044
|
|||||||
title: Expedition Redesign SHIPPED (steps 1–14) + Demo-Readiness Polish Pass
|
title: Expedition Redesign SHIPPED (steps 1–14) + Demo-Readiness Polish Pass
|
||||||
date: 2026-07-04
|
date: 2026-07-04
|
||||||
status: locked
|
status: locked
|
||||||
tags: [decision, expedition, roguelite, netcode, demo, polish, onboarding, audio]
|
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]]
|
supersedes: parts of DR-031/DR-042 (base-mining loop text); completes the [[2026-06-29_Expedition_Redesign_Build_Spec]]
|
||||||
|
permalink: gamevault/07-sessions/decisions/dr-044-expedition-redesign-shipped-demo-polish
|
||||||
---
|
---
|
||||||
|
|
||||||
# DR-044 — Expedition Redesign SHIPPED + Demo-Readiness Polish
|
# DR-044 — Expedition Redesign SHIPPED + Demo-Readiness Polish
|
||||||
|
|||||||
@@ -59,6 +59,17 @@ Rooms no longer auto-advance the instant the last enemy dies. New FSM beat betwe
|
|||||||
LEAVE"** prompt; E near the portal → `PortalInteractSendSystem.Interact()`. Never reads `RunParticipant`/`RunRuntime`/
|
LEAVE"** prompt; E near the portal → `PortalInteractSendSystem.Interact()`. Never reads `RunParticipant`/`RunRuntime`/
|
||||||
`RegionTag` client-side. Tuning: `PortalOffsetZ=-5`, `PortalInteractRange=3.5`, `ExploreGraceTicks=1800`.
|
`RegionTag` client-side. Tuning: `PortalOffsetZ=-5`, `PortalInteractRange=3.5`, `ExploreGraceTicks=1800`.
|
||||||
|
|
||||||
|
**Playtest fix (2026-07-05) — the portal was INVISIBLE.** The shipped cue was only a HUD line that appeared *when the
|
||||||
|
player was already within `PortalInteractRange` of an unmarked point*, so after picking a boon there was no world target
|
||||||
|
to walk to — players loitered until the ~30 s `ExploreGrace` timeout auto-advanced them ("nothing happens for a while
|
||||||
|
until the next room selection comes up"). Fix (client presentation only, no netcode/re-bake): (1) new
|
||||||
|
`RegionMath.ExpeditionPortalPos(baseCenter, subSlot)` = the single portal-position authority the HUD prompt AND the
|
||||||
|
beacon both resolve through (extract-before-second-caller, per this batch's own lesson); (2) `CombatFeedbackSystem` now
|
||||||
|
renders a glowing cyan HDR **portal-beacon pillar** at that position during `RoomExplore` (pooled GameObject, hidden
|
||||||
|
otherwise, breathing pulse; mirrors the danger-zone pattern); (3) the `HudSystem` prompt is now **always-on during
|
||||||
|
RoomExplore** — "ROOM CLEAR — reach the glowing portal to move on" out of range, "PRESS E TO LEAVE" in range. 456/456
|
||||||
|
EditMode; the beacon is the operator's visual fun-gate.
|
||||||
|
|
||||||
**Post-impl review fix (confirmed, medium):** the RoomExplore empty-abort guard first read `expeditionPlayers==0 &&
|
**Post-impl review fix (confirmed, medium):** the RoomExplore empty-abort guard first read `expeditionPlayers==0 &&
|
||||||
LastTerminalCleared==0`, which SKIPPED a boss clear — so a post-boss total wipe/disconnect idled the run in RoomExplore
|
LastTerminalCleared==0`, which SKIPPED a boss clear — so a post-boss total wipe/disconnect idled the run in RoomExplore
|
||||||
for the full ~30 s ExploreGrace before the win banked (a promptness regression vs the old flow, self-healing but a bad
|
for the full ~30 s ExploreGrace before the win banked (a promptness regression vs the old flow, self-healing but a bad
|
||||||
|
|||||||
Reference in New Issue
Block a user