diff --git a/docs/superpowers/specs/2026-08-21-playtest-account-keybindings-design.md b/docs/superpowers/specs/2026-08-21-playtest-account-keybindings-design.md new file mode 100644 index 00000000..322b7354 --- /dev/null +++ b/docs/superpowers/specs/2026-08-21-playtest-account-keybindings-design.md @@ -0,0 +1,145 @@ +# Playtest 本地账号键位设计 + +## 目标与真实能力边界 + +在后端不提供偏好接口的现状下,让登录用户在同一浏览器中修改一次 Playtest 键位后,切换角色、造型或重新登录仍使用同一套配置。 + +配置通过 JWT 用户 ID 分区保存在浏览器 `localStorage`。它不是服务端账号同步:清理浏览器数据、使用无痕窗口、换浏览器或换设备都会丢失或无法读取。页面只描述“已保存到此浏览器”,不得显示“已同步到账号”或“跨设备可用”。 + +本次同时修复三类现有体验问题:当前方向缺少动作素材时自动改朝向、键帽交互过于方格化、当前帧图片加载失败时没有可见反馈。 + +## 范围 + +### 包含 + +- 本地保存上、下、左、右、主动作、次动作六个控制位。 +- 使用当前登录用户的 JWT `sub` 作为本地存储命名空间。 +- 点击键帽后捕获下一次物理键盘输入。 +- 使用 `KeyboardEvent.code` 保存物理位置,显示时使用可读标签。 +- 主、次动作保存 Action `type`,运行时在当前角色中解析为首个可播放的同类型动作。 +- 键位冲突自动交换,`Escape` 取消捕获,`Delete` 或 `Backspace` 清除非移动控制位。 +- 当前角色没有对应动作,或对应动作没有当前朝向的可播放帧时,控制位禁用且不改变朝向。 +- 当前播放帧加载失败时展示错误和重试入口。 +- 键帽和动作选择改为柔和胶囊、轻微缩放及低强度光晕,并支持减少动态效果设置。 + +### 不包含 + +- 任何后端模型、接口、OpenAPI 或数据库改动。 +- 跨浏览器、跨设备同步。 +- 组合键、宏、连招、手柄和触屏手势编辑。 +- 每个项目、角色或造型单独覆盖本地账号配置。 +- 修改 Character、WorkflowRun、Generation 或导出数据。 +- mock 服务端保存、伪造同步成功或隐藏本地存储限制。 + +## 存储设计 + +### 接口边界 + +新增前端内部 `PlaytestPreferenceRepository`: + +- `read(userId)`:读取并校验当前用户配置;无配置或配置损坏时返回默认配置。 +- `write(userId, preferences)`:同步写入浏览器存储;写入失败时返回明确失败结果。 +- `remove(userId)`:删除当前用户的本地自定义配置并恢复默认值。 + +默认实现只使用浏览器 `localStorage`。Playtest 页面只依赖这个窄接口;未来如果有真实后端接口,可以增加服务端实现,但本次不创建空 API、假请求或未配置成功分支。 + +### 命名空间与数据结构 + +存储键: + +```text +windup.playtest.keybindings.v1: +``` + +版本 1 数据: + +```json +{ + "version": 1, + "bindings": { + "move_up": { "code": "KeyW" }, + "move_down": { "code": "KeyS" }, + "move_left": { "code": "KeyA" }, + "move_right": { "code": "KeyD" }, + "primary_action": { "code": "Space", "actionType": "jump" }, + "secondary_action": { "code": "ShiftLeft", "actionType": "crouch" } + } +} +``` + +移动控制位必须有键;动作控制位允许 `code` 或 `actionType` 为 `null`。读取时拒绝未知版本、重复键位、空字符串和非法结构,直接恢复内置默认配置,不尝试猜测或迁移损坏数据。 + +用户身份使用现有 JWT `sub` 解析方法。未取得合法用户 ID 时不读写持久化配置,只在当前页面使用默认值。 + +## 前端运行设计 + +### 页面状态 + +页面初始化时读取当前用户的本地配置;角色模型变化时重新按 Action type 解析动作,但不重置键位。 + +修改后同步写入本地存储: + +- 成功显示“已保存到此浏览器”。 +- 浏览器拒绝存储或容量异常时,当前会话仍使用新配置,但显示“仅本次有效,浏览器未保存”。 +- 提供“恢复默认键位”,删除当前用户的本地配置。 + +不存在网络保存、加载骨架、乐观请求或伪造的服务端同步状态。 + +### 键盘捕获 + +用户点击键帽后进入捕获模式: + +- 普通按键以 `event.code` 绑定到选中的控制位。 +- 新键已被其他控制位占用时,两个控制位交换,保证移动键不会意外丢失。 +- `Escape` 取消,不修改配置。 +- `Delete` 或 `Backspace` 只允许清除主、次动作;移动控制位保持可操作。 +- 捕获期间阻止该次按键触发角色动作。 +- 输入框、文本域、下拉框和可编辑元素仍不触发 Playtest 控制。 + +默认按键为 W、A、S、D、Space、左 Shift。运行时不再硬编码这些键,而是从当前偏好反向建立 `code -> command` 映射。 + +### 动作解析和方向约束 + +主、次动作保存 `actionType`。每次载入角色时,从当前造型中选择首个具有可播放帧的同类型动作;不存在时控制位禁用。 + +触发动作时只接受当前 facing 对应的 playback。缺少该方向时不执行、不切换 facing,并在控制位上显示“当前方向无素材”。动作列表中的直接选择同样遵循该规则。 + +移动方向缺少 locomotion playback 时,现有方向按钮和键盘输入都保持无效,不猜测其他方向。 + +### 图片失败 + +舞台按 `frame.imageUrl` 记录当前失败状态。`img` 的 `onError` 显示“当前帧加载失败”,停止展示破图;重试会清除失败状态并重新加载同一 URL。切换到不同帧时自动清除旧帧错误。 + +预加载失败不阻塞页面,但当前帧真正渲染失败必须可见。此错误不写入 Character 或 WorkflowRun。 + +### 视觉与无障碍 + +- 键帽使用圆角胶囊而非方格,按下时仅做 1–2px 位移、轻微缩放和柔和阴影。 +- 捕获态使用脉冲描边和“请按新键”文本,不依赖颜色表达状态。 +- 所有控制位有可读名称、`aria-pressed`、`aria-disabled` 和本地保存状态播报。 +- `prefers-reduced-motion` 下关闭脉冲与缩放动画。 + +## 测试策略 + +- 不同用户本地配置隔离。 +- 无配置、损坏 JSON、未知版本和浏览器拒绝写入。 +- 点击键帽后捕获 `event.code`。 +- 冲突交换、取消和清除规则。 +- 切换角色后按 Action type 重新解析,不复用旧 Action ID。 +- 缺少动作或当前方向素材时禁用且 facing 不变。 +- 自定义键位驱动四/八向移动,斜向速度继续归一化。 +- 当前帧图片失败提示与重试。 +- 键盘监听测试放在运行时 Hook;页面集成测试使用可见按钮,避免 `useEffect` 监听挂载竞态。 +- 全量前端测试、类型检查、格式、lint 与构建。 + +## 验收标准 + +1. 同一浏览器内,用户修改键位后刷新、退出再登录或切换角色仍保留配置。 +2. 同一浏览器中的不同账号互不读取对方配置。 +3. 页面明确显示配置只保存在此浏览器,不宣称跨设备同步。 +4. 切换不同角色后,键位仍对应同一动作类型;角色没有该动作时控制位不可用。 +5. 当前方向缺少动作素材时,按键不会改变朝向或播放其他方向素材。 +6. 任一物理键只对应一个控制位,冲突交换可预测。 +7. 本地写入失败不会展示虚假的“已保存”。 +8. 图片失败有明确提示和重试,不出现无解释的空白舞台。 +9. Playtest 仍只读 Character,不修改 WorkflowRun、Generation、Character 或导出数据。 diff --git a/frontend/src/pages/playtest/README.md b/frontend/src/pages/playtest/README.md index 1adc6d7c..d89451a9 100644 --- a/frontend/src/pages/playtest/README.md +++ b/frontend/src/pages/playtest/README.md @@ -24,15 +24,29 @@ Character、Outfit、Action、Frame 或后端数据。这几条由 `playtest-bou ## 操控 - 页面绑定当前造型下全部有帧的动作,点击动作名可以直接切换。 -- 默认将 jump 分配给 Space、crouch/duck/squat 分配给 Shift,并将首个 walk(没有时取 - run)同时分配给 A / D;当前预览会话可以在舞台左上角重新分配四个按键。 -- A / D 或 ← / → 始终可以切换角色朝向。只有当前动作类型是 walk 或 run 时,角色才会按 - 150 px/s 连续移动;待机、跳跃、攻击和自定义动作只播放帧,不产生水平位移。 -- 没有可分配动作的 Space / Shift 控件禁用;A / D 即使未分配动作也保留转向能力。 +- 上、下、左、右、主动作和次动作共六个控制位都可修改。点击圆角键帽后按下目标物理键即可 + 重新绑定;键位冲突时交换原键位,`Escape` 取消,`Delete` 或 `Backspace` 可清除动作键。 +- 默认键位是 W、S、A、D、Space 和左 Shift。四个方向键驱动四向或八向移动;只有当前动作 + 类型是 walk 或 run 时,角色才会按 150 px/s 连续移动,斜向速度保持归一化。 +- 主、次动作按 Action `type` 保存:默认把 jump 分配给主动作,把 crouch/duck/squat 归一为 + crouch 分配给次动作。切换角色或造型时会在新资产中重新查找同类型动作,不复用旧 Action ID。 +- 当前角色没有对应动作,或动作没有当前朝向的帧时,相关控制禁用;不会自动切换到有素材的 + 其他朝向。 - 松开所有横向按键后,存在 idle 动作时切回首个 idle 动作,找不到时保持当前动作。 - 所有动作帧进入页面时统一预加载;切换动作只重置帧游标,不重复请求素材。动作帧按各自 时长播放,角色位移使用 `requestAnimationFrame` 的真实时间差计算,两者互不耦合。 - 舞台按实际宽度限制移动范围;窗口尺寸变化时重新计算。 +- 当前帧图片真实加载失败时显示错误;点击“重试当前帧”会重新请求同一资源,不用占位图伪装 + 成功。 + +## 键位保存边界 + +登录后,键位按 JWT 用户 ID 隔离保存在当前浏览器 `localStorage`,页面明确显示“已保存到此 +浏览器”。刷新、重新登录或切换角色仍会读取本地配置;同一浏览器中的不同账号互不读取。 + +这不是服务端账号同步:换浏览器、换设备、使用无痕窗口或清理浏览器数据后不会保留。未取得 +有效用户 ID 或浏览器拒绝存储时,设置只在本次页面会话有效,界面会明确提示“浏览器未保存”。 +本模块没有新增后端接口、mock 保存请求或伪造同步成功。 视觉复用 livedemo 的白色透明棋盘画布、悬浮控制胶囊和动作状态层级,代码使用项目现有的 React、TypeScript 与 Tailwind。顶栏悬浮不占布局高度,页面自己让出 `pt-24` 的避让空间。 diff --git a/frontend/src/pages/playtest/index.tsx b/frontend/src/pages/playtest/index.tsx index 6a6a25c9..148a78c1 100644 --- a/frontend/src/pages/playtest/index.tsx +++ b/frontend/src/pages/playtest/index.tsx @@ -168,6 +168,7 @@ export function PlaytestPage({ renderToolbar }: PlaytestPageProps = {}) { outfitId={outfitId} movementMode={data.project.directionalMovement} initialActionId={initialActionId} + userId={recentOwnerId} toolbar={toolbar} /> ) diff --git a/frontend/src/pages/playtest/workbench/bindings.test.ts b/frontend/src/pages/playtest/workbench/bindings.test.ts index 6384a0cb..3e0f9fb9 100644 --- a/frontend/src/pages/playtest/workbench/bindings.test.ts +++ b/frontend/src/pages/playtest/workbench/bindings.test.ts @@ -1,7 +1,8 @@ import { describe, expect, it } from 'vitest' import type { PlaytestAction } from './model' -import { createDefaultActionBindings } from './bindings' +import { createDefaultActionBindings, resolvePlaytestActionBindings } from './bindings' +import { DEFAULT_PLAYTEST_PREFERENCES, setPlaytestActionType } from './preferences' function action(id: string, type: string): PlaytestAction { return { @@ -67,4 +68,22 @@ describe('playtest action bindings', () => { shift: 'directional-crouch', }) }) + + it('resolves persisted action types against each character instead of reusing action ids', () => { + const preferences = setPlaytestActionType( + DEFAULT_PLAYTEST_PREFERENCES, + 'primary_action', + 'attack', + ) + + expect( + resolvePlaytestActionBindings( + [action('character-2-attack', 'attack'), action('character-2-duck', 'duck')], + preferences, + ), + ).toEqual({ + space: 'character-2-attack', + shift: 'character-2-duck', + }) + }) }) diff --git a/frontend/src/pages/playtest/workbench/bindings.ts b/frontend/src/pages/playtest/workbench/bindings.ts index e6473779..30ca0488 100644 --- a/frontend/src/pages/playtest/workbench/bindings.ts +++ b/frontend/src/pages/playtest/workbench/bindings.ts @@ -1,4 +1,5 @@ import { hasPlayableFrames, type PlaytestAction } from './model' +import type { PlaytestPreferences } from './preferences' export const PLAYTEST_CONTROL_KEYS = ['space', 'shift'] as const @@ -7,6 +8,10 @@ export type PlaytestActionBindings = Readonly { + return playtestPreferenceActionType(action.type) === actionType + }) +} + function findAction( actions: readonly PlaytestAction[], predicate: (action: PlaytestAction) => boolean, diff --git a/frontend/src/pages/playtest/workbench/index.tsx b/frontend/src/pages/playtest/workbench/index.tsx index f9d227ff..981285bf 100644 --- a/frontend/src/pages/playtest/workbench/index.tsx +++ b/frontend/src/pages/playtest/workbench/index.tsx @@ -1,13 +1,25 @@ -import { useEffect, useMemo, useState, type PointerEvent, type ReactNode } from 'react' +import { useCallback, useEffect, useMemo, useState, type PointerEvent, type ReactNode } from 'react' import type { Character, DirectionalMovement } from '@/entities' import { - createDefaultActionBindings, + playtestPreferenceActionType, PLAYTEST_CONTROL_KEYS, + resolvePlaytestActionBindings, type PlaytestControlKey, } from './bindings' import { createPlaytestModel, type PlaytestModel } from './model' +import { + PLAYTEST_COMMANDS, + readPlaytestPreferences, + rebindPlaytestCommand, + removePlaytestPreferences, + setPlaytestActionType, + writePlaytestPreferences, + type PlaytestActionCommand, + type PlaytestCommand, + type PlaytestPreferences, +} from './preferences' import { usePlaytestRuntime } from './runtime/use-playtest-runtime' import { playbackForFacing, type MovementDirection } from './runtime/runtime' import { PlaytestStage } from './stage' @@ -16,51 +28,75 @@ export interface PlaytestWorkbenchProps { readonly character: Character readonly outfitId: string readonly movementMode: DirectionalMovement + readonly userId?: string | null readonly initialActionId?: string | null readonly toolbar?: ReactNode } -const controlLabels: Readonly> = { - space: '空格键', - shift: 'Shift 键', -} - -const controlKeyLabels: Readonly> = { - space: 'Space', - shift: 'Shift', -} - -const assignmentLabels: Readonly> = { - space: '空格键分配动作', - shift: 'Shift 分配动作', -} - -const assignmentKeyWidths: Readonly> = { - space: 'w-14', - shift: 'w-12', -} - -const controlButtonWidths: Readonly> = { - space: 'w-16', - shift: 'w-14', +const actionControls: Readonly< + Record +> = { + space: { command: 'primary_action', label: '主动作' }, + shift: { command: 'secondary_action', label: '次动作' }, } const movementControls: readonly { - key: 'w' | 'a' | 's' | 'd' + command: Extract direction: MovementDirection label: string gridClass: string }[] = [ - { key: 'w', direction: 'up', label: '向上移动', gridClass: 'col-start-2 row-start-1' }, - { key: 'a', direction: 'left', label: '向左移动', gridClass: 'col-start-1 row-start-2' }, - { key: 's', direction: 'down', label: '向下移动', gridClass: 'col-start-2 row-start-2' }, - { key: 'd', direction: 'right', label: '向右移动', gridClass: 'col-start-3 row-start-2' }, + { + command: 'move_up', + direction: 'up', + label: '向上移动', + gridClass: 'col-start-2 row-start-1', + }, + { + command: 'move_left', + direction: 'left', + label: '向左移动', + gridClass: 'col-start-1 row-start-2', + }, + { + command: 'move_down', + direction: 'down', + label: '向下移动', + gridClass: 'col-start-2 row-start-2', + }, + { + command: 'move_right', + direction: 'right', + label: '向右移动', + gridClass: 'col-start-3 row-start-2', + }, ] +const commandLabels: Readonly> = { + move_up: '向上移动', + move_down: '向下移动', + move_left: '向左移动', + move_right: '向右移动', + primary_action: '主动作', + secondary_action: '次动作', +} + +function keyLabel(code: string | null): string { + if (code === null) return '未绑定' + if (code.startsWith('Key')) return code.slice(3) + if (code.startsWith('Digit')) return code.slice(5) + if (code === 'Space') return 'Space' + if (code === 'ShiftLeft') return 'L Shift' + if (code === 'ShiftRight') return 'R Shift' + if (code.startsWith('Arrow')) return code.slice(5) + return code +} + export function PlaytestWorkbench({ character, outfitId, movementMode, + userId = null, initialActionId = null, toolbar = null, }: PlaytestWorkbenchProps) { @@ -83,6 +119,7 @@ export function PlaytestWorkbench({ @@ -92,21 +129,87 @@ export function PlaytestWorkbench({ function PlaytestExperience({ model, movementMode, + userId, toolbar, initialActionId, }: { readonly model: PlaytestModel readonly movementMode: DirectionalMovement + readonly userId: string | null readonly toolbar: ReactNode readonly initialActionId: string | null }) { - const [bindings, setBindings] = useState(() => createDefaultActionBindings(model.actions)) - const runtime = usePlaytestRuntime(model.actions, initialActionId, movementMode, bindings) + const [preferences, setPreferences] = useState(() => + readPlaytestPreferences(userId ?? ''), + ) + const [capturing, setCapturing] = useState(null) + const [persistenceMessage, setPersistenceMessage] = useState( + userId === null ? '仅本次有效,未找到登录账号' : '键位仅保存在此浏览器', + ) + const bindings = useMemo( + () => resolvePlaytestActionBindings(model.actions, preferences), + [model.actions, preferences], + ) + const runtime = usePlaytestRuntime(model.actions, initialActionId, movementMode, bindings, { + preferences, + keyboardEnabled: capturing === null, + }) const locomotion = model.actions.find((action) => action.type === 'walk' || action.type === 'run') + const actionChoices = useMemo(() => { + const choices = new Map() + for (const action of model.actions) { + const type = playtestPreferenceActionType(action.type) + if (!choices.has(type)) choices.set(type, action.name) + } + return [...choices].map(([type, name]) => ({ type, name })) + }, [model.actions]) useEffect(() => { - setBindings(createDefaultActionBindings(model.actions)) - }, [model.actions]) + setPreferences(readPlaytestPreferences(userId ?? '')) + setPersistenceMessage(userId === null ? '仅本次有效,未找到登录账号' : '键位仅保存在此浏览器') + setCapturing(null) + }, [userId]) + + const applyPreferences = useCallback( + (next: PlaytestPreferences) => { + setPreferences(next) + const saved = userId !== null && writePlaytestPreferences(userId, next) + setPersistenceMessage(saved ? '已保存到此浏览器' : '仅本次有效,浏览器未保存') + }, + [userId], + ) + + useEffect(() => { + if (capturing === null) return + const captureKey = (event: globalThis.KeyboardEvent) => { + event.preventDefault() + event.stopPropagation() + if (event.code === 'Escape') { + setCapturing(null) + return + } + if (event.code === 'Delete' || event.code === 'Backspace') { + if (capturing === 'primary_action' || capturing === 'secondary_action') { + applyPreferences(rebindPlaytestCommand(preferences, capturing, null)) + setCapturing(null) + } + return + } + if (!event.code) return + applyPreferences(rebindPlaytestCommand(preferences, capturing, event.code)) + setCapturing(null) + } + window.addEventListener('keydown', captureKey, true) + return () => window.removeEventListener('keydown', captureKey, true) + }, [applyPreferences, capturing, preferences]) + + const resetPreferences = () => { + const next = readPlaytestPreferences('', null) + setPreferences(next) + const removed = userId !== null && removePlaytestPreferences(userId) + setPersistenceMessage(removed ? '已恢复此浏览器默认键位' : '仅本次有效,浏览器未保存') + setCapturing(null) + } const holdControl = (key: PlaytestControlKey, pressed: boolean, source: string) => { runtime.setControl(key, pressed, source) @@ -147,7 +250,12 @@ function PlaytestExperience({
-

W / A / S / D 移动 · Space 跳跃 · Shift 下蹲

+

+ {keyLabel(preferences.bindings.move_up.code)} /{' '} + {keyLabel(preferences.bindings.move_left.code)} /{' '} + {keyLabel(preferences.bindings.move_down.code)} /{' '} + {keyLabel(preferences.bindings.move_right.code)} 移动 +

{toolbar}
@@ -168,7 +276,7 @@ function PlaytestExperience({ @@ -238,7 +386,7 @@ function PlaytestExperience({ className="absolute bottom-4 left-1/2 flex -translate-x-1/2 items-center gap-3 rounded-2xl border border-app-surface-raised/60 bg-app-surface/95 p-2 shadow-app-float backdrop-blur-2xl sm:bottom-5" >
- {movementControls.map(({ key, direction, label, gridClass }) => { + {movementControls.map(({ command, direction, label, gridClass }) => { const pressed = runtime.runtime.held[direction] const facing = direction === 'left' @@ -257,7 +405,7 @@ function PlaytestExperience({ return ( ) })} @@ -294,15 +442,18 @@ function PlaytestExperience({