-
Notifications
You must be signed in to change notification settings - Fork 4
feat(playtest): add the character playtest workbench #131
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
Show all changes
10 commits
Select commit
Hold shift + click to select a range
b607dc6
feat(playtest): map confirmed character frames into a playable model
huyanxius 5575ba9
feat(playtest): add the movement and playback state machine
huyanxius 3043946
feat(playtest): drive the runtime from keyboard and pointer input
huyanxius 7be06ef
feat(playtest): render the character stage
huyanxius 16a5df7
feat(playtest): assemble the workbench with bound actions and controls
huyanxius 1afdc74
feat(playtest): load the routed character on the page itself
huyanxius b7c013f
test(playtest): enforce the page dependency boundaries
huyanxius c53f499
docs(playtest): describe the entry, boundaries and controls
huyanxius 6840a08
fix(playtest): stop offsetting the sprite by half its width
huyanxius c97c641
fix(playtest): harden playback runtime edge cases
huyanxius File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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` 的避让空间。 |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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( | ||
| <MemoryRouter initialEntries={[path]}> | ||
| <AppRoutes /> | ||
| </MemoryRouter>, | ||
| ) | ||
| } | ||
|
|
||
| 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() | ||
| }) | ||
| }) |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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<PageData>(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 <PlaytestPageMessage>Playtest 路由参数不完整</PlaytestPageMessage> | ||
| if (data.error !== null) return <PlaytestPageMessage>{data.error}</PlaytestPageMessage> | ||
| if (data.loading || data.character === null) | ||
| return <PlaytestPageMessage>加载 Playtest 数据中</PlaytestPageMessage> | ||
|
|
||
| return ( | ||
| <PlaytestWorkbench | ||
| character={data.character} | ||
| outfitId={outfitId} | ||
| initialActionId={initialActionId} | ||
| /> | ||
| ) | ||
| } | ||
|
|
||
| function PlaytestPageMessage({ children }: { children: string }) { | ||
| return ( | ||
| <PageContainer> | ||
| <section className="border border-dashed border-slate-300 p-6"> | ||
| <h1 className="font-medium">核验台</h1> | ||
| <p className="mt-2 text-sm text-slate-500">本次只提交模块划分与接口,页面实现进后续 PR。</p> | ||
| </section> | ||
| </PageContainer> | ||
| <main aria-label="Playtest" className="grid min-h-screen place-items-center bg-[#dfe3df] p-6"> | ||
| <p className="text-sm font-medium text-[#3d443f]">{children}</p> | ||
| </main> | ||
| ) | ||
| } | ||
119 changes: 119 additions & 0 deletions
119
frontend/src/pages/playtest/playtest-boundaries.test.ts
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Nit: 这种一般不用提交,边界通过文档/注释/良好的目录组织一般就能体现了,不用单独写测试代码来保证 |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,119 @@ | ||
| /// <reference types="node" /> | ||
|
|
||
| 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([]) | ||
| }) | ||
| }) |
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.