Skip to content
Merged
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
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