Feat(Input): added lyre keys, second alternatives, key colors (#1224)

EN-US

- Added the keyboard keys to the Muses' lyre and its gold mechanism
- Added an option to show the alternative key on the lyre only
- Added a second alternative key per button, in the table, profiles and live picture
- Added Gray, PlayStation and Custom colors for the face button labels
- Fixed long key maps being cut at 2048 bytes
- Fixed alternatives being read differently by the launcher and the runtime
- Updated the Controls snapshots, the READMEs and the specs

---

PT-BR

- Adicionadas as teclas do teclado à lira das Musas e ao seu mecanismo dourado
- Adicionada a opção de mostrar a tecla alternativa só na lira
- Adicionada uma segunda tecla alternativa por botão, na tabela, nos perfis e na imagem ao vivo
- Adicionadas as cores Cinza, PlayStation e Personalizada aos rótulos dos botões de ação
- Corrigido o corte de mapas de teclas longos em 2048 bytes
- Corrigida a leitura diferente das alternativas entre launcher e runtime
- Atualizados os snapshots de Controles, os READMEs e as specs
This commit is contained in:
Edinaldo Filho authored and GitHub committed 2026-11-05 01:04:36 -03:00
1 parent 74b06dffa9
commit 7289f72a57
32 files changed
+2172 -287

No files matched your search

+14 -10
View File
@@ -313,15 +313,16 @@ L2, y *Right stick sensitivity* define cuánto mouse hace falta. Sin el launcher
propias teclas: E y Q para Cuadrado y Triángulo, 1 y 3 para L1 y R1, F para R2, C para R3, I, K, J,
L para la cruceta, Enter para Options, Tab y Retroceso para el touchpad.
**Teclas alternativas.** Cada botón puede tener una segunda tecla o entrada del mouse, en la segunda
casilla de su fila en la página Controls (el botón x al lado la borra). Las dos presionan el botón,
así que podés dejar el Cuadrado en el clic izquierdo para el combate y presionarlo también con la J
en los minijuegos. En la lira de las Musas, por ejemplo, dejá Espacio, Ctrl izquierdo y los dos
**Teclas alternativas.** Cada botón puede tener una segunda y una tercera tecla o entrada del mouse, en
la segunda y la tercera casilla de su fila en la página Controls (el botón x al lado de una casilla
la borra). Todas presionan el botón, así que podés dejar el Cuadrado en el clic izquierdo para el
combate y presionarlo también con la J, o con el Keypad 4, en los minijuegos. En la lira de las Musas, por ejemplo, dejá Espacio, Shift izquierdo y los dos
botones del mouse y agregá K, L, J e I, dispuestas como los cuatro botones (I arriba, J a la
izquierda, L a la derecha, K abajo). Una tecla que ya usa otro botón se intercambia con él, como en
las teclas principales. Los íconos de teclado en el juego muestran la tecla principal; *Keys on the
icons* (página Controls, con el estilo Keyboard) puede mostrar la alternativa. Sin el launcher,
uní los nombres con `|` en `SPARTAN_KEY_MAP`, por ejemplo `cross=Space|K,square=Mouse Left|J`.
icons* (página Controls, con el estilo Keyboard o Match my controller) puede mostrar la alternativa, o la alternativa solo en
la lira (los demás íconos conservan la principal). Los íconos dibujan la primera alternativa definida: la segunda casilla, o la tercera si la segunda está vacía. Sin el launcher,
uní los nombres con `|` en `SPARTAN_KEY_MAP`, por ejemplo `cross=Space|K|Keypad 2,square=Mouse Left|J`.
En un control sin touchpad (Xbox y otros), View abre el menú de pausa (el clic del touchpad derecho)
y L1 + View abre el modo foto (el clic izquierdo). En un DualSense el touchpad funciona como en la
@@ -336,10 +337,13 @@ siguiente arma. El ataque de cambio de la misma arma otra vez espera 0,8 segundo
Los **íconos de botones en el juego** (desactivados por defecto) se eligen en la página Controls del launcher, en
*In-game button icons*: *Match my controller*, *Xbox*, *Nintendo* o *Keyboard*. Reemplazan los íconos de
PlayStation en los tutoriales, la lista de movimientos, las indicaciones de quick-time events y, con los estilos
de control, en la lira de las Musas en la Cámara de la Llama; el dibujo del control en el menú de pausa sigue siendo un DualShock 4. *Keyboard* muestra la tecla que
presiona cada botón, según tu mapeo del teclado (una tecla cambiada aparece en el próximo inicio);
*Match my controller* también muestra las teclas cuando no hay un control conectado. El launcher escribe una copia
PlayStation en los tutoriales, la lista de movimientos, las indicaciones de quick-time events y en la lira de las
Musas en la Cámara de la Llama; el dibujo del control en el menú de pausa sigue siendo un DualShock 4. *Keyboard* muestra la tecla que
presiona cada botón, también en la lira, según tu mapeo del teclado (una tecla cambiada aparece en el próximo inicio);
*Match my controller* también muestra las teclas cuando no hay un control conectado.
*Key colors* (en el mismo lugar) pinta las teclas de X, Círculo, Cuadrado y Triángulo, en la lira y en
los demás íconos de teclado: *PlayStation colors*, o *Custom* con un color de una paleta corta para
cada botón; los demás íconos y el *Gray* por defecto no cambian. El launcher escribe una copia
parcheada de hasta tres archivos del juego (unos 410 MB) en la carpeta de datos del port y nunca modifica tu carpeta del
juego. Un control nuevo se detecta después de reiniciar. Sin el launcher, definí `SPARTAN_GLYPH_STYLE`
como `auto`, `xbox`, `nintendo` o `keyboard` antes de iniciar `run.sh`.
+13 -8
View File
@@ -347,14 +347,16 @@ how much mouse it takes. Without the launcher the game keeps its own keys: E and
Triangle, 1 and 3 for L1 and R1, F for R2, C for R3, I, K, J, L for the d-pad, Enter for Options,
Tab and Backspace for the touchpad.
**Alternative keys.** Every button can have a second key or mouse input, in the second box of its
row on the Controls page (the x button next to it clears it). Both press the button, so you can keep
Square on the left click for combat and also press it with J in the minigames. On the Muses' lyre,
for example, keep Space, Left Ctrl and the two mouse buttons and add K, L, J and I, laid out like the
**Alternative keys.** Every button can have a second and a third key or mouse input, in the second
and third boxes of its row on the Controls page (the x button next to a box clears it). All of them
press the button, so you can keep Square on the left click for combat and also press it with J, or
with Keypad 4, in the minigames. On the Muses' lyre,
for example, keep Space, Left Shift and the two mouse buttons and add K, L, J and I, laid out like the
four buttons (I up, J left, L right, K down). A key already used by another button swaps with it, as
for the main keys. The in-game keyboard icons show the main key; *Keys on the icons* (Controls page,
with the Keyboard style) can show the alternative instead. Without the launcher, join the names with
`|` in `SPARTAN_KEY_MAP`, for example `cross=Space|K,square=Mouse Left|J`.
with the Keyboard or Match my controller style) can show the alternative instead, or the alternative on the lyre only (the
other icons keep the main key). The icons draw the first alternative that is set: the second box, or the third when the second is empty. Without the launcher, join the names with
`|` in `SPARTAN_KEY_MAP`, for example `cross=Space|K|Keypad 2,square=Mouse Left|J`.
On a controller without a touchpad (Xbox and others), View opens the pause menu (the right
touchpad click) and L1 + View opens photo mode (the left click). On a DualSense the touchpad
@@ -370,10 +372,13 @@ How the game picks Kratos' moves, for further changes: [docs/moves.md](docs/move
**In-game button icons** (off by default) are chosen on the launcher's Controls page, under
*In-game button icons*: *Match my controller*, *Xbox*, *Nintendo* or *Keyboard*. They replace the PlayStation icons in the
tutorial, move-list and quick-time prompts and, for the controller styles, on the Muses' lyre in the
tutorial, move-list and quick-time prompts and on the Muses' lyre in the
Chamber of the Flame; the pause-menu controller drawing stays a DualShock 4.
*Keyboard* shows the key that presses each button, following your keyboard mapping (a changed key shows
*Keyboard* shows the key that presses each button, on the lyre too, following your keyboard mapping (a changed key shows
on the next start); *Match my controller* shows the keys too when no gamepad is connected.
*Key colors* (same place) paints the key labels of Cross, Circle, Square and Triangle, on the lyre
and on the other keyboard icons: *PlayStation colors*, or *Custom* with one color of a short palette
for each button; the other icons and the default *Gray* are unchanged.
The launcher writes a patched copy of up to three game files (about 410 MB) into the port's data folder and never changes
your game folder. A new controller is picked up after a restart. Without the launcher, set
`SPARTAN_GLYPH_STYLE` to `auto`, `xbox`, `nintendo` or `keyboard` before starting `run.sh`.
+14 -10
View File
@@ -309,15 +309,16 @@ pressionado (R), o mouse mira com o analógico direito, para a Cabeça de Hélio
teclas: E e Q para Quadrado e Triângulo, 1 e 3 para L1 e R1, F para R2, C para R3, I, K, J, L para o
direcional, Enter para Options, Tab e Backspace para o touchpad.
**Teclas alternativas.** Todo botão pode ter uma segunda tecla ou entrada do mouse, na segunda caixa
da linha dele na tela Controls (o botão x ao lado a limpa). As duas apertam o botão, então você pode
manter o Quadrado no clique esquerdo para o combate e também apertá-lo com o J nos minigames. Na lira
das Musas, por exemplo, mantenha Espaço, Ctrl esquerdo e os dois botões do mouse e acrescente K, L, J
**Teclas alternativas.** Todo botão pode ter uma segunda e uma terceira tecla ou entrada do mouse, na
segunda e na terceira caixa da linha dele na tela Controls (o botão x ao lado de uma caixa a limpa).
Todas apertam o botão, então você pode manter o Quadrado no clique esquerdo para o combate e também
apertá-lo com o J, ou com o Keypad 4, nos minigames. Na lira
das Musas, por exemplo, mantenha Espaço, Shift esquerdo e os dois botões do mouse e acrescente K, L, J
e I, dispostas como os quatro botões (I em cima, J à esquerda, L à direita, K embaixo). Uma tecla que
já é de outro botão troca com ele, como nas teclas principais. Os ícones de teclado no jogo mostram a
tecla principal; *Teclas nos ícones* (tela Controls, com o estilo Teclado) pode mostrar a alternativa
no lugar. Sem o launcher, junte os nomes com `|` no `SPARTAN_KEY_MAP`, por exemplo
`cross=Space|K,square=Mouse Left|J`.
tecla principal; *Teclas nos ícones* (tela Controls, com o estilo Teclado ou Igual ao meu controle) pode mostrar a alternativa
no lugar, ou a alternativa só na lira (os outros ícones continuam com a principal). Os ícones desenham a primeira alternativa preenchida: a segunda caixa, ou a terceira se a segunda estiver vazia. Sem o launcher, junte os nomes com `|` no `SPARTAN_KEY_MAP`, por exemplo
`cross=Space|K|Keypad 2,square=Mouse Left|J`.
Em um controle sem touchpad (Xbox e outros), View abre o menu de pausa (o clique direito do
touchpad) e L1 + View abre o modo foto (o clique esquerdo). No DualSense o touchpad funciona
@@ -332,10 +333,13 @@ não espera). O direcional sozinho troca como antes. Os tutoriais de troca de ar
Os **ícones de botões no jogo** (desligados por padrão) são escolhidos na tela Controls do launcher, em
*Ícones dos botões no jogo*: *Igual ao meu controle*, *Xbox*, *Nintendo* ou *Teclado*. Eles trocam os ícones
PlayStation dos tutoriais, da lista de golpes, dos eventos rápidos e, nos estilos de controle, da lira das Musas
na Câmara da Chama; o desenho do controle no menu de pausa continua sendo um DualShock 4. *Teclado* mostra a tecla que aperta cada botão, seguindo o seu
PlayStation dos tutoriais, da lista de golpes, dos eventos rápidos e da lira das Musas
na Câmara da Chama; o desenho do controle no menu de pausa continua sendo um DualShock 4. *Teclado* mostra a tecla que aperta cada botão, também na lira, seguindo o seu
mapeamento do teclado (uma tecla trocada aparece no próximo início); *Igual ao meu controle* também mostra
as teclas quando nenhum controle está conectado. O launcher grava uma cópia com patch de até três arquivos do
as teclas quando nenhum controle está conectado.
*Cores das teclas* (no mesmo lugar) pinta as teclas do X, Círculo, Quadrado e Triângulo, na lira e nos
outros ícones de teclado: *Cores do PlayStation*, ou *Personalizadas* com uma cor de uma paleta curta
para cada botão; os outros ícones e o *Cinza* padrão não mudam. O launcher grava uma cópia com patch de até três arquivos do
jogo (cerca de 410 MB) na pasta de dados do port e nunca altera a pasta do jogo. Um controle novo é reconhecido após
reiniciar. Sem o launcher, defina `SPARTAN_GLYPH_STYLE` como `auto`, `xbox`, `nintendo` ou `keyboard` antes de
iniciar o `run.sh`.
@@ -447,12 +447,14 @@ The rows against `src/core/legacy.rs`, `src/core/save_backup.rs` and the discove
15. Ctrl+C returns 130 from the next check point (after the CPU probe, the image steps and the glyph probe, or at once while the game runs) and does not wait for the game; the Python stops mid-step, so a step can finish first (seconds at most, up to 30 s in `--after` and 15 s in the gamepad probe). Ctrl+Break is handled as Ctrl+C, where the Python dies with `STATUS_CONTROL_C_EXIT` and no cleanup. The 130 path is tested on Linux only.
16. DXT: the transparency branch of Pillow's BC1 encoder is not ported (a DXT1 from RGB reads alpha as 255; its padding pixels read uninitialised memory in the C) and the unused DXT3 encoder is absent. `window_icon.bmp` has the Python's size and 54 header bytes; its pixels are not compared and the resize does not premultiply alpha as Pillow does (spec §2).
17. A `SPARTAN_KEY_MAP` entry with several keys (`cross=Space|K`, the runtime's alternative keys) shows its main key on the keyboard icons, and `SPARTAN_GLYPH_KEYS=alternative` (Rust launcher only; absent means main) shows the alternative where a button has one: `glyphs::shown_keys` reduces the map to the key each button shows before `glyphs::build` stamps it, so the copies are redrawn only when a shown key changes. `run.py` has neither the `|` nor the variable. `src/runner/mod.rs::tests::the_icons_show_the_main_key_unless_the_alternative_is_chosen`, `src/runner/glyphs.rs::tests::a_button_with_an_alternative_shows_its_main_key`.
18. The Keyboard glyph style repaints the Muses' lyre (`OLY150.WAD`) with the key mapped to each face button, where the Python keeps the PlayStation symbols there; arrow keys are spelled out (`UP`, `DOWN`, `LEFT`, `RIGHT`) because the lyre's font has no arrows. `glyphs::VERSION` is 13, the Python's is 12, so the `stamp.json` files differ in that number alone (`tests/dump.rs` compares them with it replaced). Xbox and Nintendo output is unchanged. Spec: `docs/specs/2026-11-02-lyre-keyboard-glyphs.md`.
## Partly covered
Rows marked `ported` or `replaced by` whose Rust tests cover the behaviour but not every assertion of
the Python test:
- `tests/test_glyphs.py::GlyphPatchTest::test_lyre_discs_are_repainted_in_place` and `KeyboardStickTest::test_stick_and_dpad_textures_are_keyboard_only`: the Python asserts that the keyboard style leaves the lyre alone; the Rust tests assert the opposite (deviation 18) and keep the Xbox and Nintendo assertions
- `tests/test_isa_rewrite.py::TableTests::test_fixture_file_is_current`: the Rust test compares the table and the sites with the generator fixture; whether the fixture is current is tests/test_runner_generators.py's job.
- `tests/test_run_isa.py::RunIsaTests::test_parse_cpu_features`: the Rust test runs the runner in process on a fake host; the names read from the `CPU: missing` line are not asserted, only that a missing set asks for the ISA table and `CPU: complete` does not.
- `tests/test_mods.py::ModTests::test_run_uses_overlay_propagates_exit_and_cleans_view`: the test runs the Rust runner in process on a fake host, whose game start reads the mod's file under `--app0` (not the Python shebang script that is skipped on Windows).
+84 -29
View File
@@ -4,41 +4,56 @@ Branch: `pr/alternative-keys`.
## Spec (SDD)
* **Objetivo:** como jogador de teclado e mouse, quero uma segunda tecla (ou botão do mouse) para
cada botão do PS4, para manter o Quadrado no clique esquerdo no combate e também apertá-lo com o
J nos minigames, como a lira das Musas, sem trocar o mapeamento toda vez.
* **Objetivo:** como jogador de teclado e mouse, quero até duas teclas extras (ou botões do mouse)
para cada botão do PS4, para manter o Quadrado no clique esquerdo no combate e também apertá-lo
com o J ou com o Keypad 4 nos minigames, como a lira das Musas, sem trocar o mapeamento toda vez.
* **Contrato:**
- Runtime (`src/runtime_pad.c`): um valor de `SPARTAN_KEY_MAP` (e de `SPARTAN_PAD_MAP`) aceita
vários nomes ligados por `|`: `cross=Space|K,square=Mouse Left|J`. A entrada troca as fontes do
botão por todas as listadas. Os limites são 4 fontes por botão, 128 entradas no mapa e 2048
bytes no valor; o mapa tem lugar para os 31 botões com 4 fontes cada. Um nome inválido da
botão por todas as listadas. Os limites são 4 fontes por botão e 128 entradas no mapa; o mapa
tem lugar para os 31 botões com 4 fontes cada, e o valor é lido inteiro, qualquer que seja o
tamanho. Um nome inválido da
lista é registrado e pulado sozinho; sem nenhum nome válido a entrada é pulada inteira e o
botão fica como estava; `None` sozinho continua tirando todas as fontes; nomes repetidos
contam uma vez. Uma entrada de um só nome se comporta como antes.
- Launcher: o ajuste `controls.key_alt` (`{botão: entrada}`) em `settings.json`, escrito só quando
existe ao menos uma alternativa. `controls_layout` continua 4 e as chaves antigas não mudam.
- Launcher: os ajustes `controls.key_alt` e `controls.key_alt2` (`{botão: entrada}`: a primeira e a
segunda alternativa) em `settings.json`, cada um escrito só quando tem ao menos uma entrada.
`controls_layout` continua 4 e as chaves antigas não mudam; um `settings.json` sem `key_alt2`
(ou só com `key_alt`) carrega e grava exatamente como antes.
Exemplo (principais: Cross Space, Circle Left Ctrl, Square Mouse Left, Triangle Mouse Right;
alternativas K, L, J, I):
`"controls": {"key": {"circle": "Left Ctrl"}, "key_alt": {"cross": "K", "circle": "L", "square": "J", "triangle": "I"}}`
vira `SPARTAN_KEY_MAP=...cross=Space|K,circle=Left Ctrl|L,square=Mouse Left|J,triangle=Mouse Right|I...`.
- O launcher escreve `botão=<principal>|<alternativa>` sempre que há alternativa, mesmo com a
Com `"key_alt2": {"cross": "Keypad 2"}` a entrada vira `cross=Space|K|Keypad 2`; uma segunda
alternativa sozinha (sem a primeira) é escrita logo depois da principal (`cross=Space|Keypad 2`),
sem nome vazio no meio, e os nomes repetidos entram uma vez.
- O launcher escreve `botão=<principal>|<alternativa>[|<segunda>]` sempre que há alternativa, mesmo com a
principal no padrão do jogo (a entrada troca todas as fontes do botão, então o padrão precisa
ser citado). Sem principal ("None") só a alternativa é escrita. Alternativas vazias, "None",
iguais à principal ou com `,`, `=` ou `|` não são enviadas. Com Rage of Sparta em uma entrada,
L3 e R3 continuam sem fontes e a alternativa deles não é enviada.
- Tela Controls: segunda caixa por linha da tabela de teclado e mouse, com o botão `×` para
esvaziá-la; captura de tecla, botão do mouse e roda como na principal. A seta da linha devolve
a principal ao padrão e remove a alternativa; "Reset keyboard & mouse to defaults" remove todas.
- Conflito: uma tecla usada por dois botões (principal ou alternativa) troca com o outro, como as
principais já faziam. A tecla que um slot cede vai para o slot que tinha a tecla escolhida; sem
nada para ceder, uma principal fica "None" e uma alternativa some.
iguais à principal (ou à primeira alternativa, no caso da segunda) ou com `,`, `=` ou `|` não são
enviadas. Com Rage of Sparta em uma entrada, L3 e R3 continuam sem fontes e as alternativas
deles não são enviadas.
- Tela Controls: segunda e terceira caixa por linha da tabela de teclado e mouse (a primeira e a
segunda alternativa), cada uma com o botão `×` para esvaziá-la; captura de tecla, botão do mouse
e roda como na principal. A seta da linha devolve a principal ao padrão e remove as duas
alternativas; "Reset keyboard & mouse to defaults" remove todas. As caixas têm 152 px para a
tabela caber em 1280x800 e 1920x1080 (en, pt, ar).
- Conflito: uma tecla usada por dois slots (principal, alternativa ou segunda alternativa, de
botões diferentes ou do mesmo) troca com o outro, como as principais já faziam. A tecla que um slot
cede vai para o slot que tinha a tecla escolhida; sem nada para ceder, uma principal fica "None"
e uma alternativa some.
- Ícones de teclado no jogo (`src/runner/glyphs.rs`): mostram a tecla principal. O ajuste novo
*Keys on the icons* (`glyph_keys`: `main`, padrão, ou `alternative`; variável
`SPARTAN_GLYPH_KEYS`, enviada só quando é `alternative`) mostra a alternativa nos botões que
têm uma e a principal nos outros. A linha só aparece com os estilos que desenham o teclado
(*Keyboard* e *Match my controller*). `glyphs::shown_keys` reduz o mapa às teclas mostradas antes
de `glyphs::build` carimbar as cópias, então elas só são redesenhadas quando uma tecla mostrada
muda. `VERSION` dos glifos não muda.
*Keys on the icons* (`glyph_keys`: `main`, padrão, `alternative` ou `lyre`; variável
`SPARTAN_GLYPH_KEYS`, enviada só quando não é `main`) mostra a alternativa nos botões que
têm uma e a principal nos outros: com `alternative` em todos os ícones, com `lyre` ("Alternative
key on the lyre only") só na lira das Musas (os quatro discos e a legenda do mecanismo dourado),
e todos os outros ícones de teclado continuam com a principal. A linha só aparece com os estilos
que desenham o teclado (*Keyboard* e *Match my controller*). `glyphs::shown_keys` reduz o mapa às
teclas mostradas antes de `glyphs::build` carimbar as cópias, então elas só são redesenhadas
quando uma tecla mostrada muda. Com `lyre`, `glyphs::lyre_keys` reduz o mapa às alternativas dos
quatro botões da lira e `Choice::lyre_key_map` leva isso só ao desenho da lira; o carimbo ganha
o elemento `["lyre", "<mapa>"]` depois do mapa de teclas, e só nesse caso, então o carimbo de
`main` e de `alternative` não muda. `VERSION` dos glifos não muda.
* **Critérios de aceite:**
- QUANDO `SPARTAN_KEY_MAP` tiver `cross=Space|K` O SISTEMA DEVE apertar Cross com Space e com K, e
com nenhuma tecla padrão do botão.
@@ -46,11 +61,29 @@ Branch: `pr/alternative-keys`.
- QUANDO nenhum nome da lista for válido O SISTEMA DEVE deixar o botão com as fontes que tinha.
- QUANDO o valor for `None` O SISTEMA DEVE deixar o botão sem fontes, como hoje.
- QUANDO todos os botões tiverem quatro fontes O SISTEMA DEVE guardar todas, sem estourar o mapa.
- QUANDO o valor de `SPARTAN_KEY_MAP` tiver mais de 2048 bytes O SISTEMA DEVE aplicar todas as
entradas, a última inclusive.
- QUANDO uma lista tiver um nome repetido ou vazio antes da alternativa (`cross=Space|Space|K`,
`cross=Space||K`) O SISTEMA DEVE desenhar `K` como a alternativa nos ícones.
- QUANDO `move_up`, `rage` ou `grab` tiverem várias fontes O SISTEMA DEVE responder a qualquer uma.
- QUANDO um `settings.json` não tiver `key_alt` (ou tiver outra forma que um objeto) O SISTEMA DEVE
carregá-lo sem alternativas e gravar o mesmo `controls` de antes.
- QUANDO só a alternativa de um botão estiver definida O SISTEMA DEVE escrever a principal padrão
antes dela (`cross=Space|K`).
- QUANDO um botão tiver as duas alternativas O SISTEMA DEVE escrever a principal, a primeira e a
segunda, nessa ordem (`cross=Space|9|Keypad 2`).
- QUANDO só a segunda alternativa de um botão estiver definida O SISTEMA DEVE escrever a principal e
a segunda sem nome vazio entre elas (`cross=Space|Keypad 2`).
- QUANDO a segunda alternativa repetir a principal ou a primeira alternativa O SISTEMA NÃO DEVE
enviá-la.
- QUANDO o jogador capturar para uma caixa uma tecla que já está em qualquer uma das três caixas de
qualquer botão O SISTEMA DEVE trocar as duas.
- QUANDO o jogador usar a seta da linha ou o reset da tabela O SISTEMA DEVE remover as duas
alternativas.
- QUANDO um `settings.json` só tiver `key_alt` O SISTEMA DEVE carregá-lo e gravar o mesmo `controls`,
sem `key_alt2`.
- QUANDO um botão tiver as duas alternativas e `glyph_keys` for `alternative` ou `lyre` O SISTEMA
DEVE desenhar a primeira alternativa e nunca a segunda.
- QUANDO a principal de um botão for "None" e houver alternativa O SISTEMA DEVE escrever só a
alternativa.
- QUANDO o jogador capturar para uma alternativa uma tecla que já é principal ou alternativa de
@@ -64,6 +97,15 @@ Branch: `pr/alternative-keys`.
principal nos outros botões.
- QUANDO só uma alternativa mudar e `glyph_keys` for `main` O SISTEMA NÃO DEVE redesenhar as
cópias dos ícones.
- QUANDO `glyph_keys` for `lyre` O SISTEMA DEVE desenhar na lira das Musas (discos e mecanismo
dourado) a alternativa de cada botão que tem uma, a principal nos botões sem alternativa, e
desenhar a tecla principal em todas as outras texturas de teclado.
- QUANDO `glyph_keys` for `lyre` e a alternativa de um dos quatro botões da lira mudar O SISTEMA DEVE
redesenhar as cópias dos ícones; QUANDO mudar a de outro botão O SISTEMA NÃO DEVE.
- QUANDO `glyph_keys` for `main` ou `alternative` O SISTEMA DEVE carimbar as cópias exatamente como
antes da escolha `lyre` existir.
- QUANDO `settings.json` tiver `glyph_keys` `lyre` O SISTEMA DEVE enviar `SPARTAN_GLYPH_KEYS=lyre`
e, depois de salvar e reabrir, mostrar a mesma escolha na linha de três valores.
* **Requisitos não funcionais:** o formato novo só aparece quando o jogador define uma alternativa;
sem alternativas, `SPARTAN_KEY_MAP` e `settings.json` ficam idênticos aos de antes. O launcher
Python continua congelado: o desvio está nos documentos de paridade (`2026-10-21-rust-launcher-parity.md`,
@@ -82,15 +124,28 @@ Branch: `pr/alternative-keys`.
- `apply_button_map` separa o valor por `|`; `named_source` devolve -1 para um nome inválido
(um botão do gamepad pode valer 0); `drop_sources` tira as fontes antigas só depois de ter ao
menos uma nova válida. `MAX_SOURCES_PER_BUTTON` e `MAP_CAPACITY` dão os limites.
- `Slot::{Main, Alternative}` e `bind_key(principais, alternativas, botão, slot, nome)` aplicam a
regra de conflito nos dois conjuntos. `ControlsView` ganha `key_alt` e `capturing_slot`, e as
mensagens `Capture`, `Captured` e `Clear` levam o slot.
- `keyboard_map` escreve `principal|alternativa`; o gamepad não tem alternativas.
- `Slot::{Main, Alternative, SecondAlternative}` e `bind_key([principais, alternativas, segundas],
botão, slot, nome)` aplicam a regra de conflito nos três conjuntos. `ControlsView` ganha `key_alt`,
`key_alt2` e `capturing_slot`, e as mensagens `Capture`, `Captured` e `Clear` levam o slot.
- `keyboard_map` escreve `principal|alternativa|segunda`, pulando o que está vazio ou repetido; o
gamepad não tem alternativas. O runtime já lê até 4 nomes por botão.
- Limite conhecido: o `SPARTAN_KEY_MAP` não distingue a primeira da segunda alternativa; com a
primeira caixa vazia, a segunda é escrita no lugar dela (`cross=Space|Keypad 2`) e é essa que os
ícones mostram com `alternative` ou `lyre`. Com as duas preenchidas os ícones mostram sempre a
primeira.
- `glyph_keys` é um ajuste de `settings.json` fora do arquivo novo (como `controls_device`), com a
variável `SPARTAN_GLYPH_KEYS`; `glyph_mounts` entrega ao `build` o mapa já reduzido por
`shown_keys`, e `key_buttons` também fica com a principal quando recebe um mapa com `|`.
- Decisão: o runtime lê o valor em um buffer de 2048 bytes; o pior caso do
launcher (todas as linhas com os nomes mais longos) fica abaixo disso, e há um teste para isso.
`shown_keys` (e, com `lyre`, o mapa de `lyre_keys`), e `key_buttons` também fica com a principal
quando recebe um mapa com `|`.
- Decisão: o runtime copia o valor inteiro para o heap (`SDL_strdup`) antes de separá-lo, em vez
de um buffer de 2048 bytes que cortava em silêncio um mapa longo e perdia as últimas entradas;
sem memória, nada é aplicado (tudo ou nada). Há um teste com mais de 2048 bytes cuja última
entrada precisa valer, e outro no launcher com o pior caso (todas as linhas com os nomes mais
longos).
- Os ícones leem os nomes como o runtime: cortam os espaços, pulam nomes vazios e repetidos (sem
diferenciar maiúsculas) e o `None` ao lado de outro nome, então `cross=Space|Space|K` tem a
alternativa `K` e um nome vazio não esconde o seguinte. Um nome que o SDL não conhece não é
descartado (o launcher não tem a tabela dele).
* **Validação:** `pad-map-test` (Windows, MSYS2 CLANG64), `cargo fmt --check`,
`cargo clippy --all-targets -- -D warnings`, `cargo test` e `cargo build --release` no
`launcher-rust/`; teste no jogo (lira das Musas) pelo mantenedor.
@@ -0,0 +1,72 @@
# Teclas do teclado na lira das Musas
Continuação de `docs/specs/2026-10-22-rust-runner-design.md` (ícones de botões no jogo).
Branch: `pr/lyre-keyboard-glyphs`.
**Situação antes:** com o estilo de ícones *Keyboard* os tutoriais, a lista de golpes e os QTEs
mostram a tecla de cada botão, mas a lira das Musas (Câmara da Chama, `OLY150.WAD`) continua com os
símbolos do PlayStation: só os estilos de controle (Xbox, Nintendo, igual ao meu controle) a
repintam. Quem joga no teclado vê, no único quebra-cabeça que pede os quatro botões, um símbolo
que não aparece em nenhum outro lugar.
## Spec (SDD)
* **Objetivo:** como jogador de teclado e mouse, quero ver nos quatro discos da lira a tecla (ou
botão do mouse) que aperta Cross, Circle, Square e Triangle, para resolver o quebra-cabeça sem
traduzir símbolos do PlayStation de cabeça.
* **Contrato:**
- O estilo `keyboard` passa a desenhar as sete texturas da lira (`Form::Lyre` e `Form::Gold`),
como os estilos de controle já faziam; `glyphs::textures("keyboard")` é a lista de
`TEXTURES` mais `KEYBOARD_TEXTURES`.
- Cada disco mostra o rótulo de `key_label()` da tecla que `key_buttons(SPARTAN_KEY_MAP)` dá ao
botão do disco (os mesmos rótulos curtos dos outros ícones de teclado: `SPACE`, `CTRL`, `LMB`,
`RMB`, `WH UP`, `NUM 4`...). A tinta, o contorno, o relevo e o brilho são os dos estilos de
controle: só o texto muda.
- O rótulo é centrado no disco, com a altura de letra do disco; um rótulo mais largo que 1,5
raio encolhe até caber (regra que já valia para os rótulos de controle).
- A fonte da lira (Bodoni Moda) não tem setas: as teclas de seta aparecem por extenso (`UP`,
`DOWN`, `LEFT`, `RIGHT`) só no estilo teclado.
- `glyphs::VERSION` sobe de 12 para 13: as cópias de `out/glyphs` já feitas (que com o teclado
não tinham `OLY150.WAD`) são refeitas na próxima partida.
- Com os estilos Xbox, Nintendo e igual ao meu controle, a lira e todas as outras texturas
ficam como estavam; o mapa de teclado não entra nessas cópias.
* **Critérios de aceite:**
- QUANDO o estilo for `keyboard` O SISTEMA DEVE repintar os quatro discos de cada uma das sete
texturas da lira com a tecla mapeada ao botão do disco, mantendo tamanho, formato e blocos
fora do canto da lira iguais aos do jogo.
- QUANDO uma tecla do mapa mudar (`circle=L`) O SISTEMA DEVE mudar o rótulo só do disco desse
botão e refazer as cópias (o carimbo `stamp.json` já leva o mapa de teclado).
- QUANDO o botão estiver no mouse (`Mouse Left`, `Mouse Right`) O SISTEMA DEVE desenhar `LMB` e
`RMB`, como as outras texturas de teclado.
- QUANDO o nome da tecla for longo (`Right Shift`, `Keypad 4`, `Left Bracket`) O SISTEMA DEVE
desenhar o rótulo curto de `key_label()` inteiro dentro do disco.
- QUANDO a tecla for uma seta O SISTEMA DEVE escrever `UP`, `DOWN`, `LEFT` ou `RIGHT` no disco,
não o nome interno do ícone (`dpup`).
- QUANDO o estilo for Xbox ou Nintendo O SISTEMA DEVE produzir os mesmos bytes de antes nas
cópias de `R_UI.WAD`, `R_PERM.WAD` e `OLY150.WAD`, mesmo com um mapa de teclado definido.
- QUANDO o estilo for `keyboard` O SISTEMA DEVE manter `R_UI.WAD` e `R_PERM.WAD` iguais ao que
eram antes desta mudança.
* **Requisitos não funcionais:** a pasta do jogo continua só de leitura; o tempo de montagem das
cópias sobe pelo `OLY150.WAD` (as mesmas sete texturas que os estilos de controle já refaziam);
o tamanho em disco do `out/glyphs` com o teclado passa de cerca de 68 MB para cerca de 410 MB, o
mesmo dos outros estilos.
* **Arquivos afetados:** `launcher-rust/src/runner/glyphs.rs`, `launcher-rust/src/runner/mod.rs`
(testes do carimbo), `launcher-rust/tests/dump.rs`, os três README e o registro de desvios do Python
(`docs/specs/2026-10-22-rust-runner-parity.md`, desvio 18).
* **Plano técnico:** `lyre_paint` escolhe o mapa de botões como `render` já escolhia
(`host_buttons`: teclado para `keyboard`, controle para os outros) e passa o rótulo pelo mesmo
`glyph()`; `textures("keyboard")` deixa de filtrar as formas da lira. Os discos de ouro (mecanismo
dourado) usam o mesmo caminho: A, B, X e Y são desenhadas como traços, qualquer outro rótulo vai
na fonte da lira, como já acontecia com letras fora dessas quatro.
* **Validação:** testes de unidade em `glyphs.rs`; `cargo fmt --check`, `cargo clippy
--all-targets -- -D warnings`, `cargo test`, `cargo build --release`; montagem das cópias sobre
o dump real nos mapas `cross=Space,circle=Left Ctrl,square=Mouse Left,triangle=Mouse Right` e
`triangle=I,square=J,cross=K,circle=L`, com as texturas extraídas e olhadas; Xbox e Nintendo
byte a byte iguais a antes. Não testado: dentro do jogo (a sala da lira) e no Linux.
* **Aprovação:** pendente do mantenedor.
## Desvio do Python
O Python (`scripts/glyphs.py`, congelado) deixa a lira com os símbolos do PlayStation no estilo
teclado e tem `VERSION = 12`. O launcher Rust passa a desenhar a lira e usa `VERSION = 13`; está
registrado como desvio 18 em `docs/specs/2026-10-22-rust-runner-parity.md`.
+65
View File
@@ -0,0 +1,65 @@
# Cores das teclas dos botões nos ícones de teclado
Branch: `pr/lyre-only-alternative-keys` (depois de `docs/specs/2026-11-02-alternative-keys.md`).
## Spec (SDD)
* **Objetivo:** como jogador de teclado e mouse, quero que as teclas dos quatro botões de ação (X,
Círculo, Quadrado e Triângulo) apareçam coloridas nos ícones de teclado do jogo, na lira das Musas
e nos outros ícones, para achar o botão certo de relance, sem mudar nenhum outro ícone.
* **Contrato:**
- Launcher (tela Controls, junto de *Keys on the icons*, só com os estilos que desenham o teclado,
*Keyboard* e *Match my controller*): a escolha *Key colors* (`glyph_key_colors`: `gray`, padrão,
`playstation` ou `custom`) e, só com `custom`, quatro caixas (*Face button colors*) com uma cor
da paleta curta por botão (`glyph_color_cross`, `glyph_color_circle`, `glyph_color_square`,
`glyph_color_triangle`: `gray`, `white`, `blue`, `orange`, `green`, `red`, `pink`, `yellow`,
`purple`). São ajustes de `settings.json` fora do arquivo novo (como `glyph_keys`): um
`settings.json` antigo carrega como `gray` e nada é gravado até o jogador mudar.
- Variável `SPARTAN_GLYPH_KEY_COLORS` (`playstation` ou `custom`; `gray` não é enviado, como
`main` em `SPARTAN_GLYPH_KEYS`) e, só com `custom`, `SPARTAN_GLYPH_KEY_COLOR_MAP` com as entradas
`botão=cor` dos botões que não estão em cinza (`cross=blue,circle=orange`). Com `custom` sem
nenhuma cor escolhida, ou com um nome fora da paleta, o mapa não é enviado e nada é colorido.
- `glyphs::face_colors` reduz esses valores a um texto canônico, `botão=rrggbb` na ordem X, Círculo,
Quadrado, Triângulo (nomes da paleta ou seis dígitos hex, com ou sem `#`; o que não é um desses
botões, nem uma cor, nem difere da tinta cinza, sai). `Choice::face_colors` leva esse texto ao
desenho: `render` (ícones inline, QTE) e `lyre_paint` (discos e legenda do mecanismo dourado)
trocam só a cor de preenchimento da tecla desses quatro botões; a tinta cinza (240, 240, 240) é a
cor de todo o resto, e contorno, relevo e brilho continuam como estão. Na lira dourada, a
legenda é gravada em tons da cor escolhida em vez do tom do ouro.
- PlayStation colors usa as cores que o próprio launcher usa nos símbolos: X `#6b9ae8`, Círculo
`#e5555e`, Quadrado `#d683d6`, Triângulo `#45c4a2`. A paleta: gray `#f0f0f0` (a tinta), white
`#ffffff`, blue `#428ee8`, orange `#f28c28`, green `#6cc44a`, red `#e84842`, pink `#d683d6`,
yellow `#f4c430`, purple `#a06ee6`.
- Cópias dos ícones: o carimbo ganha `["colors", "<texto canônico>"]` depois do mapa de teclas (e
do `["lyre", ...]`), só quando há alguma cor, então o carimbo sem cor é o mesmo de antes, a
`VERSION` não muda e a saída com `gray` é a mesma, byte a byte. Mudar uma cor redesenha as
cópias. Só o estilo de teclado tem cores: os outros estilos não carimbam nada.
* **Critérios de aceite:**
- QUANDO `glyph_key_colors` for `gray` (ou o ajuste não existir) O SISTEMA DEVE desenhar e carimbar
os ícones exatamente como antes.
- QUANDO `glyph_key_colors` for `playstation` ou `custom` e o estilo for Keyboard O SISTEMA DEVE
desenhar as teclas de X, Círculo, Quadrado e Triângulo na cor escolhida, na lira (discos e
mecanismo dourado) e nas outras texturas de teclado, e as teclas dos outros botões na tinta.
- QUANDO só alguns botões tiverem cor em `custom` O SISTEMA DEVE mudar só os discos e ícones
deles.
- QUANDO o estilo for Xbox ou Nintendo O SISTEMA NÃO DEVE aplicar as cores.
- QUANDO uma cor mudar O SISTEMA DEVE redesenhar as cópias; QUANDO o valor for outro texto da mesma
cor (nome ou hex) O SISTEMA NÃO DEVE.
- QUANDO `glyph_key_colors` for `custom` O SISTEMA DEVE mostrar as quatro caixas, e só nesse caso.
- QUANDO os ajustes forem salvos e o launcher reaberto O SISTEMA DEVE mostrar as mesmas escolhas.
- Exemplo: *Custom* com Quadrado e X azuis e Triângulo e Círculo laranjas, alternativas (primeira)
5, 6, 9 e 0, segundas no Keypad 4, 2, 8 e 6 e *Keys on the icons* em *Alternative key on the
lyre only* desenham na lira 5, 6, 9 e 0 coloridos e, nos outros ícones, Mouse Left, Space,
Mouse Right e Left Ctrl (LMB, SPACE, RMB e CTRL) com as mesmas cores.
* **Requisitos não funcionais:** sem cor escolhida nada muda: os testes de fixtures e de PSNR dos
glifos continuam sem alteração. O launcher Python continua congelado.
* **Arquivos afetados:**
- `launcher-rust/src/runner/glyphs.rs`, `launcher-rust/src/runner/mod.rs`
- `launcher-rust/src/core/catalog.rs`, `launcher-rust/src/core/options.rs`,
`launcher-rust/src/core/env.rs`, `launcher-rust/src/core/i18n.rs`
- `launcher-rust/src/ui/pages/controls.rs`
- testes do launcher (`tests/env_contract.rs`, `tests/ui_pages.rs`, `tests/ui_save.rs`)
- `README.md`, `README.pt-BR.md`, `README.es.md`
* **Validação:** `cargo fmt --check`, `cargo clippy --all-targets -- -D warnings`, `cargo test` e
`cargo build --release` no `launcher-rust/`; pré-visualizações PNG da lira com a dump do jogo;
teste no jogo pelo mantenedor.
Binary file not shown.

Before

Width:  |  Height:  |  Size: 161 KiB

After

Width:  |  Height:  |  Size: 169 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 174 KiB

After

Width:  |  Height:  |  Size: 182 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 165 KiB

After

Width:  |  Height:  |  Size: 171 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 243 KiB

After

Width:  |  Height:  |  Size: 261 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 267 KiB

After

Width:  |  Height:  |  Size: 280 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 282 KiB

After

Width:  |  Height:  |  Size: 299 KiB

+23 -4
View File
@@ -3,9 +3,9 @@
use crate::core::options::{
Choice, AIM_STYLES, ANISOTROPY, AUDIO_CHANNELS, DISPLAY_MODES, DRAW_PIPE, ENGINE_FPS_CHOICES,
FPS_LIMITS, FRAMES_QUEUED, FRAME_GENERATION, GLYPH_KEYS, GLYPH_STYLES, LANGUAGES,
LIVE_RESOLUTION, MENU_CONFIRM_STYLES, NO_MSAA, OUTPUTS, PRESETS, READBACKS, STICK_PROMPTS,
STREAM_MEMORY, UI_LANGUAGES, UI_SCALES, UPSCALERS, VIDEO_BACKENDS,
FPS_LIMITS, FRAMES_QUEUED, FRAME_GENERATION, GLYPH_KEYS, GLYPH_STYLES, KEY_COLORS,
KEY_COLOR_STYLES, LANGUAGES, LIVE_RESOLUTION, MENU_CONFIRM_STYLES, NO_MSAA, OUTPUTS, PRESETS,
READBACKS, STICK_PROMPTS, STREAM_MEMORY, UI_LANGUAGES, UI_SCALES, UPSCALERS, VIDEO_BACKENDS,
};
/// The file a setting lives in.
@@ -322,6 +322,16 @@ pub static SETTINGS: &[Setting] = &[
env: env("SPARTAN_GLYPH_KEYS", "main"),
..app_unwritten("glyph_keys", Pick(GLYPH_KEYS), Text("main"))
},
// Rust only, like `glyph_keys`: the colour of the key labels of the four face buttons. The
// four picks of a custom colouring are not variables; `game_environment` joins them.
Setting {
env: env("SPARTAN_GLYPH_KEY_COLORS", "gray"),
..app_unwritten("glyph_key_colors", Pick(KEY_COLOR_STYLES), Text("gray"))
},
app_unwritten("glyph_color_cross", Pick(KEY_COLORS), Text("gray")),
app_unwritten("glyph_color_circle", Pick(KEY_COLORS), Text("gray")),
app_unwritten("glyph_color_square", Pick(KEY_COLORS), Text("gray")),
app_unwritten("glyph_color_triangle", Pick(KEY_COLORS), Text("gray")),
app_unwritten("controls_device", Kind::Text, Text("")),
// The page follows the connected controller's family until the player picks one.
app_unwritten("pad_style_auto", Flag, Bool(true)),
@@ -561,7 +571,16 @@ mod tests {
#[test]
fn the_unwritten_rows_stay_out_of_a_new_settings_file() {
for key in ["launcher_effects", "controls_device", "glyph_keys"] {
for key in [
"launcher_effects",
"controls_device",
"glyph_keys",
"glyph_key_colors",
"glyph_color_cross",
"glyph_color_circle",
"glyph_color_square",
"glyph_color_triangle",
] {
let row = setting(key).unwrap();
assert!(!row.written, "{key}");
assert!(!app_defaults(&|_| None).contains_key(key), "{key}");
+24 -6
View File
@@ -46,6 +46,8 @@ pub struct Profile {
pub key: IndexMap<String, String>,
/// Alternative keyboard inputs by game button (`controls.key_alt`).
pub key_alt: IndexMap<String, String>,
/// Second alternative keyboard inputs by game button (`controls.key_alt2`).
pub key_alt2: IndexMap<String, String>,
pub pad: IndexMap<String, String>,
pub deadzone_left: u32,
pub deadzone_right: u32,
@@ -78,6 +80,9 @@ fn to_json(profile: &Profile) -> Map<String, Value> {
if !profile.key_alt.is_empty() {
out.insert("key_alt".to_string(), json!(profile.key_alt));
}
if !profile.key_alt2.is_empty() {
out.insert("key_alt2".to_string(), json!(profile.key_alt2));
}
out.insert("pad".to_string(), json!(profile.pad));
out.insert("deadzone_left".to_string(), json!(profile.deadzone_left));
out.insert("deadzone_right".to_string(), json!(profile.deadzone_right));
@@ -113,13 +118,18 @@ fn deadzone(value: &Value) -> Option<u32> {
.filter(|percent| *percent <= MAX_DEADZONE)
}
/// An optional table of alternative inputs; profiles from before it have none.
fn alternatives(value: &Value, field: &str) -> Option<IndexMap<String, String>> {
value
.get(field)
.map_or_else(|| Some(IndexMap::new()), |saved| table(saved, Kind::Key))
}
fn from_json(value: &Value) -> Option<Profile> {
Some(Profile {
key: table(value.get("key")?, Kind::Key)?,
key_alt: match value.get("key_alt") {
Some(alternatives) => table(alternatives, Kind::Key)?,
None => IndexMap::new(),
},
key_alt: alternatives(value, "key_alt")?,
key_alt2: alternatives(value, "key_alt2")?,
pad: table(value.get("pad")?, Kind::Pad)?,
deadzone_left: deadzone(value.get("deadzone_left")?)?,
deadzone_right: deadzone(value.get("deadzone_right")?)?,
@@ -220,6 +230,8 @@ mod tests {
let mut profile = built_in(MOUSE_SIDE_BUTTONS).unwrap();
profile.pad.insert("l1".into(), "lefttrigger".into());
profile.pad.insert("l2".into(), "leftshoulder".into());
profile.key_alt.insert("cross".into(), "K".into());
profile.key_alt2.insert("cross".into(), "Keypad 2".into());
profile.deadzone_left = 7;
profile
}
@@ -258,12 +270,13 @@ mod tests {
"format": FORMAT, "version": 1, "name": "X", "key": {"cross": "Q"}, "pad": {},
"deadzone_left": 0, "deadzone_right": 0,
});
let cases: [(&str, Value); 8] = [
let cases: [(&str, Value); 9] = [
("format", json!("other")),
("version", json!(2)),
("name", json!("")),
("key", json!({"nothing": "Q"})),
("key", json!({"cross": "Q,square=E"})),
("key_alt2", json!({"cross": "Q|E"})),
("pad", json!({"cross": "not-a-button"})),
("deadzone_left", json!(51)),
("deadzone_right", json!(-1)),
@@ -316,7 +329,12 @@ mod tests {
// Still editable row by row.
let mut alternatives = IndexMap::new();
assert_eq!(
bind_key(&mut values, &mut alternatives, "l1", Slot::Main, "G"),
bind_key(
[&mut values, &mut alternatives, &mut IndexMap::new()],
"l1",
Slot::Main,
"G"
),
None
);
}
+258 -65
View File
@@ -340,35 +340,55 @@ pub fn sdl_key_name(key: &str) -> Option<String> {
None
}
/// Which of a keyboard row's two inputs: the main one, or the alternative that presses the same
/// button as well (`controls.key_alt`).
/// Which of a keyboard row's three inputs: the main one, or one of the two alternatives that press
/// the same button as well (`controls.key_alt`, `controls.key_alt2`).
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum Slot {
Main,
Alternative,
SecondAlternative,
}
/// Gives `name` to the `slot` input of `button`; the input that had it (of another button, or the
/// other one of this button) takes what `button` had in that slot, so no key presses two buttons.
/// "None" (no input) is never taken from another input. A main input left without a key reads
/// "None"; an alternative left without one is dropped from `alternatives`. Returns the input that
impl Slot {
const ALL: [Slot; 3] = [Slot::Main, Slot::Alternative, Slot::SecondAlternative];
fn index(self) -> usize {
match self {
Slot::Main => 0,
Slot::Alternative => 1,
Slot::SecondAlternative => 2,
}
}
/// The `controls` field that saves an alternative slot; the main inputs are saved in `key`.
fn saved_field(self) -> Option<&'static str> {
match self {
Slot::Main => None,
Slot::Alternative => Some("key_alt"),
Slot::SecondAlternative => Some("key_alt2"),
}
}
}
/// Gives `name` to the `slot` input of `button`. `inputs` are the main inputs, the alternatives and
/// the second alternatives, by game button. The input that had the name (of another button, or
/// another one of this button) takes what `button` had in that slot, so no key presses two
/// buttons. "None" (no input) is never taken from another input. A main input left without a key
/// reads "None"; an alternative left without one is dropped from its table. Returns the input that
/// swapped.
pub fn bind_key(
main: &mut IndexMap<String, String>,
alternatives: &mut IndexMap<String, String>,
inputs: [&mut IndexMap<String, String>; 3],
button: &str,
slot: Slot,
name: &str,
) -> Option<(String, Slot)> {
let old = match slot {
Slot::Main => main.get(button),
Slot::Alternative => alternatives.get(button),
}
.cloned()
.unwrap_or_default();
let old = inputs[slot.index()]
.get(button)
.cloned()
.unwrap_or_default();
let wanted = name.trim().to_lowercase();
let holds = |table: &IndexMap<String, String>, table_slot: Slot| {
table
let other = Slot::ALL.into_iter().find_map(|table_slot| {
inputs[table_slot.index()]
.iter()
.find(|(other, key)| {
(other.as_str() != button || table_slot != slot)
@@ -377,38 +397,40 @@ pub fn bind_key(
&& key.trim().to_lowercase() == wanted
})
.map(|(other, _)| (other.clone(), table_slot))
};
let other = holds(main, Slot::Main).or_else(|| holds(alternatives, Slot::Alternative));
let mut set = |button: &str, slot: Slot, value: &str| match slot {
Slot::Main => {
let value = if value.trim().is_empty() {
"None"
} else {
value
};
main.insert(button.to_string(), value.to_string());
}
Slot::Alternative => {
if value.trim().is_empty() || value.trim().eq_ignore_ascii_case("none") {
alternatives.shift_remove(button);
} else {
alternatives.insert(button.to_string(), value.to_string());
}
}
};
set(button, slot, name);
});
set_input(inputs[slot.index()], slot, button, name);
let (other, other_slot) = other.filter(|(other, _)| !other.is_empty())?;
set(&other, other_slot, &old);
set_input(inputs[other_slot.index()], other_slot, &other, &old);
Some((other, other_slot))
}
fn set_input(table: &mut IndexMap<String, String>, slot: Slot, button: &str, value: &str) {
if slot == Slot::Main {
let value = if value.trim().is_empty() {
"None"
} else {
value
};
table.insert(button.to_string(), value.to_string());
} else if value.trim().is_empty() || value.trim().eq_ignore_ascii_case("none") {
table.shift_remove(button);
} else {
table.insert(button.to_string(), value.to_string());
}
}
/// Gives gamepad input `name` to `button`, as `bind_key` does. A button with no input of its own
/// has none to hand over, so the one that had `name` keeps it, and the page reports the conflict.
pub fn bind_pad(values: &mut IndexMap<String, String>, button: &str, name: &str) {
if values.get(button).is_some_and(|old| old.is_empty()) {
values.insert(button.to_string(), name.to_string());
} else {
bind_key(values, &mut IndexMap::new(), button, Slot::Main, name);
bind_key(
[values, &mut IndexMap::new(), &mut IndexMap::new()],
button,
Slot::Main,
name,
);
}
}
@@ -435,10 +457,12 @@ pub fn control_values(controls: &Value, kind: Kind) -> IndexMap<String, String>
table_values(controls.get(kind.name()), kind)
}
/// The saved alternative inputs by game button (`controls.key_alt`), for the rows of the keyboard
/// table. A row without one, an empty or "None" value and a button the table lacks are left out.
pub fn alternative_values(controls: &Value) -> IndexMap<String, String> {
let Some(Value::Object(saved)) = controls.get("key_alt") else {
/// The saved alternative inputs of `slot` by game button (`controls.key_alt` or `key_alt2`), for
/// the rows of the keyboard table. A row without one, an empty or "None" value and a button the
/// table lacks are left out; the main slot has none.
pub fn alternative_values(controls: &Value, slot: Slot) -> IndexMap<String, String> {
let Some(Value::Object(saved)) = slot.saved_field().and_then(|field| controls.get(field))
else {
return IndexMap::new();
};
table_rows(Kind::Key)
@@ -528,7 +552,8 @@ pub fn control_map(controls: &Value, kind: Kind) -> String {
/// none and the Rage row is used; otherwise the Rage row is left out.
pub fn keyboard_map(controls: &Value, rage: bool) -> String {
let chosen = control_values(controls, Kind::Key);
let alternatives = alternative_values(controls);
let alternatives =
[Slot::Alternative, Slot::SecondAlternative].map(|slot| alternative_values(controls, slot));
let mut entries = Vec::new();
for row in table_rows(Kind::Key) {
if row.key.is_empty() || (row.button == "rage" && !rage) {
@@ -548,18 +573,28 @@ pub fn keyboard_map(controls: &Value, rage: bool) -> String {
if value.contains([',', '=', '|']) {
continue;
}
let alternative = alternatives
.get(row.button)
.map(String::as_str)
.filter(|alt| !alt.contains([',', '=', '|']) && !alt.eq_ignore_ascii_case(value));
if let Some(alternative) = alternative {
// A repeated key presses the button once, and an alternative left empty is skipped, so
// a second alternative alone is written right after the main key.
let mut names = vec![value];
for table in &alternatives {
let alternative = table
.get(row.button)
.map(String::as_str)
.filter(|alt| !alt.contains([',', '=', '|']));
if let Some(alternative) = alternative {
if !names
.iter()
.any(|name| name.eq_ignore_ascii_case(alternative))
{
names.push(alternative);
}
}
}
if names.len() > 1 {
// The entry replaces every key of the button: the main one is named even when it is the
// game's own, and a button without a main key is left with the alternative alone.
entries.push(if value.eq_ignore_ascii_case("none") {
format!("{}={alternative}", row.button)
} else {
format!("{}={value}|{alternative}", row.button)
});
// game's own, and a button without a main key is left with the alternatives alone.
names.retain(|name| !name.eq_ignore_ascii_case("none"));
entries.push(format!("{}={}", row.button, names.join("|")));
continue;
}
// the game has no key of its own for an action, but has the stick directions'
@@ -777,7 +812,23 @@ mod tests {
button: &str,
name: &str,
) -> Option<String> {
bind_key(bindings, &mut IndexMap::new(), button, Slot::Main, name).map(|(other, _)| other)
bind(bindings, &mut IndexMap::new(), button, Slot::Main, name).map(|(other, _)| other)
}
/// `bind_key` for the tests that have no second alternatives.
fn bind(
main: &mut IndexMap<String, String>,
alternatives: &mut IndexMap<String, String>,
button: &str,
slot: Slot,
name: &str,
) -> Option<(String, Slot)> {
bind_key(
[main, alternatives, &mut IndexMap::new()],
button,
slot,
name,
)
}
fn object(value: Value) -> Map<String, Value> {
@@ -1288,8 +1339,8 @@ mod tests {
keyboard_map(&controls, true),
keyboard_map(&json!({}), true)
);
assert!(!alternative_values(&controls).contains_key("jump"));
assert!(!alternative_values(&controls).contains_key("r2"));
assert!(!alternative_values(&controls, Slot::Alternative).contains_key("jump"));
assert!(!alternative_values(&controls, Slot::Alternative).contains_key("r2"));
}
#[test]
@@ -1311,7 +1362,8 @@ mod tests {
let expected = keyboard_map(&controls, true);
assert_eq!(keyboard_map(&with_empty, true), expected);
assert_eq!(keyboard_map(&wrong_shape, true), expected);
assert!(alternative_values(&controls).is_empty());
assert!(alternative_values(&controls, Slot::Alternative).is_empty());
assert!(alternative_values(&controls, Slot::SecondAlternative).is_empty());
assert!(!expected.contains('|'));
}
@@ -1338,7 +1390,7 @@ mod tests {
let mut alternatives = map(&[("l1", "J")]);
// J is the alternative of L1: Square takes it, and L1 gets what Square had there (none).
assert_eq!(
bind_key(
bind(
&mut main,
&mut alternatives,
"square",
@@ -1350,7 +1402,7 @@ mod tests {
assert_eq!(alternatives, map(&[("square", "j")]));
// Q is the main key of L1: Cross's alternative takes it, and L1 is left without a key.
assert_eq!(
bind_key(
bind(
&mut main,
&mut alternatives,
"cross",
@@ -1368,7 +1420,7 @@ mod tests {
let mut main = map(&[("cross", "Space"), ("square", "Mouse Left")]);
let mut alternatives = map(&[("square", "J")]);
assert_eq!(
bind_key(&mut main, &mut alternatives, "cross", Slot::Main, "J"),
bind(&mut main, &mut alternatives, "cross", Slot::Main, "J"),
Some(("square".to_string(), Slot::Alternative))
);
assert_eq!(main["cross"], "J");
@@ -1380,7 +1432,7 @@ mod tests {
let mut main = map(&[("cross", "Space")]);
let mut alternatives = map(&[("cross", "K")]);
assert_eq!(
bind_key(&mut main, &mut alternatives, "cross", Slot::Main, "K"),
bind(&mut main, &mut alternatives, "cross", Slot::Main, "K"),
Some(("cross".to_string(), Slot::Alternative))
);
assert_eq!(
@@ -1389,7 +1441,7 @@ mod tests {
);
// Rebinding a slot to its own key changes nothing.
assert_eq!(
bind_key(&mut main, &mut alternatives, "cross", Slot::Main, "K"),
bind(&mut main, &mut alternatives, "cross", Slot::Main, "K"),
None
);
assert_eq!(
@@ -1403,7 +1455,7 @@ mod tests {
let mut main = map(&[("cross", "Space"), ("square", "Mouse Left")]);
let mut alternatives = map(&[("cross", "K")]);
assert_eq!(
bind_key(
bind(
&mut main,
&mut alternatives,
"cross",
@@ -1416,7 +1468,7 @@ mod tests {
assert_eq!(main, map(&[("cross", "Space"), ("square", "Mouse Left")]));
// A free key becomes the alternative; nothing else moves.
assert_eq!(
bind_key(
bind(
&mut main,
&mut alternatives,
"square",
@@ -1428,6 +1480,147 @@ mod tests {
assert_eq!(alternatives, map(&[("square", "J")]));
}
#[test]
fn both_alternatives_are_written_in_order_after_the_main_key() {
let controls = json!({
"key": {"circle": "Left Ctrl", "square": "Mouse Left", "triangle": "Mouse Right"},
"key_alt": {"square": "7", "triangle": "8", "cross": "9", "circle": "0"},
"key_alt2": {"square": "Keypad 4", "triangle": "Keypad 8", "cross": "Keypad 2", "circle": "Keypad 6"}
});
let spec = keyboard_map(&controls, true);
assert!(
spec.contains(
"cross=Space|9|Keypad 2,circle=Left Ctrl|0|Keypad 6,square=Mouse Left|7|Keypad 4,triangle=Mouse Right|8|Keypad 8,"
),
"{spec}"
);
}
#[test]
fn a_second_alternative_alone_keeps_the_main_key_and_writes_no_empty_name() {
let controls = json!({"key_alt2": {"cross": "Keypad 2"}});
let spec = keyboard_map(&controls, true);
assert!(spec.contains("cross=Space|Keypad 2,"), "{spec}");
assert!(!spec.contains("||") && !spec.contains("|,"), "{spec}");
// Without a main key the two alternatives are what is left.
let controls = json!({
"key": {"cross": "None"}, "key_alt": {"cross": "K"}, "key_alt2": {"cross": "J"}
});
assert!(keyboard_map(&controls, true).contains("cross=K|J,"));
}
#[test]
fn a_repeated_or_unusable_second_alternative_is_not_sent() {
// The main key, the first alternative, a separator and "None" add nothing.
let controls = json!({
"key_alt": {"cross": "K", "circle": "L"},
"key_alt2": {"cross": "k", "circle": "a,b", "square": "Mouse Left", "triangle": "None", "jump": "Q"}
});
let spec = keyboard_map(&controls, true);
assert!(spec.contains("cross=Space|K,"), "{spec}");
assert!(spec.contains("circle=Left Shift|L,"), "{spec}");
assert!(!spec.contains("square=Mouse Left|"), "{spec}");
assert!(!spec.contains("triangle=Mouse Right|"), "{spec}");
assert!(alternative_values(&controls, Slot::SecondAlternative)
.get("jump")
.is_none());
// And a wrong shape is read as no second alternatives.
let wrong = json!({"key_alt2": ["J"]});
assert_eq!(keyboard_map(&wrong, true), keyboard_map(&json!({}), true));
}
#[test]
fn rage_on_one_input_leaves_l3_and_r3_without_second_alternatives() {
let controls = json!({"key_alt2": {"l3": "G", "rage": "V"}});
assert!(keyboard_map(&controls, true).contains("l3=None,r3=None,rage=F|V"));
assert!(keyboard_map(&controls, false).contains("l3=Z|G"));
}
fn three(
main: &[(&str, &str)],
first: &[(&str, &str)],
second: &[(&str, &str)],
) -> [IndexMap<String, String>; 3] {
[map(main), map(first), map(second)]
}
fn bind_in(
tables: &mut [IndexMap<String, String>; 3],
button: &str,
slot: Slot,
name: &str,
) -> Option<(String, Slot)> {
let [main, first, second] = tables;
bind_key([main, first, second], button, slot, name)
}
#[test]
fn a_key_used_in_any_of_the_three_boxes_swaps_with_the_one_that_takes_it() {
let mut tables = three(
&[("cross", "Space"), ("square", "Mouse Left")],
&[("cross", "K")],
&[("cross", "Keypad 2"), ("square", "J")],
);
// The second alternative of Square takes the main key of Cross, which gets what that box had.
assert_eq!(
bind_in(&mut tables, "square", Slot::SecondAlternative, "Space"),
Some(("cross".to_string(), Slot::Main))
);
assert_eq!(tables[0]["cross"], "J");
assert_eq!(tables[2]["square"], "Space");
// A main key takes the first alternative of another button.
assert_eq!(
bind_in(&mut tables, "square", Slot::Main, "K"),
Some(("cross".to_string(), Slot::Alternative))
);
assert_eq!(tables[1]["cross"], "Mouse Left");
// A second alternative takes another second alternative.
assert_eq!(
bind_in(&mut tables, "cross", Slot::SecondAlternative, "Space"),
Some(("square".to_string(), Slot::SecondAlternative))
);
assert_eq!(tables[2]["square"], "Keypad 2");
assert_eq!(tables[2]["cross"], "Space");
}
#[test]
fn the_three_boxes_of_a_button_swap_among_themselves() {
let mut tables = three(
&[("cross", "Space")],
&[("cross", "K")],
&[("cross", "Keypad 2")],
);
assert_eq!(
bind_in(&mut tables, "cross", Slot::Main, "Keypad 2"),
Some(("cross".to_string(), Slot::SecondAlternative))
);
assert_eq!(
(
tables[0]["cross"].as_str(),
tables[1]["cross"].as_str(),
tables[2]["cross"].as_str()
),
("Keypad 2", "K", "Space")
);
// Clearing the first alternative leaves the second where it is.
assert_eq!(
bind_in(&mut tables, "cross", Slot::Alternative, "None"),
None
);
assert!(tables[1].is_empty());
assert_eq!(tables[2]["cross"], "Space");
}
#[test]
fn migrating_keeps_the_second_alternatives() {
let saved = object(json!({"controls_layout": 3,
"controls": {"key": {"cross": "W"}, "key_alt2": {"square": "J"}}}));
assert_eq!(
migrate_controls(saved)["controls"]["key_alt2"],
json!({"square": "J"})
);
}
#[test]
fn sdl_names_and_mouse_names() {
assert_eq!(sdl_key_name("q").as_deref(), Some("Q"));
+110 -7
View File
@@ -133,6 +133,22 @@ fn choices(key: &str) -> &'static [Choice] {
}
}
/// The custom colours of the four face buttons as `cross=blue,circle=orange`: only the buttons that
/// are not gray, which is what an unpicked one is; a name outside the palette counts as gray.
fn custom_key_colors(settings: &Settings) -> String {
["cross", "circle", "square", "triangle"]
.iter()
.filter_map(|button| {
let color = settings.get_str(&format!("glyph_color_{button}"));
let named = crate::core::options::KEY_COLORS
.iter()
.any(|choice| choice.value == color);
(named && color != "gray").then(|| format!("{button}={color}"))
})
.collect::<Vec<_>>()
.join(",")
}
/// `1` or `0` for the flag `key`, always written.
fn put_switch(env: &mut IndexMap<String, String>, settings: &Settings, key: &str) {
env.insert(var(key).into(), one_or_zero(settings.flag(key)));
@@ -319,6 +335,15 @@ pub fn environment(
put_choice(&mut env, "stick_prompts");
put_choice(&mut env, "glyph_style");
put_choice(&mut env, "glyph_keys");
put_choice(&mut env, "glyph_key_colors");
// The four picks of a custom colouring travel together, only with that choice.
env.shift_remove("SPARTAN_GLYPH_KEY_COLOR_MAP");
if env.get("SPARTAN_GLYPH_KEY_COLORS").map(String::as_str) == Some("custom") {
let custom = custom_key_colors(settings);
if !custom.is_empty() {
env.insert("SPARTAN_GLYPH_KEY_COLOR_MAP".into(), custom);
}
}
env.shift_remove("SPARTAN_MENU_CONFIRM_SWAP");
env.shift_remove("SPARTAN_MENU_CONFIRM");
if let Some(val) = settings.get("menu_confirm_swap") {
@@ -591,13 +616,29 @@ mod tests {
}
#[test]
fn every_button_with_an_alternative_still_fits_the_runtime_buffer() {
// The runtime reads SPARTAN_KEY_MAP into a 2048-byte buffer (src/runtime_pad.c).
fn both_alternatives_reach_the_key_map() {
let (settings, paths, _dir) = setup(json!({"controls": {
"key": {"circle": "Left Ctrl"},
"key_alt": {"cross": "9", "circle": "0", "square": "7", "triangle": "8"},
"key_alt2": {"cross": "Keypad 2", "circle": "Keypad 6", "square": "Keypad 4", "triangle": "Keypad 8"}
}}));
let env = game_environment(&settings, &paths, "en", &IndexMap::new(), true);
assert!(env["SPARTAN_KEY_MAP"].contains(
"cross=Space|9|Keypad 2,circle=Left Ctrl|0|Keypad 6,square=Mouse Left|7|Keypad 4,triangle=Mouse Right|8|Keypad 8,"
));
}
#[test]
fn every_button_with_two_alternatives_reaches_the_key_map() {
let rows = crate::core::controls::table_rows(crate::core::controls::Kind::Key);
let alternatives: serde_json::Map<String, Value> = rows
.into_iter()
.iter()
.map(|row| (row.button.to_string(), json!("Keypad Enter")))
.collect();
let second: serde_json::Map<String, Value> = rows
.into_iter()
.map(|row| (row.button.to_string(), json!("Keypad Period")))
.collect();
let keys: serde_json::Map<String, Value> =
crate::core::controls::table_rows(crate::core::controls::Kind::Key)
.into_iter()
@@ -605,13 +646,19 @@ mod tests {
.collect();
let (settings, paths, _dir) = setup(json!({
"rage_one_input": false,
"controls": {"key": keys, "key_alt": alternatives}
"controls": {"key": keys, "key_alt": alternatives, "key_alt2": second}
}));
let env = game_environment(&settings, &paths, "en", &IndexMap::new(), true);
let bound: Vec<&str> = env["SPARTAN_KEY_MAP"]
.split(',')
.filter(|entry| entry.contains("Mouse Forward"))
.collect();
assert!(!bound.is_empty());
assert!(
env["SPARTAN_KEY_MAP"].len() < 2048,
"{}",
env["SPARTAN_KEY_MAP"].len()
bound
.iter()
.all(|entry| entry.ends_with("|Keypad Enter|Keypad Period")),
"{bound:?}"
);
}
@@ -627,11 +674,67 @@ mod tests {
let (chosen, paths, _dir) = setup(json!({"glyph_keys": "alternative"}));
let env = game_environment(&chosen, &paths, "en", &IndexMap::new(), true);
assert_eq!(env["SPARTAN_GLYPH_KEYS"], "alternative");
let (lyre, paths, _dir) = setup(json!({"glyph_keys": "lyre"}));
let env = game_environment(&lyre, &paths, "en", &IndexMap::new(), true);
assert_eq!(env["SPARTAN_GLYPH_KEYS"], "lyre");
let (unknown, paths, _dir) = setup(json!({"glyph_keys": "both"}));
let env = game_environment(&unknown, &paths, "en", &IndexMap::new(), true);
assert!(!env.contains_key("SPARTAN_GLYPH_KEYS"));
}
#[test]
fn the_key_colors_are_sent_only_when_they_change_a_label() {
// The style, and with Custom the picks of the four buttons.
let colors = |edits: Value| {
let (settings, paths, _dir) = setup(edits);
let env = game_environment(&settings, &paths, "en", &IndexMap::new(), true);
(
env.get("SPARTAN_GLYPH_KEY_COLORS").cloned(),
env.get("SPARTAN_GLYPH_KEY_COLOR_MAP").cloned(),
)
};
let style = |name: &str| (Some(name.to_string()), None);
assert_eq!(colors(json!({})), (None, None));
assert_eq!(colors(json!({"glyph_key_colors": "gray"})), (None, None));
assert_eq!(
colors(json!({"glyph_key_colors": "playstation"})),
style("playstation")
);
// Custom joins the four picks, in the order of the buttons, leaving out the gray ones.
let custom = json!({
"glyph_key_colors": "custom",
"glyph_color_square": "blue", "glyph_color_cross": "blue",
"glyph_color_triangle": "orange", "glyph_color_circle": "orange"
});
assert_eq!(
colors(custom),
(
Some("custom".to_string()),
Some("cross=blue,circle=orange,square=blue,triangle=orange".to_string())
)
);
assert_eq!(
colors(json!({"glyph_key_colors": "custom", "glyph_color_circle": "red"})),
(Some("custom".to_string()), Some("circle=red".to_string()))
);
// Custom with nothing picked, or a name outside the palette, has no picks to send; picks
// while the style is not custom, and an unknown style, send nothing.
assert_eq!(
colors(json!({"glyph_key_colors": "custom"})),
style("custom")
);
assert_eq!(
colors(json!({"glyph_key_colors": "custom", "glyph_color_cross": "chartreuse"})),
style("custom")
);
assert_eq!(colors(json!({"glyph_color_cross": "blue"})), (None, None));
assert_eq!(colors(json!({"glyph_key_colors": "rainbow"})), (None, None));
assert_eq!(
colors(json!({"glyph_key_colors": "playstation", "glyph_color_cross": "blue"})),
style("playstation")
);
}
#[test]
fn trophy_texts_file_has_35_rows_and_the_labels() {
let dir = tempfile::tempdir().unwrap();
+144 -8
View File
@@ -114,11 +114,19 @@ static UI_EXTRA: &[(&str, &[(&str, &str)])] = &[
],
),
(
"The second box of a row is an alternative input for the same button: keep Square on the left mouse button for combat and add J for the minigames, for example. \"Clear input\" empties the main box, the × button empties the alternative, and the arrow puts the row back on its default with no alternative.",
"(2nd alternative)",
&[
("zh", "每行的第二个输入框是同一按键的备用输入:例如,战斗时将方块保留在鼠标左键,并为小游戏添加 J。“清除绑定”会清空主输入框,× 按钮会清空备用输入,箭头会将该行恢复为默认绑定并移除备用输入。"),
("pt", "A segunda caixa de uma linha é uma entrada alternativa para o mesmo botão: por exemplo, deixe o Quadrado no botão esquerdo do mouse para o combate e acrescente o J para os minigames. \"Limpar entrada\" esvazia a caixa principal, o botão × esvazia a alternativa e a seta devolve a linha ao padrão, sem alternativa."),
("es", "La segunda casilla de una fila es una entrada alternativa para el mismo botón: por ejemplo, deja el Cuadrado en el botón izquierdo del ratón para el combate y añade la J para los minijuegos. \"Borrar entrada\" vacía la casilla principal, el botón × vacía la alternativa y la flecha devuelve la fila a su valor por defecto, sin alternativa."),
("zh", "(备用 2)"),
("pt", "(2ª alternativa)"),
("es", "(2.ª alternativa)"),
],
),
(
"The second and third boxes of a row are alternative inputs for the same button: keep Square on the left mouse button for combat and add J and Keypad 4 for the minigames, for example. \"Clear input\" empties the main box, the × button next to a box empties that alternative, and the arrow puts the row back on its default with no alternatives.",
&[
("zh", "每行的第二个和第三个输入框是同一按键的备用输入:例如,战斗时将方块保留在鼠标左键,并为小游戏添加 J 和小键盘 4。“清除绑定”会清空主输入框,输入框旁的 × 按钮会清空该备用输入,箭头会将该行恢复为默认绑定并移除所有备用输入。"),
("pt", "A segunda e a terceira caixa de uma linha são entradas alternativas para o mesmo botão: por exemplo, deixe o Quadrado no botão esquerdo do mouse para o combate e acrescente o J e o Keypad 4 para os minigames. \"Limpar entrada\" esvazia a caixa principal, o botão × ao lado de uma caixa esvazia essa alternativa e a seta devolve a linha ao padrão, sem alternativas."),
("es", "La segunda y la tercera casilla de una fila son entradas alternativas para el mismo botón: por ejemplo, deja el Cuadrado en el botón izquierdo del ratón para el combate y añade la J y el Keypad 4 para los minijuegos. \"Borrar entrada\" vacía la casilla principal, el botón × junto a una casilla vacía esa alternativa y la flecha devuelve la fila a su valor por defecto, sin alternativas."),
],
),
(
@@ -146,11 +154,139 @@ static UI_EXTRA: &[(&str, &[(&str, &str)])] = &[
],
),
(
"Which input the keyboard icons in the game show for a button that has an alternative. A button without an alternative always shows its main input. Applied when the game starts.",
"Which input the keyboard icons in the game show for a button that has an alternative: its main key, its alternative key on every icon, or its alternative key only on the Muses' lyre while the other icons keep the main key. A button without an alternative always shows its main input. Applied when the game starts.",
&[
("zh", "当按键设置了备用输入时,游戏内键盘图标显示哪一个输入。没有备用输入的按键始终显示主输入。下次启动游戏生效。"),
("pt", "Qual entrada os ícones de teclado no jogo mostram para um botão que tem alternativa. Um botão sem alternativa sempre mostra a entrada principal. Aplicado ao iniciar o jogo."),
("es", "Qué entrada muestran los iconos de teclado en el juego para un botón que tiene alternativa. Un botón sin alternativa siempre muestra su entrada principal. Se aplica al iniciar el juego."),
("zh", "当按键设置了备用输入时,游戏内键盘图标显示哪一个输入:主按键、所有图标上的备用按键,或仅在缪斯竖琴上显示备用按键(其他图标仍显示主按键)。没有备用输入的按键始终显示主输入。下次启动游戏生效。"),
("pt", "Qual entrada os ícones de teclado no jogo mostram para um botão que tem alternativa: a tecla principal, a alternativa em todos os ícones, ou a alternativa só na lira das Musas, com os outros ícones na principal. Um botão sem alternativa sempre mostra a entrada principal. Aplicado ao iniciar o jogo."),
("es", "Qué entrada muestran los iconos de teclado en el juego para un botón que tiene alternativa: la tecla principal, la alternativa en todos los iconos, o la alternativa solo en la lira de las Musas, con los demás iconos en la principal. Un botón sin alternativa siempre muestra su entrada principal. Se aplica al iniciar el juego."),
],
),
(
"Alternative key on the lyre only",
&[
("zh", "仅在竖琴上显示备用按键"),
("pt", "Tecla alternativa só na lira"),
("es", "Tecla alternativa solo en la lira"),
],
),
(
"Key colors",
&[
("zh", "按键颜色"),
("pt", "Cores das teclas"),
("es", "Colores de las teclas"),
],
),
(
"Gray (default)",
&[
("zh", "灰色(默认)"),
("pt", "Cinza (padrão)"),
("es", "Gris (por defecto)"),
],
),
(
"PlayStation colors",
&[
("zh", "PlayStation 配色"),
("pt", "Cores do PlayStation"),
("es", "Colores de PlayStation"),
],
),
(
"Custom",
&[
("zh", "自定义"),
("pt", "Personalizadas"),
("es", "Personalizados"),
],
),
(
"Face button colors",
&[
("zh", "面键颜色"),
("pt", "Cores dos botões"),
("es", "Colores de los botones"),
],
),
(
"Gray",
&[
("zh", "灰色"),
("pt", "Cinza"),
("es", "Gris"),
],
),
(
"White",
&[
("zh", "白色"),
("pt", "Branco"),
("es", "Blanco"),
],
),
(
"Blue",
&[
("zh", "蓝色"),
("pt", "Azul"),
("es", "Azul"),
],
),
(
"Orange",
&[
("zh", "橙色"),
("pt", "Laranja"),
("es", "Naranja"),
],
),
(
"Green",
&[
("zh", "绿色"),
("pt", "Verde"),
("es", "Verde"),
],
),
(
"Red",
&[
("zh", "红色"),
("pt", "Vermelho"),
("es", "Rojo"),
],
),
(
"Pink",
&[
("zh", "粉色"),
("pt", "Rosa"),
("es", "Rosa"),
],
),
(
"Yellow",
&[
("zh", "黄色"),
("pt", "Amarelo"),
("es", "Amarillo"),
],
),
(
"Purple",
&[
("zh", "紫色"),
("pt", "Roxo"),
("es", "Morado"),
],
),
(
"Colour of the key labels of Cross, Circle, Square and Triangle on the keyboard icons, the Muses' lyre included; every other icon keeps its colour. PlayStation colors are the symbols' own colours; Custom picks one for each button. Applied when the game starts.",
&[
("zh", "键盘图标上叉号、圆圈、方块和三角按键标签的颜色,缪斯竖琴上的也一样;其他图标保持原色。“PlayStation 配色”使用各符号自身的颜色;“自定义”可为每个按键单独选色。下次启动游戏生效。"),
("pt", "Cor das teclas de X, Círculo, Quadrado e Triângulo nos ícones de teclado, inclusive na lira das Musas; os outros ícones ficam como estão. \"Cores do PlayStation\" usa as cores dos próprios símbolos; \"Personalizadas\" escolhe uma cor para cada botão. Aplicado ao iniciar o jogo."),
("es", "Color de las teclas de X, Círculo, Cuadrado y Triángulo en los iconos de teclado, incluida la lira de las Musas; los demás iconos conservan su color. \"Colores de PlayStation\" usa los colores de los propios símbolos; \"Personalizados\" elige un color para cada botón. Se aplica al iniciar el juego."),
],
),
(
+24 -1
View File
@@ -125,6 +125,27 @@ pub static GLYPH_STYLES: &[Choice] = &[
pub static GLYPH_KEYS: &[Choice] = &[
c("main", "Main key (default)"),
c("alternative", "Alternative key"),
c("lyre", "Alternative key on the lyre only"),
];
/// How the keyboard icons colour the key labels of the four face buttons.
pub static KEY_COLOR_STYLES: &[Choice] = &[
c("gray", "Gray (default)"),
c("playstation", "PlayStation colors"),
c("custom", "Custom"),
];
/// The palette of a custom colour; `gray` is the ink of every other label.
pub static KEY_COLORS: &[Choice] = &[
c("gray", "Gray"),
c("white", "White"),
c("blue", "Blue"),
c("orange", "Orange"),
c("green", "Green"),
c("red", "Red"),
c("pink", "Pink"),
c("yellow", "Yellow"),
c("purple", "Purple"),
];
pub static UPSCALERS: &[Choice] = &[
@@ -509,7 +530,7 @@ mod tests {
#[test]
fn every_table_has_unique_values() {
let tables: [&[Choice]; 24] = [
let tables: [&[Choice]; 26] = [
FRAME_GENERATION,
DISPLAY_MODES,
ANISOTROPY,
@@ -521,6 +542,8 @@ mod tests {
AUDIO_CHANNELS,
GLYPH_STYLES,
GLYPH_KEYS,
KEY_COLOR_STYLES,
KEY_COLORS,
UPSCALERS,
PRESETS,
OUTPUTS,
File diff suppressed because it is too large. Load diff
+120 -13
View File
@@ -926,8 +926,9 @@ pub const GAMEPAD_PROBE_LIMIT: Duration = Duration::from_secs(15);
/// of the WADs that hold the game's button icons, mounted over them (`glyphs::build`). Opt-in:
/// unless `SPARTAN_GLYPH_STYLE` is auto, xbox, nintendo or keyboard nothing is asked, drawn or
/// mounted, and old copies are removed. The keyboard keys follow `SPARTAN_KEY_MAP` (the main key of a
/// button, or its alternative with `SPARTAN_GLYPH_KEYS=alternative`); auto shows them when no
/// gamepad is connected. Whatever fails only reaches the Log: the game then keeps its own
/// button, or its alternative with `SPARTAN_GLYPH_KEYS=alternative`, or on the Muses' lyre alone with
/// `SPARTAN_GLYPH_KEYS=lyre`), coloured as `SPARTAN_GLYPH_KEY_COLORS` (and its `SPARTAN_GLYPH_KEY_COLOR_MAP`) says for the four face
/// buttons; auto shows them when no gamepad is connected. Whatever fails only reaches the Log: the game then keeps its own
/// icons. `run_probe` runs a command (`[probe, --gamepad]`) within a time limit and returns its
/// output or the error to print.
pub fn glyph_mounts(
@@ -964,11 +965,19 @@ pub fn glyph_mounts(
String::new()
};
let resolved = glyphs::resolve(choice, &pad);
// Only the key the icons show goes in, so a copy is redrawn when that key changes.
let key_map = glyphs::shown_keys(
get("SPARTAN_KEY_MAP"),
get("SPARTAN_GLYPH_KEYS") == "alternative",
);
// Only the keys the icons show go in, so a copy is redrawn when one of them changes.
let icon_keys = get("SPARTAN_GLYPH_KEYS");
let key_map = glyphs::shown_keys(get("SPARTAN_KEY_MAP"), icon_keys == "alternative");
let lyre_key_map = if icon_keys == "lyre" {
glyphs::lyre_keys(get("SPARTAN_KEY_MAP"))
} else {
String::new()
};
let face_colors = match get("SPARTAN_GLYPH_KEY_COLORS") {
"playstation" => glyphs::face_colors("playstation"),
"custom" => glyphs::face_colors(get("SPARTAN_GLYPH_KEY_COLOR_MAP")),
_ => String::new(),
};
let mounts = match glyphs::build(
game,
out,
@@ -976,6 +985,8 @@ pub fn glyph_mounts(
resolved.faces.as_ref(),
get("SPARTAN_PAD_MAP"),
&key_map,
&lyre_key_map,
&face_colors,
assets,
log,
) {
@@ -1242,7 +1253,7 @@ mod tests {
// glyphs::build got the controller's style and letters and the gamepad map, no key map.
let faces = r#"{"south": "a", "east": "b", "west": "x", "north": "y"}"#;
assert!(
stamp(&run).starts_with(&format!("[12, \"xbox\", {faces}, \"cross=b\", 1")),
stamp(&run).starts_with(&format!("[13, \"xbox\", {faces}, \"cross=b\", 1")),
"{}",
stamp(&run)
);
@@ -1273,7 +1284,7 @@ mod tests {
assert_eq!(result, (vec![], None));
assert!(calls.is_empty());
assert!(
stamp(&run).starts_with("[12, \"nintendo\", null, \"\", 1"),
stamp(&run).starts_with("[13, \"nintendo\", null, \"\", 1"),
"{}",
stamp(&run)
);
@@ -1291,7 +1302,7 @@ mod tests {
assert_eq!(style.as_deref(), Some("keyboard"));
assert!(calls.is_empty());
assert!(
stamp(&run).starts_with("[12, \"keyboard\", null, \"\", \"cross=Q\", null, "),
stamp(&run).starts_with("[13, \"keyboard\", null, \"\", \"cross=Q\", null, "),
"{}",
stamp(&run)
);
@@ -1307,7 +1318,7 @@ mod tests {
let ((_, style), _, _) = mounts_with(&run, &env, Ok("Gamepad: none\n"));
assert_eq!(style.as_deref(), Some("keyboard"));
assert!(
stamp(&run).starts_with("[12, \"keyboard\", null, \"\", \"square=G\", null, "),
stamp(&run).starts_with("[13, \"keyboard\", null, \"\", \"square=G\", null, "),
"{}",
stamp(&run)
);
@@ -1325,7 +1336,7 @@ mod tests {
let drawn_main = stamp(&run);
assert!(
drawn_main.starts_with(
"[12, \"keyboard\", null, \"\", \"cross=Space,square=Mouse Left,triangle=Q\", null, "
"[13, \"keyboard\", null, \"\", \"cross=Space,square=Mouse Left,triangle=Q\", null, "
),
"{drawn_main}"
);
@@ -1339,7 +1350,7 @@ mod tests {
let drawn_alternative = stamp(&run);
assert!(
drawn_alternative.starts_with(
"[12, \"keyboard\", null, \"\", \"cross=K,square=J,triangle=Q\", null, "
"[13, \"keyboard\", null, \"\", \"cross=K,square=J,triangle=Q\", null, "
),
"{drawn_alternative}"
);
@@ -1348,6 +1359,102 @@ mod tests {
assert_eq!(stamp(&run), drawn_main);
}
#[test]
fn the_lyre_alone_stamps_the_keys_it_shows_and_redraws_when_they_change() {
let run = glyph_run(true);
let lyre = |map: &'static str| {
[
("SPARTAN_GLYPH_STYLE", "keyboard"),
("SPARTAN_KEY_MAP", map),
("SPARTAN_GLYPH_KEYS", "lyre"),
]
};
mounts_with(&run, &lyre("cross=Space|K,l1=Q|G"), Ok(""));
let first = stamp(&run);
assert!(
first.starts_with(
"[13, \"keyboard\", null, \"\", \"cross=Space,l1=Q\", [\"lyre\", \"cross=K\"], null, "
),
"{first}"
);
// An alternative on a button the lyre shows redraws the copy.
mounts_with(&run, &lyre("cross=Space|L,l1=Q|G"), Ok(""));
let moved = stamp(&run);
assert_ne!(moved, first);
assert!(moved.contains("[\"lyre\", \"cross=L\"]"), "{moved}");
// So does one on a button that had none.
mounts_with(&run, &lyre("cross=Space|L,square=Mouse Left|J"), Ok(""));
assert_ne!(stamp(&run), moved);
// An alternative on a button it does not show does not.
mounts_with(&run, &lyre("cross=Space|L,l1=Q|G,r1=E|T"), Ok(""));
let other_button = stamp(&run);
mounts_with(&run, &lyre("cross=Space|L,l1=Q|H,r1=E"), Ok(""));
assert_eq!(stamp(&run), other_button);
// Back to the main key: the stamp has no trace of the lyre, as before this choice existed.
let main = [
("SPARTAN_GLYPH_STYLE", "keyboard"),
("SPARTAN_KEY_MAP", "cross=Space|L,l1=Q"),
];
mounts_with(&run, &main, Ok(""));
assert!(!stamp(&run).contains("lyre"), "{}", stamp(&run));
}
#[test]
fn the_colors_are_stamped_with_the_keys_the_lyre_shows() {
let run = glyph_run(true);
// Square and Cross blue, Triangle and Circle orange; first alternatives 5, 6, 9, 0 and
// second alternatives on the keypad; the lyre alone shows the first alternatives.
let map = "cross=Space|6|Keypad 2,circle=Left Ctrl|0|Keypad 6,square=Mouse Left|5|Keypad 4,triangle=Mouse Right|9|Keypad 8";
let env = |colors: &'static str| {
let style = match colors {
"playstation" | "gray" => colors,
_ => "custom",
};
[
("SPARTAN_GLYPH_STYLE", "keyboard"),
("SPARTAN_KEY_MAP", map),
("SPARTAN_GLYPH_KEYS", "lyre"),
("SPARTAN_GLYPH_KEY_COLORS", style),
("SPARTAN_GLYPH_KEY_COLOR_MAP", colors),
]
};
mounts_with(
&run,
&env("square=blue,cross=blue,triangle=orange,circle=orange"),
Ok(""),
);
let first = stamp(&run);
assert!(
first.starts_with(
"[13, \"keyboard\", null, \"\", \"cross=Space,circle=Left Ctrl,square=Mouse Left,triangle=Mouse Right\", [\"lyre\", \"cross=6,circle=0,square=5,triangle=9\"], [\"colors\", \"cross=428ee8,circle=f28c28,square=428ee8,triangle=f28c28\"], null, "
),
"{first}"
);
// A colour of another name or a hex that comes to the same colour is the same stamp.
mounts_with(
&run,
&env("triangle=#f28c28,circle=orange,cross=428ee8,square=Blue"),
Ok(""),
);
assert_eq!(stamp(&run), first);
// Another colour redraws the copies; PlayStation colors are one more stamp.
mounts_with(
&run,
&env("square=pink,cross=blue,triangle=orange,circle=orange"),
Ok(""),
);
let pink = stamp(&run);
assert_ne!(pink, first);
assert!(pink.contains("square=d683d6"), "{pink}");
mounts_with(&run, &env("playstation"), Ok(""));
assert!(stamp(&run).contains("cross=6b9ae8"), "{}", stamp(&run));
// Gray, or nothing, leaves no trace: the stamp is the one of before the colours.
for colors in ["", "gray", "cross=gray"] {
mounts_with(&run, &env(colors), Ok(""));
assert!(!stamp(&run).contains("colors"), "{colors}: {}", stamp(&run));
}
}
#[test]
fn a_new_alternative_does_not_redraw_the_icons_of_the_main_keys() {
let run = glyph_run(true);
+24 -8
View File
@@ -337,6 +337,8 @@ pub fn part_pressed(state: &mut State, part: &str) -> Task<Message> {
Slot::Main
} else if view.key_alt.get(row.button).is_some_and(matches) {
Slot::Alternative
} else if view.key_alt2.get(row.button).is_some_and(matches) {
Slot::SecondAlternative
} else {
return None;
};
@@ -437,6 +439,7 @@ fn current(state: &State) -> Profile {
Profile {
key: control_changes(&state.controls.key, Kind::Key),
key_alt: state.controls.key_alt.clone(),
key_alt2: state.controls.key_alt2.clone(),
pad: control_changes(&state.controls.pad, Kind::Pad),
deadzone_left: number("deadzone_left"),
deadzone_right: number("deadzone_right"),
@@ -457,6 +460,7 @@ fn fail(state: &mut State, error: &ProfileError) {
pub fn apply_profile(state: &mut State, profile: &Profile) -> Task<Message> {
state.controls.key_alt = profile.key_alt.clone();
state.controls.key_alt2 = profile.key_alt2.clone();
for kind in [Kind::Key, Kind::Pad] {
let (values, chosen) = match kind {
Kind::Key => (&mut state.controls.key, &profile.key),
@@ -611,7 +615,12 @@ fn pad_label(state: &State, lang: &str, name: &str) -> String {
/// The inputs of the table on show that press more than one game button.
fn shown_conflicts(view: &controls::ControlsView) -> Vec<String> {
match view.device {
Shown::Keyboard => conflicts(view.key.values().chain(view.key_alt.values())),
Shown::Keyboard => conflicts(
view.key
.values()
.chain(view.key_alt.values())
.chain(view.key_alt2.values()),
),
Shown::Gamepad => conflicts(view.pad.values()),
}
}
@@ -626,11 +635,15 @@ fn live_view(state: &State, lang: &str, picture: Picture) -> Live {
let mut bound: Vec<(String, String)> = Vec::new();
let mut changed = Vec::new();
for row in table_rows(kind) {
let alternative = match picture {
Picture::Keyboard => view.key_alt.get(row.button),
Picture::Pad(_) => None,
let [alternative, second] = match picture {
Picture::Keyboard => [view.key_alt.get(row.button), view.key_alt2.get(row.button)],
Picture::Pad(_) => [None, None],
};
for (input, extra) in [(values.get(row.button), false), (alternative, true)] {
for (input, extra) in [
(values.get(row.button), false),
(alternative, true),
(second, true),
] {
let Some(input) = input.filter(|value| !value.is_empty()) else {
continue;
};
@@ -683,6 +696,7 @@ fn live_view(state: &State, lang: &str, picture: Picture) -> Live {
.and_then(|button| match view.capturing_slot {
Slot::Main => view.key.get(button),
Slot::Alternative => view.key_alt.get(button),
Slot::SecondAlternative => view.key_alt2.get(button),
})
.and_then(|input| part_id(picture, input).map(str::to_string));
live.caption = match view.held.last() {
@@ -696,9 +710,11 @@ fn live_view(state: &State, lang: &str, picture: Picture) -> Live {
.get(button)
.is_some_and(|key| key.trim().eq_ignore_ascii_case(input))
};
let game = table_rows(Kind::Key)
.into_iter()
.find(|row| same(&view.key, row.button) || same(&view.key_alt, row.button));
let game = table_rows(Kind::Key).into_iter().find(|row| {
[&view.key, &view.key_alt, &view.key_alt2]
.iter()
.any(|table| same(table, row.button))
});
let icon = input_icon(input).map(|icon| icon.plain);
pressed_line(lang, icon, game.map(|row| row.button))
});
+127 -30
View File
@@ -4,8 +4,8 @@
//! The edited tables live in `ControlsView`, not in the settings: Task 32's `gather` writes their
//! `control_changes` into `settings.controls`. A key box waits for an input after a click: the next
//! key (`key_pressed`, fed by `Message::Key`), mouse button or wheel turn (`subscription`) is bound
//! with `bind_key`, or the box gives up after `CAPTURE_SECONDS`. Each keyboard row has two boxes: the
//! main input and an optional alternative that presses the same button.
//! with `bind_key`, or the box gives up after `CAPTURE_SECONDS`. Each keyboard row has three boxes: the
//! main input and two optional alternatives that press the same button.
use std::path::PathBuf;
use std::time::Duration;
@@ -27,7 +27,8 @@ use crate::core::gpu::connected_gamepad;
use crate::core::i18n::{tr, tr_fmt};
use crate::core::ini::INI_DEFAULTS;
use crate::core::options::{
AIM_STYLES, GLYPH_KEYS, GLYPH_STYLES, MENU_CONFIRM_STYLES, STICK_PROMPTS,
AIM_STYLES, GLYPH_KEYS, GLYPH_STYLES, KEY_COLORS, KEY_COLOR_STYLES, MENU_CONFIRM_STYLES,
STICK_PROMPTS,
};
use crate::core::pad_model::PadModel;
use crate::core::platform::tool::SystemTools;
@@ -41,7 +42,8 @@ use crate::ui::theme::{self, palette};
use crate::ui::widgets::button_icon::game_button;
use crate::ui::widgets::pad_diagram::input_glyph;
use crate::ui::widgets::{
button_icon, checkbox, min_width, row, section, select, slider, SELECT_MIN, SELECT_TEXT,
button_icon, checkbox, min_width, row, section, select, select_pairs_small, slider, SELECT_MIN,
SELECT_TEXT,
};
/// A waiting key box gives up after this long.
@@ -103,12 +105,14 @@ const DEADZONE_HINT: &str = "How far a stick must move before the game sees it.
/// Side of a button icon.
const ICON_SIZE: f32 = 28.0;
/// Width of a key box, enough for the countdown text.
const BOX_WIDTH: f32 = 220.0;
/// Width of a key box. Three boxes must leave the row's title two readable lines at 1280x800.
const BOX_WIDTH: f32 = 144.0;
/// What an empty alternative box says.
const ALTERNATIVE_EMPTY: &str = "(alternative)";
const ALTERNATIVE_NOTE: &str = "The second box of a row is an alternative input for the same button: keep Square on the left mouse button for combat and add J for the minigames, for example. \"Clear input\" empties the main box, the × button empties the alternative, and the arrow puts the row back on its default with no alternative.";
const GLYPH_KEYS_HINT: &str = "Which input the keyboard icons in the game show for a button that has an alternative. A button without an alternative always shows its main input. Applied when the game starts.";
const SECOND_ALTERNATIVE_EMPTY: &str = "(2nd alternative)";
const ALTERNATIVE_NOTE: &str = "The second and third boxes of a row are alternative inputs for the same button: keep Square on the left mouse button for combat and add J and Keypad 4 for the minigames, for example. \"Clear input\" empties the main box, the × button next to a box empties that alternative, and the arrow puts the row back on its default with no alternatives.";
const KEY_COLORS_HINT: &str = "Colour of the key labels of Cross, Circle, Square and Triangle on the keyboard icons, the Muses' lyre included; every other icon keeps its colour. PlayStation colors are the symbols' own colours; Custom picks one for each button. Applied when the game starts.";
const GLYPH_KEYS_HINT: &str = "Which input the keyboard icons in the game show for a button that has an alternative: its main key, its alternative key on every icon, or its alternative key only on the Muses' lyre while the other icons keep the main key. A button without an alternative always shows its main input. Applied when the game starts.";
/// The device whose table the page shows; both keep working in the game.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
@@ -156,6 +160,8 @@ pub struct ControlsView {
pub key: IndexMap<String, String>,
/// Alternative input by game button; a button without one has no entry.
pub key_alt: IndexMap<String, String>,
/// The second alternative input, kept like `key_alt`.
pub key_alt2: IndexMap<String, String>,
/// SDL gamepad button by game button (`CONTROLS`); empty for a Touchpad left left on L1 +
/// Touchpad right.
pub pad: IndexMap<String, String>,
@@ -164,7 +170,7 @@ pub struct ControlsView {
pub pad_model: PadModel,
/// The game button whose key box waits for an input.
pub capturing: Option<String>,
/// Which of its two boxes waits.
/// Which of its three boxes waits.
pub capturing_slot: Slot,
pub capture_started: Option<Instant>,
/// Whether the first open already looked for a gamepad.
@@ -204,7 +210,8 @@ impl ControlsView {
device: device.unwrap_or_default(),
device_chosen: device.is_some(),
key: table(saved, Kind::Key),
key_alt: alternative_values(saved),
key_alt: alternative_values(saved, Slot::Alternative),
key_alt2: alternative_values(saved, Slot::SecondAlternative),
pad: table(saved, Kind::Pad),
pad_model: PadModel::saved(&settings.get_str("pad_art"), &pad_style),
pad_style,
@@ -226,6 +233,13 @@ impl ControlsView {
}
}
impl ControlsView {
/// The main inputs, the alternatives and the second alternatives, as `bind_key` takes them.
fn key_tables(&mut self) -> [&mut IndexMap<String, String>; 3] {
[&mut self.key, &mut self.key_alt, &mut self.key_alt2]
}
}
/// Every row of a table on its saved input, or on its default (`""` when it has none).
fn table(saved: &Value, kind: Kind) -> IndexMap<String, String> {
let chosen = control_values(saved, kind);
@@ -252,9 +266,9 @@ pub enum ControlsMessage {
/// The waiting box gives up (Python `finish`): its time ran out, the window lost the focus,
/// or a mouse button without an input name was pressed.
CaptureTimeout,
/// The arrow of a row: that button back on its default, without an alternative.
/// The arrow of a row: that button back on its default, without alternatives.
ResetKey(String),
/// "Clear input" of a row: that button's main input on none, or its alternative dropped.
/// "Clear input" of a row: that button's main input on none, or an alternative dropped.
Clear(String, Slot),
ResetTable(Kind),
/// A controller picked by hand; it stops following the connected one.
@@ -307,7 +321,7 @@ pub fn update(state: &mut State, message: ControlsMessage) -> Task<Message> {
&& state.controls.capturing_slot == slot
{
let view = &mut state.controls;
bind_key(&mut view.key, &mut view.key_alt, &button, slot, &name);
bind_key(view.key_tables(), &button, slot, &name);
stop_capture(state);
}
}
@@ -320,22 +334,18 @@ pub fn update(state: &mut State, message: ControlsMessage) -> Task<Message> {
let default = default_input(row, Kind::Key).unwrap_or_default();
let view = &mut state.controls;
view.key_alt.shift_remove(&button);
bind_key(
&mut view.key,
&mut view.key_alt,
&button,
Slot::Main,
default,
);
view.key_alt2.shift_remove(&button);
bind_key(view.key_tables(), &button, Slot::Main, default);
}
}
ControlsMessage::Clear(button, slot) => {
let view = &mut state.controls;
bind_key(&mut view.key, &mut view.key_alt, &button, slot, "None");
bind_key(view.key_tables(), &button, slot, "None");
}
ControlsMessage::ResetTable(kind) => {
if kind == Kind::Key {
state.controls.key_alt.clear();
state.controls.key_alt2.clear();
}
let values = match kind {
Kind::Key => &mut state.controls.key,
@@ -689,6 +699,11 @@ pub fn clear_alternative_id(button: &str) -> iced::widget::Id {
iced::widget::Id::from(format!("controls-clear-alternative: {button}"))
}
/// The id of the button that drops the second alternative of a keyboard row.
pub fn clear_second_alternative_id(button: &str) -> iced::widget::Id {
iced::widget::Id::from(format!("controls-clear-second-alternative: {button}"))
}
/// The English name of a game button or action, which is also the translation key.
pub(crate) fn button_name(button: &'static str) -> &'static str {
PS_BUTTON_NAMES
@@ -732,6 +747,10 @@ pub struct KeyRow {
pub alternative: String,
/// Whether the row has an alternative to drop.
pub has_alternative: bool,
/// What the second alternative box shows, like `alternative`.
pub second_alternative: String,
/// Whether the row has a second alternative to drop.
pub has_second_alternative: bool,
/// Off for L3 and R3 while Rage of Sparta is on one input, and for the Rage row otherwise.
pub enabled: bool,
}
@@ -753,6 +772,8 @@ pub fn key_rows(state: &State, lang: &str) -> Vec<KeyRow> {
input: key_box_text(state, lang, row.button, Slot::Main),
alternative: key_box_text(state, lang, row.button, Slot::Alternative),
has_alternative: state.controls.key_alt.contains_key(row.button),
second_alternative: key_box_text(state, lang, row.button, Slot::SecondAlternative),
has_second_alternative: state.controls.key_alt2.contains_key(row.button),
enabled,
}
})
@@ -771,6 +792,7 @@ fn key_box_text(state: &State, lang: &str, button: &str, slot: Slot) -> String {
let (inputs, empty) = match slot {
Slot::Main => (&view.key, "(none)"),
Slot::Alternative => (&view.key_alt, ALTERNATIVE_EMPTY),
Slot::SecondAlternative => (&view.key_alt2, SECOND_ALTERNATIVE_EMPTY),
};
match inputs.get(button).map(String::as_str) {
None | Some("" | "None") => tr(lang, empty),
@@ -897,7 +919,10 @@ fn radio<'a>(label: String, active: bool, message: Message) -> Element<'a, Messa
/// whose rows may be out of sight, still shows that it acted.
fn reset_button<'a>(state: &State, label: String, kind: Kind) -> Element<'a, Message> {
let (values, has_alternatives) = match kind {
Kind::Key => (&state.controls.key, !state.controls.key_alt.is_empty()),
Kind::Key => (
&state.controls.key,
!state.controls.key_alt.is_empty() || !state.controls.key_alt2.is_empty(),
),
Kind::Pad => (&state.controls.pad, false),
};
let reset = (has_alternatives || !at_defaults(values, kind))
@@ -915,6 +940,7 @@ fn key_line<'a>(row: KeyRow, lang: &str) -> Element<'a, Message> {
let (icon, style) = game_icon(row.button);
let glyph = input_glyph(&row.input);
let alternative_glyph = input_glyph(&row.alternative);
let second_glyph = input_glyph(&row.second_alternative);
let mut input = button(text(row.input))
.width(BOX_WIDTH)
.style(theme::button_flat);
@@ -928,6 +954,14 @@ fn key_line<'a>(row: KeyRow, lang: &str) -> Element<'a, Message> {
.width(BOX_WIDTH)
.style(theme::button_flat);
let mut clear_alternative = button(text("\u{00d7}")).style(theme::button_flat);
let mut second_label = text(row.second_alternative);
if !row.has_second_alternative {
second_label = second_label.style(theme::text_muted);
}
let mut second = button(second_label)
.width(BOX_WIDTH)
.style(theme::button_flat);
let mut clear_second = button(text("\u{00d7}")).style(theme::button_flat);
if row.enabled {
let button = row.button.to_string();
input = input.on_press(Message::Controls(ControlsMessage::Capture(
@@ -938,6 +972,10 @@ fn key_line<'a>(row: KeyRow, lang: &str) -> Element<'a, Message> {
button.clone(),
Slot::Alternative,
)));
second = second.on_press(Message::Controls(ControlsMessage::Capture(
button.clone(),
Slot::SecondAlternative,
)));
reset = reset.on_press(Message::Controls(ControlsMessage::ResetKey(button.clone())));
clear = clear.on_press(Message::Controls(ControlsMessage::Clear(
button.clone(),
@@ -945,9 +983,15 @@ fn key_line<'a>(row: KeyRow, lang: &str) -> Element<'a, Message> {
)));
if row.has_alternative {
clear_alternative = clear_alternative.on_press(Message::Controls(
ControlsMessage::Clear(button, Slot::Alternative),
ControlsMessage::Clear(button.clone(), Slot::Alternative),
));
}
if row.has_second_alternative {
clear_second = clear_second.on_press(Message::Controls(ControlsMessage::Clear(
button,
Slot::SecondAlternative,
)));
}
}
let mut line = layout![
button_icon(icon, style, ICON_SIZE),
@@ -958,11 +1002,17 @@ fn key_line<'a>(row: KeyRow, lang: &str) -> Element<'a, Message> {
if let Some(glyph) = alternative_glyph {
line = line.push(glyph);
}
line.push(alternative)
.push(container(clear_alternative).id(clear_alternative_id(row.button)))
line = line
.push(alternative)
.push(container(clear_alternative).id(clear_alternative_id(row.button)));
if let Some(glyph) = second_glyph {
line = line.push(glyph);
}
line.push(second)
.push(container(clear_second).id(clear_second_alternative_id(row.button)))
.push(reset)
.push(container(clear).id(clear_id(row.button)))
.spacing(10)
.spacing(6)
.align_y(Center)
.into()
}
@@ -1186,6 +1236,7 @@ pub fn view(state: &State) -> Element<'_, Message> {
Some(&tr(lang, GLYPH_HINT)),
),
glyph_keys_row(state, lang),
glyph_key_colors_rows(state, lang),
// For both devices, under the icons as in the Python's page.
checkbox(
state.rtl,
@@ -1200,13 +1251,59 @@ pub fn view(state: &State) -> Element<'_, Message> {
.into()
}
/// Whether the in-game icons can draw the keyboard: the keyboard style, and "match my controller"
/// while no gamepad is connected.
fn draws_the_keyboard(state: &State) -> bool {
matches!(
state.store.text("glyph_style").as_str(),
"keyboard" | "auto"
)
}
/// How the key labels of the face buttons are coloured, with a box for each button when the
/// colours are custom.
fn glyph_key_colors_rows<'a>(state: &State, lang: &str) -> Element<'a, Message> {
if !draws_the_keyboard(state) {
return space().height(0).into();
}
let style = state.store.text("glyph_key_colors");
let mut rows = column![row(
state.rtl,
&tr(lang, "Key colors"),
select("glyph_key_colors", KEY_COLOR_STYLES, &style, lang),
Some(&tr(lang, KEY_COLORS_HINT)),
)]
.spacing(2);
if style == "custom" {
let palette: Vec<(String, String)> = KEY_COLORS
.iter()
.map(|choice| (choice.value.to_string(), tr(lang, choice.en)))
.collect();
let boxes = ["cross", "circle", "square", "triangle"].map(|button| {
let key = format!("glyph_color_{button}");
column![
text(tr(lang, button_name(button)))
.size(12)
.style(theme::text_muted),
select_pairs_small(&key, &palette, &state.store.text(&key)),
]
.spacing(2)
.into()
});
rows = rows.push(row(
state.rtl,
&tr(lang, "Face button colors"),
layout(boxes).spacing(10).into(),
None,
));
}
rows.into()
}
/// Which input the keyboard icons show, for the styles that can draw the keyboard (the keyboard
/// style, and "match my controller" while no gamepad is connected).
fn glyph_keys_row<'a>(state: &State, lang: &str) -> Element<'a, Message> {
if !matches!(
state.store.text("glyph_style").as_str(),
"keyboard" | "auto"
) {
if !draws_the_keyboard(state) {
return space().height(0).into();
}
row(
+7 -5
View File
@@ -123,11 +123,13 @@ pub fn gather(state: &State) -> Persisted {
string_map(control_changes(&state.controls.pad, Kind::Pad)),
);
// Only when there is one, so settings without alternatives are saved as before.
if !state.controls.key_alt.is_empty() {
controls.insert(
"key_alt".to_string(),
string_map(state.controls.key_alt.clone()),
);
for (field, alternatives) in [
("key_alt", &state.controls.key_alt),
("key_alt2", &state.controls.key_alt2),
] {
if !alternatives.is_empty() {
controls.insert(field.to_string(), string_map(alternatives.clone()));
}
}
app.insert("controls".to_string(), Value::Object(controls));
// The Controls page's own choice; `settings.pad_style` may still hold an unknown saved value.
+38 -16
View File
@@ -457,13 +457,25 @@ fn real_glyph_wads_match_python_outside_the_painted_blocks() {
if python_glyphs(&game, &python, style).is_none() {
return;
}
let mounts = glyphs::build(&game, &rust, style, None, "", "", &assets, &mut |line| {
println!("{line}")
})
let mounts = glyphs::build(
&game,
&rust,
style,
None,
"",
"",
"",
"",
&assets,
&mut |line| println!("{line}"),
)
.unwrap();
let theirs = fs::read(python.join("glyphs/stamp.json")).unwrap();
// The drawing's version is the one thing that differs (deviation 18 of the parity spec).
let theirs = String::from_utf8(fs::read(python.join("glyphs/stamp.json")).unwrap())
.unwrap()
.replacen("[12,", &format!("[{},", glyphs::VERSION), 1);
assert_eq!(
fs::read(rust.join("glyphs/stamp.json")).unwrap(),
fs::read_to_string(rust.join("glyphs/stamp.json")).unwrap(),
theirs,
"{style}: stamp.json"
);
@@ -473,12 +485,15 @@ fn real_glyph_wads_match_python_outside_the_painted_blocks() {
rust.join("glyphs").join(file),
python.join("glyphs").join(file),
);
// The Python leaves the lyre's PlayStation symbols with the keyboard style, and makes
// no copy of its WAD (deviation 18 of the parity spec).
let lyre_only_here = style == "keyboard" && name.ends_with("OLY150.WAD");
assert_eq!(
ours.is_file(),
theirs.is_file(),
theirs.is_file() || lyre_only_here,
"{style}: {name} made by one side only"
);
if !theirs.is_file() {
if !ours.is_file() {
continue;
}
assert!(
@@ -486,7 +501,12 @@ fn real_glyph_wads_match_python_outside_the_painted_blocks() {
"{style}: {name} not mounted"
);
let original = fs::read(game.join(name)).unwrap();
let (ours, theirs) = (fs::read(ours).unwrap(), fs::read(theirs).unwrap());
let ours = fs::read(ours).unwrap();
let theirs = if lyre_only_here {
original.clone()
} else {
fs::read(theirs).unwrap()
};
assert_eq!(ours.len(), theirs.len(), "{style}: {name} size");
assert_eq!(theirs.len(), original.len(), "{style}: {name} size");
// Both sides repaint the same entries; inside those, a DXT block either side
@@ -496,14 +516,16 @@ fn real_glyph_wads_match_python_outside_the_painted_blocks() {
for entry in glyphs::entries(&original) {
let (start, end) = (entry.offset + 0x40, entry.offset + 0x40 + entry.size);
let changed = |copy: &[u8]| copy[start..end] != original[start..end];
assert_eq!(
changed(&ours),
changed(&theirs),
"{style}: {name}: {} repainted by one side only (Rust: {})",
entry.name,
changed(&ours)
);
if !changed(&theirs) {
if !lyre_only_here {
assert_eq!(
changed(&ours),
changed(&theirs),
"{style}: {name}: {} repainted by one side only (Rust: {})",
entry.name,
changed(&ours)
);
}
if !changed(&ours) && !changed(&theirs) {
continue;
}
let Some(dds) = original[start..end]
+1
View File
@@ -144,6 +144,7 @@ fn runner_defaults_are_run_pys() {
("SPARTAN_LANGUAGE", "1"), // the English text and voice, as the game assumes
// Rust only (the Python launcher has no such choice): absent counts as the main key.
("SPARTAN_GLYPH_KEYS", "main"),
("SPARTAN_GLYPH_KEY_COLORS", "gray"),
];
for (name, literal) in expected {
assert_eq!(catalog::run_default(name), Some(literal), "{name}");
+4
View File
@@ -370,6 +370,8 @@ fn painted_textures_match_pillow_within_tolerance() {
faces: None,
pad_map: "",
key_map: "",
lyre_key_map: "",
face_colors: "",
};
for path in names {
let id = path.file_stem().unwrap().to_str().unwrap().to_string();
@@ -424,6 +426,8 @@ fn keyboard_glyphs_label_the_remapped_sticks() {
faces: None,
pad_map: "",
key_map: "move_up=T,move_left=F,move_down=G,move_right=H,look_left=None",
lyre_key_map: "",
face_colors: "",
};
let mut names: Vec<PathBuf> = fs::read_dir(&folder)
.unwrap()
+7
View File
@@ -423,22 +423,29 @@ fn alternative_keys_stay_with_the_profile_and_the_picture() {
state.lang = "en".into();
open(&mut state, Page::Controls);
state.controls.key_alt.insert("cross".into(), "K".into());
state.controls.key_alt2.insert("cross".into(), "J".into());
send(&mut state, ControlsMessage::PartPressed("K".into()));
assert_eq!(state.controls.capturing.as_deref(), Some("cross"));
assert_eq!(state.controls.capturing_slot, Slot::Alternative);
send(&mut state, ControlsMessage::CaptureTimeout);
send(&mut state, ControlsMessage::PartPressed("J".into()));
assert_eq!(state.controls.capturing_slot, Slot::SecondAlternative);
send(&mut state, ControlsMessage::CaptureTimeout);
send(&mut state, ControlsMessage::ProfileName("Mine".into()));
send(&mut state, ControlsMessage::ProfileSave);
let file = state.paths.data_dir.join(control_profiles::FILENAME);
let saved = control_profiles::load(&file).unwrap();
assert_eq!(saved["Mine"].key_alt["cross"], "K");
assert_eq!(saved["Mine"].key_alt2["cross"], "J");
send(&mut state, ControlsMessage::ProfilePick("Default".into()));
assert!(state.controls.key_alt.is_empty());
assert!(state.controls.key_alt2.is_empty());
send(&mut state, ControlsMessage::ProfilePick("Mine".into()));
assert_eq!(state.controls.key_alt["cross"], "K");
assert_eq!(state.controls.key_alt2["cross"], "J");
}
#[test]
+166
View File
@@ -1483,6 +1483,13 @@ fn capture_alternative(state: &mut app::State, button: &str) {
);
}
fn capture_second_alternative(state: &mut app::State, button: &str) {
controls_update(
state,
ControlsMessage::Capture(button.into(), Slot::SecondAlternative),
);
}
fn key_row(state: &app::State, button: &str) -> controls::KeyRow {
controls::key_rows(state, "en")
.into_iter()
@@ -1522,6 +1529,57 @@ fn an_alternative_box_binds_next_to_the_main_key() {
assert_eq!(state.controls.key_alt["cross"], "Mouse Forward");
}
#[test]
fn the_third_box_binds_a_second_alternative_next_to_the_other_two() {
let dir = tempfile::tempdir().unwrap();
let mut state = controls_state(dir.path());
assert_eq!(
key_row(&state, "square").second_alternative,
"(2nd alternative)"
);
capture_alternative(&mut state, "square");
press_key(&mut state, char_key("j"), Location::Standard);
capture_second_alternative(&mut state, "square");
assert_eq!(
key_row(&state, "square").second_alternative,
"Press a key or mouse (5)"
);
// Only the clicked box waits.
assert_eq!(key_row(&state, "square").alternative, "J");
press_key(&mut state, char_key("k"), Location::Standard);
let row = key_row(&state, "square");
assert_eq!(
(
row.input.as_str(),
row.alternative.as_str(),
row.second_alternative.as_str()
),
("Mouse Left", "J", "K")
);
assert!(row.has_second_alternative && !key_row(&state, "cross").has_second_alternative);
assert_eq!(state.controls.key_alt2["square"], "K");
// A key of another box of the same button swaps with it.
capture_second_alternative(&mut state, "square");
press_key(&mut state, char_key("j"), Location::Standard);
assert_eq!(state.controls.key_alt["square"], "K");
assert_eq!(state.controls.key_alt2["square"], "J");
// Its × drops it and leaves the other box alone; the row arrow and the reset drop both.
controls_update(
&mut state,
ControlsMessage::Clear("square".into(), Slot::SecondAlternative),
);
assert!(state.controls.key_alt2.is_empty());
assert_eq!(state.controls.key_alt["square"], "K");
capture_second_alternative(&mut state, "square");
press_key(&mut state, char_key("m"), Location::Standard);
controls_update(&mut state, ControlsMessage::ResetKey("square".into()));
assert!(state.controls.key_alt.is_empty() && state.controls.key_alt2.is_empty());
capture_second_alternative(&mut state, "cross");
press_key(&mut state, char_key("m"), Location::Standard);
controls_update(&mut state, ControlsMessage::ResetTable(Kind::Key));
assert!(state.controls.key_alt2.is_empty());
}
#[test]
fn an_input_for_the_other_box_of_the_row_binds_nothing() {
let dir = tempfile::tempdir().unwrap();
@@ -1660,6 +1718,46 @@ fn alternatives_are_saved_only_when_there_are_any() {
assert!(controls_state(dir.path()).controls.key_alt.is_empty());
}
#[test]
fn second_alternatives_are_saved_only_when_there_are_any_and_come_back() {
let dir = tempfile::tempdir().unwrap();
std::fs::write(
dir.path().join("settings.json"),
r#"{"controls_layout": 4, "controls": {"key": {"cross": "H"}, "key_alt": {"cross": "K"}}}"#,
)
.unwrap();
// Settings with one alternative load without a second and save the same controls.
let mut state = controls_state(dir.path());
assert!(state.controls.key_alt2.is_empty());
let saved = spartanport_launcher::ui::persist::gather(&state).app["controls"].clone();
assert_eq!(
saved,
serde_json::json!({"key": {"cross": "H"}, "pad": {}, "key_alt": {"cross": "K"}})
);
capture_second_alternative(&mut state, "cross");
controls_update(
&mut state,
ControlsMessage::Captured("cross".into(), Slot::SecondAlternative, "Keypad 2".into()),
);
let saved = spartanport_launcher::ui::persist::gather(&state).app["controls"].clone();
assert_eq!(
saved,
serde_json::json!({
"key": {"cross": "H"},
"pad": {},
"key_alt": {"cross": "K"},
"key_alt2": {"cross": "Keypad 2"}
})
);
let file = serde_json::json!({"controls_layout": 4, "controls": saved});
std::fs::write(dir.path().join("settings.json"), file.to_string()).unwrap();
let reopened = controls_state(dir.path());
assert_eq!(reopened.controls.key_alt2, state.controls.key_alt2);
let wrong = serde_json::json!({"controls_layout": 4, "controls": {"key_alt2": ["J"]}});
std::fs::write(dir.path().join("settings.json"), wrong.to_string()).unwrap();
assert!(controls_state(dir.path()).controls.key_alt2.is_empty());
}
#[test]
fn the_icon_keys_choice_shows_with_the_styles_that_draw_the_keyboard() {
let dir = tempfile::tempdir().unwrap();
@@ -1683,6 +1781,74 @@ fn the_icon_keys_choice_shows_with_the_styles_that_draw_the_keyboard() {
Message::Set("glyph_keys".into(), serde_json::json!("alternative")),
);
assert_eq!(state.store.text("glyph_keys"), "alternative");
let _ = app::update(
&mut state,
Message::Set("glyph_keys".into(), serde_json::json!("lyre")),
);
assert_eq!(state.store.text("glyph_keys"), "lyre");
}
#[test]
fn the_key_colors_choice_shows_with_the_keyboard_and_the_custom_boxes_with_custom() {
let dir = tempfile::tempdir().unwrap();
let mut state = controls_state(dir.path());
assert_eq!(state.store.text("glyph_key_colors"), "gray");
for (style, shown) in [
("off", false),
("xbox", false),
("nintendo", false),
("auto", true),
("keyboard", true),
] {
let _ = app::update(
&mut state,
Message::Set("glyph_style".into(), serde_json::json!(style)),
);
assert_eq!(tall_has(&state, "Key colors"), shown, "{style}");
}
// The four boxes appear with Custom only.
assert!(!tall_has(&state, "Face button colors"));
let _ = app::update(
&mut state,
Message::Set("glyph_key_colors".into(), serde_json::json!("playstation")),
);
assert!(!tall_has(&state, "Face button colors"));
let _ = app::update(
&mut state,
Message::Set("glyph_key_colors".into(), serde_json::json!("custom")),
);
assert!(tall_has(&state, "Face button colors"));
let _ = app::update(
&mut state,
Message::Set("glyph_color_cross".into(), serde_json::json!("blue")),
);
assert_eq!(state.store.text("glyph_color_cross"), "blue");
}
#[test]
fn the_key_colors_choice_offers_gray_playstation_and_custom() {
let styles: Vec<&str> = spartanport_launcher::core::options::KEY_COLOR_STYLES
.iter()
.map(|choice| choice.value)
.collect();
assert_eq!(styles, ["gray", "playstation", "custom"]);
let palette: Vec<&str> = spartanport_launcher::core::options::KEY_COLORS
.iter()
.map(|choice| choice.value)
.collect();
assert_eq!(
palette,
["gray", "white", "blue", "orange", "green", "red", "pink", "yellow", "purple"]
);
}
#[test]
fn the_icon_keys_choice_offers_the_main_key_the_alternative_and_the_lyre_alone() {
let values: Vec<&str> = spartanport_launcher::core::options::GLYPH_KEYS
.iter()
.map(|choice| choice.value)
.collect();
assert_eq!(values, ["main", "alternative", "lyre"]);
}
#[test]
+36
View File
@@ -733,6 +733,42 @@ fn save_failed_shown(state: &State) -> bool {
.is_some_and(|modal| matches!(modal.kind, ModalKind::Error) && modal.text == SAVE_FAILED)
}
#[test]
fn the_key_colors_are_saved_and_come_back() {
let dir = tempfile::tempdir().unwrap();
let mut state = state_in(dir.path());
for (name, key, value) in [
("style", "glyph_key_colors", "custom"),
("cross", "glyph_color_cross", "blue"),
("circle", "glyph_color_circle", "orange"),
] {
change_and_save(&mut state, name, Message::Set(key.into(), json!(value)));
}
let reopened = state_in(dir.path());
assert_eq!(reopened.store.text("glyph_key_colors"), "custom");
assert_eq!(reopened.store.text("glyph_color_cross"), "blue");
assert_eq!(reopened.store.text("glyph_color_circle"), "orange");
// A button left alone stays gray, and a file from before the colours loads as gray.
assert_eq!(reopened.store.text("glyph_color_square"), "gray");
assert!(!dirty(&reopened));
let old = tempfile::tempdir().unwrap();
assert_eq!(state_in(old.path()).store.text("glyph_key_colors"), "gray");
}
#[test]
fn the_lyre_only_icon_keys_choice_is_saved_and_comes_back() {
let dir = tempfile::tempdir().unwrap();
let mut state = state_in(dir.path());
change_and_save(
&mut state,
"glyph_keys",
Message::Set("glyph_keys".into(), json!("lyre")),
);
let reopened = state_in(dir.path());
assert_eq!(reopened.store.text("glyph_keys"), "lyre");
assert!(!dirty(&reopened));
}
#[test]
fn closing_the_window_saves_first() {
let dir = tempfile::tempdir().unwrap();
+7 -2
View File
@@ -319,8 +319,12 @@ static void drop_sources(ButtonMap *map, uint32_t ps, bool keyboard) {
* PS4 button keeps its default. */
static int apply_button_map(ButtonMap *map, const char *spec, bool keyboard) {
if (!spec || !*spec) return 0;
char copy[2048];
snprintf(copy,sizeof(copy),"%s",spec);
/* The whole value, whatever its length: a copy cut short would drop the last entries unseen. */
char *copy=SDL_strdup(spec);
if (!copy) {
printf("Runtime: pad map: out of memory, no entry applied\n");
return 0;
}
int used=0;
char *entry=copy;
while (entry) {
@@ -364,6 +368,7 @@ static int apply_button_map(ButtonMap *map, const char *spec, bool keyboard) {
map->entries[map->count++]=(ButtonSource){sources[i],ps};
++used;
}
SDL_free(copy);
return used;
}
+15
View File
@@ -590,6 +590,21 @@ int main(void) {
key_map=original_keys;
spartanport_qte.block=0;
/* A value longer than 2048 bytes is read whole: the last entry still applies. */
static char long_map[4096];
static const char *const long_names="Mouse Forward | Mouse Middle | Left Ctrl | Right Ctrl";
static const char *const last_names="Keypad 7 | Keypad 9 | Keypad 1 | Right Ctrl";
const size_t all_count=sizeof(all_buttons)/sizeof(*all_buttons);
long_map[0]=0;
for (size_t i=0;i<all_count;++i)
snprintf(long_map+strlen(long_map),sizeof(long_map)-strlen(long_map),"%s %s = %s ",
i ? "," : "",all_buttons[i],i+1==all_count ? last_names : long_names);
assert(strlen(long_map)>2048 && strlen(long_map)<sizeof(long_map));
ButtonMap long_keys=key_map;
assert(apply_button_map(&long_keys,long_map,true)==(int)all_count);
assert(pressed_by(&long_keys,SDL_SCANCODE_KP_7)==LOOK_RIGHT && pressed_by(&long_keys,SDL_SCANCODE_KP_1)==LOOK_RIGHT);
assert((pressed_by(&long_keys,SDL_SCANCODE_RCTRL) & BTN_CROSS)!=0 && (pressed_by(&long_keys,SDL_SCANCODE_RCTRL) & LOOK_RIGHT)!=0);
assert((pressed_by(&long_keys,MOUSE_FORWARD) & BTN_CROSS)!=0 && (pressed_by(&long_keys,MOUSE_FORWARD) & LOOK_RIGHT)==0);
/* Either source presses the button in a read of the keyboard, and the main one still works. */
const ButtonMap before_alternatives=key_map;
key_map=alternatives;