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({