Navigation manette : focus spatial, Gamepad API Xbox/PlayStation, répétition, glyphes adaptés, vibration, i18n FR/EN
This commit is contained in:
parent
ae54bbd8e9
commit
e7478d3c64
12 changed files with 421 additions and 0 deletions
14
src/renderer/src/i18n/en.ts
Normal file
14
src/renderer/src/i18n/en.ts
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
import type { fr } from './fr'
|
||||
|
||||
export const en: Record<keyof typeof fr, string> = {
|
||||
'hint.select': 'Select',
|
||||
'hint.back': 'Back',
|
||||
'hint.ok': 'Confirm',
|
||||
'hint.delete': 'Delete',
|
||||
'hint.space': 'Space',
|
||||
'hint.shift': 'Shift',
|
||||
'kb.title': 'Input',
|
||||
'kb.ok': 'OK',
|
||||
'kb.space': 'Space',
|
||||
'kb.delete': 'Delete'
|
||||
}
|
||||
12
src/renderer/src/i18n/fr.ts
Normal file
12
src/renderer/src/i18n/fr.ts
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
export const fr = {
|
||||
'hint.select': 'Choisir',
|
||||
'hint.back': 'Retour',
|
||||
'hint.ok': 'Valider',
|
||||
'hint.delete': 'Effacer',
|
||||
'hint.space': 'Espace',
|
||||
'hint.shift': 'Majuscules',
|
||||
'kb.title': 'Saisie',
|
||||
'kb.ok': 'OK',
|
||||
'kb.space': 'Espace',
|
||||
'kb.delete': 'Effacer'
|
||||
}
|
||||
18
src/renderer/src/i18n/i18n.tsx
Normal file
18
src/renderer/src/i18n/i18n.tsx
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
/* Traductions : FR par défaut, EN. Clés plates, variables {nom}. */
|
||||
import { useCallback } from 'react'
|
||||
import { useSettings } from '../state/settings'
|
||||
import { en } from './en'
|
||||
import { fr } from './fr'
|
||||
|
||||
export type Key = keyof typeof fr
|
||||
export const dicts = { fr, en } satisfies Record<string, Record<Key, string>>
|
||||
|
||||
export function translate(lang: 'fr' | 'en', key: string, vars?: Record<string, string | number>): string {
|
||||
const s = (dicts[lang] as Record<string, string>)[key] ?? (fr as Record<string, string>)[key] ?? key
|
||||
return vars ? s.replace(/\{(\w+)\}/g, (_, k) => String(vars[k] ?? '')) : s
|
||||
}
|
||||
|
||||
export function useT() {
|
||||
const { settings } = useSettings()
|
||||
return useCallback((key: string, vars?: Record<string, string | number>) => translate(settings.language, key, vars), [settings.language])
|
||||
}
|
||||
10
src/renderer/src/nav/glyph-map.ts
Normal file
10
src/renderer/src/nav/glyph-map.ts
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
/* Libellés des boutons selon la manette (pur, testé). */
|
||||
import type { Action, InputKind } from './input'
|
||||
|
||||
const XBOX: Partial<Record<Action, string>> = { accept: 'A', back: 'B', x: 'X', y: 'Y', lb: 'LB', rb: 'RB', lt: 'LT', rt: 'RT', menu: '☰', view: '⧉' }
|
||||
const PS: Partial<Record<Action, string>> = { accept: '✕', back: '○', x: '□', y: '△', lb: 'L1', rb: 'R1', lt: 'L2', rt: 'R2', menu: 'OPTIONS', view: 'SHARE' }
|
||||
const KEY: Partial<Record<Action, string>> = { accept: 'Entrée', back: 'Échap', x: 'X', y: 'Y', lb: 'Q', rb: 'E', lt: 'PgPréc', rt: 'PgSuiv', menu: 'F1', view: 'Tab' }
|
||||
|
||||
export function glyph(a: Action, kind: InputKind): string {
|
||||
return (kind === 'ps' ? PS : kind === 'xbox' ? XBOX : KEY)[a] ?? a
|
||||
}
|
||||
22
src/renderer/src/nav/glyphs.tsx
Normal file
22
src/renderer/src/nav/glyphs.tsx
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
/* Boutons affichés selon la manette détectée (A/B Xbox, Croix/Rond PlayStation, touches clavier). */
|
||||
import { glyph } from './glyph-map'
|
||||
import type { Action } from './input'
|
||||
import { useNavCtx } from './nav'
|
||||
import { useT } from '../i18n/i18n'
|
||||
|
||||
export function Glyph({ action }: { action: Action }) {
|
||||
const { kind } = useNavCtx()
|
||||
return <span className={`glyph glyph--${kind} glyph--${action}`}>{glyph(action, kind)}</span>
|
||||
}
|
||||
|
||||
/* Barre d'aide en bas d'écran : ce que fait chaque bouton ici */
|
||||
export function HintBar({ items }: { items: [Action, string][] }) {
|
||||
const t = useT()
|
||||
return (
|
||||
<footer className="hintbar">
|
||||
{items.map(([a, label]) => (
|
||||
<span key={a} className="hint"><Glyph action={a} /> {t(label)}</span>
|
||||
))}
|
||||
</footer>
|
||||
)
|
||||
}
|
||||
103
src/renderer/src/nav/input.ts
Normal file
103
src/renderer/src/nav/input.ts
Normal file
|
|
@ -0,0 +1,103 @@
|
|||
/* Entrées : manette (Gamepad API), clavier, souris, ramenées à des actions. Répétition quand une direction est tenue. */
|
||||
|
||||
export type Action = 'up' | 'down' | 'left' | 'right' | 'accept' | 'back' | 'x' | 'y' | 'lb' | 'rb' | 'lt' | 'rt' | 'menu' | 'view'
|
||||
export type InputKind = 'xbox' | 'ps' | 'keyboard' | 'mouse'
|
||||
|
||||
const REPEAT_DELAY = 380, REPEAT_RATE = 80 // ms
|
||||
const DIRS: Action[] = ['up', 'down', 'left', 'right']
|
||||
|
||||
/* Boutons de la disposition standard (W3C) */
|
||||
const BUTTONS: [number, Action][] = [
|
||||
[0, 'accept'], [1, 'back'], [2, 'x'], [3, 'y'], [4, 'lb'], [5, 'rb'], [6, 'lt'], [7, 'rt'], [8, 'view'], [9, 'menu'],
|
||||
[12, 'up'], [13, 'down'], [14, 'left'], [15, 'right']
|
||||
]
|
||||
|
||||
const KEYS: Record<string, Action> = {
|
||||
ArrowUp: 'up', ArrowDown: 'down', ArrowLeft: 'left', ArrowRight: 'right',
|
||||
Enter: 'accept', ' ': 'accept', Escape: 'back', Backspace: 'back',
|
||||
KeyX: 'x', KeyY: 'y', KeyQ: 'lb', KeyE: 'rb', PageUp: 'lt', PageDown: 'rt', F1: 'menu', Tab: 'view'
|
||||
}
|
||||
|
||||
export function padKind(id: string): InputKind {
|
||||
return /054c|playstation|dualsense|dualshock|wireless controller/i.test(id) ? 'ps' : 'xbox'
|
||||
}
|
||||
|
||||
/* Gestion de la répétition pour un bouton tenu ; renvoie true si l'action doit partir à cet instant */
|
||||
export class Repeater {
|
||||
private since = new Map<Action, number>()
|
||||
private last = new Map<Action, number>()
|
||||
step(a: Action, down: boolean, now: number): boolean {
|
||||
if (!down) { this.since.delete(a); return false }
|
||||
const s = this.since.get(a)
|
||||
if (s === undefined) { this.since.set(a, now); this.last.set(a, now); return true }
|
||||
if (!DIRS.includes(a)) return false // seules les directions se répètent
|
||||
if (now - s >= REPEAT_DELAY && now - (this.last.get(a) ?? 0) >= REPEAT_RATE) { this.last.set(a, now); return true }
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/* Lecture des états d'une manette en actions enfoncées */
|
||||
export function padActions(gp: Pick<Gamepad, 'buttons' | 'axes'>, dead = 0.5): Set<Action> {
|
||||
const on = new Set<Action>()
|
||||
for (const [i, a] of BUTTONS) if (gp.buttons[i]?.pressed) on.add(a)
|
||||
const [x = 0, y = 0] = gp.axes
|
||||
if (x < -dead) on.add('left')
|
||||
if (x > dead) on.add('right')
|
||||
if (y < -dead) on.add('up')
|
||||
if (y > dead) on.add('down')
|
||||
return on
|
||||
}
|
||||
|
||||
export interface InputHandlers {
|
||||
action: (a: Action) => void
|
||||
kind: (k: InputKind) => void
|
||||
guideHeld?: () => void
|
||||
}
|
||||
|
||||
/* Démarre l'écoute ; renvoie une fonction d'arrêt */
|
||||
export function startInput(h: InputHandlers): () => void {
|
||||
const rep = new Repeater()
|
||||
let raf = 0, kind: InputKind | null = null, guideSince = 0
|
||||
const setKind = (k: InputKind) => { if (k !== kind) { kind = k; h.kind(k) } }
|
||||
|
||||
const loop = () => {
|
||||
raf = requestAnimationFrame(loop)
|
||||
if (!document.hasFocus()) return
|
||||
const now = performance.now()
|
||||
const pads = navigator.getGamepads?.() ?? []
|
||||
const on = new Set<Action>()
|
||||
let guide = false
|
||||
for (const gp of pads) {
|
||||
if (!gp || !gp.connected) continue
|
||||
const acts = padActions(gp)
|
||||
if (acts.size) setKind(padKind(gp.id))
|
||||
acts.forEach((a) => on.add(a))
|
||||
if (gp.buttons[16]?.pressed) guide = true
|
||||
}
|
||||
for (const [, a] of BUTTONS) if (rep.step(a, on.has(a), now)) h.action(a)
|
||||
// bouton Guide / PS tenu 0,8 s (quand Chromium le remonte ; sinon le processus principal s'en charge via XInput)
|
||||
if (guide) { if (!guideSince) guideSince = now; else if (now - guideSince > 800) { guideSince = Infinity; h.guideHeld?.() } } else guideSince = 0
|
||||
}
|
||||
raf = requestAnimationFrame(loop)
|
||||
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
const a = KEYS[e.key] ?? KEYS[e.code]
|
||||
const t = e.target as HTMLElement
|
||||
if (t?.tagName === 'INPUT' && !['ArrowUp', 'ArrowDown', 'Enter', 'Escape', 'Tab'].includes(e.key)) return // saisie au clavier physique
|
||||
if (!a) return
|
||||
e.preventDefault()
|
||||
setKind('keyboard')
|
||||
h.action(a) // le clavier répète déjà tout seul
|
||||
}
|
||||
const onMouse = () => setKind('mouse')
|
||||
addEventListener('keydown', onKey)
|
||||
addEventListener('mousemove', onMouse)
|
||||
return () => { cancelAnimationFrame(raf); removeEventListener('keydown', onKey); removeEventListener('mousemove', onMouse) }
|
||||
}
|
||||
|
||||
export function rumble(): void {
|
||||
for (const gp of navigator.getGamepads?.() ?? []) {
|
||||
const act = (gp as Gamepad & { vibrationActuator?: { playEffect: (t: string, p: object) => Promise<unknown> } })?.vibrationActuator
|
||||
act?.playEffect('dual-rumble', { duration: 35, weakMagnitude: 0.25, strongMagnitude: 0 }).catch(() => {})
|
||||
}
|
||||
}
|
||||
94
src/renderer/src/nav/nav.tsx
Normal file
94
src/renderer/src/nav/nav.tsx
Normal file
|
|
@ -0,0 +1,94 @@
|
|||
/* Gestionnaire de navigation : focus spatial dans la portée active (écran ou fenêtre modale), actions manette/clavier, sons. */
|
||||
import { createContext, useCallback, useContext, useEffect, useRef, useState, type ReactNode } from 'react'
|
||||
import type { SoundName } from '@shared/theme'
|
||||
import { useSettings } from '../state/settings'
|
||||
import { startInput, rumble, type Action, type InputKind } from './input'
|
||||
import { play } from './sounds'
|
||||
import { pickNext, type Dir } from './spatial'
|
||||
|
||||
type Handler = (a: Action) => boolean | void // true = action consommée
|
||||
|
||||
interface Ctx {
|
||||
kind: InputKind
|
||||
focus: (el: Element | null) => void
|
||||
push: (h: { current: Handler }) => () => void
|
||||
sound: (s: SoundName) => void
|
||||
current: () => HTMLElement | null
|
||||
}
|
||||
|
||||
const NavCtx = createContext<Ctx>(null!)
|
||||
const SEL = '[data-nav]:not([disabled])'
|
||||
|
||||
/* Portée active : la dernière fenêtre modale ouverte, sinon tout le document */
|
||||
const scope = (): ParentNode => {
|
||||
const s = document.querySelectorAll('[data-nav-scope]')
|
||||
return s.length ? s[s.length - 1] : document
|
||||
}
|
||||
const visible = (el: Element) => { const r = el.getBoundingClientRect(); return r.width > 0 && r.height > 0 }
|
||||
|
||||
export function NavProvider({ children, onGuide }: { children: ReactNode; onGuide?: () => void }) {
|
||||
const [kind, setKind] = useState<InputKind>('mouse')
|
||||
const cur = useRef<HTMLElement | null>(null)
|
||||
const stack = useRef<{ current: Handler }[]>([])
|
||||
const { settings } = useSettings()
|
||||
const vib = useRef(settings.vibration)
|
||||
vib.current = settings.vibration
|
||||
|
||||
const focus = useCallback((el: Element | null) => {
|
||||
if (!(el instanceof HTMLElement) || el === cur.current) return
|
||||
cur.current?.classList.remove('is-focused')
|
||||
cur.current = el
|
||||
el.classList.add('is-focused')
|
||||
el.focus({ preventScroll: true })
|
||||
el.scrollIntoView({ block: el.dataset.navScroll === 'center' ? 'center' : 'nearest', inline: 'nearest', behavior: 'smooth' })
|
||||
}, [])
|
||||
|
||||
/* Remet le focus sur un élément valide (écran changé, élément supprimé, fenêtre fermée) */
|
||||
const refocus = useCallback(() => {
|
||||
const sc = scope(), c = cur.current
|
||||
if (c && c.isConnected && (sc === document || (sc as Element).contains(c)) && visible(c)) return
|
||||
const auto = sc.querySelector<HTMLElement>('[data-nav-autofocus]' ) ?? [...sc.querySelectorAll<HTMLElement>(SEL)].find(visible) ?? null
|
||||
if (c && !c.isConnected) cur.current = null
|
||||
if (auto) focus(auto)
|
||||
}, [focus])
|
||||
|
||||
const move = useCallback((dir: Dir) => {
|
||||
const sc = scope(), from = cur.current
|
||||
const els = [...sc.querySelectorAll<HTMLElement>(SEL)].filter(visible)
|
||||
if (!from || !from.isConnected) { if (els[0]) focus(els[0]); return }
|
||||
// un groupe (data-nav-group) garde le focus dans sa ligne pour gauche/droite
|
||||
const r = from.getBoundingClientRect(), box = { x: r.left, y: r.top, w: r.width, h: r.height }
|
||||
const next = pickNext(box, els.filter((e) => e !== from).map((el) => { const b = el.getBoundingClientRect(); return { el, box: { x: b.left, y: b.top, w: b.width, h: b.height } } }), dir)
|
||||
if (next) { focus(next.el); play('move'); if (vib.current) rumble() }
|
||||
}, [focus])
|
||||
|
||||
useEffect(() => {
|
||||
const stop = startInput({
|
||||
kind: (k) => { setKind(k); document.documentElement.dataset.input = k === 'mouse' ? 'mouse' : k === 'keyboard' ? 'keyboard' : 'pad' },
|
||||
action: (a) => {
|
||||
for (let i = stack.current.length - 1; i >= 0; i--) if (stack.current[i].current(a)) return
|
||||
if (a === 'up' || a === 'down' || a === 'left' || a === 'right') move(a)
|
||||
else if (a === 'accept' && cur.current) { play('select'); cur.current.click() }
|
||||
},
|
||||
guideHeld: onGuide
|
||||
})
|
||||
const over = (e: PointerEvent) => { const t = (e.target as Element).closest?.(SEL); if (t) focus(t) }
|
||||
const mo = new MutationObserver(() => requestAnimationFrame(refocus))
|
||||
mo.observe(document.body, { childList: true, subtree: true })
|
||||
addEventListener('pointerover', over)
|
||||
return () => { stop(); mo.disconnect(); removeEventListener('pointerover', over) }
|
||||
}, [move, focus, refocus, onGuide])
|
||||
|
||||
const push = useCallback((h: { current: Handler }) => { stack.current.push(h); return () => { stack.current = stack.current.filter((x) => x !== h) } }, [])
|
||||
return <NavCtx.Provider value={{ kind, focus, push, sound: play, current: () => cur.current }}>{children}</NavCtx.Provider>
|
||||
}
|
||||
|
||||
export const useNavCtx = () => useContext(NavCtx)
|
||||
|
||||
/* Intercepte les actions tant que le composant est affiché (le plus récent passe en premier) */
|
||||
export function useNav(handler: Handler, active = true): void {
|
||||
const { push } = useNavCtx()
|
||||
const ref = useRef(handler)
|
||||
ref.current = handler
|
||||
useEffect(() => (active ? push(ref) : undefined), [push, active])
|
||||
}
|
||||
38
src/renderer/src/nav/sounds.ts
Normal file
38
src/renderer/src/nav/sounds.ts
Normal file
|
|
@ -0,0 +1,38 @@
|
|||
/* Sons de navigation du thème (Web Audio : latence faible, plusieurs sons superposables). */
|
||||
import type { SoundName, ThemeManifest } from '@shared/theme'
|
||||
|
||||
let ctx: AudioContext | null = null
|
||||
const buffers = new Map<SoundName, AudioBuffer>()
|
||||
let volume = 0.6, token = 0
|
||||
|
||||
export async function loadSounds(base: string, sounds: ThemeManifest['sounds']): Promise<void> {
|
||||
const my = ++token
|
||||
buffers.clear()
|
||||
ctx ??= new AudioContext()
|
||||
await Promise.all(
|
||||
Object.entries(sounds).map(async ([name, rel]) => {
|
||||
try {
|
||||
const data = await (await fetch(base + rel)).arrayBuffer()
|
||||
const buf = await ctx!.decodeAudioData(data)
|
||||
if (my === token) buffers.set(name as SoundName, buf) // un autre thème a pu être choisi entre-temps
|
||||
} catch {
|
||||
/* son manquant ou illisible : silence */
|
||||
}
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
export function setVolume(v: number): void {
|
||||
volume = v
|
||||
}
|
||||
|
||||
export function play(name: SoundName): void {
|
||||
const buf = buffers.get(name)
|
||||
if (!ctx || !buf || volume <= 0) return
|
||||
if (ctx.state === 'suspended') ctx.resume()
|
||||
const src = ctx.createBufferSource(), gain = ctx.createGain()
|
||||
gain.gain.value = volume
|
||||
src.buffer = buf
|
||||
src.connect(gain).connect(ctx.destination)
|
||||
src.start()
|
||||
}
|
||||
27
src/renderer/src/nav/spatial.ts
Normal file
27
src/renderer/src/nav/spatial.ts
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
/* Focus spatial : choisit l'élément le plus naturel dans une direction. Pur (rectangles en entrée) pour être testé. */
|
||||
|
||||
export type Dir = 'up' | 'down' | 'left' | 'right'
|
||||
export interface Box { x: number; y: number; w: number; h: number }
|
||||
|
||||
/* Distance « ressentie » : on avance dans la direction, en pénalisant fort le décalage latéral.
|
||||
Un candidat qui chevauche l'axe (même ligne / même colonne) passe devant un candidat en diagonale. */
|
||||
export function pickNext<T extends { box: Box }>(from: Box, items: T[], dir: Dir): T | null {
|
||||
const horiz = dir === 'left' || dir === 'right', sign = dir === 'right' || dir === 'down' ? 1 : -1
|
||||
let best: T | null = null, bestScore = Infinity
|
||||
for (const it of items) {
|
||||
const b = it.box
|
||||
if (b === from) continue
|
||||
// avance : bord de départ -> bord d'arrivée
|
||||
const gap = horiz ? (sign > 0 ? b.x - (from.x + from.w) : from.x - (b.x + b.w)) : sign > 0 ? b.y - (from.y + from.h) : from.y - (b.y + b.h)
|
||||
const centerAdvance = horiz ? (b.x + b.w / 2 - (from.x + from.w / 2)) * sign : (b.y + b.h / 2 - (from.y + from.h / 2)) * sign
|
||||
if (centerAdvance <= 1 || gap < -Math.min(horiz ? from.w : from.h, horiz ? b.w : b.h) / 2) continue
|
||||
// décalage latéral : 0 si les projections se chevauchent
|
||||
const [a0, a1, b0, b1] = horiz ? [from.y, from.y + from.h, b.y, b.y + b.h] : [from.x, from.x + from.w, b.x, b.x + b.w]
|
||||
const overlap = Math.min(a1, b1) - Math.max(a0, b0)
|
||||
const lateral = overlap > 0 ? 0 : Math.max(b0 - a1, a0 - b1)
|
||||
const lateralCenter = Math.abs(horiz ? b.y + b.h / 2 - (from.y + from.h / 2) : b.x + b.w / 2 - (from.x + from.w / 2))
|
||||
const score = Math.max(gap, 0) + lateral * 4 + lateralCenter * 0.1 + (overlap > 0 ? 0 : 1e4)
|
||||
if (score < bestScore) { bestScore = score; best = it }
|
||||
}
|
||||
return best
|
||||
}
|
||||
13
tests/i18n.test.ts
Normal file
13
tests/i18n.test.ts
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
import { describe, expect, it } from 'vitest'
|
||||
import { en } from '../src/renderer/src/i18n/en'
|
||||
import { fr } from '../src/renderer/src/i18n/fr'
|
||||
|
||||
describe('traductions', () => {
|
||||
it('même clés et mêmes variables en FR et EN', () => {
|
||||
expect(Object.keys(en).sort()).toEqual(Object.keys(fr).sort())
|
||||
for (const [k, v] of Object.entries(fr)) {
|
||||
const vars = (s: string) => (s.match(/\{\w+\}/g) ?? []).sort()
|
||||
expect(vars(en[k as keyof typeof en]), k).toEqual(vars(v))
|
||||
}
|
||||
})
|
||||
})
|
||||
38
tests/input.test.ts
Normal file
38
tests/input.test.ts
Normal file
|
|
@ -0,0 +1,38 @@
|
|||
import { describe, expect, it } from 'vitest'
|
||||
import { padActions, padKind, Repeater } from '../src/renderer/src/nav/input'
|
||||
import { glyph } from '../src/renderer/src/nav/glyph-map'
|
||||
|
||||
const pad = (pressed: number[], axes = [0, 0]) => ({ buttons: Array.from({ length: 17 }, (_, i) => ({ pressed: pressed.includes(i), touched: false, value: 0 })), axes })
|
||||
|
||||
describe('entrées manette', () => {
|
||||
it('traduit boutons et stick', () => {
|
||||
expect([...padActions(pad([0, 13]))].sort()).toEqual(['accept', 'down'])
|
||||
expect([...padActions(pad([], [-0.9, 0.1]))]).toEqual(['left'])
|
||||
expect(padActions(pad([], [0.3, -0.3])).size).toBe(0) // zone morte
|
||||
})
|
||||
it('reconnaît les manettes PlayStation', () => {
|
||||
expect(padKind('054c-0ce6-Wireless Controller')).toBe('ps')
|
||||
expect(padKind('DualSense Wireless Controller (STANDARD GAMEPAD Vendor: 054c Product: 0ce6)')).toBe('ps')
|
||||
expect(padKind('Xbox 360 Controller (XInput STANDARD GAMEPAD)')).toBe('xbox')
|
||||
})
|
||||
it('affiche le bon bouton', () => {
|
||||
expect(glyph('accept', 'xbox')).toBe('A')
|
||||
expect(glyph('accept', 'ps')).toBe('✕')
|
||||
expect(glyph('back', 'ps')).toBe('○')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Repeater', () => {
|
||||
it('répète une direction tenue, pas un bouton', () => {
|
||||
const r = new Repeater()
|
||||
expect(r.step('down', true, 0)).toBe(true)
|
||||
expect(r.step('down', true, 200)).toBe(false)
|
||||
expect(r.step('down', true, 400)).toBe(true)
|
||||
expect(r.step('down', true, 430)).toBe(false)
|
||||
expect(r.step('down', true, 490)).toBe(true)
|
||||
expect(r.step('accept', true, 0)).toBe(true)
|
||||
expect(r.step('accept', true, 1000)).toBe(false)
|
||||
r.step('down', false, 500)
|
||||
expect(r.step('down', true, 510)).toBe(true)
|
||||
})
|
||||
})
|
||||
32
tests/spatial.test.ts
Normal file
32
tests/spatial.test.ts
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
import { describe, expect, it } from 'vitest'
|
||||
import { pickNext, type Box } from '../src/renderer/src/nav/spatial'
|
||||
|
||||
/* grille 3x3 de cartes 100x150, espacées de 20 */
|
||||
const grid = Array.from({ length: 9 }, (_, i) => ({ id: i, box: { x: (i % 3) * 120, y: Math.floor(i / 3) * 170, w: 100, h: 150 } as Box }))
|
||||
const at = (i: number) => grid[i].box
|
||||
const next = (i: number, d: 'up' | 'down' | 'left' | 'right', items = grid) => pickNext(at(i), items.filter((g) => g.id !== i), d)?.id ?? null
|
||||
|
||||
describe('pickNext', () => {
|
||||
it('se déplace dans une grille', () => {
|
||||
expect(next(4, 'right')).toBe(5)
|
||||
expect(next(4, 'left')).toBe(3)
|
||||
expect(next(4, 'up')).toBe(1)
|
||||
expect(next(4, 'down')).toBe(7)
|
||||
})
|
||||
it('ne sort pas de la grille', () => {
|
||||
expect(next(2, 'right')).toBeNull()
|
||||
expect(next(0, 'up')).toBeNull()
|
||||
})
|
||||
it('préfère le même axe à la diagonale plus proche', () => {
|
||||
const items = [{ id: 'loin', box: { x: 600, y: 0, w: 100, h: 50 } }, { id: 'diag', box: { x: 150, y: 80, w: 100, h: 50 } }]
|
||||
expect(pickNext({ x: 0, y: 0, w: 100, h: 50 }, items, 'right')?.id).toBe('loin')
|
||||
})
|
||||
it('descend vers un bouton large sous la grille', () => {
|
||||
const items = [...grid, { id: 99, box: { x: 0, y: 600, w: 400, h: 60 } }]
|
||||
expect(next(8, 'down', items)).toBe(99)
|
||||
})
|
||||
it('remonte de la barre vers la carte la plus alignée', () => {
|
||||
const items = [...grid, { id: 99, box: { x: 240, y: 600, w: 100, h: 60 } }]
|
||||
expect(pickNext(items[9].box, grid, 'up')?.id).toBe(8)
|
||||
})
|
||||
})
|
||||
Loading…
Add table
Add a link
Reference in a new issue