diff --git a/src/celemod-ui/locales/de-DE.json b/src/celemod-ui/locales/de-DE.json index 23e25b3..c7caaac 100644 --- a/src/celemod-ui/locales/de-DE.json +++ b/src/celemod-ui/locales/de-DE.json @@ -424,7 +424,7 @@ "搜索功能或 Mod…": "Search actions or mods…", "Celeste 正在运行,目前只能查看按键。退出游戏后即可编辑。": "Celeste is running. Bindings are read-only until the game exits.", "正在读取按键配置…": "Loading bindings…", - "{count} 个功能使用此按键": "Used by {count} actions", + "{count} 个功能使用此按键": "Von {count} Aktionen verwendet", "没有发现相同按键冲突": "No identical binding conflicts found", "没有符合条件的按键": "No bindings match the filters", "点击重新绑定;使用右侧按钮移除": "Click to rebind; use the button on the right to remove", @@ -441,6 +441,32 @@ "显示未启用的 Mod": "Show disabled mods", "隐藏未启用的 Mod": "Hide disabled mods", "清除绑定": "Clear binding", + "键盘占用": "Tastaturbelegung", + "Windows 键盘": "Windows-Tastatur", + "macOS 键盘": "macOS-Tastatur", + "Linux 键盘": "Linux-Tastatur", + "{used}/{total} 个键已使用": "{used}/{total} Tasten verwendet", + "占用状态": "Belegungsstatus", + "未使用": "Nicht verwendet", + "单一占用": "Einmal belegt", + "多处占用": "Mehrfach belegt", + "点击按键查看占用情况,再次点击取消筛选": "Taste anklicken, um die Belegung anzuzeigen und zu filtern; erneut klicken, um den Filter aufzuheben", + "没有功能使用此按键": "Keine Aktion verwendet diese Taste", + "其它已绑定按键": "Weitere belegte Tasten", + "清除按键筛选": "Tastenfilter löschen", + "手柄占用": "Controller-Belegung", + "Xbox 布局": "Xbox-Layout", + "PlayStation 布局": "PlayStation-Layout", + "Switch 布局": "Switch-Layout", + "已连接": "Verbunden", + "未检测到手柄": "Kein Controller erkannt", + "{used}/{total} 个按钮已使用": "{used}/{total} Eingaben verwendet", + "点击手柄按键查看占用情况,再次点击取消筛选": "Controller-Eingabe anklicken, um die Belegung anzuzeigen und zu filtern; erneut klicken, um den Filter aufzuheben", + "其它已绑定按钮": "Weitere belegte Controller-Eingaben", + "清除手柄筛选": "Controllerfilter löschen", + "手柄布局": "Controller-Layout", + "自动": "Automatisch", + "跟随已连接手柄": "Dem verbundenen Controller folgen", "字体缩放": "Schriftskalierung", "同步放大界面文字和控件": "Text und Steuerelemente der Oberfläche gemeinsam skalieren", "页面切换动画": "Seitenübergangsanimationen", diff --git a/src/celemod-ui/locales/en-US.json b/src/celemod-ui/locales/en-US.json index 931b150..87558d9 100644 --- a/src/celemod-ui/locales/en-US.json +++ b/src/celemod-ui/locales/en-US.json @@ -436,6 +436,33 @@ "显示未启用的 Mod": "Show disabled mods", "隐藏未启用的 Mod": "Hide disabled mods", "清除绑定": "Clear binding", + "键盘占用": "Keyboard usage", + "Windows 键盘": "Windows keyboard", + "macOS 键盘": "macOS keyboard", + "Linux 键盘": "Linux keyboard", + "{used}/{total} 个键已使用": "{used}/{total} keys used", + "占用状态": "Usage status", + "未使用": "Unused", + "单一占用": "Used once", + "多处占用": "Used by multiple actions", + "点击按键查看占用情况,再次点击取消筛选": "Click a key to inspect and filter its usage; click again to clear", + "{count} 个功能使用此按键": "Used by {count} actions", + "没有功能使用此按键": "No actions use this key", + "其它已绑定按键": "Other bound keys", + "清除按键筛选": "Clear key filter", + "手柄占用": "Controller usage", + "Xbox 布局": "Xbox layout", + "PlayStation 布局": "PlayStation layout", + "Switch 布局": "Switch layout", + "已连接": "Connected", + "未检测到手柄": "No controller detected", + "{used}/{total} 个按钮已使用": "{used}/{total} controls used", + "点击手柄按键查看占用情况,再次点击取消筛选": "Click a controller input to inspect and filter its usage; click again to clear", + "其它已绑定按钮": "Other bound controls", + "清除手柄筛选": "Clear controller filter", + "手柄布局": "Controller layout", + "自动": "Auto", + "跟随已连接手柄": "Follow connected controller", "字体缩放": "Font scale", "LPL Collab 2022 / 乐屁乐": "LPL Collab 2022 / 乐屁乐", "中文社区制作的欢乐向梗图合集,历时一年完成": "A collection of humorous meme maps created by the Chinese community, completed over the course of a year.", diff --git a/src/celemod-ui/locales/fr-FR.json b/src/celemod-ui/locales/fr-FR.json index 6cc301a..2a7e083 100644 --- a/src/celemod-ui/locales/fr-FR.json +++ b/src/celemod-ui/locales/fr-FR.json @@ -424,7 +424,7 @@ "搜索功能或 Mod…": "Search actions or mods…", "Celeste 正在运行,目前只能查看按键。退出游戏后即可编辑。": "Celeste is running. Bindings are read-only until the game exits.", "正在读取按键配置…": "Loading bindings…", - "{count} 个功能使用此按键": "Used by {count} actions", + "{count} 个功能使用此按键": "Utilisée par {count} actions", "没有发现相同按键冲突": "No identical binding conflicts found", "没有符合条件的按键": "No bindings match the filters", "点击重新绑定;使用右侧按钮移除": "Click to rebind; use the button on the right to remove", @@ -441,6 +441,32 @@ "显示未启用的 Mod": "Show disabled mods", "隐藏未启用的 Mod": "Hide disabled mods", "清除绑定": "Clear binding", + "键盘占用": "Occupation du clavier", + "Windows 键盘": "Clavier Windows", + "macOS 键盘": "Clavier macOS", + "Linux 键盘": "Clavier Linux", + "{used}/{total} 个键已使用": "{used}/{total} touches utilisées", + "占用状态": "État d’occupation", + "未使用": "Non utilisée", + "单一占用": "Occupation unique", + "多处占用": "Occupation multiple", + "点击按键查看占用情况,再次点击取消筛选": "Cliquez sur une touche pour afficher et filtrer son usage ; recliquez pour effacer le filtre", + "没有功能使用此按键": "Aucune action n’utilise cette touche", + "其它已绑定按键": "Autres touches attribuées", + "清除按键筛选": "Effacer le filtre de touche", + "手柄占用": "Occupation de la manette", + "Xbox 布局": "Disposition Xbox", + "PlayStation 布局": "Disposition PlayStation", + "Switch 布局": "Disposition Switch", + "已连接": "Connectée", + "未检测到手柄": "Aucune manette détectée", + "{used}/{total} 个按钮已使用": "{used}/{total} commandes utilisées", + "点击手柄按键查看占用情况,再次点击取消筛选": "Cliquez sur une commande pour afficher et filtrer son usage ; recliquez pour effacer le filtre", + "其它已绑定按钮": "Autres commandes attribuées", + "清除手柄筛选": "Effacer le filtre de manette", + "手柄布局": "Disposition de la manette", + "自动": "Auto", + "跟随已连接手柄": "Suivre la manette connectée", "字体缩放": "Échelle de police", "同步放大界面文字和控件": "Agrandir ensemble le texte et les contrôles de l’interface", "页面切换动画": "Animations de transition entre les pages", diff --git a/src/celemod-ui/locales/pt-BR.json b/src/celemod-ui/locales/pt-BR.json index 8663bb4..0a4843a 100644 --- a/src/celemod-ui/locales/pt-BR.json +++ b/src/celemod-ui/locales/pt-BR.json @@ -430,6 +430,33 @@ "显示未启用的 Mod": "Show disabled mods", "隐藏未启用的 Mod": "Hide disabled mods", "清除绑定": "Clear binding", + "键盘占用": "Uso do teclado", + "Windows 键盘": "Teclado Windows", + "macOS 键盘": "Teclado macOS", + "Linux 键盘": "Teclado Linux", + "{used}/{total} 个键已使用": "{used}/{total} teclas em uso", + "占用状态": "Estado de uso", + "未使用": "Não usada", + "单一占用": "Uso único", + "多处占用": "Uso múltiplo", + "点击按键查看占用情况,再次点击取消筛选": "Clique em uma tecla para ver e filtrar seu uso; clique novamente para limpar", + "{count} 个功能使用此按键": "Usada por {count} ações", + "没有功能使用此按键": "Nenhuma ação usa esta tecla", + "其它已绑定按键": "Outras teclas vinculadas", + "清除按键筛选": "Limpar filtro de tecla", + "手柄占用": "Uso do controle", + "Xbox 布局": "Layout do Xbox", + "PlayStation 布局": "Layout do PlayStation", + "Switch 布局": "Layout do Switch", + "已连接": "Conectado", + "未检测到手柄": "Nenhum controle detectado", + "{used}/{total} 个按钮已使用": "{used}/{total} controles em uso", + "点击手柄按键查看占用情况,再次点击取消筛选": "Clique em um controle para ver e filtrar seu uso; clique novamente para limpar", + "其它已绑定按钮": "Outros controles vinculados", + "清除手柄筛选": "Limpar filtro do controle", + "手柄布局": "Layout do controle", + "自动": "Automático", + "跟随已连接手柄": "Seguir o controle conectado", "字体缩放": "Escala da fonte", "LPL Collab 2022 / 乐屁乐": "LPL Collab 2022 / 乐屁乐", "中文社区制作的欢乐向梗图合集,历时一年完成": "Coleção de memes divertidos criados pela comunidade chinesa, concluída ao longo de um ano.", diff --git a/src/celemod-ui/locales/ru-RU.json b/src/celemod-ui/locales/ru-RU.json index db1a703..0357005 100644 --- a/src/celemod-ui/locales/ru-RU.json +++ b/src/celemod-ui/locales/ru-RU.json @@ -430,6 +430,33 @@ "显示未启用的 Mod": "Show disabled mods", "隐藏未启用的 Mod": "Hide disabled mods", "清除绑定": "Clear binding", + "键盘占用": "Использование клавиш", + "Windows 键盘": "Клавиатура Windows", + "macOS 键盘": "Клавиатура macOS", + "Linux 键盘": "Клавиатура Linux", + "{used}/{total} 个键已使用": "Используется клавиш: {used}/{total}", + "占用状态": "Состояние назначений", + "未使用": "Не используется", + "单一占用": "Одно назначение", + "多处占用": "Несколько назначений", + "点击按键查看占用情况,再次点击取消筛选": "Нажмите клавишу, чтобы посмотреть и отфильтровать назначения; нажмите ещё раз, чтобы сбросить фильтр", + "{count} 个功能使用此按键": "Используется действиями: {count}", + "没有功能使用此按键": "Эта клавиша не используется", + "其它已绑定按键": "Другие назначенные клавиши", + "清除按键筛选": "Сбросить фильтр клавиши", + "手柄占用": "Использование геймпада", + "Xbox 布局": "Раскладка Xbox", + "PlayStation 布局": "Раскладка PlayStation", + "Switch 布局": "Раскладка Switch", + "已连接": "Подключён", + "未检测到手柄": "Геймпад не обнаружен", + "{used}/{total} 个按钮已使用": "Используется элементов: {used}/{total}", + "点击手柄按键查看占用情况,再次点击取消筛选": "Нажмите элемент геймпада, чтобы посмотреть и отфильтровать назначения; нажмите ещё раз, чтобы сбросить фильтр", + "其它已绑定按钮": "Другие назначенные элементы", + "清除手柄筛选": "Сбросить фильтр геймпада", + "手柄布局": "Раскладка геймпада", + "自动": "Авто", + "跟随已连接手柄": "Использовать раскладку подключённого геймпада", "字体缩放": "Масштаб шрифта", "LPL Collab 2022 / 乐屁乐": "LPL Collab 2022 / 乐屁乐", "中文社区制作的欢乐向梗图合集,历时一年完成": "Сборник весёлых мемов, созданный китайским сообществом, завершён за год", diff --git a/src/celemod-ui/locales/zh-CN.json b/src/celemod-ui/locales/zh-CN.json index d8c427c..44d7938 100644 --- a/src/celemod-ui/locales/zh-CN.json +++ b/src/celemod-ui/locales/zh-CN.json @@ -436,6 +436,33 @@ "显示未启用的 Mod": "显示未启用的 Mod", "隐藏未启用的 Mod": "隐藏未启用的 Mod", "清除绑定": "清除绑定", + "键盘占用": "键盘占用", + "Windows 键盘": "Windows 键盘", + "macOS 键盘": "macOS 键盘", + "Linux 键盘": "Linux 键盘", + "{used}/{total} 个键已使用": "{used}/{total} 个键已使用", + "占用状态": "占用状态", + "未使用": "未使用", + "单一占用": "单一占用", + "多处占用": "多处占用", + "点击按键查看占用情况,再次点击取消筛选": "点击按键查看占用情况,再次点击取消筛选", + "{count} 个功能使用此按键": "{count} 个功能使用此按键", + "没有功能使用此按键": "没有功能使用此按键", + "其它已绑定按键": "其它已绑定按键", + "清除按键筛选": "清除按键筛选", + "手柄占用": "手柄占用", + "Xbox 布局": "Xbox 布局", + "PlayStation 布局": "PlayStation 布局", + "Switch 布局": "Switch 布局", + "已连接": "已连接", + "未检测到手柄": "未检测到手柄", + "{used}/{total} 个按钮已使用": "{used}/{total} 个按钮已使用", + "点击手柄按键查看占用情况,再次点击取消筛选": "点击手柄按键查看占用情况,再次点击取消筛选", + "其它已绑定按钮": "其它已绑定按钮", + "清除手柄筛选": "清除手柄筛选", + "手柄布局": "手柄布局", + "自动": "自动", + "跟随已连接手柄": "跟随已连接手柄", "字体缩放": "字体缩放", "LPL Collab 2022 / 乐屁乐": "LPL Collab 2022 / 乐屁乐", "中文社区制作的欢乐向梗图合集,历时一年完成": "中文社区制作的欢乐向梗图合集,历时一年完成", diff --git a/src/celemod-ui/src/components/VirtualController.scss b/src/celemod-ui/src/components/VirtualController.scss new file mode 100644 index 0000000..a0f8cf7 --- /dev/null +++ b/src/celemod-ui/src/components/VirtualController.scss @@ -0,0 +1,462 @@ +@use "sass:color"; +@use "../styles/theme" as theme; + +.virtual-controller-panel { + margin: 8px 0 0 8px; + overflow: hidden; + border: 1px solid color.change(theme.$fg, $alpha: 0.075); + border-radius: 9px; + background: color.change(theme.$bg1, $alpha: 0.5); +} + +.virtual-controller-header { + display: flex; + min-height: 34px; + align-items: center; + justify-content: space-between; + padding: 5px 9px; + gap: 12px; + border-bottom: 1px solid color.change(theme.$fg, $alpha: 0.055); + box-sizing: border-box; +} + +.virtual-controller-heading { + display: flex; + min-width: 0; + align-items: center; + gap: 7px; + + > strong { + font-size: calc(10px * var(--keybindings-font-scale, 1)); + } +} + +.virtual-controller-connection, +.virtual-controller-summary { + padding: 2px 6px; + border-radius: 99px; + background: color.change(theme.$fg, $alpha: 0.055); + color: theme.$fg3; + font-size: calc(7px * var(--keybindings-font-scale, 1)); + white-space: nowrap; +} + +.virtual-controller-layout-switch { + display: inline-flex; + flex: 0 0 auto; + padding: 2px; + gap: 2px; + border: 1px solid color.change(theme.$fg, $alpha: 0.08); + border-radius: 6px; + background: color.change(theme.$bg1, $alpha: 0.7); + + button { + height: 20px; + min-height: 20px; + padding: 0 6px; + border: 0; + border-radius: 4px; + background: transparent; + box-shadow: none; + color: theme.$fg3; + font-size: calc(6.5px * var(--keybindings-font-scale, 1)); + letter-spacing: 0; + + &:hover, + &.selected { + background: color.change(theme.$primary, $alpha: 0.13); + color: theme.$fg; + } + } +} + +.virtual-controller-connection { + display: inline-flex; + align-items: center; + gap: 4px; + + &::before { + width: 5px; + height: 5px; + border-radius: 50%; + background: color.change(theme.$fg, $alpha: 0.24); + content: ""; + } + &.connected::before { + background: #71c995; + box-shadow: 0 0 5px color.change(#71c995, $alpha: 0.45); + } +} + +.virtual-controller-summary { + background: color.change(theme.$primary, $alpha: 0.08); + color: color.adjust(theme.$primary, $lightness: 19%); +} + +.virtual-controller-legend { + display: flex; + flex: 0 0 auto; + align-items: center; + gap: 7px; + + span { + display: inline-flex; + align-items: center; + gap: 4px; + color: color.change(theme.$fg, $alpha: 0.48); + font-size: calc(7px * var(--keybindings-font-scale, 1)); + white-space: nowrap; + + &::before { + width: 7px; + height: 7px; + border: 1px solid transparent; + border-radius: 2px; + content: ""; + } + &.unused::before { + border-color: color.change(theme.$fg, $alpha: 0.13); + background: color.change(theme.$fg, $alpha: 0.025); + } + &.used::before { + border-color: color.change(theme.$primary, $alpha: 0.42); + background: color.change(theme.$primary, $alpha: 0.18); + } + &.multiple::before { + border-color: color.change(#ffc66d, $alpha: 0.48); + background: color.change(#ffc66d, $alpha: 0.18); + } + } +} + +.virtual-controller-hint { + margin: 0; + padding: 4px 9px 0; + color: color.change(theme.$fg, $alpha: 0.35); + font-size: calc(7px * var(--keybindings-font-scale, 1)); +} + +.virtual-controller-scroll { + padding: 2px 8px 8px; + overflow-x: auto; + scrollbar-width: thin; +} + +.virtual-controller-diagram { + position: relative; + width: 580px; + aspect-ratio: 1452 / 940; + margin: 0 auto; + + &.switch { + width: 500px; + aspect-ratio: 1024 / 830; + } +} + +.virtual-controller-image { + position: absolute; + inset: 0; + width: 100%; + height: 100%; + object-fit: fill; + opacity: 0.34; + filter: drop-shadow(0 6px 12px color.change(#000, $alpha: 0.15)); + pointer-events: none; + user-select: none; + + .switch & { + opacity: 0.78; + } +} + +.virtual-controller-button { + position: absolute; + z-index: 2; + display: flex; + width: 28px; + height: 28px; + min-height: 28px; + align-items: center; + justify-content: center; + padding: 0; + overflow: visible; + border: 1px solid color.change(theme.$fg, $alpha: 0.18); + border-radius: 50%; + background: color.change(theme.$bg1, $alpha: 0.66); + box-shadow: 0 1px 4px color.change(#000, $alpha: 0.2); + color: color.change(theme.$fg, $alpha: 0.58); + font-family: ui-monospace, SFMono-Regular, Consolas, monospace; + font-size: calc(7px * var(--keybindings-font-scale, 1)); + font-weight: 700; + letter-spacing: 0; + transform: translate(-50%, -50%); + + > small { + position: absolute; + top: -5px; + right: -5px; + display: grid; + min-width: 11px; + height: 11px; + padding: 0 2px; + place-items: center; + border-radius: 99px; + background: color.change(theme.$primary, $alpha: 0.78); + color: theme.$fg; + font-size: calc(5.5px * var(--keybindings-font-scale, 1)); + line-height: 11px; + } + &.unused { + opacity: 0.68; + } + &.used { + border-color: color.change(theme.$primary, $alpha: 0.72); + background: color.change(theme.$primary, $alpha: 0.25); + color: theme.$fg; + } + &.multiple { + border-color: color.change(#ffc66d, $alpha: 0.8); + background: color.change(#ffc66d, $alpha: 0.25); + color: #ffe0a8; + + > small { + background: color.change(#d89a38, $alpha: 0.92); + } + } + &:hover, + &.selected { + z-index: 4; + border-color: color.change(theme.$primary, $alpha: 0.95); + background: color.change(theme.$primary, $alpha: 0.42); + box-shadow: + 0 0 0 2px color.change(theme.$primary, $alpha: 0.2), + 0 2px 6px color.change(#000, $alpha: 0.25); + color: theme.$fg; + opacity: 1; + } + &:focus-visible { + z-index: 5; + outline: 1px solid color.change(theme.$primary, $alpha: 0.9); + outline-offset: 2px; + } + + &.pill { + width: 60px; + height: 22px; + min-height: 22px; + border-radius: 99px; + } + &.menu { + width: 42px; + height: 21px; + min-height: 21px; + border-radius: 99px; + font-size: calc(5.8px * var(--keybindings-font-scale, 1)); + } + &.face { + width: 29px; + height: 29px; + min-height: 29px; + font-size: calc(9px * var(--keybindings-font-scale, 1)); + } + &.dpad { + width: 23px; + height: 23px; + min-height: 23px; + border-radius: 6px; + } + &.stick { + width: 37px; + height: 37px; + min-height: 37px; + } +} + +.virtual-controller-stick-control { + position: absolute; + z-index: 2; + width: 78px; + height: 78px; + transform: translate(-50%, -50%); + + > svg { + display: block; + width: 100%; + height: 100%; + overflow: visible; + } + > .virtual-controller-button.stick { + top: 50%; + left: 50%; + } +} + +.stick-segment { + fill: color.change(theme.$bg1, $alpha: 0.66); + stroke: color.change(theme.$fg, $alpha: 0.18); + stroke-width: 1.5; + cursor: pointer; + transition: + fill 100ms ease, + stroke 100ms ease, + opacity 100ms ease; + + &.unused { + opacity: 0.68; + } + &.used { + fill: color.change(theme.$primary, $alpha: 0.25); + stroke: color.change(theme.$primary, $alpha: 0.72); + } + &.multiple { + fill: color.change(#ffc66d, $alpha: 0.25); + stroke: color.change(#ffc66d, $alpha: 0.8); + } + &:hover, + &.selected, + &:focus-visible { + fill: color.change(theme.$primary, $alpha: 0.42); + stroke: color.change(theme.$primary, $alpha: 0.95); + stroke-width: 2.5; + opacity: 1; + outline: none; + } +} + +.stick-segment-label, +.stick-segment-count { + fill: color.change(theme.$fg, $alpha: 0.72); + font-family: ui-monospace, SFMono-Regular, Consolas, monospace; + font-size: 11px; + font-weight: 700; + text-anchor: middle; + dominant-baseline: middle; + pointer-events: none; + user-select: none; +} + +.stick-segment-count { + fill: color.adjust(theme.$primary, $lightness: 24%); + font-size: 8px; + + &.multiple { + fill: #ffd58c; + } +} + + +.virtual-controller-extra { + display: flex; + align-items: center; + padding: 0 9px 7px; + gap: 7px; + + > span { + flex: 0 0 auto; + color: color.change(theme.$fg, $alpha: 0.38); + font-size: calc(7px * var(--keybindings-font-scale, 1)); + } + > div { + display: flex; + min-width: 0; + flex-wrap: wrap; + gap: 3px; + } + .virtual-controller-button.extra { + position: relative; + width: auto; + min-width: 40px; + padding: 0 8px; + border-radius: 99px; + transform: none; + } +} + +.virtual-controller-detail { + border-top: 1px solid color.change(theme.$fg, $alpha: 0.06); + background: color.change(theme.$bg2, $alpha: 0.2); +} + +.virtual-controller-detail-title { + display: flex; + min-height: 30px; + align-items: center; + padding: 4px 8px 4px 10px; + gap: 7px; + box-sizing: border-box; + + > strong { + color: color.adjust(theme.$primary, $lightness: 21%); + font-family: ui-monospace, SFMono-Regular, Consolas, monospace; + font-size: calc(9px * var(--keybindings-font-scale, 1)); + } + > span { + color: theme.$fg3; + font-size: calc(8px * var(--keybindings-font-scale, 1)); + } + > button { + display: grid; + width: 21px; + height: 21px; + min-height: 0; + margin-left: auto; + padding: 0; + place-items: center; + border: 0; + background: transparent; + box-shadow: none; + color: theme.$fg3; + + .icon { + width: 9px; + height: 9px; + } + } +} + +.virtual-controller-detail-actions { + display: flex; + max-height: 92px; + padding: 0 9px 8px; + flex-wrap: wrap; + gap: 4px; + overflow-y: auto; + + > div { + display: inline-flex; + min-width: 0; + align-items: center; + padding: 4px 6px; + gap: 5px; + border: 1px solid color.change(theme.$fg, $alpha: 0.065); + border-radius: 5px; + background: color.change(theme.$bg1, $alpha: 0.55); + } + strong { + max-width: 180px; + overflow: hidden; + font-size: calc(8px * var(--keybindings-font-scale, 1)); + text-overflow: ellipsis; + white-space: nowrap; + } + span { + color: theme.$fg3; + font-size: calc(7px * var(--keybindings-font-scale, 1)); + } + code { + color: color.change(theme.$fg, $alpha: 0.5); + font-size: calc(7px * var(--keybindings-font-scale, 1)); + } +} + +@media (max-width: 860px) { + .virtual-controller-header { + align-items: flex-start; + flex-direction: column; + gap: 4px; + } + .virtual-controller-legend { + align-self: stretch; + } +} diff --git a/src/celemod-ui/src/components/VirtualController.tsx b/src/celemod-ui/src/components/VirtualController.tsx new file mode 100644 index 0000000..7d63d12 --- /dev/null +++ b/src/celemod-ui/src/components/VirtualController.tsx @@ -0,0 +1,584 @@ +import { useEffect, useMemo, useState } from "react"; +import _i18n from "../i18n"; +import { Icon } from "./Icon"; +import xboxSeriesController from "../resources/controllers/xbox-series.png"; +import dualSenseController from "../resources/controllers/dualsense.png"; +import switchProController from "../resources/controllers/switch-pro.svg"; +import "./VirtualController.scss"; + +export interface VirtualControllerBinding { + actionId: string; + label: string; + source: string; + combination: string; + buttons: string[]; +} + +interface VirtualControllerProps { + bindings: VirtualControllerBinding[]; + selectedButton: string; + onSelectButton: (button: string) => void; + formatButton: (button: string) => string; +} + +type ControllerLayout = "xbox" | "playstation" | "switch"; + +// Controller diagrams are CC0 assets by Xelu, distributed by +// https://github.com/rsubtil/controller_icons. +const controllerImages: Record = { + xbox: xboxSeriesController, + playstation: dualSenseController, + switch: switchProController, +}; + +interface ControllerIdentity { + connected: boolean; + id: string; + layout: ControllerLayout; +} + +interface UsageAction { + actionId: string; + label: string; + source: string; + combinations: Set; +} + +type LayoutPreference = "auto" | ControllerLayout; +type ControlKind = "direction" | "dpad" | "face" | "menu" | "pill" | "stick"; + +interface ControlPosition { + value: string; + x: number; + y: number; + kind: ControlKind; +} + +interface StickPosition { + x: number; + y: number; +} + +const CONTROLLER_BUTTONS = [ + "A", + "B", + "X", + "Y", + "LeftShoulder", + "RightShoulder", + "LeftTrigger", + "RightTrigger", + "Back", + "Start", + "LeftStick", + "RightStick", + "DPadUp", + "DPadDown", + "DPadLeft", + "DPadRight", + "LeftThumbstickUp", + "LeftThumbstickDown", + "LeftThumbstickLeft", + "LeftThumbstickRight", + "RightThumbstickUp", + "RightThumbstickDown", + "RightThumbstickLeft", + "RightThumbstickRight", +] as const; + +const detectControllerLayout = (id: string): ControllerLayout => { + const normalized = id.toLocaleLowerCase(); + if ( + normalized.includes("dualsense") || + normalized.includes("dualshock") || + normalized.includes("playstation") || + normalized.includes("sony") || + normalized.includes("wireless controller") + ) + return "playstation"; + if ( + normalized.includes("nintendo") || + normalized.includes("switch") || + normalized.includes("joy-con") + ) + return "switch"; + return "xbox"; +}; + +const readControllerIdentity = (): ControllerIdentity => { + const gamepad = [...(navigator.getGamepads?.() ?? [])].find(Boolean); + return gamepad + ? { connected: true, id: gamepad.id, layout: detectControllerLayout(gamepad.id) } + : { connected: false, id: "", layout: "xbox" }; +}; + +const layoutTitle = (layout: ControllerLayout) => + ({ + xbox: _i18n.t("Xbox 布局"), + playstation: _i18n.t("PlayStation 布局"), + switch: _i18n.t("Switch 布局"), + })[layout]; + +const buttonLabels: Record> = { + xbox: { + A: "A", + B: "B", + X: "X", + Y: "Y", + LeftShoulder: "LB", + RightShoulder: "RB", + LeftTrigger: "LT", + RightTrigger: "RT", + Back: "View", + Start: "Menu", + LeftStick: "L3", + RightStick: "R3", + }, + playstation: { + A: "×", + B: "○", + X: "□", + Y: "△", + LeftShoulder: "L1", + RightShoulder: "R1", + LeftTrigger: "L2", + RightTrigger: "R2", + Back: "Create", + Start: "Options", + LeftStick: "L3", + RightStick: "R3", + }, + switch: { + A: "B", + B: "A", + X: "Y", + Y: "X", + LeftShoulder: "L", + RightShoulder: "R", + LeftTrigger: "ZL", + RightTrigger: "ZR", + Back: "−", + Start: "+", + LeftStick: "L", + RightStick: "R", + }, +}; + +const fixedLabels: Record = { + DPadUp: "↑", + DPadDown: "↓", + DPadLeft: "←", + DPadRight: "→", + LeftThumbstickUp: "↑", + LeftThumbstickDown: "↓", + LeftThumbstickLeft: "←", + LeftThumbstickRight: "→", + RightThumbstickUp: "↑", + RightThumbstickDown: "↓", + RightThumbstickLeft: "←", + RightThumbstickRight: "→", +}; + +const control = ( + value: string, + x: number, + y: number, + kind: ControlKind, +): ControlPosition => ({ value, x, y, kind }); + +const dpadControls = ( + x: number, + y: number, + horizontalOffset: number, + verticalOffset: number, +): ControlPosition[] => [ + control("DPadUp", x, y - verticalOffset, "dpad"), + control("DPadLeft", x - horizontalOffset, y, "dpad"), + control("DPadRight", x + horizontalOffset, y, "dpad"), + control("DPadDown", x, y + verticalOffset, "dpad"), +]; + +const faceControls = ( + x: number, + y: number, + horizontalOffset: number, + verticalOffset: number, +): ControlPosition[] => [ + control("Y", x, y - verticalOffset, "face"), + control("X", x - horizontalOffset, y, "face"), + control("B", x + horizontalOffset, y, "face"), + control("A", x, y + verticalOffset, "face"), +]; + + +const controllerPositions: Record = { + xbox: [ + control("LeftTrigger", 25, 5, "pill"), + control("RightTrigger", 75, 5, "pill"), + control("LeftShoulder", 24, 17, "pill"), + control("RightShoulder", 76, 17, "pill"), + control("Back", 44, 43, "menu"), + control("Start", 58, 43, "menu"), + ...dpadControls(38, 65, 3.8, 5.6), + ...faceControls(76, 42.5, 6.5, 9), + ], + playstation: [ + control("LeftTrigger", 21, 8, "pill"), + control("RightTrigger", 79, 8, "pill"), + control("LeftShoulder", 21, 18, "pill"), + control("RightShoulder", 79, 18, "pill"), + control("Back", 28, 32, "menu"), + control("Start", 73, 32, "menu"), + ...dpadControls(21, 47, 4.3, 6), + ...faceControls(80, 47, 6.5, 10.5), + ], + switch: [ + control("LeftTrigger", 21.7, 8.4, "pill"), + control("RightTrigger", 76, 8.4, "pill"), + control("LeftShoulder", 21.6, 21.7, "pill"), + control("RightShoulder", 76, 21.7, "pill"), + control("Back", 36.6, 32.4, "menu"), + control("Start", 61, 32.4, "menu"), + ...dpadControls(33.8, 58.3, 4.6, 5.7), + ...faceControls(74.6, 41.6, 7.6, 8.2), + ], +}; + +const controllerStickPositions: Record< + ControllerLayout, + Record<"Left" | "Right", StickPosition> +> = { + xbox: { + Left: { x: 26, y: 44 }, + Right: { x: 64, y: 65 }, + }, + playstation: { + Left: { x: 36, y: 68 }, + Right: { x: 65, y: 68 }, + }, + switch: { + Left: { x: 21.9, y: 41.8 }, + Right: { x: 61.7, y: 58.2 }, + }, +}; + +const shortLayoutTitle = (layout: ControllerLayout) => + ({ xbox: "Xbox", playstation: "PS", switch: "Switch" })[layout]; + +const stickPoint = (radius: number, degrees: number) => { + const radians = (degrees * Math.PI) / 180; + return { + x: 50 + radius * Math.cos(radians), + y: 50 + radius * Math.sin(radians), + }; +}; + +const stickSegmentPath = (start: number, end: number) => { + const outerStart = stickPoint(47, start); + const outerEnd = stickPoint(47, end); + const innerEnd = stickPoint(27, end); + const innerStart = stickPoint(27, start); + return [ + `M ${outerStart.x} ${outerStart.y}`, + `A 47 47 0 0 1 ${outerEnd.x} ${outerEnd.y}`, + `L ${innerEnd.x} ${innerEnd.y}`, + `A 27 27 0 0 0 ${innerStart.x} ${innerStart.y}`, + "Z", + ].join(" "); +}; + +const STICK_SEGMENTS = [ + { suffix: "Up", label: "↑", start: -132, end: -48, x: 50, y: 17 }, + { suffix: "Right", label: "→", start: -42, end: 42, x: 83, y: 53 }, + { suffix: "Down", label: "↓", start: 48, end: 132, x: 50, y: 88 }, + { suffix: "Left", label: "←", start: 138, end: 222, x: 17, y: 53 }, +] as const; + +export const VirtualController = ({ + bindings, + selectedButton, + onSelectButton, + formatButton, +}: VirtualControllerProps) => { + const [identity, setIdentity] = useState(readControllerIdentity); + const [layoutPreference, setLayoutPreference] = + useState("auto"); + const layout = + layoutPreference === "auto" ? identity.layout : layoutPreference; + + useEffect(() => { + const updateIdentity = () => setIdentity(readControllerIdentity()); + window.addEventListener("gamepadconnected", updateIdentity); + window.addEventListener("gamepaddisconnected", updateIdentity); + return () => { + window.removeEventListener("gamepadconnected", updateIdentity); + window.removeEventListener("gamepaddisconnected", updateIdentity); + }; + }, []); + + const usageByButton = useMemo(() => { + const result = new Map>(); + for (const binding of bindings) { + for (const value of new Set(binding.buttons)) { + const actions = result.get(value) ?? new Map(); + const action = actions.get(binding.actionId) ?? { + actionId: binding.actionId, + label: binding.label, + source: binding.source, + combinations: new Set(), + }; + action.combinations.add(binding.combination); + actions.set(binding.actionId, action); + result.set(value, actions); + } + } + return result; + }, [bindings]); + + const usedButtons = CONTROLLER_BUTTONS.filter( + (value) => (usageByButton.get(value)?.size ?? 0) > 0, + ).length; + const extraButtons = [...usageByButton.keys()] + .filter( + (value) => + !(CONTROLLER_BUTTONS as readonly string[]).includes(value), + ) + .sort((left, right) => left.localeCompare(right)); + const selectedActions = selectedButton + ? [...(usageByButton.get(selectedButton)?.values() ?? [])] + : []; + + const displayLabel = (value: string) => + fixedLabels[value] ?? buttonLabels[layout][value] ?? formatButton(value); + + const buttonPresentation = (value: string) => { + const count = usageByButton.get(value)?.size ?? 0; + const status = count === 0 ? "unused" : count === 1 ? "used" : "multiple"; + const selected = value === selectedButton; + const label = displayLabel(value); + const logicalName = formatButton(value); + const accessibleLabel = + label === logicalName ? label : `${label} (${logicalName})`; + const usageLabel = + count === 0 + ? _i18n.t("未使用") + : _i18n.t("{count} 个功能使用此按键", { count }); + return { + accessibleLabel, + count, + label, + selected, + status, + usageLabel, + }; + }; + + const renderButton = ( + value: string, + className = "", + position?: Pick, + ) => { + const { accessibleLabel, count, label, selected, status, usageLabel } = + buttonPresentation(value); + return ( + + ); + }; + + const renderStick = ( + side: "Left" | "Right", + position: StickPosition, + ) => ( +
+ + {STICK_SEGMENTS.map((segment) => { + const value = `${side}Thumbstick${segment.suffix}`; + const { + accessibleLabel, + count, + selected, + status, + usageLabel, + } = buttonPresentation(value); + const select = () => onSelectButton(selected ? "" : value); + const badgeX = + segment.x < 50 ? segment.x + 8 : segment.x > 50 ? segment.x - 8 : segment.x + 9; + return ( + + { + if (event.key !== "Enter" && event.key !== " ") return; + event.preventDefault(); + select(); + }} + > + {`${accessibleLabel} · ${usageLabel}`} + + + {segment.label} + + {count > 0 && ( + + {count} + + )} + + ); + })} + + {renderButton(`${side}Stick`, "stick")} +
+ ); + + return ( +
+
+
+ {_i18n.t("手柄占用")} +
+ {(["auto", "xbox", "playstation", "switch"] as const).map( + (option) => ( + + ), + )} +
+ + {identity.connected + ? _i18n.t("已连接") + : _i18n.t("未检测到手柄")} + + + {_i18n.t("{used}/{total} 个按钮已使用", { + used: usedButtons, + total: CONTROLLER_BUTTONS.length, + })} + +
+
+ {_i18n.t("未使用")} + {_i18n.t("单一占用")} + {_i18n.t("多处占用")} +
+
+ +

+ {_i18n.t("点击手柄按键查看占用情况,再次点击取消筛选")} +

+ +
+
+ + {controllerPositions[layout].map((position) => + renderButton(position.value, position.kind, position), + )} + {renderStick("Left", controllerStickPositions[layout].Left)} + {renderStick("Right", controllerStickPositions[layout].Right)} +
+
+ + {extraButtons.length > 0 && ( +
+ {_i18n.t("其它已绑定按钮")} +
{extraButtons.map((value) => renderButton(value, "extra"))}
+
+ )} + + {selectedButton && ( +
+
+ {displayLabel(selectedButton)} + + {selectedActions.length === 0 + ? _i18n.t("没有功能使用此按键") + : _i18n.t("{count} 个功能使用此按键", { + count: selectedActions.length, + })} + + +
+ {selectedActions.length > 0 && ( +
+ {selectedActions.map((action) => ( +
+ {action.label} + {action.source} + {[...action.combinations].join(" / ")} +
+ ))} +
+ )} +
+ )} +
+ ); +}; diff --git a/src/celemod-ui/src/components/VirtualKeyboard.scss b/src/celemod-ui/src/components/VirtualKeyboard.scss new file mode 100644 index 0000000..dcd700d --- /dev/null +++ b/src/celemod-ui/src/components/VirtualKeyboard.scss @@ -0,0 +1,349 @@ +@use "sass:color"; +@use "../styles/theme" as theme; + +.virtual-keyboard-panel { + margin: 8px 0 0 8px; + overflow: hidden; + border: 1px solid color.change(theme.$fg, $alpha: 0.075); + border-radius: 9px; + background: color.change(theme.$bg1, $alpha: 0.5); +} + +.virtual-keyboard-header { + display: flex; + min-height: 34px; + align-items: center; + justify-content: space-between; + padding: 5px 9px; + gap: 12px; + border-bottom: 1px solid color.change(theme.$fg, $alpha: 0.055); + box-sizing: border-box; + + > div { + display: flex; + align-items: center; + min-width: 0; + gap: 7px; + } + strong { + font-size: calc(10px * var(--keybindings-font-scale, 1)); + } +} + +.virtual-keyboard-platform, +.virtual-keyboard-summary { + padding: 2px 6px; + border-radius: 99px; + background: color.change(theme.$fg, $alpha: 0.055); + color: theme.$fg3; + font-size: calc(7px * var(--keybindings-font-scale, 1)); + white-space: nowrap; +} + +.virtual-keyboard-summary { + background: color.change(theme.$primary, $alpha: 0.08); + color: color.adjust(theme.$primary, $lightness: 19%); +} + +.virtual-keyboard-legend { + flex: 0 0 auto; + + span { + display: inline-flex; + align-items: center; + gap: 4px; + color: color.change(theme.$fg, $alpha: 0.48); + font-size: calc(7px * var(--keybindings-font-scale, 1)); + white-space: nowrap; + + &::before { + width: 7px; + height: 7px; + border: 1px solid transparent; + border-radius: 2px; + content: ""; + } + &.unused::before { + border-color: color.change(theme.$fg, $alpha: 0.13); + background: color.change(theme.$fg, $alpha: 0.025); + } + &.used::before { + border-color: color.change(theme.$primary, $alpha: 0.42); + background: color.change(theme.$primary, $alpha: 0.18); + } + &.multiple::before { + border-color: color.change(#ffc66d, $alpha: 0.48); + background: color.change(#ffc66d, $alpha: 0.18); + } + } +} + +.virtual-keyboard-hint { + margin: 0; + padding: 4px 9px 0; + color: color.change(theme.$fg, $alpha: 0.35); + font-size: calc(7px * var(--keybindings-font-scale, 1)); +} + +.virtual-keyboard-scroll { + padding: 7px 8px 9px; + overflow-x: auto; + scrollbar-width: thin; +} + +.virtual-keyboard-board { + --keyboard-gap: 3px; + --keyboard-key-height: 26px; + width: 776px; + margin: 0 auto; +} + +.virtual-keyboard-function-row { + display: flex; + align-items: stretch; + justify-content: space-between; + margin-bottom: 7px; + gap: 8px; +} + +.virtual-keyboard-function-group { + display: flex; + flex: 0 0 auto; + gap: var(--keyboard-gap); + + .virtual-keyboard-key { + width: 31px; + flex: 0 0 31px !important; + } +} + +.virtual-keyboard-body { + display: grid; + grid-template-columns: minmax(0, 15fr) 3fr 4fr; + align-items: stretch; + gap: 10px; +} + +.virtual-keyboard-main, +.virtual-keyboard-navigation { + display: flex; + min-width: 0; + flex-direction: column; + gap: var(--keyboard-gap); +} + +.virtual-keyboard-row { + display: flex; + min-width: 0; + height: var(--keyboard-key-height); + gap: var(--keyboard-gap); +} + +.virtual-keyboard-navigation .virtual-keyboard-row { + .virtual-keyboard-key, + .virtual-keyboard-spacer { + min-width: 0; + flex: 1 1 0 !important; + } +} + +.virtual-keyboard-spacer { + display: block; +} + +.virtual-keyboard-numpad { + display: grid; + grid-template-columns: repeat(4, minmax(0, 1fr)); + grid-template-rows: repeat(5, var(--keyboard-key-height)); + gap: var(--keyboard-gap); + + .virtual-keyboard-key { + width: auto; + min-width: 0; + } +} + +.virtual-keyboard-key { + position: relative; + display: flex; + min-width: 0; + height: var(--keyboard-key-height); + min-height: var(--keyboard-key-height); + align-items: center; + justify-content: center; + padding: 0 3px; + overflow: hidden; + border: 1px solid color.change(theme.$fg, $alpha: 0.11); + border-radius: 4px; + background: color.change(theme.$bg2, $alpha: 0.48); + box-shadow: 0 1px 0 color.change(theme.$fg, $alpha: 0.035); + color: color.change(theme.$fg, $alpha: 0.48); + font-family: ui-monospace, SFMono-Regular, Consolas, monospace; + font-size: calc(6.8px * var(--keybindings-font-scale, 1)); + font-weight: 620; + letter-spacing: -0.02em; + line-height: 1; + text-transform: none; + + > span { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + > small { + position: absolute; + top: 2px; + right: 2px; + display: grid; + min-width: 10px; + height: 10px; + padding: 0 2px; + place-items: center; + border-radius: 99px; + background: color.change(theme.$primary, $alpha: 0.26); + color: color.adjust(theme.$primary, $lightness: 26%); + font-family: inherit; + font-size: calc(5.5px * var(--keybindings-font-scale, 1)); + line-height: 10px; + } + &.used { + border-color: color.change(theme.$primary, $alpha: 0.36); + background: color.change(theme.$primary, $alpha: 0.12); + color: color.change(theme.$fg, $alpha: 0.82); + } + &.multiple { + border-color: color.change(#ffc66d, $alpha: 0.43); + background: color.change(#ffc66d, $alpha: 0.12); + color: #efc575; + > small { + background: color.change(#ffc66d, $alpha: 0.22); + color: #ffd58c; + } + } + &:hover, + &.selected { + z-index: 1; + border-color: color.change(theme.$primary, $alpha: 0.78); + background: color.change(theme.$primary, $alpha: 0.24); + box-shadow: 0 0 0 1px color.change(theme.$primary, $alpha: 0.13); + color: theme.$fg; + } + &:focus-visible { + z-index: 2; + outline: 1px solid color.change(theme.$primary, $alpha: 0.85); + outline-offset: 1px; + } +} + +.virtual-keyboard-extra { + display: flex; + align-items: center; + padding: 0 9px 7px; + gap: 7px; + + > span { + flex: 0 0 auto; + color: color.change(theme.$fg, $alpha: 0.38); + font-size: calc(7px * var(--keybindings-font-scale, 1)); + } + > div { + display: flex; + min-width: 0; + flex-wrap: wrap; + gap: 3px; + } + .virtual-keyboard-key.extra { + width: auto; + min-width: 35px; + padding: 0 7px; + flex: 0 0 auto !important; + } +} + +.virtual-keyboard-detail { + border-top: 1px solid color.change(theme.$fg, $alpha: 0.06); + background: color.change(theme.$bg2, $alpha: 0.2); +} + +.virtual-keyboard-detail-title { + display: flex; + min-height: 30px; + align-items: center; + padding: 4px 8px 4px 10px; + gap: 7px; + box-sizing: border-box; + + > strong { + color: color.adjust(theme.$primary, $lightness: 21%); + font-family: ui-monospace, SFMono-Regular, Consolas, monospace; + font-size: calc(9px * var(--keybindings-font-scale, 1)); + } + > span { + color: theme.$fg3; + font-size: calc(8px * var(--keybindings-font-scale, 1)); + } + > button { + display: grid; + width: 21px; + height: 21px; + min-height: 0; + margin-left: auto; + padding: 0; + place-items: center; + border: 0; + background: transparent; + box-shadow: none; + color: theme.$fg3; + + .icon { + width: 9px; + height: 9px; + } + } +} + +.virtual-keyboard-detail-actions { + display: flex; + max-height: 92px; + padding: 0 9px 8px; + flex-wrap: wrap; + gap: 4px; + overflow-y: auto; + + > div { + display: inline-flex; + min-width: 0; + align-items: center; + padding: 4px 6px; + gap: 5px; + border: 1px solid color.change(theme.$fg, $alpha: 0.065); + border-radius: 5px; + background: color.change(theme.$bg1, $alpha: 0.55); + } + strong { + max-width: 180px; + overflow: hidden; + font-size: calc(8px * var(--keybindings-font-scale, 1)); + text-overflow: ellipsis; + white-space: nowrap; + } + span { + color: theme.$fg3; + font-size: calc(7px * var(--keybindings-font-scale, 1)); + } + code { + color: color.change(theme.$fg, $alpha: 0.5); + font-size: calc(7px * var(--keybindings-font-scale, 1)); + } +} + +@media (max-width: 860px) { + .virtual-keyboard-header { + align-items: flex-start; + flex-direction: column; + gap: 4px; + } + .virtual-keyboard-legend { + align-self: stretch; + } +} diff --git a/src/celemod-ui/src/components/VirtualKeyboard.tsx b/src/celemod-ui/src/components/VirtualKeyboard.tsx new file mode 100644 index 0000000..2d55111 --- /dev/null +++ b/src/celemod-ui/src/components/VirtualKeyboard.tsx @@ -0,0 +1,336 @@ +import { useMemo } from "react"; +import _i18n from "../i18n"; +import { Icon } from "./Icon"; +import "./VirtualKeyboard.scss"; + +export interface VirtualKeyboardBinding { + actionId: string; + label: string; + source: string; + combination: string; + keys: string[]; +} + +interface VirtualKeyboardProps { + bindings: VirtualKeyboardBinding[]; + selectedKey: string; + onSelectKey: (key: string) => void; + formatKey: (key: string) => string; +} + +type KeyboardPlatform = "windows" | "macos" | "linux"; + +interface KeyDefinition { + value: string; + label?: string; + width?: number; + column?: string; + row?: string; +} + +interface UsageAction { + actionId: string; + label: string; + source: string; + combinations: Set; +} + +const detectKeyboardPlatform = (): KeyboardPlatform => { + const platform = `${navigator.platform} ${navigator.userAgent}`.toLowerCase(); + if (platform.includes("mac")) return "macos"; + if (platform.includes("linux")) return "linux"; + return "windows"; +}; + +const key = (value: string, label?: string, width?: number): KeyDefinition => ({ + value, + label, + width, +}); + +const mainRows = (platform: KeyboardPlatform): KeyDefinition[][] => { + const meta = platform === "macos" ? "⌘" : platform === "linux" ? "Super" : "Win"; + const alt = platform === "macos" ? "⌥" : "Alt"; + const control = platform === "macos" ? "⌃" : "Ctrl"; + const backspace = platform === "macos" ? "delete" : "Backspace"; + const enter = platform === "macos" ? "return" : "Enter"; + const caps = platform === "macos" ? "caps lock" : "Caps Lock"; + + return [ + [ + key("OemTilde", "`"), + ...Array.from({ length: 10 }, (_, index) => key(`D${(index + 1) % 10}`, String((index + 1) % 10))), + key("OemMinus", "−"), + key("OemPlus", "="), + key("Back", backspace, 2), + ], + [ + key("Tab", "Tab", 1.5), + ..."QWERTYUIOP".split("").map((value) => key(value)), + key("OemOpenBrackets", "["), + key("OemCloseBrackets", "]"), + key("OemPipe", "\\", 1.5), + ], + [ + key("CapsLock", caps, 1.8), + ..."ASDFGHJKL".split("").map((value) => key(value)), + key("OemSemicolon", ";"), + key("OemQuotes", "'"), + key("Enter", enter, 2.2), + ], + [ + key("LeftShift", "Shift", 2.3), + ..."ZXCVBNM".split("").map((value) => key(value)), + key("OemComma", ","), + key("OemPeriod", "."), + key("OemQuestion", "/"), + key("RightShift", "Shift", 2.7), + ], + platform === "macos" + ? [ + key("LeftControl", control, 1.5), + key("LeftAlt", alt, 1.5), + key("LeftWindows", meta, 1.8), + key("Space", "", 6.4), + key("RightWindows", meta, 1.8), + key("RightAlt", alt, 1.5), + key("RightControl", control, 1.5), + ] + : [ + key("LeftControl", control, 1.4), + key("LeftWindows", meta, 1.4), + key("LeftAlt", alt, 1.4), + key("Space", "", 5.4), + key("RightAlt", alt, 1.4), + key("RightWindows", meta, 1.4), + key("Apps", "Menu", 1.3), + key("RightControl", control, 1.3), + ], + ]; +}; + +const navigationRows = (platform: KeyboardPlatform): (KeyDefinition | null)[][] => [ + [ + key(platform === "macos" ? "Help" : "Insert", platform === "macos" ? "help" : "Insert"), + key("Home"), + key("PageUp", "Page Up"), + ], + [key("Delete"), key("End"), key("PageDown", "Page Down")], + [null, null, null], + [null, key("Up", "↑"), null], + [key("Left", "←"), key("Down", "↓"), key("Right", "→")], +]; + +const numpadKeys = (platform: KeyboardPlatform): KeyDefinition[] => [ + { ...key("NumLock", platform === "macos" ? "clear" : "Num"), column: "1", row: "1" }, + { ...key("Divide", "/"), column: "2", row: "1" }, + { ...key("Multiply", "×"), column: "3", row: "1" }, + { ...key("Subtract", "−"), column: "4", row: "1" }, + { ...key("NumPad7", "7"), column: "1", row: "2" }, + { ...key("NumPad8", "8"), column: "2", row: "2" }, + { ...key("NumPad9", "9"), column: "3", row: "2" }, + { ...key("Add", "+"), column: "4", row: "2 / span 2" }, + { ...key("NumPad4", "4"), column: "1", row: "3" }, + { ...key("NumPad5", "5"), column: "2", row: "3" }, + { ...key("NumPad6", "6"), column: "3", row: "3" }, + { ...key("NumPad1", "1"), column: "1", row: "4" }, + { ...key("NumPad2", "2"), column: "2", row: "4" }, + { ...key("NumPad3", "3"), column: "3", row: "4" }, + { ...key("Enter", "Enter"), column: "4", row: "4 / span 2" }, + { ...key("NumPad0", "0"), column: "1 / span 2", row: "5" }, + { ...key("Decimal", "."), column: "3", row: "5" }, +]; + +const functionGroups = (platform: KeyboardPlatform): KeyDefinition[][] => [ + [key("Escape", "Esc")], + ["F1", "F2", "F3", "F4"].map((value) => key(value)), + ["F5", "F6", "F7", "F8"].map((value) => key(value)), + ["F9", "F10", "F11", "F12"].map((value) => key(value)), + (platform === "macos" ? ["F13", "F14", "F15"] : ["PrintScreen", "Scroll", "Pause"]).map( + (value) => key(value, value === "PrintScreen" ? "Print" : value), + ), +]; + +const platformTitle = (platform: KeyboardPlatform) => + ({ + windows: _i18n.t("Windows 键盘"), + macos: _i18n.t("macOS 键盘"), + linux: _i18n.t("Linux 键盘"), + })[platform]; + +export const VirtualKeyboard = ({ + bindings, + selectedKey, + onSelectKey, + formatKey, +}: VirtualKeyboardProps) => { + const platform = useMemo(detectKeyboardPlatform, []); + const rows = useMemo(() => mainRows(platform), [platform]); + const navRows = useMemo(() => navigationRows(platform), [platform]); + const keypad = useMemo(() => numpadKeys(platform), [platform]); + const functions = useMemo(() => functionGroups(platform), [platform]); + + const usageByKey = useMemo(() => { + const result = new Map>(); + for (const binding of bindings) { + for (const value of new Set(binding.keys)) { + const actions = result.get(value) ?? new Map(); + const action = actions.get(binding.actionId) ?? { + actionId: binding.actionId, + label: binding.label, + source: binding.source, + combinations: new Set(), + }; + action.combinations.add(binding.combination); + actions.set(binding.actionId, action); + result.set(value, actions); + } + } + return result; + }, [bindings]); + + const layoutKeys = useMemo( + () => + new Set( + [ + ...functions.flat(), + ...rows.flat(), + ...navRows.flatMap((row) => row.filter((item): item is KeyDefinition => item !== null)), + ...keypad, + ].map((item) => item.value), + ), + [functions, keypad, navRows, rows], + ); + const usedLayoutKeys = [...layoutKeys].filter((value) => (usageByKey.get(value)?.size ?? 0) > 0).length; + const extraKeys = [...usageByKey.keys()] + .filter((value) => !layoutKeys.has(value)) + .sort((left, right) => left.localeCompare(right)); + const selectedActions = selectedKey ? [...(usageByKey.get(selectedKey)?.values() ?? [])] : []; + + const renderKey = (definition: KeyDefinition, className = "") => { + const count = usageByKey.get(definition.value)?.size ?? 0; + const status = count === 0 ? "unused" : count === 1 ? "used" : "multiple"; + const selected = selectedKey === definition.value; + const label = definition.label ?? formatKey(definition.value); + const accessibleLabel = label || formatKey(definition.value); + const usageLabel = + count === 0 + ? _i18n.t("未使用") + : _i18n.t("{count} 个功能使用此按键", { count }); + return ( + + ); + }; + + return ( +
+
+
+ {_i18n.t("键盘占用")} + {platformTitle(platform)} + + {_i18n.t("{used}/{total} 个键已使用", { + used: usedLayoutKeys, + total: layoutKeys.size, + })} + +
+
+ {_i18n.t("未使用")} + {_i18n.t("单一占用")} + {_i18n.t("多处占用")} +
+
+ +

+ {_i18n.t("点击按键查看占用情况,再次点击取消筛选")} +

+ +
+
+
+ {functions.map((group, index) => ( +
+ {group.map((item) => renderKey(item))} +
+ ))} +
+
+
+ {rows.map((row, index) => ( +
+ {row.map((item) => renderKey(item))} +
+ ))} +
+
+ {navRows.map((row, index) => ( +
+ {row.map((item, itemIndex) => + item ? renderKey(item) : , + )} +
+ ))} +
+
{keypad.map((item) => renderKey(item))}
+
+
+
+ + {extraKeys.length > 0 && ( +
+ {_i18n.t("其它已绑定按键")} +
{extraKeys.map((value) => renderKey(key(value), "extra"))}
+
+ )} + + {selectedKey && ( +
+
+ {formatKey(selectedKey)} + + {selectedActions.length === 0 + ? _i18n.t("没有功能使用此按键") + : _i18n.t("{count} 个功能使用此按键", { count: selectedActions.length })} + + +
+ {selectedActions.length > 0 && ( +
+ {selectedActions.map((action) => ( +
+ {action.label} + {action.source} + {[...action.combinations].join(" / ")} +
+ ))} +
+ )} +
+ )} +
+ ); +}; diff --git a/src/celemod-ui/src/resources/controllers/dualsense.png b/src/celemod-ui/src/resources/controllers/dualsense.png new file mode 100644 index 0000000..d452d34 Binary files /dev/null and b/src/celemod-ui/src/resources/controllers/dualsense.png differ diff --git a/src/celemod-ui/src/resources/controllers/switch-pro.svg b/src/celemod-ui/src/resources/controllers/switch-pro.svg new file mode 100644 index 0000000..bb09b26 --- /dev/null +++ b/src/celemod-ui/src/resources/controllers/switch-pro.svg @@ -0,0 +1,40 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/celemod-ui/src/resources/controllers/xbox-series.png b/src/celemod-ui/src/resources/controllers/xbox-series.png new file mode 100644 index 0000000..ff29b45 Binary files /dev/null and b/src/celemod-ui/src/resources/controllers/xbox-series.png differ diff --git a/src/celemod-ui/src/routes/KeyBindings.tsx b/src/celemod-ui/src/routes/KeyBindings.tsx index 2729c0e..6832587 100644 --- a/src/celemod-ui/src/routes/KeyBindings.tsx +++ b/src/celemod-ui/src/routes/KeyBindings.tsx @@ -8,6 +8,14 @@ import { } from "react"; import _i18n from "../i18n"; import { Icon } from "../components/Icon"; +import { + VirtualKeyboard, + type VirtualKeyboardBinding, +} from "../components/VirtualKeyboard"; +import { + VirtualController, + type VirtualControllerBinding, +} from "../components/VirtualController"; import { createPopup, PopupContext } from "../components/Popup"; import { useCurrentLang, useGamePath } from "../states"; import { callRemote } from "../utils"; @@ -117,6 +125,14 @@ const keyName = (value: string) => { return aliases[value] ?? value; }; +const canonicalKeyboardKey = (value: string) => + ({ + Capital: "CapsLock", + Next: "PageDown", + Prior: "PageUp", + Return: "Enter", + })[value] ?? value; + const keyboardCodeToXna = (code: string) => { if (/^Key[A-Z]$/.test(code)) return code.slice(3); if (/^Digit\d$/.test(code)) return `D${code.slice(5)}`; @@ -234,6 +250,8 @@ export const KeyBindings = () => { const [conflictOnly, setConflictOnly] = useState(false); const [inputMode, setInputMode] = useState("keyboard"); const [selectedConflict, setSelectedConflict] = useState(""); + const [selectedKeyboardKey, setSelectedKeyboardKey] = useState(""); + const [selectedControllerButton, setSelectedControllerButton] = useState(""); const [showDisabled, setShowDisabled] = useState(false); const [saving, setSaving] = useState(""); const requestId = useRef(0); @@ -304,6 +322,34 @@ export const KeyBindings = () => { [consideredEntries], ); + const virtualKeyboardBindings = useMemo( + () => + occurrences + .filter((occurrence) => occurrence.device === "keyboard") + .map((occurrence) => ({ + actionId: occurrence.entryKey, + label: displayLabel(occurrence.entry), + source: occurrence.entry.source, + combination: occurrence.group.map(keyName).join(" + "), + keys: occurrence.group.map(canonicalKeyboardKey), + })), + [occurrences], + ); + + const virtualControllerBindings = useMemo( + () => + occurrences + .filter((occurrence) => occurrence.device === "controller") + .map((occurrence) => ({ + actionId: occurrence.entryKey, + label: displayLabel(occurrence.entry), + source: occurrence.entry.source, + combination: occurrence.group.map(keyName).join(" + "), + buttons: occurrence.group, + })), + [occurrences], + ); + const conflictGroups = useMemo(() => { const grouped = new Map(); for (const occurrence of occurrences) { @@ -377,6 +423,22 @@ export const KeyBindings = () => { return consideredEntries.filter((entry) => { if (source !== "*" && entry.source !== source) return false; if (conflictOnly && !conflictsByEntry.has(entryKey(entry))) return false; + if ( + selectedKeyboardKey && + !entry.keyboard.some((group) => + group.some( + (value) => canonicalKeyboardKey(value) === selectedKeyboardKey, + ), + ) + ) + return false; + if ( + selectedControllerButton && + !entry.controller.some((group) => + group.includes(selectedControllerButton), + ) + ) + return false; if (!normalizedQuery) return true; return [ entry.source, @@ -385,7 +447,15 @@ export const KeyBindings = () => { entry.description ?? "", ].some((value) => value.toLocaleLowerCase().includes(normalizedQuery)); }); - }, [consideredEntries, conflictOnly, conflictsByEntry, query, source]); + }, [ + consideredEntries, + conflictOnly, + conflictsByEntry, + query, + selectedControllerButton, + selectedKeyboardKey, + source, + ]); const saveEntry = useCallback( async ( @@ -748,14 +818,20 @@ export const KeyBindings = () => {