diff --git a/frontend/src/pages/playtest/README.md b/frontend/src/pages/playtest/README.md new file mode 100644 index 00000000..bf152f63 --- /dev/null +++ b/frontend/src/pages/playtest/README.md @@ -0,0 +1,35 @@ +# Playtest + +核验台只负责一件事:用角色造型中已经确认的动作帧,真实操控当前角色。 + +## 入口 + +`/playtest/:characterId/:outfitId`,可选 `?actionId=` 指定打开时绑定的动作。页面通过 +`@/entities` 的 `characterApis.get` 读取 `Character`,后端地址沿用 `VITE_API_BASE_URL`。 + +读取失败或造型不存在时显示错误,不回退到任何内置数据。仓库里也不存放演示素材,正式路径的 +每一帧都来自后端。 + +## 页面边界 + +Playtest 位于 `pages/playtest`,只依赖 `@/entities` 的 Character 公开类型与读取接口,以及 +`@/shared/api` 的错误类型。页面不导入 features、不碰 Workflow 与 Generation,也不修改 +Character、Outfit、Action、Frame 或后端数据。这几条由 `playtest-boundaries.test.ts` 看着。 + +`createPlaytestModel` 是页面内的窄适配器,只留下动作 ID、名称、类型、帧图片和播放时长。 +播放顺序按 `Frame.index` 排定,不用数组下标——后端整棵下发资产树,数组顺序没有契约保证。 +帧自己的 `durationMs` 优先,缺失时才按所属 Action 的 `fps` 换算;两者都不可用时使用第三级 +兜底 `DEFAULT_FRAME_DURATION_MS` 的 100ms。审核、导出、时间线和生成字段不进入运行时。 + +## 操控 + +- 页面绑定当前造型下全部有帧的动作,点击动作名可以直接切换。 +- 按住 A / D 或 ← / → 时,角色按 150 px/s 连续移动;存在 walk 动作时切到首个 walk 动作, + 找不到时保持当前动作、移动照常。 +- 松开所有横向按键后,存在 idle 动作时切回首个 idle 动作,找不到时保持当前动作。 +- 动作帧按各自时长循环,角色位移使用 `requestAnimationFrame` 的真实时间差计算,两者互不 + 耦合。 +- 舞台按实际宽度限制移动范围;窗口尺寸变化时重新计算。 + +视觉复用 livedemo 的白色透明棋盘画布、悬浮控制胶囊和动作状态层级,代码使用项目现有的 +React、TypeScript 与 Tailwind。顶栏悬浮不占布局高度,页面自己让出 `pt-24` 的避让空间。 diff --git a/frontend/src/pages/playtest/index.test.tsx b/frontend/src/pages/playtest/index.test.tsx new file mode 100644 index 00000000..148dc8e6 --- /dev/null +++ b/frontend/src/pages/playtest/index.test.tsx @@ -0,0 +1,99 @@ +// @vitest-environment jsdom +import { cleanup, fireEvent, render, screen } from '@testing-library/react' +import { MemoryRouter } from 'react-router' +import { afterEach, describe, expect, it, vi } from 'vitest' + +import { AppRoutes } from '@/app' +import { createProjectAssetsBackend } from '@/test/project-assets-backend' + +afterEach(() => { + cleanup() + vi.unstubAllEnvs() + vi.unstubAllGlobals() +}) + +/** + * 停掉动画循环,让断言只看某一次输入之后的静止状态。 + * 逐帧推进本身由 runtime 的纯函数测试覆盖,不靠页面测试的时序碰运气。 + */ +function freezeAnimationFrame() { + vi.stubGlobal('requestAnimationFrame', () => 0) + vi.stubGlobal('cancelAnimationFrame', () => undefined) +} + +function renderPlaytest(path: string, fetchFn?: typeof globalThis.fetch) { + freezeAnimationFrame() + vi.stubEnv('VITE_API_BASE_URL', 'https://api.windup.test') + vi.stubGlobal('fetch', fetchFn ?? createProjectAssetsBackend().fetch) + return render( + + + , + ) +} + +function stageFrameUrl() { + return screen.getByRole('region', { name: '试玩舞台' }).querySelector('img')?.getAttribute('src') +} + +describe('PlaytestPage', () => { + it('loads the routed character through the Character API', async () => { + renderPlaytest('/playtest/51/outfit-default') + + expect(screen.getByText('加载 Playtest 数据中')).toBeTruthy() + expect(await screen.findByRole('heading', { name: '51 · 常态造型' })).toBeTruthy() + expect(screen.getByRole('button', { name: '绑定动作:呼吸待机' })).toBeTruthy() + expect(screen.getByRole('button', { name: '绑定动作:行走' })).toBeTruthy() + }) + + it('plays frames in backend index order, not array order', async () => { + renderPlaytest('/playtest/51/outfit-default') + + expect(await screen.findByRole('heading', { name: '51 · 常态造型' })).toBeTruthy() + // 后端给的 walk 帧顺序是 index 2、0、1;照数组播会从 walk-03 起步。 + fireEvent.click(screen.getByRole('button', { name: '绑定动作:行走' })) + + expect(stageFrameUrl()).toBe('https://cdn.windup.test/walk-01.png') + }) + + it('starts on the idle action when the route names none', async () => { + renderPlaytest('/playtest/51/outfit-default') + + expect(await screen.findByRole('heading', { name: '51 · 常态造型' })).toBeTruthy() + expect(stageFrameUrl()).toBe('https://cdn.windup.test/idle-01.png') + }) + + it('opens on the action named by the route query', async () => { + renderPlaytest('/playtest/51/outfit-default?actionId=walk') + + expect(await screen.findByRole('heading', { name: '51 · 常态造型' })).toBeTruthy() + expect(stageFrameUrl()).toBe('https://cdn.windup.test/walk-01.png') + }) + + it('reports a missing outfit instead of falling back to another one', async () => { + renderPlaytest('/playtest/51/outfit-missing') + + expect(await screen.findByText('找不到指定造型,无法进入试玩。')).toBeTruthy() + }) + + it('maps the business not-found code to a stable message', async () => { + renderPlaytest('/playtest/9999/outfit-default') + + expect(await screen.findByText('角色不存在')).toBeTruthy() + }) + + it('does not mislabel a transport failure as not found', async () => { + renderPlaytest('/playtest/51/outfit-default', () => + Promise.reject(new TypeError('network unavailable')), + ) + + expect(await screen.findByText('角色读取失败')).toBeTruthy() + }) + + it('shows an empty stage for an outfit whose actions have no frames', async () => { + renderPlaytest('/playtest/52/outfit-draft') + + expect(await screen.findByRole('heading', { name: '52 · 未命名造型' })).toBeTruthy() + expect(screen.getByText('暂无可播放帧')).toBeTruthy() + }) +}) diff --git a/frontend/src/pages/playtest/index.tsx b/frontend/src/pages/playtest/index.tsx index 8e5827d3..5185ff9b 100644 --- a/frontend/src/pages/playtest/index.tsx +++ b/frontend/src/pages/playtest/index.tsx @@ -1,13 +1,81 @@ -import { PageContainer } from '@/shared/ui' +import { useEffect, useState } from 'react' +import { useParams, useSearchParams } from 'react-router' -/** 核验台。 */ +import { characterApis, type Character } from '@/entities' +import { ApiError } from '@/shared/api' + +import { PlaytestWorkbench } from './workbench' + +interface PageData { + character: Character | null + error: string | null + loading: boolean +} + +const initialPageData: PageData = { character: null, error: null, loading: false } + +/** + * 后端把「角色不存在」表达成 HTTP 200 里的业务码 404,真正的传输失败才落在 status 上。 + * 两者都要认:只看其中一个,会把找不到的角色显示成读取失败,或者反过来。 + */ +function isNotFoundError(error: unknown): boolean { + return error instanceof ApiError && (error.code === 404 || error.status === 404) +} + +/** + * 核验台:用角色造型里已经确认的动作帧真实操控角色。 + * 页面只读 Character,不写回资产树,也不参与生成与审核。 + * 读不到角色时直接报错,不退回任何内置数据。 + */ export function PlaytestPage() { + const { characterId, outfitId } = useParams() + const [searchParams] = useSearchParams() + const initialActionId = searchParams.get('actionId') + const [data, setData] = useState(initialPageData) + + useEffect(() => { + if (characterId === undefined) return + + let cancelled = false + setData({ ...initialPageData, loading: true }) + void characterApis.get(characterId).then( + (character) => { + if (!cancelled) setData({ character, error: null, loading: false }) + }, + (error: unknown) => { + if (!cancelled) { + setData({ + ...initialPageData, + error: isNotFoundError(error) ? '角色不存在' : '角色读取失败', + }) + } + }, + ) + + return () => { + cancelled = true + } + }, [characterId]) + + if (characterId === undefined || outfitId === undefined) + return Playtest 路由参数不完整 + if (data.error !== null) return {data.error} + if (data.loading || data.character === null) + return 加载 Playtest 数据中 + + return ( + + ) +} + +function PlaytestPageMessage({ children }: { children: string }) { return ( - - - 核验台 - 本次只提交模块划分与接口,页面实现进后续 PR。 - - + + {children} + ) } diff --git a/frontend/src/pages/playtest/playtest-boundaries.test.ts b/frontend/src/pages/playtest/playtest-boundaries.test.ts new file mode 100644 index 00000000..44cab436 --- /dev/null +++ b/frontend/src/pages/playtest/playtest-boundaries.test.ts @@ -0,0 +1,119 @@ +/// + +import { readdirSync, readFileSync } from 'node:fs' +import { fileURLToPath } from 'node:url' +import { describe, expect, it } from 'vitest' +import { dirname, isAbsolute, join, relative, resolve } from 'node:path' + +const playtestDirectory = fileURLToPath(new URL('.', import.meta.url)) +const sourceDirectory = fileURLToPath(new URL('../../', import.meta.url)) +const entitiesDirectory = fileURLToPath(new URL('../../entities/', import.meta.url)) + +/** + * 依赖只向下走:pages → features → entities → shared。 + * Playtest 只读已确认的角色资产,用不到 features,因此这里连 features 一起挡住—— + * 一旦有人想在核验台里推进流程或改资产,会先在这条测试上停下来讨论,而不是默默接上去。 + */ +const forbiddenLayers = ['app', 'pages', 'features'] as const + +/** 架构规则:entities 统一从 `@/entities` 这一个门进,不走模块内部路径。 */ +const allowedEntityEntry = '@/entities' + +/** 核验台不接受随代码入库的素材:正式路径的每一帧都必须来自后端。 */ +const forbiddenAssetExtensions = /\.(?:png|jpe?g|gif|webp|avif|svg|mp4|webm)$/i + +function filesUnder(directory: string, keep: (name: string) => boolean): readonly string[] { + return readdirSync(directory, { withFileTypes: true }).flatMap((entry) => { + const path = join(directory, entry.name) + if (entry.isDirectory()) return filesUnder(path, keep) + return entry.isFile() && keep(entry.name) ? [path] : [] + }) +} + +function sourceFiles(): readonly string[] { + return filesUnder( + playtestDirectory, + (name) => /\.(?:ts|tsx)$/.test(name) && !name.includes('.test.'), + ) +} + +function moduleSpecifiers(source: string): readonly string[] { + return [...source.matchAll(/(?:from\s+|import\s*)["']([^"']+)["']/g)].map( + (match) => match[1] ?? '', + ) +} + +/** + * 把 `@/x` 与相对路径统一折算成相对 src 的位置。 + * 只匹配别名字面量是拦不住的:`../../../features/workflow-controller` 解析到同一个地方。 + */ +function sourceEntry(file: string, dependency: string): string | null { + const target = dependency.startsWith('@/') + ? resolve(sourceDirectory, dependency.slice('@/'.length)) + : dependency.startsWith('.') + ? resolve(dirname(file), dependency) + : null + if (target === null) return null + + const entry = relative(sourceDirectory, target) + if (entry.startsWith('..') || isAbsolute(entry)) return null + + return entry.replaceAll('\\', '/') +} + +/** 把相对路径也折算成 entities 内部路径,避免用 `../../entities/character` 绕过别名检查。 */ +function entityEntry(file: string, dependency: string): string | null { + if (dependency === allowedEntityEntry) return '' + if (dependency.startsWith(`${allowedEntityEntry}/`)) { + return dependency.slice(`${allowedEntityEntry}/`.length) + } + if (!dependency.startsWith('.')) return null + + const relativeEntry = relative(entitiesDirectory, resolve(dirname(file), dependency)) + if (relativeEntry.startsWith('..') || isAbsolute(relativeEntry)) return null + + return relativeEntry.replaceAll('\\', '/').replace(/\/index$/, '') +} + +describe('Playtest 架构边界', () => { + it('确实扫到了源码', () => { + // 目录改名或后缀过滤写错时,下面几条会全部空跑而依然变绿。 + expect(sourceFiles().length).toBeGreaterThan(0) + }) + + it('不向上或同层依赖', () => { + const playtestEntry = relative(sourceDirectory, playtestDirectory).replaceAll('\\', '/') + + for (const file of sourceFiles()) { + const offenders = moduleSpecifiers(readFileSync(file, 'utf8')).filter((dependency) => { + const entry = sourceEntry(file, dependency) + if (entry === null) return false + // playtest 目录内部互相引用不算跨层,它本身就在 pages 下。 + if (entry === playtestEntry.replace(/\/$/, '') || entry.startsWith(playtestEntry)) { + return false + } + return forbiddenLayers.some((layer) => entry === layer || entry.startsWith(`${layer}/`)) + }) + + expect(offenders, `${file} 依赖了上层或同层模块`).toEqual([]) + } + }) + + it('只从 @/entities 这一个门进业务模块', () => { + for (const file of sourceFiles()) { + const deepEntries = moduleSpecifiers(readFileSync(file, 'utf8')) + .map((dependency) => ({ dependency, entry: entityEntry(file, dependency) })) + .filter((candidate) => candidate.entry !== null && candidate.entry !== '') + + expect( + deepEntries.map((candidate) => candidate.dependency), + `${file} 绕过了 @/entities`, + ).toEqual([]) + } + }) + + it('目录里不存放任何素材文件', () => { + // 拦住把演示角色的帧图连同代码一起提交进主干的做法。 + expect(filesUnder(playtestDirectory, (name) => forbiddenAssetExtensions.test(name))).toEqual([]) + }) +}) diff --git a/frontend/src/pages/playtest/workbench/index.tsx b/frontend/src/pages/playtest/workbench/index.tsx new file mode 100644 index 00000000..24f6498f --- /dev/null +++ b/frontend/src/pages/playtest/workbench/index.tsx @@ -0,0 +1,170 @@ +import { useMemo, type PointerEvent } from 'react' + +import type { Character } from '@/entities' + +import { createPlaytestModel, type PlaytestModel } from './model' +import type { Direction } from './runtime/runtime' +import { usePlaytestRuntime } from './runtime/use-playtest-runtime' +import { PlaytestStage } from './stage' + +export interface PlaytestWorkbenchProps { + readonly character: Character + readonly outfitId: string + readonly initialActionId?: string | null +} + +const directionLabels: Readonly> = { + left: '向左', + right: '向右', +} + +export function PlaytestWorkbench({ + character, + outfitId, + initialActionId = null, +}: PlaytestWorkbenchProps) { + const result = useMemo(() => createPlaytestModel(character, outfitId), [character, outfitId]) + + if (!result.ok) { + return ( + + + 找不到指定造型,无法进入试玩。 + + + ) + } + + return +} + +function PlaytestExperience({ + model, + initialActionId, +}: { + readonly model: PlaytestModel + readonly initialActionId: string | null +}) { + const runtime = usePlaytestRuntime(model.actions, initialActionId) + + const holdDirection = (direction: Direction, pressed: boolean, source: string) => { + runtime.setDirection(direction, pressed, source) + } + const releasePointer = (event: PointerEvent, direction: Direction) => { + if (event.currentTarget.hasPointerCapture(event.pointerId)) { + event.currentTarget.releasePointerCapture(event.pointerId) + } + holdDirection(direction, false, `pointer:${event.pointerId}:${direction}`) + } + + // 顶栏悬浮不占布局高度,满幅页面自己让出避让空间;pt-24 与 PageContainer 同源,改顶栏尺寸时一起改。 + return ( + + + + + + PLAYTEST + + + {model.outfitName} + + + A / D 或方向键操控角色 + + + {/* + 舞台吃掉标题行之外剩下的全部高度,用 flex 分配而不是减一串魔数—— + 底部操控胶囊贴着舞台内沿,舞台只要比视口高一点,胶囊就落到折叠线以下: + 页面看着是好的,操控要滚动才找得到。下限 420px 之外不设固定高度。 + */} + + + + + + + {(['left', 'right'] as const).map((direction) => ( + { + event.preventDefault() + event.currentTarget.setPointerCapture(event.pointerId) + holdDirection(direction, true, `pointer:${event.pointerId}:${direction}`) + }} + onPointerUp={(event) => releasePointer(event, direction)} + onPointerCancel={(event) => releasePointer(event, direction)} + onLostPointerCapture={(event) => + holdDirection(direction, false, `pointer:${event.pointerId}:${direction}`) + } + className={`grid h-11 w-14 touch-none place-items-center rounded-full border font-mono text-sm transition-colors ${ + runtime.runtime.held[direction] + ? 'border-[#263f2d] bg-[#263f2d] text-white' + : 'border-[#cfd1ca] bg-white/80 text-[#2e322f] hover:border-[#929a93]' + }`} + > + {direction === 'left' ? '←' : '→'} + + ))} + + 当前动作 + + {runtime.action?.name ?? '无'} + + + + + + + ) +} diff --git a/frontend/src/pages/playtest/workbench/minimal-workbench.test.tsx b/frontend/src/pages/playtest/workbench/minimal-workbench.test.tsx new file mode 100644 index 00000000..e2b81e3f --- /dev/null +++ b/frontend/src/pages/playtest/workbench/minimal-workbench.test.tsx @@ -0,0 +1,107 @@ +// @vitest-environment jsdom +import { cleanup, fireEvent, render, screen } from '@testing-library/react' +import { afterEach, describe, expect, it } from 'vitest' + +import type { Character } from '@/entities' + +import { PlaytestWorkbench } from './index' + +const OUTFIT_ID = 'outfit-default' +const IDLE_ACTION_ID = 'idle' + +function frame(index: number, imageUrl: string) { + return { index, imageUrl, durationMs: 100 } +} + +const character: Character = { + id: '51', + projectId: '42', + name: '轻装信使', + description: null, + referenceImageUrl: null, + dataVersion: 1, + status: 1, + outfits: [ + { + id: OUTFIT_ID, + characterId: '51', + name: '常态造型', + description: null, + previewUrl: null, + actions: [ + { + id: IDLE_ACTION_ID, + outfitId: OUTFIT_ID, + name: '待机', + type: 'idle', + loop: true, + fps: 8, + frameCount: 2, + frames: [frame(0, '/idle-01.png'), frame(1, '/idle-02.png')], + }, + { + id: 'walk', + outfitId: OUTFIT_ID, + name: '行走', + type: 'walk', + loop: true, + fps: 10, + frameCount: 2, + frames: [frame(0, '/walk-01.png'), frame(1, '/walk-02.png')], + }, + ], + }, + ], +} + +function renderWorkbench() { + render( + , + ) +} + +function pressedState(actionName: string) { + return screen + .getByRole('button', { name: `绑定动作:${actionName}` }) + .getAttribute('aria-pressed') +} + +afterEach(cleanup) + +describe('PlaytestWorkbench minimal control path', () => { + it('shows one stage, the bound actions, and direct character controls', () => { + renderWorkbench() + + expect(screen.getByRole('region', { name: '试玩舞台' })).toBeTruthy() + expect(screen.getByRole('group', { name: '角色操控' })).toBeTruthy() + expect(screen.getByRole('button', { name: '绑定动作:待机' })).toBeTruthy() + expect(screen.getByRole('button', { name: '绑定动作:行走' })).toBeTruthy() + }) + + it('uses the same bound character actions for keyboard movement', () => { + renderWorkbench() + + fireEvent.keyDown(window, { key: 'd' }) + expect(pressedState('行走')).toBe('true') + + fireEvent.keyUp(window, { key: 'd' }) + expect(pressedState('待机')).toBe('true') + }) + + it('keeps moving until every key bound to the same direction is released', () => { + renderWorkbench() + + fireEvent.keyDown(window, { key: 'd', code: 'KeyD' }) + fireEvent.keyDown(window, { key: 'ArrowRight', code: 'ArrowRight' }) + fireEvent.keyUp(window, { key: 'd', code: 'KeyD' }) + + expect(pressedState('行走')).toBe('true') + + fireEvent.keyUp(window, { key: 'ArrowRight', code: 'ArrowRight' }) + expect(pressedState('待机')).toBe('true') + }) +}) diff --git a/frontend/src/pages/playtest/workbench/model.test.ts b/frontend/src/pages/playtest/workbench/model.test.ts new file mode 100644 index 00000000..377a7a5c --- /dev/null +++ b/frontend/src/pages/playtest/workbench/model.test.ts @@ -0,0 +1,102 @@ +import { describe, expect, it } from 'vitest' + +import type { Character } from '@/entities' + +import { createPlaytestModel } from './model' + +const character: Character = { + id: '51', + projectId: '42', + name: '轻装信使', + description: null, + referenceImageUrl: null, + dataVersion: 1, + status: 1, + outfits: [ + { + id: 'outfit-default', + characterId: '51', + name: '常态造型', + description: null, + previewUrl: null, + actions: [ + { + id: 'idle', + outfitId: 'outfit-default', + name: '待机', + type: 'idle', + loop: true, + fps: 8, + frameCount: 1, + frames: [{ index: 0, imageUrl: '/idle-01.png', durationMs: null }], + }, + { + id: 'walk', + outfitId: 'outfit-default', + name: '行走', + type: 'walk', + loop: true, + fps: 10, + frameCount: 3, + frames: [ + { index: 2, imageUrl: '/walk-03.png', durationMs: 100 }, + { index: 0, imageUrl: '/walk-01.png', durationMs: 100 }, + { index: 1, imageUrl: '/walk-02.png', durationMs: 100 }, + ], + }, + ], + }, + ], +} + +describe('createPlaytestModel', () => { + it('keeps only the fields needed to control and render an action', () => { + const result = createPlaytestModel(character, 'outfit-default') + + expect(result.ok && result.model.characterId).toBe('51') + expect(result.ok && result.model.outfitName).toBe('常态造型') + expect(result.ok && result.model.actions[0]).toEqual({ + id: 'idle', + name: '待机', + type: 'idle', + loop: true, + // durationMs 为 null 时按所属动作的 fps 换算,不用前端常量顶上。 + frames: [{ imageUrl: '/idle-01.png', durationMs: 125 }], + }) + }) + + it('orders frames by the backend index instead of array position', () => { + const result = createPlaytestModel(character, 'outfit-default') + + expect(result.ok && result.model.actions[1]?.frames.map((frame) => frame.imageUrl)).toEqual([ + '/walk-01.png', + '/walk-02.png', + '/walk-03.png', + ]) + }) + + it('drops actions that have no frames to play', () => { + const withEmptyAction = structuredClone(character) + withEmptyAction.outfits[0]!.actions[1]!.frames = [] + + const result = createPlaytestModel(withEmptyAction, 'outfit-default') + + expect(result.ok && result.model.actions.map((action) => action.id)).toEqual(['idle']) + }) + + it('rejects a missing outfit instead of falling back to another one', () => { + expect(createPlaytestModel(character, 'missing')).toEqual({ + ok: false, + reason: 'outfit_not_found', + }) + }) + + it('clamps an unusably short frame duration before it reaches the animation loop', () => { + const tinyDurationCharacter = structuredClone(character) + tinyDurationCharacter.outfits[0]!.actions[0]!.frames[0]!.durationMs = 0.001 + + const result = createPlaytestModel(tinyDurationCharacter, 'outfit-default') + + expect(result.ok && result.model.actions[0]?.frames[0]?.durationMs).toBe(1) + }) +}) diff --git a/frontend/src/pages/playtest/workbench/model.ts b/frontend/src/pages/playtest/workbench/model.ts new file mode 100644 index 00000000..57d035af --- /dev/null +++ b/frontend/src/pages/playtest/workbench/model.ts @@ -0,0 +1,70 @@ +import type { ActionType, Character, Frame } from '@/entities' + +export interface PlaytestFrame { + readonly imageUrl: string + readonly durationMs: number +} + +export interface PlaytestAction { + readonly id: string + readonly name: string + readonly type: ActionType + /** 一次性动作播完停在末帧;只有循环动作才回到首帧。 */ + readonly loop: boolean + readonly frames: readonly PlaytestFrame[] +} + +export interface PlaytestModel { + readonly characterId: string + readonly outfitName: string + readonly actions: readonly PlaytestAction[] +} + +export type PlaytestModelResult = + | { readonly ok: true; readonly model: PlaytestModel } + | { readonly ok: false; readonly reason: 'outfit_not_found' } + +const DEFAULT_FRAME_DURATION_MS = 100 + +function frameDuration(durationMs: number | null, fps: number): number { + if (durationMs !== null && Number.isFinite(durationMs) && durationMs > 0) { + return Math.max(1, durationMs) + } + if (Number.isFinite(fps) && fps > 0) return Math.max(1, Math.round(1000 / fps)) + return DEFAULT_FRAME_DURATION_MS +} + +/** + * 播放顺序由 Frame.index 决定,不是数组下标。 + * 后端整棵下发资产树,数组顺序没有被契约保证;照数组播的话,顺序一变动画就乱, + * 而且乱得不报错。排序在这里做一次,运行时之后只按数组下标推进。 + */ +function orderedFrames(frames: readonly Frame[]): readonly Frame[] { + return [...frames].sort((left, right) => left.index - right.index) +} + +/** Playtest 只保留渲染和操控所需的数据;审核、生成与根位移仍属于各自原有边界。 */ +export function createPlaytestModel(character: Character, outfitId: string): PlaytestModelResult { + const outfit = character.outfits.find((candidate) => candidate.id === outfitId) + if (outfit === undefined) return { ok: false, reason: 'outfit_not_found' } + + return { + ok: true, + model: { + characterId: character.id, + outfitName: outfit.name, + actions: outfit.actions + .filter((action) => action.frames.length > 0) + .map((action) => ({ + id: action.id, + name: action.name, + type: action.type, + loop: action.loop, + frames: orderedFrames(action.frames).map((frame) => ({ + imageUrl: frame.imageUrl, + durationMs: frameDuration(frame.durationMs, action.fps), + })), + })), + }, + } +} diff --git a/frontend/src/pages/playtest/workbench/runtime/runtime.test.ts b/frontend/src/pages/playtest/workbench/runtime/runtime.test.ts new file mode 100644 index 00000000..04eb859f --- /dev/null +++ b/frontend/src/pages/playtest/workbench/runtime/runtime.test.ts @@ -0,0 +1,132 @@ +import { describe, expect, it } from 'vitest' + +import type { PlaytestAction } from '../model' +import { advanceRuntime, createRuntime, selectRuntimeAction, setDirectionInput } from './runtime' + +const actions: readonly PlaytestAction[] = [ + { + id: 'idle', + name: '待机', + type: 'idle', + loop: true, + frames: [ + { imageUrl: '/idle-1.png', durationMs: 100 }, + { imageUrl: '/idle-2.png', durationMs: 100 }, + ], + }, + { + id: 'walk', + name: '行走', + type: 'walk', + loop: true, + frames: [ + { imageUrl: '/walk-1.png', durationMs: 80 }, + { imageUrl: '/walk-2.png', durationMs: 120 }, + ], + }, + { + id: 'attack', + name: '攻击', + type: 'attack', + loop: false, + frames: [ + { imageUrl: '/attack-1.png', durationMs: 150 }, + { imageUrl: '/attack-2.png', durationMs: 150 }, + ], + }, +] + +describe('playtest runtime', () => { + it('binds walk while a direction is held and returns to idle on release', () => { + const idle = createRuntime(actions, 'idle') + const walking = setDirectionInput(idle, actions, 'right', true) + const released = setDirectionInput(walking, actions, 'right', false) + + expect(walking).toMatchObject({ + actionId: 'walk', + frameIndex: 0, + facing: 1, + held: { left: false, right: true }, + }) + expect(released).toMatchObject({ + actionId: 'idle', + frameIndex: 0, + facing: 1, + held: { left: false, right: false }, + }) + }) + + it('moves continuously from elapsed time while animation uses its own frame durations', () => { + const walking = setDirectionInput(createRuntime(actions, 'idle'), actions, 'right', true) + const firstTick = advanceRuntime(walking, actions, 40, { minX: -100, maxX: 100 }, 150) + const secondTick = advanceRuntime(firstTick, actions, 40, { minX: -100, maxX: 100 }, 150) + + expect(firstTick).toMatchObject({ x: 6, frameIndex: 0, frameElapsedMs: 40 }) + expect(secondTick).toMatchObject({ x: 12, frameIndex: 1, frameElapsedMs: 0 }) + }) + + it('loops a looping action back to its first frame', () => { + const idle = createRuntime(actions, 'idle') + const wrapped = advanceRuntime(idle, actions, 50, { minX: 0, maxX: 0 }, 0) + const looped = [0, 1, 2].reduce( + (current) => advanceRuntime(current, actions, 50, { minX: 0, maxX: 0 }, 0), + wrapped, + ) + + expect(looped.frameIndex).toBe(0) + }) + + it('clamps zero duration frames inside the playback loop', () => { + const zeroDurationActions: readonly PlaytestAction[] = [ + { + id: 'idle', + name: '待机', + type: 'idle', + loop: true, + frames: [ + { imageUrl: '/idle-1.png', durationMs: 0 }, + { imageUrl: '/idle-2.png', durationMs: 100 }, + ], + }, + ] + const advanced = advanceRuntime( + createRuntime(zeroDurationActions, 'idle'), + zeroDurationActions, + 5, + { minX: 0, maxX: 0 }, + 0, + ) + + expect(advanced).toMatchObject({ frameIndex: 1, frameElapsedMs: 4 }) + }) + + it('stops a one-shot action on its last frame instead of restarting it', () => { + const attacking = selectRuntimeAction(createRuntime(actions, null), actions, 'attack') + const played = [0, 1, 2, 3, 4, 5, 6, 7].reduce( + (current) => advanceRuntime(current, actions, 50, { minX: 0, maxX: 0 }, 0), + attacking, + ) + + expect(played.frameIndex).toBe(1) + }) + + it('keeps the same runtime reference when a stopped one-shot action does not change', () => { + const stopped = { + ...selectRuntimeAction(createRuntime(actions, null), actions, 'attack'), + frameIndex: 1, + frameElapsedMs: 150, + } + + expect(advanceRuntime(stopped, actions, 50, { minX: 0, maxX: 0 }, 0)).toBe(stopped) + }) + + it('keeps every bound action directly selectable without extra playback state', () => { + const selected = selectRuntimeAction(createRuntime(actions, null), actions, 'attack') + + expect(selected).toMatchObject({ + actionId: 'attack', + frameIndex: 0, + frameElapsedMs: 0, + }) + }) +}) diff --git a/frontend/src/pages/playtest/workbench/runtime/runtime.ts b/frontend/src/pages/playtest/workbench/runtime/runtime.ts new file mode 100644 index 00000000..78bd9dac --- /dev/null +++ b/frontend/src/pages/playtest/workbench/runtime/runtime.ts @@ -0,0 +1,151 @@ +import type { PlaytestAction } from '../model' + +export type Direction = 'left' | 'right' +export type Facing = -1 | 1 + +export interface StageBounds { + readonly minX: number + readonly maxX: number +} + +export interface PlaytestRuntime { + readonly actionId: string | null + readonly frameIndex: number + readonly frameElapsedMs: number + readonly x: number + readonly facing: Facing + readonly held: Readonly> +} + +const EMPTY_HELD: PlaytestRuntime['held'] = { left: false, right: false } + +function actionById( + actions: readonly PlaytestAction[], + actionId: string | null, +): PlaytestAction | undefined { + return actions.find((action) => action.id === actionId && action.frames.length > 0) +} + +function actionByType( + actions: readonly PlaytestAction[], + type: PlaytestAction['type'], +): PlaytestAction | undefined { + return actions.find((action) => action.type === type && action.frames.length > 0) +} + +function initialAction( + actions: readonly PlaytestAction[], + requestedActionId: string | null, +): PlaytestAction | undefined { + return ( + actionById(actions, requestedActionId) ?? + actionByType(actions, 'idle') ?? + actions.find((action) => action.frames.length > 0) + ) +} + +export function createRuntime( + actions: readonly PlaytestAction[], + initialActionId: string | null, +): PlaytestRuntime { + return { + actionId: initialAction(actions, initialActionId)?.id ?? null, + frameIndex: 0, + frameElapsedMs: 0, + x: 0, + facing: 1, + held: EMPTY_HELD, + } +} + +export function selectRuntimeAction( + runtime: PlaytestRuntime, + actions: readonly PlaytestAction[], + actionId: string, +): PlaytestRuntime { + const action = actionById(actions, actionId) + if (action === undefined || action.id === runtime.actionId) return runtime + + return { + ...runtime, + actionId: action.id, + frameIndex: 0, + frameElapsedMs: 0, + } +} + +function horizontalAxis(held: PlaytestRuntime['held']): -1 | 0 | 1 { + if (held.left === held.right) return 0 + return held.left ? -1 : 1 +} + +function frameDurationMs(action: PlaytestAction, frameIndex: number): number { + return Math.max(1, action.frames[frameIndex]?.durationMs ?? 1) +} + +export function setDirectionInput( + runtime: PlaytestRuntime, + actions: readonly PlaytestAction[], + direction: Direction, + pressed: boolean, +): PlaytestRuntime { + if (runtime.held[direction] === pressed) return runtime + + const held = { ...runtime.held, [direction]: pressed } + const axis = horizontalAxis(held) + const action = axis === 0 ? actionByType(actions, 'idle') : actionByType(actions, 'walk') + const nextActionId = action?.id ?? runtime.actionId + + return { + ...runtime, + held, + facing: axis === 0 ? runtime.facing : axis, + actionId: nextActionId, + frameIndex: nextActionId === runtime.actionId ? runtime.frameIndex : 0, + frameElapsedMs: nextActionId === runtime.actionId ? runtime.frameElapsedMs : 0, + } +} + +export function advanceRuntime( + runtime: PlaytestRuntime, + actions: readonly PlaytestAction[], + deltaMs: number, + bounds: StageBounds, + movementSpeed: number, +): PlaytestRuntime { + if (!Number.isFinite(deltaMs) || deltaMs <= 0) return runtime + + const action = actionById(actions, runtime.actionId) + if (action === undefined) return runtime + + const axis = horizontalAxis(runtime.held) + const nextX = Math.min( + bounds.maxX, + Math.max(bounds.minX, runtime.x + (axis * movementSpeed * deltaMs) / 1000), + ) + const lastFrameIndex = action.frames.length - 1 + let frameIndex = Math.min(runtime.frameIndex, lastFrameIndex) + let frameElapsedMs = runtime.frameElapsedMs + deltaMs + + let currentFrameDurationMs = frameDurationMs(action, frameIndex) + while (frameElapsedMs >= currentFrameDurationMs) { + // 非循环动作走到末帧就停住:攻击、跳跃这类一次性动作回到首帧会变成假的循环动画。 + if (!action.loop && frameIndex === lastFrameIndex) { + frameElapsedMs = currentFrameDurationMs + break + } + frameElapsedMs -= currentFrameDurationMs + frameIndex = (frameIndex + 1) % action.frames.length + currentFrameDurationMs = frameDurationMs(action, frameIndex) + } + + if ( + nextX === runtime.x && + frameIndex === runtime.frameIndex && + frameElapsedMs === runtime.frameElapsedMs + ) { + return runtime + } + + return { ...runtime, x: nextX, frameIndex, frameElapsedMs } +} diff --git a/frontend/src/pages/playtest/workbench/runtime/use-playtest-runtime.test.ts b/frontend/src/pages/playtest/workbench/runtime/use-playtest-runtime.test.ts new file mode 100644 index 00000000..ae3fc1e1 --- /dev/null +++ b/frontend/src/pages/playtest/workbench/runtime/use-playtest-runtime.test.ts @@ -0,0 +1,55 @@ +import { describe, expect, it, vi } from 'vitest' + +import type { PlaytestAction } from '../model' +import { preloadActionFrames } from './use-playtest-runtime' + +const actions: readonly PlaytestAction[] = [ + { + id: 'idle', + name: '待机', + type: 'idle', + loop: true, + frames: [ + { imageUrl: '/idle-1.png', durationMs: 100 }, + { imageUrl: '/idle-2.png', durationMs: 100 }, + ], + }, + { + id: 'walk', + name: '行走', + type: 'walk', + loop: true, + frames: [ + { imageUrl: '/walk-1.png', durationMs: 100 }, + { imageUrl: '/idle-1.png', durationMs: 100 }, + ], + }, +] + +describe('preloadActionFrames', () => { + it('loads and decodes every unique bound frame before it is selected', () => { + const loaded: string[] = [] + const decoded: string[] = [] + const createImage = () => { + let currentUrl = '' + return { + get src() { + return currentUrl + }, + set src(url: string) { + currentUrl = url + loaded.push(url) + }, + decode: vi.fn(() => { + decoded.push(currentUrl) + return Promise.resolve() + }), + } as unknown as HTMLImageElement + } + + preloadActionFrames(actions, createImage) + + expect(loaded).toEqual(['/idle-1.png', '/idle-2.png', '/walk-1.png']) + expect(decoded).toEqual(loaded) + }) +}) diff --git a/frontend/src/pages/playtest/workbench/runtime/use-playtest-runtime.ts b/frontend/src/pages/playtest/workbench/runtime/use-playtest-runtime.ts new file mode 100644 index 00000000..eaf5db91 --- /dev/null +++ b/frontend/src/pages/playtest/workbench/runtime/use-playtest-runtime.ts @@ -0,0 +1,165 @@ +import { useCallback, useEffect, useMemo, useRef, useState } from 'react' + +import type { PlaytestAction } from '../model' +import { + advanceRuntime, + createRuntime, + selectRuntimeAction, + setDirectionInput, + type Direction, + type StageBounds, +} from './runtime' + +const MOVEMENT_SPEED = 150 +const MAX_FRAME_DELTA_MS = 50 +const INITIAL_BOUNDS: StageBounds = { minX: 0, maxX: 0 } + +function isTypingTarget(target: EventTarget | null): boolean { + if (!(target instanceof HTMLElement)) return false + return ( + target.isContentEditable || + target.tagName === 'INPUT' || + target.tagName === 'TEXTAREA' || + target.tagName === 'SELECT' + ) +} + +function keyDirection(key: string): Direction | null { + const normalized = key.toLowerCase() + if (normalized === 'a' || normalized === 'arrowleft') return 'left' + if (normalized === 'd' || normalized === 'arrowright') return 'right' + return null +} + +export function preloadActionFrames( + actions: readonly PlaytestAction[], + createImage?: () => HTMLImageElement, +): readonly HTMLImageElement[] { + const imageFactory = createImage ?? (typeof Image === 'undefined' ? null : () => new Image()) + if (imageFactory === null) return [] + + return [ + ...new Set(actions.flatMap((action) => action.frames.map((frame) => frame.imageUrl))), + ].map((imageUrl) => { + const image = imageFactory() + image.src = imageUrl + if (typeof image.decode === 'function') void image.decode().catch(() => undefined) + return image + }) +} + +export function usePlaytestRuntime( + actions: readonly PlaytestAction[], + initialActionId: string | null, +) { + const [runtime, setRuntime] = useState(() => createRuntime(actions, initialActionId)) + const actionsRef = useRef(actions) + const boundsRef = useRef(INITIAL_BOUNDS) + const activeInputsRef = useRef(new Map()) + const preloadedImagesRef = useRef([]) + + useEffect(() => { + actionsRef.current = actions + activeInputsRef.current.clear() + setRuntime(createRuntime(actions, initialActionId)) + }, [actions, initialActionId]) + + useEffect(() => { + preloadedImagesRef.current = preloadActionFrames(actions) + return () => { + preloadedImagesRef.current = [] + } + }, [actions]) + + useEffect(() => { + if (typeof requestAnimationFrame !== 'function') return + + let animationFrame = 0 + let previousTime: number | null = null + const tick = (time: number) => { + if (previousTime !== null) { + const deltaMs = Math.min(time - previousTime, MAX_FRAME_DELTA_MS) + setRuntime((current) => + advanceRuntime(current, actionsRef.current, deltaMs, boundsRef.current, MOVEMENT_SPEED), + ) + } + previousTime = time + animationFrame = requestAnimationFrame(tick) + } + + animationFrame = requestAnimationFrame(tick) + return () => cancelAnimationFrame(animationFrame) + }, []) + + const setDirection = useCallback( + (direction: Direction, pressed: boolean, source: string = direction) => { + if (pressed) activeInputsRef.current.set(source, direction) + else activeInputsRef.current.delete(source) + + const stillHeld = [...activeInputsRef.current.values()].includes(direction) + setRuntime((current) => setDirectionInput(current, actionsRef.current, direction, stillHeld)) + }, + [], + ) + + const clearDirections = useCallback(() => { + activeInputsRef.current.clear() + setRuntime((current) => { + const withoutLeft = setDirectionInput(current, actionsRef.current, 'left', false) + return setDirectionInput(withoutLeft, actionsRef.current, 'right', false) + }) + }, []) + + useEffect(() => { + const handleKeyDown = (event: globalThis.KeyboardEvent) => { + if (isTypingTarget(event.target)) return + const direction = keyDirection(event.key) + if (direction === null) return + event.preventDefault() + if (event.repeat) return + setDirection(direction, true, `keyboard:${event.code || event.key}`) + } + const handleKeyUp = (event: globalThis.KeyboardEvent) => { + const direction = keyDirection(event.key) + if (direction === null) return + event.preventDefault() + setDirection(direction, false, `keyboard:${event.code || event.key}`) + } + + window.addEventListener('keydown', handleKeyDown) + window.addEventListener('keyup', handleKeyUp) + window.addEventListener('blur', clearDirections) + return () => { + window.removeEventListener('keydown', handleKeyDown) + window.removeEventListener('keyup', handleKeyUp) + window.removeEventListener('blur', clearDirections) + } + }, [clearDirections, setDirection]) + + const selectAction = useCallback((actionId: string) => { + setRuntime((current) => selectRuntimeAction(current, actionsRef.current, actionId)) + }, []) + + const setBounds = useCallback((bounds: StageBounds) => { + boundsRef.current = bounds + setRuntime((current) => ({ + ...current, + x: Math.min(bounds.maxX, Math.max(bounds.minX, current.x)), + })) + }, []) + + const action = useMemo( + () => actions.find((candidate) => candidate.id === runtime.actionId) ?? null, + [actions, runtime.actionId], + ) + const frame = action?.frames[runtime.frameIndex] ?? action?.frames[0] ?? null + + return { + runtime, + action, + frame, + selectAction, + setDirection, + setBounds, + } +} diff --git a/frontend/src/pages/playtest/workbench/stage.test.tsx b/frontend/src/pages/playtest/workbench/stage.test.tsx new file mode 100644 index 00000000..7e8a118c --- /dev/null +++ b/frontend/src/pages/playtest/workbench/stage.test.tsx @@ -0,0 +1,36 @@ +// @vitest-environment jsdom +import { cleanup, render, screen } from '@testing-library/react' +import { afterEach, describe, expect, it } from 'vitest' + +import { PlaytestStage } from './stage' + +afterEach(cleanup) + +function renderStage(x: number) { + render( + undefined} + />, + ) + return screen.getByRole('region', { name: '试玩舞台' }).querySelector('img') +} + +describe('PlaytestStage', () => { + it('centers and moves the sprite through a single transform', () => { + const sprite = renderStage(40) + + expect(sprite?.style.transform).toBe('translate3d(calc(-50% + 40px), 0, 0) scaleX(1)') + // jsdom 不排版,量不出偏移,只能守住成因:Tailwind v4 的 translate 工具类走独立的 + // translate 属性,与 transform 叠加而非覆盖,两处都写会让静止位置左偏半个精灵宽。 + expect(sprite?.className).not.toMatch(/(^|\s)-?translate-/) + }) + + it('shows an empty stage when the action has no frame to play', () => { + render( undefined} />) + + expect(screen.getByText('暂无可播放帧')).toBeTruthy() + }) +}) diff --git a/frontend/src/pages/playtest/workbench/stage.tsx b/frontend/src/pages/playtest/workbench/stage.tsx new file mode 100644 index 00000000..973aa348 --- /dev/null +++ b/frontend/src/pages/playtest/workbench/stage.tsx @@ -0,0 +1,85 @@ +import { useCallback, useEffect, useRef } from 'react' + +import type { PlaytestFrame } from './model' +import type { Facing, StageBounds } from './runtime/runtime' + +export interface PlaytestStageProps { + readonly frame: PlaytestFrame | null + readonly x: number + readonly facing: Facing + readonly onBoundsChange: (bounds: StageBounds) => void +} + +export function PlaytestStage({ frame, x, facing, onBoundsChange }: PlaytestStageProps) { + const stageRef = useRef(null) + const characterRef = useRef(null) + + const measureBounds = useCallback(() => { + const stageWidth = stageRef.current?.getBoundingClientRect().width ?? 0 + const characterWidth = characterRef.current?.getBoundingClientRect().width ?? 0 + if (stageWidth <= 0) return + + const limit = Math.max(0, (stageWidth - characterWidth) / 2 - 28) + onBoundsChange({ minX: -limit, maxX: limit }) + }, [onBoundsChange]) + + useEffect(() => { + measureBounds() + const stage = stageRef.current + if (stage === null || typeof ResizeObserver === 'undefined') { + window.addEventListener('resize', measureBounds) + return () => window.removeEventListener('resize', measureBounds) + } + + const observer = new ResizeObserver(measureBounds) + observer.observe(stage) + return () => observer.disconnect() + }, [measureBounds]) + + return ( + + + + + {frame === null ? ( + + 暂无可播放帧 + + ) : ( + + )} + + ) +}
本次只提交模块划分与接口,页面实现进后续 PR。
{children}
+ 找不到指定造型,无法进入试玩。 +
+ PLAYTEST +
A / D 或方向键操控角色
当前动作
+ {runtime.action?.name ?? '无'} +
+ 暂无可播放帧 +