From 09c556093e2da2b571a5a48208d0d3c725f95d64 Mon Sep 17 00:00:00 2001 From: xyh202131 <246811510+xyh202131@users.noreply.github.com> Date: Fri, 21 Aug 2026 17:50:39 +0800 Subject: [PATCH 1/2] =?UTF-8?q?feat(playtest):=20=E6=94=AF=E6=8C=81?= =?UTF-8?q?=E8=B4=A6=E5=8F=B7=E7=BA=A7=E6=9C=AC=E5=9C=B0=E9=94=AE=E4=BD=8D?= =?UTF-8?q?=E9=85=8D=E7=BD=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- frontend/src/pages/playtest/index.tsx | 1 + .../pages/playtest/workbench/bindings.test.ts | 21 +- .../src/pages/playtest/workbench/bindings.ts | 26 ++ .../src/pages/playtest/workbench/index.tsx | 295 +++++++++++++----- .../workbench/minimal-workbench.test.tsx | 103 +++++- .../playtest/workbench/preferences.test.ts | 126 ++++++++ .../pages/playtest/workbench/preferences.ts | 189 +++++++++++ .../workbench/runtime/runtime.test.ts | 6 +- .../playtest/workbench/runtime/runtime.ts | 8 +- .../runtime/use-playtest-runtime.test.ts | 35 +++ .../workbench/runtime/use-playtest-runtime.ts | 61 +++- .../pages/playtest/workbench/stage.test.tsx | 42 +++ .../src/pages/playtest/workbench/stage.tsx | 33 +- 13 files changed, 842 insertions(+), 104 deletions(-) create mode 100644 frontend/src/pages/playtest/workbench/preferences.test.ts create mode 100644 frontend/src/pages/playtest/workbench/preferences.ts 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({