From a802fb318f2bc0716b133b631694740d1d71957a Mon Sep 17 00:00:00 2001 From: Christopher Wiebe Date: Sun, 10 May 2026 21:39:01 -0700 Subject: [PATCH] =?UTF-8?q?M7.6:=20Hostile=20encounter=20stub=20+=20marker?= =?UTF-8?q?-scale=20fix=20=E2=80=94=20closes=20M7?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- Theriapolis.Godot/Scenes/PlayScreen.cs | 68 +++++++++++++++++++++++-- Theriapolis.Godot/Scenes/TitleScreen.cs | 2 +- 2 files changed, 65 insertions(+), 5 deletions(-) diff --git a/Theriapolis.Godot/Scenes/PlayScreen.cs b/Theriapolis.Godot/Scenes/PlayScreen.cs index d87112a..a4dd9c6 100644 --- a/Theriapolis.Godot/Scenes/PlayScreen.cs +++ b/Theriapolis.Godot/Scenes/PlayScreen.cs @@ -68,6 +68,10 @@ public partial class PlayScreen : Control private NpcActor? _interactCandidate; // Edge-detect F for the talk handler so a held key doesn't fire twice. 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 private readonly Dictionary> _killedByChunk = new(); @@ -162,16 +166,19 @@ public partial class PlayScreen : Control _controller = new PlayerController(_actors.Player!, _ctx.World, _clock); _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. _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, + Scale = CounterScaleVec(), }; AddChild(_playerMarker); - - _render.Camera.Position = _playerMarker.Position; - SetInitialZoom(); BuildHud(); // M7.5/M8 will pick up _pendingEncounterRestore here once the @@ -245,6 +252,44 @@ public partial class PlayScreen : Control else _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 ?? ""; + 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 // re-open the screen while the previous one is still up. bool fNow = Godot.Input.IsKeyPressed(Key.F); @@ -433,15 +478,30 @@ public partial class PlayScreen : Control 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 { Position = new Vector2(npc.Position.X, npc.Position.Y), Allegiance = npc.Allegiance, + Scale = CounterScaleVec(), }; AddChild(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 diff --git a/Theriapolis.Godot/Scenes/TitleScreen.cs b/Theriapolis.Godot/Scenes/TitleScreen.cs index 77f914b..5fafb0c 100644 --- a/Theriapolis.Godot/Scenes/TitleScreen.cs +++ b/Theriapolis.Godot/Scenes/TitleScreen.cs @@ -18,7 +18,7 @@ namespace Theriapolis.GodotHost.Scenes; /// 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"; public override void _Ready()