Skip to content

Virtual Joystick Movement — Design Spec

Date: 2026-07-21 Scope: Unity project (unity/raiders). Replace tap-to-move pointer input with a floating virtual joystick. Sprint, dodge, tap-to-hunt, and map tap-to-move are parked (kept in code, disabled, revivable) — not deleted.

Goal

The player steers directly with a floating on-screen stick (casual-mobile standard: Archero, Survivor.io). No A* autopilot for the player while the stick is held. Combat stays fully automatic (existing AutoFire / AutoMelee / stance arbiter — untouched).

Decisions (confirmed)

Question Decision
Joystick style Floating — base appears where the thumb lands, fades on release
Tap-an-enemy hunt (SetHunt homing, stealth-chase) Parked — walk at enemies yourself; auto-combat handles the rest
Swipe dodge roll Parked with sprint — re-attach later (e.g. as a button) if wanted
Tactical-map tap-to-move Parked — map tap just closes the map
Sprint Parked (explicit user request) — machinery stays for rival raiders

Architecture

1. New input layer (2 scripts)

VirtualJoystickView (Scripts/UI/InGame/) — UGUI view, built at runtime by InGameViewController and parented inside the InGameView HUD as first sibling:

  • Inherits HUD visibility: hidden until Descend (RaidState.Active), gone on raid end.
  • First sibling ⇒ every existing HUD button draws above it and blocks its raycasts — no IsPointerOverGameObject polling needed.
  • Fullscreen transparent Image raycast catcher implementing IPointerDownHandler / IDragHandler / IPointerUpHandler. On pointer-down: stick base (ring) + knob (disc) appear at the touch point; knob clamps to joyRadius; on release both fade out.
  • Tracks the pointerId that grabbed the stick, so a second simultaneous finger can still press HUD buttons (multi-touch safe).
  • Exposes Vector2 Value (normalized, dead-zoned via joyDeadZone) and bool Active.
  • Visuals: simple UGUI images (generated sprites or Unity built-ins), semi-transparent; respects UiScale reference resolution.

JoystickInput (Scripts/World/) — world component on the Player object, replacing ClickToMove in Game.unity:

  • Each frame: read VirtualJoystickView.Value; in editor/dev also read WASD/arrow keys (both ENABLE_INPUT_SYSTEM / ENABLE_LEGACY_INPUT_MANAGER paths, same ifdef style ClickToMove used).
  • Rotate the screen-space stick vector by the camera's Y yaw so stick-up = camera-forward projected on XZ (camera is a fixed-yaw ortho iso rig).
  • Gate on PlayerController.Controllable (dead / raid over ⇒ zero input).
  • Feed the result to PathAgent.SetMoveInput(worldDir) every frame (zero vector when idle).
  • Finds the joystick view via a static registry hook (VirtualJoystickView.Current) since the HUD is DI-spawned after the player.

2. PathAgent direct-steer mode (extend, don't fork)

PathAgent stays the single mover — enemies/rivals keep using it exactly as today.

  • New: public void SetMoveInput(Vector3 worldDir) (stamped each frame; consumed in Update).
  • Non-zero input: drop any path (DropPath() semantics), then step speed × externalSpeedMul × dt along the direction with the same per-axis wall-slide the dodge/hunt code uses (Los.WallBlocked, body radius 0.32) — sliding along walls, no tunneling.
  • Reuses the existing speed pipeline untouched: gear speed, bush perk (BushSpeed), spider web-slow (externalSpeedMul) all keep working because they already flow through _agent.speed / externalSpeedMul.
  • Updates Facing (with turnRate easing), rotation, and Velocity identically to path moves (velocity inheritance for projectiles keeps working).
  • New: public bool IsMoving => HasPath || <stick input this frame>.
  • Sprint fields (Sprinting, sprintMultiplier) untouched — rival raiders still drive them.

3. "Is the player moving" consumers switch HasPathIsMoving

Site Change
PlayerController heal-channel cancel _agent.HasPath_agent.IsMoving
PlayerController melee/fire facing arbitration (!_agent.HasPath && _huntTarget == null) use IsMoving (movement facing wins while steering)
Gate.cs idle check !_playerAgent.HasPath!_playerAgent.IsMoving
CameraRig moving-zoom / look-ahead agent.HasPathagent.IsMoving (look-ahead along Facing works as-is)

4. Parked (kept, disabled, marked PARKED)

  • ClickToMove, ClickRipple: components removed from Game.unity; files kept with a PARKED — replaced by virtual joystick (see 2026-07-21 spec) header note.
  • PlayerController: sprint/stamina tick block, stealth-chase (_chaseSprint), hunt homing (UpdateHunt, SetHunt, ClearHunt, FindTapTarget), Dodge, MoveTo — call sites removed/commented with PARKED notes. Public members stay so parked callers still compile; HuntTarget remains but is always null ⇒ TargetingView hunt mark and CameraRig hunt framing die naturally (their FireTarget branches still work).
  • MapController.OnBigMapClick: tap no longer calls MovePlayerTo — just closes. MovePlayerTo / PlayerController.MoveTo kept, parked.
  • PlayerVitalsView: stamina ring hidden (stamina never changes). StaminaModel stays constructed (cheap, null-safe) but its Tick is no longer called.
  • PathVisualizer: stays; the player simply never has a path (draws nothing).
  • RaidState.PlayerHunting: stays, permanently false — Chest/Gate proximity gating degrades gracefully.
  • Tutorial: Sprinted condition + SprintedL latch stay in code, parked.

5. Config (GameConfig.Move)

  • New knobs: joyRadius (stick travel radius, px at reference resolution, default ~110) and joyDeadZone (0..1 normalized, default 0.12). Written to GameConfig.asset too (project rule: new fields get asset values, not just C# defaults).
  • Sprint/stamina/dodge/stealth-chase knobs (sprintMult, sprintDrain, stamRegen, stamMax, stamRegenDelay, dodgeCost, dodgeTime, dodgeSpeed, dodgeEase, dodgeCd, stealthChaseSprint) grouped under a — PARKED (sprint/dodge — no input attached) — comment block. Fields stay serialized so .asset values survive for revival. (sprintMult is still read at spawn for _agent.sprintMultiplier — harmless, rivals use it.)

6. Content changes

  • Game.unity: Player object — remove ClickToMove, add JoystickInput.
  • TutStage_1_FirstSteps.asset:
  • Step 1 text: "Tap anywhere to move" → joystick wording ("Drag anywhere to move with the joystick…").
  • Step 2 "Sprint ahead" removed — its Sprinted (type 2) condition would soft-lock the FTUE with sprint parked.

Error handling / edge cases

  • Stick held when the player dies / raid ends: JoystickInput gates on Controllable; the HUD (and stick) also deactivate. PathAgent gets a zero input stamp ⇒ stops.
  • Stick held while a heal channel starts: movement wins — StartHealChannel is a no-op while _agent.IsMoving (release the stick to drink). A stick push during an already-running channel still cancels it, same as tap-move's move-cancels rule.
  • Pointer starts on a HUD button: button's raycast blocks the catcher ⇒ no stick. Pointer starts on the catcher, drags over a button: stick keeps the pointer (UGUI drag capture).
  • No touch device (editor): mouse drag works through the same UGUI pointer events; WASD as a dev convenience.

Verification

  • Editor play-mode: joystick appears/steers/fades; walls slide; bush perk + web-slow still scale speed; auto-melee/auto-fire engage while strafing; heal channel cancels on stick input; chest/portal/gate proximity interactions fire when idle; FTUE stage 1 completes without the sprint step; camera moving-zoom responds to stick movement.
  • No compile errors via Unity MCP unity_get_compilation_errors.