From 6f47700820a6f6a37946ea3f26f8c3dacbf84a7b Mon Sep 17 00:00:00 2001 From: Christopher Wiebe Date: Sun, 10 May 2026 19:51:44 -0700 Subject: [PATCH] =?UTF-8?q?M7.4a:=20PlayScreen=20polish=20=E2=80=94=20faci?= =?UTF-8?q?ng=20tick,=20road=20overlap,=20WASD=20pan?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Facing tick stuck at the initial angle. PlayerMarker._Draw was computing the tick direction from a FacingAngleRad auto-property, but Godot caches CanvasItem draw commands and only re-runs _Draw on QueueRedraw. Setter never called QueueRedraw → tick never rotated. Fixed by leaning on the Node2D transform instead: tick is drawn along the local +X axis, PlayScreen sets marker.Rotation = facing each frame. The transform rotation applies to the cached commands without re-invoking _Draw — efficient and correct. FacingAngleRad property removed; ShowFacingTick became a property with QueueRedraw on change (visibility toggle still needs to invalidate the cache). Tactical view double-drew roads. TacticalChunkGen.Pass2_Polylines already bakes roads + rivers + bridges into the surface tiles of each chunk. WorldRenderNode's Line2D overlay was still visible at tactical zoom, stroking the same path on top of the rasterised version — showed as a brown line over every road. Ported the MonoGame "suppress polyline overlay in tactical" rule into UpdateLayerVisibility: _polylineLayer and _bridgeLayer hide when zoom >= TacticalRenderZoomMin. WASD now pans the world map. Previously WASD did nothing in world-map mode — only right-drag / middle-drag / mouse-wheel worked. WASD is now context-sensitive: tactical mode steps the player (unchanged), world-map mode pans the camera at 400 screen px/sec (world-pixel speed scales as 1/zoom so the perceived rate stays constant). Diagonal motion is √2-normalised to match tactical step. Suppressed during click-to-travel since the camera-follow would clobber any pan input anyway. HUD hint updated. Co-Authored-By: Claude Opus 4.7 --- Theriapolis.Godot/Rendering/PlayerMarker.cs | 42 +++++++++++------- .../Rendering/WorldRenderNode.cs | 14 +++++- Theriapolis.Godot/Scenes/PlayScreen.cs | 44 +++++++++++++------ 3 files changed, 69 insertions(+), 31 deletions(-) diff --git a/Theriapolis.Godot/Rendering/PlayerMarker.cs b/Theriapolis.Godot/Rendering/PlayerMarker.cs index b82e529..a981455 100644 --- a/Theriapolis.Godot/Rendering/PlayerMarker.cs +++ b/Theriapolis.Godot/Rendering/PlayerMarker.cs @@ -7,36 +7,46 @@ namespace Theriapolis.GodotHost.Rendering; /// Player marker — small dot with a thin facing tick. Drawn at /// /2 wp; the owner sets /// = 1/zoom every frame so the on-screen size -/// stays constant across the seamless zoom range. -/// -/// Mirrors the MonoGame PlayerSprite's visual vocabulary — dark outline, -/// faction-red fill, optional facing tick. Phase-7-styled `PlayerSprite` -/// proper (walking animation frames) lands later. +/// stays constant across the seamless zoom range. Facing is driven by +/// the owner via ; the tick is drawn along +/// the local +X axis so rotating the node rotates the tick without +/// invalidating the cached _Draw commands. /// public partial class PlayerMarker : Node2D { private const float RadiusWorldPx = C.PLAYER_MARKER_SCREEN_PX * 0.5f; private const float FacingTickPx = RadiusWorldPx * 1.4f; - /// Facing direction in radians; 0 = +X. Drives the optional - /// tick rendered on the body's leading edge. - public float FacingAngleRad { get; set; } + private bool _showFacingTick = true; - /// When true, draws a small tick at the leading edge so the - /// player can read facing without a full sprite. Hidden at low zoom - /// to avoid clutter. - public bool ShowFacingTick { get; set; } = true; + /// When true, draws a small tick along the local +X axis + /// so the player can read facing without a full sprite. Hidden at + /// low zoom to avoid clutter. Triggers + /// on change. + public bool ShowFacingTick + { + get => _showFacingTick; + set + { + if (_showFacingTick == value) return; + _showFacingTick = value; + QueueRedraw(); + } + } public override void _Draw() { DrawCircle(Vector2.Zero, RadiusWorldPx, new Color(0, 0, 0, 0.78f)); DrawCircle(Vector2.Zero, RadiusWorldPx * 0.85f, new Color(0.86f, 0.31f, 0.24f)); - if (ShowFacingTick) + if (_showFacingTick) { - var dir = new Vector2(Mathf.Cos(FacingAngleRad), Mathf.Sin(FacingAngleRad)); - DrawLine(dir * (RadiusWorldPx * 0.4f), dir * FacingTickPx, - new Color(1f, 0.96f, 0.86f), width: RadiusWorldPx * 0.18f, antialiased: false); + DrawLine( + new Vector2(RadiusWorldPx * 0.4f, 0), + new Vector2(FacingTickPx, 0), + new Color(1f, 0.96f, 0.86f), + width: RadiusWorldPx * 0.18f, + antialiased: false); } } } diff --git a/Theriapolis.Godot/Rendering/WorldRenderNode.cs b/Theriapolis.Godot/Rendering/WorldRenderNode.cs index d4456cf..04b785c 100644 --- a/Theriapolis.Godot/Rendering/WorldRenderNode.cs +++ b/Theriapolis.Godot/Rendering/WorldRenderNode.cs @@ -288,10 +288,22 @@ public partial class WorldRenderNode : Node2D { if (_camera is null) return; float zoom = _camera.Zoom.X; + bool tactical = zoom >= TacticalRenderZoomMin; + if (_tacticalLayer is not null) - _tacticalLayer.Visible = zoom >= TacticalRenderZoomMin; + _tacticalLayer.Visible = tactical; if (_settlementLayer is not null) _settlementLayer.Visible = zoom < SettlementHideZoom; + + // Polylines and bridges are baked into the tactical chunk surface + // tiles by TacticalChunkGen.Pass2_Polylines, so re-stroking the + // Line2D overlay at tactical zoom double-draws the road and shows + // as a brown line over top of the rasterised one. Hide the line + // overlay when tactical is active. + if (_polylineLayer is not null) + _polylineLayer.Visible = !tactical; + if (_bridgeLayer is not null) + _bridgeLayer.Visible = !tactical; } private void UpdateZoomScaledNodes() diff --git a/Theriapolis.Godot/Scenes/PlayScreen.cs b/Theriapolis.Godot/Scenes/PlayScreen.cs index b07397d..2105abd 100644 --- a/Theriapolis.Godot/Scenes/PlayScreen.cs +++ b/Theriapolis.Godot/Scenes/PlayScreen.cs @@ -152,7 +152,7 @@ public partial class PlayScreen : Control _playerMarker = new PlayerMarker { Position = new Vector2(_actors.Player!.Position.X, _actors.Player.Position.Y), - FacingAngleRad = _actors.Player.FacingAngleRad, + Rotation = _actors.Player.FacingAngleRad, }; AddChild(_playerMarker); @@ -182,23 +182,39 @@ public partial class PlayScreen : Control bool tactical = _render.Camera.Zoom.X >= WorldRenderNode.TacticalRenderZoomMin; - // Tactical WASD direction (world-map mode ignores keys — middle-drag - // pans, click-to-travel sets the destination). - float dx = 0f, dy = 0f; - if (tactical) + // WASD is context-sensitive: tactical mode steps the player, + // world-map mode pans the camera. Same keys, intent depends on zoom. + float wasdX = 0f, wasdY = 0f; + if (Godot.Input.IsKeyPressed(Key.W) || Godot.Input.IsKeyPressed(Key.Up)) wasdY -= 1f; + if (Godot.Input.IsKeyPressed(Key.S) || Godot.Input.IsKeyPressed(Key.Down)) wasdY += 1f; + if (Godot.Input.IsKeyPressed(Key.A) || Godot.Input.IsKeyPressed(Key.Left)) wasdX -= 1f; + if (Godot.Input.IsKeyPressed(Key.D) || Godot.Input.IsKeyPressed(Key.Right)) wasdX += 1f; + + // Controller always ticks (path-follow runs even when WASD is idle). + // Pass step input only in tactical mode. + float stepX = tactical ? wasdX : 0f; + float stepY = tactical ? wasdY : 0f; + _controller.Update(dt, stepX, stepY, tactical, isFocused: true); + + // World-map WASD pan. Skip while traveling — the follow logic below + // re-centres the camera on the player and would clobber the pan. + // Speed scales inversely with zoom so the on-screen pan rate feels + // consistent at any zoom level (matches MonoGame's 400 px/sec). + if (!tactical && !_controller.IsTraveling && (wasdX != 0f || wasdY != 0f)) { - if (Godot.Input.IsKeyPressed(Key.W) || Godot.Input.IsKeyPressed(Key.Up)) dy -= 1f; - if (Godot.Input.IsKeyPressed(Key.S) || Godot.Input.IsKeyPressed(Key.Down)) dy += 1f; - if (Godot.Input.IsKeyPressed(Key.A) || Godot.Input.IsKeyPressed(Key.Left)) dx -= 1f; - if (Godot.Input.IsKeyPressed(Key.D) || Godot.Input.IsKeyPressed(Key.Right)) dx += 1f; + const float PanScreenPxPerSec = 400f; + float invLen = (wasdX != 0f && wasdY != 0f) ? 0.70710678f : 1f; + float panSpeed = PanScreenPxPerSec / Mathf.Max(_render.Camera.Zoom.X, 0.01f); + _render.Camera.Position += new Vector2(wasdX * invLen, wasdY * invLen) * panSpeed * dt; } - _controller.Update(dt, dx, dy, tactical, isFocused: true); - - // Sync the player marker from Core state. + // Sync the player marker from Core state. Rotation drives the + // facing tick via the transform — auto-property setters on a + // PlayerMarker field would skip QueueRedraw and the cached + // _Draw commands would stay stuck at the initial angle. var p = _actors.Player; _playerMarker.Position = new Vector2(p.Position.X, p.Position.Y); - _playerMarker.FacingAngleRad = p.FacingAngleRad; + _playerMarker.Rotation = p.FacingAngleRad; // Camera follow when traveling or in tactical (matches MonoGame). if (_controller.IsTraveling || tactical) @@ -591,7 +607,7 @@ public partial class PlayScreen : Control string viewBlock = tactical ? "View: Tactical (WASD to step)" - : "View: World Map (click a tile to travel)"; + : "View: World Map (WASD to pan · click a tile to travel)"; string status = _controller.IsTraveling ? "Traveling…"