From 0bb01b91e0a705b1376b3d3acfa0c7a6c5835811 Mon Sep 17 00:00:00 2001 From: Joe Barker Date: Mon, 21 Sep 2026 23:16:12 +0100 Subject: [PATCH 01/16] Add Wii layouts, multi-source bindings and a sideways-remote mode MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three things the Wii mapping editor gained, and the Dolphin motion fix that prompted them. Layouts. A console now starts from a named layout rather than one set of defaults: Wii (as before), Joy-Con (the remote's B and 2 swapped, so the Joy-Con's own B is the remote's B) and Mario Kart (a sideways Joy-Con laid out as Mario Kart 8 uses one). Picking one resets that console's customizations, since an override only means anything against the layout it was made on. The per-console tables move out of DefaultControllerMappings into a preset/ package, one layout per file. Several sources per target. A binding holds every source bound to it and any of them fires it, stored as their ids joined by '|', so a value from an older build still reads as one source. Dolphin ORs them into one expression; Eden binds one input per key, so it keeps the first its body can emit. The editor's rows become multi-select. Sideways Wii Remote. A lone Joy-Con can stand in for a Wii Remote held sideways: its motion turns onto that remote's frame and its four D-pad bindings turn a quarter with it, since the player's up is a sideways remote's right. A layout seeds the switch and the user has the last word. The motion itself was wrong for a right Joy-Con in a wheel game, which is what started this. Sideways, that body rotates clockwise into its grip, so its top edge points where a sideways remote's tail does — half a turn out, and Mario Kart Wii steered backwards. A left Joy-Con rotates the other way and already matched, which is why only P1 was affected. Pointing wants the opposite half turn (the nose on the shoulder edge you aim), and no Dolphin option bridges them: GetOrientation() turns a quarter and never reaches the IR cursor. Hence the switch rather than a fix. Measured with tools/dsu_client and a replay of Dolphin's EmulateIMUCursor against a captured aiming session: restoring the body gives 114 degrees of cursor travel where leaving the grip frame alone gives 36, because aiming turns the body about its rail, which is a roll the cursor discards. The pointer's yaw clamp also goes from Dolphin's 25 degrees to 60, which a hand-held aim overruns constantly. Co-Authored-By: Claude Opus 5 --- README.md | 35 ++- .../com/joegec/joycon2android/AppContainer.kt | 20 +- .../com/joegec/joycon2android/MainActivity.kt | 16 +- .../joycon2android/emulator/EmulatorSetup.kt | 3 + .../buttonmapping/MappingPresetDataStore.kt | 26 +++ .../buttonmapping/SidewaysRemoteDataStore.kt | 30 +++ .../ApplyMappingPresetUseCase.kt | 18 ++ .../ControllerMappingRepository.kt | 8 +- .../DefaultControllerMappings.kt | 200 ------------------ .../buttonmapping/DefaultMappingEntries.kt | 25 --- .../buttonmapping/EmittedInput.kt | 6 +- .../buttonmapping/GetSidewaysRemoteUseCase.kt | 8 + .../buttonmapping/MappingConversions.kt | 14 +- .../buttonmapping/MappingPresetRepository.kt | 12 ++ .../buttonmapping/MappingSourceIds.kt | 15 ++ .../ObserveControllerMappingUseCase.kt | 14 +- .../ObserveMappingPresetUseCase.kt | 12 ++ .../ObserveSidewaysRemoteUseCase.kt | 15 ++ .../buttonmapping/SetSidewaysRemoteUseCase.kt | 6 + .../buttonmapping/SidewaysRemoteRepository.kt | 13 ++ .../buttonmapping/preset/GameCubeMapping.kt | 82 +++++++ .../buttonmapping/preset/JoyconWiiMapping.kt | 15 ++ .../buttonmapping/preset/MappingEntries.kt | 25 +++ .../buttonmapping/preset/MappingPreset.kt | 24 +++ .../buttonmapping/preset/MappingPresets.kt | 17 ++ .../preset/MarioKartWiiMapping.kt | 73 +++++++ .../buttonmapping/preset/SwitchProMapping.kt | 97 +++++++++ .../buttonmapping/preset/WiiMapping.kt | 97 +++++++++ .../ObserveControllerMappingUseCaseTest.kt | 33 ++- .../buttonmapping/SidewaysRemoteTest.kt | 60 ++++++ .../buttonmapping/WholeEmittedStickTest.kt | 22 +- .../buttonmapping/preset/WiiPresetsTest.kt | 78 +++++++ .../presentation/ControllerMappingScreen.kt | 67 +++++- .../ControllerMappingViewModel.kt | 41 ++++ .../presentation/MappingOptions.kt | 7 + .../src/main/res/values/strings.xml | 3 + .../ui/components/MultiSelectDropdown.kt | 87 ++++++++ docs/architecture.md | 8 +- docs/dsu-motion.md | 28 ++- .../dsu/emulator/DolphinWiimoteConfig.kt | 96 ++++++--- .../dsu/emulator/EdenDsuConfig.kt | 15 +- .../dsu/emulator/DolphinWiimoteConfigTest.kt | 87 ++++++-- .../dsu/emulator/EdenDsuConfigTest.kt | 13 +- .../gamepad/emulator/DolphinGcpadConfig.kt | 16 +- .../gamepad/emulator/EdenGamepadConfig.kt | 13 +- .../emulator/DolphinGcpadConfigTest.kt | 6 +- .../gamepad/emulator/EdenGamepadConfigTest.kt | 4 +- 47 files changed, 1275 insertions(+), 335 deletions(-) create mode 100644 core/buttonmapping/data/src/main/kotlin/com/joegec/joycon2android/buttonmapping/MappingPresetDataStore.kt create mode 100644 core/buttonmapping/data/src/main/kotlin/com/joegec/joycon2android/buttonmapping/SidewaysRemoteDataStore.kt create mode 100644 core/buttonmapping/domain/src/main/kotlin/com/joegec/joycon2android/buttonmapping/ApplyMappingPresetUseCase.kt delete mode 100644 core/buttonmapping/domain/src/main/kotlin/com/joegec/joycon2android/buttonmapping/DefaultControllerMappings.kt delete mode 100644 core/buttonmapping/domain/src/main/kotlin/com/joegec/joycon2android/buttonmapping/DefaultMappingEntries.kt create mode 100644 core/buttonmapping/domain/src/main/kotlin/com/joegec/joycon2android/buttonmapping/GetSidewaysRemoteUseCase.kt create mode 100644 core/buttonmapping/domain/src/main/kotlin/com/joegec/joycon2android/buttonmapping/MappingPresetRepository.kt create mode 100644 core/buttonmapping/domain/src/main/kotlin/com/joegec/joycon2android/buttonmapping/MappingSourceIds.kt create mode 100644 core/buttonmapping/domain/src/main/kotlin/com/joegec/joycon2android/buttonmapping/ObserveMappingPresetUseCase.kt create mode 100644 core/buttonmapping/domain/src/main/kotlin/com/joegec/joycon2android/buttonmapping/ObserveSidewaysRemoteUseCase.kt create mode 100644 core/buttonmapping/domain/src/main/kotlin/com/joegec/joycon2android/buttonmapping/SetSidewaysRemoteUseCase.kt create mode 100644 core/buttonmapping/domain/src/main/kotlin/com/joegec/joycon2android/buttonmapping/SidewaysRemoteRepository.kt create mode 100644 core/buttonmapping/domain/src/main/kotlin/com/joegec/joycon2android/buttonmapping/preset/GameCubeMapping.kt create mode 100644 core/buttonmapping/domain/src/main/kotlin/com/joegec/joycon2android/buttonmapping/preset/JoyconWiiMapping.kt create mode 100644 core/buttonmapping/domain/src/main/kotlin/com/joegec/joycon2android/buttonmapping/preset/MappingEntries.kt create mode 100644 core/buttonmapping/domain/src/main/kotlin/com/joegec/joycon2android/buttonmapping/preset/MappingPreset.kt create mode 100644 core/buttonmapping/domain/src/main/kotlin/com/joegec/joycon2android/buttonmapping/preset/MappingPresets.kt create mode 100644 core/buttonmapping/domain/src/main/kotlin/com/joegec/joycon2android/buttonmapping/preset/MarioKartWiiMapping.kt create mode 100644 core/buttonmapping/domain/src/main/kotlin/com/joegec/joycon2android/buttonmapping/preset/SwitchProMapping.kt create mode 100644 core/buttonmapping/domain/src/main/kotlin/com/joegec/joycon2android/buttonmapping/preset/WiiMapping.kt create mode 100644 core/buttonmapping/domain/src/test/kotlin/com/joegec/joycon2android/buttonmapping/SidewaysRemoteTest.kt create mode 100644 core/buttonmapping/domain/src/test/kotlin/com/joegec/joycon2android/buttonmapping/preset/WiiPresetsTest.kt create mode 100644 core/designsystem/src/main/kotlin/com/joegec/joycon2android/ui/components/MultiSelectDropdown.kt diff --git a/README.md b/README.md index 016e29f..7cb695a 100644 --- a/README.md +++ b/README.md @@ -89,7 +89,20 @@ the emulator afterwards — it only reads its config when it starts. The gamepad button beside **Set up** opens the mapping editor. A single Joy-Con is set up as a Pro Controller held sideways, so every button works in every game -([why](docs/virtual-gamepad.md#why-theyre-set-up-as-pro-controllers)). +([why](docs/virtual-gamepad.md#why-theyre-set-up-as-pro-controllers)). A target can take **several +sources at once** — tick as many as you like, and any of them fires it. + +The Wii editor also carries a **Sideways Wii Remote** switch — play a single Joy-Con as a Wii Remote +held sideways, which is what a wheel game steers by. Each layout sets it (on for Mario Kart, off for +the others) and you can override it; picking a layout hands it back. + +It also picks a **layout** to start from, which resets that console's customizations: + +| Layout | For | +|---|---| +| Wii | The Wii Remote's own arrangement: the trigger under your finger is B, 1 and 2 under the thumb | +| Joy-Con | The same, with B and 2 swapped so the Joy-Con's own B is the remote's B | +| Mario Kart | A sideways Joy-Con laid out as Mario Kart 8 uses one — 2 accelerates, 1 brakes, SR hops, and SL throws an item alongside the stick. The one layout that plays as a **sideways Wii Remote**: the wheel steers correctly, the d-pad turns with it, and a right Joy-Con aims from its tail | > [!NOTE] > Auto setup needs Shizuku, and some devices block writing into another app's `Android/data` — use @@ -155,9 +168,27 @@ shoulder buttons. | Gyroscope Roll Left / Right | `Gyro Roll Left` / `Right` | `Gyro Pitch Up` / `Down` | `Gyro Pitch Down` / `Up` | | Gyroscope Yaw Left / Right | `Gyro Yaw Left` / `Right` | same | same | + That restores each Joy-Con's own body, which is what **pointing** wants: the remote's nose is the + shoulder edge, so aim R/ZR (or L/ZL) at the screen. Also set **Total Yaw** to around 60 — + Dolphin's 25 clamps the cursor after ±12.5° of turn, which a hand-held aim overruns. + + **For a game written for a sideways Wii Remote** (Mario Kart Wii and its Wii Wheel), two changes, + both of which the in-app **Mario Kart** layout writes for you: + + - Give a **right** Joy-Con the *left* column. Sideways, its top edge points where a sideways + remote's tail does, so without that half turn the wheel steers backwards; a left Joy-Con + already matches. Aiming then moves to the tail on that body. + - **Turn the four D-pad bindings a quarter**: put the source you'd bind to Up on **D-Pad/Right**, + Right on Down, Down on Left, Left on Up. A sideways remote's d-pad turns with it, so the + player's up is the remote's right. + + Leave Dolphin's own **Sideways Wii Remote** option off either way — it would turn the + accelerometer a second quarter. + 5. Under **Swing**, set **Forward** to ``(`Accel Forward` - `Accel Backward`) - smooth((`Accel Forward` - `Accel Backward`), 0.03)``, - **Range** to 7% and **Dead Zone** to 20%. For a single Joy-Con, use `Accel Up` and `Accel Down`. + **Range** to 7% and **Dead Zone** to 20%. For a single Joy-Con use whichever pair its nose reads + above — `Accel Right`/`Left` on a right Joy-Con, and the other way round when it plays sideways. This lets thrusts reach games like Wii Play Billiards ([why](docs/dsu-motion.md#dolphin-wii-remote-mapping)). 6. **Pair only:** under **Nunchuk → Motion Input**, map each accelerometer entry to the left Joy-Con's slot, e.g. `` `DSUClient/3/Joycon2:Accel Up` `` — the highest slot no player uses (3 with one diff --git a/app/src/main/java/com/joegec/joycon2android/AppContainer.kt b/app/src/main/java/com/joegec/joycon2android/AppContainer.kt index a9d8b4e..4d5b2dd 100644 --- a/app/src/main/java/com/joegec/joycon2android/AppContainer.kt +++ b/app/src/main/java/com/joegec/joycon2android/AppContainer.kt @@ -12,12 +12,21 @@ import com.joegec.joycon2android.connection.StartScanUseCase import com.joegec.joycon2android.connection.StopScanUseCase import com.joegec.joycon2android.connection.ViewModePreferences import com.joegec.joycon2android.connection.ViewModePreferencesDataStore +import com.joegec.joycon2android.buttonmapping.ApplyMappingPresetUseCase import com.joegec.joycon2android.buttonmapping.ControllerMappingDataStore import com.joegec.joycon2android.buttonmapping.ControllerMappingRepository import com.joegec.joycon2android.buttonmapping.GetEffectiveControllerMappingUseCase +import com.joegec.joycon2android.buttonmapping.GetSidewaysRemoteUseCase +import com.joegec.joycon2android.buttonmapping.MappingPresetDataStore +import com.joegec.joycon2android.buttonmapping.MappingPresetRepository import com.joegec.joycon2android.buttonmapping.ObserveControllerMappingUseCase +import com.joegec.joycon2android.buttonmapping.ObserveMappingPresetUseCase import com.joegec.joycon2android.buttonmapping.ResetControllerMappingUseCase import com.joegec.joycon2android.buttonmapping.SetControllerMappingUseCase +import com.joegec.joycon2android.buttonmapping.SetSidewaysRemoteUseCase +import com.joegec.joycon2android.buttonmapping.SidewaysRemoteDataStore +import com.joegec.joycon2android.buttonmapping.SidewaysRemoteRepository +import com.joegec.joycon2android.buttonmapping.ObserveSidewaysRemoteUseCase import com.joegec.joycon2android.assignment.AssignmentRepository import com.joegec.joycon2android.assignment.ComboAssignmentDetector import com.joegec.joycon2android.assignment.PlayerAssignmentManager @@ -97,10 +106,18 @@ class AppContainer(context: Context) { // --- Controller button mapping (shared by Gamepad and DSU) --- private val controllerMappingRepository: ControllerMappingRepository = ControllerMappingDataStore(appContext) - val observeControllerMapping = ObserveControllerMappingUseCase(controllerMappingRepository) + private val mappingPresetRepository: MappingPresetRepository = MappingPresetDataStore(appContext) + val observeMappingPreset = ObserveMappingPresetUseCase(mappingPresetRepository) + private val sidewaysRemoteRepository: SidewaysRemoteRepository = SidewaysRemoteDataStore(appContext) + val observeSidewaysRemote = ObserveSidewaysRemoteUseCase(sidewaysRemoteRepository, observeMappingPreset) + val setSidewaysRemote = SetSidewaysRemoteUseCase(sidewaysRemoteRepository) + val applyMappingPreset = + ApplyMappingPresetUseCase(mappingPresetRepository, controllerMappingRepository, sidewaysRemoteRepository) + val observeControllerMapping = ObserveControllerMappingUseCase(controllerMappingRepository, observeMappingPreset) val setControllerMapping = SetControllerMappingUseCase(controllerMappingRepository) val resetControllerMapping = ResetControllerMappingUseCase(controllerMappingRepository) private val getControllerMapping = GetEffectiveControllerMappingUseCase(observeControllerMapping) + private val getSidewaysRemote = GetSidewaysRemoteUseCase(observeSidewaysRemote) // --- DSU --- private val dsuRepository: DsuRepository = DsuServer(scope) @@ -140,6 +157,7 @@ class AppContainer(context: Context) { gamepadDevices = { edenGamepads(appContext) }, gamepadControllerNumbers = { dolphinGamepadIds(appContext) }, getControllerMapping = getControllerMapping, + getSidewaysRemote = getSidewaysRemote, ) val emulatorLauncher = EmulatorLauncher(appContext) diff --git a/app/src/main/java/com/joegec/joycon2android/MainActivity.kt b/app/src/main/java/com/joegec/joycon2android/MainActivity.kt index ca9a513..bdd94f4 100644 --- a/app/src/main/java/com/joegec/joycon2android/MainActivity.kt +++ b/app/src/main/java/com/joegec/joycon2android/MainActivity.kt @@ -90,7 +90,15 @@ class MainActivity : ComponentActivity() { viewModelFactory { initializer { val c = (application as JoyconApplication).container - ControllerMappingViewModel(c.observeControllerMapping, c.setControllerMapping, c.resetControllerMapping) + ControllerMappingViewModel( + c.observeControllerMapping, + c.setControllerMapping, + c.resetControllerMapping, + c.observeMappingPreset, + c.applyMappingPreset, + c.observeSidewaysRemote, + c.setSidewaysRemote, + ) } } } @@ -184,12 +192,18 @@ class MainActivity : ComponentActivity() { val leftMapping by controllerMappingViewModel.mapping(console, JoyconSide.LEFT).collectAsState() val rightMapping by controllerMappingViewModel.mapping(console, JoyconSide.RIGHT).collectAsState() val dualMapping by controllerMappingViewModel.mapping(console, JoyconSide.DUAL).collectAsState() + val presetId by controllerMappingViewModel.preset(console).collectAsState() + val sidewaysRemote by controllerMappingViewModel.sidewaysRemote(console).collectAsState() ControllerMappingScreen( console = console, + presetId = presetId, + sidewaysRemote = sidewaysRemote, leftMapping = leftMapping, rightMapping = rightMapping, dualMapping = dualMapping, + onSelectPreset = { controllerMappingViewModel.selectPreset(console, it) }, + onSetSidewaysRemote = { controllerMappingViewModel.setSidewaysRemoteEnabled(console, it) }, onSetMapping = { side, targetKey, sourceId -> controllerMappingViewModel.setMapping(console, side, targetKey, sourceId) }, diff --git a/app/src/main/java/com/joegec/joycon2android/emulator/EmulatorSetup.kt b/app/src/main/java/com/joegec/joycon2android/emulator/EmulatorSetup.kt index 421f093..e7e7676 100644 --- a/app/src/main/java/com/joegec/joycon2android/emulator/EmulatorSetup.kt +++ b/app/src/main/java/com/joegec/joycon2android/emulator/EmulatorSetup.kt @@ -4,6 +4,7 @@ import android.content.pm.PackageManager import android.util.Log import com.joegec.joycon2android.buttonmapping.Console import com.joegec.joycon2android.buttonmapping.GetEffectiveControllerMappingUseCase +import com.joegec.joycon2android.buttonmapping.GetSidewaysRemoteUseCase import com.joegec.joycon2android.buttonmapping.JoyconSide import com.joegec.joycon2android.dsu.emulator.DolphinDsuConfig import com.joegec.joycon2android.dsu.emulator.DolphinWiimoteConfig @@ -38,6 +39,7 @@ class EmulatorSetup( private val gamepadDevices: () -> Map, private val gamepadControllerNumbers: () -> Map, private val getControllerMapping: GetEffectiveControllerMappingUseCase, + private val getSidewaysRemote: GetSidewaysRemoteUseCase, ) { private suspend fun mappingLookup(console: Console): (JoyconSide) -> Map { @@ -118,6 +120,7 @@ class EmulatorSetup( DolphinWiimoteConfig.merge( shell.readText(DolphinWiimoteConfig.path), players, + getSidewaysRemote(Console.WIIMOTE_NUNCHUK), mappingLookup(Console.WIIMOTE_NUNCHUK), ), ) diff --git a/core/buttonmapping/data/src/main/kotlin/com/joegec/joycon2android/buttonmapping/MappingPresetDataStore.kt b/core/buttonmapping/data/src/main/kotlin/com/joegec/joycon2android/buttonmapping/MappingPresetDataStore.kt new file mode 100644 index 0000000..aacfb37 --- /dev/null +++ b/core/buttonmapping/data/src/main/kotlin/com/joegec/joycon2android/buttonmapping/MappingPresetDataStore.kt @@ -0,0 +1,26 @@ +package com.joegec.joycon2android.buttonmapping + +import android.content.Context +import androidx.datastore.core.DataStore +import androidx.datastore.preferences.core.Preferences +import androidx.datastore.preferences.core.edit +import androidx.datastore.preferences.core.stringPreferencesKey +import androidx.datastore.preferences.preferencesDataStore +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.map + +private val Context.mappingPresetDataStore: DataStore by + preferencesDataStore(name = "mapping_preset") + +class MappingPresetDataStore(context: Context) : MappingPresetRepository { + + private val dataStore = context.applicationContext.mappingPresetDataStore + + override fun observe(console: Console): Flow = dataStore.data.map { it[preferenceKey(console)] } + + override suspend fun set(console: Console, presetId: String) { + dataStore.edit { it[preferenceKey(console)] = presetId } + } + + private fun preferenceKey(console: Console) = stringPreferencesKey(console.name) +} diff --git a/core/buttonmapping/data/src/main/kotlin/com/joegec/joycon2android/buttonmapping/SidewaysRemoteDataStore.kt b/core/buttonmapping/data/src/main/kotlin/com/joegec/joycon2android/buttonmapping/SidewaysRemoteDataStore.kt new file mode 100644 index 0000000..de4b745 --- /dev/null +++ b/core/buttonmapping/data/src/main/kotlin/com/joegec/joycon2android/buttonmapping/SidewaysRemoteDataStore.kt @@ -0,0 +1,30 @@ +package com.joegec.joycon2android.buttonmapping + +import android.content.Context +import androidx.datastore.core.DataStore +import androidx.datastore.preferences.core.Preferences +import androidx.datastore.preferences.core.booleanPreferencesKey +import androidx.datastore.preferences.core.edit +import androidx.datastore.preferences.preferencesDataStore +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.map + +private val Context.sidewaysRemoteDataStore: DataStore by + preferencesDataStore(name = "sideways_remote") + +class SidewaysRemoteDataStore(context: Context) : SidewaysRemoteRepository { + + private val dataStore = context.applicationContext.sidewaysRemoteDataStore + + override fun observe(console: Console): Flow = dataStore.data.map { it[preferenceKey(console)] } + + override suspend fun set(console: Console, enabled: Boolean) { + dataStore.edit { it[preferenceKey(console)] = enabled } + } + + override suspend fun clear(console: Console) { + dataStore.edit { it.remove(preferenceKey(console)) } + } + + private fun preferenceKey(console: Console) = booleanPreferencesKey(console.name) +} diff --git a/core/buttonmapping/domain/src/main/kotlin/com/joegec/joycon2android/buttonmapping/ApplyMappingPresetUseCase.kt b/core/buttonmapping/domain/src/main/kotlin/com/joegec/joycon2android/buttonmapping/ApplyMappingPresetUseCase.kt new file mode 100644 index 0000000..1da1487 --- /dev/null +++ b/core/buttonmapping/domain/src/main/kotlin/com/joegec/joycon2android/buttonmapping/ApplyMappingPresetUseCase.kt @@ -0,0 +1,18 @@ +package com.joegec.joycon2android.buttonmapping + +/** + * Switches a console to another layout. Overrides only make sense against the layout they were made + * on, so the console's customizations — its bindings and its sideways-remote switch — go with the + * old one. + */ +class ApplyMappingPresetUseCase( + private val presetRepository: MappingPresetRepository, + private val mappingRepository: ControllerMappingRepository, + private val sidewaysRemoteRepository: SidewaysRemoteRepository, +) { + suspend operator fun invoke(console: Console, presetId: String) { + presetRepository.set(console, presetId) + sidewaysRemoteRepository.clear(console) + JoyconSide.entries.forEach { mappingRepository.clear(console, it) } + } +} diff --git a/core/buttonmapping/domain/src/main/kotlin/com/joegec/joycon2android/buttonmapping/ControllerMappingRepository.kt b/core/buttonmapping/domain/src/main/kotlin/com/joegec/joycon2android/buttonmapping/ControllerMappingRepository.kt index d5e018f..914dcd8 100644 --- a/core/buttonmapping/domain/src/main/kotlin/com/joegec/joycon2android/buttonmapping/ControllerMappingRepository.kt +++ b/core/buttonmapping/domain/src/main/kotlin/com/joegec/joycon2android/buttonmapping/ControllerMappingRepository.kt @@ -3,10 +3,10 @@ package com.joegec.joycon2android.buttonmapping import kotlinx.coroutines.flow.Flow /** - * Stores the user's overrides to the default Joy-Con button mapping, keyed by console shape and - * body. Values are opaque strings (a [MappingSource] id, or a legacy whole-stick [StickSource] - * name) — this layer knows nothing about what a key or value means, only how to - * persist it; [DefaultControllerMappings] and the use cases give them meaning. + * Stores the user's overrides to the Joy-Con button mapping, keyed by console shape and body. + * Values are opaque strings (the [MappingSource] ids driving one target, joined by [sourceIdOf], or + * a legacy whole-stick [StickSource] name) — this layer knows nothing about what a key or value + * means, only how to persist it; the presets and use cases give them meaning. */ interface ControllerMappingRepository { fun observe(console: Console, side: JoyconSide): Flow> diff --git a/core/buttonmapping/domain/src/main/kotlin/com/joegec/joycon2android/buttonmapping/DefaultControllerMappings.kt b/core/buttonmapping/domain/src/main/kotlin/com/joegec/joycon2android/buttonmapping/DefaultControllerMappings.kt deleted file mode 100644 index 0ecec2f..0000000 --- a/core/buttonmapping/domain/src/main/kotlin/com/joegec/joycon2android/buttonmapping/DefaultControllerMappings.kt +++ /dev/null @@ -1,200 +0,0 @@ -package com.joegec.joycon2android.buttonmapping - -import com.joegec.joycon2android.buttonmapping.target.GameCubeButton -import com.joegec.joycon2android.buttonmapping.target.GameCubeStick -import com.joegec.joycon2android.buttonmapping.target.SwitchProButton -import com.joegec.joycon2android.buttonmapping.target.SwitchProStick -import com.joegec.joycon2android.buttonmapping.target.WiimoteButton -import com.joegec.joycon2android.buttonmapping.target.WiimoteStick -import com.joegec.joycon2android.model.JoyconButton -import com.joegec.joycon2android.model.JoyconButton.A -import com.joegec.joycon2android.model.JoyconButton.B -import com.joegec.joycon2android.model.JoyconButton.Capture -import com.joegec.joycon2android.model.JoyconButton.Down -import com.joegec.joycon2android.model.JoyconButton.Home -import com.joegec.joycon2android.model.JoyconButton.L -import com.joegec.joycon2android.model.JoyconButton.LS -import com.joegec.joycon2android.model.JoyconButton.Left -import com.joegec.joycon2android.model.JoyconButton.Minus -import com.joegec.joycon2android.model.JoyconButton.Plus -import com.joegec.joycon2android.model.JoyconButton.R -import com.joegec.joycon2android.model.JoyconButton.RS -import com.joegec.joycon2android.model.JoyconButton.Right -import com.joegec.joycon2android.model.JoyconButton.SlLeft -import com.joegec.joycon2android.model.JoyconButton.SlRight -import com.joegec.joycon2android.model.JoyconButton.SrLeft -import com.joegec.joycon2android.model.JoyconButton.SrRight -import com.joegec.joycon2android.model.JoyconButton.Up -import com.joegec.joycon2android.model.JoyconButton.X -import com.joegec.joycon2android.model.JoyconButton.Y -import com.joegec.joycon2android.model.JoyconButton.ZL -import com.joegec.joycon2android.model.JoyconButton.ZR -import com.joegec.joycon2android.buttonmapping.StickSource.LEFT_STICK -import com.joegec.joycon2android.buttonmapping.StickSource.RIGHT_STICK - -/** - * The assignments a fresh install starts from, per target console and per Joy-Con body, for any - * target the user has never customized. - */ -object DefaultControllerMappings { - - fun gameCubeButtons(side: JoyconSide): Map = when (side) { - JoyconSide.DUAL -> mapOf( - GameCubeButton.A to A, - GameCubeButton.B to B, - GameCubeButton.X to X, - GameCubeButton.Y to Y, - GameCubeButton.Z to R, - GameCubeButton.Start to Plus, - GameCubeButton.TriggerL to L, - GameCubeButton.TriggerR to R, - GameCubeButton.DPadUp to Up, - GameCubeButton.DPadDown to Down, - GameCubeButton.DPadLeft to Left, - GameCubeButton.DPadRight to Right, - ) - JoyconSide.LEFT -> mapOf( - GameCubeButton.A to Down, - GameCubeButton.B to Left, - GameCubeButton.X to Right, - GameCubeButton.Y to Up, - GameCubeButton.Z to Capture, - GameCubeButton.Start to Minus, - GameCubeButton.TriggerL to SlLeft, - GameCubeButton.TriggerR to SrLeft, - ) - JoyconSide.RIGHT -> mapOf( - GameCubeButton.A to X, - GameCubeButton.B to A, - GameCubeButton.X to Y, - GameCubeButton.Y to B, - GameCubeButton.Z to Home, - GameCubeButton.Start to Plus, - GameCubeButton.TriggerL to SlRight, - GameCubeButton.TriggerR to SrRight, - ) - } - - fun gameCubeSticks(side: JoyconSide): Map = when (side) { - JoyconSide.DUAL -> mapOf( - GameCubeStick.MainStick to LEFT_STICK, - GameCubeStick.CStick to RIGHT_STICK, - ) - JoyconSide.LEFT -> mapOf(GameCubeStick.MainStick to LEFT_STICK) - JoyconSide.RIGHT -> mapOf(GameCubeStick.MainStick to RIGHT_STICK) - } - - fun switchProButtons(side: JoyconSide): Map = when (side) { - JoyconSide.DUAL -> mapOf( - SwitchProButton.A to A, - SwitchProButton.B to B, - SwitchProButton.X to X, - SwitchProButton.Y to Y, - SwitchProButton.L to L, - SwitchProButton.R to R, - SwitchProButton.ZL to ZL, - SwitchProButton.ZR to ZR, - SwitchProButton.Plus to Plus, - SwitchProButton.Minus to Minus, - SwitchProButton.Home to Home, - SwitchProButton.Capture to Capture, - SwitchProButton.LStickClick to LS, - SwitchProButton.RStickClick to RS, - SwitchProButton.DPadUp to Up, - SwitchProButton.DPadDown to Down, - SwitchProButton.DPadLeft to Left, - SwitchProButton.DPadRight to Right, - ) - // Held sideways, the rail buttons are the shoulder pair, as they are on a real Switch. - // ZL/ZR stay unbound: the body's own shoulders point away from the player in that grip, so - // there is nothing honest to put there — the user can bind them if they want them. - JoyconSide.LEFT -> mapOf( - SwitchProButton.A to Down, - SwitchProButton.B to Left, - SwitchProButton.X to Right, - SwitchProButton.Y to Up, - SwitchProButton.L to SlLeft, - SwitchProButton.R to SrLeft, - SwitchProButton.Minus to Minus, - SwitchProButton.LStickClick to LS, - SwitchProButton.Capture to Capture, - ) - JoyconSide.RIGHT -> mapOf( - SwitchProButton.A to X, - SwitchProButton.B to A, - SwitchProButton.X to Y, - SwitchProButton.Y to B, - SwitchProButton.L to SlRight, - SwitchProButton.R to SrRight, - SwitchProButton.Plus to Plus, - SwitchProButton.Home to Home, - SwitchProButton.LStickClick to RS, - ) - } - - fun switchProSticks(side: JoyconSide): Map = when (side) { - JoyconSide.DUAL -> mapOf( - SwitchProStick.LStick to LEFT_STICK, - SwitchProStick.RStick to RIGHT_STICK, - ) - JoyconSide.LEFT -> mapOf(SwitchProStick.LStick to LEFT_STICK) - JoyconSide.RIGHT -> mapOf(SwitchProStick.LStick to RIGHT_STICK) - } - - fun wiimoteButtons(side: JoyconSide): Map = when (side) { - JoyconSide.DUAL -> mapOf( - WiimoteButton.A to A, - WiimoteButton.B to ZR, - WiimoteButton.One to Y, - WiimoteButton.Two to B, - WiimoteButton.Home to Home, - WiimoteButton.Plus to Plus, - WiimoteButton.Minus to X, - WiimoteButton.DPadUp to Up, - WiimoteButton.DPadDown to Down, - WiimoteButton.DPadLeft to Left, - WiimoteButton.DPadRight to Right, - WiimoteButton.NunchukC to L, - WiimoteButton.NunchukZ to ZL, - ) - JoyconSide.LEFT -> mapOf( - WiimoteButton.A to Down, - WiimoteButton.B to ZL, - WiimoteButton.One to Up, - WiimoteButton.Two to Left, - WiimoteButton.Home to Capture, - WiimoteButton.Plus to Right, - WiimoteButton.Minus to Minus, - ) - JoyconSide.RIGHT -> mapOf( - WiimoteButton.A to A, - WiimoteButton.B to ZR, - WiimoteButton.One to Y, - WiimoteButton.Two to B, - WiimoteButton.Home to Home, - WiimoteButton.Plus to Plus, - WiimoteButton.Minus to X, - ) - } - - fun wiimoteSticks(side: JoyconSide): Map = when (side) { - JoyconSide.DUAL -> mapOf(WiimoteStick.NunchukStick to LEFT_STICK) - else -> emptyMap() - } - - // A sideways Joy-Con has no d-pad left once its cluster becomes the face buttons, so its stick - // steers the Wii Remote's d-pad instead. - fun wiimoteDPadSticks(side: JoyconSide): Map { - val stick = when (side) { - JoyconSide.DUAL -> return emptyMap() - JoyconSide.LEFT -> LEFT_STICK - JoyconSide.RIGHT -> RIGHT_STICK - } - return mapOf( - WiimoteButton.DPadUp to MappingSource.Stick(stick, StickDirection.UP), - WiimoteButton.DPadDown to MappingSource.Stick(stick, StickDirection.DOWN), - WiimoteButton.DPadLeft to MappingSource.Stick(stick, StickDirection.LEFT), - WiimoteButton.DPadRight to MappingSource.Stick(stick, StickDirection.RIGHT), - ) - } -} diff --git a/core/buttonmapping/domain/src/main/kotlin/com/joegec/joycon2android/buttonmapping/DefaultMappingEntries.kt b/core/buttonmapping/domain/src/main/kotlin/com/joegec/joycon2android/buttonmapping/DefaultMappingEntries.kt deleted file mode 100644 index b615f5d..0000000 --- a/core/buttonmapping/domain/src/main/kotlin/com/joegec/joycon2android/buttonmapping/DefaultMappingEntries.kt +++ /dev/null @@ -1,25 +0,0 @@ -package com.joegec.joycon2android.buttonmapping - -import com.joegec.joycon2android.model.JoyconButton - -/** The shipped defaults for a console/body, in the repository's opaque string form. */ -fun defaultMappingEntries(console: Console, side: JoyconSide): Map = when (console) { - Console.GAMECUBE -> DefaultControllerMappings.gameCubeButtons(side).buttonEntries() + - DefaultControllerMappings.gameCubeSticks(side).stickEntries() - Console.WIIMOTE_NUNCHUK -> DefaultControllerMappings.wiimoteButtons(side).buttonEntries() + - DefaultControllerMappings.wiimoteDPadSticks(side).sourceEntries() + - DefaultControllerMappings.wiimoteSticks(side).stickEntries() - Console.SWITCH_PRO -> DefaultControllerMappings.switchProButtons(side).buttonEntries() + - DefaultControllerMappings.switchProSticks(side).stickEntries() -} - -private fun > Map.buttonEntries(): Map = - entries.associate { (target, button) -> target.name to button.name } - -private fun > Map.sourceEntries(): Map = - entries.associate { (target, source) -> target.name to source.id } - -private fun > Map.stickEntries(): Map = - entries.flatMap { (target, stick) -> - MappingSource.directionsOf(stick).map { target.directionKey(it.direction) to it.id } - }.toMap() diff --git a/core/buttonmapping/domain/src/main/kotlin/com/joegec/joycon2android/buttonmapping/EmittedInput.kt b/core/buttonmapping/domain/src/main/kotlin/com/joegec/joycon2android/buttonmapping/EmittedInput.kt index 3b86887..8b53cff 100644 --- a/core/buttonmapping/domain/src/main/kotlin/com/joegec/joycon2android/buttonmapping/EmittedInput.kt +++ b/core/buttonmapping/domain/src/main/kotlin/com/joegec/joycon2android/buttonmapping/EmittedInput.kt @@ -23,11 +23,11 @@ fun MappingSource.Stick.emittedStick(side: JoyconSide): StickSource = /** * The stick a target can read as a whole — keeping its analog range — when all four of its * directions follow the same emitted stick the natural way round; null when they're rearranged, - * partly unbound or mixed with buttons, which leaves each direction to be bound on its own. + * partly unbound, doubled up or mixed with buttons, which leaves each direction to be bound on its own. */ -fun Map.wholeEmittedStick(side: JoyconSide): StickSource? { +fun Map>.wholeEmittedStick(side: JoyconSide): StickSource? { val sticks = StickDirection.entries.map { direction -> - val source = this[direction] as? MappingSource.Stick ?: return null + val source = this[direction]?.singleOrNull() as? MappingSource.Stick ?: return null if (source.direction != direction) return null source.emittedStick(side) } diff --git a/core/buttonmapping/domain/src/main/kotlin/com/joegec/joycon2android/buttonmapping/GetSidewaysRemoteUseCase.kt b/core/buttonmapping/domain/src/main/kotlin/com/joegec/joycon2android/buttonmapping/GetSidewaysRemoteUseCase.kt new file mode 100644 index 0000000..d83923d --- /dev/null +++ b/core/buttonmapping/domain/src/main/kotlin/com/joegec/joycon2android/buttonmapping/GetSidewaysRemoteUseCase.kt @@ -0,0 +1,8 @@ +package com.joegec.joycon2android.buttonmapping + +import kotlinx.coroutines.flow.first + +/** One-shot read, for the emulator-config generators at "Set up" time. */ +class GetSidewaysRemoteUseCase(private val observeSidewaysRemote: ObserveSidewaysRemoteUseCase) { + suspend operator fun invoke(console: Console): Boolean = observeSidewaysRemote(console).first() +} diff --git a/core/buttonmapping/domain/src/main/kotlin/com/joegec/joycon2android/buttonmapping/MappingConversions.kt b/core/buttonmapping/domain/src/main/kotlin/com/joegec/joycon2android/buttonmapping/MappingConversions.kt index 85383d8..0135da7 100644 --- a/core/buttonmapping/domain/src/main/kotlin/com/joegec/joycon2android/buttonmapping/MappingConversions.kt +++ b/core/buttonmapping/domain/src/main/kotlin/com/joegec/joycon2android/buttonmapping/MappingConversions.kt @@ -6,21 +6,21 @@ fun Enum<*>.directionKey(direction: StickDirection): String = stickDirectionKey( internal fun stickDirectionKey(targetName: String, direction: StickDirection) = "${targetName}_${direction.name}" /** - * Recovers a typed target -> source map from the repository's opaque string map, silently dropping - * entries whose key isn't a [T] or whose value isn't a known source — a stale or "None"-selected + * Recovers a typed target -> sources map from the repository's opaque string map, silently dropping + * entries whose key isn't a [T] and sources that are no longer known — a stale or "None"-selected * entry simply produces no binding rather than a crash. */ -inline fun > Map.toSourceMap(): Map = +inline fun > Map.toSourceMap(): Map> = mapNotNull { (key, value) -> val target = enumValues().firstOrNull { it.name == key } ?: return@mapNotNull null - val source = MappingSource.fromId(value) ?: return@mapNotNull null - target to source + val sources = value.toMappingSources().ifEmpty { return@mapNotNull null } + target to sources }.toMap() /** Same recovery as [toSourceMap], for the four direction entries of each target stick. */ -inline fun > Map.toStickDirectionMap(): Map> = +inline fun > Map.toStickDirectionMap(): Map>> = enumValues().associateWith { target -> StickDirection.entries.mapNotNull { direction -> - this[target.directionKey(direction)]?.let(MappingSource::fromId)?.let { direction to it } + this[target.directionKey(direction)]?.toMappingSources()?.ifEmpty { null }?.let { direction to it } }.toMap() }.filterValues { it.isNotEmpty() } diff --git a/core/buttonmapping/domain/src/main/kotlin/com/joegec/joycon2android/buttonmapping/MappingPresetRepository.kt b/core/buttonmapping/domain/src/main/kotlin/com/joegec/joycon2android/buttonmapping/MappingPresetRepository.kt new file mode 100644 index 0000000..64ef889 --- /dev/null +++ b/core/buttonmapping/domain/src/main/kotlin/com/joegec/joycon2android/buttonmapping/MappingPresetRepository.kt @@ -0,0 +1,12 @@ +package com.joegec.joycon2android.buttonmapping + +import kotlinx.coroutines.flow.Flow + +/** + * Stores which layout each console is set to, as an opaque preset id — null until the user picks + * one. [com.joegec.joycon2android.buttonmapping.preset.MappingPresets] gives the id meaning. + */ +interface MappingPresetRepository { + fun observe(console: Console): Flow + suspend fun set(console: Console, presetId: String) +} diff --git a/core/buttonmapping/domain/src/main/kotlin/com/joegec/joycon2android/buttonmapping/MappingSourceIds.kt b/core/buttonmapping/domain/src/main/kotlin/com/joegec/joycon2android/buttonmapping/MappingSourceIds.kt new file mode 100644 index 0000000..1da54f6 --- /dev/null +++ b/core/buttonmapping/domain/src/main/kotlin/com/joegec/joycon2android/buttonmapping/MappingSourceIds.kt @@ -0,0 +1,15 @@ +package com.joegec.joycon2android.buttonmapping + +private const val SOURCE_SEPARATOR = "|" + +/** + * Several sources can drive one target — any of them fires it — so a stored value holds their ids + * joined together. A value written by an older version is a single id, which reads back as one source. + */ +fun sourceIdsOf(value: String): List = value.split(SOURCE_SEPARATOR).filter { it.isNotEmpty() } + +fun sourceIdOf(ids: List): String = ids.joinToString(SOURCE_SEPARATOR) + +fun String.toMappingSources(): List = sourceIdsOf(this).mapNotNull(MappingSource::fromId) + +fun List.toSourceId(): String = sourceIdOf(map { it.id }) diff --git a/core/buttonmapping/domain/src/main/kotlin/com/joegec/joycon2android/buttonmapping/ObserveControllerMappingUseCase.kt b/core/buttonmapping/domain/src/main/kotlin/com/joegec/joycon2android/buttonmapping/ObserveControllerMappingUseCase.kt index f64bfa7..a0fa755 100644 --- a/core/buttonmapping/domain/src/main/kotlin/com/joegec/joycon2android/buttonmapping/ObserveControllerMappingUseCase.kt +++ b/core/buttonmapping/domain/src/main/kotlin/com/joegec/joycon2android/buttonmapping/ObserveControllerMappingUseCase.kt @@ -1,13 +1,15 @@ package com.joegec.joycon2android.buttonmapping import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.combine -/** The mapping actually in effect for a console/body: stored overrides layered on the defaults. */ -class ObserveControllerMappingUseCase(private val repository: ControllerMappingRepository) { +/** The mapping actually in effect for a console/body: stored overrides layered on its preset. */ +class ObserveControllerMappingUseCase( + private val repository: ControllerMappingRepository, + private val observeMappingPreset: ObserveMappingPresetUseCase, +) { operator fun invoke(console: Console, side: JoyconSide): Flow> = - repository.observe(console, side).map { stored -> - defaultMappingEntries(console, side) + - stored.withLegacyButtonNamesRenamed().withLegacyStickRoutesExpanded() + combine(observeMappingPreset(console), repository.observe(console, side)) { preset, stored -> + preset.entries(side) + stored.withLegacyButtonNamesRenamed().withLegacyStickRoutesExpanded() } } diff --git a/core/buttonmapping/domain/src/main/kotlin/com/joegec/joycon2android/buttonmapping/ObserveMappingPresetUseCase.kt b/core/buttonmapping/domain/src/main/kotlin/com/joegec/joycon2android/buttonmapping/ObserveMappingPresetUseCase.kt new file mode 100644 index 0000000..080d96e --- /dev/null +++ b/core/buttonmapping/domain/src/main/kotlin/com/joegec/joycon2android/buttonmapping/ObserveMappingPresetUseCase.kt @@ -0,0 +1,12 @@ +package com.joegec.joycon2android.buttonmapping + +import com.joegec.joycon2android.buttonmapping.preset.MappingPreset +import com.joegec.joycon2android.buttonmapping.preset.MappingPresets +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.map + +/** The layout a console is set to, falling back to its default. */ +class ObserveMappingPresetUseCase(private val repository: MappingPresetRepository) { + operator fun invoke(console: Console): Flow = + repository.observe(console).map { MappingPresets.byId(console, it) } +} diff --git a/core/buttonmapping/domain/src/main/kotlin/com/joegec/joycon2android/buttonmapping/ObserveSidewaysRemoteUseCase.kt b/core/buttonmapping/domain/src/main/kotlin/com/joegec/joycon2android/buttonmapping/ObserveSidewaysRemoteUseCase.kt new file mode 100644 index 0000000..c5fcf75 --- /dev/null +++ b/core/buttonmapping/domain/src/main/kotlin/com/joegec/joycon2android/buttonmapping/ObserveSidewaysRemoteUseCase.kt @@ -0,0 +1,15 @@ +package com.joegec.joycon2android.buttonmapping + +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.combine + +/** Whether a console plays as a sideways Wii Remote: the user's choice, else its layout's. */ +class ObserveSidewaysRemoteUseCase( + private val repository: SidewaysRemoteRepository, + private val observeMappingPreset: ObserveMappingPresetUseCase, +) { + operator fun invoke(console: Console): Flow = + combine(repository.observe(console), observeMappingPreset(console)) { chosen, preset -> + chosen ?: preset.sidewaysRemote + } +} diff --git a/core/buttonmapping/domain/src/main/kotlin/com/joegec/joycon2android/buttonmapping/SetSidewaysRemoteUseCase.kt b/core/buttonmapping/domain/src/main/kotlin/com/joegec/joycon2android/buttonmapping/SetSidewaysRemoteUseCase.kt new file mode 100644 index 0000000..745d90f --- /dev/null +++ b/core/buttonmapping/domain/src/main/kotlin/com/joegec/joycon2android/buttonmapping/SetSidewaysRemoteUseCase.kt @@ -0,0 +1,6 @@ +package com.joegec.joycon2android.buttonmapping + +/** Records the user's own answer, which from then on outranks the layout's. */ +class SetSidewaysRemoteUseCase(private val repository: SidewaysRemoteRepository) { + suspend operator fun invoke(console: Console, enabled: Boolean) = repository.set(console, enabled) +} diff --git a/core/buttonmapping/domain/src/main/kotlin/com/joegec/joycon2android/buttonmapping/SidewaysRemoteRepository.kt b/core/buttonmapping/domain/src/main/kotlin/com/joegec/joycon2android/buttonmapping/SidewaysRemoteRepository.kt new file mode 100644 index 0000000..f9ff01d --- /dev/null +++ b/core/buttonmapping/domain/src/main/kotlin/com/joegec/joycon2android/buttonmapping/SidewaysRemoteRepository.kt @@ -0,0 +1,13 @@ +package com.joegec.joycon2android.buttonmapping + +import kotlinx.coroutines.flow.Flow + +/** + * The user's own answer to whether a console plays as a sideways Wii Remote, kept apart from the + * layout that seeds it: null until they touch the switch, and cleared again when a layout is applied. + */ +interface SidewaysRemoteRepository { + fun observe(console: Console): Flow + suspend fun set(console: Console, enabled: Boolean) + suspend fun clear(console: Console) +} diff --git a/core/buttonmapping/domain/src/main/kotlin/com/joegec/joycon2android/buttonmapping/preset/GameCubeMapping.kt b/core/buttonmapping/domain/src/main/kotlin/com/joegec/joycon2android/buttonmapping/preset/GameCubeMapping.kt new file mode 100644 index 0000000..97536fa --- /dev/null +++ b/core/buttonmapping/domain/src/main/kotlin/com/joegec/joycon2android/buttonmapping/preset/GameCubeMapping.kt @@ -0,0 +1,82 @@ +package com.joegec.joycon2android.buttonmapping.preset + +import com.joegec.joycon2android.buttonmapping.Console +import com.joegec.joycon2android.buttonmapping.JoyconSide +import com.joegec.joycon2android.buttonmapping.StickSource.LEFT_STICK +import com.joegec.joycon2android.buttonmapping.StickSource.RIGHT_STICK +import com.joegec.joycon2android.buttonmapping.StickSource +import com.joegec.joycon2android.buttonmapping.target.GameCubeButton +import com.joegec.joycon2android.buttonmapping.target.GameCubeStick +import com.joegec.joycon2android.model.JoyconButton +import com.joegec.joycon2android.model.JoyconButton.A +import com.joegec.joycon2android.model.JoyconButton.B +import com.joegec.joycon2android.model.JoyconButton.Capture +import com.joegec.joycon2android.model.JoyconButton.Down +import com.joegec.joycon2android.model.JoyconButton.Home +import com.joegec.joycon2android.model.JoyconButton.L +import com.joegec.joycon2android.model.JoyconButton.Left +import com.joegec.joycon2android.model.JoyconButton.Minus +import com.joegec.joycon2android.model.JoyconButton.Plus +import com.joegec.joycon2android.model.JoyconButton.R +import com.joegec.joycon2android.model.JoyconButton.Right +import com.joegec.joycon2android.model.JoyconButton.SlLeft +import com.joegec.joycon2android.model.JoyconButton.SlRight +import com.joegec.joycon2android.model.JoyconButton.SrLeft +import com.joegec.joycon2android.model.JoyconButton.SrRight +import com.joegec.joycon2android.model.JoyconButton.Up +import com.joegec.joycon2android.model.JoyconButton.X +import com.joegec.joycon2android.model.JoyconButton.Y + +object GameCubeMapping : MappingPreset { + override val id = "STANDARD" + override val displayName = "Standard" + override val console = Console.GAMECUBE + + override fun entries(side: JoyconSide) = buttons(side).buttonEntries() + sticks(side).stickEntries() + + private fun buttons(side: JoyconSide): Map = when (side) { + JoyconSide.DUAL -> mapOf( + GameCubeButton.A to A, + GameCubeButton.B to B, + GameCubeButton.X to X, + GameCubeButton.Y to Y, + GameCubeButton.Z to R, + GameCubeButton.Start to Plus, + GameCubeButton.TriggerL to L, + GameCubeButton.TriggerR to R, + GameCubeButton.DPadUp to Up, + GameCubeButton.DPadDown to Down, + GameCubeButton.DPadLeft to Left, + GameCubeButton.DPadRight to Right, + ) + JoyconSide.LEFT -> mapOf( + GameCubeButton.A to Down, + GameCubeButton.B to Left, + GameCubeButton.X to Right, + GameCubeButton.Y to Up, + GameCubeButton.Z to Capture, + GameCubeButton.Start to Minus, + GameCubeButton.TriggerL to SlLeft, + GameCubeButton.TriggerR to SrLeft, + ) + JoyconSide.RIGHT -> mapOf( + GameCubeButton.A to X, + GameCubeButton.B to A, + GameCubeButton.X to Y, + GameCubeButton.Y to B, + GameCubeButton.Z to Home, + GameCubeButton.Start to Plus, + GameCubeButton.TriggerL to SlRight, + GameCubeButton.TriggerR to SrRight, + ) + } + + private fun sticks(side: JoyconSide): Map = when (side) { + JoyconSide.DUAL -> mapOf( + GameCubeStick.MainStick to LEFT_STICK, + GameCubeStick.CStick to RIGHT_STICK, + ) + JoyconSide.LEFT -> mapOf(GameCubeStick.MainStick to LEFT_STICK) + JoyconSide.RIGHT -> mapOf(GameCubeStick.MainStick to RIGHT_STICK) + } +} diff --git a/core/buttonmapping/domain/src/main/kotlin/com/joegec/joycon2android/buttonmapping/preset/JoyconWiiMapping.kt b/core/buttonmapping/domain/src/main/kotlin/com/joegec/joycon2android/buttonmapping/preset/JoyconWiiMapping.kt new file mode 100644 index 0000000..ea364b6 --- /dev/null +++ b/core/buttonmapping/domain/src/main/kotlin/com/joegec/joycon2android/buttonmapping/preset/JoyconWiiMapping.kt @@ -0,0 +1,15 @@ +package com.joegec.joycon2android.buttonmapping.preset + +import com.joegec.joycon2android.buttonmapping.Console +import com.joegec.joycon2android.buttonmapping.JoyconSide +import com.joegec.joycon2android.buttonmapping.target.WiimoteButton + +/** The Wii layout with the remote's B and 2 swapped, so the Joy-Con's own B is the remote's B. */ +object JoyconWiiMapping : MappingPreset { + override val id = "JOYCON" + override val displayName = "Joy-Con" + override val console = Console.WIIMOTE_NUNCHUK + + override fun entries(side: JoyconSide) = + WiiMapping.entries(side).swappingSources(WiimoteButton.B, WiimoteButton.Two) +} diff --git a/core/buttonmapping/domain/src/main/kotlin/com/joegec/joycon2android/buttonmapping/preset/MappingEntries.kt b/core/buttonmapping/domain/src/main/kotlin/com/joegec/joycon2android/buttonmapping/preset/MappingEntries.kt new file mode 100644 index 0000000..890b80c --- /dev/null +++ b/core/buttonmapping/domain/src/main/kotlin/com/joegec/joycon2android/buttonmapping/preset/MappingEntries.kt @@ -0,0 +1,25 @@ +package com.joegec.joycon2android.buttonmapping.preset + +import com.joegec.joycon2android.buttonmapping.MappingSource +import com.joegec.joycon2android.buttonmapping.StickSource +import com.joegec.joycon2android.buttonmapping.directionKey +import com.joegec.joycon2android.buttonmapping.toSourceId +import com.joegec.joycon2android.model.JoyconButton + +internal fun > Map.buttonEntries(): Map = + entries.associate { (target, button) -> target.name to button.name } + +internal fun > Map>.sourceEntries(): Map = + entries.associate { (target, sources) -> target.name to sources.toSourceId() } + +internal fun > Map.stickEntries(): Map = + entries.flatMap { (target, stick) -> + MappingSource.directionsOf(stick).map { target.directionKey(it.direction) to it.id } + }.toMap() + +/** Leaves the layout alone unless both targets are bound, so a preset can't lose one to a swap. */ +internal fun Map.swappingSources(first: Enum<*>, second: Enum<*>): Map { + val firstSource = this[first.name] ?: return this + val secondSource = this[second.name] ?: return this + return this + mapOf(first.name to secondSource, second.name to firstSource) +} diff --git a/core/buttonmapping/domain/src/main/kotlin/com/joegec/joycon2android/buttonmapping/preset/MappingPreset.kt b/core/buttonmapping/domain/src/main/kotlin/com/joegec/joycon2android/buttonmapping/preset/MappingPreset.kt new file mode 100644 index 0000000..41e668f --- /dev/null +++ b/core/buttonmapping/domain/src/main/kotlin/com/joegec/joycon2android/buttonmapping/preset/MappingPreset.kt @@ -0,0 +1,24 @@ +package com.joegec.joycon2android.buttonmapping.preset + +import com.joegec.joycon2android.buttonmapping.Console +import com.joegec.joycon2android.buttonmapping.JoyconSide + +/** + * A named layout a console can start from: what each body maps to before the user overrides + * anything. Entries are in the repository's opaque string form, so a preset and a stored override + * are the same kind of value. + */ +sealed interface MappingPreset { + val id: String + val displayName: String + val console: Console + + /** + * Whether this layout stands a lone Joy-Con in for a Wii Remote held sideways, the way a game + * written for that grip expects one. Its motion turns onto the sideways remote's frame and its + * d-pad turns with it; a pair, held like a remote already, is untouched. + */ + val sidewaysRemote: Boolean get() = false + + fun entries(side: JoyconSide): Map +} diff --git a/core/buttonmapping/domain/src/main/kotlin/com/joegec/joycon2android/buttonmapping/preset/MappingPresets.kt b/core/buttonmapping/domain/src/main/kotlin/com/joegec/joycon2android/buttonmapping/preset/MappingPresets.kt new file mode 100644 index 0000000..ae4ffa2 --- /dev/null +++ b/core/buttonmapping/domain/src/main/kotlin/com/joegec/joycon2android/buttonmapping/preset/MappingPresets.kt @@ -0,0 +1,17 @@ +package com.joegec.joycon2android.buttonmapping.preset + +import com.joegec.joycon2android.buttonmapping.Console + +/** Every layout the app ships, and which one a console falls back to. */ +object MappingPresets { + + private val all = listOf(GameCubeMapping, WiiMapping, JoyconWiiMapping, MarioKartWiiMapping, SwitchProMapping) + + fun forConsole(console: Console): List = all.filter { it.console == console } + + fun default(console: Console): MappingPreset = forConsole(console).first() + + /** Falls back to the default for an id from a build that offered a preset this one doesn't. */ + fun byId(console: Console, id: String?): MappingPreset = + forConsole(console).firstOrNull { it.id == id } ?: default(console) +} diff --git a/core/buttonmapping/domain/src/main/kotlin/com/joegec/joycon2android/buttonmapping/preset/MarioKartWiiMapping.kt b/core/buttonmapping/domain/src/main/kotlin/com/joegec/joycon2android/buttonmapping/preset/MarioKartWiiMapping.kt new file mode 100644 index 0000000..fee8b9b --- /dev/null +++ b/core/buttonmapping/domain/src/main/kotlin/com/joegec/joycon2android/buttonmapping/preset/MarioKartWiiMapping.kt @@ -0,0 +1,73 @@ +package com.joegec.joycon2android.buttonmapping.preset + +import com.joegec.joycon2android.buttonmapping.Console +import com.joegec.joycon2android.buttonmapping.JoyconSide +import com.joegec.joycon2android.buttonmapping.MappingSource +import com.joegec.joycon2android.buttonmapping.target.WiimoteButton +import com.joegec.joycon2android.model.JoyconButton +import com.joegec.joycon2android.model.JoyconButton.A +import com.joegec.joycon2android.model.JoyconButton.B +import com.joegec.joycon2android.model.JoyconButton.Capture +import com.joegec.joycon2android.model.JoyconButton.Down +import com.joegec.joycon2android.model.JoyconButton.Home +import com.joegec.joycon2android.model.JoyconButton.Left +import com.joegec.joycon2android.model.JoyconButton.Minus +import com.joegec.joycon2android.model.JoyconButton.Plus +import com.joegec.joycon2android.model.JoyconButton.Right +import com.joegec.joycon2android.model.JoyconButton.SlLeft +import com.joegec.joycon2android.model.JoyconButton.SlRight +import com.joegec.joycon2android.model.JoyconButton.SrLeft +import com.joegec.joycon2android.model.JoyconButton.SrRight +import com.joegec.joycon2android.model.JoyconButton.Up +import com.joegec.joycon2android.model.JoyconButton.X +import com.joegec.joycon2android.model.JoyconButton.Y + +/** + * A sideways Joy-Con laid out the way Mario Kart 8 uses one, so the same thumb does the same job in + * both games: accelerate on 2, brake on 1, hop on SR. Mario Kart Wii throws an item with the d-pad, + * which a sideways body already steers from its stick, so SL fires it too — the shoulder that + * throws in Mario Kart 8. + * + * A pair keeps the [WiiMapping] layout: held two-handed there is no sideways grip to match. + * + * It is also the layout that plays as a sideways Wii Remote, which is what the wheel steers by. + */ +object MarioKartWiiMapping : MappingPreset { + override val id = "MARIO_KART" + override val displayName = "Mario Kart" + override val console = Console.WIIMOTE_NUNCHUK + override val sidewaysRemote = true + + override fun entries(side: JoyconSide) = when (side) { + JoyconSide.DUAL -> WiiMapping.entries(side) + else -> buttons(side).buttonEntries() + dPadSticks(side).sourceEntries() + } + + private fun buttons(side: JoyconSide): Map = when (side) { + JoyconSide.LEFT -> mapOf( + WiimoteButton.A to Right, + WiimoteButton.B to SrLeft, + WiimoteButton.One to Left, + WiimoteButton.Two to Down, + WiimoteButton.Home to Capture, + WiimoteButton.Plus to Minus, + WiimoteButton.Minus to Up, + ) + else -> mapOf( + WiimoteButton.A to Y, + WiimoteButton.B to SrRight, + WiimoteButton.One to A, + WiimoteButton.Two to X, + WiimoteButton.Home to Home, + WiimoteButton.Plus to Plus, + WiimoteButton.Minus to B, + ) + } + + private fun dPadSticks(side: JoyconSide): Map> { + val fromStick = WiiMapping.dPadSticks(side) + val item = MappingSource.Button(if (side == JoyconSide.LEFT) SlLeft else SlRight) + val throwItem = fromStick.getValue(WiimoteButton.DPadUp) + item + return fromStick + (WiimoteButton.DPadUp to throwItem) + } +} diff --git a/core/buttonmapping/domain/src/main/kotlin/com/joegec/joycon2android/buttonmapping/preset/SwitchProMapping.kt b/core/buttonmapping/domain/src/main/kotlin/com/joegec/joycon2android/buttonmapping/preset/SwitchProMapping.kt new file mode 100644 index 0000000..12bd982 --- /dev/null +++ b/core/buttonmapping/domain/src/main/kotlin/com/joegec/joycon2android/buttonmapping/preset/SwitchProMapping.kt @@ -0,0 +1,97 @@ +package com.joegec.joycon2android.buttonmapping.preset + +import com.joegec.joycon2android.buttonmapping.Console +import com.joegec.joycon2android.buttonmapping.JoyconSide +import com.joegec.joycon2android.buttonmapping.StickSource +import com.joegec.joycon2android.buttonmapping.StickSource.LEFT_STICK +import com.joegec.joycon2android.buttonmapping.StickSource.RIGHT_STICK +import com.joegec.joycon2android.buttonmapping.target.SwitchProButton +import com.joegec.joycon2android.buttonmapping.target.SwitchProStick +import com.joegec.joycon2android.model.JoyconButton +import com.joegec.joycon2android.model.JoyconButton.A +import com.joegec.joycon2android.model.JoyconButton.B +import com.joegec.joycon2android.model.JoyconButton.Capture +import com.joegec.joycon2android.model.JoyconButton.Down +import com.joegec.joycon2android.model.JoyconButton.Home +import com.joegec.joycon2android.model.JoyconButton.L +import com.joegec.joycon2android.model.JoyconButton.LS +import com.joegec.joycon2android.model.JoyconButton.Left +import com.joegec.joycon2android.model.JoyconButton.Minus +import com.joegec.joycon2android.model.JoyconButton.Plus +import com.joegec.joycon2android.model.JoyconButton.R +import com.joegec.joycon2android.model.JoyconButton.RS +import com.joegec.joycon2android.model.JoyconButton.Right +import com.joegec.joycon2android.model.JoyconButton.SlLeft +import com.joegec.joycon2android.model.JoyconButton.SlRight +import com.joegec.joycon2android.model.JoyconButton.SrLeft +import com.joegec.joycon2android.model.JoyconButton.SrRight +import com.joegec.joycon2android.model.JoyconButton.Up +import com.joegec.joycon2android.model.JoyconButton.X +import com.joegec.joycon2android.model.JoyconButton.Y +import com.joegec.joycon2android.model.JoyconButton.ZL +import com.joegec.joycon2android.model.JoyconButton.ZR + +object SwitchProMapping : MappingPreset { + override val id = "STANDARD" + override val displayName = "Standard" + override val console = Console.SWITCH_PRO + + override fun entries(side: JoyconSide) = buttons(side).buttonEntries() + sticks(side).stickEntries() + + private fun buttons(side: JoyconSide): Map = when (side) { + JoyconSide.DUAL -> mapOf( + SwitchProButton.A to A, + SwitchProButton.B to B, + SwitchProButton.X to X, + SwitchProButton.Y to Y, + SwitchProButton.L to L, + SwitchProButton.R to R, + SwitchProButton.ZL to ZL, + SwitchProButton.ZR to ZR, + SwitchProButton.Plus to Plus, + SwitchProButton.Minus to Minus, + SwitchProButton.Home to Home, + SwitchProButton.Capture to Capture, + SwitchProButton.LStickClick to LS, + SwitchProButton.RStickClick to RS, + SwitchProButton.DPadUp to Up, + SwitchProButton.DPadDown to Down, + SwitchProButton.DPadLeft to Left, + SwitchProButton.DPadRight to Right, + ) + // Held sideways, the rail buttons are the shoulder pair, as they are on a real Switch. + // ZL/ZR stay unbound: the body's own shoulders point away from the player in that grip, so + // there is nothing honest to put there — the user can bind them if they want them. + JoyconSide.LEFT -> mapOf( + SwitchProButton.A to Down, + SwitchProButton.B to Left, + SwitchProButton.X to Right, + SwitchProButton.Y to Up, + SwitchProButton.L to SlLeft, + SwitchProButton.R to SrLeft, + SwitchProButton.Minus to Minus, + SwitchProButton.LStickClick to LS, + SwitchProButton.Capture to Capture, + ) + JoyconSide.RIGHT -> mapOf( + SwitchProButton.A to X, + SwitchProButton.B to A, + SwitchProButton.X to Y, + SwitchProButton.Y to B, + SwitchProButton.L to SlRight, + SwitchProButton.R to SrRight, + SwitchProButton.Plus to Plus, + SwitchProButton.Home to Home, + SwitchProButton.LStickClick to RS, + ) + } + + private fun sticks(side: JoyconSide): Map = when (side) { + JoyconSide.DUAL -> mapOf( + SwitchProStick.LStick to LEFT_STICK, + SwitchProStick.RStick to RIGHT_STICK, + ) + JoyconSide.LEFT -> mapOf(SwitchProStick.LStick to LEFT_STICK) + JoyconSide.RIGHT -> mapOf(SwitchProStick.LStick to RIGHT_STICK) + } +} diff --git a/core/buttonmapping/domain/src/main/kotlin/com/joegec/joycon2android/buttonmapping/preset/WiiMapping.kt b/core/buttonmapping/domain/src/main/kotlin/com/joegec/joycon2android/buttonmapping/preset/WiiMapping.kt new file mode 100644 index 0000000..2477a4b --- /dev/null +++ b/core/buttonmapping/domain/src/main/kotlin/com/joegec/joycon2android/buttonmapping/preset/WiiMapping.kt @@ -0,0 +1,97 @@ +package com.joegec.joycon2android.buttonmapping.preset + +import com.joegec.joycon2android.buttonmapping.Console +import com.joegec.joycon2android.buttonmapping.JoyconSide +import com.joegec.joycon2android.buttonmapping.MappingSource +import com.joegec.joycon2android.buttonmapping.StickDirection +import com.joegec.joycon2android.buttonmapping.StickSource +import com.joegec.joycon2android.buttonmapping.StickSource.LEFT_STICK +import com.joegec.joycon2android.buttonmapping.StickSource.RIGHT_STICK +import com.joegec.joycon2android.buttonmapping.target.WiimoteButton +import com.joegec.joycon2android.buttonmapping.target.WiimoteStick +import com.joegec.joycon2android.model.JoyconButton +import com.joegec.joycon2android.model.JoyconButton.A +import com.joegec.joycon2android.model.JoyconButton.B +import com.joegec.joycon2android.model.JoyconButton.Capture +import com.joegec.joycon2android.model.JoyconButton.Down +import com.joegec.joycon2android.model.JoyconButton.Home +import com.joegec.joycon2android.model.JoyconButton.L +import com.joegec.joycon2android.model.JoyconButton.Left +import com.joegec.joycon2android.model.JoyconButton.Minus +import com.joegec.joycon2android.model.JoyconButton.Plus +import com.joegec.joycon2android.model.JoyconButton.Right +import com.joegec.joycon2android.model.JoyconButton.Up +import com.joegec.joycon2android.model.JoyconButton.X +import com.joegec.joycon2android.model.JoyconButton.Y +import com.joegec.joycon2android.model.JoyconButton.ZL +import com.joegec.joycon2android.model.JoyconButton.ZR + +/** The Wii Remote's own layout: the trigger under the finger is B, and 1 and 2 sit under the thumb. */ +object WiiMapping : MappingPreset { + override val id = "WII" + override val displayName = "Wii" + override val console = Console.WIIMOTE_NUNCHUK + + override fun entries(side: JoyconSide) = + buttons(side).buttonEntries() + dPadSticks(side).sourceEntries() + nunchukStick(side).stickEntries() + + internal fun buttons(side: JoyconSide): Map = when (side) { + JoyconSide.DUAL -> mapOf( + WiimoteButton.A to A, + WiimoteButton.B to ZR, + WiimoteButton.One to Y, + WiimoteButton.Two to B, + WiimoteButton.Home to Home, + WiimoteButton.Plus to Plus, + WiimoteButton.Minus to X, + WiimoteButton.DPadUp to Up, + WiimoteButton.DPadDown to Down, + WiimoteButton.DPadLeft to Left, + WiimoteButton.DPadRight to Right, + WiimoteButton.NunchukC to L, + WiimoteButton.NunchukZ to ZL, + ) + JoyconSide.LEFT -> mapOf( + WiimoteButton.A to Down, + WiimoteButton.B to ZL, + WiimoteButton.One to Up, + WiimoteButton.Two to Left, + WiimoteButton.Home to Capture, + WiimoteButton.Plus to Right, + WiimoteButton.Minus to Minus, + ) + JoyconSide.RIGHT -> mapOf( + WiimoteButton.A to A, + WiimoteButton.B to ZR, + WiimoteButton.One to Y, + WiimoteButton.Two to B, + WiimoteButton.Home to Home, + WiimoteButton.Plus to Plus, + WiimoteButton.Minus to X, + ) + } + + // A sideways Joy-Con has no d-pad left once its cluster becomes the face buttons, so its stick + // steers the Wii Remote's d-pad instead. + internal fun dPadSticks(side: JoyconSide): Map> { + val stick = when (side) { + JoyconSide.DUAL -> return emptyMap() + JoyconSide.LEFT -> LEFT_STICK + JoyconSide.RIGHT -> RIGHT_STICK + } + return mapOf( + WiimoteButton.DPadUp to tilt(stick, StickDirection.UP), + WiimoteButton.DPadDown to tilt(stick, StickDirection.DOWN), + WiimoteButton.DPadLeft to tilt(stick, StickDirection.LEFT), + WiimoteButton.DPadRight to tilt(stick, StickDirection.RIGHT), + ) + } + + internal fun nunchukStick(side: JoyconSide): Map = when (side) { + JoyconSide.DUAL -> mapOf(WiimoteStick.NunchukStick to LEFT_STICK) + else -> emptyMap() + } + + private fun tilt(stick: StickSource, direction: StickDirection) = + listOf(MappingSource.Stick(stick, direction)) +} diff --git a/core/buttonmapping/domain/src/test/kotlin/com/joegec/joycon2android/buttonmapping/ObserveControllerMappingUseCaseTest.kt b/core/buttonmapping/domain/src/test/kotlin/com/joegec/joycon2android/buttonmapping/ObserveControllerMappingUseCaseTest.kt index 08797a2..1407da9 100644 --- a/core/buttonmapping/domain/src/test/kotlin/com/joegec/joycon2android/buttonmapping/ObserveControllerMappingUseCaseTest.kt +++ b/core/buttonmapping/domain/src/test/kotlin/com/joegec/joycon2android/buttonmapping/ObserveControllerMappingUseCaseTest.kt @@ -1,5 +1,6 @@ package com.joegec.joycon2android.buttonmapping +import com.joegec.joycon2android.buttonmapping.preset.MarioKartWiiMapping import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.flowOf @@ -15,8 +16,21 @@ class ObserveControllerMappingUseCaseTest { override suspend fun clear(console: Console, side: JoyconSide) = Unit } - private fun observe(stored: Map, side: JoyconSide = JoyconSide.DUAL) = runBlocking { - ObserveControllerMappingUseCase(StoredMapping(stored))(Console.GAMECUBE, side).first() + private class StoredPreset(private val presetId: String? = null) : MappingPresetRepository { + override fun observe(console: Console): Flow = flowOf(presetId) + override suspend fun set(console: Console, presetId: String) = Unit + } + + private fun observe( + stored: Map, + side: JoyconSide = JoyconSide.DUAL, + console: Console = Console.GAMECUBE, + presetId: String? = null, + ) = runBlocking { + ObserveControllerMappingUseCase( + StoredMapping(stored), + ObserveMappingPresetUseCase(StoredPreset(presetId)), + )(console, side).first() } @Test @@ -57,6 +71,21 @@ class ObserveControllerMappingUseCaseTest { assertEquals("Capture", mapping["A"]) } + @Test + fun `the chosen preset supplies the defaults`() { + val mapping = observe(emptyMap(), JoyconSide.RIGHT, Console.WIIMOTE_NUNCHUK, MarioKartWiiMapping.id) + + assertEquals("X", mapping["Two"]) + assertEquals("RIGHT_STICK_UP|SlRight", mapping["DPadUp"]) + } + + @Test + fun `a preset id from another build falls back to the console's default`() { + val mapping = observe(emptyMap(), JoyconSide.RIGHT, Console.WIIMOTE_NUNCHUK, "NO_SUCH_PRESET") + + assertEquals("B", mapping["Two"]) + } + @Test fun `a direction set to None overrides its default`() { val mapping = observe(mapOf("CStick_LEFT" to "")) diff --git a/core/buttonmapping/domain/src/test/kotlin/com/joegec/joycon2android/buttonmapping/SidewaysRemoteTest.kt b/core/buttonmapping/domain/src/test/kotlin/com/joegec/joycon2android/buttonmapping/SidewaysRemoteTest.kt new file mode 100644 index 0000000..6ba3dc2 --- /dev/null +++ b/core/buttonmapping/domain/src/test/kotlin/com/joegec/joycon2android/buttonmapping/SidewaysRemoteTest.kt @@ -0,0 +1,60 @@ +package com.joegec.joycon2android.buttonmapping + +import com.joegec.joycon2android.buttonmapping.preset.MarioKartWiiMapping +import com.joegec.joycon2android.buttonmapping.preset.WiiMapping +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.runBlocking +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test + +class SidewaysRemoteTest { + + private class Chosen(private val enabled: Boolean? = null) : SidewaysRemoteRepository { + var cleared = false + override fun observe(console: Console): Flow = flowOf(enabled) + override suspend fun set(console: Console, enabled: Boolean) = Unit + override suspend fun clear(console: Console) { cleared = true } + } + + private class StoredPreset(private val presetId: String?) : MappingPresetRepository { + override fun observe(console: Console): Flow = flowOf(presetId) + override suspend fun set(console: Console, presetId: String) = Unit + } + + private class StoredMapping : ControllerMappingRepository { + override fun observe(console: Console, side: JoyconSide): Flow> = flowOf(emptyMap()) + override suspend fun set(console: Console, side: JoyconSide, targetKey: String, sourceId: String) = Unit + override suspend fun clear(console: Console, side: JoyconSide) = Unit + } + + private fun observe(chosen: Boolean?, presetId: String?) = runBlocking { + ObserveSidewaysRemoteUseCase( + Chosen(chosen), + ObserveMappingPresetUseCase(StoredPreset(presetId)), + )(Console.WIIMOTE_NUNCHUK).first() + } + + @Test + fun `the layout decides until the user does`() { + assertTrue(observe(chosen = null, presetId = MarioKartWiiMapping.id)) + assertEquals(false, observe(chosen = null, presetId = WiiMapping.id)) + } + + @Test + fun `the user's switch outranks the layout, either way`() { + assertEquals(false, observe(chosen = false, presetId = MarioKartWiiMapping.id)) + assertTrue(observe(chosen = true, presetId = WiiMapping.id)) + } + + @Test + fun `applying a layout hands the switch back to it`() = runBlocking { + val chosen = Chosen(enabled = true) + + ApplyMappingPresetUseCase(StoredPreset(null), StoredMapping(), chosen)(Console.WIIMOTE_NUNCHUK, WiiMapping.id) + + assertTrue(chosen.cleared) + } +} diff --git a/core/buttonmapping/domain/src/test/kotlin/com/joegec/joycon2android/buttonmapping/WholeEmittedStickTest.kt b/core/buttonmapping/domain/src/test/kotlin/com/joegec/joycon2android/buttonmapping/WholeEmittedStickTest.kt index de3cd1c..c44d34b 100644 --- a/core/buttonmapping/domain/src/test/kotlin/com/joegec/joycon2android/buttonmapping/WholeEmittedStickTest.kt +++ b/core/buttonmapping/domain/src/test/kotlin/com/joegec/joycon2android/buttonmapping/WholeEmittedStickTest.kt @@ -7,7 +7,8 @@ import org.junit.Test class WholeEmittedStickTest { - private fun following(stick: StickSource) = MappingSource.directionsOf(stick).associateBy { it.direction } + private fun following(stick: StickSource): Map> = + MappingSource.directionsOf(stick).associate { it.direction to listOf(it) } @Test fun `directions that follow one stick the natural way read as that whole stick`() { @@ -22,8 +23,8 @@ class WholeEmittedStickTest { @Test fun `swapped directions are not a whole stick`() { val swapped = following(StickSource.LEFT_STICK) + mapOf( - StickDirection.UP to MappingSource.Stick(StickSource.LEFT_STICK, StickDirection.DOWN), - StickDirection.DOWN to MappingSource.Stick(StickSource.LEFT_STICK, StickDirection.UP), + StickDirection.UP to listOf(MappingSource.Stick(StickSource.LEFT_STICK, StickDirection.DOWN)), + StickDirection.DOWN to listOf(MappingSource.Stick(StickSource.LEFT_STICK, StickDirection.UP)), ) assertNull(swapped.wholeEmittedStick(JoyconSide.DUAL)) @@ -31,9 +32,22 @@ class WholeEmittedStickTest { @Test fun `a direction driven by a button or left unbound is not a whole stick`() { - val withButton = following(StickSource.LEFT_STICK) + (StickDirection.UP to MappingSource.Button(JoyconButton.X)) + val withButton = following(StickSource.LEFT_STICK) + + (StickDirection.UP to listOf(MappingSource.Button(JoyconButton.X))) assertNull(withButton.wholeEmittedStick(JoyconSide.DUAL)) assertNull((following(StickSource.LEFT_STICK) - StickDirection.LEFT).wholeEmittedStick(JoyconSide.DUAL)) } + + @Test + fun `a direction with a second source of its own is not a whole stick`() { + val doubled = following(StickSource.LEFT_STICK) + ( + StickDirection.UP to listOf( + MappingSource.Stick(StickSource.LEFT_STICK, StickDirection.UP), + MappingSource.Button(JoyconButton.X), + ) + ) + + assertNull(doubled.wholeEmittedStick(JoyconSide.DUAL)) + } } diff --git a/core/buttonmapping/domain/src/test/kotlin/com/joegec/joycon2android/buttonmapping/preset/WiiPresetsTest.kt b/core/buttonmapping/domain/src/test/kotlin/com/joegec/joycon2android/buttonmapping/preset/WiiPresetsTest.kt new file mode 100644 index 0000000..48d2983 --- /dev/null +++ b/core/buttonmapping/domain/src/test/kotlin/com/joegec/joycon2android/buttonmapping/preset/WiiPresetsTest.kt @@ -0,0 +1,78 @@ +package com.joegec.joycon2android.buttonmapping.preset + +import com.joegec.joycon2android.buttonmapping.Console +import com.joegec.joycon2android.buttonmapping.JoyconSide +import com.joegec.joycon2android.buttonmapping.target.WiimoteButton +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +class WiiPresetsTest { + + @Test + fun `the Wii layout is what the console starts on`() { + assertEquals(WiiMapping, MappingPresets.default(Console.WIIMOTE_NUNCHUK)) + } + + @Test + fun `the Joy-Con layout swaps the remote's B and 2 on every body`() { + JoyconSide.entries.forEach { side -> + val wii = WiiMapping.entries(side) + val joycon = JoyconWiiMapping.entries(side) + + assertEquals("B on $side", wii.getValue(WiimoteButton.Two.name), joycon.getValue(WiimoteButton.B.name)) + assertEquals("2 on $side", wii.getValue(WiimoteButton.B.name), joycon.getValue(WiimoteButton.Two.name)) + } + } + + @Test + fun `Mario Kart accelerates and brakes on the buttons Mario Kart 8 uses`() { + val right = MarioKartWiiMapping.entries(JoyconSide.RIGHT) + + assertEquals("X", right.getValue(WiimoteButton.Two.name)) + assertEquals("A", right.getValue(WiimoteButton.One.name)) + assertEquals("SrRight", right.getValue(WiimoteButton.B.name)) + } + + @Test + fun `Mario Kart puts both bodies' jobs under the same thumb positions`() { + val left = MarioKartWiiMapping.entries(JoyconSide.LEFT) + + // Sideways, the left Joy-Con's Down sits where the right's X does, Left where its A does, + // Right where its Y does and Up where its B does (see SidewaysMapper). + assertEquals("Down", left.getValue(WiimoteButton.Two.name)) + assertEquals("Left", left.getValue(WiimoteButton.One.name)) + assertEquals("Right", left.getValue(WiimoteButton.A.name)) + assertEquals("Up", left.getValue(WiimoteButton.Minus.name)) + assertEquals("Minus", left.getValue(WiimoteButton.Plus.name)) + } + + @Test + fun `Mario Kart throws an item from SL as well as the stick`() { + assertEquals("RIGHT_STICK_UP|SlRight", MarioKartWiiMapping.entries(JoyconSide.RIGHT).getValue("DPadUp")) + assertEquals("LEFT_STICK_UP|SlLeft", MarioKartWiiMapping.entries(JoyconSide.LEFT).getValue("DPadUp")) + assertEquals("LEFT_STICK_DOWN", MarioKartWiiMapping.entries(JoyconSide.LEFT).getValue("DPadDown")) + } + + @Test + fun `only the Mario Kart layout plays as a sideways Wii Remote`() { + assertTrue(MarioKartWiiMapping.sidewaysRemote) + assertFalse(WiiMapping.sidewaysRemote) + assertFalse(JoyconWiiMapping.sidewaysRemote) + } + + @Test + fun `a pair has no sideways grip to match, so Mario Kart leaves it on the Wii layout`() { + assertEquals(WiiMapping.entries(JoyconSide.DUAL), MarioKartWiiMapping.entries(JoyconSide.DUAL)) + } + + @Test + fun `every Wii layout binds the whole remote on a lone Joy-Con`() { + val remote = (WiimoteButton.entries - WiimoteButton.NunchukC - WiimoteButton.NunchukZ).map { it.name } + + MappingPresets.forConsole(Console.WIIMOTE_NUNCHUK).forEach { preset -> + assertTrue("${preset.displayName} binds the remote", preset.entries(JoyconSide.RIGHT).keys.containsAll(remote)) + } + } +} diff --git a/core/buttonmapping/presentation/src/main/kotlin/com/joegec/joycon2android/buttonmapping/presentation/ControllerMappingScreen.kt b/core/buttonmapping/presentation/src/main/kotlin/com/joegec/joycon2android/buttonmapping/presentation/ControllerMappingScreen.kt index 74390bb..65c1409 100644 --- a/core/buttonmapping/presentation/src/main/kotlin/com/joegec/joycon2android/buttonmapping/presentation/ControllerMappingScreen.kt +++ b/core/buttonmapping/presentation/src/main/kotlin/com/joegec/joycon2android/buttonmapping/presentation/ControllerMappingScreen.kt @@ -28,18 +28,26 @@ import androidx.compose.ui.graphics.Color import androidx.compose.ui.res.stringResource import com.joegec.joycon2android.buttonmapping.Console import com.joegec.joycon2android.buttonmapping.JoyconSide +import com.joegec.joycon2android.buttonmapping.sourceIdOf +import com.joegec.joycon2android.buttonmapping.sourceIdsOf import com.joegec.joycon2android.core.buttonmapping.presentation.R import com.joegec.joycon2android.ui.components.ExpandableInfoSection import com.joegec.joycon2android.ui.components.LabeledDropdown +import com.joegec.joycon2android.ui.components.MultiSelectDropdown +import com.joegec.joycon2android.ui.components.SettingSwitch import com.joegec.joycon2android.ui.theme.Dimens import com.joegec.joycon2android.ui.theme.TextDim @Composable fun ControllerMappingScreen( console: Console, + presetId: String, + sidewaysRemote: Boolean, leftMapping: Map, rightMapping: Map, dualMapping: Map, + onSelectPreset: (presetId: String) -> Unit, + onSetSidewaysRemote: (enabled: Boolean) -> Unit, onSetMapping: (side: JoyconSide, targetKey: String, sourceId: String) -> Unit, onResetMapping: (side: JoyconSide) -> Unit, onBack: () -> Unit, @@ -65,6 +73,8 @@ fun ControllerMappingScreen( .verticalScroll(rememberScrollState()), verticalArrangement = Arrangement.spacedBy(Dimens.sectionSpacing), ) { + PresetRow(console, presetId, onSelectPreset) + SidewaysRemoteSwitch(console, sidewaysRemote, onSetSidewaysRemote) ExpandableInfoSection(JoyconSide.LEFT.displayName) { MappingSection(console, JoyconSide.LEFT, leftMapping, onSetMapping, onResetMapping) } @@ -79,6 +89,41 @@ fun ControllerMappingScreen( } } +/** Only consoles with a layout to choose between show the row. */ +@Composable +private fun PresetRow(console: Console, presetId: String, onSelectPreset: (String) -> Unit) { + val presets = MappingOptions.presets(console) + if (presets.size < 2) return + Row( + Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(Dimens.elementSpacing), + verticalAlignment = Alignment.CenterVertically, + ) { + Text(stringResource(R.string.controller_mapping_preset), color = TextDim, modifier = Modifier.weight(1f)) + LabeledDropdown( + options = presets, + selectedId = presetId, + onSelect = onSelectPreset, + modifier = Modifier.weight(1f), + ) + } +} + +/** + * A lone Joy-Con stands in for a Wii Remote held sideways: what a wheel game steers by, and what + * turns its d-pad. A layout sets it; this is the user having the last word. + */ +@Composable +private fun SidewaysRemoteSwitch(console: Console, enabled: Boolean, onSetEnabled: (Boolean) -> Unit) { + if (!MappingOptions.offersSidewaysRemote(console)) return + SettingSwitch( + title = stringResource(R.string.controller_mapping_sideways_remote), + description = stringResource(R.string.controller_mapping_sideways_remote_description), + checked = enabled, + onCheckedChange = onSetEnabled, + ) +} + @Composable private fun MappingSection( console: Console, @@ -90,7 +135,10 @@ private fun MappingSection( Column(verticalArrangement = Arrangement.spacedBy(Dimens.elementSpacing)) { val sourceOptions = MappingOptions.sources(side) (MappingOptions.buttonTargets(console) + MappingOptions.stickDirectionTargets(console)).forEach { (key, label) -> - MappingRow(label, mapping[key] ?: MappingOptions.NONE_ID, sourceOptions) { onSetMapping(side, key, it) } + val selectedIds = sourceIdsOf(mapping[key].orEmpty()) + MappingRow(label, selectedIds, sourceOptions) { toggled -> + onSetMapping(side, key, sourceIdOf(selectedIds.toggling(toggled))) + } } TextButton(onClick = { onResetMapping(side) }) { Text(stringResource(R.string.controller_mapping_reset)) @@ -101,9 +149,9 @@ private fun MappingSection( @Composable private fun MappingRow( label: String, - selectedId: String, + selectedIds: List, options: List>, - onSelect: (String) -> Unit, + onToggle: (String) -> Unit, ) { Row( Modifier.fillMaxWidth(), @@ -111,11 +159,18 @@ private fun MappingRow( verticalAlignment = Alignment.CenterVertically, ) { Text(label, color = TextDim, modifier = Modifier.weight(1f)) - LabeledDropdown( + MultiSelectDropdown( options = options, - selectedId = selectedId, - onSelect = onSelect, + selectedIds = selectedIds, + onToggle = onToggle, modifier = Modifier.weight(1f), ) } } + +/** Any source can fire a target, so picking one adds it; picking "None" empties the row. */ +private fun List.toggling(sourceId: String): List = when { + sourceId == MappingOptions.NONE_ID -> emptyList() + sourceId in this -> this - sourceId + else -> this + sourceId +} diff --git a/core/buttonmapping/presentation/src/main/kotlin/com/joegec/joycon2android/buttonmapping/presentation/ControllerMappingViewModel.kt b/core/buttonmapping/presentation/src/main/kotlin/com/joegec/joycon2android/buttonmapping/presentation/ControllerMappingViewModel.kt index 414dd8e..d80349d 100644 --- a/core/buttonmapping/presentation/src/main/kotlin/com/joegec/joycon2android/buttonmapping/presentation/ControllerMappingViewModel.kt +++ b/core/buttonmapping/presentation/src/main/kotlin/com/joegec/joycon2android/buttonmapping/presentation/ControllerMappingViewModel.kt @@ -2,13 +2,19 @@ package com.joegec.joycon2android.buttonmapping.presentation import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope +import com.joegec.joycon2android.buttonmapping.ApplyMappingPresetUseCase import com.joegec.joycon2android.buttonmapping.Console import com.joegec.joycon2android.buttonmapping.JoyconSide import com.joegec.joycon2android.buttonmapping.ObserveControllerMappingUseCase +import com.joegec.joycon2android.buttonmapping.ObserveMappingPresetUseCase +import com.joegec.joycon2android.buttonmapping.ObserveSidewaysRemoteUseCase import com.joegec.joycon2android.buttonmapping.ResetControllerMappingUseCase import com.joegec.joycon2android.buttonmapping.SetControllerMappingUseCase +import com.joegec.joycon2android.buttonmapping.SetSidewaysRemoteUseCase +import com.joegec.joycon2android.buttonmapping.preset.MappingPresets import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch @@ -17,9 +23,15 @@ class ControllerMappingViewModel( private val observeControllerMapping: ObserveControllerMappingUseCase, private val setControllerMapping: SetControllerMappingUseCase, private val resetControllerMapping: ResetControllerMappingUseCase, + private val observeMappingPreset: ObserveMappingPresetUseCase, + private val applyMappingPreset: ApplyMappingPresetUseCase, + private val observeSidewaysRemote: ObserveSidewaysRemoteUseCase, + private val setSidewaysRemote: SetSidewaysRemoteUseCase, ) : ViewModel() { private val mappingFlows = mutableMapOf, StateFlow>>() + private val presetFlows = mutableMapOf>() + private val sidewaysRemoteFlows = mutableMapOf>() fun mapping(console: Console, side: JoyconSide): StateFlow> = mappingFlows.getOrPut(console to side) { @@ -27,10 +39,39 @@ class ControllerMappingViewModel( .stateIn(viewModelScope, SharingStarted.WhileSubscribed(STOP_TIMEOUT_MS), emptyMap()) } + fun preset(console: Console): StateFlow = + presetFlows.getOrPut(console) { + observeMappingPreset(console) + .map { it.id } + .stateIn( + viewModelScope, + SharingStarted.WhileSubscribed(STOP_TIMEOUT_MS), + MappingPresets.default(console).id, + ) + } + + fun sidewaysRemote(console: Console): StateFlow = + sidewaysRemoteFlows.getOrPut(console) { + observeSidewaysRemote(console) + .stateIn( + viewModelScope, + SharingStarted.WhileSubscribed(STOP_TIMEOUT_MS), + MappingPresets.default(console).sidewaysRemote, + ) + } + + fun setSidewaysRemoteEnabled(console: Console, enabled: Boolean) { + viewModelScope.launch { setSidewaysRemote(console, enabled) } + } + fun setMapping(console: Console, side: JoyconSide, targetKey: String, sourceId: String) { viewModelScope.launch { setControllerMapping(console, side, targetKey, sourceId) } } + fun selectPreset(console: Console, presetId: String) { + viewModelScope.launch { applyMappingPreset(console, presetId) } + } + fun resetMapping(console: Console, side: JoyconSide) { viewModelScope.launch { resetControllerMapping(console, side) } } diff --git a/core/buttonmapping/presentation/src/main/kotlin/com/joegec/joycon2android/buttonmapping/presentation/MappingOptions.kt b/core/buttonmapping/presentation/src/main/kotlin/com/joegec/joycon2android/buttonmapping/presentation/MappingOptions.kt index 7896f3b..94da380 100644 --- a/core/buttonmapping/presentation/src/main/kotlin/com/joegec/joycon2android/buttonmapping/presentation/MappingOptions.kt +++ b/core/buttonmapping/presentation/src/main/kotlin/com/joegec/joycon2android/buttonmapping/presentation/MappingOptions.kt @@ -6,6 +6,7 @@ import com.joegec.joycon2android.buttonmapping.MappingSource import com.joegec.joycon2android.buttonmapping.StickDirection import com.joegec.joycon2android.buttonmapping.StickSource import com.joegec.joycon2android.buttonmapping.directionKey +import com.joegec.joycon2android.buttonmapping.preset.MappingPresets import com.joegec.joycon2android.buttonmapping.target.GameCubeButton import com.joegec.joycon2android.buttonmapping.target.GameCubeStick import com.joegec.joycon2android.buttonmapping.target.SwitchProButton @@ -18,6 +19,12 @@ import com.joegec.joycon2android.model.JoyconButton internal object MappingOptions { const val NONE_ID = "" + fun presets(console: Console): List> = + MappingPresets.forConsole(console).map { it.id to it.displayName } + + /** Only a Wii Remote can be held sideways in the sense the switch means. */ + fun offersSidewaysRemote(console: Console) = console == Console.WIIMOTE_NUNCHUK + fun buttonTargets(console: Console): List> = when (console) { Console.GAMECUBE -> GameCubeButton.entries.map { it.name to it.displayName } Console.WIIMOTE_NUNCHUK -> WiimoteButton.entries.map { it.name to it.displayName } diff --git a/core/buttonmapping/presentation/src/main/res/values/strings.xml b/core/buttonmapping/presentation/src/main/res/values/strings.xml index 232fb21..9d2ba5c 100644 --- a/core/buttonmapping/presentation/src/main/res/values/strings.xml +++ b/core/buttonmapping/presentation/src/main/res/values/strings.xml @@ -1,4 +1,7 @@ Back + Layout + Sideways Wii Remote + Play a single Joy-Con as a Wii Remote held sideways, the way Mario Kart\'s wheel expects. Steering reads correctly and the D-pad turns with it; a right Joy-Con then points from its tail rather than its R edge. Reset to defaults diff --git a/core/designsystem/src/main/kotlin/com/joegec/joycon2android/ui/components/MultiSelectDropdown.kt b/core/designsystem/src/main/kotlin/com/joegec/joycon2android/ui/components/MultiSelectDropdown.kt new file mode 100644 index 0000000..bac32fe --- /dev/null +++ b/core/designsystem/src/main/kotlin/com/joegec/joycon2android/ui/components/MultiSelectDropdown.kt @@ -0,0 +1,87 @@ +package com.joegec.joycon2android.ui.components + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.ArrowDropDown +import androidx.compose.material.icons.filled.Check +import androidx.compose.material3.DropdownMenu +import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.Icon +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.text.font.FontWeight +import com.joegec.joycon2android.ui.theme.Accent +import com.joegec.joycon2android.ui.theme.Dimens + +/** + * Id/label picker for a row that can hold several choices at once. The menu stays open while they + * are ticked off; tapping outside closes it. With nothing selected it reads as the first option, + * which callers put there as their "none" row. + */ +@Composable +fun MultiSelectDropdown( + options: List>, + selectedIds: List, + onToggle: (String) -> Unit, + modifier: Modifier = Modifier, +) { + val selectedLabels = selectedIds.mapNotNull { id -> options.firstOrNull { it.first == id }?.second } + val label = selectedLabels.takeIf { it.isNotEmpty() }?.joinToString(" + ") + ?: options.firstOrNull()?.second + ?: return + var expanded by remember { mutableStateOf(false) } + + Box(modifier) { + Row( + Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(Dimens.buttonCorner)) + .clickable { expanded = true } + .padding(horizontal = Dimens.pillPaddingHorizontal, vertical = Dimens.pillPaddingVertical), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + label, + color = Accent, + fontSize = Dimens.fontSizeSmall, + fontWeight = FontWeight.Bold, + modifier = Modifier.weight(1f), + ) + Icon(Icons.Filled.ArrowDropDown, contentDescription = null, tint = Accent) + } + DropdownMenu(expanded = expanded, onDismissRequest = { expanded = false }) { + options.forEach { (id, text) -> + DropdownMenuItem( + text = { Text(text) }, + leadingIcon = { SelectionTick(selected = id in selectedIds) }, + onClick = { onToggle(id) }, + ) + } + } + } +} + +@Composable +private fun SelectionTick(selected: Boolean) { + if (selected) { + Icon(Icons.Filled.Check, contentDescription = null, tint = Accent) + } else { + Spacer(Modifier.size(Dimens.iconSizeMedium)) + } +} diff --git a/docs/architecture.md b/docs/architecture.md index d2c1ae0..5640259 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -77,9 +77,11 @@ locations), and `EdenControls` (the `[Controls]` vocabulary both features write) gamepad and DSU features both write to Dolphin and Eden. Holds *mechanism*, not feature logic — the per-emulator config generators live in their owning feature's `domain`. -**`:core:buttonmapping`** — the user-editable Joy-Con → emulator button mapping: the per-console -defaults and mapping model (`domain`), their persistence (`data`), and the mapping editor -(`presentation`). Both the gamepad and DSU config generators read it. +**`:core:buttonmapping`** — the user-editable Joy-Con → emulator button mapping: the mapping model, +the layouts a console can start from and the sideways-remote switch they seed (`domain`, layouts in +`preset/`), their persistence (`data`), and the mapping editor (`presentation`). Both the gamepad and DSU config generators read it. A target +holds *every* source bound to it, so Dolphin ORs them into one expression while Eden, which binds +one input per key, keeps the first. ## Dependency rules diff --git a/docs/dsu-motion.md b/docs/dsu-motion.md index f99a224..a7adc5b 100644 --- a/docs/dsu-motion.md +++ b/docs/dsu-motion.md @@ -53,10 +53,30 @@ if the Joy-Con's nose pointed at the screen. the IMU axes don't line up with the stick's. - **A pair's second hand isn't turned**, even though it streams alone on its slot (`DsuStream.heldSideways`). - **Dolphin maps it back.** Its emulated Wii Remote is the Joy-Con's own body, so - `DolphinWiimoteConfig` swaps a lone Joy-Con's IMU inputs back (table in the - [README](../README.md#manual-setup)). -- **Don't enable Dolphin's "Sideways Wii Remote"** with that mapping. It rotates IMU input by 90° - itself (`Wiimote::GetOrientation`), so it would turn motion twice. + `DolphinWiimoteConfig` turns a lone Joy-Con's IMU inputs back about the button face (table in the + [README](../README.md#manual-setup)), putting the nose on the shoulder edge the player aims. The + bodies rotate into their grips opposite ways, so their tables are each other half a turn. +- **A console can play as a sideways Wii Remote** — a switch in the mapping editor, seeded by the + layout (`MappingPreset.sidewaysRemote`, true only for **Mario Kart**) and overridable by the user + (`SidewaysRemoteRepository`; applying a layout clears the override). A game written for that grip reads gravity against a remote whose nose points + left, which is where a *left* Joy-Con's L/ZL edge already points — so only a right Joy-Con turns, + giving up its own body (and with it R/ZR as the nose: aiming moves to the tail) to steer true. + Both bodies also turn their four D-pad bindings a quarter, since the player's up is a sideways + remote's right. That is Dolphin's own `dpad_sideways_bitmasks`, applied here so its *Sideways Wii + Remote* option can stay off — the option would also turn the accelerometer, which we have turned + already. +- **Pointing and a wheel want the nose half a turn apart on a right Joy-Con**, and no Dolphin option + bridges them: `GetOrientation()` turns a quarter (Sideways) or a quarter about the left axis + (Upright), and it reaches only the accelerometer the game reads, never + `GetTotalTransformation()` and so never the pointer. Hence the choice lives in the layout. +- **Measure the pointer, don't reason about it.** `tools/dsu_client` plus a replay of + `EmulateIMUCursor` settles in minutes what guessing costs days. Posed captures mislead: asked to + hold an "aim up", a player produces a different rotation from the one they make while playing — + compare a captured session against candidate tables by how much cursor travel each yields. +- **`IMUIR/Total Yaw` is widened to 60°.** Dolphin's 25° clamps the cursor after ±12.5° of turn, + which a hand-held aim overruns constantly; the clamp reads as the pointer sticking. +- **Leave Dolphin's "Sideways Wii Remote" off.** A sideways layout already writes that quarter turn + itself, into both the motion and the D-pad; the option would apply it twice. ## Report rate diff --git a/feature/dsu/domain/src/main/kotlin/com/joegec/joycon2android/dsu/emulator/DolphinWiimoteConfig.kt b/feature/dsu/domain/src/main/kotlin/com/joegec/joycon2android/dsu/emulator/DolphinWiimoteConfig.kt index d69a31a..9943d98 100644 --- a/feature/dsu/domain/src/main/kotlin/com/joegec/joycon2android/dsu/emulator/DolphinWiimoteConfig.kt +++ b/feature/dsu/domain/src/main/kotlin/com/joegec/joycon2android/dsu/emulator/DolphinWiimoteConfig.kt @@ -29,6 +29,11 @@ import com.joegec.joycon2android.model.PlayerState object DolphinWiimoteConfig { val path = DolphinPaths.config("WiimoteNew.ini") + // Dolphin clamps the pointer's accumulated yaw to half of this, and its 25 degrees is a living + // room's worth: a captured aiming session (2026-09) swung +-20 to 25, so the cursor spent its + // time pinned against the clamp. Recenter (below) is what pulls it back when it drifts. + private const val IMU_TOTAL_YAW_DEGREES = 60 + // Keeps a tilted grip's gravity leak from nudging the virtual remote off its neutral position. private const val SWING_DEAD_ZONE_PERCENT = 20 private const val SWING_RANGE_PERCENT = 7 @@ -82,10 +87,12 @@ object DolphinWiimoteConfig { listOf("Pitch Up", "Pitch Down", "Roll Left", "Roll Right", "Yaw Left", "Yaw Right") .map { "IMUGyroscope/$it" to "Gyro $it" } - // A lone Joy-Con streams in its sideways grip (SidewaysMotion), but the emulated remote is the - // Joy-Con's own body, so its inputs turn back about the button face. Up/Down and yaw lie on - // that axis and pass through name-to-name. - private val LEFT_BODY_INPUTS = mapOf( + // A lone Joy-Con streams in its sideways grip (SidewaysMotion); turning that grip back about the + // button face restores the Joy-Con's own body, which is the remote the player aims down its + // shoulder edge. The bodies rotate into their grips opposite ways, so their tables are each + // other half a turn — and a LEFT Joy-Con's own body already *is* a Wii Remote held sideways, + // since a sideways remote's nose points left just as its L/ZL edge does. + private val SIDEWAYS_REMOTE_INPUTS = mapOf( "Accel Left" to "Accel Backward", "Accel Right" to "Accel Forward", "Accel Forward" to "Accel Left", "Accel Backward" to "Accel Right", "Gyro Pitch Up" to "Gyro Roll Right", "Gyro Pitch Down" to "Gyro Roll Left", @@ -98,22 +105,40 @@ object DolphinWiimoteConfig { "Gyro Roll Left" to "Gyro Pitch Down", "Gyro Roll Right" to "Gyro Pitch Up", ) - private fun imuLines(side: JoyconSide): List { - val bodyInputs = when (side) { - JoyconSide.LEFT -> LEFT_BODY_INPUTS - JoyconSide.RIGHT -> RIGHT_BODY_INPUTS - JoyconSide.DUAL -> emptyMap() - } + // So the sideways-remote layouts change one body's motion: a right Joy-Con gives up its own + // body — and with it the R/ZR edge as the nose, aiming moving to the tail — to read gravity the + // way a wheel game expects. A left Joy-Con needs no turn either way. + private fun bodyInputs(side: JoyconSide, sidewaysRemote: Boolean): Map = when (side) { + JoyconSide.DUAL -> emptyMap() + JoyconSide.LEFT -> SIDEWAYS_REMOTE_INPUTS + JoyconSide.RIGHT -> if (sidewaysRemote) SIDEWAYS_REMOTE_INPUTS else RIGHT_BODY_INPUTS + } + + // A sideways remote's d-pad turns with it: the player's up is the remote's right. Dolphin does + // this itself (dpad_sideways_bitmasks) when its Sideways Wii Remote option is on, but that + // option also turns the accelerometer a quarter, which our own table has already done — so the + // option stays off and the four bindings are turned here instead. + private val SIDEWAYS_DPAD_KEYS = mapOf( + WiimoteButton.DPadUp to "D-Pad/Right", + WiimoteButton.DPadRight to "D-Pad/Down", + WiimoteButton.DPadDown to "D-Pad/Left", + WiimoteButton.DPadLeft to "D-Pad/Up", + ) + + private fun dolphinKey(target: WiimoteButton, sideways: Boolean): String = + (if (sideways) SIDEWAYS_DPAD_KEYS[target] else null) ?: DOLPHIN_KEYS.getValue(target) + + private fun imuLines(side: JoyconSide, sidewaysRemote: Boolean): List { + val bodyInputs = bodyInputs(side, sidewaysRemote) return IMU_CONTROLS.map { (control, input) -> "$control = `${bodyInputs[input] ?: input}`" } + - "IMUIR/Enabled = True" + listOf("IMUIR/Enabled = True", "IMUIR/Total Yaw = $IMU_TOTAL_YAW_DEGREES") } // Dolphin's emulated remote only ever translates through the Swing group — the IMU path feeds // rotation alone — so the virtual remote stays pinned in space and the IR dots never change // separation. Games that read a thrust as distance to the sensor bar (Wii Play Billiards charges // cue strength that way) see nothing from accel and gyro alone. A push toward the screen lands - // on `Accel Forward` for a pair held like a Wii Remote, and on `Accel Up` for a solo sideways - // Joy-Con (out through the button face); pairing each with its opposite input makes the value + // on whichever input the remote's nose reads; pairing it with its opposite makes the value // signed, since Dolphin clamps a single input at zero. // // An accelerometer cannot tell gravity from sustained acceleration, so a tilted grip parks up to @@ -121,9 +146,11 @@ object DolphinWiimoteConfig { // subtracting it high-passes the axis: the tracker catches a static tilt within a third of a // second and cancels it, while a thrust's ~80 ms transient outruns it. Range then trims the // inputs, which arrive at 9.8 per g, to a full-distance lunge at roughly a 1.5 g thrust. - private fun swingLines(side: JoyconSide): List { - val thrust = if (side == JoyconSide.DUAL) "Accel Forward" else "Accel Up" - val pull = if (side == JoyconSide.DUAL) "Accel Backward" else "Accel Down" + private fun swingLines(side: JoyconSide, sidewaysRemote: Boolean): List { + // A push toward the screen runs along the remote's nose, whichever input that body reads it from. + val body = bodyInputs(side, sidewaysRemote) + val thrust = body["Accel Forward"] ?: "Accel Forward" + val pull = body["Accel Backward"] ?: "Accel Backward" val signed = "(`$thrust` - `$pull`)" return listOf( "Swing/Forward = $signed - smooth($signed, $SWING_SETTLE_SECONDS)", @@ -138,15 +165,23 @@ object DolphinWiimoteConfig { private fun nunchukImuLines(slot: Int): List = ACCEL_DIRECTIONS.map { "Nunchuk/IMUAccelerometer/$it = `DSUClient/$slot/Joycon2:Accel $it`" } - fun merge(existing: String?, players: List, mappingFor: (JoyconSide) -> Map): String = - IniEditor.mergeSections(existing, sections(players, mappingFor)) + fun merge( + existing: String?, + players: List, + sidewaysRemote: Boolean, + mappingFor: (JoyconSide) -> Map, + ): String = IniEditor.mergeSections(existing, sections(players, sidewaysRemote, mappingFor)) - private fun sections(players: List, mappingFor: (JoyconSide) -> Map): Map { + private fun sections( + players: List, + sidewaysRemote: Boolean, + mappingFor: (JoyconSide) -> Map, + ): Map { val secondHands = DsuSlots.secondHands(players).associate { it.state.player to it.slot } return players.mapNotNull { player -> val slot = player.player.index - 1 if (slot !in 0..3) return@mapNotNull null - bodyFor(player, slot, secondHands[player.player], mappingFor) + bodyFor(player, slot, secondHands[player.player], sidewaysRemote, mappingFor) ?.let { "[Wiimote${player.player.index}]" to it } }.toMap() } @@ -155,6 +190,7 @@ object DolphinWiimoteConfig { player: PlayerState, slot: Int, secondHandSlot: Int?, + sidewaysRemote: Boolean, mappingFor: (JoyconSide) -> Map, ): String? { val side = when { @@ -171,13 +207,15 @@ object DolphinWiimoteConfig { } else { emptyList() } - return (header + lines(side, mappingFor(side)) + imuLines(side) + swingLines(side) + nunchukImu) + val sideways = sidewaysRemote && side != JoyconSide.DUAL + return (header + lines(side, sideways, mappingFor(side)) + imuLines(side, sidewaysRemote) + + swingLines(side, sidewaysRemote) + nunchukImu) .joinToString("\n", postfix = "\n") } - private fun lines(side: JoyconSide, mapping: Map): List { - val buttonLines = mapping.toSourceMap().mapNotNull { (target, source) -> - specFor(side, source)?.let { spec -> "${DOLPHIN_KEYS.getValue(target)} = `$spec`" } + private fun lines(side: JoyconSide, sideways: Boolean, mapping: Map): List { + val buttonLines = mapping.toSourceMap().mapNotNull { (target, sources) -> + expressionFor(side, sources)?.let { expression -> "${dolphinKey(target, sideways)} = $expression" } } val stickLines = nunchukStickLines(side, mapping) val recenterSpec = if (side == JoyconSide.LEFT) "L1" else "R1" @@ -191,11 +229,17 @@ object DolphinWiimoteConfig { private fun nunchukStickLines(side: JoyconSide, mapping: Map): List = mapping.toStickDirectionMap().values.flatMap { directions -> - directions.mapNotNull { (direction, source) -> - specFor(side, source)?.let { spec -> "Nunchuk/Stick/${direction.displayName} = `$spec`" } + directions.mapNotNull { (direction, sources) -> + expressionFor(side, sources)?.let { expression -> "Nunchuk/Stick/${direction.displayName} = $expression" } } } + // Dolphin's expression language ORs its inputs, so every source bound to a target can fire it. + private fun expressionFor(side: JoyconSide, sources: List): String? = + sources.mapNotNull { specFor(side, it) } + .takeIf { it.isNotEmpty() } + ?.joinToString(" | ") { "`$it`" } + private fun specFor(side: JoyconSide, source: MappingSource): String? = when (source) { is MappingSource.Button -> source.button.emittedFor(side)?.let { DS4_NAMES[it] ?: PAD_NAMES[it] } is MappingSource.Stick -> tiltSpec(source.emittedStick(side), source.direction) diff --git a/feature/dsu/domain/src/main/kotlin/com/joegec/joycon2android/dsu/emulator/EdenDsuConfig.kt b/feature/dsu/domain/src/main/kotlin/com/joegec/joycon2android/dsu/emulator/EdenDsuConfig.kt index 32c598b..7a45501 100644 --- a/feature/dsu/domain/src/main/kotlin/com/joegec/joycon2android/dsu/emulator/EdenDsuConfig.kt +++ b/feature/dsu/domain/src/main/kotlin/com/joegec/joycon2android/dsu/emulator/EdenDsuConfig.kt @@ -136,8 +136,8 @@ object EdenDsuConfig { private fun motion(pad: Int) = EdenControls.quote("${device(pad)},motion:0") private fun buttonBindings(side: JoyconSide, mapping: Map): Map = - mapping.toSourceMap().mapNotNull { (target, source) -> - inputFor(side, source)?.let { EdenControls.BUTTON_KEYS.getValue(target) to it } + mapping.toSourceMap().mapNotNull { (target, sources) -> + inputFor(side, sources)?.let { EdenControls.BUTTON_KEYS.getValue(target) to it } }.toMap() private fun stickBindings(side: JoyconSide, mapping: Map, device: String): Map = @@ -145,13 +145,13 @@ object EdenDsuConfig { stickFor(side, directions, device)?.let { EdenControls.STICK_KEYS.getValue(target) to it } }.toMap() - private fun stickFor(side: JoyconSide, directions: Map, device: String): String? { + private fun stickFor(side: JoyconSide, directions: Map>, device: String): String? { directions.wholeEmittedStick(side)?.let { stick -> val (x, y) = axesOf(stick) return "$device,axis_x:$x,axis_y:$y" } - val inputs = directions.mapNotNull { (direction, source) -> - inputFor(side, source)?.let { direction to "$device,$it" } + val inputs = directions.mapNotNull { (direction, sources) -> + inputFor(side, sources)?.let { direction to "$device,$it" } } return inputs.takeIf { it.isNotEmpty() }?.let { EdenControls.stickFromButtons(it.toMap()) } } @@ -159,6 +159,11 @@ object EdenDsuConfig { private fun axesOf(stick: StickSource) = if (stick == StickSource.LEFT_STICK) LEFT_STICK_AXES else RIGHT_STICK_AXES + // Eden binds one input per key, so a target driven by several sources keeps the first that its + // body can actually emit; the rest are only reachable through Dolphin. + private fun inputFor(side: JoyconSide, sources: List): String? = + sources.firstNotNullOfOrNull { inputFor(side, it) } + private fun inputFor(side: JoyconSide, source: MappingSource): String? = when (source) { is MappingSource.Button -> source.button.emittedFor(side)?.let(DS4_BITS::get)?.let { "button:$it" } is MappingSource.Stick -> tiltOf(source.emittedStick(side), source.direction) diff --git a/feature/dsu/domain/src/test/kotlin/com/joegec/joycon2android/dsu/emulator/DolphinWiimoteConfigTest.kt b/feature/dsu/domain/src/test/kotlin/com/joegec/joycon2android/dsu/emulator/DolphinWiimoteConfigTest.kt index 5126a11..38a5238 100644 --- a/feature/dsu/domain/src/test/kotlin/com/joegec/joycon2android/dsu/emulator/DolphinWiimoteConfigTest.kt +++ b/feature/dsu/domain/src/test/kotlin/com/joegec/joycon2android/dsu/emulator/DolphinWiimoteConfigTest.kt @@ -2,7 +2,7 @@ package com.joegec.joycon2android.dsu.emulator import com.joegec.joycon2android.buttonmapping.Console import com.joegec.joycon2android.buttonmapping.JoyconSide -import com.joegec.joycon2android.buttonmapping.defaultMappingEntries +import com.joegec.joycon2android.buttonmapping.preset.MappingPresets import com.joegec.joycon2android.model.ConnectedJoycon import com.joegec.joycon2android.model.PlayerNumber import com.joegec.joycon2android.model.PlayerState @@ -11,14 +11,14 @@ import org.junit.Assert.assertFalse import org.junit.Assert.assertTrue import org.junit.Test -private fun defaultWiimoteMapping(side: JoyconSide) = defaultMappingEntries(Console.WIIMOTE_NUNCHUK, side) +private fun defaultWiimoteMapping(side: JoyconSide) = MappingPresets.default(Console.WIIMOTE_NUNCHUK).entries(side) class DolphinWiimoteConfigTest { private fun joycon(side: Side) = ConnectedJoycon(address = side.name, side = side, deviceName = "Joy-Con") - private fun merge(existing: String?, players: List) = - DolphinWiimoteConfig.merge(existing, players, ::defaultWiimoteMapping) + private fun merge(existing: String?, players: List, sidewaysRemote: Boolean = false) = + DolphinWiimoteConfig.merge(existing, players, sidewaysRemote, ::defaultWiimoteMapping) @Test fun `right-only player maps the stick to the d-pad and uses no extension`() { @@ -61,7 +61,7 @@ class DolphinWiimoteConfigTest { val pair = PlayerState(PlayerNumber.P1, left = joycon(Side.LEFT), right = joycon(Side.RIGHT)) val mapping = defaultWiimoteMapping(JoyconSide.DUAL) + mapOf("NunchukStick_UP" to "Up") - val result = DolphinWiimoteConfig.merge(null, listOf(pair)) { mapping } + val result = DolphinWiimoteConfig.merge(null, listOf(pair), false) { mapping } assertTrue(result.contains("Nunchuk/Stick/Up = `Pad N`")) assertTrue(result.contains("Nunchuk/Stick/Down = `Left Y-`")) @@ -70,8 +70,9 @@ class DolphinWiimoteConfigTest { @Test fun `a lone Joy-Con plugs in a nunchuk once its stick is mapped`() { val mapping = defaultWiimoteMapping(JoyconSide.RIGHT) + mapOf("NunchukStick_UP" to "X", "NunchukStick_DOWN" to "B") + val player = listOf(PlayerState(PlayerNumber.P1, right = joycon(Side.RIGHT))) - val result = DolphinWiimoteConfig.merge(null, listOf(PlayerState(PlayerNumber.P1, right = joycon(Side.RIGHT)))) { mapping } + val result = DolphinWiimoteConfig.merge(null, player, false) { mapping } assertTrue(result.contains("Extension = Nunchuk")) assertTrue(result.contains("Nunchuk/Stick/Up = `Circle`")) // physical X rotates onto A @@ -79,6 +80,16 @@ class DolphinWiimoteConfigTest { assertFalse(result.contains("Nunchuk/IMUAccelerometer")) // no second hand to stream one } + @Test + fun `a target bound to several sources fires from any of them`() { + val mapping = defaultWiimoteMapping(JoyconSide.RIGHT) + mapOf("DPadUp" to "RIGHT_STICK_UP|SlRight") + val player = listOf(PlayerState(PlayerNumber.P1, right = joycon(Side.RIGHT))) + + val result = DolphinWiimoteConfig.merge(null, player, false) { mapping } + + assertTrue(result.contains("D-Pad/Up = `Left Y+` | `L1`")) // SL rotates onto L held sideways + } + @Test fun `a pair points the nunchuk accelerometer at the second hand's own slot`() { val both = PlayerState(PlayerNumber.P1, left = joycon(Side.LEFT), right = joycon(Side.RIGHT)) @@ -128,42 +139,76 @@ class DolphinWiimoteConfigTest { } @Test - fun `a sideways Joy-Con thrusts out through its button face`() { + fun `a lone Joy-Con thrusts along the axis its nose reads`() { val result = merge(null, listOf(PlayerState(PlayerNumber.P1, right = joycon(Side.RIGHT)))) - assertTrue(result.contains("Swing/Forward = (`Accel Up` - `Accel Down`) - smooth(")) + assertTrue(result.contains("Swing/Forward = (`Accel Right` - `Accel Left`) - smooth(")) } @Test - fun `a pair maps its motion name-to-name`() { - val both = PlayerState(PlayerNumber.P1, left = joycon(Side.LEFT), right = joycon(Side.RIGHT)) - - val result = merge(null, listOf(both)) + fun `the pointer's yaw clamp is widened past a living room's worth`() { + val result = merge(null, listOf(PlayerState(PlayerNumber.P1, right = joycon(Side.RIGHT)))) - assertTrue(result.contains("IMUAccelerometer/Forward = `Accel Forward`")) - assertTrue(result.contains("IMUGyroscope/Pitch Up = `Gyro Pitch Up`")) + assertTrue(result.contains("IMUIR/Total Yaw = 60")) } + // Off, each Joy-Con is its own body: the nose is the shoulder edge the player aims down. @Test - fun `a sideways right Joy-Con turns its streamed grip back into the remote's body`() { + fun `a right Joy-Con keeps its own body until the layout plays sideways`() { val result = merge(null, listOf(PlayerState(PlayerNumber.P1, right = joycon(Side.RIGHT)))) assertTrue(result.contains("IMUAccelerometer/Up = `Accel Up`")) assertTrue(result.contains("IMUAccelerometer/Forward = `Accel Right`")) - assertTrue(result.contains("IMUAccelerometer/Left = `Accel Forward`")) assertTrue(result.contains("IMUGyroscope/Pitch Up = `Gyro Roll Left`")) - assertTrue(result.contains("IMUGyroscope/Roll Right = `Gyro Pitch Up`")) assertTrue(result.contains("IMUGyroscope/Yaw Left = `Gyro Yaw Left`")) } + // A sideways remote's nose points left, which a left Joy-Con's own body already does. @Test - fun `a sideways left Joy-Con turns its streamed grip back the other way`() { - val result = merge(null, listOf(PlayerState(PlayerNumber.P1, left = joycon(Side.LEFT)))) + fun `a left Joy-Con reads the same either way`() { + val player = listOf(PlayerState(PlayerNumber.P1, left = joycon(Side.LEFT))) + + listOf(merge(null, player), merge(null, player, sidewaysRemote = true)).forEach { result -> + assertTrue(result.contains("IMUAccelerometer/Forward = `Accel Left`")) + assertTrue(result.contains("IMUGyroscope/Pitch Up = `Gyro Roll Right`")) + } + } + + @Test + fun `playing sideways turns a right Joy-Con onto the sideways remote's frame`() { + val player = listOf(PlayerState(PlayerNumber.P1, right = joycon(Side.RIGHT))) + val result = merge(null, player, sidewaysRemote = true) + + assertTrue(result.contains("IMUAccelerometer/Up = `Accel Up`")) assertTrue(result.contains("IMUAccelerometer/Forward = `Accel Left`")) - assertTrue(result.contains("IMUAccelerometer/Left = `Accel Backward`")) assertTrue(result.contains("IMUGyroscope/Pitch Up = `Gyro Roll Right`")) - assertTrue(result.contains("IMUGyroscope/Roll Right = `Gyro Pitch Down`")) + assertTrue(result.contains("IMUGyroscope/Yaw Left = `Gyro Yaw Left`")) + } + + // The player's up is a sideways remote's right, so the four bindings turn with the body. + @Test + fun `playing sideways turns the d-pad a quarter, on both bodies`() { + val right = merge(null, listOf(PlayerState(PlayerNumber.P1, right = joycon(Side.RIGHT))), sidewaysRemote = true) + val left = merge(null, listOf(PlayerState(PlayerNumber.P1, left = joycon(Side.LEFT))), sidewaysRemote = true) + + listOf(right, left).forEach { result -> + assertTrue(result.contains("D-Pad/Right = `Left Y+`")) // the stick's up is the remote's right + assertTrue(result.contains("D-Pad/Down = `Left X+`")) + assertTrue(result.contains("D-Pad/Left = `Left Y-`")) + assertTrue(result.contains("D-Pad/Up = `Left X-`")) + } + } + + @Test + fun `a pair is held like a remote already, so it never turns`() { + val both = PlayerState(PlayerNumber.P1, left = joycon(Side.LEFT), right = joycon(Side.RIGHT)) + + val result = merge(null, listOf(both), sidewaysRemote = true) + + assertTrue(result.contains("IMUAccelerometer/Forward = `Accel Forward`")) + assertTrue(result.contains("IMUGyroscope/Pitch Up = `Gyro Pitch Up`")) + assertTrue(result.contains("D-Pad/Up = `Pad N`")) } @Test diff --git a/feature/dsu/domain/src/test/kotlin/com/joegec/joycon2android/dsu/emulator/EdenDsuConfigTest.kt b/feature/dsu/domain/src/test/kotlin/com/joegec/joycon2android/dsu/emulator/EdenDsuConfigTest.kt index 5920034..5fde36a 100644 --- a/feature/dsu/domain/src/test/kotlin/com/joegec/joycon2android/dsu/emulator/EdenDsuConfigTest.kt +++ b/feature/dsu/domain/src/test/kotlin/com/joegec/joycon2android/dsu/emulator/EdenDsuConfigTest.kt @@ -2,7 +2,7 @@ package com.joegec.joycon2android.dsu.emulator import com.joegec.joycon2android.buttonmapping.Console import com.joegec.joycon2android.buttonmapping.JoyconSide -import com.joegec.joycon2android.buttonmapping.defaultMappingEntries +import com.joegec.joycon2android.buttonmapping.preset.MappingPresets import com.joegec.joycon2android.dsu.DsuConfig import com.joegec.joycon2android.model.ConnectedJoycon import com.joegec.joycon2android.model.PlayerNumber @@ -13,7 +13,7 @@ import org.junit.Assert.assertFalse import org.junit.Assert.assertTrue import org.junit.Test -private fun defaultSwitchProMapping(side: JoyconSide) = defaultMappingEntries(Console.SWITCH_PRO, side) +private fun defaultSwitchProMapping(side: JoyconSide) = MappingPresets.default(Console.SWITCH_PRO).entries(side) class EdenDsuConfigTest { @@ -172,6 +172,15 @@ class EdenDsuConfigTest { assertEquals("\"${device(0)},axis:1,threshold:0.5,invert:+\"", valueOf(result, "player_0_button_a")) } + @Test + fun `a target with several sources keeps the first one Eden can bind`() { + val mapping = defaultSwitchProMapping(JoyconSide.DUAL) + mapOf("A" to "X|Y") + + val result = EdenDsuConfig.merge(null, listOf(pair(PlayerNumber.P1))) { mapping } + + assertEquals("\"${device(0)},button:4096\"", valueOf(result, "player_0_button_a")) + } + @Test fun `unrelated controls keys and other sections survive`() { val existing = "[Controls]\nvibration_enabled=true\n[Core]\nuse_multi_core=true" diff --git a/feature/gamepad/domain/src/main/kotlin/com/joegec/joycon2android/gamepad/emulator/DolphinGcpadConfig.kt b/feature/gamepad/domain/src/main/kotlin/com/joegec/joycon2android/gamepad/emulator/DolphinGcpadConfig.kt index 7ccd450..286e09c 100644 --- a/feature/gamepad/domain/src/main/kotlin/com/joegec/joycon2android/gamepad/emulator/DolphinGcpadConfig.kt +++ b/feature/gamepad/domain/src/main/kotlin/com/joegec/joycon2android/gamepad/emulator/DolphinGcpadConfig.kt @@ -125,17 +125,25 @@ object DolphinGcpadConfig { } private fun lines(side: JoyconSide, mapping: Map): List { - val buttonLines = mapping.toSourceMap().mapNotNull { (target, source) -> - specFor(side, source)?.let { spec -> "${DOLPHIN_KEYS.getValue(target)} = `$spec`" } + val buttonLines = mapping.toSourceMap().mapNotNull { (target, sources) -> + expressionFor(side, sources)?.let { expression -> "${DOLPHIN_KEYS.getValue(target)} = $expression" } } val stickLines = mapping.toStickDirectionMap().flatMap { (target, directions) -> - directions.mapNotNull { (direction, source) -> - specFor(side, source)?.let { spec -> "${STICK_PREFIXES.getValue(target)}/${direction.displayName} = `$spec`" } + directions.mapNotNull { (direction, sources) -> + expressionFor(side, sources)?.let { expression -> + "${STICK_PREFIXES.getValue(target)}/${direction.displayName} = $expression" + } } } return buttonLines + stickLines } + // Dolphin's expression language ORs its inputs, so every source bound to a target can fire it. + private fun expressionFor(side: JoyconSide, sources: List): String? = + sources.mapNotNull { specFor(side, it) } + .takeIf { it.isNotEmpty() } + ?.joinToString(" | ") { "`$it`" } + private fun specFor(side: JoyconSide, source: MappingSource): String? = when (source) { is MappingSource.Button -> source.button.emittedFor(side)?.let { ANDROID_NAMES[it] ?: HAT_NAMES[it] } is MappingSource.Stick -> tiltSpec(source.emittedStick(side), source.direction) diff --git a/feature/gamepad/domain/src/main/kotlin/com/joegec/joycon2android/gamepad/emulator/EdenGamepadConfig.kt b/feature/gamepad/domain/src/main/kotlin/com/joegec/joycon2android/gamepad/emulator/EdenGamepadConfig.kt index f9d8909..10fdf1d 100644 --- a/feature/gamepad/domain/src/main/kotlin/com/joegec/joycon2android/gamepad/emulator/EdenGamepadConfig.kt +++ b/feature/gamepad/domain/src/main/kotlin/com/joegec/joycon2android/gamepad/emulator/EdenGamepadConfig.kt @@ -139,8 +139,8 @@ object EdenGamepadConfig { } private fun layoutFor(side: JoyconSide, mapping: Map): Layout { - val buttons = mapping.toSourceMap().mapNotNull { (target, source) -> - inputFor(side, source)?.let { EdenControls.BUTTON_KEYS.getValue(target) to it } + val buttons = mapping.toSourceMap().mapNotNull { (target, sources) -> + inputFor(side, sources)?.let { EdenControls.BUTTON_KEYS.getValue(target) to it } }.toMap() val sticks = mapping.toStickDirectionMap().mapNotNull { (target, directions) -> stickFor(side, directions)?.let { EdenControls.STICK_KEYS.getValue(target) to it } @@ -148,12 +148,17 @@ object EdenGamepadConfig { return Layout(buttons, sticks) } - private fun stickFor(side: JoyconSide, directions: Map): Stick? { + private fun stickFor(side: JoyconSide, directions: Map>): Stick? { directions.wholeEmittedStick(side)?.let { return AnalogStick(axesOf(it)) } - val inputs = directions.mapNotNull { (direction, source) -> inputFor(side, source)?.let { direction to it } } + val inputs = directions.mapNotNull { (direction, sources) -> inputFor(side, sources)?.let { direction to it } } return inputs.takeIf { it.isNotEmpty() }?.let { DigitalStick(it.toMap()) } } + // Eden binds one input per key, so a target driven by several sources keeps the first that its + // body can actually emit; the rest are only reachable through Dolphin. + private fun inputFor(side: JoyconSide, sources: List): Input? = + sources.firstNotNullOfOrNull { inputFor(side, it) } + private fun inputFor(side: JoyconSide, source: MappingSource): Input? = when (source) { is MappingSource.Button -> source.button.emittedFor(side)?.let { KEY_CODES[it]?.let(::Key) ?: HAT_AXES[it] } is MappingSource.Stick -> tiltOf(source.emittedStick(side), source.direction) diff --git a/feature/gamepad/domain/src/test/kotlin/com/joegec/joycon2android/gamepad/emulator/DolphinGcpadConfigTest.kt b/feature/gamepad/domain/src/test/kotlin/com/joegec/joycon2android/gamepad/emulator/DolphinGcpadConfigTest.kt index 7c96d71..2f25ac0 100644 --- a/feature/gamepad/domain/src/test/kotlin/com/joegec/joycon2android/gamepad/emulator/DolphinGcpadConfigTest.kt +++ b/feature/gamepad/domain/src/test/kotlin/com/joegec/joycon2android/gamepad/emulator/DolphinGcpadConfigTest.kt @@ -2,7 +2,7 @@ package com.joegec.joycon2android.gamepad.emulator import com.joegec.joycon2android.buttonmapping.Console import com.joegec.joycon2android.buttonmapping.JoyconSide -import com.joegec.joycon2android.buttonmapping.defaultMappingEntries +import com.joegec.joycon2android.buttonmapping.preset.MappingPresets import com.joegec.joycon2android.model.ConnectedJoycon import com.joegec.joycon2android.model.PlayerNumber import com.joegec.joycon2android.model.PlayerState @@ -20,7 +20,7 @@ class DolphinGcpadConfigTest { players: List, controllerNumbers: Map = players.associate { it.player.index to it.player.index }, ) = DolphinGcpadConfig.merge(existing, players, controllerNumbers) { side -> - defaultMappingEntries(Console.GAMECUBE, side) + MappingPresets.default(Console.GAMECUBE).entries(side) } @Test @@ -88,7 +88,7 @@ class DolphinGcpadConfigTest { @Test fun `a stick direction can be driven by a button, and a button by a stick direction`() { val both = PlayerState(PlayerNumber.P1, left = joycon(Side.LEFT), right = joycon(Side.RIGHT)) - val mapping = defaultMappingEntries(Console.GAMECUBE, JoyconSide.DUAL) + + val mapping = MappingPresets.default(Console.GAMECUBE).entries(JoyconSide.DUAL) + mapOf("MainStick_UP" to "X", "A" to "RIGHT_STICK_DOWN", "CStick_LEFT" to "") val result = DolphinGcpadConfig.merge(null, listOf(both), mapOf(1 to 1)) { mapping } diff --git a/feature/gamepad/domain/src/test/kotlin/com/joegec/joycon2android/gamepad/emulator/EdenGamepadConfigTest.kt b/feature/gamepad/domain/src/test/kotlin/com/joegec/joycon2android/gamepad/emulator/EdenGamepadConfigTest.kt index 31df18f..efc6c0e 100644 --- a/feature/gamepad/domain/src/test/kotlin/com/joegec/joycon2android/gamepad/emulator/EdenGamepadConfigTest.kt +++ b/feature/gamepad/domain/src/test/kotlin/com/joegec/joycon2android/gamepad/emulator/EdenGamepadConfigTest.kt @@ -2,7 +2,7 @@ package com.joegec.joycon2android.gamepad.emulator import com.joegec.joycon2android.buttonmapping.Console import com.joegec.joycon2android.buttonmapping.JoyconSide -import com.joegec.joycon2android.buttonmapping.defaultMappingEntries +import com.joegec.joycon2android.buttonmapping.preset.MappingPresets import com.joegec.joycon2android.model.ConnectedJoycon import com.joegec.joycon2android.model.PlayerNumber import com.joegec.joycon2android.model.PlayerState @@ -11,7 +11,7 @@ import org.junit.Assert.assertFalse import org.junit.Assert.assertTrue import org.junit.Test -private fun defaultSwitchProMapping(side: JoyconSide) = defaultMappingEntries(Console.SWITCH_PRO, side) +private fun defaultSwitchProMapping(side: JoyconSide) = MappingPresets.default(Console.SWITCH_PRO).entries(side) class EdenGamepadConfigTest { From ece8914cd1d2867700ae7ae16ce2f93ca43982cc Mon Sep 17 00:00:00 2001 From: Joe Barker Date: Tue, 22 Sep 2026 17:50:33 +0100 Subject: [PATCH 02/16] Map buttons per player, with layouts you can save The mapping editor showed one set of bindings per console, so two people holding the same body had to share them. It now shows a card per connected player: P1 and its controllers on the left, the layout that mapping amounts to on the right, opening onto the bindings themselves. Everything is keyed by PlayerBody -- a player plus the body they hold -- from the repositories down through the four emulator-config generators, which take a lookup per body rather than per side. A layout is no longer a choice remembered against a body, only a name for a set of bindings. Applying one copies out everything it says, and the name is read back by matching what a body is bound to against every layout the app ships and every one the user saved. That is what lets a deleted layout take away its name and nothing else -- everyone keeps their buttons, and those same buttons answer to the layout again the day an identical one is saved back. No match is "Custom". Saving is offered only when there is no name yet and suggests the first free "Custom N", so no two layouts can compete to name the same bindings. All players, at the top, sets everyone at once and saves the session under a name. A saved set carries every player's bindings in full rather than a layout id, so it restores what it saved whatever has happened to the layouts since; it also carries the bodies it was saved from, which is why it is greyed out -- and says which players it wants -- until they are back. Sideways Wii Remote was a console-wide switch and is now one per player, since the players at a table need not agree. A flick of a sideways Joy-Con is now amplified into Mario Kart Wii's tricks through the accelerometer rather than synthesised from the gyroscope. The game has no MotionPlus and picks between its four tricks by the direction of the flick, which a Shake group cannot carry -- it is one axis and symmetric -- so the real jerk has to arrive big enough instead. Subtracting a slew limiter leaves what gravity is not, and adding that back doubles the transient while leaving the gravity the wheel steers by untouched: measured 2026-09, a flick carries 1.6 to 3.6 g against 0.35 for the sharpest steering. Mappings stored per console are handed to every player by a DataStore migration, which is what they already meant. Co-Authored-By: Claude Opus 5 --- README.md | 31 ++- .../com/joegec/joycon2android/AppContainer.kt | 50 +++-- .../com/joegec/joycon2android/MainActivity.kt | 63 +++--- .../joycon2android/emulator/EmulatorSetup.kt | 36 +++- .../ControllerMappingDataStore.kt | 31 ++- .../buttonmapping/GlobalLayoutDataStore.kt | 26 +++ .../buttonmapping/JsonDocumentStore.kt | 27 +++ .../buttonmapping/LayoutJson.kt | 68 ++++++ .../buttonmapping/MappingPresetDataStore.kt | 26 --- .../buttonmapping/PerPlayerKeys.kt | 28 +++ .../buttonmapping/PerPlayerMigration.kt | 40 ++++ .../buttonmapping/SavedLayoutDataStore.kt | 26 +++ .../buttonmapping/SidewaysRemoteDataStore.kt | 26 ++- .../buttonmapping/ApplyGlobalLayoutUseCase.kt | 24 +++ .../ApplyMappingLayoutUseCase.kt | 19 ++ .../ApplyMappingPresetUseCase.kt | 18 -- .../ApplyPlayerMappingUseCase.kt | 20 ++ .../ControllerMappingRepository.kt | 16 +- .../DeleteCustomLayoutUseCase.kt | 6 + .../DeleteGlobalLayoutUseCase.kt | 6 + .../GetEffectiveControllerMappingUseCase.kt | 4 +- .../buttonmapping/GetSidewaysRemoteUseCase.kt | 3 +- .../buttonmapping/GlobalLayout.kt | 18 ++ .../buttonmapping/GlobalLayoutRepository.kt | 10 + .../buttonmapping/GlobalMapping.kt | 25 +++ .../buttonmapping/JoyconSide.kt | 8 +- .../buttonmapping/MappingLayout.kt | 21 ++ .../buttonmapping/MappingLayouts.kt | 44 ++++ .../buttonmapping/MappingPresetRepository.kt | 12 -- .../ObserveControllerMappingUseCase.kt | 20 +- .../ObserveGlobalMappingUseCase.kt | 24 +++ .../ObserveMappingPresetUseCase.kt | 12 -- .../ObservePlayerMappingUseCase.kt | 25 +++ .../ObserveSavedLayoutsUseCase.kt | 10 + .../ObserveSidewaysRemoteUseCase.kt | 16 +- .../buttonmapping/PlayerBody.kt | 17 ++ .../buttonmapping/PlayerLayoutSnapshot.kt | 8 + .../buttonmapping/PlayerMapping.kt | 12 ++ .../ResetControllerMappingUseCase.kt | 9 +- .../buttonmapping/SaveCustomLayoutUseCase.kt | 26 +++ .../buttonmapping/SaveGlobalLayoutUseCase.kt | 14 ++ .../buttonmapping/SavedLayout.kt | 14 ++ .../buttonmapping/SavedLayoutRepository.kt | 10 + .../SetControllerMappingUseCase.kt | 4 +- .../buttonmapping/SetSidewaysRemoteUseCase.kt | 3 +- .../buttonmapping/SidewaysRemoteRepository.kt | 10 +- .../buttonmapping/preset/MappingPreset.kt | 21 +- .../buttonmapping/FakeMappingRepositories.kt | 61 ++++++ .../buttonmapping/GlobalMappingTest.kt | 118 ++++++++++ .../buttonmapping/MappingFixture.kt | 43 ++++ .../buttonmapping/MappingLayoutsTest.kt | 27 +++ .../ObserveControllerMappingUseCaseTest.kt | 48 ++--- .../buttonmapping/PlayerMappingTest.kt | 129 +++++++++++ .../buttonmapping/SidewaysRemoteTest.kt | 60 +++--- .../presentation/ControllerMappingScreen.kt | 201 +++++++++--------- .../presentation/ControllerMappingUiState.kt | 78 +++++++ .../ControllerMappingViewModel.kt | 118 +++++----- .../buttonmapping/presentation/LayoutRow.kt | 92 ++++++++ .../presentation/MappingActions.kt | 16 ++ .../presentation/MappingBindings.kt | 66 ++++++ .../presentation/MappingOptions.kt | 9 +- .../presentation/PlayerMappingCard.kt | 167 +++++++++++++++ .../src/main/res/values/strings.xml | 19 +- .../ui/components/DropdownOption.kt | 13 ++ .../ui/components/EmulatorDropdown.kt | 62 +----- .../ui/components/LabeledDropdown.kt | 70 ------ .../ui/components/OptionDropdown.kt | 82 +++++++ .../ui/components/PanelDropdownMenu.kt | 61 ++++-- .../ui/components/TextInputDialog.kt | 79 +++++++ .../src/main/res/values/strings.xml | 1 + docs/DESIGN.md | 7 +- docs/architecture.md | 19 +- docs/dsu-motion.md | 17 +- .../dsu/emulator/DolphinWiimoteConfig.kt | 52 +++-- .../dsu/emulator/EdenDsuConfig.kt | 7 +- .../dsu/emulator/DolphinWiimoteConfigTest.kt | 40 +++- .../dsu/emulator/EdenDsuConfigTest.kt | 5 +- .../gamepad/emulator/DolphinGcpadConfig.kt | 15 +- .../gamepad/emulator/EdenGamepadConfig.kt | 7 +- .../emulator/DolphinGcpadConfigTest.kt | 5 +- .../gamepad/emulator/EdenGamepadConfigTest.kt | 7 +- 81 files changed, 2177 insertions(+), 611 deletions(-) create mode 100644 core/buttonmapping/data/src/main/kotlin/com/joegec/joycon2android/buttonmapping/GlobalLayoutDataStore.kt create mode 100644 core/buttonmapping/data/src/main/kotlin/com/joegec/joycon2android/buttonmapping/JsonDocumentStore.kt create mode 100644 core/buttonmapping/data/src/main/kotlin/com/joegec/joycon2android/buttonmapping/LayoutJson.kt delete mode 100644 core/buttonmapping/data/src/main/kotlin/com/joegec/joycon2android/buttonmapping/MappingPresetDataStore.kt create mode 100644 core/buttonmapping/data/src/main/kotlin/com/joegec/joycon2android/buttonmapping/PerPlayerKeys.kt create mode 100644 core/buttonmapping/data/src/main/kotlin/com/joegec/joycon2android/buttonmapping/PerPlayerMigration.kt create mode 100644 core/buttonmapping/data/src/main/kotlin/com/joegec/joycon2android/buttonmapping/SavedLayoutDataStore.kt create mode 100644 core/buttonmapping/domain/src/main/kotlin/com/joegec/joycon2android/buttonmapping/ApplyGlobalLayoutUseCase.kt create mode 100644 core/buttonmapping/domain/src/main/kotlin/com/joegec/joycon2android/buttonmapping/ApplyMappingLayoutUseCase.kt delete mode 100644 core/buttonmapping/domain/src/main/kotlin/com/joegec/joycon2android/buttonmapping/ApplyMappingPresetUseCase.kt create mode 100644 core/buttonmapping/domain/src/main/kotlin/com/joegec/joycon2android/buttonmapping/ApplyPlayerMappingUseCase.kt create mode 100644 core/buttonmapping/domain/src/main/kotlin/com/joegec/joycon2android/buttonmapping/DeleteCustomLayoutUseCase.kt create mode 100644 core/buttonmapping/domain/src/main/kotlin/com/joegec/joycon2android/buttonmapping/DeleteGlobalLayoutUseCase.kt create mode 100644 core/buttonmapping/domain/src/main/kotlin/com/joegec/joycon2android/buttonmapping/GlobalLayout.kt create mode 100644 core/buttonmapping/domain/src/main/kotlin/com/joegec/joycon2android/buttonmapping/GlobalLayoutRepository.kt create mode 100644 core/buttonmapping/domain/src/main/kotlin/com/joegec/joycon2android/buttonmapping/GlobalMapping.kt create mode 100644 core/buttonmapping/domain/src/main/kotlin/com/joegec/joycon2android/buttonmapping/MappingLayout.kt create mode 100644 core/buttonmapping/domain/src/main/kotlin/com/joegec/joycon2android/buttonmapping/MappingLayouts.kt delete mode 100644 core/buttonmapping/domain/src/main/kotlin/com/joegec/joycon2android/buttonmapping/MappingPresetRepository.kt create mode 100644 core/buttonmapping/domain/src/main/kotlin/com/joegec/joycon2android/buttonmapping/ObserveGlobalMappingUseCase.kt delete mode 100644 core/buttonmapping/domain/src/main/kotlin/com/joegec/joycon2android/buttonmapping/ObserveMappingPresetUseCase.kt create mode 100644 core/buttonmapping/domain/src/main/kotlin/com/joegec/joycon2android/buttonmapping/ObservePlayerMappingUseCase.kt create mode 100644 core/buttonmapping/domain/src/main/kotlin/com/joegec/joycon2android/buttonmapping/ObserveSavedLayoutsUseCase.kt create mode 100644 core/buttonmapping/domain/src/main/kotlin/com/joegec/joycon2android/buttonmapping/PlayerBody.kt create mode 100644 core/buttonmapping/domain/src/main/kotlin/com/joegec/joycon2android/buttonmapping/PlayerLayoutSnapshot.kt create mode 100644 core/buttonmapping/domain/src/main/kotlin/com/joegec/joycon2android/buttonmapping/PlayerMapping.kt create mode 100644 core/buttonmapping/domain/src/main/kotlin/com/joegec/joycon2android/buttonmapping/SaveCustomLayoutUseCase.kt create mode 100644 core/buttonmapping/domain/src/main/kotlin/com/joegec/joycon2android/buttonmapping/SaveGlobalLayoutUseCase.kt create mode 100644 core/buttonmapping/domain/src/main/kotlin/com/joegec/joycon2android/buttonmapping/SavedLayout.kt create mode 100644 core/buttonmapping/domain/src/main/kotlin/com/joegec/joycon2android/buttonmapping/SavedLayoutRepository.kt create mode 100644 core/buttonmapping/domain/src/test/kotlin/com/joegec/joycon2android/buttonmapping/FakeMappingRepositories.kt create mode 100644 core/buttonmapping/domain/src/test/kotlin/com/joegec/joycon2android/buttonmapping/GlobalMappingTest.kt create mode 100644 core/buttonmapping/domain/src/test/kotlin/com/joegec/joycon2android/buttonmapping/MappingFixture.kt create mode 100644 core/buttonmapping/domain/src/test/kotlin/com/joegec/joycon2android/buttonmapping/MappingLayoutsTest.kt create mode 100644 core/buttonmapping/domain/src/test/kotlin/com/joegec/joycon2android/buttonmapping/PlayerMappingTest.kt create mode 100644 core/buttonmapping/presentation/src/main/kotlin/com/joegec/joycon2android/buttonmapping/presentation/ControllerMappingUiState.kt create mode 100644 core/buttonmapping/presentation/src/main/kotlin/com/joegec/joycon2android/buttonmapping/presentation/LayoutRow.kt create mode 100644 core/buttonmapping/presentation/src/main/kotlin/com/joegec/joycon2android/buttonmapping/presentation/MappingActions.kt create mode 100644 core/buttonmapping/presentation/src/main/kotlin/com/joegec/joycon2android/buttonmapping/presentation/MappingBindings.kt create mode 100644 core/buttonmapping/presentation/src/main/kotlin/com/joegec/joycon2android/buttonmapping/presentation/PlayerMappingCard.kt create mode 100644 core/designsystem/src/main/kotlin/com/joegec/joycon2android/ui/components/DropdownOption.kt delete mode 100644 core/designsystem/src/main/kotlin/com/joegec/joycon2android/ui/components/LabeledDropdown.kt create mode 100644 core/designsystem/src/main/kotlin/com/joegec/joycon2android/ui/components/OptionDropdown.kt create mode 100644 core/designsystem/src/main/kotlin/com/joegec/joycon2android/ui/components/TextInputDialog.kt diff --git a/README.md b/README.md index 7cb695a..69e9049 100644 --- a/README.md +++ b/README.md @@ -87,16 +87,29 @@ the emulator afterwards — it only reads its config when it starts. | Virtual Gamepad | Eden, Eden Nightly, Dolphin (GameCube) | buttons and sticks | | DSU Motion Server | Eden, Eden Nightly, Dolphin (Wii) | buttons, sticks and motion | -The gamepad button beside **Set up** opens the mapping editor. A single Joy-Con is set up as a Pro -Controller held sideways, so every button works in every game +The gamepad button beside **Set up** opens the mapping editor. Every connected player gets a card — +tap one to open its bindings. A single Joy-Con is set up as a Pro Controller held sideways, so every +button works in every game ([why](docs/virtual-gamepad.md#why-theyre-set-up-as-pro-controllers)). A target can take **several sources at once** — tick as many as you like, and any of them fires it. -The Wii editor also carries a **Sideways Wii Remote** switch — play a single Joy-Con as a Wii Remote -held sideways, which is what a wheel game steers by. Each layout sets it (on for Mario Kart, off for -the others) and you can override it; picking a layout hands it back. +Each player picks their own **layout** to start from, which resets that player's customizations. The +name on the card reads **Custom** the moment you change a binding, and reads the layout's own name +again as soon as you change it back. The **save** icon beside the name keeps what you have built as a +layout of your own, offered to any player holding the same body — it suggests the next free +**Custom N**, and dims once there is nothing new to save, since a mapping that already reads as a +layout has a name. The bin in the dropdown deletes +one: your buttons stay exactly as they are, the name just becomes **Custom** until you save it again. -It also picks a **layout** to start from, which resets that console's customizations: +**All players** at the top sets everyone at once, and saves the same way. A saved set remembers which +player held which body — the sub-label under its name says which ("P1 L, P2 R, P3 L/R") — so it stays +greyed out until those players are back. + +A player holding a lone Joy-Con on the Wii console also gets a **Sideways Wii Remote** switch — play +it as a Wii Remote held sideways, which is what a wheel game steers by. Each layout sets it (on for +Mario Kart, off for the others) and you can override it; picking a layout hands it back. + +The layouts the app ships: | Layout | For | |---|---| @@ -181,6 +194,12 @@ shoulder buttons. - **Turn the four D-pad bindings a quarter**: put the source you'd bind to Up on **D-Pad/Right**, Right on Down, Down on Left, Left on Up. A sideways remote's d-pad turns with it, so the player's up is the remote's right. + - For **tricks**, amplify each accelerometer input's transient — write + ``\`Accel Up\` + (\`Accel Up\` - smooth(\`Accel Up\`, 0.03)) * 2`` in place of ``\`Accel Up\``, + and so on for all six. A Joy-Con flick lands a fraction of the jerk a Wii Wheel does; this lifts + it without moving the gravity that steers. Flick in the **plane of the wheel** — an upward jab + of a flat-held Joy-Con goes along the axle, which has no trick + ([why](docs/dsu-motion.md#dolphin-wii-remote-mapping)). Leave Dolphin's own **Sideways Wii Remote** option off either way — it would turn the accelerometer a second quarter. diff --git a/app/src/main/java/com/joegec/joycon2android/AppContainer.kt b/app/src/main/java/com/joegec/joycon2android/AppContainer.kt index 4d5b2dd..a29eb5d 100644 --- a/app/src/main/java/com/joegec/joycon2android/AppContainer.kt +++ b/app/src/main/java/com/joegec/joycon2android/AppContainer.kt @@ -12,21 +12,31 @@ import com.joegec.joycon2android.connection.StartScanUseCase import com.joegec.joycon2android.connection.StopScanUseCase import com.joegec.joycon2android.connection.ViewModePreferences import com.joegec.joycon2android.connection.ViewModePreferencesDataStore -import com.joegec.joycon2android.buttonmapping.ApplyMappingPresetUseCase +import com.joegec.joycon2android.buttonmapping.ApplyGlobalLayoutUseCase +import com.joegec.joycon2android.buttonmapping.ApplyMappingLayoutUseCase import com.joegec.joycon2android.buttonmapping.ControllerMappingDataStore import com.joegec.joycon2android.buttonmapping.ControllerMappingRepository +import com.joegec.joycon2android.buttonmapping.DeleteCustomLayoutUseCase +import com.joegec.joycon2android.buttonmapping.DeleteGlobalLayoutUseCase import com.joegec.joycon2android.buttonmapping.GetEffectiveControllerMappingUseCase import com.joegec.joycon2android.buttonmapping.GetSidewaysRemoteUseCase -import com.joegec.joycon2android.buttonmapping.MappingPresetDataStore -import com.joegec.joycon2android.buttonmapping.MappingPresetRepository +import com.joegec.joycon2android.buttonmapping.GlobalLayoutDataStore +import com.joegec.joycon2android.buttonmapping.GlobalLayoutRepository +import com.joegec.joycon2android.buttonmapping.ApplyPlayerMappingUseCase import com.joegec.joycon2android.buttonmapping.ObserveControllerMappingUseCase -import com.joegec.joycon2android.buttonmapping.ObserveMappingPresetUseCase +import com.joegec.joycon2android.buttonmapping.ObserveGlobalMappingUseCase +import com.joegec.joycon2android.buttonmapping.ObserveSavedLayoutsUseCase +import com.joegec.joycon2android.buttonmapping.ObservePlayerMappingUseCase +import com.joegec.joycon2android.buttonmapping.ObserveSidewaysRemoteUseCase import com.joegec.joycon2android.buttonmapping.ResetControllerMappingUseCase +import com.joegec.joycon2android.buttonmapping.SaveCustomLayoutUseCase +import com.joegec.joycon2android.buttonmapping.SaveGlobalLayoutUseCase +import com.joegec.joycon2android.buttonmapping.SavedLayoutDataStore +import com.joegec.joycon2android.buttonmapping.SavedLayoutRepository import com.joegec.joycon2android.buttonmapping.SetControllerMappingUseCase import com.joegec.joycon2android.buttonmapping.SetSidewaysRemoteUseCase import com.joegec.joycon2android.buttonmapping.SidewaysRemoteDataStore import com.joegec.joycon2android.buttonmapping.SidewaysRemoteRepository -import com.joegec.joycon2android.buttonmapping.ObserveSidewaysRemoteUseCase import com.joegec.joycon2android.assignment.AssignmentRepository import com.joegec.joycon2android.assignment.ComboAssignmentDetector import com.joegec.joycon2android.assignment.PlayerAssignmentManager @@ -106,16 +116,30 @@ class AppContainer(context: Context) { // --- Controller button mapping (shared by Gamepad and DSU) --- private val controllerMappingRepository: ControllerMappingRepository = ControllerMappingDataStore(appContext) - private val mappingPresetRepository: MappingPresetRepository = MappingPresetDataStore(appContext) - val observeMappingPreset = ObserveMappingPresetUseCase(mappingPresetRepository) + private val savedLayoutRepository: SavedLayoutRepository = SavedLayoutDataStore(appContext) + private val globalLayoutRepository: GlobalLayoutRepository = GlobalLayoutDataStore(appContext) private val sidewaysRemoteRepository: SidewaysRemoteRepository = SidewaysRemoteDataStore(appContext) - val observeSidewaysRemote = ObserveSidewaysRemoteUseCase(sidewaysRemoteRepository, observeMappingPreset) - val setSidewaysRemote = SetSidewaysRemoteUseCase(sidewaysRemoteRepository) - val applyMappingPreset = - ApplyMappingPresetUseCase(mappingPresetRepository, controllerMappingRepository, sidewaysRemoteRepository) - val observeControllerMapping = ObserveControllerMappingUseCase(controllerMappingRepository, observeMappingPreset) + + private val observeControllerMapping = ObserveControllerMappingUseCase(controllerMappingRepository) + private val observeSidewaysRemote = ObserveSidewaysRemoteUseCase(sidewaysRemoteRepository) + private val observePlayerMapping = + ObservePlayerMappingUseCase(observeControllerMapping, observeSidewaysRemote, savedLayoutRepository) + private val applyPlayerMapping = + ApplyPlayerMappingUseCase(controllerMappingRepository, sidewaysRemoteRepository) + + val observeGlobalMapping = ObserveGlobalMappingUseCase(observePlayerMapping, globalLayoutRepository) + val observeSavedLayouts = ObserveSavedLayoutsUseCase(savedLayoutRepository) val setControllerMapping = SetControllerMappingUseCase(controllerMappingRepository) - val resetControllerMapping = ResetControllerMappingUseCase(controllerMappingRepository) + val setSidewaysRemote = SetSidewaysRemoteUseCase(sidewaysRemoteRepository) + val applyMappingLayout = ApplyMappingLayoutUseCase(savedLayoutRepository, applyPlayerMapping) + val resetControllerMapping = ResetControllerMappingUseCase(applyMappingLayout) + val applyGlobalLayout = + ApplyGlobalLayoutUseCase(globalLayoutRepository, applyMappingLayout, applyPlayerMapping) + val saveCustomLayout = SaveCustomLayoutUseCase(savedLayoutRepository, observePlayerMapping) + val saveGlobalLayout = SaveGlobalLayoutUseCase(globalLayoutRepository, observePlayerMapping) + val deleteCustomLayout = DeleteCustomLayoutUseCase(savedLayoutRepository) + val deleteGlobalLayout = DeleteGlobalLayoutUseCase(globalLayoutRepository) + private val getControllerMapping = GetEffectiveControllerMappingUseCase(observeControllerMapping) private val getSidewaysRemote = GetSidewaysRemoteUseCase(observeSidewaysRemote) diff --git a/app/src/main/java/com/joegec/joycon2android/MainActivity.kt b/app/src/main/java/com/joegec/joycon2android/MainActivity.kt index bdd94f4..d13dbfd 100644 --- a/app/src/main/java/com/joegec/joycon2android/MainActivity.kt +++ b/app/src/main/java/com/joegec/joycon2android/MainActivity.kt @@ -24,9 +24,10 @@ import androidx.compose.ui.Modifier import androidx.lifecycle.viewmodel.initializer import androidx.lifecycle.viewmodel.viewModelFactory import com.joegec.joycon2android.buttonmapping.Console -import com.joegec.joycon2android.buttonmapping.JoyconSide +import com.joegec.joycon2android.buttonmapping.body import com.joegec.joycon2android.buttonmapping.presentation.ControllerMappingScreen import com.joegec.joycon2android.buttonmapping.presentation.ControllerMappingViewModel +import com.joegec.joycon2android.buttonmapping.presentation.MappingActions import com.joegec.joycon2android.dsu.presentation.DsuViewModel import com.joegec.joycon2android.gamepad.presentation.GamepadViewModel import com.joegec.joycon2android.ui.Joycon2ViewModel @@ -91,13 +92,17 @@ class MainActivity : ComponentActivity() { initializer { val c = (application as JoyconApplication).container ControllerMappingViewModel( - c.observeControllerMapping, + c.observeGlobalMapping, + c.observeSavedLayouts, + c.applyMappingLayout, + c.applyGlobalLayout, c.setControllerMapping, c.resetControllerMapping, - c.observeMappingPreset, - c.applyMappingPreset, - c.observeSidewaysRemote, c.setSidewaysRemote, + c.saveCustomLayout, + c.saveGlobalLayout, + c.deleteCustomLayout, + c.deleteGlobalLayout, ) } } @@ -189,29 +194,37 @@ class MainActivity : ComponentActivity() { @Composable private fun ControllerMappingRoute(console: Console, onBack: () -> Unit) { - val leftMapping by controllerMappingViewModel.mapping(console, JoyconSide.LEFT).collectAsState() - val rightMapping by controllerMappingViewModel.mapping(console, JoyconSide.RIGHT).collectAsState() - val dualMapping by controllerMappingViewModel.mapping(console, JoyconSide.DUAL).collectAsState() - val presetId by controllerMappingViewModel.preset(console).collectAsState() - val sidewaysRemote by controllerMappingViewModel.sidewaysRemote(console).collectAsState() + val session by viewModel.uiState.collectAsState() + val players = session.activePlayers + val bodies = players.mapNotNull { it.body() } + val state by controllerMappingViewModel.uiState.collectAsState() - ControllerMappingScreen( - console = console, - presetId = presetId, - sidewaysRemote = sidewaysRemote, - leftMapping = leftMapping, - rightMapping = rightMapping, - dualMapping = dualMapping, - onSelectPreset = { controllerMappingViewModel.selectPreset(console, it) }, - onSetSidewaysRemote = { controllerMappingViewModel.setSidewaysRemoteEnabled(console, it) }, - onSetMapping = { side, targetKey, sourceId -> - controllerMappingViewModel.setMapping(console, side, targetKey, sourceId) - }, - onResetMapping = { side -> controllerMappingViewModel.resetMapping(console, side) }, - onBack = onBack, - ) + LaunchedEffect(console, bodies) { controllerMappingViewModel.edit(console, bodies) } + + state?.let { + ControllerMappingScreen( + state = it, + players = players, + actions = mappingActions, + onBack = onBack, + ) + } } + private val mappingActions = MappingActions( + selectLayout = { body, layoutId -> controllerMappingViewModel.selectLayout(body, layoutId) }, + selectGlobalLayout = { controllerMappingViewModel.selectGlobalLayout(it) }, + saveLayout = { body, name -> controllerMappingViewModel.saveLayout(body, name) }, + deleteLayout = { layoutId, global -> controllerMappingViewModel.deleteLayout(layoutId, global) }, + setMapping = { body, targetKey, sourceId -> + controllerMappingViewModel.setMapping(body, targetKey, sourceId) + }, + resetMapping = { controllerMappingViewModel.resetMapping(it) }, + setSidewaysRemote = { body, enabled -> + controllerMappingViewModel.setSidewaysRemoteEnabled(body, enabled) + }, + ) + @Composable private fun MainRoute(onScan: () -> Unit, onOpenMapping: (Console) -> Unit) { val state by viewModel.uiState.collectAsState() diff --git a/app/src/main/java/com/joegec/joycon2android/emulator/EmulatorSetup.kt b/app/src/main/java/com/joegec/joycon2android/emulator/EmulatorSetup.kt index e7e7676..a8e4c2d 100644 --- a/app/src/main/java/com/joegec/joycon2android/emulator/EmulatorSetup.kt +++ b/app/src/main/java/com/joegec/joycon2android/emulator/EmulatorSetup.kt @@ -5,7 +5,9 @@ import android.util.Log import com.joegec.joycon2android.buttonmapping.Console import com.joegec.joycon2android.buttonmapping.GetEffectiveControllerMappingUseCase import com.joegec.joycon2android.buttonmapping.GetSidewaysRemoteUseCase -import com.joegec.joycon2android.buttonmapping.JoyconSide +import com.joegec.joycon2android.buttonmapping.PlayerBody +import com.joegec.joycon2android.buttonmapping.body +import com.joegec.joycon2android.buttonmapping.preset.MappingPresets import com.joegec.joycon2android.dsu.emulator.DolphinDsuConfig import com.joegec.joycon2android.dsu.emulator.DolphinWiimoteConfig import com.joegec.joycon2android.dsu.emulator.EdenDsuConfig @@ -42,10 +44,26 @@ class EmulatorSetup( private val getSidewaysRemote: GetSidewaysRemoteUseCase, ) { - private suspend fun mappingLookup(console: Console): (JoyconSide) -> Map { - val bySide = JoyconSide.entries.associateWith { getControllerMapping(console, it) } - return { side -> bySide.getValue(side) } + // Read up front, once per body in play: the generators are synchronous, and a player whose body + // was never stored falls back to the console's default layout rather than to nothing. + private suspend fun mappingLookup( + console: Console, + players: List, + ): (PlayerBody) -> Map { + val stored = bodiesOf(players).associateWith { getControllerMapping(console, it) } + return { body -> stored[body] ?: MappingPresets.default(console).entries(body.side) } + } + + private suspend fun sidewaysRemoteLookup( + console: Console, + players: List, + ): (PlayerBody) -> Boolean { + val stored = bodiesOf(players).associateWith { getSidewaysRemote(console, it) } + return { body -> stored[body] ?: false } } + + private fun bodiesOf(players: List) = players.mapNotNull { it.body() }.distinct() + /** Installed emulators whose controller mapping the Virtual Gamepad can configure. */ fun gamepadEmulators(): List = buildList { if (isInstalled(DolphinPaths.PACKAGE)) { @@ -96,7 +114,7 @@ class EmulatorSetup( val path = EdenDsuConfig.path(emulatorId) val written = shell.writeText( path, - EdenDsuConfig.merge(shell.readText(path), players, mappingLookup(Console.SWITCH_PRO)), + EdenDsuConfig.merge(shell.readText(path), players, mappingLookup(Console.SWITCH_PRO, players)), ) if (written) EmulatorSetupResult.SUCCESS else EmulatorSetupResult.FAILED } @@ -120,8 +138,8 @@ class EmulatorSetup( DolphinWiimoteConfig.merge( shell.readText(DolphinWiimoteConfig.path), players, - getSidewaysRemote(Console.WIIMOTE_NUNCHUK), - mappingLookup(Console.WIIMOTE_NUNCHUK), + sidewaysRemoteLookup(Console.WIIMOTE_NUNCHUK, players), + mappingLookup(Console.WIIMOTE_NUNCHUK, players), ), ) @@ -146,7 +164,7 @@ class EmulatorSetup( shell.readText(path), players, gamepadDevices(), - mappingLookup(Console.SWITCH_PRO), + mappingLookup(Console.SWITCH_PRO, players), ), ) } else { @@ -154,7 +172,7 @@ class EmulatorSetup( shell.readText(DolphinGcpadConfig.path), players, gamepadControllerNumbers(), - mappingLookup(Console.GAMECUBE), + mappingLookup(Console.GAMECUBE, players), ) val mappingsOk = shell.writeText(DolphinGcpadConfig.path, mappings) // Dolphin GC ports default to "None"; set them to Standard Controller diff --git a/core/buttonmapping/data/src/main/kotlin/com/joegec/joycon2android/buttonmapping/ControllerMappingDataStore.kt b/core/buttonmapping/data/src/main/kotlin/com/joegec/joycon2android/buttonmapping/ControllerMappingDataStore.kt index 7ac37e4..d1558c6 100644 --- a/core/buttonmapping/data/src/main/kotlin/com/joegec/joycon2android/buttonmapping/ControllerMappingDataStore.kt +++ b/core/buttonmapping/data/src/main/kotlin/com/joegec/joycon2android/buttonmapping/ControllerMappingDataStore.kt @@ -9,34 +9,43 @@ import androidx.datastore.preferences.preferencesDataStore import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.map -private val Context.controllerMappingDataStore: DataStore by - preferencesDataStore(name = "controller_mapping") +private val Context.controllerMappingDataStore: DataStore by preferencesDataStore( + name = "controller_mapping", + produceMigrations = { listOf(PerPlayerMigration(::perPlayerMappingNames)) }, +) + +// A stored override used to be keyed CONSOLE|SIDE|TARGET, for every player at once. +private fun perPlayerMappingNames(legacyName: String): List? { + val (console, side, target) = segmentsOf(legacyName).takeIf { it.size == 3 } ?: return null + return everyPlayerKey(consoleNamed(console) ?: return null, sideNamed(side) ?: return null, target) +} class ControllerMappingDataStore(context: Context) : ControllerMappingRepository { private val dataStore = context.applicationContext.controllerMappingDataStore - override fun observe(console: Console, side: JoyconSide): Flow> = + override fun observe(console: Console, body: PlayerBody): Flow> = dataStore.data.map { prefs -> - val prefix = keyPrefix(console, side) + val prefix = keyPrefix(console, body) prefs.asMap().entries .filter { (key, _) -> key.name.startsWith(prefix) } .associate { (key, value) -> key.name.removePrefix(prefix) to value.toString() } } - override suspend fun set(console: Console, side: JoyconSide, targetKey: String, sourceId: String) { - dataStore.edit { it[preferenceKey(console, side, targetKey)] = sourceId } + override suspend fun set(console: Console, body: PlayerBody, targetKey: String, sourceId: String) { + dataStore.edit { it[preferenceKey(console, body, targetKey)] = sourceId } } - override suspend fun clear(console: Console, side: JoyconSide) { - val prefix = keyPrefix(console, side) + override suspend fun replace(console: Console, body: PlayerBody, entries: Map) { + val prefix = keyPrefix(console, body) dataStore.edit { prefs -> prefs.asMap().keys.filter { it.name.startsWith(prefix) }.forEach { prefs.remove(it) } + entries.forEach { (targetKey, sourceId) -> prefs[stringPreferencesKey(prefix + targetKey)] = sourceId } } } - private fun keyPrefix(console: Console, side: JoyconSide) = "${console.name}|${side.name}|" + private fun keyPrefix(console: Console, body: PlayerBody) = "${bodyKeyPrefix(console, body)}|" - private fun preferenceKey(console: Console, side: JoyconSide, targetKey: String) = - stringPreferencesKey(keyPrefix(console, side) + targetKey) + private fun preferenceKey(console: Console, body: PlayerBody, targetKey: String) = + stringPreferencesKey(keyPrefix(console, body) + targetKey) } diff --git a/core/buttonmapping/data/src/main/kotlin/com/joegec/joycon2android/buttonmapping/GlobalLayoutDataStore.kt b/core/buttonmapping/data/src/main/kotlin/com/joegec/joycon2android/buttonmapping/GlobalLayoutDataStore.kt new file mode 100644 index 0000000..ebd3b59 --- /dev/null +++ b/core/buttonmapping/data/src/main/kotlin/com/joegec/joycon2android/buttonmapping/GlobalLayoutDataStore.kt @@ -0,0 +1,26 @@ +package com.joegec.joycon2android.buttonmapping + +import android.content.Context +import androidx.datastore.core.DataStore +import androidx.datastore.preferences.core.Preferences +import androidx.datastore.preferences.preferencesDataStore +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.map + +private val Context.globalLayoutDataStore: DataStore by preferencesDataStore(name = "global_layouts") + +class GlobalLayoutDataStore(context: Context) : GlobalLayoutRepository { + + private val documents = JsonDocumentStore( + context.applicationContext.globalLayoutDataStore, + encode = GlobalLayout::toJson, + decode = ::globalLayoutOf, + ) + + override fun observe(): Flow> = + documents.observe().map { layouts -> layouts.sortedBy { it.displayName } } + + override suspend fun save(layout: GlobalLayout) = documents.save(layout.id, layout) + + override suspend fun delete(layoutId: String) = documents.delete(layoutId) +} diff --git a/core/buttonmapping/data/src/main/kotlin/com/joegec/joycon2android/buttonmapping/JsonDocumentStore.kt b/core/buttonmapping/data/src/main/kotlin/com/joegec/joycon2android/buttonmapping/JsonDocumentStore.kt new file mode 100644 index 0000000..553dcf7 --- /dev/null +++ b/core/buttonmapping/data/src/main/kotlin/com/joegec/joycon2android/buttonmapping/JsonDocumentStore.kt @@ -0,0 +1,27 @@ +package com.joegec.joycon2android.buttonmapping + +import androidx.datastore.core.DataStore +import androidx.datastore.preferences.core.Preferences +import androidx.datastore.preferences.core.edit +import androidx.datastore.preferences.core.stringPreferencesKey +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.map + +/** A set of JSON documents in preference storage, one per key, keyed by the document's own id. */ +internal class JsonDocumentStore( + private val dataStore: DataStore, + private val encode: (T) -> String, + private val decode: (id: String, json: String) -> T?, +) { + fun observe(): Flow> = dataStore.data.map { prefs -> + prefs.asMap().entries.mapNotNull { (key, value) -> decode(key.name, value.toString()) } + } + + suspend fun save(id: String, document: T) { + dataStore.edit { it[stringPreferencesKey(id)] = encode(document) } + } + + suspend fun delete(id: String) { + dataStore.edit { it.remove(stringPreferencesKey(id)) } + } +} diff --git a/core/buttonmapping/data/src/main/kotlin/com/joegec/joycon2android/buttonmapping/LayoutJson.kt b/core/buttonmapping/data/src/main/kotlin/com/joegec/joycon2android/buttonmapping/LayoutJson.kt new file mode 100644 index 0000000..99ced3b --- /dev/null +++ b/core/buttonmapping/data/src/main/kotlin/com/joegec/joycon2android/buttonmapping/LayoutJson.kt @@ -0,0 +1,68 @@ +package com.joegec.joycon2android.buttonmapping + +import com.joegec.joycon2android.model.PlayerNumber +import org.json.JSONArray +import org.json.JSONObject + +private const val NAME = "name" +private const val CONSOLE = "console" +private const val SIDE = "side" +private const val SIDEWAYS_REMOTE = "sidewaysRemote" +private const val BINDINGS = "bindings" +private const val BODIES = "bodies" +private const val PLAYER = "player" +private const val ENTRIES = "entries" + +internal fun SavedLayout.toJson(): String = JSONObject() + .put(NAME, displayName) + .put(CONSOLE, console.name) + .put(SIDE, side.name) + .put(SIDEWAYS_REMOTE, sidewaysRemote) + .put(BINDINGS, JSONObject(bindings)) + .toString() + +/** Null for a document this build can no longer read, so one bad entry can't take the list with it. */ +internal fun savedLayoutOf(id: String, json: String): SavedLayout? = runCatching { + val document = JSONObject(json) + SavedLayout( + id = id, + displayName = document.getString(NAME), + console = Console.valueOf(document.getString(CONSOLE)), + side = JoyconSide.valueOf(document.getString(SIDE)), + bindings = document.getJSONObject(BINDINGS).toStringMap(), + sidewaysRemote = document.optBoolean(SIDEWAYS_REMOTE), + ) +}.getOrNull() + +internal fun GlobalLayout.toJson(): String = JSONObject() + .put(NAME, displayName) + .put(CONSOLE, console.name) + .put(BODIES, JSONArray(bodies.map { it.toJson() })) + .toString() + +internal fun globalLayoutOf(id: String, json: String): GlobalLayout? = runCatching { + val document = JSONObject(json) + GlobalLayout( + id = id, + displayName = document.getString(NAME), + console = Console.valueOf(document.getString(CONSOLE)), + bodies = document.getJSONArray(BODIES).objects().map { it.toSnapshot() }, + ) +}.getOrNull() + +private fun PlayerLayoutSnapshot.toJson() = JSONObject() + .put(PLAYER, body.player.name) + .put(SIDE, body.side.name) + .put(SIDEWAYS_REMOTE, sidewaysRemote) + .put(ENTRIES, JSONObject(entries)) + +private fun JSONObject.toSnapshot() = PlayerLayoutSnapshot( + body = PlayerBody(PlayerNumber.valueOf(getString(PLAYER)), JoyconSide.valueOf(getString(SIDE))), + entries = getJSONObject(ENTRIES).toStringMap(), + sidewaysRemote = optBoolean(SIDEWAYS_REMOTE), +) + +private fun JSONObject.toStringMap(): Map = + keys().asSequence().associateWith { getString(it) } + +private fun JSONArray.objects(): List = (0 until length()).map { getJSONObject(it) } diff --git a/core/buttonmapping/data/src/main/kotlin/com/joegec/joycon2android/buttonmapping/MappingPresetDataStore.kt b/core/buttonmapping/data/src/main/kotlin/com/joegec/joycon2android/buttonmapping/MappingPresetDataStore.kt deleted file mode 100644 index aacfb37..0000000 --- a/core/buttonmapping/data/src/main/kotlin/com/joegec/joycon2android/buttonmapping/MappingPresetDataStore.kt +++ /dev/null @@ -1,26 +0,0 @@ -package com.joegec.joycon2android.buttonmapping - -import android.content.Context -import androidx.datastore.core.DataStore -import androidx.datastore.preferences.core.Preferences -import androidx.datastore.preferences.core.edit -import androidx.datastore.preferences.core.stringPreferencesKey -import androidx.datastore.preferences.preferencesDataStore -import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.flow.map - -private val Context.mappingPresetDataStore: DataStore by - preferencesDataStore(name = "mapping_preset") - -class MappingPresetDataStore(context: Context) : MappingPresetRepository { - - private val dataStore = context.applicationContext.mappingPresetDataStore - - override fun observe(console: Console): Flow = dataStore.data.map { it[preferenceKey(console)] } - - override suspend fun set(console: Console, presetId: String) { - dataStore.edit { it[preferenceKey(console)] = presetId } - } - - private fun preferenceKey(console: Console) = stringPreferencesKey(console.name) -} diff --git a/core/buttonmapping/data/src/main/kotlin/com/joegec/joycon2android/buttonmapping/PerPlayerKeys.kt b/core/buttonmapping/data/src/main/kotlin/com/joegec/joycon2android/buttonmapping/PerPlayerKeys.kt new file mode 100644 index 0000000..44b4560 --- /dev/null +++ b/core/buttonmapping/data/src/main/kotlin/com/joegec/joycon2android/buttonmapping/PerPlayerKeys.kt @@ -0,0 +1,28 @@ +package com.joegec.joycon2android.buttonmapping + +import com.joegec.joycon2android.model.PlayerNumber + +private const val SEPARATOR = "|" + +internal fun bodyKeyPrefix(console: Console, body: PlayerBody) = + listOf(console.name, body.player.name, body.side.name).joinToString(SEPARATOR) + +internal fun segmentsOf(name: String) = name.split(SEPARATOR) + +internal fun consoleNamed(name: String) = Console.entries.firstOrNull { it.name == name } + +internal fun sideNamed(name: String) = JoyconSide.entries.firstOrNull { it.name == name } + +/** Every body a console-wide setting now belongs to, in the new key's shape. */ +internal fun everyBodyKey(console: Console, target: String? = null): List = + PlayerNumber.entries.flatMap { player -> + JoyconSide.entries.map { side -> + listOfNotNull(console.name, player.name, side.name, target).joinToString(SEPARATOR) + } + } + +/** Every player's copy of a setting that already names the body it belongs to. */ +internal fun everyPlayerKey(console: Console, side: JoyconSide, target: String): List = + PlayerNumber.entries.map { player -> + listOf(console.name, player.name, side.name, target).joinToString(SEPARATOR) + } diff --git a/core/buttonmapping/data/src/main/kotlin/com/joegec/joycon2android/buttonmapping/PerPlayerMigration.kt b/core/buttonmapping/data/src/main/kotlin/com/joegec/joycon2android/buttonmapping/PerPlayerMigration.kt new file mode 100644 index 0000000..5d35e3c --- /dev/null +++ b/core/buttonmapping/data/src/main/kotlin/com/joegec/joycon2android/buttonmapping/PerPlayerMigration.kt @@ -0,0 +1,40 @@ +package com.joegec.joycon2android.buttonmapping + +import androidx.datastore.core.DataMigration +import androidx.datastore.preferences.core.MutablePreferences +import androidx.datastore.preferences.core.Preferences +import androidx.datastore.preferences.core.booleanPreferencesKey +import androidx.datastore.preferences.core.stringPreferencesKey + +/** + * Mapping used to be one setting per console, shared by every player. Each player now holds their + * own, so a console-wide value already stored is handed to all of them — which is what it meant. + */ +internal class PerPlayerMigration( + private val perPlayerNames: (legacyName: String) -> List?, +) : DataMigration { + + override suspend fun shouldMigrate(currentData: Preferences) = + currentData.asMap().keys.any { perPlayerNames(it.name) != null } + + override suspend fun migrate(currentData: Preferences): Preferences { + val migrated = currentData.toMutablePreferences() + currentData.asMap().forEach { (key, value) -> + val names = perPlayerNames(key.name) ?: return@forEach + names.forEach { name -> migrated.put(name, value) } + migrated.remove(key) + } + return migrated + } + + override suspend fun cleanUp() = Unit +} + +// Preferences keys carry their value type, and only these two are ever stored here. +@Suppress("UNCHECKED_CAST") +private fun MutablePreferences.put(name: String, value: Any) { + when (value) { + is String -> this[stringPreferencesKey(name)] = value + is Boolean -> this[booleanPreferencesKey(name)] = value + } +} diff --git a/core/buttonmapping/data/src/main/kotlin/com/joegec/joycon2android/buttonmapping/SavedLayoutDataStore.kt b/core/buttonmapping/data/src/main/kotlin/com/joegec/joycon2android/buttonmapping/SavedLayoutDataStore.kt new file mode 100644 index 0000000..482235c --- /dev/null +++ b/core/buttonmapping/data/src/main/kotlin/com/joegec/joycon2android/buttonmapping/SavedLayoutDataStore.kt @@ -0,0 +1,26 @@ +package com.joegec.joycon2android.buttonmapping + +import android.content.Context +import androidx.datastore.core.DataStore +import androidx.datastore.preferences.core.Preferences +import androidx.datastore.preferences.preferencesDataStore +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.map + +private val Context.savedLayoutDataStore: DataStore by preferencesDataStore(name = "saved_layouts") + +class SavedLayoutDataStore(context: Context) : SavedLayoutRepository { + + private val documents = JsonDocumentStore( + context.applicationContext.savedLayoutDataStore, + encode = SavedLayout::toJson, + decode = ::savedLayoutOf, + ) + + override fun observe(): Flow> = + documents.observe().map { layouts -> layouts.sortedBy { it.displayName } } + + override suspend fun save(layout: SavedLayout) = documents.save(layout.id, layout) + + override suspend fun delete(layoutId: String) = documents.delete(layoutId) +} diff --git a/core/buttonmapping/data/src/main/kotlin/com/joegec/joycon2android/buttonmapping/SidewaysRemoteDataStore.kt b/core/buttonmapping/data/src/main/kotlin/com/joegec/joycon2android/buttonmapping/SidewaysRemoteDataStore.kt index de4b745..3dfd302 100644 --- a/core/buttonmapping/data/src/main/kotlin/com/joegec/joycon2android/buttonmapping/SidewaysRemoteDataStore.kt +++ b/core/buttonmapping/data/src/main/kotlin/com/joegec/joycon2android/buttonmapping/SidewaysRemoteDataStore.kt @@ -9,22 +9,28 @@ import androidx.datastore.preferences.preferencesDataStore import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.map -private val Context.sidewaysRemoteDataStore: DataStore by - preferencesDataStore(name = "sideways_remote") +private val Context.sidewaysRemoteDataStore: DataStore by preferencesDataStore( + name = "sideways_remote", + produceMigrations = { listOf(PerPlayerMigration(::perPlayerSidewaysNames)) }, +) + +// The switch used to be keyed by the console alone, for every player and body at once. +private fun perPlayerSidewaysNames(legacyName: String): List? = + segmentsOf(legacyName).singleOrNull() + ?.let(::consoleNamed) + ?.let { everyBodyKey(it) } class SidewaysRemoteDataStore(context: Context) : SidewaysRemoteRepository { private val dataStore = context.applicationContext.sidewaysRemoteDataStore - override fun observe(console: Console): Flow = dataStore.data.map { it[preferenceKey(console)] } - - override suspend fun set(console: Console, enabled: Boolean) { - dataStore.edit { it[preferenceKey(console)] = enabled } - } + override fun observe(console: Console, body: PlayerBody): Flow = + dataStore.data.map { it[preferenceKey(console, body)] } - override suspend fun clear(console: Console) { - dataStore.edit { it.remove(preferenceKey(console)) } + override suspend fun set(console: Console, body: PlayerBody, enabled: Boolean) { + dataStore.edit { it[preferenceKey(console, body)] = enabled } } - private fun preferenceKey(console: Console) = booleanPreferencesKey(console.name) + private fun preferenceKey(console: Console, body: PlayerBody) = + booleanPreferencesKey(bodyKeyPrefix(console, body)) } diff --git a/core/buttonmapping/domain/src/main/kotlin/com/joegec/joycon2android/buttonmapping/ApplyGlobalLayoutUseCase.kt b/core/buttonmapping/domain/src/main/kotlin/com/joegec/joycon2android/buttonmapping/ApplyGlobalLayoutUseCase.kt new file mode 100644 index 0000000..082008c --- /dev/null +++ b/core/buttonmapping/domain/src/main/kotlin/com/joegec/joycon2android/buttonmapping/ApplyGlobalLayoutUseCase.kt @@ -0,0 +1,24 @@ +package com.joegec.joycon2android.buttonmapping + +import kotlinx.coroutines.flow.first + +/** + * Sets every player at once: a shipped or saved layout goes to all of them, while a saved set gives + * each player back the bindings it froze. Those bindings stand on their own, so a set still restores + * exactly what it saved even after the layout a player was on has been deleted — the card simply + * reads Custom until an identical layout exists again. + */ +class ApplyGlobalLayoutUseCase( + private val globalLayouts: GlobalLayoutRepository, + private val applyMappingLayout: ApplyMappingLayoutUseCase, + private val applyPlayerMapping: ApplyPlayerMappingUseCase, +) { + suspend operator fun invoke(console: Console, bodies: List, layoutId: String) { + val saved = globalLayouts.observe().first().firstOrNull { it.id == layoutId } + if (saved == null) { + bodies.forEach { applyMappingLayout(console, it, layoutId) } + } else { + saved.bodies.forEach { applyPlayerMapping(console, it.body, it.entries, it.sidewaysRemote) } + } + } +} diff --git a/core/buttonmapping/domain/src/main/kotlin/com/joegec/joycon2android/buttonmapping/ApplyMappingLayoutUseCase.kt b/core/buttonmapping/domain/src/main/kotlin/com/joegec/joycon2android/buttonmapping/ApplyMappingLayoutUseCase.kt new file mode 100644 index 0000000..1eeb092 --- /dev/null +++ b/core/buttonmapping/domain/src/main/kotlin/com/joegec/joycon2android/buttonmapping/ApplyMappingLayoutUseCase.kt @@ -0,0 +1,19 @@ +package com.joegec.joycon2android.buttonmapping + +import kotlinx.coroutines.flow.first + +/** Sets one player's body to a layout, taking a copy of everything it says. */ +class ApplyMappingLayoutUseCase( + private val savedLayouts: SavedLayoutRepository, + private val applyPlayerMapping: ApplyPlayerMappingUseCase, +) { + suspend operator fun invoke(console: Console, body: PlayerBody, layoutId: String) { + val layout = MappingLayouts.byId(console, body.side, layoutId, savedLayouts.observe().first()) + applyPlayerMapping( + console, + body, + MappingLayouts.entriesOf(console, body.side, layout), + layout.sidewaysRemote, + ) + } +} diff --git a/core/buttonmapping/domain/src/main/kotlin/com/joegec/joycon2android/buttonmapping/ApplyMappingPresetUseCase.kt b/core/buttonmapping/domain/src/main/kotlin/com/joegec/joycon2android/buttonmapping/ApplyMappingPresetUseCase.kt deleted file mode 100644 index 1da1487..0000000 --- a/core/buttonmapping/domain/src/main/kotlin/com/joegec/joycon2android/buttonmapping/ApplyMappingPresetUseCase.kt +++ /dev/null @@ -1,18 +0,0 @@ -package com.joegec.joycon2android.buttonmapping - -/** - * Switches a console to another layout. Overrides only make sense against the layout they were made - * on, so the console's customizations — its bindings and its sideways-remote switch — go with the - * old one. - */ -class ApplyMappingPresetUseCase( - private val presetRepository: MappingPresetRepository, - private val mappingRepository: ControllerMappingRepository, - private val sidewaysRemoteRepository: SidewaysRemoteRepository, -) { - suspend operator fun invoke(console: Console, presetId: String) { - presetRepository.set(console, presetId) - sidewaysRemoteRepository.clear(console) - JoyconSide.entries.forEach { mappingRepository.clear(console, it) } - } -} diff --git a/core/buttonmapping/domain/src/main/kotlin/com/joegec/joycon2android/buttonmapping/ApplyPlayerMappingUseCase.kt b/core/buttonmapping/domain/src/main/kotlin/com/joegec/joycon2android/buttonmapping/ApplyPlayerMappingUseCase.kt new file mode 100644 index 0000000..945f55c --- /dev/null +++ b/core/buttonmapping/domain/src/main/kotlin/com/joegec/joycon2android/buttonmapping/ApplyPlayerMappingUseCase.kt @@ -0,0 +1,20 @@ +package com.joegec.joycon2android.buttonmapping + +/** + * Writes a body's whole mapping at once. Everything a player is given is written out in full, so + * nothing they play with depends on a layout that can later be deleted. + */ +class ApplyPlayerMappingUseCase( + private val mappingRepository: ControllerMappingRepository, + private val sidewaysRemoteRepository: SidewaysRemoteRepository, +) { + suspend operator fun invoke( + console: Console, + body: PlayerBody, + entries: Map, + sidewaysRemote: Boolean, + ) { + mappingRepository.replace(console, body, entries) + sidewaysRemoteRepository.set(console, body, sidewaysRemote) + } +} diff --git a/core/buttonmapping/domain/src/main/kotlin/com/joegec/joycon2android/buttonmapping/ControllerMappingRepository.kt b/core/buttonmapping/domain/src/main/kotlin/com/joegec/joycon2android/buttonmapping/ControllerMappingRepository.kt index 914dcd8..c7501cd 100644 --- a/core/buttonmapping/domain/src/main/kotlin/com/joegec/joycon2android/buttonmapping/ControllerMappingRepository.kt +++ b/core/buttonmapping/domain/src/main/kotlin/com/joegec/joycon2android/buttonmapping/ControllerMappingRepository.kt @@ -3,13 +3,15 @@ package com.joegec.joycon2android.buttonmapping import kotlinx.coroutines.flow.Flow /** - * Stores the user's overrides to the Joy-Con button mapping, keyed by console shape and body. - * Values are opaque strings (the [MappingSource] ids driving one target, joined by [sourceIdOf], or - * a legacy whole-stick [StickSource] name) — this layer knows nothing about what a key or value - * means, only how to persist it; the presets and use cases give them meaning. + * Stores what each player's body is bound to, keyed by console shape and body. Values are opaque + * strings (the [MappingSource] ids driving one target, joined by [sourceIdOf], or a legacy + * whole-stick [StickSource] name) — this layer knows nothing about what a key or value means, only + * how to persist it; the layouts and use cases give them meaning. */ interface ControllerMappingRepository { - fun observe(console: Console, side: JoyconSide): Flow> - suspend fun set(console: Console, side: JoyconSide, targetKey: String, sourceId: String) - suspend fun clear(console: Console, side: JoyconSide) + fun observe(console: Console, body: PlayerBody): Flow> + suspend fun set(console: Console, body: PlayerBody, targetKey: String, sourceId: String) + + /** Swaps a body's whole mapping in one write, so a layout never lands half-applied. */ + suspend fun replace(console: Console, body: PlayerBody, entries: Map) } diff --git a/core/buttonmapping/domain/src/main/kotlin/com/joegec/joycon2android/buttonmapping/DeleteCustomLayoutUseCase.kt b/core/buttonmapping/domain/src/main/kotlin/com/joegec/joycon2android/buttonmapping/DeleteCustomLayoutUseCase.kt new file mode 100644 index 0000000..e9758ca --- /dev/null +++ b/core/buttonmapping/domain/src/main/kotlin/com/joegec/joycon2android/buttonmapping/DeleteCustomLayoutUseCase.kt @@ -0,0 +1,6 @@ +package com.joegec.joycon2android.buttonmapping + +/** Only the name goes: every player keeps the bindings, which read as Custom until it is saved back. */ +class DeleteCustomLayoutUseCase(private val savedLayouts: SavedLayoutRepository) { + suspend operator fun invoke(layoutId: String) = savedLayouts.delete(layoutId) +} diff --git a/core/buttonmapping/domain/src/main/kotlin/com/joegec/joycon2android/buttonmapping/DeleteGlobalLayoutUseCase.kt b/core/buttonmapping/domain/src/main/kotlin/com/joegec/joycon2android/buttonmapping/DeleteGlobalLayoutUseCase.kt new file mode 100644 index 0000000..901ad9b --- /dev/null +++ b/core/buttonmapping/domain/src/main/kotlin/com/joegec/joycon2android/buttonmapping/DeleteGlobalLayoutUseCase.kt @@ -0,0 +1,6 @@ +package com.joegec.joycon2android.buttonmapping + +/** Deleting a saved set leaves every player exactly where they are; only the name goes. */ +class DeleteGlobalLayoutUseCase(private val globalLayouts: GlobalLayoutRepository) { + suspend operator fun invoke(layoutId: String) = globalLayouts.delete(layoutId) +} diff --git a/core/buttonmapping/domain/src/main/kotlin/com/joegec/joycon2android/buttonmapping/GetEffectiveControllerMappingUseCase.kt b/core/buttonmapping/domain/src/main/kotlin/com/joegec/joycon2android/buttonmapping/GetEffectiveControllerMappingUseCase.kt index 50f6cd4..5ca1cf5 100644 --- a/core/buttonmapping/domain/src/main/kotlin/com/joegec/joycon2android/buttonmapping/GetEffectiveControllerMappingUseCase.kt +++ b/core/buttonmapping/domain/src/main/kotlin/com/joegec/joycon2android/buttonmapping/GetEffectiveControllerMappingUseCase.kt @@ -6,6 +6,6 @@ import kotlinx.coroutines.flow.first class GetEffectiveControllerMappingUseCase( private val observeControllerMapping: ObserveControllerMappingUseCase, ) { - suspend operator fun invoke(console: Console, side: JoyconSide): Map = - observeControllerMapping(console, side).first() + suspend operator fun invoke(console: Console, body: PlayerBody): Map = + observeControllerMapping(console, body).first() } diff --git a/core/buttonmapping/domain/src/main/kotlin/com/joegec/joycon2android/buttonmapping/GetSidewaysRemoteUseCase.kt b/core/buttonmapping/domain/src/main/kotlin/com/joegec/joycon2android/buttonmapping/GetSidewaysRemoteUseCase.kt index d83923d..9c1d4dd 100644 --- a/core/buttonmapping/domain/src/main/kotlin/com/joegec/joycon2android/buttonmapping/GetSidewaysRemoteUseCase.kt +++ b/core/buttonmapping/domain/src/main/kotlin/com/joegec/joycon2android/buttonmapping/GetSidewaysRemoteUseCase.kt @@ -4,5 +4,6 @@ import kotlinx.coroutines.flow.first /** One-shot read, for the emulator-config generators at "Set up" time. */ class GetSidewaysRemoteUseCase(private val observeSidewaysRemote: ObserveSidewaysRemoteUseCase) { - suspend operator fun invoke(console: Console): Boolean = observeSidewaysRemote(console).first() + suspend operator fun invoke(console: Console, body: PlayerBody): Boolean = + observeSidewaysRemote(console, body).first() } diff --git a/core/buttonmapping/domain/src/main/kotlin/com/joegec/joycon2android/buttonmapping/GlobalLayout.kt b/core/buttonmapping/domain/src/main/kotlin/com/joegec/joycon2android/buttonmapping/GlobalLayout.kt new file mode 100644 index 0000000..4ba94e5 --- /dev/null +++ b/core/buttonmapping/domain/src/main/kotlin/com/joegec/joycon2android/buttonmapping/GlobalLayout.kt @@ -0,0 +1,18 @@ +package com.joegec.joycon2android.buttonmapping + +/** + * Every player's mapping saved together under one name. A set is bound to the bodies it was saved + * from — a mapping written for a lone Joy-Con says nothing about a pair — so it can only be + * restored onto the same players holding the same bodies. + */ +data class GlobalLayout( + val id: String, + val displayName: String, + val console: Console, + val bodies: List, +) { + val playerSummary: String + get() = bodies.joinToString(", ") { "P${it.body.player.index} ${it.body.side.shortName}" } + + fun fits(bodies: List): Boolean = this.bodies.map { it.body }.toSet() == bodies.toSet() +} diff --git a/core/buttonmapping/domain/src/main/kotlin/com/joegec/joycon2android/buttonmapping/GlobalLayoutRepository.kt b/core/buttonmapping/domain/src/main/kotlin/com/joegec/joycon2android/buttonmapping/GlobalLayoutRepository.kt new file mode 100644 index 0000000..7d26519 --- /dev/null +++ b/core/buttonmapping/domain/src/main/kotlin/com/joegec/joycon2android/buttonmapping/GlobalLayoutRepository.kt @@ -0,0 +1,10 @@ +package com.joegec.joycon2android.buttonmapping + +import kotlinx.coroutines.flow.Flow + +/** Stores the whole-session layouts the user saved, across every console. */ +interface GlobalLayoutRepository { + fun observe(): Flow> + suspend fun save(layout: GlobalLayout) + suspend fun delete(layoutId: String) +} diff --git a/core/buttonmapping/domain/src/main/kotlin/com/joegec/joycon2android/buttonmapping/GlobalMapping.kt b/core/buttonmapping/domain/src/main/kotlin/com/joegec/joycon2android/buttonmapping/GlobalMapping.kt new file mode 100644 index 0000000..3827473 --- /dev/null +++ b/core/buttonmapping/domain/src/main/kotlin/com/joegec/joycon2android/buttonmapping/GlobalMapping.kt @@ -0,0 +1,25 @@ +package com.joegec.joycon2android.buttonmapping + +/** + * The session read as one setting. It has a name only while every player agrees on one — a saved + * set whose bindings they all still carry, or a single layout every one of them reads as; change + * one player and the session stops being that thing. + */ +data class GlobalMapping( + val players: List, + val savedLayouts: List, +) { + val bodies: List get() = players.map { it.body } + + private val matchingSaved: GlobalLayout? + get() = savedLayouts.firstOrNull { it.bodies == players.map(PlayerMapping::snapshot) } + + private val sharedLayout: MappingLayout? + get() = players.takeIf { it.isNotEmpty() }?.map { it.layout }?.distinct()?.singleOrNull() + + val selectedId: String? get() = matchingSaved?.id ?: sharedLayout?.id + + val displayName: String? get() = matchingSaved?.displayName ?: sharedLayout?.displayName + + val playerSummary: String? get() = matchingSaved?.playerSummary +} diff --git a/core/buttonmapping/domain/src/main/kotlin/com/joegec/joycon2android/buttonmapping/JoyconSide.kt b/core/buttonmapping/domain/src/main/kotlin/com/joegec/joycon2android/buttonmapping/JoyconSide.kt index 63faf1f..61e1346 100644 --- a/core/buttonmapping/domain/src/main/kotlin/com/joegec/joycon2android/buttonmapping/JoyconSide.kt +++ b/core/buttonmapping/domain/src/main/kotlin/com/joegec/joycon2android/buttonmapping/JoyconSide.kt @@ -1,8 +1,8 @@ package com.joegec.joycon2android.buttonmapping /** Which physical body a mapping applies to: a lone Joy-Con of one side, or a full controller. */ -enum class JoyconSide(val displayName: String) { - LEFT("Left Joy-Con"), - RIGHT("Right Joy-Con"), - DUAL("Dual Joy-Cons / Pro Controller"), +enum class JoyconSide(val displayName: String, val shortName: String) { + LEFT("Left Joy-Con", "L"), + RIGHT("Right Joy-Con", "R"), + DUAL("Dual Joy-Cons / Pro Controller", "L/R"), } diff --git a/core/buttonmapping/domain/src/main/kotlin/com/joegec/joycon2android/buttonmapping/MappingLayout.kt b/core/buttonmapping/domain/src/main/kotlin/com/joegec/joycon2android/buttonmapping/MappingLayout.kt new file mode 100644 index 0000000..4a041d5 --- /dev/null +++ b/core/buttonmapping/domain/src/main/kotlin/com/joegec/joycon2android/buttonmapping/MappingLayout.kt @@ -0,0 +1,21 @@ +package com.joegec.joycon2android.buttonmapping + +/** + * A named set of bindings a body can be set to: one the app ships + * ([com.joegec.joycon2android.buttonmapping.preset.MappingPreset]) or one the user saved + * ([SavedLayout]). Entries are in the repository's opaque string form, so a layout and a stored + * override are the same kind of value. + */ +interface MappingLayout { + val id: String + val displayName: String + + /** + * Whether this layout stands a lone Joy-Con in for a Wii Remote held sideways, the way a game + * written for that grip expects one. Its motion turns onto the sideways remote's frame and its + * d-pad turns with it; a pair, held like a remote already, is untouched. + */ + val sidewaysRemote: Boolean get() = false + + fun entries(side: JoyconSide): Map +} diff --git a/core/buttonmapping/domain/src/main/kotlin/com/joegec/joycon2android/buttonmapping/MappingLayouts.kt b/core/buttonmapping/domain/src/main/kotlin/com/joegec/joycon2android/buttonmapping/MappingLayouts.kt new file mode 100644 index 0000000..2c67449 --- /dev/null +++ b/core/buttonmapping/domain/src/main/kotlin/com/joegec/joycon2android/buttonmapping/MappingLayouts.kt @@ -0,0 +1,44 @@ +package com.joegec.joycon2android.buttonmapping + +import com.joegec.joycon2android.buttonmapping.preset.MappingPresets + +/** The layouts one body can choose between: the console's shipped ones, then the user's own. */ +object MappingLayouts { + + fun forBody(console: Console, side: JoyconSide, saved: List): List = + MappingPresets.forConsole(console) + saved.filter { it.console == console && it.side == side } + + /** + * What applying [layout] leaves behind: its own bindings over the console's default ones, so a + * target no layout mentions — one a later build adds, say — still arrives bound to something. + */ + fun entriesOf(console: Console, side: JoyconSide, layout: MappingLayout): Map = + MappingPresets.default(console).entries(side) + layout.entries(side) + + /** + * A layout is recognised by what it says, never by a choice remembered against it: a body reads + * as a layout whenever its bindings *are* that layout's, whoever set them. So deleting a layout + * takes away its name and nothing else, and those same bindings read as it again the day an + * identical layout is saved back. Null is the editor's "Custom". + */ + fun matching( + console: Console, + side: JoyconSide, + entries: Map, + sidewaysRemote: Boolean, + saved: List, + ): MappingLayout? = forBody(console, side, saved).firstOrNull { + it.sidewaysRemote == sidewaysRemote && entriesOf(console, side, it) == entries + } + + /** Falls back to the console's default for an id whose layout has since been deleted. */ + fun byId(console: Console, side: JoyconSide, id: String?, saved: List): MappingLayout = + forBody(console, side, saved).firstOrNull { it.id == id } ?: MappingPresets.default(console) + + /** Ids the user chose, so a saved layout can never collide with a shipped one. */ + fun newId(): String = "saved-${java.util.UUID.randomUUID()}" + + /** The first " N" nothing already answers to, so a suggested name is never a duplicate. */ + fun nextName(prefix: String, taken: Collection): String = + generateSequence(1) { it + 1 }.map { "$prefix $it" }.first { it !in taken } +} diff --git a/core/buttonmapping/domain/src/main/kotlin/com/joegec/joycon2android/buttonmapping/MappingPresetRepository.kt b/core/buttonmapping/domain/src/main/kotlin/com/joegec/joycon2android/buttonmapping/MappingPresetRepository.kt deleted file mode 100644 index 64ef889..0000000 --- a/core/buttonmapping/domain/src/main/kotlin/com/joegec/joycon2android/buttonmapping/MappingPresetRepository.kt +++ /dev/null @@ -1,12 +0,0 @@ -package com.joegec.joycon2android.buttonmapping - -import kotlinx.coroutines.flow.Flow - -/** - * Stores which layout each console is set to, as an opaque preset id — null until the user picks - * one. [com.joegec.joycon2android.buttonmapping.preset.MappingPresets] gives the id meaning. - */ -interface MappingPresetRepository { - fun observe(console: Console): Flow - suspend fun set(console: Console, presetId: String) -} diff --git a/core/buttonmapping/domain/src/main/kotlin/com/joegec/joycon2android/buttonmapping/ObserveControllerMappingUseCase.kt b/core/buttonmapping/domain/src/main/kotlin/com/joegec/joycon2android/buttonmapping/ObserveControllerMappingUseCase.kt index a0fa755..735a83f 100644 --- a/core/buttonmapping/domain/src/main/kotlin/com/joegec/joycon2android/buttonmapping/ObserveControllerMappingUseCase.kt +++ b/core/buttonmapping/domain/src/main/kotlin/com/joegec/joycon2android/buttonmapping/ObserveControllerMappingUseCase.kt @@ -1,15 +1,17 @@ package com.joegec.joycon2android.buttonmapping +import com.joegec.joycon2android.buttonmapping.preset.MappingPresets import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.map -/** The mapping actually in effect for a console/body: stored overrides layered on its preset. */ -class ObserveControllerMappingUseCase( - private val repository: ControllerMappingRepository, - private val observeMappingPreset: ObserveMappingPresetUseCase, -) { - operator fun invoke(console: Console, side: JoyconSide): Flow> = - combine(observeMappingPreset(console), repository.observe(console, side)) { preset, stored -> - preset.entries(side) + stored.withLegacyButtonNamesRenamed().withLegacyStickRoutesExpanded() +/** + * What a player's body is bound to: whatever has been set on it, over the console's default layout + * so that every target is answered even when nothing has ever set that one. + */ +class ObserveControllerMappingUseCase(private val repository: ControllerMappingRepository) { + operator fun invoke(console: Console, body: PlayerBody): Flow> = + repository.observe(console, body).map { stored -> + MappingPresets.default(console).entries(body.side) + + stored.withLegacyButtonNamesRenamed().withLegacyStickRoutesExpanded() } } diff --git a/core/buttonmapping/domain/src/main/kotlin/com/joegec/joycon2android/buttonmapping/ObserveGlobalMappingUseCase.kt b/core/buttonmapping/domain/src/main/kotlin/com/joegec/joycon2android/buttonmapping/ObserveGlobalMappingUseCase.kt new file mode 100644 index 0000000..b78b63f --- /dev/null +++ b/core/buttonmapping/domain/src/main/kotlin/com/joegec/joycon2android/buttonmapping/ObserveGlobalMappingUseCase.kt @@ -0,0 +1,24 @@ +package com.joegec.joycon2android.buttonmapping + +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.flow.map + +/** Every player's mapping alongside the saved sets they could be restored from. */ +class ObserveGlobalMappingUseCase( + private val observePlayerMapping: ObservePlayerMappingUseCase, + private val globalLayouts: GlobalLayoutRepository, +) { + operator fun invoke(console: Console, bodies: List): Flow = + combine(everyPlayer(console, bodies), forConsole(console)) { players, saved -> + GlobalMapping(players, saved) + } + + private fun everyPlayer(console: Console, bodies: List): Flow> = + if (bodies.isEmpty()) flowOf(emptyList()) + else combine(bodies.map { observePlayerMapping(console, it) }) { it.toList() } + + private fun forConsole(console: Console) = + globalLayouts.observe().map { layouts -> layouts.filter { it.console == console } } +} diff --git a/core/buttonmapping/domain/src/main/kotlin/com/joegec/joycon2android/buttonmapping/ObserveMappingPresetUseCase.kt b/core/buttonmapping/domain/src/main/kotlin/com/joegec/joycon2android/buttonmapping/ObserveMappingPresetUseCase.kt deleted file mode 100644 index 080d96e..0000000 --- a/core/buttonmapping/domain/src/main/kotlin/com/joegec/joycon2android/buttonmapping/ObserveMappingPresetUseCase.kt +++ /dev/null @@ -1,12 +0,0 @@ -package com.joegec.joycon2android.buttonmapping - -import com.joegec.joycon2android.buttonmapping.preset.MappingPreset -import com.joegec.joycon2android.buttonmapping.preset.MappingPresets -import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.flow.map - -/** The layout a console is set to, falling back to its default. */ -class ObserveMappingPresetUseCase(private val repository: MappingPresetRepository) { - operator fun invoke(console: Console): Flow = - repository.observe(console).map { MappingPresets.byId(console, it) } -} diff --git a/core/buttonmapping/domain/src/main/kotlin/com/joegec/joycon2android/buttonmapping/ObservePlayerMappingUseCase.kt b/core/buttonmapping/domain/src/main/kotlin/com/joegec/joycon2android/buttonmapping/ObservePlayerMappingUseCase.kt new file mode 100644 index 0000000..32d4005 --- /dev/null +++ b/core/buttonmapping/domain/src/main/kotlin/com/joegec/joycon2android/buttonmapping/ObservePlayerMappingUseCase.kt @@ -0,0 +1,25 @@ +package com.joegec.joycon2android.buttonmapping + +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.combine + +/** Everything the editor shows for one player: their bindings, their switch, and what those name. */ +class ObservePlayerMappingUseCase( + private val observeControllerMapping: ObserveControllerMappingUseCase, + private val observeSidewaysRemote: ObserveSidewaysRemoteUseCase, + private val savedLayouts: SavedLayoutRepository, +) { + operator fun invoke(console: Console, body: PlayerBody): Flow = + combine( + observeControllerMapping(console, body), + observeSidewaysRemote(console, body), + savedLayouts.observe(), + ) { entries, sidewaysRemote, saved -> + PlayerMapping( + body = body, + entries = entries, + sidewaysRemote = sidewaysRemote, + layout = MappingLayouts.matching(console, body.side, entries, sidewaysRemote, saved), + ) + } +} diff --git a/core/buttonmapping/domain/src/main/kotlin/com/joegec/joycon2android/buttonmapping/ObserveSavedLayoutsUseCase.kt b/core/buttonmapping/domain/src/main/kotlin/com/joegec/joycon2android/buttonmapping/ObserveSavedLayoutsUseCase.kt new file mode 100644 index 0000000..ebece06 --- /dev/null +++ b/core/buttonmapping/domain/src/main/kotlin/com/joegec/joycon2android/buttonmapping/ObserveSavedLayoutsUseCase.kt @@ -0,0 +1,10 @@ +package com.joegec.joycon2android.buttonmapping + +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.map + +/** Every layout the user has saved for a console, whichever body each was saved from. */ +class ObserveSavedLayoutsUseCase(private val savedLayouts: SavedLayoutRepository) { + operator fun invoke(console: Console): Flow> = + savedLayouts.observe().map { layouts -> layouts.filter { it.console == console } } +} diff --git a/core/buttonmapping/domain/src/main/kotlin/com/joegec/joycon2android/buttonmapping/ObserveSidewaysRemoteUseCase.kt b/core/buttonmapping/domain/src/main/kotlin/com/joegec/joycon2android/buttonmapping/ObserveSidewaysRemoteUseCase.kt index c5fcf75..3a4279a 100644 --- a/core/buttonmapping/domain/src/main/kotlin/com/joegec/joycon2android/buttonmapping/ObserveSidewaysRemoteUseCase.kt +++ b/core/buttonmapping/domain/src/main/kotlin/com/joegec/joycon2android/buttonmapping/ObserveSidewaysRemoteUseCase.kt @@ -1,15 +1,11 @@ package com.joegec.joycon2android.buttonmapping +import com.joegec.joycon2android.buttonmapping.preset.MappingPresets import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.map -/** Whether a console plays as a sideways Wii Remote: the user's choice, else its layout's. */ -class ObserveSidewaysRemoteUseCase( - private val repository: SidewaysRemoteRepository, - private val observeMappingPreset: ObserveMappingPresetUseCase, -) { - operator fun invoke(console: Console): Flow = - combine(repository.observe(console), observeMappingPreset(console)) { chosen, preset -> - chosen ?: preset.sidewaysRemote - } +/** Whether a body plays as a sideways Wii Remote, falling back to the console's default layout. */ +class ObserveSidewaysRemoteUseCase(private val repository: SidewaysRemoteRepository) { + operator fun invoke(console: Console, body: PlayerBody): Flow = + repository.observe(console, body).map { it ?: MappingPresets.default(console).sidewaysRemote } } diff --git a/core/buttonmapping/domain/src/main/kotlin/com/joegec/joycon2android/buttonmapping/PlayerBody.kt b/core/buttonmapping/domain/src/main/kotlin/com/joegec/joycon2android/buttonmapping/PlayerBody.kt new file mode 100644 index 0000000..3d54069 --- /dev/null +++ b/core/buttonmapping/domain/src/main/kotlin/com/joegec/joycon2android/buttonmapping/PlayerBody.kt @@ -0,0 +1,17 @@ +package com.joegec.joycon2android.buttonmapping + +import com.joegec.joycon2android.model.PlayerNumber +import com.joegec.joycon2android.model.PlayerState + +/** Which player, and which body they are holding — the pair every mapping is stored against. */ +data class PlayerBody(val player: PlayerNumber, val side: JoyconSide) + +/** Null while the player holds nothing. A Pro Controller has a pair's button set, so it maps as one. */ +fun PlayerState.body(): PlayerBody? = joyconSide()?.let { PlayerBody(player, it) } + +fun PlayerState.joyconSide(): JoyconSide? = when { + hasPro || hasFullController -> JoyconSide.DUAL + left != null -> JoyconSide.LEFT + right != null -> JoyconSide.RIGHT + else -> null +} diff --git a/core/buttonmapping/domain/src/main/kotlin/com/joegec/joycon2android/buttonmapping/PlayerLayoutSnapshot.kt b/core/buttonmapping/domain/src/main/kotlin/com/joegec/joycon2android/buttonmapping/PlayerLayoutSnapshot.kt new file mode 100644 index 0000000..e612016 --- /dev/null +++ b/core/buttonmapping/domain/src/main/kotlin/com/joegec/joycon2android/buttonmapping/PlayerLayoutSnapshot.kt @@ -0,0 +1,8 @@ +package com.joegec.joycon2android.buttonmapping + +/** One player's whole mapping, frozen — every binding, not a reference to a layout that can go. */ +data class PlayerLayoutSnapshot( + val body: PlayerBody, + val entries: Map, + val sidewaysRemote: Boolean, +) diff --git a/core/buttonmapping/domain/src/main/kotlin/com/joegec/joycon2android/buttonmapping/PlayerMapping.kt b/core/buttonmapping/domain/src/main/kotlin/com/joegec/joycon2android/buttonmapping/PlayerMapping.kt new file mode 100644 index 0000000..3e213cb --- /dev/null +++ b/core/buttonmapping/domain/src/main/kotlin/com/joegec/joycon2android/buttonmapping/PlayerMapping.kt @@ -0,0 +1,12 @@ +package com.joegec.joycon2android.buttonmapping + +/** One player's mapping as the editor sees it: what their body is bound to, and what that amounts to. */ +data class PlayerMapping( + val body: PlayerBody, + val entries: Map, + val sidewaysRemote: Boolean, + /** The layout these bindings *are*; null once they are no layout's, which the editor calls Custom. */ + val layout: MappingLayout?, +) { + fun snapshot() = PlayerLayoutSnapshot(body, entries, sidewaysRemote) +} diff --git a/core/buttonmapping/domain/src/main/kotlin/com/joegec/joycon2android/buttonmapping/ResetControllerMappingUseCase.kt b/core/buttonmapping/domain/src/main/kotlin/com/joegec/joycon2android/buttonmapping/ResetControllerMappingUseCase.kt index b79119a..9a24280 100644 --- a/core/buttonmapping/domain/src/main/kotlin/com/joegec/joycon2android/buttonmapping/ResetControllerMappingUseCase.kt +++ b/core/buttonmapping/domain/src/main/kotlin/com/joegec/joycon2android/buttonmapping/ResetControllerMappingUseCase.kt @@ -1,6 +1,9 @@ package com.joegec.joycon2android.buttonmapping -/** Discards every override for a console/body, reverting it to the shipped defaults. */ -class ResetControllerMappingUseCase(private val repository: ControllerMappingRepository) { - suspend operator fun invoke(console: Console, side: JoyconSide) = repository.clear(console, side) +import com.joegec.joycon2android.buttonmapping.preset.MappingPresets + +/** Back to the console's own layout, whatever the body had become. */ +class ResetControllerMappingUseCase(private val applyMappingLayout: ApplyMappingLayoutUseCase) { + suspend operator fun invoke(console: Console, body: PlayerBody) = + applyMappingLayout(console, body, MappingPresets.default(console).id) } diff --git a/core/buttonmapping/domain/src/main/kotlin/com/joegec/joycon2android/buttonmapping/SaveCustomLayoutUseCase.kt b/core/buttonmapping/domain/src/main/kotlin/com/joegec/joycon2android/buttonmapping/SaveCustomLayoutUseCase.kt new file mode 100644 index 0000000..be99328 --- /dev/null +++ b/core/buttonmapping/domain/src/main/kotlin/com/joegec/joycon2android/buttonmapping/SaveCustomLayoutUseCase.kt @@ -0,0 +1,26 @@ +package com.joegec.joycon2android.buttonmapping + +import kotlinx.coroutines.flow.first + +/** + * Names what a player has built so any player on that body can pick it again. Nothing is applied: + * the bindings already *are* the layout, so the card takes the new name as soon as it exists. + */ +class SaveCustomLayoutUseCase( + private val savedLayouts: SavedLayoutRepository, + private val observePlayerMapping: ObservePlayerMappingUseCase, +) { + suspend operator fun invoke(console: Console, body: PlayerBody, name: String) { + val current = observePlayerMapping(console, body).first() + savedLayouts.save( + SavedLayout( + id = MappingLayouts.newId(), + displayName = name, + console = console, + side = body.side, + bindings = current.entries, + sidewaysRemote = current.sidewaysRemote, + ), + ) + } +} diff --git a/core/buttonmapping/domain/src/main/kotlin/com/joegec/joycon2android/buttonmapping/SaveGlobalLayoutUseCase.kt b/core/buttonmapping/domain/src/main/kotlin/com/joegec/joycon2android/buttonmapping/SaveGlobalLayoutUseCase.kt new file mode 100644 index 0000000..22da031 --- /dev/null +++ b/core/buttonmapping/domain/src/main/kotlin/com/joegec/joycon2android/buttonmapping/SaveGlobalLayoutUseCase.kt @@ -0,0 +1,14 @@ +package com.joegec.joycon2android.buttonmapping + +import kotlinx.coroutines.flow.first + +/** Names the whole session, bodies and all, so it can be restored once those players return. */ +class SaveGlobalLayoutUseCase( + private val globalLayouts: GlobalLayoutRepository, + private val observePlayerMapping: ObservePlayerMappingUseCase, +) { + suspend operator fun invoke(console: Console, bodies: List, name: String) { + val snapshots = bodies.map { observePlayerMapping(console, it).first().snapshot() } + globalLayouts.save(GlobalLayout(MappingLayouts.newId(), name, console, snapshots)) + } +} diff --git a/core/buttonmapping/domain/src/main/kotlin/com/joegec/joycon2android/buttonmapping/SavedLayout.kt b/core/buttonmapping/domain/src/main/kotlin/com/joegec/joycon2android/buttonmapping/SavedLayout.kt new file mode 100644 index 0000000..d4bd55a --- /dev/null +++ b/core/buttonmapping/domain/src/main/kotlin/com/joegec/joycon2android/buttonmapping/SavedLayout.kt @@ -0,0 +1,14 @@ +package com.joegec.joycon2android.buttonmapping + +/** A layout the user saved from one player's body, offered back to any player holding that body. */ +data class SavedLayout( + override val id: String, + override val displayName: String, + val console: Console, + val side: JoyconSide, + val bindings: Map, + override val sidewaysRemote: Boolean = false, +) : MappingLayout { + /** Saved from one body and only ever offered back to it, so [side] is already the side asked for. */ + override fun entries(side: JoyconSide) = bindings +} diff --git a/core/buttonmapping/domain/src/main/kotlin/com/joegec/joycon2android/buttonmapping/SavedLayoutRepository.kt b/core/buttonmapping/domain/src/main/kotlin/com/joegec/joycon2android/buttonmapping/SavedLayoutRepository.kt new file mode 100644 index 0000000..5eeccd5 --- /dev/null +++ b/core/buttonmapping/domain/src/main/kotlin/com/joegec/joycon2android/buttonmapping/SavedLayoutRepository.kt @@ -0,0 +1,10 @@ +package com.joegec.joycon2android.buttonmapping + +import kotlinx.coroutines.flow.Flow + +/** Stores the layouts the user saved for a single body, across every console. */ +interface SavedLayoutRepository { + fun observe(): Flow> + suspend fun save(layout: SavedLayout) + suspend fun delete(layoutId: String) +} diff --git a/core/buttonmapping/domain/src/main/kotlin/com/joegec/joycon2android/buttonmapping/SetControllerMappingUseCase.kt b/core/buttonmapping/domain/src/main/kotlin/com/joegec/joycon2android/buttonmapping/SetControllerMappingUseCase.kt index 9a217f3..6a4a5b3 100644 --- a/core/buttonmapping/domain/src/main/kotlin/com/joegec/joycon2android/buttonmapping/SetControllerMappingUseCase.kt +++ b/core/buttonmapping/domain/src/main/kotlin/com/joegec/joycon2android/buttonmapping/SetControllerMappingUseCase.kt @@ -2,6 +2,6 @@ package com.joegec.joycon2android.buttonmapping /** Records the user's choice of physical source for one target button or stick. */ class SetControllerMappingUseCase(private val repository: ControllerMappingRepository) { - suspend operator fun invoke(console: Console, side: JoyconSide, targetKey: String, sourceId: String) = - repository.set(console, side, targetKey, sourceId) + suspend operator fun invoke(console: Console, body: PlayerBody, targetKey: String, sourceId: String) = + repository.set(console, body, targetKey, sourceId) } diff --git a/core/buttonmapping/domain/src/main/kotlin/com/joegec/joycon2android/buttonmapping/SetSidewaysRemoteUseCase.kt b/core/buttonmapping/domain/src/main/kotlin/com/joegec/joycon2android/buttonmapping/SetSidewaysRemoteUseCase.kt index 745d90f..889b4d2 100644 --- a/core/buttonmapping/domain/src/main/kotlin/com/joegec/joycon2android/buttonmapping/SetSidewaysRemoteUseCase.kt +++ b/core/buttonmapping/domain/src/main/kotlin/com/joegec/joycon2android/buttonmapping/SetSidewaysRemoteUseCase.kt @@ -2,5 +2,6 @@ package com.joegec.joycon2android.buttonmapping /** Records the user's own answer, which from then on outranks the layout's. */ class SetSidewaysRemoteUseCase(private val repository: SidewaysRemoteRepository) { - suspend operator fun invoke(console: Console, enabled: Boolean) = repository.set(console, enabled) + suspend operator fun invoke(console: Console, body: PlayerBody, enabled: Boolean) = + repository.set(console, body, enabled) } diff --git a/core/buttonmapping/domain/src/main/kotlin/com/joegec/joycon2android/buttonmapping/SidewaysRemoteRepository.kt b/core/buttonmapping/domain/src/main/kotlin/com/joegec/joycon2android/buttonmapping/SidewaysRemoteRepository.kt index f9ff01d..f033e24 100644 --- a/core/buttonmapping/domain/src/main/kotlin/com/joegec/joycon2android/buttonmapping/SidewaysRemoteRepository.kt +++ b/core/buttonmapping/domain/src/main/kotlin/com/joegec/joycon2android/buttonmapping/SidewaysRemoteRepository.kt @@ -2,12 +2,8 @@ package com.joegec.joycon2android.buttonmapping import kotlinx.coroutines.flow.Flow -/** - * The user's own answer to whether a console plays as a sideways Wii Remote, kept apart from the - * layout that seeds it: null until they touch the switch, and cleared again when a layout is applied. - */ +/** The body's answer to whether it plays as a sideways Wii Remote; null until anything has set it. */ interface SidewaysRemoteRepository { - fun observe(console: Console): Flow - suspend fun set(console: Console, enabled: Boolean) - suspend fun clear(console: Console) + fun observe(console: Console, body: PlayerBody): Flow + suspend fun set(console: Console, body: PlayerBody, enabled: Boolean) } diff --git a/core/buttonmapping/domain/src/main/kotlin/com/joegec/joycon2android/buttonmapping/preset/MappingPreset.kt b/core/buttonmapping/domain/src/main/kotlin/com/joegec/joycon2android/buttonmapping/preset/MappingPreset.kt index 41e668f..ea588f3 100644 --- a/core/buttonmapping/domain/src/main/kotlin/com/joegec/joycon2android/buttonmapping/preset/MappingPreset.kt +++ b/core/buttonmapping/domain/src/main/kotlin/com/joegec/joycon2android/buttonmapping/preset/MappingPreset.kt @@ -1,24 +1,9 @@ package com.joegec.joycon2android.buttonmapping.preset import com.joegec.joycon2android.buttonmapping.Console -import com.joegec.joycon2android.buttonmapping.JoyconSide +import com.joegec.joycon2android.buttonmapping.MappingLayout -/** - * A named layout a console can start from: what each body maps to before the user overrides - * anything. Entries are in the repository's opaque string form, so a preset and a stored override - * are the same kind of value. - */ -sealed interface MappingPreset { - val id: String - val displayName: String +/** A layout the app ships: what each body maps to before the user overrides anything. */ +sealed interface MappingPreset : MappingLayout { val console: Console - - /** - * Whether this layout stands a lone Joy-Con in for a Wii Remote held sideways, the way a game - * written for that grip expects one. Its motion turns onto the sideways remote's frame and its - * d-pad turns with it; a pair, held like a remote already, is untouched. - */ - val sidewaysRemote: Boolean get() = false - - fun entries(side: JoyconSide): Map } diff --git a/core/buttonmapping/domain/src/test/kotlin/com/joegec/joycon2android/buttonmapping/FakeMappingRepositories.kt b/core/buttonmapping/domain/src/test/kotlin/com/joegec/joycon2android/buttonmapping/FakeMappingRepositories.kt new file mode 100644 index 0000000..e7f28d9 --- /dev/null +++ b/core/buttonmapping/domain/src/test/kotlin/com/joegec/joycon2android/buttonmapping/FakeMappingRepositories.kt @@ -0,0 +1,61 @@ +package com.joegec.joycon2android.buttonmapping + +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.update + +private typealias BodyKey = Pair + +internal class FakeControllerMappings : ControllerMappingRepository { + private val stored = MutableStateFlow(emptyMap>()) + + override fun observe(console: Console, body: PlayerBody): Flow> = + stored.map { it[console to body].orEmpty() } + + override suspend fun set(console: Console, body: PlayerBody, targetKey: String, sourceId: String) { + stored.update { it + ((console to body) to (it[console to body].orEmpty() + (targetKey to sourceId))) } + } + + override suspend fun replace(console: Console, body: PlayerBody, entries: Map) { + stored.update { it + ((console to body) to entries) } + } +} + +internal class FakeSidewaysRemotes : SidewaysRemoteRepository { + private val stored = MutableStateFlow(emptyMap()) + + override fun observe(console: Console, body: PlayerBody): Flow = stored.map { it[console to body] } + + override suspend fun set(console: Console, body: PlayerBody, enabled: Boolean) { + stored.update { it + ((console to body) to enabled) } + } +} + +internal class FakeSavedLayouts(vararg initial: SavedLayout) : SavedLayoutRepository { + private val stored = MutableStateFlow(initial.associateBy { it.id }) + + override fun observe(): Flow> = stored.map { it.values.toList() } + + override suspend fun save(layout: SavedLayout) { + stored.update { it + (layout.id to layout) } + } + + override suspend fun delete(layoutId: String) { + stored.update { it - layoutId } + } +} + +internal class FakeGlobalLayouts(vararg initial: GlobalLayout) : GlobalLayoutRepository { + private val stored = MutableStateFlow(initial.associateBy { it.id }) + + override fun observe(): Flow> = stored.map { it.values.toList() } + + override suspend fun save(layout: GlobalLayout) { + stored.update { it + (layout.id to layout) } + } + + override suspend fun delete(layoutId: String) { + stored.update { it - layoutId } + } +} diff --git a/core/buttonmapping/domain/src/test/kotlin/com/joegec/joycon2android/buttonmapping/GlobalMappingTest.kt b/core/buttonmapping/domain/src/test/kotlin/com/joegec/joycon2android/buttonmapping/GlobalMappingTest.kt new file mode 100644 index 0000000..b9148ec --- /dev/null +++ b/core/buttonmapping/domain/src/test/kotlin/com/joegec/joycon2android/buttonmapping/GlobalMappingTest.kt @@ -0,0 +1,118 @@ +package com.joegec.joycon2android.buttonmapping + +import com.joegec.joycon2android.buttonmapping.MappingFixture.Companion.left +import com.joegec.joycon2android.buttonmapping.MappingFixture.Companion.right +import com.joegec.joycon2android.buttonmapping.preset.MarioKartWiiMapping +import com.joegec.joycon2android.buttonmapping.preset.WiiMapping +import com.joegec.joycon2android.model.PlayerNumber +import kotlinx.coroutines.runBlocking +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test + +class GlobalMappingTest { + + private val fixture = MappingFixture() + private val first = left() + private val second = right() + private val bodies = listOf(first, second) + + private suspend fun putBothOn(layoutId: String) = + fixture.applyGlobalLayout(fixture.console, bodies, layoutId) + + private suspend fun savedSet() = fixture.globalMapping(first, second).savedLayouts.single() + + @Test + fun `a layout every player reads as names the session`() = runBlocking { + putBothOn(WiiMapping.id) + + assertEquals(WiiMapping.displayName, fixture.globalMapping(first, second).displayName) + } + + @Test + fun `one player changing leaves the session with no name of its own`() = runBlocking { + putBothOn(WiiMapping.id) + + fixture.setMapping(fixture.console, first, "A", "Up") + + assertNull(fixture.globalMapping(first, second).displayName) + } + + @Test + fun `players on different layouts leave the session with no name of its own`() = runBlocking { + fixture.applyLayout(fixture.console, first, MarioKartWiiMapping.id) + fixture.applyLayout(fixture.console, second, WiiMapping.id) + + assertNull(fixture.globalMapping(first, second).displayName) + } + + @Test + fun `a saved set names the session again for as long as every player still matches it`() = runBlocking { + putBothOn(WiiMapping.id) + fixture.setMapping(fixture.console, first, "A", "Up") + + fixture.saveGlobalLayout(fixture.console, bodies, "Party") + assertEquals("Party", fixture.globalMapping(first, second).displayName) + + fixture.setMapping(fixture.console, second, "A", "Down") + assertNull(fixture.globalMapping(first, second).displayName) + } + + @Test + fun `a saved set fits only the players and bodies it was saved from`() = runBlocking { + fixture.saveGlobalLayout(fixture.console, bodies, "Party") + + val saved = savedSet() + + assertTrue(saved.fits(listOf(second, first))) + assertFalse(saved.fits(listOf(first))) + assertFalse(saved.fits(listOf(first, PlayerBody(PlayerNumber.P2, JoyconSide.DUAL)))) + } + + @Test + fun `a saved set names the bodies it wants, player by player`() = runBlocking { + val three = bodies + PlayerBody(PlayerNumber.P3, JoyconSide.DUAL) + fixture.saveGlobalLayout(fixture.console, three, "Party") + + assertEquals("P1 L, P2 R, P3 L/R", savedSet().playerSummary) + } + + @Test + fun `restoring a saved set gives every player back the bindings it froze`() = runBlocking { + fixture.applyLayout(fixture.console, first, MarioKartWiiMapping.id) + fixture.setMapping(fixture.console, first, "A", "Up") + fixture.applyLayout(fixture.console, second, WiiMapping.id) + fixture.saveGlobalLayout(fixture.console, bodies, "Party") + val saved = savedSet() + + putBothOn(WiiMapping.id) + fixture.applyGlobalLayout(fixture.console, bodies, saved.id) + + val restored = fixture.playerMapping(first) + assertEquals("Up", restored.entries["A"]) + assertTrue(restored.sidewaysRemote) + assertEquals(WiiMapping.id, fixture.playerMapping(second).layout?.id) + } + + @Test + fun `a set still restores a deleted layout's bindings, and names them again once it is back`() = runBlocking { + fixture.setMapping(fixture.console, first, "A", "Up") + fixture.saveCustomLayout(fixture.console, first, "My Wheel") + fixture.saveGlobalLayout(fixture.console, bodies, "Party") + val saved = savedSet() + fixture.deleteCustomLayout(fixture.savedLayoutNamed("My Wheel").id) + + putBothOn(WiiMapping.id) + fixture.applyGlobalLayout(fixture.console, bodies, saved.id) + + assertEquals("Up", fixture.playerMapping(first).entries["A"]) + assertNull(fixture.playerMapping(first).layout) + + fixture.saveCustomLayout(fixture.console, first, "My Wheel") + + assertEquals("My Wheel", fixture.playerMapping(first).layout?.displayName) + assertEquals("Party", fixture.globalMapping(first, second).displayName) + } +} diff --git a/core/buttonmapping/domain/src/test/kotlin/com/joegec/joycon2android/buttonmapping/MappingFixture.kt b/core/buttonmapping/domain/src/test/kotlin/com/joegec/joycon2android/buttonmapping/MappingFixture.kt new file mode 100644 index 0000000..0d6afcf --- /dev/null +++ b/core/buttonmapping/domain/src/test/kotlin/com/joegec/joycon2android/buttonmapping/MappingFixture.kt @@ -0,0 +1,43 @@ +package com.joegec.joycon2android.buttonmapping + +import com.joegec.joycon2android.model.PlayerNumber +import kotlinx.coroutines.flow.first + +/** The editor's use cases wired over in-memory stores, the way the composition root wires them. */ +internal class MappingFixture(val console: Console = Console.WIIMOTE_NUNCHUK) { + private val mappings = FakeControllerMappings() + private val sidewaysRemotes = FakeSidewaysRemotes() + private val savedLayouts = FakeSavedLayouts() + private val globalLayouts = FakeGlobalLayouts() + + private val observeMapping = ObserveControllerMappingUseCase(mappings) + private val observeSideways = ObserveSidewaysRemoteUseCase(sidewaysRemotes) + private val applyPlayerMapping = ApplyPlayerMappingUseCase(mappings, sidewaysRemotes) + + val observePlayerMapping = ObservePlayerMappingUseCase(observeMapping, observeSideways, savedLayouts) + val applyLayout = ApplyMappingLayoutUseCase(savedLayouts, applyPlayerMapping) + val resetMapping = ResetControllerMappingUseCase(applyLayout) + val setMapping = SetControllerMappingUseCase(mappings) + val setSidewaysRemote = SetSidewaysRemoteUseCase(sidewaysRemotes) + val saveCustomLayout = SaveCustomLayoutUseCase(savedLayouts, observePlayerMapping) + val deleteCustomLayout = DeleteCustomLayoutUseCase(savedLayouts) + val saveGlobalLayout = SaveGlobalLayoutUseCase(globalLayouts, observePlayerMapping) + val applyGlobalLayout = ApplyGlobalLayoutUseCase(globalLayouts, applyLayout, applyPlayerMapping) + val observeGlobalMapping = ObserveGlobalMappingUseCase(observePlayerMapping, globalLayouts) + val observeSavedLayouts = ObserveSavedLayoutsUseCase(savedLayouts) + + suspend fun playerMapping(body: PlayerBody) = observePlayerMapping(console, body).first() + + suspend fun globalMapping(vararg bodies: PlayerBody) = observeGlobalMapping(console, bodies.toList()).first() + + suspend fun savedLayoutNamed(name: String) = + observeSavedLayouts(console).first().first { it.displayName == name } + + suspend fun layoutsFor(side: JoyconSide) = + MappingLayouts.forBody(console, side, observeSavedLayouts(console).first()) + + companion object { + fun left(player: PlayerNumber = PlayerNumber.P1) = PlayerBody(player, JoyconSide.LEFT) + fun right(player: PlayerNumber = PlayerNumber.P2) = PlayerBody(player, JoyconSide.RIGHT) + } +} diff --git a/core/buttonmapping/domain/src/test/kotlin/com/joegec/joycon2android/buttonmapping/MappingLayoutsTest.kt b/core/buttonmapping/domain/src/test/kotlin/com/joegec/joycon2android/buttonmapping/MappingLayoutsTest.kt new file mode 100644 index 0000000..31c57e7 --- /dev/null +++ b/core/buttonmapping/domain/src/test/kotlin/com/joegec/joycon2android/buttonmapping/MappingLayoutsTest.kt @@ -0,0 +1,27 @@ +package com.joegec.joycon2android.buttonmapping + +import org.junit.Assert.assertEquals +import org.junit.Test + +class MappingLayoutsTest { + + @Test + fun `the first suggestion is the first number`() { + assertEquals("Custom 1", MappingLayouts.nextName("Custom", emptyList())) + } + + @Test + fun `each suggestion counts past the names already taken`() { + assertEquals("Custom 3", MappingLayouts.nextName("Custom", listOf("Custom 1", "Custom 2"))) + } + + @Test + fun `a number freed by a deleted layout is suggested again`() { + assertEquals("Custom 2", MappingLayouts.nextName("Custom", listOf("Custom 1", "Custom 3"))) + } + + @Test + fun `names of the user's own choosing stand in nobody's way`() { + assertEquals("Custom 1", MappingLayouts.nextName("Custom", listOf("My Wheel", "Customised"))) + } +} diff --git a/core/buttonmapping/domain/src/test/kotlin/com/joegec/joycon2android/buttonmapping/ObserveControllerMappingUseCaseTest.kt b/core/buttonmapping/domain/src/test/kotlin/com/joegec/joycon2android/buttonmapping/ObserveControllerMappingUseCaseTest.kt index 1407da9..aae7f38 100644 --- a/core/buttonmapping/domain/src/test/kotlin/com/joegec/joycon2android/buttonmapping/ObserveControllerMappingUseCaseTest.kt +++ b/core/buttonmapping/domain/src/test/kotlin/com/joegec/joycon2android/buttonmapping/ObserveControllerMappingUseCaseTest.kt @@ -1,36 +1,23 @@ package com.joegec.joycon2android.buttonmapping import com.joegec.joycon2android.buttonmapping.preset.MarioKartWiiMapping -import kotlinx.coroutines.flow.Flow +import com.joegec.joycon2android.model.PlayerNumber import kotlinx.coroutines.flow.first -import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.runBlocking import org.junit.Assert.assertEquals import org.junit.Test class ObserveControllerMappingUseCaseTest { - private class StoredMapping(private val stored: Map) : ControllerMappingRepository { - override fun observe(console: Console, side: JoyconSide): Flow> = flowOf(stored) - override suspend fun set(console: Console, side: JoyconSide, targetKey: String, sourceId: String) = Unit - override suspend fun clear(console: Console, side: JoyconSide) = Unit - } - - private class StoredPreset(private val presetId: String? = null) : MappingPresetRepository { - override fun observe(console: Console): Flow = flowOf(presetId) - override suspend fun set(console: Console, presetId: String) = Unit - } - private fun observe( stored: Map, side: JoyconSide = JoyconSide.DUAL, console: Console = Console.GAMECUBE, - presetId: String? = null, ) = runBlocking { - ObserveControllerMappingUseCase( - StoredMapping(stored), - ObserveMappingPresetUseCase(StoredPreset(presetId)), - )(console, side).first() + val body = PlayerBody(PlayerNumber.P1, side) + val mappings = FakeControllerMappings() + stored.forEach { (target, source) -> mappings.set(console, body, target, source) } + ObserveControllerMappingUseCase(mappings)(console, body).first() } @Test @@ -72,25 +59,22 @@ class ObserveControllerMappingUseCaseTest { } @Test - fun `the chosen preset supplies the defaults`() { - val mapping = observe(emptyMap(), JoyconSide.RIGHT, Console.WIIMOTE_NUNCHUK, MarioKartWiiMapping.id) + fun `a direction set to None overrides its default`() { + val mapping = observe(mapOf("CStick_LEFT" to "")) - assertEquals("X", mapping["Two"]) - assertEquals("RIGHT_STICK_UP|SlRight", mapping["DPadUp"]) + assertEquals("", mapping["CStick_LEFT"]) + assertEquals(null, MappingSource.fromId(mapping.getValue("CStick_LEFT"))) } @Test - fun `a preset id from another build falls back to the console's default`() { - val mapping = observe(emptyMap(), JoyconSide.RIGHT, Console.WIIMOTE_NUNCHUK, "NO_SUCH_PRESET") + fun `applying a layout writes out everything it says, so the layout is no longer needed`() = runBlocking { + val fixture = MappingFixture() + val body = MappingFixture.right(PlayerNumber.P1) - assertEquals("B", mapping["Two"]) - } + fixture.applyLayout(fixture.console, body, MarioKartWiiMapping.id) - @Test - fun `a direction set to None overrides its default`() { - val mapping = observe(mapOf("CStick_LEFT" to "")) - - assertEquals("", mapping["CStick_LEFT"]) - assertEquals(null, MappingSource.fromId(mapping.getValue("CStick_LEFT"))) + val mapping = fixture.playerMapping(body) + assertEquals("X", mapping.entries["Two"]) + assertEquals("RIGHT_STICK_UP|SlRight", mapping.entries["DPadUp"]) } } diff --git a/core/buttonmapping/domain/src/test/kotlin/com/joegec/joycon2android/buttonmapping/PlayerMappingTest.kt b/core/buttonmapping/domain/src/test/kotlin/com/joegec/joycon2android/buttonmapping/PlayerMappingTest.kt new file mode 100644 index 0000000..e4c3fe7 --- /dev/null +++ b/core/buttonmapping/domain/src/test/kotlin/com/joegec/joycon2android/buttonmapping/PlayerMappingTest.kt @@ -0,0 +1,129 @@ +package com.joegec.joycon2android.buttonmapping + +import com.joegec.joycon2android.buttonmapping.MappingFixture.Companion.left +import com.joegec.joycon2android.buttonmapping.MappingFixture.Companion.right +import com.joegec.joycon2android.buttonmapping.preset.MarioKartWiiMapping +import com.joegec.joycon2android.buttonmapping.preset.WiiMapping +import com.joegec.joycon2android.model.PlayerNumber +import kotlinx.coroutines.runBlocking +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test + +class PlayerMappingTest { + + private val fixture = MappingFixture() + private val body = left() + + @Test + fun `an untouched player reads as the layout they chose`() = runBlocking { + fixture.applyLayout(fixture.console, body, MarioKartWiiMapping.id) + + assertEquals(MarioKartWiiMapping.displayName, fixture.playerMapping(body).layout?.displayName) + } + + @Test + fun `changing a binding turns it custom, and undoing that change turns it back`() = runBlocking { + fixture.applyLayout(fixture.console, body, MarioKartWiiMapping.id) + val original = MarioKartWiiMapping.entries(body.side).getValue("A") + + fixture.setMapping(fixture.console, body, "A", "Up") + assertNull(fixture.playerMapping(body).layout) + + fixture.setMapping(fixture.console, body, "A", original) + assertEquals(MarioKartWiiMapping.id, fixture.playerMapping(body).layout?.id) + } + + @Test + fun `the sideways-remote switch counts as a change of its own`() = runBlocking { + fixture.applyLayout(fixture.console, body, MarioKartWiiMapping.id) + + fixture.setSidewaysRemote(fixture.console, body, false) + + assertNull(fixture.playerMapping(body).layout) + } + + @Test + fun `one player's change leaves the next player's mapping alone`() = runBlocking { + val other = left(PlayerNumber.P2) + fixture.applyLayout(fixture.console, body, MarioKartWiiMapping.id) + fixture.applyLayout(fixture.console, other, WiiMapping.id) + + fixture.setMapping(fixture.console, body, "A", "Up") + + assertNull(fixture.playerMapping(body).layout) + assertEquals(WiiMapping.id, fixture.playerMapping(other).layout?.id) + } + + @Test + fun `resetting puts the body back on the console's own layout`() = runBlocking { + fixture.applyLayout(fixture.console, body, MarioKartWiiMapping.id) + + fixture.resetMapping(fixture.console, body) + + val mapping = fixture.playerMapping(body) + assertEquals(WiiMapping.id, mapping.layout?.id) + assertFalse(mapping.sidewaysRemote) + } + + @Test + fun `saving names what the player built and offers it to that body`() = runBlocking { + fixture.applyLayout(fixture.console, body, MarioKartWiiMapping.id) + fixture.setMapping(fixture.console, body, "A", "Up") + + fixture.saveCustomLayout(fixture.console, body, "My Wheel") + + val mapping = fixture.playerMapping(body) + assertEquals("My Wheel", mapping.layout?.displayName) + assertEquals("Up", mapping.entries["A"]) + assertTrue(fixture.layoutsFor(body.side).any { it.displayName == "My Wheel" }) + } + + @Test + fun `a layout saved from one body is not offered to another`() = runBlocking { + fixture.saveCustomLayout(fixture.console, body, "My Wheel") + + val other = fixture.layoutsFor(right().side) + + assertFalse(other.any { it.displayName == "My Wheel" }) + } + + @Test + fun `deleting a layout keeps the bindings of everyone on it and only takes the name`() = runBlocking { + fixture.setMapping(fixture.console, body, "A", "Up") + fixture.saveCustomLayout(fixture.console, body, "My Wheel") + val saved = fixture.savedLayoutNamed("My Wheel") + + fixture.deleteCustomLayout(saved.id) + + val mapping = fixture.playerMapping(body) + assertNull(mapping.layout) + assertEquals("Up", mapping.entries["A"]) + } + + @Test + fun `saving the same layout again gives those bindings their name back`() = runBlocking { + fixture.setMapping(fixture.console, body, "A", "Up") + fixture.saveCustomLayout(fixture.console, body, "My Wheel") + fixture.deleteCustomLayout(fixture.savedLayoutNamed("My Wheel").id) + + fixture.saveCustomLayout(fixture.console, body, "My Wheel") + + assertEquals("My Wheel", fixture.playerMapping(body).layout?.displayName) + } + + @Test + fun `a layout someone else saved names an identical mapping arrived at alone`() = runBlocking { + val other = left(PlayerNumber.P2) + fixture.setMapping(fixture.console, body, "A", "Up") + fixture.saveCustomLayout(fixture.console, body, "My Wheel") + + fixture.setMapping(fixture.console, other, "A", "Up") + + assertNotNull(fixture.playerMapping(other).layout) + assertEquals("My Wheel", fixture.playerMapping(other).layout?.displayName) + } +} diff --git a/core/buttonmapping/domain/src/test/kotlin/com/joegec/joycon2android/buttonmapping/SidewaysRemoteTest.kt b/core/buttonmapping/domain/src/test/kotlin/com/joegec/joycon2android/buttonmapping/SidewaysRemoteTest.kt index 6ba3dc2..3f19572 100644 --- a/core/buttonmapping/domain/src/test/kotlin/com/joegec/joycon2android/buttonmapping/SidewaysRemoteTest.kt +++ b/core/buttonmapping/domain/src/test/kotlin/com/joegec/joycon2android/buttonmapping/SidewaysRemoteTest.kt @@ -1,60 +1,52 @@ package com.joegec.joycon2android.buttonmapping +import com.joegec.joycon2android.buttonmapping.MappingFixture.Companion.right import com.joegec.joycon2android.buttonmapping.preset.MarioKartWiiMapping import com.joegec.joycon2android.buttonmapping.preset.WiiMapping -import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.flow.first -import kotlinx.coroutines.flow.flowOf +import com.joegec.joycon2android.model.PlayerNumber import kotlinx.coroutines.runBlocking import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse import org.junit.Assert.assertTrue import org.junit.Test class SidewaysRemoteTest { - private class Chosen(private val enabled: Boolean? = null) : SidewaysRemoteRepository { - var cleared = false - override fun observe(console: Console): Flow = flowOf(enabled) - override suspend fun set(console: Console, enabled: Boolean) = Unit - override suspend fun clear(console: Console) { cleared = true } - } + private val fixture = MappingFixture() + private val body = right(PlayerNumber.P1) - private class StoredPreset(private val presetId: String?) : MappingPresetRepository { - override fun observe(console: Console): Flow = flowOf(presetId) - override suspend fun set(console: Console, presetId: String) = Unit + @Test + fun `a body nothing has set follows the console's default layout`() = runBlocking { + assertFalse(fixture.playerMapping(body).sidewaysRemote) } - private class StoredMapping : ControllerMappingRepository { - override fun observe(console: Console, side: JoyconSide): Flow> = flowOf(emptyMap()) - override suspend fun set(console: Console, side: JoyconSide, targetKey: String, sourceId: String) = Unit - override suspend fun clear(console: Console, side: JoyconSide) = Unit - } + @Test + fun `applying a layout takes its answer with it, either way`() = runBlocking { + fixture.applyLayout(fixture.console, body, MarioKartWiiMapping.id) + assertTrue(fixture.playerMapping(body).sidewaysRemote) - private fun observe(chosen: Boolean?, presetId: String?) = runBlocking { - ObserveSidewaysRemoteUseCase( - Chosen(chosen), - ObserveMappingPresetUseCase(StoredPreset(presetId)), - )(Console.WIIMOTE_NUNCHUK).first() + fixture.applyLayout(fixture.console, body, WiiMapping.id) + assertFalse(fixture.playerMapping(body).sidewaysRemote) } @Test - fun `the layout decides until the user does`() { - assertTrue(observe(chosen = null, presetId = MarioKartWiiMapping.id)) - assertEquals(false, observe(chosen = null, presetId = WiiMapping.id)) - } + fun `the player's switch stands until a layout is applied over it`() = runBlocking { + fixture.applyLayout(fixture.console, body, WiiMapping.id) - @Test - fun `the user's switch outranks the layout, either way`() { - assertEquals(false, observe(chosen = false, presetId = MarioKartWiiMapping.id)) - assertTrue(observe(chosen = true, presetId = WiiMapping.id)) + fixture.setSidewaysRemote(fixture.console, body, true) + assertTrue(fixture.playerMapping(body).sidewaysRemote) + + fixture.applyLayout(fixture.console, body, WiiMapping.id) + assertFalse(fixture.playerMapping(body).sidewaysRemote) } @Test - fun `applying a layout hands the switch back to it`() = runBlocking { - val chosen = Chosen(enabled = true) + fun `one player's switch leaves the others alone`() = runBlocking { + val other = right(PlayerNumber.P2) - ApplyMappingPresetUseCase(StoredPreset(null), StoredMapping(), chosen)(Console.WIIMOTE_NUNCHUK, WiiMapping.id) + fixture.setSidewaysRemote(fixture.console, body, true) - assertTrue(chosen.cleared) + assertTrue(fixture.playerMapping(body).sidewaysRemote) + assertEquals(false, fixture.playerMapping(other).sidewaysRemote) } } diff --git a/core/buttonmapping/presentation/src/main/kotlin/com/joegec/joycon2android/buttonmapping/presentation/ControllerMappingScreen.kt b/core/buttonmapping/presentation/src/main/kotlin/com/joegec/joycon2android/buttonmapping/presentation/ControllerMappingScreen.kt index 65c1409..c206c5d 100644 --- a/core/buttonmapping/presentation/src/main/kotlin/com/joegec/joycon2android/buttonmapping/presentation/ControllerMappingScreen.kt +++ b/core/buttonmapping/presentation/src/main/kotlin/com/joegec/joycon2android/buttonmapping/presentation/ControllerMappingScreen.kt @@ -20,52 +20,42 @@ import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text -import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.res.stringResource -import com.joegec.joycon2android.buttonmapping.Console -import com.joegec.joycon2android.buttonmapping.JoyconSide -import com.joegec.joycon2android.buttonmapping.sourceIdOf -import com.joegec.joycon2android.buttonmapping.sourceIdsOf +import com.joegec.joycon2android.buttonmapping.MappingLayouts +import com.joegec.joycon2android.buttonmapping.PlayerBody import com.joegec.joycon2android.core.buttonmapping.presentation.R -import com.joegec.joycon2android.ui.components.ExpandableInfoSection -import com.joegec.joycon2android.ui.components.LabeledDropdown -import com.joegec.joycon2android.ui.components.MultiSelectDropdown -import com.joegec.joycon2android.ui.components.SettingSwitch +import com.joegec.joycon2android.model.PlayerState +import com.joegec.joycon2android.ui.components.ConfirmDialog +import com.joegec.joycon2android.ui.components.TextInputDialog import com.joegec.joycon2android.ui.theme.Dimens import com.joegec.joycon2android.ui.theme.TextDim @Composable fun ControllerMappingScreen( - console: Console, - presetId: String, - sidewaysRemote: Boolean, - leftMapping: Map, - rightMapping: Map, - dualMapping: Map, - onSelectPreset: (presetId: String) -> Unit, - onSetSidewaysRemote: (enabled: Boolean) -> Unit, - onSetMapping: (side: JoyconSide, targetKey: String, sourceId: String) -> Unit, - onResetMapping: (side: JoyconSide) -> Unit, + state: ControllerMappingUiState, + players: List, + actions: MappingActions, onBack: () -> Unit, modifier: Modifier = Modifier, ) { BackHandler(onBack = onBack) + var dialog by remember { mutableStateOf(null) } + Column( modifier .fillMaxSize() .windowInsetsPadding(WindowInsets.systemBars) .padding(horizontal = Dimens.screenPaddingHorizontal), ) { - Row(verticalAlignment = Alignment.CenterVertically) { - IconButton(onClick = onBack) { - Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = stringResource(R.string.controller_mapping_back)) - } - Text(console.displayName, style = MaterialTheme.typography.headlineSmall, color = Color.White) - } + ScreenHeader(state, onBack) Spacer(Modifier.height(Dimens.sectionSpacing)) Column( Modifier @@ -73,104 +63,115 @@ fun ControllerMappingScreen( .verticalScroll(rememberScrollState()), verticalArrangement = Arrangement.spacedBy(Dimens.sectionSpacing), ) { - PresetRow(console, presetId, onSelectPreset) - SidewaysRemoteSwitch(console, sidewaysRemote, onSetSidewaysRemote) - ExpandableInfoSection(JoyconSide.LEFT.displayName) { - MappingSection(console, JoyconSide.LEFT, leftMapping, onSetMapping, onResetMapping) - } - ExpandableInfoSection(JoyconSide.RIGHT.displayName) { - MappingSection(console, JoyconSide.RIGHT, rightMapping, onSetMapping, onResetMapping) + if (state.players.isEmpty()) { + Text(stringResource(R.string.controller_mapping_no_players), color = TextDim) + } else { + AllPlayersRow(state.global, actions) { dialog = it } } - ExpandableInfoSection(JoyconSide.DUAL.displayName) { - MappingSection(console, JoyconSide.DUAL, dualMapping, onSetMapping, onResetMapping) + state.players.forEach { player -> + val connected = players.firstOrNull { it.player == player.body.player } + if (connected != null) { + PlayerMappingCard( + console = state.console, + player = connected, + state = player, + actions = actions, + onSaveLayout = { dialog = MappingDialog.Save(player.body) }, + onDeleteLayout = { dialog = MappingDialog.Delete(it.id, it.label, global = false) }, + ) + } } Spacer(Modifier.height(Dimens.sectionSpacing)) } } -} - -/** Only consoles with a layout to choose between show the row. */ -@Composable -private fun PresetRow(console: Console, presetId: String, onSelectPreset: (String) -> Unit) { - val presets = MappingOptions.presets(console) - if (presets.size < 2) return - Row( - Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.spacedBy(Dimens.elementSpacing), - verticalAlignment = Alignment.CenterVertically, - ) { - Text(stringResource(R.string.controller_mapping_preset), color = TextDim, modifier = Modifier.weight(1f)) - LabeledDropdown( - options = presets, - selectedId = presetId, - onSelect = onSelectPreset, - modifier = Modifier.weight(1f), - ) - } -} -/** - * A lone Joy-Con stands in for a Wii Remote held sideways: what a wheel game steers by, and what - * turns its d-pad. A layout sets it; this is the user having the last word. - */ -@Composable -private fun SidewaysRemoteSwitch(console: Console, enabled: Boolean, onSetEnabled: (Boolean) -> Unit) { - if (!MappingOptions.offersSidewaysRemote(console)) return - SettingSwitch( - title = stringResource(R.string.controller_mapping_sideways_remote), - description = stringResource(R.string.controller_mapping_sideways_remote_description), - checked = enabled, - onCheckedChange = onSetEnabled, - ) + MappingDialogs(state, dialog, actions) { dialog = null } } @Composable -private fun MappingSection( - console: Console, - side: JoyconSide, - mapping: Map, - onSetMapping: (side: JoyconSide, targetKey: String, sourceId: String) -> Unit, - onResetMapping: (side: JoyconSide) -> Unit, -) { - Column(verticalArrangement = Arrangement.spacedBy(Dimens.elementSpacing)) { - val sourceOptions = MappingOptions.sources(side) - (MappingOptions.buttonTargets(console) + MappingOptions.stickDirectionTargets(console)).forEach { (key, label) -> - val selectedIds = sourceIdsOf(mapping[key].orEmpty()) - MappingRow(label, selectedIds, sourceOptions) { toggled -> - onSetMapping(side, key, sourceIdOf(selectedIds.toggling(toggled))) - } - } - TextButton(onClick = { onResetMapping(side) }) { - Text(stringResource(R.string.controller_mapping_reset)) +private fun ScreenHeader(state: ControllerMappingUiState, onBack: () -> Unit) { + Row(verticalAlignment = Alignment.CenterVertically) { + IconButton(onClick = onBack) { + Icon( + Icons.AutoMirrored.Filled.ArrowBack, + contentDescription = stringResource(R.string.controller_mapping_back), + ) } + Text(state.console.displayName, style = MaterialTheme.typography.headlineSmall, color = Color.White) } } +/** The session read as one setting, so a whole table can be set — and kept — in a single move. */ @Composable -private fun MappingRow( - label: String, - selectedIds: List, - options: List>, - onToggle: (String) -> Unit, +private fun AllPlayersRow( + state: GlobalLayoutUiState, + actions: MappingActions, + onDialog: (MappingDialog) -> Unit, ) { Row( Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(Dimens.elementSpacing), verticalAlignment = Alignment.CenterVertically, ) { - Text(label, color = TextDim, modifier = Modifier.weight(1f)) - MultiSelectDropdown( - options = options, - selectedIds = selectedIds, - onToggle = onToggle, + Text( + stringResource(R.string.controller_mapping_all_players), + color = TextDim, + modifier = Modifier.weight(1f), + ) + LayoutRow( + options = state.options, + selectedId = state.selectedId, + layoutName = state.layoutName, + subLabel = state.playerSummary, + onSelect = actions.selectGlobalLayout, + onSave = { onDialog(MappingDialog.Save(body = null)) }, + onDelete = { onDialog(MappingDialog.Delete(it.id, it.label, global = true)) }, modifier = Modifier.weight(1f), ) } } -/** Any source can fire a target, so picking one adds it; picking "None" empties the row. */ -private fun List.toggling(sourceId: String): List = when { - sourceId == MappingOptions.NONE_ID -> emptyList() - sourceId in this -> this - sourceId - else -> this + sourceId +@Composable +private fun MappingDialogs( + state: ControllerMappingUiState, + dialog: MappingDialog?, + actions: MappingActions, + onDismiss: () -> Unit, +) { + when (dialog) { + null -> Unit + is MappingDialog.Save -> TextInputDialog( + title = stringResource(R.string.controller_mapping_save_layout), + fieldLabel = stringResource(R.string.controller_mapping_layout_name), + defaultValue = MappingLayouts.nextName( + stringResource(R.string.controller_mapping_layout_custom), + if (dialog.body == null) state.global.savedNames else state.savedLayoutNames, + ), + confirmLabel = stringResource(R.string.controller_mapping_save), + dismissLabel = stringResource(R.string.controller_mapping_cancel), + onConfirm = { name -> + actions.saveLayout(dialog.body, name) + onDismiss() + }, + onDismiss = onDismiss, + ) + is MappingDialog.Delete -> ConfirmDialog( + title = stringResource(R.string.controller_mapping_delete_layout), + body = stringResource(R.string.controller_mapping_delete_layout_body, dialog.name), + confirmLabel = stringResource(R.string.controller_mapping_delete), + dismissLabel = stringResource(R.string.controller_mapping_cancel), + onConfirm = { + actions.deleteLayout(dialog.id, dialog.global) + onDismiss() + }, + onDismiss = onDismiss, + ) + } +} + +private sealed interface MappingDialog { + /** A null body names the session as a whole rather than one player. */ + data class Save(val body: PlayerBody?) : MappingDialog + + data class Delete(val id: String, val name: String, val global: Boolean) : MappingDialog } diff --git a/core/buttonmapping/presentation/src/main/kotlin/com/joegec/joycon2android/buttonmapping/presentation/ControllerMappingUiState.kt b/core/buttonmapping/presentation/src/main/kotlin/com/joegec/joycon2android/buttonmapping/presentation/ControllerMappingUiState.kt new file mode 100644 index 0000000..390542d --- /dev/null +++ b/core/buttonmapping/presentation/src/main/kotlin/com/joegec/joycon2android/buttonmapping/presentation/ControllerMappingUiState.kt @@ -0,0 +1,78 @@ +package com.joegec.joycon2android.buttonmapping.presentation + +import com.joegec.joycon2android.buttonmapping.Console +import com.joegec.joycon2android.buttonmapping.GlobalMapping +import com.joegec.joycon2android.buttonmapping.MappingLayout +import com.joegec.joycon2android.buttonmapping.MappingLayouts +import com.joegec.joycon2android.buttonmapping.PlayerBody +import com.joegec.joycon2android.buttonmapping.PlayerMapping +import com.joegec.joycon2android.buttonmapping.SavedLayout +import com.joegec.joycon2android.buttonmapping.preset.MappingPresets +import com.joegec.joycon2android.ui.components.DropdownOption + +/** A null [layoutName] is the editor's way of saying the bindings no longer match any layout. */ +data class ControllerMappingUiState( + val console: Console, + val global: GlobalLayoutUiState, + val players: List, + /** Every name already taken on this console, so a suggested one is never a duplicate. */ + val savedLayoutNames: List, +) + +data class GlobalLayoutUiState( + val options: List, + val selectedId: String?, + val layoutName: String?, + val playerSummary: String?, + val savedNames: List, +) + +data class PlayerMappingUiState( + val body: PlayerBody, + val layoutOptions: List, + val selectedLayoutId: String?, + val layoutName: String?, + val sidewaysRemote: Boolean, + val offersSidewaysRemote: Boolean, + val mapping: Map, +) + +internal fun controllerMappingUiState( + console: Console, + mapping: GlobalMapping, + savedLayouts: List, +) = ControllerMappingUiState( + console = console, + global = mapping.uiState(console), + players = mapping.players.map { + it.uiState(console, MappingLayouts.forBody(console, it.body.side, savedLayouts)) + }, + savedLayoutNames = savedLayouts.map { it.displayName }, +) + +private fun GlobalMapping.uiState(console: Console) = GlobalLayoutUiState( + options = MappingPresets.forConsole(console).map { DropdownOption(it.id, it.displayName) } + + savedLayouts.map { + DropdownOption( + id = it.id, + label = it.displayName, + subLabel = it.playerSummary, + available = it.fits(bodies), + deletable = true, + ) + }, + selectedId = selectedId, + layoutName = displayName, + playerSummary = playerSummary, + savedNames = savedLayouts.map { it.displayName }, +) + +private fun PlayerMapping.uiState(console: Console, layouts: List) = PlayerMappingUiState( + body = body, + layoutOptions = layouts.map { DropdownOption(it.id, it.displayName, deletable = it is SavedLayout) }, + selectedLayoutId = layout?.id, + layoutName = layout?.displayName, + sidewaysRemote = sidewaysRemote, + offersSidewaysRemote = MappingOptions.offersSidewaysRemote(console, body.side), + mapping = entries, +) diff --git a/core/buttonmapping/presentation/src/main/kotlin/com/joegec/joycon2android/buttonmapping/presentation/ControllerMappingViewModel.kt b/core/buttonmapping/presentation/src/main/kotlin/com/joegec/joycon2android/buttonmapping/presentation/ControllerMappingViewModel.kt index d80349d..cf18119 100644 --- a/core/buttonmapping/presentation/src/main/kotlin/com/joegec/joycon2android/buttonmapping/presentation/ControllerMappingViewModel.kt +++ b/core/buttonmapping/presentation/src/main/kotlin/com/joegec/joycon2android/buttonmapping/presentation/ControllerMappingViewModel.kt @@ -2,81 +2,97 @@ package com.joegec.joycon2android.buttonmapping.presentation import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope -import com.joegec.joycon2android.buttonmapping.ApplyMappingPresetUseCase +import com.joegec.joycon2android.buttonmapping.ApplyGlobalLayoutUseCase +import com.joegec.joycon2android.buttonmapping.ApplyMappingLayoutUseCase import com.joegec.joycon2android.buttonmapping.Console -import com.joegec.joycon2android.buttonmapping.JoyconSide -import com.joegec.joycon2android.buttonmapping.ObserveControllerMappingUseCase -import com.joegec.joycon2android.buttonmapping.ObserveMappingPresetUseCase -import com.joegec.joycon2android.buttonmapping.ObserveSidewaysRemoteUseCase +import com.joegec.joycon2android.buttonmapping.DeleteCustomLayoutUseCase +import com.joegec.joycon2android.buttonmapping.DeleteGlobalLayoutUseCase +import com.joegec.joycon2android.buttonmapping.ObserveGlobalMappingUseCase +import com.joegec.joycon2android.buttonmapping.ObserveSavedLayoutsUseCase +import com.joegec.joycon2android.buttonmapping.PlayerBody import com.joegec.joycon2android.buttonmapping.ResetControllerMappingUseCase +import com.joegec.joycon2android.buttonmapping.SaveCustomLayoutUseCase +import com.joegec.joycon2android.buttonmapping.SaveGlobalLayoutUseCase import com.joegec.joycon2android.buttonmapping.SetControllerMappingUseCase import com.joegec.joycon2android.buttonmapping.SetSidewaysRemoteUseCase -import com.joegec.joycon2android.buttonmapping.preset.MappingPresets +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.flatMapLatest +import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch -/** Feature-scoped state holder for the controller mapping editor screen. */ +/** State holder for the mapping editor: one console's layouts, per player. */ +@OptIn(ExperimentalCoroutinesApi::class) class ControllerMappingViewModel( - private val observeControllerMapping: ObserveControllerMappingUseCase, + private val observeGlobalMapping: ObserveGlobalMappingUseCase, + private val observeSavedLayouts: ObserveSavedLayoutsUseCase, + private val applyMappingLayout: ApplyMappingLayoutUseCase, + private val applyGlobalLayout: ApplyGlobalLayoutUseCase, private val setControllerMapping: SetControllerMappingUseCase, private val resetControllerMapping: ResetControllerMappingUseCase, - private val observeMappingPreset: ObserveMappingPresetUseCase, - private val applyMappingPreset: ApplyMappingPresetUseCase, - private val observeSidewaysRemote: ObserveSidewaysRemoteUseCase, private val setSidewaysRemote: SetSidewaysRemoteUseCase, + private val saveCustomLayout: SaveCustomLayoutUseCase, + private val saveGlobalLayout: SaveGlobalLayoutUseCase, + private val deleteCustomLayout: DeleteCustomLayoutUseCase, + private val deleteGlobalLayout: DeleteGlobalLayoutUseCase, ) : ViewModel() { - private val mappingFlows = mutableMapOf, StateFlow>>() - private val presetFlows = mutableMapOf>() - private val sidewaysRemoteFlows = mutableMapOf>() - - fun mapping(console: Console, side: JoyconSide): StateFlow> = - mappingFlows.getOrPut(console to side) { - observeControllerMapping(console, side) - .stateIn(viewModelScope, SharingStarted.WhileSubscribed(STOP_TIMEOUT_MS), emptyMap()) - } - - fun preset(console: Console): StateFlow = - presetFlows.getOrPut(console) { - observeMappingPreset(console) - .map { it.id } - .stateIn( - viewModelScope, - SharingStarted.WhileSubscribed(STOP_TIMEOUT_MS), - MappingPresets.default(console).id, - ) - } - - fun sidewaysRemote(console: Console): StateFlow = - sidewaysRemoteFlows.getOrPut(console) { - observeSidewaysRemote(console) - .stateIn( - viewModelScope, - SharingStarted.WhileSubscribed(STOP_TIMEOUT_MS), - MappingPresets.default(console).sidewaysRemote, - ) - } - - fun setSidewaysRemoteEnabled(console: Console, enabled: Boolean) { - viewModelScope.launch { setSidewaysRemote(console, enabled) } + private val editing = MutableStateFlow(null) + + val uiState: StateFlow = editing + .flatMapLatest { target -> target?.let(::observe) ?: flowOf(null) } + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(STOP_TIMEOUT_MS), null) + + fun edit(console: Console, bodies: List) { + editing.value = MappingTarget(console, bodies) + } + + fun selectLayout(body: PlayerBody, layoutId: String) = onTarget { + applyMappingLayout(it.console, body, layoutId) } - fun setMapping(console: Console, side: JoyconSide, targetKey: String, sourceId: String) { - viewModelScope.launch { setControllerMapping(console, side, targetKey, sourceId) } + fun selectGlobalLayout(layoutId: String) = onTarget { + applyGlobalLayout(it.console, it.bodies, layoutId) } - fun selectPreset(console: Console, presetId: String) { - viewModelScope.launch { applyMappingPreset(console, presetId) } + fun setMapping(body: PlayerBody, targetKey: String, sourceId: String) = onTarget { + setControllerMapping(it.console, body, targetKey, sourceId) + } + + fun resetMapping(body: PlayerBody) = onTarget { resetControllerMapping(it.console, body) } + + fun setSidewaysRemoteEnabled(body: PlayerBody, enabled: Boolean) = onTarget { + setSidewaysRemote(it.console, body, enabled) } - fun resetMapping(console: Console, side: JoyconSide) { - viewModelScope.launch { resetControllerMapping(console, side) } + /** A null body names the session as a whole rather than one player. */ + fun saveLayout(body: PlayerBody?, name: String) = onTarget { target -> + if (body == null) saveGlobalLayout(target.console, target.bodies, name) + else saveCustomLayout(target.console, body, name) + } + + fun deleteLayout(layoutId: String, global: Boolean) = onTarget { + if (global) deleteGlobalLayout(layoutId) else deleteCustomLayout(layoutId) + } + + private fun observe(target: MappingTarget): Flow = combine( + observeGlobalMapping(target.console, target.bodies), + observeSavedLayouts(target.console), + ) { mapping, saved -> controllerMappingUiState(target.console, mapping, saved) } + + private fun onTarget(block: suspend (MappingTarget) -> Unit) { + val target = editing.value ?: return + viewModelScope.launch { block(target) } } private companion object { const val STOP_TIMEOUT_MS = 5_000L } } + +private data class MappingTarget(val console: Console, val bodies: List) diff --git a/core/buttonmapping/presentation/src/main/kotlin/com/joegec/joycon2android/buttonmapping/presentation/LayoutRow.kt b/core/buttonmapping/presentation/src/main/kotlin/com/joegec/joycon2android/buttonmapping/presentation/LayoutRow.kt new file mode 100644 index 0000000..f85a533 --- /dev/null +++ b/core/buttonmapping/presentation/src/main/kotlin/com/joegec/joycon2android/buttonmapping/presentation/LayoutRow.kt @@ -0,0 +1,92 @@ +package com.joegec.joycon2android.buttonmapping.presentation + +import android.content.Context +import android.widget.Toast +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.size +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Save +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import com.joegec.joycon2android.core.buttonmapping.presentation.R +import com.joegec.joycon2android.ui.components.DropdownOption +import com.joegec.joycon2android.ui.components.OptionDropdown +import com.joegec.joycon2android.ui.theme.Accent +import com.joegec.joycon2android.ui.theme.Dimens +import com.joegec.joycon2android.ui.theme.TextDim + +/** + * Picks the layout a body — or the whole session — follows, and offers to keep what it has become. + * The name reads "Custom" the moment the bindings stop matching a layout, and reads a layout's own + * name again the moment they match one. + */ +@Composable +fun LayoutRow( + options: List, + selectedId: String?, + layoutName: String?, + onSelect: (String) -> Unit, + onSave: () -> Unit, + onDelete: (DropdownOption) -> Unit, + modifier: Modifier = Modifier, + subLabel: String? = null, +) { + val context = LocalContext.current + + Row( + modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(Dimens.elementSpacing), + verticalAlignment = Alignment.CenterVertically, + ) { + OptionDropdown( + options = options, + selectedId = selectedId, + label = layoutName ?: stringResource(R.string.controller_mapping_layout_custom), + subLabel = subLabel, + onSelect = onSelect, + onDelete = onDelete, + onUnavailable = { option -> + context.toast( + R.string.controller_mapping_layout_unavailable, + option.label, + option.subLabel.orEmpty(), + ) + }, + modifier = Modifier.weight(1f), + ) + SaveButton(layoutName, onSave) + } +} + +/** + * Only a mapping with no name of its own is worth naming: one that already reads as a layout has + * been saved once already, so the icon dims and says which layout it is rather than making a twin. + */ +@Composable +private fun SaveButton(layoutName: String?, onSave: () -> Unit) { + val context = LocalContext.current + + IconButton( + onClick = { + if (layoutName == null) onSave() + else context.toast(R.string.controller_mapping_layout_already_saved, layoutName) + }, + ) { + Icon( + Icons.Filled.Save, + contentDescription = stringResource(R.string.controller_mapping_save_layout), + tint = if (layoutName == null) Accent else TextDim, + modifier = Modifier.size(Dimens.iconSizeMedium), + ) + } +} + +private fun Context.toast(messageRes: Int, vararg args: String) = + Toast.makeText(this, getString(messageRes, *args), Toast.LENGTH_LONG).show() diff --git a/core/buttonmapping/presentation/src/main/kotlin/com/joegec/joycon2android/buttonmapping/presentation/MappingActions.kt b/core/buttonmapping/presentation/src/main/kotlin/com/joegec/joycon2android/buttonmapping/presentation/MappingActions.kt new file mode 100644 index 0000000..b5b6d13 --- /dev/null +++ b/core/buttonmapping/presentation/src/main/kotlin/com/joegec/joycon2android/buttonmapping/presentation/MappingActions.kt @@ -0,0 +1,16 @@ +package com.joegec.joycon2android.buttonmapping.presentation + +import androidx.compose.runtime.Immutable +import com.joegec.joycon2android.buttonmapping.PlayerBody + +/** What the editor can do, so each card takes one collaborator rather than a fistful of lambdas. */ +@Immutable +class MappingActions( + val selectLayout: (body: PlayerBody, layoutId: String) -> Unit, + val selectGlobalLayout: (layoutId: String) -> Unit, + val saveLayout: (body: PlayerBody?, name: String) -> Unit, + val deleteLayout: (layoutId: String, global: Boolean) -> Unit, + val setMapping: (body: PlayerBody, targetKey: String, sourceId: String) -> Unit, + val resetMapping: (body: PlayerBody) -> Unit, + val setSidewaysRemote: (body: PlayerBody, enabled: Boolean) -> Unit, +) diff --git a/core/buttonmapping/presentation/src/main/kotlin/com/joegec/joycon2android/buttonmapping/presentation/MappingBindings.kt b/core/buttonmapping/presentation/src/main/kotlin/com/joegec/joycon2android/buttonmapping/presentation/MappingBindings.kt new file mode 100644 index 0000000..64ff909 --- /dev/null +++ b/core/buttonmapping/presentation/src/main/kotlin/com/joegec/joycon2android/buttonmapping/presentation/MappingBindings.kt @@ -0,0 +1,66 @@ +package com.joegec.joycon2android.buttonmapping.presentation + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import com.joegec.joycon2android.buttonmapping.Console +import com.joegec.joycon2android.buttonmapping.sourceIdOf +import com.joegec.joycon2android.buttonmapping.sourceIdsOf +import com.joegec.joycon2android.core.buttonmapping.presentation.R +import com.joegec.joycon2android.ui.components.MultiSelectDropdown +import com.joegec.joycon2android.ui.theme.Dimens +import com.joegec.joycon2android.ui.theme.TextDim + +/** Every target this console offers, against the physical controls the player's body can produce. */ +@Composable +fun MappingBindings(console: Console, state: PlayerMappingUiState, actions: MappingActions) { + Column(verticalArrangement = Arrangement.spacedBy(Dimens.elementSpacing)) { + val sourceOptions = MappingOptions.sources(state.body.side) + (MappingOptions.buttonTargets(console) + MappingOptions.stickDirectionTargets(console)) + .forEach { (key, label) -> + val selectedIds = sourceIdsOf(state.mapping[key].orEmpty()) + BindingRow(label, selectedIds, sourceOptions) { toggled -> + actions.setMapping(state.body, key, sourceIdOf(selectedIds.toggling(toggled))) + } + } + TextButton(onClick = { actions.resetMapping(state.body) }) { + Text(stringResource(R.string.controller_mapping_reset)) + } + } +} + +@Composable +private fun BindingRow( + label: String, + selectedIds: List, + options: List>, + onToggle: (String) -> Unit, +) { + Row( + Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(Dimens.elementSpacing), + verticalAlignment = Alignment.CenterVertically, + ) { + Text(label, color = TextDim, modifier = Modifier.weight(1f)) + MultiSelectDropdown( + options = options, + selectedIds = selectedIds, + onToggle = onToggle, + modifier = Modifier.weight(1f), + ) + } +} + +/** Any source can fire a target, so picking one adds it; picking "None" empties the row. */ +private fun List.toggling(sourceId: String): List = when { + sourceId == MappingOptions.NONE_ID -> emptyList() + sourceId in this -> this - sourceId + else -> this + sourceId +} diff --git a/core/buttonmapping/presentation/src/main/kotlin/com/joegec/joycon2android/buttonmapping/presentation/MappingOptions.kt b/core/buttonmapping/presentation/src/main/kotlin/com/joegec/joycon2android/buttonmapping/presentation/MappingOptions.kt index 94da380..0ec1da2 100644 --- a/core/buttonmapping/presentation/src/main/kotlin/com/joegec/joycon2android/buttonmapping/presentation/MappingOptions.kt +++ b/core/buttonmapping/presentation/src/main/kotlin/com/joegec/joycon2android/buttonmapping/presentation/MappingOptions.kt @@ -6,7 +6,6 @@ import com.joegec.joycon2android.buttonmapping.MappingSource import com.joegec.joycon2android.buttonmapping.StickDirection import com.joegec.joycon2android.buttonmapping.StickSource import com.joegec.joycon2android.buttonmapping.directionKey -import com.joegec.joycon2android.buttonmapping.preset.MappingPresets import com.joegec.joycon2android.buttonmapping.target.GameCubeButton import com.joegec.joycon2android.buttonmapping.target.GameCubeStick import com.joegec.joycon2android.buttonmapping.target.SwitchProButton @@ -19,11 +18,9 @@ import com.joegec.joycon2android.model.JoyconButton internal object MappingOptions { const val NONE_ID = "" - fun presets(console: Console): List> = - MappingPresets.forConsole(console).map { it.id to it.displayName } - - /** Only a Wii Remote can be held sideways in the sense the switch means. */ - fun offersSidewaysRemote(console: Console) = console == Console.WIIMOTE_NUNCHUK + /** Only a lone Joy-Con standing in for a Wii Remote can be held sideways in the sense the switch means. */ + fun offersSidewaysRemote(console: Console, side: JoyconSide) = + console == Console.WIIMOTE_NUNCHUK && side != JoyconSide.DUAL fun buttonTargets(console: Console): List> = when (console) { Console.GAMECUBE -> GameCubeButton.entries.map { it.name to it.displayName } diff --git a/core/buttonmapping/presentation/src/main/kotlin/com/joegec/joycon2android/buttonmapping/presentation/PlayerMappingCard.kt b/core/buttonmapping/presentation/src/main/kotlin/com/joegec/joycon2android/buttonmapping/presentation/PlayerMappingCard.kt new file mode 100644 index 0000000..b7cfc25 --- /dev/null +++ b/core/buttonmapping/presentation/src/main/kotlin/com/joegec/joycon2android/buttonmapping/presentation/PlayerMappingCard.kt @@ -0,0 +1,167 @@ +package com.joegec.joycon2android.buttonmapping.presentation + +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.expandVertically +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.shrinkVertically +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.ExpandLess +import androidx.compose.material.icons.filled.ExpandMore +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextOverflow +import com.joegec.joycon2android.buttonmapping.Console +import com.joegec.joycon2android.core.buttonmapping.presentation.R +import com.joegec.joycon2android.model.ConnectedJoycon +import com.joegec.joycon2android.model.PlayerState +import com.joegec.joycon2android.ui.components.DropdownOption +import com.joegec.joycon2android.ui.components.SettingSwitch +import com.joegec.joycon2android.ui.theme.Accent +import com.joegec.joycon2android.ui.theme.CardBg +import com.joegec.joycon2android.ui.theme.Dimens +import com.joegec.joycon2android.ui.theme.JoyconDefaultColor +import com.joegec.joycon2android.ui.theme.TextDim +import com.joegec.joycon2android.ui.theme.joyconBorderColor + +/** One player's whole mapping: who they are and what layout they are on, opening onto its bindings. */ +@Composable +fun PlayerMappingCard( + console: Console, + player: PlayerState, + state: PlayerMappingUiState, + actions: MappingActions, + onSaveLayout: () -> Unit, + onDeleteLayout: (DropdownOption) -> Unit, + modifier: Modifier = Modifier, +) { + var expanded by rememberSaveable(state.body) { mutableStateOf(false) } + + Column( + modifier + .fillMaxWidth() + .clip(RoundedCornerShape(Dimens.cardCorner)) + .background(CardBg), + ) { + CardHeader(player, state.layoutName, expanded) { expanded = !expanded } + AnimatedVisibility( + visible = expanded, + enter = fadeIn() + expandVertically(), + exit = fadeOut() + shrinkVertically(), + ) { + Column( + Modifier.padding( + start = Dimens.compactRowPaddingHorizontal, + end = Dimens.compactRowPaddingHorizontal, + bottom = Dimens.cardPadding, + ), + verticalArrangement = Arrangement.spacedBy(Dimens.elementSpacing), + ) { + LayoutRow( + options = state.layoutOptions, + selectedId = state.selectedLayoutId, + layoutName = state.layoutName, + onSelect = { actions.selectLayout(state.body, it) }, + onSave = onSaveLayout, + onDelete = onDeleteLayout, + ) + if (state.offersSidewaysRemote) { + SidewaysRemoteSwitch(state.sidewaysRemote) { actions.setSidewaysRemote(state.body, it) } + } + MappingBindings(console, state, actions) + } + } + } +} + +@Composable +private fun CardHeader(player: PlayerState, layoutName: String?, expanded: Boolean, onToggle: () -> Unit) { + Row( + Modifier + .fillMaxWidth() + .clickable(onClick = onToggle) + .padding( + horizontal = Dimens.compactRowPaddingHorizontal, + vertical = Dimens.compactRowPaddingVertical, + ), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(Dimens.elementSpacing), + ) { + Text( + stringResource(R.string.player_label, player.player.index), + color = Accent, + style = MaterialTheme.typography.titleMedium, + ) + ControllerChips(player) + Text( + layoutName ?: stringResource(R.string.controller_mapping_layout_custom), + color = TextDim, + style = MaterialTheme.typography.labelMedium, + textAlign = TextAlign.End, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.weight(1f), + ) + Icon( + if (expanded) Icons.Filled.ExpandLess else Icons.Filled.ExpandMore, + contentDescription = null, + tint = TextDim, + modifier = Modifier.size(Dimens.iconSizeSmall), + ) + } +} + +@Composable +private fun ControllerChips(player: PlayerState) { + Row(horizontalArrangement = Arrangement.spacedBy(Dimens.compactControllerGap)) { + if (player.hasPro) { + ControllerChip(R.string.controller_pro, player.left!!) + } else { + player.left?.let { ControllerChip(R.string.controller_left, it) } + player.right?.let { ControllerChip(R.string.controller_right, it) } + } + } +} + +@Composable +private fun ControllerChip(textRes: Int, joycon: ConnectedJoycon) { + Text( + stringResource(textRes), + color = joyconBorderColor(joycon.accentColor, JoyconDefaultColor), + style = MaterialTheme.typography.titleMedium, + ) +} + +/** + * A lone Joy-Con stands in for a Wii Remote held sideways: what a wheel game steers by, and what + * turns its d-pad. A layout sets it; this is the player having the last word. + */ +@Composable +private fun SidewaysRemoteSwitch(enabled: Boolean, onSetEnabled: (Boolean) -> Unit) { + SettingSwitch( + title = stringResource(R.string.controller_mapping_sideways_remote), + description = stringResource(R.string.controller_mapping_sideways_remote_description), + checked = enabled, + onCheckedChange = onSetEnabled, + ) +} diff --git a/core/buttonmapping/presentation/src/main/res/values/strings.xml b/core/buttonmapping/presentation/src/main/res/values/strings.xml index 9d2ba5c..a2a82e4 100644 --- a/core/buttonmapping/presentation/src/main/res/values/strings.xml +++ b/core/buttonmapping/presentation/src/main/res/values/strings.xml @@ -1,7 +1,22 @@ Back - Layout + All players + Custom + Name + \"%1$s\" was saved for %2$s + Save layout + This is already \"%1$s\" + Save + Cancel + Delete layout? + Only the name goes — everyone playing on it keeps their buttons, and reads as \"Custom\" until you save it again. + Delete + Connect a controller to map it. Sideways Wii Remote - Play a single Joy-Con as a Wii Remote held sideways, the way Mario Kart\'s wheel expects. Steering reads correctly and the D-pad turns with it; a right Joy-Con then points from its tail rather than its R edge. + Steering reads correctly, the D-pad turns with it, and flicks are amplified.\nA right Joy-Con points from its tail rather than its R edge. Reset to defaults + P%1$d + Left + Right + Pro diff --git a/core/designsystem/src/main/kotlin/com/joegec/joycon2android/ui/components/DropdownOption.kt b/core/designsystem/src/main/kotlin/com/joegec/joycon2android/ui/components/DropdownOption.kt new file mode 100644 index 0000000..4ef6160 --- /dev/null +++ b/core/designsystem/src/main/kotlin/com/joegec/joycon2android/ui/components/DropdownOption.kt @@ -0,0 +1,13 @@ +package com.joegec.joycon2android.ui.components + +/** + * One row of an [OptionDropdown]. [subLabel] is the qualifier under the name; an option that + * cannot be chosen right now stays visible but dimmed, so the reason can be explained on tap. + */ +data class DropdownOption( + val id: String, + val label: String, + val subLabel: String? = null, + val available: Boolean = true, + val deletable: Boolean = false, +) diff --git a/core/designsystem/src/main/kotlin/com/joegec/joycon2android/ui/components/EmulatorDropdown.kt b/core/designsystem/src/main/kotlin/com/joegec/joycon2android/ui/components/EmulatorDropdown.kt index d50e5c9..4edcd5e 100644 --- a/core/designsystem/src/main/kotlin/com/joegec/joycon2android/ui/components/EmulatorDropdown.kt +++ b/core/designsystem/src/main/kotlin/com/joegec/joycon2android/ui/components/EmulatorDropdown.kt @@ -1,30 +1,10 @@ package com.joegec.joycon2android.ui.components -import androidx.compose.foundation.clickable -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.heightIn import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.width -import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.ArrowDropDown -import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.setValue -import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.clip -import androidx.compose.ui.layout.onSizeChanged -import androidx.compose.ui.platform.LocalDensity -import androidx.compose.ui.unit.dp import com.joegec.joycon2android.ui.theme.Accent import com.joegec.joycon2android.ui.theme.Dimens @@ -47,39 +27,11 @@ fun EmulatorDropdown( return } - var expanded by remember { mutableStateOf(false) } - var anchorWidth by remember { mutableStateOf(0.dp) } - val density = LocalDensity.current - - Box(modifier.onSizeChanged { anchorWidth = with(density) { it.width.toDp() } }) { - Row( - Modifier - .fillMaxWidth() - .clip(RoundedCornerShape(Dimens.buttonCorner)) - .clickable { expanded = true } - .heightIn(min = Dimens.minTouchTarget) - .padding(horizontal = Dimens.emulatorPickerPadding), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically, - ) { - Text( - selected.label, - color = Accent, - style = MaterialTheme.typography.labelMedium, - modifier = Modifier.weight(1f), - ) - Icon(Icons.Filled.ArrowDropDown, contentDescription = null, tint = Accent) - } - PanelDropdownMenu( - expanded = expanded, - onDismissRequest = { expanded = false }, - options = options.map { it.id to it.label }, - selectedId = selected.id, - modifier = Modifier.width(anchorWidth), - onSelect = { id -> - onSelect(id) - expanded = false - }, - ) - } + OptionDropdown( + options = options.map { DropdownOption(it.id, it.label) }, + selectedId = selected.id, + label = selected.label, + onSelect = onSelect, + modifier = modifier, + ) } diff --git a/core/designsystem/src/main/kotlin/com/joegec/joycon2android/ui/components/LabeledDropdown.kt b/core/designsystem/src/main/kotlin/com/joegec/joycon2android/ui/components/LabeledDropdown.kt deleted file mode 100644 index 9afbdb8..0000000 --- a/core/designsystem/src/main/kotlin/com/joegec/joycon2android/ui/components/LabeledDropdown.kt +++ /dev/null @@ -1,70 +0,0 @@ -package com.joegec.joycon2android.ui.components - -import androidx.compose.foundation.clickable -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.ArrowDropDown -import androidx.compose.material3.DropdownMenu -import androidx.compose.material3.DropdownMenuItem -import androidx.compose.material3.Icon -import androidx.compose.material3.Text -import androidx.compose.runtime.Composable -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.setValue -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.clip -import androidx.compose.ui.text.font.FontWeight -import com.joegec.joycon2android.ui.theme.Accent -import com.joegec.joycon2android.ui.theme.Dimens - -/** Generic id/label picker for rows that aren't a fixed [EmulatorOption] list, e.g. mapping editors. */ -@Composable -fun LabeledDropdown( - options: List>, - selectedId: String, - onSelect: (String) -> Unit, - modifier: Modifier = Modifier, -) { - val selected = options.firstOrNull { it.first == selectedId } ?: options.firstOrNull() ?: return - var expanded by remember { mutableStateOf(false) } - - Box(modifier) { - Row( - Modifier - .fillMaxWidth() - .clip(RoundedCornerShape(Dimens.buttonCorner)) - .clickable { expanded = true } - .padding(horizontal = Dimens.pillPaddingHorizontal, vertical = Dimens.pillPaddingVertical), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically, - ) { - Text( - selected.second, - color = Accent, - fontSize = Dimens.fontSizeSmall, - fontWeight = FontWeight.Bold, - modifier = Modifier.weight(1f), - ) - Icon(Icons.Filled.ArrowDropDown, contentDescription = null, tint = Accent) - } - DropdownMenu(expanded = expanded, onDismissRequest = { expanded = false }) { - options.forEach { (id, label) -> - DropdownMenuItem( - text = { Text(label) }, - onClick = { - onSelect(id) - expanded = false - }, - ) - } - } - } -} diff --git a/core/designsystem/src/main/kotlin/com/joegec/joycon2android/ui/components/OptionDropdown.kt b/core/designsystem/src/main/kotlin/com/joegec/joycon2android/ui/components/OptionDropdown.kt new file mode 100644 index 0000000..21d7be8 --- /dev/null +++ b/core/designsystem/src/main/kotlin/com/joegec/joycon2android/ui/components/OptionDropdown.kt @@ -0,0 +1,82 @@ +package com.joegec.joycon2android.ui.components + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.ArrowDropDown +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.layout.onSizeChanged +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.unit.dp +import com.joegec.joycon2android.ui.theme.Accent +import com.joegec.joycon2android.ui.theme.Dimens +import com.joegec.joycon2android.ui.theme.TextDim + +/** + * The app's picker: an accented current value that opens a panel of alternatives. [label] is shown + * rather than derived, so a caller whose state has drifted off the list can say so in its own words. + */ +@Composable +fun OptionDropdown( + options: List, + selectedId: String?, + label: String, + onSelect: (String) -> Unit, + modifier: Modifier = Modifier, + subLabel: String? = null, + onUnavailable: (DropdownOption) -> Unit = {}, + onDelete: ((DropdownOption) -> Unit)? = null, +) { + var expanded by remember { mutableStateOf(false) } + var anchorWidth by remember { mutableStateOf(0.dp) } + val density = LocalDensity.current + + Box(modifier.onSizeChanged { anchorWidth = with(density) { it.width.toDp() } }) { + Row( + Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(Dimens.buttonCorner)) + .clickable { expanded = true } + .heightIn(min = Dimens.minTouchTarget) + .padding(horizontal = Dimens.emulatorPickerPadding), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Column(Modifier.weight(1f)) { + Text(label, color = Accent, style = MaterialTheme.typography.labelMedium) + subLabel?.let { Text(it, color = TextDim, style = MaterialTheme.typography.labelSmall) } + } + Icon(Icons.Filled.ArrowDropDown, contentDescription = null, tint = Accent) + } + PanelDropdownMenu( + expanded = expanded, + onDismissRequest = { expanded = false }, + options = options, + selectedId = selectedId, + modifier = Modifier.width(anchorWidth), + onDelete = onDelete?.let { delete -> { option -> expanded = false; delete(option) } }, + onSelect = { option -> + expanded = false + if (option.available) onSelect(option.id) else onUnavailable(option) + }, + ) + } +} diff --git a/core/designsystem/src/main/kotlin/com/joegec/joycon2android/ui/components/PanelDropdownMenu.kt b/core/designsystem/src/main/kotlin/com/joegec/joycon2android/ui/components/PanelDropdownMenu.kt index 848198a..2924ef6 100644 --- a/core/designsystem/src/main/kotlin/com/joegec/joycon2android/ui/components/PanelDropdownMenu.kt +++ b/core/designsystem/src/main/kotlin/com/joegec/joycon2android/ui/components/PanelDropdownMenu.kt @@ -1,15 +1,23 @@ package com.joegec.joycon2android.ui.components import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.size import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.DeleteOutline import androidx.compose.material3.DropdownMenu import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color +import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp +import com.joegec.joycon2android.core.designsystem.R import com.joegec.joycon2android.ui.theme.Accent import com.joegec.joycon2android.ui.theme.CardBg import com.joegec.joycon2android.ui.theme.Dimens @@ -19,10 +27,11 @@ import com.joegec.joycon2android.ui.theme.TextDim fun PanelDropdownMenu( expanded: Boolean, onDismissRequest: () -> Unit, - options: List>, - selectedId: String, - onSelect: (id: String) -> Unit, + options: List, + selectedId: String?, + onSelect: (DropdownOption) -> Unit, modifier: Modifier = Modifier, + onDelete: ((DropdownOption) -> Unit)? = null, ) { DropdownMenu( expanded = expanded, @@ -33,16 +42,44 @@ fun PanelDropdownMenu( tonalElevation = 0.dp, border = BorderStroke(Dimens.cardBorderWidth, TextDim), ) { - options.forEach { (id, label) -> + options.forEach { option -> DropdownMenuItem( - text = { - Text( - label, - color = if (id == selectedId) Accent else Color.White, - style = MaterialTheme.typography.labelMedium, - ) - }, - onClick = { onSelect(id) }, + text = { OptionText(option, selected = option.id == selectedId) }, + trailingIcon = deleteAction(option, onDelete), + onClick = { onSelect(option) }, + ) + } + } +} + +@Composable +private fun OptionText(option: DropdownOption, selected: Boolean) { + val color = when { + !option.available -> TextDim + selected -> Accent + else -> Color.White + } + Column { + Text(option.label, color = color, style = MaterialTheme.typography.labelMedium) + option.subLabel?.let { + Text(it, color = TextDim, style = MaterialTheme.typography.labelSmall) + } + } +} + +// Null rather than an empty composable, so a row with nothing to delete keeps no room for it. +private fun deleteAction( + option: DropdownOption, + onDelete: ((DropdownOption) -> Unit)?, +): (@Composable () -> Unit)? { + if (onDelete == null || !option.deletable) return null + return { + IconButton(onClick = { onDelete(option) }, modifier = Modifier.size(Dimens.iconButtonSize)) { + Icon( + Icons.Filled.DeleteOutline, + contentDescription = stringResource(R.string.dropdown_delete_option), + tint = TextDim, + modifier = Modifier.size(Dimens.iconSizeMedium), ) } } diff --git a/core/designsystem/src/main/kotlin/com/joegec/joycon2android/ui/components/TextInputDialog.kt b/core/designsystem/src/main/kotlin/com/joegec/joycon2android/ui/components/TextInputDialog.kt new file mode 100644 index 0000000..4695404 --- /dev/null +++ b/core/designsystem/src/main/kotlin/com/joegec/joycon2android/ui/components/TextInputDialog.kt @@ -0,0 +1,79 @@ +package com.joegec.joycon2android.ui.components + +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.OutlinedTextFieldDefaults +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.onFocusChanged +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.font.FontWeight +import com.joegec.joycon2android.ui.theme.Accent +import com.joegec.joycon2android.ui.theme.CardBg +import com.joegec.joycon2android.ui.theme.TextDim + +/** + * Asks for one line of text in the app's card styling. [defaultValue] is offered already filled in + * and stands if nothing is typed, but steps aside the moment the field is tapped — so accepting it + * costs nothing and replacing it needs no deleting first. + */ +@Composable +fun TextInputDialog( + title: String, + fieldLabel: String, + defaultValue: String, + confirmLabel: String, + dismissLabel: String, + onConfirm: (String) -> Unit, + onDismiss: () -> Unit, +) { + var text by rememberSaveable { mutableStateOf(defaultValue) } + var offering by rememberSaveable { mutableStateOf(true) } + + AlertDialog( + onDismissRequest = onDismiss, + containerColor = CardBg, + title = { Text(title, color = Color.White, style = MaterialTheme.typography.titleSmall) }, + text = { + OutlinedTextField( + value = text, + onValueChange = { + offering = false + text = it + }, + singleLine = true, + label = { Text(fieldLabel) }, + modifier = Modifier.onFocusChanged { focus -> + if (focus.isFocused && offering) { + offering = false + text = "" + } + }, + colors = OutlinedTextFieldDefaults.colors( + focusedTextColor = Color.White, + unfocusedTextColor = Color.White, + focusedBorderColor = Accent, + unfocusedBorderColor = TextDim, + focusedLabelColor = Accent, + unfocusedLabelColor = TextDim, + cursorColor = Accent, + ), + ) + }, + confirmButton = { + TextButton(onClick = { onConfirm(text.trim().ifBlank { defaultValue }) }) { + Text(confirmLabel, color = Accent, fontWeight = FontWeight.Bold) + } + }, + dismissButton = { + TextButton(onClick = onDismiss) { Text(dismissLabel, color = TextDim) } + }, + ) +} diff --git a/core/designsystem/src/main/res/values/strings.xml b/core/designsystem/src/main/res/values/strings.xml index 39c06bd..a53e2e0 100644 --- a/core/designsystem/src/main/res/values/strings.xml +++ b/core/designsystem/src/main/res/values/strings.xml @@ -14,4 +14,5 @@ Not now Configure button mapping Auto setup might not work on every device. If the emulator doesn\'t respond to Joy-Con input after setting up, configure the controller manually in the emulator\'s own settings instead. + Delete diff --git a/docs/DESIGN.md b/docs/DESIGN.md index 502c5f6..f007a21 100644 --- a/docs/DESIGN.md +++ b/docs/DESIGN.md @@ -133,9 +133,12 @@ Odd trailing items take a half cell with a weighted `Spacer` filling the other h Shared in `core/designsystem/.../ui/components/`: - `FeatureToggleCard` — the primary on/off feature surface (gamepad, DSU) -- `EmulatorDropdown` / `EmulatorOption` / `EmulatorAutoSetup` / `PanelDropdownMenu` — emulator picker + one-tap setup +- `OptionDropdown` / `DropdownOption` / `PanelDropdownMenu` — the app's picker: an accented current + value over a panel of alternatives, each row optionally sub-labelled, dimmed or deletable +- `EmulatorDropdown` / `EmulatorOption` / `EmulatorAutoSetup` — emulator picker + one-tap setup - `DolphinSetupButton` / `DolphinSetupPhase` / `CloseEmulatorDialog` — staged setup flow -- `SettingsRow` · `SettingSwitch` · `LabeledDropdown` — settings surfaces (e.g. Motion settings) +- `SettingsRow` · `SettingSwitch` — settings surfaces (e.g. Motion settings) +- `ConfirmDialog` · `TextInputDialog` — ask before a change lands, or ask it for a name - `ErrorBox` · `WarningBox` · `LabeledBorderBox` · `ExpandableInfoSection` · `CopyableCode` ## Motion diff --git a/docs/architecture.md b/docs/architecture.md index 5640259..a408ed2 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -78,10 +78,21 @@ gamepad and DSU features both write to Dolphin and Eden. Holds *mechanism*, not per-emulator config generators live in their owning feature's `domain`. **`:core:buttonmapping`** — the user-editable Joy-Con → emulator button mapping: the mapping model, -the layouts a console can start from and the sideways-remote switch they seed (`domain`, layouts in -`preset/`), their persistence (`data`), and the mapping editor (`presentation`). Both the gamepad and DSU config generators read it. A target -holds *every* source bound to it, so Dolphin ORs them into one expression while Eden, which binds -one input per key, keeps the first. +the layouts a body can start from and the sideways-remote switch they seed (`domain`, shipped +layouts in `preset/`), their persistence (`data`), and the mapping editor (`presentation`). Both the +gamepad and DSU config generators read it. A target holds *every* source bound to it, so Dolphin ORs +them into one expression while Eden, which binds one input per key, keeps the first. + +Everything is keyed by `PlayerBody` — a player plus the body they hold — so each player maps +independently. **A layout is never a stored reference, only a name for a set of bindings**: applying +one copies out everything it says (`ApplyPlayerMappingUseCase`), and `MappingLayouts.matching` reads +the name back by comparing what a body is bound to against every layout the app ships and every one +the user saved (`SavedLayout`, scoped to the body it came from). No match is the editor's "Custom". +That is what lets a deleted layout take away its name and nothing else, and lets the same bindings +answer to it again the day an identical layout is saved back. `GlobalLayout` freezes the whole +session the same way — every player's bindings in full, not a layout id — so it restores what it +saved whatever has happened to the layouts since; it carries the bodies it was saved from, which is +why it can only be restored onto those players. ## Dependency rules diff --git a/docs/dsu-motion.md b/docs/dsu-motion.md index a7adc5b..e2056e2 100644 --- a/docs/dsu-motion.md +++ b/docs/dsu-motion.md @@ -56,15 +56,26 @@ if the Joy-Con's nose pointed at the screen. `DolphinWiimoteConfig` turns a lone Joy-Con's IMU inputs back about the button face (table in the [README](../README.md#manual-setup)), putting the nose on the shoulder edge the player aims. The bodies rotate into their grips opposite ways, so their tables are each other half a turn. -- **A console can play as a sideways Wii Remote** — a switch in the mapping editor, seeded by the - layout (`MappingPreset.sidewaysRemote`, true only for **Mario Kart**) and overridable by the user - (`SidewaysRemoteRepository`; applying a layout clears the override). A game written for that grip reads gravity against a remote whose nose points +- **A player can play as a sideways Wii Remote** — a switch on their card in the mapping editor, + seeded by their layout (`MappingLayout.sidewaysRemote`, true only for **Mario Kart**) and + overridable per player (`SidewaysRemoteRepository`; applying a layout clears the override). A game written for that grip reads gravity against a remote whose nose points left, which is where a *left* Joy-Con's L/ZL edge already points — so only a right Joy-Con turns, giving up its own body (and with it R/ZR as the nose: aiming moves to the tail) to steer true. Both bodies also turn their four D-pad bindings a quarter, since the player's up is a sideways remote's right. That is Dolphin's own `dpad_sideways_bitmasks`, applied here so its *Sideways Wii Remote* option can stay off — the option would also turn the accelerometer, which we have turned already. +- **A sideways layout amplifies the flick, for tricks.** Mario Kart Wii has four tricks and picks + between them by the *direction* of the flick, read from the accelerometer alone (no MotionPlus), + so nothing synthetic serves: Dolphin's `Shake` group is one axis and symmetric, and fires whichever + trick that axis happens to mean. The real jerk is amplified instead — `smooth()` is a slew limiter, + so subtracting it leaves what gravity is not, and adding that back over again lifts a flick while + leaving the gravity that steers and settles the pointer alone. Measured: a flick carries 1.6–3.6 g + against 0.35 g for the sharpest steering, so doubling the transient keeps them well apart. +- **A flick has to land in the plane of the wheel.** Captured flicks went along the *axle* five times + in six — the player held the Joy-Con nearly flat (31–38° off vertical) and flicked upward, which + pushes along the face normal, a direction the game has no trick for. Hardware wouldn't trick off + that either. Held like a wheel, up/down/left/right flicks fall in the plane the game reads. - **Pointing and a wheel want the nose half a turn apart on a right Joy-Con**, and no Dolphin option bridges them: `GetOrientation()` turns a quarter (Sideways) or a quarter about the left axis (Upright), and it reaches only the accelerometer the game reads, never diff --git a/feature/dsu/domain/src/main/kotlin/com/joegec/joycon2android/dsu/emulator/DolphinWiimoteConfig.kt b/feature/dsu/domain/src/main/kotlin/com/joegec/joycon2android/dsu/emulator/DolphinWiimoteConfig.kt index 9943d98..76d7768 100644 --- a/feature/dsu/domain/src/main/kotlin/com/joegec/joycon2android/dsu/emulator/DolphinWiimoteConfig.kt +++ b/feature/dsu/domain/src/main/kotlin/com/joegec/joycon2android/dsu/emulator/DolphinWiimoteConfig.kt @@ -2,6 +2,7 @@ package com.joegec.joycon2android.dsu.emulator import com.joegec.joycon2android.buttonmapping.JoyconSide import com.joegec.joycon2android.buttonmapping.MappingSource +import com.joegec.joycon2android.buttonmapping.PlayerBody import com.joegec.joycon2android.buttonmapping.StickDirection import com.joegec.joycon2android.buttonmapping.StickSource import com.joegec.joycon2android.buttonmapping.emittedFor @@ -83,9 +84,11 @@ object DolphinWiimoteConfig { private val ACCEL_DIRECTIONS = listOf("Up", "Down", "Left", "Right", "Forward", "Backward") - private val IMU_CONTROLS = ACCEL_DIRECTIONS.map { "IMUAccelerometer/$it" to "Accel $it" } + + private val GYRO_DIRECTIONS = listOf("Pitch Up", "Pitch Down", "Roll Left", "Roll Right", "Yaw Left", "Yaw Right") - .map { "IMUGyroscope/$it" to "Gyro $it" } + + private val IMU_CONTROLS = ACCEL_DIRECTIONS.map { "IMUAccelerometer/$it" to "Accel $it" } + + GYRO_DIRECTIONS.map { "IMUGyroscope/$it" to "Gyro $it" } // A lone Joy-Con streams in its sideways grip (SidewaysMotion); turning that grip back about the // button face restores the Joy-Con's own body, which is the remote the player aims down its @@ -128,10 +131,31 @@ object DolphinWiimoteConfig { private fun dolphinKey(target: WiimoteButton, sideways: Boolean): String = (if (sideways) SIDEWAYS_DPAD_KEYS[target] else null) ?: DOLPHIN_KEYS.getValue(target) + // Mario Kart Wii has four tricks and picks between them by the direction of the flick, read from + // the accelerometer alone since it has no MotionPlus. Nothing synthetic can carry that — a shake + // is one axis and symmetric — so the real jerk has to arrive big enough instead: a Joy-Con is a + // fraction of the mass a Wii Wheel throws, and a flick lands a fraction of the jerk with it. + // + // smooth() is a slew limiter, so subtracting it leaves what gravity is not, and adding that back + // over again amplifies the flick while leaving untouched the gravity the wheel steers by and the + // pointer settles against. Measured 2026-09: a flick carries 1.6 to 3.6 g, the sharpest steering + // 0.35 g, so doubling the transient keeps those a wheel's turn apart. + private const val TRICK_GAIN = 2 + private const val TRICK_SETTLE_SECONDS = 0.03 + + private fun accelExpression(input: String, amplified: Boolean): String = + if (!amplified) "`$input`" + else "`$input` + (`$input` - smooth(`$input`, $TRICK_SETTLE_SECONDS)) * $TRICK_GAIN" + private fun imuLines(side: JoyconSide, sidewaysRemote: Boolean): List { val bodyInputs = bodyInputs(side, sidewaysRemote) - return IMU_CONTROLS.map { (control, input) -> "$control = `${bodyInputs[input] ?: input}`" } + - listOf("IMUIR/Enabled = True", "IMUIR/Total Yaw = $IMU_TOTAL_YAW_DEGREES") + val amplified = sidewaysRemote && side != JoyconSide.DUAL + return IMU_CONTROLS.map { (control, input) -> + val read = bodyInputs[input] ?: input + val expression = + if (control.startsWith("IMUAccelerometer")) accelExpression(read, amplified) else "`$read`" + "$control = $expression" + } + listOf("IMUIR/Enabled = True", "IMUIR/Total Yaw = $IMU_TOTAL_YAW_DEGREES") } // Dolphin's emulated remote only ever translates through the Swing group — the IMU path feeds @@ -168,20 +192,20 @@ object DolphinWiimoteConfig { fun merge( existing: String?, players: List, - sidewaysRemote: Boolean, - mappingFor: (JoyconSide) -> Map, - ): String = IniEditor.mergeSections(existing, sections(players, sidewaysRemote, mappingFor)) + sidewaysRemoteFor: (PlayerBody) -> Boolean, + mappingFor: (PlayerBody) -> Map, + ): String = IniEditor.mergeSections(existing, sections(players, sidewaysRemoteFor, mappingFor)) private fun sections( players: List, - sidewaysRemote: Boolean, - mappingFor: (JoyconSide) -> Map, + sidewaysRemoteFor: (PlayerBody) -> Boolean, + mappingFor: (PlayerBody) -> Map, ): Map { val secondHands = DsuSlots.secondHands(players).associate { it.state.player to it.slot } return players.mapNotNull { player -> val slot = player.player.index - 1 if (slot !in 0..3) return@mapNotNull null - bodyFor(player, slot, secondHands[player.player], sidewaysRemote, mappingFor) + bodyFor(player, slot, secondHands[player.player], sidewaysRemoteFor, mappingFor) ?.let { "[Wiimote${player.player.index}]" to it } }.toMap() } @@ -190,8 +214,8 @@ object DolphinWiimoteConfig { player: PlayerState, slot: Int, secondHandSlot: Int?, - sidewaysRemote: Boolean, - mappingFor: (JoyconSide) -> Map, + sidewaysRemoteFor: (PlayerBody) -> Boolean, + mappingFor: (PlayerBody) -> Map, ): String? { val side = when { player.hasPro -> return null @@ -200,6 +224,8 @@ object DolphinWiimoteConfig { player.left != null -> JoyconSide.LEFT else -> return null } + val body = PlayerBody(player.player, side) + val sidewaysRemote = sidewaysRemoteFor(body) // Source = 1 forces this Wii Remote slot to Emulated, so the mappings actually apply val header = listOf("Source = 1", "Device = DSUClient/$slot/Joycon2") val nunchukImu = if (side == JoyconSide.DUAL && secondHandSlot != null) { @@ -208,7 +234,7 @@ object DolphinWiimoteConfig { emptyList() } val sideways = sidewaysRemote && side != JoyconSide.DUAL - return (header + lines(side, sideways, mappingFor(side)) + imuLines(side, sidewaysRemote) + + return (header + lines(side, sideways, mappingFor(body)) + imuLines(side, sidewaysRemote) + swingLines(side, sidewaysRemote) + nunchukImu) .joinToString("\n", postfix = "\n") } diff --git a/feature/dsu/domain/src/main/kotlin/com/joegec/joycon2android/dsu/emulator/EdenDsuConfig.kt b/feature/dsu/domain/src/main/kotlin/com/joegec/joycon2android/dsu/emulator/EdenDsuConfig.kt index 7a45501..28bec71 100644 --- a/feature/dsu/domain/src/main/kotlin/com/joegec/joycon2android/dsu/emulator/EdenDsuConfig.kt +++ b/feature/dsu/domain/src/main/kotlin/com/joegec/joycon2android/dsu/emulator/EdenDsuConfig.kt @@ -2,6 +2,7 @@ package com.joegec.joycon2android.dsu.emulator import com.joegec.joycon2android.buttonmapping.JoyconSide import com.joegec.joycon2android.buttonmapping.MappingSource +import com.joegec.joycon2android.buttonmapping.PlayerBody import com.joegec.joycon2android.buttonmapping.StickDirection import com.joegec.joycon2android.buttonmapping.StickSource import com.joegec.joycon2android.buttonmapping.emittedFor @@ -71,7 +72,7 @@ object EdenDsuConfig { fun merge( existing: String?, players: List, - mappingFor: (JoyconSide) -> Map, + mappingFor: (PlayerBody) -> Map, ): String { // A reassignment leaves stale bindings on players who no longer hold a controller, and // those would keep feeding an emulated pad from whoever now owns that slot. @@ -105,7 +106,7 @@ object EdenDsuConfig { private fun playerKeys( players: List, - mappingFor: (JoyconSide) -> Map, + mappingFor: (PlayerBody) -> Map, ): Map { val secondHands = DsuSlots.secondHands(players).associate { it.state.player to it.slot } val keys = LinkedHashMap() @@ -114,7 +115,7 @@ object EdenDsuConfig { if (slot !in 0 until DsuSlots.COUNT) return@forEach val type = EdenControls.npadType(player) ?: return@forEach val side = sideFor(player) ?: return@forEach - val mapping = mappingFor(side) + val mapping = mappingFor(PlayerBody(player.player, side)) val device = device(slot) keys.defineEdenKey("player_${slot}_type", type.toString()) diff --git a/feature/dsu/domain/src/test/kotlin/com/joegec/joycon2android/dsu/emulator/DolphinWiimoteConfigTest.kt b/feature/dsu/domain/src/test/kotlin/com/joegec/joycon2android/dsu/emulator/DolphinWiimoteConfigTest.kt index 38a5238..0e700e8 100644 --- a/feature/dsu/domain/src/test/kotlin/com/joegec/joycon2android/dsu/emulator/DolphinWiimoteConfigTest.kt +++ b/feature/dsu/domain/src/test/kotlin/com/joegec/joycon2android/dsu/emulator/DolphinWiimoteConfigTest.kt @@ -2,6 +2,7 @@ package com.joegec.joycon2android.dsu.emulator import com.joegec.joycon2android.buttonmapping.Console import com.joegec.joycon2android.buttonmapping.JoyconSide +import com.joegec.joycon2android.buttonmapping.PlayerBody import com.joegec.joycon2android.buttonmapping.preset.MappingPresets import com.joegec.joycon2android.model.ConnectedJoycon import com.joegec.joycon2android.model.PlayerNumber @@ -13,12 +14,14 @@ import org.junit.Test private fun defaultWiimoteMapping(side: JoyconSide) = MappingPresets.default(Console.WIIMOTE_NUNCHUK).entries(side) +private val wiimoteMapping: (PlayerBody) -> Map = { defaultWiimoteMapping(it.side) } + class DolphinWiimoteConfigTest { private fun joycon(side: Side) = ConnectedJoycon(address = side.name, side = side, deviceName = "Joy-Con") private fun merge(existing: String?, players: List, sidewaysRemote: Boolean = false) = - DolphinWiimoteConfig.merge(existing, players, sidewaysRemote, ::defaultWiimoteMapping) + DolphinWiimoteConfig.merge(existing, players, { sidewaysRemote }, wiimoteMapping) @Test fun `right-only player maps the stick to the d-pad and uses no extension`() { @@ -61,7 +64,7 @@ class DolphinWiimoteConfigTest { val pair = PlayerState(PlayerNumber.P1, left = joycon(Side.LEFT), right = joycon(Side.RIGHT)) val mapping = defaultWiimoteMapping(JoyconSide.DUAL) + mapOf("NunchukStick_UP" to "Up") - val result = DolphinWiimoteConfig.merge(null, listOf(pair), false) { mapping } + val result = DolphinWiimoteConfig.merge(null, listOf(pair), { false }) { mapping } assertTrue(result.contains("Nunchuk/Stick/Up = `Pad N`")) assertTrue(result.contains("Nunchuk/Stick/Down = `Left Y-`")) @@ -72,7 +75,7 @@ class DolphinWiimoteConfigTest { val mapping = defaultWiimoteMapping(JoyconSide.RIGHT) + mapOf("NunchukStick_UP" to "X", "NunchukStick_DOWN" to "B") val player = listOf(PlayerState(PlayerNumber.P1, right = joycon(Side.RIGHT))) - val result = DolphinWiimoteConfig.merge(null, player, false) { mapping } + val result = DolphinWiimoteConfig.merge(null, player, { false }) { mapping } assertTrue(result.contains("Extension = Nunchuk")) assertTrue(result.contains("Nunchuk/Stick/Up = `Circle`")) // physical X rotates onto A @@ -85,7 +88,7 @@ class DolphinWiimoteConfigTest { val mapping = defaultWiimoteMapping(JoyconSide.RIGHT) + mapOf("DPadUp" to "RIGHT_STICK_UP|SlRight") val player = listOf(PlayerState(PlayerNumber.P1, right = joycon(Side.RIGHT))) - val result = DolphinWiimoteConfig.merge(null, player, false) { mapping } + val result = DolphinWiimoteConfig.merge(null, player, { false }) { mapping } assertTrue(result.contains("D-Pad/Up = `Left Y+` | `L1`")) // SL rotates onto L held sideways } @@ -172,6 +175,8 @@ class DolphinWiimoteConfigTest { assertTrue(result.contains("IMUAccelerometer/Forward = `Accel Left`")) assertTrue(result.contains("IMUGyroscope/Pitch Up = `Gyro Roll Right`")) } + // ...though only the sideways one amplifies its flick. + assertFalse(merge(null, player).contains("smooth(`Accel")) } @Test @@ -180,8 +185,8 @@ class DolphinWiimoteConfigTest { val result = merge(null, player, sidewaysRemote = true) - assertTrue(result.contains("IMUAccelerometer/Up = `Accel Up`")) - assertTrue(result.contains("IMUAccelerometer/Forward = `Accel Left`")) + assertTrue(result.contains("IMUAccelerometer/Up = `Accel Up` +")) + assertTrue(result.contains("IMUAccelerometer/Forward = `Accel Left` +")) assertTrue(result.contains("IMUGyroscope/Pitch Up = `Gyro Roll Right`")) assertTrue(result.contains("IMUGyroscope/Yaw Left = `Gyro Yaw Left`")) } @@ -211,6 +216,29 @@ class DolphinWiimoteConfigTest { assertTrue(result.contains("D-Pad/Up = `Pad N`")) } + // Tricks pick a direction out of the flick itself, so the real jerk is amplified rather than + // replaced by anything synthetic — gravity, which steers and settles the pointer, is untouched. + @Test + fun `playing sideways amplifies the flick, not the gravity under it`() { + val result = merge(null, listOf(PlayerState(PlayerNumber.P1, right = joycon(Side.RIGHT))), sidewaysRemote = true) + + assertTrue( + result.contains( + "IMUAccelerometer/Forward = `Accel Left` + (`Accel Left` - smooth(`Accel Left`, 0.03)) * 2", + ), + ) + assertTrue(result.contains("IMUGyroscope/Pitch Up = `Gyro Roll Right`")) // gyroscope passes through + } + + @Test + fun `nothing is amplified unless the layout plays sideways`() { + val lone = merge(null, listOf(PlayerState(PlayerNumber.P1, right = joycon(Side.RIGHT)))) + val pair = PlayerState(PlayerNumber.P1, left = joycon(Side.LEFT), right = joycon(Side.RIGHT)) + + assertFalse(lone.contains("smooth(`Accel")) + assertFalse(merge(null, listOf(pair), sidewaysRemote = true).contains("smooth(`Accel")) + } + @Test fun `pro controllers are skipped`() { val result = merge(null, listOf(PlayerState(PlayerNumber.P1, left = joycon(Side.PRO)))) diff --git a/feature/dsu/domain/src/test/kotlin/com/joegec/joycon2android/dsu/emulator/EdenDsuConfigTest.kt b/feature/dsu/domain/src/test/kotlin/com/joegec/joycon2android/dsu/emulator/EdenDsuConfigTest.kt index 5fde36a..2f380d8 100644 --- a/feature/dsu/domain/src/test/kotlin/com/joegec/joycon2android/dsu/emulator/EdenDsuConfigTest.kt +++ b/feature/dsu/domain/src/test/kotlin/com/joegec/joycon2android/dsu/emulator/EdenDsuConfigTest.kt @@ -2,6 +2,7 @@ package com.joegec.joycon2android.dsu.emulator import com.joegec.joycon2android.buttonmapping.Console import com.joegec.joycon2android.buttonmapping.JoyconSide +import com.joegec.joycon2android.buttonmapping.PlayerBody import com.joegec.joycon2android.buttonmapping.preset.MappingPresets import com.joegec.joycon2android.dsu.DsuConfig import com.joegec.joycon2android.model.ConnectedJoycon @@ -15,6 +16,8 @@ import org.junit.Test private fun defaultSwitchProMapping(side: JoyconSide) = MappingPresets.default(Console.SWITCH_PRO).entries(side) +private val switchProMapping: (PlayerBody) -> Map = { defaultSwitchProMapping(it.side) } + class EdenDsuConfigTest { private fun joycon(side: Side) = ConnectedJoycon(address = side.name, side = side, deviceName = "Joy-Con") @@ -30,7 +33,7 @@ class EdenDsuConfigTest { config.lines().first { it.substringBefore('=').trim() == key }.substringAfter('=').trim() private fun merge(existing: String?, players: List) = - EdenDsuConfig.merge(existing, players, ::defaultSwitchProMapping) + EdenDsuConfig.merge(existing, players, switchProMapping) private fun device(pad: Int) = "engine:cemuhookudp,guid:0000000000000000000000007f000001,port:${DsuConfig.PORT},pad:$pad" diff --git a/feature/gamepad/domain/src/main/kotlin/com/joegec/joycon2android/gamepad/emulator/DolphinGcpadConfig.kt b/feature/gamepad/domain/src/main/kotlin/com/joegec/joycon2android/gamepad/emulator/DolphinGcpadConfig.kt index 286e09c..4399b80 100644 --- a/feature/gamepad/domain/src/main/kotlin/com/joegec/joycon2android/gamepad/emulator/DolphinGcpadConfig.kt +++ b/feature/gamepad/domain/src/main/kotlin/com/joegec/joycon2android/gamepad/emulator/DolphinGcpadConfig.kt @@ -2,6 +2,7 @@ package com.joegec.joycon2android.gamepad.emulator import com.joegec.joycon2android.buttonmapping.JoyconSide import com.joegec.joycon2android.buttonmapping.MappingSource +import com.joegec.joycon2android.buttonmapping.PlayerBody import com.joegec.joycon2android.buttonmapping.StickDirection import com.joegec.joycon2android.buttonmapping.StickSource import com.joegec.joycon2android.buttonmapping.emittedFor @@ -83,7 +84,7 @@ object DolphinGcpadConfig { existing: String?, players: List, controllerNumbers: Map, - mappingFor: (JoyconSide) -> Map, + mappingFor: (PlayerBody) -> Map, ): String = IniEditor.mergeSections(existing, sections(players, controllerNumbers, mappingFor)) /** Sets each configured player's GameCube port to a Standard Controller in Dolphin.ini. */ @@ -101,7 +102,7 @@ object DolphinGcpadConfig { private fun sections( players: List, controllerNumbers: Map, - mappingFor: (JoyconSide) -> Map, + mappingFor: (PlayerBody) -> Map, ): Map = players.filter { it.hasController } .sortedBy { it.player.index } @@ -112,7 +113,12 @@ object DolphinGcpadConfig { bodyFor(player, index, deviceId, mappingFor)?.let { "[GCPad$index]" to it } }.toMap() - private fun bodyFor(player: PlayerState, index: Int, deviceId: Int, mappingFor: (JoyconSide) -> Map): String? { + private fun bodyFor( + player: PlayerState, + index: Int, + deviceId: Int, + mappingFor: (PlayerBody) -> Map, + ): String? { val side = when { player.hasPro -> return null player.hasFullController -> JoyconSide.DUAL @@ -121,7 +127,8 @@ object DolphinGcpadConfig { else -> return null } val device = "Device = Android/$deviceId/Joy-Con Virtual Gamepad $index" - return (listOf(device) + lines(side, mappingFor(side))).joinToString("\n", postfix = "\n") + return (listOf(device) + lines(side, mappingFor(PlayerBody(player.player, side)))) + .joinToString("\n", postfix = "\n") } private fun lines(side: JoyconSide, mapping: Map): List { diff --git a/feature/gamepad/domain/src/main/kotlin/com/joegec/joycon2android/gamepad/emulator/EdenGamepadConfig.kt b/feature/gamepad/domain/src/main/kotlin/com/joegec/joycon2android/gamepad/emulator/EdenGamepadConfig.kt index 10fdf1d..c26ba69 100644 --- a/feature/gamepad/domain/src/main/kotlin/com/joegec/joycon2android/gamepad/emulator/EdenGamepadConfig.kt +++ b/feature/gamepad/domain/src/main/kotlin/com/joegec/joycon2android/gamepad/emulator/EdenGamepadConfig.kt @@ -2,6 +2,7 @@ package com.joegec.joycon2android.gamepad.emulator import com.joegec.joycon2android.buttonmapping.JoyconSide import com.joegec.joycon2android.buttonmapping.MappingSource +import com.joegec.joycon2android.buttonmapping.PlayerBody import com.joegec.joycon2android.buttonmapping.StickDirection import com.joegec.joycon2android.buttonmapping.StickSource import com.joegec.joycon2android.buttonmapping.emittedFor @@ -87,7 +88,7 @@ object EdenGamepadConfig { existing: String?, players: List, gamepads: Map, - mappingFor: (JoyconSide) -> Map, + mappingFor: (PlayerBody) -> Map, ): String { // Drop every player's prior bindings first: a layout or port change leaves stale keys that // would otherwise linger and cross-fire onto another player's port. @@ -98,7 +99,7 @@ object EdenGamepadConfig { private fun controlKeys( players: List, gamepads: Map, - mappingFor: (JoyconSide) -> Map, + mappingFor: (PlayerBody) -> Map, ): Map { val keys = LinkedHashMap() players.forEach { player -> @@ -107,7 +108,7 @@ object EdenGamepadConfig { val gamepad = gamepads[index] ?: return@forEach val type = EdenControls.npadType(player) ?: return@forEach val side = sideFor(player) ?: return@forEach - val layout = layoutFor(side, mappingFor(side)) + val layout = layoutFor(side, mappingFor(PlayerBody(player.player, side))) val p = index - 1 val device = "engine:android,port:${gamepad.port},guid:${gamepad.guid},pad:0" val display = "Joy-Con Virtual Gamepad $index ${gamepad.port}" diff --git a/feature/gamepad/domain/src/test/kotlin/com/joegec/joycon2android/gamepad/emulator/DolphinGcpadConfigTest.kt b/feature/gamepad/domain/src/test/kotlin/com/joegec/joycon2android/gamepad/emulator/DolphinGcpadConfigTest.kt index 2f25ac0..d78a689 100644 --- a/feature/gamepad/domain/src/test/kotlin/com/joegec/joycon2android/gamepad/emulator/DolphinGcpadConfigTest.kt +++ b/feature/gamepad/domain/src/test/kotlin/com/joegec/joycon2android/gamepad/emulator/DolphinGcpadConfigTest.kt @@ -2,6 +2,7 @@ package com.joegec.joycon2android.gamepad.emulator import com.joegec.joycon2android.buttonmapping.Console import com.joegec.joycon2android.buttonmapping.JoyconSide +import com.joegec.joycon2android.buttonmapping.PlayerBody import com.joegec.joycon2android.buttonmapping.preset.MappingPresets import com.joegec.joycon2android.model.ConnectedJoycon import com.joegec.joycon2android.model.PlayerNumber @@ -19,8 +20,8 @@ class DolphinGcpadConfigTest { existing: String?, players: List, controllerNumbers: Map = players.associate { it.player.index to it.player.index }, - ) = DolphinGcpadConfig.merge(existing, players, controllerNumbers) { side -> - MappingPresets.default(Console.GAMECUBE).entries(side) + ) = DolphinGcpadConfig.merge(existing, players, controllerNumbers) { body -> + MappingPresets.default(Console.GAMECUBE).entries(body.side) } @Test diff --git a/feature/gamepad/domain/src/test/kotlin/com/joegec/joycon2android/gamepad/emulator/EdenGamepadConfigTest.kt b/feature/gamepad/domain/src/test/kotlin/com/joegec/joycon2android/gamepad/emulator/EdenGamepadConfigTest.kt index efc6c0e..1a0e6e7 100644 --- a/feature/gamepad/domain/src/test/kotlin/com/joegec/joycon2android/gamepad/emulator/EdenGamepadConfigTest.kt +++ b/feature/gamepad/domain/src/test/kotlin/com/joegec/joycon2android/gamepad/emulator/EdenGamepadConfigTest.kt @@ -2,6 +2,7 @@ package com.joegec.joycon2android.gamepad.emulator import com.joegec.joycon2android.buttonmapping.Console import com.joegec.joycon2android.buttonmapping.JoyconSide +import com.joegec.joycon2android.buttonmapping.PlayerBody import com.joegec.joycon2android.buttonmapping.preset.MappingPresets import com.joegec.joycon2android.model.ConnectedJoycon import com.joegec.joycon2android.model.PlayerNumber @@ -13,6 +14,8 @@ import org.junit.Test private fun defaultSwitchProMapping(side: JoyconSide) = MappingPresets.default(Console.SWITCH_PRO).entries(side) +private val switchProMapping: (PlayerBody) -> Map = { defaultSwitchProMapping(it.side) } + class EdenGamepadConfigTest { private fun joycon(side: Side) = ConnectedJoycon(address = side.name, side = side, deviceName = "Joy-Con") @@ -22,7 +25,7 @@ class EdenGamepadConfigTest { existing, players, ports.mapValues { (_, port) -> EdenGamepad.of(port, VENDOR_ID, PRODUCT_ID) }, - ::defaultSwitchProMapping, + switchProMapping, ) @Test @@ -128,7 +131,7 @@ class EdenGamepadConfigTest { // A handheld that re-publishes our pad under its built-in controller's vendor/product. val republished = mapOf(1 to EdenGamepad.of(port = 2, vendorId = 0x2020, productId = 0x0111)) - val result = EdenGamepadConfig.merge(null, players, republished, ::defaultSwitchProMapping) + val result = EdenGamepadConfig.merge(null, players, republished, switchProMapping) assertTrue(result.contains("guid:00000000000001110000000000002020")) assertFalse(result.contains(GUID)) From a28b2eb1bdf78b0c7fe1b36267446461d8460b57 Mon Sep 17 00:00:00 2001 From: Joe Barker Date: Tue, 22 Sep 2026 17:56:49 +0100 Subject: [PATCH 03/16] Say what the sideways switch does to the body in hand The switch described a right Joy-Con to both of them. A left Joy-Con's own body already is a Wii Remote held sideways -- a sideways remote's nose points left, just as its L/ZL edge does -- so there is no steering for the switch to correct there, and it only turns the d-pad and amplifies flicks. Only a right Joy-Con gives up its own body, and with it the R edge it aims down, so only it carries that caveat. The caveat now reads as one: SettingSwitch takes an optional warning, drawn under the description behind an amber caution icon in the colour WarningBox already uses, and announced as a warning to a screen reader rather than arriving as one more sentence. Co-Authored-By: Claude Opus 5 --- .../presentation/PlayerMappingCard.kt | 21 +++++++++++---- .../src/main/res/values/strings.xml | 4 ++- .../ui/components/SettingSwitch.kt | 27 +++++++++++++++++++ .../joegec/joycon2android/ui/theme/Dimens.kt | 1 + .../src/main/res/values/strings.xml | 1 + 5 files changed, 48 insertions(+), 6 deletions(-) diff --git a/core/buttonmapping/presentation/src/main/kotlin/com/joegec/joycon2android/buttonmapping/presentation/PlayerMappingCard.kt b/core/buttonmapping/presentation/src/main/kotlin/com/joegec/joycon2android/buttonmapping/presentation/PlayerMappingCard.kt index b7cfc25..524122e 100644 --- a/core/buttonmapping/presentation/src/main/kotlin/com/joegec/joycon2android/buttonmapping/presentation/PlayerMappingCard.kt +++ b/core/buttonmapping/presentation/src/main/kotlin/com/joegec/joycon2android/buttonmapping/presentation/PlayerMappingCard.kt @@ -32,6 +32,7 @@ import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextOverflow import com.joegec.joycon2android.buttonmapping.Console +import com.joegec.joycon2android.buttonmapping.JoyconSide import com.joegec.joycon2android.core.buttonmapping.presentation.R import com.joegec.joycon2android.model.ConnectedJoycon import com.joegec.joycon2android.model.PlayerState @@ -86,7 +87,9 @@ fun PlayerMappingCard( onDelete = onDeleteLayout, ) if (state.offersSidewaysRemote) { - SidewaysRemoteSwitch(state.sidewaysRemote) { actions.setSidewaysRemote(state.body, it) } + SidewaysRemoteSwitch(state.body.side, state.sidewaysRemote) { + actions.setSidewaysRemote(state.body, it) + } } MappingBindings(console, state, actions) } @@ -153,14 +156,22 @@ private fun ControllerChip(textRes: Int, joycon: ConnectedJoycon) { } /** - * A lone Joy-Con stands in for a Wii Remote held sideways: what a wheel game steers by, and what - * turns its d-pad. A layout sets it; this is the player having the last word. + * A lone Joy-Con stands in for a Wii Remote held sideways. A left Joy-Con's own body already is one + * — a sideways remote's nose points left, just as its L/ZL edge does — so the switch only turns its + * d-pad and amplifies its flicks. A right Joy-Con additionally gives up its own body to steer true, + * and with it the R edge as the nose it aims down, which is what the warning is for. */ @Composable -private fun SidewaysRemoteSwitch(enabled: Boolean, onSetEnabled: (Boolean) -> Unit) { +private fun SidewaysRemoteSwitch(side: JoyconSide, enabled: Boolean, onSetEnabled: (Boolean) -> Unit) { + val aimsFromItsTail = side == JoyconSide.RIGHT SettingSwitch( title = stringResource(R.string.controller_mapping_sideways_remote), - description = stringResource(R.string.controller_mapping_sideways_remote_description), + description = stringResource( + if (aimsFromItsTail) R.string.controller_mapping_sideways_remote_description_right + else R.string.controller_mapping_sideways_remote_description_left, + ), + warning = stringResource(R.string.controller_mapping_sideways_remote_warning) + .takeIf { aimsFromItsTail }, checked = enabled, onCheckedChange = onSetEnabled, ) diff --git a/core/buttonmapping/presentation/src/main/res/values/strings.xml b/core/buttonmapping/presentation/src/main/res/values/strings.xml index a2a82e4..b8c1442 100644 --- a/core/buttonmapping/presentation/src/main/res/values/strings.xml +++ b/core/buttonmapping/presentation/src/main/res/values/strings.xml @@ -13,7 +13,9 @@ Delete Connect a controller to map it. Sideways Wii Remote - Steering reads correctly, the D-pad turns with it, and flicks are amplified.\nA right Joy-Con points from its tail rather than its R edge. + Rotates the d-pad and flicks are amplified. + Steering reads correctly, the D-pad turns with it, and flicks are amplified. + When enabled, a right Joy-Con points from its tail rather than its R edge. Reset to defaults P%1$d Left diff --git a/core/designsystem/src/main/kotlin/com/joegec/joycon2android/ui/components/SettingSwitch.kt b/core/designsystem/src/main/kotlin/com/joegec/joycon2android/ui/components/SettingSwitch.kt index e21ac49..e5f8a41 100644 --- a/core/designsystem/src/main/kotlin/com/joegec/joycon2android/ui/components/SettingSwitch.kt +++ b/core/designsystem/src/main/kotlin/com/joegec/joycon2android/ui/components/SettingSwitch.kt @@ -1,12 +1,17 @@ package com.joegec.joycon2android.ui.components +import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width import androidx.compose.foundation.selection.toggleable +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.WarningAmber +import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Switch import androidx.compose.material3.SwitchDefaults @@ -15,10 +20,13 @@ import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color +import androidx.compose.ui.res.stringResource import androidx.compose.ui.semantics.Role +import com.joegec.joycon2android.core.designsystem.R import com.joegec.joycon2android.ui.theme.Accent import com.joegec.joycon2android.ui.theme.Dimens import com.joegec.joycon2android.ui.theme.TextDim +import com.joegec.joycon2android.ui.theme.WarningText @Composable fun SettingSwitch( @@ -28,6 +36,7 @@ fun SettingSwitch( onCheckedChange: (Boolean) -> Unit, modifier: Modifier = Modifier, descriptionColor: Color = TextDim, + warning: String? = null, ) { Row( modifier @@ -39,6 +48,10 @@ fun SettingSwitch( Text(title, color = Color.White, style = MaterialTheme.typography.bodyMedium) Spacer(Modifier.height(Dimens.featureCardTitleGap)) Text(description, color = descriptionColor, style = MaterialTheme.typography.bodySmall) + warning?.let { + Spacer(Modifier.height(Dimens.featureCardTitleGap)) + SettingWarning(it) + } } Spacer(Modifier.width(Dimens.featureCardSwitchGap)) Switch( @@ -48,3 +61,17 @@ fun SettingSwitch( ) } } + +/** A caveat the setting carries, marked so it reads as one rather than as more description. */ +@Composable +private fun SettingWarning(text: String) { + Row(horizontalArrangement = Arrangement.spacedBy(Dimens.statusDotGap)) { + Icon( + Icons.Filled.WarningAmber, + contentDescription = stringResource(R.string.setting_warning), + tint = WarningText, + modifier = Modifier.size(Dimens.iconSizeTiny), + ) + Text(text, color = WarningText, style = MaterialTheme.typography.bodySmall) + } +} diff --git a/core/designsystem/src/main/kotlin/com/joegec/joycon2android/ui/theme/Dimens.kt b/core/designsystem/src/main/kotlin/com/joegec/joycon2android/ui/theme/Dimens.kt index 9c044ba..7455df0 100644 --- a/core/designsystem/src/main/kotlin/com/joegec/joycon2android/ui/theme/Dimens.kt +++ b/core/designsystem/src/main/kotlin/com/joegec/joycon2android/ui/theme/Dimens.kt @@ -30,6 +30,7 @@ object Dimens { val dpadSize = 46.dp val faceButtonSize = 46.dp val iconButtonSize = 36.dp + val iconSizeTiny = 14.dp // sits beside bodySmall text rather than standing on its own val iconSizeSmall = 18.dp val iconSizeMedium = 20.dp val progressIndicatorSmall = 14.dp diff --git a/core/designsystem/src/main/res/values/strings.xml b/core/designsystem/src/main/res/values/strings.xml index a53e2e0..16e3c6c 100644 --- a/core/designsystem/src/main/res/values/strings.xml +++ b/core/designsystem/src/main/res/values/strings.xml @@ -15,4 +15,5 @@ Configure button mapping Auto setup might not work on every device. If the emulator doesn\'t respond to Joy-Con input after setting up, configure the controller manually in the emulator\'s own settings instead. Delete + Warning From 2daf10056b2d7608366869c7814dc7ef6ed1dba2 Mon Sep 17 00:00:00 2001 From: Joe Barker Date: Tue, 22 Sep 2026 20:21:56 +0100 Subject: [PATCH 04/16] Land Mario Kart tricks, by shaking the accelerometer A trick is a flick, and a flick of something Joy-Con sized is mostly rotation: captured ones peak past 1200 deg/s summed while carrying barely a g of linear jerk, where jerking a real Wii Wheel throws the whole thing. Mario Kart Wii has no MotionPlus and reads only the accelerometer, so the flick never arrives -- on hardware it wouldn't either. The gyroscope fires the trick instead, which hardware could not do. Two ways of delivering it were ruled out on the way, and both are written down so they are not tried a third time. Amplifying the accelerometer's own transient buys nothing: an emulated remote saturates around +3.9/-4.9 g (ACCEL_ZERO_G 0x80, ACCEL_ONE_G 0x9A over 8 bits), so a bigger number only clips sooner. Dolphin's Shake group does nothing either -- bound straight to a key in Dolphin's own config, a full 7 g oscillation of it never once landed a trick -- even though its acceleration is added unconditionally and plainly reaches the game. What did land one by hand was shaking a Joy-Con hard for about a second, so the synthetic trick copies that shape rather than a push: an oscillation held for 0.6 s at about 6.7 Hz across every IMUAccelerometer input, the opposites carrying half a turn of phase so the remote is thrown back and forth instead of leaned on. The accelerometer is the one path known to reach the game, since steering is read from it. A rate alone cannot tell a flick from a turn, because steering a lone Joy-Con held as a wheel *is* rotation; a pair steers from the Nunchuk's stick with its remote hand still, which is why only single Joy-Cons suffered for it. The trigger subtracts a slew limiter, leaving only what climbs faster than the limiter can follow: it catches the sharpest measured steering (6.5 rad/s) within a seventh of a second and leaves nothing behind, while a flick's 40 ms rise to 21 outruns it almost untouched. Until it did, a firm turn fired a trick and dumped the shake onto the very accelerometer the wheel is read from, which made steering lurch and swallowed the next flick with it. Shake is a mapping target of its own, so any body can trick from a button, which was a pair's only route before it learned to flick. It sits below the sticks in the editor, being a motion of the remote rather than a button on it. Mario Kart takes it on SR for a lone Joy-Con -- the shoulder that already hops, so the finger that jumps is the finger that tricks -- and on R for a pair, whose index fingers move onto the shoulders their hands' controllers keep a trigger on. Reset to defaults goes with all this: picking the console's own layout has done the same job since layouts arrived. Co-Authored-By: Claude Opus 5 --- README.md | 17 ++-- .../com/joegec/joycon2android/AppContainer.kt | 2 - .../com/joegec/joycon2android/MainActivity.kt | 2 - .../ResetControllerMappingUseCase.kt | 9 -- .../preset/MarioKartWiiMapping.kt | 22 ++++- .../buttonmapping/target/WiimoteButton.kt | 7 +- .../buttonmapping/MappingFixture.kt | 1 - .../buttonmapping/PlayerMappingTest.kt | 11 --- .../buttonmapping/preset/WiiPresetsTest.kt | 35 ++++++- .../ControllerMappingViewModel.kt | 4 - .../presentation/MappingActions.kt | 1 - .../presentation/MappingBindings.kt | 15 +-- .../presentation/MappingOptions.kt | 17 +++- .../src/main/res/values/strings.xml | 5 +- docs/dsu-motion.md | 51 +++++++--- .../dsu/emulator/DolphinWiimoteConfig.kt | 96 ++++++++++++++----- .../dsu/emulator/DolphinWiimoteConfigTest.kt | 80 +++++++++++++--- 17 files changed, 268 insertions(+), 107 deletions(-) delete mode 100644 core/buttonmapping/domain/src/main/kotlin/com/joegec/joycon2android/buttonmapping/ResetControllerMappingUseCase.kt diff --git a/README.md b/README.md index 69e9049..eea21aa 100644 --- a/README.md +++ b/README.md @@ -194,12 +194,17 @@ shoulder buttons. - **Turn the four D-pad bindings a quarter**: put the source you'd bind to Up on **D-Pad/Right**, Right on Down, Down on Left, Left on Up. A sideways remote's d-pad turns with it, so the player's up is the remote's right. - - For **tricks**, amplify each accelerometer input's transient — write - ``\`Accel Up\` + (\`Accel Up\` - smooth(\`Accel Up\`, 0.03)) * 2`` in place of ``\`Accel Up\``, - and so on for all six. A Joy-Con flick lands a fraction of the jerk a Wii Wheel does; this lifts - it without moving the gravity that steers. Flick in the **plane of the wheel** — an upward jab - of a flat-held Joy-Con goes along the axle, which has no trick - ([why](docs/dsu-motion.md#dolphin-wii-remote-mapping)). + - For **tricks**, append + ``+ pulse(deadzone((), 0.2), 0.6) * sin(timer(0.15) * 6.2832) * 50`` to each + **IMUAccelerometer** input, where `` is + ``( - smooth(, 0.02)) / 15`` and `` is + ``(\`Gyro Pitch Up\` + \`Gyro Pitch Down\` + \`Gyro Roll Left\` + \`Gyro Roll Right\` + \`Gyro Yaw Left\` + \`Gyro Yaw Right\`)`` + — and add `+ 3.1416` inside the `sin` for Down, Right and Backward so they swing the other way. + A flick of a Joy-Con is nearly all rotation, which the game can't read from an accelerometer + alone, so the gyroscope shakes the accelerometer for you — and the `smooth` subtraction is what + keeps steering, which is also rotation, from setting it off + ([why](docs/dsu-motion.md#dolphin-wii-remote-mapping)). Don't use Dolphin's **Shake** group — + it doesn't land tricks. You can also bind **Shake** to a button. Leave Dolphin's own **Sideways Wii Remote** option off either way — it would turn the accelerometer a second quarter. diff --git a/app/src/main/java/com/joegec/joycon2android/AppContainer.kt b/app/src/main/java/com/joegec/joycon2android/AppContainer.kt index a29eb5d..923ebb6 100644 --- a/app/src/main/java/com/joegec/joycon2android/AppContainer.kt +++ b/app/src/main/java/com/joegec/joycon2android/AppContainer.kt @@ -28,7 +28,6 @@ import com.joegec.joycon2android.buttonmapping.ObserveGlobalMappingUseCase import com.joegec.joycon2android.buttonmapping.ObserveSavedLayoutsUseCase import com.joegec.joycon2android.buttonmapping.ObservePlayerMappingUseCase import com.joegec.joycon2android.buttonmapping.ObserveSidewaysRemoteUseCase -import com.joegec.joycon2android.buttonmapping.ResetControllerMappingUseCase import com.joegec.joycon2android.buttonmapping.SaveCustomLayoutUseCase import com.joegec.joycon2android.buttonmapping.SaveGlobalLayoutUseCase import com.joegec.joycon2android.buttonmapping.SavedLayoutDataStore @@ -132,7 +131,6 @@ class AppContainer(context: Context) { val setControllerMapping = SetControllerMappingUseCase(controllerMappingRepository) val setSidewaysRemote = SetSidewaysRemoteUseCase(sidewaysRemoteRepository) val applyMappingLayout = ApplyMappingLayoutUseCase(savedLayoutRepository, applyPlayerMapping) - val resetControllerMapping = ResetControllerMappingUseCase(applyMappingLayout) val applyGlobalLayout = ApplyGlobalLayoutUseCase(globalLayoutRepository, applyMappingLayout, applyPlayerMapping) val saveCustomLayout = SaveCustomLayoutUseCase(savedLayoutRepository, observePlayerMapping) diff --git a/app/src/main/java/com/joegec/joycon2android/MainActivity.kt b/app/src/main/java/com/joegec/joycon2android/MainActivity.kt index d13dbfd..43312a9 100644 --- a/app/src/main/java/com/joegec/joycon2android/MainActivity.kt +++ b/app/src/main/java/com/joegec/joycon2android/MainActivity.kt @@ -97,7 +97,6 @@ class MainActivity : ComponentActivity() { c.applyMappingLayout, c.applyGlobalLayout, c.setControllerMapping, - c.resetControllerMapping, c.setSidewaysRemote, c.saveCustomLayout, c.saveGlobalLayout, @@ -219,7 +218,6 @@ class MainActivity : ComponentActivity() { setMapping = { body, targetKey, sourceId -> controllerMappingViewModel.setMapping(body, targetKey, sourceId) }, - resetMapping = { controllerMappingViewModel.resetMapping(it) }, setSidewaysRemote = { body, enabled -> controllerMappingViewModel.setSidewaysRemoteEnabled(body, enabled) }, diff --git a/core/buttonmapping/domain/src/main/kotlin/com/joegec/joycon2android/buttonmapping/ResetControllerMappingUseCase.kt b/core/buttonmapping/domain/src/main/kotlin/com/joegec/joycon2android/buttonmapping/ResetControllerMappingUseCase.kt deleted file mode 100644 index 9a24280..0000000 --- a/core/buttonmapping/domain/src/main/kotlin/com/joegec/joycon2android/buttonmapping/ResetControllerMappingUseCase.kt +++ /dev/null @@ -1,9 +0,0 @@ -package com.joegec.joycon2android.buttonmapping - -import com.joegec.joycon2android.buttonmapping.preset.MappingPresets - -/** Back to the console's own layout, whatever the body had become. */ -class ResetControllerMappingUseCase(private val applyMappingLayout: ApplyMappingLayoutUseCase) { - suspend operator fun invoke(console: Console, body: PlayerBody) = - applyMappingLayout(console, body, MappingPresets.default(console).id) -} diff --git a/core/buttonmapping/domain/src/main/kotlin/com/joegec/joycon2android/buttonmapping/preset/MarioKartWiiMapping.kt b/core/buttonmapping/domain/src/main/kotlin/com/joegec/joycon2android/buttonmapping/preset/MarioKartWiiMapping.kt index fee8b9b..cf3efbe 100644 --- a/core/buttonmapping/domain/src/main/kotlin/com/joegec/joycon2android/buttonmapping/preset/MarioKartWiiMapping.kt +++ b/core/buttonmapping/domain/src/main/kotlin/com/joegec/joycon2android/buttonmapping/preset/MarioKartWiiMapping.kt @@ -10,9 +10,11 @@ import com.joegec.joycon2android.model.JoyconButton.B import com.joegec.joycon2android.model.JoyconButton.Capture import com.joegec.joycon2android.model.JoyconButton.Down import com.joegec.joycon2android.model.JoyconButton.Home +import com.joegec.joycon2android.model.JoyconButton.L import com.joegec.joycon2android.model.JoyconButton.Left import com.joegec.joycon2android.model.JoyconButton.Minus import com.joegec.joycon2android.model.JoyconButton.Plus +import com.joegec.joycon2android.model.JoyconButton.R import com.joegec.joycon2android.model.JoyconButton.Right import com.joegec.joycon2android.model.JoyconButton.SlLeft import com.joegec.joycon2android.model.JoyconButton.SlRight @@ -28,7 +30,8 @@ import com.joegec.joycon2android.model.JoyconButton.Y * which a sideways body already steers from its stick, so SL fires it too — the shoulder that * throws in Mario Kart 8. * - * A pair keeps the [WiiMapping] layout: held two-handed there is no sideways grip to match. + * A pair keeps the [WiiMapping] layout's shape — held two-handed there is no sideways grip to match + * — but moves the jobs an index finger does onto the shoulders that finger already rests on. * * It is also the layout that plays as a sideways Wii Remote, which is what the wheel steers by. */ @@ -39,10 +42,23 @@ object MarioKartWiiMapping : MappingPreset { override val sidewaysRemote = true override fun entries(side: JoyconSide) = when (side) { - JoyconSide.DUAL -> WiiMapping.entries(side) + JoyconSide.DUAL -> WiiMapping.entries(side) + pairButtons() else -> buttons(side).buttonEntries() + dPadSticks(side).sourceEntries() } + // Held as a remote and a nunchuk, each index finger rests on that hand's shoulder, which is + // where the controller it stands in for keeps its trigger: the remote's B on the right, the + // Nunchuk's Z on the left. Hopping also keeps the Joy-Con's own B, so either the thumb or the + // index finger can do it. The trick rides the same shoulder as the hop, as SR does on a lone + // Joy-Con, so the finger that jumps is the finger that tricks. + private fun pairButtons(): Map = mapOf( + WiimoteButton.B to listOf(R, B), + WiimoteButton.Shake to listOf(R), + WiimoteButton.Minus to listOf(Minus), + WiimoteButton.NunchukC to listOf(X), + WiimoteButton.NunchukZ to listOf(L), + ).mapValues { (_, buttons) -> buttons.map(MappingSource::Button) }.sourceEntries() + private fun buttons(side: JoyconSide): Map = when (side) { JoyconSide.LEFT -> mapOf( WiimoteButton.A to Right, @@ -52,6 +68,7 @@ object MarioKartWiiMapping : MappingPreset { WiimoteButton.Home to Capture, WiimoteButton.Plus to Minus, WiimoteButton.Minus to Up, + WiimoteButton.Shake to SrLeft, ) else -> mapOf( WiimoteButton.A to Y, @@ -61,6 +78,7 @@ object MarioKartWiiMapping : MappingPreset { WiimoteButton.Home to Home, WiimoteButton.Plus to Plus, WiimoteButton.Minus to B, + WiimoteButton.Shake to SrRight, ) } diff --git a/core/buttonmapping/domain/src/main/kotlin/com/joegec/joycon2android/buttonmapping/target/WiimoteButton.kt b/core/buttonmapping/domain/src/main/kotlin/com/joegec/joycon2android/buttonmapping/target/WiimoteButton.kt index 7e394da..5ef32e1 100644 --- a/core/buttonmapping/domain/src/main/kotlin/com/joegec/joycon2android/buttonmapping/target/WiimoteButton.kt +++ b/core/buttonmapping/domain/src/main/kotlin/com/joegec/joycon2android/buttonmapping/target/WiimoteButton.kt @@ -1,6 +1,10 @@ package com.joegec.joycon2android.buttonmapping.target -/** A Wii Remote's own buttons plus its Nunchuk's two buttons. */ +/** + * A Wii Remote's own buttons, its Nunchuk's two, and the one thing on it that is not a button at + * all: [Shake], the jerk of the remote that a game like Mario Kart Wii reads as a trick. It sits + * here because the editor binds sources to it exactly as it does to a button. + */ enum class WiimoteButton(val displayName: String) { A("A"), B("B"), @@ -15,4 +19,5 @@ enum class WiimoteButton(val displayName: String) { DPadRight("D-Pad Right"), NunchukC("Nunchuk C"), NunchukZ("Nunchuk Z"), + Shake("Shake"), } diff --git a/core/buttonmapping/domain/src/test/kotlin/com/joegec/joycon2android/buttonmapping/MappingFixture.kt b/core/buttonmapping/domain/src/test/kotlin/com/joegec/joycon2android/buttonmapping/MappingFixture.kt index 0d6afcf..ac3c998 100644 --- a/core/buttonmapping/domain/src/test/kotlin/com/joegec/joycon2android/buttonmapping/MappingFixture.kt +++ b/core/buttonmapping/domain/src/test/kotlin/com/joegec/joycon2android/buttonmapping/MappingFixture.kt @@ -16,7 +16,6 @@ internal class MappingFixture(val console: Console = Console.WIIMOTE_NUNCHUK) { val observePlayerMapping = ObservePlayerMappingUseCase(observeMapping, observeSideways, savedLayouts) val applyLayout = ApplyMappingLayoutUseCase(savedLayouts, applyPlayerMapping) - val resetMapping = ResetControllerMappingUseCase(applyLayout) val setMapping = SetControllerMappingUseCase(mappings) val setSidewaysRemote = SetSidewaysRemoteUseCase(sidewaysRemotes) val saveCustomLayout = SaveCustomLayoutUseCase(savedLayouts, observePlayerMapping) diff --git a/core/buttonmapping/domain/src/test/kotlin/com/joegec/joycon2android/buttonmapping/PlayerMappingTest.kt b/core/buttonmapping/domain/src/test/kotlin/com/joegec/joycon2android/buttonmapping/PlayerMappingTest.kt index e4c3fe7..63f04e5 100644 --- a/core/buttonmapping/domain/src/test/kotlin/com/joegec/joycon2android/buttonmapping/PlayerMappingTest.kt +++ b/core/buttonmapping/domain/src/test/kotlin/com/joegec/joycon2android/buttonmapping/PlayerMappingTest.kt @@ -58,17 +58,6 @@ class PlayerMappingTest { assertEquals(WiiMapping.id, fixture.playerMapping(other).layout?.id) } - @Test - fun `resetting puts the body back on the console's own layout`() = runBlocking { - fixture.applyLayout(fixture.console, body, MarioKartWiiMapping.id) - - fixture.resetMapping(fixture.console, body) - - val mapping = fixture.playerMapping(body) - assertEquals(WiiMapping.id, mapping.layout?.id) - assertFalse(mapping.sidewaysRemote) - } - @Test fun `saving names what the player built and offers it to that body`() = runBlocking { fixture.applyLayout(fixture.console, body, MarioKartWiiMapping.id) diff --git a/core/buttonmapping/domain/src/test/kotlin/com/joegec/joycon2android/buttonmapping/preset/WiiPresetsTest.kt b/core/buttonmapping/domain/src/test/kotlin/com/joegec/joycon2android/buttonmapping/preset/WiiPresetsTest.kt index 48d2983..2d34629 100644 --- a/core/buttonmapping/domain/src/test/kotlin/com/joegec/joycon2android/buttonmapping/preset/WiiPresetsTest.kt +++ b/core/buttonmapping/domain/src/test/kotlin/com/joegec/joycon2android/buttonmapping/preset/WiiPresetsTest.kt @@ -63,13 +63,42 @@ class WiiPresetsTest { } @Test - fun `a pair has no sideways grip to match, so Mario Kart leaves it on the Wii layout`() { - assertEquals(WiiMapping.entries(JoyconSide.DUAL), MarioKartWiiMapping.entries(JoyconSide.DUAL)) + fun `Mario Kart moves a pair's index fingers onto the shoulders, and tricks from one`() { + val pair = MarioKartWiiMapping.entries(JoyconSide.DUAL) + + // The remote's trigger hand, and the Joy-Con's own B so either finger can hop. + assertEquals("R|B", pair.getValue(WiimoteButton.B.name)) + assertEquals("L", pair.getValue(WiimoteButton.NunchukZ.name)) // the Nunchuk's + assertEquals("X", pair.getValue(WiimoteButton.NunchukC.name)) + assertEquals("Minus", pair.getValue(WiimoteButton.Minus.name)) + assertEquals("R", pair.getValue(WiimoteButton.Shake.name)) // the finger that hops also tricks + } + + @Test + fun `a pair has no sideways grip to match, so the rest stays the Wii layout`() { + val untouched = WiiMapping.entries(JoyconSide.DUAL) - MarioKartWiiMapping.entries(JoyconSide.DUAL).keys + + assertTrue(untouched.isEmpty()) + assertEquals( + WiiMapping.entries(JoyconSide.DUAL).getValue(WiimoteButton.A.name), + MarioKartWiiMapping.entries(JoyconSide.DUAL).getValue(WiimoteButton.A.name), + ) + } + + @Test + fun `Mario Kart tricks off SR on a lone Joy-Con, the shoulder that already hops`() { + JoyconSide.entries.filterNot { it == JoyconSide.DUAL }.forEach { side -> + val lone = MarioKartWiiMapping.entries(side) + + assertEquals("$side", lone.getValue(WiimoteButton.B.name), lone.getValue(WiimoteButton.Shake.name)) + } } @Test fun `every Wii layout binds the whole remote on a lone Joy-Con`() { - val remote = (WiimoteButton.entries - WiimoteButton.NunchukC - WiimoteButton.NunchukZ).map { it.name } + // Shake is a motion of the remote rather than a button on it, so no layout owes it a source. + val remote = (WiimoteButton.entries - WiimoteButton.NunchukC - WiimoteButton.NunchukZ - + WiimoteButton.Shake).map { it.name } MappingPresets.forConsole(Console.WIIMOTE_NUNCHUK).forEach { preset -> assertTrue("${preset.displayName} binds the remote", preset.entries(JoyconSide.RIGHT).keys.containsAll(remote)) diff --git a/core/buttonmapping/presentation/src/main/kotlin/com/joegec/joycon2android/buttonmapping/presentation/ControllerMappingViewModel.kt b/core/buttonmapping/presentation/src/main/kotlin/com/joegec/joycon2android/buttonmapping/presentation/ControllerMappingViewModel.kt index cf18119..ddc0906 100644 --- a/core/buttonmapping/presentation/src/main/kotlin/com/joegec/joycon2android/buttonmapping/presentation/ControllerMappingViewModel.kt +++ b/core/buttonmapping/presentation/src/main/kotlin/com/joegec/joycon2android/buttonmapping/presentation/ControllerMappingViewModel.kt @@ -10,7 +10,6 @@ import com.joegec.joycon2android.buttonmapping.DeleteGlobalLayoutUseCase import com.joegec.joycon2android.buttonmapping.ObserveGlobalMappingUseCase import com.joegec.joycon2android.buttonmapping.ObserveSavedLayoutsUseCase import com.joegec.joycon2android.buttonmapping.PlayerBody -import com.joegec.joycon2android.buttonmapping.ResetControllerMappingUseCase import com.joegec.joycon2android.buttonmapping.SaveCustomLayoutUseCase import com.joegec.joycon2android.buttonmapping.SaveGlobalLayoutUseCase import com.joegec.joycon2android.buttonmapping.SetControllerMappingUseCase @@ -34,7 +33,6 @@ class ControllerMappingViewModel( private val applyMappingLayout: ApplyMappingLayoutUseCase, private val applyGlobalLayout: ApplyGlobalLayoutUseCase, private val setControllerMapping: SetControllerMappingUseCase, - private val resetControllerMapping: ResetControllerMappingUseCase, private val setSidewaysRemote: SetSidewaysRemoteUseCase, private val saveCustomLayout: SaveCustomLayoutUseCase, private val saveGlobalLayout: SaveGlobalLayoutUseCase, @@ -64,8 +62,6 @@ class ControllerMappingViewModel( setControllerMapping(it.console, body, targetKey, sourceId) } - fun resetMapping(body: PlayerBody) = onTarget { resetControllerMapping(it.console, body) } - fun setSidewaysRemoteEnabled(body: PlayerBody, enabled: Boolean) = onTarget { setSidewaysRemote(it.console, body, enabled) } diff --git a/core/buttonmapping/presentation/src/main/kotlin/com/joegec/joycon2android/buttonmapping/presentation/MappingActions.kt b/core/buttonmapping/presentation/src/main/kotlin/com/joegec/joycon2android/buttonmapping/presentation/MappingActions.kt index b5b6d13..c451794 100644 --- a/core/buttonmapping/presentation/src/main/kotlin/com/joegec/joycon2android/buttonmapping/presentation/MappingActions.kt +++ b/core/buttonmapping/presentation/src/main/kotlin/com/joegec/joycon2android/buttonmapping/presentation/MappingActions.kt @@ -11,6 +11,5 @@ class MappingActions( val saveLayout: (body: PlayerBody?, name: String) -> Unit, val deleteLayout: (layoutId: String, global: Boolean) -> Unit, val setMapping: (body: PlayerBody, targetKey: String, sourceId: String) -> Unit, - val resetMapping: (body: PlayerBody) -> Unit, val setSidewaysRemote: (body: PlayerBody, enabled: Boolean) -> Unit, ) diff --git a/core/buttonmapping/presentation/src/main/kotlin/com/joegec/joycon2android/buttonmapping/presentation/MappingBindings.kt b/core/buttonmapping/presentation/src/main/kotlin/com/joegec/joycon2android/buttonmapping/presentation/MappingBindings.kt index 64ff909..9bf8feb 100644 --- a/core/buttonmapping/presentation/src/main/kotlin/com/joegec/joycon2android/buttonmapping/presentation/MappingBindings.kt +++ b/core/buttonmapping/presentation/src/main/kotlin/com/joegec/joycon2android/buttonmapping/presentation/MappingBindings.kt @@ -5,15 +5,12 @@ import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.material3.Text -import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.res.stringResource import com.joegec.joycon2android.buttonmapping.Console import com.joegec.joycon2android.buttonmapping.sourceIdOf import com.joegec.joycon2android.buttonmapping.sourceIdsOf -import com.joegec.joycon2android.core.buttonmapping.presentation.R import com.joegec.joycon2android.ui.components.MultiSelectDropdown import com.joegec.joycon2android.ui.theme.Dimens import com.joegec.joycon2android.ui.theme.TextDim @@ -23,15 +20,11 @@ import com.joegec.joycon2android.ui.theme.TextDim fun MappingBindings(console: Console, state: PlayerMappingUiState, actions: MappingActions) { Column(verticalArrangement = Arrangement.spacedBy(Dimens.elementSpacing)) { val sourceOptions = MappingOptions.sources(state.body.side) - (MappingOptions.buttonTargets(console) + MappingOptions.stickDirectionTargets(console)) - .forEach { (key, label) -> - val selectedIds = sourceIdsOf(state.mapping[key].orEmpty()) - BindingRow(label, selectedIds, sourceOptions) { toggled -> - actions.setMapping(state.body, key, sourceIdOf(selectedIds.toggling(toggled))) - } + MappingOptions.targets(console).forEach { (key, label) -> + val selectedIds = sourceIdsOf(state.mapping[key].orEmpty()) + BindingRow(label, selectedIds, sourceOptions) { toggled -> + actions.setMapping(state.body, key, sourceIdOf(selectedIds.toggling(toggled))) } - TextButton(onClick = { actions.resetMapping(state.body) }) { - Text(stringResource(R.string.controller_mapping_reset)) } } } diff --git a/core/buttonmapping/presentation/src/main/kotlin/com/joegec/joycon2android/buttonmapping/presentation/MappingOptions.kt b/core/buttonmapping/presentation/src/main/kotlin/com/joegec/joycon2android/buttonmapping/presentation/MappingOptions.kt index 0ec1da2..beb7e75 100644 --- a/core/buttonmapping/presentation/src/main/kotlin/com/joegec/joycon2android/buttonmapping/presentation/MappingOptions.kt +++ b/core/buttonmapping/presentation/src/main/kotlin/com/joegec/joycon2android/buttonmapping/presentation/MappingOptions.kt @@ -22,13 +22,24 @@ internal object MappingOptions { fun offersSidewaysRemote(console: Console, side: JoyconSide) = console == Console.WIIMOTE_NUNCHUK && side != JoyconSide.DUAL - fun buttonTargets(console: Console): List> = when (console) { + /** Every row the editor offers, in reading order: buttons, then sticks, then what is neither. */ + fun targets(console: Console): List> = + buttonTargets(console) + stickDirectionTargets(console) + motionTargets(console) + + private fun buttonTargets(console: Console): List> = when (console) { Console.GAMECUBE -> GameCubeButton.entries.map { it.name to it.displayName } - Console.WIIMOTE_NUNCHUK -> WiimoteButton.entries.map { it.name to it.displayName } + Console.WIIMOTE_NUNCHUK -> (WiimoteButton.entries - MOTION_TARGETS).map { it.name to it.displayName } Console.SWITCH_PRO -> SwitchProButton.entries.map { it.name to it.displayName } } - fun stickDirectionTargets(console: Console): List> { + // Shaking the remote is a motion of it rather than a button on it, so it sits below the sticks + // instead of among the face buttons. + private val MOTION_TARGETS = setOf(WiimoteButton.Shake) + + private fun motionTargets(console: Console): List> = + if (console == Console.WIIMOTE_NUNCHUK) MOTION_TARGETS.map { it.name to it.displayName } else emptyList() + + private fun stickDirectionTargets(console: Console): List> { val sticks = when (console) { Console.GAMECUBE -> GameCubeStick.entries.map { it to it.displayName } Console.WIIMOTE_NUNCHUK -> WiimoteStick.entries.map { it to it.displayName } diff --git a/core/buttonmapping/presentation/src/main/res/values/strings.xml b/core/buttonmapping/presentation/src/main/res/values/strings.xml index b8c1442..c85c7ab 100644 --- a/core/buttonmapping/presentation/src/main/res/values/strings.xml +++ b/core/buttonmapping/presentation/src/main/res/values/strings.xml @@ -13,10 +13,9 @@ Delete Connect a controller to map it. Sideways Wii Remote - Rotates the d-pad and flicks are amplified. - Steering reads correctly, the D-pad turns with it, and flicks are amplified. + Rotates the d-pad, and a flick counts as a shake. + Steering reads correctly, the D-pad turns with it, and a flick counts as a shake. When enabled, a right Joy-Con points from its tail rather than its R edge. - Reset to defaults P%1$d Left Right diff --git a/docs/dsu-motion.md b/docs/dsu-motion.md index e2056e2..164d781 100644 --- a/docs/dsu-motion.md +++ b/docs/dsu-motion.md @@ -65,17 +65,46 @@ if the Joy-Con's nose pointed at the screen. remote's right. That is Dolphin's own `dpad_sideways_bitmasks`, applied here so its *Sideways Wii Remote* option can stay off — the option would also turn the accelerometer, which we have turned already. -- **A sideways layout amplifies the flick, for tricks.** Mario Kart Wii has four tricks and picks - between them by the *direction* of the flick, read from the accelerometer alone (no MotionPlus), - so nothing synthetic serves: Dolphin's `Shake` group is one axis and symmetric, and fires whichever - trick that axis happens to mean. The real jerk is amplified instead — `smooth()` is a slew limiter, - so subtracting it leaves what gravity is not, and adding that back over again lifts a flick while - leaving the gravity that steers and settles the pointer alone. Measured: a flick carries 1.6–3.6 g - against 0.35 g for the sharpest steering, so doubling the transient keeps them well apart. -- **A flick has to land in the plane of the wheel.** Captured flicks went along the *axle* five times - in six — the player held the Joy-Con nearly flat (31–38° off vertical) and flicked upward, which - pushes along the face normal, a direction the game has no trick for. Hardware wouldn't trick off - that either. Held like a wheel, up/down/left/right flicks fall in the plane the game reads. +- **A sideways body turns a flick into a trick.** Mario Kart Wii tricks off a flick, and a flick of + something Joy-Con sized is mostly rotation: captured ones peak past 1200 °/s summed while carrying + barely a g of linear jerk, where jerking a real Wii Wheel throws the whole thing. The game has no + MotionPlus and reads only the accelerometer, so the flick never reaches it — on hardware it + wouldn't either. The gyroscope therefore fires it, which hardware could not do: each axis summed + with its opposite input gives |rate| (Dolphin clamps one of a pair at zero), over `/15` and a half + dead zone, which fires above 11 rad/s and leaves the sharpest measured steering (6.5) and aiming + (4.1) a wide berth. `Shake` is a mapping target of its own too, so a pair — which has no sideways + flick to read — can trick from a button. + + **Dolphin's own `Shake` group is not how it is delivered.** Bound straight to a key in Dolphin's + config, a full 7 g oscillation of it never once landed a trick (tested 2026-09), so the group is + not written at all. The accelerometer is the path that demonstrably reaches the game, since + steering is read from it, and the jerk goes there instead: + `pulse(deadzone(trigger, 0.2), 0.6) * sin(timer(0.15) * 2π) * 50` added to every + `IMUAccelerometer` input, the three opposites carrying a half-turn of phase. + + It is a *shake*, not a push: an oscillation held for 0.6 s at about 6.7 Hz, each input of a pair + swung half a cycle apart so the remote is thrown back and forth rather than leaned on. That shape + is what landed a trick by hand — shaking a Joy-Con hard for about a second — where a single held + push did not. Amplitude is not the lever: an emulated Wii Remote saturates around +3.9/−4.9 g + (`ACCEL_ZERO_G` 0x80, `ACCEL_ONE_G` 0x9A over 8 bits), which the 50 m/s² already passes, so a + bigger number only clips sooner. `pulse()` gives a flick and a held button the same shake however + long either lasted. + + **A rate alone cannot tell a flick from a turn**, because steering a lone Joy-Con held as a wheel + *is* rotation — which is why only single Joy-Cons suffered for it, a pair steering from the + Nunchuk's stick with its remote hand still. The trigger therefore subtracts a slew limiter, + `(rate − smooth(rate, 0.02)) / 15`, leaving only what climbs faster than the limiter can follow: + at 0.02 the tracker moves 50 rad/s, so it has caught the sharpest measured steering (6.5) within + about a seventh of a second and left nothing behind, while a flick's ~40 ms rise to 21 outruns it + almost untouched. A trick fired by accident costs nothing — the game only tricks a kart already + airborne — but one fired *while steering* costs plenty, since the shake lands on the very + accelerometer the wheel is read from. Every body flicks, a pair included: its remote hand is still while the Nunchuk's + stick steers. Only a layout that plays as a sideways remote flicks at all, so no other game is + handed a shake it never asked for when its remote is swung. + + Verified against Dolphin's source (2026-09): `|` is a max and binds looser than `/`; + `m_shake_state.acceleration` is added to the reported acceleration unconditionally, so binding + `IMUAccelerometer` does not disable the Shake group — it simply never produced a trick. - **Pointing and a wheel want the nose half a turn apart on a right Joy-Con**, and no Dolphin option bridges them: `GetOrientation()` turns a quarter (Sideways) or a quarter about the left axis (Upright), and it reaches only the accelerometer the game reads, never diff --git a/feature/dsu/domain/src/main/kotlin/com/joegec/joycon2android/dsu/emulator/DolphinWiimoteConfig.kt b/feature/dsu/domain/src/main/kotlin/com/joegec/joycon2android/dsu/emulator/DolphinWiimoteConfig.kt index 76d7768..a172916 100644 --- a/feature/dsu/domain/src/main/kotlin/com/joegec/joycon2android/dsu/emulator/DolphinWiimoteConfig.kt +++ b/feature/dsu/domain/src/main/kotlin/com/joegec/joycon2android/dsu/emulator/DolphinWiimoteConfig.kt @@ -131,30 +131,77 @@ object DolphinWiimoteConfig { private fun dolphinKey(target: WiimoteButton, sideways: Boolean): String = (if (sideways) SIDEWAYS_DPAD_KEYS[target] else null) ?: DOLPHIN_KEYS.getValue(target) - // Mario Kart Wii has four tricks and picks between them by the direction of the flick, read from - // the accelerometer alone since it has no MotionPlus. Nothing synthetic can carry that — a shake - // is one axis and symmetric — so the real jerk has to arrive big enough instead: a Joy-Con is a - // fraction of the mass a Wii Wheel throws, and a flick lands a fraction of the jerk with it. + // A trick is a flick, and a flick of something Joy-Con sized is mostly rotation: captured ones + // peak past 1200 deg/s summed while carrying barely a g of linear jerk, where jerking a real Wii + // Wheel throws the whole thing. Mario Kart Wii has no MotionPlus and reads only the + // accelerometer, so the flick never reaches it — on hardware it wouldn't either. The gyroscope + // therefore fires the trick, which hardware could not do. Summing each axis with its opposite + // input gives |rate|, since Dolphin clamps one of any pair at zero. // - // smooth() is a slew limiter, so subtracting it leaves what gravity is not, and adding that back - // over again amplifies the flick while leaving untouched the gravity the wheel steers by and the - // pointer settles against. Measured 2026-09: a flick carries 1.6 to 3.6 g, the sharpest steering - // 0.35 g, so doubling the transient keeps those a wheel's turn apart. - private const val TRICK_GAIN = 2 - private const val TRICK_SETTLE_SECONDS = 0.03 + // Dolphin's own Shake group is not the way to deliver it: bound straight to a key in Dolphin's + // config, a full 7 g oscillation of it never landed a trick (tested 2026-09). The accelerometer + // is the path that demonstrably reaches the game, since steering is read from it, so the jerk + // goes there — onto one input of each pair, a diagonal no axis can miss, with the opposites left + // alone so the pair cannot cancel it. + // + // It is a shake, not a push. What actually landed one (2026-09) was shaking a Joy-Con hard for + // about a second, so the synthetic trick copies that shape: an oscillation held for + // TRICK_SECONDS, with each input of a pair swung in antiphase so the remote is thrown back and + // forth rather than leaned on. Amplitude is not the lever — an emulated Wii Remote saturates + // around +3.9/-4.9 g (ACCEL_ZERO_G 0x80, ACCEL_ONE_G 0x9A over 8 bits), which + // TRICK_ACCELERATION already passes — so a bigger number only clips sooner. Duration and + // swinging are what a held push was missing, and pulse() gives a flick and a held button the + // same one however long either lasted. + // + // A rate alone cannot tell a flick from a turn, because steering a lone Joy-Con held as a wheel + // *is* rotation — which is why only single Joy-Cons suffered for it: a pair steers from the + // Nunchuk's stick with its remote hand still. Subtracting a slew limiter leaves only what climbs + // faster than the limiter can follow. At 0.02 the tracker moves 50 rad/s, so it has caught the + // sharpest measured steering (6.5) within about a seventh of a second and left nothing behind, + // while a flick's ~40 ms rise to 21 outruns it almost untouched. + // + // A trick fired by accident costs nothing — the game only tricks a kart already airborne — but a + // trick fired *while steering* costs plenty, since the shake below lands on the accelerometer the + // wheel is read from. Hence a discriminator rather than a bigger number. + private const val FLICK_RADIANS = 15 + private const val FLICK_SETTLE_SECONDS = 0.02 + private const val FLICK_DEAD_ZONE = 0.2 + private const val TRICK_ACCELERATION = 50 // m/s^2, past what an emulated remote can report + private const val TRICK_SECONDS = 0.6 + private const val TRICK_PERIOD_SECONDS = 0.15 + private const val FULL_TURN = 6.2832 + private const val HALF_TURN = 3.1416 + + // The three that lead; their opposites follow half a cycle later, which is the swing. + private val TRICK_LEADING = + setOf("IMUAccelerometer/Up", "IMUAccelerometer/Left", "IMUAccelerometer/Forward") + + /** + * What fires a trick: a flick, and whatever is bound to Shake. Every body flicks, a pair + * included — its remote hand is still while the Nunchuk's stick does the steering — but only + * while the layout plays as a sideways remote, so no other game is handed a shake it never asked + * for when its remote is swung. + */ + private fun shakeTrigger(side: JoyconSide, sidewaysRemote: Boolean, bound: List?): String? { + val rate = "(${GYRO_DIRECTIONS.joinToString(" + ") { "`Gyro $it`" }})" + val flick = if (sidewaysRemote) "($rate - smooth($rate, $FLICK_SETTLE_SECONDS)) / $FLICK_RADIANS" else null + return listOfNotNull(flick, bound?.let { expressionFor(side, it) }) + .takeIf { it.isNotEmpty() } + ?.joinToString(" | ") + } - private fun accelExpression(input: String, amplified: Boolean): String = - if (!amplified) "`$input`" - else "`$input` + (`$input` - smooth(`$input`, $TRICK_SETTLE_SECONDS)) * $TRICK_GAIN" + private fun trickShake(trigger: String?, control: String): String? { + if (trigger == null || !control.startsWith("IMUAccelerometer/")) return null + val phase = if (control in TRICK_LEADING) "" else " + $HALF_TURN" + return "pulse(deadzone(($trigger), $FLICK_DEAD_ZONE), $TRICK_SECONDS) * " + + "sin(timer($TRICK_PERIOD_SECONDS) * $FULL_TURN$phase) * $TRICK_ACCELERATION" + } - private fun imuLines(side: JoyconSide, sidewaysRemote: Boolean): List { + private fun imuLines(side: JoyconSide, sidewaysRemote: Boolean, trigger: String?): List { val bodyInputs = bodyInputs(side, sidewaysRemote) - val amplified = sidewaysRemote && side != JoyconSide.DUAL return IMU_CONTROLS.map { (control, input) -> - val read = bodyInputs[input] ?: input - val expression = - if (control.startsWith("IMUAccelerometer")) accelExpression(read, amplified) else "`$read`" - "$control = $expression" + val read = "`${bodyInputs[input] ?: input}`" + "$control = " + (trickShake(trigger, control)?.let { "$read + $it" } ?: read) } + listOf("IMUIR/Enabled = True", "IMUIR/Total Yaw = $IMU_TOTAL_YAW_DEGREES") } @@ -234,15 +281,18 @@ object DolphinWiimoteConfig { emptyList() } val sideways = sidewaysRemote && side != JoyconSide.DUAL - return (header + lines(side, sideways, mappingFor(body)) + imuLines(side, sidewaysRemote) + + val mapping = mappingFor(body) + val trigger = shakeTrigger(side, sidewaysRemote, mapping.toSourceMap()[WiimoteButton.Shake]) + return (header + lines(side, sideways, mapping) + imuLines(side, sidewaysRemote, trigger) + swingLines(side, sidewaysRemote) + nunchukImu) .joinToString("\n", postfix = "\n") } private fun lines(side: JoyconSide, sideways: Boolean, mapping: Map): List { - val buttonLines = mapping.toSourceMap().mapNotNull { (target, sources) -> - expressionFor(side, sources)?.let { expression -> "${dolphinKey(target, sideways)} = $expression" } - } + val buttonLines = (mapping.toSourceMap() - WiimoteButton.Shake) + .mapNotNull { (target, sources) -> + expressionFor(side, sources)?.let { expression -> "${dolphinKey(target, sideways)} = $expression" } + } val stickLines = nunchukStickLines(side, mapping) val recenterSpec = if (side == JoyconSide.LEFT) "L1" else "R1" val extension = if (usesNunchuk(side, buttonLines + stickLines)) "Nunchuk" else "None" diff --git a/feature/dsu/domain/src/test/kotlin/com/joegec/joycon2android/dsu/emulator/DolphinWiimoteConfigTest.kt b/feature/dsu/domain/src/test/kotlin/com/joegec/joycon2android/dsu/emulator/DolphinWiimoteConfigTest.kt index 0e700e8..5778753 100644 --- a/feature/dsu/domain/src/test/kotlin/com/joegec/joycon2android/dsu/emulator/DolphinWiimoteConfigTest.kt +++ b/feature/dsu/domain/src/test/kotlin/com/joegec/joycon2android/dsu/emulator/DolphinWiimoteConfigTest.kt @@ -8,6 +8,7 @@ import com.joegec.joycon2android.model.ConnectedJoycon import com.joegec.joycon2android.model.PlayerNumber import com.joegec.joycon2android.model.PlayerState import com.joegec.joycon2android.model.Side +import org.junit.Assert.assertEquals import org.junit.Assert.assertFalse import org.junit.Assert.assertTrue import org.junit.Test @@ -16,6 +17,8 @@ private fun defaultWiimoteMapping(side: JoyconSide) = MappingPresets.default(Con private val wiimoteMapping: (PlayerBody) -> Map = { defaultWiimoteMapping(it.side) } +private fun wiimoteMappingFor(body: PlayerBody) = defaultWiimoteMapping(body.side) + class DolphinWiimoteConfigTest { private fun joycon(side: Side) = ConnectedJoycon(address = side.name, side = side, deviceName = "Joy-Con") @@ -175,8 +178,8 @@ class DolphinWiimoteConfigTest { assertTrue(result.contains("IMUAccelerometer/Forward = `Accel Left`")) assertTrue(result.contains("IMUGyroscope/Pitch Up = `Gyro Roll Right`")) } - // ...though only the sideways one amplifies its flick. - assertFalse(merge(null, player).contains("smooth(`Accel")) + // ...though only the sideways one turns its flick into a trick. + assertFalse(merge(null, player).contains("pulse(")) } @Test @@ -185,8 +188,8 @@ class DolphinWiimoteConfigTest { val result = merge(null, player, sidewaysRemote = true) - assertTrue(result.contains("IMUAccelerometer/Up = `Accel Up` +")) - assertTrue(result.contains("IMUAccelerometer/Forward = `Accel Left` +")) + assertTrue(result.contains("IMUAccelerometer/Up = `Accel Up`")) + assertTrue(result.contains("IMUAccelerometer/Forward = `Accel Left`")) assertTrue(result.contains("IMUGyroscope/Pitch Up = `Gyro Roll Right`")) assertTrue(result.contains("IMUGyroscope/Yaw Left = `Gyro Yaw Left`")) } @@ -216,27 +219,76 @@ class DolphinWiimoteConfigTest { assertTrue(result.contains("D-Pad/Up = `Pad N`")) } - // Tricks pick a direction out of the flick itself, so the real jerk is amplified rather than - // replaced by anything synthetic — gravity, which steers and settles the pointer, is untouched. + // A flick is nearly all rotation, which the game cannot read, so the trick is fired from the + // gyroscope — and delivered through the accelerometer, the path steering proves reaches the game. @Test - fun `playing sideways amplifies the flick, not the gravity under it`() { + fun `playing sideways turns a wrist flick into a trick`() { val result = merge(null, listOf(PlayerState(PlayerNumber.P1, right = joycon(Side.RIGHT))), sidewaysRemote = true) + val rate = "(`Gyro Pitch Up` + `Gyro Pitch Down` + `Gyro Roll Left` + `Gyro Roll Right` + " + + "`Gyro Yaw Left` + `Gyro Yaw Right`)" + val flick = "($rate - smooth($rate, 0.02)) / 15" assertTrue( result.contains( - "IMUAccelerometer/Forward = `Accel Left` + (`Accel Left` - smooth(`Accel Left`, 0.03)) * 2", + "IMUAccelerometer/Up = `Accel Up` + pulse(deadzone(($flick), 0.2), 0.6) * " + + "sin(timer(0.15) * 6.2832) * 50", ), ) - assertTrue(result.contains("IMUGyroscope/Pitch Up = `Gyro Roll Right`")) // gyroscope passes through + assertFalse(result.contains("Shake/")) // Dolphin's own group never landed one } + // Steering a lone Joy-Con held as a wheel is itself rotation, so only what outruns the tracker + // counts as a flick — otherwise a firm turn shakes the accelerometer the wheel is read from. @Test - fun `nothing is amplified unless the layout plays sideways`() { - val lone = merge(null, listOf(PlayerState(PlayerNumber.P1, right = joycon(Side.RIGHT)))) - val pair = PlayerState(PlayerNumber.P1, left = joycon(Side.LEFT), right = joycon(Side.RIGHT)) + fun `a sustained turn is subtracted out of the flick`() { + val result = merge(null, listOf(PlayerState(PlayerNumber.P1, right = joycon(Side.RIGHT))), sidewaysRemote = true) + + assertTrue(result.contains("- smooth((`Gyro Pitch Up`")) + } + + // What landed a trick by hand was a hard shake, so opposite inputs swing half a cycle apart + // rather than one being leaned on. + @Test + fun `the trick swings every input, opposites in antiphase`() { + val result = merge(null, listOf(PlayerState(PlayerNumber.P1, right = joycon(Side.RIGHT))), sidewaysRemote = true) + + listOf("Up", "Left", "Forward").forEach { + assertTrue(it, result.contains("IMUAccelerometer/$it = `Accel") && result.contains("* 6.2832) * 50")) + } + listOf("Down", "Right", "Backward").forEach { + assertTrue(it, result.contains("* 6.2832 + 3.1416) * 50")) + } + assertEquals(6, result.split("pulse(").size - 1) + } + + // Its remote hand is still while the Nunchuk's stick steers, so a pair can flick for a trick too. + @Test + fun `a pair flicks for a trick as well, once the layout plays as a sideways remote`() { + val pair = listOf(PlayerState(PlayerNumber.P1, left = joycon(Side.LEFT), right = joycon(Side.RIGHT))) + + val result = DolphinWiimoteConfig.merge(null, pair, { true }, ::wiimoteMappingFor) + + assertTrue(result.contains("pulse(deadzone((((`Gyro Pitch Up`")) + + // ...but its motion frame is untouched, since it is already held like a remote. + assertTrue(result.contains("IMUAccelerometer/Forward = `Accel Forward` +")) + } + + @Test + fun `a bound source tricks without a flick, so any body can trick at all`() { + val pair = listOf(PlayerState(PlayerNumber.P1, left = joycon(Side.LEFT), right = joycon(Side.RIGHT))) + val mapping = defaultWiimoteMapping(JoyconSide.DUAL) + mapOf("Shake" to "R") + + val result = DolphinWiimoteConfig.merge(null, pair, { false }) { mapping } + + assertTrue(result.contains("pulse(deadzone((`R1`), 0.2), 0.6)")) + } + + @Test + fun `nothing shakes the accelerometer when there is no trick to fire`() { + val result = merge(null, listOf(PlayerState(PlayerNumber.P1, right = joycon(Side.RIGHT)))) - assertFalse(lone.contains("smooth(`Accel")) - assertFalse(merge(null, listOf(pair), sidewaysRemote = true).contains("smooth(`Accel")) + assertFalse(result.contains("pulse(")) } @Test From 514f258288905df8a21389e882a4bd68ae28276a Mon Sep 17 00:00:00 2001 From: Joe Barker Date: Tue, 22 Sep 2026 20:46:34 +0100 Subject: [PATCH 05/16] Set the flick threshold from a measured hand The trick fired about half the time, and tuning it by feel was never going to work: the threshold was built on a flick of 21 rad/s recorded in June, and a capture of the hand actually playing peaks at 11 to 16. It was sitting squarely inside the range it was meant to be under. Of four flicks leaving 5.6, 6.1, 8.3 and 9.1 rad/s behind the limiter, the old bar at 6.0 cleared two, cleared one by two percent, and missed the fourth. It now fires at 2.5, which is 2.1x above the worst of 25 s of hard steering (3.6 rad/s peak, 1.2 residual) and 2.2x below the weakest flick -- as evenly as two sparsely sampled distributions can be split. Erring low is right regardless: a trick fired by accident costs nothing, since the game only tricks a kart already airborne, while one fired mid-corner drops the shake onto the accelerometer the wheel is read from. The limiter stays at 0.01. A slower one lifts a flick's residual but lifts steering's faster, and the ratio between them -- all that matters -- falls from 4.7 to 3.8 at 0.02 and 2.0 at 0.04. Worth writing down, since a slower limiter is exactly what it looks like you should reach for. flick_stats.py is how any of this was known. It reads a dsu_client capture, reconstructs what Dolphin's expression leaves after the limiter, and reports it per event, so the constant can come from a hand rather than a guess. The stream itself came out healthy -- 15 ms, mean and median -- so sampling was never the problem, though three to five samples across a flick is why two of the same strength can read several rad/s apart. Co-Authored-By: Claude Opus 5 --- README.md | 4 +- docs/dsu-motion.md | 24 +++-- .../dsu/emulator/DolphinWiimoteConfig.kt | 26 +++-- .../dsu/emulator/DolphinWiimoteConfigTest.kt | 8 +- tools/README.md | 28 +++++ tools/flick_stats.py | 101 ++++++++++++++++++ 6 files changed, 169 insertions(+), 22 deletions(-) create mode 100755 tools/flick_stats.py diff --git a/README.md b/README.md index eea21aa..7f1b1bb 100644 --- a/README.md +++ b/README.md @@ -195,9 +195,9 @@ shoulder buttons. Right on Down, Down on Left, Left on Up. A sideways remote's d-pad turns with it, so the player's up is the remote's right. - For **tricks**, append - ``+ pulse(deadzone((), 0.2), 0.6) * sin(timer(0.15) * 6.2832) * 50`` to each + ``+ pulse(, 0.6) * sin(timer(0.15) * 6.2832) * 50`` to each **IMUAccelerometer** input, where `` is - ``( - smooth(, 0.02)) / 15`` and `` is + ``( - smooth(, 0.01)) / 5`` and `` is ``(\`Gyro Pitch Up\` + \`Gyro Pitch Down\` + \`Gyro Roll Left\` + \`Gyro Roll Right\` + \`Gyro Yaw Left\` + \`Gyro Yaw Right\`)`` — and add `+ 3.1416` inside the `sin` for Down, Right and Backward so they swing the other way. A flick of a Joy-Con is nearly all rotation, which the game can't read from an accelerometer diff --git a/docs/dsu-motion.md b/docs/dsu-motion.md index 164d781..a163b86 100644 --- a/docs/dsu-motion.md +++ b/docs/dsu-motion.md @@ -93,12 +93,24 @@ if the Joy-Con's nose pointed at the screen. **A rate alone cannot tell a flick from a turn**, because steering a lone Joy-Con held as a wheel *is* rotation — which is why only single Joy-Cons suffered for it, a pair steering from the Nunchuk's stick with its remote hand still. The trigger therefore subtracts a slew limiter, - `(rate − smooth(rate, 0.02)) / 15`, leaving only what climbs faster than the limiter can follow: - at 0.02 the tracker moves 50 rad/s, so it has caught the sharpest measured steering (6.5) within - about a seventh of a second and left nothing behind, while a flick's ~40 ms rise to 21 outruns it - almost untouched. A trick fired by accident costs nothing — the game only tricks a kart already - airborne — but one fired *while steering* costs plenty, since the shake lands on the very - accelerometer the wheel is read from. Every body flicks, a pair included: its remote hand is still while the Nunchuk's + `(rate − smooth(rate, 0.01)) / 5`, leaving only what climbs faster than the limiter can follow. + + Both numbers are measured, from a capture of flicks and a capture of hard steering read back by + [`tools/flick_stats.py`](../tools/README.md#flick-measurement) (2026-09-22, right Joy-Con, 15 ms + stream): + + | | peak rate | residual after the limiter | + |---|---|---| + | flicks (4) | 11–16 rad/s | 5.6, 6.1, 8.3, 9.1 | + | hard steering (25 s) | 3.6 rad/s | ≤ 1.2 | + + A *slower* limiter is worse, not better: it lifts a flick's residual but lifts steering's faster, + and the ratio between them — all that matters — falls from 4.7 at 0.01 to 3.8 at 0.02 and 2.0 at + 0.04. `pulse()` fires as its input crosses a half, so the threshold is 2.5 rad/s of residual: + 2.1× above the worst steering and 2.2× below the weakest flick. Erring low is right anyway — a + trick fired by accident costs nothing, since the game only tricks a kart already airborne, while + one fired *while steering* costs plenty, the shake landing on the very accelerometer the wheel is + read from. Every body flicks, a pair included: its remote hand is still while the Nunchuk's stick steers. Only a layout that plays as a sideways remote flicks at all, so no other game is handed a shake it never asked for when its remote is swung. diff --git a/feature/dsu/domain/src/main/kotlin/com/joegec/joycon2android/dsu/emulator/DolphinWiimoteConfig.kt b/feature/dsu/domain/src/main/kotlin/com/joegec/joycon2android/dsu/emulator/DolphinWiimoteConfig.kt index a172916..d4030da 100644 --- a/feature/dsu/domain/src/main/kotlin/com/joegec/joycon2android/dsu/emulator/DolphinWiimoteConfig.kt +++ b/feature/dsu/domain/src/main/kotlin/com/joegec/joycon2android/dsu/emulator/DolphinWiimoteConfig.kt @@ -156,16 +156,22 @@ object DolphinWiimoteConfig { // A rate alone cannot tell a flick from a turn, because steering a lone Joy-Con held as a wheel // *is* rotation — which is why only single Joy-Cons suffered for it: a pair steers from the // Nunchuk's stick with its remote hand still. Subtracting a slew limiter leaves only what climbs - // faster than the limiter can follow. At 0.02 the tracker moves 50 rad/s, so it has caught the - // sharpest measured steering (6.5) within about a seventh of a second and left nothing behind, - // while a flick's ~40 ms rise to 21 outruns it almost untouched. + // faster than the limiter can follow. // - // A trick fired by accident costs nothing — the game only tricks a kart already airborne — but a - // trick fired *while steering* costs plenty, since the shake below lands on the accelerometer the - // wheel is read from. Hence a discriminator rather than a bigger number. - private const val FLICK_RADIANS = 15 - private const val FLICK_SETTLE_SECONDS = 0.02 - private const val FLICK_DEAD_ZONE = 0.2 + // Both numbers are measured, over a capture of flicks and a capture of hard steering read back + // by tools/flick_stats.py (2026-09-22, right Joy-Con, 15 ms stream). Flicks peaked at 11 to 16 + // rad/s and left 5.6 to 9.1 behind the limiter; 25 s of the sharpest steering peaked at 3.6 and + // left at most 1.2. A slower limiter is worse, not better: it lifts a flick's residual but lifts + // steering's faster, and the ratio between them — all that matters — falls from 4.7 at 0.01 to + // 3.8 at 0.02 and 2.0 at 0.04. + // + // pulse() fires as its input crosses a half, so the threshold is 2.5 rad/s of residual: 2.1x + // above the worst steering and 2.2x below the weakest flick, which is as evenly as two sparsely + // sampled distributions can be split. Erring low is right anyway — a trick fired by accident + // costs nothing, since the game only tricks a kart already airborne, while one fired *while + // steering* costs plenty, the shake landing on the accelerometer the wheel is read from. + private const val FLICK_RADIANS = 5 + private const val FLICK_SETTLE_SECONDS = 0.01 private const val TRICK_ACCELERATION = 50 // m/s^2, past what an emulated remote can report private const val TRICK_SECONDS = 0.6 private const val TRICK_PERIOD_SECONDS = 0.15 @@ -193,7 +199,7 @@ object DolphinWiimoteConfig { private fun trickShake(trigger: String?, control: String): String? { if (trigger == null || !control.startsWith("IMUAccelerometer/")) return null val phase = if (control in TRICK_LEADING) "" else " + $HALF_TURN" - return "pulse(deadzone(($trigger), $FLICK_DEAD_ZONE), $TRICK_SECONDS) * " + + return "pulse($trigger, $TRICK_SECONDS) * " + "sin(timer($TRICK_PERIOD_SECONDS) * $FULL_TURN$phase) * $TRICK_ACCELERATION" } diff --git a/feature/dsu/domain/src/test/kotlin/com/joegec/joycon2android/dsu/emulator/DolphinWiimoteConfigTest.kt b/feature/dsu/domain/src/test/kotlin/com/joegec/joycon2android/dsu/emulator/DolphinWiimoteConfigTest.kt index 5778753..8149584 100644 --- a/feature/dsu/domain/src/test/kotlin/com/joegec/joycon2android/dsu/emulator/DolphinWiimoteConfigTest.kt +++ b/feature/dsu/domain/src/test/kotlin/com/joegec/joycon2android/dsu/emulator/DolphinWiimoteConfigTest.kt @@ -227,10 +227,10 @@ class DolphinWiimoteConfigTest { val rate = "(`Gyro Pitch Up` + `Gyro Pitch Down` + `Gyro Roll Left` + `Gyro Roll Right` + " + "`Gyro Yaw Left` + `Gyro Yaw Right`)" - val flick = "($rate - smooth($rate, 0.02)) / 15" + val flick = "($rate - smooth($rate, 0.01)) / 5" assertTrue( result.contains( - "IMUAccelerometer/Up = `Accel Up` + pulse(deadzone(($flick), 0.2), 0.6) * " + + "IMUAccelerometer/Up = `Accel Up` + pulse($flick, 0.6) * " + "sin(timer(0.15) * 6.2832) * 50", ), ) @@ -268,7 +268,7 @@ class DolphinWiimoteConfigTest { val result = DolphinWiimoteConfig.merge(null, pair, { true }, ::wiimoteMappingFor) - assertTrue(result.contains("pulse(deadzone((((`Gyro Pitch Up`")) + assertTrue(result.contains("pulse(((`Gyro Pitch Up`")) // ...but its motion frame is untouched, since it is already held like a remote. assertTrue(result.contains("IMUAccelerometer/Forward = `Accel Forward` +")) @@ -281,7 +281,7 @@ class DolphinWiimoteConfigTest { val result = DolphinWiimoteConfig.merge(null, pair, { false }) { mapping } - assertTrue(result.contains("pulse(deadzone((`R1`), 0.2), 0.6)")) + assertTrue(result.contains("pulse(`R1`, 0.6)")) } @Test diff --git a/tools/README.md b/tools/README.md index cbe3724..438e19b 100644 --- a/tools/README.md +++ b/tools/README.md @@ -20,6 +20,34 @@ The third argument sets the motion print interval; it defaults to a readable 0.2 `0` prints every packet (~90 Hz), which is what differentiating the gravity vector needs. +## Flick measurement + +`flick_stats.py` reads a `dsu_client` capture and reports what a flick leaves behind after +the slew limiter `DolphinWiimoteConfig` subtracts — the number that decides whether a trick +fires. Use it to set `FLICK_RADIANS` from a hand rather than from an assumption. + +Enable DSU in the app with a single Joy-Con on P1 (slot 0), then capture twice: + +```sh +adb shell /data/local/tmp/dsu_client 127.0.0.1 20 0 > flick.log # ~10 flicks, as if tricking +adb shell /data/local/tmp/dsu_client 127.0.0.1 20 0 > steer.log # steering hard, as if racing +tools/flick_stats.py flick.log +tools/flick_stats.py steer.log +``` + +The motion interval must be `0`, or the peaks are averaged away before the file is written. + +Three things to read out of it: + +- **The interval.** A flick lasts 40–80 ms. At the ~30 ms of a balanced connection it is + sampled once or twice and its crest is often missed entirely, which no threshold can + recover; fast motion roughly halves that. +- **The spread across events.** Flicks of the same strength reading very different residuals + means the stream is catching them at different points, not that the hand varied. +- **The gap between the two captures.** `FLICK_RADIANS` has to sit under twice the weakest + flick and over twice the largest steering residual. If those cross, the limiter is the + wrong discriminator and no threshold will do. + ### Axis calibration workflow 1. Capture while performing slow single-axis motions with holds (still → yaw left → diff --git a/tools/flick_stats.py b/tools/flick_stats.py new file mode 100755 index 0000000..ecf37c6 --- /dev/null +++ b/tools/flick_stats.py @@ -0,0 +1,101 @@ +#!/usr/bin/env python3 +"""Works out what a flick actually looks like on the wire, so the trick threshold can be set +from a hand rather than from an assumption. + +Feed it a capture from dsu_client with the motion interval set to 0 (every packet): + + adb shell /data/local/tmp/dsu_client 127.0.0.1 20 0 > flick.log + tools/flick_stats.py flick.log + +Capture twice — once flicking as you would to trick, once steering as hard as you would +race — and compare. Dolphin sees `rate`, the sum of the six one-way gyro inputs, which is +|pitch| + |yaw| + |roll| in rad/s. DolphinWiimoteConfig subtracts a slew limiter from it and +fires a trick when what is left passes half of FLICK_RADIANS, so `residual` below is the +number that decides whether a flick lands. +""" +import argparse +import math +import re +import statistics +import sys + +LINE = re.compile( + r"\[\s*([\d.]+)\] slot=(\d) accel=\([^)]*\)g gyro\(pitch,yaw,roll\)=\(([^)]*)\)dps" +) + + +def samples(path, slot): + """(timestamp, rate in rad/s) per packet, rate being what Dolphin sums from the six inputs.""" + for line in open(path): + found = LINE.match(line.strip()) + if not found or int(found.group(2)) != slot: + continue + pitch, yaw, roll = (float(v) for v in found.group(3).split(",")) + yield float(found.group(1)), math.radians(abs(pitch) + abs(yaw) + abs(roll)) + + +def residuals(rates, settle): + """What survives Dolphin's `rate - smooth(rate, settle)`: a limiter moving 1/settle per second.""" + state = rates[0][1] + for previous, (at, rate) in zip(rates, rates[1:]): + most = (at - previous[0]) / settle + state += max(-most, min(most, rate - state)) + yield at, rate, rate - state + + +def peaks(measured, floor, apart): + """One entry per burst, so a single flick is not counted as several.""" + burst = [] + for at, rate, residual in measured: + if residual < floor: + continue + if burst and at - burst[-1][0] > apart: + yield max(burst, key=lambda it: it[2]) + burst = [] + burst.append((at, rate, residual)) + if burst: + yield max(burst, key=lambda it: it[2]) + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("capture") + parser.add_argument("--slot", type=int, default=0) + parser.add_argument("--settle", type=float, default=0.01, help="FLICK_SETTLE_SECONDS") + parser.add_argument("--floor", type=float, default=2.0, help="rad/s of residual worth reporting") + parser.add_argument("--apart", type=float, default=0.3, help="seconds between separate events") + args = parser.parse_args() + + rates = list(samples(args.capture, args.slot)) + if len(rates) < 3: + sys.exit(f"{args.capture}: no motion lines for slot {args.slot} — run dsu_client with interval 0") + + gaps = [b[0] - a[0] for a, b in zip(rates, rates[1:])] + span = rates[-1][0] - rates[0][0] + print(f"{len(rates)} packets over {span:.1f} s") + print( + f"interval: mean {statistics.mean(gaps) * 1e3:.1f} ms, " + f"median {statistics.median(gaps) * 1e3:.1f} ms, worst {max(gaps) * 1e3:.1f} ms" + ) + print(" a flick lasts 40-80 ms, so anything near that is sampling it once or twice\n") + + measured = list(residuals(rates, args.settle)) + found = list(peaks(measured, args.floor, args.apart)) + if not found: + sys.exit(f"nothing above {args.floor} rad/s of residual — flick harder, or lower --floor") + + plural = "" if len(found) == 1 else "s" + print(f"{len(found)} event{plural} (peak residual >= {args.floor}, at least {args.apart}s apart):") + for at, rate, residual in found: + print(f" t={at:8.2f} rate {rate:6.1f} residual {residual:6.1f} fires while FLICK_RADIANS <= {2 * residual:.0f}") + + weakest = min(it[2] for it in found) + print(f"\nweakest event leaves {weakest:.1f} rad/s of residual.") + print(f" to catch every one of these, FLICK_RADIANS <= {2 * weakest:.0f}") + print(" run this over a steering-only capture too: FLICK_RADIANS must stay above twice its") + print(" largest residual, or racing will fire tricks. No gap between the two means the") + print(" limiter is the wrong discriminator, not the threshold.") + + +if __name__ == "__main__": + main() From 3d63272c6a8b1c65626fbcb894ce1be05c288fb3 Mon Sep 17 00:00:00 2001 From: Joe Barker Date: Tue, 22 Sep 2026 21:41:43 +0100 Subject: [PATCH 06/16] Move the long derivations out of the code and into the docs Measurements, byte layouts and emulator archaeology had been accumulating as comment blocks at the top of whichever class happened to need them, and more than half of it had quietly become a second copy of something already in docs/. Two places to keep in sync, and one of them was already wrong: the trick expression in dsu-motion.md still carried a deadzone() that came out of the code when we measured it. 1024 comment lines to 864. The ratio barely moves, 8% to 7%, because that was never the problem -- the distribution was. The longest block anywhere is now 12 lines rather than 31, and nothing sits above 35% of its file. MotionConverter was the sharpest case, at 65% comment: dsu-motion.md said "the MotionConverter KDoc records the frames and signs", so the doc was deferring to the code. That is inverted now, with both frames as a table, the 2026-09 discovery that raw x had been documented mirrored, and the note that yaw is the one sign no static pose can pin. Three sections carry what had nowhere to go: the SPI reply layout and why the accent colour rather than the body colour (protocol.md), Eden's cemuhook guid/port/pad addressing (dsu-motion.md), and the Dolphin GC pad's device qualifier and per-direction stick inputs (virtual-gamepad.md). The rest was already written down; those classes now point at it. What stays in the code is what a reader needs at that line and cannot reconstruct from it: the per-byte labels on the HID descriptor, that Nintendo's VID/PID makes hid-nintendo intercept the device, that Source = 1 forces a Wii Remote slot to Emulated. One-liners, where opening a doc would cost more than it gave. Every cross-reference was checked to resolve, in both directions. Co-Authored-By: Claude Opus 5 --- .../emulator/VirtualGamepadIdentity.kt | 25 +--- .../joycon2android/ui/theme/AppTextStyles.kt | 18 +-- .../emulatorconfig/EdenControls.kt | 14 +-- .../joycon2android/model/SidewaysMapper.kt | 18 +-- docs/dsu-motion.md | 94 +++++++++++---- docs/protocol.md | 22 ++++ docs/virtual-gamepad.md | 43 ++++++- .../connection/SpiColorParser.kt | 25 +--- .../connection/StickCalibrator.kt | 19 +-- .../com/joegec/joycon2android/dsu/DsuSlots.kt | 10 +- .../dsu/emulator/DolphinWiimoteConfig.kt | 109 ++++-------------- .../dsu/emulator/EdenDsuConfig.kt | 23 +--- .../dsu/motion/MotionConverter.kt | 34 +----- .../dsu/motion/SidewaysMotion.kt | 13 +-- .../joycon2android/gamepad/UhidRelay.kt | 7 +- .../gamepad/emulator/DolphinGcpadConfig.kt | 13 +-- .../gamepad/emulator/EdenGamepad.kt | 10 +- .../gamepad/emulator/EdenGamepadConfig.kt | 25 +--- 18 files changed, 205 insertions(+), 317 deletions(-) diff --git a/app/src/main/java/com/joegec/joycon2android/emulator/VirtualGamepadIdentity.kt b/app/src/main/java/com/joegec/joycon2android/emulator/VirtualGamepadIdentity.kt index ab2c043..e526e30 100644 --- a/app/src/main/java/com/joegec/joycon2android/emulator/VirtualGamepadIdentity.kt +++ b/app/src/main/java/com/joegec/joycon2android/emulator/VirtualGamepadIdentity.kt @@ -8,28 +8,9 @@ import android.view.MotionEvent import com.joegec.joycon2android.gamepad.emulator.EdenGamepad /* - * Resolves how each emulator identifies our virtual gamepads, by reading the live input-device - * list rather than deriving a number from the player index. - * - * Every emulator picks its own quantity, and none of them is the player number, so each rule is - * read from the emulator's own source and mirrored here: - * - * - **Dolphin** takes the id in its `Android//` qualifier from - * `InputDevice.getControllerNumber()` — Android's gamepad enumeration counter. - * `ControllerInterface::AddDevice` prefers `GetPreferredId()`, which the Android backend fills - * from `getControllerNumber()`, falling back to a duplicate-name index only for non-gamepads. - * - **Eden** (yuzu lineage) numbers `port` by walking `InputDevice.getDeviceIds()` and counting - * *every* physical game controller it passes, so any built-in pad shifts ours along. See - * `InputHandler.getDevices()`: a controller number already registered is skipped but still - * consumes a port, which edenGamepadPorts reproduces. - * - * A guessed number binds a config to the wrong device or to none, and any handheld with a built-in - * controller already occupies the low numbers — hence read, never derive. - * - * The same goes for the vendor/product ids behind Eden's `guid`: some handheld firmware re-publishes - * an external gamepad under the built-in controller's ids, leaving two devices with our name — so - * every field of a player's identity is taken from one and the same [InputDevice], the last match, - * which is the republished one where that happens. + * Resolves how each emulator identifies our virtual gamepads, by reading the live input-device list + * rather than deriving a number from the player index. Each emulator's rule, and why a guess breaks: + * docs/virtual-gamepad.md#device-identity. */ private const val PREFIX = "Joy-Con Virtual Gamepad " diff --git a/core/designsystem/src/main/kotlin/com/joegec/joycon2android/ui/theme/AppTextStyles.kt b/core/designsystem/src/main/kotlin/com/joegec/joycon2android/ui/theme/AppTextStyles.kt index fab2efb..85d4b6f 100644 --- a/core/designsystem/src/main/kotlin/com/joegec/joycon2android/ui/theme/AppTextStyles.kt +++ b/core/designsystem/src/main/kotlin/com/joegec/joycon2android/ui/theme/AppTextStyles.kt @@ -6,28 +6,16 @@ import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.sp -/** - * App-specific text roles the Material type scale doesn't cover. These live outside [Typography] - * because they aren't part of the reading hierarchy: [telemetry] is data-viz, [statusOverline] is - * a fixed chrome label. - */ +/** The two text roles that aren't reading hierarchy, so aren't in the scale: docs/DESIGN.md#typography. */ object AppType { - /** - * Live numeric readouts (IMU, stick coordinates, battery %, DSU port, config snippets). - * Tabular figures (`tnum`) keep digit columns aligned as values change; padding is stripped so - * the mono line sits tight against the graphics it annotates. Size is intentionally left to the - * call site — telemetry is sized to the control it labels, not to a hierarchy step. - */ + /** Sized by the caller: telemetry is sized to the control it labels, not to a hierarchy step. */ val telemetry = TextStyle( fontFamily = FontFamily.Monospace, fontFeatureSettings = "tnum", platformStyle = PlatformTextStyle(includeFontPadding = false), ) - /** - * Wide-tracked chrome label for the connection/Shizuku status line in the app bar. Line height - * is left at the font default so the two stacked status lines keep their breathing room. - */ + /** Line height stays at the font default, so the two stacked status lines keep their room. */ val statusOverline = TextStyle( fontWeight = FontWeight.Medium, fontSize = 11.sp, diff --git a/core/emulatorconfig/src/main/kotlin/com/joegec/joycon2android/emulatorconfig/EdenControls.kt b/core/emulatorconfig/src/main/kotlin/com/joegec/joycon2android/emulatorconfig/EdenControls.kt index 2961024..bb633f7 100644 --- a/core/emulatorconfig/src/main/kotlin/com/joegec/joycon2android/emulatorconfig/EdenControls.kt +++ b/core/emulatorconfig/src/main/kotlin/com/joegec/joycon2android/emulatorconfig/EdenControls.kt @@ -30,12 +30,7 @@ object EdenControls { val STICK_KEYS = mapOf(SwitchProStick.LStick to "lstick", SwitchProStick.RStick to "rstick") - /** - * Eden's npad type. Both single Joy-Cons report as Pro: Eden doesn't translate a sideways - * Joy-Con — it only sets an `is_horizontal` flag and masks an npad by type, so a JoyconLeft - * can't even report A/B/X/Y — so a normalised full controller is presented and the rotation - * done on our side instead. - */ + /** Both single Joy-Cons report as Pro: docs/virtual-gamepad.md#why-theyre-set-up-as-pro-controllers. */ fun npadType(player: PlayerState): Int? = when { player.hasPro -> PRO player.hasFullController -> DUAL_JOYCON @@ -45,11 +40,8 @@ object EdenControls { fun quote(value: String) = "\"$value\"" - /** - * A stick assembled from up to four digital inputs, each a whole binding of its own. Eden parses - * the nested bindings out of one value, so their `:`, `,` and `$` are escaped as `$0`, `$1` and - * `$2`, exactly as its ParamPackage serializes them. - */ + /** A stick built from up to four whole bindings nested in one value, escaped as Eden's + * ParamPackage serializes them: docs/virtual-gamepad.md#emulator-config. */ fun stickFromButtons(directions: Map): String = (listOf("engine:analog_from_button") + directions.map { (direction, binding) -> "${direction.name.lowercase()}:${escapeNested(binding)}" diff --git a/core/model/src/main/kotlin/com/joegec/joycon2android/model/SidewaysMapper.kt b/core/model/src/main/kotlin/com/joegec/joycon2android/model/SidewaysMapper.kt index 7f51f30..c64e460 100644 --- a/core/model/src/main/kotlin/com/joegec/joycon2android/model/SidewaysMapper.kt +++ b/core/model/src/main/kotlin/com/joegec/joycon2android/model/SidewaysMapper.kt @@ -1,16 +1,8 @@ package com.joegec.joycon2android.model /** - * Transforms raw Joy-Con input into gamepad-oriented values for sideways (single Joy-Con) mode. - * - * Left Joy-Con rotated 90° CCW, right Joy-Con 90° CW, matching how the Switch treats a lone - * Joy-Con: the stick axes rotate with the body, the four-button cluster becomes the face buttons in - * its rotated positions, and the SL/SR rail buttons fill in the shoulder pair the body lacks. - * - * The cluster must land on real face buttons rather than the HID hat. A sideways Joy-Con has no - * d-pad, so a virtual pad that reports its cluster as hat directions has no A/B/X/Y at all, and - * anything binding a hat axis without its sign — Eden's manual mapping does exactly this — cannot - * tell left from right or up from down. + * Turns a lone Joy-Con's input into its sideways grip — left 90° counter-clockwise, right 90° + * clockwise, as on a Switch. Table and reasoning: docs/virtual-gamepad.md#sidewaysmapper. */ object SidewaysMapper { @@ -28,8 +20,7 @@ object SidewaysMapper { fun remapButtonsRight(pressed: Set): Set = pressed.mapTo(mutableSetOf()) { RIGHT_REMAP[it] ?: it } - // D-pad rotates 90° CCW onto the faces: Right sits at the top, Down at the right, Left at the - // bottom, Up at the left. Rail buttons fill the missing right-hand shoulders. + // The cluster must land on real face buttons, never the hat: a sideways body has no d-pad. private val LEFT_REMAP = mapOf( JoyconButton.Right.id to JoyconButton.X.id, JoyconButton.Down.id to JoyconButton.A.id, @@ -39,8 +30,7 @@ object SidewaysMapper { JoyconButton.SrLeft.id to JoyconButton.ZR.id, ) - // Faces rotate 90° CW onto themselves: Y sits at the top, X at the right, A at the bottom, B at - // the left. Stick click becomes LS (the stick is now the left one); rails fill the left shoulders. + // Stick click becomes LS, the stick now being the left one. private val RIGHT_REMAP = mapOf( JoyconButton.Y.id to JoyconButton.X.id, JoyconButton.X.id to JoyconButton.A.id, diff --git a/docs/dsu-motion.md b/docs/dsu-motion.md index a163b86..567f205 100644 --- a/docs/dsu-motion.md +++ b/docs/dsu-motion.md @@ -33,10 +33,37 @@ Players always win their own slot; pairs take what's left, so four players leave ## Motion frame -Scale factors are the Switch 1 values, verified on Joy-Con 2: accel 4096 LSB per g, gyro -0.061 °/s per LSB. The axis mapping was measured against Dolphin's Wii pointer — the -`MotionConverter` KDoc records the frames and signs, and [tools/README.md](../tools/README.md) the -calibration workflow. +Scale factors are the Switch 1 values, verified on Joy-Con 2 (2026-06: at rest gravity reads +exactly −1.00 g): accel ±8 g over 4096 LSB per g, gyro ±2000 °/s at 0.06103 °/s per LSB. +[tools/README.md](../tools/README.md) has the calibration workflow. + +**The two frames.** `MotionConverter` turns one into the other, and neither is guessable: + +| | x | y | z | +|---|---|---|---| +| Joy-Con (R) raw, measured | the controller's **right** | toward the **tail** | out of the **button face** | +| cemuhook wire | **left** | **down** through the controller | toward the **player** | + +Flat at rest reads accel `(0, −1, 0)`; nose up reads accel z = −1 and gyro pitch −; turning right +reads +yaw; rolling right reads +roll. + +**The signs are DS4 hardware history, not a consistent right-handed frame**, so derive nothing from +them — verify any change against Dolphin's on-screen pointer, testing fast and slow movements +separately. Its complementary filter makes the *accelerometer* the authority on sustained pitch, so +gyro signs cannot be judged from pointer direction alone; gyro shows up in the fast response, accel +in the settled position. + +- **Raw x was documented as "left" until 2026-09**, when a rail-down static pose — SL/SR against the + table, so gravity points toward the controller's right — read +1 g on the wire's left axis. + Mirrored. Left/right tilt had been reaching games reversed, and because angular velocity is a + pseudovector, roll had to mirror with it to stay physically consistent, which is why both flipped + together. +- **Yaw is the one sign no measurement here pins.** It turns about gravity, so a static pose cannot + see it and neither can the accel/gyro consistency check. It is kept as the pointer's horizontal + response reports it. Mirroring x strictly implies mirroring yaw too, so if horizontal pointing + ever reads backwards, flip yaw rather than re-deriving the frame. +- **Left Joy-Con and Pro are assumed to share the raw frame** — unverified. Recalibrate with + `tools/dsu_client` if their motion feels rotated. **Gyro bias.** Joy-Con 2 gyros idle with a constant offset (+0.2 °/s yaw, +0.9 °/s roll observed), which clients integrate into pointer drift. Whenever a controller stays within ~2.4 °/s for ~2 s, @@ -75,20 +102,22 @@ if the Joy-Con's nose pointed at the screen. (4.1) a wide berth. `Shake` is a mapping target of its own too, so a pair — which has no sideways flick to read — can trick from a button. - **Dolphin's own `Shake` group is not how it is delivered.** Bound straight to a key in Dolphin's - config, a full 7 g oscillation of it never once landed a trick (tested 2026-09), so the group is - not written at all. The accelerometer is the path that demonstrably reaches the game, since - steering is read from it, and the jerk goes there instead: - `pulse(deadzone(trigger, 0.2), 0.6) * sin(timer(0.15) * 2π) * 50` added to every - `IMUAccelerometer` input, the three opposites carrying a half-turn of phase. - - It is a *shake*, not a push: an oscillation held for 0.6 s at about 6.7 Hz, each input of a pair - swung half a cycle apart so the remote is thrown back and forth rather than leaned on. That shape - is what landed a trick by hand — shaking a Joy-Con hard for about a second — where a single held - push did not. Amplitude is not the lever: an emulated Wii Remote saturates around +3.9/−4.9 g - (`ACCEL_ZERO_G` 0x80, `ACCEL_ONE_G` 0x9A over 8 bits), which the 50 m/s² already passes, so a - bigger number only clips sooner. `pulse()` gives a flick and a held button the same shake however - long either lasted. + **Two ways of delivering it do not work, and both were tried.** *Amplifying the accelerometer's + own transient* does nothing, because an emulated Wii Remote saturates around +3.9/−4.9 g + (`ACCEL_ZERO_G` 0x80, `ACCEL_ONE_G` 0x9A over 8 bits) and the push already passes that, so a + bigger number only clips sooner. *Dolphin's `Shake` group* does nothing either: bound straight to + a key in Dolphin's own config, a full 7 g oscillation of it never once landed a trick (tested + 2026-09) — and not for want of reaching the game, since `m_shake_state.acceleration` is added to + the reported acceleration unconditionally, whether or not `IMUAccelerometer` is bound. Neither is + written any more. + + **What is written goes into the accelerometer**, the path that demonstrably reaches the game since + steering is read from it: `pulse(flick, 0.6) * sin(timer(0.15) * 2π) * 50` added to every + `IMUAccelerometer` input, the three opposites carrying a half-turn of phase. It is a *shake*, not + a push — an oscillation held for 0.6 s at about 6.7 Hz, each input of a pair swung half a cycle + apart so the remote is thrown back and forth rather than leaned on. That shape is what landed a + trick by hand, shaking a Joy-Con hard for about a second, where a single held push did not. + `pulse()` gives a flick and a held button the same shake however long either lasted. **A rate alone cannot tell a flick from a turn**, because steering a lone Joy-Con held as a wheel *is* rotation — which is why only single Joy-Cons suffered for it, a pair steering from the @@ -114,9 +143,8 @@ if the Joy-Con's nose pointed at the screen. stick steers. Only a layout that plays as a sideways remote flicks at all, so no other game is handed a shake it never asked for when its remote is swung. - Verified against Dolphin's source (2026-09): `|` is a max and binds looser than `/`; - `m_shake_state.acceleration` is added to the reported acceleration unconditionally, so binding - `IMUAccelerometer` does not disable the Shake group — it simply never produced a trick. + One more thing verified against Dolphin's source (2026-09), since the expressions depend on it: + `|` is a max, and it binds looser than `/`. - **Pointing and a wheel want the nose half a turn apart on a right Joy-Con**, and no Dolphin option bridges them: `GetOrientation()` turns a quarter (Sideways) or a quarter about the left axis (Upright), and it reaches only the accelerometer the game reads, never @@ -137,6 +165,30 @@ The Joy-Con reports once per BLE connection interval. Android's balanced priorit `CONNECTION_PRIORITY_HIGH` while DSU runs — 15 ms (~67 Hz) on the same Thor — at a battery cost on both ends. +## Eden's cemuhook bindings + +Eden's cemuhook engine addresses a pad by `guid`, `port` and `pad`, and nothing else: + +- **`guid`** is the server's IPv4 as a 32-bit integer in hex, right-aligned in an otherwise-zero + UUID written raw, no dashes — so loopback is `0000000000000000000000007f000001`. +- **`port`** is the UDP port, not a controller index. +- **`pad`** is a global index, `client * 4 + slot`, so with our server as Eden's only client it is + the DSU slot itself. + +`EdenDsuConfig`'s button table is protocol wiring, not preference: cemuhook's two button bytes +packed low-then-high are exactly Eden's `PadButton` values, with Home and the touchpad click riding +the bytes above them. Sticks arrive as raw bytes that Eden reads as `(v − 127) / 127`, so the axis +pairs need no inversion. + +**Motion is the one thing a pad cannot share.** A pad packet carries a single accelerometer and +gyroscope, so `motion` is always index 0 and each hand streams on a slot of its own ([Slots](#slots)). +The hand holding the player's own slot lands on `motionright` and its second hand on `motionleft`; +one Joy-Con, or a pair that ran out of slots, binds both to the same pad — which is what Eden's own +auto-mapping does for every device. + +A cemuhook pad carries the full DS4 button set and both sticks, so nothing else is needed for a +player to play: the Virtual Gamepad is an alternative route to the same keys, not a prerequisite. + ## Eden reads the device's own motion Eden's Android build feeds the device's gyro and accelerometer into Player 1 on top of any mapped diff --git a/docs/protocol.md b/docs/protocol.md index f790123..2ead36b 100644 --- a/docs/protocol.md +++ b/docs/protocol.md @@ -116,6 +116,28 @@ A left Joy-Con's right-stick bytes are garbage, and a right Joy-Con's left-stick 0x01 GR 0x02 GL ``` +## SPI reads + +The controller keeps its factory data in SPI flash, read back through the command-response +characteristic. `SpiColorParser` wants one field out of it: the **shell accent colour**, 3 bytes +RGB at `0x01301F`. Not the body colour at `0x013019` — that is the near-black shell, identical on +both Switch 2 Joy-Cons, so it identifies nothing. The accent is the per-side colour (coral right, +blue left) the UI paints each controller with. We request the surrounding DeviceInfo block and pull +the field out of the reply. + +Reply layout, little-endian, confirmed against a live controller: + +| Offset | Meaning | +|---|---| +| `0` | report type — `0x02` for SPI | +| `3` | command — `0x04` for SPI read | +| `8` | data length | +| `12..15` | source address, echoing the address requested | +| `16..` | data bytes, starting at that source address | + +The echoed source address is what makes the read robust: the field's offset in the reply is +`16 + (wanted address − echoed address)`, so the block can be requested at any alignment. + ## Stick range and centre The raw 12-bit sticks neither span `0x000..0xFFF` nor rest at the midpoint, and both vary per diff --git a/docs/virtual-gamepad.md b/docs/virtual-gamepad.md index 9e380d8..65f0301 100644 --- a/docs/virtual-gamepad.md +++ b/docs/virtual-gamepad.md @@ -100,11 +100,46 @@ Left Joy-Con turned 90° counter-clockwise, right 90° clockwise, as on a Switch Motion is turned too, but for DSU only — see [dsu-motion.md](dsu-motion.md#sideways-joy-cons). +## Emulator config + +- **Dolphin** sees each player's UHID pad as a distinct Android input device and qualifies its + bindings `Android//Joy-Con Virtual Gamepad `. `DolphinGcpadConfig`'s + name tables are Dolphin's own fixed names for each Android keycode and hat direction, captured + from a real mapping rather than derived. +- **Every stick direction is its own Dolphin input**, so a stick target can mix tilts and buttons + freely without the tilts losing their analog range. +- **Eden nests whole bindings inside one value** for a stick assembled from digital inputs, so + `EdenControls` escapes their `:`, `,` and `$` as `$0`, `$1` and `$2` — exactly as Eden's own + `ParamPackage` serializes them. +- **The relay remaps by orientation** before anything reaches an emulator (see + [`SidewaysMapper`](#sidewaysmapper)), so which physical button arrives at a given Android control + differs between a sideways single Joy-Con and a pair. Both Dolphin and Eden configs resolve a + customized source to what that body actually emits. + ## Device identity An emulator addresses a pad by `port` — its enumeration rank, not the player number — plus, for Eden, a `guid` built from vendor/product IDs. Both are read from the live input-device list on every -setup, never derived: handheld firmware may re-publish an external gamepad under the built-in -controller's IDs (AYN's Odin/Thor line does), and a binding with the wrong guid is silently -ignored. Each setup also clears the player's old keys, so a layout or port change can't leave a -stale binding firing on another player's port. +setup, never derived. A guessed number binds a config to the wrong device or to none, and any +handheld with a built-in controller already occupies the low numbers. + +**Every emulator picks its own quantity, and none of them is the player number.** Each rule below is +read from that emulator's own source and mirrored in `VirtualGamepadIdentity`: + +- **Dolphin** takes the id in its `Android//` qualifier from + `InputDevice.getControllerNumber()`, Android's gamepad enumeration counter. + `ControllerInterface::AddDevice` prefers `GetPreferredId()`, which the Android backend fills from + `getControllerNumber()`, falling back to a duplicate-name index only for non-gamepads. +- **Eden** (yuzu lineage) numbers `port` by walking `InputDevice.getDeviceIds()` and counting + *every* physical game controller it passes, so any built-in pad shifts ours along. In + `InputHandler.getDevices()` a controller number already registered is skipped but still consumes + a port, which `edenGamepadPorts` reproduces. + +The same goes for the vendor/product ids behind Eden's `guid`: some handheld firmware re-publishes +an external gamepad under the built-in controller's ids (AYN's Odin/Thor line does), leaving two +devices carrying our name, and a binding with the wrong guid is silently ignored. So every field of +a player's identity is taken from one and the same `InputDevice` — the last match, which is the +republished one where that happens. + +Each setup also clears the player's old keys, so a layout or port change can't leave a stale +binding firing on another player's port. diff --git a/feature/connection/data/src/main/kotlin/com/joegec/joycon2android/connection/SpiColorParser.kt b/feature/connection/data/src/main/kotlin/com/joegec/joycon2android/connection/SpiColorParser.kt index d32fa8c..e53170c 100644 --- a/feature/connection/data/src/main/kotlin/com/joegec/joycon2android/connection/SpiColorParser.kt +++ b/feature/connection/data/src/main/kotlin/com/joegec/joycon2android/connection/SpiColorParser.kt @@ -1,23 +1,6 @@ package com.joegec.joycon2android.connection -/** - * Extracts the Joy-Con 2 shell accent color from an SPI-flash read reply. - * - * The controller stores several colors in SPI flash. The body color at - * 0x013019 is the near-black shell, identical across both Switch 2 Joy-Cons; - * the accent color at [ACCENT_COLOR_ADDRESS] is the per-side colour (coral on - * the right, blue on the left) that actually identifies a controller. We - * request a read of the surrounding DeviceInfo block and pull the accent out - * of the reply. - * - * Reply layout (command-response characteristic, little-endian), confirmed - * against a live controller: - * [0] report type (0x02 = SPI) - * [3] command (0x04 = SPI read) - * [8] data length - * [12..15] source address (LE) — echoes the requested read address - * [16..] data bytes, starting at the source address - */ +/** Pulls the shell accent colour out of an SPI-flash read reply: docs/protocol.md#spi-reads. */ object SpiColorParser { private const val REPORT_TYPE_SPI = 0x02 @@ -25,13 +8,9 @@ object SpiColorParser { private const val ADDRESS_OFFSET = 0x0C private const val DATA_OFFSET = 0x10 - /** SPI flash address of the shell accent color, 3 bytes RGB. */ const val ACCENT_COLOR_ADDRESS = 0x01301F - /** - * Returns the packed 0xRRGGBB accent color from an SPI-read reply, or null - * if [reply] is not an SPI-read reply or doesn't span the accent address. - */ + /** Packed 0xRRGGBB, or null if this is not an SPI read or does not span the accent address. */ fun parseAccentColor(reply: ByteArray): Int? { if (reply.size < DATA_OFFSET) return null if (reply[0].toInt() and 0xFF != REPORT_TYPE_SPI) return null diff --git a/feature/connection/domain/src/main/kotlin/com/joegec/joycon2android/connection/StickCalibrator.kt b/feature/connection/domain/src/main/kotlin/com/joegec/joycon2android/connection/StickCalibrator.kt index d7c209c..a6bd24e 100644 --- a/feature/connection/domain/src/main/kotlin/com/joegec/joycon2android/connection/StickCalibrator.kt +++ b/feature/connection/domain/src/main/kotlin/com/joegec/joycon2android/connection/StickCalibrator.kt @@ -3,22 +3,11 @@ package com.joegec.joycon2android.connection import com.joegec.joycon2android.model.JoyconInput /** - * Rescales one controller's raw stick readings onto the full 0..4095 range, centred on 2048, - * that every downstream consumer assumes. + * Rescales one controller's raw sticks onto the full 0..4095 range, centred on 2048, that every + * downstream consumer assumes. Measured travel and rest points: docs/protocol.md#stick-range-and-centre. * - * Measured on hardware (2026-09): the raw 12-bit sticks reach only about +-1250 LSB of travel - * (full left 900, full right 3400) and they do not rest at 2048 — left Joy-Con x 2080 / y 2157, - * right x 2014 / y 2022. Taking 2048 as both the centre and the half-span therefore leaves full - * deflection at roughly 60% of range with a permanent 4-5% drift at rest. - * - * Travel is asymmetric about rest, so each direction carries its own span — the same - * centre/below/above triple the controller's own factory calibration stores. Spans start at - * [seedHalfSpan] and only ever widen, so a stick reaches full tilt from the first packet and - * self-corrects to units that travel further. - * - * Centre is learned from the first still window after connect and then frozen. Gyro bias can be - * re-learned whenever the controller goes quiet, but a stick held at full deflection is perfectly - * still, so "no movement means at rest" would happily adopt full tilt as centre. + * Centre is learned from the first still window and then frozen, because a stick held at full + * deflection is perfectly still too; spans only ever widen, so a stick tilts fully from packet one. */ class StickCalibrator( restWindowSize: Int = DEFAULT_REST_WINDOW, diff --git a/feature/dsu/domain/src/main/kotlin/com/joegec/joycon2android/dsu/DsuSlots.kt b/feature/dsu/domain/src/main/kotlin/com/joegec/joycon2android/dsu/DsuSlots.kt index 2132f7a..7e1fefe 100644 --- a/feature/dsu/domain/src/main/kotlin/com/joegec/joycon2android/dsu/DsuSlots.kt +++ b/feature/dsu/domain/src/main/kotlin/com/joegec/joycon2android/dsu/DsuSlots.kt @@ -3,14 +3,8 @@ package com.joegec.joycon2android.dsu import com.joegec.joycon2android.model.PlayerState /** - * Maps players onto the protocol's four slots. Player N streams on slot N-1, so P5-P8 get no - * slot and are not served. - * - * A pad packet carries exactly one accelerometer and gyroscope, so a player holding two Joy-Cons - * cannot report both hands on one slot: the second hand needs a slot of its own for an emulator - * to read it (Dolphin's Nunchuk accelerometer, say). Players themselves always win the slot their - * number gives them; slotted pairs then take whatever is left, highest first, in player order. - * Four players therefore leave nothing over and no pair gets a second hand. + * Maps players onto the protocol's four slots, and a pair's second hand onto a slot of its own + * since one packet carries one IMU: docs/dsu-motion.md#slots. */ object DsuSlots { const val COUNT = 4 diff --git a/feature/dsu/domain/src/main/kotlin/com/joegec/joycon2android/dsu/emulator/DolphinWiimoteConfig.kt b/feature/dsu/domain/src/main/kotlin/com/joegec/joycon2android/dsu/emulator/DolphinWiimoteConfig.kt index d4030da..3121d5c 100644 --- a/feature/dsu/domain/src/main/kotlin/com/joegec/joycon2android/dsu/emulator/DolphinWiimoteConfig.kt +++ b/feature/dsu/domain/src/main/kotlin/com/joegec/joycon2android/dsu/emulator/DolphinWiimoteConfig.kt @@ -18,21 +18,14 @@ import com.joegec.joycon2android.model.JoyconButton import com.joegec.joycon2android.model.PlayerState /** - * Generates Dolphin's WiimoteNew.ini button mappings for the DSU device, one `[WiimoteN]` - * section per assigned player (player N streams on DSU slot N-1 → `DSUClient//Joycon2`, - * the name matching our [DolphinDsuConfig] entry), driven by the user's customizable Joy-Con -> - * Wiimote/Nunchuk mapping. By default a single sideways Joy-Con drives the D-pad target from its - * own analog stick with no extension, and a pair drives it from the physical D-pad and exposes its - * left stick as the Nunchuk; a single Joy-Con gains a Nunchuk once the user binds one of its controls. [DS4_NAMES]/[PAD_NAMES] are the fixed, body-independent DS4-convention names the DSU - * device exposes for each Android input (see the in-app mapping table); [specFor] resolves a - * customized source to the one its body actually emits. + * Generates Dolphin's `WiimoteNew.ini` bindings for the DSU device, one `[WiimoteN]` section per + * assigned player, driven by the user's own Joy-Con → Wiimote/Nunchuk mapping. What it writes and + * why, measurements included: docs/dsu-motion.md#dolphin-wii-remote-mapping. */ object DolphinWiimoteConfig { val path = DolphinPaths.config("WiimoteNew.ini") - // Dolphin clamps the pointer's accumulated yaw to half of this, and its 25 degrees is a living - // room's worth: a captured aiming session (2026-09) swung +-20 to 25, so the cursor spent its - // time pinned against the clamp. Recenter (below) is what pulls it back when it drifts. + // Dolphin's 25 clamps the cursor after +-12.5 degrees of turn, which a hand-held aim overruns. private const val IMU_TOTAL_YAW_DEGREES = 60 // Keeps a tilted grip's gravity leak from nudging the virtual remote off its neutral position. @@ -90,11 +83,9 @@ object DolphinWiimoteConfig { private val IMU_CONTROLS = ACCEL_DIRECTIONS.map { "IMUAccelerometer/$it" to "Accel $it" } + GYRO_DIRECTIONS.map { "IMUGyroscope/$it" to "Gyro $it" } - // A lone Joy-Con streams in its sideways grip (SidewaysMotion); turning that grip back about the - // button face restores the Joy-Con's own body, which is the remote the player aims down its - // shoulder edge. The bodies rotate into their grips opposite ways, so their tables are each - // other half a turn — and a LEFT Joy-Con's own body already *is* a Wii Remote held sideways, - // since a sideways remote's nose points left just as its L/ZL edge does. + // A lone Joy-Con streams in its sideways grip (SidewaysMotion); these turn it back about the + // button face onto the body the player actually aims. Two tables because the bodies rotate into + // their grips opposite ways: docs/dsu-motion.md#sideways-joy-cons. private val SIDEWAYS_REMOTE_INPUTS = mapOf( "Accel Left" to "Accel Backward", "Accel Right" to "Accel Forward", "Accel Forward" to "Accel Left", "Accel Backward" to "Accel Right", @@ -108,19 +99,16 @@ object DolphinWiimoteConfig { "Gyro Roll Left" to "Gyro Pitch Down", "Gyro Roll Right" to "Gyro Pitch Up", ) - // So the sideways-remote layouts change one body's motion: a right Joy-Con gives up its own - // body — and with it the R/ZR edge as the nose, aiming moving to the tail — to read gravity the - // way a wheel game expects. A left Joy-Con needs no turn either way. + // Only a right Joy-Con gives up its own body to steer true; a left one already is a sideways + // remote, its nose and its L/ZL edge pointing the same way, so it needs no turn either way. private fun bodyInputs(side: JoyconSide, sidewaysRemote: Boolean): Map = when (side) { JoyconSide.DUAL -> emptyMap() JoyconSide.LEFT -> SIDEWAYS_REMOTE_INPUTS JoyconSide.RIGHT -> if (sidewaysRemote) SIDEWAYS_REMOTE_INPUTS else RIGHT_BODY_INPUTS } - // A sideways remote's d-pad turns with it: the player's up is the remote's right. Dolphin does - // this itself (dpad_sideways_bitmasks) when its Sideways Wii Remote option is on, but that - // option also turns the accelerometer a quarter, which our own table has already done — so the - // option stays off and the four bindings are turned here instead. + // The player's up is a sideways remote's right. Dolphin's own option would turn the + // accelerometer a second time, so it stays off and these turn the bindings instead. private val SIDEWAYS_DPAD_KEYS = mapOf( WiimoteButton.DPadUp to "D-Pad/Right", WiimoteButton.DPadRight to "D-Pad/Down", @@ -131,47 +119,13 @@ object DolphinWiimoteConfig { private fun dolphinKey(target: WiimoteButton, sideways: Boolean): String = (if (sideways) SIDEWAYS_DPAD_KEYS[target] else null) ?: DOLPHIN_KEYS.getValue(target) - // A trick is a flick, and a flick of something Joy-Con sized is mostly rotation: captured ones - // peak past 1200 deg/s summed while carrying barely a g of linear jerk, where jerking a real Wii - // Wheel throws the whole thing. Mario Kart Wii has no MotionPlus and reads only the - // accelerometer, so the flick never reaches it — on hardware it wouldn't either. The gyroscope - // therefore fires the trick, which hardware could not do. Summing each axis with its opposite - // input gives |rate|, since Dolphin clamps one of any pair at zero. - // - // Dolphin's own Shake group is not the way to deliver it: bound straight to a key in Dolphin's - // config, a full 7 g oscillation of it never landed a trick (tested 2026-09). The accelerometer - // is the path that demonstrably reaches the game, since steering is read from it, so the jerk - // goes there — onto one input of each pair, a diagonal no axis can miss, with the opposites left - // alone so the pair cannot cancel it. - // - // It is a shake, not a push. What actually landed one (2026-09) was shaking a Joy-Con hard for - // about a second, so the synthetic trick copies that shape: an oscillation held for - // TRICK_SECONDS, with each input of a pair swung in antiphase so the remote is thrown back and - // forth rather than leaned on. Amplitude is not the lever — an emulated Wii Remote saturates - // around +3.9/-4.9 g (ACCEL_ZERO_G 0x80, ACCEL_ONE_G 0x9A over 8 bits), which - // TRICK_ACCELERATION already passes — so a bigger number only clips sooner. Duration and - // swinging are what a held push was missing, and pulse() gives a flick and a held button the - // same one however long either lasted. - // - // A rate alone cannot tell a flick from a turn, because steering a lone Joy-Con held as a wheel - // *is* rotation — which is why only single Joy-Cons suffered for it: a pair steers from the - // Nunchuk's stick with its remote hand still. Subtracting a slew limiter leaves only what climbs - // faster than the limiter can follow. - // - // Both numbers are measured, over a capture of flicks and a capture of hard steering read back - // by tools/flick_stats.py (2026-09-22, right Joy-Con, 15 ms stream). Flicks peaked at 11 to 16 - // rad/s and left 5.6 to 9.1 behind the limiter; 25 s of the sharpest steering peaked at 3.6 and - // left at most 1.2. A slower limiter is worse, not better: it lifts a flick's residual but lifts - // steering's faster, and the ratio between them — all that matters — falls from 4.7 at 0.01 to - // 3.8 at 0.02 and 2.0 at 0.04. - // - // pulse() fires as its input crosses a half, so the threshold is 2.5 rad/s of residual: 2.1x - // above the worst steering and 2.2x below the weakest flick, which is as evenly as two sparsely - // sampled distributions can be split. Erring low is right anyway — a trick fired by accident - // costs nothing, since the game only tricks a kart already airborne, while one fired *while - // steering* costs plenty, the shake landing on the accelerometer the wheel is read from. - private const val FLICK_RADIANS = 5 - private const val FLICK_SETTLE_SECONDS = 0.01 + // A trick is fired from the gyroscope and delivered as a shake of the accelerometer: a Joy-Con + // flick carries almost no linear jerk, and Mario Kart Wii has no MotionPlus, so it reads only + // the accelerometer. Every constant below is measured, and the two obvious alternatives — + // amplifying the accelerometer's own transient, and Dolphin's Shake group — were tried and do + // not work. Numbers, measurements and dead ends: docs/dsu-motion.md#sideways-joy-cons. + private const val FLICK_RADIANS = 5 // pulse() fires at half of it: 2.5 rad/s past the limiter + private const val FLICK_SETTLE_SECONDS = 0.01 // the limiter that tells a flick from a turn private const val TRICK_ACCELERATION = 50 // m/s^2, past what an emulated remote can report private const val TRICK_SECONDS = 0.6 private const val TRICK_PERIOD_SECONDS = 0.15 @@ -182,12 +136,7 @@ object DolphinWiimoteConfig { private val TRICK_LEADING = setOf("IMUAccelerometer/Up", "IMUAccelerometer/Left", "IMUAccelerometer/Forward") - /** - * What fires a trick: a flick, and whatever is bound to Shake. Every body flicks, a pair - * included — its remote hand is still while the Nunchuk's stick does the steering — but only - * while the layout plays as a sideways remote, so no other game is handed a shake it never asked - * for when its remote is swung. - */ + /** Only a layout that plays as a sideways remote flicks, so no other game is handed a shake. */ private fun shakeTrigger(side: JoyconSide, sidewaysRemote: Boolean, bound: List?): String? { val rate = "(${GYRO_DIRECTIONS.joinToString(" + ") { "`Gyro $it`" }})" val flick = if (sidewaysRemote) "($rate - smooth($rate, $FLICK_SETTLE_SECONDS)) / $FLICK_RADIANS" else null @@ -211,18 +160,9 @@ object DolphinWiimoteConfig { } + listOf("IMUIR/Enabled = True", "IMUIR/Total Yaw = $IMU_TOTAL_YAW_DEGREES") } - // Dolphin's emulated remote only ever translates through the Swing group — the IMU path feeds - // rotation alone — so the virtual remote stays pinned in space and the IR dots never change - // separation. Games that read a thrust as distance to the sensor bar (Wii Play Billiards charges - // cue strength that way) see nothing from accel and gyro alone. A push toward the screen lands - // on whichever input the remote's nose reads; pairing it with its opposite makes the value - // signed, since Dolphin clamps a single input at zero. - // - // An accelerometer cannot tell gravity from sustained acceleration, so a tilted grip parks up to - // 1 g on that axis and Swing reads it as a thrust held forever. smooth() is a slew limiter, so - // subtracting it high-passes the axis: the tracker catches a static tilt within a third of a - // second and cancels it, while a thrust's ~80 ms transient outruns it. Range then trims the - // inputs, which arrive at 9.8 per g, to a full-distance lunge at roughly a 1.5 g thrust. + // Swing is the only way a thrust toward the sensor bar reaches a game, and it is signed and + // high-passed because an accelerometer cannot tell one from a tilted grip: + // docs/dsu-motion.md#dolphin-wii-remote-mapping. private fun swingLines(side: JoyconSide, sidewaysRemote: Boolean): List { // A push toward the screen runs along the remote's nose, whichever input that body reads it from. val body = bodyInputs(side, sidewaysRemote) @@ -236,9 +176,8 @@ object DolphinWiimoteConfig { ) } - // A pad packet carries one IMU, so the Nunchuk hand streams on a slot of its own and the - // extension reads it across devices: Dolphin splits a control on its last colon, so - // `:` reaches another pad. A real Nunchuk has no gyroscope, only this accel. + // Dolphin splits a control on its last colon, so `:` reaches the second hand's + // slot. A real Nunchuk has no gyroscope, only this accel. private fun nunchukImuLines(slot: Int): List = ACCEL_DIRECTIONS.map { "Nunchuk/IMUAccelerometer/$it = `DSUClient/$slot/Joycon2:Accel $it`" } diff --git a/feature/dsu/domain/src/main/kotlin/com/joegec/joycon2android/dsu/emulator/EdenDsuConfig.kt b/feature/dsu/domain/src/main/kotlin/com/joegec/joycon2android/dsu/emulator/EdenDsuConfig.kt index 28bec71..f8e4133 100644 --- a/feature/dsu/domain/src/main/kotlin/com/joegec/joycon2android/dsu/emulator/EdenDsuConfig.kt +++ b/feature/dsu/domain/src/main/kotlin/com/joegec/joycon2android/dsu/emulator/EdenDsuConfig.kt @@ -23,26 +23,9 @@ import com.joegec.joycon2android.model.PlayerState /** * Binds Eden to our DSU server in `config.ini`'s `[Controls]`: the three server switches, then a - * whole controller per assigned player — buttons, sticks and motion — driven by the user's - * customizable Joy-Con -> Pro Controller mapping. A cemuhook pad carries the full DS4 button set - * and both sticks, so nothing else is needed for a player to play; the Virtual Gamepad is an - * alternative route to the same keys, not a prerequisite. - * - * Eden's cemuhook engine addresses a pad by `guid`, `port` and `pad`, and nothing else. The - * server's `guid` is its IPv4 address as a 32-bit integer, hex, right-aligned in an otherwise-zero - * UUID written raw (no dashes); `port` is the UDP port, not a controller index. `pad` is a global - * index, `client * 4 + slot`, so with our server as Eden's only client it is the DSU slot itself. - * - * [DS4_BITS] is the protocol wiring, not a preference: cemuhook's two button bytes packed - * low-then-high are exactly Eden's `PadButton` values, and Home and the touchpad click ride the - * bytes above them. Sticks arrive as raw bytes that Eden reads as `(v - 127) / 127`, so the - * axis pairs need no inversion. - * - * Motion is the one thing a pad cannot share: a pad packet carries a single accelerometer and - * gyroscope, so `motion` is always index 0 and each hand streams on a slot of its own - * ([DsuSlots]). The hand holding the player's own slot lands on `motionright`, its second hand on - * `motionleft`; one Joy-Con, or a pair that ran out of slots, binds both to the same pad, which is - * what Eden's own auto-mapping does for every device. + * whole controller per assigned player — buttons, sticks and motion — driven by the user's own + * Joy-Con → Pro Controller mapping. How Eden addresses a cemuhook pad, and why the button table + * reads the way it does: docs/dsu-motion.md#edens-cemuhook-bindings. */ object EdenDsuConfig { private const val ENGINE = "cemuhookudp" diff --git a/feature/dsu/domain/src/main/kotlin/com/joegec/joycon2android/dsu/motion/MotionConverter.kt b/feature/dsu/domain/src/main/kotlin/com/joegec/joycon2android/dsu/motion/MotionConverter.kt index 88b0e37..7bd5ca4 100644 --- a/feature/dsu/domain/src/main/kotlin/com/joegec/joycon2android/dsu/motion/MotionConverter.kt +++ b/feature/dsu/domain/src/main/kotlin/com/joegec/joycon2android/dsu/motion/MotionConverter.kt @@ -3,35 +3,13 @@ package com.joegec.joycon2android.dsu.motion import com.joegec.joycon2android.model.JoyconInput /** - * Raw Joy-Con IMU → cemuhook/DS4 motion frame. + * Raw Joy-Con IMU → cemuhook's DS4 motion frame. Both frames, the scale factors and the sign + * history are in docs/dsu-motion.md#motion-frame — the signs are DS4 hardware convention rather + * than a right-handed frame, so verify any change against Dolphin's pointer rather than reasoning + * about it. * - * Scale factors are the Switch 1 family values, verified on Joy-Con 2 hardware - * (2026-06: at rest gravity reads exactly −1.00 g): accel ±8 g → 0.000244 g/LSB, - * gyro ±2000 dps → 0.06103 dps/LSB. - * - * Measured Joy-Con (R) raw frame: X = controller's RIGHT, Y = toward the tail, Z = out of - * the button face. X was documented as "left" until 2026-09, when a rail-down static pose - * (SL/SR against the table, so gravity points toward the controller's right) read +1 g on - * the wire's left axis — mirrored. Left/right tilt reached games reversed, and because - * angular velocity is a pseudovector, roll had to be mirrored with it to stay physically - * consistent, which is why both flipped together. The cemuhook wire frame, - * anchored against Dolphin's on-screen Wii pointer (its complementary filter makes the - * ACCELEROMETER the authority on sustained pitch — gyro signs alone can't be judged - * from pointer direction): x = left, y = down through the controller, z = toward the - * player. Flat at rest → accel (0,−1,0); nose up → accel z = −1 and gyro pitch − - * (verified via pointer flicks: gyro shows up in the fast response, accel in the - * settled position); turn right → +yaw; roll right → +roll. The axis signs are DS4 - * hardware history, not a consistent right-handed frame — verify any change against - * the pointer itself, fast and slow movements separately. - * - * Yaw is the one sign no measurement here pins: it turns about gravity, so a static pose - * cannot see it and the accel/gyro consistency check cannot either. It is kept as the - * pointer's horizontal response reports it. Mirroring x strictly implies mirroring yaw too, - * so if horizontal pointing ever reads backwards, flip yaw rather than re-deriving this. - * - * This converts whatever frame it is given; a lone sideways Joy-Con is turned into its grip - * first by [SidewaysMotion]. Left Joy-Con and Pro are assumed to share the raw frame — - * unverified; recalibrate with tools/dsu_client if motion feels rotated. + * Converts whatever frame it is given; a lone sideways Joy-Con is turned into its grip first by + * [SidewaysMotion]. */ object MotionConverter { diff --git a/feature/dsu/domain/src/main/kotlin/com/joegec/joycon2android/dsu/motion/SidewaysMotion.kt b/feature/dsu/domain/src/main/kotlin/com/joegec/joycon2android/dsu/motion/SidewaysMotion.kt index 997918c..4a27a4e 100644 --- a/feature/dsu/domain/src/main/kotlin/com/joegec/joycon2android/dsu/motion/SidewaysMotion.kt +++ b/feature/dsu/domain/src/main/kotlin/com/joegec/joycon2android/dsu/motion/SidewaysMotion.kt @@ -5,15 +5,10 @@ import com.joegec.joycon2android.model.JoyconInput import com.joegec.joycon2android.model.Side /** - * Turns a lone Joy-Con's IMU 90° about its button face into the grip it is held in sideways. The - * pad's buttons and stick already arrive in that grip, so an emulator that presents it as a Pro - * Controller (Eden) needs its motion there too, the way SDL delivers a real horizontal Joy-Con; - * otherwise tilting reads as if the Joy-Con's nose pointed at the screen. - * - * Directions measured in Eden's Mario Kart 8 (2026-09), held up like a wheel: turning each body the - * way `SidewaysMapper` turns its stick steered upside down on both Joy-Cons, so the IMU turns the - * opposite way — its raw axes evidently don't line up with the stick's. Z (out of the face) is the - * rotation axis, so it passes through unchanged. + * Turns a lone Joy-Con's IMU 90° about its button face into the grip it is held in, so an emulator + * presenting it as a Pro Controller reads its tilt the way SDL delivers a real horizontal Joy-Con. + * The direction was measured, and is the opposite of the stick's turn: + * docs/dsu-motion.md#sideways-joy-cons. */ object SidewaysMotion { diff --git a/feature/gamepad/data/src/main/kotlin/com/joegec/joycon2android/gamepad/UhidRelay.kt b/feature/gamepad/data/src/main/kotlin/com/joegec/joycon2android/gamepad/UhidRelay.kt index 4d258d9..3ecd41e 100644 --- a/feature/gamepad/data/src/main/kotlin/com/joegec/joycon2android/gamepad/UhidRelay.kt +++ b/feature/gamepad/data/src/main/kotlin/com/joegec/joycon2android/gamepad/UhidRelay.kt @@ -167,11 +167,8 @@ class UhidRelay(private val name: String, private val playerIndex: Int) { 0x09, 0x05, // Usage (Game Pad) 0xA1.toByte(), 0x01, // Collection (Application) - // Buttons 1-15, the most a Game Pad collection can spend: Linux maps Button n to - // BTN_GAMEPAD + n - 1, and that range ends at BTN_THUMBR (Button 15). A 16th would land - // on 0x13F, which no Android key layout names, so it would reach no app at all. - // ReportMapper picks which Joy-Con button takes which bit so that each lands on its - // same-named Android keycode. + // Buttons 1-15, the most a Game Pad collection can spend before a button reaches no + // app at all: docs/virtual-gamepad.md#buttons-and-keycodes. 0x05, 0x09, // Usage Page (Button) 0x19, 0x01, // Usage Minimum (Button 1) 0x29, 0x0F, // Usage Maximum (Button 15) diff --git a/feature/gamepad/domain/src/main/kotlin/com/joegec/joycon2android/gamepad/emulator/DolphinGcpadConfig.kt b/feature/gamepad/domain/src/main/kotlin/com/joegec/joycon2android/gamepad/emulator/DolphinGcpadConfig.kt index 4399b80..d8c5eb6 100644 --- a/feature/gamepad/domain/src/main/kotlin/com/joegec/joycon2android/gamepad/emulator/DolphinGcpadConfig.kt +++ b/feature/gamepad/domain/src/main/kotlin/com/joegec/joycon2android/gamepad/emulator/DolphinGcpadConfig.kt @@ -17,16 +17,9 @@ import com.joegec.joycon2android.model.JoyconButton import com.joegec.joycon2android.model.PlayerState /** - * Generates Dolphin's GCPadNew.ini mappings for the Virtual Gamepad, one `[GCPadN]` section per - * assigned player, driven by the user's customizable Joy-Con -> GameCube mapping. Each player's - * UHID pad shows up to Dolphin as a distinct Android input device - * (`Android//Joy-Con Virtual Gamepad `); the relay remaps buttons/sticks - * by orientation (see [SidewaysMapper]), so which physical button reaches a given Android control differs - * between a sideways single Joy-Con and a pair. [ANDROID_NAMES]/[HAT_NAMES] are the fixed, - * body-independent Dolphin names for each Android keycode/hat direction our virtual pad emits - * (captured from a real mapping); [specFor] resolves a customized source to the one its body - * actually emits. Every stick direction is its own Dolphin input, so a stick target can mix - * stick tilts and buttons freely without losing analog range on the tilts. + * Generates Dolphin's `GCPadNew.ini` mappings for the Virtual Gamepad, one `[GCPadN]` section per + * assigned player, driven by the user's own Joy-Con → GameCube mapping. The device qualifier, the + * name tables and why each stick direction binds separately: docs/virtual-gamepad.md#emulator-config. */ object DolphinGcpadConfig { val path = DolphinPaths.config("GCPadNew.ini") diff --git a/feature/gamepad/domain/src/main/kotlin/com/joegec/joycon2android/gamepad/emulator/EdenGamepad.kt b/feature/gamepad/domain/src/main/kotlin/com/joegec/joycon2android/gamepad/emulator/EdenGamepad.kt index 5e97dbc..2bced2c 100644 --- a/feature/gamepad/domain/src/main/kotlin/com/joegec/joycon2android/gamepad/emulator/EdenGamepad.kt +++ b/feature/gamepad/domain/src/main/kotlin/com/joegec/joycon2android/gamepad/emulator/EdenGamepad.kt @@ -2,13 +2,9 @@ package com.joegec.joycon2android.gamepad.emulator /** * How Eden addresses one of our virtual gamepads: the `port` it assigns while enumerating input - * devices, plus the `guid` it derives from the device's USB ids — product then vendor, each as a - * 16-digit hex half. - * - * Both are read from the live input device, never assumed. Handhelds whose firmware re-publishes - * external gamepads under the built-in controller's vendor/product (AYN's Odin/Thor line does this) - * hand the emulator ids that are not the ones our uhid device was created with, and a binding whose - * guid doesn't match the device Eden sees is silently ignored. + * devices, and the `guid` it derives from the device's USB ids — product then vendor, each a + * 16-digit hex half. Both read from the live device, never assumed: + * docs/virtual-gamepad.md#device-identity. */ data class EdenGamepad(val port: Int, val guid: String) { companion object { diff --git a/feature/gamepad/domain/src/main/kotlin/com/joegec/joycon2android/gamepad/emulator/EdenGamepadConfig.kt b/feature/gamepad/domain/src/main/kotlin/com/joegec/joycon2android/gamepad/emulator/EdenGamepadConfig.kt index c26ba69..832b909 100644 --- a/feature/gamepad/domain/src/main/kotlin/com/joegec/joycon2android/gamepad/emulator/EdenGamepadConfig.kt +++ b/feature/gamepad/domain/src/main/kotlin/com/joegec/joycon2android/gamepad/emulator/EdenGamepadConfig.kt @@ -19,26 +19,11 @@ import com.joegec.joycon2android.model.JoyconButton import com.joegec.joycon2android.model.PlayerState /** - * Generates Eden's `config.ini` `[Controls]` bindings for the Virtual Gamepad, driven by the - * user's customizable Joy-Con -> Pro Controller mapping. - * - * The relay exposes every player as one standard Android HID gamepad, wired so each Joy-Con button - * lands on the keycode of the same name (Switch A is BUTTON_A, ZL is BUTTON_L2, − is BUTTON_SELECT). - * The d-pad is the HID hat (HAT_X = axis 15, HAT_Y = axis 16); [KEY_CODES]/[HAT_AXES] are that - * fixed, body-independent wiring. - * - * Eden does not translate a sideways single Joy-Con: it only sets an `is_horizontal` flag (which on - * hardware the game's own nn::hid honours, but Eden has no equivalent), and it masks an npad by - * type — a JoyconLeft can't even report A/B/X/Y. So we present each single Joy-Con as a Pro - * Controller and apply the sideways rotation ourselves: [inputFor] resolves a customized source to - * what its body actually emits, so e.g. the left Joy-Con's d-pad resolves to the face-button - * keycodes it is rotated onto. - * - * A target stick whose directions still follow one real stick binds its axes, keeping the analog - * range; any other arrangement is assembled from its directions with [EdenControls.stickFromButtons]. - * - * Each pad's [EdenGamepad] — port and guid both — comes from the app's read of the live - * input-device list, since neither can be derived from the player number. + * Generates Eden's `config.ini` `[Controls]` bindings for the Virtual Gamepad, driven by the user's + * own Joy-Con → Pro Controller mapping. The relay exposes every player as one standard Android HID + * gamepad; that fixed wiring, and why a single Joy-Con is presented as a Pro Controller and rotated + * on our side, are in docs/virtual-gamepad.md#buttons-and-keycodes and + * docs/virtual-gamepad.md#why-theyre-set-up-as-pro-controllers. */ object EdenGamepadConfig { // Joy-Con button -> the Android keycode the relay's HID gamepad emits for it. ReportMapper From bd5b10f9dc3a76c7e17f242395ba2fdddb99d1f6 Mon Sep 17 00:00:00 2001 From: Joe Barker Date: Tue, 22 Sep 2026 21:45:04 +0100 Subject: [PATCH 07/16] Send the long comments to the docs, in the guidance too The rule that let them grow was the one saying physical-world comments "ARE welcome and can be detailed". They cannot be derived from code, which is true, but that is an argument for writing them down properly rather than for putting them at the top of whichever class happened to need them first. So: three lines is the ceiling, and past it the tell is that the comment has stopped explaining the line in front of it and started explaining the subject. Those go to docs/, which is named per kind of fact so the next one has a destination rather than a judgement call. One home per fact is the lesson from this branch specifically -- dsu-motion.md described the trick expression with a deadzone() for two commits after it came out of the code, because the fact had two homes and only one was updated. The last bullet is what stops the rest reading as "no comments": what a reader needs at that line and cannot reconstruct from it stays, which is why the HID descriptor still labels its bytes. Co-Authored-By: Claude Opus 5 --- CLAUDE.md | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/CLAUDE.md b/CLAUDE.md index a67219f..eb2b427 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -12,12 +12,25 @@ - Code should read like well-written prose - Methods should be short enough that they explain themselves - Methods and composables should be reusable like components + ## Comments - Default to **no comment**: a well-named class or method is its own documentation - Never write a comment that restates the code, the signature, or what the next line does — if a comment can be made redundant by renaming or extracting, do that instead - A genuine "why" (a constraint the code cannot express, e.g. "StateFlow conflation requires a synchronous callback") gets one or two lines, never a paragraph - New classes get **no KDoc by default**; earn it only with a non-obvious "why" -- Comments describing the physical world ARE welcome and can be detailed: BLE protocol details, byte layouts, timing constraints, and hardware behavior being mirrored (e.g. Switch combo/LED conventions) — these cannot be derived from code +- **Three lines is the ceiling.** If a comment is outgrowing that, it has stopped explaining the + line in front of it and started explaining the subject — a derivation, a measurement, a byte + layout, an emulator's behaviour, why two other approaches failed. That belongs in + [`docs/`](docs/README.md), with a one-line pointer where the code needs it: + `/** … : docs/dsu-motion.md#motion-frame */` +- Physical-world facts still can't be derived from code — BLE protocol, byte layouts, timing + constraints, hardware conventions being mirrored — so write them down properly, in the doc that + owns them (`protocol.md`, `virtual-gamepad.md`, `dsu-motion.md`, `DESIGN.md`, `architecture.md`) + rather than at the top of whichever class happened to need them first +- **One home per fact.** A comment and a doc saying the same thing will drift, and the stale one is + found only once it has misled someone +- Keep in code only what a reader needs *at that line* and cannot reconstruct from it: a byte's + meaning in a descriptor, a constant's unit, what a workaround is working around - Litmus test before writing any comment: "could a reader reconstruct this from the code alone?" If yes, delete it ## SOLID Principles From 413459a6821a2ca87c4002553b2851a456035fe5 Mon Sep 17 00:00:00 2001 From: Joe Barker Date: Tue, 22 Sep 2026 22:00:52 +0100 Subject: [PATCH 08/16] Put a pair's 1 and 2 on the lower shoulders Mario Kart's pair now gives each hand both its shoulders: the remote's B and the Nunchuk's Z on the upper pair, 1 and 2 on the lower. It also settles two overlaps by accident and by design. The Joy-Con's own B had been firing the remote's 2 as well as its B, inherited from the Wii layout; 2 moving away leaves B to hop and nothing else. And 1 and 2 passed through X on the way here, which collided with the Nunchuk's C, so the lower shoulders were the better landing spot -- they were free precisely because B and Z had moved up. The one source still driving two targets is R, which hops and tricks, and that is the point of it. A target with several sources also reads "SR, Stick Up" now rather than "SR + Stick Up". The plus looked like both at once when it means either. Co-Authored-By: Claude Opus 5 --- .../buttonmapping/preset/MarioKartWiiMapping.kt | 12 +++++++----- .../buttonmapping/preset/WiiPresetsTest.kt | 2 ++ .../ui/components/MultiSelectDropdown.kt | 2 +- 3 files changed, 10 insertions(+), 6 deletions(-) diff --git a/core/buttonmapping/domain/src/main/kotlin/com/joegec/joycon2android/buttonmapping/preset/MarioKartWiiMapping.kt b/core/buttonmapping/domain/src/main/kotlin/com/joegec/joycon2android/buttonmapping/preset/MarioKartWiiMapping.kt index cf3efbe..8398842 100644 --- a/core/buttonmapping/domain/src/main/kotlin/com/joegec/joycon2android/buttonmapping/preset/MarioKartWiiMapping.kt +++ b/core/buttonmapping/domain/src/main/kotlin/com/joegec/joycon2android/buttonmapping/preset/MarioKartWiiMapping.kt @@ -23,6 +23,8 @@ import com.joegec.joycon2android.model.JoyconButton.SrRight import com.joegec.joycon2android.model.JoyconButton.Up import com.joegec.joycon2android.model.JoyconButton.X import com.joegec.joycon2android.model.JoyconButton.Y +import com.joegec.joycon2android.model.JoyconButton.ZL +import com.joegec.joycon2android.model.JoyconButton.ZR /** * A sideways Joy-Con laid out the way Mario Kart 8 uses one, so the same thumb does the same job in @@ -46,12 +48,12 @@ object MarioKartWiiMapping : MappingPreset { else -> buttons(side).buttonEntries() + dPadSticks(side).sourceEntries() } - // Held as a remote and a nunchuk, each index finger rests on that hand's shoulder, which is - // where the controller it stands in for keeps its trigger: the remote's B on the right, the - // Nunchuk's Z on the left. Hopping also keeps the Joy-Con's own B, so either the thumb or the - // index finger can do it. The trick rides the same shoulder as the hop, as SR does on a lone - // Joy-Con, so the finger that jumps is the finger that tricks. + // Held as a remote and a nunchuk, the four shoulders carry what each hand's controller keeps + // under a finger: the remote's B and the Nunchuk's Z on the upper pair, 1 and 2 on the lower. + // The hop also takes the Joy-Con's own B, and the trick rides its shoulder as SR does alone. private fun pairButtons(): Map = mapOf( + WiimoteButton.One to listOf(ZL), + WiimoteButton.Two to listOf(ZR), WiimoteButton.B to listOf(R, B), WiimoteButton.Shake to listOf(R), WiimoteButton.Minus to listOf(Minus), diff --git a/core/buttonmapping/domain/src/test/kotlin/com/joegec/joycon2android/buttonmapping/preset/WiiPresetsTest.kt b/core/buttonmapping/domain/src/test/kotlin/com/joegec/joycon2android/buttonmapping/preset/WiiPresetsTest.kt index 2d34629..dcf7ee3 100644 --- a/core/buttonmapping/domain/src/test/kotlin/com/joegec/joycon2android/buttonmapping/preset/WiiPresetsTest.kt +++ b/core/buttonmapping/domain/src/test/kotlin/com/joegec/joycon2android/buttonmapping/preset/WiiPresetsTest.kt @@ -66,6 +66,8 @@ class WiiPresetsTest { fun `Mario Kart moves a pair's index fingers onto the shoulders, and tricks from one`() { val pair = MarioKartWiiMapping.entries(JoyconSide.DUAL) + assertEquals("ZL", pair.getValue(WiimoteButton.One.name)) + assertEquals("ZR", pair.getValue(WiimoteButton.Two.name)) // The remote's trigger hand, and the Joy-Con's own B so either finger can hop. assertEquals("R|B", pair.getValue(WiimoteButton.B.name)) assertEquals("L", pair.getValue(WiimoteButton.NunchukZ.name)) // the Nunchuk's diff --git a/core/designsystem/src/main/kotlin/com/joegec/joycon2android/ui/components/MultiSelectDropdown.kt b/core/designsystem/src/main/kotlin/com/joegec/joycon2android/ui/components/MultiSelectDropdown.kt index bac32fe..b61e2b5 100644 --- a/core/designsystem/src/main/kotlin/com/joegec/joycon2android/ui/components/MultiSelectDropdown.kt +++ b/core/designsystem/src/main/kotlin/com/joegec/joycon2android/ui/components/MultiSelectDropdown.kt @@ -41,7 +41,7 @@ fun MultiSelectDropdown( modifier: Modifier = Modifier, ) { val selectedLabels = selectedIds.mapNotNull { id -> options.firstOrNull { it.first == id }?.second } - val label = selectedLabels.takeIf { it.isNotEmpty() }?.joinToString(" + ") + val label = selectedLabels.takeIf { it.isNotEmpty() }?.joinToString(", ") ?: options.firstOrNull()?.second ?: return var expanded by remember { mutableStateOf(false) } From d165be57ee646d9982b793026cca0281849fad11 Mon Sep 17 00:00:00 2001 From: Joe Barker Date: Tue, 22 Sep 2026 22:28:19 +0100 Subject: [PATCH 09/16] Jerk the remote the way it was flicked, so a wheelie holds A wheelie is a state an up-flick starts and a down-flick drops, where a trick takes any direction and only one per jump. The shake was a symmetric oscillation, so it started and cancelled a wheelie four times a second -- the "on and off" -- and the flick meant to drop one was answered by the same oscillation restarting it. Three measured changes, from captures of flicks, of hard steering, and of an actual race, read back by tools/flick_stats.py. The trigger reads pitch, and only pitch. A flick is 59 to 89% pitch on both bodies -- a lone sideways Joy-Con and a pair alike, despite a lone one being rotated into its grip before it reaches the wire -- while steering a wheel is roll and never passed 2.8 rad/s of pitch against a flick's 7 to 17. That separates them by axis rather than by rate, which no slew limiter could: the down-flick is a slow gesture, and a limiter quick enough to reject a turn ate all but 0.9 rad/s of it. So the limiter is gone and the bar is 4.5 rad/s. The wave is half rectified, so the jerks all go the way the flick did. Each direction locks the other out for 0.4 s, which the captures are what found: every flick rebounds the opposite way 0.12 to 0.32 s later, and a rebound is routinely stronger than a genuine flick elsewhere in the same run -- 8.5 against 7.0 -- so nothing but order can tell them apart. The gate sits on the pulse's input rather than its output, so a jerk already running finishes. Simulated against 45 s of real racing it turns 31 raw peaks into 11 alternating fires, rebounds included. Untested on device: the expression is simulated against real motion, not yet played. The one thing still assumed rather than measured is that Dolphin's `Gyro Pitch Up` is the positive half of wire pitch; if a wheelie starts on a down-flick, swap the pair in TRICK_AXES. flick_stats.py reports each gesture's axis and direction now, which is how any of this was knowable. Co-Authored-By: Claude Opus 5 --- README.md | 20 +++--- docs/dsu-motion.md | 48 ++++++------- .../dsu/emulator/DolphinWiimoteConfig.kt | 69 +++++++++++-------- .../dsu/emulator/DolphinWiimoteConfigTest.kt | 57 +++++---------- tools/flick_stats.py | 36 +++++++--- 5 files changed, 120 insertions(+), 110 deletions(-) diff --git a/README.md b/README.md index 7f1b1bb..05e2548 100644 --- a/README.md +++ b/README.md @@ -194,17 +194,15 @@ shoulder buttons. - **Turn the four D-pad bindings a quarter**: put the source you'd bind to Up on **D-Pad/Right**, Right on Down, Down on Left, Left on Up. A sideways remote's d-pad turns with it, so the player's up is the remote's right. - - For **tricks**, append - ``+ pulse(, 0.6) * sin(timer(0.15) * 6.2832) * 50`` to each - **IMUAccelerometer** input, where `` is - ``( - smooth(, 0.01)) / 5`` and `` is - ``(\`Gyro Pitch Up\` + \`Gyro Pitch Down\` + \`Gyro Roll Left\` + \`Gyro Roll Right\` + \`Gyro Yaw Left\` + \`Gyro Yaw Right\`)`` - — and add `+ 3.1416` inside the `sin` for Down, Right and Backward so they swing the other way. - A flick of a Joy-Con is nearly all rotation, which the game can't read from an accelerometer - alone, so the gyroscope shakes the accelerometer for you — and the `smooth` subtraction is what - keeps steering, which is also rotation, from setting it off - ([why](docs/dsu-motion.md#dolphin-wii-remote-mapping)). Don't use Dolphin's **Shake** group — - it doesn't land tricks. You can also bind **Shake** to a button. + - For **tricks and wheelies**, append + ``+ pulse(, 0.6) * max(sin(timer(0.15) * 6.2832), 0) * 50`` to **IMUAccelerometer/Up**, + where `` is ``(\`Gyro Pitch Up\` / 9) & not(pulse(\`Gyro Pitch Down\` / 9, 0.4))`` — + then the same on **/Down** with Up and Down swapped. A flick of a Joy-Con is nearly all + rotation, which the game can't read from an accelerometer alone + ([why](docs/dsu-motion.md#dolphin-wii-remote-mapping)). Steering is roll, never pitch, so it + can't set this off; the lock-out stops a flick's rebound cancelling the wheelie it just + started. Don't use Dolphin's **Shake** group — it doesn't land tricks. You can also bind + **Shake** to a button. Leave Dolphin's own **Sideways Wii Remote** option off either way — it would turn the accelerometer a second quarter. diff --git a/docs/dsu-motion.md b/docs/dsu-motion.md index 567f205..2a4b6e0 100644 --- a/docs/dsu-motion.md +++ b/docs/dsu-motion.md @@ -119,29 +119,31 @@ if the Joy-Con's nose pointed at the screen. trick by hand, shaking a Joy-Con hard for about a second, where a single held push did not. `pulse()` gives a flick and a held button the same shake however long either lasted. - **A rate alone cannot tell a flick from a turn**, because steering a lone Joy-Con held as a wheel - *is* rotation — which is why only single Joy-Cons suffered for it, a pair steering from the - Nunchuk's stick with its remote hand still. The trigger therefore subtracts a slew limiter, - `(rate − smooth(rate, 0.01)) / 5`, leaving only what climbs faster than the limiter can follow. - - Both numbers are measured, from a capture of flicks and a capture of hard steering read back by - [`tools/flick_stats.py`](../tools/README.md#flick-measurement) (2026-09-22, right Joy-Con, 15 ms - stream): - - | | peak rate | residual after the limiter | - |---|---|---| - | flicks (4) | 11–16 rad/s | 5.6, 6.1, 8.3, 9.1 | - | hard steering (25 s) | 3.6 rad/s | ≤ 1.2 | - - A *slower* limiter is worse, not better: it lifts a flick's residual but lifts steering's faster, - and the ratio between them — all that matters — falls from 4.7 at 0.01 to 3.8 at 0.02 and 2.0 at - 0.04. `pulse()` fires as its input crosses a half, so the threshold is 2.5 rad/s of residual: - 2.1× above the worst steering and 2.2× below the weakest flick. Erring low is right anyway — a - trick fired by accident costs nothing, since the game only tricks a kart already airborne, while - one fired *while steering* costs plenty, the shake landing on the very accelerometer the wheel is - read from. Every body flicks, a pair included: its remote hand is still while the Nunchuk's - stick steers. Only a layout that plays as a sideways remote flicks at all, so no other game is - handed a shake it never asked for when its remote is swung. + **The flick reads pitch, and only pitch.** Measured over three captures (2026-09-22, right + Joy-Con, 15 ms stream), a flick is 59–89% pitch on *both* bodies — a lone sideways Joy-Con and a + pair alike, despite a lone one being rotated into its grip before it reaches the wire — while + steering a wheel is roll and never exceeds 2.8 rad/s of pitch: + + | | raw pitch, per gesture | + |---|---| + | steering, hard, 25 s | ≤ 2.8 rad/s | + | wheelie flicks | 7.0 – 10.7 | + | trick flicks | 8.8 – 14.5 | + + Reading pitch alone therefore separates a flick from a turn by axis rather than by rate, which no + slew limiter could: the wheelie's down-flick is a slow gesture, and a limiter fast enough to + reject a turn ate all but 0.9 rad/s of it. `pulse()` fires as its input crosses a half, so the + threshold is 4.5 rad/s — 1.6× above the worst steering, 1.6× below the weakest gesture. + + **Direction matters, because a wheelie is a state.** An up-flick starts one and a down-flick drops + it, where a trick takes any direction and only one per jump. So the remote is jerked the way it was + flicked — positive wire pitch is up on both bodies — and the wave is *half* rectified + (`max(sin(…), 0)`), since a full one would cancel the wheelie it just started four times a second. + + **Each direction locks the other out for 0.4 s**, because every flick rebounds the opposite way + 0.12–0.32 s later, and a rebound is often stronger than a genuine flick elsewhere in the same + capture — 8.5 against 7.0 — so only order can tell them apart. The gate sits on the pulse's input + rather than its output, so a jerk already running finishes. One more thing verified against Dolphin's source (2026-09), since the expressions depend on it: `|` is a max, and it binds looser than `/`. diff --git a/feature/dsu/domain/src/main/kotlin/com/joegec/joycon2android/dsu/emulator/DolphinWiimoteConfig.kt b/feature/dsu/domain/src/main/kotlin/com/joegec/joycon2android/dsu/emulator/DolphinWiimoteConfig.kt index 3121d5c..ee58e23 100644 --- a/feature/dsu/domain/src/main/kotlin/com/joegec/joycon2android/dsu/emulator/DolphinWiimoteConfig.kt +++ b/feature/dsu/domain/src/main/kotlin/com/joegec/joycon2android/dsu/emulator/DolphinWiimoteConfig.kt @@ -119,44 +119,59 @@ object DolphinWiimoteConfig { private fun dolphinKey(target: WiimoteButton, sideways: Boolean): String = (if (sideways) SIDEWAYS_DPAD_KEYS[target] else null) ?: DOLPHIN_KEYS.getValue(target) - // A trick is fired from the gyroscope and delivered as a shake of the accelerometer: a Joy-Con - // flick carries almost no linear jerk, and Mario Kart Wii has no MotionPlus, so it reads only - // the accelerometer. Every constant below is measured, and the two obvious alternatives — - // amplifying the accelerometer's own transient, and Dolphin's Shake group — were tried and do - // not work. Numbers, measurements and dead ends: docs/dsu-motion.md#sideways-joy-cons. - private const val FLICK_RADIANS = 5 // pulse() fires at half of it: 2.5 rad/s past the limiter - private const val FLICK_SETTLE_SECONDS = 0.01 // the limiter that tells a flick from a turn + // A flick is fired from the gyroscope and delivered as a jerk of the accelerometer, because a + // Joy-Con flick carries almost no linear jerk and Mario Kart Wii reads only the accelerometer. + // Every constant is measured: docs/dsu-motion.md#sideways-joy-cons. + private const val FLICK_RADIANS = 9 + private const val FLICK_LOCKOUT_SECONDS = 0.4 private const val TRICK_ACCELERATION = 50 // m/s^2, past what an emulated remote can report private const val TRICK_SECONDS = 0.6 private const val TRICK_PERIOD_SECONDS = 0.15 private const val FULL_TURN = 6.2832 - private const val HALF_TURN = 3.1416 - // The three that lead; their opposites follow half a cycle later, which is the swing. - private val TRICK_LEADING = - setOf("IMUAccelerometer/Up", "IMUAccelerometer/Left", "IMUAccelerometer/Forward") + // A wheelie is a state an up-flick starts and a down-flick drops, so unlike a trick it needs the + // direction the player flicked. Pitch carries it on both bodies; the remote is jerked the same + // way it was flicked. + private const val UP = "IMUAccelerometer/Up" + private val TRICK_AXES = mapOf(UP to ("Pitch Up" to "Pitch Down"), "IMUAccelerometer/Down" to ("Pitch Down" to "Pitch Up")) - /** Only a layout that plays as a sideways remote flicks, so no other game is handed a shake. */ - private fun shakeTrigger(side: JoyconSide, sidewaysRemote: Boolean, bound: List?): String? { - val rate = "(${GYRO_DIRECTIONS.joinToString(" + ") { "`Gyro $it`" }})" - val flick = if (sidewaysRemote) "($rate - smooth($rate, $FLICK_SETTLE_SECONDS)) / $FLICK_RADIANS" else null - return listOfNotNull(flick, bound?.let { expressionFor(side, it) }) - .takeIf { it.isNotEmpty() } - ?.joinToString(" | ") + /** + * Each direction locks the other out: every flick rebounds the opposite way about a quarter of a + * second later, and that rebound would otherwise answer the gesture and cancel the wheelie. + * Gating the pulse's input rather than its output lets a jerk already running finish. + */ + private fun trickTrigger( + side: JoyconSide, + control: String, + sidewaysRemote: Boolean, + bound: List?, + ): String? { + val (own, opposite) = TRICK_AXES[control] ?: return null + val flick = if (sidewaysRemote) { + "(`Gyro $own` / $FLICK_RADIANS) & not(pulse(`Gyro $opposite` / $FLICK_RADIANS, $FLICK_LOCKOUT_SECONDS))" + } else { + null + } + val pressed = bound?.takeIf { control == UP }?.let { expressionFor(side, it) } + return listOfNotNull(flick, pressed).takeIf { it.isNotEmpty() }?.joinToString(" | ") } - private fun trickShake(trigger: String?, control: String): String? { - if (trigger == null || !control.startsWith("IMUAccelerometer/")) return null - val phase = if (control in TRICK_LEADING) "" else " + $HALF_TURN" - return "pulse($trigger, $TRICK_SECONDS) * " + - "sin(timer($TRICK_PERIOD_SECONDS) * $FULL_TURN$phase) * $TRICK_ACCELERATION" + // Half a wave, so the jerks all go the way the flick did — a full one would cancel the wheelie + // it just started, four times a second. + private fun trickShake(trigger: String?): String? = trigger?.let { + "pulse($it, $TRICK_SECONDS) * max(sin(timer($TRICK_PERIOD_SECONDS) * $FULL_TURN), 0) * $TRICK_ACCELERATION" } - private fun imuLines(side: JoyconSide, sidewaysRemote: Boolean, trigger: String?): List { + private fun imuLines( + side: JoyconSide, + sidewaysRemote: Boolean, + bound: List?, + ): List { val bodyInputs = bodyInputs(side, sidewaysRemote) return IMU_CONTROLS.map { (control, input) -> val read = "`${bodyInputs[input] ?: input}`" - "$control = " + (trickShake(trigger, control)?.let { "$read + $it" } ?: read) + val shake = trickShake(trickTrigger(side, control, sidewaysRemote, bound)) + "$control = " + (shake?.let { "$read + $it" } ?: read) } + listOf("IMUIR/Enabled = True", "IMUIR/Total Yaw = $IMU_TOTAL_YAW_DEGREES") } @@ -227,8 +242,8 @@ object DolphinWiimoteConfig { } val sideways = sidewaysRemote && side != JoyconSide.DUAL val mapping = mappingFor(body) - val trigger = shakeTrigger(side, sidewaysRemote, mapping.toSourceMap()[WiimoteButton.Shake]) - return (header + lines(side, sideways, mapping) + imuLines(side, sidewaysRemote, trigger) + + val shake = mapping.toSourceMap()[WiimoteButton.Shake] + return (header + lines(side, sideways, mapping) + imuLines(side, sidewaysRemote, shake) + swingLines(side, sidewaysRemote) + nunchukImu) .joinToString("\n", postfix = "\n") } diff --git a/feature/dsu/domain/src/test/kotlin/com/joegec/joycon2android/dsu/emulator/DolphinWiimoteConfigTest.kt b/feature/dsu/domain/src/test/kotlin/com/joegec/joycon2android/dsu/emulator/DolphinWiimoteConfigTest.kt index 8149584..4a37b4a 100644 --- a/feature/dsu/domain/src/test/kotlin/com/joegec/joycon2android/dsu/emulator/DolphinWiimoteConfigTest.kt +++ b/feature/dsu/domain/src/test/kotlin/com/joegec/joycon2android/dsu/emulator/DolphinWiimoteConfigTest.kt @@ -219,73 +219,54 @@ class DolphinWiimoteConfigTest { assertTrue(result.contains("D-Pad/Up = `Pad N`")) } - // A flick is nearly all rotation, which the game cannot read, so the trick is fired from the - // gyroscope — and delivered through the accelerometer, the path steering proves reaches the game. + // A flick is nearly all rotation, which the game cannot read, so it is fired from the gyroscope + // and delivered through the accelerometer, the path steering proves reaches the game. @Test - fun `playing sideways turns a wrist flick into a trick`() { + fun `playing sideways turns a wrist flick into a jerk the way it was flicked`() { val result = merge(null, listOf(PlayerState(PlayerNumber.P1, right = joycon(Side.RIGHT))), sidewaysRemote = true) - val rate = "(`Gyro Pitch Up` + `Gyro Pitch Down` + `Gyro Roll Left` + `Gyro Roll Right` + " + - "`Gyro Yaw Left` + `Gyro Yaw Right`)" - val flick = "($rate - smooth($rate, 0.01)) / 5" + val up = "(`Gyro Pitch Up` / 9) & not(pulse(`Gyro Pitch Down` / 9, 0.4))" assertTrue( result.contains( - "IMUAccelerometer/Up = `Accel Up` + pulse($flick, 0.6) * " + - "sin(timer(0.15) * 6.2832) * 50", + "IMUAccelerometer/Up = `Accel Up` + pulse($up, 0.6) * " + + "max(sin(timer(0.15) * 6.2832), 0) * 50", ), ) assertFalse(result.contains("Shake/")) // Dolphin's own group never landed one } - // Steering a lone Joy-Con held as a wheel is itself rotation, so only what outruns the tracker - // counts as a flick — otherwise a firm turn shakes the accelerometer the wheel is read from. + // Every flick rebounds the opposite way a quarter of a second later, and that rebound would + // otherwise answer the gesture — which is what made a wheelie chatter on and off. @Test - fun `a sustained turn is subtracted out of the flick`() { + fun `each flick direction locks the other out`() { val result = merge(null, listOf(PlayerState(PlayerNumber.P1, right = joycon(Side.RIGHT))), sidewaysRemote = true) - assertTrue(result.contains("- smooth((`Gyro Pitch Up`")) + assertTrue(result.contains("IMUAccelerometer/Down = `Accel Down` + pulse((`Gyro Pitch Down` / 9) & " + + "not(pulse(`Gyro Pitch Up` / 9, 0.4))")) } - // What landed a trick by hand was a hard shake, so opposite inputs swing half a cycle apart - // rather than one being leaned on. + // A wheelie is a state, so a full wave would cancel it four times a second. @Test - fun `the trick swings every input, opposites in antiphase`() { + fun `the jerks all go the way the flick did`() { val result = merge(null, listOf(PlayerState(PlayerNumber.P1, right = joycon(Side.RIGHT))), sidewaysRemote = true) - listOf("Up", "Left", "Forward").forEach { - assertTrue(it, result.contains("IMUAccelerometer/$it = `Accel") && result.contains("* 6.2832) * 50")) - } - listOf("Down", "Right", "Backward").forEach { - assertTrue(it, result.contains("* 6.2832 + 3.1416) * 50")) - } - assertEquals(6, result.split("pulse(").size - 1) - } - - // Its remote hand is still while the Nunchuk's stick steers, so a pair can flick for a trick too. - @Test - fun `a pair flicks for a trick as well, once the layout plays as a sideways remote`() { - val pair = listOf(PlayerState(PlayerNumber.P1, left = joycon(Side.LEFT), right = joycon(Side.RIGHT))) - - val result = DolphinWiimoteConfig.merge(null, pair, { true }, ::wiimoteMappingFor) - - assertTrue(result.contains("pulse(((`Gyro Pitch Up`")) - - // ...but its motion frame is untouched, since it is already held like a remote. - assertTrue(result.contains("IMUAccelerometer/Forward = `Accel Forward` +")) + assertEquals(2, result.split("pulse((`Gyro").size - 1) // one per direction, nothing sideways + assertFalse(result.contains("3.1416")) // no antiphase left to cancel it } @Test - fun `a bound source tricks without a flick, so any body can trick at all`() { + fun `a bound source jerks without a flick, so any body can trick at all`() { val pair = listOf(PlayerState(PlayerNumber.P1, left = joycon(Side.LEFT), right = joycon(Side.RIGHT))) val mapping = defaultWiimoteMapping(JoyconSide.DUAL) + mapOf("Shake" to "R") val result = DolphinWiimoteConfig.merge(null, pair, { false }) { mapping } - assertTrue(result.contains("pulse(`R1`, 0.6)")) + assertTrue(result.contains("IMUAccelerometer/Up = `Accel Up` + pulse(`R1`, 0.6)")) + assertFalse(result.contains("IMUAccelerometer/Down = `Accel Down` + pulse")) // a button has no direction } @Test - fun `nothing shakes the accelerometer when there is no trick to fire`() { + fun `nothing jerks the accelerometer when there is no flick to fire`() { val result = merge(null, listOf(PlayerState(PlayerNumber.P1, right = joycon(Side.RIGHT)))) assertFalse(result.contains("pulse(")) diff --git a/tools/flick_stats.py b/tools/flick_stats.py index ecf37c6..fd17f68 100755 --- a/tools/flick_stats.py +++ b/tools/flick_stats.py @@ -25,38 +25,51 @@ def samples(path, slot): - """(timestamp, rate in rad/s) per packet, rate being what Dolphin sums from the six inputs.""" + """(timestamp, rate, axes) per packet — rate is what Dolphin sums from the six one-way inputs, + axes keeps the signed pitch/yaw/roll so a gesture's direction can be read back.""" for line in open(path): found = LINE.match(line.strip()) if not found or int(found.group(2)) != slot: continue - pitch, yaw, roll = (float(v) for v in found.group(3).split(",")) - yield float(found.group(1)), math.radians(abs(pitch) + abs(yaw) + abs(roll)) + axes = tuple(math.radians(float(v)) for v in found.group(3).split(",")) + yield float(found.group(1)), sum(abs(a) for a in axes), axes def residuals(rates, settle): """What survives Dolphin's `rate - smooth(rate, settle)`: a limiter moving 1/settle per second.""" state = rates[0][1] - for previous, (at, rate) in zip(rates, rates[1:]): + for previous, (at, rate, axes) in zip(rates, rates[1:]): most = (at - previous[0]) / settle state += max(-most, min(most, rate - state)) - yield at, rate, rate - state + yield at, rate, rate - state, axes def peaks(measured, floor, apart): """One entry per burst, so a single flick is not counted as several.""" burst = [] - for at, rate, residual in measured: - if residual < floor: + for sample in measured: + if sample[2] < floor: continue - if burst and at - burst[-1][0] > apart: + if burst and sample[0] - burst[-1][0] > apart: yield max(burst, key=lambda it: it[2]) burst = [] - burst.append((at, rate, residual)) + burst.append(sample) if burst: yield max(burst, key=lambda it: it[2]) +AXES = ("pitch", "yaw", "roll") + + +def direction(axes): + """The turn the gesture mostly is, named as Dolphin names its two one-way inputs for that axis.""" + size = max(range(3), key=lambda i: abs(axes[i])) + ends = {"pitch": ("Pitch Up", "Pitch Down"), "yaw": ("Yaw Left", "Yaw Right"), + "roll": ("Roll Left", "Roll Right")}[AXES[size]] + share = abs(axes[size]) / sum(abs(a) for a in axes) + return f"{ends[0] if axes[size] > 0 else ends[1]:11} {share:.0%} of the turn" + + def main(): parser = argparse.ArgumentParser(description=__doc__) parser.add_argument("capture") @@ -86,8 +99,9 @@ def main(): plural = "" if len(found) == 1 else "s" print(f"{len(found)} event{plural} (peak residual >= {args.floor}, at least {args.apart}s apart):") - for at, rate, residual in found: - print(f" t={at:8.2f} rate {rate:6.1f} residual {residual:6.1f} fires while FLICK_RADIANS <= {2 * residual:.0f}") + for at, rate, residual, axes in found: + signed = " ".join(f"{n} {v:+6.1f}" for n, v in zip(AXES, axes)) + print(f" t={at:8.2f} residual {residual:6.1f} [{signed}] {direction(axes)}") weakest = min(it[2] for it in found) print(f"\nweakest event leaves {weakest:.1f} rad/s of residual.") From 97973ec1edeec915a49aa18c57a5c7b50aa4b19e Mon Sep 17 00:00:00 2001 From: Joe Barker Date: Tue, 22 Sep 2026 22:57:06 +0100 Subject: [PATCH 10/16] Split Mario Kart into its two grips The one Mario Kart layout was really two: a lone Joy-Con held sideways as a wheel, steering by tilt, and a pair split across the hands as a remote and nunchuk, steering by stick. They are now named for what they are -- Mario Kart Wheel and Mario Kart Nunchuck -- and a lone Joy-Con can have either, playing both halves of the nunchuk scheme itself with its own stick standing in for the Nunchuk's and its rails carrying C and Z. A preset says which bodies it is offered to, because a pair cannot be held as a wheel and should not be shown one. It also says which family it belongs to, which does two jobs: setting a grip globally gives every other body the same game's other grip rather than dropping it to the console default, and a table each on their own grip still names itself -- "Mario Kart" -- instead of reading Custom the moment it is set. The lone layout says None for the d-pad, 1, 2 and the remote's minus rather than leaving them out. A layout lies over the console's default, so a target it never mentions keeps whatever that default bound: the d-pad kept the stick the Nunchuk now wants, and the minus button fired the remote's plus and minus together. A test now asserts that no button on a lone body fires two targets, bar the shoulder that hops and tricks, since that is the general shape of it and it is invisible until someone plays. Left and right agree under the sideways rotation, which the first draft of the left hand did not: Down, Left and Right emit what X, A and Y do on the other body, so the same thumb position does the same job in either hand. That too has a test, against emittedFor rather than against the table. Co-Authored-By: Claude Opus 5 --- README.md | 8 +- .../buttonmapping/GlobalMapping.kt | 17 ++- .../buttonmapping/MappingLayouts.kt | 19 +++- .../buttonmapping/preset/MappingPreset.kt | 10 ++ .../buttonmapping/preset/MappingPresets.kt | 11 +- .../preset/MarioKartNunchukMapping.kt | 105 +++++++++++++++++ ...WiiMapping.kt => MarioKartWheelMapping.kt} | 48 +++----- .../buttonmapping/GlobalMappingTest.kt | 29 ++++- .../ObserveControllerMappingUseCaseTest.kt | 4 +- .../buttonmapping/PlayerMappingTest.kt | 18 +-- .../buttonmapping/SidewaysRemoteTest.kt | 4 +- .../buttonmapping/preset/WiiPresetsTest.kt | 106 +++++++++++++++--- 12 files changed, 304 insertions(+), 75 deletions(-) create mode 100644 core/buttonmapping/domain/src/main/kotlin/com/joegec/joycon2android/buttonmapping/preset/MarioKartNunchukMapping.kt rename core/buttonmapping/domain/src/main/kotlin/com/joegec/joycon2android/buttonmapping/preset/{MarioKartWiiMapping.kt => MarioKartWheelMapping.kt} (53%) diff --git a/README.md b/README.md index 05e2548..6488f68 100644 --- a/README.md +++ b/README.md @@ -101,7 +101,10 @@ layout of your own, offered to any player holding the same body — it suggests layout has a name. The bin in the dropdown deletes one: your buttons stay exactly as they are, the name just becomes **Custom** until you save it again. -**All players** at the top sets everyone at once, and saves the same way. A saved set remembers which +**All players** at the top sets everyone at once, and saves the same way. Picking a grip only some +bodies can be held in — **Mario Kart Wheel**, say — gives every other body the same game's other +grip, so a table of singles and pairs all end up on Mario Kart rather than half of them on the +default. A saved set remembers which player held which body — the sub-label under its name says which ("P1 L, P2 R, P3 L/R") — so it stays greyed out until those players are back. @@ -115,7 +118,8 @@ The layouts the app ships: |---|---| | Wii | The Wii Remote's own arrangement: the trigger under your finger is B, 1 and 2 under the thumb | | Joy-Con | The same, with B and 2 swapped so the Joy-Con's own B is the remote's B | -| Mario Kart | A sideways Joy-Con laid out as Mario Kart 8 uses one — 2 accelerates, 1 brakes, SR hops, and SL throws an item alongside the stick. The one layout that plays as a **sideways Wii Remote**: the wheel steers correctly, the d-pad turns with it, and a right Joy-Con aims from its tail | +| Mario Kart Wheel | A lone Joy-Con held sideways as a wheel, laid out the way Mario Kart 8 uses one — 2 accelerates, 1 brakes, SR hops and tricks, and SL throws an item alongside the stick. Steers by **tilt**, so it plays as a sideways Wii Remote: the d-pad turns with it and a right Joy-Con aims from its tail | +| Mario Kart Nunchuck | The remote-and-nunchuk scheme, which steers by **stick** instead. A pair splits the halves across the hands, each index finger on the shoulder its controller keeps a trigger on; a lone Joy-Con plays both halves itself, its own stick standing in for the Nunchuk's and its rails carrying C and Z | > [!NOTE] > Auto setup needs Shizuku, and some devices block writing into another app's `Android/data` — use diff --git a/core/buttonmapping/domain/src/main/kotlin/com/joegec/joycon2android/buttonmapping/GlobalMapping.kt b/core/buttonmapping/domain/src/main/kotlin/com/joegec/joycon2android/buttonmapping/GlobalMapping.kt index 3827473..6413832 100644 --- a/core/buttonmapping/domain/src/main/kotlin/com/joegec/joycon2android/buttonmapping/GlobalMapping.kt +++ b/core/buttonmapping/domain/src/main/kotlin/com/joegec/joycon2android/buttonmapping/GlobalMapping.kt @@ -1,9 +1,12 @@ package com.joegec.joycon2android.buttonmapping +import com.joegec.joycon2android.buttonmapping.preset.MappingPreset + /** * The session read as one setting. It has a name only while every player agrees on one — a saved - * set whose bindings they all still carry, or a single layout every one of them reads as; change - * one player and the session stops being that thing. + * set whose bindings they all still carry, a single layout every one of them reads as, or one + * family of layouts they are each on their own body's grip of; change one player and the session + * stops being that thing. */ data class GlobalMapping( val players: List, @@ -17,9 +20,17 @@ data class GlobalMapping( private val sharedLayout: MappingLayout? get() = players.takeIf { it.isNotEmpty() }?.map { it.layout }?.distinct()?.singleOrNull() + /** A table rarely holds the same thing, so one family across two grips still agrees. */ + private val sharedFamily: String? + get() = players.takeIf { it.isNotEmpty() } + ?.map { (it.layout as? MappingPreset)?.family } + ?.distinct() + ?.singleOrNull() + val selectedId: String? get() = matchingSaved?.id ?: sharedLayout?.id - val displayName: String? get() = matchingSaved?.displayName ?: sharedLayout?.displayName + val displayName: String? + get() = matchingSaved?.displayName ?: sharedLayout?.displayName ?: sharedFamily val playerSummary: String? get() = matchingSaved?.playerSummary } diff --git a/core/buttonmapping/domain/src/main/kotlin/com/joegec/joycon2android/buttonmapping/MappingLayouts.kt b/core/buttonmapping/domain/src/main/kotlin/com/joegec/joycon2android/buttonmapping/MappingLayouts.kt index 2c67449..fd0d346 100644 --- a/core/buttonmapping/domain/src/main/kotlin/com/joegec/joycon2android/buttonmapping/MappingLayouts.kt +++ b/core/buttonmapping/domain/src/main/kotlin/com/joegec/joycon2android/buttonmapping/MappingLayouts.kt @@ -1,12 +1,14 @@ package com.joegec.joycon2android.buttonmapping +import com.joegec.joycon2android.buttonmapping.preset.MappingPreset import com.joegec.joycon2android.buttonmapping.preset.MappingPresets /** The layouts one body can choose between: the console's shipped ones, then the user's own. */ object MappingLayouts { fun forBody(console: Console, side: JoyconSide, saved: List): List = - MappingPresets.forConsole(console) + saved.filter { it.console == console && it.side == side } + MappingPresets.forConsole(console).filter { side in it.sides } + + saved.filter { it.console == console && it.side == side } /** * What applying [layout] leaves behind: its own bindings over the console's default ones, so a @@ -31,9 +33,20 @@ object MappingLayouts { it.sidewaysRemote == sidewaysRemote && entriesOf(console, side, it) == entries } - /** Falls back to the console's default for an id whose layout has since been deleted. */ + /** + * A body that cannot be held in the grip asked for takes its family's other grip instead, and + * failing that the console's default — which is also where a deleted layout lands. + */ fun byId(console: Console, side: JoyconSide, id: String?, saved: List): MappingLayout = - forBody(console, side, saved).firstOrNull { it.id == id } ?: MappingPresets.default(console) + forBody(console, side, saved).firstOrNull { it.id == id } + ?: familyMember(console, side, id) + ?: MappingPresets.default(console) + + private fun familyMember(console: Console, side: JoyconSide, id: String?): MappingPreset? { + val presets = MappingPresets.forConsole(console) + val family = presets.firstOrNull { it.id == id }?.family ?: return null + return presets.firstOrNull { it.family == family && side in it.sides } + } /** Ids the user chose, so a saved layout can never collide with a shipped one. */ fun newId(): String = "saved-${java.util.UUID.randomUUID()}" diff --git a/core/buttonmapping/domain/src/main/kotlin/com/joegec/joycon2android/buttonmapping/preset/MappingPreset.kt b/core/buttonmapping/domain/src/main/kotlin/com/joegec/joycon2android/buttonmapping/preset/MappingPreset.kt index ea588f3..4dec938 100644 --- a/core/buttonmapping/domain/src/main/kotlin/com/joegec/joycon2android/buttonmapping/preset/MappingPreset.kt +++ b/core/buttonmapping/domain/src/main/kotlin/com/joegec/joycon2android/buttonmapping/preset/MappingPreset.kt @@ -1,9 +1,19 @@ package com.joegec.joycon2android.buttonmapping.preset import com.joegec.joycon2android.buttonmapping.Console +import com.joegec.joycon2android.buttonmapping.JoyconSide import com.joegec.joycon2android.buttonmapping.MappingLayout /** A layout the app ships: what each body maps to before the user overrides anything. */ sealed interface MappingPreset : MappingLayout { val console: Console + + /** The bodies it is offered to — a grip that only one of them can be held in says so. */ + val sides: Set get() = JoyconSide.entries.toSet() + + /** + * The same game in another grip. Setting one of a family sets every player to whichever of them + * their own body can be held in, since a table is rarely holding the same thing. + */ + val family: String? get() = null } diff --git a/core/buttonmapping/domain/src/main/kotlin/com/joegec/joycon2android/buttonmapping/preset/MappingPresets.kt b/core/buttonmapping/domain/src/main/kotlin/com/joegec/joycon2android/buttonmapping/preset/MappingPresets.kt index ae4ffa2..f5bc1b7 100644 --- a/core/buttonmapping/domain/src/main/kotlin/com/joegec/joycon2android/buttonmapping/preset/MappingPresets.kt +++ b/core/buttonmapping/domain/src/main/kotlin/com/joegec/joycon2android/buttonmapping/preset/MappingPresets.kt @@ -2,10 +2,19 @@ package com.joegec.joycon2android.buttonmapping.preset import com.joegec.joycon2android.buttonmapping.Console +internal const val MARIO_KART = "Mario Kart" + /** Every layout the app ships, and which one a console falls back to. */ object MappingPresets { - private val all = listOf(GameCubeMapping, WiiMapping, JoyconWiiMapping, MarioKartWiiMapping, SwitchProMapping) + private val all = listOf( + GameCubeMapping, + WiiMapping, + JoyconWiiMapping, + MarioKartWheelMapping, + MarioKartNunchukMapping, + SwitchProMapping, + ) fun forConsole(console: Console): List = all.filter { it.console == console } diff --git a/core/buttonmapping/domain/src/main/kotlin/com/joegec/joycon2android/buttonmapping/preset/MarioKartNunchukMapping.kt b/core/buttonmapping/domain/src/main/kotlin/com/joegec/joycon2android/buttonmapping/preset/MarioKartNunchukMapping.kt new file mode 100644 index 0000000..2a59a6c --- /dev/null +++ b/core/buttonmapping/domain/src/main/kotlin/com/joegec/joycon2android/buttonmapping/preset/MarioKartNunchukMapping.kt @@ -0,0 +1,105 @@ +package com.joegec.joycon2android.buttonmapping.preset + +import com.joegec.joycon2android.buttonmapping.Console +import com.joegec.joycon2android.buttonmapping.JoyconSide +import com.joegec.joycon2android.buttonmapping.MappingSource +import com.joegec.joycon2android.buttonmapping.StickSource +import com.joegec.joycon2android.buttonmapping.StickSource.LEFT_STICK +import com.joegec.joycon2android.buttonmapping.StickSource.RIGHT_STICK +import com.joegec.joycon2android.buttonmapping.target.WiimoteButton +import com.joegec.joycon2android.buttonmapping.target.WiimoteStick +import com.joegec.joycon2android.model.JoyconButton +import com.joegec.joycon2android.model.JoyconButton.A +import com.joegec.joycon2android.model.JoyconButton.B +import com.joegec.joycon2android.model.JoyconButton.Capture +import com.joegec.joycon2android.model.JoyconButton.Down +import com.joegec.joycon2android.model.JoyconButton.Home +import com.joegec.joycon2android.model.JoyconButton.Left +import com.joegec.joycon2android.model.JoyconButton.L +import com.joegec.joycon2android.model.JoyconButton.Minus +import com.joegec.joycon2android.model.JoyconButton.Plus +import com.joegec.joycon2android.model.JoyconButton.R +import com.joegec.joycon2android.model.JoyconButton.Right +import com.joegec.joycon2android.model.JoyconButton.SlLeft +import com.joegec.joycon2android.model.JoyconButton.SlRight +import com.joegec.joycon2android.model.JoyconButton.SrLeft +import com.joegec.joycon2android.model.JoyconButton.SrRight +import com.joegec.joycon2android.model.JoyconButton.X +import com.joegec.joycon2android.model.JoyconButton.Y +import com.joegec.joycon2android.model.JoyconButton.ZL +import com.joegec.joycon2android.model.JoyconButton.ZR + +/** + * Mario Kart's remote-and-nunchuk scheme, where a stick steers rather than the tilt of a wheel. + * + * A pair splits the two halves across the hands, so its four shoulders carry what each hand's + * controller keeps under a finger: the remote's B and the Nunchuk's Z on the upper pair, 1 and 2 on + * the lower. The hop also takes the Joy-Con's own B, and the trick rides its shoulder. + * + * A lone Joy-Con plays both halves at once, its own stick standing in for the Nunchuk's, with the + * rails carrying the two buttons a second hand would have held. + */ +object MarioKartNunchukMapping : MappingPreset { + override val id = "MARIO_KART_NUNCHUK" + override val displayName = "Mario Kart Nunchuck" + override val console = Console.WIIMOTE_NUNCHUK + override val family = MARIO_KART + override val sidewaysRemote = true + + override fun entries(side: JoyconSide) = when (side) { + JoyconSide.DUAL -> WiiMapping.entries(side) + pairButtons() + else -> loneButtons(side) + nunchukStick(side).stickEntries() + } + + private fun pairButtons() = mapOf( + WiimoteButton.One to listOf(ZL), + WiimoteButton.Two to listOf(ZR), + WiimoteButton.B to listOf(R, B), + WiimoteButton.Shake to listOf(R), + WiimoteButton.Minus to listOf(Minus), + WiimoteButton.NunchukC to listOf(X), + WiimoteButton.NunchukZ to listOf(L), + ).sources() + + private fun loneButtons(side: JoyconSide) = (loneFaces(side) + UNBOUND).sources() + + private fun loneFaces(side: JoyconSide) = when (side) { + JoyconSide.LEFT -> mapOf( + WiimoteButton.A to listOf(Down), + WiimoteButton.B to listOf(Left, SrLeft), + WiimoteButton.NunchukC to listOf(Right), + WiimoteButton.NunchukZ to listOf(SlLeft), + WiimoteButton.Plus to listOf(Minus), + WiimoteButton.Home to listOf(Capture), + WiimoteButton.Shake to listOf(SrLeft), + ) + else -> mapOf( + WiimoteButton.A to listOf(X), + WiimoteButton.B to listOf(A, SrRight), + WiimoteButton.NunchukC to listOf(Y), + WiimoteButton.NunchukZ to listOf(SlRight), + WiimoteButton.Plus to listOf(Plus), + WiimoteButton.Home to listOf(Home), + WiimoteButton.Shake to listOf(SrRight), + ) + } + + // Said rather than left out: a layout lies over the console's default, so a target it never + // mentions keeps whatever that default bound — the stick the Nunchuk now wants, and buttons + // that would otherwise double up with the ones named above. + private val UNBOUND = listOf( + WiimoteButton.DPadUp, + WiimoteButton.DPadDown, + WiimoteButton.DPadLeft, + WiimoteButton.DPadRight, + WiimoteButton.One, + WiimoteButton.Two, + WiimoteButton.Minus, + ).associateWith { emptyList() } + + private fun nunchukStick(side: JoyconSide): Map = + mapOf(WiimoteStick.NunchukStick to if (side == JoyconSide.LEFT) LEFT_STICK else RIGHT_STICK) + + private fun Map>.sources() = + mapValues { (_, buttons) -> buttons.map(MappingSource::Button) }.sourceEntries() +} diff --git a/core/buttonmapping/domain/src/main/kotlin/com/joegec/joycon2android/buttonmapping/preset/MarioKartWiiMapping.kt b/core/buttonmapping/domain/src/main/kotlin/com/joegec/joycon2android/buttonmapping/preset/MarioKartWheelMapping.kt similarity index 53% rename from core/buttonmapping/domain/src/main/kotlin/com/joegec/joycon2android/buttonmapping/preset/MarioKartWiiMapping.kt rename to core/buttonmapping/domain/src/main/kotlin/com/joegec/joycon2android/buttonmapping/preset/MarioKartWheelMapping.kt index 8398842..3a871c6 100644 --- a/core/buttonmapping/domain/src/main/kotlin/com/joegec/joycon2android/buttonmapping/preset/MarioKartWiiMapping.kt +++ b/core/buttonmapping/domain/src/main/kotlin/com/joegec/joycon2android/buttonmapping/preset/MarioKartWheelMapping.kt @@ -10,11 +10,9 @@ import com.joegec.joycon2android.model.JoyconButton.B import com.joegec.joycon2android.model.JoyconButton.Capture import com.joegec.joycon2android.model.JoyconButton.Down import com.joegec.joycon2android.model.JoyconButton.Home -import com.joegec.joycon2android.model.JoyconButton.L import com.joegec.joycon2android.model.JoyconButton.Left import com.joegec.joycon2android.model.JoyconButton.Minus import com.joegec.joycon2android.model.JoyconButton.Plus -import com.joegec.joycon2android.model.JoyconButton.R import com.joegec.joycon2android.model.JoyconButton.Right import com.joegec.joycon2android.model.JoyconButton.SlLeft import com.joegec.joycon2android.model.JoyconButton.SlRight @@ -23,43 +21,26 @@ import com.joegec.joycon2android.model.JoyconButton.SrRight import com.joegec.joycon2android.model.JoyconButton.Up import com.joegec.joycon2android.model.JoyconButton.X import com.joegec.joycon2android.model.JoyconButton.Y -import com.joegec.joycon2android.model.JoyconButton.ZL -import com.joegec.joycon2android.model.JoyconButton.ZR /** - * A sideways Joy-Con laid out the way Mario Kart 8 uses one, so the same thumb does the same job in - * both games: accelerate on 2, brake on 1, hop on SR. Mario Kart Wii throws an item with the d-pad, - * which a sideways body already steers from its stick, so SL fires it too — the shoulder that - * throws in Mario Kart 8. + * A lone Joy-Con held sideways as a wheel, laid out the way Mario Kart 8 uses one so the same thumb + * does the same job in both games: accelerate on 2, brake on 1, hop on SR. Mario Kart Wii throws an + * item with the d-pad, which a sideways body already steers from its stick, so SL fires it too — + * the shoulder that throws in Mario Kart 8. * - * A pair keeps the [WiiMapping] layout's shape — held two-handed there is no sideways grip to match - * — but moves the jobs an index finger does onto the shoulders that finger already rests on. - * - * It is also the layout that plays as a sideways Wii Remote, which is what the wheel steers by. + * It is the layout that plays as a sideways Wii Remote, which is what the wheel steers by, and a + * pair has no such grip to match — so only a lone Joy-Con is offered it. */ -object MarioKartWiiMapping : MappingPreset { - override val id = "MARIO_KART" - override val displayName = "Mario Kart" +object MarioKartWheelMapping : MappingPreset { + override val id = "MARIO_KART_WHEEL" + override val displayName = "Mario Kart Wheel" override val console = Console.WIIMOTE_NUNCHUK + override val family = MARIO_KART + override val sides = setOf(JoyconSide.LEFT, JoyconSide.RIGHT) override val sidewaysRemote = true - override fun entries(side: JoyconSide) = when (side) { - JoyconSide.DUAL -> WiiMapping.entries(side) + pairButtons() - else -> buttons(side).buttonEntries() + dPadSticks(side).sourceEntries() - } - - // Held as a remote and a nunchuk, the four shoulders carry what each hand's controller keeps - // under a finger: the remote's B and the Nunchuk's Z on the upper pair, 1 and 2 on the lower. - // The hop also takes the Joy-Con's own B, and the trick rides its shoulder as SR does alone. - private fun pairButtons(): Map = mapOf( - WiimoteButton.One to listOf(ZL), - WiimoteButton.Two to listOf(ZR), - WiimoteButton.B to listOf(R, B), - WiimoteButton.Shake to listOf(R), - WiimoteButton.Minus to listOf(Minus), - WiimoteButton.NunchukC to listOf(X), - WiimoteButton.NunchukZ to listOf(L), - ).mapValues { (_, buttons) -> buttons.map(MappingSource::Button) }.sourceEntries() + override fun entries(side: JoyconSide) = + buttons(side).buttonEntries() + dPadSticks(side).sourceEntries() private fun buttons(side: JoyconSide): Map = when (side) { JoyconSide.LEFT -> mapOf( @@ -87,7 +68,6 @@ object MarioKartWiiMapping : MappingPreset { private fun dPadSticks(side: JoyconSide): Map> { val fromStick = WiiMapping.dPadSticks(side) val item = MappingSource.Button(if (side == JoyconSide.LEFT) SlLeft else SlRight) - val throwItem = fromStick.getValue(WiimoteButton.DPadUp) + item - return fromStick + (WiimoteButton.DPadUp to throwItem) + return fromStick + (WiimoteButton.DPadUp to fromStick.getValue(WiimoteButton.DPadUp) + item) } } diff --git a/core/buttonmapping/domain/src/test/kotlin/com/joegec/joycon2android/buttonmapping/GlobalMappingTest.kt b/core/buttonmapping/domain/src/test/kotlin/com/joegec/joycon2android/buttonmapping/GlobalMappingTest.kt index b9148ec..c2ffa8e 100644 --- a/core/buttonmapping/domain/src/test/kotlin/com/joegec/joycon2android/buttonmapping/GlobalMappingTest.kt +++ b/core/buttonmapping/domain/src/test/kotlin/com/joegec/joycon2android/buttonmapping/GlobalMappingTest.kt @@ -2,7 +2,8 @@ package com.joegec.joycon2android.buttonmapping import com.joegec.joycon2android.buttonmapping.MappingFixture.Companion.left import com.joegec.joycon2android.buttonmapping.MappingFixture.Companion.right -import com.joegec.joycon2android.buttonmapping.preset.MarioKartWiiMapping +import com.joegec.joycon2android.buttonmapping.preset.MarioKartNunchukMapping +import com.joegec.joycon2android.buttonmapping.preset.MarioKartWheelMapping import com.joegec.joycon2android.buttonmapping.preset.WiiMapping import com.joegec.joycon2android.model.PlayerNumber import kotlinx.coroutines.runBlocking @@ -42,7 +43,7 @@ class GlobalMappingTest { @Test fun `players on different layouts leave the session with no name of its own`() = runBlocking { - fixture.applyLayout(fixture.console, first, MarioKartWiiMapping.id) + fixture.applyLayout(fixture.console, first, MarioKartWheelMapping.id) fixture.applyLayout(fixture.console, second, WiiMapping.id) assertNull(fixture.globalMapping(first, second).displayName) @@ -60,6 +61,28 @@ class GlobalMappingTest { assertNull(fixture.globalMapping(first, second).displayName) } + // A table rarely holds the same thing, so the grip each body can be held in is what it gets. + @Test + fun `setting a grip nobody but a lone Joy-Con has gives a pair the other grip of the same game`() = runBlocking { + val pair = PlayerBody(PlayerNumber.P3, JoyconSide.DUAL) + val mixed = bodies + pair + + fixture.applyGlobalLayout(fixture.console, mixed, MarioKartWheelMapping.id) + + assertEquals(MarioKartWheelMapping.id, fixture.playerMapping(first).layout?.id) + assertEquals(MarioKartNunchukMapping.id, fixture.playerMapping(pair).layout?.id) + } + + @Test + fun `players on two grips of one game still name the session`() = runBlocking { + val pair = PlayerBody(PlayerNumber.P3, JoyconSide.DUAL) + val mixed = bodies + pair + + fixture.applyGlobalLayout(fixture.console, mixed, MarioKartWheelMapping.id) + + assertEquals("Mario Kart", fixture.globalMapping(first, second, pair).displayName) + } + @Test fun `a saved set fits only the players and bodies it was saved from`() = runBlocking { fixture.saveGlobalLayout(fixture.console, bodies, "Party") @@ -81,7 +104,7 @@ class GlobalMappingTest { @Test fun `restoring a saved set gives every player back the bindings it froze`() = runBlocking { - fixture.applyLayout(fixture.console, first, MarioKartWiiMapping.id) + fixture.applyLayout(fixture.console, first, MarioKartWheelMapping.id) fixture.setMapping(fixture.console, first, "A", "Up") fixture.applyLayout(fixture.console, second, WiiMapping.id) fixture.saveGlobalLayout(fixture.console, bodies, "Party") diff --git a/core/buttonmapping/domain/src/test/kotlin/com/joegec/joycon2android/buttonmapping/ObserveControllerMappingUseCaseTest.kt b/core/buttonmapping/domain/src/test/kotlin/com/joegec/joycon2android/buttonmapping/ObserveControllerMappingUseCaseTest.kt index aae7f38..a37b953 100644 --- a/core/buttonmapping/domain/src/test/kotlin/com/joegec/joycon2android/buttonmapping/ObserveControllerMappingUseCaseTest.kt +++ b/core/buttonmapping/domain/src/test/kotlin/com/joegec/joycon2android/buttonmapping/ObserveControllerMappingUseCaseTest.kt @@ -1,6 +1,6 @@ package com.joegec.joycon2android.buttonmapping -import com.joegec.joycon2android.buttonmapping.preset.MarioKartWiiMapping +import com.joegec.joycon2android.buttonmapping.preset.MarioKartWheelMapping import com.joegec.joycon2android.model.PlayerNumber import kotlinx.coroutines.flow.first import kotlinx.coroutines.runBlocking @@ -71,7 +71,7 @@ class ObserveControllerMappingUseCaseTest { val fixture = MappingFixture() val body = MappingFixture.right(PlayerNumber.P1) - fixture.applyLayout(fixture.console, body, MarioKartWiiMapping.id) + fixture.applyLayout(fixture.console, body, MarioKartWheelMapping.id) val mapping = fixture.playerMapping(body) assertEquals("X", mapping.entries["Two"]) diff --git a/core/buttonmapping/domain/src/test/kotlin/com/joegec/joycon2android/buttonmapping/PlayerMappingTest.kt b/core/buttonmapping/domain/src/test/kotlin/com/joegec/joycon2android/buttonmapping/PlayerMappingTest.kt index 63f04e5..b0dbbb9 100644 --- a/core/buttonmapping/domain/src/test/kotlin/com/joegec/joycon2android/buttonmapping/PlayerMappingTest.kt +++ b/core/buttonmapping/domain/src/test/kotlin/com/joegec/joycon2android/buttonmapping/PlayerMappingTest.kt @@ -2,7 +2,7 @@ package com.joegec.joycon2android.buttonmapping import com.joegec.joycon2android.buttonmapping.MappingFixture.Companion.left import com.joegec.joycon2android.buttonmapping.MappingFixture.Companion.right -import com.joegec.joycon2android.buttonmapping.preset.MarioKartWiiMapping +import com.joegec.joycon2android.buttonmapping.preset.MarioKartWheelMapping import com.joegec.joycon2android.buttonmapping.preset.WiiMapping import com.joegec.joycon2android.model.PlayerNumber import kotlinx.coroutines.runBlocking @@ -20,26 +20,26 @@ class PlayerMappingTest { @Test fun `an untouched player reads as the layout they chose`() = runBlocking { - fixture.applyLayout(fixture.console, body, MarioKartWiiMapping.id) + fixture.applyLayout(fixture.console, body, MarioKartWheelMapping.id) - assertEquals(MarioKartWiiMapping.displayName, fixture.playerMapping(body).layout?.displayName) + assertEquals(MarioKartWheelMapping.displayName, fixture.playerMapping(body).layout?.displayName) } @Test fun `changing a binding turns it custom, and undoing that change turns it back`() = runBlocking { - fixture.applyLayout(fixture.console, body, MarioKartWiiMapping.id) - val original = MarioKartWiiMapping.entries(body.side).getValue("A") + fixture.applyLayout(fixture.console, body, MarioKartWheelMapping.id) + val original = MarioKartWheelMapping.entries(body.side).getValue("A") fixture.setMapping(fixture.console, body, "A", "Up") assertNull(fixture.playerMapping(body).layout) fixture.setMapping(fixture.console, body, "A", original) - assertEquals(MarioKartWiiMapping.id, fixture.playerMapping(body).layout?.id) + assertEquals(MarioKartWheelMapping.id, fixture.playerMapping(body).layout?.id) } @Test fun `the sideways-remote switch counts as a change of its own`() = runBlocking { - fixture.applyLayout(fixture.console, body, MarioKartWiiMapping.id) + fixture.applyLayout(fixture.console, body, MarioKartWheelMapping.id) fixture.setSidewaysRemote(fixture.console, body, false) @@ -49,7 +49,7 @@ class PlayerMappingTest { @Test fun `one player's change leaves the next player's mapping alone`() = runBlocking { val other = left(PlayerNumber.P2) - fixture.applyLayout(fixture.console, body, MarioKartWiiMapping.id) + fixture.applyLayout(fixture.console, body, MarioKartWheelMapping.id) fixture.applyLayout(fixture.console, other, WiiMapping.id) fixture.setMapping(fixture.console, body, "A", "Up") @@ -60,7 +60,7 @@ class PlayerMappingTest { @Test fun `saving names what the player built and offers it to that body`() = runBlocking { - fixture.applyLayout(fixture.console, body, MarioKartWiiMapping.id) + fixture.applyLayout(fixture.console, body, MarioKartWheelMapping.id) fixture.setMapping(fixture.console, body, "A", "Up") fixture.saveCustomLayout(fixture.console, body, "My Wheel") diff --git a/core/buttonmapping/domain/src/test/kotlin/com/joegec/joycon2android/buttonmapping/SidewaysRemoteTest.kt b/core/buttonmapping/domain/src/test/kotlin/com/joegec/joycon2android/buttonmapping/SidewaysRemoteTest.kt index 3f19572..2b9f53d 100644 --- a/core/buttonmapping/domain/src/test/kotlin/com/joegec/joycon2android/buttonmapping/SidewaysRemoteTest.kt +++ b/core/buttonmapping/domain/src/test/kotlin/com/joegec/joycon2android/buttonmapping/SidewaysRemoteTest.kt @@ -1,7 +1,7 @@ package com.joegec.joycon2android.buttonmapping import com.joegec.joycon2android.buttonmapping.MappingFixture.Companion.right -import com.joegec.joycon2android.buttonmapping.preset.MarioKartWiiMapping +import com.joegec.joycon2android.buttonmapping.preset.MarioKartWheelMapping import com.joegec.joycon2android.buttonmapping.preset.WiiMapping import com.joegec.joycon2android.model.PlayerNumber import kotlinx.coroutines.runBlocking @@ -22,7 +22,7 @@ class SidewaysRemoteTest { @Test fun `applying a layout takes its answer with it, either way`() = runBlocking { - fixture.applyLayout(fixture.console, body, MarioKartWiiMapping.id) + fixture.applyLayout(fixture.console, body, MarioKartWheelMapping.id) assertTrue(fixture.playerMapping(body).sidewaysRemote) fixture.applyLayout(fixture.console, body, WiiMapping.id) diff --git a/core/buttonmapping/domain/src/test/kotlin/com/joegec/joycon2android/buttonmapping/preset/WiiPresetsTest.kt b/core/buttonmapping/domain/src/test/kotlin/com/joegec/joycon2android/buttonmapping/preset/WiiPresetsTest.kt index dcf7ee3..cc574d3 100644 --- a/core/buttonmapping/domain/src/test/kotlin/com/joegec/joycon2android/buttonmapping/preset/WiiPresetsTest.kt +++ b/core/buttonmapping/domain/src/test/kotlin/com/joegec/joycon2android/buttonmapping/preset/WiiPresetsTest.kt @@ -2,7 +2,12 @@ package com.joegec.joycon2android.buttonmapping.preset import com.joegec.joycon2android.buttonmapping.Console import com.joegec.joycon2android.buttonmapping.JoyconSide +import com.joegec.joycon2android.buttonmapping.MappingLayouts +import com.joegec.joycon2android.buttonmapping.MappingSource +import com.joegec.joycon2android.buttonmapping.emittedFor +import com.joegec.joycon2android.buttonmapping.sourceIdsOf import com.joegec.joycon2android.buttonmapping.target.WiimoteButton +import com.joegec.joycon2android.buttonmapping.target.WiimoteStick import org.junit.Assert.assertEquals import org.junit.Assert.assertFalse import org.junit.Assert.assertTrue @@ -28,7 +33,7 @@ class WiiPresetsTest { @Test fun `Mario Kart accelerates and brakes on the buttons Mario Kart 8 uses`() { - val right = MarioKartWiiMapping.entries(JoyconSide.RIGHT) + val right = MarioKartWheelMapping.entries(JoyconSide.RIGHT) assertEquals("X", right.getValue(WiimoteButton.Two.name)) assertEquals("A", right.getValue(WiimoteButton.One.name)) @@ -37,7 +42,7 @@ class WiiPresetsTest { @Test fun `Mario Kart puts both bodies' jobs under the same thumb positions`() { - val left = MarioKartWiiMapping.entries(JoyconSide.LEFT) + val left = MarioKartWheelMapping.entries(JoyconSide.LEFT) // Sideways, the left Joy-Con's Down sits where the right's X does, Left where its A does, // Right where its Y does and Up where its B does (see SidewaysMapper). @@ -50,21 +55,85 @@ class WiiPresetsTest { @Test fun `Mario Kart throws an item from SL as well as the stick`() { - assertEquals("RIGHT_STICK_UP|SlRight", MarioKartWiiMapping.entries(JoyconSide.RIGHT).getValue("DPadUp")) - assertEquals("LEFT_STICK_UP|SlLeft", MarioKartWiiMapping.entries(JoyconSide.LEFT).getValue("DPadUp")) - assertEquals("LEFT_STICK_DOWN", MarioKartWiiMapping.entries(JoyconSide.LEFT).getValue("DPadDown")) + assertEquals("RIGHT_STICK_UP|SlRight", MarioKartWheelMapping.entries(JoyconSide.RIGHT).getValue("DPadUp")) + assertEquals("LEFT_STICK_UP|SlLeft", MarioKartWheelMapping.entries(JoyconSide.LEFT).getValue("DPadUp")) + assertEquals("LEFT_STICK_DOWN", MarioKartWheelMapping.entries(JoyconSide.LEFT).getValue("DPadDown")) } @Test - fun `only the Mario Kart layout plays as a sideways Wii Remote`() { - assertTrue(MarioKartWiiMapping.sidewaysRemote) + fun `a lone Joy-Con on the Nunchuck layout plays both halves itself`() { + JoyconSide.entries.filterNot { it == JoyconSide.DUAL }.forEach { side -> + val lone = MarioKartNunchukMapping.entries(side) + val rail = if (side == JoyconSide.LEFT) "SlLeft" else "SlRight" + + assertEquals("$side", rail, lone.getValue(WiimoteButton.NunchukZ.name)) + assertTrue("$side steers from its own stick", lone.keys.any { it.startsWith(WiimoteStick.NunchukStick.name) }) + // Bound to nothing on purpose: left out, each would keep what the Wii layout bound. + listOf( + WiimoteButton.DPadUp, WiimoteButton.DPadDown, WiimoteButton.DPadLeft, WiimoteButton.DPadRight, + WiimoteButton.One, WiimoteButton.Two, WiimoteButton.Minus, + ).forEach { assertEquals("$side ${it.name}", "", lone.getValue(it.name)) } + } + } + + // Sideways, a left Joy-Con's cluster rotates onto the faces, so the same thumb position can do + // the same job on both bodies — which is only true if each names the button that gets it there. + @Test + fun `the Nunchuck layout puts the same job under the same thumb on both bodies`() { + val left = MarioKartNunchukMapping.entries(JoyconSide.LEFT) + val right = MarioKartNunchukMapping.entries(JoyconSide.RIGHT) + + listOf(WiimoteButton.A, WiimoteButton.B, WiimoteButton.NunchukC).forEach { target -> + assertEquals( + target.name, + emittedFace(left, target, JoyconSide.LEFT), + emittedFace(right, target, JoyconSide.RIGHT), + ) + } + } + + private fun emittedFace(entries: Map, target: WiimoteButton, side: JoyconSide) = + (MappingSource.fromId(sourceIdsOf(entries.getValue(target.name)).first()) as? MappingSource.Button) + ?.button + ?.emittedFor(side) + + // A layout lies over the console's default, so anything it leaves out keeps the default's + // binding and quietly doubles up with whatever it did name. + @Test + fun `no button on a lone Joy-Con fires two targets, bar the shoulder that hops and tricks`() { + JoyconSide.entries.filterNot { it == JoyconSide.DUAL }.forEach { side -> + val entries = MappingLayouts.entriesOf(Console.WIIMOTE_NUNCHUK, side, MarioKartNunchukMapping) + val fired = mutableMapOf>() + entries.forEach { (target, value) -> + sourceIdsOf(value).forEach { fired.getOrPut(it) { mutableSetOf() }.add(target) } + } + val rail = if (side == JoyconSide.LEFT) "SrLeft" else "SrRight" + + assertEquals( + "$side", + mapOf(rail to setOf(WiimoteButton.B.name, WiimoteButton.Shake.name)), + fired.filterValues { it.size > 1 }, + ) + } + } + + @Test + fun `the wheel is offered to a lone Joy-Con alone, the Nunchuck layout to every body`() { + assertEquals(setOf(JoyconSide.LEFT, JoyconSide.RIGHT), MarioKartWheelMapping.sides) + assertEquals(JoyconSide.entries.toSet(), MarioKartNunchukMapping.sides) + } + + @Test + fun `only the Mario Kart layouts play as a sideways Wii Remote`() { + assertTrue(MarioKartWheelMapping.sidewaysRemote) + assertTrue(MarioKartNunchukMapping.sidewaysRemote) assertFalse(WiiMapping.sidewaysRemote) assertFalse(JoyconWiiMapping.sidewaysRemote) } @Test - fun `Mario Kart moves a pair's index fingers onto the shoulders, and tricks from one`() { - val pair = MarioKartWiiMapping.entries(JoyconSide.DUAL) + fun `Mario Kart Nunchuck moves a pair's fingers onto the shoulders, and tricks from one`() { + val pair = MarioKartNunchukMapping.entries(JoyconSide.DUAL) assertEquals("ZL", pair.getValue(WiimoteButton.One.name)) assertEquals("ZR", pair.getValue(WiimoteButton.Two.name)) @@ -78,19 +147,19 @@ class WiiPresetsTest { @Test fun `a pair has no sideways grip to match, so the rest stays the Wii layout`() { - val untouched = WiiMapping.entries(JoyconSide.DUAL) - MarioKartWiiMapping.entries(JoyconSide.DUAL).keys + val untouched = WiiMapping.entries(JoyconSide.DUAL) - MarioKartNunchukMapping.entries(JoyconSide.DUAL).keys assertTrue(untouched.isEmpty()) assertEquals( WiiMapping.entries(JoyconSide.DUAL).getValue(WiimoteButton.A.name), - MarioKartWiiMapping.entries(JoyconSide.DUAL).getValue(WiimoteButton.A.name), + MarioKartNunchukMapping.entries(JoyconSide.DUAL).getValue(WiimoteButton.A.name), ) } @Test - fun `Mario Kart tricks off SR on a lone Joy-Con, the shoulder that already hops`() { + fun `Mario Kart Wheel tricks off SR on a lone Joy-Con, the shoulder that already hops`() { JoyconSide.entries.filterNot { it == JoyconSide.DUAL }.forEach { side -> - val lone = MarioKartWiiMapping.entries(side) + val lone = MarioKartWheelMapping.entries(side) assertEquals("$side", lone.getValue(WiimoteButton.B.name), lone.getValue(WiimoteButton.Shake.name)) } @@ -102,8 +171,13 @@ class WiiPresetsTest { val remote = (WiimoteButton.entries - WiimoteButton.NunchukC - WiimoteButton.NunchukZ - WiimoteButton.Shake).map { it.name } - MappingPresets.forConsole(Console.WIIMOTE_NUNCHUK).forEach { preset -> - assertTrue("${preset.displayName} binds the remote", preset.entries(JoyconSide.RIGHT).keys.containsAll(remote)) - } + MappingPresets.forConsole(Console.WIIMOTE_NUNCHUK) + .filterNot { it == MarioKartNunchukMapping } + .forEach { preset -> + assertTrue( + "${preset.displayName} binds the remote", + preset.entries(JoyconSide.RIGHT).keys.containsAll(remote), + ) + } } } From 0aba6e5072a7df79989b0c554353f1fd87e45ec3 Mon Sep 17 00:00:00 2001 From: Joe Barker Date: Tue, 22 Sep 2026 22:58:27 +0100 Subject: [PATCH 11/16] Close a binding's menu once None is picked The menu stays open while sources are ticked off, since a target can hold several. None empties the row, so there is nothing left to tick and waiting there only asks to be tapped away. It keys off the contract the component already had -- callers put their "none" row first, which is what the trigger falls back to when nothing is selected -- rather than taking a parameter for it. Co-Authored-By: Claude Opus 5 --- .../ui/components/MultiSelectDropdown.kt | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/core/designsystem/src/main/kotlin/com/joegec/joycon2android/ui/components/MultiSelectDropdown.kt b/core/designsystem/src/main/kotlin/com/joegec/joycon2android/ui/components/MultiSelectDropdown.kt index b61e2b5..1f27ee0 100644 --- a/core/designsystem/src/main/kotlin/com/joegec/joycon2android/ui/components/MultiSelectDropdown.kt +++ b/core/designsystem/src/main/kotlin/com/joegec/joycon2android/ui/components/MultiSelectDropdown.kt @@ -31,7 +31,8 @@ import com.joegec.joycon2android.ui.theme.Dimens /** * Id/label picker for a row that can hold several choices at once. The menu stays open while they * are ticked off; tapping outside closes it. With nothing selected it reads as the first option, - * which callers put there as their "none" row. + * which callers put there as their "none" row — picking that one empties the row, so it closes + * rather than waiting for a tick that cannot come. */ @Composable fun MultiSelectDropdown( @@ -46,6 +47,8 @@ fun MultiSelectDropdown( ?: return var expanded by remember { mutableStateOf(false) } + val none = options.firstOrNull()?.first + Box(modifier) { Row( Modifier @@ -70,7 +73,10 @@ fun MultiSelectDropdown( DropdownMenuItem( text = { Text(text) }, leadingIcon = { SelectionTick(selected = id in selectedIds) }, - onClick = { onToggle(id) }, + onClick = { + onToggle(id) + if (id == none) expanded = false + }, ) } } From c2c5010d7270f54126c973aef29f49e881e507d8 Mon Sep 17 00:00:00 2001 From: Joe Barker Date: Tue, 22 Sep 2026 23:03:17 +0100 Subject: [PATCH 12/16] Put the binding rows on the same dropdown as everything else A binding's menu was a pill of bold 10sp text over an unstyled Material menu, where the emulator and layout pickers are an accented value over a bordered panel. Same job, two appearances. Rather than copy the styling across, the pieces are now shared: DropdownTrigger is the accented value every dropdown opens from, and PanelDropdownMenu takes an optional set of ticked ids -- a menu of a set rather than a choice, where every row keeps room for its tick so the labels do not shift as one is chosen. The pickers that pass no set are untouched. Each dropdown keeps its own behaviour on top: one closes when something is picked, the other stays open until None. The binding menu is the one that sits at least its trigger's width rather than exactly it. A source's name runs far longer than an emulator's and its trigger is half a card wide, so pinning it would wrap every row. Co-Authored-By: Claude Opus 5 --- .../ui/components/DropdownTrigger.kt | 43 +++++++++ .../ui/components/MultiSelectDropdown.kt | 92 ++++++------------- .../ui/components/OptionDropdown.kt | 34 +------ .../ui/components/PanelDropdownMenu.kt | 22 +++++ 4 files changed, 92 insertions(+), 99 deletions(-) create mode 100644 core/designsystem/src/main/kotlin/com/joegec/joycon2android/ui/components/DropdownTrigger.kt diff --git a/core/designsystem/src/main/kotlin/com/joegec/joycon2android/ui/components/DropdownTrigger.kt b/core/designsystem/src/main/kotlin/com/joegec/joycon2android/ui/components/DropdownTrigger.kt new file mode 100644 index 0000000..1bc1551 --- /dev/null +++ b/core/designsystem/src/main/kotlin/com/joegec/joycon2android/ui/components/DropdownTrigger.kt @@ -0,0 +1,43 @@ +package com.joegec.joycon2android.ui.components + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.ArrowDropDown +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import com.joegec.joycon2android.ui.theme.Accent +import com.joegec.joycon2android.ui.theme.Dimens +import com.joegec.joycon2android.ui.theme.TextDim + +/** The accented current value every dropdown in the app opens from. */ +@Composable +internal fun DropdownTrigger(label: String, subLabel: String?, onClick: () -> Unit) { + Row( + Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(Dimens.buttonCorner)) + .clickable(onClick = onClick) + .heightIn(min = Dimens.minTouchTarget) + .padding(horizontal = Dimens.emulatorPickerPadding), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Column(Modifier.weight(1f)) { + Text(label, color = Accent, style = MaterialTheme.typography.labelMedium) + subLabel?.let { Text(it, color = TextDim, style = MaterialTheme.typography.labelSmall) } + } + Icon(Icons.Filled.ArrowDropDown, contentDescription = null, tint = Accent) + } +} diff --git a/core/designsystem/src/main/kotlin/com/joegec/joycon2android/ui/components/MultiSelectDropdown.kt b/core/designsystem/src/main/kotlin/com/joegec/joycon2android/ui/components/MultiSelectDropdown.kt index 1f27ee0..b49c9da 100644 --- a/core/designsystem/src/main/kotlin/com/joegec/joycon2android/ui/components/MultiSelectDropdown.kt +++ b/core/designsystem/src/main/kotlin/com/joegec/joycon2android/ui/components/MultiSelectDropdown.kt @@ -1,32 +1,16 @@ package com.joegec.joycon2android.ui.components -import androidx.compose.foundation.clickable -import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size -import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.ArrowDropDown -import androidx.compose.material.icons.filled.Check -import androidx.compose.material3.DropdownMenu -import androidx.compose.material3.DropdownMenuItem -import androidx.compose.material3.Icon -import androidx.compose.material3.Text +import androidx.compose.foundation.layout.widthIn import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue -import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.clip -import androidx.compose.ui.text.font.FontWeight -import com.joegec.joycon2android.ui.theme.Accent -import com.joegec.joycon2android.ui.theme.Dimens +import androidx.compose.ui.layout.onSizeChanged +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.unit.dp /** * Id/label picker for a row that can hold several choices at once. The menu stays open while they @@ -41,53 +25,29 @@ fun MultiSelectDropdown( onToggle: (String) -> Unit, modifier: Modifier = Modifier, ) { - val selectedLabels = selectedIds.mapNotNull { id -> options.firstOrNull { it.first == id }?.second } - val label = selectedLabels.takeIf { it.isNotEmpty() }?.joinToString(", ") - ?: options.firstOrNull()?.second - ?: return + val none = options.firstOrNull() ?: return + val label = selectedIds.mapNotNull { id -> options.firstOrNull { it.first == id }?.second } + .takeIf { it.isNotEmpty() } + ?.joinToString(", ") + ?: none.second var expanded by remember { mutableStateOf(false) } + var anchorWidth by remember { mutableStateOf(0.dp) } + val density = LocalDensity.current - val none = options.firstOrNull()?.first - - Box(modifier) { - Row( - Modifier - .fillMaxWidth() - .clip(RoundedCornerShape(Dimens.buttonCorner)) - .clickable { expanded = true } - .padding(horizontal = Dimens.pillPaddingHorizontal, vertical = Dimens.pillPaddingVertical), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically, - ) { - Text( - label, - color = Accent, - fontSize = Dimens.fontSizeSmall, - fontWeight = FontWeight.Bold, - modifier = Modifier.weight(1f), - ) - Icon(Icons.Filled.ArrowDropDown, contentDescription = null, tint = Accent) - } - DropdownMenu(expanded = expanded, onDismissRequest = { expanded = false }) { - options.forEach { (id, text) -> - DropdownMenuItem( - text = { Text(text) }, - leadingIcon = { SelectionTick(selected = id in selectedIds) }, - onClick = { - onToggle(id) - if (id == none) expanded = false - }, - ) - } - } - } -} - -@Composable -private fun SelectionTick(selected: Boolean) { - if (selected) { - Icon(Icons.Filled.Check, contentDescription = null, tint = Accent) - } else { - Spacer(Modifier.size(Dimens.iconSizeMedium)) + Box(modifier.onSizeChanged { anchorWidth = with(density) { it.width.toDp() } }) { + DropdownTrigger(label, subLabel = null) { expanded = true } + PanelDropdownMenu( + expanded = expanded, + onDismissRequest = { expanded = false }, + options = options.map { (id, text) -> DropdownOption(id, text) }, + selectedId = null, + ticked = selectedIds.toSet(), + // A source's name runs longer than an emulator's, so the panel may outgrow its trigger. + modifier = Modifier.widthIn(min = anchorWidth), + onSelect = { option -> + onToggle(option.id) + if (option.id == none.first) expanded = false + }, + ) } } diff --git a/core/designsystem/src/main/kotlin/com/joegec/joycon2android/ui/components/OptionDropdown.kt b/core/designsystem/src/main/kotlin/com/joegec/joycon2android/ui/components/OptionDropdown.kt index 21d7be8..41180af 100644 --- a/core/designsystem/src/main/kotlin/com/joegec/joycon2android/ui/components/OptionDropdown.kt +++ b/core/designsystem/src/main/kotlin/com/joegec/joycon2android/ui/components/OptionDropdown.kt @@ -1,34 +1,17 @@ package com.joegec.joycon2android.ui.components -import androidx.compose.foundation.clickable -import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.heightIn -import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.width -import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.ArrowDropDown -import androidx.compose.material3.Icon -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue -import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.clip import androidx.compose.ui.layout.onSizeChanged import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.unit.dp -import com.joegec.joycon2android.ui.theme.Accent import com.joegec.joycon2android.ui.theme.Dimens -import com.joegec.joycon2android.ui.theme.TextDim /** * The app's picker: an accented current value that opens a panel of alternatives. [label] is shown @@ -50,22 +33,7 @@ fun OptionDropdown( val density = LocalDensity.current Box(modifier.onSizeChanged { anchorWidth = with(density) { it.width.toDp() } }) { - Row( - Modifier - .fillMaxWidth() - .clip(RoundedCornerShape(Dimens.buttonCorner)) - .clickable { expanded = true } - .heightIn(min = Dimens.minTouchTarget) - .padding(horizontal = Dimens.emulatorPickerPadding), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically, - ) { - Column(Modifier.weight(1f)) { - Text(label, color = Accent, style = MaterialTheme.typography.labelMedium) - subLabel?.let { Text(it, color = TextDim, style = MaterialTheme.typography.labelSmall) } - } - Icon(Icons.Filled.ArrowDropDown, contentDescription = null, tint = Accent) - } + DropdownTrigger(label, subLabel) { expanded = true } PanelDropdownMenu( expanded = expanded, onDismissRequest = { expanded = false }, diff --git a/core/designsystem/src/main/kotlin/com/joegec/joycon2android/ui/components/PanelDropdownMenu.kt b/core/designsystem/src/main/kotlin/com/joegec/joycon2android/ui/components/PanelDropdownMenu.kt index 2924ef6..02aa20a 100644 --- a/core/designsystem/src/main/kotlin/com/joegec/joycon2android/ui/components/PanelDropdownMenu.kt +++ b/core/designsystem/src/main/kotlin/com/joegec/joycon2android/ui/components/PanelDropdownMenu.kt @@ -2,9 +2,11 @@ package com.joegec.joycon2android.ui.components import androidx.compose.foundation.BorderStroke import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.size import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Check import androidx.compose.material.icons.filled.DeleteOutline import androidx.compose.material3.DropdownMenu import androidx.compose.material3.DropdownMenuItem @@ -32,6 +34,8 @@ fun PanelDropdownMenu( onSelect: (DropdownOption) -> Unit, modifier: Modifier = Modifier, onDelete: ((DropdownOption) -> Unit)? = null, + /** A menu of a set rather than a choice: every row keeps room for its tick. */ + ticked: Set? = null, ) { DropdownMenu( expanded = expanded, @@ -45,6 +49,7 @@ fun PanelDropdownMenu( options.forEach { option -> DropdownMenuItem( text = { OptionText(option, selected = option.id == selectedId) }, + leadingIcon = tickSlot(ticked, option), trailingIcon = deleteAction(option, onDelete), onClick = { onSelect(option) }, ) @@ -84,3 +89,20 @@ private fun deleteAction( } } } + +// Every row of a set keeps the slot, ticked or not, or the labels would shift as one is chosen. +private fun tickSlot(ticked: Set?, option: DropdownOption): (@Composable () -> Unit)? { + if (ticked == null) return null + return { + if (option.id in ticked) { + Icon( + Icons.Filled.Check, + contentDescription = null, + tint = Accent, + modifier = Modifier.size(Dimens.iconSizeMedium), + ) + } else { + Spacer(Modifier.size(Dimens.iconSizeMedium)) + } + } +} From e2b1235892b0dc4d8ec596c3856daa962f8eaad2 Mon Sep 17 00:00:00 2001 From: Joe Barker Date: Tue, 22 Sep 2026 23:28:47 +0100 Subject: [PATCH 13/16] Say what each layout does, and move 1 and 2 onto the shoulders A layout now carries a line saying what picking it does, in the same dim sub-label the saved sets use for their player list -- so it shows in a player's dropdown rows, under the selected name on that dropdown, and in the All players rows. The two Standard presets have none: they are the only layout their console offers, so there is nothing to tell apart. The Joy-Con layout puts the remote's 1 and 2 on the shoulders, where a thumb need not leave the stick to reach them, and the left hand's A, B and plus move with them. It was literally "Wii with B and 2 exchanged" and is now its own arrangement stated over the Wii base, which takes swappingSources with it -- that swap was its only caller. Its left hand shuffles four sources at once, so a test asserts no button fires two of its targets, the way the Nunchuk layout already does. The sideways switch's own copy is the author's. Co-Authored-By: Claude Opus 5 --- .../buttonmapping/MappingLayout.kt | 3 ++ .../buttonmapping/preset/JoyconWiiMapping.kt | 41 +++++++++++++++++-- .../buttonmapping/preset/MappingEntries.kt | 7 ---- .../preset/MarioKartNunchukMapping.kt | 1 + .../preset/MarioKartWheelMapping.kt | 1 + .../buttonmapping/preset/WiiMapping.kt | 1 + .../buttonmapping/preset/WiiPresetsTest.kt | 33 ++++++++++++--- .../presentation/ControllerMappingUiState.kt | 8 +++- .../presentation/PlayerMappingCard.kt | 1 + .../src/main/res/values/strings.xml | 4 +- 10 files changed, 81 insertions(+), 19 deletions(-) diff --git a/core/buttonmapping/domain/src/main/kotlin/com/joegec/joycon2android/buttonmapping/MappingLayout.kt b/core/buttonmapping/domain/src/main/kotlin/com/joegec/joycon2android/buttonmapping/MappingLayout.kt index 4a041d5..56a933f 100644 --- a/core/buttonmapping/domain/src/main/kotlin/com/joegec/joycon2android/buttonmapping/MappingLayout.kt +++ b/core/buttonmapping/domain/src/main/kotlin/com/joegec/joycon2android/buttonmapping/MappingLayout.kt @@ -10,6 +10,9 @@ interface MappingLayout { val id: String val displayName: String + /** A line under the name, saying what picking it does. */ + val description: String? get() = null + /** * Whether this layout stands a lone Joy-Con in for a Wii Remote held sideways, the way a game * written for that grip expects one. Its motion turns onto the sideways remote's frame and its diff --git a/core/buttonmapping/domain/src/main/kotlin/com/joegec/joycon2android/buttonmapping/preset/JoyconWiiMapping.kt b/core/buttonmapping/domain/src/main/kotlin/com/joegec/joycon2android/buttonmapping/preset/JoyconWiiMapping.kt index ea364b6..850c216 100644 --- a/core/buttonmapping/domain/src/main/kotlin/com/joegec/joycon2android/buttonmapping/preset/JoyconWiiMapping.kt +++ b/core/buttonmapping/domain/src/main/kotlin/com/joegec/joycon2android/buttonmapping/preset/JoyconWiiMapping.kt @@ -3,13 +3,48 @@ package com.joegec.joycon2android.buttonmapping.preset import com.joegec.joycon2android.buttonmapping.Console import com.joegec.joycon2android.buttonmapping.JoyconSide import com.joegec.joycon2android.buttonmapping.target.WiimoteButton +import com.joegec.joycon2android.model.JoyconButton +import com.joegec.joycon2android.model.JoyconButton.B +import com.joegec.joycon2android.model.JoyconButton.Down +import com.joegec.joycon2android.model.JoyconButton.L +import com.joegec.joycon2android.model.JoyconButton.Minus +import com.joegec.joycon2android.model.JoyconButton.R +import com.joegec.joycon2android.model.JoyconButton.Right +import com.joegec.joycon2android.model.JoyconButton.Up +import com.joegec.joycon2android.model.JoyconButton.ZL +import com.joegec.joycon2android.model.JoyconButton.ZR -/** The Wii layout with the remote's B and 2 swapped, so the Joy-Con's own B is the remote's B. */ +/** + * The Wii layout moved onto the buttons a Joy-Con keeps under the same fingers: the remote's B is + * the Joy-Con's own B, and 1 and 2 are the shoulders rather than face buttons a thumb has to leave + * the stick for. + */ object JoyconWiiMapping : MappingPreset { override val id = "JOYCON" override val displayName = "Joy-Con" + override val description = "True to Joy-Con buttons, B → B" override val console = Console.WIIMOTE_NUNCHUK - override fun entries(side: JoyconSide) = - WiiMapping.entries(side).swappingSources(WiimoteButton.B, WiimoteButton.Two) + override fun entries(side: JoyconSide) = WiiMapping.entries(side) + buttons(side).buttonEntries() + + private fun buttons(side: JoyconSide): Map = when (side) { + JoyconSide.DUAL -> mapOf( + WiimoteButton.B to B, + WiimoteButton.One to R, + WiimoteButton.Two to ZR, + WiimoteButton.Minus to Minus, + ) + JoyconSide.RIGHT -> mapOf( + WiimoteButton.B to B, + WiimoteButton.One to R, + WiimoteButton.Two to ZR, + ) + JoyconSide.LEFT -> mapOf( + WiimoteButton.A to Right, + WiimoteButton.B to Down, + WiimoteButton.One to L, + WiimoteButton.Two to ZL, + WiimoteButton.Plus to Up, + ) + } } diff --git a/core/buttonmapping/domain/src/main/kotlin/com/joegec/joycon2android/buttonmapping/preset/MappingEntries.kt b/core/buttonmapping/domain/src/main/kotlin/com/joegec/joycon2android/buttonmapping/preset/MappingEntries.kt index 890b80c..788ecae 100644 --- a/core/buttonmapping/domain/src/main/kotlin/com/joegec/joycon2android/buttonmapping/preset/MappingEntries.kt +++ b/core/buttonmapping/domain/src/main/kotlin/com/joegec/joycon2android/buttonmapping/preset/MappingEntries.kt @@ -16,10 +16,3 @@ internal fun > Map.stickEntries(): Map MappingSource.directionsOf(stick).map { target.directionKey(it.direction) to it.id } }.toMap() - -/** Leaves the layout alone unless both targets are bound, so a preset can't lose one to a swap. */ -internal fun Map.swappingSources(first: Enum<*>, second: Enum<*>): Map { - val firstSource = this[first.name] ?: return this - val secondSource = this[second.name] ?: return this - return this + mapOf(first.name to secondSource, second.name to firstSource) -} diff --git a/core/buttonmapping/domain/src/main/kotlin/com/joegec/joycon2android/buttonmapping/preset/MarioKartNunchukMapping.kt b/core/buttonmapping/domain/src/main/kotlin/com/joegec/joycon2android/buttonmapping/preset/MarioKartNunchukMapping.kt index 2a59a6c..7a8e095 100644 --- a/core/buttonmapping/domain/src/main/kotlin/com/joegec/joycon2android/buttonmapping/preset/MarioKartNunchukMapping.kt +++ b/core/buttonmapping/domain/src/main/kotlin/com/joegec/joycon2android/buttonmapping/preset/MarioKartNunchukMapping.kt @@ -42,6 +42,7 @@ import com.joegec.joycon2android.model.JoyconButton.ZR object MarioKartNunchukMapping : MappingPreset { override val id = "MARIO_KART_NUNCHUK" override val displayName = "Mario Kart Nunchuck" + override val description = "Stick steering, MK8 mapping" override val console = Console.WIIMOTE_NUNCHUK override val family = MARIO_KART override val sidewaysRemote = true diff --git a/core/buttonmapping/domain/src/main/kotlin/com/joegec/joycon2android/buttonmapping/preset/MarioKartWheelMapping.kt b/core/buttonmapping/domain/src/main/kotlin/com/joegec/joycon2android/buttonmapping/preset/MarioKartWheelMapping.kt index 3a871c6..5d21ade 100644 --- a/core/buttonmapping/domain/src/main/kotlin/com/joegec/joycon2android/buttonmapping/preset/MarioKartWheelMapping.kt +++ b/core/buttonmapping/domain/src/main/kotlin/com/joegec/joycon2android/buttonmapping/preset/MarioKartWheelMapping.kt @@ -34,6 +34,7 @@ import com.joegec.joycon2android.model.JoyconButton.Y object MarioKartWheelMapping : MappingPreset { override val id = "MARIO_KART_WHEEL" override val displayName = "Mario Kart Wheel" + override val description = "Motion steering, MK8 mapping" override val console = Console.WIIMOTE_NUNCHUK override val family = MARIO_KART override val sides = setOf(JoyconSide.LEFT, JoyconSide.RIGHT) diff --git a/core/buttonmapping/domain/src/main/kotlin/com/joegec/joycon2android/buttonmapping/preset/WiiMapping.kt b/core/buttonmapping/domain/src/main/kotlin/com/joegec/joycon2android/buttonmapping/preset/WiiMapping.kt index 2477a4b..fee2861 100644 --- a/core/buttonmapping/domain/src/main/kotlin/com/joegec/joycon2android/buttonmapping/preset/WiiMapping.kt +++ b/core/buttonmapping/domain/src/main/kotlin/com/joegec/joycon2android/buttonmapping/preset/WiiMapping.kt @@ -30,6 +30,7 @@ import com.joegec.joycon2android.model.JoyconButton.ZR object WiiMapping : MappingPreset { override val id = "WII" override val displayName = "Wii" + override val description = "Like a Wiimote, B → ZR" override val console = Console.WIIMOTE_NUNCHUK override fun entries(side: JoyconSide) = diff --git a/core/buttonmapping/domain/src/test/kotlin/com/joegec/joycon2android/buttonmapping/preset/WiiPresetsTest.kt b/core/buttonmapping/domain/src/test/kotlin/com/joegec/joycon2android/buttonmapping/preset/WiiPresetsTest.kt index cc574d3..8457dcf 100644 --- a/core/buttonmapping/domain/src/test/kotlin/com/joegec/joycon2android/buttonmapping/preset/WiiPresetsTest.kt +++ b/core/buttonmapping/domain/src/test/kotlin/com/joegec/joycon2android/buttonmapping/preset/WiiPresetsTest.kt @@ -20,14 +20,37 @@ class WiiPresetsTest { assertEquals(WiiMapping, MappingPresets.default(Console.WIIMOTE_NUNCHUK)) } + // 1 and 2 go on the shoulders, so a thumb never leaves the stick to reach them. @Test - fun `the Joy-Con layout swaps the remote's B and 2 on every body`() { + fun `the Joy-Con layout puts the remote's buttons where a Joy-Con keeps them`() { + val dual = JoyconWiiMapping.entries(JoyconSide.DUAL) + assertEquals("B", dual.getValue(WiimoteButton.B.name)) + assertEquals("R", dual.getValue(WiimoteButton.One.name)) + assertEquals("ZR", dual.getValue(WiimoteButton.Two.name)) + assertEquals("Minus", dual.getValue(WiimoteButton.Minus.name)) + + val right = JoyconWiiMapping.entries(JoyconSide.RIGHT) + assertEquals("B", right.getValue(WiimoteButton.B.name)) + assertEquals("R", right.getValue(WiimoteButton.One.name)) + assertEquals("ZR", right.getValue(WiimoteButton.Two.name)) + + val left = JoyconWiiMapping.entries(JoyconSide.LEFT) + assertEquals("Right", left.getValue(WiimoteButton.A.name)) + assertEquals("Down", left.getValue(WiimoteButton.B.name)) + assertEquals("L", left.getValue(WiimoteButton.One.name)) + assertEquals("ZL", left.getValue(WiimoteButton.Two.name)) + assertEquals("Up", left.getValue(WiimoteButton.Plus.name)) + } + + @Test + fun `no button fires two of the Joy-Con layout's targets`() { JoyconSide.entries.forEach { side -> - val wii = WiiMapping.entries(side) - val joycon = JoyconWiiMapping.entries(side) + val fired = mutableMapOf>() + JoyconWiiMapping.entries(side).forEach { (target, value) -> + sourceIdsOf(value).forEach { fired.getOrPut(it) { mutableSetOf() }.add(target) } + } - assertEquals("B on $side", wii.getValue(WiimoteButton.Two.name), joycon.getValue(WiimoteButton.B.name)) - assertEquals("2 on $side", wii.getValue(WiimoteButton.B.name), joycon.getValue(WiimoteButton.Two.name)) + assertEquals("$side", emptyMap>(), fired.filterValues { it.size > 1 }) } } diff --git a/core/buttonmapping/presentation/src/main/kotlin/com/joegec/joycon2android/buttonmapping/presentation/ControllerMappingUiState.kt b/core/buttonmapping/presentation/src/main/kotlin/com/joegec/joycon2android/buttonmapping/presentation/ControllerMappingUiState.kt index 390542d..22638ab 100644 --- a/core/buttonmapping/presentation/src/main/kotlin/com/joegec/joycon2android/buttonmapping/presentation/ControllerMappingUiState.kt +++ b/core/buttonmapping/presentation/src/main/kotlin/com/joegec/joycon2android/buttonmapping/presentation/ControllerMappingUiState.kt @@ -32,6 +32,7 @@ data class PlayerMappingUiState( val layoutOptions: List, val selectedLayoutId: String?, val layoutName: String?, + val layoutDescription: String?, val sidewaysRemote: Boolean, val offersSidewaysRemote: Boolean, val mapping: Map, @@ -51,7 +52,7 @@ internal fun controllerMappingUiState( ) private fun GlobalMapping.uiState(console: Console) = GlobalLayoutUiState( - options = MappingPresets.forConsole(console).map { DropdownOption(it.id, it.displayName) } + + options = MappingPresets.forConsole(console).map { DropdownOption(it.id, it.displayName, it.description) } + savedLayouts.map { DropdownOption( id = it.id, @@ -69,9 +70,12 @@ private fun GlobalMapping.uiState(console: Console) = GlobalLayoutUiState( private fun PlayerMapping.uiState(console: Console, layouts: List) = PlayerMappingUiState( body = body, - layoutOptions = layouts.map { DropdownOption(it.id, it.displayName, deletable = it is SavedLayout) }, + layoutOptions = layouts.map { + DropdownOption(it.id, it.displayName, subLabel = it.description, deletable = it is SavedLayout) + }, selectedLayoutId = layout?.id, layoutName = layout?.displayName, + layoutDescription = layout?.description, sidewaysRemote = sidewaysRemote, offersSidewaysRemote = MappingOptions.offersSidewaysRemote(console, body.side), mapping = entries, diff --git a/core/buttonmapping/presentation/src/main/kotlin/com/joegec/joycon2android/buttonmapping/presentation/PlayerMappingCard.kt b/core/buttonmapping/presentation/src/main/kotlin/com/joegec/joycon2android/buttonmapping/presentation/PlayerMappingCard.kt index 524122e..e6b1c61 100644 --- a/core/buttonmapping/presentation/src/main/kotlin/com/joegec/joycon2android/buttonmapping/presentation/PlayerMappingCard.kt +++ b/core/buttonmapping/presentation/src/main/kotlin/com/joegec/joycon2android/buttonmapping/presentation/PlayerMappingCard.kt @@ -82,6 +82,7 @@ fun PlayerMappingCard( options = state.layoutOptions, selectedId = state.selectedLayoutId, layoutName = state.layoutName, + subLabel = state.layoutDescription, onSelect = { actions.selectLayout(state.body, it) }, onSave = onSaveLayout, onDelete = onDeleteLayout, diff --git a/core/buttonmapping/presentation/src/main/res/values/strings.xml b/core/buttonmapping/presentation/src/main/res/values/strings.xml index c85c7ab..e735a4e 100644 --- a/core/buttonmapping/presentation/src/main/res/values/strings.xml +++ b/core/buttonmapping/presentation/src/main/res/values/strings.xml @@ -13,8 +13,8 @@ Delete Connect a controller to map it. Sideways Wii Remote - Rotates the d-pad, and a flick counts as a shake. - Steering reads correctly, the D-pad turns with it, and a flick counts as a shake. + Rotates the d-pad and stick. + Steering reads correctly, rotates the d-pad and stick. When enabled, a right Joy-Con points from its tail rather than its R edge. P%1$d Left From cb06b9e095dc5970c6a5625acbdc3e9f6b912df9 Mon Sep 17 00:00:00 2001 From: Joe Barker Date: Tue, 22 Sep 2026 23:42:16 +0100 Subject: [PATCH 14/16] Take the user-facing strings out of the domain A domain module is pure Kotlin and cannot see R.string, so every name in one was a name that could never be translated or reworded without touching logic. They are all gone: Console, JoyconSide, StickDirection, StickSource and the six target vocabularies are bare enums now, and GlobalLayout no longer builds "P1 L, P2 R" -- that is a sentence, and sentences belong where sentences are built. Presentation names all of it, through an exhaustive `when` over each type rather than a map, so a target added without a word does not build. That is worth more here than brevity: the alternative fails at runtime, on a screen, with an enum constant where a label should be. Two things that look like copy are not, and stay where they are. Dolphin's ini spelling of a stick direction was riding on StickDirection.displayName, the editor's "Up" and the key "Main Stick/Up" being the same word by coincidence; it moves to DolphinControls beside EdenControls, shared by the two generators that write one. And JoyconButton.label -- "ZL", "+", "A" -- is the marking printed on the hardware, drawn onto the controller picture. It is part of the drawing rather than prose, and reads the same in any language. Both exceptions are written into CLAUDE.md with the rule, so the line is recorded rather than argued again. One domain test asserted "P1 L, P2 R, P3 L/R", which is exactly the smell being removed; it now asserts the bodies and their order. Co-Authored-By: Claude Opus 5 --- CLAUDE.md | 11 +- .../buttonmapping/GlobalLayoutDataStore.kt | 2 +- .../buttonmapping/LayoutJson.kt | 8 +- .../buttonmapping/SavedLayoutDataStore.kt | 2 +- .../joycon2android/buttonmapping/Console.kt | 6 +- .../buttonmapping/GlobalLayout.kt | 5 +- .../buttonmapping/GlobalMapping.kt | 20 ++- .../buttonmapping/JoyconSide.kt | 6 +- .../buttonmapping/LayoutFamily.kt | 7 + .../buttonmapping/MappingLayout.kt | 7 +- .../buttonmapping/SaveCustomLayoutUseCase.kt | 2 +- .../buttonmapping/SavedLayout.kt | 2 +- .../buttonmapping/StickDirection.kt | 7 +- .../buttonmapping/StickSource.kt | 5 +- .../buttonmapping/preset/GameCubeMapping.kt | 1 - .../buttonmapping/preset/JoyconWiiMapping.kt | 2 - .../buttonmapping/preset/MappingPreset.kt | 8 +- .../buttonmapping/preset/MappingPresets.kt | 2 - .../preset/MarioKartNunchukMapping.kt | 5 +- .../preset/MarioKartWheelMapping.kt | 5 +- .../buttonmapping/preset/SwitchProMapping.kt | 1 - .../buttonmapping/preset/WiiMapping.kt | 2 - .../buttonmapping/target/GameCubeButton.kt | 26 ++-- .../buttonmapping/target/GameCubeStick.kt | 6 +- .../buttonmapping/target/SwitchProButton.kt | 38 +++--- .../buttonmapping/target/SwitchProStick.kt | 6 +- .../buttonmapping/target/WiimoteButton.kt | 30 ++-- .../buttonmapping/target/WiimoteStick.kt | 4 +- .../buttonmapping/GlobalMappingTest.kt | 25 ++-- .../buttonmapping/MappingFixture.kt | 2 +- .../buttonmapping/PlayerMappingTest.kt | 12 +- .../buttonmapping/preset/WiiPresetsTest.kt | 2 +- .../presentation/ControllerMappingScreen.kt | 34 +++-- .../presentation/ControllerMappingUiState.kt | 55 ++------ .../presentation/LayoutLabels.kt | 93 +++++++++++++ .../presentation/MappingLabels.kt | 128 ++++++++++++++++++ .../presentation/MappingOptions.kt | 34 +++-- .../presentation/PlayerMappingCard.kt | 11 +- .../src/main/res/values/strings.xml | 43 ++++++ .../emulatorconfig/DolphinControls.kt | 16 +++ .../dsu/emulator/DolphinWiimoteConfig.kt | 3 +- .../gamepad/emulator/DolphinGcpadConfig.kt | 3 +- 42 files changed, 472 insertions(+), 215 deletions(-) create mode 100644 core/buttonmapping/domain/src/main/kotlin/com/joegec/joycon2android/buttonmapping/LayoutFamily.kt create mode 100644 core/buttonmapping/presentation/src/main/kotlin/com/joegec/joycon2android/buttonmapping/presentation/LayoutLabels.kt create mode 100644 core/buttonmapping/presentation/src/main/kotlin/com/joegec/joycon2android/buttonmapping/presentation/MappingLabels.kt create mode 100644 core/emulatorconfig/src/main/kotlin/com/joegec/joycon2android/emulatorconfig/DolphinControls.kt diff --git a/CLAUDE.md b/CLAUDE.md index eb2b427..43e7486 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -62,7 +62,16 @@ ## Conventions - Use `enableEdgeToEdge()` with `WindowInsets.systemBars` for edge-to-edge inset handling -- Avoid hard-coded strings — use string resources where possible +- **User-facing strings never live in a domain module.** Those modules are pure Kotlin/JVM and + cannot see `R.string` at all, so a name in one is a name that can never be translated or reworded + without touching logic. A domain type carries its *identity* — the enum entry, the id it is stored + under — and presentation gives it a word, through an exhaustive `when` over the type so that + adding a case without a word fails to build (see `MappingLabels` / `LayoutLabels`) +- Two things that look like copy but are not, and stay: **wire tokens** an emulator or protocol must + match exactly (`DolphinControls.DIRECTIONS`, `EdenControls`), and the **markings printed on the + hardware** that the controller graphics draw (`JoyconButton.label` — "ZL", "+", "A" are the same + in every language, and are part of the picture rather than prose) +- Avoid hard-coded strings elsewhere too — use string resources where possible - Use theme for dimensions and colors rather than inline literals - Prefer immutable data classes for state - Use `@SuppressLint("MissingPermission")` only on methods guarded by the permission launcher diff --git a/core/buttonmapping/data/src/main/kotlin/com/joegec/joycon2android/buttonmapping/GlobalLayoutDataStore.kt b/core/buttonmapping/data/src/main/kotlin/com/joegec/joycon2android/buttonmapping/GlobalLayoutDataStore.kt index ebd3b59..001e958 100644 --- a/core/buttonmapping/data/src/main/kotlin/com/joegec/joycon2android/buttonmapping/GlobalLayoutDataStore.kt +++ b/core/buttonmapping/data/src/main/kotlin/com/joegec/joycon2android/buttonmapping/GlobalLayoutDataStore.kt @@ -18,7 +18,7 @@ class GlobalLayoutDataStore(context: Context) : GlobalLayoutRepository { ) override fun observe(): Flow> = - documents.observe().map { layouts -> layouts.sortedBy { it.displayName } } + documents.observe().map { layouts -> layouts.sortedBy { it.name } } override suspend fun save(layout: GlobalLayout) = documents.save(layout.id, layout) diff --git a/core/buttonmapping/data/src/main/kotlin/com/joegec/joycon2android/buttonmapping/LayoutJson.kt b/core/buttonmapping/data/src/main/kotlin/com/joegec/joycon2android/buttonmapping/LayoutJson.kt index 99ced3b..c6d970f 100644 --- a/core/buttonmapping/data/src/main/kotlin/com/joegec/joycon2android/buttonmapping/LayoutJson.kt +++ b/core/buttonmapping/data/src/main/kotlin/com/joegec/joycon2android/buttonmapping/LayoutJson.kt @@ -14,7 +14,7 @@ private const val PLAYER = "player" private const val ENTRIES = "entries" internal fun SavedLayout.toJson(): String = JSONObject() - .put(NAME, displayName) + .put(NAME, name) .put(CONSOLE, console.name) .put(SIDE, side.name) .put(SIDEWAYS_REMOTE, sidewaysRemote) @@ -26,7 +26,7 @@ internal fun savedLayoutOf(id: String, json: String): SavedLayout? = runCatching val document = JSONObject(json) SavedLayout( id = id, - displayName = document.getString(NAME), + name = document.getString(NAME), console = Console.valueOf(document.getString(CONSOLE)), side = JoyconSide.valueOf(document.getString(SIDE)), bindings = document.getJSONObject(BINDINGS).toStringMap(), @@ -35,7 +35,7 @@ internal fun savedLayoutOf(id: String, json: String): SavedLayout? = runCatching }.getOrNull() internal fun GlobalLayout.toJson(): String = JSONObject() - .put(NAME, displayName) + .put(NAME, name) .put(CONSOLE, console.name) .put(BODIES, JSONArray(bodies.map { it.toJson() })) .toString() @@ -44,7 +44,7 @@ internal fun globalLayoutOf(id: String, json: String): GlobalLayout? = runCatchi val document = JSONObject(json) GlobalLayout( id = id, - displayName = document.getString(NAME), + name = document.getString(NAME), console = Console.valueOf(document.getString(CONSOLE)), bodies = document.getJSONArray(BODIES).objects().map { it.toSnapshot() }, ) diff --git a/core/buttonmapping/data/src/main/kotlin/com/joegec/joycon2android/buttonmapping/SavedLayoutDataStore.kt b/core/buttonmapping/data/src/main/kotlin/com/joegec/joycon2android/buttonmapping/SavedLayoutDataStore.kt index 482235c..a2b3ee3 100644 --- a/core/buttonmapping/data/src/main/kotlin/com/joegec/joycon2android/buttonmapping/SavedLayoutDataStore.kt +++ b/core/buttonmapping/data/src/main/kotlin/com/joegec/joycon2android/buttonmapping/SavedLayoutDataStore.kt @@ -18,7 +18,7 @@ class SavedLayoutDataStore(context: Context) : SavedLayoutRepository { ) override fun observe(): Flow> = - documents.observe().map { layouts -> layouts.sortedBy { it.displayName } } + documents.observe().map { layouts -> layouts.sortedBy { it.name } } override suspend fun save(layout: SavedLayout) = documents.save(layout.id, layout) diff --git a/core/buttonmapping/domain/src/main/kotlin/com/joegec/joycon2android/buttonmapping/Console.kt b/core/buttonmapping/domain/src/main/kotlin/com/joegec/joycon2android/buttonmapping/Console.kt index 1b93669..b275184 100644 --- a/core/buttonmapping/domain/src/main/kotlin/com/joegec/joycon2android/buttonmapping/Console.kt +++ b/core/buttonmapping/domain/src/main/kotlin/com/joegec/joycon2android/buttonmapping/Console.kt @@ -1,8 +1,4 @@ package com.joegec.joycon2android.buttonmapping /** A distinct controller shape an emulator can present to the user, independent of which emulator. */ -enum class Console(val displayName: String) { - GAMECUBE("Gamecube"), - WIIMOTE_NUNCHUK("Wiimote & Nunchuck"), - SWITCH_PRO("Joycons"), -} +enum class Console { GAMECUBE, WIIMOTE_NUNCHUK, SWITCH_PRO } diff --git a/core/buttonmapping/domain/src/main/kotlin/com/joegec/joycon2android/buttonmapping/GlobalLayout.kt b/core/buttonmapping/domain/src/main/kotlin/com/joegec/joycon2android/buttonmapping/GlobalLayout.kt index 4ba94e5..c95b359 100644 --- a/core/buttonmapping/domain/src/main/kotlin/com/joegec/joycon2android/buttonmapping/GlobalLayout.kt +++ b/core/buttonmapping/domain/src/main/kotlin/com/joegec/joycon2android/buttonmapping/GlobalLayout.kt @@ -7,12 +7,9 @@ package com.joegec.joycon2android.buttonmapping */ data class GlobalLayout( val id: String, - val displayName: String, + val name: String, val console: Console, val bodies: List, ) { - val playerSummary: String - get() = bodies.joinToString(", ") { "P${it.body.player.index} ${it.body.side.shortName}" } - fun fits(bodies: List): Boolean = this.bodies.map { it.body }.toSet() == bodies.toSet() } diff --git a/core/buttonmapping/domain/src/main/kotlin/com/joegec/joycon2android/buttonmapping/GlobalMapping.kt b/core/buttonmapping/domain/src/main/kotlin/com/joegec/joycon2android/buttonmapping/GlobalMapping.kt index 6413832..c0ccb66 100644 --- a/core/buttonmapping/domain/src/main/kotlin/com/joegec/joycon2android/buttonmapping/GlobalMapping.kt +++ b/core/buttonmapping/domain/src/main/kotlin/com/joegec/joycon2android/buttonmapping/GlobalMapping.kt @@ -3,10 +3,11 @@ package com.joegec.joycon2android.buttonmapping import com.joegec.joycon2android.buttonmapping.preset.MappingPreset /** - * The session read as one setting. It has a name only while every player agrees on one — a saved - * set whose bindings they all still carry, a single layout every one of them reads as, or one - * family of layouts they are each on their own body's grip of; change one player and the session - * stops being that thing. + * The session read as one setting. It agrees only while every player does — on a saved set whose + * bindings they all still carry, on a single layout every one of them reads as, or on one family of + * layouts they are each on their own body's grip of. Change one player and it agrees on nothing. + * + * Which of those it is, rather than what to call it: the naming is presentation's. */ data class GlobalMapping( val players: List, @@ -14,23 +15,18 @@ data class GlobalMapping( ) { val bodies: List get() = players.map { it.body } - private val matchingSaved: GlobalLayout? + val matchingSaved: GlobalLayout? get() = savedLayouts.firstOrNull { it.bodies == players.map(PlayerMapping::snapshot) } - private val sharedLayout: MappingLayout? + val sharedLayout: MappingLayout? get() = players.takeIf { it.isNotEmpty() }?.map { it.layout }?.distinct()?.singleOrNull() /** A table rarely holds the same thing, so one family across two grips still agrees. */ - private val sharedFamily: String? + val sharedFamily: LayoutFamily? get() = players.takeIf { it.isNotEmpty() } ?.map { (it.layout as? MappingPreset)?.family } ?.distinct() ?.singleOrNull() val selectedId: String? get() = matchingSaved?.id ?: sharedLayout?.id - - val displayName: String? - get() = matchingSaved?.displayName ?: sharedLayout?.displayName ?: sharedFamily - - val playerSummary: String? get() = matchingSaved?.playerSummary } diff --git a/core/buttonmapping/domain/src/main/kotlin/com/joegec/joycon2android/buttonmapping/JoyconSide.kt b/core/buttonmapping/domain/src/main/kotlin/com/joegec/joycon2android/buttonmapping/JoyconSide.kt index 61e1346..b48ff8f 100644 --- a/core/buttonmapping/domain/src/main/kotlin/com/joegec/joycon2android/buttonmapping/JoyconSide.kt +++ b/core/buttonmapping/domain/src/main/kotlin/com/joegec/joycon2android/buttonmapping/JoyconSide.kt @@ -1,8 +1,4 @@ package com.joegec.joycon2android.buttonmapping /** Which physical body a mapping applies to: a lone Joy-Con of one side, or a full controller. */ -enum class JoyconSide(val displayName: String, val shortName: String) { - LEFT("Left Joy-Con", "L"), - RIGHT("Right Joy-Con", "R"), - DUAL("Dual Joy-Cons / Pro Controller", "L/R"), -} +enum class JoyconSide { LEFT, RIGHT, DUAL } diff --git a/core/buttonmapping/domain/src/main/kotlin/com/joegec/joycon2android/buttonmapping/LayoutFamily.kt b/core/buttonmapping/domain/src/main/kotlin/com/joegec/joycon2android/buttonmapping/LayoutFamily.kt new file mode 100644 index 0000000..1836b71 --- /dev/null +++ b/core/buttonmapping/domain/src/main/kotlin/com/joegec/joycon2android/buttonmapping/LayoutFamily.kt @@ -0,0 +1,7 @@ +package com.joegec.joycon2android.buttonmapping + +/** + * Layouts of one game in different grips. Setting one of a family sets every player to whichever of + * them their own body can be held in, since a table is rarely holding the same thing. + */ +enum class LayoutFamily { MARIO_KART } diff --git a/core/buttonmapping/domain/src/main/kotlin/com/joegec/joycon2android/buttonmapping/MappingLayout.kt b/core/buttonmapping/domain/src/main/kotlin/com/joegec/joycon2android/buttonmapping/MappingLayout.kt index 56a933f..3f39c37 100644 --- a/core/buttonmapping/domain/src/main/kotlin/com/joegec/joycon2android/buttonmapping/MappingLayout.kt +++ b/core/buttonmapping/domain/src/main/kotlin/com/joegec/joycon2android/buttonmapping/MappingLayout.kt @@ -5,13 +5,12 @@ package com.joegec.joycon2android.buttonmapping * ([com.joegec.joycon2android.buttonmapping.preset.MappingPreset]) or one the user saved * ([SavedLayout]). Entries are in the repository's opaque string form, so a layout and a stored * override are the same kind of value. + * + * What a shipped one is *called* is not here — that is copy, and it lives in presentation's + * resources, keyed by the layout itself. */ interface MappingLayout { val id: String - val displayName: String - - /** A line under the name, saying what picking it does. */ - val description: String? get() = null /** * Whether this layout stands a lone Joy-Con in for a Wii Remote held sideways, the way a game diff --git a/core/buttonmapping/domain/src/main/kotlin/com/joegec/joycon2android/buttonmapping/SaveCustomLayoutUseCase.kt b/core/buttonmapping/domain/src/main/kotlin/com/joegec/joycon2android/buttonmapping/SaveCustomLayoutUseCase.kt index be99328..0124919 100644 --- a/core/buttonmapping/domain/src/main/kotlin/com/joegec/joycon2android/buttonmapping/SaveCustomLayoutUseCase.kt +++ b/core/buttonmapping/domain/src/main/kotlin/com/joegec/joycon2android/buttonmapping/SaveCustomLayoutUseCase.kt @@ -15,7 +15,7 @@ class SaveCustomLayoutUseCase( savedLayouts.save( SavedLayout( id = MappingLayouts.newId(), - displayName = name, + name = name, console = console, side = body.side, bindings = current.entries, diff --git a/core/buttonmapping/domain/src/main/kotlin/com/joegec/joycon2android/buttonmapping/SavedLayout.kt b/core/buttonmapping/domain/src/main/kotlin/com/joegec/joycon2android/buttonmapping/SavedLayout.kt index d4bd55a..6a1305a 100644 --- a/core/buttonmapping/domain/src/main/kotlin/com/joegec/joycon2android/buttonmapping/SavedLayout.kt +++ b/core/buttonmapping/domain/src/main/kotlin/com/joegec/joycon2android/buttonmapping/SavedLayout.kt @@ -3,7 +3,7 @@ package com.joegec.joycon2android.buttonmapping /** A layout the user saved from one player's body, offered back to any player holding that body. */ data class SavedLayout( override val id: String, - override val displayName: String, + val name: String, val console: Console, val side: JoyconSide, val bindings: Map, diff --git a/core/buttonmapping/domain/src/main/kotlin/com/joegec/joycon2android/buttonmapping/StickDirection.kt b/core/buttonmapping/domain/src/main/kotlin/com/joegec/joycon2android/buttonmapping/StickDirection.kt index e3c32f3..f15e084 100644 --- a/core/buttonmapping/domain/src/main/kotlin/com/joegec/joycon2android/buttonmapping/StickDirection.kt +++ b/core/buttonmapping/domain/src/main/kotlin/com/joegec/joycon2android/buttonmapping/StickDirection.kt @@ -1,8 +1,3 @@ package com.joegec.joycon2android.buttonmapping -enum class StickDirection(val displayName: String) { - UP("Up"), - DOWN("Down"), - LEFT("Left"), - RIGHT("Right"), -} +enum class StickDirection { UP, DOWN, LEFT, RIGHT } diff --git a/core/buttonmapping/domain/src/main/kotlin/com/joegec/joycon2android/buttonmapping/StickSource.kt b/core/buttonmapping/domain/src/main/kotlin/com/joegec/joycon2android/buttonmapping/StickSource.kt index de498c9..d0b3a89 100644 --- a/core/buttonmapping/domain/src/main/kotlin/com/joegec/joycon2android/buttonmapping/StickSource.kt +++ b/core/buttonmapping/domain/src/main/kotlin/com/joegec/joycon2android/buttonmapping/StickSource.kt @@ -1,7 +1,4 @@ package com.joegec.joycon2android.buttonmapping /** A physical analog stick. A lone Joy-Con has just the one on its own side. */ -enum class StickSource(val displayName: String) { - LEFT_STICK("Left Stick"), - RIGHT_STICK("Right Stick"), -} +enum class StickSource { LEFT_STICK, RIGHT_STICK } diff --git a/core/buttonmapping/domain/src/main/kotlin/com/joegec/joycon2android/buttonmapping/preset/GameCubeMapping.kt b/core/buttonmapping/domain/src/main/kotlin/com/joegec/joycon2android/buttonmapping/preset/GameCubeMapping.kt index 97536fa..efdbf01 100644 --- a/core/buttonmapping/domain/src/main/kotlin/com/joegec/joycon2android/buttonmapping/preset/GameCubeMapping.kt +++ b/core/buttonmapping/domain/src/main/kotlin/com/joegec/joycon2android/buttonmapping/preset/GameCubeMapping.kt @@ -29,7 +29,6 @@ import com.joegec.joycon2android.model.JoyconButton.Y object GameCubeMapping : MappingPreset { override val id = "STANDARD" - override val displayName = "Standard" override val console = Console.GAMECUBE override fun entries(side: JoyconSide) = buttons(side).buttonEntries() + sticks(side).stickEntries() diff --git a/core/buttonmapping/domain/src/main/kotlin/com/joegec/joycon2android/buttonmapping/preset/JoyconWiiMapping.kt b/core/buttonmapping/domain/src/main/kotlin/com/joegec/joycon2android/buttonmapping/preset/JoyconWiiMapping.kt index 850c216..1e780c3 100644 --- a/core/buttonmapping/domain/src/main/kotlin/com/joegec/joycon2android/buttonmapping/preset/JoyconWiiMapping.kt +++ b/core/buttonmapping/domain/src/main/kotlin/com/joegec/joycon2android/buttonmapping/preset/JoyconWiiMapping.kt @@ -21,8 +21,6 @@ import com.joegec.joycon2android.model.JoyconButton.ZR */ object JoyconWiiMapping : MappingPreset { override val id = "JOYCON" - override val displayName = "Joy-Con" - override val description = "True to Joy-Con buttons, B → B" override val console = Console.WIIMOTE_NUNCHUK override fun entries(side: JoyconSide) = WiiMapping.entries(side) + buttons(side).buttonEntries() diff --git a/core/buttonmapping/domain/src/main/kotlin/com/joegec/joycon2android/buttonmapping/preset/MappingPreset.kt b/core/buttonmapping/domain/src/main/kotlin/com/joegec/joycon2android/buttonmapping/preset/MappingPreset.kt index 4dec938..08df49a 100644 --- a/core/buttonmapping/domain/src/main/kotlin/com/joegec/joycon2android/buttonmapping/preset/MappingPreset.kt +++ b/core/buttonmapping/domain/src/main/kotlin/com/joegec/joycon2android/buttonmapping/preset/MappingPreset.kt @@ -2,6 +2,7 @@ package com.joegec.joycon2android.buttonmapping.preset import com.joegec.joycon2android.buttonmapping.Console import com.joegec.joycon2android.buttonmapping.JoyconSide +import com.joegec.joycon2android.buttonmapping.LayoutFamily import com.joegec.joycon2android.buttonmapping.MappingLayout /** A layout the app ships: what each body maps to before the user overrides anything. */ @@ -11,9 +12,6 @@ sealed interface MappingPreset : MappingLayout { /** The bodies it is offered to — a grip that only one of them can be held in says so. */ val sides: Set get() = JoyconSide.entries.toSet() - /** - * The same game in another grip. Setting one of a family sets every player to whichever of them - * their own body can be held in, since a table is rarely holding the same thing. - */ - val family: String? get() = null + /** The same game in another grip; see [LayoutFamily]. */ + val family: LayoutFamily? get() = null } diff --git a/core/buttonmapping/domain/src/main/kotlin/com/joegec/joycon2android/buttonmapping/preset/MappingPresets.kt b/core/buttonmapping/domain/src/main/kotlin/com/joegec/joycon2android/buttonmapping/preset/MappingPresets.kt index f5bc1b7..879404e 100644 --- a/core/buttonmapping/domain/src/main/kotlin/com/joegec/joycon2android/buttonmapping/preset/MappingPresets.kt +++ b/core/buttonmapping/domain/src/main/kotlin/com/joegec/joycon2android/buttonmapping/preset/MappingPresets.kt @@ -2,8 +2,6 @@ package com.joegec.joycon2android.buttonmapping.preset import com.joegec.joycon2android.buttonmapping.Console -internal const val MARIO_KART = "Mario Kart" - /** Every layout the app ships, and which one a console falls back to. */ object MappingPresets { diff --git a/core/buttonmapping/domain/src/main/kotlin/com/joegec/joycon2android/buttonmapping/preset/MarioKartNunchukMapping.kt b/core/buttonmapping/domain/src/main/kotlin/com/joegec/joycon2android/buttonmapping/preset/MarioKartNunchukMapping.kt index 7a8e095..522e0d2 100644 --- a/core/buttonmapping/domain/src/main/kotlin/com/joegec/joycon2android/buttonmapping/preset/MarioKartNunchukMapping.kt +++ b/core/buttonmapping/domain/src/main/kotlin/com/joegec/joycon2android/buttonmapping/preset/MarioKartNunchukMapping.kt @@ -2,6 +2,7 @@ package com.joegec.joycon2android.buttonmapping.preset import com.joegec.joycon2android.buttonmapping.Console import com.joegec.joycon2android.buttonmapping.JoyconSide +import com.joegec.joycon2android.buttonmapping.LayoutFamily import com.joegec.joycon2android.buttonmapping.MappingSource import com.joegec.joycon2android.buttonmapping.StickSource import com.joegec.joycon2android.buttonmapping.StickSource.LEFT_STICK @@ -41,10 +42,8 @@ import com.joegec.joycon2android.model.JoyconButton.ZR */ object MarioKartNunchukMapping : MappingPreset { override val id = "MARIO_KART_NUNCHUK" - override val displayName = "Mario Kart Nunchuck" - override val description = "Stick steering, MK8 mapping" override val console = Console.WIIMOTE_NUNCHUK - override val family = MARIO_KART + override val family = LayoutFamily.MARIO_KART override val sidewaysRemote = true override fun entries(side: JoyconSide) = when (side) { diff --git a/core/buttonmapping/domain/src/main/kotlin/com/joegec/joycon2android/buttonmapping/preset/MarioKartWheelMapping.kt b/core/buttonmapping/domain/src/main/kotlin/com/joegec/joycon2android/buttonmapping/preset/MarioKartWheelMapping.kt index 5d21ade..ad4432a 100644 --- a/core/buttonmapping/domain/src/main/kotlin/com/joegec/joycon2android/buttonmapping/preset/MarioKartWheelMapping.kt +++ b/core/buttonmapping/domain/src/main/kotlin/com/joegec/joycon2android/buttonmapping/preset/MarioKartWheelMapping.kt @@ -2,6 +2,7 @@ package com.joegec.joycon2android.buttonmapping.preset import com.joegec.joycon2android.buttonmapping.Console import com.joegec.joycon2android.buttonmapping.JoyconSide +import com.joegec.joycon2android.buttonmapping.LayoutFamily import com.joegec.joycon2android.buttonmapping.MappingSource import com.joegec.joycon2android.buttonmapping.target.WiimoteButton import com.joegec.joycon2android.model.JoyconButton @@ -33,10 +34,8 @@ import com.joegec.joycon2android.model.JoyconButton.Y */ object MarioKartWheelMapping : MappingPreset { override val id = "MARIO_KART_WHEEL" - override val displayName = "Mario Kart Wheel" - override val description = "Motion steering, MK8 mapping" override val console = Console.WIIMOTE_NUNCHUK - override val family = MARIO_KART + override val family = LayoutFamily.MARIO_KART override val sides = setOf(JoyconSide.LEFT, JoyconSide.RIGHT) override val sidewaysRemote = true diff --git a/core/buttonmapping/domain/src/main/kotlin/com/joegec/joycon2android/buttonmapping/preset/SwitchProMapping.kt b/core/buttonmapping/domain/src/main/kotlin/com/joegec/joycon2android/buttonmapping/preset/SwitchProMapping.kt index 12bd982..d67d893 100644 --- a/core/buttonmapping/domain/src/main/kotlin/com/joegec/joycon2android/buttonmapping/preset/SwitchProMapping.kt +++ b/core/buttonmapping/domain/src/main/kotlin/com/joegec/joycon2android/buttonmapping/preset/SwitchProMapping.kt @@ -33,7 +33,6 @@ import com.joegec.joycon2android.model.JoyconButton.ZR object SwitchProMapping : MappingPreset { override val id = "STANDARD" - override val displayName = "Standard" override val console = Console.SWITCH_PRO override fun entries(side: JoyconSide) = buttons(side).buttonEntries() + sticks(side).stickEntries() diff --git a/core/buttonmapping/domain/src/main/kotlin/com/joegec/joycon2android/buttonmapping/preset/WiiMapping.kt b/core/buttonmapping/domain/src/main/kotlin/com/joegec/joycon2android/buttonmapping/preset/WiiMapping.kt index fee2861..632d785 100644 --- a/core/buttonmapping/domain/src/main/kotlin/com/joegec/joycon2android/buttonmapping/preset/WiiMapping.kt +++ b/core/buttonmapping/domain/src/main/kotlin/com/joegec/joycon2android/buttonmapping/preset/WiiMapping.kt @@ -29,8 +29,6 @@ import com.joegec.joycon2android.model.JoyconButton.ZR /** The Wii Remote's own layout: the trigger under the finger is B, and 1 and 2 sit under the thumb. */ object WiiMapping : MappingPreset { override val id = "WII" - override val displayName = "Wii" - override val description = "Like a Wiimote, B → ZR" override val console = Console.WIIMOTE_NUNCHUK override fun entries(side: JoyconSide) = diff --git a/core/buttonmapping/domain/src/main/kotlin/com/joegec/joycon2android/buttonmapping/target/GameCubeButton.kt b/core/buttonmapping/domain/src/main/kotlin/com/joegec/joycon2android/buttonmapping/target/GameCubeButton.kt index 8794cf7..e3cf70d 100644 --- a/core/buttonmapping/domain/src/main/kotlin/com/joegec/joycon2android/buttonmapping/target/GameCubeButton.kt +++ b/core/buttonmapping/domain/src/main/kotlin/com/joegec/joycon2android/buttonmapping/target/GameCubeButton.kt @@ -1,17 +1,17 @@ package com.joegec.joycon2android.buttonmapping.target /** A GameCube controller's own digital buttons and d-pad directions. */ -enum class GameCubeButton(val displayName: String) { - A("A"), - B("B"), - X("X"), - Y("Y"), - Z("Z"), - Start("Start"), - TriggerL("L"), - TriggerR("R"), - DPadUp("D-Pad Up"), - DPadDown("D-Pad Down"), - DPadLeft("D-Pad Left"), - DPadRight("D-Pad Right"), +enum class GameCubeButton { + A, + B, + X, + Y, + Z, + Start, + TriggerL, + TriggerR, + DPadUp, + DPadDown, + DPadLeft, + DPadRight, } diff --git a/core/buttonmapping/domain/src/main/kotlin/com/joegec/joycon2android/buttonmapping/target/GameCubeStick.kt b/core/buttonmapping/domain/src/main/kotlin/com/joegec/joycon2android/buttonmapping/target/GameCubeStick.kt index e7f823e..8f8fd94 100644 --- a/core/buttonmapping/domain/src/main/kotlin/com/joegec/joycon2android/buttonmapping/target/GameCubeStick.kt +++ b/core/buttonmapping/domain/src/main/kotlin/com/joegec/joycon2android/buttonmapping/target/GameCubeStick.kt @@ -1,7 +1,3 @@ package com.joegec.joycon2android.buttonmapping.target -/** A GameCube controller's two analog sticks, only routable when a full body is connected. */ -enum class GameCubeStick(val displayName: String) { - MainStick("Main Stick"), - CStick("C-Stick"), -} +enum class GameCubeStick { MainStick, CStick } diff --git a/core/buttonmapping/domain/src/main/kotlin/com/joegec/joycon2android/buttonmapping/target/SwitchProButton.kt b/core/buttonmapping/domain/src/main/kotlin/com/joegec/joycon2android/buttonmapping/target/SwitchProButton.kt index 73ccb9f..1a21346 100644 --- a/core/buttonmapping/domain/src/main/kotlin/com/joegec/joycon2android/buttonmapping/target/SwitchProButton.kt +++ b/core/buttonmapping/domain/src/main/kotlin/com/joegec/joycon2android/buttonmapping/target/SwitchProButton.kt @@ -1,23 +1,23 @@ package com.joegec.joycon2android.buttonmapping.target /** A Nintendo Switch Pro Controller's own buttons and d-pad directions. */ -enum class SwitchProButton(val displayName: String) { - A("A"), - B("B"), - X("X"), - Y("Y"), - L("L"), - R("R"), - ZL("ZL"), - ZR("ZR"), - Plus("+"), - Minus("-"), - Home("Home"), - Capture("Capture"), - LStickClick("L-Stick Click"), - RStickClick("R-Stick Click"), - DPadUp("D-Pad Up"), - DPadDown("D-Pad Down"), - DPadLeft("D-Pad Left"), - DPadRight("D-Pad Right"), +enum class SwitchProButton { + A, + B, + X, + Y, + L, + R, + ZL, + ZR, + Plus, + Minus, + Home, + Capture, + LStickClick, + RStickClick, + DPadUp, + DPadDown, + DPadLeft, + DPadRight, } diff --git a/core/buttonmapping/domain/src/main/kotlin/com/joegec/joycon2android/buttonmapping/target/SwitchProStick.kt b/core/buttonmapping/domain/src/main/kotlin/com/joegec/joycon2android/buttonmapping/target/SwitchProStick.kt index adbe23a..e226b71 100644 --- a/core/buttonmapping/domain/src/main/kotlin/com/joegec/joycon2android/buttonmapping/target/SwitchProStick.kt +++ b/core/buttonmapping/domain/src/main/kotlin/com/joegec/joycon2android/buttonmapping/target/SwitchProStick.kt @@ -1,7 +1,3 @@ package com.joegec.joycon2android.buttonmapping.target -/** A Pro Controller's two analog sticks, only routable when a full body is connected. */ -enum class SwitchProStick(val displayName: String) { - LStick("L-Stick"), - RStick("R-Stick"), -} +enum class SwitchProStick { LStick, RStick } diff --git a/core/buttonmapping/domain/src/main/kotlin/com/joegec/joycon2android/buttonmapping/target/WiimoteButton.kt b/core/buttonmapping/domain/src/main/kotlin/com/joegec/joycon2android/buttonmapping/target/WiimoteButton.kt index 5ef32e1..7e42a0c 100644 --- a/core/buttonmapping/domain/src/main/kotlin/com/joegec/joycon2android/buttonmapping/target/WiimoteButton.kt +++ b/core/buttonmapping/domain/src/main/kotlin/com/joegec/joycon2android/buttonmapping/target/WiimoteButton.kt @@ -5,19 +5,19 @@ package com.joegec.joycon2android.buttonmapping.target * all: [Shake], the jerk of the remote that a game like Mario Kart Wii reads as a trick. It sits * here because the editor binds sources to it exactly as it does to a button. */ -enum class WiimoteButton(val displayName: String) { - A("A"), - B("B"), - One("1"), - Two("2"), - Home("Home"), - Plus("+"), - Minus("-"), - DPadUp("D-Pad Up"), - DPadDown("D-Pad Down"), - DPadLeft("D-Pad Left"), - DPadRight("D-Pad Right"), - NunchukC("Nunchuk C"), - NunchukZ("Nunchuk Z"), - Shake("Shake"), +enum class WiimoteButton { + A, + B, + One, + Two, + Home, + Plus, + Minus, + DPadUp, + DPadDown, + DPadLeft, + DPadRight, + NunchukC, + NunchukZ, + Shake, } diff --git a/core/buttonmapping/domain/src/main/kotlin/com/joegec/joycon2android/buttonmapping/target/WiimoteStick.kt b/core/buttonmapping/domain/src/main/kotlin/com/joegec/joycon2android/buttonmapping/target/WiimoteStick.kt index 0bdc9f4..d3d5c98 100644 --- a/core/buttonmapping/domain/src/main/kotlin/com/joegec/joycon2android/buttonmapping/target/WiimoteStick.kt +++ b/core/buttonmapping/domain/src/main/kotlin/com/joegec/joycon2android/buttonmapping/target/WiimoteStick.kt @@ -1,5 +1,3 @@ package com.joegec.joycon2android.buttonmapping.target -enum class WiimoteStick(val displayName: String) { - NunchukStick("Nunchuk Stick"), -} +enum class WiimoteStick { NunchukStick } diff --git a/core/buttonmapping/domain/src/test/kotlin/com/joegec/joycon2android/buttonmapping/GlobalMappingTest.kt b/core/buttonmapping/domain/src/test/kotlin/com/joegec/joycon2android/buttonmapping/GlobalMappingTest.kt index c2ffa8e..f017317 100644 --- a/core/buttonmapping/domain/src/test/kotlin/com/joegec/joycon2android/buttonmapping/GlobalMappingTest.kt +++ b/core/buttonmapping/domain/src/test/kotlin/com/joegec/joycon2android/buttonmapping/GlobalMappingTest.kt @@ -23,13 +23,17 @@ class GlobalMappingTest { private suspend fun putBothOn(layoutId: String) = fixture.applyGlobalLayout(fixture.console, bodies, layoutId) + // The domain says what a session agrees on; naming it is presentation's, so this stands in. + private fun GlobalMapping.agreedName(): String? = + matchingSaved?.name ?: sharedLayout?.id ?: sharedFamily?.name + private suspend fun savedSet() = fixture.globalMapping(first, second).savedLayouts.single() @Test fun `a layout every player reads as names the session`() = runBlocking { putBothOn(WiiMapping.id) - assertEquals(WiiMapping.displayName, fixture.globalMapping(first, second).displayName) + assertEquals(WiiMapping.id, fixture.globalMapping(first, second).agreedName()) } @Test @@ -38,7 +42,7 @@ class GlobalMappingTest { fixture.setMapping(fixture.console, first, "A", "Up") - assertNull(fixture.globalMapping(first, second).displayName) + assertNull(fixture.globalMapping(first, second).agreedName()) } @Test @@ -46,7 +50,7 @@ class GlobalMappingTest { fixture.applyLayout(fixture.console, first, MarioKartWheelMapping.id) fixture.applyLayout(fixture.console, second, WiiMapping.id) - assertNull(fixture.globalMapping(first, second).displayName) + assertNull(fixture.globalMapping(first, second).agreedName()) } @Test @@ -55,10 +59,10 @@ class GlobalMappingTest { fixture.setMapping(fixture.console, first, "A", "Up") fixture.saveGlobalLayout(fixture.console, bodies, "Party") - assertEquals("Party", fixture.globalMapping(first, second).displayName) + assertEquals("Party", fixture.globalMapping(first, second).agreedName()) fixture.setMapping(fixture.console, second, "A", "Down") - assertNull(fixture.globalMapping(first, second).displayName) + assertNull(fixture.globalMapping(first, second).agreedName()) } // A table rarely holds the same thing, so the grip each body can be held in is what it gets. @@ -80,7 +84,7 @@ class GlobalMappingTest { fixture.applyGlobalLayout(fixture.console, mixed, MarioKartWheelMapping.id) - assertEquals("Mario Kart", fixture.globalMapping(first, second, pair).displayName) + assertEquals(LayoutFamily.MARIO_KART.name, fixture.globalMapping(first, second, pair).agreedName()) } @Test @@ -94,12 +98,13 @@ class GlobalMappingTest { assertFalse(saved.fits(listOf(first, PlayerBody(PlayerNumber.P2, JoyconSide.DUAL)))) } + // Which bodies, in which order — what they are *called* is presentation's, so it is not here. @Test - fun `a saved set names the bodies it wants, player by player`() = runBlocking { + fun `a saved set records the bodies it wants, player by player`() = runBlocking { val three = bodies + PlayerBody(PlayerNumber.P3, JoyconSide.DUAL) fixture.saveGlobalLayout(fixture.console, three, "Party") - assertEquals("P1 L, P2 R, P3 L/R", savedSet().playerSummary) + assertEquals(three, savedSet().bodies.map { it.body }) } @Test @@ -135,7 +140,7 @@ class GlobalMappingTest { fixture.saveCustomLayout(fixture.console, first, "My Wheel") - assertEquals("My Wheel", fixture.playerMapping(first).layout?.displayName) - assertEquals("Party", fixture.globalMapping(first, second).displayName) + assertEquals("My Wheel", (fixture.playerMapping(first).layout as? SavedLayout)?.name) + assertEquals("Party", fixture.globalMapping(first, second).agreedName()) } } diff --git a/core/buttonmapping/domain/src/test/kotlin/com/joegec/joycon2android/buttonmapping/MappingFixture.kt b/core/buttonmapping/domain/src/test/kotlin/com/joegec/joycon2android/buttonmapping/MappingFixture.kt index ac3c998..fcea5f0 100644 --- a/core/buttonmapping/domain/src/test/kotlin/com/joegec/joycon2android/buttonmapping/MappingFixture.kt +++ b/core/buttonmapping/domain/src/test/kotlin/com/joegec/joycon2android/buttonmapping/MappingFixture.kt @@ -30,7 +30,7 @@ internal class MappingFixture(val console: Console = Console.WIIMOTE_NUNCHUK) { suspend fun globalMapping(vararg bodies: PlayerBody) = observeGlobalMapping(console, bodies.toList()).first() suspend fun savedLayoutNamed(name: String) = - observeSavedLayouts(console).first().first { it.displayName == name } + observeSavedLayouts(console).first().first { it.name == name } suspend fun layoutsFor(side: JoyconSide) = MappingLayouts.forBody(console, side, observeSavedLayouts(console).first()) diff --git a/core/buttonmapping/domain/src/test/kotlin/com/joegec/joycon2android/buttonmapping/PlayerMappingTest.kt b/core/buttonmapping/domain/src/test/kotlin/com/joegec/joycon2android/buttonmapping/PlayerMappingTest.kt index b0dbbb9..e2e06e1 100644 --- a/core/buttonmapping/domain/src/test/kotlin/com/joegec/joycon2android/buttonmapping/PlayerMappingTest.kt +++ b/core/buttonmapping/domain/src/test/kotlin/com/joegec/joycon2android/buttonmapping/PlayerMappingTest.kt @@ -22,7 +22,7 @@ class PlayerMappingTest { fun `an untouched player reads as the layout they chose`() = runBlocking { fixture.applyLayout(fixture.console, body, MarioKartWheelMapping.id) - assertEquals(MarioKartWheelMapping.displayName, fixture.playerMapping(body).layout?.displayName) + assertEquals(MarioKartWheelMapping, fixture.playerMapping(body).layout) } @Test @@ -66,9 +66,9 @@ class PlayerMappingTest { fixture.saveCustomLayout(fixture.console, body, "My Wheel") val mapping = fixture.playerMapping(body) - assertEquals("My Wheel", mapping.layout?.displayName) + assertEquals("My Wheel", (mapping.layout as? SavedLayout)?.name) assertEquals("Up", mapping.entries["A"]) - assertTrue(fixture.layoutsFor(body.side).any { it.displayName == "My Wheel" }) + assertTrue(fixture.layoutsFor(body.side).any { it is SavedLayout && it.name == "My Wheel" }) } @Test @@ -77,7 +77,7 @@ class PlayerMappingTest { val other = fixture.layoutsFor(right().side) - assertFalse(other.any { it.displayName == "My Wheel" }) + assertFalse(other.any { it is SavedLayout && it.name == "My Wheel" }) } @Test @@ -101,7 +101,7 @@ class PlayerMappingTest { fixture.saveCustomLayout(fixture.console, body, "My Wheel") - assertEquals("My Wheel", fixture.playerMapping(body).layout?.displayName) + assertEquals("My Wheel", (fixture.playerMapping(body).layout as? SavedLayout)?.name) } @Test @@ -113,6 +113,6 @@ class PlayerMappingTest { fixture.setMapping(fixture.console, other, "A", "Up") assertNotNull(fixture.playerMapping(other).layout) - assertEquals("My Wheel", fixture.playerMapping(other).layout?.displayName) + assertEquals("My Wheel", (fixture.playerMapping(other).layout as? SavedLayout)?.name) } } diff --git a/core/buttonmapping/domain/src/test/kotlin/com/joegec/joycon2android/buttonmapping/preset/WiiPresetsTest.kt b/core/buttonmapping/domain/src/test/kotlin/com/joegec/joycon2android/buttonmapping/preset/WiiPresetsTest.kt index 8457dcf..c48421e 100644 --- a/core/buttonmapping/domain/src/test/kotlin/com/joegec/joycon2android/buttonmapping/preset/WiiPresetsTest.kt +++ b/core/buttonmapping/domain/src/test/kotlin/com/joegec/joycon2android/buttonmapping/preset/WiiPresetsTest.kt @@ -198,7 +198,7 @@ class WiiPresetsTest { .filterNot { it == MarioKartNunchukMapping } .forEach { preset -> assertTrue( - "${preset.displayName} binds the remote", + "${preset.id} binds the remote", preset.entries(JoyconSide.RIGHT).keys.containsAll(remote), ) } diff --git a/core/buttonmapping/presentation/src/main/kotlin/com/joegec/joycon2android/buttonmapping/presentation/ControllerMappingScreen.kt b/core/buttonmapping/presentation/src/main/kotlin/com/joegec/joycon2android/buttonmapping/presentation/ControllerMappingScreen.kt index c206c5d..d9be786 100644 --- a/core/buttonmapping/presentation/src/main/kotlin/com/joegec/joycon2android/buttonmapping/presentation/ControllerMappingScreen.kt +++ b/core/buttonmapping/presentation/src/main/kotlin/com/joegec/joycon2android/buttonmapping/presentation/ControllerMappingScreen.kt @@ -29,7 +29,11 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.res.stringResource +import com.joegec.joycon2android.buttonmapping.Console +import com.joegec.joycon2android.buttonmapping.GlobalMapping import com.joegec.joycon2android.buttonmapping.MappingLayouts +import com.joegec.joycon2android.buttonmapping.preset.MappingPresets +import com.joegec.joycon2android.ui.components.DropdownOption import com.joegec.joycon2android.buttonmapping.PlayerBody import com.joegec.joycon2android.core.buttonmapping.presentation.R import com.joegec.joycon2android.model.PlayerState @@ -48,6 +52,7 @@ fun ControllerMappingScreen( ) { BackHandler(onBack = onBack) var dialog by remember { mutableStateOf(null) } + val labels = rememberLayoutLabels() Column( modifier @@ -66,7 +71,7 @@ fun ControllerMappingScreen( if (state.players.isEmpty()) { Text(stringResource(R.string.controller_mapping_no_players), color = TextDim) } else { - AllPlayersRow(state.global, actions) { dialog = it } + AllPlayersRow(state.console, state.global, labels, actions) { dialog = it } } state.players.forEach { player -> val connected = players.firstOrNull { it.player == player.body.player } @@ -75,6 +80,7 @@ fun ControllerMappingScreen( console = state.console, player = connected, state = player, + labels = labels, actions = actions, onSaveLayout = { dialog = MappingDialog.Save(player.body) }, onDeleteLayout = { dialog = MappingDialog.Delete(it.id, it.label, global = false) }, @@ -97,17 +103,29 @@ private fun ScreenHeader(state: ControllerMappingUiState, onBack: () -> Unit) { contentDescription = stringResource(R.string.controller_mapping_back), ) } - Text(state.console.displayName, style = MaterialTheme.typography.headlineSmall, color = Color.White) + Text(state.console.label(), style = MaterialTheme.typography.headlineSmall, color = Color.White) } } /** The session read as one setting, so a whole table can be set — and kept — in a single move. */ @Composable private fun AllPlayersRow( - state: GlobalLayoutUiState, + console: Console, + global: GlobalMapping, + labels: LayoutLabels, actions: MappingActions, onDialog: (MappingDialog) -> Unit, ) { + val saved = global.savedLayouts.map { + DropdownOption( + id = it.id, + label = labels.name(it), + subLabel = it.playerSummary(), + available = it.fits(global.bodies), + deletable = true, + ) + } + Row( Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(Dimens.elementSpacing), @@ -119,10 +137,10 @@ private fun AllPlayersRow( modifier = Modifier.weight(1f), ) LayoutRow( - options = state.options, - selectedId = state.selectedId, - layoutName = state.layoutName, - subLabel = state.playerSummary, + options = MappingPresets.forConsole(console).map { labels.option(it) } + saved, + selectedId = global.selectedId, + layoutName = labels.sessionName(global), + subLabel = global.matchingSaved?.let { it.playerSummary() }, onSelect = actions.selectGlobalLayout, onSave = { onDialog(MappingDialog.Save(body = null)) }, onDelete = { onDialog(MappingDialog.Delete(it.id, it.label, global = true)) }, @@ -145,7 +163,7 @@ private fun MappingDialogs( fieldLabel = stringResource(R.string.controller_mapping_layout_name), defaultValue = MappingLayouts.nextName( stringResource(R.string.controller_mapping_layout_custom), - if (dialog.body == null) state.global.savedNames else state.savedLayoutNames, + state.takenNames(dialog.body == null), ), confirmLabel = stringResource(R.string.controller_mapping_save), dismissLabel = stringResource(R.string.controller_mapping_cancel), diff --git a/core/buttonmapping/presentation/src/main/kotlin/com/joegec/joycon2android/buttonmapping/presentation/ControllerMappingUiState.kt b/core/buttonmapping/presentation/src/main/kotlin/com/joegec/joycon2android/buttonmapping/presentation/ControllerMappingUiState.kt index 22638ab..6d926a8 100644 --- a/core/buttonmapping/presentation/src/main/kotlin/com/joegec/joycon2android/buttonmapping/presentation/ControllerMappingUiState.kt +++ b/core/buttonmapping/presentation/src/main/kotlin/com/joegec/joycon2android/buttonmapping/presentation/ControllerMappingUiState.kt @@ -7,32 +7,18 @@ import com.joegec.joycon2android.buttonmapping.MappingLayouts import com.joegec.joycon2android.buttonmapping.PlayerBody import com.joegec.joycon2android.buttonmapping.PlayerMapping import com.joegec.joycon2android.buttonmapping.SavedLayout -import com.joegec.joycon2android.buttonmapping.preset.MappingPresets -import com.joegec.joycon2android.ui.components.DropdownOption -/** A null [layoutName] is the editor's way of saying the bindings no longer match any layout. */ +/** A null [layout] is the editor's way of saying the bindings no longer match any of them. */ data class ControllerMappingUiState( val console: Console, - val global: GlobalLayoutUiState, + val global: GlobalMapping, val players: List, - /** Every name already taken on this console, so a suggested one is never a duplicate. */ - val savedLayoutNames: List, -) - -data class GlobalLayoutUiState( - val options: List, - val selectedId: String?, - val layoutName: String?, - val playerSummary: String?, - val savedNames: List, ) data class PlayerMappingUiState( val body: PlayerBody, - val layoutOptions: List, - val selectedLayoutId: String?, - val layoutName: String?, - val layoutDescription: String?, + val layouts: List, + val layout: MappingLayout?, val sidewaysRemote: Boolean, val offersSidewaysRemote: Boolean, val mapping: Map, @@ -44,39 +30,22 @@ internal fun controllerMappingUiState( savedLayouts: List, ) = ControllerMappingUiState( console = console, - global = mapping.uiState(console), + global = mapping, players = mapping.players.map { it.uiState(console, MappingLayouts.forBody(console, it.body.side, savedLayouts)) }, - savedLayoutNames = savedLayouts.map { it.displayName }, -) - -private fun GlobalMapping.uiState(console: Console) = GlobalLayoutUiState( - options = MappingPresets.forConsole(console).map { DropdownOption(it.id, it.displayName, it.description) } + - savedLayouts.map { - DropdownOption( - id = it.id, - label = it.displayName, - subLabel = it.playerSummary, - available = it.fits(bodies), - deletable = true, - ) - }, - selectedId = selectedId, - layoutName = displayName, - playerSummary = playerSummary, - savedNames = savedLayouts.map { it.displayName }, ) private fun PlayerMapping.uiState(console: Console, layouts: List) = PlayerMappingUiState( body = body, - layoutOptions = layouts.map { - DropdownOption(it.id, it.displayName, subLabel = it.description, deletable = it is SavedLayout) - }, - selectedLayoutId = layout?.id, - layoutName = layout?.displayName, - layoutDescription = layout?.description, + layouts = layouts, + layout = layout, sidewaysRemote = sidewaysRemote, offersSidewaysRemote = MappingOptions.offersSidewaysRemote(console, body.side), mapping = entries, ) + +/** Names already taken, so a suggested one is never a duplicate of what it sits beside. */ +internal fun ControllerMappingUiState.takenNames(session: Boolean): List = + if (session) global.savedLayouts.map { it.name } + else players.flatMap { it.layouts }.filterIsInstance().map { it.name }.distinct() diff --git a/core/buttonmapping/presentation/src/main/kotlin/com/joegec/joycon2android/buttonmapping/presentation/LayoutLabels.kt b/core/buttonmapping/presentation/src/main/kotlin/com/joegec/joycon2android/buttonmapping/presentation/LayoutLabels.kt new file mode 100644 index 0000000..b01547b --- /dev/null +++ b/core/buttonmapping/presentation/src/main/kotlin/com/joegec/joycon2android/buttonmapping/presentation/LayoutLabels.kt @@ -0,0 +1,93 @@ +package com.joegec.joycon2android.buttonmapping.presentation + +import androidx.compose.runtime.Composable +import androidx.compose.ui.res.stringResource +import com.joegec.joycon2android.buttonmapping.Console +import com.joegec.joycon2android.buttonmapping.GlobalLayout +import com.joegec.joycon2android.buttonmapping.GlobalMapping +import com.joegec.joycon2android.buttonmapping.LayoutFamily +import com.joegec.joycon2android.buttonmapping.MappingLayout +import com.joegec.joycon2android.buttonmapping.SavedLayout +import com.joegec.joycon2android.buttonmapping.preset.GameCubeMapping +import com.joegec.joycon2android.buttonmapping.preset.JoyconWiiMapping +import com.joegec.joycon2android.buttonmapping.preset.MappingPreset +import com.joegec.joycon2android.buttonmapping.preset.MappingPresets +import com.joegec.joycon2android.buttonmapping.preset.MarioKartNunchukMapping +import com.joegec.joycon2android.buttonmapping.preset.MarioKartWheelMapping +import com.joegec.joycon2android.buttonmapping.preset.SwitchProMapping +import com.joegec.joycon2android.buttonmapping.preset.WiiMapping +import com.joegec.joycon2android.core.buttonmapping.presentation.R +import com.joegec.joycon2android.ui.components.DropdownOption + +/** + * What the shipped layouts are called, and the line under each saying what picking it does. A + * layout the user saved answers with their own words instead, which are data rather than copy. + * + * Resolved once, up in composition, because that is the only place resources can be read — the + * layouts themselves are domain and know nothing of what they are called. + */ +class LayoutLabels internal constructor( + private val names: Map, + private val descriptions: Map, + private val families: Map, +) { + fun name(layout: MappingLayout): String = + if (layout is SavedLayout) layout.name else names.getValue(layout as MappingPreset) + + fun name(layout: GlobalLayout): String = layout.name + + fun name(family: LayoutFamily): String = families.getValue(family) + + fun description(layout: MappingLayout): String? = descriptions[layout] +} + +@Composable +internal fun rememberLayoutLabels(): LayoutLabels { + val presets = Console.entries.flatMap(MappingPresets::forConsole).distinct() + return LayoutLabels( + names = presets.associateWith { nameOf(it) }, + descriptions = presets.mapNotNull { preset -> descriptionOf(preset)?.let { preset to it } }.toMap(), + families = LayoutFamily.entries.associateWith { nameOf(it) }, + ) +} + +// A `when` over the sealed type rather than a map, so a layout added without a name will not build. +@Composable +private fun nameOf(preset: MappingPreset): String = when (preset) { + GameCubeMapping, SwitchProMapping -> stringResource(R.string.layout_standard) + WiiMapping -> stringResource(R.string.layout_wii) + JoyconWiiMapping -> stringResource(R.string.layout_joycon) + MarioKartWheelMapping -> stringResource(R.string.layout_mario_kart_wheel) + MarioKartNunchukMapping -> stringResource(R.string.layout_mario_kart_nunchuk) +} + +@Composable +private fun descriptionOf(preset: MappingPreset): String? = when (preset) { + GameCubeMapping, SwitchProMapping -> null // the only layout their console offers + WiiMapping -> stringResource(R.string.layout_wii_description) + JoyconWiiMapping -> stringResource(R.string.layout_joycon_description) + MarioKartWheelMapping -> stringResource(R.string.layout_mario_kart_wheel_description) + MarioKartNunchukMapping -> stringResource(R.string.layout_mario_kart_nunchuk_description) +} + +@Composable +private fun nameOf(family: LayoutFamily): String = when (family) { + LayoutFamily.MARIO_KART -> stringResource(R.string.layout_family_mario_kart) +} + +/** One layout as a row of a dropdown: its name, what it does, and whether it is the user's to delete. */ +internal fun LayoutLabels.option(layout: MappingLayout) = DropdownOption( + id = layout.id, + label = name(layout), + subLabel = description(layout), + deletable = layout is SavedLayout, +) + +/** + * What the whole table is on: a saved set they all still match, the one layout they all read as, or + * the family they are each on their own grip of. Null once any of them has gone its own way. + */ +fun LayoutLabels.sessionName(global: GlobalMapping): String? = + global.matchingSaved?.let(::name) + ?: global.sharedLayout?.let(::name) + ?: global.sharedFamily?.let(::name) diff --git a/core/buttonmapping/presentation/src/main/kotlin/com/joegec/joycon2android/buttonmapping/presentation/MappingLabels.kt b/core/buttonmapping/presentation/src/main/kotlin/com/joegec/joycon2android/buttonmapping/presentation/MappingLabels.kt new file mode 100644 index 0000000..dbfa9fd --- /dev/null +++ b/core/buttonmapping/presentation/src/main/kotlin/com/joegec/joycon2android/buttonmapping/presentation/MappingLabels.kt @@ -0,0 +1,128 @@ +package com.joegec.joycon2android.buttonmapping.presentation + +import androidx.compose.runtime.Composable +import androidx.compose.ui.res.stringResource +import com.joegec.joycon2android.buttonmapping.Console +import com.joegec.joycon2android.buttonmapping.GlobalLayout +import com.joegec.joycon2android.buttonmapping.JoyconSide +import com.joegec.joycon2android.buttonmapping.StickDirection +import com.joegec.joycon2android.buttonmapping.StickSource +import com.joegec.joycon2android.buttonmapping.target.GameCubeButton +import com.joegec.joycon2android.buttonmapping.target.GameCubeStick +import com.joegec.joycon2android.buttonmapping.target.SwitchProButton +import com.joegec.joycon2android.buttonmapping.target.SwitchProStick +import com.joegec.joycon2android.buttonmapping.target.WiimoteButton +import com.joegec.joycon2android.buttonmapping.target.WiimoteStick +import com.joegec.joycon2android.core.buttonmapping.presentation.R + +/** + * What the mapping vocabulary is called. The domain names none of it: a target is an identity there + * and a word only here, and a `when` over each enum means one added without a word will not build. + */ +@Composable +internal fun Console.label(): String = when (this) { + Console.GAMECUBE -> stringResource(R.string.console_gamecube) + Console.WIIMOTE_NUNCHUK -> stringResource(R.string.console_wiimote_nunchuk) + Console.SWITCH_PRO -> stringResource(R.string.console_switch_pro) +} + +/** Short enough to sit in a list of players, as in "P1 L, P2 R". */ +@Composable +internal fun JoyconSide.shortLabel(): String = when (this) { + JoyconSide.LEFT -> stringResource(R.string.side_left_short) + JoyconSide.RIGHT -> stringResource(R.string.side_right_short) + JoyconSide.DUAL -> stringResource(R.string.side_dual_short) +} + +/** Which players a saved set wants, and in which hands. */ +@Composable +internal fun GlobalLayout.playerSummary(): String = bodies + .map { stringResource(R.string.player_body, it.body.player.index, it.body.side.shortLabel()) } + .joinToString(", ") + +@Composable +internal fun StickDirection.label(): String = when (this) { + StickDirection.UP -> stringResource(R.string.direction_up) + StickDirection.DOWN -> stringResource(R.string.direction_down) + StickDirection.LEFT -> stringResource(R.string.direction_left) + StickDirection.RIGHT -> stringResource(R.string.direction_right) +} + +@Composable +internal fun StickSource.label(): String = when (this) { + StickSource.LEFT_STICK -> stringResource(R.string.stick_left) + StickSource.RIGHT_STICK -> stringResource(R.string.stick_right) +} + +@Composable +internal fun GameCubeButton.label(): String = when (this) { + GameCubeButton.A -> "A" + GameCubeButton.B -> "B" + GameCubeButton.X -> "X" + GameCubeButton.Y -> "Y" + GameCubeButton.Z -> "Z" + GameCubeButton.Start -> stringResource(R.string.button_start) + GameCubeButton.TriggerL -> "L" + GameCubeButton.TriggerR -> "R" + GameCubeButton.DPadUp -> stringResource(R.string.button_dpad_up) + GameCubeButton.DPadDown -> stringResource(R.string.button_dpad_down) + GameCubeButton.DPadLeft -> stringResource(R.string.button_dpad_left) + GameCubeButton.DPadRight -> stringResource(R.string.button_dpad_right) +} + +@Composable +internal fun GameCubeStick.label(): String = when (this) { + GameCubeStick.MainStick -> stringResource(R.string.stick_main) + GameCubeStick.CStick -> stringResource(R.string.stick_c) +} + +@Composable +internal fun WiimoteButton.label(): String = when (this) { + WiimoteButton.A -> "A" + WiimoteButton.B -> "B" + WiimoteButton.One -> "1" + WiimoteButton.Two -> "2" + WiimoteButton.Home -> stringResource(R.string.button_home) + WiimoteButton.Plus -> "+" + WiimoteButton.Minus -> "-" + WiimoteButton.DPadUp -> stringResource(R.string.button_dpad_up) + WiimoteButton.DPadDown -> stringResource(R.string.button_dpad_down) + WiimoteButton.DPadLeft -> stringResource(R.string.button_dpad_left) + WiimoteButton.DPadRight -> stringResource(R.string.button_dpad_right) + WiimoteButton.NunchukC -> stringResource(R.string.button_nunchuk_c) + WiimoteButton.NunchukZ -> stringResource(R.string.button_nunchuk_z) + WiimoteButton.Shake -> stringResource(R.string.button_shake) +} + +@Composable +internal fun WiimoteStick.label(): String = when (this) { + WiimoteStick.NunchukStick -> stringResource(R.string.stick_nunchuk) +} + +@Composable +internal fun SwitchProButton.label(): String = when (this) { + SwitchProButton.A -> "A" + SwitchProButton.B -> "B" + SwitchProButton.X -> "X" + SwitchProButton.Y -> "Y" + SwitchProButton.L -> "L" + SwitchProButton.R -> "R" + SwitchProButton.ZL -> "ZL" + SwitchProButton.ZR -> "ZR" + SwitchProButton.Plus -> "+" + SwitchProButton.Minus -> "-" + SwitchProButton.Home -> stringResource(R.string.button_home) + SwitchProButton.Capture -> stringResource(R.string.button_capture) + SwitchProButton.LStickClick -> stringResource(R.string.button_lstick_click) + SwitchProButton.RStickClick -> stringResource(R.string.button_rstick_click) + SwitchProButton.DPadUp -> stringResource(R.string.button_dpad_up) + SwitchProButton.DPadDown -> stringResource(R.string.button_dpad_down) + SwitchProButton.DPadLeft -> stringResource(R.string.button_dpad_left) + SwitchProButton.DPadRight -> stringResource(R.string.button_dpad_right) +} + +@Composable +internal fun SwitchProStick.label(): String = when (this) { + SwitchProStick.LStick -> stringResource(R.string.stick_l) + SwitchProStick.RStick -> stringResource(R.string.stick_r) +} diff --git a/core/buttonmapping/presentation/src/main/kotlin/com/joegec/joycon2android/buttonmapping/presentation/MappingOptions.kt b/core/buttonmapping/presentation/src/main/kotlin/com/joegec/joycon2android/buttonmapping/presentation/MappingOptions.kt index beb7e75..78ddcc2 100644 --- a/core/buttonmapping/presentation/src/main/kotlin/com/joegec/joycon2android/buttonmapping/presentation/MappingOptions.kt +++ b/core/buttonmapping/presentation/src/main/kotlin/com/joegec/joycon2android/buttonmapping/presentation/MappingOptions.kt @@ -1,5 +1,7 @@ package com.joegec.joycon2android.buttonmapping.presentation +import androidx.compose.runtime.Composable +import androidx.compose.ui.res.stringResource import com.joegec.joycon2android.buttonmapping.Console import com.joegec.joycon2android.buttonmapping.JoyconSide import com.joegec.joycon2android.buttonmapping.MappingSource @@ -12,6 +14,7 @@ import com.joegec.joycon2android.buttonmapping.target.SwitchProButton import com.joegec.joycon2android.buttonmapping.target.SwitchProStick import com.joegec.joycon2android.buttonmapping.target.WiimoteButton import com.joegec.joycon2android.buttonmapping.target.WiimoteStick +import com.joegec.joycon2android.core.buttonmapping.presentation.R import com.joegec.joycon2android.model.JoyconButton /** The (storage key, label) rows and (source id, label) choices the mapping editor offers. */ @@ -23,39 +26,47 @@ internal object MappingOptions { console == Console.WIIMOTE_NUNCHUK && side != JoyconSide.DUAL /** Every row the editor offers, in reading order: buttons, then sticks, then what is neither. */ + @Composable fun targets(console: Console): List> = buttonTargets(console) + stickDirectionTargets(console) + motionTargets(console) + @Composable private fun buttonTargets(console: Console): List> = when (console) { - Console.GAMECUBE -> GameCubeButton.entries.map { it.name to it.displayName } - Console.WIIMOTE_NUNCHUK -> (WiimoteButton.entries - MOTION_TARGETS).map { it.name to it.displayName } - Console.SWITCH_PRO -> SwitchProButton.entries.map { it.name to it.displayName } + Console.GAMECUBE -> GameCubeButton.entries.map { it.name to it.label() } + Console.WIIMOTE_NUNCHUK -> (WiimoteButton.entries - MOTION_TARGETS).map { it.name to it.label() } + Console.SWITCH_PRO -> SwitchProButton.entries.map { it.name to it.label() } } // Shaking the remote is a motion of it rather than a button on it, so it sits below the sticks // instead of among the face buttons. private val MOTION_TARGETS = setOf(WiimoteButton.Shake) + @Composable private fun motionTargets(console: Console): List> = - if (console == Console.WIIMOTE_NUNCHUK) MOTION_TARGETS.map { it.name to it.displayName } else emptyList() + if (console == Console.WIIMOTE_NUNCHUK) MOTION_TARGETS.map { it.name to it.label() } else emptyList() + @Composable private fun stickDirectionTargets(console: Console): List> { val sticks = when (console) { - Console.GAMECUBE -> GameCubeStick.entries.map { it to it.displayName } - Console.WIIMOTE_NUNCHUK -> WiimoteStick.entries.map { it to it.displayName } - Console.SWITCH_PRO -> SwitchProStick.entries.map { it to it.displayName } + Console.GAMECUBE -> GameCubeStick.entries.map { it to it.label() } + Console.WIIMOTE_NUNCHUK -> WiimoteStick.entries.map { it to it.label() } + Console.SWITCH_PRO -> SwitchProStick.entries.map { it to it.label() } } return sticks.flatMap { (stick, label) -> StickDirection.entries.map { direction -> - stick.directionKey(direction) to "$label ${direction.displayName}" + stick.directionKey(direction) to stringResource(R.string.source_direction, label, direction.label()) } } } + @Composable fun sources(side: JoyconSide): List> = - listOf(NONE_ID to "None") + physicalButtons(side).map { it.name to it.id } + stickDirections(side) + listOf(NONE_ID to stringResource(R.string.source_none)) + + physicalButtons(side).map { it.name to it.id } + + stickDirections(side) // A lone Joy-Con has one stick, so its directions need no "Left"/"Right" to tell them apart. + @Composable private fun stickDirections(side: JoyconSide): List> { val sticks = when (side) { JoyconSide.DUAL -> StickSource.entries @@ -63,8 +74,9 @@ internal object MappingOptions { JoyconSide.RIGHT -> listOf(StickSource.RIGHT_STICK) } return sticks.flatMap(MappingSource::directionsOf).map { source -> - val stickLabel = if (side == JoyconSide.DUAL) source.stick.displayName else "Stick" - source.id to "$stickLabel ${source.direction.displayName}" + val stick = + if (side == JoyconSide.DUAL) source.stick.label() else stringResource(R.string.stick_lone) + source.id to stringResource(R.string.source_direction, stick, source.direction.label()) } } diff --git a/core/buttonmapping/presentation/src/main/kotlin/com/joegec/joycon2android/buttonmapping/presentation/PlayerMappingCard.kt b/core/buttonmapping/presentation/src/main/kotlin/com/joegec/joycon2android/buttonmapping/presentation/PlayerMappingCard.kt index e6b1c61..d03ae37 100644 --- a/core/buttonmapping/presentation/src/main/kotlin/com/joegec/joycon2android/buttonmapping/presentation/PlayerMappingCard.kt +++ b/core/buttonmapping/presentation/src/main/kotlin/com/joegec/joycon2android/buttonmapping/presentation/PlayerMappingCard.kt @@ -51,6 +51,7 @@ fun PlayerMappingCard( console: Console, player: PlayerState, state: PlayerMappingUiState, + labels: LayoutLabels, actions: MappingActions, onSaveLayout: () -> Unit, onDeleteLayout: (DropdownOption) -> Unit, @@ -64,7 +65,7 @@ fun PlayerMappingCard( .clip(RoundedCornerShape(Dimens.cardCorner)) .background(CardBg), ) { - CardHeader(player, state.layoutName, expanded) { expanded = !expanded } + CardHeader(player, state.layout?.let(labels::name), expanded) { expanded = !expanded } AnimatedVisibility( visible = expanded, enter = fadeIn() + expandVertically(), @@ -79,10 +80,10 @@ fun PlayerMappingCard( verticalArrangement = Arrangement.spacedBy(Dimens.elementSpacing), ) { LayoutRow( - options = state.layoutOptions, - selectedId = state.selectedLayoutId, - layoutName = state.layoutName, - subLabel = state.layoutDescription, + options = state.layouts.map { labels.option(it) }, + selectedId = state.layout?.id, + layoutName = state.layout?.let(labels::name), + subLabel = state.layout?.let(labels::description), onSelect = { actions.selectLayout(state.body, it) }, onSave = onSaveLayout, onDelete = onDeleteLayout, diff --git a/core/buttonmapping/presentation/src/main/res/values/strings.xml b/core/buttonmapping/presentation/src/main/res/values/strings.xml index e735a4e..c3e8ee2 100644 --- a/core/buttonmapping/presentation/src/main/res/values/strings.xml +++ b/core/buttonmapping/presentation/src/main/res/values/strings.xml @@ -20,4 +20,47 @@ Left Right Pro + Standard + Wii + Like a Wiimote, ZR \u2192 B + Joy-Con + True to Joy-Con buttons, B \u2192 B + Mario Kart Wheel + Steer with motion, mapped like MK8 + Mario Kart Nunchuck + Steer with the stick, mapped like MK8 + Mario Kart + Gamecube + Wiimote & Nunchuck + Joycons + L + R + L/R + P%1$d %2$s + Up + Down + Left + Right + Left Stick + Right Stick + Main Stick + C-Stick + Nunchuk Stick + L-Stick + R-Stick + Stick + Start + Home + Capture + Shake + Nunchuk C + Nunchuk Z + L-Stick Click + R-Stick Click + D-Pad Up + D-Pad Down + D-Pad Left + D-Pad Right + None + %1$s %2$s diff --git a/core/emulatorconfig/src/main/kotlin/com/joegec/joycon2android/emulatorconfig/DolphinControls.kt b/core/emulatorconfig/src/main/kotlin/com/joegec/joycon2android/emulatorconfig/DolphinControls.kt new file mode 100644 index 0000000..60f2899 --- /dev/null +++ b/core/emulatorconfig/src/main/kotlin/com/joegec/joycon2android/emulatorconfig/DolphinControls.kt @@ -0,0 +1,16 @@ +package com.joegec.joycon2android.emulatorconfig + +import com.joegec.joycon2android.buttonmapping.StickDirection + +/** + * The spellings Dolphin's ini uses, shared by the two generators that write one. These are wire + * tokens rather than anything a user reads — Dolphin will not match a key spelled otherwise. + */ +object DolphinControls { + val DIRECTIONS = mapOf( + StickDirection.UP to "Up", + StickDirection.DOWN to "Down", + StickDirection.LEFT to "Left", + StickDirection.RIGHT to "Right", + ) +} diff --git a/feature/dsu/domain/src/main/kotlin/com/joegec/joycon2android/dsu/emulator/DolphinWiimoteConfig.kt b/feature/dsu/domain/src/main/kotlin/com/joegec/joycon2android/dsu/emulator/DolphinWiimoteConfig.kt index ee58e23..287d958 100644 --- a/feature/dsu/domain/src/main/kotlin/com/joegec/joycon2android/dsu/emulator/DolphinWiimoteConfig.kt +++ b/feature/dsu/domain/src/main/kotlin/com/joegec/joycon2android/dsu/emulator/DolphinWiimoteConfig.kt @@ -12,6 +12,7 @@ import com.joegec.joycon2android.buttonmapping.target.WiimoteStick import com.joegec.joycon2android.buttonmapping.toSourceMap import com.joegec.joycon2android.buttonmapping.toStickDirectionMap import com.joegec.joycon2android.dsu.DsuSlots +import com.joegec.joycon2android.emulatorconfig.DolphinControls import com.joegec.joycon2android.emulatorconfig.DolphinPaths import com.joegec.joycon2android.emulatorconfig.IniEditor import com.joegec.joycon2android.model.JoyconButton @@ -266,7 +267,7 @@ object DolphinWiimoteConfig { private fun nunchukStickLines(side: JoyconSide, mapping: Map): List = mapping.toStickDirectionMap().values.flatMap { directions -> directions.mapNotNull { (direction, sources) -> - expressionFor(side, sources)?.let { expression -> "Nunchuk/Stick/${direction.displayName} = $expression" } + expressionFor(side, sources)?.let { expression -> "Nunchuk/Stick/${DolphinControls.DIRECTIONS.getValue(direction)} = $expression" } } } diff --git a/feature/gamepad/domain/src/main/kotlin/com/joegec/joycon2android/gamepad/emulator/DolphinGcpadConfig.kt b/feature/gamepad/domain/src/main/kotlin/com/joegec/joycon2android/gamepad/emulator/DolphinGcpadConfig.kt index d8c5eb6..56324f1 100644 --- a/feature/gamepad/domain/src/main/kotlin/com/joegec/joycon2android/gamepad/emulator/DolphinGcpadConfig.kt +++ b/feature/gamepad/domain/src/main/kotlin/com/joegec/joycon2android/gamepad/emulator/DolphinGcpadConfig.kt @@ -11,6 +11,7 @@ import com.joegec.joycon2android.buttonmapping.target.GameCubeButton import com.joegec.joycon2android.buttonmapping.target.GameCubeStick import com.joegec.joycon2android.buttonmapping.toSourceMap import com.joegec.joycon2android.buttonmapping.toStickDirectionMap +import com.joegec.joycon2android.emulatorconfig.DolphinControls import com.joegec.joycon2android.emulatorconfig.DolphinPaths import com.joegec.joycon2android.emulatorconfig.IniEditor import com.joegec.joycon2android.model.JoyconButton @@ -131,7 +132,7 @@ object DolphinGcpadConfig { val stickLines = mapping.toStickDirectionMap().flatMap { (target, directions) -> directions.mapNotNull { (direction, sources) -> expressionFor(side, sources)?.let { expression -> - "${STICK_PREFIXES.getValue(target)}/${direction.displayName} = $expression" + "${STICK_PREFIXES.getValue(target)}/${DolphinControls.DIRECTIONS.getValue(direction)} = $expression" } } } From 379bc321b993bfbb463571b570189f8f5401183a Mon Sep 17 00:00:00 2001 From: Joe Barker Date: Wed, 23 Sep 2026 11:20:01 +0100 Subject: [PATCH 15/16] Cut comments to what the code can't say, and tighten the docs Comments were reading as prose and repeating the docs. Measurements, byte layouts and emulator internals now live in docs/ with one-line pointers, and name-restating KDoc is gone. CLAUDE.md gains rules on comment voice, sibling duplication and a Docs section. Fixes stale docs along the way: the Joy-Con layout row, the trick expression in dsu-motion.md, CONTRIBUTING's comment policy, PRODUCT's battery note, and a nonexistent ADB shell backend. Co-Authored-By: Claude Opus 5.5 --- CLAUDE.md | 63 +++--- CONTRIBUTING.md | 51 ++--- README.md | 56 ++---- .../com/joegec/joycon2android/AppContainer.kt | 10 +- .../joegec/joycon2android/DsuMotionPolicy.kt | 8 +- .../com/joegec/joycon2android/MainActivity.kt | 6 +- .../emulator/EdenDeviceMotionBlocker.kt | 5 - .../emulator/EmulatorLauncher.kt | 1 - .../joycon2android/emulator/EmulatorSetup.kt | 31 +-- .../emulator/VirtualGamepadIdentity.kt | 6 +- .../joycon2android/service/Joycon2Service.kt | 9 +- .../joycon2android/ui/Joycon2ViewModel.kt | 10 +- .../joegec/joycon2android/ui/JoyconScreen.kt | 33 +--- .../AndroidLibraryComposeConventionPlugin.kt | 1 - .../kotlin/AndroidLibraryConventionPlugin.kt | 6 +- .../main/kotlin/KotlinJvmConventionPlugin.kt | 1 - build.gradle.kts | 1 - .../buttonmapping/JsonDocumentStore.kt | 1 - .../buttonmapping/PerPlayerMigration.kt | 5 +- .../buttonmapping/ApplyGlobalLayoutUseCase.kt | 7 +- .../ApplyMappingLayoutUseCase.kt | 1 - .../ApplyPlayerMappingUseCase.kt | 4 - .../joycon2android/buttonmapping/Console.kt | 2 +- .../ControllerMappingRepository.kt | 7 +- .../DeleteCustomLayoutUseCase.kt | 1 - .../DeleteGlobalLayoutUseCase.kt | 1 - .../buttonmapping/EmittedInput.kt | 11 +- .../buttonmapping/GlobalLayout.kt | 6 +- .../buttonmapping/GlobalLayoutRepository.kt | 1 - .../buttonmapping/GlobalMapping.kt | 9 +- .../buttonmapping/JoyconSide.kt | 2 +- .../buttonmapping/LayoutFamily.kt | 5 +- .../buttonmapping/LegacyStickRoutes.kt | 6 +- .../buttonmapping/MappingConversions.kt | 6 +- .../buttonmapping/MappingLayout.kt | 16 +- .../buttonmapping/MappingLayouts.kt | 21 +- .../buttonmapping/MappingSourceIds.kt | 5 +- .../ObserveControllerMappingUseCase.kt | 5 +- .../ObserveGlobalMappingUseCase.kt | 1 - .../ObservePlayerMappingUseCase.kt | 1 - .../ObserveSavedLayoutsUseCase.kt | 1 - .../ObserveSidewaysRemoteUseCase.kt | 2 +- .../buttonmapping/PlayerBody.kt | 2 +- .../buttonmapping/PlayerLayoutSnapshot.kt | 1 - .../buttonmapping/PlayerMapping.kt | 3 +- .../buttonmapping/SaveCustomLayoutUseCase.kt | 5 +- .../buttonmapping/SaveGlobalLayoutUseCase.kt | 1 - .../buttonmapping/SavedLayout.kt | 3 +- .../buttonmapping/SavedLayoutRepository.kt | 1 - .../SetControllerMappingUseCase.kt | 1 - .../buttonmapping/SetSidewaysRemoteUseCase.kt | 2 +- .../buttonmapping/SidewaysRemoteRepository.kt | 2 +- .../buttonmapping/preset/JoyconWiiMapping.kt | 6 +- .../buttonmapping/preset/MappingPreset.kt | 2 - .../buttonmapping/preset/MappingPresets.kt | 1 - .../preset/MarioKartNunchukMapping.kt | 14 +- .../preset/MarioKartWheelMapping.kt | 10 +- .../buttonmapping/preset/SwitchProMapping.kt | 4 +- .../buttonmapping/preset/WiiMapping.kt | 3 +- .../buttonmapping/target/GameCubeButton.kt | 1 - .../buttonmapping/target/SwitchProButton.kt | 1 - .../buttonmapping/target/WiimoteButton.kt | 6 +- .../buttonmapping/GlobalMappingTest.kt | 4 +- .../buttonmapping/preset/WiiPresetsTest.kt | 14 +- .../presentation/ControllerMappingScreen.kt | 3 +- .../presentation/ControllerMappingUiState.kt | 2 - .../ControllerMappingViewModel.kt | 3 +- .../presentation/LayoutLabels.kt | 14 +- .../buttonmapping/presentation/LayoutRow.kt | 10 +- .../presentation/MappingActions.kt | 1 - .../presentation/MappingBindings.kt | 1 - .../presentation/MappingLabels.kt | 5 - .../presentation/MappingOptions.kt | 10 +- .../presentation/PlayerMappingCard.kt | 8 +- .../ui/components/CloseEmulatorDialog.kt | 1 - .../ui/components/ConfirmDialog.kt | 1 - .../ui/components/DolphinSetupButton.kt | 1 - .../ui/components/DropdownOption.kt | 5 +- .../ui/components/DropdownTrigger.kt | 1 - .../ui/components/EmulatorAutoSetup.kt | 1 - .../ui/components/EmulatorOption.kt | 2 +- .../ui/components/LabeledBorderBox.kt | 8 +- .../ui/components/MultiSelectDropdown.kt | 7 +- .../ui/components/OptionDropdown.kt | 5 +- .../ui/components/SettingSwitch.kt | 1 - .../ui/components/StartEmulatorDialog.kt | 2 +- .../ui/components/TextInputDialog.kt | 6 +- .../joegec/joycon2android/ui/theme/Color.kt | 8 +- .../joegec/joycon2android/ui/theme/Dimens.kt | 4 +- .../joegec/joycon2android/ui/theme/Type.kt | 4 +- .../emulatorconfig/DolphinControls.kt | 5 +- .../emulatorconfig/DolphinPaths.kt | 5 - .../emulatorconfig/EdenControls.kt | 5 - .../emulatorconfig/EdenPaths.kt | 9 +- .../emulatorconfig/IniEditor.kt | 14 +- .../joycon2android/model/BatteryGauge.kt | 5 +- .../joycon2android/model/ConnectedJoycon.kt | 1 - .../model/EmulatorSetupResult.kt | 7 +- .../model/JoyconConnectionState.kt | 2 +- .../joycon2android/model/PlayerState.kt | 7 +- .../joycon2android/model/SidewaysMapper.kt | 5 +- .../session/SessionCoordinator.kt | 12 +- docs/DESIGN.md | 125 +++++------- docs/PRODUCT.md | 51 ++--- docs/adding-a-feature.md | 76 +++---- docs/architecture.md | 97 ++++----- docs/dsu-motion.md | 185 +++++++++--------- docs/protocol.md | 62 ++++-- docs/virtual-gamepad.md | 46 ++--- .../assignment/PlayerAssignmentManager.kt | 4 - .../assignment/AssignmentRepository.kt | 5 +- .../assignment/ComboAssignment.kt | 1 - .../assignment/ComboAssignmentDetector.kt | 7 +- .../assignment/PlayerStateResolver.kt | 5 +- .../assignment/SideInference.kt | 7 +- .../joycon2android/connection/BleScanner.kt | 21 +- .../connection/ConnectionPool.kt | 13 +- .../joycon2android/connection/GattOpQueue.kt | 9 +- .../connection/Joycon2Manager.kt | 7 - .../connection/JoyconAdvertisement.kt | 9 +- .../connection/JoyconConnection.kt | 29 +-- .../joycon2android/connection/PacketParser.kt | 4 +- .../connection/ControllerRepository.kt | 6 +- .../connection/StickCalibrator.kt | 12 +- .../connection/ViewModePreferences.kt | 1 - .../presentation/ControllerAccent.kt | 7 +- .../connection/presentation/ImuDisplay.kt | 1 - .../presentation/SidewaysImuDisplay.kt | 2 - .../connection/presentation/StickCard.kt | 3 +- .../joycon2android/dsu/DsuClientRegistry.kt | 9 +- .../joycon2android/dsu/DsuPacketEncoder.kt | 11 +- .../joycon2android/dsu/DsuRequestParser.kt | 5 +- .../joegec/joycon2android/dsu/DsuServer.kt | 9 +- .../joycon2android/dsu/DsuServerTest.kt | 2 - .../joegec/joycon2android/dsu/DsuCoverage.kt | 6 +- .../com/joegec/joycon2android/dsu/DsuSlots.kt | 5 +- .../joegec/joycon2android/dsu/DsuStream.kt | 1 - .../dsu/emulator/DolphinDsuConfig.kt | 7 +- .../dsu/emulator/DolphinWiimoteConfig.kt | 39 +--- .../dsu/emulator/EdenDsuConfig.kt | 13 +- .../dsu/motion/DeviceMotionBlocker.kt | 5 +- .../dsu/motion/GyroCalibrator.kt | 9 +- .../dsu/motion/MotionConverter.kt | 10 +- .../dsu/motion/SidewaysMotion.kt | 7 +- .../dsu/emulator/DolphinWiimoteConfigTest.kt | 8 - .../dsu/presentation/DsuViewModel.kt | 7 +- .../joycon2android/gamepad/ReportMapper.kt | 6 +- .../joycon2android/gamepad/UhidRelay.kt | 12 +- .../gamepad/privileged/PrivilegedAccess.kt | 4 - .../gamepad/privileged/PrivilegedShell.kt | 6 +- .../shizuku/ShizukuPermissionHandler.kt | 4 +- .../gamepad/ReportMapperTest.kt | 6 +- .../gamepad/GamepadRepository.kt | 1 - .../gamepad/PrivilegedAccessRepository.kt | 1 - .../gamepad/emulator/DolphinGcpadConfig.kt | 20 +- .../gamepad/emulator/EdenGamepad.kt | 7 +- .../gamepad/emulator/EdenGamepadConfig.kt | 18 +- .../gamepad/presentation/GamepadViewModel.kt | 5 - .../gamepad/presentation/ShizukuSetupCard.kt | 2 +- .../update/ApkUpdateInstaller.kt | 3 +- .../update/GitHubReleaseParser.kt | 4 - .../joycon2android/update/GitHubReleases.kt | 5 +- .../joycon2android/update/ReleaseNotes.kt | 8 +- .../konsist/ArchitectureTest.kt | 6 +- tools/README.md | 24 +-- 165 files changed, 535 insertions(+), 1258 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 43e7486..1860ca2 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -9,29 +9,39 @@ - Follow current Android, Kotlin, and Compose conventions - Boy Scout Rule: leave code better than you found it - One class per file -- Code should read like well-written prose +- Names should make code read like well-written prose (comments do not — see below) - Methods should be short enough that they explain themselves - Methods and composables should be reusable like components ## Comments -- Default to **no comment**: a well-named class or method is its own documentation -- Never write a comment that restates the code, the signature, or what the next line does — if a comment can be made redundant by renaming or extracting, do that instead -- A genuine "why" (a constraint the code cannot express, e.g. "StateFlow conflation requires a synchronous callback") gets one or two lines, never a paragraph -- New classes get **no KDoc by default**; earn it only with a non-obvious "why" -- **Three lines is the ceiling.** If a comment is outgrowing that, it has stopped explaining the - line in front of it and started explaining the subject — a derivation, a measurement, a byte - layout, an emulator's behaviour, why two other approaches failed. That belongs in - [`docs/`](docs/README.md), with a one-line pointer where the code needs it: - `/** … : docs/dsu-motion.md#motion-frame */` -- Physical-world facts still can't be derived from code — BLE protocol, byte layouts, timing - constraints, hardware conventions being mirrored — so write them down properly, in the doc that - owns them (`protocol.md`, `virtual-gamepad.md`, `dsu-motion.md`, `DESIGN.md`, `architecture.md`) - rather than at the top of whichever class happened to need them first -- **One home per fact.** A comment and a doc saying the same thing will drift, and the stale one is - found only once it has misled someone -- Keep in code only what a reader needs *at that line* and cannot reconstruct from it: a byte's - meaning in a descriptor, a constant's unit, what a workaround is working around -- Litmus test before writing any comment: "could a reader reconstruct this from the code alone?" If yes, delete it +- Default to **no comment**. A name is the documentation; if renaming or extracting would make a + comment redundant, do that instead +- A comment says only what the code cannot: a constraint, a unit, a byte's meaning, what a + workaround works around. Litmus test: "could a reader reconstruct this from the code?" If yes, delete it +- **Plain and terse.** State the fact like a spec sheet, not a story: no narrative, aphorism or + flourish. `// Dolphin ORs its inputs, so any bound source fires the target.` +- **One line is the norm, three the ceiling.** Longer means it is explaining the subject rather + than the line — a derivation, a measurement, a byte layout, an emulator's internals, approaches + that failed. That goes in the doc that owns it, with a one-line pointer: + `/** Axes and signs: docs/dsu-motion.md#motion-frame */` +- **No KDoc on classes, interfaces, use cases or properties by default** — remove one that restates + the name when you touch the file. Keep it only for a non-obvious "why" +- **One home per fact.** Never repeat a doc in a comment, or the same comment in sibling files (two + emulator generators, two ViewModels): point at the doc, or put it on the shared type. Copies drift +- Physical-world facts (BLE protocol, byte layouts, timing, measurements, emulator internals) live in + `protocol.md`, `virtual-gamepad.md`, `dsu-motion.md`; UI decisions in `DESIGN.md`; structure in + `architecture.md` +- Tests: say it in the test name. An inline comment only labels a magic value (`// slot`, `// BUTTON_A 96`) + +## Docs +- Written for someone about to change the code: what is true now, and why. No changelogs, "was X + until Y" stories or ticked-off to-do lists — git holds history +- Lead with the fact. Tables and short bullets over paragraphs; cut preamble and restatement +- Keep a failed approach only when it stops someone retrying it, in a sentence or two +- Date a measurement and name the hardware it came from; nothing else needs a date +- **Docs are part of the change.** When behaviour a doc or the README describes changes, update it + in the same commit +- `README.md` is for players (setup, troubleshooting); implementation detail goes in `docs/` ## SOLID Principles - **Single Responsibility:** each class has one reason to change — if you need "Manager" or "Handler" in the name, it's probably doing too much @@ -62,15 +72,12 @@ ## Conventions - Use `enableEdgeToEdge()` with `WindowInsets.systemBars` for edge-to-edge inset handling -- **User-facing strings never live in a domain module.** Those modules are pure Kotlin/JVM and - cannot see `R.string` at all, so a name in one is a name that can never be translated or reworded - without touching logic. A domain type carries its *identity* — the enum entry, the id it is stored - under — and presentation gives it a word, through an exhaustive `when` over the type so that - adding a case without a word fails to build (see `MappingLabels` / `LayoutLabels`) -- Two things that look like copy but are not, and stay: **wire tokens** an emulator or protocol must - match exactly (`DolphinControls.DIRECTIONS`, `EdenControls`), and the **markings printed on the - hardware** that the controller graphics draw (`JoyconButton.label` — "ZL", "+", "A" are the same - in every language, and are part of the picture rather than prose) +- **User-facing strings never live in a domain module** (pure JVM, no `R.string`). A domain type + carries its identity — the enum entry, the stored id — and presentation names it through an + exhaustive `when`, so a new case without a word fails to build (`MappingLabels`, `LayoutLabels`) +- Not copy, so they stay in code: **wire tokens** an emulator or protocol must match exactly + (`DolphinControls`, `EdenControls`), and **hardware markings** the controller graphics draw + (`JoyconButton.label` — "ZL", "+", "A") - Avoid hard-coded strings elsewhere too — use string resources where possible - Use theme for dimensions and colors rather than inline literals - Prefer immutable data classes for state diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 37aa493..00a51cd 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -1,39 +1,25 @@ # Contributing to Joycon2Android -Thanks for your interest in improving Joycon2Android! This guide covers how to get set up, the -standards your change is expected to meet, and how to get it merged. +Thanks for helping improve Joycon2Android! This covers setup, the standards a change must meet, and +how to get it merged. ## Coding standards -**[`CLAUDE.md`](CLAUDE.md) is the project's coding-standards contract, and it applies to everyone — -human or AI.** Read it before writing code. In short, it asks for: - -- **Kotlin, Jetpack Compose, Material 3**, targeting Android API 24+. -- **Clean, self-documenting code** — short, well-named methods; one class per file; reusable - composables. Prefer renaming or extracting over adding a comment. -- **Minimal comments** — no KDoc that restates the signature. A comment earns its place only when - it captures a *why* the code can't express. Comments describing hardware/BLE protocol details - (byte layouts, timing, Switch conventions) are welcome and can be detailed. -- **SOLID and clean architecture** — small focused classes, dependencies injected via constructors, - orchestrators that delegate rather than implement. -- **No hard-coded strings** — use string resources. **No inline dimensions/colors** — use the - theme. -- Immutable data classes for state; `@SuppressLint("MissingPermission")` only on permission-guarded - methods. +**[`CLAUDE.md`](CLAUDE.md) is the coding-standards contract for everyone, human or AI.** Read it +before writing code. In short: -## Architecture - -This is a **Gradle multi-module** app split by **feature × layer** (`domain` / `data` / -`presentation`), and the module graph *enforces* the dependency rules at compile time. Before -making structural changes: +- **Self-documenting code** — short, well-named methods, one class per file, reusable composables. +- **Minimal, terse comments** — only what the code can't say. Protocol details, measurements and + emulator internals go in [`docs/`](docs/README.md), not in comments. +- **SOLID and clean architecture** — small focused classes, constructor injection, orchestrators that + delegate. +- **No hard-coded strings, dimensions or colours** — string resources and the theme. -- Read [`docs/architecture.md`](docs/architecture.md) — the module graph, layers, and dependency - rules. -- Follow [`docs/adding-a-feature.md`](docs/adding-a-feature.md) when adding or changing a feature — - it has step-by-step recipes and explains the convention plugins in `build-logic/`. +## Architecture -Presentation reaches data **only through use cases** — never bypass it. Every module applies one of -the convention plugins; don't hand-roll `android {}` blocks in a module. +A Gradle multi-module app split by feature × layer, whose module graph enforces the dependency rules. +Read [`docs/architecture.md`](docs/architecture.md) before structural changes, and follow +[`docs/adding-a-feature.md`](docs/adding-a-feature.md) when adding or changing a feature. ## Development setup @@ -58,13 +44,8 @@ Run the same checks CI runs, and make sure they pass: ./gradlew build :konsist:test ``` -This compiles every module, runs the unit tests, runs Android lint, and runs the **Konsist** -architecture tests that enforce layer placement. If you moved classes between modules, `:konsist:test` -is what catches a misplacement. - -- **Add tests** for new domain/data logic where practical — the layering exists so each class is - independently testable. -- **Leave the code better than you found it** (the Boy Scout Rule). +That compiles every module and runs the unit tests, Android lint and the Konsist architecture tests. +Add tests for new domain and data logic, and update any doc your change makes stale. ## Pull requests diff --git a/README.md b/README.md index 6488f68..a397072 100644 --- a/README.md +++ b/README.md @@ -87,37 +87,29 @@ the emulator afterwards — it only reads its config when it starts. | Virtual Gamepad | Eden, Eden Nightly, Dolphin (GameCube) | buttons and sticks | | DSU Motion Server | Eden, Eden Nightly, Dolphin (Wii) | buttons, sticks and motion | -The gamepad button beside **Set up** opens the mapping editor. Every connected player gets a card — +The gamepad button beside **Set up** opens the mapping editor, with a card per connected player — tap one to open its bindings. A single Joy-Con is set up as a Pro Controller held sideways, so every -button works in every game -([why](docs/virtual-gamepad.md#why-theyre-set-up-as-pro-controllers)). A target can take **several -sources at once** — tick as many as you like, and any of them fires it. - -Each player picks their own **layout** to start from, which resets that player's customizations. The -name on the card reads **Custom** the moment you change a binding, and reads the layout's own name -again as soon as you change it back. The **save** icon beside the name keeps what you have built as a -layout of your own, offered to any player holding the same body — it suggests the next free -**Custom N**, and dims once there is nothing new to save, since a mapping that already reads as a -layout has a name. The bin in the dropdown deletes -one: your buttons stay exactly as they are, the name just becomes **Custom** until you save it again. - -**All players** at the top sets everyone at once, and saves the same way. Picking a grip only some -bodies can be held in — **Mario Kart Wheel**, say — gives every other body the same game's other -grip, so a table of singles and pairs all end up on Mario Kart rather than half of them on the -default. A saved set remembers which -player held which body — the sub-label under its name says which ("P1 L, P2 R, P3 L/R") — so it stays -greyed out until those players are back. - -A player holding a lone Joy-Con on the Wii console also gets a **Sideways Wii Remote** switch — play -it as a Wii Remote held sideways, which is what a wheel game steers by. Each layout sets it (on for -Mario Kart, off for the others) and you can override it; picking a layout hands it back. +button works in every game ([why](docs/virtual-gamepad.md#why-theyre-set-up-as-pro-controllers)). A +target can take **several sources** — tick as many as you like, and any of them fires it. + +- **Layouts.** Each player picks a layout to start from, which resets their changes. The card reads + **Custom** once you change a binding, and the layout's name again if you change it back. +- **Saving.** The save icon beside the name keeps your mapping as a layout for any player holding the + same body. It dims when the mapping already matches a layout. Deleting a layout (the bin in the + dropdown) keeps everyone's buttons; they just read **Custom** until you save again. +- **All players** at the top sets and saves everyone at once. A grip only some bodies fit — + **Mario Kart Wheel**, say — gives the rest the same game's other grip. A saved set remembers who + held which body ("P1 L, P2 R, P3 L/R") and stays greyed out until those players are back. +- **Sideways Wii Remote.** On the Wii console, a lone Joy-Con also gets this switch, for games that + steer by tilting a sideways remote. The layout sets it (on for Mario Kart) and you can override + it until you next pick a layout. The layouts the app ships: | Layout | For | |---|---| | Wii | The Wii Remote's own arrangement: the trigger under your finger is B, 1 and 2 under the thumb | -| Joy-Con | The same, with B and 2 swapped so the Joy-Con's own B is the remote's B | +| Joy-Con | The same, with the Joy-Con's own B as B, and 1 and 2 on the shoulders so your thumb stays on the stick | | Mario Kart Wheel | A lone Joy-Con held sideways as a wheel, laid out the way Mario Kart 8 uses one — 2 accelerates, 1 brakes, SR hops and tricks, and SL throws an item alongside the stick. Steers by **tilt**, so it plays as a sideways Wii Remote: the d-pad turns with it and a right Joy-Con aims from its tail | | Mario Kart Nunchuck | The remote-and-nunchuk scheme, which steers by **stick** instead. A pair splits the halves across the hands, each index finger on the shoulder its controller keeps a trigger on; a lone Joy-Con plays both halves itself, its own stick standing in for the Nunchuk's and its rails carrying C and Z | @@ -189,8 +181,8 @@ shoulder buttons. shoulder edge, so aim R/ZR (or L/ZL) at the screen. Also set **Total Yaw** to around 60 — Dolphin's 25 clamps the cursor after ±12.5° of turn, which a hand-held aim overruns. - **For a game written for a sideways Wii Remote** (Mario Kart Wii and its Wii Wheel), two changes, - both of which the in-app **Mario Kart** layout writes for you: + **For a game written for a sideways Wii Remote** (Mario Kart Wii and its Wii Wheel) — both + **Mario Kart** layouts write these for you: - Give a **right** Joy-Con the *left* column. Sideways, its top edge points where a sideways remote's tail does, so without that half turn the wheel steers backwards; a left Joy-Con @@ -201,15 +193,9 @@ shoulder buttons. - For **tricks and wheelies**, append ``+ pulse(, 0.6) * max(sin(timer(0.15) * 6.2832), 0) * 50`` to **IMUAccelerometer/Up**, where `` is ``(\`Gyro Pitch Up\` / 9) & not(pulse(\`Gyro Pitch Down\` / 9, 0.4))`` — - then the same on **/Down** with Up and Down swapped. A flick of a Joy-Con is nearly all - rotation, which the game can't read from an accelerometer alone - ([why](docs/dsu-motion.md#dolphin-wii-remote-mapping)). Steering is roll, never pitch, so it - can't set this off; the lock-out stops a flick's rebound cancelling the wheelie it just - started. Don't use Dolphin's **Shake** group — it doesn't land tricks. You can also bind - **Shake** to a button. - - Leave Dolphin's own **Sideways Wii Remote** option off either way — it would turn the - accelerometer a second quarter. + then the same on **/Down** with Up and Down swapped + ([why](docs/dsu-motion.md#tricks-and-wheelies)). Dolphin's own **Shake** group doesn't land + tricks, but you can bind **Shake** to a button. 5. Under **Swing**, set **Forward** to ``(`Accel Forward` - `Accel Backward`) - smooth((`Accel Forward` - `Accel Backward`), 0.03)``, diff --git a/app/src/main/java/com/joegec/joycon2android/AppContainer.kt b/app/src/main/java/com/joegec/joycon2android/AppContainer.kt index 923ebb6..f5e5cbd 100644 --- a/app/src/main/java/com/joegec/joycon2android/AppContainer.kt +++ b/app/src/main/java/com/joegec/joycon2android/AppContainer.kt @@ -89,12 +89,7 @@ import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.flow.map import java.io.File -/** - * Composition root: owns app-scoped repositories (data) and binds them to use cases - * (domain). Presentation reaches data only through these use cases. Held by - * [JoyconApplication] so the servers/connections outlive any single Activity or the - * foreground service. - */ +/** Composition root: docs/architecture.md#composition-root--appcontainer */ class AppContainer(context: Context) { private val appContext = context.applicationContext @@ -154,8 +149,6 @@ class AppContainer(context: Context) { val setBlockDeviceMotion = SetBlockDeviceMotionUseCase(dsuMotionSettings) // --- Assignment --- - // Cross-feature orchestration that reacts to assignment (gamepad/DSU lifecycle) lives in - // the SessionCoordinator below. val assignmentRepository: AssignmentRepository = PlayerAssignmentManager() // --- Gamepad + privileged access --- @@ -225,7 +218,6 @@ class AppContainer(context: Context) { val assignController = AssignControllerUseCase(sessionCoordinator) val unassignController = UnassignControllerUseCase(sessionCoordinator) - /** Cross-feature shutdown: stop every output and clear assignments/connections. */ fun disconnectAll() { disableGamepad() disableDsu() diff --git a/app/src/main/java/com/joegec/joycon2android/DsuMotionPolicy.kt b/app/src/main/java/com/joegec/joycon2android/DsuMotionPolicy.kt index fa0a3b1..9946589 100644 --- a/app/src/main/java/com/joegec/joycon2android/DsuMotionPolicy.kt +++ b/app/src/main/java/com/joegec/joycon2android/DsuMotionPolicy.kt @@ -9,11 +9,7 @@ import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.launch -/** - * Applies the DSU motion settings only while DSU runs, so neither the battery cost nor the - * sensor block lingers once it stops. DSU starts off, so launch also clears a block left behind - * by a process that was killed before it could lift it. - */ +/** Motion settings apply only while DSU runs: docs/dsu-motion.md#eden-reads-the-devices-own-motion */ class DsuMotionPolicy( private val scope: CoroutineScope, private val dsuEnabled: Flow, @@ -30,7 +26,7 @@ class DsuMotionPolicy( .collect { setHighConnectionPriority(it) } } scope.launch { - // Re-applied when the shell comes back, since the block can only be set through it. + // The block can only be set through the shell, so re-apply when it returns. combine(dsuEnabled, settings, privilegedShellAvailable) { dsuOn, current, shellUp -> (dsuOn && current.blockDeviceMotion) to shellUp } diff --git a/app/src/main/java/com/joegec/joycon2android/MainActivity.kt b/app/src/main/java/com/joegec/joycon2android/MainActivity.kt index 43312a9..c035ea3 100644 --- a/app/src/main/java/com/joegec/joycon2android/MainActivity.kt +++ b/app/src/main/java/com/joegec/joycon2android/MainActivity.kt @@ -113,8 +113,7 @@ class MainActivity : ComponentActivity() { } override fun onCreate(savedInstanceState: Bundle?) { - // The UI is always dark, so force light bar icons rather than letting them follow the - // device's light/dark mode (which would render dark-on-dark on a light-mode device). + // The UI is always dark; following a light-mode device would draw bar icons dark-on-dark. enableEdgeToEdge( statusBarStyle = SystemBarStyle.dark(Color.TRANSPARENT), navigationBarStyle = SystemBarStyle.dark(Color.TRANSPARENT), @@ -241,8 +240,7 @@ class MainActivity : ComponentActivity() { val permissionDenied by viewModel.permissionDenied.collectAsState() val viewMode by viewModel.viewMode.collectAsState() - // A written emulator config is keyed to the current assignment; once it changes, - // the Done/Failed state is stale, so reset both setup buttons. + // A written config matches one assignment, so Done/Failed goes stale when it changes. val assignmentKey = state.players.map { Triple(it.player.index, it.left?.address, it.right?.address) } diff --git a/app/src/main/java/com/joegec/joycon2android/emulator/EdenDeviceMotionBlocker.kt b/app/src/main/java/com/joegec/joycon2android/emulator/EdenDeviceMotionBlocker.kt index e36e3ff..7901d54 100644 --- a/app/src/main/java/com/joegec/joycon2android/emulator/EdenDeviceMotionBlocker.kt +++ b/app/src/main/java/com/joegec/joycon2android/emulator/EdenDeviceMotionBlocker.kt @@ -8,11 +8,6 @@ import com.joegec.joycon2android.gamepad.privileged.PrivilegedShell import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext -/** - * Marks Eden's uid idle in the sensor service, which withholds continuous sensors (gyro, - * accelerometer) from it as if it were in the background. The override lives in system_server - * until it is reset or the device reboots, so it outlives our process if we are killed. - */ class EdenDeviceMotionBlocker( private val readyShell: () -> PrivilegedShell?, ) : DeviceMotionBlocker { diff --git a/app/src/main/java/com/joegec/joycon2android/emulator/EmulatorLauncher.kt b/app/src/main/java/com/joegec/joycon2android/emulator/EmulatorLauncher.kt index e071d5a..3e3caa4 100644 --- a/app/src/main/java/com/joegec/joycon2android/emulator/EmulatorLauncher.kt +++ b/app/src/main/java/com/joegec/joycon2android/emulator/EmulatorLauncher.kt @@ -4,7 +4,6 @@ import android.content.Context import android.content.Intent import android.util.Log -/** Starts an installed emulator so it reloads the config auto setup just wrote. */ class EmulatorLauncher(context: Context) { private val appContext = context.applicationContext diff --git a/app/src/main/java/com/joegec/joycon2android/emulator/EmulatorSetup.kt b/app/src/main/java/com/joegec/joycon2android/emulator/EmulatorSetup.kt index a8e4c2d..c1ace1a 100644 --- a/app/src/main/java/com/joegec/joycon2android/emulator/EmulatorSetup.kt +++ b/app/src/main/java/com/joegec/joycon2android/emulator/EmulatorSetup.kt @@ -28,12 +28,7 @@ import kotlinx.coroutines.suspendCancellableCoroutine import kotlinx.coroutines.withTimeoutOrNull import kotlin.coroutines.resume -/** - * Writes emulator config to match the current player assignment, through the privileged shell - * (Shizuku / wireless debugging). Best-effort: returns false — and the UI falls back to manual - * setup — when no shell is available or the write can't be verified, since some OEM builds deny - * even the shell user access to another app's Android/data. - */ +/** Best-effort: some OEM builds deny even the shell user another app's `Android/data`. */ class EmulatorSetup( private val packageManager: PackageManager, private val acquireShell: (onResult: (PrivilegedShell?) -> Unit) -> Unit, @@ -44,8 +39,7 @@ class EmulatorSetup( private val getSidewaysRemote: GetSidewaysRemoteUseCase, ) { - // Read up front, once per body in play: the generators are synchronous, and a player whose body - // was never stored falls back to the console's default layout rather than to nothing. + // Read up front: the generators are synchronous. private suspend fun mappingLookup( console: Console, players: List, @@ -64,7 +58,6 @@ class EmulatorSetup( private fun bodiesOf(players: List) = players.mapNotNull { it.body() }.distinct() - /** Installed emulators whose controller mapping the Virtual Gamepad can configure. */ fun gamepadEmulators(): List = buildList { if (isInstalled(DolphinPaths.PACKAGE)) { add(EmulatorOption(DolphinPaths.PACKAGE, "Dolphin (GameCube)")) @@ -72,7 +65,6 @@ class EmulatorSetup( addAll(edenOptions()) } - /** Installed emulators whose motion input the DSU server can configure. */ fun dsuEmulators(): List = buildList { if (isInstalled(DolphinPaths.PACKAGE)) { add(EmulatorOption(DolphinPaths.PACKAGE, "Dolphin (Wii)")) @@ -92,7 +84,6 @@ class EmulatorSetup( private fun isInstalled(pkg: String) = runCatching { packageManager.getPackageInfo(pkg, 0) }.isSuccess - /** Motion input for the selected emulator (DSU card). */ suspend fun configureDsu( emulatorId: String, players: List, @@ -146,7 +137,6 @@ class EmulatorSetup( if (dsuOk && wiimoteOk) EmulatorSetupResult.SUCCESS else EmulatorSetupResult.FAILED } - /** Controller mapping for the selected emulator (Gamepad card). */ suspend fun configureGamepad( emulatorId: String, players: List, @@ -175,7 +165,7 @@ class EmulatorSetup( mappingLookup(Console.GAMECUBE, players), ) val mappingsOk = shell.writeText(DolphinGcpadConfig.path, mappings) - // Dolphin GC ports default to "None"; set them to Standard Controller + // Dolphin's GameCube ports default to None. val core = DolphinGcpadConfig.mergeCore(shell.readText(DolphinGcpadConfig.corePath), players) val coreOk = shell.writeText(DolphinGcpadConfig.corePath, core) mappingsOk && coreOk @@ -183,9 +173,8 @@ class EmulatorSetup( if (written) EmulatorSetupResult.SUCCESS else EmulatorSetupResult.FAILED } - // Shell reads/writes block on native binder/socket calls that coroutine cancellation can't - // interrupt, so run them on a scope that outlives the wait and abandon it on timeout — that - // way the "Setting up…" spinner always resolves instead of pinning if a call never returns. + // Shell calls block in native code that cancellation can't interrupt, so they run on an outer + // scope that is abandoned on timeout; otherwise a hung call pins the spinner. private suspend fun bounded(tag: String, block: suspend () -> EmulatorSetupResult): EmulatorSetupResult { val work = scope.async(Dispatchers.IO) { runCatching { block() } @@ -217,14 +206,8 @@ class EmulatorSetup( } /** - * Clears the way for a write, or reports that the caller must ask first. An emulator flushes its - * in-memory config over ours when it exits, so a write while one is loaded is lost with no error to - * report — the only reliable fix is to stop it first, which costs unsaved progress and therefore - * needs consent. Returns null once the way is clear. - * - * Android keeps a process cached long after the user leaves the app, and `pidof` cannot tell cached - * from running, so this asks whenever a process exists at all. Stopping a cached one costs nothing, - * and the confirmation covers the case where it is live. + * Null once the way is clear, else [EmulatorSetupResult.EMULATOR_RUNNING]. `pidof` can't tell a + * cached process from a live one, so any process at all asks first. */ private fun PrivilegedShell.settle(packageName: String, closeIt: Boolean): EmulatorSetupResult? { if (!hasProcess(packageName)) return null diff --git a/app/src/main/java/com/joegec/joycon2android/emulator/VirtualGamepadIdentity.kt b/app/src/main/java/com/joegec/joycon2android/emulator/VirtualGamepadIdentity.kt index e526e30..ee28068 100644 --- a/app/src/main/java/com/joegec/joycon2android/emulator/VirtualGamepadIdentity.kt +++ b/app/src/main/java/com/joegec/joycon2android/emulator/VirtualGamepadIdentity.kt @@ -7,11 +7,7 @@ import android.view.KeyEvent import android.view.MotionEvent import com.joegec.joycon2android.gamepad.emulator.EdenGamepad -/* - * Resolves how each emulator identifies our virtual gamepads, by reading the live input-device list - * rather than deriving a number from the player index. Each emulator's rule, and why a guess breaks: - * docs/virtual-gamepad.md#device-identity. - */ +// Read from the live device list, never derived: docs/virtual-gamepad.md#device-identity private const val PREFIX = "Joy-Con Virtual Gamepad " diff --git a/app/src/main/java/com/joegec/joycon2android/service/Joycon2Service.kt b/app/src/main/java/com/joegec/joycon2android/service/Joycon2Service.kt index 423f845..42812c1 100644 --- a/app/src/main/java/com/joegec/joycon2android/service/Joycon2Service.kt +++ b/app/src/main/java/com/joegec/joycon2android/service/Joycon2Service.kt @@ -16,11 +16,7 @@ import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.cancel import kotlinx.coroutines.launch -/** - * Keeps the app's BLE connections and outputs alive past the Activity: holds a wake lock - * and promotes to a foreground service (with notification) while a Joy-Con is connected. - * All app state lives in [AppContainer]; the Activity binds this only for that lifetime. - */ +/** Keeps BLE alive past the Activity. Holds no state: that lives in [AppContainer]. */ class Joycon2Service : Service() { inner class LocalBinder : Binder() { @@ -39,8 +35,7 @@ class Joycon2Service : Service() { super.onCreate() wakeLock = PartialWakeLock(this, WAKE_LOCK_TAG) wakeLock.acquire() - // Foreground (and its notification) only while a Joy-Con is actually connected; - // otherwise the bound Activity keeps us alive without a notification + // Foreground only while a Joy-Con is connected; otherwise the bound Activity keeps us alive. serviceScope.launch { container.observeSession().collect { updateForeground(it.anyConnected) } } diff --git a/app/src/main/java/com/joegec/joycon2android/ui/Joycon2ViewModel.kt b/app/src/main/java/com/joegec/joycon2android/ui/Joycon2ViewModel.kt index b6323a1..f29af5f 100644 --- a/app/src/main/java/com/joegec/joycon2android/ui/Joycon2ViewModel.kt +++ b/app/src/main/java/com/joegec/joycon2android/ui/Joycon2ViewModel.kt @@ -40,8 +40,7 @@ class Joycon2ViewModel(application: Application) : AndroidViewModel(application) val viewMode: StateFlow = container.observeViewMode() .stateIn(viewModelScope, SharingStarted.Eagerly, ConnectionViewMode.DETAILED) - // Bound only to keep the service (foreground lifetime) alive; all state is read from - // the app-scoped container, not the binder. + // Bound only for the service's lifetime; state comes from the container, not the binder. private val connection = object : ServiceConnection { override fun onServiceConnected(name: ComponentName?, binder: IBinder?) { bound = true @@ -56,7 +55,6 @@ class Joycon2ViewModel(application: Application) : AndroidViewModel(application) if (permissionHandler.isGranted()) { startAndBind() } - // All state comes from the app-scoped container via its use cases, not the binder viewModelScope.launch { container.observeSession().collect { _uiState.value = it } } @@ -101,10 +99,6 @@ class Joycon2ViewModel(application: Application) : AndroidViewModel(application) viewModelScope.launch { container.setViewMode(mode) } } - /** - * Stops the service entirely — disconnects all devices and removes the notification. - * Called when the user explicitly wants to shut everything down. - */ fun stopService() { container.disconnectAll() val app = getApplication() @@ -114,8 +108,6 @@ class Joycon2ViewModel(application: Application) : AndroidViewModel(application) private fun startAndBind() { val app = getApplication() val intent = Intent(app, Joycon2Service::class.java) - // Bind only — the service promotes itself to foreground once a Joy-Con connects, - // so there's no notification while idle app.bindService(intent, connection, Context.BIND_AUTO_CREATE) } } diff --git a/app/src/main/java/com/joegec/joycon2android/ui/JoyconScreen.kt b/app/src/main/java/com/joegec/joycon2android/ui/JoyconScreen.kt index d815226..d41773b 100644 --- a/app/src/main/java/com/joegec/joycon2android/ui/JoyconScreen.kt +++ b/app/src/main/java/com/joegec/joycon2android/ui/JoyconScreen.kt @@ -105,19 +105,12 @@ import com.joegec.joycon2android.ui.theme.TextOnAccent import kotlinx.coroutines.launch import kotlin.math.roundToInt -// Material 3 small top-app-bar container height; the app bar overlays the content, so screens add -// this (plus the status-bar inset) as top clearance rather than the Scaffold reserving it. +// Material 3 small top app bar's container height. private val AppBarHeight = 64.dp -// Landscape packs two players per row, so each detailed controller is shrunk to help a full player -// fit the short landscape height. private const val LandscapePlayerScale = 0.7f -/** - * Lays the content out as if it had 1/[scale] the space, then draws it scaled down and reports the - * smaller size — shrinking the whole controller (buttons, labels, spacing) uniformly while still - * reflowing siblings, unlike a plain graphicsLayer scale which leaves the original bounds behind. - */ +/** Reports the scaled size, so siblings reflow — unlike graphicsLayer, which keeps the original bounds. */ private fun Modifier.scaleLayout(scale: Float): Modifier = layout { measurable, constraints -> fun up(value: Int) = (value / scale).roundToInt() val placeable = measurable.measure( @@ -176,8 +169,7 @@ fun JoyconScreen( val controllerRemovedMessage = stringResource(R.string.snackbar_controller_removed) val playerRemovedTemplate = stringResource(R.string.snackbar_player_removed) - // Unassigning is reachable by tapping the live display, so every removal is offered back as an - // undo (re-assigning the same controllers to the same player) rather than being silent. + // Unassigning is one tap on the live display, so every removal offers an undo. fun offerUndo(message: String, restore: List>) { scope.launch { val result = snackbarHostState.showSnackbar( @@ -226,9 +218,7 @@ fun JoyconScreen( } } }, - // Only reserve the horizontal insets: content passes under both the status bar (as the app - // bar collapses on scroll) and the nav bar. Each screen re-applies those where its own - // content must stay clear of the system bars. + // Edge-to-edge and the overlaid app bar: docs/DESIGN.md#connection-screen-chrome contentWindowInsets = WindowInsets.systemBars.only(WindowInsetsSides.Horizontal), ) { innerPadding -> val screenState = when { @@ -243,9 +233,6 @@ fun JoyconScreen( } } - // The app bar overlays the content instead of reserving space, so the scroll passes behind - // the transparent status bar; each screen adds the bar's height back as top clearance, and - // the bar itself is translated up in lockstep with the scroll so it slides away without a gap. val appBarSpace = WindowInsets.statusBars.asPaddingValues().calculateTopPadding() + AppBarHeight val appBarSpacePx = with(LocalDensity.current) { appBarSpace.toPx() } @@ -284,13 +271,12 @@ fun JoyconScreen( ) else -> ScanningContent(state) } - // Lets the last item scroll clear of the nav bar it now passes under + // Content passes under the nav bar, so the last item needs clearance. Spacer(Modifier.windowInsetsBottomHeight(WindowInsets.navigationBars)) } } } - // Overlaid so content scrolls behind it and the transparent status bar; collapses on scroll. TopAppBar( title = { AppTitle(state, shizukuAvailable) }, actions = { @@ -339,7 +325,6 @@ private fun AppTitle(state: AppUiState, shizukuAvailable: Boolean) { } } -// Shizuku is the privileged backend for the /dev/uhid access the gamepad needs. @Composable private fun PrivilegedAccessStatus(shizukuAvailable: Boolean) { val color = if (shizukuAvailable) Accent else TextDim @@ -415,8 +400,6 @@ private fun ScanningContent(state: AppUiState) { ErrorBox(text = state.error) } -// The "Looking for Joy-Con 2" card and the sync-button illustration: side by side in landscape, -// stacked in portrait. @Composable private fun ScanningGraphics(landscape: Boolean) { if (landscape) { @@ -551,8 +534,6 @@ private fun ConnectedContent( val landscape = LocalConfiguration.current.orientation == Configuration.ORIENTATION_LANDSCAPE if (landscape) { - // Two players per row to use the wide landscape space; detailed players are shrunk so a - // full controller is more likely to fit the short height, compact rows fit as-is. state.activePlayers.chunked(2).forEach { rowPlayers -> Row(horizontalArrangement = Arrangement.spacedBy(Dimens.sectionSpacing)) { rowPlayers.forEach { playerState -> @@ -646,8 +627,6 @@ private fun ConnectedContent( } if (landscape) { - // Two columns: the virtual gamepad and its Shizuku dependency on the left, DSU on the - // right — so the Shizuku card always sits directly under the gamepad it belongs to. Row(horizontalArrangement = Arrangement.spacedBy(Dimens.sectionSpacing)) { Column( Modifier.weight(1f), @@ -683,8 +662,6 @@ private fun ConnectedContent( ErrorBox(text = state.error) if (landscape) { - // Disconnect on the left, Scan on the right; Disconnect keeps its half when a scan is in - // progress and the Scan button drops out. Row(horizontalArrangement = Arrangement.spacedBy(Dimens.sectionSpacing)) { DisconnectAllButton(onDisconnectAll, Modifier.weight(1f)) if (!state.scanning) { diff --git a/build-logic/convention/src/main/kotlin/AndroidLibraryComposeConventionPlugin.kt b/build-logic/convention/src/main/kotlin/AndroidLibraryComposeConventionPlugin.kt index 9537299..e923d2c 100644 --- a/build-logic/convention/src/main/kotlin/AndroidLibraryComposeConventionPlugin.kt +++ b/build-logic/convention/src/main/kotlin/AndroidLibraryComposeConventionPlugin.kt @@ -3,7 +3,6 @@ import org.gradle.api.Plugin import org.gradle.api.Project import org.gradle.kotlin.dsl.configure -/** Adds Compose to an Android library module. */ class AndroidLibraryComposeConventionPlugin : Plugin { override fun apply(target: Project) = with(target) { pluginManager.apply("joycon.android.library") diff --git a/build-logic/convention/src/main/kotlin/AndroidLibraryConventionPlugin.kt b/build-logic/convention/src/main/kotlin/AndroidLibraryConventionPlugin.kt index 09a4042..82e01eb 100644 --- a/build-logic/convention/src/main/kotlin/AndroidLibraryConventionPlugin.kt +++ b/build-logic/convention/src/main/kotlin/AndroidLibraryConventionPlugin.kt @@ -4,11 +4,7 @@ import org.gradle.api.Plugin import org.gradle.api.Project import org.gradle.kotlin.dsl.configure -/** - * Android library module targeting minSdk 24 / Java 11, matching the app. AGP 9 provides - * Kotlin built-in, so the Kotlin plugin is not applied separately (doing so collides on - * the `kotlin` extension). - */ +/** AGP 9 has Kotlin built in; applying the Kotlin plugin as well collides on the `kotlin` extension. */ class AndroidLibraryConventionPlugin : Plugin { override fun apply(target: Project) = with(target) { pluginManager.apply("com.android.library") diff --git a/build-logic/convention/src/main/kotlin/KotlinJvmConventionPlugin.kt b/build-logic/convention/src/main/kotlin/KotlinJvmConventionPlugin.kt index ee542f0..f4dc841 100644 --- a/build-logic/convention/src/main/kotlin/KotlinJvmConventionPlugin.kt +++ b/build-logic/convention/src/main/kotlin/KotlinJvmConventionPlugin.kt @@ -6,7 +6,6 @@ import org.gradle.kotlin.dsl.configure import org.jetbrains.kotlin.gradle.dsl.JvmTarget import org.jetbrains.kotlin.gradle.dsl.KotlinJvmProjectExtension -/** Pure-Kotlin module (domain / model) targeting the JVM at Java 11. */ class KotlinJvmConventionPlugin : Plugin { override fun apply(target: Project) = with(target) { pluginManager.apply("org.jetbrains.kotlin.jvm") diff --git a/build.gradle.kts b/build.gradle.kts index b546c74..020183f 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -1,4 +1,3 @@ -// Top-level build file where you can add configuration options common to all sub-projects/modules. plugins { alias(libs.plugins.android.application) apply false alias(libs.plugins.kotlin.compose) apply false diff --git a/core/buttonmapping/data/src/main/kotlin/com/joegec/joycon2android/buttonmapping/JsonDocumentStore.kt b/core/buttonmapping/data/src/main/kotlin/com/joegec/joycon2android/buttonmapping/JsonDocumentStore.kt index 553dcf7..fc02f28 100644 --- a/core/buttonmapping/data/src/main/kotlin/com/joegec/joycon2android/buttonmapping/JsonDocumentStore.kt +++ b/core/buttonmapping/data/src/main/kotlin/com/joegec/joycon2android/buttonmapping/JsonDocumentStore.kt @@ -7,7 +7,6 @@ import androidx.datastore.preferences.core.stringPreferencesKey import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.map -/** A set of JSON documents in preference storage, one per key, keyed by the document's own id. */ internal class JsonDocumentStore( private val dataStore: DataStore, private val encode: (T) -> String, diff --git a/core/buttonmapping/data/src/main/kotlin/com/joegec/joycon2android/buttonmapping/PerPlayerMigration.kt b/core/buttonmapping/data/src/main/kotlin/com/joegec/joycon2android/buttonmapping/PerPlayerMigration.kt index 5d35e3c..12dcd31 100644 --- a/core/buttonmapping/data/src/main/kotlin/com/joegec/joycon2android/buttonmapping/PerPlayerMigration.kt +++ b/core/buttonmapping/data/src/main/kotlin/com/joegec/joycon2android/buttonmapping/PerPlayerMigration.kt @@ -6,10 +6,7 @@ import androidx.datastore.preferences.core.Preferences import androidx.datastore.preferences.core.booleanPreferencesKey import androidx.datastore.preferences.core.stringPreferencesKey -/** - * Mapping used to be one setting per console, shared by every player. Each player now holds their - * own, so a console-wide value already stored is handed to all of them — which is what it meant. - */ +/** Hands an older build's console-wide value to every player, which is what it meant. */ internal class PerPlayerMigration( private val perPlayerNames: (legacyName: String) -> List?, ) : DataMigration { diff --git a/core/buttonmapping/domain/src/main/kotlin/com/joegec/joycon2android/buttonmapping/ApplyGlobalLayoutUseCase.kt b/core/buttonmapping/domain/src/main/kotlin/com/joegec/joycon2android/buttonmapping/ApplyGlobalLayoutUseCase.kt index 082008c..fc6781d 100644 --- a/core/buttonmapping/domain/src/main/kotlin/com/joegec/joycon2android/buttonmapping/ApplyGlobalLayoutUseCase.kt +++ b/core/buttonmapping/domain/src/main/kotlin/com/joegec/joycon2android/buttonmapping/ApplyGlobalLayoutUseCase.kt @@ -2,12 +2,7 @@ package com.joegec.joycon2android.buttonmapping import kotlinx.coroutines.flow.first -/** - * Sets every player at once: a shipped or saved layout goes to all of them, while a saved set gives - * each player back the bindings it froze. Those bindings stand on their own, so a set still restores - * exactly what it saved even after the layout a player was on has been deleted — the card simply - * reads Custom until an identical layout exists again. - */ +/** A layout goes to every player; a saved set restores each player's own frozen bindings. */ class ApplyGlobalLayoutUseCase( private val globalLayouts: GlobalLayoutRepository, private val applyMappingLayout: ApplyMappingLayoutUseCase, diff --git a/core/buttonmapping/domain/src/main/kotlin/com/joegec/joycon2android/buttonmapping/ApplyMappingLayoutUseCase.kt b/core/buttonmapping/domain/src/main/kotlin/com/joegec/joycon2android/buttonmapping/ApplyMappingLayoutUseCase.kt index 1eeb092..00da5f3 100644 --- a/core/buttonmapping/domain/src/main/kotlin/com/joegec/joycon2android/buttonmapping/ApplyMappingLayoutUseCase.kt +++ b/core/buttonmapping/domain/src/main/kotlin/com/joegec/joycon2android/buttonmapping/ApplyMappingLayoutUseCase.kt @@ -2,7 +2,6 @@ package com.joegec.joycon2android.buttonmapping import kotlinx.coroutines.flow.first -/** Sets one player's body to a layout, taking a copy of everything it says. */ class ApplyMappingLayoutUseCase( private val savedLayouts: SavedLayoutRepository, private val applyPlayerMapping: ApplyPlayerMappingUseCase, diff --git a/core/buttonmapping/domain/src/main/kotlin/com/joegec/joycon2android/buttonmapping/ApplyPlayerMappingUseCase.kt b/core/buttonmapping/domain/src/main/kotlin/com/joegec/joycon2android/buttonmapping/ApplyPlayerMappingUseCase.kt index 945f55c..044af1e 100644 --- a/core/buttonmapping/domain/src/main/kotlin/com/joegec/joycon2android/buttonmapping/ApplyPlayerMappingUseCase.kt +++ b/core/buttonmapping/domain/src/main/kotlin/com/joegec/joycon2android/buttonmapping/ApplyPlayerMappingUseCase.kt @@ -1,9 +1,5 @@ package com.joegec.joycon2android.buttonmapping -/** - * Writes a body's whole mapping at once. Everything a player is given is written out in full, so - * nothing they play with depends on a layout that can later be deleted. - */ class ApplyPlayerMappingUseCase( private val mappingRepository: ControllerMappingRepository, private val sidewaysRemoteRepository: SidewaysRemoteRepository, diff --git a/core/buttonmapping/domain/src/main/kotlin/com/joegec/joycon2android/buttonmapping/Console.kt b/core/buttonmapping/domain/src/main/kotlin/com/joegec/joycon2android/buttonmapping/Console.kt index b275184..7133c02 100644 --- a/core/buttonmapping/domain/src/main/kotlin/com/joegec/joycon2android/buttonmapping/Console.kt +++ b/core/buttonmapping/domain/src/main/kotlin/com/joegec/joycon2android/buttonmapping/Console.kt @@ -1,4 +1,4 @@ package com.joegec.joycon2android.buttonmapping -/** A distinct controller shape an emulator can present to the user, independent of which emulator. */ +/** A controller shape an emulator presents, whichever emulator it is. */ enum class Console { GAMECUBE, WIIMOTE_NUNCHUK, SWITCH_PRO } diff --git a/core/buttonmapping/domain/src/main/kotlin/com/joegec/joycon2android/buttonmapping/ControllerMappingRepository.kt b/core/buttonmapping/domain/src/main/kotlin/com/joegec/joycon2android/buttonmapping/ControllerMappingRepository.kt index c7501cd..554306d 100644 --- a/core/buttonmapping/domain/src/main/kotlin/com/joegec/joycon2android/buttonmapping/ControllerMappingRepository.kt +++ b/core/buttonmapping/domain/src/main/kotlin/com/joegec/joycon2android/buttonmapping/ControllerMappingRepository.kt @@ -2,12 +2,7 @@ package com.joegec.joycon2android.buttonmapping import kotlinx.coroutines.flow.Flow -/** - * Stores what each player's body is bound to, keyed by console shape and body. Values are opaque - * strings (the [MappingSource] ids driving one target, joined by [sourceIdOf], or a legacy - * whole-stick [StickSource] name) — this layer knows nothing about what a key or value means, only - * how to persist it; the layouts and use cases give them meaning. - */ +/** Values are opaque strings ([sourceIdsOf]); the layouts and use cases give them meaning. */ interface ControllerMappingRepository { fun observe(console: Console, body: PlayerBody): Flow> suspend fun set(console: Console, body: PlayerBody, targetKey: String, sourceId: String) diff --git a/core/buttonmapping/domain/src/main/kotlin/com/joegec/joycon2android/buttonmapping/DeleteCustomLayoutUseCase.kt b/core/buttonmapping/domain/src/main/kotlin/com/joegec/joycon2android/buttonmapping/DeleteCustomLayoutUseCase.kt index e9758ca..21cbe26 100644 --- a/core/buttonmapping/domain/src/main/kotlin/com/joegec/joycon2android/buttonmapping/DeleteCustomLayoutUseCase.kt +++ b/core/buttonmapping/domain/src/main/kotlin/com/joegec/joycon2android/buttonmapping/DeleteCustomLayoutUseCase.kt @@ -1,6 +1,5 @@ package com.joegec.joycon2android.buttonmapping -/** Only the name goes: every player keeps the bindings, which read as Custom until it is saved back. */ class DeleteCustomLayoutUseCase(private val savedLayouts: SavedLayoutRepository) { suspend operator fun invoke(layoutId: String) = savedLayouts.delete(layoutId) } diff --git a/core/buttonmapping/domain/src/main/kotlin/com/joegec/joycon2android/buttonmapping/DeleteGlobalLayoutUseCase.kt b/core/buttonmapping/domain/src/main/kotlin/com/joegec/joycon2android/buttonmapping/DeleteGlobalLayoutUseCase.kt index 901ad9b..e8aad87 100644 --- a/core/buttonmapping/domain/src/main/kotlin/com/joegec/joycon2android/buttonmapping/DeleteGlobalLayoutUseCase.kt +++ b/core/buttonmapping/domain/src/main/kotlin/com/joegec/joycon2android/buttonmapping/DeleteGlobalLayoutUseCase.kt @@ -1,6 +1,5 @@ package com.joegec.joycon2android.buttonmapping -/** Deleting a saved set leaves every player exactly where they are; only the name goes. */ class DeleteGlobalLayoutUseCase(private val globalLayouts: GlobalLayoutRepository) { suspend operator fun invoke(layoutId: String) = globalLayouts.delete(layoutId) } diff --git a/core/buttonmapping/domain/src/main/kotlin/com/joegec/joycon2android/buttonmapping/EmittedInput.kt b/core/buttonmapping/domain/src/main/kotlin/com/joegec/joycon2android/buttonmapping/EmittedInput.kt index 8b53cff..7e4c384 100644 --- a/core/buttonmapping/domain/src/main/kotlin/com/joegec/joycon2android/buttonmapping/EmittedInput.kt +++ b/core/buttonmapping/domain/src/main/kotlin/com/joegec/joycon2android/buttonmapping/EmittedInput.kt @@ -3,10 +3,7 @@ package com.joegec.joycon2android.buttonmapping import com.joegec.joycon2android.model.JoyconButton import com.joegec.joycon2android.model.SidewaysMapper -/** - * The button the relay actually reports for this physical button. A lone Joy-Con is rotated - * sideways by [SidewaysMapper] before its input leaves the app, so its d-pad arrives as face buttons. - */ +/** What the relay reports for this button once [SidewaysMapper] has rotated a lone Joy-Con. */ fun JoyconButton.emittedFor(side: JoyconSide): JoyconButton? { val emittedId = when (side) { JoyconSide.DUAL -> id @@ -20,11 +17,7 @@ fun JoyconButton.emittedFor(side: JoyconSide): JoyconButton? { fun MappingSource.Stick.emittedStick(side: JoyconSide): StickSource = if (side == JoyconSide.DUAL) stick else StickSource.LEFT_STICK -/** - * The stick a target can read as a whole — keeping its analog range — when all four of its - * directions follow the same emitted stick the natural way round; null when they're rearranged, - * partly unbound, doubled up or mixed with buttons, which leaves each direction to be bound on its own. - */ +/** Non-null only when all four directions follow one stick the natural way round, so it stays analog. */ fun Map>.wholeEmittedStick(side: JoyconSide): StickSource? { val sticks = StickDirection.entries.map { direction -> val source = this[direction]?.singleOrNull() as? MappingSource.Stick ?: return null diff --git a/core/buttonmapping/domain/src/main/kotlin/com/joegec/joycon2android/buttonmapping/GlobalLayout.kt b/core/buttonmapping/domain/src/main/kotlin/com/joegec/joycon2android/buttonmapping/GlobalLayout.kt index c95b359..f5901a7 100644 --- a/core/buttonmapping/domain/src/main/kotlin/com/joegec/joycon2android/buttonmapping/GlobalLayout.kt +++ b/core/buttonmapping/domain/src/main/kotlin/com/joegec/joycon2android/buttonmapping/GlobalLayout.kt @@ -1,10 +1,6 @@ package com.joegec.joycon2android.buttonmapping -/** - * Every player's mapping saved together under one name. A set is bound to the bodies it was saved - * from — a mapping written for a lone Joy-Con says nothing about a pair — so it can only be - * restored onto the same players holding the same bodies. - */ +/** Restores only onto the same players holding the same bodies it was saved from. */ data class GlobalLayout( val id: String, val name: String, diff --git a/core/buttonmapping/domain/src/main/kotlin/com/joegec/joycon2android/buttonmapping/GlobalLayoutRepository.kt b/core/buttonmapping/domain/src/main/kotlin/com/joegec/joycon2android/buttonmapping/GlobalLayoutRepository.kt index 7d26519..5577d73 100644 --- a/core/buttonmapping/domain/src/main/kotlin/com/joegec/joycon2android/buttonmapping/GlobalLayoutRepository.kt +++ b/core/buttonmapping/domain/src/main/kotlin/com/joegec/joycon2android/buttonmapping/GlobalLayoutRepository.kt @@ -2,7 +2,6 @@ package com.joegec.joycon2android.buttonmapping import kotlinx.coroutines.flow.Flow -/** Stores the whole-session layouts the user saved, across every console. */ interface GlobalLayoutRepository { fun observe(): Flow> suspend fun save(layout: GlobalLayout) diff --git a/core/buttonmapping/domain/src/main/kotlin/com/joegec/joycon2android/buttonmapping/GlobalMapping.kt b/core/buttonmapping/domain/src/main/kotlin/com/joegec/joycon2android/buttonmapping/GlobalMapping.kt index c0ccb66..999fa49 100644 --- a/core/buttonmapping/domain/src/main/kotlin/com/joegec/joycon2android/buttonmapping/GlobalMapping.kt +++ b/core/buttonmapping/domain/src/main/kotlin/com/joegec/joycon2android/buttonmapping/GlobalMapping.kt @@ -2,13 +2,7 @@ package com.joegec.joycon2android.buttonmapping import com.joegec.joycon2android.buttonmapping.preset.MappingPreset -/** - * The session read as one setting. It agrees only while every player does — on a saved set whose - * bindings they all still carry, on a single layout every one of them reads as, or on one family of - * layouts they are each on their own body's grip of. Change one player and it agrees on nothing. - * - * Which of those it is, rather than what to call it: the naming is presentation's. - */ +/** Agrees only while every player does: on a saved set, one layout, or one [LayoutFamily]. */ data class GlobalMapping( val players: List, val savedLayouts: List, @@ -21,7 +15,6 @@ data class GlobalMapping( val sharedLayout: MappingLayout? get() = players.takeIf { it.isNotEmpty() }?.map { it.layout }?.distinct()?.singleOrNull() - /** A table rarely holds the same thing, so one family across two grips still agrees. */ val sharedFamily: LayoutFamily? get() = players.takeIf { it.isNotEmpty() } ?.map { (it.layout as? MappingPreset)?.family } diff --git a/core/buttonmapping/domain/src/main/kotlin/com/joegec/joycon2android/buttonmapping/JoyconSide.kt b/core/buttonmapping/domain/src/main/kotlin/com/joegec/joycon2android/buttonmapping/JoyconSide.kt index b48ff8f..84eda56 100644 --- a/core/buttonmapping/domain/src/main/kotlin/com/joegec/joycon2android/buttonmapping/JoyconSide.kt +++ b/core/buttonmapping/domain/src/main/kotlin/com/joegec/joycon2android/buttonmapping/JoyconSide.kt @@ -1,4 +1,4 @@ package com.joegec.joycon2android.buttonmapping -/** Which physical body a mapping applies to: a lone Joy-Con of one side, or a full controller. */ +/** A lone Joy-Con of one side, or a full controller (pair or Pro). */ enum class JoyconSide { LEFT, RIGHT, DUAL } diff --git a/core/buttonmapping/domain/src/main/kotlin/com/joegec/joycon2android/buttonmapping/LayoutFamily.kt b/core/buttonmapping/domain/src/main/kotlin/com/joegec/joycon2android/buttonmapping/LayoutFamily.kt index 1836b71..3715f82 100644 --- a/core/buttonmapping/domain/src/main/kotlin/com/joegec/joycon2android/buttonmapping/LayoutFamily.kt +++ b/core/buttonmapping/domain/src/main/kotlin/com/joegec/joycon2android/buttonmapping/LayoutFamily.kt @@ -1,7 +1,4 @@ package com.joegec.joycon2android.buttonmapping -/** - * Layouts of one game in different grips. Setting one of a family sets every player to whichever of - * them their own body can be held in, since a table is rarely holding the same thing. - */ +/** One game's layouts in different grips; setting one gives each player the grip their body fits. */ enum class LayoutFamily { MARIO_KART } diff --git a/core/buttonmapping/domain/src/main/kotlin/com/joegec/joycon2android/buttonmapping/LegacyStickRoutes.kt b/core/buttonmapping/domain/src/main/kotlin/com/joegec/joycon2android/buttonmapping/LegacyStickRoutes.kt index a326c69..76d1b33 100644 --- a/core/buttonmapping/domain/src/main/kotlin/com/joegec/joycon2android/buttonmapping/LegacyStickRoutes.kt +++ b/core/buttonmapping/domain/src/main/kotlin/com/joegec/joycon2android/buttonmapping/LegacyStickRoutes.kt @@ -1,10 +1,6 @@ package com.joegec.joycon2android.buttonmapping -/** - * Older versions stored a target stick as one entry naming a whole physical stick - * (`MainStick = LEFT_STICK`). Expands those into the four direction entries used now; a direction - * the user has since set on its own keeps its explicit choice. - */ +/** Expands an older whole-stick entry (`MainStick = LEFT_STICK`) into four; an explicit direction wins. */ internal fun Map.withLegacyStickRoutesExpanded(): Map { val legacy = filterValues { value -> StickSource.entries.any { it.name == value } } val expanded = legacy.flatMap { (target, stickName) -> diff --git a/core/buttonmapping/domain/src/main/kotlin/com/joegec/joycon2android/buttonmapping/MappingConversions.kt b/core/buttonmapping/domain/src/main/kotlin/com/joegec/joycon2android/buttonmapping/MappingConversions.kt index 0135da7..cfa25f6 100644 --- a/core/buttonmapping/domain/src/main/kotlin/com/joegec/joycon2android/buttonmapping/MappingConversions.kt +++ b/core/buttonmapping/domain/src/main/kotlin/com/joegec/joycon2android/buttonmapping/MappingConversions.kt @@ -5,11 +5,7 @@ fun Enum<*>.directionKey(direction: StickDirection): String = stickDirectionKey( internal fun stickDirectionKey(targetName: String, direction: StickDirection) = "${targetName}_${direction.name}" -/** - * Recovers a typed target -> sources map from the repository's opaque string map, silently dropping - * entries whose key isn't a [T] and sources that are no longer known — a stale or "None"-selected - * entry simply produces no binding rather than a crash. - */ +/** Drops unknown keys and stale sources, so a bad entry binds nothing rather than crashing. */ inline fun > Map.toSourceMap(): Map> = mapNotNull { (key, value) -> val target = enumValues().firstOrNull { it.name == key } ?: return@mapNotNull null diff --git a/core/buttonmapping/domain/src/main/kotlin/com/joegec/joycon2android/buttonmapping/MappingLayout.kt b/core/buttonmapping/domain/src/main/kotlin/com/joegec/joycon2android/buttonmapping/MappingLayout.kt index 3f39c37..5b83e6b 100644 --- a/core/buttonmapping/domain/src/main/kotlin/com/joegec/joycon2android/buttonmapping/MappingLayout.kt +++ b/core/buttonmapping/domain/src/main/kotlin/com/joegec/joycon2android/buttonmapping/MappingLayout.kt @@ -1,22 +1,10 @@ package com.joegec.joycon2android.buttonmapping -/** - * A named set of bindings a body can be set to: one the app ships - * ([com.joegec.joycon2android.buttonmapping.preset.MappingPreset]) or one the user saved - * ([SavedLayout]). Entries are in the repository's opaque string form, so a layout and a stored - * override are the same kind of value. - * - * What a shipped one is *called* is not here — that is copy, and it lives in presentation's - * resources, keyed by the layout itself. - */ +/** Shipped or saved; entries are in the repository's string form. docs/architecture.md#button-mapping */ interface MappingLayout { val id: String - /** - * Whether this layout stands a lone Joy-Con in for a Wii Remote held sideways, the way a game - * written for that grip expects one. Its motion turns onto the sideways remote's frame and its - * d-pad turns with it; a pair, held like a remote already, is untouched. - */ + /** docs/dsu-motion.md#playing-as-a-sideways-wii-remote */ val sidewaysRemote: Boolean get() = false fun entries(side: JoyconSide): Map diff --git a/core/buttonmapping/domain/src/main/kotlin/com/joegec/joycon2android/buttonmapping/MappingLayouts.kt b/core/buttonmapping/domain/src/main/kotlin/com/joegec/joycon2android/buttonmapping/MappingLayouts.kt index fd0d346..ea79107 100644 --- a/core/buttonmapping/domain/src/main/kotlin/com/joegec/joycon2android/buttonmapping/MappingLayouts.kt +++ b/core/buttonmapping/domain/src/main/kotlin/com/joegec/joycon2android/buttonmapping/MappingLayouts.kt @@ -3,26 +3,17 @@ package com.joegec.joycon2android.buttonmapping import com.joegec.joycon2android.buttonmapping.preset.MappingPreset import com.joegec.joycon2android.buttonmapping.preset.MappingPresets -/** The layouts one body can choose between: the console's shipped ones, then the user's own. */ object MappingLayouts { fun forBody(console: Console, side: JoyconSide, saved: List): List = MappingPresets.forConsole(console).filter { side in it.sides } + saved.filter { it.console == console && it.side == side } - /** - * What applying [layout] leaves behind: its own bindings over the console's default ones, so a - * target no layout mentions — one a later build adds, say — still arrives bound to something. - */ + /** Layered over the console default, so a target the layout omits is still bound. */ fun entriesOf(console: Console, side: JoyconSide, layout: MappingLayout): Map = MappingPresets.default(console).entries(side) + layout.entries(side) - /** - * A layout is recognised by what it says, never by a choice remembered against it: a body reads - * as a layout whenever its bindings *are* that layout's, whoever set them. So deleting a layout - * takes away its name and nothing else, and those same bindings read as it again the day an - * identical layout is saved back. Null is the editor's "Custom". - */ + /** Matched by bindings, never a stored choice: docs/architecture.md#button-mapping. Null is "Custom". */ fun matching( console: Console, side: JoyconSide, @@ -33,10 +24,7 @@ object MappingLayouts { it.sidewaysRemote == sidewaysRemote && entriesOf(console, side, it) == entries } - /** - * A body that cannot be held in the grip asked for takes its family's other grip instead, and - * failing that the console's default — which is also where a deleted layout lands. - */ + /** Falls back to the family's other grip, then the console default (as a deleted layout does). */ fun byId(console: Console, side: JoyconSide, id: String?, saved: List): MappingLayout = forBody(console, side, saved).firstOrNull { it.id == id } ?: familyMember(console, side, id) @@ -48,10 +36,9 @@ object MappingLayouts { return presets.firstOrNull { it.family == family && side in it.sides } } - /** Ids the user chose, so a saved layout can never collide with a shipped one. */ + /** Prefixed so it can never collide with a shipped id. */ fun newId(): String = "saved-${java.util.UUID.randomUUID()}" - /** The first " N" nothing already answers to, so a suggested name is never a duplicate. */ fun nextName(prefix: String, taken: Collection): String = generateSequence(1) { it + 1 }.map { "$prefix $it" }.first { it !in taken } } diff --git a/core/buttonmapping/domain/src/main/kotlin/com/joegec/joycon2android/buttonmapping/MappingSourceIds.kt b/core/buttonmapping/domain/src/main/kotlin/com/joegec/joycon2android/buttonmapping/MappingSourceIds.kt index 1da54f6..d7f7b68 100644 --- a/core/buttonmapping/domain/src/main/kotlin/com/joegec/joycon2android/buttonmapping/MappingSourceIds.kt +++ b/core/buttonmapping/domain/src/main/kotlin/com/joegec/joycon2android/buttonmapping/MappingSourceIds.kt @@ -2,10 +2,7 @@ package com.joegec.joycon2android.buttonmapping private const val SOURCE_SEPARATOR = "|" -/** - * Several sources can drive one target — any of them fires it — so a stored value holds their ids - * joined together. A value written by an older version is a single id, which reads back as one source. - */ +/** Any of several sources fires a target, so a value joins their ids; an older single id reads as one. */ fun sourceIdsOf(value: String): List = value.split(SOURCE_SEPARATOR).filter { it.isNotEmpty() } fun sourceIdOf(ids: List): String = ids.joinToString(SOURCE_SEPARATOR) diff --git a/core/buttonmapping/domain/src/main/kotlin/com/joegec/joycon2android/buttonmapping/ObserveControllerMappingUseCase.kt b/core/buttonmapping/domain/src/main/kotlin/com/joegec/joycon2android/buttonmapping/ObserveControllerMappingUseCase.kt index 735a83f..e49a42b 100644 --- a/core/buttonmapping/domain/src/main/kotlin/com/joegec/joycon2android/buttonmapping/ObserveControllerMappingUseCase.kt +++ b/core/buttonmapping/domain/src/main/kotlin/com/joegec/joycon2android/buttonmapping/ObserveControllerMappingUseCase.kt @@ -4,10 +4,7 @@ import com.joegec.joycon2android.buttonmapping.preset.MappingPresets import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.map -/** - * What a player's body is bound to: whatever has been set on it, over the console's default layout - * so that every target is answered even when nothing has ever set that one. - */ +/** Layered over the console's default layout, so every target is answered. */ class ObserveControllerMappingUseCase(private val repository: ControllerMappingRepository) { operator fun invoke(console: Console, body: PlayerBody): Flow> = repository.observe(console, body).map { stored -> diff --git a/core/buttonmapping/domain/src/main/kotlin/com/joegec/joycon2android/buttonmapping/ObserveGlobalMappingUseCase.kt b/core/buttonmapping/domain/src/main/kotlin/com/joegec/joycon2android/buttonmapping/ObserveGlobalMappingUseCase.kt index b78b63f..0266ca3 100644 --- a/core/buttonmapping/domain/src/main/kotlin/com/joegec/joycon2android/buttonmapping/ObserveGlobalMappingUseCase.kt +++ b/core/buttonmapping/domain/src/main/kotlin/com/joegec/joycon2android/buttonmapping/ObserveGlobalMappingUseCase.kt @@ -5,7 +5,6 @@ import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.flow.map -/** Every player's mapping alongside the saved sets they could be restored from. */ class ObserveGlobalMappingUseCase( private val observePlayerMapping: ObservePlayerMappingUseCase, private val globalLayouts: GlobalLayoutRepository, diff --git a/core/buttonmapping/domain/src/main/kotlin/com/joegec/joycon2android/buttonmapping/ObservePlayerMappingUseCase.kt b/core/buttonmapping/domain/src/main/kotlin/com/joegec/joycon2android/buttonmapping/ObservePlayerMappingUseCase.kt index 32d4005..121b0a4 100644 --- a/core/buttonmapping/domain/src/main/kotlin/com/joegec/joycon2android/buttonmapping/ObservePlayerMappingUseCase.kt +++ b/core/buttonmapping/domain/src/main/kotlin/com/joegec/joycon2android/buttonmapping/ObservePlayerMappingUseCase.kt @@ -3,7 +3,6 @@ package com.joegec.joycon2android.buttonmapping import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.combine -/** Everything the editor shows for one player: their bindings, their switch, and what those name. */ class ObservePlayerMappingUseCase( private val observeControllerMapping: ObserveControllerMappingUseCase, private val observeSidewaysRemote: ObserveSidewaysRemoteUseCase, diff --git a/core/buttonmapping/domain/src/main/kotlin/com/joegec/joycon2android/buttonmapping/ObserveSavedLayoutsUseCase.kt b/core/buttonmapping/domain/src/main/kotlin/com/joegec/joycon2android/buttonmapping/ObserveSavedLayoutsUseCase.kt index ebece06..58b8454 100644 --- a/core/buttonmapping/domain/src/main/kotlin/com/joegec/joycon2android/buttonmapping/ObserveSavedLayoutsUseCase.kt +++ b/core/buttonmapping/domain/src/main/kotlin/com/joegec/joycon2android/buttonmapping/ObserveSavedLayoutsUseCase.kt @@ -3,7 +3,6 @@ package com.joegec.joycon2android.buttonmapping import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.map -/** Every layout the user has saved for a console, whichever body each was saved from. */ class ObserveSavedLayoutsUseCase(private val savedLayouts: SavedLayoutRepository) { operator fun invoke(console: Console): Flow> = savedLayouts.observe().map { layouts -> layouts.filter { it.console == console } } diff --git a/core/buttonmapping/domain/src/main/kotlin/com/joegec/joycon2android/buttonmapping/ObserveSidewaysRemoteUseCase.kt b/core/buttonmapping/domain/src/main/kotlin/com/joegec/joycon2android/buttonmapping/ObserveSidewaysRemoteUseCase.kt index 3a4279a..6700d6a 100644 --- a/core/buttonmapping/domain/src/main/kotlin/com/joegec/joycon2android/buttonmapping/ObserveSidewaysRemoteUseCase.kt +++ b/core/buttonmapping/domain/src/main/kotlin/com/joegec/joycon2android/buttonmapping/ObserveSidewaysRemoteUseCase.kt @@ -4,7 +4,7 @@ import com.joegec.joycon2android.buttonmapping.preset.MappingPresets import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.map -/** Whether a body plays as a sideways Wii Remote, falling back to the console's default layout. */ +/** Falls back to the console's default layout. */ class ObserveSidewaysRemoteUseCase(private val repository: SidewaysRemoteRepository) { operator fun invoke(console: Console, body: PlayerBody): Flow = repository.observe(console, body).map { it ?: MappingPresets.default(console).sidewaysRemote } diff --git a/core/buttonmapping/domain/src/main/kotlin/com/joegec/joycon2android/buttonmapping/PlayerBody.kt b/core/buttonmapping/domain/src/main/kotlin/com/joegec/joycon2android/buttonmapping/PlayerBody.kt index 3d54069..b2e5c10 100644 --- a/core/buttonmapping/domain/src/main/kotlin/com/joegec/joycon2android/buttonmapping/PlayerBody.kt +++ b/core/buttonmapping/domain/src/main/kotlin/com/joegec/joycon2android/buttonmapping/PlayerBody.kt @@ -3,7 +3,7 @@ package com.joegec.joycon2android.buttonmapping import com.joegec.joycon2android.model.PlayerNumber import com.joegec.joycon2android.model.PlayerState -/** Which player, and which body they are holding — the pair every mapping is stored against. */ +/** What every mapping is stored against. */ data class PlayerBody(val player: PlayerNumber, val side: JoyconSide) /** Null while the player holds nothing. A Pro Controller has a pair's button set, so it maps as one. */ diff --git a/core/buttonmapping/domain/src/main/kotlin/com/joegec/joycon2android/buttonmapping/PlayerLayoutSnapshot.kt b/core/buttonmapping/domain/src/main/kotlin/com/joegec/joycon2android/buttonmapping/PlayerLayoutSnapshot.kt index e612016..ab10938 100644 --- a/core/buttonmapping/domain/src/main/kotlin/com/joegec/joycon2android/buttonmapping/PlayerLayoutSnapshot.kt +++ b/core/buttonmapping/domain/src/main/kotlin/com/joegec/joycon2android/buttonmapping/PlayerLayoutSnapshot.kt @@ -1,6 +1,5 @@ package com.joegec.joycon2android.buttonmapping -/** One player's whole mapping, frozen — every binding, not a reference to a layout that can go. */ data class PlayerLayoutSnapshot( val body: PlayerBody, val entries: Map, diff --git a/core/buttonmapping/domain/src/main/kotlin/com/joegec/joycon2android/buttonmapping/PlayerMapping.kt b/core/buttonmapping/domain/src/main/kotlin/com/joegec/joycon2android/buttonmapping/PlayerMapping.kt index 3e213cb..9b6c778 100644 --- a/core/buttonmapping/domain/src/main/kotlin/com/joegec/joycon2android/buttonmapping/PlayerMapping.kt +++ b/core/buttonmapping/domain/src/main/kotlin/com/joegec/joycon2android/buttonmapping/PlayerMapping.kt @@ -1,11 +1,10 @@ package com.joegec.joycon2android.buttonmapping -/** One player's mapping as the editor sees it: what their body is bound to, and what that amounts to. */ data class PlayerMapping( val body: PlayerBody, val entries: Map, val sidewaysRemote: Boolean, - /** The layout these bindings *are*; null once they are no layout's, which the editor calls Custom. */ + /** Null is "Custom". */ val layout: MappingLayout?, ) { fun snapshot() = PlayerLayoutSnapshot(body, entries, sidewaysRemote) diff --git a/core/buttonmapping/domain/src/main/kotlin/com/joegec/joycon2android/buttonmapping/SaveCustomLayoutUseCase.kt b/core/buttonmapping/domain/src/main/kotlin/com/joegec/joycon2android/buttonmapping/SaveCustomLayoutUseCase.kt index 0124919..b4c3391 100644 --- a/core/buttonmapping/domain/src/main/kotlin/com/joegec/joycon2android/buttonmapping/SaveCustomLayoutUseCase.kt +++ b/core/buttonmapping/domain/src/main/kotlin/com/joegec/joycon2android/buttonmapping/SaveCustomLayoutUseCase.kt @@ -2,10 +2,7 @@ package com.joegec.joycon2android.buttonmapping import kotlinx.coroutines.flow.first -/** - * Names what a player has built so any player on that body can pick it again. Nothing is applied: - * the bindings already *are* the layout, so the card takes the new name as soon as it exists. - */ +/** Applies nothing: the bindings already match, so the card picks up the name. */ class SaveCustomLayoutUseCase( private val savedLayouts: SavedLayoutRepository, private val observePlayerMapping: ObservePlayerMappingUseCase, diff --git a/core/buttonmapping/domain/src/main/kotlin/com/joegec/joycon2android/buttonmapping/SaveGlobalLayoutUseCase.kt b/core/buttonmapping/domain/src/main/kotlin/com/joegec/joycon2android/buttonmapping/SaveGlobalLayoutUseCase.kt index 22da031..0819de6 100644 --- a/core/buttonmapping/domain/src/main/kotlin/com/joegec/joycon2android/buttonmapping/SaveGlobalLayoutUseCase.kt +++ b/core/buttonmapping/domain/src/main/kotlin/com/joegec/joycon2android/buttonmapping/SaveGlobalLayoutUseCase.kt @@ -2,7 +2,6 @@ package com.joegec.joycon2android.buttonmapping import kotlinx.coroutines.flow.first -/** Names the whole session, bodies and all, so it can be restored once those players return. */ class SaveGlobalLayoutUseCase( private val globalLayouts: GlobalLayoutRepository, private val observePlayerMapping: ObservePlayerMappingUseCase, diff --git a/core/buttonmapping/domain/src/main/kotlin/com/joegec/joycon2android/buttonmapping/SavedLayout.kt b/core/buttonmapping/domain/src/main/kotlin/com/joegec/joycon2android/buttonmapping/SavedLayout.kt index 6a1305a..aec39a5 100644 --- a/core/buttonmapping/domain/src/main/kotlin/com/joegec/joycon2android/buttonmapping/SavedLayout.kt +++ b/core/buttonmapping/domain/src/main/kotlin/com/joegec/joycon2android/buttonmapping/SavedLayout.kt @@ -1,6 +1,5 @@ package com.joegec.joycon2android.buttonmapping -/** A layout the user saved from one player's body, offered back to any player holding that body. */ data class SavedLayout( override val id: String, val name: String, @@ -9,6 +8,6 @@ data class SavedLayout( val bindings: Map, override val sidewaysRemote: Boolean = false, ) : MappingLayout { - /** Saved from one body and only ever offered back to it, so [side] is already the side asked for. */ + /** Only ever offered back to the body it was saved from, so [side] is always that one. */ override fun entries(side: JoyconSide) = bindings } diff --git a/core/buttonmapping/domain/src/main/kotlin/com/joegec/joycon2android/buttonmapping/SavedLayoutRepository.kt b/core/buttonmapping/domain/src/main/kotlin/com/joegec/joycon2android/buttonmapping/SavedLayoutRepository.kt index 5eeccd5..24e25ed 100644 --- a/core/buttonmapping/domain/src/main/kotlin/com/joegec/joycon2android/buttonmapping/SavedLayoutRepository.kt +++ b/core/buttonmapping/domain/src/main/kotlin/com/joegec/joycon2android/buttonmapping/SavedLayoutRepository.kt @@ -2,7 +2,6 @@ package com.joegec.joycon2android.buttonmapping import kotlinx.coroutines.flow.Flow -/** Stores the layouts the user saved for a single body, across every console. */ interface SavedLayoutRepository { fun observe(): Flow> suspend fun save(layout: SavedLayout) diff --git a/core/buttonmapping/domain/src/main/kotlin/com/joegec/joycon2android/buttonmapping/SetControllerMappingUseCase.kt b/core/buttonmapping/domain/src/main/kotlin/com/joegec/joycon2android/buttonmapping/SetControllerMappingUseCase.kt index 6a4a5b3..ad98490 100644 --- a/core/buttonmapping/domain/src/main/kotlin/com/joegec/joycon2android/buttonmapping/SetControllerMappingUseCase.kt +++ b/core/buttonmapping/domain/src/main/kotlin/com/joegec/joycon2android/buttonmapping/SetControllerMappingUseCase.kt @@ -1,6 +1,5 @@ package com.joegec.joycon2android.buttonmapping -/** Records the user's choice of physical source for one target button or stick. */ class SetControllerMappingUseCase(private val repository: ControllerMappingRepository) { suspend operator fun invoke(console: Console, body: PlayerBody, targetKey: String, sourceId: String) = repository.set(console, body, targetKey, sourceId) diff --git a/core/buttonmapping/domain/src/main/kotlin/com/joegec/joycon2android/buttonmapping/SetSidewaysRemoteUseCase.kt b/core/buttonmapping/domain/src/main/kotlin/com/joegec/joycon2android/buttonmapping/SetSidewaysRemoteUseCase.kt index 889b4d2..bd3ad1a 100644 --- a/core/buttonmapping/domain/src/main/kotlin/com/joegec/joycon2android/buttonmapping/SetSidewaysRemoteUseCase.kt +++ b/core/buttonmapping/domain/src/main/kotlin/com/joegec/joycon2android/buttonmapping/SetSidewaysRemoteUseCase.kt @@ -1,6 +1,6 @@ package com.joegec.joycon2android.buttonmapping -/** Records the user's own answer, which from then on outranks the layout's. */ +/** Overrides the layout's answer until a layout is next applied. */ class SetSidewaysRemoteUseCase(private val repository: SidewaysRemoteRepository) { suspend operator fun invoke(console: Console, body: PlayerBody, enabled: Boolean) = repository.set(console, body, enabled) diff --git a/core/buttonmapping/domain/src/main/kotlin/com/joegec/joycon2android/buttonmapping/SidewaysRemoteRepository.kt b/core/buttonmapping/domain/src/main/kotlin/com/joegec/joycon2android/buttonmapping/SidewaysRemoteRepository.kt index f033e24..fc69cc5 100644 --- a/core/buttonmapping/domain/src/main/kotlin/com/joegec/joycon2android/buttonmapping/SidewaysRemoteRepository.kt +++ b/core/buttonmapping/domain/src/main/kotlin/com/joegec/joycon2android/buttonmapping/SidewaysRemoteRepository.kt @@ -2,7 +2,7 @@ package com.joegec.joycon2android.buttonmapping import kotlinx.coroutines.flow.Flow -/** The body's answer to whether it plays as a sideways Wii Remote; null until anything has set it. */ +/** Null until set, meaning the layout decides. */ interface SidewaysRemoteRepository { fun observe(console: Console, body: PlayerBody): Flow suspend fun set(console: Console, body: PlayerBody, enabled: Boolean) diff --git a/core/buttonmapping/domain/src/main/kotlin/com/joegec/joycon2android/buttonmapping/preset/JoyconWiiMapping.kt b/core/buttonmapping/domain/src/main/kotlin/com/joegec/joycon2android/buttonmapping/preset/JoyconWiiMapping.kt index 1e780c3..adf8462 100644 --- a/core/buttonmapping/domain/src/main/kotlin/com/joegec/joycon2android/buttonmapping/preset/JoyconWiiMapping.kt +++ b/core/buttonmapping/domain/src/main/kotlin/com/joegec/joycon2android/buttonmapping/preset/JoyconWiiMapping.kt @@ -14,11 +14,7 @@ import com.joegec.joycon2android.model.JoyconButton.Up import com.joegec.joycon2android.model.JoyconButton.ZL import com.joegec.joycon2android.model.JoyconButton.ZR -/** - * The Wii layout moved onto the buttons a Joy-Con keeps under the same fingers: the remote's B is - * the Joy-Con's own B, and 1 and 2 are the shoulders rather than face buttons a thumb has to leave - * the stick for. - */ +/** The Wii layout with B on the Joy-Con's B, and 1 and 2 on the shoulders so the thumb stays on the stick. */ object JoyconWiiMapping : MappingPreset { override val id = "JOYCON" override val console = Console.WIIMOTE_NUNCHUK diff --git a/core/buttonmapping/domain/src/main/kotlin/com/joegec/joycon2android/buttonmapping/preset/MappingPreset.kt b/core/buttonmapping/domain/src/main/kotlin/com/joegec/joycon2android/buttonmapping/preset/MappingPreset.kt index 08df49a..c7bfcc4 100644 --- a/core/buttonmapping/domain/src/main/kotlin/com/joegec/joycon2android/buttonmapping/preset/MappingPreset.kt +++ b/core/buttonmapping/domain/src/main/kotlin/com/joegec/joycon2android/buttonmapping/preset/MappingPreset.kt @@ -5,11 +5,9 @@ import com.joegec.joycon2android.buttonmapping.JoyconSide import com.joegec.joycon2android.buttonmapping.LayoutFamily import com.joegec.joycon2android.buttonmapping.MappingLayout -/** A layout the app ships: what each body maps to before the user overrides anything. */ sealed interface MappingPreset : MappingLayout { val console: Console - /** The bodies it is offered to — a grip that only one of them can be held in says so. */ val sides: Set get() = JoyconSide.entries.toSet() /** The same game in another grip; see [LayoutFamily]. */ diff --git a/core/buttonmapping/domain/src/main/kotlin/com/joegec/joycon2android/buttonmapping/preset/MappingPresets.kt b/core/buttonmapping/domain/src/main/kotlin/com/joegec/joycon2android/buttonmapping/preset/MappingPresets.kt index 879404e..1a0b8b2 100644 --- a/core/buttonmapping/domain/src/main/kotlin/com/joegec/joycon2android/buttonmapping/preset/MappingPresets.kt +++ b/core/buttonmapping/domain/src/main/kotlin/com/joegec/joycon2android/buttonmapping/preset/MappingPresets.kt @@ -2,7 +2,6 @@ package com.joegec.joycon2android.buttonmapping.preset import com.joegec.joycon2android.buttonmapping.Console -/** Every layout the app ships, and which one a console falls back to. */ object MappingPresets { private val all = listOf( diff --git a/core/buttonmapping/domain/src/main/kotlin/com/joegec/joycon2android/buttonmapping/preset/MarioKartNunchukMapping.kt b/core/buttonmapping/domain/src/main/kotlin/com/joegec/joycon2android/buttonmapping/preset/MarioKartNunchukMapping.kt index 522e0d2..64174ad 100644 --- a/core/buttonmapping/domain/src/main/kotlin/com/joegec/joycon2android/buttonmapping/preset/MarioKartNunchukMapping.kt +++ b/core/buttonmapping/domain/src/main/kotlin/com/joegec/joycon2android/buttonmapping/preset/MarioKartNunchukMapping.kt @@ -31,14 +31,8 @@ import com.joegec.joycon2android.model.JoyconButton.ZL import com.joegec.joycon2android.model.JoyconButton.ZR /** - * Mario Kart's remote-and-nunchuk scheme, where a stick steers rather than the tilt of a wheel. - * - * A pair splits the two halves across the hands, so its four shoulders carry what each hand's - * controller keeps under a finger: the remote's B and the Nunchuk's Z on the upper pair, 1 and 2 on - * the lower. The hop also takes the Joy-Con's own B, and the trick rides its shoulder. - * - * A lone Joy-Con plays both halves at once, its own stick standing in for the Nunchuk's, with the - * rails carrying the two buttons a second hand would have held. + * Steers by stick. A pair puts B and Z on the upper shoulders, 1 and 2 on the lower; a lone Joy-Con + * plays both halves, its stick as the Nunchuk's and its rails as Z and B. */ object MarioKartNunchukMapping : MappingPreset { override val id = "MARIO_KART_NUNCHUK" @@ -84,9 +78,7 @@ object MarioKartNunchukMapping : MappingPreset { ) } - // Said rather than left out: a layout lies over the console's default, so a target it never - // mentions keeps whatever that default bound — the stick the Nunchuk now wants, and buttons - // that would otherwise double up with the ones named above. + // Listed rather than omitted, or they keep the console default's bindings ([MappingLayouts.entriesOf]). private val UNBOUND = listOf( WiimoteButton.DPadUp, WiimoteButton.DPadDown, diff --git a/core/buttonmapping/domain/src/main/kotlin/com/joegec/joycon2android/buttonmapping/preset/MarioKartWheelMapping.kt b/core/buttonmapping/domain/src/main/kotlin/com/joegec/joycon2android/buttonmapping/preset/MarioKartWheelMapping.kt index ad4432a..ed79338 100644 --- a/core/buttonmapping/domain/src/main/kotlin/com/joegec/joycon2android/buttonmapping/preset/MarioKartWheelMapping.kt +++ b/core/buttonmapping/domain/src/main/kotlin/com/joegec/joycon2android/buttonmapping/preset/MarioKartWheelMapping.kt @@ -23,15 +23,7 @@ import com.joegec.joycon2android.model.JoyconButton.Up import com.joegec.joycon2android.model.JoyconButton.X import com.joegec.joycon2android.model.JoyconButton.Y -/** - * A lone Joy-Con held sideways as a wheel, laid out the way Mario Kart 8 uses one so the same thumb - * does the same job in both games: accelerate on 2, brake on 1, hop on SR. Mario Kart Wii throws an - * item with the d-pad, which a sideways body already steers from its stick, so SL fires it too — - * the shoulder that throws in Mario Kart 8. - * - * It is the layout that plays as a sideways Wii Remote, which is what the wheel steers by, and a - * pair has no such grip to match — so only a lone Joy-Con is offered it. - */ +/** Mario Kart 8's sideways layout, so a thumb does the same job in both games; SL also throws an item. */ object MarioKartWheelMapping : MappingPreset { override val id = "MARIO_KART_WHEEL" override val console = Console.WIIMOTE_NUNCHUK diff --git a/core/buttonmapping/domain/src/main/kotlin/com/joegec/joycon2android/buttonmapping/preset/SwitchProMapping.kt b/core/buttonmapping/domain/src/main/kotlin/com/joegec/joycon2android/buttonmapping/preset/SwitchProMapping.kt index d67d893..4a6c07f 100644 --- a/core/buttonmapping/domain/src/main/kotlin/com/joegec/joycon2android/buttonmapping/preset/SwitchProMapping.kt +++ b/core/buttonmapping/domain/src/main/kotlin/com/joegec/joycon2android/buttonmapping/preset/SwitchProMapping.kt @@ -58,9 +58,7 @@ object SwitchProMapping : MappingPreset { SwitchProButton.DPadLeft to Left, SwitchProButton.DPadRight to Right, ) - // Held sideways, the rail buttons are the shoulder pair, as they are on a real Switch. - // ZL/ZR stay unbound: the body's own shoulders point away from the player in that grip, so - // there is nothing honest to put there — the user can bind them if they want them. + // Rails as shoulders, ZL/ZR unbound: docs/virtual-gamepad.md#sidewaysmapper JoyconSide.LEFT -> mapOf( SwitchProButton.A to Down, SwitchProButton.B to Left, diff --git a/core/buttonmapping/domain/src/main/kotlin/com/joegec/joycon2android/buttonmapping/preset/WiiMapping.kt b/core/buttonmapping/domain/src/main/kotlin/com/joegec/joycon2android/buttonmapping/preset/WiiMapping.kt index 632d785..931a856 100644 --- a/core/buttonmapping/domain/src/main/kotlin/com/joegec/joycon2android/buttonmapping/preset/WiiMapping.kt +++ b/core/buttonmapping/domain/src/main/kotlin/com/joegec/joycon2android/buttonmapping/preset/WiiMapping.kt @@ -70,8 +70,7 @@ object WiiMapping : MappingPreset { ) } - // A sideways Joy-Con has no d-pad left once its cluster becomes the face buttons, so its stick - // steers the Wii Remote's d-pad instead. + // Sideways, the cluster is the face buttons, so the stick drives the d-pad. internal fun dPadSticks(side: JoyconSide): Map> { val stick = when (side) { JoyconSide.DUAL -> return emptyMap() diff --git a/core/buttonmapping/domain/src/main/kotlin/com/joegec/joycon2android/buttonmapping/target/GameCubeButton.kt b/core/buttonmapping/domain/src/main/kotlin/com/joegec/joycon2android/buttonmapping/target/GameCubeButton.kt index e3cf70d..be791f5 100644 --- a/core/buttonmapping/domain/src/main/kotlin/com/joegec/joycon2android/buttonmapping/target/GameCubeButton.kt +++ b/core/buttonmapping/domain/src/main/kotlin/com/joegec/joycon2android/buttonmapping/target/GameCubeButton.kt @@ -1,6 +1,5 @@ package com.joegec.joycon2android.buttonmapping.target -/** A GameCube controller's own digital buttons and d-pad directions. */ enum class GameCubeButton { A, B, diff --git a/core/buttonmapping/domain/src/main/kotlin/com/joegec/joycon2android/buttonmapping/target/SwitchProButton.kt b/core/buttonmapping/domain/src/main/kotlin/com/joegec/joycon2android/buttonmapping/target/SwitchProButton.kt index 1a21346..a74caf4 100644 --- a/core/buttonmapping/domain/src/main/kotlin/com/joegec/joycon2android/buttonmapping/target/SwitchProButton.kt +++ b/core/buttonmapping/domain/src/main/kotlin/com/joegec/joycon2android/buttonmapping/target/SwitchProButton.kt @@ -1,6 +1,5 @@ package com.joegec.joycon2android.buttonmapping.target -/** A Nintendo Switch Pro Controller's own buttons and d-pad directions. */ enum class SwitchProButton { A, B, diff --git a/core/buttonmapping/domain/src/main/kotlin/com/joegec/joycon2android/buttonmapping/target/WiimoteButton.kt b/core/buttonmapping/domain/src/main/kotlin/com/joegec/joycon2android/buttonmapping/target/WiimoteButton.kt index 7e42a0c..dd73f21 100644 --- a/core/buttonmapping/domain/src/main/kotlin/com/joegec/joycon2android/buttonmapping/target/WiimoteButton.kt +++ b/core/buttonmapping/domain/src/main/kotlin/com/joegec/joycon2android/buttonmapping/target/WiimoteButton.kt @@ -1,10 +1,6 @@ package com.joegec.joycon2android.buttonmapping.target -/** - * A Wii Remote's own buttons, its Nunchuk's two, and the one thing on it that is not a button at - * all: [Shake], the jerk of the remote that a game like Mario Kart Wii reads as a trick. It sits - * here because the editor binds sources to it exactly as it does to a button. - */ +/** Includes [Shake], a motion rather than a button, because the editor binds it like one. */ enum class WiimoteButton { A, B, diff --git a/core/buttonmapping/domain/src/test/kotlin/com/joegec/joycon2android/buttonmapping/GlobalMappingTest.kt b/core/buttonmapping/domain/src/test/kotlin/com/joegec/joycon2android/buttonmapping/GlobalMappingTest.kt index f017317..ddae634 100644 --- a/core/buttonmapping/domain/src/test/kotlin/com/joegec/joycon2android/buttonmapping/GlobalMappingTest.kt +++ b/core/buttonmapping/domain/src/test/kotlin/com/joegec/joycon2android/buttonmapping/GlobalMappingTest.kt @@ -23,7 +23,7 @@ class GlobalMappingTest { private suspend fun putBothOn(layoutId: String) = fixture.applyGlobalLayout(fixture.console, bodies, layoutId) - // The domain says what a session agrees on; naming it is presentation's, so this stands in. + // Stands in for presentation's naming. private fun GlobalMapping.agreedName(): String? = matchingSaved?.name ?: sharedLayout?.id ?: sharedFamily?.name @@ -65,7 +65,6 @@ class GlobalMappingTest { assertNull(fixture.globalMapping(first, second).agreedName()) } - // A table rarely holds the same thing, so the grip each body can be held in is what it gets. @Test fun `setting a grip nobody but a lone Joy-Con has gives a pair the other grip of the same game`() = runBlocking { val pair = PlayerBody(PlayerNumber.P3, JoyconSide.DUAL) @@ -98,7 +97,6 @@ class GlobalMappingTest { assertFalse(saved.fits(listOf(first, PlayerBody(PlayerNumber.P2, JoyconSide.DUAL)))) } - // Which bodies, in which order — what they are *called* is presentation's, so it is not here. @Test fun `a saved set records the bodies it wants, player by player`() = runBlocking { val three = bodies + PlayerBody(PlayerNumber.P3, JoyconSide.DUAL) diff --git a/core/buttonmapping/domain/src/test/kotlin/com/joegec/joycon2android/buttonmapping/preset/WiiPresetsTest.kt b/core/buttonmapping/domain/src/test/kotlin/com/joegec/joycon2android/buttonmapping/preset/WiiPresetsTest.kt index c48421e..1563770 100644 --- a/core/buttonmapping/domain/src/test/kotlin/com/joegec/joycon2android/buttonmapping/preset/WiiPresetsTest.kt +++ b/core/buttonmapping/domain/src/test/kotlin/com/joegec/joycon2android/buttonmapping/preset/WiiPresetsTest.kt @@ -20,7 +20,6 @@ class WiiPresetsTest { assertEquals(WiiMapping, MappingPresets.default(Console.WIIMOTE_NUNCHUK)) } - // 1 and 2 go on the shoulders, so a thumb never leaves the stick to reach them. @Test fun `the Joy-Con layout puts the remote's buttons where a Joy-Con keeps them`() { val dual = JoyconWiiMapping.entries(JoyconSide.DUAL) @@ -91,7 +90,7 @@ class WiiPresetsTest { assertEquals("$side", rail, lone.getValue(WiimoteButton.NunchukZ.name)) assertTrue("$side steers from its own stick", lone.keys.any { it.startsWith(WiimoteStick.NunchukStick.name) }) - // Bound to nothing on purpose: left out, each would keep what the Wii layout bound. + // Unbound on purpose; omitted, each would keep the Wii layout's binding. listOf( WiimoteButton.DPadUp, WiimoteButton.DPadDown, WiimoteButton.DPadLeft, WiimoteButton.DPadRight, WiimoteButton.One, WiimoteButton.Two, WiimoteButton.Minus, @@ -99,8 +98,6 @@ class WiiPresetsTest { } } - // Sideways, a left Joy-Con's cluster rotates onto the faces, so the same thumb position can do - // the same job on both bodies — which is only true if each names the button that gets it there. @Test fun `the Nunchuck layout puts the same job under the same thumb on both bodies`() { val left = MarioKartNunchukMapping.entries(JoyconSide.LEFT) @@ -120,8 +117,6 @@ class WiiPresetsTest { ?.button ?.emittedFor(side) - // A layout lies over the console's default, so anything it leaves out keeps the default's - // binding and quietly doubles up with whatever it did name. @Test fun `no button on a lone Joy-Con fires two targets, bar the shoulder that hops and tricks`() { JoyconSide.entries.filterNot { it == JoyconSide.DUAL }.forEach { side -> @@ -160,12 +155,11 @@ class WiiPresetsTest { assertEquals("ZL", pair.getValue(WiimoteButton.One.name)) assertEquals("ZR", pair.getValue(WiimoteButton.Two.name)) - // The remote's trigger hand, and the Joy-Con's own B so either finger can hop. assertEquals("R|B", pair.getValue(WiimoteButton.B.name)) - assertEquals("L", pair.getValue(WiimoteButton.NunchukZ.name)) // the Nunchuk's + assertEquals("L", pair.getValue(WiimoteButton.NunchukZ.name)) assertEquals("X", pair.getValue(WiimoteButton.NunchukC.name)) assertEquals("Minus", pair.getValue(WiimoteButton.Minus.name)) - assertEquals("R", pair.getValue(WiimoteButton.Shake.name)) // the finger that hops also tricks + assertEquals("R", pair.getValue(WiimoteButton.Shake.name)) } @Test @@ -190,7 +184,7 @@ class WiiPresetsTest { @Test fun `every Wii layout binds the whole remote on a lone Joy-Con`() { - // Shake is a motion of the remote rather than a button on it, so no layout owes it a source. + // Shake is a motion, not a button. val remote = (WiimoteButton.entries - WiimoteButton.NunchukC - WiimoteButton.NunchukZ - WiimoteButton.Shake).map { it.name } diff --git a/core/buttonmapping/presentation/src/main/kotlin/com/joegec/joycon2android/buttonmapping/presentation/ControllerMappingScreen.kt b/core/buttonmapping/presentation/src/main/kotlin/com/joegec/joycon2android/buttonmapping/presentation/ControllerMappingScreen.kt index d9be786..80d8db1 100644 --- a/core/buttonmapping/presentation/src/main/kotlin/com/joegec/joycon2android/buttonmapping/presentation/ControllerMappingScreen.kt +++ b/core/buttonmapping/presentation/src/main/kotlin/com/joegec/joycon2android/buttonmapping/presentation/ControllerMappingScreen.kt @@ -107,7 +107,6 @@ private fun ScreenHeader(state: ControllerMappingUiState, onBack: () -> Unit) { } } -/** The session read as one setting, so a whole table can be set — and kept — in a single move. */ @Composable private fun AllPlayersRow( console: Console, @@ -188,7 +187,7 @@ private fun MappingDialogs( } private sealed interface MappingDialog { - /** A null body names the session as a whole rather than one player. */ + /** Null means the whole session. */ data class Save(val body: PlayerBody?) : MappingDialog data class Delete(val id: String, val name: String, val global: Boolean) : MappingDialog diff --git a/core/buttonmapping/presentation/src/main/kotlin/com/joegec/joycon2android/buttonmapping/presentation/ControllerMappingUiState.kt b/core/buttonmapping/presentation/src/main/kotlin/com/joegec/joycon2android/buttonmapping/presentation/ControllerMappingUiState.kt index 6d926a8..e9d8d00 100644 --- a/core/buttonmapping/presentation/src/main/kotlin/com/joegec/joycon2android/buttonmapping/presentation/ControllerMappingUiState.kt +++ b/core/buttonmapping/presentation/src/main/kotlin/com/joegec/joycon2android/buttonmapping/presentation/ControllerMappingUiState.kt @@ -8,7 +8,6 @@ import com.joegec.joycon2android.buttonmapping.PlayerBody import com.joegec.joycon2android.buttonmapping.PlayerMapping import com.joegec.joycon2android.buttonmapping.SavedLayout -/** A null [layout] is the editor's way of saying the bindings no longer match any of them. */ data class ControllerMappingUiState( val console: Console, val global: GlobalMapping, @@ -45,7 +44,6 @@ private fun PlayerMapping.uiState(console: Console, layouts: List mapping = entries, ) -/** Names already taken, so a suggested one is never a duplicate of what it sits beside. */ internal fun ControllerMappingUiState.takenNames(session: Boolean): List = if (session) global.savedLayouts.map { it.name } else players.flatMap { it.layouts }.filterIsInstance().map { it.name }.distinct() diff --git a/core/buttonmapping/presentation/src/main/kotlin/com/joegec/joycon2android/buttonmapping/presentation/ControllerMappingViewModel.kt b/core/buttonmapping/presentation/src/main/kotlin/com/joegec/joycon2android/buttonmapping/presentation/ControllerMappingViewModel.kt index ddc0906..a19b856 100644 --- a/core/buttonmapping/presentation/src/main/kotlin/com/joegec/joycon2android/buttonmapping/presentation/ControllerMappingViewModel.kt +++ b/core/buttonmapping/presentation/src/main/kotlin/com/joegec/joycon2android/buttonmapping/presentation/ControllerMappingViewModel.kt @@ -25,7 +25,6 @@ import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch -/** State holder for the mapping editor: one console's layouts, per player. */ @OptIn(ExperimentalCoroutinesApi::class) class ControllerMappingViewModel( private val observeGlobalMapping: ObserveGlobalMappingUseCase, @@ -66,7 +65,7 @@ class ControllerMappingViewModel( setSidewaysRemote(it.console, body, enabled) } - /** A null body names the session as a whole rather than one player. */ + /** Null means the whole session. */ fun saveLayout(body: PlayerBody?, name: String) = onTarget { target -> if (body == null) saveGlobalLayout(target.console, target.bodies, name) else saveCustomLayout(target.console, body, name) diff --git a/core/buttonmapping/presentation/src/main/kotlin/com/joegec/joycon2android/buttonmapping/presentation/LayoutLabels.kt b/core/buttonmapping/presentation/src/main/kotlin/com/joegec/joycon2android/buttonmapping/presentation/LayoutLabels.kt index b01547b..d561eed 100644 --- a/core/buttonmapping/presentation/src/main/kotlin/com/joegec/joycon2android/buttonmapping/presentation/LayoutLabels.kt +++ b/core/buttonmapping/presentation/src/main/kotlin/com/joegec/joycon2android/buttonmapping/presentation/LayoutLabels.kt @@ -19,13 +19,7 @@ import com.joegec.joycon2android.buttonmapping.preset.WiiMapping import com.joegec.joycon2android.core.buttonmapping.presentation.R import com.joegec.joycon2android.ui.components.DropdownOption -/** - * What the shipped layouts are called, and the line under each saying what picking it does. A - * layout the user saved answers with their own words instead, which are data rather than copy. - * - * Resolved once, up in composition, because that is the only place resources can be read — the - * layouts themselves are domain and know nothing of what they are called. - */ +/** Resolved in composition, the only place resources can be read. Saved layouts carry their own name. */ class LayoutLabels internal constructor( private val names: Map, private val descriptions: Map, @@ -51,7 +45,6 @@ internal fun rememberLayoutLabels(): LayoutLabels { ) } -// A `when` over the sealed type rather than a map, so a layout added without a name will not build. @Composable private fun nameOf(preset: MappingPreset): String = when (preset) { GameCubeMapping, SwitchProMapping -> stringResource(R.string.layout_standard) @@ -75,7 +68,6 @@ private fun nameOf(family: LayoutFamily): String = when (family) { LayoutFamily.MARIO_KART -> stringResource(R.string.layout_family_mario_kart) } -/** One layout as a row of a dropdown: its name, what it does, and whether it is the user's to delete. */ internal fun LayoutLabels.option(layout: MappingLayout) = DropdownOption( id = layout.id, label = name(layout), @@ -83,10 +75,6 @@ internal fun LayoutLabels.option(layout: MappingLayout) = DropdownOption( deletable = layout is SavedLayout, ) -/** - * What the whole table is on: a saved set they all still match, the one layout they all read as, or - * the family they are each on their own grip of. Null once any of them has gone its own way. - */ fun LayoutLabels.sessionName(global: GlobalMapping): String? = global.matchingSaved?.let(::name) ?: global.sharedLayout?.let(::name) diff --git a/core/buttonmapping/presentation/src/main/kotlin/com/joegec/joycon2android/buttonmapping/presentation/LayoutRow.kt b/core/buttonmapping/presentation/src/main/kotlin/com/joegec/joycon2android/buttonmapping/presentation/LayoutRow.kt index f85a533..e031cc2 100644 --- a/core/buttonmapping/presentation/src/main/kotlin/com/joegec/joycon2android/buttonmapping/presentation/LayoutRow.kt +++ b/core/buttonmapping/presentation/src/main/kotlin/com/joegec/joycon2android/buttonmapping/presentation/LayoutRow.kt @@ -22,11 +22,6 @@ import com.joegec.joycon2android.ui.theme.Accent import com.joegec.joycon2android.ui.theme.Dimens import com.joegec.joycon2android.ui.theme.TextDim -/** - * Picks the layout a body — or the whole session — follows, and offers to keep what it has become. - * The name reads "Custom" the moment the bindings stop matching a layout, and reads a layout's own - * name again the moment they match one. - */ @Composable fun LayoutRow( options: List, @@ -65,10 +60,7 @@ fun LayoutRow( } } -/** - * Only a mapping with no name of its own is worth naming: one that already reads as a layout has - * been saved once already, so the icon dims and says which layout it is rather than making a twin. - */ +/** Dimmed once the mapping already reads as a layout, rather than saving a twin. */ @Composable private fun SaveButton(layoutName: String?, onSave: () -> Unit) { val context = LocalContext.current diff --git a/core/buttonmapping/presentation/src/main/kotlin/com/joegec/joycon2android/buttonmapping/presentation/MappingActions.kt b/core/buttonmapping/presentation/src/main/kotlin/com/joegec/joycon2android/buttonmapping/presentation/MappingActions.kt index c451794..688cf83 100644 --- a/core/buttonmapping/presentation/src/main/kotlin/com/joegec/joycon2android/buttonmapping/presentation/MappingActions.kt +++ b/core/buttonmapping/presentation/src/main/kotlin/com/joegec/joycon2android/buttonmapping/presentation/MappingActions.kt @@ -3,7 +3,6 @@ package com.joegec.joycon2android.buttonmapping.presentation import androidx.compose.runtime.Immutable import com.joegec.joycon2android.buttonmapping.PlayerBody -/** What the editor can do, so each card takes one collaborator rather than a fistful of lambdas. */ @Immutable class MappingActions( val selectLayout: (body: PlayerBody, layoutId: String) -> Unit, diff --git a/core/buttonmapping/presentation/src/main/kotlin/com/joegec/joycon2android/buttonmapping/presentation/MappingBindings.kt b/core/buttonmapping/presentation/src/main/kotlin/com/joegec/joycon2android/buttonmapping/presentation/MappingBindings.kt index 9bf8feb..4448d7c 100644 --- a/core/buttonmapping/presentation/src/main/kotlin/com/joegec/joycon2android/buttonmapping/presentation/MappingBindings.kt +++ b/core/buttonmapping/presentation/src/main/kotlin/com/joegec/joycon2android/buttonmapping/presentation/MappingBindings.kt @@ -15,7 +15,6 @@ import com.joegec.joycon2android.ui.components.MultiSelectDropdown import com.joegec.joycon2android.ui.theme.Dimens import com.joegec.joycon2android.ui.theme.TextDim -/** Every target this console offers, against the physical controls the player's body can produce. */ @Composable fun MappingBindings(console: Console, state: PlayerMappingUiState, actions: MappingActions) { Column(verticalArrangement = Arrangement.spacedBy(Dimens.elementSpacing)) { diff --git a/core/buttonmapping/presentation/src/main/kotlin/com/joegec/joycon2android/buttonmapping/presentation/MappingLabels.kt b/core/buttonmapping/presentation/src/main/kotlin/com/joegec/joycon2android/buttonmapping/presentation/MappingLabels.kt index dbfa9fd..b7fea0a 100644 --- a/core/buttonmapping/presentation/src/main/kotlin/com/joegec/joycon2android/buttonmapping/presentation/MappingLabels.kt +++ b/core/buttonmapping/presentation/src/main/kotlin/com/joegec/joycon2android/buttonmapping/presentation/MappingLabels.kt @@ -15,10 +15,6 @@ import com.joegec.joycon2android.buttonmapping.target.WiimoteButton import com.joegec.joycon2android.buttonmapping.target.WiimoteStick import com.joegec.joycon2android.core.buttonmapping.presentation.R -/** - * What the mapping vocabulary is called. The domain names none of it: a target is an identity there - * and a word only here, and a `when` over each enum means one added without a word will not build. - */ @Composable internal fun Console.label(): String = when (this) { Console.GAMECUBE -> stringResource(R.string.console_gamecube) @@ -34,7 +30,6 @@ internal fun JoyconSide.shortLabel(): String = when (this) { JoyconSide.DUAL -> stringResource(R.string.side_dual_short) } -/** Which players a saved set wants, and in which hands. */ @Composable internal fun GlobalLayout.playerSummary(): String = bodies .map { stringResource(R.string.player_body, it.body.player.index, it.body.side.shortLabel()) } diff --git a/core/buttonmapping/presentation/src/main/kotlin/com/joegec/joycon2android/buttonmapping/presentation/MappingOptions.kt b/core/buttonmapping/presentation/src/main/kotlin/com/joegec/joycon2android/buttonmapping/presentation/MappingOptions.kt index 78ddcc2..7e14f19 100644 --- a/core/buttonmapping/presentation/src/main/kotlin/com/joegec/joycon2android/buttonmapping/presentation/MappingOptions.kt +++ b/core/buttonmapping/presentation/src/main/kotlin/com/joegec/joycon2android/buttonmapping/presentation/MappingOptions.kt @@ -17,15 +17,12 @@ import com.joegec.joycon2android.buttonmapping.target.WiimoteStick import com.joegec.joycon2android.core.buttonmapping.presentation.R import com.joegec.joycon2android.model.JoyconButton -/** The (storage key, label) rows and (source id, label) choices the mapping editor offers. */ internal object MappingOptions { const val NONE_ID = "" - /** Only a lone Joy-Con standing in for a Wii Remote can be held sideways in the sense the switch means. */ fun offersSidewaysRemote(console: Console, side: JoyconSide) = console == Console.WIIMOTE_NUNCHUK && side != JoyconSide.DUAL - /** Every row the editor offers, in reading order: buttons, then sticks, then what is neither. */ @Composable fun targets(console: Console): List> = buttonTargets(console) + stickDirectionTargets(console) + motionTargets(console) @@ -37,8 +34,7 @@ internal object MappingOptions { Console.SWITCH_PRO -> SwitchProButton.entries.map { it.name to it.label() } } - // Shaking the remote is a motion of it rather than a button on it, so it sits below the sticks - // instead of among the face buttons. + // Shake is a motion, so it's listed after the sticks. private val MOTION_TARGETS = setOf(WiimoteButton.Shake) @Composable @@ -80,9 +76,7 @@ internal object MappingOptions { } } - // The buttons a real, lone Joy-Con of that side can actually produce — matches what the physical - // hardware has, so a mapping chosen here can always fire (see JoyconButton for the full set; SL/SR - // are split per side, A/B/X/Y/Home/C only exist on the right Joy-Con, the d-pad only on the left). + // Only what that side physically has, so every choice can fire. private fun physicalButtons(side: JoyconSide): List = when (side) { JoyconSide.DUAL -> JoyconButton.entries JoyconSide.LEFT -> listOf( diff --git a/core/buttonmapping/presentation/src/main/kotlin/com/joegec/joycon2android/buttonmapping/presentation/PlayerMappingCard.kt b/core/buttonmapping/presentation/src/main/kotlin/com/joegec/joycon2android/buttonmapping/presentation/PlayerMappingCard.kt index d03ae37..91a282b 100644 --- a/core/buttonmapping/presentation/src/main/kotlin/com/joegec/joycon2android/buttonmapping/presentation/PlayerMappingCard.kt +++ b/core/buttonmapping/presentation/src/main/kotlin/com/joegec/joycon2android/buttonmapping/presentation/PlayerMappingCard.kt @@ -45,7 +45,6 @@ import com.joegec.joycon2android.ui.theme.JoyconDefaultColor import com.joegec.joycon2android.ui.theme.TextDim import com.joegec.joycon2android.ui.theme.joyconBorderColor -/** One player's whole mapping: who they are and what layout they are on, opening onto its bindings. */ @Composable fun PlayerMappingCard( console: Console, @@ -157,12 +156,7 @@ private fun ControllerChip(textRes: Int, joycon: ConnectedJoycon) { ) } -/** - * A lone Joy-Con stands in for a Wii Remote held sideways. A left Joy-Con's own body already is one - * — a sideways remote's nose points left, just as its L/ZL edge does — so the switch only turns its - * d-pad and amplifies its flicks. A right Joy-Con additionally gives up its own body to steer true, - * and with it the R edge as the nose it aims down, which is what the warning is for. - */ +/** A right Joy-Con aims from its tail: docs/dsu-motion.md#playing-as-a-sideways-wii-remote */ @Composable private fun SidewaysRemoteSwitch(side: JoyconSide, enabled: Boolean, onSetEnabled: (Boolean) -> Unit) { val aimsFromItsTail = side == JoyconSide.RIGHT diff --git a/core/designsystem/src/main/kotlin/com/joegec/joycon2android/ui/components/CloseEmulatorDialog.kt b/core/designsystem/src/main/kotlin/com/joegec/joycon2android/ui/components/CloseEmulatorDialog.kt index 0de38ba..93864ef 100644 --- a/core/designsystem/src/main/kotlin/com/joegec/joycon2android/ui/components/CloseEmulatorDialog.kt +++ b/core/designsystem/src/main/kotlin/com/joegec/joycon2android/ui/components/CloseEmulatorDialog.kt @@ -4,7 +4,6 @@ import androidx.compose.runtime.Composable import androidx.compose.ui.res.stringResource import com.joegec.joycon2android.core.designsystem.R -/** Consent for the one destructive step auto setup needs: closing the emulator before writing. */ @Composable fun CloseEmulatorDialog( emulatorName: String, diff --git a/core/designsystem/src/main/kotlin/com/joegec/joycon2android/ui/components/ConfirmDialog.kt b/core/designsystem/src/main/kotlin/com/joegec/joycon2android/ui/components/ConfirmDialog.kt index a076ef9..27abfee 100644 --- a/core/designsystem/src/main/kotlin/com/joegec/joycon2android/ui/components/ConfirmDialog.kt +++ b/core/designsystem/src/main/kotlin/com/joegec/joycon2android/ui/components/ConfirmDialog.kt @@ -11,7 +11,6 @@ import com.joegec.joycon2android.ui.theme.Accent import com.joegec.joycon2android.ui.theme.CardBg import com.joegec.joycon2android.ui.theme.TextDim -/** Two-button dialog in the app's card styling: an accented confirm and a dimmed way out. */ @Composable fun ConfirmDialog( title: String, diff --git a/core/designsystem/src/main/kotlin/com/joegec/joycon2android/ui/components/DolphinSetupButton.kt b/core/designsystem/src/main/kotlin/com/joegec/joycon2android/ui/components/DolphinSetupButton.kt index 2259c75..7b69288 100644 --- a/core/designsystem/src/main/kotlin/com/joegec/joycon2android/ui/components/DolphinSetupButton.kt +++ b/core/designsystem/src/main/kotlin/com/joegec/joycon2android/ui/components/DolphinSetupButton.kt @@ -20,7 +20,6 @@ import com.joegec.joycon2android.ui.theme.Dimens import com.joegec.joycon2android.ui.theme.ErrorText import com.joegec.joycon2android.ui.theme.TextOnAccent -/** Filled accent button (Scan-button styling) that runs a one-shot emulator config write. */ @Composable fun DolphinSetupButton( phase: DolphinSetupPhase, diff --git a/core/designsystem/src/main/kotlin/com/joegec/joycon2android/ui/components/DropdownOption.kt b/core/designsystem/src/main/kotlin/com/joegec/joycon2android/ui/components/DropdownOption.kt index 4ef6160..26d086f 100644 --- a/core/designsystem/src/main/kotlin/com/joegec/joycon2android/ui/components/DropdownOption.kt +++ b/core/designsystem/src/main/kotlin/com/joegec/joycon2android/ui/components/DropdownOption.kt @@ -1,9 +1,6 @@ package com.joegec.joycon2android.ui.components -/** - * One row of an [OptionDropdown]. [subLabel] is the qualifier under the name; an option that - * cannot be chosen right now stays visible but dimmed, so the reason can be explained on tap. - */ +/** A disabled option stays visible, dimmed, so its reason can be explained on tap. */ data class DropdownOption( val id: String, val label: String, diff --git a/core/designsystem/src/main/kotlin/com/joegec/joycon2android/ui/components/DropdownTrigger.kt b/core/designsystem/src/main/kotlin/com/joegec/joycon2android/ui/components/DropdownTrigger.kt index 1bc1551..ec359c6 100644 --- a/core/designsystem/src/main/kotlin/com/joegec/joycon2android/ui/components/DropdownTrigger.kt +++ b/core/designsystem/src/main/kotlin/com/joegec/joycon2android/ui/components/DropdownTrigger.kt @@ -21,7 +21,6 @@ import com.joegec.joycon2android.ui.theme.Accent import com.joegec.joycon2android.ui.theme.Dimens import com.joegec.joycon2android.ui.theme.TextDim -/** The accented current value every dropdown in the app opens from. */ @Composable internal fun DropdownTrigger(label: String, subLabel: String?, onClick: () -> Unit) { Row( diff --git a/core/designsystem/src/main/kotlin/com/joegec/joycon2android/ui/components/EmulatorAutoSetup.kt b/core/designsystem/src/main/kotlin/com/joegec/joycon2android/ui/components/EmulatorAutoSetup.kt index 20350bc..cef6c80 100644 --- a/core/designsystem/src/main/kotlin/com/joegec/joycon2android/ui/components/EmulatorAutoSetup.kt +++ b/core/designsystem/src/main/kotlin/com/joegec/joycon2android/ui/components/EmulatorAutoSetup.kt @@ -34,7 +34,6 @@ import com.joegec.joycon2android.ui.theme.CardBg import com.joegec.joycon2android.ui.theme.Dimens import com.joegec.joycon2android.ui.theme.TextDim -/** Emulator picker paired with its one-shot config button, framed as a self-explaining group. */ @Composable fun EmulatorAutoSetup( emulators: List, diff --git a/core/designsystem/src/main/kotlin/com/joegec/joycon2android/ui/components/EmulatorOption.kt b/core/designsystem/src/main/kotlin/com/joegec/joycon2android/ui/components/EmulatorOption.kt index f670819..b4ce63a 100644 --- a/core/designsystem/src/main/kotlin/com/joegec/joycon2android/ui/components/EmulatorOption.kt +++ b/core/designsystem/src/main/kotlin/com/joegec/joycon2android/ui/components/EmulatorOption.kt @@ -1,4 +1,4 @@ package com.joegec.joycon2android.ui.components -/** A selectable emulator in a setup dropdown. [id] is the package name; [label] is shown. */ +/** [id] is the package name. */ data class EmulatorOption(val id: String, val label: String) diff --git a/core/designsystem/src/main/kotlin/com/joegec/joycon2android/ui/components/LabeledBorderBox.kt b/core/designsystem/src/main/kotlin/com/joegec/joycon2android/ui/components/LabeledBorderBox.kt index 980d6e6..4d958fd 100644 --- a/core/designsystem/src/main/kotlin/com/joegec/joycon2android/ui/components/LabeledBorderBox.kt +++ b/core/designsystem/src/main/kotlin/com/joegec/joycon2android/ui/components/LabeledBorderBox.kt @@ -22,13 +22,7 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import com.joegec.joycon2android.ui.theme.Dimens -/** - * Fieldset-style container: a rounded border with [label] straddling the top edge, its own - * background masking the border line behind it — like an HTML `` or an outlined text - * field's notched label. [labelBackground] must match the surface the box sits on for the mask - * to read as a clean gap. When [onInfoClick] is set, an info icon joins the label and the whole - * legend becomes its tap target. - */ +/** Fieldset-style box with [label] notched into the top border; [labelBackground] must match the surface behind. */ @Composable fun LabeledBorderBox( label: String, diff --git a/core/designsystem/src/main/kotlin/com/joegec/joycon2android/ui/components/MultiSelectDropdown.kt b/core/designsystem/src/main/kotlin/com/joegec/joycon2android/ui/components/MultiSelectDropdown.kt index b49c9da..9eb7825 100644 --- a/core/designsystem/src/main/kotlin/com/joegec/joycon2android/ui/components/MultiSelectDropdown.kt +++ b/core/designsystem/src/main/kotlin/com/joegec/joycon2android/ui/components/MultiSelectDropdown.kt @@ -12,12 +12,7 @@ import androidx.compose.ui.layout.onSizeChanged import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.unit.dp -/** - * Id/label picker for a row that can hold several choices at once. The menu stays open while they - * are ticked off; tapping outside closes it. With nothing selected it reads as the first option, - * which callers put there as their "none" row — picking that one empties the row, so it closes - * rather than waiting for a tick that cannot come. - */ +/** Stays open while choices are ticked. The first option is the caller's "none", which empties the row and closes. */ @Composable fun MultiSelectDropdown( options: List>, diff --git a/core/designsystem/src/main/kotlin/com/joegec/joycon2android/ui/components/OptionDropdown.kt b/core/designsystem/src/main/kotlin/com/joegec/joycon2android/ui/components/OptionDropdown.kt index 41180af..b0bf9e9 100644 --- a/core/designsystem/src/main/kotlin/com/joegec/joycon2android/ui/components/OptionDropdown.kt +++ b/core/designsystem/src/main/kotlin/com/joegec/joycon2android/ui/components/OptionDropdown.kt @@ -13,10 +13,7 @@ import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.unit.dp import com.joegec.joycon2android.ui.theme.Dimens -/** - * The app's picker: an accented current value that opens a panel of alternatives. [label] is shown - * rather than derived, so a caller whose state has drifted off the list can say so in its own words. - */ +/** [label] is passed in rather than derived, so a caller whose value is off the list can say so. */ @Composable fun OptionDropdown( options: List, diff --git a/core/designsystem/src/main/kotlin/com/joegec/joycon2android/ui/components/SettingSwitch.kt b/core/designsystem/src/main/kotlin/com/joegec/joycon2android/ui/components/SettingSwitch.kt index e5f8a41..3b29ffc 100644 --- a/core/designsystem/src/main/kotlin/com/joegec/joycon2android/ui/components/SettingSwitch.kt +++ b/core/designsystem/src/main/kotlin/com/joegec/joycon2android/ui/components/SettingSwitch.kt @@ -62,7 +62,6 @@ fun SettingSwitch( } } -/** A caveat the setting carries, marked so it reads as one rather than as more description. */ @Composable private fun SettingWarning(text: String) { Row(horizontalArrangement = Arrangement.spacedBy(Dimens.statusDotGap)) { diff --git a/core/designsystem/src/main/kotlin/com/joegec/joycon2android/ui/components/StartEmulatorDialog.kt b/core/designsystem/src/main/kotlin/com/joegec/joycon2android/ui/components/StartEmulatorDialog.kt index a165c5d..766ed21 100644 --- a/core/designsystem/src/main/kotlin/com/joegec/joycon2android/ui/components/StartEmulatorDialog.kt +++ b/core/designsystem/src/main/kotlin/com/joegec/joycon2android/ui/components/StartEmulatorDialog.kt @@ -4,7 +4,7 @@ import androidx.compose.runtime.Composable import androidx.compose.ui.res.stringResource import com.joegec.joycon2android.core.designsystem.R -/** Offer to launch the emulator once its config is written — it only reads that config on start. */ +/** An emulator only reads its config on start. */ @Composable fun StartEmulatorDialog( emulatorName: String, diff --git a/core/designsystem/src/main/kotlin/com/joegec/joycon2android/ui/components/TextInputDialog.kt b/core/designsystem/src/main/kotlin/com/joegec/joycon2android/ui/components/TextInputDialog.kt index 4695404..123c517 100644 --- a/core/designsystem/src/main/kotlin/com/joegec/joycon2android/ui/components/TextInputDialog.kt +++ b/core/designsystem/src/main/kotlin/com/joegec/joycon2android/ui/components/TextInputDialog.kt @@ -19,11 +19,7 @@ import com.joegec.joycon2android.ui.theme.Accent import com.joegec.joycon2android.ui.theme.CardBg import com.joegec.joycon2android.ui.theme.TextDim -/** - * Asks for one line of text in the app's card styling. [defaultValue] is offered already filled in - * and stands if nothing is typed, but steps aside the moment the field is tapped — so accepting it - * costs nothing and replacing it needs no deleting first. - */ +/** [defaultValue] stands if nothing is typed, and clears when the field is tapped. */ @Composable fun TextInputDialog( title: String, diff --git a/core/designsystem/src/main/kotlin/com/joegec/joycon2android/ui/theme/Color.kt b/core/designsystem/src/main/kotlin/com/joegec/joycon2android/ui/theme/Color.kt index 10d96dd..734ca13 100644 --- a/core/designsystem/src/main/kotlin/com/joegec/joycon2android/ui/theme/Color.kt +++ b/core/designsystem/src/main/kotlin/com/joegec/joycon2android/ui/theme/Color.kt @@ -26,7 +26,6 @@ val CrosshairColor = Color(0xFF222C36) val BatteryHigh = Accent val BatteryMedium = Color(0xFFFBBF24) -// A lighter red than ErrorText so the low-battery readout clears WCAG AA on the AccentDim pill. val BatteryLow = Color(0xFFFF8A8A) private const val BATTERY_LOW_PERCENT = 20 @@ -40,8 +39,7 @@ fun batteryColor(percent: Int): Color = when { private const val ACCENT_SATURATION_BOOST = 1.4f -// A near-black shell would vanish when it fills a pressed control on the dark UI, so the active -// variant floors brightness while the thin border keeps the colour verbatim. +// Shell colour and its brightness floor: docs/DESIGN.md#color private const val ACTIVE_VALUE_FLOOR = 0.72f // accentColor is the controller's real shell accent read from SPI flash, packed as 0xRRGGBB. @@ -60,15 +58,13 @@ private fun boostedShellColor(accentColor: Int, valueFloor: Float): Color { ) } -/** The controller's shell colour as a hairline border; falls back when the shell reports no colour. */ fun joyconBorderColor(accentColor: Int?, fallback: Color): Color = if (accentColor == null) fallback else boostedShellColor(accentColor, valueFloor = 0f) -/** The shell colour raised to a brightness floor so it still reads as "lit" filling a pressed control. */ fun controllerActiveColor(accentColor: Int?, fallback: Color = JoyconDefaultColor): Color = if (accentColor == null) fallback else boostedShellColor(accentColor, ACTIVE_VALUE_FLOOR) -/** Dark ink or white, whichever has the higher WCAG contrast against [background]. */ +/** Whichever of dark ink or white has the higher WCAG contrast. */ fun readableInkOn(background: Color): Color = if (contrastRatio(TextOnAccent, background) >= contrastRatio(Color.White, background)) { TextOnAccent diff --git a/core/designsystem/src/main/kotlin/com/joegec/joycon2android/ui/theme/Dimens.kt b/core/designsystem/src/main/kotlin/com/joegec/joycon2android/ui/theme/Dimens.kt index 7455df0..0e27df6 100644 --- a/core/designsystem/src/main/kotlin/com/joegec/joycon2android/ui/theme/Dimens.kt +++ b/core/designsystem/src/main/kotlin/com/joegec/joycon2android/ui/theme/Dimens.kt @@ -88,9 +88,7 @@ object Dimens { val statusDotSize = 7.dp val statusDotGap = 6.dp - // Glyph and telemetry sizes, tuned to the controller graphics they sit on rather than to a - // text-hierarchy step. UI text goes through MaterialTheme.typography; telemetry readouts through - // AppType.telemetry, which these size. + // Sized to the controller graphics, outside the type scale: docs/DESIGN.md#typography val fontSizeButton = 14.sp // on-controller button label (SmallButton) val fontSizeSmall = 10.sp // on-controller rail label + code/table telemetry val fontSizeLabel = 9.sp // IMU / stick coordinate telemetry diff --git a/core/designsystem/src/main/kotlin/com/joegec/joycon2android/ui/theme/Type.kt b/core/designsystem/src/main/kotlin/com/joegec/joycon2android/ui/theme/Type.kt index 720fb40..ff1ffcd 100644 --- a/core/designsystem/src/main/kotlin/com/joegec/joycon2android/ui/theme/Type.kt +++ b/core/designsystem/src/main/kotlin/com/joegec/joycon2android/ui/theme/Type.kt @@ -5,9 +5,7 @@ import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.sp -// Fixed sp scale (product UI, not fluid), ~1.2 ratio between steps. Weight, colour, and tracking -// carry the hierarchy alongside size so adjacent steps never lean on size alone. Light-on-dark -// text gets a small line-height and tracking bump versus the Material defaults. +// Scale and rationale: docs/DESIGN.md#typography private val SemiBold = FontWeight.SemiBold val Typography = Typography( diff --git a/core/emulatorconfig/src/main/kotlin/com/joegec/joycon2android/emulatorconfig/DolphinControls.kt b/core/emulatorconfig/src/main/kotlin/com/joegec/joycon2android/emulatorconfig/DolphinControls.kt index 60f2899..f4cf969 100644 --- a/core/emulatorconfig/src/main/kotlin/com/joegec/joycon2android/emulatorconfig/DolphinControls.kt +++ b/core/emulatorconfig/src/main/kotlin/com/joegec/joycon2android/emulatorconfig/DolphinControls.kt @@ -2,10 +2,7 @@ package com.joegec.joycon2android.emulatorconfig import com.joegec.joycon2android.buttonmapping.StickDirection -/** - * The spellings Dolphin's ini uses, shared by the two generators that write one. These are wire - * tokens rather than anything a user reads — Dolphin will not match a key spelled otherwise. - */ +/** Wire tokens: Dolphin won't match a key spelled otherwise. */ object DolphinControls { val DIRECTIONS = mapOf( StickDirection.UP to "Up", diff --git a/core/emulatorconfig/src/main/kotlin/com/joegec/joycon2android/emulatorconfig/DolphinPaths.kt b/core/emulatorconfig/src/main/kotlin/com/joegec/joycon2android/emulatorconfig/DolphinPaths.kt index 5ad5ab0..bbbc13e 100644 --- a/core/emulatorconfig/src/main/kotlin/com/joegec/joycon2android/emulatorconfig/DolphinPaths.kt +++ b/core/emulatorconfig/src/main/kotlin/com/joegec/joycon2android/emulatorconfig/DolphinPaths.kt @@ -1,10 +1,5 @@ package com.joegec.joycon2android.emulatorconfig -/** - * Dolphin's package and config-file locations on Android. Shared because both the DSU feature - * (motion input) and the Virtual Gamepad feature (GameCube pad mapping) write to the same app's - * external config dir — writable by a shell-uid process (Shizuku / wireless debugging), not by us. - */ object DolphinPaths { const val PACKAGE = "org.dolphinemu.dolphinemu" diff --git a/core/emulatorconfig/src/main/kotlin/com/joegec/joycon2android/emulatorconfig/EdenControls.kt b/core/emulatorconfig/src/main/kotlin/com/joegec/joycon2android/emulatorconfig/EdenControls.kt index bb633f7..ebfa0b9 100644 --- a/core/emulatorconfig/src/main/kotlin/com/joegec/joycon2android/emulatorconfig/EdenControls.kt +++ b/core/emulatorconfig/src/main/kotlin/com/joegec/joycon2android/emulatorconfig/EdenControls.kt @@ -5,11 +5,6 @@ import com.joegec.joycon2android.buttonmapping.target.SwitchProButton import com.joegec.joycon2android.buttonmapping.target.SwitchProStick import com.joegec.joycon2android.model.PlayerState -/** - * The vocabulary of Eden's `config.ini` `[Controls]` section, shared by the two features that can - * bind a player there — the Virtual Gamepad as an Android HID pad, DSU as a cemuhook pad. Both - * write the same keys for the same player; only the device half of each binding differs. - */ object EdenControls { const val SECTION = "[Controls]" diff --git a/core/emulatorconfig/src/main/kotlin/com/joegec/joycon2android/emulatorconfig/EdenPaths.kt b/core/emulatorconfig/src/main/kotlin/com/joegec/joycon2android/emulatorconfig/EdenPaths.kt index 3aa309b..2a609e2 100644 --- a/core/emulatorconfig/src/main/kotlin/com/joegec/joycon2android/emulatorconfig/EdenPaths.kt +++ b/core/emulatorconfig/src/main/kotlin/com/joegec/joycon2android/emulatorconfig/EdenPaths.kt @@ -1,13 +1,6 @@ package com.joegec.joycon2android.emulatorconfig -/** - * Eden's packages and config-file location on Android. Shared because both the DSU feature - * (cemuhook motion input) and the Virtual Gamepad feature (Pro Controller mapping) write to the - * same `config.ini` — writable by a shell-uid process (Shizuku / wireless debugging), not by us. - * - * Stable and nightly install side by side under different package names, so a path is always - * derived from the package the user picked rather than assumed. - */ +/** Stable and nightly install side by side, so a path derives from the package the user picked. */ object EdenPaths { const val PACKAGE = "dev.eden.eden_emulator" const val NIGHTLY_PACKAGE = "dev.eden.eden_emulator.nightly" diff --git a/core/emulatorconfig/src/main/kotlin/com/joegec/joycon2android/emulatorconfig/IniEditor.kt b/core/emulatorconfig/src/main/kotlin/com/joegec/joycon2android/emulatorconfig/IniEditor.kt index 2e074a6..c5e711b 100644 --- a/core/emulatorconfig/src/main/kotlin/com/joegec/joycon2android/emulatorconfig/IniEditor.kt +++ b/core/emulatorconfig/src/main/kotlin/com/joegec/joycon2android/emulatorconfig/IniEditor.kt @@ -1,10 +1,6 @@ package com.joegec.joycon2android.emulatorconfig -/** - * Edits ini-format config text (read → transform → write), used to splice our settings into an - * emulator's config files without disturbing the user's other keys. Emulator-agnostic: Dolphin's - * `GCPadNew.ini`/`Dolphin.ini`, Eden's `config.ini`, etc. all share this section/key grammar. - */ +/** Splices keys into an emulator's ini, leaving the user's other keys and sections intact. */ object IniEditor { fun mergeSections(existing: String?, sections: Map): String { val bodies = LinkedHashMap() @@ -28,7 +24,6 @@ object IniEditor { return out.toString() } - /** The value of `key` in a `[section]`, or null when either is absent. */ fun valueOf(existing: String?, section: String, key: String): String? { val lines = existing?.lines() ?: return null val headerIndex = lines.indexOfFirst { it.trim() == section } @@ -42,7 +37,6 @@ object IniEditor { return null } - /** Removes keys in a `[section]` whose name matches [keyMatches], leaving other keys and sections intact. */ fun removeKeys(existing: String?, section: String, keyMatches: (String) -> Boolean): String { if (existing == null) return "" val lines = existing.lines() @@ -64,11 +58,7 @@ object IniEditor { return (lines.subList(0, headerIndex + 1) + kept + lines.subList(end, lines.size)).joinToString("\n") } - /** - * Sets `key = value` entries inside a single `[section]`, replacing matching keys and appending - * the rest, while leaving every other key and section intact. Used for shared files like - * Dolphin.ini where wholesale section replacement would wipe unrelated settings. - */ + /** Merges keys into one section rather than replacing it, for shared files like Dolphin.ini. */ fun setKeys( existing: String?, section: String, diff --git a/core/model/src/main/kotlin/com/joegec/joycon2android/model/BatteryGauge.kt b/core/model/src/main/kotlin/com/joegec/joycon2android/model/BatteryGauge.kt index eb13df5..834e55d 100644 --- a/core/model/src/main/kotlin/com/joegec/joycon2android/model/BatteryGauge.kt +++ b/core/model/src/main/kotlin/com/joegec/joycon2android/model/BatteryGauge.kt @@ -3,10 +3,7 @@ package com.joegec.joycon2android.model import kotlin.math.roundToInt object BatteryGauge { - // The BLE packet reports a regulated/under-load voltage ~0.6 V below the true cell - // voltage (observed: ~3.30 V reads 75% on a Switch 2, ~3.60 V reads 100%). Anchors are - // Nintendo's Joy-Con level thresholds (dekuNukem docs: 3.3/3.6/3.76/3.9/4.2 V) shifted - // down 0.6 V to match. Below ~3.0 V is extrapolated — no low-battery readings observed yet. + // Anchors and the 0.6 V offset: docs/protocol.md#battery private val voltsToPercent = listOf( 2.70f to 0, 3.00f to 25, diff --git a/core/model/src/main/kotlin/com/joegec/joycon2android/model/ConnectedJoycon.kt b/core/model/src/main/kotlin/com/joegec/joycon2android/model/ConnectedJoycon.kt index a9cc16d..839e9f2 100644 --- a/core/model/src/main/kotlin/com/joegec/joycon2android/model/ConnectedJoycon.kt +++ b/core/model/src/main/kotlin/com/joegec/joycon2android/model/ConnectedJoycon.kt @@ -9,6 +9,5 @@ data class ConnectedJoycon( val assignedPlayer: PlayerNumber? = null, val ready: Boolean = false, ) { - /** Shell accent color (0xRRGGBB) read from the controller's SPI flash, or null if not yet known. */ val accentColor: Int? get() = connectionState.accentColor } diff --git a/core/model/src/main/kotlin/com/joegec/joycon2android/model/EmulatorSetupResult.kt b/core/model/src/main/kotlin/com/joegec/joycon2android/model/EmulatorSetupResult.kt index c32e66c..8df1668 100644 --- a/core/model/src/main/kotlin/com/joegec/joycon2android/model/EmulatorSetupResult.kt +++ b/core/model/src/main/kotlin/com/joegec/joycon2android/model/EmulatorSetupResult.kt @@ -1,15 +1,10 @@ package com.joegec.joycon2android.model -/** Outcome of a one-shot emulator config write. */ enum class EmulatorSetupResult { SUCCESS, NO_PRIVILEGED_ACCESS, - /** - * The emulator was running. Emulators hold their config in memory and flush it on exit, so a - * write while one is open is silently overwritten — verified against Eden, whose in-memory - * bindings replaced ours on shutdown. - */ + /** Emulators flush their in-memory config over ours on exit (seen on Eden), so it must close first. */ EMULATOR_RUNNING, FAILED, } diff --git a/core/model/src/main/kotlin/com/joegec/joycon2android/model/JoyconConnectionState.kt b/core/model/src/main/kotlin/com/joegec/joycon2android/model/JoyconConnectionState.kt index fe860b8..92d5e24 100644 --- a/core/model/src/main/kotlin/com/joegec/joycon2android/model/JoyconConnectionState.kt +++ b/core/model/src/main/kotlin/com/joegec/joycon2android/model/JoyconConnectionState.kt @@ -6,6 +6,6 @@ data class JoyconConnectionState( val ready: Boolean = false, val deviceName: String? = null, val error: String? = null, - /** Shell accent color read from SPI flash, packed as 0xRRGGBB. Null until read (or unset on the controller). */ + /** 0xRRGGBB from SPI flash; null until read, or unset on the controller. */ val accentColor: Int? = null, ) diff --git a/core/model/src/main/kotlin/com/joegec/joycon2android/model/PlayerState.kt b/core/model/src/main/kotlin/com/joegec/joycon2android/model/PlayerState.kt index 3af8521..223a322 100644 --- a/core/model/src/main/kotlin/com/joegec/joycon2android/model/PlayerState.kt +++ b/core/model/src/main/kotlin/com/joegec/joycon2android/model/PlayerState.kt @@ -10,7 +10,7 @@ data class PlayerState( val hasFullController: Boolean get() = left != null && right != null val isSideways: Boolean get() = hasController && !hasFullController && !hasPro - // Raw hardware button state (for UI display showing physical button activity) + // As the hardware reports it, before any sideways rotation ([gamepad]). val pressed: Set get() = if (hasPro) left!!.input.pressed else (left?.input?.pressed ?: emptySet()) + (right?.input?.pressed ?: emptySet()) @@ -23,11 +23,8 @@ data class PlayerState( val leftInput: JoyconInput get() = left?.input ?: JoyconInput() val rightInput: JoyconInput get() = right?.input ?: JoyconInput() - // IMU source for motion consumers: the right Joy-Con of a pair (the "Wiimote hand"), - // otherwise whichever controller is present + // A pair's right Joy-Con is the Wii Remote hand. val motionSource: ConnectedJoycon? get() = right ?: left - // Gamepad-oriented state (rotated sticks + remapped buttons for HID output and consumers - // that want standard gamepad semantics regardless of physical orientation) val gamepad: GamepadState get() = GamepadState.from(this) } diff --git a/core/model/src/main/kotlin/com/joegec/joycon2android/model/SidewaysMapper.kt b/core/model/src/main/kotlin/com/joegec/joycon2android/model/SidewaysMapper.kt index c64e460..63d35a1 100644 --- a/core/model/src/main/kotlin/com/joegec/joycon2android/model/SidewaysMapper.kt +++ b/core/model/src/main/kotlin/com/joegec/joycon2android/model/SidewaysMapper.kt @@ -1,9 +1,6 @@ package com.joegec.joycon2android.model -/** - * Turns a lone Joy-Con's input into its sideways grip — left 90° counter-clockwise, right 90° - * clockwise, as on a Switch. Table and reasoning: docs/virtual-gamepad.md#sidewaysmapper. - */ +/** docs/virtual-gamepad.md#sidewaysmapper */ object SidewaysMapper { private const val STICK_MAX = 4096 diff --git a/core/session/src/main/kotlin/com/joegec/joycon2android/session/SessionCoordinator.kt b/core/session/src/main/kotlin/com/joegec/joycon2android/session/SessionCoordinator.kt index b18eaa8..afd65fd 100644 --- a/core/session/src/main/kotlin/com/joegec/joycon2android/session/SessionCoordinator.kt +++ b/core/session/src/main/kotlin/com/joegec/joycon2android/session/SessionCoordinator.kt @@ -15,16 +15,8 @@ import kotlinx.coroutines.flow.combine import kotlinx.coroutines.launch /** - * The cross-feature glue: joins connected controllers with player assignments into - * [AppUiState], drives the per-packet output pipeline, and orchestrates assignment - * (including its connection/gamepad side effects). - * - * Lives above the feature domains and depends only on their interfaces. The actual - * gamepad/DSU effects are injected as callbacks ([onState], [onPlayerAssigned], - * [onPlayerUnassigned]) so this module never depends on those features. - * - * [onState] fires synchronously on every emission — the controller list re-emits on each - * input change, so per-packet consumers (gamepad, DSU motion) see every update. + * Gamepad and DSU effects arrive as callbacks, so this never depends on those features. [onState] + * is synchronous: a conflated flow would drop per-packet motion. */ class SessionCoordinator( private val scope: CoroutineScope, diff --git a/docs/DESIGN.md b/docs/DESIGN.md index f007a21..78933c2 100644 --- a/docs/DESIGN.md +++ b/docs/DESIGN.md @@ -1,25 +1,17 @@ # Design -> Compose-adapted design-system snapshot. This is a native Android (Jetpack Compose, -> Material 3) app, so this doc captures the **theme in code** rather than web CSS tokens. -> Source of truth: `core/designsystem/.../ui/theme/{Color,Type,Dimens,Theme,AppTextStyles}.kt`, -> plus the connection-screen chrome and landscape layout in `app/.../ui/JoyconScreen.kt`. -> Update this doc when those change. +The theme as built in `core/designsystem/.../ui/theme/` and the screen layout in +`app/.../ui/JoyconScreen.kt`. Update this doc when those change; who the app is for is in +[PRODUCT.md](PRODUCT.md). ## Theme -Dark-only. `Joycon2AndroidTheme` wraps Material 3 with a `darkColorScheme` that maps only -`primary`, `surface`, and `background`; the rest of the palette lives as top-level `Color` -vals consumed directly. Deep near-black blue-gray canvas, single teal accent, controller-color -accents on cards. No light scheme currently exists. - -Physical scene: an enthusiast at a desk or on a couch, often in a dimly lit room, mid-setup, -frequently holding a controller in the other hand. Dark is a deliberate fit, not a default. +Dark-only, deliberately: the app is used mid-setup, often in a dim room. `Joycon2AndroidTheme` maps +only `primary`, `surface` and `background` into a `darkColorScheme`; the rest of the palette is +top-level `Color` vals used directly. One teal accent, plus each controller's own shell colour. ## Color -Defined in `Color.kt`. Values are the real hex in code. - **Surfaces & ink** - `Background` / `surface` — `#0E1116` (deep near-black blue-gray canvas) - `CardBg` — `#161B22` (raised card surface) @@ -39,28 +31,22 @@ Defined in `Color.kt`. Values are the real hex in code. (a lighter red than `ErrorText` so the low % clears AA on the `AccentDim` pill). Shown with a battery icon whose fill tracks the level, so it isn't colour-only. -**Signature: controller shell color.** The controller's real shell accent is read from SPI flash -(packed `0xRRGGBB`), converted to HSV, saturation-boosted ×1.4 (capped). It drives two things: -- `joyconBorderColor()` — the card's hairline border (colour verbatim). -- `controllerActiveColor()` — the same hue with a brightness floor (0.72) so it reads as "lit" - filling a control; every live input inside a `JoyconCard` glows in it (pressed d-pad / face / - shoulder / rail / special buttons, and the stick ring + dot). Delivered via the - `LocalControllerAccent` CompositionLocal, so a dual pair lights each side in its own colour. - `readableInkOn()` picks dark-ink-or-white by WCAG contrast for the label on that fill. - -This is the app's identity move — the UI, not just its border, wears the colour of the actual -hardware. `JoyconBlue`/`JoyconRed` and the teal `Accent` are the fallbacks. Lean into this. +**Signature: the controller's shell colour.** The UI wears the colour of the actual hardware — the +app's identity move, so lean into it. The shell accent is read from SPI flash (`0xRRGGBB`) and +saturation-boosted ×1.4 (capped) in HSV. It drives: +- `joyconBorderColor()` — the card's hairline border, colour verbatim. +- `controllerActiveColor()` — the fill of every live input in a `JoyconCard` (pressed buttons, stick + ring and dot), with a brightness floor of 0.72: a near-black shell would otherwise vanish on the + dark UI. `ControllerAccent` provides it per card, so a pair lights each side in its own colour, and + `readableInkOn()` picks dark ink or white for the label on it. -**Color strategy:** Committed-dark — one teal accent doing most of the lifting, with the -controller shell color as a per-item second accent. Not restrained (the hardware color is -load-bearing), not full-palette. +`JoyconBlue` / `JoyconRed` and the teal `Accent` are the fallbacks. ## Typography -`Type.kt` defines a full Material 3 `Typography` — a fixed sp scale (product UI, not fluid), -~1.2 ratio, with weight/tracking carrying hierarchy alongside size and a small line-height + -tracking bump for light-on-dark. UI text is styled via `MaterialTheme.typography.*`; there are -no scattered `fontSize` literals in the UI. +`Type.kt` is a full Material 3 `Typography`: a fixed sp scale at ~1.2 ratio, with weight and +tracking carrying hierarchy alongside size, and a small line-height and tracking bump for +light-on-dark. UI text uses `MaterialTheme.typography.*`, never a `fontSize` literal. | Role | Size / LH | Weight | Use | |---|---|---|---| @@ -97,37 +83,33 @@ From `Dimens.kt` (all dp unless noted): variants, IMU/legend/battery-icon sub-scales, plus stick sub-tokens (`stickValueGap`, `stickAxisGap`, `crosshairStroke`, `stickIdleRingAlpha`) — fully tokenised, no hard-coded values -Card-based, but cards are the correct affordance here (each = one controller/feature). The -controller-color border gives them identity beyond a plain card grid. +Cards are the right affordance here — each is one controller or feature — and the shell-colour +border gives them identity beyond a plain grid. -### Connection-screen chrome (`app/.../ui/JoyconScreen.kt`) +### Connection-screen chrome -- **Edge-to-edge, top and bottom.** `contentWindowInsets` reserves only the horizontal insets, so - content passes under the transparent status bar and nav bar (`enableEdgeToEdge` in `MainActivity`). -- **Overlaid, scroll-away app bar.** The top app bar is *not* in the Scaffold `topBar` slot (which - reserves space and blocks content going behind the status bar). It's overlaid on the content and - translated up in lockstep with the scroll offset (`graphicsLayer`), so it slides away with no gap - and the content — including the Ko-fi banner, now the first scroll item rather than pinned — - passes behind the status bar. +In `JoyconScreen.kt`: + +- **Edge-to-edge.** `contentWindowInsets` reserves only the horizontal insets, so content passes + under the transparent status and nav bars; each screen adds its own clearance. +- **Overlaid, scroll-away app bar.** Not in the Scaffold's `topBar` slot, which would reserve space. + It overlays the content and is translated up in lockstep with the scroll, so it slides away with + no gap and content (the Ko-fi banner included) passes behind the status bar. Screens add its + height plus the status-bar inset as top clearance. ### Landscape -Landscape lays the whole connected screen out **two-up** to use the wide, short viewport; portrait -keeps single full-width columns. All of it lives in `JoyconScreen.kt`: +Two-up, to use the wide, short viewport; portrait keeps single columns. -- **Players** — a two-column grid (both detailed and compact views). Detailed players are shrunk to - `LandscapePlayerScale` (`0.7`) by a `scaleLayout` modifier that measures the content at `1/scale` - space, draws it scaled down, and reports the smaller size — so the whole controller (buttons, - labels, spacing) shrinks uniformly *and* reflows, letting a full player fit the short height. - Compact rows aren't scaled (already short). -- **Feature cards** — two columns: Virtual Gamepad with its Shizuku dependency stacked beneath it on - the left, DSU Motion Server on the right (so the Shizuku card always sits under the gamepad). -- **Scanning graphics** — the "Looking for Joy-Con 2" card and the sync-button illustration sit side - by side (`ScanningGraphics`). -- **Action buttons** — a row with Disconnect All on the left and Scan on the right; Disconnect keeps - its half (weighted spacer) while a scan is running and the Scan button drops out. +- **Players** — a two-column grid. Detailed players are shrunk to `LandscapePlayerScale` (0.7) by + `scaleLayout`, which scales the whole controller uniformly and reflows, so a full player fits the + short height. Compact rows aren't scaled. +- **Feature cards** — Virtual Gamepad with its Shizuku card beneath on the left, DSU on the right. +- **Scanning graphics** — the "Looking for Joy-Con 2" card and sync-button illustration side by side. +- **Action buttons** — Disconnect All left, Scan right; Disconnect keeps its half while Scan is hidden + during a scan. -Odd trailing items take a half cell with a weighted `Spacer` filling the other half. +An odd trailing item takes a half cell, a weighted `Spacer` filling the other half. ## Components @@ -143,25 +125,12 @@ Shared in `core/designsystem/.../ui/components/`: ## Motion -Mostly minimal and functional today: the top app bar sliding up in lockstep with the scroll (see -chrome above), the portrait view-mode `AnimatedContent` crossfade, and expand/fade transitions on -error boxes and feature-card content. Given the "playful gaming gear" personality, connect / -assign moments still have room for more character (opportunity area). Any motion must honor system -reduced-motion (see PRODUCT.md accessibility). Ease-out curves, no bounce/elastic. - -## Opportunity Areas (for future impeccable passes) - -1. ~~**Type hierarchy** — replace scattered `fontSize*` literals with a real Material type scale.~~ - ✅ Done: full Material 3 `Typography` + `AppType` telemetry/overline roles (see Typography above). -2. **Personality gap** — partly closed: each controller's live inputs now glow in its real shell - colour (see the controller-color signature above). Remaining: motion, and celebratory - connect/assign moments. -3. ~~**Contrast** — reduced-alpha telemetry labels + battery-low failed WCAG AA.~~ ✅ Done: - telemetry now uses solid `TextBright` (values) / `TextDim` (labels) with no sub-threshold alpha, - and `BatteryLow` was lightened to `#FF8A8A`; all clear 4.5:1 (verified numerically). -4. ~~**Color-only status** — pair battery/connection color with icon or text.~~ ✅ Largely addressed: - battery shows a level-filled icon + %, and the connection/Shizuku status pairs its dot with a - text label. -5. **Motion system** — define purposeful, reduced-motion-aware transitions for connect / assign. -6. **Responsive polish** — landscape grid + player scaling is in; a ≤320dp / 200%-font density - check on the dual layout is still open (needs a device). +Minimal and functional: the scroll-away app bar, the portrait view-mode crossfade, and expand/fade +on error boxes and feature-card content. Ease-out curves, no bounce or elastic, and always honour the +system's reduced-motion setting. + +## Open work + +- **Motion with character** — purposeful, reduced-motion-aware transitions for connect and assign + moments, which the "playful gaming gear" personality still lacks. +- **Density check** — the dual layout at ≤320dp and 200% font scale (needs a device). diff --git a/docs/PRODUCT.md b/docs/PRODUCT.md index 70bdcac..0c3efa0 100644 --- a/docs/PRODUCT.md +++ b/docs/PRODUCT.md @@ -6,15 +6,13 @@ product ## Users -Android gaming and emulation enthusiasts who want to use Nintendo Switch 2 **Joy-Con 2** -controllers as system-wide gamepads on their phone or tablet. They are comfortable with -Developer Options, Shizuku, and per-emulator config — this is not a mainstream consumer -audience. Their context is hands-on: often mid-setup at a desk or on a couch, frequently -*holding a controller in one hand* while operating the app with the other, wanting to get -connected and into a game (or emulator) with as little friction as possible and then have -the app stay out of the way. - -The primary jobs, screen by screen: +Android gaming and emulation enthusiasts using Nintendo Switch 2 **Joy-Con 2** and Pro Controllers +as gamepads on a phone or tablet. They are comfortable with Developer Options, Shizuku and +per-emulator config. They use the app mid-setup, at a desk or on a couch, often in a dim room and +*holding a controller in one hand*, and want to get into a game fast and then have the app stay out +of the way. + +The primary jobs: - **Connect** — pair one or more Joy-Con 2 over BLE and confirm they're live. - **Assign** — map controllers to player slots (P1–P8), single or dual (L+R) layouts. - **Enable output** — turn on the virtual gamepad and/or DSU motion server. @@ -23,31 +21,24 @@ The primary jobs, screen by screen: ## Product Purpose -Joy-Con 2 controllers speak BLE over a custom GATT service rather than standard -HID-over-GATT, so Android cannot pair them through normal Bluetooth settings. Joycon2Android -bridges that gap: it connects over GATT, sends the vendor init sequence, parses raw -notification packets, and exposes each assigned player as its own standard virtual HID -gamepad via UHID (through Shizuku's privileged path). It also runs a DSU motion server so -emulators get gyro/accel, and can write emulator controller configs directly. +The controllers speak a custom BLE GATT service rather than HID-over-GATT, so Android can't pair +them from Bluetooth settings. The app connects itself, presents each player as a virtual HID gamepad +through UHID, runs a DSU motion server, and writes emulator configs. -Success is: a controller goes from SYNC-button to "working in my game/emulator" in well -under a minute, multiplayer "just works" (one distinct device per player), and the app is -trustworthy enough to leave running in the background without a second thought. +Success: SYNC to "working in my game" in well under a minute, multiplayer that just works (one device +per player), and an app trustworthy enough to leave running in the background. ## Brand Personality **Playful gaming gear** — energetic, characterful, unmistakably *about controllers and play*, not a generic system utility. Three words: **playful, precise, native-to-gaming**. -The personality is carried by substance, not decoration: the standout identity move is that -each controller's card border is drawn from the controller's **real shell color** read out of -SPI flash (saturation-boosted so it reads on the dark UI). The design should lean into that — -color, motion, and layout that celebrate the hardware — while staying tasteful and technically -credible. Playful with restraint, never toy-like. +Personality comes from substance, not decoration — above all each controller's **real shell +colour** ([DESIGN.md](DESIGN.md#color)). Colour, motion and layout should celebrate the hardware while +staying technically credible: playful with restraint, never toy-like. -Voice: confident and direct, speaks the user's language (BLE, DSU, UHID, emulator names) without -over-explaining. Copy is generic and self-explanatory — toggles describe their use case rather -than giving app-specific step-by-step instructions. +Voice: confident and direct, in the user's language (BLE, DSU, UHID, emulator names). Toggles +describe their use case rather than giving step-by-step instructions. ## Anti-references @@ -75,12 +66,10 @@ than giving app-specific step-by-step instructions. ## Accessibility & Inclusion -- **Contrast (WCAG AA).** Body text ≥4.5:1, large/bold text ≥3:1 against its background — a real - risk on the dark theme with muted-gray text (`TextDim #8B98A5`); verify and bump toward ink - where borderline. +- **Contrast (WCAG AA).** Body text ≥4.5:1, large/bold text ≥3:1 — watch muted text on the dark + theme. - **Reduced motion.** Any animation or live-readout motion honours the system reduced-motion setting with a calm fallback. - **Large touch targets.** ≥48dp for primary controls — the app is often operated one-handed while the other hand holds a controller. -- **Don't rely on color alone.** Battery and connection state pair color with icon/text so - status survives color-blindness (currently battery is color-only via `batteryColor()`). +- **Don't rely on colour alone.** Battery and connection state pair colour with an icon or text. diff --git a/docs/adding-a-feature.md b/docs/adding-a-feature.md index b1f790f..c9297c8 100644 --- a/docs/adding-a-feature.md +++ b/docs/adding-a-feature.md @@ -1,33 +1,28 @@ # Adding or changing a feature -A practical recipe for working within this app's [architecture](architecture.md). Read that -first if you haven't — this guide assumes the layer/module rules. +Assumes the layer and module rules in [architecture.md](architecture.md). -Start by deciding which case you're in: +- **Extending an existing feature** (new action, state or screen section) → + [Recipe A](#recipe-a--extend-an-existing-feature). The common case. +- **A new capability nothing else owns** → [Recipe B](#recipe-b--add-a-new-feature). -- **Extending an existing feature** (new action, new bit of state, new screen section) → - [Recipe A](#recipe-a--extend-an-existing-feature). This is the common one. -- **A genuinely new feature** (a new capability nothing else owns) → - [Recipe B](#recipe-b--add-a-new-feature). - -If you're unsure whether something is its own feature, ask *"what changes for what reason?"* — -if it changes for the same reason as `connection`/`assignment`/`gamepad`/`dsu`, it belongs in -that feature. +Unsure? Ask *"what changes for what reason?"* If it changes for the same reason as an existing +feature, it belongs there. ## The convention plugins -Every module's `build.gradle.kts` applies exactly one of these (defined in -`build-logic/convention/`). Use them — never hand-roll `android {}`/`compileSdk` in a module. +Every module's `build.gradle.kts` applies exactly one of these (`build-logic/convention/`). Never +hand-roll `android {}` or `compileSdk` in a module. | Plugin id | For | Gives you | |---|---|---| | `joycon.kotlin.jvm` | `domain`, `:core:model`, `:core:session`, pure-Kotlin `data` | Kotlin/JVM, Java 11 | -| `joycon.android.library` | Android `data` modules | `com.android.library`, compileSdk 36, minSdk 24, Java 11, JVM unit-test defaults. (AGP 9 has Kotlin built-in — do **not** also apply `org.jetbrains.kotlin.android`; it collides on the `kotlin` extension.) | +| `joycon.android.library` | Android `data` modules | `com.android.library`, compileSdk 36, minSdk 24, Java 11, JVM unit-test defaults. Never also apply `org.jetbrains.kotlin.android`: AGP 9 has Kotlin built in. | | `joycon.android.library.compose` | `presentation`, `:core:designsystem` | the above + Compose | ## Recipe A — extend an existing feature -Adding "do X" to a feature is four edits, following the existing pattern in that feature: +Five edits, following the feature's existing pattern: 1. **Domain — add the capability to the repository interface** (if the data layer needs to do something new). e.g. add `fun setFoo(enabled: Boolean)` to `DsuRepository`. @@ -35,8 +30,8 @@ Adding "do X" to a feature is four edits, following the existing pattern in that 2. **Data — implement it** in the repository impl (`DsuServer`, `Joycon2Manager`, `GamepadOutput`, …). -3. **Domain — add a one-line use case.** Use cases are non-negotiable: presentation reaches data - *only* through them. Always an `operator fun invoke`: +3. **Domain — add a one-line use case.** Presentation reaches data *only* through use cases, always + an `operator fun invoke`: ```kotlin class SetFooUseCase(private val repository: DsuRepository) { @@ -54,14 +49,13 @@ Adding "do X" to a feature is four edits, following the existing pattern in that DsuViewModel(c.observeDsuStatus, c.enableDsu, c.disableDsu, c.setFoo) ``` -For new *state* to observe, the repository exposes a `StateFlow`/`Flow`, an -`Observe…StatusUseCase` wraps it (often `combine`-ing several flows into a status data class), -and the ViewModel `stateIn`s it. Mirror `ObserveDsuStatusUseCase` / `DsuViewModel`. +For new *state*, the repository exposes a `Flow`, an `Observe…StatusUseCase` wraps it (often +`combine`-ing several into a status data class), and the ViewModel `stateIn`s it. Mirror +`ObserveDsuStatusUseCase` / `DsuViewModel`. ## Recipe B — add a new feature -Say the feature is `foo`. Create three modules (drop `presentation` if it has no UI, or `data` -if it's pure logic). +For a feature `foo`, create three modules (drop `presentation` without UI, `data` for pure logic). ### 1. Register the modules @@ -119,8 +113,8 @@ feature/foo/domain/src/main/kotlin/.../foo/ ### 4. Data: the implementation -`feature/foo/data/src/main/kotlin/.../foo/FooManager.kt` implements `FooRepository`. This is the -only layer allowed to touch BLE, the relay, sockets, or framework APIs. +`FooManager` implements `FooRepository`. Only this layer touches BLE, the relay, sockets or +framework APIs. ### 5. Presentation: ViewModel + UI @@ -147,32 +141,14 @@ Compose UI goes alongside it, built from `:core:designsystem` components. - If the feature reacts to player assignment (like gamepad/dsu), hook it into the `SessionCoordinator`'s `onState` in `AppContainer` rather than calling it from the UI. -## Conventions & gotchas - -- **Packages are feature-rooted:** `com.joegec.joycon2android.` for a feature's domain + - data, and `com.joegec.joycon2android..presentation` for its ViewModel + composables. A - module's `namespace` matches its package root (so generated `R`/`BuildConfig` land there). The - one exception is `:core:designsystem`, which owns `com.joegec.joycon2android.ui.components` / - `ui.theme` — features must not add to those packages. -- **Split a crowded package by concern, not layer.** When one package accumulates unrelated - clusters, give each its own sub-package — e.g. gamepad is `gamepad` (the relay output), - `gamepad.privileged` (shell access), `gamepad.emulator` - (emulator config); dsu is `dsu` / `dsu.motion` / `dsu.emulator`. Concern sub-packages live in the - same module, so this is free of build-graph changes. -- **Strings & `R` are per-namespace.** A composable moved into a feature module needs its strings - copied into that module's `res/values/strings.xml` and its `R` import set to the module's - namespace (`com.joegec.joycon2android.foo.presentation.R`). When you rename a package, update the - module's `namespace` to match or the `R` import will dangle. -- **Repositories are app-scoped singletons** owned by `AppContainer` (held by `Application`), so - they outlive the Activity and the foreground service. **ViewModels are feature-scoped** and - depend only on their feature's use cases — never on `:app` or another feature. -- **Cross-feature interactions go through `:core:session`**, not feature-to-feature deps. If two - features need to talk, the coordinator is where. -- **Per-packet paths stay synchronous.** Motion/gamepad output rides `onState`, not a conflated - `StateFlow` — conflation drops samples. -- **Run `:konsist:test` after moving classes.** It fails the build if a ViewModel/use - case/repository-interface lands in the wrong layer. The rules and what they catch are in - [architecture.md](architecture.md#dependency-rules). +## Gotchas + +- **Strings and `R` are per-namespace.** A composable moved into a feature module needs its strings + copied into that module's `res/values/strings.xml` and its `R` import switched to the module's + namespace. Rename a package, and update the module's `namespace` to match. +- Package layout, singleton scope, cross-feature coordination and the per-packet path are in + [architecture.md](architecture.md); `:konsist:test` fails the build if a class lands in the wrong + layer. ## Before you're done — checklist diff --git a/docs/architecture.md b/docs/architecture.md index a408ed2..b8942e6 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -1,16 +1,12 @@ # Architecture -The living reference for how this app is structured. For *how to add or change* a feature, -see [adding-a-feature.md](adding-a-feature.md). +How the app is structured. To add or change a feature, follow [adding-a-feature.md](adding-a-feature.md). -## Shape in one paragraph - -A single-activity Compose app built as a **Gradle multi-module** project, split by **feature × -layer**. Each feature (`connection`, `assignment`, `gamepad`, `dsu`, `update`) has up to three modules — -`domain`, `data`, `presentation` — plus shared `:core` modules and a thin `:app` that wires -everything together. The split exists to *enforce* the dependency rules at compile time: a -ViewModel physically cannot reach a repository implementation, because presentation and data -live in separate modules that share only domain. +A single-activity Compose app, split into Gradle modules by **feature × layer**. Each feature +(`connection`, `assignment`, `gamepad`, `dsu`, `update`) has up to three modules — `domain`, `data`, +`presentation` — over shared `:core` modules and a thin `:app` that wires them together. The split +*enforces* the dependency rules at compile time: presentation and data share only domain, so a +ViewModel cannot reach a repository implementation. ## Module graph @@ -23,9 +19,9 @@ live in separate modules that share only domain. | `:core:buttonmapping:domain` | `joycon.kotlin.jvm` | `:core:model` | | `:core:buttonmapping:data` | `joycon.android.library` | `:core:buttonmapping:domain`, `:core:model` | | `:core:buttonmapping:presentation` | `joycon.android.library.compose` | `:core:buttonmapping:domain`, `:core:designsystem`, `:core:model` | -| `:feature::domain` | `joycon.kotlin.jvm` | `:core:model` ² | -| `:feature::data` | `joycon.android.library`¹ | `:feature::domain`, `:core:model` | -| `:feature::presentation` | `joycon.android.library.compose` | `:feature::domain`, `:core:designsystem`, `:core:model` | +| `:feature::domain` | `joycon.kotlin.jvm` | `:core:model` ²³ | +| `:feature::data` | `joycon.android.library`¹ | `:feature::domain`, `:core:model` ³ | +| `:feature::presentation` | `joycon.android.library.compose` | `:feature::domain`, `:core:designsystem`, `:core:model` ³ | | `:app` | `com.android.application` | every feature module + all `:core` | | `:konsist` | `joycon.kotlin.jvm` (test-only) | — (scans the whole project) | @@ -34,17 +30,13 @@ live in separate modules that share only domain. ¹ `assignment:data` is pure Kotlin (`joycon.kotlin.jvm`) — it has no Android dependencies. ² `gamepad:domain` and `dsu:domain` also depend on `:core:emulatorconfig` and -`:core:buttonmapping:domain` for the one-tap emulator setup (shared ini editing, emulator paths, and -the user's button mapping). Each feature owns its own emulator-config -*generators* — gamepad mapping in `gamepad:domain`, DSU/motion mapping in `dsu:domain` — over that -shared leaf; no feature depends on another feature. +`:core:buttonmapping:domain` for one-tap emulator setup. Each owns its own config *generators* over +that shared leaf; no feature depends on another. -³ `update` needs neither `:core:model` nor `:core:emulatorconfig` — it only reads GitHub's -releases API and hands an APK to the system installer. +³ Not `update`, which only reads GitHub's releases API and hands an APK to the system installer. -The three convention plugins live in `build-logic/convention/` and dedupe all the per-module -Gradle config (compileSdk, Java 11, Compose, test options) so each `build.gradle.kts` is a few -lines. See [adding-a-feature.md](adding-a-feature.md) for what each plugin sets up. +Per-module Gradle config (compileSdk, Java 11, Compose, test options) lives in three convention +plugins in `build-logic/convention/` ([which to use](adding-a-feature.md#the-convention-plugins)). ## The layers @@ -71,28 +63,28 @@ app-specific lives in a feature's presentation, not here. cases. It's the one place that depends on more than one feature's domain, because assembling the app's `AppUiState` *is* the cross-feature concern (connection + assignment → player state). -**`:core:emulatorconfig`** — shared primitives for the one-tap setup: `IniEditor` (splices keys -into ini text, leaving the user's others intact), `DolphinPaths` / `EdenPaths` (package and config -locations), and `EdenControls` (the `[Controls]` vocabulary both features write). Shared because the -gamepad and DSU features both write to Dolphin and Eden. Holds *mechanism*, not feature logic — the -per-emulator config generators live in their owning feature's `domain`. - -**`:core:buttonmapping`** — the user-editable Joy-Con → emulator button mapping: the mapping model, -the layouts a body can start from and the sideways-remote switch they seed (`domain`, shipped -layouts in `preset/`), their persistence (`data`), and the mapping editor (`presentation`). Both the -gamepad and DSU config generators read it. A target holds *every* source bound to it, so Dolphin ORs -them into one expression while Eden, which binds one input per key, keeps the first. - -Everything is keyed by `PlayerBody` — a player plus the body they hold — so each player maps -independently. **A layout is never a stored reference, only a name for a set of bindings**: applying -one copies out everything it says (`ApplyPlayerMappingUseCase`), and `MappingLayouts.matching` reads -the name back by comparing what a body is bound to against every layout the app ships and every one -the user saved (`SavedLayout`, scoped to the body it came from). No match is the editor's "Custom". -That is what lets a deleted layout take away its name and nothing else, and lets the same bindings -answer to it again the day an identical layout is saved back. `GlobalLayout` freezes the whole -session the same way — every player's bindings in full, not a layout id — so it restores what it -saved whatever has happened to the layouts since; it carries the bodies it was saved from, which is -why it can only be restored onto those players. +**`:core:emulatorconfig`** — mechanism shared by the gamepad and DSU setup, which both write to +Dolphin and Eden: `IniEditor`, `DolphinPaths` / `EdenPaths`, and `EdenControls` (the `[Controls]` +keys both features write; each setup first clears every player's old keys). The generators +themselves live in their feature's `domain`. The config files sit in the emulator's `Android/data`, +writable by a shell-uid process (Shizuku), not by the app. + +**`:core:buttonmapping`** — the user's Joy-Con → emulator mapping: model and layouts (`domain`, +shipped layouts in `preset/`), persistence (`data`) and the editor (`presentation`). See +[Button mapping](#button-mapping). + +## Button mapping + +- **Keyed by `PlayerBody`** — a player plus the body they hold — so each player maps independently. +- **A target holds every source bound to it.** Dolphin ORs them into one expression; Eden binds one + input per key, so it keeps the first the body can emit. +- **A layout is a name for a set of bindings, never a stored reference.** Applying one copies out + everything it says, layered over the console default so a target it omits is still bound. + `MappingLayouts.matching` reads the name back by comparing a body's bindings with every shipped + layout and every one the user saved for that body; no match is "Custom". So deleting a layout + removes only its name, and the same bindings answer to it again if an identical one is saved. +- **`GlobalLayout` freezes the whole session** — every player's bindings in full, and the bodies they + held — so it restores exactly what it saved, but only onto those players. ## Dependency rules @@ -125,7 +117,7 @@ lands there. `:core:designsystem` solely owns `com.joegec.joycon2android.ui.comp ## Composition root — `AppContainer` -`app/.../AppContainer.kt` is the only place the abstractions and implementations meet. It: +The only place abstractions and implementations meet. It: - constructs each **repository implementation** (`Joycon2Manager`, `DsuServer`, `PlayerAssignmentManager`, `GamepadOutput`, `PrivilegedAccess`) as **app-scoped singletons**, @@ -139,10 +131,8 @@ no state of its own. ## ViewModels and the UI -One **ViewModel per feature**, each in its own presentation module, constructed in -`MainActivity` via `viewModelFactory { initializer { … } }` that pulls the relevant use cases -off `AppContainer`. This keeps the ViewModel class dependent only on its domain — never on -`:app`. +One **ViewModel per feature**, in its presentation module, built in `MainActivity` by a +`viewModelFactory` that pulls use cases off `AppContainer` — so it depends only on its domain. - `DsuViewModel` — DSU status, enable toggle, motion settings and emulator auto setup. - `GamepadViewModel` — gamepad status, Shizuku availability and emulator auto setup. @@ -167,9 +157,9 @@ BLE notify ─→ Joycon2Manager (connection/data, ControllerRepository) └─→ ObserveSessionUseCase ─→ Joycon2ViewModel ─→ AppUiState ─→ Compose ``` -The gamepad and DSU outputs ride a **synchronous per-packet path** off the coordinator's -`onState` callback, not a conflated `StateFlow` — conflation would drop motion samples. The -hardware-level detail is in [virtual-gamepad.md](virtual-gamepad.md) and [dsu-motion.md](dsu-motion.md). +The gamepad and DSU outputs ride the coordinator's **synchronous** `onState` callback, not a +conflated `StateFlow`, which would drop motion samples. Hardware detail: +[virtual-gamepad.md](virtual-gamepad.md), [dsu-motion.md](dsu-motion.md). ## Build & test @@ -179,6 +169,3 @@ hardware-level detail is in [virtual-gamepad.md](virtual-gamepad.md) and [dsu-mo ./gradlew :konsist:test # architecture-rule tests only ./gradlew :feature:dsu:data:test # one module's tests ``` - -`:konsist` enforces the layer-placement rules described above; run it after moving classes -between modules. diff --git a/docs/dsu-motion.md b/docs/dsu-motion.md index 2a4b6e0..1f9db75 100644 --- a/docs/dsu-motion.md +++ b/docs/dsu-motion.md @@ -7,9 +7,8 @@ How motion reaches emulators, and why the emulator bindings are shaped the way t `DsuServer` implements the [cemuhook protocol](https://v1993.github.io/cemuhook-protocol/) over UDP on port 26760. -- **Bound to IPv4 `127.0.0.1`.** `getLoopbackAddress()` resolves to IPv6 `::1` on Android, and a - socket there never sees the `127.0.0.1` datagrams emulators send. -- **Pad batches ride a buffered channel**, not a `StateFlow` — conflation would drop motion samples. +**Bound to IPv4 `127.0.0.1`.** `getLoopbackAddress()` resolves to IPv6 `::1` on Android, and a +socket there never sees the `127.0.0.1` datagrams emulators send. | Class | Job | |---|---| @@ -53,112 +52,104 @@ separately. Its complementary filter makes the *accelerometer* the authority on gyro signs cannot be judged from pointer direction alone; gyro shows up in the fast response, accel in the settled position. -- **Raw x was documented as "left" until 2026-09**, when a rail-down static pose — SL/SR against the - table, so gravity points toward the controller's right — read +1 g on the wire's left axis. - Mirrored. Left/right tilt had been reaching games reversed, and because angular velocity is a - pseudovector, roll had to mirror with it to stay physically consistent, which is why both flipped - together. -- **Yaw is the one sign no measurement here pins.** It turns about gravity, so a static pose cannot - see it and neither can the accel/gyro consistency check. It is kept as the pointer's horizontal - response reports it. Mirroring x strictly implies mirroring yaw too, so if horizontal pointing - ever reads backwards, flip yaw rather than re-deriving the frame. +- **Raw x is the controller's right.** A rail-down pose (SL/SR on the table, gravity toward the + controller's right) reads +1 g on the wire's *left* axis (2026-09). Roll mirrors with x, since + angular velocity is a pseudovector. +- **Yaw is the one sign no measurement pins.** It turns about gravity, so neither a static pose nor + the accel/gyro consistency check sees it; it follows the pointer's horizontal response. Mirroring x + strictly implies mirroring yaw too, so if horizontal pointing ever reads backwards, flip yaw rather + than re-deriving the frame. - **Left Joy-Con and Pro are assumed to share the raw frame** — unverified. Recalibrate with `tools/dsu_client` if their motion feels rotated. -**Gyro bias.** Joy-Con 2 gyros idle with a constant offset (+0.2 °/s yaw, +0.9 °/s roll observed), -which clients integrate into pointer drift. Whenever a controller stays within ~2.4 °/s for ~2 s, -`GyroCalibrator` adopts the window mean as its bias, as the Switch does. +### Gyro bias + +Joy-Con 2 gyros idle with a constant offset (+0.2 °/s yaw, +0.9 °/s roll observed), which clients +integrate into pointer drift. Whenever a controller's gyro stays within ~2.4 °/s for 240 samples +(hand tremor exceeds that), `GyroCalibrator` adopts the window mean as its bias, as the Switch does. ### Sideways Joy-Cons -A lone Joy-Con's buttons and stick already arrive rotated into its sideways grip -([virtual-gamepad.md](virtual-gamepad.md#sidewaysmapper)), and Eden presents it as a Pro -Controller, so its motion is turned 90° about the button face to match. Without it, tilting read as -if the Joy-Con's nose pointed at the screen. +A lone Joy-Con's buttons and stick arrive already rotated into its sideways grip +([virtual-gamepad.md](virtual-gamepad.md#sidewaysmapper)), and Eden presents it as a Pro Controller, +so `SidewaysMotion` turns its motion 90° about the button face to match. Without that, tilt reads as +if the nose pointed at the screen. -- **The direction was measured**, in Eden's Mario Kart 8, and is the *opposite* of the stick's turn — - the IMU axes don't line up with the stick's. -- **A pair's second hand isn't turned**, even though it streams alone on its slot (`DsuStream.heldSideways`). -- **Dolphin maps it back.** Its emulated Wii Remote is the Joy-Con's own body, so - `DolphinWiimoteConfig` turns a lone Joy-Con's IMU inputs back about the button face (table in the +- **The direction was measured** (Eden, Mario Kart 8) and is the *opposite* of the stick's turn: the + IMU axes don't line up with the stick's. +- **A pair's second hand isn't turned**, though it streams alone on its slot (`DsuStream.heldSideways`). +- **Dolphin turns it back.** Its emulated Wii Remote is the Joy-Con's own body, so + `DolphinWiimoteConfig` maps a lone Joy-Con's IMU inputs back about the button face (table in the [README](../README.md#manual-setup)), putting the nose on the shoulder edge the player aims. The - bodies rotate into their grips opposite ways, so their tables are each other half a turn. -- **A player can play as a sideways Wii Remote** — a switch on their card in the mapping editor, - seeded by their layout (`MappingLayout.sidewaysRemote`, true only for **Mario Kart**) and - overridable per player (`SidewaysRemoteRepository`; applying a layout clears the override). A game written for that grip reads gravity against a remote whose nose points - left, which is where a *left* Joy-Con's L/ZL edge already points — so only a right Joy-Con turns, - giving up its own body (and with it R/ZR as the nose: aiming moves to the tail) to steer true. - Both bodies also turn their four D-pad bindings a quarter, since the player's up is a sideways - remote's right. That is Dolphin's own `dpad_sideways_bitmasks`, applied here so its *Sideways Wii - Remote* option can stay off — the option would also turn the accelerometer, which we have turned - already. -- **A sideways body turns a flick into a trick.** Mario Kart Wii tricks off a flick, and a flick of - something Joy-Con sized is mostly rotation: captured ones peak past 1200 °/s summed while carrying - barely a g of linear jerk, where jerking a real Wii Wheel throws the whole thing. The game has no - MotionPlus and reads only the accelerometer, so the flick never reaches it — on hardware it - wouldn't either. The gyroscope therefore fires it, which hardware could not do: each axis summed - with its opposite input gives |rate| (Dolphin clamps one of a pair at zero), over `/15` and a half - dead zone, which fires above 11 rad/s and leaves the sharpest measured steering (6.5) and aiming - (4.1) a wide berth. `Shake` is a mapping target of its own too, so a pair — which has no sideways - flick to read — can trick from a button. - - **Two ways of delivering it do not work, and both were tried.** *Amplifying the accelerometer's - own transient* does nothing, because an emulated Wii Remote saturates around +3.9/−4.9 g - (`ACCEL_ZERO_G` 0x80, `ACCEL_ONE_G` 0x9A over 8 bits) and the push already passes that, so a - bigger number only clips sooner. *Dolphin's `Shake` group* does nothing either: bound straight to - a key in Dolphin's own config, a full 7 g oscillation of it never once landed a trick (tested - 2026-09) — and not for want of reaching the game, since `m_shake_state.acceleration` is added to - the reported acceleration unconditionally, whether or not `IMUAccelerometer` is bound. Neither is - written any more. - - **What is written goes into the accelerometer**, the path that demonstrably reaches the game since - steering is read from it: `pulse(flick, 0.6) * sin(timer(0.15) * 2π) * 50` added to every - `IMUAccelerometer` input, the three opposites carrying a half-turn of phase. It is a *shake*, not - a push — an oscillation held for 0.6 s at about 6.7 Hz, each input of a pair swung half a cycle - apart so the remote is thrown back and forth rather than leaned on. That shape is what landed a - trick by hand, shaking a Joy-Con hard for about a second, where a single held push did not. - `pulse()` gives a flick and a held button the same shake however long either lasted. - - **The flick reads pitch, and only pitch.** Measured over three captures (2026-09-22, right - Joy-Con, 15 ms stream), a flick is 59–89% pitch on *both* bodies — a lone sideways Joy-Con and a - pair alike, despite a lone one being rotated into its grip before it reaches the wire — while - steering a wheel is roll and never exceeds 2.8 rad/s of pitch: - - | | raw pitch, per gesture | + bodies rotate into their grips opposite ways, so their tables are half a turn apart. +- **Measure the pointer, don't reason about it.** `tools/dsu_client` plus a replay of + `EmulateIMUCursor` settles in minutes what guessing costs days. Posed captures mislead: asked to + hold an "aim up", a player makes a different rotation from the one they make while playing, so + compare a captured session against candidate tables by how much cursor travel each yields. + +### Playing as a sideways Wii Remote + +A per-player switch in the mapping editor, seeded by the layout (`MappingLayout.sidewaysRemote`, on +for both Mario Kart layouts) and overridable (`SidewaysRemoteRepository`; applying a layout clears +the override). + +- **Only a right Joy-Con turns.** A game written for that grip reads gravity against a remote whose + nose points left, where a left Joy-Con's L/ZL edge already points. A right Joy-Con gives up its own + body to steer true, and with it R/ZR as the nose: aiming moves to the tail. +- **The D-pad turns a quarter on both bodies**, since the player's up is a sideways remote's right. + That is Dolphin's own `dpad_sideways_bitmasks`, written into the bindings so Dolphin's *Sideways + Wii Remote* option stays off — the option would turn the accelerometer a second time. +- **Pointing and a wheel want a right Joy-Con's nose half a turn apart**, and no Dolphin option + bridges them: `GetOrientation()` turns a quarter (Sideways) or a quarter about the left axis + (Upright), and reaches only the accelerometer, never `GetTotalTransformation()` and so never the + pointer. Hence the choice lives in the layout. + +### Tricks and wheelies + +Mario Kart Wii tricks and pops wheelies off a flick, and reads only the accelerometer (no +MotionPlus). A Joy-Con flick is mostly rotation — captures peak past 1200 °/s summed with barely a g +of linear jerk, where jerking a real Wii Wheel throws the whole thing — so on its own it never +reaches the game. Playing sideways, the flick is read from the gyroscope and delivered as a shake of +the accelerometer, the path steering proves reaches the game. `Shake` is also a mapping target, so a +button can trick too. + +`DolphinWiimoteConfig` appends this to `IMUAccelerometer/Up`, and to `/Down` with Up and Down +swapped: + +``` ++ pulse(, 0.6) * max(sin(timer(0.15) * 6.2832), 0) * 50 + = (`Gyro Pitch Up` / 9) & not(pulse(`Gyro Pitch Down` / 9, 0.4)) +``` + +- **Pitch only.** Over three captures (2026-09-22, right Joy-Con, 15 ms stream) a flick is 59–89% + pitch on a lone sideways Joy-Con and a pair alike, while steering is roll: + + | Gesture | Raw pitch | |---|---| | steering, hard, 25 s | ≤ 2.8 rad/s | | wheelie flicks | 7.0 – 10.7 | | trick flicks | 8.8 – 14.5 | - Reading pitch alone therefore separates a flick from a turn by axis rather than by rate, which no - slew limiter could: the wheelie's down-flick is a slow gesture, and a limiter fast enough to - reject a turn ate all but 0.9 rad/s of it. `pulse()` fires as its input crosses a half, so the - threshold is 4.5 rad/s — 1.6× above the worst steering, 1.6× below the weakest gesture. - - **Direction matters, because a wheelie is a state.** An up-flick starts one and a down-flick drops - it, where a trick takes any direction and only one per jump. So the remote is jerked the way it was - flicked — positive wire pitch is up on both bodies — and the wave is *half* rectified - (`max(sin(…), 0)`), since a full one would cancel the wheelie it just started four times a second. - - **Each direction locks the other out for 0.4 s**, because every flick rebounds the opposite way - 0.12–0.32 s later, and a rebound is often stronger than a genuine flick elsewhere in the same - capture — 8.5 against 7.0 — so only order can tell them apart. The gate sits on the pulse's input - rather than its output, so a jerk already running finishes. - - One more thing verified against Dolphin's source (2026-09), since the expressions depend on it: - `|` is a max, and it binds looser than `/`. -- **Pointing and a wheel want the nose half a turn apart on a right Joy-Con**, and no Dolphin option - bridges them: `GetOrientation()` turns a quarter (Sideways) or a quarter about the left axis - (Upright), and it reaches only the accelerometer the game reads, never - `GetTotalTransformation()` and so never the pointer. Hence the choice lives in the layout. -- **Measure the pointer, don't reason about it.** `tools/dsu_client` plus a replay of - `EmulateIMUCursor` settles in minutes what guessing costs days. Posed captures mislead: asked to - hold an "aim up", a player produces a different rotation from the one they make while playing — - compare a captured session against candidate tables by how much cursor travel each yields. -- **`IMUIR/Total Yaw` is widened to 60°.** Dolphin's 25° clamps the cursor after ±12.5° of turn, - which a hand-held aim overruns constantly; the clamp reads as the pointer sticking. -- **Leave Dolphin's "Sideways Wii Remote" off.** A sideways layout already writes that quarter turn - itself, into both the motion and the D-pad; the option would apply it twice. + `pulse()` fires as its input crosses a half, so the threshold is 4.5 rad/s: 1.6× above the worst + steering, 1.6× below the weakest flick. Separating by axis beats a slew limiter, which, fast enough + to reject a turn, ate all but 0.9 rad/s of the slow wheelie down-flick. +- **Direction matters, because a wheelie is a state**: an up-flick starts one, a down-flick drops it. + The remote is jerked the way it was flicked (positive wire pitch is up on both bodies), and the wave + is half-rectified; a full one would cancel the wheelie four times a second. +- **Each direction locks the other out for 0.4 s.** Every flick rebounds the opposite way + 0.12–0.32 s later, often stronger than a genuine flick (8.5 against 7.0), so only order tells them + apart. Gating `pulse()`'s input rather than its output lets a running shake finish. +- **A shake, not a push**: 0.6 s at ~6.7 Hz, 50 m/s² — past what an emulated remote reports. Shaking + a Joy-Con hard for about a second landed tricks by hand where a single held push didn't. +- In Dolphin's expressions `|` is a max and binds looser than `/` (checked against its source, 2026-09). + +Two approaches fail, so don't retry them: + +- **Amplifying the accelerometer's own transient.** An emulated Wii Remote saturates around + +3.9/−4.9 g (`ACCEL_ZERO_G` 0x80, `ACCEL_ONE_G` 0x9A over 8 bits) and the push already exceeds it, + so a bigger number only clips sooner. +- **Dolphin's `Shake` group.** A full 7 g oscillation bound to a key never landed a trick (2026-09), + though `m_shake_state.acceleration` does reach the reported acceleration. ## Report rate @@ -221,6 +212,8 @@ What `DolphinWiimoteConfig` writes, and why: `DSUClient//Joycon2:Accel Up` reads the second hand's slot. A real Nunchuk has no gyro. - **Recenter** is R1 (L1 on a lone left Joy-Con). Gyro pointing drifts, and pressing it while aiming at the screen centre is what summons the pointer. +- **`IMUIR/Total Yaw` is 60°.** Dolphin's 25° clamps the cursor after ±12.5° of turn, which a + hand-held aim overruns; the clamp reads as the pointer sticking. ## MotionPlus tutorial replays diff --git a/docs/protocol.md b/docs/protocol.md index 2ead36b..58b64f9 100644 --- a/docs/protocol.md +++ b/docs/protocol.md @@ -25,7 +25,8 @@ Manufacturer data for ID `0x0553` carries: - **Bytes `[5..6]`** — little-endian product ID: `0x2067` left Joy-Con 2, `0x2066` right Joy-Con 2, `0x2069` Switch 2 Pro Controller. The advertisement has no local name, so this is the only type - signal before input starts. + signal before input starts. Left and right are confirmed on hardware; the Pro value is community + reverse-engineering. - **Bytes `[10..15]`** — the bonded host's MAC. Holding SYNC zeroes it; a button press on a synced controller wakes it into a short reconnect advertisement carrying the address. The scanner only accepts a zeroed field, so stray presses on nearby synced Joy-Cons don't flash into the list. @@ -116,16 +117,34 @@ A left Joy-Con's right-stick bytes are garbage, and a right Joy-Con's left-stick 0x01 GR 0x02 GL ``` +## Player LEDs + +``` +09 91 01 07 00 08 00 00 00 00 00 00 00 00 00 +``` + +The mask's low nibble lights P1–P4 solid (`0x01`, `0x02`, `0x04`, `0x08`), its high nibble flashes +them (`0x10` … `0x80`). `0xF0`, all flashing, is the controller's default cycling animation. + ## SPI reads -The controller keeps its factory data in SPI flash, read back through the command-response -characteristic. `SpiColorParser` wants one field out of it: the **shell accent colour**, 3 bytes -RGB at `0x01301F`. Not the body colour at `0x013019` — that is the near-black shell, identical on -both Switch 2 Joy-Cons, so it identifies nothing. The accent is the per-side colour (coral right, -blue left) the UI paints each controller with. We request the surrounding DeviceInfo block and pull -the field out of the reply. +The controller keeps its factory data in SPI flash. The app wants one field: the **shell accent +colour**, 3 bytes RGB at `0x01301F` — the per-side colour (coral right, blue left) the UI paints each +controller with. Not the body colour at `0x013019`: that is the near-black shell, the same on both +Joy-Cons. -Reply layout, little-endian, confirmed against a live controller: +The request reads the surrounding DeviceInfo block, `0x40` bytes from `0x013000`: + +``` +02 91 00 04 00 08 00 00 40 7E 00 00 00 30 01 00 +report cmd len magic address, LE +``` + +Byte 2 must be `0x00`, as HandHeldLegend's procon2tool sends it. The init commands carry `0x01` +there, but an SPI read with `0x01` gets no reply. + +The reply arrives on the command-response characteristic. Layout, little-endian, confirmed on a live +controller: | Offset | Meaning | |---|---| @@ -135,8 +154,15 @@ Reply layout, little-endian, confirmed against a live controller: | `12..15` | source address, echoing the address requested | | `16..` | data bytes, starting at that source address | -The echoed source address is what makes the read robust: the field's offset in the reply is -`16 + (wanted address − echoed address)`, so the block can be requested at any alignment. +`SpiColorParser` finds the field at `16 + (wanted address − echoed address)`, so the block can be +requested at any alignment. + +## Battery + +The packet's voltage reads ~0.6 V below the cell's: ~3.30 V shows 75% on a Switch 2, ~3.60 V shows +100%. `BatteryGauge` interpolates Nintendo's Joy-Con thresholds (3.3 / 3.6 / 3.76 / 3.9 / 4.2 V, +from dekuNukem's docs) shifted down 0.6 V. Below ~3.0 V is extrapolated; no low readings have been +captured yet. ## Stick range and centre @@ -151,17 +177,21 @@ rest: left Joy-Con x 2080 y 2157 right Joy-Con x 2014 y 2022 Rest isn't the midpoint of travel (those extremes midpoint to 2150/2125), so it has to be sampled. Treating 2048 as centre and half-span leaves full deflection at ~60% with a 4–5% drift at rest. -`StickCalibrator` learns each axis' centre from the first still window after connecting, then -freezes it — a stick held at full deflection is perfectly still too. It scales each direction by -its own span, the same centre/below/above triple the factory calibration stores. It runs where -packets are parsed, so the live display, gamepad and DSU all see corrected values. +`StickCalibrator` runs where packets are parsed, so the live display, gamepad and DSU all see +corrected values: + +- **Centre** is learned from the first still window (30 samples), then frozen: a stick held at full + deflection is perfectly still too. +- **Each direction scales by its own span**, the centre/below/above triple the factory calibration + stores. Spans are seeded just under the smallest travel measured (~1180 LSB), so full tilt works + from the first packet, and only ever widen. ## Android BLE gotchas 1. **MTU first.** The default ATT MTU of 23 truncates 63-byte notifications: `requestMtu(247)` after connecting, wait for `onMtuChanged`, then discover services. -2. **One GATT operation at a time.** Queue them and advance only on the matching callback - (`GattOpQueue`). +2. **One GATT operation at a time.** A second issued before the callback is silently dropped. + `GattOpQueue` advances on the matching callback, or after a timeout if none comes. 3. **Write the CCCD.** `setCharacteristicNotification(true)` alone delivers nothing; descriptor `0x2902` must be written too. 4. **Pass `TRANSPORT_LE`** to `connectGatt`, or it may try classic Bluetooth. diff --git a/docs/virtual-gamepad.md b/docs/virtual-gamepad.md index 65f0301..3ed21ea 100644 --- a/docs/virtual-gamepad.md +++ b/docs/virtual-gamepad.md @@ -17,12 +17,9 @@ The app creates gamepads through Linux's UHID (user-space HID) interface: The device uses `BUS_USB` with generic IDs `0x1234:0x5678` so the kernel's `hid-generic` driver binds it. Nintendo's IDs would let `hid-nintendo` claim it and reject it. -### One device per player - -Each assigned player gets its own device, `Joy-Con Virtual Gamepad `. The name carries the -player number, but Android numbers input devices by enumeration order: with P1, P2 and P4 (no P3), -P4 is the third pad, `Android/3/Joy-Con Virtual Gamepad 4`. That's why `DolphinGcpadConfig` keys -`Device = Android//…` on enumeration rank while the port stays on the player number. +Each assigned player gets its own device, `Joy-Con Virtual Gamepad `, named by player number. +Emulators address it by enumeration rank instead: with P1, P2 and P4, P4 is the third pad, +`Android/3/Joy-Con Virtual Gamepad 4` ([Device identity](#device-identity)). ## Report layout @@ -59,7 +56,8 @@ shift: - **GR and C overflow.** One gamepad collection carries 15 buttons — a 16th lands on `0x13F`, which no key layout names — and the Switch 2 controllers have 17. For a Button usage outside a pointer/joystick/gamepad collection Linux falls back to `BTN_MISC + n - 1`, which key layouts name - `BUTTON_1..16`: GR is **188**, C is **189**. Firmware that re-publishes pads (see + `BUTTON_1..16`: GR is **188**, C is **189**. The collection is vendor-defined so nothing + interprets it. Firmware that re-publishes pads (see [Device identity](#device-identity)) forwards only keys it knows, so it may drop these two. - **Triggers are Brake (left) and Accelerator (right)**, never reversed. Android aliases `AXIS_LTRIGGER` to `AXIS_BRAKE` and `AXIS_RTRIGGER` to `AXIS_GAS`, and re-publishing firmware @@ -102,29 +100,27 @@ Motion is turned too, but for DSU only — see [dsu-motion.md](dsu-motion.md#sid ## Emulator config -- **Dolphin** sees each player's UHID pad as a distinct Android input device and qualifies its - bindings `Android//Joy-Con Virtual Gamepad `. `DolphinGcpadConfig`'s - name tables are Dolphin's own fixed names for each Android keycode and hat direction, captured - from a real mapping rather than derived. +- **Dolphin's names** for each Android keycode and hat direction (`DolphinGcpadConfig`) are + captured from a real mapping, not derived. - **Every stick direction is its own Dolphin input**, so a stick target can mix tilts and buttons freely without the tilts losing their analog range. - **Eden nests whole bindings inside one value** for a stick assembled from digital inputs, so `EdenControls` escapes their `:`, `,` and `$` as `$0`, `$1` and `$2` — exactly as Eden's own `ParamPackage` serializes them. -- **The relay remaps by orientation** before anything reaches an emulator (see - [`SidewaysMapper`](#sidewaysmapper)), so which physical button arrives at a given Android control - differs between a sideways single Joy-Con and a pair. Both Dolphin and Eden configs resolve a - customized source to what that body actually emits. +- **Sources resolve to what the body emits.** The relay rotates a lone Joy-Con + ([`SidewaysMapper`](#sidewaysmapper)) before anything reaches an emulator, so both generators map a + chosen source through that rotation. ## Device identity An emulator addresses a pad by `port` — its enumeration rank, not the player number — plus, for Eden, a `guid` built from vendor/product IDs. Both are read from the live input-device list on every -setup, never derived. A guessed number binds a config to the wrong device or to none, and any -handheld with a built-in controller already occupies the low numbers. +setup, never derived: a guessed number binds a config to the wrong device or to none, and a handheld's +built-in controller already occupies the low numbers. A player whose pad isn't enumerated yet is +skipped. -**Every emulator picks its own quantity, and none of them is the player number.** Each rule below is -read from that emulator's own source and mirrored in `VirtualGamepadIdentity`: +Each emulator counts differently. Each rule below is read from that emulator's source and mirrored +in `VirtualGamepadIdentity`: - **Dolphin** takes the id in its `Android//` qualifier from `InputDevice.getControllerNumber()`, Android's gamepad enumeration counter. @@ -133,13 +129,13 @@ read from that emulator's own source and mirrored in `VirtualGamepadIdentity`: - **Eden** (yuzu lineage) numbers `port` by walking `InputDevice.getDeviceIds()` and counting *every* physical game controller it passes, so any built-in pad shifts ours along. In `InputHandler.getDevices()` a controller number already registered is skipped but still consumes - a port, which `edenGamepadPorts` reproduces. + a port, which `edenGamepads` reproduces. -The same goes for the vendor/product ids behind Eden's `guid`: some handheld firmware re-publishes -an external gamepad under the built-in controller's ids (AYN's Odin/Thor line does), leaving two -devices carrying our name, and a binding with the wrong guid is silently ignored. So every field of -a player's identity is taken from one and the same `InputDevice` — the last match, which is the -republished one where that happens. +Eden's `guid` is product then vendor ID, each a 16-digit hex half. Some handheld firmware +re-publishes an external gamepad under the built-in controller's IDs (AYN's Odin/Thor line does), +leaving two devices with our name, and a binding with the wrong guid is silently ignored. So every +field of a player's identity comes from one `InputDevice`: the last match, the republished one where +that happens. Each setup also clears the player's old keys, so a layout or port change can't leave a stale binding firing on another player's port. diff --git a/feature/assignment/data/src/main/kotlin/com/joegec/joycon2android/assignment/PlayerAssignmentManager.kt b/feature/assignment/data/src/main/kotlin/com/joegec/joycon2android/assignment/PlayerAssignmentManager.kt index 73f0990..02b0cb5 100644 --- a/feature/assignment/data/src/main/kotlin/com/joegec/joycon2android/assignment/PlayerAssignmentManager.kt +++ b/feature/assignment/data/src/main/kotlin/com/joegec/joycon2android/assignment/PlayerAssignmentManager.kt @@ -6,10 +6,6 @@ import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow -/** - * Maps Joy-Con BLE addresses to player numbers. - * Enforces that each player can have at most one Left and one Right controller. - */ class PlayerAssignmentManager : AssignmentRepository { private val _assignments = MutableStateFlow>(emptyMap()) diff --git a/feature/assignment/domain/src/main/kotlin/com/joegec/joycon2android/assignment/AssignmentRepository.kt b/feature/assignment/domain/src/main/kotlin/com/joegec/joycon2android/assignment/AssignmentRepository.kt index 3a055b7..282b799 100644 --- a/feature/assignment/domain/src/main/kotlin/com/joegec/joycon2android/assignment/AssignmentRepository.kt +++ b/feature/assignment/domain/src/main/kotlin/com/joegec/joycon2android/assignment/AssignmentRepository.kt @@ -4,10 +4,7 @@ import com.joegec.joycon2android.model.PlayerNumber import com.joegec.joycon2android.model.Side import kotlinx.coroutines.flow.StateFlow -/** - * Holds which Joy-Con (by BLE address) is assigned to which player, enforcing one Left + - * one Right (or one Pro) per player. Implemented in the data layer. - */ +/** One Left and one Right, or one Pro, per player. */ interface AssignmentRepository { val assignments: StateFlow> diff --git a/feature/assignment/domain/src/main/kotlin/com/joegec/joycon2android/assignment/ComboAssignment.kt b/feature/assignment/domain/src/main/kotlin/com/joegec/joycon2android/assignment/ComboAssignment.kt index d9909ba..d7260e2 100644 --- a/feature/assignment/domain/src/main/kotlin/com/joegec/joycon2android/assignment/ComboAssignment.kt +++ b/feature/assignment/domain/src/main/kotlin/com/joegec/joycon2android/assignment/ComboAssignment.kt @@ -1,4 +1,3 @@ package com.joegec.joycon2android.assignment -/** Controllers that requested player assignment together via a button combo. */ data class ComboAssignment(val addresses: List) diff --git a/feature/assignment/domain/src/main/kotlin/com/joegec/joycon2android/assignment/ComboAssignmentDetector.kt b/feature/assignment/domain/src/main/kotlin/com/joegec/joycon2android/assignment/ComboAssignmentDetector.kt index 77c2d8a..a984fdf 100644 --- a/feature/assignment/domain/src/main/kotlin/com/joegec/joycon2android/assignment/ComboAssignmentDetector.kt +++ b/feature/assignment/domain/src/main/kotlin/com/joegec/joycon2android/assignment/ComboAssignmentDetector.kt @@ -5,11 +5,8 @@ import com.joegec.joycon2android.model.JoyconButton import com.joegec.joycon2android.model.Side /** - * Detects the Switch "Change Grip/Order" assignment combos. - * - L held on one Joy-Con while R is held on another pairs both onto one player - * - SL + SR held on a single Joy-Con assigns it solo (sideways) - * - L + R held on a Pro Controller assigns it solo - * A triggered controller stays latched until released so one held combo yields exactly one assignment. + * The Switch's "Change Grip/Order" combos: L and R across two Joy-Cons, SL + SR on one, L + R on a + * Pro. Latched until release, so one hold assigns once. */ class ComboAssignmentDetector { diff --git a/feature/assignment/domain/src/main/kotlin/com/joegec/joycon2android/assignment/PlayerStateResolver.kt b/feature/assignment/domain/src/main/kotlin/com/joegec/joycon2android/assignment/PlayerStateResolver.kt index b5eb94d..453fb63 100644 --- a/feature/assignment/domain/src/main/kotlin/com/joegec/joycon2android/assignment/PlayerStateResolver.kt +++ b/feature/assignment/domain/src/main/kotlin/com/joegec/joycon2android/assignment/PlayerStateResolver.kt @@ -40,10 +40,7 @@ class PlayerStateResolver(private val evictConflicting: (address: String) -> Uni return when (SideInference.inferSide(joycon.input)) { Side.RIGHT -> PlayerState(player = player, left = null, right = joycon) - // LEFT, or not yet determinable: keep it in the left slot rather than defaulting an - // undetermined lone Joy-Con to "right". A genuinely unknown one corrects to its real - // side once a side-exclusive button is pressed — though the scan now usually identifies - // it up front (see BleScanner.sideFromManufacturerData). + // An unknown side waits in the left slot until a side-exclusive button corrects it. else -> PlayerState(player = player, left = joycon, right = null) } } diff --git a/feature/assignment/domain/src/main/kotlin/com/joegec/joycon2android/assignment/SideInference.kt b/feature/assignment/domain/src/main/kotlin/com/joegec/joycon2android/assignment/SideInference.kt index 6f0e1e4..ce9bcce 100644 --- a/feature/assignment/domain/src/main/kotlin/com/joegec/joycon2android/assignment/SideInference.kt +++ b/feature/assignment/domain/src/main/kotlin/com/joegec/joycon2android/assignment/SideInference.kt @@ -4,12 +4,7 @@ import com.joegec.joycon2android.model.JoyconButton import com.joegec.joycon2android.model.JoyconInput import com.joegec.joycon2android.model.Side -/** - * Infers Joy-Con side from observed input when BLE advertisement didn't identify it. - * - * Left-exclusive buttons: ZL, L, Minus, LS, DPad (Up/Down/Left/Right), Capture, SL(L), SR(L) - * Right-exclusive buttons: ZR, R, Plus, RS, A, B, X, Y, Home, Chat, SL(R), SR(R) - */ +/** For a Joy-Con whose advertisement didn't reveal its side. */ object SideInference { private val leftButtons = setOf( diff --git a/feature/connection/data/src/main/kotlin/com/joegec/joycon2android/connection/BleScanner.kt b/feature/connection/data/src/main/kotlin/com/joegec/joycon2android/connection/BleScanner.kt index 60b6aff..909e879 100644 --- a/feature/connection/data/src/main/kotlin/com/joegec/joycon2android/connection/BleScanner.kt +++ b/feature/connection/data/src/main/kotlin/com/joegec/joycon2android/connection/BleScanner.kt @@ -12,13 +12,6 @@ import android.os.Looper import android.util.Log import com.joegec.joycon2android.model.Side -/** - * Handles BLE scanning for Nintendo Joy-Con 2 controllers. - * Emits discovered devices via the [onDeviceFound] callback. - * - * All BLE operations require BLUETOOTH_SCAN and BLUETOOTH_CONNECT permissions, - * which are verified by the permission launcher in MainActivity before any BLE code is reached. - */ @SuppressLint("MissingPermission") class BleScanner(context: Context) { @@ -69,9 +62,7 @@ class BleScanner(context: Context) { if (!isScanning) return val manufacturerData = nintendoData(result) ?: return logAdvertisement(result, manufacturerData) - // A button press wakes a synced Joy-Con into a short-lived reconnect - // advertisement that only its bonded host can connect to (foreign - // connects fail with status 133) — connecting just flashes the UI + // Only the bonded host can connect to a wake advert: docs/protocol.md#advertising if (!JoyconAdvertisement.isPairing(manufacturerData)) return if (isKnownAddress(result.device.address)) return @@ -128,15 +119,7 @@ class BleScanner(context: Context) { else -> null } - /** - * Nintendo manufacturer data (company 0x0553) carries the little-endian USB/BLE product ID at - * bytes [5..6], so index 5 is its low byte: 0x67 = Left Joy-Con 2 (PID 0x2067), 0x66 = Right - * Joy-Con 2 (PID 0x2066), 0x69 = Switch 2 Pro Controller (PID 0x2069). Left/Right are confirmed - * on hardware and cross-checked against each controller's SPI accent colour (cyan left, coral - * right); the Pro value comes from community reverse-engineering of the same advertisement - * scheme. The pairing advertisement has no local name, so this byte is the only type signal - * available before the controller starts streaming input. - */ + /** The product ID's low byte: docs/protocol.md#advertising */ private fun sideFromManufacturerData(result: ScanResult): Side? { val mfgData = result.scanRecord ?.getManufacturerSpecificData(NINTENDO_MANUFACTURER_ID) ?: return null diff --git a/feature/connection/data/src/main/kotlin/com/joegec/joycon2android/connection/ConnectionPool.kt b/feature/connection/data/src/main/kotlin/com/joegec/joycon2android/connection/ConnectionPool.kt index 2493720..53691df 100644 --- a/feature/connection/data/src/main/kotlin/com/joegec/joycon2android/connection/ConnectionPool.kt +++ b/feature/connection/data/src/main/kotlin/com/joegec/joycon2android/connection/ConnectionPool.kt @@ -6,13 +6,7 @@ import android.content.Context import com.joegec.joycon2android.model.Side import java.util.concurrent.ConcurrentHashMap -/** - * Manages all active Joy-Con BLE connections, keyed by device address. - * Thread-safe: BLE callbacks arrive on binder threads. - * - * All BLE operations require BLUETOOTH_CONNECT permission, which is verified - * by the permission launcher in MainActivity before any BLE code is reached. - */ +/** Thread-safe: BLE callbacks arrive on binder threads. */ @SuppressLint("MissingPermission") class ConnectionPool(private val context: Context) { @@ -24,10 +18,7 @@ class ConnectionPool(private val context: Context) { val addresses: Set get() = connections.keys.toSet() val size: Int get() = connections.size - /** - * Atomically creates and starts a connection for [result]. - * Returns null if this address is already in the pool (duplicate scan result). - */ + /** Null for an address already in the pool (a duplicate scan result). */ fun connect(result: ScanResult, side: Side, name: String, highPriority: Boolean): JoyconConnection? { val address = result.device.address val connection = JoyconConnection(context, side, name) { diff --git a/feature/connection/data/src/main/kotlin/com/joegec/joycon2android/connection/GattOpQueue.kt b/feature/connection/data/src/main/kotlin/com/joegec/joycon2android/connection/GattOpQueue.kt index bd8024d..62da567 100644 --- a/feature/connection/data/src/main/kotlin/com/joegec/joycon2android/connection/GattOpQueue.kt +++ b/feature/connection/data/src/main/kotlin/com/joegec/joycon2android/connection/GattOpQueue.kt @@ -5,14 +5,7 @@ import android.os.Looper import android.util.Log import java.util.ArrayDeque -/** - * Serializes GATT operations. Android's BluetoothGatt allows only one - * outstanding write/descriptor-write at a time — issuing a second before - * the callback fires silently drops it. - * - * Includes a safety timeout: if a callback never arrives (e.g. writeCharacteristic - * returned false), the queue advances after [TIMEOUT_MS] to avoid permanent stalls. - */ +/** Android silently drops a second outstanding GATT op. Advances after [TIMEOUT_MS] if a callback never comes. */ class GattOpQueue { companion object { diff --git a/feature/connection/data/src/main/kotlin/com/joegec/joycon2android/connection/Joycon2Manager.kt b/feature/connection/data/src/main/kotlin/com/joegec/joycon2android/connection/Joycon2Manager.kt index 676ac3c..d7df5d2 100644 --- a/feature/connection/data/src/main/kotlin/com/joegec/joycon2android/connection/Joycon2Manager.kt +++ b/feature/connection/data/src/main/kotlin/com/joegec/joycon2android/connection/Joycon2Manager.kt @@ -14,12 +14,6 @@ import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.launch -/** - * Orchestrates BLE scanning and Joy-Con connection lifecycle, exposing connected - * controllers as domain [ConnectedJoycon]s. Delegates scanning to [BleScanner] and - * connection tracking to [ConnectionPool], and assembles the [controllers] list from each - * connection's live input + state (re-emitting on every change). - */ class Joycon2Manager( private val context: Context, private val scope: CoroutineScope, @@ -105,7 +99,6 @@ class Joycon2Manager( rebuildControllers() } - // Each connection's input + state drives a rebuild, so [controllers] reflects live data private fun syncCollectors() { (connectionJobs.keys - pool.addresses).forEach { connectionJobs.remove(it)?.cancel() } (pool.addresses - connectionJobs.keys).forEach { address -> diff --git a/feature/connection/data/src/main/kotlin/com/joegec/joycon2android/connection/JoyconAdvertisement.kt b/feature/connection/data/src/main/kotlin/com/joegec/joycon2android/connection/JoyconAdvertisement.kt index cf400c5..d19a346 100644 --- a/feature/connection/data/src/main/kotlin/com/joegec/joycon2android/connection/JoyconAdvertisement.kt +++ b/feature/connection/data/src/main/kotlin/com/joegec/joycon2android/connection/JoyconAdvertisement.kt @@ -1,18 +1,11 @@ package com.joegec.joycon2android.connection -/** - * Joy-Con 2 advertisements (manufacturer ID 0x0553) carry the bonded host's MAC at - * bytes [10..15]: a button press wakes the controller to reconnect to that host and - * advertises its address; holding SYNC (pairing mode) zeroes the field. Observed on - * hardware 2026-06 — wake: `… 01 00 09 A7 9A 55 E2 98 0F …`, pairing: - * `… 01 00 00 00 00 00 00 00 0F …`. - */ +/** Bonded-host MAC at bytes [10..15], zeroed while pairing: docs/protocol.md#advertising */ object JoyconAdvertisement { private const val HOST_MAC_OFFSET = 10 private const val HOST_MAC_LENGTH = 6 - /** True when the controller is open for pairing rather than waking for its bonded host. */ fun isPairing(manufacturerData: ByteArray): Boolean { if (manufacturerData.size < HOST_MAC_OFFSET + HOST_MAC_LENGTH) return true return (HOST_MAC_OFFSET until HOST_MAC_OFFSET + HOST_MAC_LENGTH) diff --git a/feature/connection/data/src/main/kotlin/com/joegec/joycon2android/connection/JoyconConnection.kt b/feature/connection/data/src/main/kotlin/com/joegec/joycon2android/connection/JoyconConnection.kt index 4f4b11a..cb75c71 100644 --- a/feature/connection/data/src/main/kotlin/com/joegec/joycon2android/connection/JoyconConnection.kt +++ b/feature/connection/data/src/main/kotlin/com/joegec/joycon2android/connection/JoyconConnection.kt @@ -22,13 +22,6 @@ import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow import java.util.UUID -/** - * Manages a single BLE GATT connection to one Joy-Con 2. - * Each Joy-Con gets its own instance with independent state. - * - * All BLE operations require BLUETOOTH_CONNECT permission, which is verified - * by the permission launcher in MainActivity before any BLE code is reached. - */ @SuppressLint("MissingPermission") class JoyconConnection( private val context: Context, @@ -54,23 +47,13 @@ class JoyconConnection( 0x00, 0x00, 0xFF.toByte(), 0x00, 0x00, 0x00 ) - // SPI read (report 0x02, cmd 0x04): read 0x40 bytes from the DeviceInfo block - // at 0x013000, which contains the shell colors (body color at 0x013019). - // Payload: read length (0x40), 0x7E magic, then the 4-byte LE source address. - // The reply arrives on the command-response characteristic and is decoded - // by [SpiColorParser]. - // Byte [2] is 0x00 for SPI reads (matching HandHeldLegend procon2tool); - // the INIT_CMD_* feature commands use 0x01 there, but SPI reads only - // reply when this is 0x00. + // 0x40 bytes of the DeviceInfo block at 0x013000: docs/protocol.md#spi-reads private val SPI_READ_COLOR_CMD = byteArrayOf( 0x02, 0x91.toByte(), 0x00, 0x04, 0x00, 0x08, 0x00, 0x00, 0x40, 0x7E, 0x00, 0x00, 0x00, 0x30, 0x01, 0x00 ) - // Subcommand 0x07: set LED pattern via bitmask (16 bytes) - // Lower nibble = solid LEDs (0x01=P1, 0x02=P2, 0x04=P3, 0x08=P4) - // Upper nibble = flashing LEDs (0x10=P1, 0x20=P2, 0x40=P3, 0x80=P4) - // 0xF0 = all flashing = default cycling animation + // Bitmask layout: docs/protocol.md#player-leds private fun playerLedCmd(bitmask: Byte): ByteArray { return byteArrayOf( 0x09, 0x91.toByte(), 0x01, 0x07, 0x00, 0x08, 0x00, 0x00, @@ -78,7 +61,7 @@ class JoyconConnection( ) } - // All 4 player LEDs solid on (0x0F = P1+P2+P3+P4) + // All four solid. private val LED_ALL_ON_CMD = byteArrayOf( 0x09, 0x91.toByte(), 0x01, 0x07, 0x00, 0x08, 0x00, 0x00, 0x0F, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00 @@ -185,7 +168,7 @@ class JoyconConnection( } writeChar!!.writeType = BluetoothGattCharacteristic.WRITE_TYPE_NO_RESPONSE - // Subscribe to command response notifications (required for LED commands) + // LED and SPI replies arrive here. if (cmdResponseChar != null) { g.setCharacteristicNotification(cmdResponseChar, true) val cmdCccd = cmdResponseChar!!.getDescriptor(CCCD) @@ -199,7 +182,6 @@ class JoyconConnection( } } - // Subscribe to input notifications g.setCharacteristicNotification(notifyChar, true) val notifyCccd = notifyChar!!.getDescriptor(CCCD) if (notifyCccd != null) { @@ -259,8 +241,7 @@ class JoyconConnection( if (initComplete) gatt?.let(::requestPriority) } - // The default "balanced" connection interval lands on 30 ms on some phones, so the Joy-Con - // can only report ~33 times a second; high priority asks the stack for 7.5-15 ms. + // The connection interval is the report rate: docs/protocol.md#android-ble-gotchas private fun requestPriority(g: BluetoothGatt) { val priority = if (highPriority) { BluetoothGatt.CONNECTION_PRIORITY_HIGH diff --git a/feature/connection/data/src/main/kotlin/com/joegec/joycon2android/connection/PacketParser.kt b/feature/connection/data/src/main/kotlin/com/joegec/joycon2android/connection/PacketParser.kt index c120417..ea9fdd3 100644 --- a/feature/connection/data/src/main/kotlin/com/joegec/joycon2android/connection/PacketParser.kt +++ b/feature/connection/data/src/main/kotlin/com/joegec/joycon2android/connection/PacketParser.kt @@ -10,9 +10,7 @@ object PacketParser { private const val MIN_PACKET_SIZE = 0x3B - // Button bitmask → enum. Bits 0..31 come from the uint32 at packet offset 0x03; the Pro - // Controller's two back paddles live in the next byte (0x07), folded into bits 32..39 so the - // whole set decodes through one mask table. GR is bit 0 of byte 0x07, GL is bit 1. + // docs/protocol.md#packet-layout. The back-paddle byte (0x07) is folded into bits 32..39. private val buttonMasks: List> = listOf( 0x80000000L to JoyconButton.ZL, 0x40000000L to JoyconButton.L, 0x00010000L to JoyconButton.Minus, 0x00080000L to JoyconButton.LS, 0x01000000L to JoyconButton.Down, 0x02000000L to JoyconButton.Up, diff --git a/feature/connection/domain/src/main/kotlin/com/joegec/joycon2android/connection/ControllerRepository.kt b/feature/connection/domain/src/main/kotlin/com/joegec/joycon2android/connection/ControllerRepository.kt index 900f27d..223ebb8 100644 --- a/feature/connection/domain/src/main/kotlin/com/joegec/joycon2android/connection/ControllerRepository.kt +++ b/feature/connection/domain/src/main/kotlin/com/joegec/joycon2android/connection/ControllerRepository.kt @@ -4,11 +4,7 @@ import com.joegec.joycon2android.model.ConnectedJoycon import com.joegec.joycon2android.model.PlayerNumber import kotlinx.coroutines.flow.StateFlow -/** - * Connected Joy-Cons as domain entities. The data layer assembles each [ConnectedJoycon] - * (live input + connection state) from its BLE connection, so callers never see the raw - * GATT layer. [controllers] re-emits on every input/state change. - */ +/** [controllers] re-emits on every input or state change. */ interface ControllerRepository { val controllers: StateFlow> val scanning: StateFlow diff --git a/feature/connection/domain/src/main/kotlin/com/joegec/joycon2android/connection/StickCalibrator.kt b/feature/connection/domain/src/main/kotlin/com/joegec/joycon2android/connection/StickCalibrator.kt index a6bd24e..5182f97 100644 --- a/feature/connection/domain/src/main/kotlin/com/joegec/joycon2android/connection/StickCalibrator.kt +++ b/feature/connection/domain/src/main/kotlin/com/joegec/joycon2android/connection/StickCalibrator.kt @@ -2,13 +2,7 @@ package com.joegec.joycon2android.connection import com.joegec.joycon2android.model.JoyconInput -/** - * Rescales one controller's raw sticks onto the full 0..4095 range, centred on 2048, that every - * downstream consumer assumes. Measured travel and rest points: docs/protocol.md#stick-range-and-centre. - * - * Centre is learned from the first still window and then frozen, because a stick held at full - * deflection is perfectly still too; spans only ever widen, so a stick tilts fully from packet one. - */ +/** Onto 0..4095 centred on 2048, as everything downstream assumes: docs/protocol.md#stick-range-and-centre */ class StickCalibrator( restWindowSize: Int = DEFAULT_REST_WINDOW, maxRestSpreadLsb: Int = DEFAULT_MAX_REST_SPREAD, @@ -91,9 +85,7 @@ class StickCalibrator( private const val DEFAULT_REST_WINDOW = 30 private const val DEFAULT_MAX_REST_SPREAD = 32 - // Smallest travel measured across the user's two Joy-Cons was ~1180 LSB; seeding just - // under that means full tilt saturates slightly early rather than falling short, and - // the widening in [Axis.rescale] recovers the exact span once the stick is rolled. + // Just under the smallest measured travel (~1180), so full tilt saturates early rather than short. private const val DEFAULT_SEED_HALF_SPAN = 1150 } } diff --git a/feature/connection/domain/src/main/kotlin/com/joegec/joycon2android/connection/ViewModePreferences.kt b/feature/connection/domain/src/main/kotlin/com/joegec/joycon2android/connection/ViewModePreferences.kt index d9a3524..f487751 100644 --- a/feature/connection/domain/src/main/kotlin/com/joegec/joycon2android/connection/ViewModePreferences.kt +++ b/feature/connection/domain/src/main/kotlin/com/joegec/joycon2android/connection/ViewModePreferences.kt @@ -3,7 +3,6 @@ package com.joegec.joycon2android.connection import com.joegec.joycon2android.model.ConnectionViewMode import kotlinx.coroutines.flow.Flow -/** The connection view mode the user last chose, persisted across app restarts. */ interface ViewModePreferences { val viewMode: Flow suspend fun setViewMode(mode: ConnectionViewMode) diff --git a/feature/connection/presentation/src/main/kotlin/com/joegec/joycon2android/connection/presentation/ControllerAccent.kt b/feature/connection/presentation/src/main/kotlin/com/joegec/joycon2android/connection/presentation/ControllerAccent.kt index b5809d3..eb1fde0 100644 --- a/feature/connection/presentation/src/main/kotlin/com/joegec/joycon2android/connection/presentation/ControllerAccent.kt +++ b/feature/connection/presentation/src/main/kotlin/com/joegec/joycon2android/connection/presentation/ControllerAccent.kt @@ -5,12 +5,7 @@ import androidx.compose.ui.graphics.Color import com.joegec.joycon2android.ui.theme.Accent import com.joegec.joycon2android.ui.theme.TextOnAccent -/** - * The colour a controller's live inputs light up in — its real shell colour — with the ink that - * stays readable on it. [JoyconCard] provides one per controller so each player's buttons and - * stick glow in that controller's own colour; the default is the teal accent for anything drawn - * outside a card. - */ +/** Provided per [JoyconCard]: docs/DESIGN.md#color */ data class ControllerAccent(val color: Color, val onColor: Color) val LocalControllerAccent = staticCompositionLocalOf { ControllerAccent(Accent, TextOnAccent) } diff --git a/feature/connection/presentation/src/main/kotlin/com/joegec/joycon2android/connection/presentation/ImuDisplay.kt b/feature/connection/presentation/src/main/kotlin/com/joegec/joycon2android/connection/presentation/ImuDisplay.kt index b09e62d..d986f24 100644 --- a/feature/connection/presentation/src/main/kotlin/com/joegec/joycon2android/connection/presentation/ImuDisplay.kt +++ b/feature/connection/presentation/src/main/kotlin/com/joegec/joycon2android/connection/presentation/ImuDisplay.kt @@ -82,7 +82,6 @@ private fun ImuValue(text: String) { ) } -// Telemetry (mono, tabular figures, no font padding) pulled tight so the IMU grid stays compact. private val imuTextStyle = AppType.telemetry.copy( lineHeight = Dimens.fontSizeLabel * 1.1f, ) diff --git a/feature/connection/presentation/src/main/kotlin/com/joegec/joycon2android/connection/presentation/SidewaysImuDisplay.kt b/feature/connection/presentation/src/main/kotlin/com/joegec/joycon2android/connection/presentation/SidewaysImuDisplay.kt index c0883dd..7da3366 100644 --- a/feature/connection/presentation/src/main/kotlin/com/joegec/joycon2android/connection/presentation/SidewaysImuDisplay.kt +++ b/feature/connection/presentation/src/main/kotlin/com/joegec/joycon2android/connection/presentation/SidewaysImuDisplay.kt @@ -85,8 +85,6 @@ private fun ImuText(text: String, bold: Boolean = false, dimmed: Boolean = false ) } -// Telemetry (mono, tabular figures, no font padding) pulled tight so the IMU grid stays compact; -// bold section labels override back to the default family. private val tightTextStyle = AppType.telemetry.copy( lineHeight = Dimens.fontSizeLabel * 1.1f, ) diff --git a/feature/connection/presentation/src/main/kotlin/com/joegec/joycon2android/connection/presentation/StickCard.kt b/feature/connection/presentation/src/main/kotlin/com/joegec/joycon2android/connection/presentation/StickCard.kt index ca92dba..1210a80 100644 --- a/feature/connection/presentation/src/main/kotlin/com/joegec/joycon2android/connection/presentation/StickCard.kt +++ b/feature/connection/presentation/src/main/kotlin/com/joegec/joycon2android/connection/presentation/StickCard.kt @@ -68,8 +68,7 @@ private fun StickCanvas( } } -// Each axis is normalised against its own travel, so a full diagonal reaches 1 on both and lands -// outside the ring. The stick's gate is round, so it's the magnitude that clamps, not each axis. +// Each axis reaches 1 on its own, so a diagonal would leave the round gate: clamp the magnitude. private fun dotPosition(centre: Offset, nx: Float, ny: Float, travel: Float): Offset { val magnitude = hypot(nx, ny) val scale = if (magnitude > 1f) 1f / magnitude else 1f diff --git a/feature/dsu/data/src/main/kotlin/com/joegec/joycon2android/dsu/DsuClientRegistry.kt b/feature/dsu/data/src/main/kotlin/com/joegec/joycon2android/dsu/DsuClientRegistry.kt index 8a164f4..e15ebaf 100644 --- a/feature/dsu/data/src/main/kotlin/com/joegec/joycon2android/dsu/DsuClientRegistry.kt +++ b/feature/dsu/data/src/main/kotlin/com/joegec/joycon2android/dsu/DsuClientRegistry.kt @@ -3,13 +3,8 @@ package com.joegec.joycon2android.dsu import java.net.SocketAddress /** - * Tracks pad-data subscribers and which slots each one wants. Routing per slot on the - * server side matters: Dolphin's DSU devices overwrite their pad state with every - * received packet without checking the slot, so a server that broadcasts all slots to - * every client makes the last controller win. Clients are dropped after - * [timeoutMillis] of silence (the spec's ~5 s liveness convention). Callers supply the - * clock so the registry stays pure; register and recipientsFor run on different - * coroutines. + * Routes each slot only to its subscribers: docs/dsu-motion.md#the-server. [timeoutMillis] is the + * spec's liveness convention. [register] and [recipientsFor] run on different coroutines. */ class DsuClientRegistry(private val timeoutMillis: Long = 5_000) { diff --git a/feature/dsu/data/src/main/kotlin/com/joegec/joycon2android/dsu/DsuPacketEncoder.kt b/feature/dsu/data/src/main/kotlin/com/joegec/joycon2android/dsu/DsuPacketEncoder.kt index f0ce6eb..0b0b522 100644 --- a/feature/dsu/data/src/main/kotlin/com/joegec/joycon2android/dsu/DsuPacketEncoder.kt +++ b/feature/dsu/data/src/main/kotlin/com/joegec/joycon2android/dsu/DsuPacketEncoder.kt @@ -11,13 +11,7 @@ import java.nio.ByteBuffer import java.nio.ByteOrder import java.util.zip.CRC32 -/** - * Encodes DSU (cemuhook) server packets. Spec: https://v1993.github.io/cemuhook-protocol/ - * - * All fields little-endian. 16-byte header: magic "DSUS", uint16 protocol version (1001), - * uint16 payload length (counts the uint32 message type that follows), uint32 CRC32 - * (computed over the whole packet with this field zeroed), uint32 server ID. - */ +/** Spec: https://v1993.github.io/cemuhook-protocol/ */ class DsuPacketEncoder( private val serverId: Int, private val motion: (DsuStream) -> DsuMotion = { stream -> @@ -121,8 +115,7 @@ class DsuPacketEncoder( return bits.toByte() } - // DSU sticks are uint8 centered at 128, Y up-positive — same polarity as the raw - // Joy-Con 0–4095 range, so scale only (unlike the HID report, which inverts Y) + // uint8 centred on 128, Y up like the raw Joy-Con range, so scale only. private fun putSticks(packet: ByteBuffer, gamepad: GamepadState) { packet.put(stickByte(gamepad.leftStickX)) packet.put(stickByte(gamepad.leftStickY)) diff --git a/feature/dsu/data/src/main/kotlin/com/joegec/joycon2android/dsu/DsuRequestParser.kt b/feature/dsu/data/src/main/kotlin/com/joegec/joycon2android/dsu/DsuRequestParser.kt index 9b4f736..0a2f9ba 100644 --- a/feature/dsu/data/src/main/kotlin/com/joegec/joycon2android/dsu/DsuRequestParser.kt +++ b/feature/dsu/data/src/main/kotlin/com/joegec/joycon2android/dsu/DsuRequestParser.kt @@ -4,10 +4,7 @@ import java.nio.ByteBuffer import java.nio.ByteOrder import java.util.zip.CRC32 -/** - * Parses client → server DSU packets: magic "DSUC", then the same header layout the - * encoder writes. Packets with a bad magic, length, or CRC are dropped (returns null). - */ +/** Null for a bad magic, length or CRC. */ object DsuRequestParser { private const val HEADER_SIZE = 16 diff --git a/feature/dsu/data/src/main/kotlin/com/joegec/joycon2android/dsu/DsuServer.kt b/feature/dsu/data/src/main/kotlin/com/joegec/joycon2android/dsu/DsuServer.kt index 608e5d9..0a5d6f2 100644 --- a/feature/dsu/data/src/main/kotlin/com/joegec/joycon2android/dsu/DsuServer.kt +++ b/feature/dsu/data/src/main/kotlin/com/joegec/joycon2android/dsu/DsuServer.kt @@ -37,8 +37,7 @@ class DsuServer( private val sendBuffer = ByteArray(DsuPacketEncoder.PAD_DATA_PACKET_SIZE) private val packetCounters = LongArray(DsuPacketEncoder.SLOT_COUNT) - // Pad batches ride a buffered channel instead of a StateFlow: conflation would drop - // motion samples, and UDP sends can't run on the synchronous onState (main) thread + // Not a StateFlow: conflation drops motion samples, and sends can't run on the synchronous onState. private val batches = Channel(BATCH_BUFFER, BufferOverflow.DROP_OLDEST) private var socket: DatagramSocket? = null @@ -97,8 +96,7 @@ class DsuServer( batches.trySend(PadDataBatch(players, timestampMicros())) } - // Emulators dial the IPv4 address we advertise; getLoopbackAddress() resolves to - // IPv6 ::1 on Android, and a socket bound there never sees 127.0.0.1 datagrams + // Not getLoopbackAddress(), which is ::1 on Android: docs/dsu-motion.md#the-server private fun bindAddress(): InetAddress = InetAddress.getByAddress(byteArrayOf(127, 0, 0, 1)) private fun currentAddress(): String = "127.0.0.1:$port" @@ -111,8 +109,7 @@ class DsuServer( val datagram = DatagramPacket(ByteArray(RECEIVE_BUFFER_SIZE), RECEIVE_BUFFER_SIZE) while (!socket.isClosed) { try { - // receive() shrinks the packet to the last datagram's size; without a reset - // every following packet that is longer gets truncated and fails its CRC + // receive() shrinks the length to the last datagram, truncating any longer one after it. datagram.setLength(RECEIVE_BUFFER_SIZE) socket.receive(datagram) handleRequest(socket, datagram) diff --git a/feature/dsu/data/src/test/kotlin/com/joegec/joycon2android/dsu/DsuServerTest.kt b/feature/dsu/data/src/test/kotlin/com/joegec/joycon2android/dsu/DsuServerTest.kt index 0196952..f559728 100644 --- a/feature/dsu/data/src/test/kotlin/com/joegec/joycon2android/dsu/DsuServerTest.kt +++ b/feature/dsu/data/src/test/kotlin/com/joegec/joycon2android/dsu/DsuServerTest.kt @@ -30,8 +30,6 @@ class DsuServerTest { private val server = DsuServer(scope, port) { 1_000_000L } private lateinit var client: DatagramSocket - // IPv4 throughout: the server must answer at the 127.0.0.1 address emulators dial, - // not just whatever getLoopbackAddress() resolves to (::1 on Android) private val loopback: InetAddress = InetAddress.getByAddress(byteArrayOf(127, 0, 0, 1)) @Before diff --git a/feature/dsu/domain/src/main/kotlin/com/joegec/joycon2android/dsu/DsuCoverage.kt b/feature/dsu/domain/src/main/kotlin/com/joegec/joycon2android/dsu/DsuCoverage.kt index 168d41f..82b24a8 100644 --- a/feature/dsu/domain/src/main/kotlin/com/joegec/joycon2android/dsu/DsuCoverage.kt +++ b/feature/dsu/domain/src/main/kotlin/com/joegec/joycon2android/dsu/DsuCoverage.kt @@ -2,11 +2,7 @@ package com.joegec.joycon2android.dsu import com.joegec.joycon2android.model.PlayerNumber -/** - * What the four slots cannot carry. A pad packet holds one accelerometer and gyroscope, so a - * player without a second slot still streams both Joy-Cons' buttons — only the left hand's - * motion is lost, which is why the two shortfalls are reported apart. - */ +/** Reported apart: a player without a second slot still streams both hands' buttons, losing only left-hand motion. */ data class DsuCoverage( val unservedPlayers: List = emptyList(), val unservedSecondHands: List = emptyList(), diff --git a/feature/dsu/domain/src/main/kotlin/com/joegec/joycon2android/dsu/DsuSlots.kt b/feature/dsu/domain/src/main/kotlin/com/joegec/joycon2android/dsu/DsuSlots.kt index 7e1fefe..1d6c3bb 100644 --- a/feature/dsu/domain/src/main/kotlin/com/joegec/joycon2android/dsu/DsuSlots.kt +++ b/feature/dsu/domain/src/main/kotlin/com/joegec/joycon2android/dsu/DsuSlots.kt @@ -2,10 +2,7 @@ package com.joegec.joycon2android.dsu import com.joegec.joycon2android.model.PlayerState -/** - * Maps players onto the protocol's four slots, and a pair's second hand onto a slot of its own - * since one packet carries one IMU: docs/dsu-motion.md#slots. - */ +/** docs/dsu-motion.md#slots */ object DsuSlots { const val COUNT = 4 diff --git a/feature/dsu/domain/src/main/kotlin/com/joegec/joycon2android/dsu/DsuStream.kt b/feature/dsu/domain/src/main/kotlin/com/joegec/joycon2android/dsu/DsuStream.kt index c54d9eb..5a9bcfe 100644 --- a/feature/dsu/domain/src/main/kotlin/com/joegec/joycon2android/dsu/DsuStream.kt +++ b/feature/dsu/domain/src/main/kotlin/com/joegec/joycon2android/dsu/DsuStream.kt @@ -2,7 +2,6 @@ package com.joegec.joycon2android.dsu import com.joegec.joycon2android.model.PlayerState -/** One DSU slot and the controller state it reports. */ data class DsuStream( val slot: Int, val state: PlayerState, diff --git a/feature/dsu/domain/src/main/kotlin/com/joegec/joycon2android/dsu/emulator/DolphinDsuConfig.kt b/feature/dsu/domain/src/main/kotlin/com/joegec/joycon2android/dsu/emulator/DolphinDsuConfig.kt index 7acb2b3..efbb88b 100644 --- a/feature/dsu/domain/src/main/kotlin/com/joegec/joycon2android/dsu/emulator/DolphinDsuConfig.kt +++ b/feature/dsu/domain/src/main/kotlin/com/joegec/joycon2android/dsu/emulator/DolphinDsuConfig.kt @@ -3,12 +3,7 @@ package com.joegec.joycon2android.dsu.emulator import com.joegec.joycon2android.dsu.DsuConfig import com.joegec.joycon2android.emulatorconfig.DolphinPaths -/** - * Dolphin's DSUClient.ini on Android. It lives in Dolphin's external data dir — writable by a - * shell-uid process (Shizuku / wireless debugging) but not by us directly. Servers are listed - * on the `Entries` line as `;`-separated `name:host:port` tokens; [merge] adds ours without - * disturbing any the user already configured. - */ +/** `Entries` lists servers as `;`-separated `name:host:port`; [merge] keeps the user's own. */ object DolphinDsuConfig { val path = DolphinPaths.config("DSUClient.ini") diff --git a/feature/dsu/domain/src/main/kotlin/com/joegec/joycon2android/dsu/emulator/DolphinWiimoteConfig.kt b/feature/dsu/domain/src/main/kotlin/com/joegec/joycon2android/dsu/emulator/DolphinWiimoteConfig.kt index 287d958..4a65136 100644 --- a/feature/dsu/domain/src/main/kotlin/com/joegec/joycon2android/dsu/emulator/DolphinWiimoteConfig.kt +++ b/feature/dsu/domain/src/main/kotlin/com/joegec/joycon2android/dsu/emulator/DolphinWiimoteConfig.kt @@ -18,11 +18,7 @@ import com.joegec.joycon2android.emulatorconfig.IniEditor import com.joegec.joycon2android.model.JoyconButton import com.joegec.joycon2android.model.PlayerState -/** - * Generates Dolphin's `WiimoteNew.ini` bindings for the DSU device, one `[WiimoteN]` section per - * assigned player, driven by the user's own Joy-Con → Wiimote/Nunchuk mapping. What it writes and - * why, measurements included: docs/dsu-motion.md#dolphin-wii-remote-mapping. - */ +/** What it writes and why: docs/dsu-motion.md#dolphin-wii-remote-mapping */ object DolphinWiimoteConfig { val path = DolphinPaths.config("WiimoteNew.ini") @@ -84,9 +80,7 @@ object DolphinWiimoteConfig { private val IMU_CONTROLS = ACCEL_DIRECTIONS.map { "IMUAccelerometer/$it" to "Accel $it" } + GYRO_DIRECTIONS.map { "IMUGyroscope/$it" to "Gyro $it" } - // A lone Joy-Con streams in its sideways grip (SidewaysMotion); these turn it back about the - // button face onto the body the player actually aims. Two tables because the bodies rotate into - // their grips opposite ways: docs/dsu-motion.md#sideways-joy-cons. + // Turn a lone Joy-Con's sideways stream back onto the body it aims: docs/dsu-motion.md#sideways-joy-cons private val SIDEWAYS_REMOTE_INPUTS = mapOf( "Accel Left" to "Accel Backward", "Accel Right" to "Accel Forward", "Accel Forward" to "Accel Left", "Accel Backward" to "Accel Right", @@ -100,8 +94,7 @@ object DolphinWiimoteConfig { "Gyro Roll Left" to "Gyro Pitch Down", "Gyro Roll Right" to "Gyro Pitch Up", ) - // Only a right Joy-Con gives up its own body to steer true; a left one already is a sideways - // remote, its nose and its L/ZL edge pointing the same way, so it needs no turn either way. + // A left Joy-Con's L/ZL edge is already a sideways remote's nose, so only a right one differs. private fun bodyInputs(side: JoyconSide, sidewaysRemote: Boolean): Map = when (side) { JoyconSide.DUAL -> emptyMap() JoyconSide.LEFT -> SIDEWAYS_REMOTE_INPUTS @@ -120,9 +113,7 @@ object DolphinWiimoteConfig { private fun dolphinKey(target: WiimoteButton, sideways: Boolean): String = (if (sideways) SIDEWAYS_DPAD_KEYS[target] else null) ?: DOLPHIN_KEYS.getValue(target) - // A flick is fired from the gyroscope and delivered as a jerk of the accelerometer, because a - // Joy-Con flick carries almost no linear jerk and Mario Kart Wii reads only the accelerometer. - // Every constant is measured: docs/dsu-motion.md#sideways-joy-cons. + // Measured: docs/dsu-motion.md#tricks-and-wheelies private const val FLICK_RADIANS = 9 private const val FLICK_LOCKOUT_SECONDS = 0.4 private const val TRICK_ACCELERATION = 50 // m/s^2, past what an emulated remote can report @@ -130,17 +121,10 @@ object DolphinWiimoteConfig { private const val TRICK_PERIOD_SECONDS = 0.15 private const val FULL_TURN = 6.2832 - // A wheelie is a state an up-flick starts and a down-flick drops, so unlike a trick it needs the - // direction the player flicked. Pitch carries it on both bodies; the remote is jerked the same - // way it was flicked. private const val UP = "IMUAccelerometer/Up" private val TRICK_AXES = mapOf(UP to ("Pitch Up" to "Pitch Down"), "IMUAccelerometer/Down" to ("Pitch Down" to "Pitch Up")) - /** - * Each direction locks the other out: every flick rebounds the opposite way about a quarter of a - * second later, and that rebound would otherwise answer the gesture and cancel the wheelie. - * Gating the pulse's input rather than its output lets a jerk already running finish. - */ + // Each direction locks out the other, so a flick's rebound can't cancel the wheelie. private fun trickTrigger( side: JoyconSide, control: String, @@ -157,8 +141,7 @@ object DolphinWiimoteConfig { return listOfNotNull(flick, pressed).takeIf { it.isNotEmpty() }?.joinToString(" | ") } - // Half a wave, so the jerks all go the way the flick did — a full one would cancel the wheelie - // it just started, four times a second. + // Half-rectified, so every jerk goes the way the flick did. private fun trickShake(trigger: String?): String? = trigger?.let { "pulse($it, $TRICK_SECONDS) * max(sin(timer($TRICK_PERIOD_SECONDS) * $FULL_TURN), 0) * $TRICK_ACCELERATION" } @@ -176,12 +159,8 @@ object DolphinWiimoteConfig { } + listOf("IMUIR/Enabled = True", "IMUIR/Total Yaw = $IMU_TOTAL_YAW_DEGREES") } - // Swing is the only way a thrust toward the sensor bar reaches a game, and it is signed and - // high-passed because an accelerometer cannot tell one from a tilted grip: - // docs/dsu-motion.md#dolphin-wii-remote-mapping. private fun swingLines(side: JoyconSide, sidewaysRemote: Boolean): List { - // A push toward the screen runs along the remote's nose, whichever input that body reads it from. - val body = bodyInputs(side, sidewaysRemote) + val body = bodyInputs(side, sidewaysRemote) val thrust = body["Accel Forward"] ?: "Accel Forward" val pull = body["Accel Backward"] ?: "Accel Backward" val signed = "(`$thrust` - `$pull`)" @@ -192,8 +171,7 @@ object DolphinWiimoteConfig { ) } - // Dolphin splits a control on its last colon, so `:` reaches the second hand's - // slot. A real Nunchuk has no gyroscope, only this accel. + // Dolphin splits on the last colon, so `:` reads the second hand's slot. private fun nunchukImuLines(slot: Int): List = ACCEL_DIRECTIONS.map { "Nunchuk/IMUAccelerometer/$it = `DSUClient/$slot/Joycon2:Accel $it`" } @@ -271,7 +249,6 @@ object DolphinWiimoteConfig { } } - // Dolphin's expression language ORs its inputs, so every source bound to a target can fire it. private fun expressionFor(side: JoyconSide, sources: List): String? = sources.mapNotNull { specFor(side, it) } .takeIf { it.isNotEmpty() } diff --git a/feature/dsu/domain/src/main/kotlin/com/joegec/joycon2android/dsu/emulator/EdenDsuConfig.kt b/feature/dsu/domain/src/main/kotlin/com/joegec/joycon2android/dsu/emulator/EdenDsuConfig.kt index f8e4133..c2ff1d6 100644 --- a/feature/dsu/domain/src/main/kotlin/com/joegec/joycon2android/dsu/emulator/EdenDsuConfig.kt +++ b/feature/dsu/domain/src/main/kotlin/com/joegec/joycon2android/dsu/emulator/EdenDsuConfig.kt @@ -21,12 +21,7 @@ import com.joegec.joycon2android.emulatorconfig.defineEdenKey import com.joegec.joycon2android.model.JoyconButton import com.joegec.joycon2android.model.PlayerState -/** - * Binds Eden to our DSU server in `config.ini`'s `[Controls]`: the three server switches, then a - * whole controller per assigned player — buttons, sticks and motion — driven by the user's own - * Joy-Con → Pro Controller mapping. How Eden addresses a cemuhook pad, and why the button table - * reads the way it does: docs/dsu-motion.md#edens-cemuhook-bindings. - */ +/** How Eden addresses a cemuhook pad: docs/dsu-motion.md#edens-cemuhook-bindings */ object EdenDsuConfig { private const val ENGINE = "cemuhookudp" @@ -57,8 +52,6 @@ object EdenDsuConfig { players: List, mappingFor: (PlayerBody) -> Map, ): String { - // A reassignment leaves stale bindings on players who no longer hold a controller, and - // those would keep feeding an emulated pad from whoever now owns that slot. val cleared = IniEditor.removeKeys(existing, EdenControls.SECTION) { it.matches(EdenControls.PLAYER_KEY) } return IniEditor.setKeys( cleared, @@ -72,7 +65,7 @@ object EdenDsuConfig { val keys = LinkedHashMap() keys.defineEdenKey("motion_enabled", "true") keys.defineEdenKey("udp_input_servers", serverList(existing)) - // What makes Eden offer the UDP pads at all — off, and none of the bindings below resolve. + // Off, none of the bindings below resolve. keys.defineEdenKey("enable_udp_controller", "true") return keys } @@ -143,8 +136,6 @@ object EdenDsuConfig { private fun axesOf(stick: StickSource) = if (stick == StickSource.LEFT_STICK) LEFT_STICK_AXES else RIGHT_STICK_AXES - // Eden binds one input per key, so a target driven by several sources keeps the first that its - // body can actually emit; the rest are only reachable through Dolphin. private fun inputFor(side: JoyconSide, sources: List): String? = sources.firstNotNullOfOrNull { inputFor(side, it) } diff --git a/feature/dsu/domain/src/main/kotlin/com/joegec/joycon2android/dsu/motion/DeviceMotionBlocker.kt b/feature/dsu/domain/src/main/kotlin/com/joegec/joycon2android/dsu/motion/DeviceMotionBlocker.kt index 3b99fbe..59f3458 100644 --- a/feature/dsu/domain/src/main/kotlin/com/joegec/joycon2android/dsu/motion/DeviceMotionBlocker.kt +++ b/feature/dsu/domain/src/main/kotlin/com/joegec/joycon2android/dsu/motion/DeviceMotionBlocker.kt @@ -1,9 +1,6 @@ package com.joegec.joycon2android.dsu.motion -/** - * Stops emulators reading this device's own motion sensors. Eden's Android build feeds them into - * Player 1 on top of any mapped motion, so a DSU controller's readings alternate with the device's. - */ +/** docs/dsu-motion.md#eden-reads-the-devices-own-motion */ interface DeviceMotionBlocker { suspend fun setBlocked(blocked: Boolean) } diff --git a/feature/dsu/domain/src/main/kotlin/com/joegec/joycon2android/dsu/motion/GyroCalibrator.kt b/feature/dsu/domain/src/main/kotlin/com/joegec/joycon2android/dsu/motion/GyroCalibrator.kt index 42572b3..2298bf1 100644 --- a/feature/dsu/domain/src/main/kotlin/com/joegec/joycon2android/dsu/motion/GyroCalibrator.kt +++ b/feature/dsu/domain/src/main/kotlin/com/joegec/joycon2android/dsu/motion/GyroCalibrator.kt @@ -2,14 +2,7 @@ package com.joegec.joycon2android.dsu.motion import com.joegec.joycon2android.model.JoyconInput -/** - * Removes per-controller gyro bias. Joy-Con 2 gyros idle with a constant offset - * (+0.2 dps yaw / +0.9 dps roll observed on hardware), which DSU clients integrate - * into a steady pointer drift. Whenever a controller's gyro stays within - * [maxSpreadLsb] (~2.4 dps) for [windowSize] consecutive samples (~2 s at 120 Hz — - * true at rest; hand tremor exceeds it), the window mean becomes that controller's - * bias. Mirrors the runtime recalibration the Switch itself performs. - */ +/** docs/dsu-motion.md#gyro-bias */ class GyroCalibrator( private val windowSize: Int = 240, private val maxSpreadLsb: Int = 40, diff --git a/feature/dsu/domain/src/main/kotlin/com/joegec/joycon2android/dsu/motion/MotionConverter.kt b/feature/dsu/domain/src/main/kotlin/com/joegec/joycon2android/dsu/motion/MotionConverter.kt index 7bd5ca4..1de6b94 100644 --- a/feature/dsu/domain/src/main/kotlin/com/joegec/joycon2android/dsu/motion/MotionConverter.kt +++ b/feature/dsu/domain/src/main/kotlin/com/joegec/joycon2android/dsu/motion/MotionConverter.kt @@ -2,15 +2,7 @@ package com.joegec.joycon2android.dsu.motion import com.joegec.joycon2android.model.JoyconInput -/** - * Raw Joy-Con IMU → cemuhook's DS4 motion frame. Both frames, the scale factors and the sign - * history are in docs/dsu-motion.md#motion-frame — the signs are DS4 hardware convention rather - * than a right-handed frame, so verify any change against Dolphin's pointer rather than reasoning - * about it. - * - * Converts whatever frame it is given; a lone sideways Joy-Con is turned into its grip first by - * [SidewaysMotion]. - */ +/** Frames, scales and signs: docs/dsu-motion.md#motion-frame. Verify changes against Dolphin's pointer. */ object MotionConverter { private const val ACCEL_G_PER_LSB = 0.000244140625f diff --git a/feature/dsu/domain/src/main/kotlin/com/joegec/joycon2android/dsu/motion/SidewaysMotion.kt b/feature/dsu/domain/src/main/kotlin/com/joegec/joycon2android/dsu/motion/SidewaysMotion.kt index 4a27a4e..da6642e 100644 --- a/feature/dsu/domain/src/main/kotlin/com/joegec/joycon2android/dsu/motion/SidewaysMotion.kt +++ b/feature/dsu/domain/src/main/kotlin/com/joegec/joycon2android/dsu/motion/SidewaysMotion.kt @@ -4,12 +4,7 @@ import com.joegec.joycon2android.dsu.DsuStream import com.joegec.joycon2android.model.JoyconInput import com.joegec.joycon2android.model.Side -/** - * Turns a lone Joy-Con's IMU 90° about its button face into the grip it is held in, so an emulator - * presenting it as a Pro Controller reads its tilt the way SDL delivers a real horizontal Joy-Con. - * The direction was measured, and is the opposite of the stick's turn: - * docs/dsu-motion.md#sideways-joy-cons. - */ +/** Measured, and opposite to the stick's turn: docs/dsu-motion.md#sideways-joy-cons */ object SidewaysMotion { fun orient(stream: DsuStream, input: JoyconInput): JoyconInput { diff --git a/feature/dsu/domain/src/test/kotlin/com/joegec/joycon2android/dsu/emulator/DolphinWiimoteConfigTest.kt b/feature/dsu/domain/src/test/kotlin/com/joegec/joycon2android/dsu/emulator/DolphinWiimoteConfigTest.kt index 4a37b4a..e56166f 100644 --- a/feature/dsu/domain/src/test/kotlin/com/joegec/joycon2android/dsu/emulator/DolphinWiimoteConfigTest.kt +++ b/feature/dsu/domain/src/test/kotlin/com/joegec/joycon2android/dsu/emulator/DolphinWiimoteConfigTest.kt @@ -158,7 +158,6 @@ class DolphinWiimoteConfigTest { assertTrue(result.contains("IMUIR/Total Yaw = 60")) } - // Off, each Joy-Con is its own body: the nose is the shoulder edge the player aims down. @Test fun `a right Joy-Con keeps its own body until the layout plays sideways`() { val result = merge(null, listOf(PlayerState(PlayerNumber.P1, right = joycon(Side.RIGHT)))) @@ -169,7 +168,6 @@ class DolphinWiimoteConfigTest { assertTrue(result.contains("IMUGyroscope/Yaw Left = `Gyro Yaw Left`")) } - // A sideways remote's nose points left, which a left Joy-Con's own body already does. @Test fun `a left Joy-Con reads the same either way`() { val player = listOf(PlayerState(PlayerNumber.P1, left = joycon(Side.LEFT))) @@ -194,7 +192,6 @@ class DolphinWiimoteConfigTest { assertTrue(result.contains("IMUGyroscope/Yaw Left = `Gyro Yaw Left`")) } - // The player's up is a sideways remote's right, so the four bindings turn with the body. @Test fun `playing sideways turns the d-pad a quarter, on both bodies`() { val right = merge(null, listOf(PlayerState(PlayerNumber.P1, right = joycon(Side.RIGHT))), sidewaysRemote = true) @@ -219,8 +216,6 @@ class DolphinWiimoteConfigTest { assertTrue(result.contains("D-Pad/Up = `Pad N`")) } - // A flick is nearly all rotation, which the game cannot read, so it is fired from the gyroscope - // and delivered through the accelerometer, the path steering proves reaches the game. @Test fun `playing sideways turns a wrist flick into a jerk the way it was flicked`() { val result = merge(null, listOf(PlayerState(PlayerNumber.P1, right = joycon(Side.RIGHT))), sidewaysRemote = true) @@ -235,8 +230,6 @@ class DolphinWiimoteConfigTest { assertFalse(result.contains("Shake/")) // Dolphin's own group never landed one } - // Every flick rebounds the opposite way a quarter of a second later, and that rebound would - // otherwise answer the gesture — which is what made a wheelie chatter on and off. @Test fun `each flick direction locks the other out`() { val result = merge(null, listOf(PlayerState(PlayerNumber.P1, right = joycon(Side.RIGHT))), sidewaysRemote = true) @@ -245,7 +238,6 @@ class DolphinWiimoteConfigTest { "not(pulse(`Gyro Pitch Up` / 9, 0.4))")) } - // A wheelie is a state, so a full wave would cancel it four times a second. @Test fun `the jerks all go the way the flick did`() { val result = merge(null, listOf(PlayerState(PlayerNumber.P1, right = joycon(Side.RIGHT))), sidewaysRemote = true) diff --git a/feature/dsu/presentation/src/main/kotlin/com/joegec/joycon2android/dsu/presentation/DsuViewModel.kt b/feature/dsu/presentation/src/main/kotlin/com/joegec/joycon2android/dsu/presentation/DsuViewModel.kt index 61b0f5d..722565a 100644 --- a/feature/dsu/presentation/src/main/kotlin/com/joegec/joycon2android/dsu/presentation/DsuViewModel.kt +++ b/feature/dsu/presentation/src/main/kotlin/com/joegec/joycon2android/dsu/presentation/DsuViewModel.kt @@ -12,7 +12,7 @@ import com.joegec.joycon2android.dsu.motion.SetBlockDeviceMotionUseCase import com.joegec.joycon2android.dsu.motion.SetFastMotionUseCase import com.joegec.joycon2android.model.EmulatorSetupResult import com.joegec.joycon2android.model.PlayerState -import com.joegec.joycon2android.ui.components.DolphinSetupPhase // shared, in :core:designsystem +import com.joegec.joycon2android.ui.components.DolphinSetupPhase import com.joegec.joycon2android.ui.components.EmulatorOption import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharingStarted @@ -22,7 +22,6 @@ import kotlinx.coroutines.CancellationException import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch -/** Feature-scoped state holder for the DSU card. Use cases are injected by the app. */ class DsuViewModel( observeDsuStatus: ObserveDsuStatusUseCase, private val enableDsu: EnableDsuUseCase, @@ -47,11 +46,9 @@ class DsuViewModel( private val _setupPhase = MutableStateFlow(DolphinSetupPhase.IDLE) val setupPhase: StateFlow = _setupPhase.asStateFlow() - /** The emulator that has to be closed before its config can be written, once the user agrees. */ private val _emulatorToClose = MutableStateFlow(null) val emulatorToClose: StateFlow = _emulatorToClose.asStateFlow() - /** The emulator to offer to start once its config has been written. */ private val _emulatorToStart = MutableStateFlow(null) val emulatorToStart: StateFlow = _emulatorToStart.asStateFlow() @@ -74,7 +71,6 @@ class DsuViewModel( resetSetupPhase() } - /** Clears a stale Done/Failed and its start prompt once the written config no longer matches the assignment. */ fun resetSetupPhase() { if (_setupPhase.value == DolphinSetupPhase.WORKING) return _setupPhase.value = DolphinSetupPhase.IDLE @@ -83,7 +79,6 @@ class DsuViewModel( fun configureDsu(players: List) = write(players, closeEmulator = false) - /** The user accepted losing unsaved progress, so stop the emulator and write. */ fun closeEmulatorAndConfigure(players: List) { _emulatorToClose.value = null write(players, closeEmulator = true) diff --git a/feature/gamepad/data/src/main/kotlin/com/joegec/joycon2android/gamepad/ReportMapper.kt b/feature/gamepad/data/src/main/kotlin/com/joegec/joycon2android/gamepad/ReportMapper.kt index 4a945cb..03048e7 100644 --- a/feature/gamepad/data/src/main/kotlin/com/joegec/joycon2android/gamepad/ReportMapper.kt +++ b/feature/gamepad/data/src/main/kotlin/com/joegec/joycon2android/gamepad/ReportMapper.kt @@ -7,11 +7,7 @@ object ReportMapper { private const val REPORT_SIZE = 14 - // Bit n of the button bytes is the descriptor's Button n+1, which Linux maps to BTN_GAMEPAD + n - // and Android's key layout then names. Each Joy-Con button takes the bit whose keycode carries - // its own name — A on BTN_SOUTH (BUTTON_A), ZL on BTN_TL2 (BUTTON_L2), Minus on BTN_SELECT, and - // so on — so nothing downstream has to know about a shift, and ZL/ZR agree with the brake and - // accelerator axes below. Capture and GL take the two leftover slots (BUTTON_C, BUTTON_Z). + // Each bit's keycode carries the button's own name: docs/virtual-gamepad.md#buttons-and-keycodes private val BUTTON_MAP: Map = mapOf( JoyconButton.A.id to 0, JoyconButton.B.id to 1, diff --git a/feature/gamepad/data/src/main/kotlin/com/joegec/joycon2android/gamepad/UhidRelay.kt b/feature/gamepad/data/src/main/kotlin/com/joegec/joycon2android/gamepad/UhidRelay.kt index 3ecd41e..b1cdb38 100644 --- a/feature/gamepad/data/src/main/kotlin/com/joegec/joycon2android/gamepad/UhidRelay.kt +++ b/feature/gamepad/data/src/main/kotlin/com/joegec/joycon2android/gamepad/UhidRelay.kt @@ -42,7 +42,6 @@ class UhidRelay(private val name: String, private val playerIndex: Int) { read += n } - // Send UHID_CREATE2 event val createEvent = buildCreateEvent() val header = ByteBuffer.allocate(4).order(ByteOrder.LITTLE_ENDIAN) header.putInt(createEvent.size) @@ -217,10 +216,7 @@ class UhidRelay(private val name: String, private val playerIndex: Int) { 0x09, 0x35, // Usage (Rz) 0x81.toByte(), 0x02, // Input (Data, Var, Abs) - // Left Trigger. Brake is the left one and Accelerator the right one, never the other - // way round: Android aliases AXIS_LTRIGGER to AXIS_BRAKE and AXIS_RTRIGGER to AXIS_GAS, - // and firmware that re-publishes a pad (AYN's Odin/Thor) synthesises its L2/R2 buttons - // from those axes — inverted, it hands the emulator an L2 press for a ZR pull. + // Left Trigger. Never swap Brake and Accelerator: docs/virtual-gamepad.md#buttons-and-keycodes 0x05, 0x02, // Usage Page (Simulation Controls) 0x09, 0xC5.toByte(), // Usage (Brake) 0x15, 0x00, // Logical Minimum (0) @@ -235,11 +231,7 @@ class UhidRelay(private val name: String, private val playerIndex: Int) { 0xC0.toByte(), // End Collection - // The Switch 2 controllers have 17 buttons, two more than one Game Pad collection can - // carry. A second application collection outside the gamepad usages takes the overflow: - // Linux falls back to BTN_MISC + n - 1 for a Button usage whose application is neither - // pointer, joystick nor gamepad, and every Android key layout names that range - // BUTTON_1..BUTTON_16. Vendor-defined so nothing tries to interpret the collection. + // Overflow buttons GR and C: docs/virtual-gamepad.md#buttons-and-keycodes 0x06, 0x00, 0xFF.toByte(), // Usage Page (Vendor Defined FF00) 0x09, 0x01, // Usage (Vendor 1) 0xA1.toByte(), 0x01, // Collection (Application) diff --git a/feature/gamepad/data/src/main/kotlin/com/joegec/joycon2android/gamepad/privileged/PrivilegedAccess.kt b/feature/gamepad/data/src/main/kotlin/com/joegec/joycon2android/gamepad/privileged/PrivilegedAccess.kt index 0f495af..99d2d11 100644 --- a/feature/gamepad/data/src/main/kotlin/com/joegec/joycon2android/gamepad/privileged/PrivilegedAccess.kt +++ b/feature/gamepad/data/src/main/kotlin/com/joegec/joycon2android/gamepad/privileged/PrivilegedAccess.kt @@ -9,10 +9,6 @@ import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow import rikka.shizuku.Shizuku -/** - * Grants the virtual gamepad and emulator-config writes shell-uid access to `/dev/uhid` - * and app config files through Shizuku, the app's only privileged backend. - */ class PrivilegedAccess : PrivilegedAccessRepository { private val shizuku = ShizukuShell() diff --git a/feature/gamepad/data/src/main/kotlin/com/joegec/joycon2android/gamepad/privileged/PrivilegedShell.kt b/feature/gamepad/data/src/main/kotlin/com/joegec/joycon2android/gamepad/privileged/PrivilegedShell.kt index ecd37de..011830e 100644 --- a/feature/gamepad/data/src/main/kotlin/com/joegec/joycon2android/gamepad/privileged/PrivilegedShell.kt +++ b/feature/gamepad/data/src/main/kotlin/com/joegec/joycon2android/gamepad/privileged/PrivilegedShell.kt @@ -3,11 +3,7 @@ package com.joegec.joycon2android.gamepad.privileged import java.io.InputStream import java.io.OutputStream -/** - * A source of shell-uid processes — the one privilege the UHID relay needs (to reach - * `/dev/uhid`). Implemented over Shizuku and over an in-app ADB/wireless-debugging - * connection, so the relay layer is unaware of which grants the privilege. - */ +/** Shell-uid processes, for `/dev/uhid` and other apps' config files. */ interface PrivilegedShell { val isReady: Boolean fun newProcess(argv: Array): ShellProcess? diff --git a/feature/gamepad/data/src/main/kotlin/com/joegec/joycon2android/gamepad/shizuku/ShizukuPermissionHandler.kt b/feature/gamepad/data/src/main/kotlin/com/joegec/joycon2android/gamepad/shizuku/ShizukuPermissionHandler.kt index f13dc9e..b493c64 100644 --- a/feature/gamepad/data/src/main/kotlin/com/joegec/joycon2android/gamepad/shizuku/ShizukuPermissionHandler.kt +++ b/feature/gamepad/data/src/main/kotlin/com/joegec/joycon2android/gamepad/shizuku/ShizukuPermissionHandler.kt @@ -27,9 +27,7 @@ object ShizukuPermissionHandler { false } - // Shizuku dispatches the result to a main-thread listener, so the request must be issued - // on the main thread — issuing it from a background thread drops the callback and the - // caller waits forever. + // Must be issued on the main thread: from any other, Shizuku drops the callback and the caller hangs. fun requestPermission(callback: (granted: Boolean) -> Unit) { if (isPermissionGranted) { callback(true) diff --git a/feature/gamepad/data/src/test/kotlin/com/joegec/joycon2android/gamepad/ReportMapperTest.kt b/feature/gamepad/data/src/test/kotlin/com/joegec/joycon2android/gamepad/ReportMapperTest.kt index 7e0642f..4967c45 100644 --- a/feature/gamepad/data/src/test/kotlin/com/joegec/joycon2android/gamepad/ReportMapperTest.kt +++ b/feature/gamepad/data/src/test/kotlin/com/joegec/joycon2android/gamepad/ReportMapperTest.kt @@ -9,11 +9,7 @@ import com.joegec.joycon2android.model.Side import org.junit.Assert.assertEquals import org.junit.Test -/** - * Pins the bit each button takes, because the bit *is* the Android keycode: Linux maps HID Button - * n to BTN_GAMEPAD + n - 1 and the key layout names that. Move a button and every emulator config - * the app writes points at the wrong control. - */ +/** The bit is the Android keycode, so a moved button breaks every emulator config the app writes. */ class ReportMapperTest { private fun report(vararg pressed: JoyconButton): ByteArray { diff --git a/feature/gamepad/domain/src/main/kotlin/com/joegec/joycon2android/gamepad/GamepadRepository.kt b/feature/gamepad/domain/src/main/kotlin/com/joegec/joycon2android/gamepad/GamepadRepository.kt index e1d35e6..deb00fb 100644 --- a/feature/gamepad/domain/src/main/kotlin/com/joegec/joycon2android/gamepad/GamepadRepository.kt +++ b/feature/gamepad/domain/src/main/kotlin/com/joegec/joycon2android/gamepad/GamepadRepository.kt @@ -4,7 +4,6 @@ import com.joegec.joycon2android.model.PlayerNumber import com.joegec.joycon2android.model.PlayerState import kotlinx.coroutines.flow.StateFlow -/** The system-wide virtual gamepad output, as the domain sees it. */ interface GamepadRepository { val enabled: StateFlow val error: StateFlow diff --git a/feature/gamepad/domain/src/main/kotlin/com/joegec/joycon2android/gamepad/PrivilegedAccessRepository.kt b/feature/gamepad/domain/src/main/kotlin/com/joegec/joycon2android/gamepad/PrivilegedAccessRepository.kt index f92f7f0..ea87076 100644 --- a/feature/gamepad/domain/src/main/kotlin/com/joegec/joycon2android/gamepad/PrivilegedAccessRepository.kt +++ b/feature/gamepad/domain/src/main/kotlin/com/joegec/joycon2android/gamepad/PrivilegedAccessRepository.kt @@ -2,7 +2,6 @@ package com.joegec.joycon2android.gamepad import kotlinx.coroutines.flow.StateFlow -/** Availability of the privileged backend (Shizuku) that the virtual gamepad depends on. */ interface PrivilegedAccessRepository { val shizukuAvailable: StateFlow } diff --git a/feature/gamepad/domain/src/main/kotlin/com/joegec/joycon2android/gamepad/emulator/DolphinGcpadConfig.kt b/feature/gamepad/domain/src/main/kotlin/com/joegec/joycon2android/gamepad/emulator/DolphinGcpadConfig.kt index 56324f1..2b22a53 100644 --- a/feature/gamepad/domain/src/main/kotlin/com/joegec/joycon2android/gamepad/emulator/DolphinGcpadConfig.kt +++ b/feature/gamepad/domain/src/main/kotlin/com/joegec/joycon2android/gamepad/emulator/DolphinGcpadConfig.kt @@ -17,11 +17,7 @@ import com.joegec.joycon2android.emulatorconfig.IniEditor import com.joegec.joycon2android.model.JoyconButton import com.joegec.joycon2android.model.PlayerState -/** - * Generates Dolphin's `GCPadNew.ini` mappings for the Virtual Gamepad, one `[GCPadN]` section per - * assigned player, driven by the user's own Joy-Con → GameCube mapping. The device qualifier, the - * name tables and why each stick direction binds separately: docs/virtual-gamepad.md#emulator-config. - */ +/** Device qualifier and name tables: docs/virtual-gamepad.md#emulator-config */ object DolphinGcpadConfig { val path = DolphinPaths.config("GCPadNew.ini") val corePath = DolphinPaths.config("Dolphin.ini") @@ -43,9 +39,7 @@ object DolphinGcpadConfig { GameCubeButton.DPadRight to "D-Pad/Right", ) - // Dolphin's name for each Android keycode our virtual pad emits, fixed regardless of body. - // GR and Chat are absent: they land on BUTTON_1/BUTTON_2, and a GameCube pad has no target - // left for them anyway. + // GR and Chat are absent: a GameCube pad has no target left for them. private val ANDROID_NAMES = mapOf( JoyconButton.A to "Button A", JoyconButton.B to "Button B", @@ -81,7 +75,6 @@ object DolphinGcpadConfig { mappingFor: (PlayerBody) -> Map, ): String = IniEditor.mergeSections(existing, sections(players, controllerNumbers, mappingFor)) - /** Sets each configured player's GameCube port to a Standard Controller in Dolphin.ini. */ fun mergeCore(existing: String?, players: List): String { val siDevices = players .filter { it.hasController && !it.hasPro && it.player.index in 1..4 } @@ -89,10 +82,7 @@ object DolphinGcpadConfig { return IniEditor.setKeys(existing, "[Core]", siDevices) } - // Dolphin's device id comes from Android's own gamepad enumeration counter - // (InputDevice.getControllerNumber()), so it has to be read from the live device list rather - // than derived — any built-in controller already holds number 1. A player whose pad isn't - // enumerated yet is skipped: a guessed id binds the section to the wrong device, or to none. + // A player whose pad isn't enumerated yet is skipped: docs/virtual-gamepad.md#device-identity private fun sections( players: List, controllerNumbers: Map, @@ -139,7 +129,6 @@ object DolphinGcpadConfig { return buttonLines + stickLines } - // Dolphin's expression language ORs its inputs, so every source bound to a target can fire it. private fun expressionFor(side: JoyconSide, sources: List): String? = sources.mapNotNull { specFor(side, it) } .takeIf { it.isNotEmpty() } @@ -150,8 +139,7 @@ object DolphinGcpadConfig { is MappingSource.Stick -> tiltSpec(source.emittedStick(side), source.direction) } - // Physical left stick lands on Android axes 0/1, physical right stick on axes 11/14 (see - // ReportMapper); Android's Y axis grows downward. + // Android's Y axis grows downward. private fun tiltSpec(stick: StickSource, direction: StickDirection): String { val (x, y) = if (stick == StickSource.LEFT_STICK) 0 to 1 else 11 to 14 return when (direction) { diff --git a/feature/gamepad/domain/src/main/kotlin/com/joegec/joycon2android/gamepad/emulator/EdenGamepad.kt b/feature/gamepad/domain/src/main/kotlin/com/joegec/joycon2android/gamepad/emulator/EdenGamepad.kt index 2bced2c..df13199 100644 --- a/feature/gamepad/domain/src/main/kotlin/com/joegec/joycon2android/gamepad/emulator/EdenGamepad.kt +++ b/feature/gamepad/domain/src/main/kotlin/com/joegec/joycon2android/gamepad/emulator/EdenGamepad.kt @@ -1,11 +1,6 @@ package com.joegec.joycon2android.gamepad.emulator -/** - * How Eden addresses one of our virtual gamepads: the `port` it assigns while enumerating input - * devices, and the `guid` it derives from the device's USB ids — product then vendor, each a - * 16-digit hex half. Both read from the live device, never assumed: - * docs/virtual-gamepad.md#device-identity. - */ +/** Read from the live device, never assumed: docs/virtual-gamepad.md#device-identity */ data class EdenGamepad(val port: Int, val guid: String) { companion object { fun of(port: Int, vendorId: Int, productId: Int) = diff --git a/feature/gamepad/domain/src/main/kotlin/com/joegec/joycon2android/gamepad/emulator/EdenGamepadConfig.kt b/feature/gamepad/domain/src/main/kotlin/com/joegec/joycon2android/gamepad/emulator/EdenGamepadConfig.kt index 832b909..5f7a750 100644 --- a/feature/gamepad/domain/src/main/kotlin/com/joegec/joycon2android/gamepad/emulator/EdenGamepadConfig.kt +++ b/feature/gamepad/domain/src/main/kotlin/com/joegec/joycon2android/gamepad/emulator/EdenGamepadConfig.kt @@ -18,17 +18,9 @@ import com.joegec.joycon2android.emulatorconfig.defineEdenKey import com.joegec.joycon2android.model.JoyconButton import com.joegec.joycon2android.model.PlayerState -/** - * Generates Eden's `config.ini` `[Controls]` bindings for the Virtual Gamepad, driven by the user's - * own Joy-Con → Pro Controller mapping. The relay exposes every player as one standard Android HID - * gamepad; that fixed wiring, and why a single Joy-Con is presented as a Pro Controller and rotated - * on our side, are in docs/virtual-gamepad.md#buttons-and-keycodes and - * docs/virtual-gamepad.md#why-theyre-set-up-as-pro-controllers. - */ +/** Why a lone Joy-Con is a Pro Controller: docs/virtual-gamepad.md#why-theyre-set-up-as-pro-controllers */ object EdenGamepadConfig { - // Joy-Con button -> the Android keycode the relay's HID gamepad emits for it. ReportMapper - // places each one so the keycode carries its own name; Capture and GL take the two gamepad slots - // with no Switch equivalent, and GR/Chat the trailing vendor collection's BUTTON_1/BUTTON_2. + // Keycodes the relay emits: docs/virtual-gamepad.md#buttons-and-keycodes private const val A = 96 private const val B = 97 private const val CAPTURE = 98 @@ -75,8 +67,6 @@ object EdenGamepadConfig { gamepads: Map, mappingFor: (PlayerBody) -> Map, ): String { - // Drop every player's prior bindings first: a layout or port change leaves stale keys that - // would otherwise linger and cross-fire onto another player's port. val cleared = IniEditor.removeKeys(existing, EdenControls.SECTION) { it.matches(PLAYER_KEY) } return IniEditor.setKeys(cleared, EdenControls.SECTION, controlKeys(players, gamepads, mappingFor), assign = "=") } @@ -140,8 +130,6 @@ object EdenGamepadConfig { return inputs.takeIf { it.isNotEmpty() }?.let { DigitalStick(it.toMap()) } } - // Eden binds one input per key, so a target driven by several sources keeps the first that its - // body can actually emit; the rest are only reachable through Dolphin. private fun inputFor(side: JoyconSide, sources: List): Input? = sources.firstNotNullOfOrNull { inputFor(side, it) } @@ -150,8 +138,6 @@ object EdenGamepadConfig { is MappingSource.Stick -> tiltOf(source.emittedStick(side), source.direction) } - // Physical left stick lands on Android axes 0/1, physical right stick on axes 11/14 (see - // ReportMapper); Android's Y axis grows downward. private fun axesOf(stick: StickSource) = if (stick == StickSource.LEFT_STICK) 0 to 1 else 11 to 14 private fun tiltOf(stick: StickSource, direction: StickDirection): Axis { diff --git a/feature/gamepad/presentation/src/main/kotlin/com/joegec/joycon2android/gamepad/presentation/GamepadViewModel.kt b/feature/gamepad/presentation/src/main/kotlin/com/joegec/joycon2android/gamepad/presentation/GamepadViewModel.kt index 1505c70..7630629 100644 --- a/feature/gamepad/presentation/src/main/kotlin/com/joegec/joycon2android/gamepad/presentation/GamepadViewModel.kt +++ b/feature/gamepad/presentation/src/main/kotlin/com/joegec/joycon2android/gamepad/presentation/GamepadViewModel.kt @@ -19,7 +19,6 @@ import kotlinx.coroutines.CancellationException import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch -/** Feature-scoped state holder for the virtual gamepad and its privileged-access setup. */ class GamepadViewModel( observeGamepadStatus: ObserveGamepadStatusUseCase, observeShizukuAvailability: ObserveShizukuAvailabilityUseCase, @@ -42,11 +41,9 @@ class GamepadViewModel( private val _setupPhase = MutableStateFlow(DolphinSetupPhase.IDLE) val setupPhase: StateFlow = _setupPhase.asStateFlow() - /** The emulator that has to be closed before its config can be written, once the user agrees. */ private val _emulatorToClose = MutableStateFlow(null) val emulatorToClose: StateFlow = _emulatorToClose.asStateFlow() - /** The emulator to offer to start once its config has been written. */ private val _emulatorToStart = MutableStateFlow(null) val emulatorToStart: StateFlow = _emulatorToStart.asStateFlow() @@ -61,7 +58,6 @@ class GamepadViewModel( resetSetupPhase() } - /** Clears a stale Done/Failed and its start prompt once the written config no longer matches the assignment. */ fun resetSetupPhase() { if (_setupPhase.value == DolphinSetupPhase.WORKING) return _setupPhase.value = DolphinSetupPhase.IDLE @@ -70,7 +66,6 @@ class GamepadViewModel( fun configureGamepad(players: List) = write(players, closeEmulator = false) - /** The user accepted losing unsaved progress, so stop the emulator and write. */ fun closeEmulatorAndConfigure(players: List) { _emulatorToClose.value = null write(players, closeEmulator = true) diff --git a/feature/gamepad/presentation/src/main/kotlin/com/joegec/joycon2android/gamepad/presentation/ShizukuSetupCard.kt b/feature/gamepad/presentation/src/main/kotlin/com/joegec/joycon2android/gamepad/presentation/ShizukuSetupCard.kt index 9dc89a7..72f5a7b 100644 --- a/feature/gamepad/presentation/src/main/kotlin/com/joegec/joycon2android/gamepad/presentation/ShizukuSetupCard.kt +++ b/feature/gamepad/presentation/src/main/kotlin/com/joegec/joycon2android/gamepad/presentation/ShizukuSetupCard.kt @@ -28,7 +28,7 @@ import com.joegec.joycon2android.ui.theme.CardBg import com.joegec.joycon2android.ui.theme.Dimens import com.joegec.joycon2android.ui.theme.TextDim -/** Shown when Shizuku isn't running — the privileged backend the gamepad depends on. */ +/** Shown while Shizuku isn't running. */ @Composable fun ShizukuSetupCard(modifier: Modifier = Modifier) { val uriHandler = LocalUriHandler.current diff --git a/feature/update/data/src/main/kotlin/com/joegec/joycon2android/update/ApkUpdateInstaller.kt b/feature/update/data/src/main/kotlin/com/joegec/joycon2android/update/ApkUpdateInstaller.kt index 96d6afe..3c3573e 100644 --- a/feature/update/data/src/main/kotlin/com/joegec/joycon2android/update/ApkUpdateInstaller.kt +++ b/feature/update/data/src/main/kotlin/com/joegec/joycon2android/update/ApkUpdateInstaller.kt @@ -41,8 +41,7 @@ class ApkUpdateInstaller( emit(InstallProgress.HandedOff) }.flowOn(Dispatchers.IO) - // The previous attempt's APK is dead weight once a new one starts, and a half-written file - // from an interrupted download would install as a corrupt package. + // A half-written file from an interrupted download would install as a corrupt package. private fun emptyApkFile(update: AvailableUpdate): File { downloadDirectory.mkdirs() downloadDirectory.listFiles()?.forEach { it.delete() } diff --git a/feature/update/data/src/main/kotlin/com/joegec/joycon2android/update/GitHubReleaseParser.kt b/feature/update/data/src/main/kotlin/com/joegec/joycon2android/update/GitHubReleaseParser.kt index 898937f..589f134 100644 --- a/feature/update/data/src/main/kotlin/com/joegec/joycon2android/update/GitHubReleaseParser.kt +++ b/feature/update/data/src/main/kotlin/com/joegec/joycon2android/update/GitHubReleaseParser.kt @@ -3,10 +3,6 @@ package com.joegec.joycon2android.update import org.json.JSONArray import org.json.JSONObject -/** - * Reads what the prompt needs out of a GitHub `releases/latest` payload: the tag as a version, - * the body as highlights, and the APK asset's download URL. - */ class GitHubReleaseParser { fun parse(json: String): AvailableUpdate? { diff --git a/feature/update/data/src/main/kotlin/com/joegec/joycon2android/update/GitHubReleases.kt b/feature/update/data/src/main/kotlin/com/joegec/joycon2android/update/GitHubReleases.kt index d56d864..c98f0bf 100644 --- a/feature/update/data/src/main/kotlin/com/joegec/joycon2android/update/GitHubReleases.kt +++ b/feature/update/data/src/main/kotlin/com/joegec/joycon2android/update/GitHubReleases.kt @@ -8,9 +8,8 @@ import java.net.HttpURLConnection import java.net.URL /** - * The `releases/latest` endpoint never returns a prerelease, so the `-debug.N` builds cut for - * sharing are invisible here. Unauthenticated calls are rate limited to 60 an hour per address; - * exceeding it reads as "no update", like being offline. + * `releases/latest` never returns a prerelease, so `-debug.N` builds are invisible here. The + * unauthenticated limit is 60 calls an hour per address; past it reads as "no update". */ class GitHubReleases( private val repository: String, diff --git a/feature/update/domain/src/main/kotlin/com/joegec/joycon2android/update/ReleaseNotes.kt b/feature/update/domain/src/main/kotlin/com/joegec/joycon2android/update/ReleaseNotes.kt index 3ca9dd4..b3553bd 100644 --- a/feature/update/domain/src/main/kotlin/com/joegec/joycon2android/update/ReleaseNotes.kt +++ b/feature/update/domain/src/main/kotlin/com/joegec/joycon2android/update/ReleaseNotes.kt @@ -1,9 +1,6 @@ package com.joegec.joycon2android.update -/** - * Reduces a GitHub release body to the few lines the prompt shows: the bullets under its - * "What's new" heading, stripped of markdown the dialog cannot render. - */ +/** The bullets under "What's new", stripped of markdown the dialog can't render. */ object ReleaseNotes { fun highlights(body: String): List { @@ -35,8 +32,7 @@ private fun String.asHighlight(): String { return (bullet.boldLead() ?: bullet).withoutMarkdown() } -// Release bullets lead with a bolded summary and follow it with a sentence of consequence. The -// prompt is a glance before an install, so it shows the summary and leaves the detail to the notes. +// A bullet opens with a bold summary; the prompt shows only that. private fun String.boldLead(): String? { if (!startsWith(BOLD)) return null val close = indexOf(BOLD, startIndex = BOLD.length) diff --git a/konsist/src/test/kotlin/com/joegec/joycon2android/konsist/ArchitectureTest.kt b/konsist/src/test/kotlin/com/joegec/joycon2android/konsist/ArchitectureTest.kt index d985703..f023967 100644 --- a/konsist/src/test/kotlin/com/joegec/joycon2android/konsist/ArchitectureTest.kt +++ b/konsist/src/test/kotlin/com/joegec/joycon2android/konsist/ArchitectureTest.kt @@ -5,11 +5,7 @@ import com.lemonappdev.konsist.api.ext.list.withNameEndingWith import com.lemonappdev.konsist.api.verify.assertTrue import org.junit.Test -/** - * Layer conventions the Gradle module graph cannot enforce on its own — *where* a kind of class - * is allowed to live. The graph already stops presentation from seeing data; these stop a - * ViewModel, use case, or repository interface from landing in the wrong layer in the first place. - */ +/** Placement rules the module graph can't enforce: docs/architecture.md#dependency-rules */ class ArchitectureTest { @Test diff --git a/tools/README.md b/tools/README.md index 438e19b..15866b9 100644 --- a/tools/README.md +++ b/tools/README.md @@ -17,14 +17,17 @@ via adb: ``` The third argument sets the motion print interval; it defaults to a readable 0.25 s, and -`0` prints every packet (~90 Hz), which is what differentiating the gravity vector needs. - +`0` prints every packet, which is what differentiating the gravity vector needs. ## Flick measurement -`flick_stats.py` reads a `dsu_client` capture and reports what a flick leaves behind after -the slew limiter `DolphinWiimoteConfig` subtracts — the number that decides whether a trick -fires. Use it to set `FLICK_RADIANS` from a hand rather than from an assumption. +`flick_stats.py` lists each flick in a `dsu_client` capture with its signed pitch, yaw and roll, +for setting `FLICK_RADIANS` from a hand rather than an assumption. + +> [!WARNING] +> Its `residual` column still models the retired discriminator (summed `|pitch| + |yaw| + |roll|` +> minus a slew limiter). `DolphinWiimoteConfig` reads raw pitch alone +> ([why](../docs/dsu-motion.md#tricks-and-wheelies)), so go by the pitch figures. Enable DSU in the app with a single Joy-Con on P1 (slot 0), then capture twice: @@ -45,19 +48,16 @@ Three things to read out of it: - **The spread across events.** Flicks of the same strength reading very different residuals means the stream is catching them at different points, not that the hand varied. - **The gap between the two captures.** `FLICK_RADIANS` has to sit under twice the weakest - flick and over twice the largest steering residual. If those cross, the limiter is the - wrong discriminator and no threshold will do. + flick and over twice the strongest steering. If those cross, no threshold will do. -### Axis calibration workflow +## Axis calibration 1. Capture while performing slow single-axis motions with holds (still → yaw left → pitch up → roll right), or any rich motion if direction labels aren't trusted. -2. Static holds anchor the accel frame (cemuhook: x=left, y=down, z=forward; flat at - rest reads (0,−1,0)). +2. Static holds anchor the accel frame ([the frames](../docs/dsu-motion.md#motion-frame)). 3. Gyro signs follow from the physics constraint `dv/dt = v × ω` applied to the normalized accel vector — fit the 16 sign combinations and break the mirror - degeneracy with one static-hold anchor. (Done for the right Joy-Con, 2026-06; - see `MotionConverter`.) + degeneracy with one static-hold anchor. (Done for the right Joy-Con, 2026-06.) ### Checking a gyro sign against gravity From 4d6f64392cd1874c909ccc68e91c211f0f710092 Mon Sep 17 00:00:00 2001 From: Joe Barker Date: Wed, 23 Sep 2026 17:55:31 +0100 Subject: [PATCH 16/16] Point a lone Joy-Con's stick up toward L/R unless it plays sideways On the left Joy-Con's Wii layout, A moves to Right and + to Up, with 1 and 2 on Left and Down where a right Joy-Con keeps them. Co-Authored-By: Claude Opus 5.5 --- README.md | 4 +-- .../buttonmapping/EmittedInput.kt | 22 ++++++++++++++++ .../buttonmapping/preset/WiiMapping.kt | 8 +++--- .../buttonmapping/preset/WiiPresetsTest.kt | 10 +++++++ .../presentation/PlayerMappingCard.kt | 5 +--- .../src/main/res/values/strings.xml | 3 +-- docs/dsu-motion.md | 2 ++ .../dsu/emulator/DolphinWiimoteConfig.kt | 26 ++++++++++--------- .../dsu/emulator/DolphinWiimoteConfigTest.kt | 19 +++++++++++--- 9 files changed, 71 insertions(+), 28 deletions(-) diff --git a/README.md b/README.md index a397072..9dbfcd1 100644 --- a/README.md +++ b/README.md @@ -101,8 +101,8 @@ target can take **several sources** — tick as many as you like, and any of the **Mario Kart Wheel**, say — gives the rest the same game's other grip. A saved set remembers who held which body ("P1 L, P2 R, P3 L/R") and stays greyed out until those players are back. - **Sideways Wii Remote.** On the Wii console, a lone Joy-Con also gets this switch, for games that - steer by tilting a sideways remote. The layout sets it (on for Mario Kart) and you can override - it until you next pick a layout. + steer by tilting a sideways remote. On, up on the stick is towards the rail; off, towards L or R. + The layout sets it (on for Mario Kart) and you can override it until you next pick a layout. The layouts the app ships: diff --git a/core/buttonmapping/domain/src/main/kotlin/com/joegec/joycon2android/buttonmapping/EmittedInput.kt b/core/buttonmapping/domain/src/main/kotlin/com/joegec/joycon2android/buttonmapping/EmittedInput.kt index 7e4c384..acbe3df 100644 --- a/core/buttonmapping/domain/src/main/kotlin/com/joegec/joycon2android/buttonmapping/EmittedInput.kt +++ b/core/buttonmapping/domain/src/main/kotlin/com/joegec/joycon2android/buttonmapping/EmittedInput.kt @@ -17,6 +17,28 @@ fun JoyconButton.emittedFor(side: JoyconSide): JoyconButton? { fun MappingSource.Stick.emittedStick(side: JoyconSide): StickSource = if (side == JoyconSide.DUAL) stick else StickSource.LEFT_STICK +/** The relay reports a lone Joy-Con's up as its rail; off a sideways remote, up is its L/R edge. */ +fun MappingSource.Stick.emittedDirection(side: JoyconSide, sidewaysRemote: Boolean): StickDirection = when { + sidewaysRemote -> direction + side == JoyconSide.LEFT -> UPRIGHT_LEFT.getValue(direction) + side == JoyconSide.RIGHT -> UPRIGHT_RIGHT.getValue(direction) + else -> direction +} + +private val UPRIGHT_LEFT = mapOf( + StickDirection.UP to StickDirection.LEFT, + StickDirection.RIGHT to StickDirection.UP, + StickDirection.DOWN to StickDirection.RIGHT, + StickDirection.LEFT to StickDirection.DOWN, +) + +private val UPRIGHT_RIGHT = mapOf( + StickDirection.UP to StickDirection.RIGHT, + StickDirection.LEFT to StickDirection.UP, + StickDirection.DOWN to StickDirection.LEFT, + StickDirection.RIGHT to StickDirection.DOWN, +) + /** Non-null only when all four directions follow one stick the natural way round, so it stays analog. */ fun Map>.wholeEmittedStick(side: JoyconSide): StickSource? { val sticks = StickDirection.entries.map { direction -> diff --git a/core/buttonmapping/domain/src/main/kotlin/com/joegec/joycon2android/buttonmapping/preset/WiiMapping.kt b/core/buttonmapping/domain/src/main/kotlin/com/joegec/joycon2android/buttonmapping/preset/WiiMapping.kt index 931a856..dd3ddf9 100644 --- a/core/buttonmapping/domain/src/main/kotlin/com/joegec/joycon2android/buttonmapping/preset/WiiMapping.kt +++ b/core/buttonmapping/domain/src/main/kotlin/com/joegec/joycon2android/buttonmapping/preset/WiiMapping.kt @@ -51,12 +51,12 @@ object WiiMapping : MappingPreset { WiimoteButton.NunchukZ to ZL, ) JoyconSide.LEFT -> mapOf( - WiimoteButton.A to Down, + WiimoteButton.A to Right, WiimoteButton.B to ZL, - WiimoteButton.One to Up, - WiimoteButton.Two to Left, + WiimoteButton.One to Left, + WiimoteButton.Two to Down, WiimoteButton.Home to Capture, - WiimoteButton.Plus to Right, + WiimoteButton.Plus to Up, WiimoteButton.Minus to Minus, ) JoyconSide.RIGHT -> mapOf( diff --git a/core/buttonmapping/domain/src/test/kotlin/com/joegec/joycon2android/buttonmapping/preset/WiiPresetsTest.kt b/core/buttonmapping/domain/src/test/kotlin/com/joegec/joycon2android/buttonmapping/preset/WiiPresetsTest.kt index 1563770..227d809 100644 --- a/core/buttonmapping/domain/src/test/kotlin/com/joegec/joycon2android/buttonmapping/preset/WiiPresetsTest.kt +++ b/core/buttonmapping/domain/src/test/kotlin/com/joegec/joycon2android/buttonmapping/preset/WiiPresetsTest.kt @@ -20,6 +20,16 @@ class WiiPresetsTest { assertEquals(WiiMapping, MappingPresets.default(Console.WIIMOTE_NUNCHUK)) } + @Test + fun `a left Joy-Con on the Wii layout keeps A and 1 and 2 where a right Joy-Con does, and + on top`() { + val left = WiiMapping.entries(JoyconSide.LEFT) + + assertEquals("Right", left.getValue(WiimoteButton.A.name)) + assertEquals("Left", left.getValue(WiimoteButton.One.name)) + assertEquals("Down", left.getValue(WiimoteButton.Two.name)) + assertEquals("Up", left.getValue(WiimoteButton.Plus.name)) + } + @Test fun `the Joy-Con layout puts the remote's buttons where a Joy-Con keeps them`() { val dual = JoyconWiiMapping.entries(JoyconSide.DUAL) diff --git a/core/buttonmapping/presentation/src/main/kotlin/com/joegec/joycon2android/buttonmapping/presentation/PlayerMappingCard.kt b/core/buttonmapping/presentation/src/main/kotlin/com/joegec/joycon2android/buttonmapping/presentation/PlayerMappingCard.kt index 91a282b..c2c876e 100644 --- a/core/buttonmapping/presentation/src/main/kotlin/com/joegec/joycon2android/buttonmapping/presentation/PlayerMappingCard.kt +++ b/core/buttonmapping/presentation/src/main/kotlin/com/joegec/joycon2android/buttonmapping/presentation/PlayerMappingCard.kt @@ -162,10 +162,7 @@ private fun SidewaysRemoteSwitch(side: JoyconSide, enabled: Boolean, onSetEnable val aimsFromItsTail = side == JoyconSide.RIGHT SettingSwitch( title = stringResource(R.string.controller_mapping_sideways_remote), - description = stringResource( - if (aimsFromItsTail) R.string.controller_mapping_sideways_remote_description_right - else R.string.controller_mapping_sideways_remote_description_left, - ), + description = stringResource(R.string.controller_mapping_sideways_remote_description), warning = stringResource(R.string.controller_mapping_sideways_remote_warning) .takeIf { aimsFromItsTail }, checked = enabled, diff --git a/core/buttonmapping/presentation/src/main/res/values/strings.xml b/core/buttonmapping/presentation/src/main/res/values/strings.xml index c3e8ee2..33f6eb2 100644 --- a/core/buttonmapping/presentation/src/main/res/values/strings.xml +++ b/core/buttonmapping/presentation/src/main/res/values/strings.xml @@ -13,8 +13,7 @@ Delete Connect a controller to map it. Sideways Wii Remote - Rotates the d-pad and stick. - Steering reads correctly, rotates the d-pad and stick. + Fixes motion steering. Up is towards the rail. When enabled, a right Joy-Con points from its tail rather than its R edge. P%1$d Left diff --git a/docs/dsu-motion.md b/docs/dsu-motion.md index 1f9db75..9d183e0 100644 --- a/docs/dsu-motion.md +++ b/docs/dsu-motion.md @@ -96,6 +96,8 @@ the override). - **Only a right Joy-Con turns.** A game written for that grip reads gravity against a remote whose nose points left, where a left Joy-Con's L/ZL edge already points. A right Joy-Con gives up its own body to steer true, and with it R/ZR as the nose: aiming moves to the tail. +- **Up follows the grip.** Off, a lone Joy-Con's stick reads up toward its L/R edge; on, toward its + rail, where the relay already puts it (`emittedDirection`). - **The D-pad turns a quarter on both bodies**, since the player's up is a sideways remote's right. That is Dolphin's own `dpad_sideways_bitmasks`, written into the bindings so Dolphin's *Sideways Wii Remote* option stays off — the option would turn the accelerometer a second time. diff --git a/feature/dsu/domain/src/main/kotlin/com/joegec/joycon2android/dsu/emulator/DolphinWiimoteConfig.kt b/feature/dsu/domain/src/main/kotlin/com/joegec/joycon2android/dsu/emulator/DolphinWiimoteConfig.kt index 4a65136..13b5ea6 100644 --- a/feature/dsu/domain/src/main/kotlin/com/joegec/joycon2android/dsu/emulator/DolphinWiimoteConfig.kt +++ b/feature/dsu/domain/src/main/kotlin/com/joegec/joycon2android/dsu/emulator/DolphinWiimoteConfig.kt @@ -5,6 +5,7 @@ import com.joegec.joycon2android.buttonmapping.MappingSource import com.joegec.joycon2android.buttonmapping.PlayerBody import com.joegec.joycon2android.buttonmapping.StickDirection import com.joegec.joycon2android.buttonmapping.StickSource +import com.joegec.joycon2android.buttonmapping.emittedDirection import com.joegec.joycon2android.buttonmapping.emittedFor import com.joegec.joycon2android.buttonmapping.emittedStick import com.joegec.joycon2android.buttonmapping.target.WiimoteButton @@ -137,7 +138,7 @@ object DolphinWiimoteConfig { } else { null } - val pressed = bound?.takeIf { control == UP }?.let { expressionFor(side, it) } + val pressed = bound?.takeIf { control == UP }?.let { expressionFor(side, sidewaysRemote, it) } return listOfNotNull(flick, pressed).takeIf { it.isNotEmpty() }?.joinToString(" | ") } @@ -219,20 +220,21 @@ object DolphinWiimoteConfig { } else { emptyList() } - val sideways = sidewaysRemote && side != JoyconSide.DUAL val mapping = mappingFor(body) val shake = mapping.toSourceMap()[WiimoteButton.Shake] - return (header + lines(side, sideways, mapping) + imuLines(side, sidewaysRemote, shake) + + return (header + lines(side, sidewaysRemote, mapping) + imuLines(side, sidewaysRemote, shake) + swingLines(side, sidewaysRemote) + nunchukImu) .joinToString("\n", postfix = "\n") } - private fun lines(side: JoyconSide, sideways: Boolean, mapping: Map): List { + private fun lines(side: JoyconSide, sidewaysRemote: Boolean, mapping: Map): List { + val sideways = sidewaysRemote && side != JoyconSide.DUAL val buttonLines = (mapping.toSourceMap() - WiimoteButton.Shake) .mapNotNull { (target, sources) -> - expressionFor(side, sources)?.let { expression -> "${dolphinKey(target, sideways)} = $expression" } + expressionFor(side, sidewaysRemote, sources) + ?.let { expression -> "${dolphinKey(target, sideways)} = $expression" } } - val stickLines = nunchukStickLines(side, mapping) + val stickLines = nunchukStickLines(side, sidewaysRemote, mapping) val recenterSpec = if (side == JoyconSide.LEFT) "L1" else "R1" val extension = if (usesNunchuk(side, buttonLines + stickLines)) "Nunchuk" else "None" return buttonLines + listOf("IMUIR/Recenter = `$recenterSpec`", "Extension = $extension") + stickLines @@ -242,21 +244,21 @@ object DolphinWiimoteConfig { private fun usesNunchuk(side: JoyconSide, mappedLines: List) = side == JoyconSide.DUAL || mappedLines.any { it.startsWith("Nunchuk/") } - private fun nunchukStickLines(side: JoyconSide, mapping: Map): List = + private fun nunchukStickLines(side: JoyconSide, sidewaysRemote: Boolean, mapping: Map): List = mapping.toStickDirectionMap().values.flatMap { directions -> directions.mapNotNull { (direction, sources) -> - expressionFor(side, sources)?.let { expression -> "Nunchuk/Stick/${DolphinControls.DIRECTIONS.getValue(direction)} = $expression" } + expressionFor(side, sidewaysRemote, sources)?.let { expression -> "Nunchuk/Stick/${DolphinControls.DIRECTIONS.getValue(direction)} = $expression" } } } - private fun expressionFor(side: JoyconSide, sources: List): String? = - sources.mapNotNull { specFor(side, it) } + private fun expressionFor(side: JoyconSide, sidewaysRemote: Boolean, sources: List): String? = + sources.mapNotNull { specFor(side, sidewaysRemote, it) } .takeIf { it.isNotEmpty() } ?.joinToString(" | ") { "`$it`" } - private fun specFor(side: JoyconSide, source: MappingSource): String? = when (source) { + private fun specFor(side: JoyconSide, sidewaysRemote: Boolean, source: MappingSource): String? = when (source) { is MappingSource.Button -> source.button.emittedFor(side)?.let { DS4_NAMES[it] ?: PAD_NAMES[it] } - is MappingSource.Stick -> tiltSpec(source.emittedStick(side), source.direction) + is MappingSource.Stick -> tiltSpec(source.emittedStick(side), source.emittedDirection(side, sidewaysRemote)) } // DSU sticks report up as a positive Y, unlike Android's axes. diff --git a/feature/dsu/domain/src/test/kotlin/com/joegec/joycon2android/dsu/emulator/DolphinWiimoteConfigTest.kt b/feature/dsu/domain/src/test/kotlin/com/joegec/joycon2android/dsu/emulator/DolphinWiimoteConfigTest.kt index e56166f..fbd3754 100644 --- a/feature/dsu/domain/src/test/kotlin/com/joegec/joycon2android/dsu/emulator/DolphinWiimoteConfigTest.kt +++ b/feature/dsu/domain/src/test/kotlin/com/joegec/joycon2android/dsu/emulator/DolphinWiimoteConfigTest.kt @@ -34,7 +34,7 @@ class DolphinWiimoteConfigTest { assertTrue(result.contains("Source = 1")) assertTrue(result.contains("Device = DSUClient/0/Joycon2")) assertTrue(result.contains("Buttons/A = `Cross`")) // physical A rotates onto B - assertTrue(result.contains("D-Pad/Up = `Left Y+`")) + assertTrue(result.contains("D-Pad/Up = `Left X+`")) assertTrue(result.contains("IMUIR/Recenter = `R1`")) assertTrue(result.contains("Extension = None")) } @@ -43,7 +43,7 @@ class DolphinWiimoteConfigTest { fun `left-only player maps its directions onto faces and recenters on L`() { val result = merge(null, listOf(PlayerState(PlayerNumber.P1, left = joycon(Side.LEFT)))) - assertTrue(result.contains("Buttons/A = `Circle`")) // Down rotates onto A + assertTrue(result.contains("Buttons/A = `Triangle`")) // Right rotates onto X assertTrue(result.contains("Buttons/Home = `Touch Button`")) assertTrue(result.contains("IMUIR/Recenter = `L1`")) } @@ -82,7 +82,7 @@ class DolphinWiimoteConfigTest { assertTrue(result.contains("Extension = Nunchuk")) assertTrue(result.contains("Nunchuk/Stick/Up = `Circle`")) // physical X rotates onto A - assertTrue(result.contains("D-Pad/Up = `Left Y+`")) // its own stick still steers the d-pad + assertTrue(result.contains("D-Pad/Up = `Left X+`")) // its own stick still steers the d-pad assertFalse(result.contains("Nunchuk/IMUAccelerometer")) // no second hand to stream one } @@ -93,7 +93,7 @@ class DolphinWiimoteConfigTest { val result = DolphinWiimoteConfig.merge(null, player, { false }) { mapping } - assertTrue(result.contains("D-Pad/Up = `Left Y+` | `L1`")) // SL rotates onto L held sideways + assertTrue(result.contains("D-Pad/Up = `Left X+` | `L1`")) // SL rotates onto L held sideways } @Test @@ -192,6 +192,17 @@ class DolphinWiimoteConfigTest { assertTrue(result.contains("IMUGyroscope/Yaw Left = `Gyro Yaw Left`")) } + @Test + fun `off a sideways remote, a lone Joy-Con's stick points up toward its L or R`() { + val right = merge(null, listOf(PlayerState(PlayerNumber.P1, right = joycon(Side.RIGHT)))) + val left = merge(null, listOf(PlayerState(PlayerNumber.P1, left = joycon(Side.LEFT)))) + + assertTrue(right.contains("D-Pad/Up = `Left X+`")) + assertTrue(right.contains("D-Pad/Left = `Left Y+`")) // the rail + assertTrue(left.contains("D-Pad/Up = `Left X-`")) + assertTrue(left.contains("D-Pad/Right = `Left Y+`")) // the rail + } + @Test fun `playing sideways turns the d-pad a quarter, on both bodies`() { val right = merge(null, listOf(PlayerState(PlayerNumber.P1, right = joycon(Side.RIGHT))), sidewaysRemote = true)