From 16ca83177cccfe5deaad26fd123c1549aaccbfb8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?p=C3=ADa=20pe=C3=B1a?= <64539962+piapenia@users.noreply.github.com> Date: Tue, 14 Jul 2026 03:44:15 -0300 Subject: [PATCH] Add accessibility layer for blind and low-vision players A fully additive gameplay accessibility layer (zero edits to existing scripts, scenes, prefabs or input assets), built and QA-tested by a blind IAAP-certified accessibility specialist. Implements the roadmap's 'Accessibility options' item for blind play: - Proximity sonar with semantic sounds, wall occlusion and on-demand area scan with height-encoded pitch (B / L3) - KOTOR-style object navigation: Q/E cycle targets with spoken distance and direction, Enter auto-walks and interacts (d-pad on gamepads) - Mouse-free camera: turn keys, announced 45-degree snap turns, spoken compass; NVDA screen reader output via the official controller client - Spoken room announcements, health/sanity/crouch state, scene descriptions with alt text for menu artwork, UI reader for menus - Safety net: fall announcements, auto-rescue to safe ground, ledge warnings, stuck detection with on-demand rescue (R) - Purposeful haptics on gamepads: subtle wall ticks, damage-scaled rumble - Audio cue glossary while paused (G); status report on demand (H) - 60+ batchmode smoke tests; CC0 sounds; LGPL 2.1 NVDA client with license See ACCESSIBILITY.md for the full guideline mapping (gameaccessibilityguidelines.com), architecture notes and accessibility-friendly engineering recommendations. Co-Authored-By: Claude Fable 5 --- ACCESSIBILITY.md | 182 +++++++ Assets/StreamingAssets/A11y.meta | 8 + Assets/StreamingAssets/A11y/door_beep.ogg | 3 + .../StreamingAssets/A11y/door_beep.ogg.meta | 7 + Assets/StreamingAssets/A11y/item_beep.ogg | 3 + .../StreamingAssets/A11y/item_beep.ogg.meta | 7 + Assets/StreamingAssets/A11y/wall_bump_000.ogg | 3 + .../A11y/wall_bump_000.ogg.meta | 7 + Assets/StreamingAssets/A11y/wall_bump_001.ogg | 3 + .../A11y/wall_bump_001.ogg.meta | 7 + Assets/StreamingAssets/A11y/wall_bump_002.ogg | 3 + .../A11y/wall_bump_002.ogg.meta | 7 + Assets/StreamingAssets/A11y/wall_bump_003.ogg | 3 + .../A11y/wall_bump_003.ogg.meta | 7 + Assets/StreamingAssets/A11y/wall_bump_004.ogg | 3 + .../A11y/wall_bump_004.ogg.meta | 7 + Assets/_Project/_Features/Accessibility.meta | 8 + .../Accessibility/A11Y_AUDIO_CREDITS.md | 22 + .../Accessibility/A11Y_AUDIO_CREDITS.md.meta | 7 + .../_Features/Accessibility/Plugins.meta | 8 + .../Accessibility/Plugins/x86_64.meta | 8 + .../x86_64/NVDA_CONTROLLER_CLIENT_LICENSE.txt | 504 ++++++++++++++++++ .../NVDA_CONTROLLER_CLIENT_LICENSE.txt.meta | 7 + .../Plugins/x86_64/nvdaControllerClient.dll | 3 + .../x86_64/nvdaControllerClient.dll.meta | 2 + .../_Features/Accessibility/Resources.meta | 8 + .../Accessibility/Resources/A11y.meta | 8 + .../Resources/A11y/alt_texts.json | 72 +++ .../Resources/A11y/alt_texts.json.meta | 7 + .../_Features/Accessibility/_Scripts.meta | 8 + .../Accessibility/_Scripts/A11yAltText.cs | 66 +++ .../_Scripts/A11yAltText.cs.meta | 2 + .../Accessibility/_Scripts/A11yFmodAudio.cs | 130 +++++ .../_Scripts/A11yFmodAudio.cs.meta | 2 + .../Accessibility/_Scripts/A11yHaptics.cs | 53 ++ .../_Scripts/A11yHaptics.cs.meta | 2 + .../_Scripts/AccessibilityBootstrap.cs | 18 + .../_Scripts/AccessibilityBootstrap.cs.meta | 2 + .../_Scripts/AccessibilityManager.cs | 158 ++++++ .../_Scripts/AccessibilityManager.cs.meta | 2 + .../Accessibility/_Scripts/BlinkWarning.cs | 60 +++ .../_Scripts/BlinkWarning.cs.meta | 2 + .../Accessibility/_Scripts/Editor.meta | 8 + .../_Scripts/Editor/A11yBuild.cs | 41 ++ .../_Scripts/Editor/A11yBuild.cs.meta | 2 + .../_Scripts/Editor/AccessibilitySmokeTest.cs | 292 ++++++++++ .../Editor/AccessibilitySmokeTest.cs.meta | 2 + .../Accessibility/_Scripts/FallGuard.cs | 173 ++++++ .../Accessibility/_Scripts/FallGuard.cs.meta | 2 + .../Accessibility/_Scripts/KeyboardLook.cs | 143 +++++ .../_Scripts/KeyboardLook.cs.meta | 2 + .../Accessibility/_Scripts/ProximitySonar.cs | 209 ++++++++ .../_Scripts/ProximitySonar.cs.meta | 2 + .../Accessibility/_Scripts/RoomTracker.cs | 94 ++++ .../_Scripts/RoomTracker.cs.meta | 2 + .../Accessibility/_Scripts/ScanPulse.cs | 98 ++++ .../Accessibility/_Scripts/ScanPulse.cs.meta | 2 + .../_Scripts/ScreenReaderOutput.cs | 70 +++ .../_Scripts/ScreenReaderOutput.cs.meta | 2 + .../Accessibility/_Scripts/SonarAudio.cs | 37 ++ .../Accessibility/_Scripts/SonarAudio.cs.meta | 2 + .../Accessibility/_Scripts/SonarLogic.cs | 89 ++++ .../Accessibility/_Scripts/SonarLogic.cs.meta | 2 + .../Accessibility/_Scripts/SoundGlossary.cs | 75 +++ .../_Scripts/SoundGlossary.cs.meta | 2 + .../Accessibility/_Scripts/StanceAnnouncer.cs | 34 ++ .../_Scripts/StanceAnnouncer.cs.meta | 2 + .../Accessibility/_Scripts/StatusReporter.cs | 83 +++ .../_Scripts/StatusReporter.cs.meta | 2 + .../Accessibility/_Scripts/TargetNavigator.cs | 269 ++++++++++ .../_Scripts/TargetNavigator.cs.meta | 2 + .../Accessibility/_Scripts/UIReader.cs | 108 ++++ .../Accessibility/_Scripts/UIReader.cs.meta | 2 + .../_Scripts/VitalStatusAnnouncer.cs | 91 ++++ .../_Scripts/VitalStatusAnnouncer.cs.meta | 2 + .../_Scripts/WallBumpFeedback.cs | 69 +++ .../_Scripts/WallBumpFeedback.cs.meta | 2 + 77 files changed, 3446 insertions(+) create mode 100644 ACCESSIBILITY.md create mode 100644 Assets/StreamingAssets/A11y.meta create mode 100644 Assets/StreamingAssets/A11y/door_beep.ogg create mode 100644 Assets/StreamingAssets/A11y/door_beep.ogg.meta create mode 100644 Assets/StreamingAssets/A11y/item_beep.ogg create mode 100644 Assets/StreamingAssets/A11y/item_beep.ogg.meta create mode 100644 Assets/StreamingAssets/A11y/wall_bump_000.ogg create mode 100644 Assets/StreamingAssets/A11y/wall_bump_000.ogg.meta create mode 100644 Assets/StreamingAssets/A11y/wall_bump_001.ogg create mode 100644 Assets/StreamingAssets/A11y/wall_bump_001.ogg.meta create mode 100644 Assets/StreamingAssets/A11y/wall_bump_002.ogg create mode 100644 Assets/StreamingAssets/A11y/wall_bump_002.ogg.meta create mode 100644 Assets/StreamingAssets/A11y/wall_bump_003.ogg create mode 100644 Assets/StreamingAssets/A11y/wall_bump_003.ogg.meta create mode 100644 Assets/StreamingAssets/A11y/wall_bump_004.ogg create mode 100644 Assets/StreamingAssets/A11y/wall_bump_004.ogg.meta create mode 100644 Assets/_Project/_Features/Accessibility.meta create mode 100644 Assets/_Project/_Features/Accessibility/A11Y_AUDIO_CREDITS.md create mode 100644 Assets/_Project/_Features/Accessibility/A11Y_AUDIO_CREDITS.md.meta create mode 100644 Assets/_Project/_Features/Accessibility/Plugins.meta create mode 100644 Assets/_Project/_Features/Accessibility/Plugins/x86_64.meta create mode 100644 Assets/_Project/_Features/Accessibility/Plugins/x86_64/NVDA_CONTROLLER_CLIENT_LICENSE.txt create mode 100644 Assets/_Project/_Features/Accessibility/Plugins/x86_64/NVDA_CONTROLLER_CLIENT_LICENSE.txt.meta create mode 100644 Assets/_Project/_Features/Accessibility/Plugins/x86_64/nvdaControllerClient.dll create mode 100644 Assets/_Project/_Features/Accessibility/Plugins/x86_64/nvdaControllerClient.dll.meta create mode 100644 Assets/_Project/_Features/Accessibility/Resources.meta create mode 100644 Assets/_Project/_Features/Accessibility/Resources/A11y.meta create mode 100644 Assets/_Project/_Features/Accessibility/Resources/A11y/alt_texts.json create mode 100644 Assets/_Project/_Features/Accessibility/Resources/A11y/alt_texts.json.meta create mode 100644 Assets/_Project/_Features/Accessibility/_Scripts.meta create mode 100644 Assets/_Project/_Features/Accessibility/_Scripts/A11yAltText.cs create mode 100644 Assets/_Project/_Features/Accessibility/_Scripts/A11yAltText.cs.meta create mode 100644 Assets/_Project/_Features/Accessibility/_Scripts/A11yFmodAudio.cs create mode 100644 Assets/_Project/_Features/Accessibility/_Scripts/A11yFmodAudio.cs.meta create mode 100644 Assets/_Project/_Features/Accessibility/_Scripts/A11yHaptics.cs create mode 100644 Assets/_Project/_Features/Accessibility/_Scripts/A11yHaptics.cs.meta create mode 100644 Assets/_Project/_Features/Accessibility/_Scripts/AccessibilityBootstrap.cs create mode 100644 Assets/_Project/_Features/Accessibility/_Scripts/AccessibilityBootstrap.cs.meta create mode 100644 Assets/_Project/_Features/Accessibility/_Scripts/AccessibilityManager.cs create mode 100644 Assets/_Project/_Features/Accessibility/_Scripts/AccessibilityManager.cs.meta create mode 100644 Assets/_Project/_Features/Accessibility/_Scripts/BlinkWarning.cs create mode 100644 Assets/_Project/_Features/Accessibility/_Scripts/BlinkWarning.cs.meta create mode 100644 Assets/_Project/_Features/Accessibility/_Scripts/Editor.meta create mode 100644 Assets/_Project/_Features/Accessibility/_Scripts/Editor/A11yBuild.cs create mode 100644 Assets/_Project/_Features/Accessibility/_Scripts/Editor/A11yBuild.cs.meta create mode 100644 Assets/_Project/_Features/Accessibility/_Scripts/Editor/AccessibilitySmokeTest.cs create mode 100644 Assets/_Project/_Features/Accessibility/_Scripts/Editor/AccessibilitySmokeTest.cs.meta create mode 100644 Assets/_Project/_Features/Accessibility/_Scripts/FallGuard.cs create mode 100644 Assets/_Project/_Features/Accessibility/_Scripts/FallGuard.cs.meta create mode 100644 Assets/_Project/_Features/Accessibility/_Scripts/KeyboardLook.cs create mode 100644 Assets/_Project/_Features/Accessibility/_Scripts/KeyboardLook.cs.meta create mode 100644 Assets/_Project/_Features/Accessibility/_Scripts/ProximitySonar.cs create mode 100644 Assets/_Project/_Features/Accessibility/_Scripts/ProximitySonar.cs.meta create mode 100644 Assets/_Project/_Features/Accessibility/_Scripts/RoomTracker.cs create mode 100644 Assets/_Project/_Features/Accessibility/_Scripts/RoomTracker.cs.meta create mode 100644 Assets/_Project/_Features/Accessibility/_Scripts/ScanPulse.cs create mode 100644 Assets/_Project/_Features/Accessibility/_Scripts/ScanPulse.cs.meta create mode 100644 Assets/_Project/_Features/Accessibility/_Scripts/ScreenReaderOutput.cs create mode 100644 Assets/_Project/_Features/Accessibility/_Scripts/ScreenReaderOutput.cs.meta create mode 100644 Assets/_Project/_Features/Accessibility/_Scripts/SonarAudio.cs create mode 100644 Assets/_Project/_Features/Accessibility/_Scripts/SonarAudio.cs.meta create mode 100644 Assets/_Project/_Features/Accessibility/_Scripts/SonarLogic.cs create mode 100644 Assets/_Project/_Features/Accessibility/_Scripts/SonarLogic.cs.meta create mode 100644 Assets/_Project/_Features/Accessibility/_Scripts/SoundGlossary.cs create mode 100644 Assets/_Project/_Features/Accessibility/_Scripts/SoundGlossary.cs.meta create mode 100644 Assets/_Project/_Features/Accessibility/_Scripts/StanceAnnouncer.cs create mode 100644 Assets/_Project/_Features/Accessibility/_Scripts/StanceAnnouncer.cs.meta create mode 100644 Assets/_Project/_Features/Accessibility/_Scripts/StatusReporter.cs create mode 100644 Assets/_Project/_Features/Accessibility/_Scripts/StatusReporter.cs.meta create mode 100644 Assets/_Project/_Features/Accessibility/_Scripts/TargetNavigator.cs create mode 100644 Assets/_Project/_Features/Accessibility/_Scripts/TargetNavigator.cs.meta create mode 100644 Assets/_Project/_Features/Accessibility/_Scripts/UIReader.cs create mode 100644 Assets/_Project/_Features/Accessibility/_Scripts/UIReader.cs.meta create mode 100644 Assets/_Project/_Features/Accessibility/_Scripts/VitalStatusAnnouncer.cs create mode 100644 Assets/_Project/_Features/Accessibility/_Scripts/VitalStatusAnnouncer.cs.meta create mode 100644 Assets/_Project/_Features/Accessibility/_Scripts/WallBumpFeedback.cs create mode 100644 Assets/_Project/_Features/Accessibility/_Scripts/WallBumpFeedback.cs.meta diff --git a/ACCESSIBILITY.md b/ACCESSIBILITY.md new file mode 100644 index 00000000..c7968250 --- /dev/null +++ b/ACCESSIBILITY.md @@ -0,0 +1,182 @@ +# Accessibility Layer for SCP: Project Unity + +A gameplay accessibility layer that makes SCP: Project Unity playable by blind and +low-vision players, built and QA-tested by a blind IAAP-certified accessibility +specialist (WCAG 2.2) who is a native screen reader user, with an AI coding agent +doing the implementation under her direction. + +*Author / QA: María Pía Peña Foissac — find me on any social network as **@blindaudiogamer**.* + +The project roadmap already lists **"Accessibility options"** as a planned feature — +this layer is a working, tested implementation of that roadmap item, offered upstream. + +Every feature maps to a specific guideline from +[gameaccessibilityguidelines.com](https://gameaccessibilityguidelines.com/) (noted per +feature below as GAG, with its category and tier). + +## Features + +### Audio navigation +- **Proximity sonar**: a spatialized 3D sound plays from the nearest reachable + interactable, parking-sensor style (faster cadence = closer), with a double + beat inside interaction range. Doors knock (wood), pickable items chime (bell) — + semantic sounds, chosen by blind QA over abstract beeps. + *GAG Vision, advanced: "Provide a pingable sonar-style audio map"; intermediate: + "Use distinct sound/music design for all objects and events".* +- **Wall occlusion**: the sonar never sounds through walls (eye-height raycast over + level geometry; occluded targets are skipped for the nearest visible one). +- **Wall contact feedback**: a soft thud (plus a subtle high-frequency rumble tick on + gamepads) when deliberately walking into a wall. Sliding along walls stays silent. + *GAG Vision, basic: "Ensure no essential information is conveyed by visuals alone".* + +### Object navigation (KOTOR-style, requested by blind QA) +- **Q / E** (gamepad: d-pad left/right) cycle through reachable interactables, nearest + first, announcing type, distance and relative direction via the screen reader: + *"Door, 4 meters, ahead to the right. 1 of 3."* +- **Enter** (gamepad: d-pad down) interacts immediately if the target is in reach, + or auto-walks to it and then interacts. Any manual movement cancels the walk; + a watchdog reports *"Path blocked"* instead of walking into walls forever. + *GAG Motor: "Include assist modes such as auto-aim and assisted steering".* + +### Mouse-free camera +- **Comma / period**: smooth turn (speed persisted, adjustable). +- **Shift+comma / Shift+period**: 45° snap turns, announced ("northeast") — + predictable orientation, an audiogame convention. **M**: 180° turn. **N** (gamepad: + R3): speak compass heading. Pitch is deliberately unmapped: with no mouse input the + vertical view never drifts. + *GAG Motor: "Ensure that all areas of the user interface can be accessed using the + same input method". Note: Shift was chosen over Ctrl as snap modifier because Ctrl + is Crouch in this game — blind QA discovered Ctrl-combos were silently crouching + the player.* + +### Screen reader output (NVDA) +- Direct integration with the official **NVDA Controller Client** (LGPL 2.1, + distributed unmodified with its license; P/Invoke following NV Access's own C# + example). Degrades silently when NVDA is absent. +- Spoken: layer startup confirmation, scene descriptions on load (from an editable + JSON alt-text registry, including transcriptions of text baked into menu images), + health level changes, sanity threshold, crouch state changes, target announcements, + fall/rescue events, sonar toggle state. + *GAG Vision, advanced: "Provide pre-recorded voiceovers for all text including + menus" (via the player's own TTS); basic: "no essential information by visuals + alone".* +- **UI reader**: announces the focused uGUI element (alt-text → visible label → + humanized object name, plus role: "button", "slider"...) and keeps an element + focused in menu contexts so keyboard navigation always has a starting point. + +### Situational awareness on demand (modeled on TLOU Part II, tuned by blind QA) +- **Area scan** (B / L3): pings every reachable interactable, nearest first — each + target's own semantic sound plays at its position, with **pitch encoding relative + height** (above = higher), ending with a spoken count. Preferred by our blind QA + over a continuous sonar after playing TLOU's Enhanced Listen Mode; the continuous + parking-sensor mode remains available as an opt-in toggle (Shift+F6). +- **Status report** (H, for "health" — it reads first): speaks health, stance, + compass heading and nearest target: + *"Healthy. Standing. Facing north. Door, 4 meters, ahead."* +- **Audio cue glossary** (G, while paused): cycles through every accessibility sound + followed by its spoken meaning — learn the vocabulary before you need it in a panic + (a TLOU feature our QA rated essential). Pause-only by QA design: a reference you + consult calmly, and the natural candidate for a pause-menu entry once the real + menu exists. +- **Room announcements**: on entering a new area, the layer says where you are + ("You entered: Light Containment Zone"), using the game's own room system in + generated facilities (RoomInstance bounds + RoomData name + zone) and a hierarchy + fallback in hand-built scenes. Descriptions are editable JSON alt-text. *This is + our blind QA's own addition — her one criticism of TLOU: "it never tells you which + room you walked into."* +- **Ledge warning**: probes the floor ahead of your movement and warns "Ledge ahead" + before the drop (a full movement-blocking Ledge Guard needs an upstream hook — + see recommendations). + +### Safety net (born from a real QA incident) +The blind tester walked through a door at the edge of the test map, fell into the +void, and got **total silence** — no footsteps, no sonar, nothing to indicate what +happened. Now: +- *"You are falling."* after 1.5 s airborne; *"You fell out of the map."* plus + **automatic rescue** to the last safe ground after a 20 m drop below it. +- *"Movement blocked. R returns to safe ground."* when pushing against geometry + without progress. **R** (gamepad: d-pad up) rescues on demand. + +### Vitals and tension +- Health level changes are spoken (from the game's own `OnHealthLevelChanged` event); + life-threatening states interrupt current speech. +- **Damage rumbles** on gamepads, scaled by hit size (design rule from blind QA: + strong haptics are reserved for tense moments; ambient feedback stays subtle). +- **Blink warning** (dev branch): a short cue when the blink meter is about to force + a blink — essential once SCP-173-style enemies land. Sound, not speech, on purpose: + blinks recur every ~15 s and TTS would fatigue. + +## Architecture: 100% additive + +- All code lives in `Assets/_Project/_Features/Accessibility/` plus sound files in + `StreamingAssets/A11y/`. **Zero edits to existing scripts, scenes, prefabs or the + shared InputActionAsset.** +- A `[RuntimeInitializeOnLoadMethod]` bootstrap self-instantiates the layer in any + scene; player-bound components attach at runtime via `Core.Player`. +- All accessibility keys are code-created `InputAction`s (audited against the + project's bindings to avoid collisions); settings persist in PlayerPrefs under + `A11y_*` keys. +- Audio goes through the **FMOD Core API** (the project rightly disables Unity's + audio engine): file-based CC0 sounds with procedurally generated PCM fallbacks, + no Studio banks required — so the layer keeps working in test scenes. +- Verified by **50+ batchmode smoke tests** (target selection, occlusion logic, wall + bump rules, compass math, PCM encoding, damage-haptics curve, alt-text registry, + UI descriptions) runnable via + `Unity.exe -batchmode -quit -executeMethod AccessibilitySmokeTest.Run`. + +## Licensing + +| Component | Source | License | Compatibility | +| --- | --- | --- | --- | +| Layer source code | this contribution | CC BY-SA 4.0 (project license) | native | +| Sounds (`StreamingAssets/A11y`) | Kenney "Impact Sounds" & "Interface Sounds" (kenney.nl) | CC0 | verified on source pages | +| `nvdaControllerClient.dll` | NV Access official release (download.nvaccess.org) | LGPL 2.1, distributed unmodified with license file | dynamic linking, compatible | + +## Recommendations for accessibility-friendly development + +Practical, code-level advice for the remaining roadmap items, each grounded in +something we actually hit while building this layer: + +1. **Raise C# events for every player-relevant state change.** + `PlayerHealth.OnHealthLevelChanged` made spoken vitals a 20-line subscriber. + Crouch has no event, so we had to poll — and a blind player spent a session + mysteriously slow because crouching is communicated only visually. If a sighted + player can perceive a state, there should be an event for it. +2. **Keep (and enrich) the `IInteractable` interface.** It is the single reason a + sonar and an object navigator could be built without touching game code. Consider + adding a display name / category to the interface (or a metadata component): + assistive tech currently has to guess from GameObject names like "HandObject". +3. **Give every action a keyboard binding and keep bindings data-driven.** + Interact currently has no keyboard key (mouse only), and one debug feature used a + hardcoded `Input.GetKeyDown`. Everything in the InputActionAsset + user rebinding + support covers GAG Motor "Allow controls to be remapped" almost for free. +4. **Render text as text.** The current menu is an image with the title baked in — + we had to ship a hand-written alt-text registry. When the real UI arrives, uGUI/TMP + labels (which the planned localization work needs anyway) are directly readable by + assistive layers; localization keys double as speech strings. +5. **Design audio as a first-class information channel.** Each SCP will need a unique + sound signature (GAG: "distinct sound design for all objects and events") — 173's + presence, in particular, is life-or-death information that today exists only as + pixels. FMOD events with distance/occlusion parameters (already the project's + direction) are the right foundation; just keep them per-entity, not shared. +6. **Add kill-Z / world-bounds volumes to maps, including test maps.** Our tester + fell through an unfinished map edge into an infinite, silent void. A bounds volume + that respawns (or at least an event that fires) helps every player and every mod. +7. **Expose game events through the planned Lua modding API.** An accessibility layer + is the prototypical mod: everything above consumes events, interfaces and + registries. If the modding API exposes those, community a11y (and this layer) + can live as a first-class mod. +8. **Prefer CLI-friendly build hooks.** FMOD's bank copy relies on an editor-update + watcher that never fires in `-batchmode` builds; we had to call + `EventManager.CopyToStreamingAssets` explicitly. Deterministic build steps help + CI and modders alike. + +## How to try it + +1. Open the project with Unity 6000.0.67f1, run any Testing scene, have NVDA running. +2. Or build headless: `-executeMethod A11yBuild.BuildTestingCore` (declares scenes, + refreshes FMOD banks, builds Addressables, Mono backend for QA machines without + the C++ toolchain). +3. Keys: WASD move, Shift sprint, comma/period turn (Shift+ = snap 45°), M half turn, + N compass, Q/E cycle targets, Enter go & interact, R rescue, F6 toggles the layer. + DualSense/Xbox: d-pad left/right cycle, d-pad down go, d-pad up rescue, R3 compass. diff --git a/Assets/StreamingAssets/A11y.meta b/Assets/StreamingAssets/A11y.meta new file mode 100644 index 00000000..f464c3fd --- /dev/null +++ b/Assets/StreamingAssets/A11y.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: ce0d30569d16cca488c34ea9e9ef528e +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/StreamingAssets/A11y/door_beep.ogg b/Assets/StreamingAssets/A11y/door_beep.ogg new file mode 100644 index 00000000..838a6267 --- /dev/null +++ b/Assets/StreamingAssets/A11y/door_beep.ogg @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:4b76bf3ccc8e60d19188f3165b778a7817786faa6887c55d9049bcbeef3b425f +size 6373 diff --git a/Assets/StreamingAssets/A11y/door_beep.ogg.meta b/Assets/StreamingAssets/A11y/door_beep.ogg.meta new file mode 100644 index 00000000..1a16f47d --- /dev/null +++ b/Assets/StreamingAssets/A11y/door_beep.ogg.meta @@ -0,0 +1,7 @@ +fileFormatVersion: 2 +guid: d1f1fe6965b79484d9878a38eb035f92 +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/StreamingAssets/A11y/item_beep.ogg b/Assets/StreamingAssets/A11y/item_beep.ogg new file mode 100644 index 00000000..1ab1aee7 --- /dev/null +++ b/Assets/StreamingAssets/A11y/item_beep.ogg @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:063564703b6094d70718a3e787a55cc9141611e4ecd6b6637f8828f79b4a8c3a +size 8968 diff --git a/Assets/StreamingAssets/A11y/item_beep.ogg.meta b/Assets/StreamingAssets/A11y/item_beep.ogg.meta new file mode 100644 index 00000000..2af3e05f --- /dev/null +++ b/Assets/StreamingAssets/A11y/item_beep.ogg.meta @@ -0,0 +1,7 @@ +fileFormatVersion: 2 +guid: 5481fd8f4db23f348867272515f90823 +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/StreamingAssets/A11y/wall_bump_000.ogg b/Assets/StreamingAssets/A11y/wall_bump_000.ogg new file mode 100644 index 00000000..210b0084 --- /dev/null +++ b/Assets/StreamingAssets/A11y/wall_bump_000.ogg @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:49e7ca88743fca974bb8676ea138b751cfd8f9033b5e7af8736c2a215d6edbc1 +size 6570 diff --git a/Assets/StreamingAssets/A11y/wall_bump_000.ogg.meta b/Assets/StreamingAssets/A11y/wall_bump_000.ogg.meta new file mode 100644 index 00000000..24bae449 --- /dev/null +++ b/Assets/StreamingAssets/A11y/wall_bump_000.ogg.meta @@ -0,0 +1,7 @@ +fileFormatVersion: 2 +guid: 82d1aaeb8a4d2b64087549dd1c30fe41 +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/StreamingAssets/A11y/wall_bump_001.ogg b/Assets/StreamingAssets/A11y/wall_bump_001.ogg new file mode 100644 index 00000000..3d3f04a1 --- /dev/null +++ b/Assets/StreamingAssets/A11y/wall_bump_001.ogg @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:4d0096364ba9e46119d2ff6df493fdc101bd2e1efae061da5e5f77a53b3fdcb6 +size 6689 diff --git a/Assets/StreamingAssets/A11y/wall_bump_001.ogg.meta b/Assets/StreamingAssets/A11y/wall_bump_001.ogg.meta new file mode 100644 index 00000000..817449fd --- /dev/null +++ b/Assets/StreamingAssets/A11y/wall_bump_001.ogg.meta @@ -0,0 +1,7 @@ +fileFormatVersion: 2 +guid: eb08b0e3bbe951e4297b2c7a1df97212 +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/StreamingAssets/A11y/wall_bump_002.ogg b/Assets/StreamingAssets/A11y/wall_bump_002.ogg new file mode 100644 index 00000000..a4a46832 --- /dev/null +++ b/Assets/StreamingAssets/A11y/wall_bump_002.ogg @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:d130ae541951243d3f6ed963d0057450a445ffe117fbf9a084d34eb1635cb563 +size 6749 diff --git a/Assets/StreamingAssets/A11y/wall_bump_002.ogg.meta b/Assets/StreamingAssets/A11y/wall_bump_002.ogg.meta new file mode 100644 index 00000000..c7506e27 --- /dev/null +++ b/Assets/StreamingAssets/A11y/wall_bump_002.ogg.meta @@ -0,0 +1,7 @@ +fileFormatVersion: 2 +guid: e02cb465cf1e3ba49bd1571ed6ac6b60 +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/StreamingAssets/A11y/wall_bump_003.ogg b/Assets/StreamingAssets/A11y/wall_bump_003.ogg new file mode 100644 index 00000000..0ba0a864 --- /dev/null +++ b/Assets/StreamingAssets/A11y/wall_bump_003.ogg @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:d00286a2dc62ee5cb4d42bf56120e7050a855f69e655fc1294410aadd337eba3 +size 6665 diff --git a/Assets/StreamingAssets/A11y/wall_bump_003.ogg.meta b/Assets/StreamingAssets/A11y/wall_bump_003.ogg.meta new file mode 100644 index 00000000..dd57c965 --- /dev/null +++ b/Assets/StreamingAssets/A11y/wall_bump_003.ogg.meta @@ -0,0 +1,7 @@ +fileFormatVersion: 2 +guid: 820db515d75dc4744ac3ce05f1e1de5b +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/StreamingAssets/A11y/wall_bump_004.ogg b/Assets/StreamingAssets/A11y/wall_bump_004.ogg new file mode 100644 index 00000000..56d60c6f --- /dev/null +++ b/Assets/StreamingAssets/A11y/wall_bump_004.ogg @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:e008f0b6b14f62339c8fe9f9b45cbd6317ba4f66604c4899053b8017020a36f0 +size 6478 diff --git a/Assets/StreamingAssets/A11y/wall_bump_004.ogg.meta b/Assets/StreamingAssets/A11y/wall_bump_004.ogg.meta new file mode 100644 index 00000000..dcbd81dd --- /dev/null +++ b/Assets/StreamingAssets/A11y/wall_bump_004.ogg.meta @@ -0,0 +1,7 @@ +fileFormatVersion: 2 +guid: 9972c4f288518e6459501be6b3d9cba5 +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/_Project/_Features/Accessibility.meta b/Assets/_Project/_Features/Accessibility.meta new file mode 100644 index 00000000..de27974c --- /dev/null +++ b/Assets/_Project/_Features/Accessibility.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 5eec5e3154eca9443b813ed091161df0 +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/_Project/_Features/Accessibility/A11Y_AUDIO_CREDITS.md b/Assets/_Project/_Features/Accessibility/A11Y_AUDIO_CREDITS.md new file mode 100644 index 00000000..c4c8cb1f --- /dev/null +++ b/Assets/_Project/_Features/Accessibility/A11Y_AUDIO_CREDITS.md @@ -0,0 +1,22 @@ +# Créditos de audio — capa de accesibilidad + +Todos los sonidos de esta capa provienen de packs **CC0 (dominio público)** de Kenney (kenney.nl), +verificados en su página el 2026-07-13. CC0 es compatible con CC BY-SA 4.0 (el proyecto base). + +| Archivo en `StreamingAssets/A11y/` | Original | Pack | Autor | Licencia | +| --- | --- | --- | --- | --- | +| `door_beep.ogg` | `impactWood_light_001.ogg` (toc en madera = puerta) | Impact Sounds | Kenney (kenney.nl) | CC0 | +| `item_beep.ogg` | `confirmation_001.ogg` (campanita = objeto agarrable) | Interface Sounds | Kenney (kenney.nl) | CC0 | +| `wall_bump_000..004.ogg` | `impactSoft_heavy_000..004.ogg` (golpe sordo = pared) | Impact Sounds | Kenney (kenney.nl) | CC0 | + +Historial: la primera iteración usaba tonos abstractos (`bong_001`/`tick_001`); el QA de María Pía pidió +sonidos semánticos ("que la puerta suene a puerta") y se reemplazaron el 2026-07-13. + +Fuentes: https://kenney.nl/assets/interface-sounds · https://kenney.nl/assets/impact-sounds + +Si un archivo de `Resources/A11y/` falta, el código genera un tono procedural equivalente +(`SonarAudio.CreateBeep`), así que borrar un archivo es una forma segura de volver al beep sintético. + +Nota aparte: `Plugins/x86_64/nvdaControllerClient.dll` NO es un asset de audio; es el +NVDA Controller Client oficial de NV Access, licencia LGPL 2.1 (ver +`NVDA_CONTROLLER_CLIENT_LICENSE.txt` en la misma carpeta). Se distribuye sin modificar. diff --git a/Assets/_Project/_Features/Accessibility/A11Y_AUDIO_CREDITS.md.meta b/Assets/_Project/_Features/Accessibility/A11Y_AUDIO_CREDITS.md.meta new file mode 100644 index 00000000..3d08d8c7 --- /dev/null +++ b/Assets/_Project/_Features/Accessibility/A11Y_AUDIO_CREDITS.md.meta @@ -0,0 +1,7 @@ +fileFormatVersion: 2 +guid: 5c09f88f4ca9a734fb8c2893f9c13d9e +TextScriptImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/_Project/_Features/Accessibility/Plugins.meta b/Assets/_Project/_Features/Accessibility/Plugins.meta new file mode 100644 index 00000000..d64388b2 --- /dev/null +++ b/Assets/_Project/_Features/Accessibility/Plugins.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: f1baeea460c89bf4ca9803fd13a1fa8d +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/_Project/_Features/Accessibility/Plugins/x86_64.meta b/Assets/_Project/_Features/Accessibility/Plugins/x86_64.meta new file mode 100644 index 00000000..ca424ad9 --- /dev/null +++ b/Assets/_Project/_Features/Accessibility/Plugins/x86_64.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 12cb317c1a99bca499d69d7e3b189ab9 +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/_Project/_Features/Accessibility/Plugins/x86_64/NVDA_CONTROLLER_CLIENT_LICENSE.txt b/Assets/_Project/_Features/Accessibility/Plugins/x86_64/NVDA_CONTROLLER_CLIENT_LICENSE.txt new file mode 100644 index 00000000..602bfc94 --- /dev/null +++ b/Assets/_Project/_Features/Accessibility/Plugins/x86_64/NVDA_CONTROLLER_CLIENT_LICENSE.txt @@ -0,0 +1,504 @@ + GNU LESSER GENERAL PUBLIC LICENSE + Version 2.1, February 1999 + + Copyright (C) 1991, 1999 Free Software Foundation, Inc. + 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA + Everyone is permitted to copy and distribute verbatim copies + of this license document, but changing it is not allowed. + +[This is the first released version of the Lesser GPL. It also counts + as the successor of the GNU Library Public License, version 2, hence + the version number 2.1.] + + Preamble + + The licenses for most software are designed to take away your +freedom to share and change it. By contrast, the GNU General Public +Licenses are intended to guarantee your freedom to share and change +free software--to make sure the software is free for all its users. + + This license, the Lesser General Public License, applies to some +specially designated software packages--typically libraries--of the +Free Software Foundation and other authors who decide to use it. You +can use it too, but we suggest you first think carefully about whether +this license or the ordinary General Public License is the better +strategy to use in any particular case, based on the explanations below. + + When we speak of free software, we are referring to freedom of use, +not price. Our General Public Licenses are designed to make sure that +you have the freedom to distribute copies of free software (and charge +for this service if you wish); that you receive source code or can get +it if you want it; that you can change the software and use pieces of +it in new free programs; and that you are informed that you can do +these things. + + To protect your rights, we need to make restrictions that forbid +distributors to deny you these rights or to ask you to surrender these +rights. These restrictions translate to certain responsibilities for +you if you distribute copies of the library or if you modify it. + + For example, if you distribute copies of the library, whether gratis +or for a fee, you must give the recipients all the rights that we gave +you. You must make sure that they, too, receive or can get the source +code. If you link other code with the library, you must provide +complete object files to the recipients, so that they can relink them +with the library after making changes to the library and recompiling +it. And you must show them these terms so they know their rights. + + We protect your rights with a two-step method: (1) we copyright the +library, and (2) we offer you this license, which gives you legal +permission to copy, distribute and/or modify the library. + + To protect each distributor, we want to make it very clear that +there is no warranty for the free library. Also, if the library is +modified by someone else and passed on, the recipients should know +that what they have is not the original version, so that the original +author's reputation will not be affected by problems that might be +introduced by others. + + Finally, software patents pose a constant threat to the existence of +any free program. We wish to make sure that a company cannot +effectively restrict the users of a free program by obtaining a +restrictive license from a patent holder. Therefore, we insist that +any patent license obtained for a version of the library must be +consistent with the full freedom of use specified in this license. + + Most GNU software, including some libraries, is covered by the +ordinary GNU General Public License. This license, the GNU Lesser +General Public License, applies to certain designated libraries, and +is quite different from the ordinary General Public License. We use +this license for certain libraries in order to permit linking those +libraries into non-free programs. + + When a program is linked with a library, whether statically or using +a shared library, the combination of the two is legally speaking a +combined work, a derivative of the original library. The ordinary +General Public License therefore permits such linking only if the +entire combination fits its criteria of freedom. The Lesser General +Public License permits more lax criteria for linking other code with +the library. + + We call this license the "Lesser" General Public License because it +does Less to protect the user's freedom than the ordinary General +Public License. It also provides other free software developers Less +of an advantage over competing non-free programs. These disadvantages +are the reason we use the ordinary General Public License for many +libraries. However, the Lesser license provides advantages in certain +special circumstances. + + For example, on rare occasions, there may be a special need to +encourage the widest possible use of a certain library, so that it becomes +a de-facto standard. To achieve this, non-free programs must be +allowed to use the library. A more frequent case is that a free +library does the same job as widely used non-free libraries. In this +case, there is little to gain by limiting the free library to free +software only, so we use the Lesser General Public License. + + In other cases, permission to use a particular library in non-free +programs enables a greater number of people to use a large body of +free software. For example, permission to use the GNU C Library in +non-free programs enables many more people to use the whole GNU +operating system, as well as its variant, the GNU/Linux operating +system. + + Although the Lesser General Public License is Less protective of the +users' freedom, it does ensure that the user of a program that is +linked with the Library has the freedom and the wherewithal to run +that program using a modified version of the Library. + + The precise terms and conditions for copying, distribution and +modification follow. Pay close attention to the difference between a +"work based on the library" and a "work that uses the library". The +former contains code derived from the library, whereas the latter must +be combined with the library in order to run. + + GNU LESSER GENERAL PUBLIC LICENSE + TERMS AND CONDITIONS FOR COPYING, DISTRIBUTION AND MODIFICATION + + 0. This License Agreement applies to any software library or other +program which contains a notice placed by the copyright holder or +other authorized party saying it may be distributed under the terms of +this Lesser General Public License (also called "this License"). +Each licensee is addressed as "you". + + A "library" means a collection of software functions and/or data +prepared so as to be conveniently linked with application programs +(which use some of those functions and data) to form executables. + + The "Library", below, refers to any such software library or work +which has been distributed under these terms. A "work based on the +Library" means either the Library or any derivative work under +copyright law: that is to say, a work containing the Library or a +portion of it, either verbatim or with modifications and/or translated +straightforwardly into another language. (Hereinafter, translation is +included without limitation in the term "modification".) + + "Source code" for a work means the preferred form of the work for +making modifications to it. For a library, complete source code means +all the source code for all modules it contains, plus any associated +interface definition files, plus the scripts used to control compilation +and installation of the library. + + Activities other than copying, distribution and modification are not +covered by this License; they are outside its scope. The act of +running a program using the Library is not restricted, and output from +such a program is covered only if its contents constitute a work based +on the Library (independent of the use of the Library in a tool for +writing it). Whether that is true depends on what the Library does +and what the program that uses the Library does. + + 1. You may copy and distribute verbatim copies of the Library's +complete source code as you receive it, in any medium, provided that +you conspicuously and appropriately publish on each copy an +appropriate copyright notice and disclaimer of warranty; keep intact +all the notices that refer to this License and to the absence of any +warranty; and distribute a copy of this License along with the +Library. + + You may charge a fee for the physical act of transferring a copy, +and you may at your option offer warranty protection in exchange for a +fee. + + 2. You may modify your copy or copies of the Library or any portion +of it, thus forming a work based on the Library, and copy and +distribute such modifications or work under the terms of Section 1 +above, provided that you also meet all of these conditions: + + a) The modified work must itself be a software library. + + b) You must cause the files modified to carry prominent notices + stating that you changed the files and the date of any change. + + c) You must cause the whole of the work to be licensed at no + charge to all third parties under the terms of this License. + + d) If a facility in the modified Library refers to a function or a + table of data to be supplied by an application program that uses + the facility, other than as an argument passed when the facility + is invoked, then you must make a good faith effort to ensure that, + in the event an application does not supply such function or + table, the facility still operates, and performs whatever part of + its purpose remains meaningful. + + (For example, a function in a library to compute square roots has + a purpose that is entirely well-defined independent of the + application. Therefore, Subsection 2d requires that any + application-supplied function or table used by this function must + be optional: if the application does not supply it, the square + root function must still compute square roots.) + +These requirements apply to the modified work as a whole. If +identifiable sections of that work are not derived from the Library, +and can be reasonably considered independent and separate works in +themselves, then this License, and its terms, do not apply to those +sections when you distribute them as separate works. But when you +distribute the same sections as part of a whole which is a work based +on the Library, the distribution of the whole must be on the terms of +this License, whose permissions for other licensees extend to the +entire whole, and thus to each and every part regardless of who wrote +it. + +Thus, it is not the intent of this section to claim rights or contest +your rights to work written entirely by you; rather, the intent is to +exercise the right to control the distribution of derivative or +collective works based on the Library. + +In addition, mere aggregation of another work not based on the Library +with the Library (or with a work based on the Library) on a volume of +a storage or distribution medium does not bring the other work under +the scope of this License. + + 3. You may opt to apply the terms of the ordinary GNU General Public +License instead of this License to a given copy of the Library. To do +this, you must alter all the notices that refer to this License, so +that they refer to the ordinary GNU General Public License, version 2, +instead of to this License. (If a newer version than version 2 of the +ordinary GNU General Public License has appeared, then you can specify +that version instead if you wish.) Do not make any other change in +these notices. + + Once this change is made in a given copy, it is irreversible for +that copy, so the ordinary GNU General Public License applies to all +subsequent copies and derivative works made from that copy. + + This option is useful when you wish to copy part of the code of +the Library into a program that is not a library. + + 4. You may copy and distribute the Library (or a portion or +derivative of it, under Section 2) in object code or executable form +under the terms of Sections 1 and 2 above provided that you accompany +it with the complete corresponding machine-readable source code, which +must be distributed under the terms of Sections 1 and 2 above on a +medium customarily used for software interchange. + + If distribution of object code is made by offering access to copy +from a designated place, then offering equivalent access to copy the +source code from the same place satisfies the requirement to +distribute the source code, even though third parties are not +compelled to copy the source along with the object code. + + 5. A program that contains no derivative of any portion of the +Library, but is designed to work with the Library by being compiled or +linked with it, is called a "work that uses the Library". Such a +work, in isolation, is not a derivative work of the Library, and +therefore falls outside the scope of this License. + + However, linking a "work that uses the Library" with the Library +creates an executable that is a derivative of the Library (because it +contains portions of the Library), rather than a "work that uses the +library". The executable is therefore covered by this License. +Section 6 states terms for distribution of such executables. + + When a "work that uses the Library" uses material from a header file +that is part of the Library, the object code for the work may be a +derivative work of the Library even though the source code is not. +Whether this is true is especially significant if the work can be +linked without the Library, or if the work is itself a library. The +threshold for this to be true is not precisely defined by law. + + If such an object file uses only numerical parameters, data +structure layouts and accessors, and small macros and small inline +functions (ten lines or less in length), then the use of the object +file is unrestricted, regardless of whether it is legally a derivative +work. (Executables containing this object code plus portions of the +Library will still fall under Section 6.) + + Otherwise, if the work is a derivative of the Library, you may +distribute the object code for the work under the terms of Section 6. +Any executables containing that work also fall under Section 6, +whether or not they are linked directly with the Library itself. + + 6. As an exception to the Sections above, you may also combine or +link a "work that uses the Library" with the Library to produce a +work containing portions of the Library, and distribute that work +under terms of your choice, provided that the terms permit +modification of the work for the customer's own use and reverse +engineering for debugging such modifications. + + You must give prominent notice with each copy of the work that the +Library is used in it and that the Library and its use are covered by +this License. You must supply a copy of this License. If the work +during execution displays copyright notices, you must include the +copyright notice for the Library among them, as well as a reference +directing the user to the copy of this License. Also, you must do one +of these things: + + a) Accompany the work with the complete corresponding + machine-readable source code for the Library including whatever + changes were used in the work (which must be distributed under + Sections 1 and 2 above); and, if the work is an executable linked + with the Library, with the complete machine-readable "work that + uses the Library", as object code and/or source code, so that the + user can modify the Library and then relink to produce a modified + executable containing the modified Library. (It is understood + that the user who changes the contents of definitions files in the + Library will not necessarily be able to recompile the application + to use the modified definitions.) + + b) Use a suitable shared library mechanism for linking with the + Library. A suitable mechanism is one that (1) uses at run time a + copy of the library already present on the user's computer system, + rather than copying library functions into the executable, and (2) + will operate properly with a modified version of the library, if + the user installs one, as long as the modified version is + interface-compatible with the version that the work was made with. + + c) Accompany the work with a written offer, valid for at + least three years, to give the same user the materials + specified in Subsection 6a, above, for a charge no more + than the cost of performing this distribution. + + d) If distribution of the work is made by offering access to copy + from a designated place, offer equivalent access to copy the above + specified materials from the same place. + + e) Verify that the user has already received a copy of these + materials or that you have already sent this user a copy. + + For an executable, the required form of the "work that uses the +Library" must include any data and utility programs needed for +reproducing the executable from it. However, as a special exception, +the materials to be distributed need not include anything that is +normally distributed (in either source or binary form) with the major +components (compiler, kernel, and so on) of the operating system on +which the executable runs, unless that component itself accompanies +the executable. + + It may happen that this requirement contradicts the license +restrictions of other proprietary libraries that do not normally +accompany the operating system. Such a contradiction means you cannot +use both them and the Library together in an executable that you +distribute. + + 7. You may place library facilities that are a work based on the +Library side-by-side in a single library together with other library +facilities not covered by this License, and distribute such a combined +library, provided that the separate distribution of the work based on +the Library and of the other library facilities is otherwise +permitted, and provided that you do these two things: + + a) Accompany the combined library with a copy of the same work + based on the Library, uncombined with any other library + facilities. This must be distributed under the terms of the + Sections above. + + b) Give prominent notice with the combined library of the fact + that part of it is a work based on the Library, and explaining + where to find the accompanying uncombined form of the same work. + + 8. You may not copy, modify, sublicense, link with, or distribute +the Library except as expressly provided under this License. Any +attempt otherwise to copy, modify, sublicense, link with, or +distribute the Library is void, and will automatically terminate your +rights under this License. However, parties who have received copies, +or rights, from you under this License will not have their licenses +terminated so long as such parties remain in full compliance. + + 9. You are not required to accept this License, since you have not +signed it. However, nothing else grants you permission to modify or +distribute the Library or its derivative works. These actions are +prohibited by law if you do not accept this License. Therefore, by +modifying or distributing the Library (or any work based on the +Library), you indicate your acceptance of this License to do so, and +all its terms and conditions for copying, distributing or modifying +the Library or works based on it. + + 10. Each time you redistribute the Library (or any work based on the +Library), the recipient automatically receives a license from the +original licensor to copy, distribute, link with or modify the Library +subject to these terms and conditions. You may not impose any further +restrictions on the recipients' exercise of the rights granted herein. +You are not responsible for enforcing compliance by third parties with +this License. + + 11. If, as a consequence of a court judgment or allegation of patent +infringement or for any other reason (not limited to patent issues), +conditions are imposed on you (whether by court order, agreement or +otherwise) that contradict the conditions of this License, they do not +excuse you from the conditions of this License. If you cannot +distribute so as to satisfy simultaneously your obligations under this +License and any other pertinent obligations, then as a consequence you +may not distribute the Library at all. For example, if a patent +license would not permit royalty-free redistribution of the Library by +all those who receive copies directly or indirectly through you, then +the only way you could satisfy both it and this License would be to +refrain entirely from distribution of the Library. + +If any portion of this section is held invalid or unenforceable under any +particular circumstance, the balance of the section is intended to apply, +and the section as a whole is intended to apply in other circumstances. + +It is not the purpose of this section to induce you to infringe any +patents or other property right claims or to contest validity of any +such claims; this section has the sole purpose of protecting the +integrity of the free software distribution system which is +implemented by public license practices. Many people have made +generous contributions to the wide range of software distributed +through that system in reliance on consistent application of that +system; it is up to the author/donor to decide if he or she is willing +to distribute software through any other system and a licensee cannot +impose that choice. + +This section is intended to make thoroughly clear what is believed to +be a consequence of the rest of this License. + + 12. If the distribution and/or use of the Library is restricted in +certain countries either by patents or by copyrighted interfaces, the +original copyright holder who places the Library under this License may add +an explicit geographical distribution limitation excluding those countries, +so that distribution is permitted only in or among countries not thus +excluded. In such case, this License incorporates the limitation as if +written in the body of this License. + + 13. The Free Software Foundation may publish revised and/or new +versions of the Lesser General Public License from time to time. +Such new versions will be similar in spirit to the present version, +but may differ in detail to address new problems or concerns. + +Each version is given a distinguishing version number. If the Library +specifies a version number of this License which applies to it and +"any later version", you have the option of following the terms and +conditions either of that version or of any later version published by +the Free Software Foundation. If the Library does not specify a +license version number, you may choose any version ever published by +the Free Software Foundation. + + 14. If you wish to incorporate parts of the Library into other free +programs whose distribution conditions are incompatible with these, +write to the author to ask for permission. For software which is +copyrighted by the Free Software Foundation, write to the Free +Software Foundation; we sometimes make exceptions for this. Our +decision will be guided by the two goals of preserving the free status +of all derivatives of our free software and of promoting the sharing +and reuse of software generally. + + NO WARRANTY + + 15. BECAUSE THE LIBRARY IS LICENSED FREE OF CHARGE, THERE IS NO +WARRANTY FOR THE LIBRARY, TO THE EXTENT PERMITTED BY APPLICABLE LAW. +EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT HOLDERS AND/OR +OTHER PARTIES PROVIDE THE LIBRARY "AS IS" WITHOUT WARRANTY OF ANY +KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, THE +IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR +PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE +LIBRARY IS WITH YOU. SHOULD THE LIBRARY PROVE DEFECTIVE, YOU ASSUME +THE COST OF ALL NECESSARY SERVICING, REPAIR OR CORRECTION. + + 16. IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN +WRITING WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MAY MODIFY +AND/OR REDISTRIBUTE THE LIBRARY AS PERMITTED ABOVE, BE LIABLE TO YOU +FOR DAMAGES, INCLUDING ANY GENERAL, SPECIAL, INCIDENTAL OR +CONSEQUENTIAL DAMAGES ARISING OUT OF THE USE OR INABILITY TO USE THE +LIBRARY (INCLUDING BUT NOT LIMITED TO LOSS OF DATA OR DATA BEING +RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD PARTIES OR A +FAILURE OF THE LIBRARY TO OPERATE WITH ANY OTHER SOFTWARE), EVEN IF +SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF SUCH +DAMAGES. + + END OF TERMS AND CONDITIONS + + How to Apply These Terms to Your New Libraries + + If you develop a new library, and you want it to be of the greatest +possible use to the public, we recommend making it free software that +everyone can redistribute and change. You can do so by permitting +redistribution under these terms (or, alternatively, under the terms of the +ordinary General Public License). + + To apply these terms, attach the following notices to the library. It is +safest to attach them to the start of each source file to most effectively +convey the exclusion of warranty; and each file should have at least the +"copyright" line and a pointer to where the full notice is found. + + + Copyright (C) + + This library is free software; you can redistribute it and/or + modify it under the terms of the GNU Lesser General Public + License as published by the Free Software Foundation; either + version 2.1 of the License, or (at your option) any later version. + + This library is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + Lesser General Public License for more details. + + You should have received a copy of the GNU Lesser General Public + License along with this library; if not, write to the Free Software + Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA + +Also add information on how to contact you by electronic and paper mail. + +You should also get your employer (if you work as a programmer) or your +school, if any, to sign a "copyright disclaimer" for the library, if +necessary. Here is a sample; alter the names: + + Yoyodyne, Inc., hereby disclaims all copyright interest in the + library `Frob' (a library for tweaking knobs) written by James Random Hacker. + + , 1 April 1990 + Ty Coon, President of Vice + +That's all there is to it! + + diff --git a/Assets/_Project/_Features/Accessibility/Plugins/x86_64/NVDA_CONTROLLER_CLIENT_LICENSE.txt.meta b/Assets/_Project/_Features/Accessibility/Plugins/x86_64/NVDA_CONTROLLER_CLIENT_LICENSE.txt.meta new file mode 100644 index 00000000..fa93581b --- /dev/null +++ b/Assets/_Project/_Features/Accessibility/Plugins/x86_64/NVDA_CONTROLLER_CLIENT_LICENSE.txt.meta @@ -0,0 +1,7 @@ +fileFormatVersion: 2 +guid: b990e8f789d7c5745a0c79a1c614b7b8 +TextScriptImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/_Project/_Features/Accessibility/Plugins/x86_64/nvdaControllerClient.dll b/Assets/_Project/_Features/Accessibility/Plugins/x86_64/nvdaControllerClient.dll new file mode 100644 index 00000000..4e6cd34a --- /dev/null +++ b/Assets/_Project/_Features/Accessibility/Plugins/x86_64/nvdaControllerClient.dll @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:2fe60cf00be929aae32e95c1e1507a20ada4902c8fec273b3cc2d3bf5472932a +size 262808 diff --git a/Assets/_Project/_Features/Accessibility/Plugins/x86_64/nvdaControllerClient.dll.meta b/Assets/_Project/_Features/Accessibility/Plugins/x86_64/nvdaControllerClient.dll.meta new file mode 100644 index 00000000..98c04fff --- /dev/null +++ b/Assets/_Project/_Features/Accessibility/Plugins/x86_64/nvdaControllerClient.dll.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 09cc0592642d7af48bc2b061fa9c78ca \ No newline at end of file diff --git a/Assets/_Project/_Features/Accessibility/Resources.meta b/Assets/_Project/_Features/Accessibility/Resources.meta new file mode 100644 index 00000000..98628b8c --- /dev/null +++ b/Assets/_Project/_Features/Accessibility/Resources.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: ff3eb9f83bf6ef04d92acee41bebaf5b +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/_Project/_Features/Accessibility/Resources/A11y.meta b/Assets/_Project/_Features/Accessibility/Resources/A11y.meta new file mode 100644 index 00000000..cdf285e8 --- /dev/null +++ b/Assets/_Project/_Features/Accessibility/Resources/A11y.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: f85284e7f0310cb40be9a9ff75fe900c +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/_Project/_Features/Accessibility/Resources/A11y/alt_texts.json b/Assets/_Project/_Features/Accessibility/Resources/A11y/alt_texts.json new file mode 100644 index 00000000..6e66ad06 --- /dev/null +++ b/Assets/_Project/_Features/Accessibility/Resources/A11y/alt_texts.json @@ -0,0 +1,72 @@ +{ + "entries": [ + { + "key": "scene:MainMenu", + "text": "Menú principal de SCP Project Unity. En pantalla: logo de SCP Containment Breach con el emblema de la Fundación y el lema Secure, Contain, Protect. Hay enlaces a Discord, GitHub, Reddit y YouTube." + }, + { + "key": "scene:MainMenu_Credits", + "text": "Pantalla de créditos. Fondo con el emblema de la Fundación SCP en marca de agua." + }, + { + "key": "scene:Facility", + "text": "Instalación de la Fundación. Partida en curso." + }, + { + "key": "scene:Facility_Exterior", + "text": "Exterior de la instalación. Partida en curso." + }, + { + "key": "scene:Facility_MapGen", + "text": "Instalación generada proceduralmente. Partida en curso." + }, + { + "key": "scene:Testing_Core", + "text": "Escena de pruebas del núcleo del juego. Partida en curso. El sonar de accesibilidad está activo." + }, + { + "key": "scene:Testing_Rooms", + "text": "Escena de pruebas de habitaciones." + }, + { + "key": "scene:Testing_UI", + "text": "Escena de pruebas de interfaz." + }, + { + "key": "room:Entrance Zone", + "text": "Zona de Entrada" + }, + { + "key": "room:Light Containment Zone", + "text": "Zona de Contención Ligera" + }, + { + "key": "room:Heavy Containment Zone", + "text": "Zona de Contención Pesada" + }, + { + "key": "MainMenu_BG", + "text": "Logo del juego: SCP Containment Breach, con el emblema de la Fundación SCP. Lema: Secure, Contain, Protect. Fondo oscuro con una figura apenas visible junto a una puerta." + }, + { + "key": "MainMenu_Credits_BG", + "text": "Emblema de la Fundación SCP en marca de agua sobre fondo oscuro con salpicaduras." + }, + { + "key": "Discord", + "text": "Enlace a la comunidad de Discord del juego" + }, + { + "key": "GitHub", + "text": "Enlace al repositorio del juego en GitHub" + }, + { + "key": "Reddit", + "text": "Enlace a la comunidad de Reddit del juego" + }, + { + "key": "YouTube", + "text": "Enlace al canal de YouTube del juego" + } + ] +} diff --git a/Assets/_Project/_Features/Accessibility/Resources/A11y/alt_texts.json.meta b/Assets/_Project/_Features/Accessibility/Resources/A11y/alt_texts.json.meta new file mode 100644 index 00000000..b748f461 --- /dev/null +++ b/Assets/_Project/_Features/Accessibility/Resources/A11y/alt_texts.json.meta @@ -0,0 +1,7 @@ +fileFormatVersion: 2 +guid: 8b4955a8fcb59ed47b1dbc791a69eb04 +TextScriptImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/_Project/_Features/Accessibility/_Scripts.meta b/Assets/_Project/_Features/Accessibility/_Scripts.meta new file mode 100644 index 00000000..9a2b55ab --- /dev/null +++ b/Assets/_Project/_Features/Accessibility/_Scripts.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: f9b14f144f61e0440b39e57c57835d12 +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/_Project/_Features/Accessibility/_Scripts/A11yAltText.cs b/Assets/_Project/_Features/Accessibility/_Scripts/A11yAltText.cs new file mode 100644 index 00000000..21d0c391 --- /dev/null +++ b/Assets/_Project/_Features/Accessibility/_Scripts/A11yAltText.cs @@ -0,0 +1,66 @@ +using System; +using System.Collections.Generic; +using UnityEngine; + +/// +/// Alt-text registry for visual content (images with baked-in text, scene descriptions). +/// Data lives in Resources/A11y/alt_texts.json so QA can edit descriptions without recompiling. +/// Keys are case-insensitive; "scene:{name}" keys describe whole screens on load. +/// +public static class A11yAltText +{ + [Serializable] + public class AltTextEntry + { + public string key; + public string text; + } + + [Serializable] + public class AltTextData + { + public AltTextEntry[] entries; + } + + private static Dictionary _entries; + + public static bool TryGet(string key, out string text) + { + EnsureLoaded(); + if (!string.IsNullOrEmpty(key) && _entries.TryGetValue(key.Trim().ToLowerInvariant(), out text)) + { + return true; + } + text = null; + return false; + } + + /// Fallback when no alt text is registered: turn an internal object name into something speakable. + public static string HumanizeName(string rawName) + { + if (string.IsNullOrEmpty(rawName)) return string.Empty; + return rawName.Replace("(Clone)", string.Empty).Replace('_', ' ').Trim(); + } + + private static void EnsureLoaded() + { + if (_entries != null) return; + _entries = new Dictionary(); + + var json = Resources.Load("A11y/alt_texts"); + if (json == null) + { + Debug.LogWarning("[Accessibility] Resources/A11y/alt_texts.json not found; alt text lookups will all fall back to object names."); + return; + } + + var data = JsonUtility.FromJson(json.text); + if (data?.entries == null) return; + + foreach (var entry in data.entries) + { + if (string.IsNullOrEmpty(entry.key) || string.IsNullOrEmpty(entry.text)) continue; + _entries[entry.key.Trim().ToLowerInvariant()] = entry.text; + } + } +} diff --git a/Assets/_Project/_Features/Accessibility/_Scripts/A11yAltText.cs.meta b/Assets/_Project/_Features/Accessibility/_Scripts/A11yAltText.cs.meta new file mode 100644 index 00000000..db64a3d3 --- /dev/null +++ b/Assets/_Project/_Features/Accessibility/_Scripts/A11yAltText.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: b808840371db80e4982460775593c50a \ No newline at end of file diff --git a/Assets/_Project/_Features/Accessibility/_Scripts/A11yFmodAudio.cs b/Assets/_Project/_Features/Accessibility/_Scripts/A11yFmodAudio.cs new file mode 100644 index 00000000..b8d09286 --- /dev/null +++ b/Assets/_Project/_Features/Accessibility/_Scripts/A11yFmodAudio.cs @@ -0,0 +1,130 @@ +using System; +using System.IO; +using System.Runtime.InteropServices; +using FMODUnity; +using UnityEngine; + +/// +/// Audio backend for the accessibility layer on top of FMOD's Core API. +/// The project disables Unity's built-in audio engine entirely (ProjectSettings: +/// m_DisableAudio, standard for FMOD games), so AudioSource is silent in builds — +/// all accessibility sounds must go through FMOD. The Core API needs no Studio banks, +/// which also keeps the layer working in test scenes where no banks are loaded. +/// Sounds come from StreamingAssets/A11y/*.ogg (CC0, see A11Y_AUDIO_CREDITS.md) with +/// procedurally generated PCM tones as fallback. Every failure degrades to silence. +/// +public static class A11yFmodAudio +{ + public struct A11ySound + { + public FMOD.Sound sound; + public bool valid; + } + + /// Load an .ogg from StreamingAssets/A11y; falls back to a generated tone. + public static A11ySound LoadOrGenerate(string fileName, float fallbackFrequency, float fallbackDuration) + { + string path = Path.Combine(Application.streamingAssetsPath, "A11y", fileName); + if (File.Exists(path)) + { + try + { + var result = RuntimeManager.CoreSystem.createSound(path, FMOD.MODE._3D | FMOD.MODE.CREATESAMPLE | FMOD.MODE.LOOP_OFF, out FMOD.Sound fileSound); + if (result == FMOD.RESULT.OK) + { + fileSound.set3DMinMaxDistance(1.5f, 60f); + return new A11ySound { sound = fileSound, valid = true }; + } + Debug.LogWarning($"[Accessibility] FMOD could not load {fileName}: {result}. Falling back to generated tone."); + } + catch (Exception e) + { + Debug.LogWarning($"[Accessibility] FMOD file load failed for {fileName}: {e.Message}"); + } + } + + return Generate(fallbackFrequency, fallbackDuration); + } + + /// Create an FMOD sound from a procedurally generated tone (no assets needed). + public static A11ySound Generate(float frequency, float duration, int sampleRate = 44100) + { + try + { + float[] samples = SonarAudio.GenerateBeepSamples(frequency, duration, sampleRate); + byte[] pcm = SonarAudio.SamplesToPcm16(samples); + + var exinfo = new FMOD.CREATESOUNDEXINFO + { + cbsize = Marshal.SizeOf(typeof(FMOD.CREATESOUNDEXINFO)), + length = (uint)pcm.Length, + numchannels = 1, + defaultfrequency = sampleRate, + format = FMOD.SOUND_FORMAT.PCM16 + }; + + var result = RuntimeManager.CoreSystem.createSound(pcm, + FMOD.MODE.OPENMEMORY | FMOD.MODE.OPENRAW | FMOD.MODE._3D | FMOD.MODE.LOOP_OFF, + ref exinfo, out FMOD.Sound rawSound); + + if (result != FMOD.RESULT.OK) + { + Debug.LogWarning($"[Accessibility] FMOD createSound failed: {result}"); + return default; + } + + rawSound.set3DMinMaxDistance(1.5f, 60f); + return new A11ySound { sound = rawSound, valid = true }; + } + catch (Exception e) + { + Debug.LogWarning($"[Accessibility] FMOD tone generation failed: {e.Message}"); + return default; + } + } + + /// Play a sound spatialized at a world position, optionally pitch-shifted + /// (TLOU-style: pitch encodes the target's relative height). + public static void PlayAt(A11ySound a11ySound, Vector3 position, float volume, float pitch = 1f) + { + if (!a11ySound.valid) return; + try + { + RuntimeManager.CoreSystem.playSound(a11ySound.sound, default, true, out FMOD.Channel channel); + FMOD.VECTOR pos = RuntimeUtils.ToFMODVector(position); + FMOD.VECTOR vel = default; + channel.set3DAttributes(ref pos, ref vel); + channel.setVolume(Mathf.Clamp01(volume)); + if (!Mathf.Approximately(pitch, 1f)) channel.setPitch(pitch); + channel.setPaused(false); + } + catch (Exception e) + { + Debug.LogWarning($"[Accessibility] FMOD playback failed: {e.Message}"); + } + } + + /// Play a sound flat (no spatialization) — for cues about the player's own body. + public static void Play2D(A11ySound a11ySound, float volume) + { + if (!a11ySound.valid) return; + try + { + RuntimeManager.CoreSystem.playSound(a11ySound.sound, default, true, out FMOD.Channel channel); + channel.setMode(FMOD.MODE._2D); + channel.setVolume(Mathf.Clamp01(volume)); + channel.setPaused(false); + } + catch (Exception e) + { + Debug.LogWarning($"[Accessibility] FMOD 2D playback failed: {e.Message}"); + } + } + + public static void Release(ref A11ySound a11ySound) + { + if (!a11ySound.valid) return; + try { a11ySound.sound.release(); } catch { } + a11ySound.valid = false; + } +} diff --git a/Assets/_Project/_Features/Accessibility/_Scripts/A11yFmodAudio.cs.meta b/Assets/_Project/_Features/Accessibility/_Scripts/A11yFmodAudio.cs.meta new file mode 100644 index 00000000..e4802f52 --- /dev/null +++ b/Assets/_Project/_Features/Accessibility/_Scripts/A11yFmodAudio.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: d0b8d9c3ef086734fb6f29317af02d88 \ No newline at end of file diff --git a/Assets/_Project/_Features/Accessibility/_Scripts/A11yHaptics.cs b/Assets/_Project/_Features/Accessibility/_Scripts/A11yHaptics.cs new file mode 100644 index 00000000..0b6ca8d0 --- /dev/null +++ b/Assets/_Project/_Features/Accessibility/_Scripts/A11yHaptics.cs @@ -0,0 +1,53 @@ +using System.Collections; +using UnityEngine; +using UnityEngine.InputSystem; + +/// +/// Gamepad rumble pulses for the accessibility layer (works with DualSense, Xbox and +/// any pad the Input System exposes). Haptics add a sensory channel that keyboards +/// lack — e.g. feeling the wall you just bumped into. +/// Maps to gameaccessibilityguidelines.com (Motor/Vision): "Provide vibration feedback" +/// and reinforces "Ensure no essential information is conveyed by visuals alone". +/// +public static class A11yHaptics +{ + /// Short rumble pulse; host runs the coroutine that stops the motors. + public static void Pulse(MonoBehaviour host, float lowFrequency, float highFrequency, float duration) + { + var pad = Gamepad.current; + if (pad == null || host == null || !host.isActiveAndEnabled) return; + + pad.SetMotorSpeeds(Mathf.Clamp01(lowFrequency), Mathf.Clamp01(highFrequency)); + host.StartCoroutine(StopAfter(pad, duration)); + } + + private static IEnumerator StopAfter(Gamepad pad, float duration) + { + yield return new WaitForSecondsRealtime(duration); + if (pad != null && pad == Gamepad.current) + { + pad.ResetHaptics(); + } + } + + /// + /// Rumble parameters for taking damage, scaled by how big the hit was (0..1 of max + /// health). Design rule from QA: strong haptics are reserved for tense moments — + /// damage rumbles hard, everything ambient stays subtle. + /// Returns (lowFrequency, highFrequency, duration). + /// + public static Vector3 DamagePulseParams(float damageFraction) + { + float t = Mathf.Clamp01(damageFraction); + float low = Mathf.Clamp01(0.35f + t * 0.65f); // grave: cuerpo del golpe + float high = Mathf.Clamp01(0.15f + t * 0.55f); // agudo: mordida + float duration = 0.15f + t * 0.35f; + return new Vector3(low, high, duration); + } + + public static void PulseDamage(MonoBehaviour host, float damageFraction) + { + Vector3 p = DamagePulseParams(damageFraction); + Pulse(host, p.x, p.y, p.z); + } +} diff --git a/Assets/_Project/_Features/Accessibility/_Scripts/A11yHaptics.cs.meta b/Assets/_Project/_Features/Accessibility/_Scripts/A11yHaptics.cs.meta new file mode 100644 index 00000000..6e511e43 --- /dev/null +++ b/Assets/_Project/_Features/Accessibility/_Scripts/A11yHaptics.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: c12200cc489970f4487e5177fd56054e \ No newline at end of file diff --git a/Assets/_Project/_Features/Accessibility/_Scripts/AccessibilityBootstrap.cs b/Assets/_Project/_Features/Accessibility/_Scripts/AccessibilityBootstrap.cs new file mode 100644 index 00000000..e6910e2b --- /dev/null +++ b/Assets/_Project/_Features/Accessibility/_Scripts/AccessibilityBootstrap.cs @@ -0,0 +1,18 @@ +using UnityEngine; + +/// +/// Creates the accessibility layer automatically after the first scene loads, in any scene, +/// so no scene or prefab in the base game needs editing. +/// +public static class AccessibilityBootstrap +{ + [RuntimeInitializeOnLoadMethod(RuntimeInitializeLoadType.AfterSceneLoad)] + private static void Initialize() + { + if (AccessibilityManager.Instance != null) return; + + var accessibilityObject = new GameObject("[Accessibility]"); + accessibilityObject.AddComponent(); + Debug.Log("[Accessibility] Layer initialized (sonar + settings)."); + } +} diff --git a/Assets/_Project/_Features/Accessibility/_Scripts/AccessibilityBootstrap.cs.meta b/Assets/_Project/_Features/Accessibility/_Scripts/AccessibilityBootstrap.cs.meta new file mode 100644 index 00000000..e2ea62d8 --- /dev/null +++ b/Assets/_Project/_Features/Accessibility/_Scripts/AccessibilityBootstrap.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: ff36be91e3678c64bbb9db0d5343013c \ No newline at end of file diff --git a/Assets/_Project/_Features/Accessibility/_Scripts/AccessibilityManager.cs b/Assets/_Project/_Features/Accessibility/_Scripts/AccessibilityManager.cs new file mode 100644 index 00000000..4f25e0f0 --- /dev/null +++ b/Assets/_Project/_Features/Accessibility/_Scripts/AccessibilityManager.cs @@ -0,0 +1,158 @@ +using System; +using UnityEngine; +using UnityEngine.InputSystem; + +/// +/// Central settings hub for the accessibility layer. Created automatically by AccessibilityBootstrap; +/// lives across scene loads. Persists settings in PlayerPrefs under A11y_* keys (self-contained on +/// purpose: no dependency on SettingsManager so the layer survives upstream settings refactors). +/// +public class AccessibilityManager : MonoBehaviour +{ + public static AccessibilityManager Instance { get; private set; } + + [Header("Sonar Settings")] + public bool sonarEnabled = true; + // Continuous parking-sensor beeping is opt-in: blind QA preferred the on-demand + // scan (B) after playing TLOU's Enhanced Listen Mode ("I like firing it when I + // actually need it"). Shift+F6 toggles it back on. + public bool sonarContinuous = false; + [Range(0f, 1f)] public float sonarVolume = 0.8f; + [Range(3f, 30f)] public float sonarRadius = 12f; + + public event Action OnAccessibilitySettingsChanged; + + private InputAction _toggleSonarAction; + private InputAction _toggleContinuousAction; + + public static class Keys + { + public const string SonarEnabled = "A11y_SonarEnabled"; + public const string SonarContinuous = "A11y_SonarContinuous"; + public const string SonarVolume = "A11y_SonarVolume"; + public const string SonarRadius = "A11y_SonarRadius"; + } + + private void Awake() + { + if (Instance != null && Instance != this) + { + Destroy(gameObject); + return; + } + Instance = this; + DontDestroyOnLoad(gameObject); + + LoadSettings(); + + if (GetComponent() == null) + { + gameObject.AddComponent(); + } + + if (GetComponent() == null) + { + gameObject.AddComponent(); + } + + if (GetComponent() == null) + { + gameObject.AddComponent(); + } + } + + private void Start() + { + // Immediate, audible confirmation for screen reader users that the layer is up + ScreenReaderOutput.Speak("Capa de accesibilidad activa. B escanea el área, H dice tu estado. Con el juego en pausa, G abre el glosario de sonidos."); + } + + private void OnEnable() + { + // Standalone InputActions so the game's shared InputActionAsset stays untouched + _toggleSonarAction = new InputAction("A11yToggleSonar", binding: "/f6"); + _toggleSonarAction.performed += OnToggleSonarPerformed; + _toggleSonarAction.Enable(); + + _toggleContinuousAction = new InputAction("A11yToggleContinuous"); + _toggleContinuousAction.AddCompositeBinding("ButtonWithOneModifier") + .With("Modifier", "/leftShift") + .With("Button", "/f6"); + _toggleContinuousAction.performed += OnToggleContinuousPerformed; + _toggleContinuousAction.Enable(); + } + + private void OnDisable() + { + if (_toggleSonarAction != null) + { + _toggleSonarAction.performed -= OnToggleSonarPerformed; + _toggleSonarAction.Disable(); + _toggleSonarAction.Dispose(); + _toggleSonarAction = null; + } + if (_toggleContinuousAction != null) + { + _toggleContinuousAction.performed -= OnToggleContinuousPerformed; + _toggleContinuousAction.Disable(); + _toggleContinuousAction.Dispose(); + _toggleContinuousAction = null; + } + } + + private void OnToggleSonarPerformed(InputAction.CallbackContext ctx) + { + // Shift+F6 is the continuous-mode gesture; plain F6 must not also fire then + var keyboard = Keyboard.current; + if (keyboard != null && (keyboard.leftShiftKey.isPressed || keyboard.rightShiftKey.isPressed)) return; + SetSonarEnabled(!sonarEnabled); + } + + private void OnToggleContinuousPerformed(InputAction.CallbackContext ctx) + { + sonarContinuous = !sonarContinuous; + SaveSettings(); + OnAccessibilitySettingsChanged?.Invoke(); + ScreenReaderOutput.Speak(sonarContinuous ? "Sonar continuo activado." : "Sonar continuo desactivado.", true); + } + + public void SetSonarEnabled(bool value) + { + sonarEnabled = value; + SaveSettings(); + OnAccessibilitySettingsChanged?.Invoke(); + ScreenReaderOutput.Speak(value ? "Sonar activado." : "Sonar desactivado.", true); + Debug.Log($"[Accessibility] Sonar {(value ? "enabled" : "disabled")}."); + } + + public void SetSonarVolume(float value) + { + sonarVolume = Mathf.Clamp01(value); + SaveSettings(); + OnAccessibilitySettingsChanged?.Invoke(); + } + + public void SetSonarRadius(float value) + { + sonarRadius = Mathf.Clamp(value, 3f, 30f); + SaveSettings(); + OnAccessibilitySettingsChanged?.Invoke(); + } + + public void LoadSettings() + { + sonarEnabled = PlayerPrefs.GetInt(Keys.SonarEnabled, 1) == 1; + sonarContinuous = PlayerPrefs.GetInt(Keys.SonarContinuous, 0) == 1; + sonarVolume = PlayerPrefs.GetFloat(Keys.SonarVolume, 0.8f); + sonarRadius = PlayerPrefs.GetFloat(Keys.SonarRadius, 12f); + } + + public void SaveSettings() + { + PlayerPrefs.SetInt(Keys.SonarEnabled, sonarEnabled ? 1 : 0); + PlayerPrefs.SetInt(Keys.SonarContinuous, sonarContinuous ? 1 : 0); + PlayerPrefs.SetFloat(Keys.SonarVolume, sonarVolume); + PlayerPrefs.SetFloat(Keys.SonarRadius, sonarRadius); + PlayerPrefs.Save(); + } +} diff --git a/Assets/_Project/_Features/Accessibility/_Scripts/AccessibilityManager.cs.meta b/Assets/_Project/_Features/Accessibility/_Scripts/AccessibilityManager.cs.meta new file mode 100644 index 00000000..474b9c41 --- /dev/null +++ b/Assets/_Project/_Features/Accessibility/_Scripts/AccessibilityManager.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 6eca6af2fdca75042adcfde34f8931de \ No newline at end of file diff --git a/Assets/_Project/_Features/Accessibility/_Scripts/BlinkWarning.cs b/Assets/_Project/_Features/Accessibility/_Scripts/BlinkWarning.cs new file mode 100644 index 00000000..a06a9e7c --- /dev/null +++ b/Assets/_Project/_Features/Accessibility/_Scripts/BlinkWarning.cs @@ -0,0 +1,60 @@ +using UnityEngine; + +/// +/// Short non-spatial audio cue when the blink meter is about to force a blink (~15% left), +/// so blind players can time their surroundings check before their eyes close — essential +/// once SCP-173-style enemies land. Fires once per blink cycle; re-arms when the blink ends. +/// Attached to the player GameObject at runtime by ProximitySonar — no prefab edits. +/// Audio goes through FMOD Core (the project disables Unity's audio engine). +/// Maps to gameaccessibilityguidelines.com (Vision): "Ensure no essential information is +/// conveyed by visuals alone" (the blink meter is only a visual bar). +/// A sound (not speech) on purpose: blinks recur every ~15 seconds and TTS would fatigue. +/// +public class BlinkWarning : MonoBehaviour +{ + [SerializeField, Range(0.05f, 0.5f)] private float warnAtBlinkFraction = 0.15f; + [SerializeField] private float cueFrequency = 880f; + [SerializeField] private float cueDuration = 0.045f; + + private PlayerBlink _blink; + private A11yFmodAudio.A11ySound _cue; + private bool _armed = true; + + private void Awake() + { + _blink = GetComponent(); + _cue = A11yFmodAudio.LoadOrGenerate("blink_warning.ogg", cueFrequency, cueDuration); + } + + private void OnDestroy() + { + A11yFmodAudio.Release(ref _cue); + } + + private void OnEnable() + { + if (_blink != null) _blink.OnBlinkEnded += Rearm; + } + + private void OnDisable() + { + if (_blink != null) _blink.OnBlinkEnded -= Rearm; + } + + private void Update() + { + var manager = AccessibilityManager.Instance; + if (manager == null || !manager.sonarEnabled || _blink == null) return; + + var gameManager = Core.GameManager; + if (gameManager != null && gameManager.gamePaused) return; + + if (SonarLogic.ShouldWarnBlink(_armed, _blink.IsBlinking, _blink.currentBlink, warnAtBlinkFraction)) + { + _armed = false; + A11yFmodAudio.Play2D(_cue, manager.sonarVolume); + } + } + + private void Rearm() => _armed = true; +} diff --git a/Assets/_Project/_Features/Accessibility/_Scripts/BlinkWarning.cs.meta b/Assets/_Project/_Features/Accessibility/_Scripts/BlinkWarning.cs.meta new file mode 100644 index 00000000..0d96d9ad --- /dev/null +++ b/Assets/_Project/_Features/Accessibility/_Scripts/BlinkWarning.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 6bc0bbfadd36cc64fad1273edfd168e2 \ No newline at end of file diff --git a/Assets/_Project/_Features/Accessibility/_Scripts/Editor.meta b/Assets/_Project/_Features/Accessibility/_Scripts/Editor.meta new file mode 100644 index 00000000..22057ac7 --- /dev/null +++ b/Assets/_Project/_Features/Accessibility/_Scripts/Editor.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 21ffaeaa7fd648d4ab9e430fc60ab72a +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/_Project/_Features/Accessibility/_Scripts/Editor/A11yBuild.cs b/Assets/_Project/_Features/Accessibility/_Scripts/Editor/A11yBuild.cs new file mode 100644 index 00000000..e57ccd66 --- /dev/null +++ b/Assets/_Project/_Features/Accessibility/_Scripts/Editor/A11yBuild.cs @@ -0,0 +1,41 @@ +using UnityEditor; +using UnityEditor.AddressableAssets.Settings; +using UnityEditor.Build; +using UnityEditor.Build.Reporting; +using UnityEngine; + +/// +/// Reproducible QA build for the accessibility layer, driven from batchmode: +/// Unity.exe -batchmode -quit -projectPath (proj) -executeMethod A11yBuild.BuildTestingCore +/// - Declares its scene list explicitly (the project's EditorBuildSettings ships with every scene disabled). +/// - Refreshes and copies FMOD banks explicitly (FMOD's BuildStatusWatcher never fires in CLI builds). +/// - Builds Addressables content first (the game loads audio data through Addressables). +/// - Forces the Mono scripting backend for local QA builds: upstream targets IL2CPP, which +/// requires the Visual Studio C++ toolchain that QA machines may not have. Runtime behavior +/// of the accessibility layer is identical under both backends. +/// +public static class A11yBuild +{ + public static void BuildTestingCore() + { + PlayerSettings.SetScriptingBackend(NamedBuildTarget.Standalone, ScriptingImplementation.Mono2x); + + FMODUnity.EventManager.RefreshBanks(); + FMODUnity.EventManager.CopyToStreamingAssets(BuildTarget.StandaloneWindows64); + AssetDatabase.SaveAssets(); + + AddressableAssetSettings.BuildPlayerContent(); + + var options = new BuildPlayerOptions + { + scenes = new[] { "Assets/_Project/_Scenes/Testing/Testing_Core.unity" }, + locationPathName = "Builds/A11yTest/SCPPU-Dev-A11yTest.exe", + target = BuildTarget.StandaloneWindows64, + options = BuildOptions.None + }; + + BuildReport report = BuildPipeline.BuildPlayer(options); + Debug.Log($"A11Y BUILD RESULT: {report.summary.result}, size {report.summary.totalSize / (1024 * 1024)} MB, errors {report.summary.totalErrors}"); + if (Application.isBatchMode) EditorApplication.Exit(report.summary.result == BuildResult.Succeeded ? 0 : 1); + } +} diff --git a/Assets/_Project/_Features/Accessibility/_Scripts/Editor/A11yBuild.cs.meta b/Assets/_Project/_Features/Accessibility/_Scripts/Editor/A11yBuild.cs.meta new file mode 100644 index 00000000..bc285df9 --- /dev/null +++ b/Assets/_Project/_Features/Accessibility/_Scripts/Editor/A11yBuild.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 1c0af37885335cd4ca228c530a2cf589 \ No newline at end of file diff --git a/Assets/_Project/_Features/Accessibility/_Scripts/Editor/AccessibilitySmokeTest.cs b/Assets/_Project/_Features/Accessibility/_Scripts/Editor/AccessibilitySmokeTest.cs new file mode 100644 index 00000000..35f1c4b1 --- /dev/null +++ b/Assets/_Project/_Features/Accessibility/_Scripts/Editor/AccessibilitySmokeTest.cs @@ -0,0 +1,292 @@ +using UnityEditor; +using UnityEngine; + +/// +/// Batchmode-verifiable checks for the accessibility layer: +/// Unity.exe -batchmode -quit -projectPath (proj) -executeMethod AccessibilitySmokeTest.Run +/// Exits with code 0 when all checks pass, 1 otherwise. +/// +public static class AccessibilitySmokeTest +{ + private static int _failures; + + public static void Run() + { + _failures = 0; + + TestIntervalMapping(); + TestBeepGeneration(); + TestTargetSelection(); + TestOcclusionPredicate(); + TestWallBumpRule(); + TestBlinkWarningRule(); + TestCompassHeadings(); + TestRelativeDirections(); + TestScanPitch(); + TestDamageHaptics(); + TestAltTextRegistry(); + TestVitalStatusTexts(); + TestUIElementDescription(); + + Debug.Log(_failures == 0 + ? "A11Y SMOKE TESTS: ALL PASS" + : $"A11Y SMOKE TESTS: {_failures} FAILURE(S)"); + + if (Application.isBatchMode) EditorApplication.Exit(_failures == 0 ? 0 : 1); + } + + private static void Check(bool condition, string label) + { + if (condition) + { + Debug.Log($"[PASS] {label}"); + } + else + { + _failures++; + Debug.LogError($"[FAIL] {label}"); + } + } + + private static void TestIntervalMapping() + { + float min = 0.13f, max = 1.1f, radius = 12f; + Check(Mathf.Approximately(SonarLogic.IntervalForDistance(0f, radius, min, max), min), "Interval at distance 0 == min"); + Check(Mathf.Approximately(SonarLogic.IntervalForDistance(radius, radius, min, max), max), "Interval at radius == max"); + Check(SonarLogic.IntervalForDistance(radius * 2f, radius, min, max) <= max + 0.0001f, "Interval clamps beyond radius"); + float near = SonarLogic.IntervalForDistance(2f, radius, min, max); + float far = SonarLogic.IntervalForDistance(9f, radius, min, max); + Check(near < far, "Closer target beeps faster"); + } + + private static void TestBeepGeneration() + { + // AudioClip creation itself is not testable in batchmode (audio engine disabled: + // "AudioClip.SetData failed"), so we verify the pure sample generation instead. + var data = SonarAudio.GenerateBeepSamples(520f, 0.09f); + Check(data != null && data.Length > 100, "Beep samples generated"); + + bool hasSignal = false; + bool inRange = true; + foreach (var sample in data) + { + if (Mathf.Abs(sample) > 0.1f) hasSignal = true; + if (float.IsNaN(sample) || Mathf.Abs(sample) > 1f) inRange = false; + } + Check(hasSignal, "Beep contains audible signal"); + Check(inRange, "Beep samples within [-1, 1] and not NaN"); + + // Distinct timbres: door and item beeps must differ in frequency content + var doorData = SonarAudio.GenerateBeepSamples(520f, 0.05f); + var itemData = SonarAudio.GenerateBeepSamples(1175f, 0.05f); + int doorCrossings = CountZeroCrossings(doorData); + int itemCrossings = CountZeroCrossings(itemData); + Check(itemCrossings > doorCrossings * 15 / 10, "Item beep is audibly higher-pitched than door beep"); + + // PCM16 conversion for FMOD OPENRAW: length, little-endian layout and clipping + var pcm = SonarAudio.SamplesToPcm16(new float[] { 0f, 1f, -1f, 2f }); + Check(pcm.Length == 8, "PCM16 output is 2 bytes per sample"); + Check(pcm[0] == 0 && pcm[1] == 0, "Zero sample encodes as zero"); + short maxVal = (short)(pcm[2] | (pcm[3] << 8)); + short minVal = (short)(pcm[4] | (pcm[5] << 8)); + short clipped = (short)(pcm[6] | (pcm[7] << 8)); + Check(maxVal == short.MaxValue && minVal == -short.MaxValue, "Full-scale samples encode to PCM16 limits"); + Check(clipped == short.MaxValue, "Out-of-range samples clip instead of wrapping"); + } + + private static void TestRelativeDirections() + { + Vector3 fwd = Vector3.forward; + Check(TargetNavigator.RelativeDirectionName(fwd, Vector3.forward) == "adelante", "Target ahead = adelante"); + Check(TargetNavigator.RelativeDirectionName(fwd, Vector3.right) == "a la derecha", "Target right = a la derecha"); + Check(TargetNavigator.RelativeDirectionName(fwd, Vector3.back) == "atrás", "Target behind = atrás"); + Check(TargetNavigator.RelativeDirectionName(fwd, Vector3.left) == "a la izquierda", "Target left = a la izquierda"); + Check(TargetNavigator.RelativeDirectionName(fwd, new Vector3(1f, 0f, 1f)) == "adelante a la derecha", "Diagonal = adelante a la derecha"); + Check(TargetNavigator.RelativeDirectionName(fwd, new Vector3(0f, 5f, 0.01f)) == "adelante", "Vertical offset ignored"); + } + + private static int CountZeroCrossings(float[] data) + { + int crossings = 0; + for (int i = 1; i < data.Length; i++) + { + if ((data[i - 1] < 0f) != (data[i] < 0f)) crossings++; + } + return crossings; + } + + private static void TestTargetSelection() + { + var doorGo = new GameObject("SmokeDoorButton"); + doorGo.transform.position = new Vector3(0f, 0f, 5f); + var doorCollider = doorGo.AddComponent(); + doorGo.AddComponent(); + + var itemGo = new GameObject("SmokeItem"); + itemGo.transform.position = new Vector3(0f, 0f, 2f); + var itemCollider = itemGo.AddComponent(); + itemGo.AddComponent(); + + var emptyGo = new GameObject("SmokeNotInteractable"); + emptyGo.transform.position = Vector3.zero; + var emptyCollider = emptyGo.AddComponent(); + + try + { + var hits = new Collider[] { doorCollider, emptyCollider, itemCollider }; + + bool found = SonarLogic.TryChooseTarget(Vector3.zero, hits, hits.Length, out var kind, out var chosen, out var pos, out var dist); + Check(found, "Target found among mixed colliders"); + Check(kind == SonarTargetKind.Item, "Nearest target (item at 2m) wins over door at 5m"); + Check(chosen == itemCollider, "Chosen collider is the item's"); + Check(Mathf.Approximately(dist, 2f), "Distance to chosen target is 2m"); + + var doorOnly = new Collider[] { doorCollider, emptyCollider }; + found = SonarLogic.TryChooseTarget(Vector3.zero, doorOnly, doorOnly.Length, out kind, out chosen, out pos, out dist); + Check(found && kind == SonarTargetKind.Door, "BaseDoorActivator subclass classified as Door"); + + var noneOnly = new Collider[] { emptyCollider }; + found = SonarLogic.TryChooseTarget(Vector3.zero, noneOnly, noneOnly.Length, out kind, out chosen, out pos, out dist); + Check(!found, "No interactables -> no target"); + } + finally + { + Object.DestroyImmediate(doorGo); + Object.DestroyImmediate(itemGo); + Object.DestroyImmediate(emptyGo); + } + } + + private static void TestOcclusionPredicate() + { + var doorGo = new GameObject("SmokeOccludedDoor"); + doorGo.transform.position = new Vector3(0f, 0f, 5f); + var doorCollider = doorGo.AddComponent(); + doorGo.AddComponent(); + + var itemGo = new GameObject("SmokeVisibleItem"); + itemGo.transform.position = new Vector3(0f, 0f, 2f); + var itemCollider = itemGo.AddComponent(); + itemGo.AddComponent(); + + try + { + var hits = new Collider[] { doorCollider, itemCollider }; + + // Predicate rejects the nearer item (as if a wall blocked it): the sonar must fall back to the door + bool found = SonarLogic.TryChooseTarget(Vector3.zero, hits, hits.Length, out var kind, out var chosen, out _, out var dist, + collider => collider != itemCollider); + Check(found && kind == SonarTargetKind.Door && Mathf.Approximately(dist, 5f), "Occluded nearest target is skipped in favor of a visible one"); + + // Predicate rejects everything: total occlusion means silence + found = SonarLogic.TryChooseTarget(Vector3.zero, hits, hits.Length, out kind, out chosen, out _, out _, _ => false); + Check(!found, "All targets occluded -> no beep"); + } + finally + { + Object.DestroyImmediate(doorGo); + Object.DestroyImmediate(itemGo); + } + } + + private static void TestWallBumpRule() + { + Vector3 wallNormal = new Vector3(0f, 0f, -1f); // wall facing the player + Check(SonarLogic.ShouldBumpFeedback(Vector3.forward, wallNormal), "Walking head-on into a wall bumps"); + Check(!SonarLogic.ShouldBumpFeedback(Vector3.right, wallNormal), "Sliding parallel to a wall stays silent"); + Check(!SonarLogic.ShouldBumpFeedback(Vector3.forward, Vector3.up), "Floor contact never bumps"); + Check(!SonarLogic.ShouldBumpFeedback(Vector3.zero, wallNormal), "No movement input, no bump"); + Vector3 diagonal = new Vector3(0.5f, 0f, 0.5f).normalized; // 45 degrees into the wall (dot ~0.707) + Check(SonarLogic.ShouldBumpFeedback(diagonal, wallNormal), "Diagonal push into the wall still bumps"); + } + + private static void TestBlinkWarningRule() + { + Check(SonarLogic.ShouldWarnBlink(true, false, 0.10f, 0.15f), "Warns when meter drains past threshold"); + Check(!SonarLogic.ShouldWarnBlink(true, false, 0.50f, 0.15f), "Silent while meter is high"); + Check(!SonarLogic.ShouldWarnBlink(true, true, 0.10f, 0.15f), "Silent while already blinking"); + Check(!SonarLogic.ShouldWarnBlink(false, false, 0.10f, 0.15f), "Fires only once per blink cycle (disarmed)"); + } + + private static void TestCompassHeadings() + { + Check(KeyboardLook.HeadingName(0f) == "norte", "Yaw 0 = norte"); + Check(KeyboardLook.HeadingName(90f) == "este", "Yaw 90 = este"); + Check(KeyboardLook.HeadingName(180f) == "sur", "Yaw 180 = sur"); + Check(KeyboardLook.HeadingName(270f) == "oeste", "Yaw 270 = oeste"); + Check(KeyboardLook.HeadingName(45f) == "noreste", "Yaw 45 = noreste"); + Check(KeyboardLook.HeadingName(-45f) == "noroeste", "Negative yaw wraps (-45 = noroeste)"); + Check(KeyboardLook.HeadingName(359f) == "norte", "Yaw 359 rounds to norte"); + Check(KeyboardLook.HeadingName(675f) == "noroeste", "Yaw beyond 360 wraps (675 = noroeste)"); + } + + private static void TestScanPitch() + { + Check(Mathf.Approximately(SonarLogic.PitchForHeightDelta(0f), 1f), "Level target = neutral pitch"); + Check(SonarLogic.PitchForHeightDelta(3f) > 1f, "Target above = higher pitch"); + Check(SonarLogic.PitchForHeightDelta(-3f) < 1f, "Target below = lower pitch"); + Check(SonarLogic.PitchForHeightDelta(100f) <= 1.6f, "Pitch clamps upward"); + Check(SonarLogic.PitchForHeightDelta(-100f) >= 0.6f, "Pitch clamps downward"); + } + + private static void TestDamageHaptics() + { + Vector3 small = A11yHaptics.DamagePulseParams(0.05f); + Vector3 big = A11yHaptics.DamagePulseParams(0.6f); + Vector3 over = A11yHaptics.DamagePulseParams(5f); + Check(big.x > small.x && big.y > small.y && big.z > small.z, "Bigger hits rumble harder and longer"); + Check(over.x <= 1f && over.y <= 1f, "Rumble intensities clamp at 1"); + Check(small.x >= 0.35f, "Even small hits are clearly felt (tense-moment rule)"); + } + + private static void TestAltTextRegistry() + { + Check(A11yAltText.TryGet("MainMenu_BG", out var bgText) && bgText.Contains("Containment Breach"), + "Alt text for menu background loads from JSON"); + Check(A11yAltText.TryGet("scene:MainMenu", out var sceneText) && sceneText.Contains("Discord"), + "Scene description for MainMenu loads from JSON"); + Check(A11yAltText.TryGet("discord", out _), "Alt text lookup is case-insensitive"); + Check(!A11yAltText.TryGet("NoExisteEstaClave", out _), "Unknown keys report a miss"); + Check(A11yAltText.HumanizeName("Puerta_Oficina(Clone)") == "Puerta Oficina", "Humanized fallback strips clutter"); + } + + private static void TestVitalStatusTexts() + { + var levels = (PlayerHealth.HealthLevel[])System.Enum.GetValues(typeof(PlayerHealth.HealthLevel)); + var seen = new System.Collections.Generic.HashSet(); + bool allValid = true; + foreach (var level in levels) + { + string text = VitalStatusAnnouncer.HealthLevelText(level); + if (string.IsNullOrWhiteSpace(text) || !seen.Add(text)) allValid = false; + } + Check(allValid, "Every health level has a distinct spoken message"); + Check(VitalStatusAnnouncer.HealthLevelText(PlayerHealth.HealthLevel.Dead) == "Has muerto.", "Dead state message is explicit"); + } + + private static void TestUIElementDescription() + { + // A button labeled by its child legacy Text, with a role suffix + var buttonGo = new GameObject("SmokeButton"); + buttonGo.AddComponent(); + var labelGo = new GameObject("Label"); + labelGo.transform.SetParent(buttonGo.transform); + var label = labelGo.AddComponent(); + label.text = "Jugar"; + + // An unlabeled selectable falls back to its humanized name + var sliderGo = new GameObject("Volumen_Musica"); + sliderGo.AddComponent(); + + try + { + Check(UIReader.DescribeElement(buttonGo) == "Jugar, botón", "Button description = visible label + role"); + Check(UIReader.DescribeElement(sliderGo) == "Volumen Musica, deslizador", "Slider falls back to humanized name + role"); + } + finally + { + Object.DestroyImmediate(buttonGo); + Object.DestroyImmediate(sliderGo); + } + } +} diff --git a/Assets/_Project/_Features/Accessibility/_Scripts/Editor/AccessibilitySmokeTest.cs.meta b/Assets/_Project/_Features/Accessibility/_Scripts/Editor/AccessibilitySmokeTest.cs.meta new file mode 100644 index 00000000..c983172a --- /dev/null +++ b/Assets/_Project/_Features/Accessibility/_Scripts/Editor/AccessibilitySmokeTest.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 98b47d4adcec5614fbe673f8fd9016a9 \ No newline at end of file diff --git a/Assets/_Project/_Features/Accessibility/_Scripts/FallGuard.cs b/Assets/_Project/_Features/Accessibility/_Scripts/FallGuard.cs new file mode 100644 index 00000000..f3180c63 --- /dev/null +++ b/Assets/_Project/_Features/Accessibility/_Scripts/FallGuard.cs @@ -0,0 +1,173 @@ +using UnityEngine; +using UnityEngine.InputSystem; + +/// +/// Safety net for blind navigation, born from QA: the player walked through a door at the +/// edge of the test map, fell into the void and got total silence — no footsteps, no sonar, +/// no announcement. This component: announces sustained falls, auto-rescues after falling +/// far below the last safe ground, warns when movement is blocked for a while, and lets the +/// player return to safe ground on demand (R key / gamepad dpad up). +/// Attached to the player GameObject at runtime by ProximitySonar — no prefab edits. +/// Maps to gameaccessibilityguidelines.com (Vision): "Ensure no essential information is +/// conveyed by visuals alone"; (Motor): "Include assist modes". +/// +public class FallGuard : MonoBehaviour +{ + [SerializeField] private float fallAnnounceAfter = 1.5f; // seconds airborne before speaking up + [SerializeField] private float voidDropDistance = 20f; // fallen this far below safe ground = out of the map + [SerializeField] private float safeSaveInterval = 2f; + [SerializeField] private float stuckAfter = 2.5f; // pushing forward this long without moving = blocked + + private CharacterController _controller; + private Player _player; + + private Vector3 _safeRecent; + private Vector3 _safePrevious; // rescue target: one save older, so it sits further from the cliff edge + private bool _hasSafe; + private float _nextSafeSave; + + private float _airborneSince = -1f; + private bool _announcedFalling; + + private Vector3 _stuckAnchor; + private float _stuckSince = -1f; + private bool _announcedStuck; + private float _nextLedgeWarnTime; + + private InputAction _rescueAction; + + private void Awake() + { + _controller = GetComponent(); + _player = GetComponent(); + + _rescueAction = new InputAction("A11yRescue", binding: "/r"); + _rescueAction.AddBinding("/dpad/up"); + _rescueAction.performed += _ => Rescue(); + } + + private void OnEnable() => _rescueAction.Enable(); + private void OnDisable() => _rescueAction.Disable(); + private void OnDestroy() => _rescueAction.Dispose(); + + private static bool IsGamePaused() + { + var gameManager = Core.GameManager; + if (gameManager != null) return gameManager.gamePaused; + return Time.timeScale == 0f; + } + + private void Update() + { + var manager = AccessibilityManager.Instance; + if (manager == null || !manager.sonarEnabled || _controller == null) return; + if (IsGamePaused()) return; + + if (_controller.isGrounded) + { + _airborneSince = -1f; + _announcedFalling = false; + SaveSafeGround(); + CheckStuck(); + CheckLedgeAhead(); + } + else + { + CheckFalling(); + } + } + + // Preventive ledge warning (TLOU's Ledge Guard, warning flavor): probe the ground + // just ahead of the movement direction; a missing floor means a drop is coming. + private void CheckLedgeAhead() + { + if (Time.time < _nextLedgeWarnTime) return; + + Vector2 moveInput = _player != null && _player.Inputs != null ? _player.Inputs.MoveInput : Vector2.zero; + if (moveInput.sqrMagnitude < 0.04f) return; + + Vector3 moveDirection = (transform.right * moveInput.x + transform.forward * moveInput.y).normalized; + Vector3 probeOrigin = transform.position + moveDirection * 0.8f + Vector3.up * 0.3f; + if (!Physics.Raycast(probeOrigin, Vector3.down, 2.3f, ~LayerMask.GetMask("Interactable", "Player", "Ignore Raycast", "TransparentFX", "UI", "Debris"), QueryTriggerInteraction.Ignore)) + { + _nextLedgeWarnTime = Time.time + 2.5f; + ScreenReaderOutput.Speak("Borde adelante.", true); + } + } + + private void SaveSafeGround() + { + if (Time.time < _nextSafeSave) return; + _nextSafeSave = Time.time + safeSaveInterval; + _safePrevious = _hasSafe ? _safeRecent : transform.position; + _safeRecent = transform.position; + _hasSafe = true; + } + + private void CheckFalling() + { + if (_airborneSince < 0f) + { + _airborneSince = Time.time; + } + else if (!_announcedFalling && Time.time - _airborneSince > fallAnnounceAfter) + { + _announcedFalling = true; + ScreenReaderOutput.Speak("Estás cayendo.", true); + } + + if (_hasSafe && transform.position.y < Mathf.Min(_safeRecent.y, _safePrevious.y) - voidDropDistance) + { + ScreenReaderOutput.Speak("Caíste fuera del mapa.", true); + Rescue(); + } + } + + private void CheckStuck() + { + Vector2 moveInput = _player != null && _player.Inputs != null ? _player.Inputs.MoveInput : Vector2.zero; + if (moveInput.sqrMagnitude < 0.04f) + { + _stuckSince = -1f; + _announcedStuck = false; + return; + } + + if (_stuckSince < 0f || (transform.position - _stuckAnchor).sqrMagnitude >= 0.04f) + { + _stuckSince = Time.time; + _stuckAnchor = transform.position; + _announcedStuck = false; + return; + } + + if (!_announcedStuck && Time.time - _stuckSince > stuckAfter) + { + _announcedStuck = true; + ScreenReaderOutput.Speak("Movimiento bloqueado. R vuelve a suelo seguro."); + } + } + + private void Rescue() + { + var manager = AccessibilityManager.Instance; + if (manager == null || !manager.sonarEnabled) return; + + if (!_hasSafe) + { + ScreenReaderOutput.Speak("Todavía no hay un punto seguro registrado.", true); + return; + } + + _controller.enabled = false; + transform.position = _safePrevious + Vector3.up * 0.3f; + _controller.enabled = true; + + _airborneSince = -1f; + _announcedFalling = false; + _stuckSince = -1f; + _announcedStuck = false; + + ScreenReaderOutput.Speak("De vuelta en suelo seguro.", true); + } +} diff --git a/Assets/_Project/_Features/Accessibility/_Scripts/FallGuard.cs.meta b/Assets/_Project/_Features/Accessibility/_Scripts/FallGuard.cs.meta new file mode 100644 index 00000000..9daa3efe --- /dev/null +++ b/Assets/_Project/_Features/Accessibility/_Scripts/FallGuard.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 77b10345df75f5e4c92704dfc02a2544 \ No newline at end of file diff --git a/Assets/_Project/_Features/Accessibility/_Scripts/KeyboardLook.cs b/Assets/_Project/_Features/Accessibility/_Scripts/KeyboardLook.cs new file mode 100644 index 00000000..2eb41795 --- /dev/null +++ b/Assets/_Project/_Features/Accessibility/_Scripts/KeyboardLook.cs @@ -0,0 +1,143 @@ +using UnityEngine; +using UnityEngine.InputSystem; + +/// +/// Mouse-free camera control for blind players, who navigate by keyboard only: +/// comma/period = smooth turn (hold), Ctrl+comma/period = 45-degree snap turn (announced), +/// M = half turn (announced), N = speak current compass heading. +/// Yaw is applied additively to the player transform, which PlayerController tolerates +/// (its own look also uses transform.Rotate). Pitch is deliberately not mapped: without +/// mouse input the vertical view never drifts, so it stays level on its own. +/// Attached to the player GameObject at runtime by ProximitySonar — no prefab or asset edits. +/// Maps to gameaccessibilityguidelines.com (Motor): "Ensure that all areas of the user +/// interface can be accessed using the same input method"; compass output also supports +/// (Vision): "Ensure no essential information is conveyed by visuals alone". +/// +public class KeyboardLook : MonoBehaviour +{ + [SerializeField] private float turnSpeed = 90f; // degrees per second, persisted as A11y_TurnSpeed + [SerializeField] private float snapAngle = 45f; + + private const string TurnSpeedKey = "A11y_TurnSpeed"; + + private InputAction _turnLeft; + private InputAction _turnRight; + private InputAction _snapLeft; + private InputAction _snapRight; + private InputAction _halfTurn; + private InputAction _compass; + + private void Awake() + { + turnSpeed = PlayerPrefs.GetFloat(TurnSpeedKey, turnSpeed); + + _turnLeft = new InputAction("A11yTurnLeft", binding: "/comma"); + _turnRight = new InputAction("A11yTurnRight", binding: "/period"); + + // Shift as modifier, NOT Ctrl: this game binds Crouch to leftCtrl, and QA found that + // Ctrl+turn was silently crouching the player (which also blocks sprinting). + // Shift is Sprint (hold), which is harmless on a stationary snap turn. + _snapLeft = new InputAction("A11ySnapLeft"); + _snapLeft.AddCompositeBinding("ButtonWithOneModifier") + .With("Modifier", "/leftShift") + .With("Button", "/comma"); + + _snapRight = new InputAction("A11ySnapRight"); + _snapRight.AddCompositeBinding("ButtonWithOneModifier") + .With("Modifier", "/leftShift") + .With("Button", "/period"); + + _halfTurn = new InputAction("A11yHalfTurn", binding: "/m"); + _compass = new InputAction("A11yCompass", binding: "/n"); + _compass.AddBinding("/rightStickPress"); // R3 speaks the heading on pads + + _snapLeft.performed += _ => SnapTurn(-snapAngle); + _snapRight.performed += _ => SnapTurn(snapAngle); + _halfTurn.performed += _ => SnapTurn(180f); + _compass.performed += _ => AnnounceHeading("Mirando al "); + } + + private void OnEnable() + { + _turnLeft.Enable(); + _turnRight.Enable(); + _snapLeft.Enable(); + _snapRight.Enable(); + _halfTurn.Enable(); + _compass.Enable(); + } + + private void OnDisable() + { + _turnLeft.Disable(); + _turnRight.Disable(); + _snapLeft.Disable(); + _snapRight.Disable(); + _halfTurn.Disable(); + _compass.Disable(); + } + + private void OnDestroy() + { + _turnLeft.Dispose(); + _turnRight.Dispose(); + _snapLeft.Dispose(); + _snapRight.Dispose(); + _halfTurn.Dispose(); + _compass.Dispose(); + } + + private void Update() + { + var manager = AccessibilityManager.Instance; + if (manager == null || !manager.sonarEnabled) return; + + var gameManager = Core.GameManager; + if (gameManager != null && gameManager.gamePaused) return; + + // Shift+key is the snap gesture; don't also smooth-turn on the same press + var keyboard = Keyboard.current; + if (keyboard != null && (keyboard.leftShiftKey.isPressed || keyboard.rightShiftKey.isPressed)) return; + + float direction = 0f; + if (_turnLeft.IsPressed()) direction -= 1f; + if (_turnRight.IsPressed()) direction += 1f; + if (direction != 0f) + { + transform.Rotate(Vector3.up, direction * turnSpeed * Time.deltaTime); + } + } + + private void SnapTurn(float degrees) + { + var manager = AccessibilityManager.Instance; + if (manager == null || !manager.sonarEnabled) return; + + var gameManager = Core.GameManager; + if (gameManager != null && gameManager.gamePaused) return; + + transform.Rotate(Vector3.up, degrees); + AnnounceHeading(string.Empty); + } + + private void AnnounceHeading(string prefix) + { + ScreenReaderOutput.Speak(prefix + HeadingName(transform.eulerAngles.y) + ".", true); + } + + public void SetTurnSpeed(float degreesPerSecond) + { + turnSpeed = Mathf.Clamp(degreesPerSecond, 30f, 240f); + PlayerPrefs.SetFloat(TurnSpeedKey, turnSpeed); + PlayerPrefs.Save(); + } + + /// World yaw in degrees to an 8-way Spanish compass name (0 = north = world +Z). + public static string HeadingName(float yawDegrees) + { + string[] names = { "norte", "noreste", "este", "sudeste", "sur", "sudoeste", "oeste", "noroeste" }; + float normalized = Mathf.Repeat(yawDegrees, 360f); + int sector = Mathf.RoundToInt(normalized / 45f) % 8; + return names[sector]; + } +} diff --git a/Assets/_Project/_Features/Accessibility/_Scripts/KeyboardLook.cs.meta b/Assets/_Project/_Features/Accessibility/_Scripts/KeyboardLook.cs.meta new file mode 100644 index 00000000..af6b5ec1 --- /dev/null +++ b/Assets/_Project/_Features/Accessibility/_Scripts/KeyboardLook.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: e7a6a4ff946a0734c8f876c9bc940f61 \ No newline at end of file diff --git a/Assets/_Project/_Features/Accessibility/_Scripts/ProximitySonar.cs b/Assets/_Project/_Features/Accessibility/_Scripts/ProximitySonar.cs new file mode 100644 index 00000000..a7ab2e31 --- /dev/null +++ b/Assets/_Project/_Features/Accessibility/_Scripts/ProximitySonar.cs @@ -0,0 +1,209 @@ +using System.Collections; +using UnityEngine; +using UnityEngine.SceneManagement; + +/// +/// Automatic proximity sonar for blind / low-vision players: emits a spatialized beep from the +/// nearest interactable, parking-sensor style (faster = closer). Doors and items use distinct timbres, +/// and a double beep signals the target is within interaction range. Audio goes through FMOD Core +/// (the project disables Unity's audio engine). +/// Maps to gameaccessibilityguidelines.com (Vision): "Provide a pingable sonar-style audio map". +/// +public class ProximitySonar : MonoBehaviour +{ + [Header("Beep Timing")] + [SerializeField] private float minBeepInterval = 0.13f; // beep cadence when nearly touching the target + [SerializeField] private float maxBeepInterval = 1.1f; // cadence at the edge of the sonar radius + [SerializeField] private float closeRange = 3f; // double-beep inside this range (matches PlayerInteract's reach) + [SerializeField] private float doubleBeepGap = 0.09f; + + [Header("Timbres (fallback tones when the CC0 files are missing)")] + [SerializeField] private float doorBeepFrequency = 520f; + [SerializeField] private float doorBeepDuration = 0.09f; + [SerializeField] private float itemBeepFrequency = 1175f; + [SerializeField] private float itemBeepDuration = 0.05f; + + [Header("Occlusion")] + [SerializeField] private float eyeHeight = 1.5f; + [SerializeField] private float occlusionSkin = 0.15f; // tolerance so the wall right behind a button doesn't count as blocking + + private const float PlayerSearchInterval = 2f; + private const float IdleRescanInterval = 0.3f; + + private Player _player; + private A11yFmodAudio.A11ySound _doorBeep; + private A11yFmodAudio.A11ySound _itemBeep; + private readonly Collider[] _hits = new Collider[32]; + private int _interactableLayerMask; + private int _occlusionMask; + private float _nextBeepTime; + private float _nextPlayerSearchTime; + private Collider _announcedTarget; + private System.Func _lineOfSightPredicate; + + private void Awake() + { + _interactableLayerMask = LayerMask.GetMask("Interactable"); + if (_interactableLayerMask == 0) + { + Debug.LogWarning("[Accessibility] 'Interactable' layer not found; sonar will scan all layers.", this); + _interactableLayerMask = ~0; + } + + // Walls and level geometry block the sonar; these layers never do + _occlusionMask = ~LayerMask.GetMask("Interactable", "Player", "Ignore Raycast", "TransparentFX", "UI", "Debris"); + _lineOfSightPredicate = HasLineOfSight; + + // Bundled CC0 files (StreamingAssets/A11y, see A11Y_AUDIO_CREDITS.md); generated tones as fallback + _doorBeep = A11yFmodAudio.LoadOrGenerate("door_beep.ogg", doorBeepFrequency, doorBeepDuration); + _itemBeep = A11yFmodAudio.LoadOrGenerate("item_beep.ogg", itemBeepFrequency, itemBeepDuration); + } + + private void OnDestroy() + { + A11yFmodAudio.Release(ref _doorBeep); + A11yFmodAudio.Release(ref _itemBeep); + } + + private void OnEnable() => SceneManager.sceneLoaded += OnSceneLoaded; + private void OnDisable() => SceneManager.sceneLoaded -= OnSceneLoaded; + + private void OnSceneLoaded(Scene scene, LoadSceneMode mode) => _player = null; + + private void Update() + { + var manager = AccessibilityManager.Instance; + if (manager == null || !manager.sonarEnabled) return; + if (IsGamePaused()) return; + + if (_player == null) + { + if (Time.unscaledTime < _nextPlayerSearchTime) return; + _nextPlayerSearchTime = Time.unscaledTime + PlayerSearchInterval; + _player = Core.Player; + if (_player == null) return; // no player in this scene (e.g. main menu) + + // These need to live on the player object (controller hits, health events, blink meter, camera yaw, navigation) + if (_player.GetComponent() == null) + { + _player.gameObject.AddComponent(); + } + if (_player.GetComponent() == null) + { + _player.gameObject.AddComponent(); + } + if (_player.GetComponent() == null) + { + _player.gameObject.AddComponent(); + } + if (_player.GetComponent() == null) + { + _player.gameObject.AddComponent(); + } + if (_player.GetComponent() == null) + { + _player.gameObject.AddComponent(); + } + if (_player.GetComponent() == null) + { + _player.gameObject.AddComponent(); + } + if (_player.GetComponent() == null) + { + _player.gameObject.AddComponent(); + } + if (_player.GetComponent() == null) + { + _player.gameObject.AddComponent(); + } + if (_player.GetComponent() == null) + { + _player.gameObject.AddComponent(); + } + if (_player.GetComponent() == null) + { + _player.gameObject.AddComponent(); + } + } + + if (Time.time < _nextBeepTime) return; + + Vector3 origin = _player.transform.position; + int count = Physics.OverlapSphereNonAlloc(origin, manager.sonarRadius, _hits, _interactableLayerMask); + + if (!SonarLogic.TryChooseTarget(origin, _hits, count, out SonarTargetKind kind, out Collider chosen, out Vector3 targetPosition, out float distance, _lineOfSightPredicate)) + { + _nextBeepTime = Time.time + IdleRescanInterval; + return; + } + + // Continuous parking-sensor beeping is opt-in (Shift+F6); the in-reach + // announcement stays on in both modes because it is event-like, not a drone + if (manager.sonarContinuous) + { + EmitBeep(kind, targetPosition, distance, manager); + } + AnnounceIfNewCloseTarget(kind, chosen, distance); + _nextBeepTime = Time.time + SonarLogic.IntervalForDistance(distance, manager.sonarRadius, minBeepInterval, maxBeepInterval); + } + + private static bool IsGamePaused() + { + var gameManager = Core.GameManager; + if (gameManager != null) return gameManager.gamePaused; + return Time.timeScale == 0f; + } + + // Shared with TargetNavigator so cycling sees exactly what the sonar sees + public int ScanInteractables(Vector3 origin, float radius, Collider[] results) + { + return Physics.OverlapSphereNonAlloc(origin, radius, results, _interactableLayerMask); + } + + public bool HasLineOfSight(Collider target) + { + if (_player == null) return false; + Vector3 eye = _player.transform.position + Vector3.up * eyeHeight; + Vector3 delta = target.bounds.center - eye; + float length = delta.magnitude - occlusionSkin; + if (length <= 0f) return true; + + return !Physics.Raycast(eye, delta.normalized, length, _occlusionMask, QueryTriggerInteraction.Ignore); + } + + // First NVDA use case: announce a target once when it enters interaction range + private void AnnounceIfNewCloseTarget(SonarTargetKind kind, Collider chosen, float distance) + { + if (distance > closeRange) + { + if (_announcedTarget != null && chosen != _announcedTarget) _announcedTarget = null; + return; + } + + if (chosen == _announcedTarget) return; + _announcedTarget = chosen; + + string text = kind == SonarTargetKind.Door + ? "Puerta al alcance." + : $"Objeto al alcance: {A11yAltText.HumanizeName(chosen.name)}."; + ScreenReaderOutput.Speak(text); + } + + private void EmitBeep(SonarTargetKind kind, Vector3 position, float distance, AccessibilityManager manager) + { + var sound = kind == SonarTargetKind.Door ? _doorBeep : _itemBeep; + A11yFmodAudio.PlayAt(sound, position, manager.sonarVolume); + + // Double beep = "you are close enough to interact" + if (distance <= closeRange) + { + StartCoroutine(PlaySecondBeep(sound, position, manager.sonarVolume)); + } + } + + private IEnumerator PlaySecondBeep(A11yFmodAudio.A11ySound sound, Vector3 position, float volume) + { + yield return new WaitForSeconds(doubleBeepGap); + A11yFmodAudio.PlayAt(sound, position, volume); + } +} diff --git a/Assets/_Project/_Features/Accessibility/_Scripts/ProximitySonar.cs.meta b/Assets/_Project/_Features/Accessibility/_Scripts/ProximitySonar.cs.meta new file mode 100644 index 00000000..7d7ce56a --- /dev/null +++ b/Assets/_Project/_Features/Accessibility/_Scripts/ProximitySonar.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 97bf0e2c67dbca845b4f88d8fb9621be \ No newline at end of file diff --git a/Assets/_Project/_Features/Accessibility/_Scripts/RoomTracker.cs b/Assets/_Project/_Features/Accessibility/_Scripts/RoomTracker.cs new file mode 100644 index 00000000..a926990a --- /dev/null +++ b/Assets/_Project/_Features/Accessibility/_Scripts/RoomTracker.cs @@ -0,0 +1,94 @@ +using Facility.Generation; +using UnityEngine; + +/// +/// Announces where you are when you enter a new area — the feature our blind QA +/// missed most in The Last of Us ("it never tells you which room you walked into"). +/// In generated facilities this uses the game's own RoomInstance bounds (room name + +/// zone from RoomData); in hand-built test scenes it falls back to raycasting the +/// floor and climbing to the zone container. Descriptions come from the alt-text +/// registry ("room:{name}" keys) with a humanized name as fallback. +/// Attached to the player GameObject at runtime by ProximitySonar — no prefab edits. +/// Maps to gameaccessibilityguidelines.com (Vision): "Ensure no essential information +/// is conveyed by visuals alone". +/// +public class RoomTracker : MonoBehaviour +{ + private const float PollInterval = 1f; + private const float RoomListRefreshInterval = 10f; + + private int _floorMask; + private float _nextPoll; + private float _nextRoomListRefresh; + private RoomInstance[] _rooms = new RoomInstance[0]; + private string _currentArea; + + private void Awake() + { + _floorMask = ~LayerMask.GetMask("Interactable", "Player", "Ignore Raycast", "TransparentFX", "UI", "Debris"); + } + + private void Update() + { + var manager = AccessibilityManager.Instance; + if (manager == null || !manager.sonarEnabled) return; + var gameManager = Core.GameManager; + if (gameManager != null && gameManager.gamePaused) return; + if (Time.unscaledTime < _nextPoll) return; + _nextPoll = Time.unscaledTime + PollInterval; + + string area = ResolveFromRoomInstances() ?? ResolveFromHierarchy(); + if (string.IsNullOrEmpty(area) || area == _currentArea) return; + _currentArea = area; + + string description = A11yAltText.TryGet("room:" + area, out string alt) + ? alt + : A11yAltText.HumanizeName(area); + ScreenReaderOutput.Speak($"Entraste a: {description}."); + } + + // Generated facility: the game's own room system knows exactly where we are + private string ResolveFromRoomInstances() + { + if (Time.unscaledTime >= _nextRoomListRefresh) + { + _nextRoomListRefresh = Time.unscaledTime + RoomListRefreshInterval; + _rooms = FindObjectsByType(FindObjectsSortMode.None); + } + + for (int i = 0; i < _rooms.Length; i++) + { + var room = _rooms[i]; + if (room == null || room.Bounds == null) continue; + if (!room.Bounds.bounds.Contains(transform.position)) continue; + + string roomName = room.RoomData != null ? room.RoomData.RoomName : room.gameObject.name; + return $"{room.Zone}/{roomName}"; + } + + return null; + } + + // Hand-built test scenes: climb from the floor we stand on to the zone container + private string ResolveFromHierarchy() + { + if (!Physics.Raycast(transform.position + Vector3.up * 0.3f, Vector3.down, out RaycastHit hit, 5f, _floorMask, QueryTriggerInteraction.Ignore)) + { + return null; + } + + Transform node = hit.transform; + Transform room = node; + while (node != null) + { + if (node.name.EndsWith("Zone")) + { + return room != node ? $"{node.name}/{room.name}" : node.name; + } + room = node; + node = node.parent; + } + + return null; + } +} diff --git a/Assets/_Project/_Features/Accessibility/_Scripts/RoomTracker.cs.meta b/Assets/_Project/_Features/Accessibility/_Scripts/RoomTracker.cs.meta new file mode 100644 index 00000000..aee133e7 --- /dev/null +++ b/Assets/_Project/_Features/Accessibility/_Scripts/RoomTracker.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 0fc7a8b7f212f2c408a2ad2d29fb477c \ No newline at end of file diff --git a/Assets/_Project/_Features/Accessibility/_Scripts/ScanPulse.cs b/Assets/_Project/_Features/Accessibility/_Scripts/ScanPulse.cs new file mode 100644 index 00000000..de024aa2 --- /dev/null +++ b/Assets/_Project/_Features/Accessibility/_Scripts/ScanPulse.cs @@ -0,0 +1,98 @@ +using System.Collections; +using UnityEngine; +using UnityEngine.InputSystem; + +/// +/// On-demand area scan, modeled on The Last of Us Part II's Enhanced Listen Mode +/// (preferred over a continuous sonar by our blind QA: "I like firing it when I +/// actually need it"). Press B (gamepad: L3) to ping every reachable interactable in +/// range, nearest first: each target's own semantic sound plays AT its position, with +/// the pitch encoding relative height (higher = above you). Ends with a spoken count. +/// Attached to the player GameObject at runtime by ProximitySonar — no prefab edits. +/// Maps to gameaccessibilityguidelines.com (Vision): "Provide a pingable sonar-style +/// audio map" — this is the literal "pingable" half. +/// +public class ScanPulse : MonoBehaviour +{ + [SerializeField] private float pingGap = 0.22f; + [SerializeField] private int maxTargets = 8; + [SerializeField] private float cooldown = 1f; + + private ProximitySonar _sonar; + private A11yFmodAudio.A11ySound _doorSound; + private A11yFmodAudio.A11ySound _itemSound; + private readonly Collider[] _hits = new Collider[32]; + private float _lastScanTime = -10f; + private InputAction _scanAction; + + private void Awake() + { + _doorSound = A11yFmodAudio.LoadOrGenerate("door_beep.ogg", 520f, 0.09f); + _itemSound = A11yFmodAudio.LoadOrGenerate("item_beep.ogg", 1175f, 0.05f); + + _scanAction = new InputAction("A11yScan", binding: "/b"); + _scanAction.AddBinding("/leftStickPress"); + _scanAction.performed += _ => TriggerScan(); + } + + private void OnEnable() => _scanAction.Enable(); + private void OnDisable() => _scanAction.Disable(); + + private void OnDestroy() + { + _scanAction.Dispose(); + A11yFmodAudio.Release(ref _doorSound); + A11yFmodAudio.Release(ref _itemSound); + } + + private void TriggerScan() + { + var manager = AccessibilityManager.Instance; + if (manager == null || !manager.sonarEnabled) return; + if (Time.timeScale == 0f) return; + if (Time.unscaledTime - _lastScanTime < cooldown) return; + if (_sonar == null) + { + _sonar = manager.GetComponent(); + if (_sonar == null) return; + } + + _lastScanTime = Time.unscaledTime; + StopAllCoroutines(); + StartCoroutine(RunScan(manager)); + } + + private IEnumerator RunScan(AccessibilityManager manager) + { + int count = _sonar.ScanInteractables(transform.position, manager.sonarRadius, _hits); + + // Collect visible interactables, nearest first + var targets = new System.Collections.Generic.List(); + for (int i = 0; i < count; i++) + { + var hit = _hits[i]; + if (hit == null || !hit.TryGetComponent(out IInteractable _)) continue; + if (!_sonar.HasLineOfSight(hit)) continue; + targets.Add(hit); + } + Vector3 origin = transform.position; + targets.Sort((a, b) => + (a.transform.position - origin).sqrMagnitude.CompareTo((b.transform.position - origin).sqrMagnitude)); + + int announced = Mathf.Min(targets.Count, maxTargets); + for (int i = 0; i < announced; i++) + { + var target = targets[i]; + if (target == null) continue; + target.TryGetComponent(out IInteractable interactable); + var sound = SonarLogic.IsDoor(interactable) ? _doorSound : _itemSound; + float pitch = SonarLogic.PitchForHeightDelta(target.transform.position.y - transform.position.y); + A11yFmodAudio.PlayAt(sound, target.transform.position, manager.sonarVolume, pitch); + yield return new WaitForSeconds(pingGap); + } + + ScreenReaderOutput.Speak(announced == 0 + ? "Sin objetivos." + : announced == 1 ? "1 objetivo." : $"{announced} objetivos."); + } +} diff --git a/Assets/_Project/_Features/Accessibility/_Scripts/ScanPulse.cs.meta b/Assets/_Project/_Features/Accessibility/_Scripts/ScanPulse.cs.meta new file mode 100644 index 00000000..9e1fdd12 --- /dev/null +++ b/Assets/_Project/_Features/Accessibility/_Scripts/ScanPulse.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: da794cf68428a2e4cbdd7941f8d7dd0b \ No newline at end of file diff --git a/Assets/_Project/_Features/Accessibility/_Scripts/ScreenReaderOutput.cs b/Assets/_Project/_Features/Accessibility/_Scripts/ScreenReaderOutput.cs new file mode 100644 index 00000000..367b467e --- /dev/null +++ b/Assets/_Project/_Features/Accessibility/_Scripts/ScreenReaderOutput.cs @@ -0,0 +1,70 @@ +using System; +using System.Runtime.InteropServices; +using UnityEngine; + +/// +/// Minimal bridge to the official NVDA Controller Client (LGPL 2.1, NV Access). +/// The DLL lives in Assets/_Scripts/Accessibility/Plugins/x86_64/ and keeps its own license. +/// All failures degrade silently: no NVDA (or no DLL) simply means no speech output. +/// Signatures follow NV Access's official C# example (extras/controllerClient/examples). +/// +public static class ScreenReaderOutput +{ + private static bool _available; + private static bool _checked; + + public static bool IsAvailable + { + get + { + if (!_checked) CheckAvailability(); + return _available; + } + } + + public static void Speak(string text, bool interrupt = false) + { + if (string.IsNullOrEmpty(text) || !IsAvailable) return; + + try + { + if (interrupt) nvdaController_cancelSpeech(); + nvdaController_speakText(text); + } + catch (Exception e) + { + Debug.LogWarning($"[Accessibility] NVDA speech failed: {e.Message}"); + _available = false; + } + } + + private static void CheckAvailability() + { + _checked = true; + try + { + // Returns 0 when NVDA is running and reachable + _available = nvdaController_testIfRunning() == 0; + if (!_available) Debug.Log("[Accessibility] NVDA is not running; screen reader output disabled."); + } + catch (DllNotFoundException) + { + _available = false; + Debug.LogWarning("[Accessibility] nvdaControllerClient.dll not found; screen reader output disabled."); + } + catch (Exception e) + { + _available = false; + Debug.LogWarning($"[Accessibility] NVDA availability check failed: {e.Message}"); + } + } + + [DllImport("nvdaControllerClient", CharSet = CharSet.Unicode)] + private static extern int nvdaController_speakText(string text); + + [DllImport("nvdaControllerClient")] + private static extern int nvdaController_cancelSpeech(); + + [DllImport("nvdaControllerClient")] + private static extern int nvdaController_testIfRunning(); +} diff --git a/Assets/_Project/_Features/Accessibility/_Scripts/ScreenReaderOutput.cs.meta b/Assets/_Project/_Features/Accessibility/_Scripts/ScreenReaderOutput.cs.meta new file mode 100644 index 00000000..85c3941f --- /dev/null +++ b/Assets/_Project/_Features/Accessibility/_Scripts/ScreenReaderOutput.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 8393d2e3aed04d84c93ea3401276d32e \ No newline at end of file diff --git a/Assets/_Project/_Features/Accessibility/_Scripts/SonarAudio.cs b/Assets/_Project/_Features/Accessibility/_Scripts/SonarAudio.cs new file mode 100644 index 00000000..d469d8dc --- /dev/null +++ b/Assets/_Project/_Features/Accessibility/_Scripts/SonarAudio.cs @@ -0,0 +1,37 @@ +using UnityEngine; + +/// +/// Procedural tone generation for the accessibility layer. Pure math, verified in +/// batchmode; A11yFmodAudio turns these samples into playable FMOD sounds at runtime. +/// +public static class SonarAudio +{ + public static float[] GenerateBeepSamples(float frequency, float duration, int sampleRate = 44100) + { + int sampleCount = Mathf.Max(2, (int)(duration * sampleRate)); + float[] samples = new float[sampleCount]; + + for (int i = 0; i < sampleCount; i++) + { + float time = (float)i / sampleRate; + // Half-sine envelope: smooth attack and release, avoids clicks at the clip edges + float envelope = Mathf.Sin(Mathf.PI * i / (sampleCount - 1f)); + samples[i] = Mathf.Sin(2f * Mathf.PI * frequency * time) * envelope * 0.9f; + } + + return samples; + } + + /// Float samples [-1,1] to little-endian PCM16 bytes (FMOD OPENRAW format). + public static byte[] SamplesToPcm16(float[] samples) + { + byte[] bytes = new byte[samples.Length * 2]; + for (int i = 0; i < samples.Length; i++) + { + short value = (short)(Mathf.Clamp(samples[i], -1f, 1f) * short.MaxValue); + bytes[i * 2] = (byte)(value & 0xFF); + bytes[i * 2 + 1] = (byte)((value >> 8) & 0xFF); + } + return bytes; + } +} diff --git a/Assets/_Project/_Features/Accessibility/_Scripts/SonarAudio.cs.meta b/Assets/_Project/_Features/Accessibility/_Scripts/SonarAudio.cs.meta new file mode 100644 index 00000000..c6021902 --- /dev/null +++ b/Assets/_Project/_Features/Accessibility/_Scripts/SonarAudio.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: d65bc3615ed1c004b81d32f530c62dd9 \ No newline at end of file diff --git a/Assets/_Project/_Features/Accessibility/_Scripts/SonarLogic.cs b/Assets/_Project/_Features/Accessibility/_Scripts/SonarLogic.cs new file mode 100644 index 00000000..f50e4d3a --- /dev/null +++ b/Assets/_Project/_Features/Accessibility/_Scripts/SonarLogic.cs @@ -0,0 +1,89 @@ +using UnityEngine; + +public enum SonarTargetKind +{ + None, + Door, + Item +} + +/// +/// Pure decision logic for the accessibility proximity sonar. +/// Kept static and side-effect free so it can be verified in batchmode without entering play mode. +/// +public static class SonarLogic +{ + public static bool TryChooseTarget(Vector3 origin, Collider[] hits, int count, out SonarTargetKind kind, out Collider chosen, out Vector3 position, out float distance, System.Func isReachable = null) + { + kind = SonarTargetKind.None; + chosen = null; + position = Vector3.zero; + distance = float.PositiveInfinity; + + float closestSqr = float.PositiveInfinity; + for (int i = 0; i < count; i++) + { + var hit = hits[i]; + if (hit == null) continue; + if (!hit.TryGetComponent(out IInteractable interactable)) continue; // same detection rule as PlayerInteract + + float sqr = (origin - hit.transform.position).sqrMagnitude; + if (sqr >= closestSqr) continue; + if (isReachable != null && !isReachable(hit)) continue; // e.g. occluded by a wall (checked last: raycasts cost more than distance math) + + closestSqr = sqr; + chosen = hit; + position = hit.transform.position; + kind = IsDoor(interactable) ? SonarTargetKind.Door : SonarTargetKind.Item; + } + + if (kind == SonarTargetKind.None) return false; + distance = Mathf.Sqrt(closestSqr); + return true; + } + + // All door/gate activators inherit BaseDoorActivator on dev; everything else counts as an item + public static bool IsDoor(IInteractable interactable) + { + return interactable is BaseDoorActivator; + } + + public static float IntervalForDistance(float distance, float radius, float minInterval, float maxInterval) + { + float t = Mathf.Clamp01(distance / Mathf.Max(radius, 0.01f)); + return Mathf.Lerp(minInterval, maxInterval, t); + } + + /// + /// TLOU-style height encoding for scan pings: targets above the player sound + /// higher-pitched, below sound lower. Level ground = pitch 1. + /// + public static float PitchForHeightDelta(float heightDelta) + { + return Mathf.Clamp(1f + Mathf.Clamp(heightDelta * 0.12f, -0.4f, 0.6f), 0.6f, 1.6f); + } + + /// + /// A wall bump only counts when the surface is roughly vertical and the player is + /// pushing mostly straight into it (sliding along a wall stays silent). + /// + public static bool ShouldBumpFeedback(Vector3 moveDirection, Vector3 surfaceNormal, float maxNormalY = 0.35f, float minFacingDot = 0.6f) + { + if (Mathf.Abs(surfaceNormal.y) > maxNormalY) return false; // floor or ceiling, not a wall + + Vector3 flatMove = new Vector3(moveDirection.x, 0f, moveDirection.z); + Vector3 flatNormal = new Vector3(surfaceNormal.x, 0f, surfaceNormal.z); + if (flatMove.sqrMagnitude < 0.0001f || flatNormal.sqrMagnitude < 0.0001f) return false; + + return Vector3.Dot(flatMove.normalized, -flatNormal.normalized) >= minFacingDot; + } + + /// + /// Blink warning fires once per blink cycle, only while eyes are open and the + /// blink meter has drained past the warning threshold. + /// + public static bool ShouldWarnBlink(bool armed, bool isBlinking, float currentBlink, float warnThreshold) + { + return armed && !isBlinking && currentBlink <= warnThreshold; + } +} diff --git a/Assets/_Project/_Features/Accessibility/_Scripts/SonarLogic.cs.meta b/Assets/_Project/_Features/Accessibility/_Scripts/SonarLogic.cs.meta new file mode 100644 index 00000000..96fd30bc --- /dev/null +++ b/Assets/_Project/_Features/Accessibility/_Scripts/SonarLogic.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: cecdde791de456245bdbaeb2a8f8cfde \ No newline at end of file diff --git a/Assets/_Project/_Features/Accessibility/_Scripts/SoundGlossary.cs b/Assets/_Project/_Features/Accessibility/_Scripts/SoundGlossary.cs new file mode 100644 index 00000000..0bdf4a34 --- /dev/null +++ b/Assets/_Project/_Features/Accessibility/_Scripts/SoundGlossary.cs @@ -0,0 +1,75 @@ +using UnityEngine; +using UnityEngine.InputSystem; + +/// +/// Browsable audio-cue glossary, modeled on The Last of Us Part II's: while the game +/// is paused, press G to hear each accessibility sound followed by its spoken meaning — +/// learn the vocabulary before you need it in a panic. Cycles on repeated presses. +/// Pause-only by QA design decision: the glossary is a reference you consult calmly, +/// not a gameplay key (additive stand-in for a proper pause-menu entry). +/// Lives on the accessibility manager object (works in any scene). +/// +public class SoundGlossary : MonoBehaviour +{ + private struct Entry + { + public string fileName; + public float fallbackFrequency; + public float fallbackDuration; + public string meaning; + } + + private static readonly Entry[] Entries = + { + new Entry { fileName = "door_beep.ogg", fallbackFrequency = 520f, fallbackDuration = 0.09f, meaning = "Una puerta." }, + new Entry { fileName = "item_beep.ogg", fallbackFrequency = 1175f, fallbackDuration = 0.05f, meaning = "Un objeto agarrable." }, + new Entry { fileName = "wall_bump_000.ogg", fallbackFrequency = 90f, fallbackDuration = 0.06f, meaning = "Choque contra una pared." }, + new Entry { fileName = "blink_warning.ogg", fallbackFrequency = 880f, fallbackDuration = 0.045f, meaning = "Parpadeo inminente." }, + }; + + private A11yFmodAudio.A11ySound[] _sounds; + private int _index = -1; + private InputAction _glossaryAction; + + private void Awake() + { + _sounds = new A11yFmodAudio.A11ySound[Entries.Length]; + for (int i = 0; i < Entries.Length; i++) + { + _sounds[i] = A11yFmodAudio.LoadOrGenerate(Entries[i].fileName, Entries[i].fallbackFrequency, Entries[i].fallbackDuration); + } + + _glossaryAction = new InputAction("A11yGlossary", binding: "/g"); + _glossaryAction.performed += _ => Next(); + } + + private void OnEnable() => _glossaryAction.Enable(); + private void OnDisable() => _glossaryAction.Disable(); + + private void OnDestroy() + { + _glossaryAction.Dispose(); + for (int i = 0; i < _sounds.Length; i++) + { + A11yFmodAudio.Release(ref _sounds[i]); + } + } + + private void Next() + { + var manager = AccessibilityManager.Instance; + if (manager == null || !manager.sonarEnabled) return; + + var gameManager = Core.GameManager; + bool paused = gameManager != null ? gameManager.gamePaused : Time.timeScale == 0f; + if (!paused) + { + ScreenReaderOutput.Speak("El glosario se abre con el juego en pausa.", true); + return; + } + + _index = (_index + 1) % Entries.Length; + A11yFmodAudio.Play2D(_sounds[_index], manager.sonarVolume); + ScreenReaderOutput.Speak($"{Entries[_index].meaning} {_index + 1} de {Entries.Length}.", true); + } +} diff --git a/Assets/_Project/_Features/Accessibility/_Scripts/SoundGlossary.cs.meta b/Assets/_Project/_Features/Accessibility/_Scripts/SoundGlossary.cs.meta new file mode 100644 index 00000000..0af5de9c --- /dev/null +++ b/Assets/_Project/_Features/Accessibility/_Scripts/SoundGlossary.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: f554ebc07b8cf634d9aa7e96705424b5 \ No newline at end of file diff --git a/Assets/_Project/_Features/Accessibility/_Scripts/StanceAnnouncer.cs b/Assets/_Project/_Features/Accessibility/_Scripts/StanceAnnouncer.cs new file mode 100644 index 00000000..4625ce0a --- /dev/null +++ b/Assets/_Project/_Features/Accessibility/_Scripts/StanceAnnouncer.cs @@ -0,0 +1,34 @@ +using UnityEngine; + +/// +/// Announces crouch state changes through the screen reader. Born from QA: the player got +/// crouched without knowing it (crouch is silent, only shown visually) and then "sprint +/// didn't work" — crouching caps speed and blocks sprinting in this game. +/// Attached to the player GameObject at runtime by ProximitySonar — no prefab edits. +/// Maps to gameaccessibilityguidelines.com (Vision): "Ensure no essential information is +/// conveyed by visuals alone". +/// +public class StanceAnnouncer : MonoBehaviour +{ + private Player _player; + private bool _wasCrouching; + + private void Awake() + { + _player = GetComponent(); + _wasCrouching = _player != null && _player.IsInState(PlayerState.Crouching); + } + + private void Update() + { + var manager = AccessibilityManager.Instance; + if (manager == null || !manager.sonarEnabled || _player == null) return; + + bool crouching = _player.IsInState(PlayerState.Crouching); + if (crouching != _wasCrouching) + { + _wasCrouching = crouching; + ScreenReaderOutput.Speak(_wasCrouching ? "Agachado. Control vuelve a ponerte de pie." : "De pie."); + } + } +} diff --git a/Assets/_Project/_Features/Accessibility/_Scripts/StanceAnnouncer.cs.meta b/Assets/_Project/_Features/Accessibility/_Scripts/StanceAnnouncer.cs.meta new file mode 100644 index 00000000..6c054110 --- /dev/null +++ b/Assets/_Project/_Features/Accessibility/_Scripts/StanceAnnouncer.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 0ba76cf0abe1d2341b115a6336c50cf6 \ No newline at end of file diff --git a/Assets/_Project/_Features/Accessibility/_Scripts/StatusReporter.cs b/Assets/_Project/_Features/Accessibility/_Scripts/StatusReporter.cs new file mode 100644 index 00000000..e6aec28c --- /dev/null +++ b/Assets/_Project/_Features/Accessibility/_Scripts/StatusReporter.cs @@ -0,0 +1,83 @@ +using UnityEngine; +using UnityEngine.InputSystem; + +/// +/// On-demand status narration (TLOU's touchpad-swipe equivalent): press X to hear +/// health, stance, compass heading and the nearest reachable target, in that order — +/// e.g. "Sano. De pie. Mirando al norte. Puerta, 4 metros, adelante." +/// Attached to the player GameObject at runtime by ProximitySonar — no prefab edits. +/// Maps to gameaccessibilityguidelines.com (Vision): "Ensure no essential information +/// is conveyed by visuals alone". +/// +public class StatusReporter : MonoBehaviour +{ + private PlayerHealth _health; + private Player _player; + private ProximitySonar _sonar; + private readonly Collider[] _hits = new Collider[32]; + private string _healthText = "Sano"; + private InputAction _statusAction; + + private void Awake() + { + _health = GetComponent(); + _player = GetComponent(); + + _statusAction = new InputAction("A11yStatus", binding: "/h"); // H for health, which reads first (QA preference) + _statusAction.performed += _ => SpeakStatus(); + } + + private void OnEnable() + { + _statusAction.Enable(); + if (_health != null) _health.OnHealthLevelChanged += OnHealthLevelChanged; + } + + private void OnDisable() + { + _statusAction.Disable(); + if (_health != null) _health.OnHealthLevelChanged -= OnHealthLevelChanged; + } + + private void OnDestroy() => _statusAction.Dispose(); + + private void OnHealthLevelChanged(PlayerHealth.HealthLevel level) + { + // Reuse the announcer's wording, trimmed to a noun-ish phrase for status reads + _healthText = VitalStatusAnnouncer.HealthLevelText(level).TrimEnd('.'); + } + + private void SpeakStatus() + { + var manager = AccessibilityManager.Instance; + if (manager == null || !manager.sonarEnabled) return; + var gameManager = Core.GameManager; + if (gameManager != null && gameManager.gamePaused) return; + + string stance = _player != null && _player.IsInState(PlayerState.Crouching) ? "Agachado" : "De pie"; + string heading = "Mirando al " + KeyboardLook.HeadingName(transform.eulerAngles.y); + string target = DescribeNearestTarget(manager); + + ScreenReaderOutput.Speak($"{_healthText}. {stance}. {heading}. {target}", true); + } + + private string DescribeNearestTarget(AccessibilityManager manager) + { + if (_sonar == null) + { + _sonar = manager.GetComponent(); + if (_sonar == null) return "Sin objetivos cerca."; + } + + int count = _sonar.ScanInteractables(transform.position, manager.sonarRadius, _hits); + if (!SonarLogic.TryChooseTarget(transform.position, _hits, count, out SonarTargetKind kind, out Collider chosen, out _, out float distance, _sonar.HasLineOfSight)) + { + return "Sin objetivos cerca."; + } + + string label = kind == SonarTargetKind.Door ? "Puerta" : A11yAltText.HumanizeName(chosen.name); + int meters = Mathf.Max(1, Mathf.RoundToInt(distance)); + string direction = TargetNavigator.RelativeDirectionName(transform.forward, chosen.transform.position - transform.position); + return $"{label}, {meters} {(meters == 1 ? "metro" : "metros")}, {direction}."; + } +} diff --git a/Assets/_Project/_Features/Accessibility/_Scripts/StatusReporter.cs.meta b/Assets/_Project/_Features/Accessibility/_Scripts/StatusReporter.cs.meta new file mode 100644 index 00000000..e0de00cc --- /dev/null +++ b/Assets/_Project/_Features/Accessibility/_Scripts/StatusReporter.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 3b912b8f95df64544abdb9f90c47fa5f \ No newline at end of file diff --git a/Assets/_Project/_Features/Accessibility/_Scripts/TargetNavigator.cs b/Assets/_Project/_Features/Accessibility/_Scripts/TargetNavigator.cs new file mode 100644 index 00000000..a2a1cd83 --- /dev/null +++ b/Assets/_Project/_Features/Accessibility/_Scripts/TargetNavigator.cs @@ -0,0 +1,269 @@ +using System.Collections.Generic; +using UnityEngine; +using UnityEngine.InputSystem; + +/// +/// KOTOR-style object navigation for blind players (suggested by María Pía from the +/// KOTOR accessibility mod): Q cycles through reachable interactables (announcing type, +/// distance and relative direction), Shift+Q cycles backwards, Enter auto-walks to the +/// selected target and interacts with it. Any manual movement cancels the auto-walk. +/// Attached to the player GameObject at runtime by ProximitySonar — no prefab edits. +/// Maps to gameaccessibilityguidelines.com (Vision): "Provide a pingable sonar-style +/// audio map" and (Motor): "Include assist modes such as auto-aim and assisted steering". +/// +public class TargetNavigator : MonoBehaviour +{ + [SerializeField] private float walkSpeed = 4.5f; // just under the game's own walk speed (5) + [SerializeField] private float sprintSpeed = 8f; // used while the game's sprint key (Shift) is held + [SerializeField] private float turnSpeedWhileWalking = 240f; // deg/s facing the target + [SerializeField] private float arriveDistance = 2.5f; // just inside interaction range + [SerializeField] private float stuckTimeout = 1.2f; // no progress for this long = blocked + + private Player _player; + private ProximitySonar _sonar; + private CharacterController _controller; + + private readonly Collider[] _hits = new Collider[32]; + private readonly List _targets = new List(); + private int _cycleIndex = -1; + private Collider _selected; + + private bool _autoWalking; + private float _lastProgressTime; + private float _lastDistance; + + private InputAction _cycleForward; + private InputAction _cycleBackward; + private InputAction _goAction; + + private void Awake() + { + _player = GetComponent(); + _controller = GetComponent(); + + // KOTOR-style pair requested by QA: Q = previous, E = next. Both keys are free in + // this project (Interact is not keyboard-bound; inventory uses its own key). + _cycleForward = new InputAction("A11yCycleTarget", binding: "/e"); + _cycleForward.AddBinding("/dpad/right"); + + _cycleBackward = new InputAction("A11yCycleTargetBack", binding: "/q"); + _cycleBackward.AddBinding("/dpad/left"); + + _goAction = new InputAction("A11yGoToTarget", binding: "/enter"); + _goAction.AddBinding("/numpadEnter"); + _goAction.AddBinding("/dpad/down"); + + _cycleForward.performed += _ => Cycle(1); + _cycleBackward.performed += _ => Cycle(-1); + _goAction.performed += _ => StartAutoWalk(); + } + + private void OnEnable() + { + _cycleForward.Enable(); + _cycleBackward.Enable(); + _goAction.Enable(); + } + + private void OnDisable() + { + _cycleForward.Disable(); + _cycleBackward.Disable(); + _goAction.Disable(); + _autoWalking = false; + } + + private void OnDestroy() + { + _cycleForward.Dispose(); + _cycleBackward.Dispose(); + _goAction.Dispose(); + } + + private static bool IsGamePaused() + { + var gameManager = Core.GameManager; + if (gameManager != null) return gameManager.gamePaused; + return Time.timeScale == 0f; + } + + private bool IsActive() + { + var manager = AccessibilityManager.Instance; + return manager != null && manager.sonarEnabled && !IsGamePaused(); + } + + private void Cycle(int step) + { + if (!IsActive()) return; + + RefreshTargets(); + if (_targets.Count == 0) + { + ScreenReaderOutput.Speak("No hay objetivos al alcance.", true); + _selected = null; + _cycleIndex = -1; + return; + } + + _cycleIndex = ((_cycleIndex + step) % _targets.Count + _targets.Count) % _targets.Count; + _selected = _targets[_cycleIndex]; + AnnounceTarget(_selected, _cycleIndex + 1, _targets.Count); + } + + private void RefreshTargets() + { + _targets.Clear(); + var manager = AccessibilityManager.Instance; + if (manager == null || _sonar == null && (_sonar = GetSonar()) == null) return; + + int count = _sonar.ScanInteractables(transform.position, manager.sonarRadius, _hits); + for (int i = 0; i < count; i++) + { + var hit = _hits[i]; + if (hit == null || !hit.TryGetComponent(out IInteractable _)) continue; + if (!_sonar.HasLineOfSight(hit)) continue; + _targets.Add(hit); + } + + // Nearest first, so Q starts with the most relevant target + Vector3 origin = transform.position; + _targets.Sort((a, b) => + (a.transform.position - origin).sqrMagnitude.CompareTo((b.transform.position - origin).sqrMagnitude)); + + if (_cycleIndex >= _targets.Count) _cycleIndex = -1; + } + + private static ProximitySonar GetSonar() + { + return AccessibilityManager.Instance != null + ? AccessibilityManager.Instance.GetComponent() + : null; + } + + private void AnnounceTarget(Collider target, int position, int total) + { + target.TryGetComponent(out IInteractable interactable); + string label = SonarLogic.IsDoor(interactable) ? "Puerta" : A11yAltText.HumanizeName(target.name); + + Vector3 toTarget = target.transform.position - transform.position; + int meters = Mathf.Max(1, Mathf.RoundToInt(toTarget.magnitude)); + string direction = RelativeDirectionName(transform.forward, toTarget); + + ScreenReaderOutput.Speak($"{label}, {meters} {(meters == 1 ? "metro" : "metros")}, {direction}. {position} de {total}.", true); + } + + private void StartAutoWalk() + { + if (!IsActive()) return; + + if (_selected == null) + { + // Enter with nothing selected: pick the nearest target first + RefreshTargets(); + if (_targets.Count == 0) + { + ScreenReaderOutput.Speak("No hay objetivos al alcance.", true); + return; + } + _cycleIndex = 0; + _selected = _targets[0]; + } + + // Already within reach: act like the KOTOR default action and interact right away + Vector3 toSelected = _selected.transform.position - transform.position; + Vector3 flatToSelected = new Vector3(toSelected.x, 0f, toSelected.z); + if (flatToSelected.magnitude <= arriveDistance) + { + FaceTarget(flatToSelected); + if (_selected.TryGetComponent(out IInteractable interactable)) + { + interactable.Interact(); + } + return; + } + + _autoWalking = true; + _lastProgressTime = Time.time; + _lastDistance = float.MaxValue; + ScreenReaderOutput.Speak("Caminando.", true); + } + + private void Update() + { + if (!_autoWalking) return; + if (!IsActive() || _selected == null || _controller == null) + { + _autoWalking = false; + return; + } + + // Manual movement cancels the assist, the player is always in charge + if (_player != null && _player.Inputs != null && _player.Inputs.MoveInput.sqrMagnitude > 0.04f) + { + _autoWalking = false; + ScreenReaderOutput.Speak("Caminata cancelada.", true); + return; + } + + Vector3 toTarget = _selected.transform.position - transform.position; + Vector3 flat = new Vector3(toTarget.x, 0f, toTarget.z); + float distance = flat.magnitude; + + if (distance <= arriveDistance) + { + _autoWalking = false; + FaceTarget(flat); + if (_selected.TryGetComponent(out IInteractable interactable)) + { + interactable.Interact(); + } + return; + } + + // Watchdog: if we stopped making progress, something is in the way + if (distance < _lastDistance - 0.05f) + { + _lastDistance = distance; + _lastProgressTime = Time.time; + } + else if (Time.time - _lastProgressTime > stuckTimeout) + { + _autoWalking = false; + ScreenReaderOutput.Speak("Camino bloqueado.", true); + return; + } + + // Turn toward the target while walking so audio and movement stay aligned + float signedAngle = Vector3.SignedAngle(transform.forward, flat, Vector3.up); + float maxTurn = turnSpeedWhileWalking * Time.deltaTime; + transform.Rotate(Vector3.up, Mathf.Clamp(signedAngle, -maxTurn, maxTurn)); + + // Holding the game's sprint key speeds up the assisted walk too + bool sprinting = _player != null && _player.Inputs != null && _player.Inputs.SprintHeld; + Vector3 step = flat.normalized * (sprinting ? sprintSpeed : walkSpeed) * Time.deltaTime; + step.y = -0.5f * Time.deltaTime; // keep grounded on ramps + _controller.Move(step); + } + + private void FaceTarget(Vector3 flatDirection) + { + if (flatDirection.sqrMagnitude < 0.001f) return; + float signedAngle = Vector3.SignedAngle(transform.forward, flatDirection, Vector3.up); + transform.Rotate(Vector3.up, signedAngle); + } + + /// Relative direction of a target in Spanish, 8 sectors around the player's facing. + public static string RelativeDirectionName(Vector3 forward, Vector3 toTarget) + { + Vector3 flatForward = new Vector3(forward.x, 0f, forward.z); + Vector3 flatTarget = new Vector3(toTarget.x, 0f, toTarget.z); + if (flatForward.sqrMagnitude < 0.0001f || flatTarget.sqrMagnitude < 0.0001f) return "adelante"; + + float angle = Vector3.SignedAngle(flatForward.normalized, flatTarget.normalized, Vector3.up); // -180..180 + float normalized = Mathf.Repeat(angle + 360f, 360f); + string[] names = { "adelante", "adelante a la derecha", "a la derecha", "atrás a la derecha", "atrás", "atrás a la izquierda", "a la izquierda", "adelante a la izquierda" }; + int sector = Mathf.RoundToInt(normalized / 45f) % 8; + return names[sector]; + } +} diff --git a/Assets/_Project/_Features/Accessibility/_Scripts/TargetNavigator.cs.meta b/Assets/_Project/_Features/Accessibility/_Scripts/TargetNavigator.cs.meta new file mode 100644 index 00000000..c844e121 --- /dev/null +++ b/Assets/_Project/_Features/Accessibility/_Scripts/TargetNavigator.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 0937936a792e7174cb050ea28f62390b \ No newline at end of file diff --git a/Assets/_Project/_Features/Accessibility/_Scripts/UIReader.cs b/Assets/_Project/_Features/Accessibility/_Scripts/UIReader.cs new file mode 100644 index 00000000..419e2d24 --- /dev/null +++ b/Assets/_Project/_Features/Accessibility/_Scripts/UIReader.cs @@ -0,0 +1,108 @@ +using TMPro; +using UnityEngine; +using UnityEngine.EventSystems; +using UnityEngine.SceneManagement; +using UnityEngine.UI; + +/// +/// Generic screen reader support for uGUI: announces the scene on load, keeps something +/// selected so keyboard navigation works, and speaks the focused element (alt text from +/// A11yAltText first, visible label second, humanized object name last, plus its role). +/// Maps to gameaccessibilityguidelines.com (Vision): "Provide pre-recorded voiceovers for +/// all text including menus" (via the player's own screen reader TTS). +/// +public class UIReader : MonoBehaviour +{ + private const float AutoSelectInterval = 0.5f; + + private GameObject _lastSelected; + private float _nextAutoSelectTime; + + private void OnEnable() => SceneManager.sceneLoaded += OnSceneLoaded; + private void OnDisable() => SceneManager.sceneLoaded -= OnSceneLoaded; + + private void OnSceneLoaded(Scene scene, LoadSceneMode mode) + { + _lastSelected = null; + if (A11yAltText.TryGet("scene:" + scene.name, out string sceneText)) + { + ScreenReaderOutput.Speak(sceneText); + } + } + + private void Update() + { + var eventSystem = EventSystem.current; + if (eventSystem == null) return; + + GameObject selected = eventSystem.currentSelectedGameObject; + + if (selected == null) + { + TryAutoSelect(eventSystem); + return; + } + + if (selected != _lastSelected) + { + _lastSelected = selected; + ScreenReaderOutput.Speak(DescribeElement(selected), true); + } + } + + // Menus should always have a focused element, otherwise keyboard (and screen reader) + // navigation has no starting point. Gated on the cursor being free: during first-person + // gameplay the cursor is locked, and grabbing stray HUD sliders there hijacks the + // keyboard (found by QA in Testing_Core). + private void TryAutoSelect(EventSystem eventSystem) + { + if (Cursor.lockState == CursorLockMode.Locked) return; + if (Time.unscaledTime < _nextAutoSelectTime) return; + _nextAutoSelectTime = Time.unscaledTime + AutoSelectInterval; + + var selectables = Selectable.allSelectablesArray; + for (int i = 0; i < selectables.Length; i++) + { + var candidate = selectables[i]; + if (candidate != null && candidate.IsActive() && candidate.IsInteractable()) + { + eventSystem.SetSelectedGameObject(candidate.gameObject); + return; + } + } + } + + public static string DescribeElement(GameObject element) + { + string label = ResolveLabel(element); + string role = ResolveRole(element); + return string.IsNullOrEmpty(role) ? label : $"{label}, {role}"; + } + + private static string ResolveLabel(GameObject element) + { + // 1) Curated alt text beats everything (covers images with baked-in text) + if (A11yAltText.TryGet(element.name, out string altText)) return altText; + + // 2) The visible label of the control + var tmpLabel = element.GetComponentInChildren(true); + if (tmpLabel != null && !string.IsNullOrWhiteSpace(tmpLabel.text)) return tmpLabel.text; + + var legacyLabel = element.GetComponentInChildren(true); + if (legacyLabel != null && !string.IsNullOrWhiteSpace(legacyLabel.text)) return legacyLabel.text; + + // 3) Whatever the developers named the object + return A11yAltText.HumanizeName(element.name); + } + + private static string ResolveRole(GameObject element) + { + if (element.GetComponent