M7.6: Hostile encounter stub + marker-scale fix — closes M7
EncounterTrigger.FindHostileTrigger now polls each tactical-mode
tick. Edge-detected by NPC actor id: a fresh hostile entering
ENCOUNTER_TRIGGER_TILES range fires the stub exactly once — console
log with name/allegiance/template, save-flash toast "Combat HUD
lands with M8 — encounter logged: {name}". Player keeps moving;
the same hostile won't re-fire until you've left the trigger ring
and come back.
Deviation from the M7 plan §6.6: the plan proposed autosaving on
hostile detect so M8 testing would have fresh combat starts. Wired
that, then walked into a wolf and got a respawn loop — SaveTo →
CaptureBody → _streamer.FlushAll evicts every loaded chunk → NPCs
respawn with fresh actor ids on the next tactical tick → fresh id
breaks edge detection → stub re-fires → autosave again → loop. The
visible symptom was grey untiled chunks and a screen-filling red
blob (an unscaled NPC marker on the spawn frame at zoom 32 renders
at ~307 screen px radius). M8 owns combat-start autosave anyway:
at that point CombatHUDScreen captures combatant state before
FlushAll, so the loop can't form. Removed the SaveTo here; comment
in code records the reason.
Marker scale stamped at construction. NPCs spawn inside _Process
(EnsureLoadedAround → OnChunkLoaded → MountNpcMarker) *after* the
per-frame counter-scale loop has already iterated _npcMarkers, so
a new marker would render at Scale=(1,1) for one frame. New
CounterScaleVec() helper reads the current camera zoom and is
stamped into Scale at marker construction (both PlayerMarker on
spawn/restore and every NpcMarker). The player path also reorders
SetInitialZoom to run BEFORE the player marker constructs so the
counter-scale picks the post-zoom value rather than the fit-zoom
default.
That closes the M7 milestone — title → wizard → worldgen → play →
pause / save / load / dialogue / hostile-stub all wired, only the
M7.4 polish items left were the bugs surfaced in play-testing.
Next: §11.1 cross-build save-bytes parity test (user-driven), then
M8 (combat HUD, inventory, level-up, shop, quest log, dungeon).
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -68,6 +68,10 @@ public partial class PlayScreen : Control
|
|||||||
private NpcActor? _interactCandidate;
|
private NpcActor? _interactCandidate;
|
||||||
// Edge-detect F for the talk handler so a held key doesn't fire twice.
|
// Edge-detect F for the talk handler so a held key doesn't fire twice.
|
||||||
private bool _fWasDown;
|
private bool _fWasDown;
|
||||||
|
// M7.6 — most recent hostile NPC id that tripped the encounter trigger.
|
||||||
|
// Edge-detection: only fires the stub once per *fresh* hostile entering
|
||||||
|
// range, so walking next to the same wolf doesn't spam autosaves.
|
||||||
|
private int _lastHostileTriggerId;
|
||||||
|
|
||||||
// M7.3 — save round-trip plumbing
|
// M7.3 — save round-trip plumbing
|
||||||
private readonly Dictionary<ChunkCoord, HashSet<int>> _killedByChunk = new();
|
private readonly Dictionary<ChunkCoord, HashSet<int>> _killedByChunk = new();
|
||||||
@@ -162,16 +166,19 @@ public partial class PlayScreen : Control
|
|||||||
_controller = new PlayerController(_actors.Player!, _ctx.World, _clock);
|
_controller = new PlayerController(_actors.Player!, _ctx.World, _clock);
|
||||||
_controller.TacticalIsWalkable = (tx, ty) => _streamer.SampleTile(tx, ty).IsWalkable;
|
_controller.TacticalIsWalkable = (tx, ty) => _streamer.SampleTile(tx, ty).IsWalkable;
|
||||||
|
|
||||||
|
// Set the initial zoom BEFORE building the player marker so the
|
||||||
|
// counter-scale below picks a sane scale on the spawn frame.
|
||||||
|
_render.Camera.Position = new Vector2(_actors.Player!.Position.X, _actors.Player.Position.Y);
|
||||||
|
SetInitialZoom();
|
||||||
|
|
||||||
// Player marker.
|
// Player marker.
|
||||||
_playerMarker = new PlayerMarker
|
_playerMarker = new PlayerMarker
|
||||||
{
|
{
|
||||||
Position = new Vector2(_actors.Player!.Position.X, _actors.Player.Position.Y),
|
Position = new Vector2(_actors.Player.Position.X, _actors.Player.Position.Y),
|
||||||
Rotation = _actors.Player.FacingAngleRad,
|
Rotation = _actors.Player.FacingAngleRad,
|
||||||
|
Scale = CounterScaleVec(),
|
||||||
};
|
};
|
||||||
AddChild(_playerMarker);
|
AddChild(_playerMarker);
|
||||||
|
|
||||||
_render.Camera.Position = _playerMarker.Position;
|
|
||||||
SetInitialZoom();
|
|
||||||
BuildHud();
|
BuildHud();
|
||||||
|
|
||||||
// M7.5/M8 will pick up _pendingEncounterRestore here once the
|
// M7.5/M8 will pick up _pendingEncounterRestore here once the
|
||||||
@@ -245,6 +252,44 @@ public partial class PlayScreen : Control
|
|||||||
else
|
else
|
||||||
_interactCandidate = null;
|
_interactCandidate = null;
|
||||||
|
|
||||||
|
// M7.6 — hostile encounter stub. The real combat HUD ships with M8;
|
||||||
|
// for now, an autosave + console log + toast on each fresh hostile
|
||||||
|
// entering range gives the player a heads-up and ensures M8 has a
|
||||||
|
// valid snapshot to wire combat-restore into. Edge-detected by
|
||||||
|
// NPC id so movement past the same hostile doesn't refire.
|
||||||
|
if (tactical)
|
||||||
|
{
|
||||||
|
var hostile = EncounterTrigger.FindHostileTrigger(_actors);
|
||||||
|
if (hostile is not null)
|
||||||
|
{
|
||||||
|
if (hostile.Id != _lastHostileTriggerId)
|
||||||
|
{
|
||||||
|
_lastHostileTriggerId = hostile.Id;
|
||||||
|
string tpl = hostile.Template?.Id ?? "<resident>";
|
||||||
|
GD.Print($"[encounter] Would start fight with {hostile.DisplayName} "
|
||||||
|
+ $"(allegiance={hostile.Allegiance}, template={tpl})");
|
||||||
|
FlashSavedToast($"Combat HUD lands with M8 — encounter logged: {hostile.DisplayName}");
|
||||||
|
// NB: deliberately do NOT autosave here, even though the
|
||||||
|
// doc proposes it. SaveTo → CaptureBody → FlushAll evicts
|
||||||
|
// every loaded chunk, which despawns NPCs and respawns
|
||||||
|
// them on the next tactical tick with fresh actor ids —
|
||||||
|
// breaking _lastHostileTriggerId's edge detection and
|
||||||
|
// looping the stub. M8 owns combat-start autosave; at
|
||||||
|
// that point the combat HUD is pushed *before* FlushAll
|
||||||
|
// happens, so the encounter snapshot covers the live
|
||||||
|
// state and the loop can't form.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
_lastHostileTriggerId = 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
_lastHostileTriggerId = 0;
|
||||||
|
}
|
||||||
|
|
||||||
// F-press → push dialogue overlay. Edge-detect so a held key doesn't
|
// F-press → push dialogue overlay. Edge-detect so a held key doesn't
|
||||||
// re-open the screen while the previous one is still up.
|
// re-open the screen while the previous one is still up.
|
||||||
bool fNow = Godot.Input.IsKeyPressed(Key.F);
|
bool fNow = Godot.Input.IsKeyPressed(Key.F);
|
||||||
@@ -433,15 +478,30 @@ public partial class PlayScreen : Control
|
|||||||
|
|
||||||
private void MountNpcMarker(NpcActor npc)
|
private void MountNpcMarker(NpcActor npc)
|
||||||
{
|
{
|
||||||
|
// Stamp the counter-scale at construction time. NPCs spawn from
|
||||||
|
// OnChunkLoaded inside _Process, *after* the per-frame counter-scale
|
||||||
|
// loop has already iterated _npcMarkers. Without an initial scale,
|
||||||
|
// the new marker would render at Scale=(1,1) for one frame — at
|
||||||
|
// tactical zoom 32 that's a ~307 screen-pixel-radius red blob.
|
||||||
var marker = new NpcMarker
|
var marker = new NpcMarker
|
||||||
{
|
{
|
||||||
Position = new Vector2(npc.Position.X, npc.Position.Y),
|
Position = new Vector2(npc.Position.X, npc.Position.Y),
|
||||||
Allegiance = npc.Allegiance,
|
Allegiance = npc.Allegiance,
|
||||||
|
Scale = CounterScaleVec(),
|
||||||
};
|
};
|
||||||
AddChild(marker);
|
AddChild(marker);
|
||||||
_npcMarkers[npc.Id] = marker;
|
_npcMarkers[npc.Id] = marker;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private Vector2 CounterScaleVec()
|
||||||
|
{
|
||||||
|
if (_render is null) return Vector2.One;
|
||||||
|
float zoom = _render.Camera.Zoom.X;
|
||||||
|
if (zoom <= 0f) return Vector2.One;
|
||||||
|
float inv = 1f / zoom;
|
||||||
|
return new Vector2(inv, inv);
|
||||||
|
}
|
||||||
|
|
||||||
// ──────────────────────────────────────────────────────────────────────
|
// ──────────────────────────────────────────────────────────────────────
|
||||||
// M7.3 — Save / Load
|
// M7.3 — Save / Load
|
||||||
|
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ namespace Theriapolis.GodotHost.Scenes;
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
public partial class TitleScreen : Control
|
public partial class TitleScreen : Control
|
||||||
{
|
{
|
||||||
private const string VersionLabel = "PORT / GODOT · M7.5";
|
private const string VersionLabel = "PORT / GODOT · M7.6";
|
||||||
private const string WizardScenePath = "res://Scenes/Wizard.tscn";
|
private const string WizardScenePath = "res://Scenes/Wizard.tscn";
|
||||||
|
|
||||||
public override void _Ready()
|
public override void _Ready()
|
||||||
|
|||||||
Reference in New Issue
Block a user