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_errorsmust return zero errors, plus the play-mode checks listed in the final task. - Never script-write
GameConfig.assetdestructively — new serialized fields are persisted viaEditorUtility.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.csstays in the project and referencesPlayerController.Dodge/SetHunt/FindTapTarget/HuntTarget/SprintingandPathAgent.Sprinting— those public members MUST remain. - Rival raiders still sprint:
PathAgent.Sprinting/sprintMultiplierandGameConfig.move.sprintMultstay 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
DirectSteerwith 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
Moveclass 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_componentforClickToMove→ note the Player GameObject. unity_component_removeClickToMovefrom it.-
unity_component_addShadowRaiders.World.JoystickInputto the same object (leavecamempty — it self-findsCamera.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.