NiceVibrations Haptics — Design Spec¶
Date: 2026-07-09 · Status: approved scope: core tier, static in-code map
Goal¶
Wire the newly imported NiceVibrations module (Assets/ExternalAssets/Feel/NiceVibrations/, Lofelt 4.x,
self-contained — no other Feel content required) into Shadow Raiders so that key
player-facing beats fire mobile haptic feedback, gated by the existing settings toggle.
Components¶
1. Assets/Scripts/Presentation/Haptics.cs (new)¶
Static helper mirroring the Floaters / UiFeedback seam pattern:
public enum HapticEvent
{
UiTap, UiConfirmArm, // UI
PlayerHit, LowHp, Death, // survival
MeleeHit, Crit, Kill, BackstabKill, BossDown, // combat
ChestOpen, ChestLegendary, GateOpen, // interaction
PortalCalled, PortalOpen, Extracted, KeyPickup, // extraction
LootCommon, LootRare, // loot
Dodge, HealDone, // movement
StormWarn, StormBreak, // world alerts
}
public static class Haptics
{
public static void Play(HapticEvent e);
}
Playmaps the event to aHapticPatterns.PresetTypevia one static switch/table — the single retuning point.- Gates:
GameSettings.Vibrationmust be true (read live per call — no subscription needed); global minimum gap of 60 ms between fires (Time.unscaledTime) so simultaneous beats don't mush into one long buzz. Exception:UiTap/Selectionpresets never block a bigger event; ordering is fire-and-forget, first-come. - No
Configure()needed — the class is stateless apart from the last-fire timestamp. - Calls
HapticPatterns.PlayPreset(...)(fire-and-forget; safe no-op in editor without a gamepad).
2. Scene wiring¶
- Add one
HapticReceivercomponent (fromLofelt.NiceVibrations) to the persistent bootstrap GameObject in the boot scene (the object carryingMetaLoopController, or theUIRoot— whichever survives scene loads). It handles app-focus interruption. Leave its serializedhapticsEnabled/outputLevelat defaults; our gate is upstream inHaptics.Play.
3. Settings integration (already exists — no new work)¶
SaveService.Data.vibration(persisted),GameSettings.Vibration+SetVibration()(runtime seam),SettingsView/SettingsController(UI toggle) are all wired end-to-end today.Haptics.PlayreadingGameSettings.Vibrationcompletes the loop.GameSettings.OnVibrationhook stays unused for now.
Event → preset map (core tier)¶
| HapticEvent | Preset | Call site |
|---|---|---|
| UiTap | Selection | UIButton.cs:91 beside UiFeedback.PlayClick (universal button funnel) |
| UiConfirmArm | MediumImpact | UIButton.cs:95 (ArmConfirm branch) |
| PlayerHit | MediumImpact | PlayerController.cs:621 OnDamaged, beside shake.playerHit |
| LowHp | Warning | same handler — edge-detect Health.Fraction crossing below 0.3, latch; reset when healed back above |
| Death | Failure | PlayerController.cs:747 OnDied |
| MeleeHit | LightImpact | PlayerController.cs:373 (melee strike lands) |
| Crit | RigidImpact | PlayerController.cs:394 (backstab crit, target survives) |
| BackstabKill | HeavyImpact | PlayerController.cs:386 (silent-kill beat, pairs with hitstop) |
| Kill | RigidImpact | EnemyController.cs:942 (player-attributed kill; LastAttacker == player guard) |
| BossDown | Success | EnemyController.cs:964 (floor guard slain) |
| ChestOpen | MediumImpact | Chest.cs:165 Open() — rarity already branched here |
| ChestLegendary | HeavyImpact | same branch, legendary case |
| GateOpen | HeavyImpact | Gate.cs:90 Open() (player-channel only; rival path bypasses) |
| PortalCalled | LightImpact | Portal.cs Call(), only when by != "rival" |
| PortalOpen | MediumImpact | Portal.cs:143 (board window opens) |
| Extracted | Success | Portal.cs:177 (extraction/descend completes) |
| KeyPickup | Success | KeyPickup.cs:85 (player acquires vault key) |
| LootCommon | Selection | LootService.cs:135 Collect switch — currency/blueprints |
| LootRare | LightImpact / MediumImpact | same switch — gear/consumables; epic+legendary → MediumImpact |
| Dodge | LightImpact | PlayerController.cs:594 (successful dodge only) |
| HealDone | Success | PlayerController.cs:268 (heal channel completes) |
| StormWarn | Warning | RaidController.cs:79 (storm2 "make for an exit") |
| StormBreak | HeavyImpact | RaidController.cs:80 beside shake.storm |
| Purchase | Success | InventoryController.cs:375 (level-up spend succeeds) |
| Equip | LightImpact | InventoryController.cs:321 (gear equipped) |
(Purchase and Equip are also in the enum — the code snippet above omits them for
brevity.) Win/lose result cards get no haptic of their own: Extracted fired seconds
earlier in-raid and Death fired at OnDied — a card-time repeat would double-fire.
Explicitly excluded (this iteration)¶
- Per-shot ranged-fire haptics (auto-fire cadence = constant buzz on mobile).
- Optional alert tier: spotted, afflicted (burn/web), stamina empty, heal interrupted, "need a key" denial, dodge-fail.
- Continuous/parametric haptics (
PlayConstantramp on extraction boarding) — phase 2. - Anything rival/AI-initiated — structurally unreachable from the chosen call sites.
Build / project settings¶
No changes required (verified): Android .aar self-declares VIBRATE permission and
minSdk 17 (project minSdk 25, IL2CPP, ARM64); iOS LofeltHaptics.framework needs iOS 13+
(project targets 15.0); Input System 1.19 present → asmdef version-define compiles the
editor/gamepad path; game code lives in the default assembly which auto-references
Lofelt.NiceVibrations; the three JSON templates in Scripts/Components/Resources/ are
present.
Testing¶
- Editor: verify compile via Unity MCP console logs; play-mode smoke: trigger each beat,
confirm no exceptions from
HapticPatterns(editor playback is a no-op without a gamepad — correctness = no errors + gate logic). - Gate check: toggle vibration off in Settings →
Haptics.Playearly-outs (add a temporary debug assertion or log during verification only). - On-device (Android) verification is the user's playtest.