Skip to content

Virtual Joystick Movement Implementation Plan

For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (- [ ]) syntax for tracking.

Goal: Replace the player's tap-to-move pointer input with a floating virtual joystick; park sprint, dodge, tap-to-hunt, and map tap-to-move (kept in code, disabled, revivable).

Architecture: A UGUI VirtualJoystickView (built in code, mounted inside the in-raid HUD) exposes a dead-zoned stick vector. A JoystickInput component on the player converts it to a world direction (camera-yaw rotated) and feeds a new direct-steer mode on PathAgent — which stays the single mover (enemies/rivals untouched) and grows an IsMoving property that "is the player moving" consumers switch to. Spec: docs/superpowers/specs/2026-07-21-virtual-joystick-design.md.

Tech Stack: Unity (project at unity/raiders), UGUI + EventSystem (already present, InputSystemUIInputModule), Zenject DI, Unity MCP tools (unity_*) for compile checks, scene edits, and asset saves.

Global Constraints

  • No test suite exists (per CLAUDE.md). The test cycle per task is: mcp unity_get_compilation_errors must return zero errors, plus the play-mode checks listed in the final task.
  • Never script-write GameConfig.asset destructively — new serialized fields are persisted via EditorUtility.SetDirty + AssetDatabase.SaveAssets (Task 7), never by regenerating the file. Do not delete the user's save (user playtests live).
  • Park, don't delete: parked code keeps compiling. ClickToMove.cs stays in the project and references PlayerController.Dodge/SetHunt/FindTapTarget/HuntTarget/Sprinting and PathAgent.Sprinting — those public members MUST remain.
  • Rival raiders still sprint: PathAgent.Sprinting/sprintMultiplier and GameConfig.move.sprintMult stay functional. Only the player stops driving sprint.
  • Mark every parked call site with a comment containing PARKED (virtual joystick, 2026-07-21).
  • Work on branch bza-dev. Commit after each task.
  • All paths below are relative to repo root E:\lumex\raiders.

Task 1: PathAgent direct-steer mode

Files: - Modify: unity/raiders/Assets/Scripts/World/PathAgent.cs

Interfaces: - Produces: void SetMoveInput(Vector3 worldDir) (stamp each frame; magnitude ≤ 1 scales speed), bool IsMoving (path OR live stick input). Consumed by Tasks 3 and 5.

  • [ ] Step 1: Add state + API

Add fields/properties to PathAgent (below the existing Sprinting property):

        /// <summary>Direct-steer input (virtual joystick): a world-XZ direction stamped each frame by
        /// JoystickInput, consumed (and cleared) in Update. Magnitude ≤ 1 scales the walk speed (analog
        /// stick). Non-zero input overrides path-following. Zero/stale ⇒ normal path behaviour.</summary>
        Vector3 _moveInput;
        bool _steering;   // direct-steer applied this frame (feeds IsMoving)

        /// <summary>The agent is in motion — following a path OR being direct-steered by the joystick.
        /// Use this (not HasPath) for every "is the player moving" gameplay check.</summary>
        public bool IsMoving => HasPath || _steering;

        /// <summary>Stamp this frame's direct-steer direction (world XZ, magnitude clamped to 1).
        /// Call every frame while steering; the stamp is consumed once, so a dead caller can't
        /// leave the agent walking forever.</summary>
        public void SetMoveInput(Vector3 worldDir)
        {
            worldDir.y = 0f;
            _moveInput = worldDir.sqrMagnitude > 1f ? worldDir.normalized : worldDir;
        }
  • [ ] Step 2: Consume the input in Update

Replace the existing Update:

        void Update()
        {
            if (_moveInput.sqrMagnitude > 1e-4f)
            {
                if (HasPath) DropPath();          // stick input overrides any pending path
                DirectSteer(_moveInput, Time.deltaTime);
                _steering = true;
            }
            else
            {
                _steering = false;
                if (autoMove) MoveAlongPath(Time.deltaTime);
            }
            _moveInput = Vector3.zero;            // consume the stamp (see SetMoveInput)
        }
  • [ ] Step 3: Add DirectSteer with the shared wall-slide

Add below MoveAlongPath (same per-axis Los.WallBlocked slide the player's dodge/hunt steering uses):

        // Direct steering (virtual joystick): step along the input with the same per-axis wall-slide
        // the dodge/hunt code uses, so walls are slid along, never tunneled. Analog: |input| < 1 walks
        // proportionally slower. Sprint is NOT applied here (player sprint is parked; rivals never steer).
        void DirectSteer(Vector3 input, float dt)
        {
            float mag = input.magnitude;
            if (mag < 1e-4f || dt <= 0f) return;
            Vector3 dir = input / mag;
            float step = speed * externalSpeedMul * Mathf.Min(1f, mag) * dt;

            var grid = Map;
            float ts = map != null ? map.tileSize : 1f;
            const float br = 0.32f;   // player body radius — matches the hunt-homing steering constant
            Vector3 pos = transform.position;
            float nx = pos.x + dir.x * step, nz = pos.z + dir.z * step;
            if (grid == null || !Los.WallBlocked(grid, ts, nx, pos.z, br)) pos.x = nx;
            if (grid == null || !Los.WallBlocked(grid, ts, pos.x, nz, br)) pos.z = nz;
            transform.position = pos;

            ApplyFacing(dir.x, dir.z, dt);
        }
  • [ ] Step 4: Extract the facing block so both movers share it

In MoveAlongPath, replace the whole if (moved) { ... } tail block with:

            if (moved) ApplyFacing(dirX, dirZ, dt);

and add the extracted helper (verbatim logic from the old block):

        // shared by path-follow and direct steering: ease (or snap) the facing toward the move direction
        void ApplyFacing(float dirX, float dirZ, float dt)
        {
            float want = Mathf.Atan2(dirZ, dirX);
            if (turnRate > 0f)
            {
                // lerp from the LIVE facing (transform.forward), not the stored Facing — so handing off
                // between path-following and direct-steer combat facing never jump-cuts (web moveAlongPath
                // turnRate: "turn the head/cone gradually, not in one frame").
                float cur = Mathf.Atan2(transform.forward.z, transform.forward.x);
                Facing = Pathfinder.LerpAngle(cur, want, Mathf.Clamp01(turnRate * dt));
            }
            else Facing = want;
            var look = new Vector3(Mathf.Cos(Facing), 0f, Mathf.Sin(Facing));
            if (look.sqrMagnitude > 1e-6f) transform.rotation = Quaternion.LookRotation(look, Vector3.up);
        }
  • [ ] Step 5: Compile check

Run MCP unity_get_compilation_errors. Expected: 0 errors.

  • [ ] Step 6: Commit
git add unity/raiders/Assets/Scripts/World/PathAgent.cs
git commit -m "PathAgent: direct-steer mode (SetMoveInput/IsMoving) for the virtual joystick"

Task 2: GameConfig joystick knobs + PARKED block

Files: - Modify: unity/raiders/Assets/Scripts/Config/GameConfig.cs:247-265 (the Move class)

Interfaces: - Produces: cfg.move.joyRadius (float, default 110), cfg.move.joyDeadZone (float, default 0.12). Consumed by Task 5's HUD mount.

  • [ ] Step 1: Rewrite the Move class body

Replace the field list of GameConfig.Move (keep the class/attribute wrapper) with — note every existing field keeps its exact name and default so serialized .asset values survive:

            [Tooltip("Ungeared move pace (px/s) — the floor with no speed gear.")] public float baseMove = 80;
            [Tooltip("px/s added per point of gear speed (statSpeed − baseSpeed).")] public float speedGain = 0.25f;
            [Tooltip("Virtual joystick: stick travel radius (px at the UI reference resolution).")] public float joyRadius = 110;
            [Tooltip("Virtual joystick: dead zone as a fraction of joyRadius (0..1); deflection past it is rescaled to 0..1.")] public float joyDeadZone = 0.12f;

            // —— PARKED (virtual joystick, 2026-07-21): sprint & dodge have no input attached ——
            // Fields stay serialized so the tuned .asset values survive for revival. sprintMult is still
            // applied to PathAgent.sprintMultiplier at spawn (rival raiders sprint); the rest is read only
            // by parked code paths (StaminaModel, PlayerController.Dodge, stealth-chase).
            [Tooltip("PARKED — Sprint speed ×.")] public float sprintMult = 1.9f;
            [Tooltip("PARKED — Stamina/sec while sprinting.")] public float sprintDrain = 32;
            [Tooltip("PARKED — Stamina/sec recovered.")] public float stamRegen = 24;
            [Tooltip("PARKED — Max stamina.")] public float stamMax = 100;
            [Tooltip("PARKED — Pause before stamina refills (s).")] public float stamRegenDelay = 0.6f;
            [Tooltip("PARKED — Stamina per dodge roll.")] public float dodgeCost = 30;
            [Tooltip("PARKED — Roll duration (s).")] public float dodgeTime = 0.25f;
            [Tooltip("PARKED — PEAK roll speed (px/s) at launch — eases down over the roll per dodgeEase; travel ≈ speed×0.5×dodgeTime at ease 1.")] public float dodgeSpeed = 500;
            [Tooltip("PARKED — Roll deceleration exponent: 1 = constant brake (clear stop), <1 = sustain then brake, >1 = front-loaded burst.")] public float dodgeEase = 1.0f;
            [Tooltip("PARKED — Roll cooldown (s).")] public float dodgeCd = 0.55f;
            [Tooltip("PARKED — Casual aid: tapping an UNDETECTED enemy auto-sprints toward it FREE, ending the instant any enemy spots you.")] public bool stealthChaseSprint = true;
            // EXCLUDED (audit): tap/swipe input feel was owned by the (parked) ClickToMove component
            // (doubleTapWindow, swipeMinPixels, swipeWindow) and isn't read here.
  • [ ] Step 2: Compile check

Run MCP unity_get_compilation_errors. Expected: 0 errors.

  • [ ] Step 3: Commit
git add unity/raiders/Assets/Scripts/Config/GameConfig.cs
git commit -m "Config: joystick knobs (joyRadius/joyDeadZone); sprint/dodge knobs marked PARKED"

Task 3: Switch "is the player moving" consumers to IsMoving

Files: - Modify: unity/raiders/Assets/Scripts/World/PlayerController.cs:265,367,456 - Modify: unity/raiders/Assets/Scripts/World/Gate.cs:55 - Modify: unity/raiders/Assets/Scripts/World/CameraRig.cs:86

Interfaces: - Consumes: PathAgent.IsMoving (Task 1).

  • [ ] Step 1: PlayerController — heal-channel cancel (line ~265)
                if (_agent.IsMoving || _dodgeT > 0f) { _healT = -1f; HealInterrupted(); }   // moved → interrupted (web floater 1493)
  • [ ] Step 2: PlayerController — melee facing arbitration (line ~367, in AutoMelee)
            if (found != null && !_agent.IsMoving && _huntTarget == null) FacePoint(found.Transform.position);
  • [ ] Step 3: PlayerController — fire facing arbitration (line ~456, in AutoFire)
            if (best != null && !_agent.IsMoving && _huntTarget == null) FacePoint(best.Transform.position);
  • [ ] Step 4: Gate idle check (line ~55)
            bool idle = (_playerAgent == null || !_playerAgent.IsMoving) && !_raid.PlayerHunting;
  • [ ] Step 5: CameraRig moving-zoom (line ~86)
            bool moving = agent != null && agent.IsMoving;
  • [ ] Step 6: Compile check

Run MCP unity_get_compilation_errors. Expected: 0 errors.

  • [ ] Step 7: Commit
git add unity/raiders/Assets/Scripts/World/PlayerController.cs unity/raiders/Assets/Scripts/World/Gate.cs unity/raiders/Assets/Scripts/World/CameraRig.cs
git commit -m "Movement consumers: HasPath -> IsMoving (heal cancel, facing, gate idle, camera zoom)"

Task 4: VirtualJoystickView (UGUI, code-built)

Files: - Create: unity/raiders/Assets/Scripts/UI/InGame/VirtualJoystickView.cs

Interfaces: - Produces: static VirtualJoystickView Current, Vector2 Value (screen-space, y-up, dead-zoned, |v| ≤ 1), bool Active, static VirtualJoystickView Build(RectTransform parent, float radiusPx, float deadZone). Consumed by Task 5.

  • [ ] Step 1: Write the component (complete file)
using UnityEngine;
using UnityEngine.EventSystems;
using UnityEngine.UI;

namespace ShadowRaiders.UI.InGame
{
    /// <summary>
    /// Floating virtual movement joystick — the tap-to-move replacement. A fullscreen transparent
    /// raycast catcher inside the in-raid HUD: touch anywhere NOT covered by a HUD widget and the
    /// stick base appears under the finger; the knob follows within <see cref="radiusPx"/>; release
    /// fades it out. Built entirely in code (procedural circle sprites) via <see cref="Build"/> — no
    /// prefab, no scene setup. Tracks the grabbing pointerId so a second simultaneous finger can
    /// still press HUD buttons. Pure input view: exposes <see cref="Value"/>/<see cref="Active"/>,
    /// consumed by <c>JoystickInput</c> on the player. Mounted FIRST sibling so every authored HUD
    /// widget draws above it and blocks its raycasts (buttons win over the stick).
    /// </summary>
    public class VirtualJoystickView : MonoBehaviour, IPointerDownHandler, IDragHandler, IPointerUpHandler
    {
        /// <summary>The live instance (one per raid HUD) — read by JoystickInput. Null when no HUD is up.</summary>
        public static VirtualJoystickView Current { get; private set; }

        [Tooltip("Stick travel radius (px at the UI reference resolution). Driven from GameConfig.move.joyRadius.")]
        public float radiusPx = 110f;
        [Tooltip("Dead zone as a fraction of radiusPx. Driven from GameConfig.move.joyDeadZone.")]
        public float deadZone = 0.12f;
        [Tooltip("Fade in/out time (s) for the stick visuals.")]
        public float fadeTime = 0.12f;

        RectTransform _rt;            // the fullscreen catcher (this object)
        RectTransform _base, _knob;   // stick visuals
        CanvasGroup _fade;
        int _pointerId = int.MinValue;   // int.MinValue = no finger owns the stick
        Vector2 _origin;                 // stick centre in catcher-local px
        Vector2 _value;

        /// <summary>Dead-zoned stick deflection (screen space, y up), magnitude 0..1. Zero when idle.</summary>
        public Vector2 Value => _value;
        /// <summary>A finger (or the mouse) currently owns the stick.</summary>
        public bool Active => _pointerId != int.MinValue;

        /// <summary>Create the joystick under a HUD rect: fullscreen catcher + base ring + knob.</summary>
        public static VirtualJoystickView Build(RectTransform parent, float radiusPx, float deadZone)
        {
            var go = new GameObject("VirtualJoystick", typeof(RectTransform));
            var rt = (RectTransform)go.transform;
            rt.SetParent(parent, false);
            rt.anchorMin = Vector2.zero; rt.anchorMax = Vector2.one;
            rt.offsetMin = Vector2.zero; rt.offsetMax = Vector2.zero;

            var catcher = go.AddComponent<Image>();   // invisible but raycastable — the touch surface
            catcher.color = Color.clear;
            catcher.raycastTarget = true;

            var joy = go.AddComponent<VirtualJoystickView>();
            joy.radiusPx = radiusPx;
            joy.deadZone = deadZone;
            joy.BuildStick();
            return joy;
        }

        void BuildStick()
        {
            _rt = (RectTransform)transform;
            var holder = new GameObject("Stick", typeof(RectTransform), typeof(CanvasGroup));
            var hrt = (RectTransform)holder.transform;
            hrt.SetParent(_rt, false);
            _fade = holder.GetComponent<CanvasGroup>();
            _fade.alpha = 0f;
            _fade.blocksRaycasts = false;
            _fade.interactable = false;

            _base = MakeCircle(hrt, "Base", CircleSprite(ring: true), radiusPx * 2f);
            _knob = MakeCircle(hrt, "Knob", CircleSprite(ring: false), radiusPx * 0.8f);
        }

        static RectTransform MakeCircle(RectTransform parent, string name, Sprite sprite, float sizePx)
        {
            var go = new GameObject(name, typeof(RectTransform), typeof(Image));
            var rt = (RectTransform)go.transform;
            rt.SetParent(parent, false);
            rt.sizeDelta = new Vector2(sizePx, sizePx);
            var img = go.GetComponent<Image>();
            img.sprite = sprite;
            img.color = new Color(1f, 1f, 1f, 0.9f);
            img.raycastTarget = false;
            return rt;
        }

        // procedural anti-aliased circle sprite: a solid disc (knob) or an outlined ring + faint fill (base)
        static Sprite CircleSprite(bool ring)
        {
            const int S = 96;
            float c = (S - 1) * 0.5f, R = c - 2f;
            var tex = new Texture2D(S, S, TextureFormat.RGBA32, false) { wrapMode = TextureWrapMode.Clamp };
            var px = new Color32[S * S];
            for (int y = 0; y < S; y++)
                for (int x = 0; x < S; x++)
                {
                    float d = Mathf.Sqrt((x - c) * (x - c) + (y - c) * (y - c));
                    float a;
                    if (ring)
                    {
                        float outline = Mathf.Clamp01(2.5f - Mathf.Abs(d - (R - 1.5f)));   // ~3px edge ring
                        float fill = Mathf.Clamp01(R - d) * 0.10f;                          // faint interior
                        a = Mathf.Max(outline * 0.85f, fill);
                    }
                    else a = Mathf.Clamp01(R - d);                                          // solid disc, AA edge
                    px[y * S + x] = new Color32(255, 255, 255, (byte)(a * 255f));
                }
            tex.SetPixels32(px);
            tex.Apply();
            return Sprite.Create(tex, new Rect(0, 0, S, S), new Vector2(0.5f, 0.5f), 100f);
        }

        void OnEnable() { Current = this; }
        void OnDisable() { if (Current == this) Current = null; Release(); }

        public void OnPointerDown(PointerEventData e)
        {
            if (Active) return;   // one finger owns the stick; extra touches fall through
            _pointerId = e.pointerId;
            _origin = LocalPoint(e);
            _base.anchoredPosition = _origin;
            _knob.anchoredPosition = _origin;
            _value = Vector2.zero;
        }

        public void OnDrag(PointerEventData e)
        {
            if (e.pointerId != _pointerId) return;
            Vector2 d = LocalPoint(e) - _origin;
            Vector2 clamped = Vector2.ClampMagnitude(d, radiusPx);
            _knob.anchoredPosition = _origin + clamped;
            float mag = clamped.magnitude / radiusPx;   // 0..1
            _value = mag < deadZone
                ? Vector2.zero
                : clamped.normalized * ((mag - deadZone) / (1f - deadZone));   // rescale past the dead zone
        }

        public void OnPointerUp(PointerEventData e)
        {
            if (e.pointerId != _pointerId) return;
            Release();
        }

        void Release() { _pointerId = int.MinValue; _value = Vector2.zero; }

        void Update()
        {
            float target = Active ? 1f : 0f;
            if (_fade != null && !Mathf.Approximately(_fade.alpha, target))
                _fade.alpha = Mathf.MoveTowards(_fade.alpha, target, Time.deltaTime / Mathf.Max(0.01f, fadeTime));
        }

        Vector2 LocalPoint(PointerEventData e)
        {
            RectTransformUtility.ScreenPointToLocalPointInRectangle(_rt, e.position, e.pressEventCamera, out Vector2 lp);
            return lp;
        }
    }
}
  • [ ] Step 2: Compile check

Run MCP unity_get_compilation_errors. Expected: 0 errors.

  • [ ] Step 3: Commit
git add unity/raiders/Assets/Scripts/UI/InGame/VirtualJoystickView.cs
git commit -m "UI: floating VirtualJoystickView (code-built, multi-touch-safe, procedural sprites)"

Task 5: JoystickInput on the player + HUD mount

Files: - Create: unity/raiders/Assets/Scripts/World/JoystickInput.cs - Modify: unity/raiders/Assets/Scripts/UI/InGame/InGameViewController.cs:56 (after the canvas-sorting block in Start)

Interfaces: - Consumes: VirtualJoystickView.Current/.Value/.Active (Task 4), PathAgent.SetMoveInput (Task 1), PlayerController.Controllable, cfg.move.joyRadius/joyDeadZone (Task 2).

  • [ ] Step 1: Write JoystickInput (complete file)
using UnityEngine;

namespace ShadowRaiders.World
{
    /// <summary>
    /// Virtual-joystick movement input — the tap-to-move (ClickToMove, PARKED) replacement. Reads the
    /// HUD's <see cref="ShadowRaiders.UI.InGame.VirtualJoystickView"/> (plus WASD/arrows as a dev
    /// convenience) each frame, rotates the screen-space stick vector by the camera's Y yaw so
    /// stick-up = camera-forward on the ground plane, and feeds <see cref="PathAgent.SetMoveInput"/>.
    /// Gates on <see cref="PlayerController.Controllable"/> (dead / raid over ⇒ zero input).
    /// Sprint/dodge have no input here yet — PARKED (virtual joystick, 2026-07-21).
    /// </summary>
    [RequireComponent(typeof(PathAgent))]
    [RequireComponent(typeof(PlayerController))]
    public class JoystickInput : MonoBehaviour
    {
        [Tooltip("Camera whose yaw maps stick-up to world-forward. Defaults to Camera.main.")]
        public Camera cam;

        PathAgent _agent;
        PlayerController _player;

        void Awake()
        {
            _agent = GetComponent<PathAgent>();
            _player = GetComponent<PlayerController>();
            if (cam == null) cam = Camera.main;
        }

        void Update()
        {
            if (cam == null) { cam = Camera.main; if (cam == null) return; }
            if (_player != null && !_player.Controllable) { _agent.SetMoveInput(Vector3.zero); return; }

            Vector2 v = Vector2.zero;
            var joy = ShadowRaiders.UI.InGame.VirtualJoystickView.Current;
            if (joy != null && joy.Active) v = joy.Value;
            if (v == Vector2.zero) v = KeyboardAxis();
            if (v == Vector2.zero) { _agent.SetMoveInput(Vector3.zero); return; }

            // stick-up = camera-forward on XZ: rotate the 2D stick vector by the camera's Y yaw
            float yaw = cam.transform.eulerAngles.y * Mathf.Deg2Rad;
            float sin = Mathf.Sin(yaw), cos = Mathf.Cos(yaw);
            _agent.SetMoveInput(new Vector3(v.x * cos + v.y * sin, 0f, -v.x * sin + v.y * cos));
        }

        // WASD / arrows — editor & desktop testing aid (same ifdef style ClickToMove used)
        static Vector2 KeyboardAxis()
        {
            float x = 0f, y = 0f;
#if ENABLE_INPUT_SYSTEM
            var kb = UnityEngine.InputSystem.Keyboard.current;
            if (kb != null)
            {
                if (kb.aKey.isPressed || kb.leftArrowKey.isPressed) x -= 1f;
                if (kb.dKey.isPressed || kb.rightArrowKey.isPressed) x += 1f;
                if (kb.sKey.isPressed || kb.downArrowKey.isPressed) y -= 1f;
                if (kb.wKey.isPressed || kb.upArrowKey.isPressed) y += 1f;
            }
#elif ENABLE_LEGACY_INPUT_MANAGER
            if (Input.GetKey(KeyCode.A) || Input.GetKey(KeyCode.LeftArrow)) x -= 1f;
            if (Input.GetKey(KeyCode.D) || Input.GetKey(KeyCode.RightArrow)) x += 1f;
            if (Input.GetKey(KeyCode.S) || Input.GetKey(KeyCode.DownArrow)) y -= 1f;
            if (Input.GetKey(KeyCode.W) || Input.GetKey(KeyCode.UpArrow)) y += 1f;
#endif
            var v = new Vector2(x, y);
            return v.sqrMagnitude > 1f ? v.normalized : v;
        }
    }
}
  • [ ] Step 2: Mount the joystick in InGameViewController.Start

Directly after the canvas-sorting block (canvas.sortingOrder = 100; line) and before the _view.HealClicked += OnHeal; wiring, insert:

            // floating movement joystick — FIRST sibling inside the HUD so every authored widget draws
            // above it and blocks its raycasts (buttons win over the stick); inherits HUD visibility
            // (hidden until Descend, gone on raid end)
            var joy = VirtualJoystickView.Build((RectTransform)_view.transform, _cfg.move.joyRadius, _cfg.move.joyDeadZone);
            joy.transform.SetAsFirstSibling();

(VirtualJoystickView is in the same ShadowRaiders.UI.InGame namespace — no new using needed.)

  • [ ] Step 3: Compile check

Run MCP unity_get_compilation_errors. Expected: 0 errors.

  • [ ] Step 4: Commit
git add unity/raiders/Assets/Scripts/World/JoystickInput.cs unity/raiders/Assets/Scripts/UI/InGame/InGameViewController.cs
git commit -m "Input: JoystickInput drives PathAgent direct-steer; HUD mounts the floating stick"

Task 6: Park the tap-era gameplay paths

Files: - Modify: unity/raiders/Assets/Scripts/World/PlayerController.cs (Update blocks + doc comments) - Modify: unity/raiders/Assets/Scripts/UI/Map/MapController.cs:181-186 (OnBigMapClick) - Modify: unity/raiders/Assets/Scripts/Presentation/PlayerVitalsView.cs (stamina bar) - Modify: unity/raiders/Assets/Scripts/World/ClickToMove.cs:6-11 (header note) - Modify: unity/raiders/Assets/Scripts/World/ClickRipple.cs (header note)

Interfaces: - Consumes: nothing new. Public members Dodge/SetHunt/ClearHunt/MoveTo/FindTapTarget/HuntTarget/Sprinting/Stamina MUST stay (ClickToMove/MapController/TargetingView/CameraRig/TutorialController still compile against them).

  • [ ] Step 1: PlayerController — park the sprint/stamina tick (Update, lines ~240-248)

Replace the stamina block with:

            // PARKED (virtual joystick, 2026-07-21): sprint has no input — the paid-sprint stamina drain,
            // free stealth-chase, and hunt homing below are parked with it. Machinery kept for revival.
            // if (_huntTarget == null || !_huntTarget.IsAlive) _chaseSprint = false;
            // bool freeSprint = _chaseSprint && !_raid.PlayerSpotted;
            // bool sprintMoving = _agent.HasPath || (_huntTarget != null && _huntTarget.IsAlive);
            // bool draining = _agent.Sprinting && sprintMoving && !freeSprint;
            // if (!_stam.Tick(dt, draining)) _agent.Sprinting = false;
            // if (!sprintMoving) { _agent.Sprinting = false; _chaseSprint = false; }

            // UpdateHunt(dt);   // PARKED — tap-to-hunt homing (no tap input to set a hunt target)

(the original UpdateHunt(dt); call line is deleted — it lives in the comment above now.)

  • [ ] Step 2: PlayerController — park the spotted-chase drop (line ~300)
            // PARKED (virtual joystick, 2026-07-21) — stealth-chase sprint (no tap input engages it):
            // if (_raid.PlayerSpotted && _chaseSprint) { _agent.Sprinting = false; _chaseSprint = false; }
  • [ ] Step 3: PlayerController — mark parked public methods

Prepend one line to the <summary> doc of each of Dodge, SetHunt, ClearHunt, MoveTo, FindTapTarget, and UpdateHunt:

/// PARKED (virtual joystick, 2026-07-21): no live input calls this — kept compiling for revival.
  • [ ] Step 4: MapController — map tap no longer walks the player

Replace OnBigMapClick:

        // web handleMapTap — PARKED (virtual joystick, 2026-07-21): the tap-to-walk autopilot conflicts
        // with direct stick control, so a tap anywhere now just closes the map. MovePlayerTo kept for revival.
        void OnBigMapClick(Vector2 screenPos)
        {
            Close();
        }
  • [ ] Step 5: PlayerVitalsView — hide the stamina bar

In Awake, after _stBg = BuildLine(); _stFill = BuildLine(); add:

            // PARKED (virtual joystick, 2026-07-21): sprint/dodge have no input → stamina never moves;
            // hide the stamina bar until sprint returns. (Built but never shown — SetBar won't run on them.)
            _stBg.gameObject.SetActive(false); _stFill.gameObject.SetActive(false);

In LateUpdate, replace the fraction block and the bar stack (lines ~96-108) with:

            float hpFrac = _hp.Fraction;
            bool full = hpFrac >= 0.999f;   // stamina PARKED (always full) — HP alone drives the ghost fade

            // ---- HP bar under the feet (stamina bar PARKED with sprint/dodge) ----
            float barAlpha = full ? idleAlpha : activeAlpha;
            float y = stackTop * k;
            SetBar(_hpBg, feet, right, up, y, barWidth * k, hpThickness * k, 1f, Fade(hpBg, barAlpha));
            SetBar(_hpFill, feet, right, up, y, barWidth * k, hpThickness * k, hpFrac, Fade(hpFrac < lowHpFrac ? hpLow : hpFull, barAlpha));
            y -= (hpThickness + rowGap) * k;
  • [ ] Step 6: ClickToMove + ClickRipple — PARKED header notes

At the top of each class's <summary>, add as the first line:

/// PARKED (virtual joystick, 2026-07-21): removed from Game.unity — movement is JoystickInput +
/// PathAgent.SetMoveInput now. Kept compiling for revival (tap-hunt / swipe-dodge / ripple feedback).
  • [ ] Step 7: Compile check

Run MCP unity_get_compilation_errors. Expected: 0 errors (watch for unused-field warnings — fine; errors — not).

  • [ ] Step 8: Commit
git add unity/raiders/Assets/Scripts/World/PlayerController.cs unity/raiders/Assets/Scripts/UI/Map/MapController.cs unity/raiders/Assets/Scripts/Presentation/PlayerVitalsView.cs unity/raiders/Assets/Scripts/World/ClickToMove.cs unity/raiders/Assets/Scripts/World/ClickRipple.cs
git commit -m "Park tap-era paths: sprint/stamina tick, hunt homing, map tap-walk, stamina bar, ClickToMove"

Task 7: Scene + config-asset wiring (Unity MCP)

Files: - Modify: unity/raiders/Assets/Scenes/Game.unity (Player object: − ClickToMove, + JoystickInput) - Modify: unity/raiders/Assets/Config/GameConfig.asset (persist the two new fields — via editor serialization, NOT a hand edit)

Interfaces: - Consumes: JoystickInput (Task 5), GameConfig.joyRadius/joyDeadZone (Task 2).

  • [ ] Step 1: Ensure the editor is connected

Run MCP unity_editor_ping (select the instance if unity_list_instances shows several). Ensure Game.unity is the open scene (unity_scene_info); open it via unity_scene_open if not.

  • [ ] Step 2: Swap the input component on the Player

  • unity_search_by_component for ClickToMove → note the Player GameObject.

  • unity_component_remove ClickToMove from it.
  • unity_component_add ShadowRaiders.World.JoystickInput to the same object (leave cam empty — it self-finds Camera.main).

  • [ ] Step 3: Save the scene

unity_scene_save. Then unity_get_compilation_errors → 0 errors.

  • [ ] Step 4: Persist the new config fields into the asset

Run via unity_execute_code (re-serializes the asset with the new fields at their C# defaults; every user-tuned value is preserved):

var cfg = UnityEditor.AssetDatabase.LoadAssetAtPath<ShadowRaiders.Config.GameConfig>("Assets/Config/GameConfig.asset");
UnityEditor.EditorUtility.SetDirty(cfg);
UnityEditor.AssetDatabase.SaveAssets();

Verify with Grep on unity/raiders/Assets/Config/GameConfig.asset for joyRadius. Expected: joyRadius: 110 and joyDeadZone: 0.12 present.

  • [ ] Step 5: Commit
git add unity/raiders/Assets/Scenes/Game.unity unity/raiders/Assets/Config/GameConfig.asset
git commit -m "Scene: Player uses JoystickInput (ClickToMove off); GameConfig.asset gains joystick knobs"

Task 8: FTUE tutorial — joystick wording, sprint step removed

Files: - Modify: unity/raiders/Assets/Config/Tutorial/TutStage_1_FirstSteps.asset (steps list)

Why: step 2 ("Sprint ahead") gates on the Sprinted condition (type 2) — with sprint parked it can never latch and the FTUE soft-locks. Sprinted/SprintedL stay in code, parked.

  • [ ] Step 1: Edit the steps via unity_execute_code

(Adjust member names if they differ — read Scripts/Tutorial/TutorialStageDef.cs first; the steps list holds entries with header / text fields per the asset YAML.)

var stage = UnityEditor.AssetDatabase.LoadAssetAtPath<ShadowRaiders.Tutorial.TutorialStageDef>(
    "Assets/Config/Tutorial/TutStage_1_FirstSteps.asset");
stage.steps[0].text = "Drag anywhere to move with the joystick. Head up the path and grab the coins.";
stage.steps.RemoveAt(1);   // "Sprint ahead" — Sprinted condition would soft-lock with sprint parked
UnityEditor.EditorUtility.SetDirty(stage);
UnityEditor.AssetDatabase.SaveAssets();
  • [ ] Step 2: Verify

Grep TutStage_1_FirstSteps.asset for Sprint. Expected: no Sprint ahead step remains; step 1 text mentions the joystick; the step count dropped by one (move → board portal).

  • [ ] Step 3: Commit
git add "unity/raiders/Assets/Config/Tutorial/TutStage_1_FirstSteps.asset"
git commit -m "FTUE stage 1: joystick wording; sprint step removed (would soft-lock, sprint parked)"

Task 9: Play-mode verification

No test suite — this is the release gate. Run via MCP unity_play_mode + unity_screenshot_game / unity_graphics_game_capture, plus hands-on checks with the user where noted.

  • [ ] Step 1: Compile + console clean

unity_get_compilation_errors → 0. unity_console_log after entering play mode → no new errors/exceptions.

  • [ ] Step 2: Core movement

In play mode (post-Descend): WASD (editor stand-in for the stick) moves the player; camera moving-zoom widens; facing follows the move direction; walls slide (walk diagonally into a wall — no tunneling, no stall); bush tiles still apply BushSpeed.

  • [ ] Step 3: Joystick UI (mouse-driven)

Click-drag on empty screen space: base+knob appear at the press point, knob clamps to the radius, release fades it out, player stops. Click a HUD button (heal/zoom/retreat): button fires, NO stick appears. Fullscreen map open: map on top, no stick underneath it.

  • [ ] Step 4: Combat + interaction regressions

Walk at an enemy: auto-melee/auto-fire engage while steering; backstab tell + silent kill still work from behind. Stand next to a chest/portal/gate with the stick released: proximity interaction starts (IsMoving false when idle); nudging the stick cancels boarding-style idleness (gate crack pauses). Start a heal channel, then push the stick: "Interrupted!" fires.

  • [ ] Step 5: FTUE stage 1

Fresh-save run (coordinate with the user — do NOT delete their live save without asking): stage 1 shows joystick wording, no sprint step, completes to the portal.

  • [ ] Step 6: Report + finish

Report results (with screenshots) to the user. Then use superpowers:finishing-a-development-branch.