@the-inclusionist/engine 7.0.1 → 8.0.0-rc.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (116) hide show
  1. package/README.md +5 -3
  2. package/app/css/style.css +22 -5
  3. package/app/public/vendor/fonts/fondamento-400-ext.woff2 +0 -0
  4. package/app/public/vendor/fonts/fondamento-400.woff2 +0 -0
  5. package/app/public/vendor/fonts/opendyslexic-400.woff2 +0 -0
  6. package/app/public/vendor/fonts/pressstart-400.woff2 +0 -0
  7. package/app/public/vendor/fonts.css +43 -2
  8. package/dist-pkg/boot/create-game.d.ts +76 -2
  9. package/dist-pkg/boot/create-game.js +271 -13
  10. package/dist-pkg/core/constants.d.ts +0 -28
  11. package/dist-pkg/core/constants.js +34 -17
  12. package/dist-pkg/core/contract.d.ts +39 -0
  13. package/dist-pkg/core/contract.js +36 -0
  14. package/dist-pkg/core/entity.d.ts +47 -4
  15. package/dist-pkg/core/layers.d.ts +18 -0
  16. package/dist-pkg/core/layers.js +18 -0
  17. package/dist-pkg/core/rng.js +14 -4
  18. package/dist-pkg/core/route.d.ts +42 -0
  19. package/dist-pkg/core/route.js +158 -0
  20. package/dist-pkg/core/state.d.ts +10 -1
  21. package/dist-pkg/core/state.js +13 -0
  22. package/dist-pkg/educational/adaptive-engine.d.ts +65 -0
  23. package/dist-pkg/educational/adaptive-engine.js +117 -0
  24. package/dist-pkg/educational/segment-bar.d.ts +96 -0
  25. package/dist-pkg/educational/segment-bar.js +89 -0
  26. package/dist-pkg/i18n/en.js +33 -0
  27. package/dist-pkg/i18n/es.js +33 -0
  28. package/dist-pkg/i18n/pt.js +46 -0
  29. package/dist-pkg/input/default-bindings.d.ts +40 -0
  30. package/dist-pkg/input/default-bindings.js +143 -9
  31. package/dist-pkg/input/gamepad.d.ts +18 -11
  32. package/dist-pkg/input/gamepad.js +75 -9
  33. package/dist-pkg/input/keyboard-runtime.d.ts +6 -8
  34. package/dist-pkg/input/keyboard-runtime.js +24 -5
  35. package/dist-pkg/input/keyboard.d.ts +10 -0
  36. package/dist-pkg/input/keyboard.js +41 -11
  37. package/dist-pkg/input/keydown.d.ts +27 -2
  38. package/dist-pkg/input/keydown.js +20 -6
  39. package/dist-pkg/input/latch-scope.d.ts +70 -0
  40. package/dist-pkg/input/latch-scope.js +110 -0
  41. package/dist-pkg/input/latch-store.d.ts +45 -0
  42. package/dist-pkg/input/latch-store.js +74 -0
  43. package/dist-pkg/input/origem-sintetica.d.ts +44 -0
  44. package/dist-pkg/input/origem-sintetica.js +60 -0
  45. package/dist-pkg/input/pointer.d.ts +61 -0
  46. package/dist-pkg/input/pointer.js +71 -0
  47. package/dist-pkg/input/state.d.ts +86 -1
  48. package/dist-pkg/input/state.js +128 -1
  49. package/dist-pkg/input/touch-bindings.d.ts +11 -2
  50. package/dist-pkg/input/touch-bindings.js +9 -3
  51. package/dist-pkg/input/touch.js +9 -1
  52. package/dist-pkg/input/transporte-em-uso.d.ts +101 -0
  53. package/dist-pkg/input/transporte-em-uso.js +130 -0
  54. package/dist-pkg/input/transports.d.ts +88 -2
  55. package/dist-pkg/input/transports.js +60 -5
  56. package/dist-pkg/input/vocabulary-migration.d.ts +18 -7
  57. package/dist-pkg/platform/audio-earcons.d.ts +29 -2
  58. package/dist-pkg/platform/audio-earcons.js +10 -0
  59. package/dist-pkg/platform/audio-nav.d.ts +1 -1
  60. package/dist-pkg/platform/audio-sonar.d.ts +149 -9
  61. package/dist-pkg/platform/audio-sonar.js +232 -21
  62. package/dist-pkg/platform/guide-intensity.d.ts +40 -0
  63. package/dist-pkg/platform/guide-intensity.js +73 -0
  64. package/dist-pkg/platform/storage.d.ts +30 -0
  65. package/dist-pkg/platform/storage.js +35 -0
  66. package/dist-pkg/platform/tts.js +16 -1
  67. package/dist-pkg/platform/voice-plan.d.ts +90 -0
  68. package/dist-pkg/platform/voice-plan.js +137 -0
  69. package/dist-pkg/render/draw.d.ts +1 -1
  70. package/dist-pkg/render/draw.js +15 -5
  71. package/dist-pkg/render/viz-axes.d.ts +17 -0
  72. package/dist-pkg/render/viz-axes.js +19 -0
  73. package/dist-pkg/render/viz-setters.d.ts +34 -2
  74. package/dist-pkg/render/viz-setters.js +189 -33
  75. package/dist-pkg/render/wheelchair-sprites.d.ts +11 -3
  76. package/dist-pkg/render/wheelchair-sprites.js +10 -4
  77. package/dist-pkg/ui/dom.js +20 -2
  78. package/dist-pkg/ui/fonts.d.ts +47 -1
  79. package/dist-pkg/ui/fonts.js +47 -9
  80. package/dist-pkg/ui/latch-refusal.d.ts +32 -0
  81. package/dist-pkg/ui/latch-refusal.js +60 -0
  82. package/dist-pkg/ui/layout.d.ts +32 -0
  83. package/dist-pkg/ui/layout.js +64 -1
  84. package/dist-pkg/ui/motion-scene.d.ts +41 -0
  85. package/dist-pkg/ui/motion-scene.js +76 -0
  86. package/dist-pkg/ui/panel-shell.d.ts +53 -0
  87. package/dist-pkg/ui/panel-shell.js +103 -0
  88. package/dist-pkg/ui/pause-icons.d.ts +134 -20
  89. package/dist-pkg/ui/pause-icons.js +307 -45
  90. package/dist-pkg/ui/reach-notice.js +8 -0
  91. package/dist-pkg/ui/settings-audio.d.ts +14 -1
  92. package/dist-pkg/ui/settings-audio.js +43 -3
  93. package/dist-pkg/ui/settings-controls.d.ts +66 -6
  94. package/dist-pkg/ui/settings-controls.js +179 -17
  95. package/dist-pkg/ui/settings-empathy.d.ts +15 -0
  96. package/dist-pkg/ui/settings-empathy.js +2 -0
  97. package/dist-pkg/ui/settings-motion.d.ts +25 -19
  98. package/dist-pkg/ui/settings-motion.js +32 -19
  99. package/dist-pkg/ui/settings-motor.d.ts +81 -3
  100. package/dist-pkg/ui/settings-motor.js +118 -9
  101. package/dist-pkg/ui/settings-typo.d.ts +32 -2
  102. package/dist-pkg/ui/settings-typo.js +64 -18
  103. package/dist-pkg/ui/settings-visual.d.ts +21 -1
  104. package/dist-pkg/ui/settings-visual.js +48 -4
  105. package/dist-pkg/ui/shell.d.ts +13 -3
  106. package/dist-pkg/ui/shell.js +3 -4
  107. package/dist-pkg/ui/simulation-refusal.d.ts +32 -0
  108. package/dist-pkg/ui/simulation-refusal.js +57 -0
  109. package/dist-pkg/ui/visual-axes-panel.d.ts +48 -0
  110. package/dist-pkg/ui/visual-axes-panel.js +95 -0
  111. package/dist-pkg/ui/webcam.js +6 -1
  112. package/docs/CREDITS.md +18 -0
  113. package/docs/LICENSES.md +12 -0
  114. package/package.json +24 -4
  115. package/app/public/vendor/fonts/greatvibes-400.woff2 +0 -0
  116. package/app/public/vendor/fonts/ufcook-700.woff2 +0 -0
@@ -1,8 +1,28 @@
1
1
  // SPDX-License-Identifier: AGPL-3.0-or-later
2
- import { type Spot, type Topology, type Speakable } from '../core/contract.js';
2
+ import { type Spot, type Topology, type Speakable, type Role } from '../core/contract.js';
3
3
  export type SinkAC = AudioContext & {
4
4
  setSinkId?: (id: string) => Promise<void>;
5
5
  };
6
+ /**
7
+ * Quantos PASSOS da métrica declarada saturam o estéreo. Além disto, "à direita" é só à direita.
8
+ *
9
+ * ⚠️ ONZE NÃO É NÚMERO NOVO — é o que a plataforma sempre teve, relido na régua certa. O denominador antigo
10
+ * era `LOGICAL_W * 0.55 = 320 × 0,55 = 176` pixels, e a plataforma declara `unit: TILE` = 16: são **exatamente
11
+ * 11 tiles**. Reescrever em passos preserva o que essa criança já ouve, letra por letra, e passa a dizer o
12
+ * mesmo em qualquer gênero — 11 casas num tabuleiro, 11 metros num campo.
13
+ *
14
+ * E encaixa na régua que o resto do módulo já usa: `chaveDeDistancia` corta "muito perto" em 4 passos e
15
+ * "perto" em 9. O estéreo satura logo depois de a coisa passar a ser "longe", que é onde a direção deixa de
16
+ * precisar de mais precisão.
17
+ */
18
+ export declare const PAN_PACES = 11;
19
+ /**
20
+ * Quanto vale UM passo, em unidades do mundo. Zero = este espaço não tem lado.
21
+ *
22
+ * Contínuo: o `unit` declarado. Grade: uma célula, por definição. Lista: nada — `hotspots` é uma ordem, não
23
+ * uma geometria, e inventar-lhe uma largura de estéreo seria apontar para um lado que não existe.
24
+ */
25
+ export declare function passoDoMundo(topo: Topology): number;
6
26
  /**
7
27
  * O jogador visto pela navegação sonora. É a fatia MÍNIMA, e ela encolheu com o corte: `facing` ficou com a
8
28
  * bengala (é ela que bate "à frente") e `wnT` com o nado. Sobrou identidade, posição, visão e o dispositivo.
@@ -11,10 +31,70 @@ export interface SonarPlayer extends PlayerAudioOut {
11
31
  readonly i: number;
12
32
  readonly x: number;
13
33
  readonly y: number;
14
- readonly viz: string;
15
34
  readonly audioSink?: string | null;
16
- guideT?: number;
17
35
  }
36
+ /** O grafo contínuo do guia: oscilador → passa-baixo → ganho → panorâmica → categoria `guide`. */
37
+ export interface GuiaVivo {
38
+ /** O contexto que o construiu — é dele que sai o `currentTime` de cada `setTargetAtTime`. */
39
+ readonly ac: AudioContext;
40
+ readonly osc: OscillatorNode;
41
+ readonly filtro: BiquadFilterNode;
42
+ readonly ganho: GainNode;
43
+ /** `null` em motor sem `createStereoPanner` — o guia fica mono em vez de não existir. */
44
+ readonly panner: StereoPannerNode | null;
45
+ /** Quadros desde a última vez que a ROTA foi recalculada. A rota é cara; o som não pode esperar por ela. */
46
+ desdeARota: number;
47
+ /** O último `passos` medido. É daqui que a intensidade sai a cada quadro. */
48
+ passos: number;
49
+ /** A última panorâmica medida, pelo mesmo motivo: ela vem do alvo, que só se procura com a rota. */
50
+ pan: number;
51
+ }
52
+ /**
53
+ * ⚠️ O TIMBRE TEM DE TER HARMÓNICOS, e isto é requisito técnico, não gosto.
54
+ *
55
+ * O eixo do #84 item 2 é o BRILHO, e brilho é um passa-baixo a abrir e a fechar. **Um passa-baixo sobre uma
56
+ * onda `sine` não faz absolutamente nada**: a senoide não tem nada acima da fundamental para o filtro cortar,
57
+ * e o guia ficaria com um eixo morto e só o volume a trabalhar. A dente-de-serra é a mais rica das quatro
58
+ * ondas do Web Audio — tem TODOS os harmónicos —, e é por isso que ela é a escolha.
59
+ *
60
+ * E ela resolve, de graça, a outra restrição do plano: o sonar e a bengala usam `sine`. Um timbre diferente
61
+ * era exigência de não colidirem no mesmo canal; aqui o timbre diferente É o mecanismo.
62
+ */
63
+ export declare const GUIA_TIPO: OscillatorType;
64
+ /**
65
+ * A fundamental do guia, fixa.
66
+ *
67
+ * ⚠️ FIXA DE PROPÓSITO: a ALTURA já é a linguagem do sonar (`380 + 740 * near`, mais perto = mais agudo). Se o
68
+ * guia também subisse de tom, os dois estariam a dizer a mesma coisa pelo mesmo meio, e quem ouve os dois ao
69
+ * mesmo tempo não teria como separá-los. O guia diz distância por brilho; o sonar, por altura.
70
+ */
71
+ export declare const GUIA_HZ = 220;
72
+ /**
73
+ * Quantos quadros entre dois cálculos de rota.
74
+ *
75
+ * ⚠️ A ROTA É UMA BUSCA EM LARGURA, e o `__incl.update(dt)` conta QUADROS: correr uma BFS a cada quadro num
76
+ * mapa de plataforma gasta o orçamento do quadro inteiro no aparelho-alvo (Positivo/Chromebook, pilar 1). Doze
77
+ * quadros são ~0,2 s a 60 fps — mais depressa do que a criança anda um passo, e o som não espera por eles: a
78
+ * intensidade é reescrita TODO quadro, com o `passos` que a última rota deixou.
79
+ */
80
+ export declare const QUADROS_ENTRE_ROTAS = 12;
81
+ /**
82
+ * O tecto de pontos da rota do guia. Bem abaixo dos 4096 do `core/route`, e o próprio módulo diz porquê:
83
+ * «uma pista por quadro tolera muito menos do que um cálculo ao carregar a fase». Estourar devolve `null`, que
84
+ * é «não sei» — e o guia cai na reta, que ainda soa.
85
+ */
86
+ export declare const ORCAMENTO_DA_ROTA = 1024;
87
+ /**
88
+ * O ganho de base do guia, antes de a intensidade e o volume mestre o multiplicarem.
89
+ *
90
+ * ⚠️ MAIS BAIXO DO QUE O BIPE QUE ELE SUBSTITUI (0,11), e não por engano: um som que **nunca para** é
91
+ * percebido como mais alto do que um transiente do mesmo pico, e cansa por permanência em vez de por
92
+ * intensidade — exactamente o que o modo TEA existe para não fazer.
93
+ */
94
+ export declare const GUIA_VOL = 0.06;
95
+ /** Constante de tempo do `setTargetAtTime`. Curta o bastante para acompanhar o passo, longa o bastante para
96
+ * que a mudança seja um deslize e não um degrau — um degrau a cada rota seria um bipe outra vez. */
97
+ export declare const TAU_DO_GUIA = 0.08;
18
98
  /**
19
99
  * A SAÍDA DEDICADA de um jogador, e este módulo é o DONO dela: é aqui que os dois campos NASCEM
20
100
  * (`new AC()` + `createGain()`, logo abaixo). Pelo ADR-0039 o dono declara onde o campo nasce, e o
@@ -28,14 +108,25 @@ export interface SonarPlayer extends PlayerAudioOut {
28
108
  export interface PlayerAudioOut {
29
109
  _ac?: SinkAC | null;
30
110
  _acOut?: GainNode | null;
111
+ /**
112
+ * O GRAFO VIVO DO GUIA deste jogador, e mora AQUI, ao lado dos outros dois, pelo mesmo motivo que eles
113
+ * (ADR-0039: o dono declara onde o campo nasce; ADR-0033: a entidade da engine declara o que a ENGINE
114
+ * possui, e um oscilador não é dela). `null`/ausente = calado.
115
+ *
116
+ * ⚠️ ELE PRECISA DE SOBREVIVER AOS QUADROS, e é aí que ele difere de tudo o resto neste ficheiro. O sonar e
117
+ * o bipe antigo criavam um oscilador, tocavam-no e deitavam-no fora; uma presença CONTÍNUA (#84 item 2) é um
118
+ * oscilador que FICA, com o filtro e o ganho a mover-se por baixo dele. É por isso que ele tem de estar
119
+ * pendurado no jogador: não há outro lugar onde algo por jogador dure de um quadro para o outro.
120
+ *
121
+ * ⚠️ E É POR ISSO QUE `desligarGuia` EXISTE. Um campo que dura é um campo que vaza: sem alguém a pará-lo,
122
+ * desligar a categoria `guide` no mixer deixaria o som a tocar.
123
+ */
124
+ _guia?: GuiaVivo | null;
31
125
  }
32
126
  export interface PlayerCtxOut {
33
127
  ac: AudioContext;
34
128
  out: GainNode;
35
129
  }
36
- type VizDef = {
37
- kind?: string;
38
- } | undefined;
39
130
  export interface SonarCtx {
40
131
  /** Campo 1: a métrica. Função, porque um jogo com fases troca de topologia entre elas. */
41
132
  topology: () => Topology;
@@ -43,13 +134,57 @@ export interface SonarCtx {
43
134
  targetsOf: (playerIndex: number) => readonly Spot[];
44
135
  /** Campo 3: como se chama o que está ali. `null` = sem nome, e o anúncio cai no genérico. */
45
136
  nameAt: (at: Spot) => Speakable | null;
137
+ /**
138
+ * Campo 2: o que há neste ponto. É o que o `core/route` atravessa (ou não) para achar o caminho.
139
+ *
140
+ * ⚠️ OPCIONAL AQUI, e obrigatório na `GameDeclaration` — a diferença não é descuido. Tornar um campo do
141
+ * `SonarCtx` obrigatório quebra todo o consumidor que já monta este ctx à mão, e o `create-game` (que é
142
+ * quem o monta de verdade) sempre o tem, porque o `validateDeclaration` o exige. Ausente aqui, o guia não
143
+ * fica calado: ele cai na distância em reta, que é o que ele já fazia antes desta mudança.
144
+ */
145
+ roleAt?: (at: Spot) => Role;
46
146
  tonePan: (freq: number, dur: number, cat: string, pan?: number | null, vol?: number, type?: OscillatorType, pc?: PlayerCtxOut | null) => void;
47
147
  srSay: (text: string) => void;
48
148
  narrate: (text: string) => void;
49
- VIZ_BY_KEY: Record<string, VizDef>;
149
+ /**
150
+ * O barramento de uma categoria do mixer. A MESMA forma que `audio-ambient`, `audio-earcons`, `audio-jingles`
151
+ * e `tts` já recebem — o guia deixou de poder usar o `tonePan` porque `tonePan` toca e esquece, e uma
152
+ * presença contínua é um grafo que FICA.
153
+ *
154
+ * Opcional pelo mesmo motivo que o `roleAt`: quem não o injectar cai no `audioOut` e, na falta dele, no
155
+ * `destination`. O que se perde é o cursor da categoria `guide`, não o som.
156
+ */
157
+ catNode?: (cat: string) => AudioNode | null;
158
+ /** O nó mestre, recuo do `catNode`. */
159
+ audioOut?: () => AudioNode | null;
160
+ /**
161
+ * O volume mestre (0..1), que cada síntese multiplica por si — o `_masterGain` do `platform/audio` é o mudo
162
+ * da pausa, não o cursor. Ausente = 1: o guia soa, e ignora o cursor. Por isso o `create-game` injecta-o.
163
+ */
164
+ getVolume?: () => number;
165
+ /**
166
+ * Esta criança tem a visão comprometida — cegueira simulada ou baixa visão?
167
+ *
168
+ * ⚠️ SUBSTITUIU O `VIZ_BY_KEY` EM 2026-09-08 (#104), e a troca encolheu este módulo em vez de o migrar.
169
+ * Ele recebia a TABELA de modos de render e atravessava-a com `pl.viz`; agora recebe a RESPOSTA. Quem a dá
170
+ * é a raiz de composição, que conhece os dois eixos e pode importar de `render/` — o que este ficheiro,
171
+ * estando em `platform/`, não pode sem inverter uma aresta de camada.
172
+ *
173
+ * O `pl` inteiro e não o índice: quem responde já tem o jogador em mão, e passar o índice obrigaria a raiz
174
+ * a procurá-lo outra vez numa lista que ela acabou de percorrer.
175
+ */
176
+ visaoComprometida: (pl: SonarPlayer) => boolean;
50
177
  getModoCego: () => boolean;
51
178
  /** Largura LÓGICA da tela. É do console, não do gênero — por isso ctx, e não campo de contrato. */
52
- LOGICAL_W: number;
179
+ /**
180
+ * @deprecated ⚠️ SEM LEITOR DESDE 2026-09-07 (#121). Era o denominador do pan, e era o defeito: media a
181
+ * largura do estéreo em pixels de ecrã e dividia por ela uma distância de MUNDO. Agora a largura vem da
182
+ * topologia (`PAN_PACES * passoDoMundo`).
183
+ *
184
+ * Ficou OPCIONAL em vez de removido — tornar um campo obrigatório em opcional é compatível para trás, e
185
+ * quem já o injecta continua a compilar. Removê-lo de vez é candidato ao próximo major.
186
+ */
187
+ LOGICAL_W?: number;
53
188
  getPlayers: () => SonarPlayer[];
54
189
  getNumPlayers: () => number;
55
190
  getAudioCtx: () => AudioContext | null;
@@ -65,7 +200,12 @@ export interface AudioSonar {
65
200
  sonar: (pl: SonarPlayer) => void;
66
201
  updateGuide: () => void;
67
202
  readonly sonarCount: number;
203
+ /**
204
+ * ⚠️ MUDOU DE SIGNIFICADO com o #84 item 2, e o número passa a ser muito maior. Contava BIPES (um a cada 48
205
+ * quadros); conta agora QUADROS EM QUE O GUIA SOA, porque não há mais nada discreto para contar — é essa a
206
+ * mudança. Quem o lê para dizer «o guia está a funcionar» continua certo; quem o lesse para dizer «tocou
207
+ * três vezes» estaria a perguntar por uma coisa que deixou de existir.
208
+ */
68
209
  readonly guideCount: number;
69
210
  }
70
211
  export declare function createAudioSonar(ctx: SonarCtx): AudioSonar;
71
- export {};
@@ -24,13 +24,96 @@
24
24
  // · `t('sr.nav.coin')` (o nome do alvo) → `nameAt(spot)`, campo 3. É o que faz o anúncio dizer "moeda",
25
25
  // "pergunta" ou "caixa" sem a engine conhecer nenhum dos três.
26
26
  //
27
- // Sobrou UMA constante de mundo: `LOGICAL_W`, usada só pelo pan. Ela é da TELA e não do gênero — todo jogo
28
- // deste console tem 320 px de largura lógica —, e por isso entra por ctx em vez de virar campo de contrato.
27
+ // ⚠️ E SOBRAVA UMA, QUE ERA A ÚLTIMA E A PIOR: o pan media a largura do estéreo em `LOGICAL_W * 0.55` — a
28
+ // largura da TELA. Escrevi aqui que ela era «da tela e não do gênero, e por isso entra por ctx». A frase
29
+ // estava certa sobre a origem e errada sobre a consequência: o `wx` que ela divide vem da TOPOLOGIA, e
30
+ // dividir uma medida de mundo pela largura de um ecrã só funciona quando as duas usam a mesma régua.
31
+ //
32
+ // Medido ao construir o `game-soccer` (#121): num campo de 90 METROS, um colega dez metros à direita dá
33
+ // `10 / 176 = 0,057` — mono, na prática. O sonar ficaria **certo e inaudível**, que é a mesma classe de
34
+ // defeito que o quiz registou como «certo e inútil». Para a plataforma era verdade por acaso (o mundo dela é
35
+ // medido em pixels) e para `grid`/`hotspots` era vácuo, e é por isso que nunca se viu.
36
+ //
37
+ // A largura do estéreo passa a vir da métrica declarada: `PAN_PACES * passo`, onde o passo é o `unit` do
38
+ // contínuo, uma célula na grade, e nada na lista — que não tem espaço.
29
39
  //
30
40
  // ========================= SEM I/O NO IMPORT =========================
31
41
  // Nada aqui toca `window` fora de `playerCtx`, que é chamada e não importada. Roda no project `node`.
32
42
  import { distance, bearing } from '../core/contract.js';
33
43
  import { t } from '../core/i18n.js';
44
+ // O GUIA (#84 item 2) é feito destes dois, e de mais nada: a ROTA diz quantos passos faltam contornando
45
+ // parede, e a INTENSIDADE traduz esse número em brilho e volume. Nenhum dos dois toca no Web Audio; a fiação
46
+ // — a única parte que toca — é o `updateGuide` lá em baixo, e é por isso que o desenho é conferível em `node`.
47
+ import { rotaAte } from '../core/route.js';
48
+ import { intensidadeDoGuia, CORTE_LONGE, PASSOS_ATE_O_FUNDO } from './guide-intensity.js';
49
+ /**
50
+ * Quantos PASSOS da métrica declarada saturam o estéreo. Além disto, "à direita" é só à direita.
51
+ *
52
+ * ⚠️ ONZE NÃO É NÚMERO NOVO — é o que a plataforma sempre teve, relido na régua certa. O denominador antigo
53
+ * era `LOGICAL_W * 0.55 = 320 × 0,55 = 176` pixels, e a plataforma declara `unit: TILE` = 16: são **exatamente
54
+ * 11 tiles**. Reescrever em passos preserva o que essa criança já ouve, letra por letra, e passa a dizer o
55
+ * mesmo em qualquer gênero — 11 casas num tabuleiro, 11 metros num campo.
56
+ *
57
+ * E encaixa na régua que o resto do módulo já usa: `chaveDeDistancia` corta "muito perto" em 4 passos e
58
+ * "perto" em 9. O estéreo satura logo depois de a coisa passar a ser "longe", que é onde a direção deixa de
59
+ * precisar de mais precisão.
60
+ */
61
+ export const PAN_PACES = 11;
62
+ /**
63
+ * Quanto vale UM passo, em unidades do mundo. Zero = este espaço não tem lado.
64
+ *
65
+ * Contínuo: o `unit` declarado. Grade: uma célula, por definição. Lista: nada — `hotspots` é uma ordem, não
66
+ * uma geometria, e inventar-lhe uma largura de estéreo seria apontar para um lado que não existe.
67
+ */
68
+ export function passoDoMundo(topo) {
69
+ return topo.kind === 'continuous' ? topo.unit : topo.kind === 'grid' ? 1 : 0;
70
+ }
71
+ /**
72
+ * ⚠️ O TIMBRE TEM DE TER HARMÓNICOS, e isto é requisito técnico, não gosto.
73
+ *
74
+ * O eixo do #84 item 2 é o BRILHO, e brilho é um passa-baixo a abrir e a fechar. **Um passa-baixo sobre uma
75
+ * onda `sine` não faz absolutamente nada**: a senoide não tem nada acima da fundamental para o filtro cortar,
76
+ * e o guia ficaria com um eixo morto e só o volume a trabalhar. A dente-de-serra é a mais rica das quatro
77
+ * ondas do Web Audio — tem TODOS os harmónicos —, e é por isso que ela é a escolha.
78
+ *
79
+ * E ela resolve, de graça, a outra restrição do plano: o sonar e a bengala usam `sine`. Um timbre diferente
80
+ * era exigência de não colidirem no mesmo canal; aqui o timbre diferente É o mecanismo.
81
+ */
82
+ export const GUIA_TIPO = 'sawtooth';
83
+ /**
84
+ * A fundamental do guia, fixa.
85
+ *
86
+ * ⚠️ FIXA DE PROPÓSITO: a ALTURA já é a linguagem do sonar (`380 + 740 * near`, mais perto = mais agudo). Se o
87
+ * guia também subisse de tom, os dois estariam a dizer a mesma coisa pelo mesmo meio, e quem ouve os dois ao
88
+ * mesmo tempo não teria como separá-los. O guia diz distância por brilho; o sonar, por altura.
89
+ */
90
+ export const GUIA_HZ = 220;
91
+ /**
92
+ * Quantos quadros entre dois cálculos de rota.
93
+ *
94
+ * ⚠️ A ROTA É UMA BUSCA EM LARGURA, e o `__incl.update(dt)` conta QUADROS: correr uma BFS a cada quadro num
95
+ * mapa de plataforma gasta o orçamento do quadro inteiro no aparelho-alvo (Positivo/Chromebook, pilar 1). Doze
96
+ * quadros são ~0,2 s a 60 fps — mais depressa do que a criança anda um passo, e o som não espera por eles: a
97
+ * intensidade é reescrita TODO quadro, com o `passos` que a última rota deixou.
98
+ */
99
+ export const QUADROS_ENTRE_ROTAS = 12;
100
+ /**
101
+ * O tecto de pontos da rota do guia. Bem abaixo dos 4096 do `core/route`, e o próprio módulo diz porquê:
102
+ * «uma pista por quadro tolera muito menos do que um cálculo ao carregar a fase». Estourar devolve `null`, que
103
+ * é «não sei» — e o guia cai na reta, que ainda soa.
104
+ */
105
+ export const ORCAMENTO_DA_ROTA = 1024;
106
+ /**
107
+ * O ganho de base do guia, antes de a intensidade e o volume mestre o multiplicarem.
108
+ *
109
+ * ⚠️ MAIS BAIXO DO QUE O BIPE QUE ELE SUBSTITUI (0,11), e não por engano: um som que **nunca para** é
110
+ * percebido como mais alto do que um transiente do mesmo pico, e cansa por permanência em vez de por
111
+ * intensidade — exactamente o que o modo TEA existe para não fazer.
112
+ */
113
+ export const GUIA_VOL = 0.06;
114
+ /** Constante de tempo do `setTargetAtTime`. Curta o bastante para acompanhar o passo, longa o bastante para
115
+ * que a mudança seja um deslize e não um degrau — um degrau a cada rota seria um bipe outra vez. */
116
+ export const TAU_DO_GUIA = 0.08;
34
117
  export function createAudioSonar(ctx) {
35
118
  let _sonarCount = 0, _guideCount = 0;
36
119
  /** AudioContext do jogador (para o `setSinkId` no dispositivo dele), ou null → contexto global. */
@@ -57,14 +140,32 @@ export function createAudioSonar(ctx) {
57
140
  }
58
141
  }
59
142
  function panFor(wx, pl) {
60
- return Math.max(-1, Math.min(1, (wx - pl.x) / (ctx.LOGICAL_W * 0.55)));
143
+ const passo = passoDoMundo(ctx.topology());
144
+ // `hotspots` não tem espaço, logo não tem lado. O `bearing` já responde `none` pelo mesmo motivo, e
145
+ // centrar é a única resposta honesta — um pan calculado sobre índices de lista aponta para nada.
146
+ if (!(passo > 0))
147
+ return 0;
148
+ return Math.max(-1, Math.min(1, (wx - pl.x) / (PAN_PACES * passo)));
61
149
  }
62
- /** Visão comprometida? Guarda e guia só existem quando a resposta é sim (ou no modo cego). */
150
+ /**
151
+ * Visão comprometida? Guarda e guia só existem quando a resposta é sim (ou no modo cego).
152
+ *
153
+ * ⚠️ A METADE VISUAL PASSOU A SER INJECTADA (#104), e o módulo ficou MENOR em vez de migrado. Ele
154
+ * consultava `ctx.VIZ_BY_KEY[pl.viz]` — uma tabela de modos de RENDER, atravessada por uma chave de
155
+ * render, dentro de `platform/`. A #104 obrigava a escolher: ou este ficheiro passava a importar
156
+ * `render/viz-axes` (uma aresta ao contrário: medido, `render/` importa de `platform/` em cinco pontos e o
157
+ * inverso em nenhum), ou deixava de saber o que é um modo visual.
158
+ *
159
+ * A segunda é a certa, e é o movimento que o `touch.ts` já nomeia como «o mesmo do achado 10
160
+ * (`isNavigable`): injetar o BOOLEANO, não o estado». O que este módulo precisa de saber é «esta criança
161
+ * precisa de pista sonora», e isso não é uma pergunta sobre tabelas de filtro — é uma pergunta que a raiz
162
+ * de composição responde, porque é ela que conhece os dois eixos.
163
+ *
164
+ * O que FICA aqui é a regra que é mesmo deste módulo: **o modo cego liga as pistas para toda a gente**,
165
+ * independentemente do que a visão diga.
166
+ */
63
167
  function needsAudioCues(pl) {
64
- if (ctx.getModoCego())
65
- return true;
66
- const m = ctx.VIZ_BY_KEY[pl.viz];
67
- return !!(m && (m.kind === 'blind' || m.kind === 'lowvision'));
168
+ return ctx.getModoCego() || ctx.visaoComprometida(pl);
68
169
  }
69
170
  /**
70
171
  * O alvo mais perto deste jogador, na MÉTRICA DECLARADA — e `null` se não houver nenhum.
@@ -145,23 +246,133 @@ export function createAudioSonar(ctx) {
145
246
  ctx.srSay(msg);
146
247
  ctx.narrate(msg);
147
248
  }
148
- /** Beacon automático por quadro: o sonar contínuo de quem não vê a tela. */
249
+ /**
250
+ * QUANTOS PASSOS FALTAM, e a resposta preferida é a que contorna parede.
251
+ *
252
+ * ⚠️ AS DUAS RESPOSTAS JÁ ESTÃO NA MESMA UNIDADE, e é a única razão pela qual esta função é uma linha em vez
253
+ * de uma conversão: `rotaAte().passos` conta passos por definição, e `distance()` também devolve passos em
254
+ * TODA topologia — ela própria divide pela `unit` no ramo contínuo. Dividir aqui outra vez pelo passo do
255
+ * mundo era o erro à espera de ser cometido, e num jogo com `unit = 16` ele poria o guia no brilho máximo
256
+ * para sempre. É o mesmo defeito que a #121 tirou do `panFor`: misturar régua de mundo com régua de ecrã.
257
+ *
258
+ * A rota perde-se de duas maneiras — jogo que não injectou `roleAt`, e orçamento estourado — e as duas caem
259
+ * no mesmo recuo: a RETA. ⚠️ Ela mente atrás de parede (diz «perto» de um alvo que exige dar a volta), e é
260
+ * por isso que é recuo e não escolha. Mas é o que o guia já dizia antes desta mudança, e continuar a dizê-lo
261
+ * é estritamente melhor do que calar — calar afirmaria que não há alvo.
262
+ */
263
+ function passosAteOAlvo(pl, alvo) {
264
+ const roleAt = ctx.roleAt;
265
+ if (roleAt) {
266
+ const rota = rotaAte({ topology: ctx.topology(), roleAt, orcamento: ORCAMENTO_DA_ROTA }, { x: pl.x, y: pl.y }, [alvo.at]);
267
+ if (rota)
268
+ return rota.passos;
269
+ }
270
+ return alvo.d;
271
+ }
272
+ /** Acende o grafo contínuo deste jogador. `null` = não deu (sem contexto, ou motor sem Web Audio). */
273
+ function ligarGuia(pl) {
274
+ const pc = playerCtx(pl);
275
+ const ac = pc ? pc.ac : ctx.getAudioCtx();
276
+ if (!ac)
277
+ return null;
278
+ try {
279
+ const osc = ac.createOscillator(), filtro = ac.createBiquadFilter(), ganho = ac.createGain();
280
+ osc.type = GUIA_TIPO;
281
+ osc.frequency.value = GUIA_HZ;
282
+ filtro.type = 'lowpass';
283
+ filtro.frequency.value = CORTE_LONGE; // nasce no fundo da escala e sobe; nascer aberto seria um susto
284
+ ganho.gain.value = 0; // e nasce calado, para não estalar ao ligar
285
+ let saida = ganho;
286
+ let panner = null;
287
+ if (ac.createStereoPanner) {
288
+ panner = ac.createStereoPanner();
289
+ ganho.connect(panner);
290
+ saida = panner;
291
+ }
292
+ osc.connect(filtro).connect(ganho);
293
+ saida.connect(pc ? pc.out : (ctx.catNode?.('guide') || ctx.audioOut?.() || ac.destination));
294
+ osc.start();
295
+ // `desdeARota` nasce no tecto para que a PRIMEIRA volta já meça a rota, em vez de soar doze quadros
296
+ // com um `passos` inventado.
297
+ return { ac, osc, filtro, ganho, panner, desdeARota: QUADROS_ENTRE_ROTAS, passos: PASSOS_ATE_O_FUNDO, pan: 0 };
298
+ }
299
+ catch (e) {
300
+ return null;
301
+ }
302
+ }
303
+ /**
304
+ * Apaga o grafo — e ele TEM de ser apagado, porque um oscilador que fica é a diferença entre esta forma e a
305
+ * anterior. O bipe morria sozinho ao fim de 0,12 s; este toca até alguém o parar. Sem esta função, desligar
306
+ * a categoria `guide` no mixer deixaria o som a tocar, e trocar de modo visual deixaria um segundo grafo a
307
+ * somar-se ao primeiro.
308
+ *
309
+ * Desce em rampa (não corta) porque um corte seco num oscilador vivo é um clique — um transiente, que é
310
+ * precisamente o que este item existe para tirar do ouvido da criança.
311
+ */
312
+ function desligarGuia(pl) {
313
+ const g = pl._guia;
314
+ if (!g)
315
+ return;
316
+ pl._guia = null;
317
+ try {
318
+ const agora = g.ac.currentTime;
319
+ g.ganho.gain.setTargetAtTime(0, agora, 0.05);
320
+ g.osc.stop(agora + 0.3);
321
+ }
322
+ catch (e) { /* noop */ }
323
+ }
324
+ /**
325
+ * A PRESENÇA CONTÍNUA, um quadro de cada vez (#84 item 2).
326
+ *
327
+ * ⚠️ O QUE SAIU DAQUI FOI UM BIPE: um `triangle` de 0,12 s a cada 48 quadros, para sempre, independente de a
328
+ * criança se mexer ou de algo ter mudado. O veredicto do Dev: «um ping é a pior escolha possível, tenebroso
329
+ * para quem tem TEA». O que entra não dispara nada — o som já está lá, e muda de brilho.
330
+ *
331
+ * ⚠️ E CALAR CONTINUA A SER UMA AFIRMAÇÃO, com um significado só: NÃO HÁ ALVO. É por isso que «sem alvo»
332
+ * apaga o grafo e «longe» não: o piso do `guide-intensity` (`VOL_LONGE`) existe exactamente para que a
333
+ * criança não confunda «está longe» com «não há nada para achar».
334
+ */
149
335
  function updateGuide() {
150
336
  const cat = ctx.getAudioCat();
151
- if (!ctx.getAudioCtx() || !ctx.getSoundOn() || !cat || !cat.guide || !cat.guide.on)
152
- return;
337
+ const ligado = !!ctx.getAudioCtx() && ctx.getSoundOn() && !!cat && !!cat.guide && cat.guide.on;
338
+ const vol = ctx.getVolume ? ctx.getVolume() : 1;
153
339
  for (const pl of ctx.getPlayers()) {
154
- if (!needsAudioCues(pl))
155
- continue;
156
- pl.guideT = (pl.guideT || 0) + 1;
157
- if (pl.guideT < 48)
340
+ if (!ligado || !needsAudioCues(pl)) {
341
+ desligarGuia(pl);
158
342
  continue;
159
- pl.guideT = 0; // pinga ~0,8s
160
- const alvo = alvoMaisProximo(pl);
161
- if (!alvo)
162
- continue;
163
- const pan = panFor(alvo.at.x, pl), near = Math.max(0, 1 - alvo.d / 14);
164
- ctx.tonePan(300 + 380 * near, 0.12, 'guide', pan, 0.11, 'triangle', playerCtx(pl));
343
+ }
344
+ let g = pl._guia;
345
+ if (!g) {
346
+ // ⚠️ A PERGUNTA «HÁ ALVO?» VEM ANTES DE ACENDER, e o gate cobrou-a: com o grafo a nascer primeiro, um
347
+ // jogador sem alvo criava um oscilador, media a rota, não achava nada e apagava-o — SESSENTA VEZES
348
+ // POR SEGUNDO. O bipe não tinha este problema porque não tinha nada que durasse; foi a permanência
349
+ // que o trouxe. `alvoMaisProximo` é um laço sobre `targetsOf`, não a BFS: perguntar por quadro custa
350
+ // zero quando a lista está vazia, que é exactamente o caso em questão.
351
+ if (!alvoMaisProximo(pl))
352
+ continue;
353
+ g = pl._guia = ligarGuia(pl);
354
+ if (!g)
355
+ continue;
356
+ }
357
+ if (++g.desdeARota >= QUADROS_ENTRE_ROTAS) {
358
+ g.desdeARota = 0;
359
+ const alvo = alvoMaisProximo(pl);
360
+ if (!alvo) {
361
+ desligarGuia(pl);
362
+ continue;
363
+ }
364
+ g.passos = passosAteOAlvo(pl, alvo);
365
+ g.pan = panFor(alvo.at.x, pl);
366
+ }
367
+ // TODO quadro, e não só quando a rota é nova: é isto que faz a mudança ser um deslize.
368
+ const i = intensidadeDoGuia(g.passos);
369
+ try {
370
+ const agora = g.ac.currentTime;
371
+ g.filtro.frequency.setTargetAtTime(i.corte, agora, TAU_DO_GUIA);
372
+ g.ganho.gain.setTargetAtTime(GUIA_VOL * i.volume * vol, agora, TAU_DO_GUIA);
373
+ g.panner?.pan.setTargetAtTime(g.pan, agora, TAU_DO_GUIA);
374
+ }
375
+ catch (e) { /* noop */ }
165
376
  _guideCount++;
166
377
  }
167
378
  }
@@ -0,0 +1,40 @@
1
+ // SPDX-License-Identifier: AGPL-3.0-or-later
2
+ /** O que o guia soa, para uma dada distância. */
3
+ export interface Intensidade {
4
+ /** Corte do passa-baixo, em hertz. Grave e abafado longe; aberto e brilhante perto. */
5
+ readonly corte: number;
6
+ /** Fator sobre o volume da categoria `guide`, entre `VOL_LONGE` e 1. NUNCA zero. */
7
+ readonly volume: number;
8
+ }
9
+ /**
10
+ * A partir de quantos passos o guia deixa de escurecer mais.
11
+ *
12
+ * Doze, e o número não é novo: o `chaveDeDistancia` do sonar corta «muito perto» em 4 passos e «perto» em 9,
13
+ * e o `PAN_PACES` satura o estéreo em 11. O guia satura logo depois — a informação fina serve para quem já
14
+ * está a chegar, e mais longe do que isso «longe» basta.
15
+ */
16
+ export declare const PASSOS_ATE_O_FUNDO = 12;
17
+ /** O corte no fundo da escala: abafado, presente, sem ser um som de alarme. */
18
+ export declare const CORTE_LONGE = 320;
19
+ /** O corte no alvo: aberto. Acima disto o timbre passa a sibilar, e sibilar chama atenção como um bipe. */
20
+ export declare const CORTE_PERTO = 3200;
21
+ /**
22
+ * O fator de volume mais baixo.
23
+ *
24
+ * ⚠️ NUNCA ZERO, E É A ASSERÇÃO MAIS IMPORTANTE DESTE FICHEIRO. Se o guia emudecesse ao longe, «longe» ficaria
25
+ * indistinguível de «não há alvo» — e a criança que depende dele concluiria que não há nada para achar,
26
+ * exatamente quando há e está distante. Silêncio é uma afirmação, e aqui seria uma afirmação falsa.
27
+ */
28
+ export declare const VOL_LONGE = 0.55;
29
+ /**
30
+ * A intensidade para `passos` de distância ao longo da rota.
31
+ *
32
+ * ⚠️ A INTERPOLAÇÃO DO CORTE É EXPONENCIAL, e não linear, pelo mesmo motivo do earcon da #124: o ouvido
33
+ * percebe altura e brilho em RAZÃO, não em diferença. Uma rampa linear de 320 a 3200 abriria quase tudo no
34
+ * primeiro terço do caminho e depois pareceria parada — a criança sentiria que chegou quando ainda faltava
35
+ * metade.
36
+ *
37
+ * O volume interpola LINEARMENTE, e a assimetria é deliberada: ele é o eixo secundário, e uma curva também
38
+ * exponencial ali faria os dois acelerarem no mesmo ponto, que é o oposto de ter dois eixos.
39
+ */
40
+ export declare function intensidadeDoGuia(passos: number): Intensidade;
@@ -0,0 +1,73 @@
1
+ // SPDX-License-Identifier: AGPL-3.0-or-later
2
+ // platform/guide-intensity — QUÃO PERTO SOA, sem bipe (#84 item 2).
3
+ //
4
+ // ========================= O QUE ISTO SUBSTITUI =========================
5
+ // O guia tocava um `triangle` de 0,12 s a cada 0,8 s, PARA SEMPRE, sem depender de movimento nem de nada ter
6
+ // mudado. O veredicto do Dev: «um ping é a pior escolha possível, tenebroso para quem tem TEA». Não era a
7
+ // frequência que estava errada — era o bipe. Reduzi-lo a «só andando» deixaria a mesma coisa a doer menos
8
+ // vezes.
9
+ //
10
+ // O que entra é uma presença CONTÍNUA que fica mais intensa conforme a criança se aproxima, ao longo da rota
11
+ // mapeada (`core/route`). Nada dispara; a coisa apenas fica mais presente.
12
+ //
13
+ // ========================= O EIXO É O BRILHO, E A DECISÃO É DO DEV =========================
14
+ // Quatro eixos foram postos na mesa — volume, brilho (corte de filtro), camadas e andamento — e a escolha foi
15
+ // **brilho como principal, com uma parcela pequena de volume como secundário**. As razões, para quem reabrir:
16
+ //
17
+ // · VOLUME SOZINHO colide com o cursor do mixer: a criança que baixou a categoria `guide` perderia o sinal
18
+ // inteiro, e variação de volume é a mais cansativa das quatro.
19
+ // · ANDAMENTO lê-se como PRESSA, que é o oposto do que o modo TEA existe para proteger.
20
+ // · CAMADAS precisa de material composto, e esta engine sintetiza.
21
+ // · BRILHO é contínuo, sai de um `BiquadFilter` que o Web Audio já tem, e não disputa o cursor.
22
+ //
23
+ // ⚠️ E A PARCELA DE VOLUME NÃO É ENFEITE: para uma criança com perda auditiva o brilho pode cair exatamente na
24
+ // banda que ela não alcança. Dois eixos redundantes significam que nenhum deles sozinho decide.
25
+ //
26
+ // ========================= O QUE ESTE MÓDULO NÃO FAZ =========================
27
+ // Não toca nada. Devolve dois números a partir de UM: quantos passos faltam ao longo da rota. Quem monta o
28
+ // grafo é o `platform/audio-sonar`, e quem calcula a rota é o `core/route` — que já sabe contornar parede, e é
29
+ // isso que torna a intensidade honesta: ela cresce com a distância que a criança REALMENTE vai andar.
30
+ //
31
+ // Módulo-folha: não importa nada.
32
+ /**
33
+ * A partir de quantos passos o guia deixa de escurecer mais.
34
+ *
35
+ * Doze, e o número não é novo: o `chaveDeDistancia` do sonar corta «muito perto» em 4 passos e «perto» em 9,
36
+ * e o `PAN_PACES` satura o estéreo em 11. O guia satura logo depois — a informação fina serve para quem já
37
+ * está a chegar, e mais longe do que isso «longe» basta.
38
+ */
39
+ export const PASSOS_ATE_O_FUNDO = 12;
40
+ /** O corte no fundo da escala: abafado, presente, sem ser um som de alarme. */
41
+ export const CORTE_LONGE = 320;
42
+ /** O corte no alvo: aberto. Acima disto o timbre passa a sibilar, e sibilar chama atenção como um bipe. */
43
+ export const CORTE_PERTO = 3200;
44
+ /**
45
+ * O fator de volume mais baixo.
46
+ *
47
+ * ⚠️ NUNCA ZERO, E É A ASSERÇÃO MAIS IMPORTANTE DESTE FICHEIRO. Se o guia emudecesse ao longe, «longe» ficaria
48
+ * indistinguível de «não há alvo» — e a criança que depende dele concluiria que não há nada para achar,
49
+ * exatamente quando há e está distante. Silêncio é uma afirmação, e aqui seria uma afirmação falsa.
50
+ */
51
+ export const VOL_LONGE = 0.55;
52
+ /**
53
+ * A intensidade para `passos` de distância ao longo da rota.
54
+ *
55
+ * ⚠️ A INTERPOLAÇÃO DO CORTE É EXPONENCIAL, e não linear, pelo mesmo motivo do earcon da #124: o ouvido
56
+ * percebe altura e brilho em RAZÃO, não em diferença. Uma rampa linear de 320 a 3200 abriria quase tudo no
57
+ * primeiro terço do caminho e depois pareceria parada — a criança sentiria que chegou quando ainda faltava
58
+ * metade.
59
+ *
60
+ * O volume interpola LINEARMENTE, e a assimetria é deliberada: ele é o eixo secundário, e uma curva também
61
+ * exponencial ali faria os dois acelerarem no mesmo ponto, que é o oposto de ter dois eixos.
62
+ */
63
+ export function intensidadeDoGuia(passos) {
64
+ if (!Number.isFinite(passos) || passos < 0)
65
+ return { corte: CORTE_LONGE, volume: VOL_LONGE };
66
+ // 0 = em cima do alvo; 1 = no fundo da escala ou além.
67
+ const longe = Math.min(1, passos / PASSOS_ATE_O_FUNDO);
68
+ const perto = 1 - longe;
69
+ return {
70
+ corte: CORTE_LONGE * Math.pow(CORTE_PERTO / CORTE_LONGE, perto),
71
+ volume: VOL_LONGE + (1 - VOL_LONGE) * perto,
72
+ };
73
+ }
@@ -62,6 +62,7 @@ export declare const KEYS: {
62
62
  lang: string;
63
63
  letterCase: string;
64
64
  captions: string;
65
+ tea: string;
65
66
  menuIndex: string;
66
67
  fontKey: string;
67
68
  padDesign: string;
@@ -74,9 +75,38 @@ export declare const KEYS: {
74
75
  padDpadMm: string;
75
76
  reducedMotion: string;
76
77
  toggleMoveLegacy: string;
78
+ /**
79
+ * @deprecated ⚠️ A CHAVE LEGADA do modo visual — UM valor, do tempo em que só cabia um (issue #104).
80
+ *
81
+ * Continua a ser LIDA, e é isso que impede a criança de perder o que já escolheu; continua a ser ESCRITA
82
+ * enquanto os controles ainda escreverem um valor de cada vez, porque um leitor antigo (o cartucho na
83
+ * versão publicada) faz `if (v && VIZ_BY_KEY[v])` e rejeitaria um JSON — escrever a forma nova AQUI
84
+ * apagaria o ajuste dela em silêncio, que é exactamente o defeito que a migração existe para não cometer.
85
+ */
77
86
  vizP: (i: number) => string;
87
+ /**
88
+ * O ESTADO VISUAL de dois eixos, em JSON (ADR-0076, issue #104).
89
+ *
90
+ * ⚠️ CHAVE NOVA AO LADO DA VELHA, e não a mesma chave com conteúdo novo. É o mesmo desenho que o campo
91
+ * `visual` usa ao lado do `viz`: as duas formas coexistem enquanto houver leitores das duas, cada um lê a
92
+ * que entende, e a velha só morre quando não sobrar quem a leia. `migrarVisual` aceita as duas, então o
93
+ * recuo — chave nova ausente, chave velha presente — devolve exactamente o que a criança escolheu.
94
+ */
95
+ visualP: (i: number) => string;
78
96
  sinkP: (i: number) => string;
79
97
  easyP: (i: number) => string;
98
+ /**
99
+ * ⚠️ AS DUAS DE BAIXO SÃO AS CHAVES LEGADAS desde 2026-09-08 (ADR-0104 §C, issue #114). Continuam a ser
100
+ * LIDAS — é o ajuste da criança, e a herança dele é o que a impede de o perder — e não voltam a ser
101
+ * escritas. O que se escreve é a chave COM TRANSPORTE, porque a alternância é do APARELHO e não da pessoa:
102
+ * ligá-la no controle de tela, onde ninguém segura um botão virtual com conforto, ligava-a também no
103
+ * teclado, onde segurar uma tecla é exactamente o que a criança sabe fazer.
104
+ *
105
+ * ⚠️ E A CHAVE NOVA NÃO MORA AQUI, de propósito. Ela é `chaveDaAlternancia`, em `input/latch-scope` — este
106
+ * ficheiro é módulo-FOLHA e `platform/` não importa de `input/`, que é a camada acima. Montá-la aqui
107
+ * exigiria ou uma aresta ao contrário ou uma segunda cópia do nome, e a segunda cópia é exactamente o que
108
+ * o comentário do bloco acima existe para impedir. O dono do nome é quem conhece a regra do transporte.
109
+ */
80
110
  toggleMoveP: (i: number) => string;
81
111
  toggleRunP: (i: number) => string;
82
112
  rmWalkP: (i: number) => string;