Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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:<userId>
```

版本 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 或导出数据。
24 changes: 19 additions & 5 deletions frontend/src/pages/playtest/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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` 的避让空间。
1 change: 1 addition & 0 deletions frontend/src/pages/playtest/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -168,6 +168,7 @@ export function PlaytestPage({ renderToolbar }: PlaytestPageProps = {}) {
outfitId={outfitId}
movementMode={data.project.directionalMovement}
initialActionId={initialActionId}
userId={recentOwnerId}
toolbar={toolbar}
/>
)
Expand Down
21 changes: 20 additions & 1 deletion frontend/src/pages/playtest/workbench/bindings.test.ts
Original file line number Diff line number Diff line change
@@ -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 {
Expand Down Expand Up @@ -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',
})
})
})
26 changes: 26 additions & 0 deletions frontend/src/pages/playtest/workbench/bindings.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { hasPlayableFrames, type PlaytestAction } from './model'
import type { PlaytestPreferences } from './preferences'

export const PLAYTEST_CONTROL_KEYS = ['space', 'shift'] as const

Expand All @@ -7,6 +8,10 @@ export type PlaytestActionBindings = Readonly<Record<PlaytestControlKey, string

const CROUCH_TYPES = new Set(['crouch', 'duck', 'squat'])

export function playtestPreferenceActionType(actionType: string): string {
return CROUCH_TYPES.has(actionType) ? 'crouch' : actionType
}

export function createDefaultActionBindings(
actions: readonly PlaytestAction[],
): PlaytestActionBindings {
Expand All @@ -16,6 +21,27 @@ export function createDefaultActionBindings(
}
}

export function resolvePlaytestActionBindings(
actions: readonly PlaytestAction[],
preferences: PlaytestPreferences,
): PlaytestActionBindings {
return {
space:
findActionByPreferenceType(actions, preferences.bindings.primary_action.actionType ?? null)
?.id ?? null,
shift:
findActionByPreferenceType(actions, preferences.bindings.secondary_action.actionType ?? null)
?.id ?? null,
}
}

function findActionByPreferenceType(actions: readonly PlaytestAction[], actionType: string | null) {
if (actionType === null) return undefined
return findAction(actions, (action) => {
return playtestPreferenceActionType(action.type) === actionType
})
}

function findAction(
actions: readonly PlaytestAction[],
predicate: (action: PlaytestAction) => boolean,
Expand Down
Loading
Loading