From b852b4421f3d9ca2ff697ee7b81a4c5c19940fbb Mon Sep 17 00:00:00 2001 From: xyh202131 <246811510+xyh202131@users.noreply.github.com> Date: Tue, 4 Aug 2026 10:10:20 +0800 Subject: [PATCH 1/5] feat(playtest): add backend data adapters --- frontend/src/entities/character/api.test.ts | 66 ++++++++ frontend/src/entities/character/api.ts | 158 ++++++++++++++++++ frontend/src/entities/character/index.ts | 3 + frontend/src/entities/index.ts | 12 ++ .../entities/playtest-inspection/api.test.ts | 67 ++++++++ .../src/entities/playtest-inspection/api.ts | 65 +++++++ .../src/entities/playtest-inspection/index.ts | 24 +++ frontend/src/entities/project/api.test.ts | 57 +++++++ frontend/src/entities/project/api.ts | 74 ++++++++ frontend/src/shared/api/http-client.test.ts | 80 +++++++++ frontend/src/shared/api/http-client.ts | 142 ++++++++++++++++ frontend/src/shared/api/index.ts | 2 + 12 files changed, 750 insertions(+) create mode 100644 frontend/src/entities/character/api.test.ts create mode 100644 frontend/src/entities/character/api.ts create mode 100644 frontend/src/entities/playtest-inspection/api.test.ts create mode 100644 frontend/src/entities/playtest-inspection/api.ts create mode 100644 frontend/src/entities/playtest-inspection/index.ts create mode 100644 frontend/src/entities/project/api.test.ts create mode 100644 frontend/src/entities/project/api.ts create mode 100644 frontend/src/shared/api/http-client.test.ts create mode 100644 frontend/src/shared/api/http-client.ts create mode 100644 frontend/src/shared/api/index.ts diff --git a/frontend/src/entities/character/api.test.ts b/frontend/src/entities/character/api.test.ts new file mode 100644 index 00000000..1ca38de3 --- /dev/null +++ b/frontend/src/entities/character/api.test.ts @@ -0,0 +1,66 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' + +import { createCharacterApis } from './api' + +afterEach(() => { + vi.unstubAllGlobals() +}) + +describe('character API adapter', () => { + it('preserves an action loop flag when saving the complete character tree', async () => { + const backendCharacter = { + id: 25, + project_id: 3, + description: null, + reference_image_url: null, + status: 1, + character_data: { + version: 1, + outfits: [ + { + id: 'outfit-default', + name: 'Default', + description: null, + preview_url: null, + actions: [ + { + id: 'idle', + type: 'idle', + name: 'Idle', + loop: true, + fps: 8, + frame_count: 1, + frames: [ + { index: 0, image_url: '/idle-0.png', duration_ms: 125, root_motion: null }, + ], + }, + ], + }, + ], + }, + } + const fetchMock = vi + .fn() + .mockResolvedValueOnce(jsonResponse(backendCharacter)) + .mockResolvedValueOnce(jsonResponse(backendCharacter)) + vi.stubGlobal('fetch', fetchMock) + + const apis = createCharacterApis() + const character = await apis.get('25') + await apis.update(character) + + expect(character.outfits[0]?.actions[0]?.loop).toBe(true) + const updateRequest = fetchMock.mock.calls[1]?.[1] as RequestInit + const updateBody = JSON.parse(String(updateRequest.body)) as { + character_data: { outfits: Array<{ actions: Array<{ loop: boolean }> }> } + } + expect(updateBody.character_data.outfits[0]?.actions[0]?.loop).toBe(true) + }) +}) + +function jsonResponse(data: unknown) { + return new Response(JSON.stringify({ code: 200, message: 'success', data }), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }) +} diff --git a/frontend/src/entities/character/api.ts b/frontend/src/entities/character/api.ts new file mode 100644 index 00000000..4359abe9 --- /dev/null +++ b/frontend/src/entities/character/api.ts @@ -0,0 +1,158 @@ +import type { Action, ActionType, Character, CharacterApis, Frame, Outfit } from '.' + +import { del, get, patch } from '@/shared/api' + +/* ─── 后端 DTO ─── */ + +interface BackendFrame { + index: number + image_url: string + duration_ms: number | null + root_motion?: { dx: number; dy: number } | null +} + +interface BackendAction { + id: string + type: string + name: string + loop: boolean + fps: number + frame_count: number + frames: BackendFrame[] +} + +interface BackendOutfit { + id: string + name: string + description: string | null + preview_url: string | null + actions: BackendAction[] +} + +interface BackendCharacterData { + version: number + outfits: BackendOutfit[] +} + +interface BackendCharacter { + id: number + project_id: number + description: string | null + reference_image_url: string | null + character_data: BackendCharacterData + status: number +} + +/* ─── 映射 ─── */ + +const ACTION_TYPE_SET = new Set(['walk', 'idle', 'attack', 'jump', 'custom']) + +function toActionType(raw: string): ActionType { + return ACTION_TYPE_SET.has(raw) ? (raw as ActionType) : 'custom' +} + +function toFrame(raw: BackendFrame): Frame { + return { + imageUrl: raw.image_url, + durationMs: raw.duration_ms, + rootMotion: raw.root_motion ?? null, + } +} + +function toAction(raw: BackendAction, outfitId: string): Action { + return { + id: raw.id, + outfitId, + name: raw.name, + loop: raw.loop, + kind: 'custom', // 后端不区分 preset/custom + type: toActionType(raw.type), + fps: raw.fps, + keyFrameIndex: null, // 后端不提供关键帧索引 + frames: raw.frames.sort((a, b) => a.index - b.index).map(toFrame), + } +} + +function toOutfit(raw: BackendOutfit, characterId: string): Outfit { + return { + id: raw.id, + characterId, + name: raw.name, + candidateCharacterTemplates: [], // 后端 character_data 不含候选 + characterTemplateUrl: raw.preview_url, + baseFrames: [], + actions: raw.actions.map((a) => toAction(a, raw.id)), + } +} + +function toCharacter(raw: BackendCharacter): Character { + const id = String(raw.id) + return { + id, + projectId: String(raw.project_id), + createdAt: '', // 后端列表不返回时间戳 + updatedAt: '', + outfits: (raw.character_data?.outfits ?? []).map((o) => toOutfit(o, id)), + } +} + +/* ─── 适配器 ─── */ + +export function createCharacterApis(): Pick< + CharacterApis, + 'get' | 'listByProject' | 'update' | 'remove' +> { + return { + async get(id: string): Promise { + const raw = await get(`/characters/${id}`) + return toCharacter(raw) + }, + + async listByProject(projectId: string): Promise { + // http-client 已解包 ApiEnvelope,data 字段就是角色数组本身 + const raw = await get( + `/characters?project_id=${encodeURIComponent(projectId)}&page_size=100`, + ) + return raw.map(toCharacter) + }, + + async update(character: Character): Promise { + const payload = { + project_id: Number(character.projectId), + character_data: { + version: 1, + outfits: character.outfits.map((outfit) => ({ + id: outfit.id, + name: outfit.name, + description: null, + preview_url: outfit.characterTemplateUrl, + actions: outfit.actions.map((action) => ({ + id: action.id, + type: action.type, + name: action.name, + loop: action.loop ?? false, + fps: action.fps, + frame_count: action.frames.length, + frames: action.frames.map((frame, index) => ({ + index, + image_url: frame.imageUrl, + duration_ms: frame.durationMs, + root_motion: frame.rootMotion, + })), + })), + })), + }, + } + const raw = await patch(`/characters/${character.id}`, payload) + const saved = toCharacter(raw) + if (saved.projectId !== character.projectId) { + throw new Error('后端未保存新的项目归属') + } + return saved + }, + + async remove(id: string): Promise { + await del(`/characters/${id}`) + }, + } +} diff --git a/frontend/src/entities/character/index.ts b/frontend/src/entities/character/index.ts index 616b5db0..c06751ac 100644 --- a/frontend/src/entities/character/index.ts +++ b/frontend/src/entities/character/index.ts @@ -61,6 +61,8 @@ export interface Action { id: string outfitId: Outfit['id'] name: string + /** 是否在播放到末帧后从首帧继续;整树更新时必须原样保存。 */ + loop?: boolean /** 定义来源方式;与 type 正交,不用于推断动作业务语义。 */ kind: ActionKind /** 动作业务语义;与 kind 的 preset/custom 来源维度相互独立。 */ @@ -134,4 +136,5 @@ export interface CharacterApis { listByProject(projectId: string): Promise create(input: CreateCharacterInput): Promise update(character: Character): Promise + remove(id: Character['id']): Promise } diff --git a/frontend/src/entities/index.ts b/frontend/src/entities/index.ts index 203359dd..95876937 100644 --- a/frontend/src/entities/index.ts +++ b/frontend/src/entities/index.ts @@ -5,6 +5,7 @@ /* 项目 —— 全局约束:视角、朝向、精灵尺寸、画风 */ export { CHARACTER_PERSPECTIVE, DIRECTIONAL_MOVEMENT, SPRITE_SIZES } from './project' +export { createProjectApis } from './project/api' export type { CharacterPerspective, CreateProjectInput, @@ -29,6 +30,7 @@ export type { FrameRootMotion, Outfit, } from './character' +export { createCharacterApis } from './character/api' /* 动作模板 —— 能跨角色复用的配方 */ export type { ActionTemplate, ActionTemplateApis } from './action-template' @@ -55,6 +57,16 @@ export type { /* 媒体引用 —— 不承诺 URL 或后端 Media ID 的具体表示 */ export type { MediaReference } from './media' +/* Playtest 核验 —— 只保存某个动作当前的核验结论,不承担历史记录。 */ +export { createPlaytestInspectionApis } from './playtest-inspection/api' +export type { + PlaytestInspection, + PlaytestInspectionApis, + PlaytestInspectionStatus, + PlaytestInspectionTarget, + SavePlaytestInspectionInput, +} from './playtest-inspection' + /* 工作流 —— 节点与运行状态都由前端管理 */ export { WORKFLOW_STEP_ORDER } from './workflow-run' export type { diff --git a/frontend/src/entities/playtest-inspection/api.test.ts b/frontend/src/entities/playtest-inspection/api.test.ts new file mode 100644 index 00000000..adb19d7f --- /dev/null +++ b/frontend/src/entities/playtest-inspection/api.test.ts @@ -0,0 +1,67 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' + +import { createPlaytestInspectionApis } from './api' + +afterEach(() => { + vi.unstubAllGlobals() +}) + +describe('Playtest inspection API adapter', () => { + it('treats the backend business 404 as an empty current inspection', async () => { + vi.stubGlobal( + 'fetch', + vi.fn().mockResolvedValue( + new Response(JSON.stringify({ code: 404, message: '尚未核验', data: null }), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }), + ), + ) + + await expect( + createPlaytestInspectionApis().get({ + characterId: '25', + outfitId: 'default', + actionId: 'idle', + }), + ).resolves.toBeNull() + }) + + it('sends stable target IDs and maps the saved inspection', async () => { + const fetchMock = vi.fn().mockResolvedValue( + new Response( + JSON.stringify({ + code: 200, + message: '核验已保存', + data: { + id: 9, + character_id: 25, + outfit_id: 'default', + action_id: 'idle', + status: 'issues_found', + create_at: '2026-08-04T00:00:00Z', + update_at: '2026-08-04T00:01:00Z', + }, + }), + { status: 200, headers: { 'Content-Type': 'application/json' } }, + ), + ) + vi.stubGlobal('fetch', fetchMock) + + const saved = await createPlaytestInspectionApis().save({ + characterId: '25', + outfitId: 'default', + actionId: 'idle', + status: 'issues_found', + }) + + expect(saved).toMatchObject({ id: '9', characterId: '25', status: 'issues_found' }) + const request = fetchMock.mock.calls[0]?.[1] as RequestInit + expect(JSON.parse(String(request.body))).toEqual({ + character_id: 25, + outfit_id: 'default', + action_id: 'idle', + status: 'issues_found', + }) + }) +}) diff --git a/frontend/src/entities/playtest-inspection/api.ts b/frontend/src/entities/playtest-inspection/api.ts new file mode 100644 index 00000000..6c62412e --- /dev/null +++ b/frontend/src/entities/playtest-inspection/api.ts @@ -0,0 +1,65 @@ +import { ApiError, get, post } from '@/shared/api' + +import type { + PlaytestInspection, + PlaytestInspectionApis, + PlaytestInspectionTarget, + SavePlaytestInspectionInput, +} from '.' + +interface BackendPlaytestInspection { + id: number + character_id: number + outfit_id: string + action_id: string + status: PlaytestInspection['status'] + create_at: string + update_at: string +} + +function toInspection(raw: BackendPlaytestInspection): PlaytestInspection { + return { + id: String(raw.id), + characterId: String(raw.character_id), + outfitId: raw.outfit_id, + actionId: raw.action_id, + status: raw.status, + createdAt: raw.create_at, + updatedAt: raw.update_at, + } +} + +function queryFor(target: PlaytestInspectionTarget): string { + const query = new URLSearchParams({ + character_id: target.characterId, + outfit_id: target.outfitId, + action_id: target.actionId, + }) + return query.toString() +} + +export function createPlaytestInspectionApis(): PlaytestInspectionApis { + return { + async get(target) { + try { + const raw = await get( + `/playtest-inspections?${queryFor(target)}`, + ) + return toInspection(raw) + } catch (cause) { + if (cause instanceof ApiError && cause.code === 404) return null + throw cause + } + }, + + async save(input: SavePlaytestInspectionInput) { + const raw = await post('/playtest-inspections', { + character_id: Number(input.characterId), + outfit_id: input.outfitId, + action_id: input.actionId, + status: input.status, + }) + return toInspection(raw) + }, + } +} diff --git a/frontend/src/entities/playtest-inspection/index.ts b/frontend/src/entities/playtest-inspection/index.ts new file mode 100644 index 00000000..2a160165 --- /dev/null +++ b/frontend/src/entities/playtest-inspection/index.ts @@ -0,0 +1,24 @@ +/** Playtest 对某个动作保存的当前核验结论,不属于资产或创作历史。 */ +export type PlaytestInspectionStatus = 'passed' | 'issues_found' + +export interface PlaytestInspectionTarget { + characterId: string + outfitId: string + actionId: string +} + +export interface PlaytestInspection extends PlaytestInspectionTarget { + id: string + status: PlaytestInspectionStatus + createdAt: string + updatedAt: string +} + +export interface SavePlaytestInspectionInput extends PlaytestInspectionTarget { + status: PlaytestInspectionStatus +} + +export interface PlaytestInspectionApis { + get(target: PlaytestInspectionTarget): Promise + save(input: SavePlaytestInspectionInput): Promise +} diff --git a/frontend/src/entities/project/api.test.ts b/frontend/src/entities/project/api.test.ts new file mode 100644 index 00000000..c83e4977 --- /dev/null +++ b/frontend/src/entities/project/api.test.ts @@ -0,0 +1,57 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' + +import { createProjectApis } from './api' + +afterEach(() => { + vi.unstubAllGlobals() +}) + +describe('project API adapter', () => { + it('maps backend projects without losing server pagination metadata', async () => { + const fetchMock = vi.fn().mockResolvedValue( + new Response( + JSON.stringify({ + code: 200, + message: 'success', + data: [ + { + id: 3, + user_id: 7, + project_name: '像素冒险', + character_perspective: 1, + directional_movement: 2, + sprite_width: 64, + sprite_height: 96, + workflow_id: null, + game_style: '明亮像素风', + sprite_sample_url: null, + create_at: '2026-08-01T00:00:00Z', + update_at: '2026-08-02T00:00:00Z', + }, + ], + total: 21, + page: 2, + page_size: 10, + }), + { status: 200, headers: { 'Content-Type': 'application/json' } }, + ), + ) + vi.stubGlobal('fetch', fetchMock) + + const result = await createProjectApis().list({ page: 2, pageSize: 10 }) + + expect(result).toMatchObject({ total: 21, page: 2, pageSize: 10 }) + expect(result.items[0]).toMatchObject({ + id: '3', + ownerId: '7', + name: '像素冒险', + perspective: 'side', + directionalMovement: 'four-way', + spriteSize: { width: 64, height: 96 }, + }) + expect(fetchMock).toHaveBeenCalledWith( + 'http://127.0.0.1:8000/projects?page=2&page_size=10', + expect.any(Object), + ) + }) +}) diff --git a/frontend/src/entities/project/api.ts b/frontend/src/entities/project/api.ts new file mode 100644 index 00000000..3776a257 --- /dev/null +++ b/frontend/src/entities/project/api.ts @@ -0,0 +1,74 @@ +import type { Project, ProjectApis } from '.' +import type { Paged, PageQuery } from '@/shared/pagination' + +import { del, get, getPage } from '@/shared/api' + +/* ─── 后端 DTO ─── */ + +interface BackendProject { + id: number + user_id: number + project_name: string + character_perspective: number + directional_movement: number + sprite_width: number + sprite_height: number + workflow_id: number | null + game_style: string | null + sprite_sample_url: string | null + create_at: string + update_at: string +} + +/* ─── 映射 ─── */ + +const PERSPECTIVE_MAP: Record = { + 1: 'side', + 2: 'top-down', + 3: 'isometric', +} + +const MOVEMENT_MAP: Record = { + 1: 'single', + 2: 'four-way', + 3: 'eight-way', +} + +function toProject(raw: BackendProject): Project { + return { + id: String(raw.id), + ownerId: String(raw.user_id), + name: raw.project_name, + perspective: PERSPECTIVE_MAP[raw.character_perspective] ?? 'side', + directionalMovement: MOVEMENT_MAP[raw.directional_movement] ?? 'single', + spriteSize: { width: raw.sprite_width, height: raw.sprite_height }, + gameStyle: raw.game_style, + sampleImageUrl: raw.sprite_sample_url, + createdAt: raw.create_at, + updatedAt: raw.update_at, + } +} + +/* ─── 适配器 ─── */ + +export function createProjectApis(): Pick { + return { + async list(query?: PageQuery): Promise> { + const params = new URLSearchParams() + if (query?.page) params.set('page', String(query.page)) + if (query?.pageSize) params.set('page_size', String(query.pageSize)) + const qs = params.toString() + const result = await getPage(`/projects${qs ? `?${qs}` : ''}`) + return { ...result, items: result.items.map(toProject) } + }, + + async get(id: string): Promise { + const raw = await get(`/projects/${id}`) + return toProject(raw) + }, + + async remove(id: string): Promise { + await del(`/projects/${id}`) + }, + } +} diff --git a/frontend/src/shared/api/http-client.test.ts b/frontend/src/shared/api/http-client.test.ts new file mode 100644 index 00000000..52d1cbab --- /dev/null +++ b/frontend/src/shared/api/http-client.test.ts @@ -0,0 +1,80 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' + +import { ApiError, del, get, getPage, post } from './http-client' + +afterEach(() => { + vi.unstubAllGlobals() +}) + +describe('http client request headers', () => { + it('does not force a CORS preflight for a bodyless GET request', async () => { + const fetchMock = vi.fn().mockResolvedValue(jsonResponse({ ok: true })) + vi.stubGlobal('fetch', fetchMock) + + await get('/projects') + + const init = fetchMock.mock.calls[0]?.[1] as RequestInit + expect(new Headers(init.headers).has('Content-Type')).toBe(false) + }) + + it('marks a JSON request body with the correct content type', async () => { + const fetchMock = vi.fn().mockResolvedValue(jsonResponse({ ok: true })) + vi.stubGlobal('fetch', fetchMock) + + await post('/projects', { name: 'Windup' }) + + const init = fetchMock.mock.calls[0]?.[1] as RequestInit + expect(new Headers(init.headers).get('Content-Type')).toBe('application/json') + }) + + it('accepts a successful delete with no response body', async () => { + vi.stubGlobal('fetch', vi.fn().mockResolvedValue(new Response(null, { status: 204 }))) + + await expect(del('/characters/25')).resolves.toBeUndefined() + }) + + it('accepts a successful delete envelope whose data is null', async () => { + vi.stubGlobal('fetch', vi.fn().mockResolvedValue(jsonResponse(null))) + + await expect(del('/projects/3')).resolves.toBeUndefined() + }) + + it('preserves backend pagination metadata for list responses', async () => { + vi.stubGlobal( + 'fetch', + vi.fn().mockResolvedValue( + new Response( + JSON.stringify({ + code: 200, + message: 'success', + data: [{ id: 1 }], + total: 23, + page: 2, + page_size: 10, + }), + { status: 200, headers: { 'Content-Type': 'application/json' } }, + ), + ), + ) + + await expect(getPage<{ id: number }>('/projects?page=2')).resolves.toEqual({ + items: [{ id: 1 }], + total: 23, + page: 2, + pageSize: 10, + }) + }) + + it('rejects a malformed list response instead of inventing pagination values', async () => { + vi.stubGlobal('fetch', vi.fn().mockResolvedValue(jsonResponse([]))) + + await expect(getPage('/projects')).rejects.toBeInstanceOf(ApiError) + }) +}) + +function jsonResponse(data: unknown) { + return new Response(JSON.stringify({ code: 200, message: 'success', data }), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }) +} diff --git a/frontend/src/shared/api/http-client.ts b/frontend/src/shared/api/http-client.ts new file mode 100644 index 00000000..a76985e1 --- /dev/null +++ b/frontend/src/shared/api/http-client.ts @@ -0,0 +1,142 @@ +import type { Paged } from '@/shared/pagination' + +const BASE_URL = import.meta.env.VITE_API_BASE_URL ?? 'http://127.0.0.1:8000' + +interface ApiEnvelope { + code: number + message: string + data: T +} + +interface ApiListEnvelope extends ApiEnvelope { + total: number + page: number + page_size: number +} + +export class ApiError extends Error { + /** HTTP 状态码。 */ + readonly status: number + /** 业务码(与 HTTP 状态码分离)。 */ + readonly code: number + + constructor(status: number, code: number, message: string) { + super(message) + this.name = 'ApiError' + this.status = status + this.code = code + } +} + +async function executeRequest( + path: string, + init: RequestInit | undefined, + allowEmpty: boolean, +): Promise> { + const url = `${BASE_URL}${path}` + const headers = new Headers(init?.headers) + + if ( + init?.body !== undefined && + init.body !== null && + !headers.has('Content-Type') && + !(init.body instanceof FormData) + ) { + headers.set('Content-Type', 'application/json') + } + + const response = await fetch(url, { ...init, headers }) + + if (!response.ok) { + let code = response.status + let message = response.statusText + try { + const body = (await response.json()) as Partial> + if (body.code !== undefined) code = body.code + if (body.message) message = body.message + } catch { + // 响应体不是 JSON,保留默认错误信息 + } + throw new ApiError(response.status, code, message) + } + + if (allowEmpty && response.status === 204) { + return { code: response.status, message: '', data: undefined as T } + } + + let envelope: ApiEnvelope + try { + envelope = (await response.json()) as ApiEnvelope + } catch { + throw new ApiError(response.status, response.status, '响应格式错误,无法解析 JSON') + } + if (envelope.data === null || envelope.data === undefined) { + if (allowEmpty) return envelope + throw new ApiError( + response.status, + envelope.code ?? response.status, + envelope.message || '服务端未返回数据', + ) + } + if (envelope.code < 200 || envelope.code >= 300) { + throw new ApiError(response.status, envelope.code, envelope.message || '请求失败') + } + return envelope +} + +export async function request(path: string, init?: RequestInit): Promise { + return (await executeRequest(path, init, false)).data +} + +export function get(path: string): Promise { + return request(path) +} + +/** + * 读取后端 ListResponse,保留 data 之外的分页元数据。 + * 普通 get() 只返回 data,分页列表必须使用本入口,不能用当前页长度伪造 total。 + */ +export async function getPage(path: string): Promise> { + const envelope = (await executeRequest(path, undefined, false)) as ApiListEnvelope + if ( + !Number.isInteger(envelope.total) || + envelope.total < 0 || + !Number.isInteger(envelope.page) || + envelope.page < 1 || + !Number.isInteger(envelope.page_size) || + envelope.page_size < 1 + ) { + throw new ApiError(200, envelope.code, '分页响应缺少有效的 total、page 或 page_size') + } + return { + items: envelope.data, + total: envelope.total, + page: envelope.page, + pageSize: envelope.page_size, + } +} + +export function post(path: string, body: unknown): Promise { + return request(path, { + method: 'POST', + body: JSON.stringify(body), + }) +} + +export function patch(path: string, body: unknown): Promise { + return request(path, { + method: 'PATCH', + body: JSON.stringify(body), + }) +} + +export async function del(path: string): Promise { + await executeRequest(path, { method: 'DELETE' }, true) +} + +export function upload(path: string, formData: FormData): Promise { + return request(path, { + method: 'POST', + body: formData, + }) +} diff --git a/frontend/src/shared/api/index.ts b/frontend/src/shared/api/index.ts new file mode 100644 index 00000000..d512fcfe --- /dev/null +++ b/frontend/src/shared/api/index.ts @@ -0,0 +1,2 @@ +/** 通用 HTTP 能力。这里只处理传输协议,不包含项目、角色等业务映射。 */ +export { ApiError, del, get, getPage, patch, post, request, upload } from './http-client' From a7c2aa055575afb294880632c11c7db982acd53c Mon Sep 17 00:00:00 2001 From: xyh202131 <246811510+xyh202131@users.noreply.github.com> Date: Tue, 4 Aug 2026 10:10:22 +0800 Subject: [PATCH 2/5] feat(playtest): add preview and quality engine --- .../workbench/analysis/frame-geometry.test.ts | 129 +++++ .../workbench/analysis/frame-geometry.ts | 197 +++++++ .../workbench/analysis/image-geometry.test.ts | 146 +++++ .../workbench/analysis/image-geometry.ts | 76 +++ .../workbench/analysis/quality-policy.ts | 123 +++++ .../analysis/sequence-evidence.test.ts | 407 ++++++++++++++ .../workbench/analysis/sequence-evidence.ts | 498 ++++++++++++++++++ .../use-frame-review-evidence.test.tsx | 203 +++++++ .../analysis/use-frame-review-evidence.ts | 106 ++++ .../workbench/audit/audit-session.test.ts | 53 ++ .../playtest/workbench/audit/audit-session.ts | 41 ++ .../model/create-preview-model.test.ts | 124 +++++ .../workbench/model/create-preview-model.ts | 94 ++++ .../pages/playtest/workbench/model/types.ts | 45 ++ .../workbench/playback/playback-state.test.ts | 342 ++++++++++++ .../workbench/playback/playback-state.ts | 155 ++++++ .../playback/use-playback-controller.test.tsx | 300 +++++++++++ .../playback/use-playback-controller.ts | 155 ++++++ .../playtest/workbench/stage-motion.test.tsx | 250 +++++++++ .../pages/playtest/workbench/stage-motion.ts | 136 +++++ .../workbench/use-playtest-keyboard.test.tsx | 265 ++++++++++ .../workbench/use-playtest-keyboard.ts | 91 ++++ 22 files changed, 3936 insertions(+) create mode 100644 frontend/src/pages/playtest/workbench/analysis/frame-geometry.test.ts create mode 100644 frontend/src/pages/playtest/workbench/analysis/frame-geometry.ts create mode 100644 frontend/src/pages/playtest/workbench/analysis/image-geometry.test.ts create mode 100644 frontend/src/pages/playtest/workbench/analysis/image-geometry.ts create mode 100644 frontend/src/pages/playtest/workbench/analysis/quality-policy.ts create mode 100644 frontend/src/pages/playtest/workbench/analysis/sequence-evidence.test.ts create mode 100644 frontend/src/pages/playtest/workbench/analysis/sequence-evidence.ts create mode 100644 frontend/src/pages/playtest/workbench/analysis/use-frame-review-evidence.test.tsx create mode 100644 frontend/src/pages/playtest/workbench/analysis/use-frame-review-evidence.ts create mode 100644 frontend/src/pages/playtest/workbench/audit/audit-session.test.ts create mode 100644 frontend/src/pages/playtest/workbench/audit/audit-session.ts create mode 100644 frontend/src/pages/playtest/workbench/model/create-preview-model.test.ts create mode 100644 frontend/src/pages/playtest/workbench/model/create-preview-model.ts create mode 100644 frontend/src/pages/playtest/workbench/model/types.ts create mode 100644 frontend/src/pages/playtest/workbench/playback/playback-state.test.ts create mode 100644 frontend/src/pages/playtest/workbench/playback/playback-state.ts create mode 100644 frontend/src/pages/playtest/workbench/playback/use-playback-controller.test.tsx create mode 100644 frontend/src/pages/playtest/workbench/playback/use-playback-controller.ts create mode 100644 frontend/src/pages/playtest/workbench/stage-motion.test.tsx create mode 100644 frontend/src/pages/playtest/workbench/stage-motion.ts create mode 100644 frontend/src/pages/playtest/workbench/use-playtest-keyboard.test.tsx create mode 100644 frontend/src/pages/playtest/workbench/use-playtest-keyboard.ts diff --git a/frontend/src/pages/playtest/workbench/analysis/frame-geometry.test.ts b/frontend/src/pages/playtest/workbench/analysis/frame-geometry.test.ts new file mode 100644 index 00000000..e9bc8f3f --- /dev/null +++ b/frontend/src/pages/playtest/workbench/analysis/frame-geometry.test.ts @@ -0,0 +1,129 @@ +import { describe, expect, it } from 'vitest' + +import { measureFrameGeometry, type FramePixelData } from './frame-geometry' + +function createPixels( + width: number, + height: number, + visible: readonly { x: number; y: number; alpha: number }[], +): FramePixelData { + const data = new Uint8ClampedArray(width * height * 4) + + for (const pixel of visible) data[(pixel.y * width + pixel.x) * 4 + 3] = pixel.alpha + + return { data, width, height } +} + +describe('measureFrameGeometry', () => { + it('treats only alpha values greater than 24 as visible', () => { + // Catches the review algorithm including matte noise at the old Alpha cutoff. + expect(measureFrameGeometry(createPixels(1, 1, [{ x: 0, y: 0, alpha: 24 }]))).toBeNull() + expect(measureFrameGeometry(createPixels(1, 1, [{ x: 0, y: 0, alpha: 25 }]))).toMatchObject({ + opaquePixels: 1, + coverageRatio: 1, + }) + }) + + it('measures bounds, centroid, foot line, height, area and coverage from visible pixels', () => { + // Catches off-by-one bounds or a centroid derived from the box instead of real visible pixels. + const geometry = measureFrameGeometry( + createPixels(4, 4, [ + { x: 1, y: 1, alpha: 255 }, + { x: 2, y: 1, alpha: 255 }, + { x: 1, y: 2, alpha: 255 }, + { x: 2, y: 2, alpha: 255 }, + ]), + ) + + expect(geometry).toEqual({ + width: 4, + height: 4, + bounds: { left: 1, top: 1, right: 2, bottom: 2, width: 2, height: 2 }, + centroid: { x: 1.5, y: 1.5 }, + footY: 2, + subjectHeight: 2, + opaquePixels: 4, + coverageRatio: 0.25, + fingerprint: expect.any(Array), + contentHash: expect.any(String), + }) + expect(geometry?.fingerprint).toHaveLength(64) + }) + + it('produces different compact fingerprints for different silhouettes with equal bounds', () => { + const leftTop = measureFrameGeometry( + createPixels(4, 4, [ + { x: 0, y: 0, alpha: 255 }, + { x: 1, y: 0, alpha: 255 }, + { x: 2, y: 0, alpha: 255 }, + { x: 3, y: 0, alpha: 255 }, + { x: 3, y: 1, alpha: 255 }, + { x: 3, y: 2, alpha: 255 }, + { x: 3, y: 3, alpha: 255 }, + ]), + ) + const leftBottom = measureFrameGeometry( + createPixels(4, 4, [ + { x: 0, y: 0, alpha: 255 }, + { x: 0, y: 1, alpha: 255 }, + { x: 0, y: 2, alpha: 255 }, + { x: 0, y: 3, alpha: 255 }, + { x: 1, y: 3, alpha: 255 }, + { x: 2, y: 3, alpha: 255 }, + { x: 3, y: 3, alpha: 255 }, + ]), + ) + + expect(leftTop?.bounds).toEqual(leftBottom?.bounds) + expect(leftTop?.fingerprint).not.toEqual(leftBottom?.fingerprint) + }) + + it('keeps small dark silhouettes distinguishable instead of diluting them across the canvas', () => { + const topLeft: Array<{ x: number; y: number; alpha: number }> = [] + const bottomRight: Array<{ x: number; y: number; alpha: number }> = [] + for (let y = 96; y < 160; y += 1) { + for (let x = 96; x < 160; x += 1) { + if (y < 128 || x < 112) topLeft.push({ x, y, alpha: 255 }) + if (y >= 128 || x >= 144) bottomRight.push({ x, y, alpha: 255 }) + } + } + const first = measureFrameGeometry(createPixels(256, 256, topLeft)) + const second = measureFrameGeometry(createPixels(256, 256, bottomRight)) + const distance = + first?.fingerprint?.reduce( + (total, value, index) => total + Math.abs(value - (second?.fingerprint?.[index] ?? value)), + 0, + ) ?? 0 + + expect(first?.bounds).toEqual(second?.bounds) + expect(distance / 64).toBeGreaterThan(0.02) + }) + + it('rejects an RGBA buffer whose dimensions do not match its length', () => { + // Catches silent geometry corruption when Canvas data and dimensions diverge. + expect(() => + measureFrameGeometry({ data: new Uint8ClampedArray(4), width: 2, height: 2 }), + ).toThrowError('RGBA 像素长度与画布尺寸不一致') + }) + + it('ignores a tiny isolated Alpha component outside the visible subject', () => { + // Catches one stray generated pixel moving the measured foot line and centroid. + const geometry = measureFrameGeometry( + createPixels(4, 4, [ + { x: 0, y: 0, alpha: 255 }, + { x: 1, y: 0, alpha: 255 }, + { x: 0, y: 1, alpha: 255 }, + { x: 1, y: 1, alpha: 255 }, + { x: 3, y: 3, alpha: 255 }, + ]), + ) + + expect(geometry).toMatchObject({ + bounds: { left: 0, top: 0, right: 1, bottom: 1, width: 2, height: 2 }, + centroid: { x: 0.5, y: 0.5 }, + footY: 1, + opaquePixels: 4, + coverageRatio: 0.25, + }) + }) +}) diff --git a/frontend/src/pages/playtest/workbench/analysis/frame-geometry.ts b/frontend/src/pages/playtest/workbench/analysis/frame-geometry.ts new file mode 100644 index 00000000..9fff8c39 --- /dev/null +++ b/frontend/src/pages/playtest/workbench/analysis/frame-geometry.ts @@ -0,0 +1,197 @@ +export const ALPHA_THRESHOLD = 24 +const MIN_COMPONENT_PIXELS = 4 +const RELATIVE_COMPONENT_RATIO = 0.002 + +export interface FramePixelData { + data: Uint8ClampedArray + width: number + height: number +} + +export interface FrameGeometry { + width: number + height: number + bounds: { + left: number + top: number + right: number + bottom: number + width: number + height: number + } + centroid: { x: number; y: number } + footY: number + subjectHeight: number + opaquePixels: number + coverageRatio: number + /** Compact 8×8 alpha/luminance signature used for adjacent-frame similarity checks. */ + fingerprint?: readonly number[] + /** Exact RGBA content hash used to identify genuinely duplicated frames. */ + contentHash?: string +} + +function hashPixels(data: Uint8ClampedArray): string { + let hash = 0x811c9dc5 + for (const value of data) { + hash ^= value + hash = Math.imul(hash, 0x01000193) + } + return (hash >>> 0).toString(16).padStart(8, '0') +} + +function createFingerprint( + data: Uint8ClampedArray, + width: number, + subjectPixels: readonly number[], + bounds: { left: number; top: number; width: number; height: number }, +): readonly number[] { + const sums = new Float64Array(64) + const cellPixels = new Uint32Array(64) + + for (const index of subjectPixels) { + const x = index % width + const y = Math.floor(index / width) + const offset = index * 4 + const red = data[offset] ?? 0 + const green = data[offset + 1] ?? 0 + const blue = data[offset + 2] ?? 0 + const alpha = (data[offset + 3] ?? 0) / 255 + const luminance = (red * 0.2126 + green * 0.7152 + blue * 0.0722) / 255 + const cellX = Math.min(7, Math.floor(((x - bounds.left) * 8) / bounds.width)) + const cellY = Math.min(7, Math.floor(((y - bounds.top) * 8) / bounds.height)) + const cell = cellY * 8 + cellX + sums[cell] += alpha * (0.25 + luminance * 0.75) + cellPixels[cell] += 1 + } + + return Array.from(sums, (sum, index) => { + const count = cellPixels[index] ?? 0 + return count === 0 ? 0 : Number((sum / count).toFixed(4)) + }) +} + +interface VisibleComponentsResult { + components: number[][] + largestSize: number +} + +function visibleComponents( + data: Uint8ClampedArray, + width: number, + height: number, +): VisibleComponentsResult { + const pixelCount = width * height + const visible = new Uint8Array(pixelCount) + const visited = new Uint8Array(pixelCount) + const components: number[][] = [] + const queue = new Int32Array(pixelCount) + let largestSize = 0 + + for (let index = 0; index < pixelCount; index += 1) { + const alpha = data[index * 4 + 3] + if (alpha !== undefined && alpha > ALPHA_THRESHOLD) visible[index] = 1 + } + + for (let start = 0; start < pixelCount; start += 1) { + if (visible[start] === 0 || visited[start] === 1) continue + + const component: number[] = [] + let head = 0 + let tail = 0 + queue[tail++] = start + visited[start] = 1 + + while (head < tail) { + const index = queue[head++] + component.push(index) + + const x = index % width + const y = Math.floor(index / width) + for (let offsetY = -1; offsetY <= 1; offsetY += 1) { + for (let offsetX = -1; offsetX <= 1; offsetX += 1) { + if (offsetX === 0 && offsetY === 0) continue + const nextX = x + offsetX + const nextY = y + offsetY + if (nextX < 0 || nextX >= width || nextY < 0 || nextY >= height) continue + + const next = nextY * width + nextX + if (visible[next] === 0 || visited[next] === 1) continue + visited[next] = 1 + queue[tail++] = next + } + } + } + + if (component.length > largestSize) largestSize = component.length + components.push(component) + } + + return { components, largestSize } +} + +export function measureFrameGeometry(pixels: FramePixelData): FrameGeometry | null { + const { data, width, height } = pixels + + if (data.length !== width * height * 4) { + throw new RangeError('RGBA 像素长度与画布尺寸不一致') + } + + const { components, largestSize } = visibleComponents(data, width, height) + if (components.length === 0) return null + + const minimumSize = Math.min( + largestSize, + Math.max(MIN_COMPONENT_PIXELS, Math.ceil(largestSize * RELATIVE_COMPONENT_RATIO)), + ) + const subjectPixels = components.flatMap((component) => + component.length === largestSize || component.length >= minimumSize ? component : [], + ) + + let left = width + let top = height + let right = -1 + let bottom = -1 + let opaquePixels = 0 + let sumX = 0 + let sumY = 0 + + for (const index of subjectPixels) { + const x = index % width + const y = Math.floor(index / width) + left = Math.min(left, x) + top = Math.min(top, y) + right = Math.max(right, x) + bottom = Math.max(bottom, y) + opaquePixels += 1 + sumX += x + sumY += y + } + + const subjectWidth = right - left + 1 + const subjectHeight = bottom - top + 1 + + return { + width, + height, + bounds: { + left, + top, + right, + bottom, + width: subjectWidth, + height: subjectHeight, + }, + centroid: { x: sumX / opaquePixels, y: sumY / opaquePixels }, + footY: bottom, + subjectHeight, + opaquePixels, + coverageRatio: opaquePixels / (width * height), + fingerprint: createFingerprint(data, width, subjectPixels, { + left, + top, + width: subjectWidth, + height: subjectHeight, + }), + contentHash: hashPixels(data), + } +} diff --git a/frontend/src/pages/playtest/workbench/analysis/image-geometry.test.ts b/frontend/src/pages/playtest/workbench/analysis/image-geometry.test.ts new file mode 100644 index 00000000..6b4f2ae1 --- /dev/null +++ b/frontend/src/pages/playtest/workbench/analysis/image-geometry.test.ts @@ -0,0 +1,146 @@ +/** @vitest-environment jsdom */ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +import { readImageGeometry } from './image-geometry' + +type ImageBehavior = 'load' | 'error' | 'pending' + +let imageBehavior: ImageBehavior +let crossOriginAtSourceAssignment: string | null +let lastAssignedSource: string +let canvasContext: Pick | null + +class FakeImage { + crossOrigin: string | null = null + naturalWidth = 2 + naturalHeight = 2 + onerror: OnErrorEventHandler | null = null + onload: ((this: GlobalEventHandlers, event: Event) => unknown) | null = null + private source = '' + + get src(): string { + return this.source + } + + set src(value: string) { + this.source = value + crossOriginAtSourceAssignment = this.crossOrigin + lastAssignedSource = value + if (value === '' || imageBehavior === 'pending') return + + queueMicrotask(() => { + if (imageBehavior === 'load') this.onload?.call(this as never, new Event('load')) + else this.onerror?.call(this as never, 'error', '', 0, 0, new Error('load failed')) + }) + } +} + +function pixelsWithAlpha(alpha: number): ImageData { + const data = new Uint8ClampedArray(2 * 2 * 4) + data[3] = alpha + return { data, width: 2, height: 2, colorSpace: 'srgb' } as ImageData +} + +beforeEach(() => { + imageBehavior = 'load' + crossOriginAtSourceAssignment = null + lastAssignedSource = '' + canvasContext = { + drawImage: vi.fn(), + getImageData: vi.fn(() => pixelsWithAlpha(255)), + } + vi.stubGlobal('Image', FakeImage) + vi.spyOn(document, 'createElement').mockImplementation(((tagName: string) => { + if (tagName !== 'canvas') + return document.createElementNS('http://www.w3.org/1999/xhtml', tagName) + return { + width: 0, + height: 0, + getContext: () => canvasContext, + } as unknown as HTMLCanvasElement + }) as typeof document.createElement) +}) + +afterEach(() => { + vi.restoreAllMocks() + vi.unstubAllGlobals() +}) + +describe('readImageGeometry', () => { + it('requests anonymous image access before reading real Canvas pixels', async () => { + // Catches crossOrigin being assigned after src or a placeholder geometry replacing actual pixels. + const result = await readImageGeometry('https://cdn.example.test/frame.png') + + expect(crossOriginAtSourceAssignment).toBe('anonymous') + expect(result).toEqual({ + status: 'ready', + geometry: { + width: 2, + height: 2, + bounds: { left: 0, top: 0, right: 0, bottom: 0, width: 1, height: 1 }, + centroid: { x: 0, y: 0 }, + footY: 0, + subjectHeight: 1, + opaquePixels: 1, + coverageRatio: 0.25, + fingerprint: expect.any(Array), + contentHash: expect.any(String), + }, + }) + }) + + it('reports asset, Canvas and transparent-frame failures instead of zero evidence', async () => { + // Catches unavailable evidence being silently presented as a successful zero measurement. + imageBehavior = 'error' + await expect(readImageGeometry('/missing.png')).resolves.toEqual({ + status: 'unavailable', + reason: '图片加载失败', + }) + + imageBehavior = 'load' + canvasContext = null + await expect(readImageGeometry('/no-canvas.png')).resolves.toEqual({ + status: 'unavailable', + reason: '浏览器无法读取图片像素', + }) + + canvasContext = { + drawImage: vi.fn(), + getImageData: vi.fn(() => pixelsWithAlpha(24)), + } + await expect(readImageGeometry('/transparent.png')).resolves.toEqual({ + status: 'unavailable', + reason: '图片没有可见主体', + }) + }) + + it('reports a tainted Canvas as a cross-origin pixel failure', async () => { + // Catches signed remote images being mislabelled as transparent or valid when CORS blocks inspection. + canvasContext = { + drawImage: vi.fn(), + getImageData: vi.fn(() => { + throw new DOMException('tainted', 'SecurityError') + }), + } + + await expect(readImageGeometry('https://remote.example.test/frame.png')).resolves.toEqual({ + status: 'unavailable', + reason: '图片跨域,无法计算像素', + }) + }) + + it('cancels a pending image read through AbortSignal', async () => { + // Catches a stale sequence load surviving a direction switch and updating the new review. + imageBehavior = 'pending' + const controller = new AbortController() + const result = readImageGeometry('/slow.png', controller.signal) + + controller.abort() + + await expect(result).resolves.toEqual({ + status: 'unavailable', + reason: '分析已取消', + }) + expect(lastAssignedSource).toBe('') + }) +}) diff --git a/frontend/src/pages/playtest/workbench/analysis/image-geometry.ts b/frontend/src/pages/playtest/workbench/analysis/image-geometry.ts new file mode 100644 index 00000000..b8ff09d6 --- /dev/null +++ b/frontend/src/pages/playtest/workbench/analysis/image-geometry.ts @@ -0,0 +1,76 @@ +import { measureFrameGeometry } from './frame-geometry' +import type { FrameGeometryResult } from './sequence-evidence' + +function unavailable(reason: string): FrameGeometryResult { + return { status: 'unavailable', reason } +} + +function pixelReadFailure(error: unknown): FrameGeometryResult { + if (error instanceof DOMException && error.name === 'SecurityError') { + return unavailable('图片跨域,无法计算像素') + } + + return unavailable('浏览器无法读取图片像素') +} + +/** + * 每次读取创建独立 canvas。帧检查是低频操作(每帧 onload 一次), + * 不缓存可避免模块级状态在测试间泄漏(mock 无法重置)。 + */ +function getSharedContext(width: number, height: number): CanvasRenderingContext2D | null { + const canvas = document.createElement('canvas') + canvas.width = width + canvas.height = height + return canvas.getContext('2d', { willReadFrequently: true }) +} + +export function readImageGeometry( + imageUrl: string, + signal?: AbortSignal, +): Promise { + return new Promise((resolve) => { + const image = new Image() + let settled = false + + const finish = (result: FrameGeometryResult) => { + if (settled) return + settled = true + image.onload = null + image.onerror = null + signal?.removeEventListener('abort', abort) + resolve(result) + } + const abort = () => { + image.src = '' + finish(unavailable('分析已取消')) + } + + image.crossOrigin = 'anonymous' + image.onload = () => { + const context = getSharedContext(image.naturalWidth, image.naturalHeight) + + if (context === null) { + finish(unavailable('浏览器无法读取图片像素')) + return + } + + try { + context.drawImage(image, 0, 0) + const imageData = context.getImageData(0, 0, image.naturalWidth, image.naturalHeight) + const geometry = measureFrameGeometry(imageData) + finish(geometry === null ? unavailable('图片没有可见主体') : { status: 'ready', geometry }) + } catch (error) { + finish(pixelReadFailure(error)) + } + } + image.onerror = () => finish(unavailable('图片加载失败')) + + if (signal?.aborted) { + abort() + return + } + + signal?.addEventListener('abort', abort, { once: true }) + image.src = imageUrl + }) +} diff --git a/frontend/src/pages/playtest/workbench/analysis/quality-policy.ts b/frontend/src/pages/playtest/workbench/analysis/quality-policy.ts new file mode 100644 index 00000000..4aa8c459 --- /dev/null +++ b/frontend/src/pages/playtest/workbench/analysis/quality-policy.ts @@ -0,0 +1,123 @@ +import type { PlaytestActionType } from '../model/types' +import type { FrameGeometry } from './frame-geometry' + +export interface CanvasBaseline { + width: number + height: number +} + +export interface LocalQualityPolicy { + expectedCanvas: CanvasBaseline | null + edgeMargin: { x: number; y: number } + minimumCoverageRatio: number + maximumCoverageRatio: number + footDriftThreshold: number | null + heightDriftThreshold: number | null + heightAttentionThreshold: number | null + areaDeltaThresholdPercent: number + movementPadding: number + movementFloor: number + movementCeiling: number + rootMotionDirectionMinimum: number +} + +const REFERENCE_CANVAS_SIZE = 256 +const MINIMUM_COVERAGE_RATIO = 0.005 +const MAXIMUM_COVERAGE_RATIO = 0.65 +const AREA_DELTA_THRESHOLD_PERCENT = 28 + +function scaledPixels(referencePixels: number, scale: number): number { + return Number((referencePixels * scale).toFixed(4)) +} + +function inferCanvasBaseline(geometries: readonly FrameGeometry[]): CanvasBaseline | null { + const candidates = new Map< + string, + { canvas: CanvasBaseline; count: number; firstIndex: number } + >() + + geometries.forEach((geometry, index) => { + const key = `${geometry.width}x${geometry.height}` + const existing = candidates.get(key) + if (existing) existing.count += 1 + else { + candidates.set(key, { + canvas: { width: geometry.width, height: geometry.height }, + count: 1, + firstIndex: index, + }) + } + }) + + const selected = [...candidates.values()].sort( + (left, right) => right.count - left.count || left.firstIndex - right.firstIndex, + )[0] + return selected?.canvas ?? null +} + +function heightReferencePixels(actionType: PlaytestActionType): number | null { + if (actionType === 'jump' || actionType === 'crouch') return null + if (actionType === 'idle') return 7 + if (actionType === 'walk') return 12 + return 20 +} + +function movementCeilingReferencePixels(actionType: PlaytestActionType): number { + if (actionType === 'idle') return 6 + if (actionType === 'walk') return 24 + if (actionType === 'crouch') return 16 + if (actionType === 'jump') return 64 + return 48 +} + +/** + * Keeps the existing local heuristics, but scales pixel thresholds from the sequence's dominant + * canvas size. The 256px values are calibration references, not a required asset contract. + */ +export function deriveLocalQualityPolicy( + geometries: readonly FrameGeometry[], + actionType: PlaytestActionType, + expectedCanvasOverride: CanvasBaseline | null = null, +): LocalQualityPolicy { + const expectedCanvas = expectedCanvasOverride ?? inferCanvasBaseline(geometries) + if (expectedCanvas === null) { + return { + expectedCanvas: null, + edgeMargin: { x: 1, y: 1 }, + minimumCoverageRatio: MINIMUM_COVERAGE_RATIO, + maximumCoverageRatio: MAXIMUM_COVERAGE_RATIO, + footDriftThreshold: null, + heightDriftThreshold: null, + heightAttentionThreshold: null, + areaDeltaThresholdPercent: AREA_DELTA_THRESHOLD_PERCENT, + movementPadding: 2, + movementFloor: 6, + movementCeiling: movementCeilingReferencePixels(actionType), + rootMotionDirectionMinimum: 2, + } + } + + const horizontalScale = expectedCanvas.width / REFERENCE_CANVAS_SIZE + const verticalScale = expectedCanvas.height / REFERENCE_CANVAS_SIZE + const distanceScale = Math.sqrt(horizontalScale * verticalScale) + const heightReference = heightReferencePixels(actionType) + + return { + expectedCanvas, + edgeMargin: { + x: Math.max(1, scaledPixels(2, horizontalScale)), + y: Math.max(1, scaledPixels(2, verticalScale)), + }, + minimumCoverageRatio: MINIMUM_COVERAGE_RATIO, + maximumCoverageRatio: MAXIMUM_COVERAGE_RATIO, + footDriftThreshold: scaledPixels(3, verticalScale), + heightDriftThreshold: + heightReference === null ? null : scaledPixels(heightReference, verticalScale), + heightAttentionThreshold: scaledPixels(7, verticalScale), + areaDeltaThresholdPercent: AREA_DELTA_THRESHOLD_PERCENT, + movementPadding: scaledPixels(2, distanceScale), + movementFloor: scaledPixels(6, distanceScale), + movementCeiling: scaledPixels(movementCeilingReferencePixels(actionType), distanceScale), + rootMotionDirectionMinimum: scaledPixels(2, distanceScale), + } +} diff --git a/frontend/src/pages/playtest/workbench/analysis/sequence-evidence.test.ts b/frontend/src/pages/playtest/workbench/analysis/sequence-evidence.test.ts new file mode 100644 index 00000000..bf7697e1 --- /dev/null +++ b/frontend/src/pages/playtest/workbench/analysis/sequence-evidence.test.ts @@ -0,0 +1,407 @@ +import { describe, expect, it } from 'vitest' + +import type { FrameGeometry } from './frame-geometry' +import { buildSequenceEvidence, type FrameEvidenceInput } from './sequence-evidence' + +function geometry( + overrides: Partial< + Pick< + FrameGeometry, + 'width' | 'height' | 'footY' | 'subjectHeight' | 'opaquePixels' | 'coverageRatio' + > + > & { + x?: number + y?: number + fingerprint?: readonly number[] + contentHash?: string + cropped?: boolean + } = {}, +): FrameGeometry { + const subjectHeight = overrides.subjectHeight ?? 20 + + return { + width: overrides.width ?? 256, + height: overrides.height ?? 256, + bounds: { + left: overrides.cropped ? 0 : 100, + top: overrides.cropped ? 0 : 100, + right: overrides.cropped ? 9 : 109, + bottom: overrides.cropped ? subjectHeight - 1 : 100 + subjectHeight - 1, + width: 10, + height: subjectHeight, + }, + centroid: { x: overrides.x ?? 0, y: overrides.y ?? 0 }, + footY: overrides.footY ?? 100, + subjectHeight, + opaquePixels: overrides.opaquePixels ?? 100, + coverageRatio: overrides.coverageRatio ?? 0.25, + fingerprint: overrides.fingerprint, + contentHash: overrides.contentHash, + } +} + +function ready( + value: FrameGeometry, + rootMotion: FrameEvidenceInput['rootMotion'] = null, +): FrameEvidenceInput { + return { geometry: { status: 'ready', geometry: value }, rootMotion } +} + +describe('buildSequenceEvidence', () => { + it('returns structured findings for incomplete, cropped and duplicate frames', () => { + const contentHash = 'same-frame' + const evidence = buildSequenceEvidence( + [ + ready(geometry({ cropped: true, contentHash })), + ready(geometry({ contentHash })), + { geometry: { status: 'unavailable', reason: '图片没有可见主体' }, rootMotion: null }, + ], + 'walk', + ) + + expect(evidence.complete).toBe(false) + expect(evidence.findings.map((finding) => finding.code)).toEqual( + expect.arrayContaining(['subject_cropped', 'duplicate_frame', 'blank_subject']), + ) + expect(evidence.findings.find((finding) => finding.code === 'duplicate_frame')).toMatchObject({ + frameIndex: 1, + severity: 'warning', + }) + }) + + it('uses action-aware findings for foot and height changes', () => { + const frames = [ + ready(geometry({ footY: 100, subjectHeight: 30 })), + ready(geometry({ footY: 112, subjectHeight: 15 })), + ] + + expect(buildSequenceEvidence(frames, 'idle').findings.map((finding) => finding.code)).toEqual( + expect.arrayContaining(['foot_drift', 'height_drift']), + ) + expect( + buildSequenceEvidence(frames, 'jump').findings.map((finding) => finding.code), + ).not.toContain('foot_drift') + expect( + buildSequenceEvidence(frames, 'crouch').findings.map((finding) => finding.code), + ).not.toContain('height_drift') + }) + + it('flags motion outliers and root-motion direction contradictions', () => { + const evidence = buildSequenceEvidence( + [ + ready(geometry({ x: 0 })), + ready(geometry({ x: 1 }), { dx: 1, dy: 0 }), + ready(geometry({ x: 2 }), { dx: 1, dy: 0 }), + ready(geometry({ x: -18 }), { dx: 5, dy: 0 }), + ], + 'walk', + ) + + expect(evidence.findings.map((finding) => finding.code)).toEqual( + expect.arrayContaining(['motion_spike', 'root_motion_mismatch']), + ) + }) + + it('calculates a selected frame delta and the hand-derived sequence baseline', () => { + // Catches image-derived offsets being calculated from bounds or against the first frame instead of the previous frame. + const evidence = buildSequenceEvidence( + [ready(geometry()), ready(geometry({ x: 3, y: 4, opaquePixels: 80 }))], + 'walk', + ) + + expect(evidence.frames[0]?.previousDelta).toBeNull() + expect(evidence.frames[1]?.previousDelta).toEqual({ + dx: 3, + dy: 4, + distance: 5, + areaDeltaPercent: 20, + }) + expect(evidence.summary).toMatchObject({ + medianStep: 5, + maxStep: 5, + movementThreshold: 15, + maxAreaDeltaPercent: 20, + movementState: 'normal', + areaState: 'normal', + }) + }) + + it('infers a consistent canvas baseline instead of requiring 256 pixels', () => { + const evidence = buildSequenceEvidence( + [ + ready(geometry({ width: 512, height: 512, footY: 200, subjectHeight: 40 })), + ready(geometry({ width: 512, height: 512, footY: 205, subjectHeight: 60 })), + ], + 'walk', + ) + + expect(evidence.findings.map((finding) => finding.code)).not.toContain('canvas_size_mismatch') + expect(evidence.summary).toMatchObject({ + expectedCanvas: { width: 512, height: 512 }, + footThreshold: 6, + heightThreshold: 24, + footState: 'normal', + heightState: 'normal', + }) + }) + + it('flags only frames that disagree with the locally inferred canvas baseline', () => { + const evidence = buildSequenceEvidence( + [ + ready(geometry({ width: 512, height: 512 })), + ready(geometry({ width: 512, height: 512 })), + ready(geometry({ width: 256, height: 256 })), + ], + 'idle', + ) + + expect( + evidence.findings + .filter((finding) => finding.code === 'canvas_size_mismatch') + .map((finding) => finding.frameIndex), + ).toEqual([2]) + expect(evidence.summary.expectedCanvas).toEqual({ width: 512, height: 512 }) + expect(evidence.frames[2]?.previousDelta).toBeNull() + expect(evidence.findings.map((finding) => finding.code)).not.toContain('motion_spike') + }) + + it('excludes non-baseline canvas frames from sequence-level measurements', () => { + const evidence = buildSequenceEvidence( + [ + ready(geometry({ width: 512, height: 512, footY: 200, subjectHeight: 40 })), + ready(geometry({ width: 512, height: 512, footY: 205, subjectHeight: 42 })), + ready(geometry({ width: 512, height: 512, footY: 202, subjectHeight: 41 })), + ready(geometry({ width: 256, height: 256, footY: 100, subjectHeight: 10 })), + ready(geometry({ width: 256, height: 256, footY: 120, subjectHeight: 20 })), + ], + 'walk', + ) + + expect(evidence.summary).toMatchObject({ + expectedCanvas: { width: 512, height: 512 }, + footDrift: 5, + heightDrift: 2, + }) + expect(evidence.findings.map((finding) => finding.code)).toEqual( + expect.arrayContaining(['canvas_size_mismatch']), + ) + expect(evidence.findings.map((finding) => finding.code)).not.toContain('foot_drift') + }) + + it('scales the local motion floor with the inferred canvas size', () => { + const evidence = buildSequenceEvidence( + [ + ready(geometry({ width: 512, height: 512, x: 0 })), + ready(geometry({ width: 512, height: 512, x: 2 })), + ready(geometry({ width: 512, height: 512, x: 4 })), + ready(geometry({ width: 512, height: 512, x: 14 })), + ], + 'attack', + ) + + expect(evidence.summary).toMatchObject({ + maxStep: 10, + movementThreshold: 12, + movementState: 'normal', + }) + expect(evidence.findings.map((finding) => finding.code)).not.toContain('motion_spike') + }) + + it('does not compare across an unreadable middle frame', () => { + // Catches filtered valid frames becoming false neighbours and producing a misleading offset. + const evidence = buildSequenceEvidence( + [ + ready(geometry()), + { geometry: { status: 'unavailable', reason: '图片加载失败' }, rootMotion: null }, + ready(geometry({ x: 20, y: 20 })), + ], + 'walk', + ) + + expect(evidence.complete).toBe(false) + expect(evidence.unavailableFrameCount).toBe(1) + expect(evidence.frames.map((frame) => frame.previousDelta)).toEqual([null, null, null]) + expect(evidence.summary.medianStep).toBeNull() + expect(evidence.summary.movementState).toBe('not_applicable') + }) + + it('marks excessive coverage, foot drift and height drift with action-aware states', () => { + // Catches jump lift being rejected as foot drift or foreground/background problems being hidden. + const frames = [ + ready(geometry({ footY: 100, subjectHeight: 20, coverageRatio: 0.66 })), + ready(geometry({ footY: 104, subjectHeight: 28 })), + ] + + const walk = buildSequenceEvidence(frames, 'walk') + const jump = buildSequenceEvidence(frames, 'jump') + + expect(walk.frames[0]?.coverageState).toBe('anomaly') + expect(walk.summary).toMatchObject({ + footDrift: 4, + heightDrift: 8, + footState: 'anomaly', + heightThreshold: 12, + heightState: 'normal', + }) + expect(jump.summary.footState).toBe('attention') + }) + + it('flags a single movement spike against the sequence median', () => { + // Catches a sudden position jump being normalized away by the animation's ordinary movement. + const evidence = buildSequenceEvidence( + [ + ready(geometry({ x: 0 })), + ready(geometry({ x: 1 })), + ready(geometry({ x: 2 })), + ready(geometry({ x: 22 })), + ], + 'walk', + ) + + expect(evidence.summary).toMatchObject({ + medianStep: 1, + maxStep: 20, + movementThreshold: 6, + movementState: 'anomaly', + }) + expect(evidence.frames.map((frame) => frame.movementState)).toEqual([ + 'not_applicable', + 'normal', + 'normal', + 'anomaly', + ]) + }) + + it('keeps an action-aware absolute movement ceiling when every step is large', () => { + // Catches an entire drifting sequence normalizing its own 100px jumps through the median. + const evidence = buildSequenceEvidence( + [ready(geometry({ x: 0 })), ready(geometry({ x: 100 })), ready(geometry({ x: 200 }))], + 'walk', + ) + + expect(evidence.summary.movementThreshold).toBeLessThan(100) + expect(evidence.summary.movementState).toBe('anomaly') + expect(evidence.findings.map((finding) => finding.code)).toContain('motion_spike') + }) + + it('marks jump and crouch height variation as action-allowed attention', () => { + const frames = [ready(geometry({ subjectHeight: 20 })), ready(geometry({ subjectHeight: 60 }))] + + for (const actionType of ['jump', 'crouch'] as const) { + const evidence = buildSequenceEvidence(frames, actionType) + expect(evidence.summary.heightThreshold).toBeNull() + expect(evidence.summary.heightState).toBe('attention') + expect(evidence.findings.map((finding) => finding.code)).not.toContain('height_drift') + } + }) + + it('flags adjacent outline area changes over 28 percent', () => { + // Catches a character silhouette abruptly shrinking without a visible review warning. + const evidence = buildSequenceEvidence( + [ready(geometry({ opaquePixels: 100 })), ready(geometry({ opaquePixels: 70 }))], + 'attack', + ) + + expect(evidence.summary).toMatchObject({ + maxAreaDeltaPercent: 30, + areaState: 'anomaly', + }) + expect(evidence.frames[1]?.areaState).toBe('anomaly') + }) + + it('marks adjacency-only checks not applicable for one frame', () => { + // Catches the first frame displaying fabricated zero deltas as a successful comparison. + const evidence = buildSequenceEvidence([ready(geometry())], 'idle') + + expect(evidence.frames[0]).toMatchObject({ + previousDelta: null, + movementState: 'not_applicable', + areaState: 'not_applicable', + }) + expect(evidence.summary).toMatchObject({ + footDrift: null, + heightDrift: null, + medianStep: null, + movementThreshold: null, + movementState: 'not_applicable', + areaState: 'not_applicable', + }) + }) + + it('combines measured image drift with adjacent root-motion increments using an upward y axis', () => { + // Catches root motion being subtracted twice or image-space positive-down y being added as positive-up. + const evidence = buildSequenceEvidence( + [ready(geometry({ x: 0, y: 10 }), null), ready(geometry({ x: 3, y: 14 }), { dx: 10, dy: 6 })], + 'walk', + ) + + expect(evidence.frames[0]).toMatchObject({ + expectedRootDelta: null, + composedPreviewDelta: null, + }) + expect(evidence.frames[1]?.expectedRootDelta).toMatchObject({ dx: 10, dy: 6 }) + expect(evidence.frames[1]?.expectedRootDelta?.distance).toBeCloseTo(Math.sqrt(136)) + expect(evidence.frames[1]?.composedPreviewDelta).toMatchObject({ dx: 13, dy: 2 }) + expect(evidence.frames[1]?.composedPreviewDelta?.distance).toBeCloseTo(Math.sqrt(173)) + expect(evidence.frames[1]?.movementState).toBe('normal') + }) + + it('treats each frame root motion as an increment instead of subtracting the previous frame', () => { + // Catches repeated per-frame dx values collapsing to zero and leaving a walking sprite in place. + const evidence = buildSequenceEvidence( + [ + ready(geometry({ x: 0, y: 10 }), null), + ready(geometry({ x: 1, y: 11 }), { dx: 2, dy: 1 }), + ready(geometry({ x: 3, y: 12 }), { dx: 3, dy: 2 }), + ], + 'walk', + ) + + expect(evidence.frames[2]?.expectedRootDelta).toMatchObject({ dx: 3, dy: 2 }) + expect(evidence.frames[2]?.composedPreviewDelta).toMatchObject({ dx: 5, dy: 1 }) + }) + + it('does not mislabel merely similar adjacent frames as duplicates', () => { + const fingerprint = Array.from({ length: 64 }, () => 0.5) + const evidence = buildSequenceEvidence( + [ + ready(geometry({ fingerprint, contentHash: 'frame-a' })), + ready(geometry({ fingerprint, contentHash: 'frame-b' })), + ], + 'attack', + ) + + expect(evidence.findings.map((finding) => finding.code)).not.toContain('duplicate_frame') + }) + + it('checks a loop boundary against the ordinary adjacent-frame baseline', () => { + const evidence = buildSequenceEvidence( + [ready(geometry({ x: 0 })), ready(geometry({ x: 20 })), ready(geometry({ x: 40 }))], + 'walk', + ) + + expect(evidence.summary.movementThreshold).toBe(24) + expect(evidence.findings).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + code: 'motion_spike', + frameIndex: 0, + message: '循环首尾出现异常位移突变', + }), + ]), + ) + }) + + it('uses the project canvas contract instead of accepting a consistently wrong sequence', () => { + const evidence = buildSequenceEvidence( + [ready(geometry({ width: 256, height: 256 })), ready(geometry({ width: 256, height: 256 }))], + 'idle', + { width: 512, height: 512 }, + ) + + expect(evidence.summary.expectedCanvas).toEqual({ width: 512, height: 512 }) + expect( + evidence.findings.filter((finding) => finding.code === 'canvas_size_mismatch'), + ).toHaveLength(2) + }) +}) diff --git a/frontend/src/pages/playtest/workbench/analysis/sequence-evidence.ts b/frontend/src/pages/playtest/workbench/analysis/sequence-evidence.ts new file mode 100644 index 00000000..ce6a5d9d --- /dev/null +++ b/frontend/src/pages/playtest/workbench/analysis/sequence-evidence.ts @@ -0,0 +1,498 @@ +import type { Frame } from '@/entities/character' + +import type { PlaytestActionType } from '../model/types' +import type { FrameGeometry } from './frame-geometry' +import { deriveLocalQualityPolicy, type CanvasBaseline } from './quality-policy' + +export type EvidenceState = 'normal' | 'attention' | 'anomaly' | 'not_applicable' + +export type QualityFindingCode = + | 'image_unavailable' + | 'blank_subject' + | 'canvas_size_mismatch' + | 'subject_cropped' + | 'coverage_too_low' + | 'coverage_too_high' + | 'duplicate_frame' + | 'motion_spike' + | 'foot_drift' + | 'height_drift' + | 'area_spike' + | 'root_motion_mismatch' + +export interface QualityFinding { + code: QualityFindingCode + severity: 'warning' | 'error' + frameIndex: number | null + message: string + metrics: Readonly> +} + +export type FrameGeometryResult = + | { status: 'ready'; geometry: FrameGeometry } + | { status: 'unavailable'; reason: string } + +export interface FrameEvidenceInput { + geometry: FrameGeometryResult + rootMotion: Frame['rootMotion'] +} + +export interface AdjacentFrameDelta { + dx: number + dy: number + distance: number + areaDeltaPercent: number +} + +export interface MotionVector { + dx: number + dy: number + distance: number +} + +export interface FrameReviewEvidence { + geometry: FrameGeometry | null + unavailableReason: string | null + previousDelta: AdjacentFrameDelta | null + expectedRootDelta: MotionVector | null + composedPreviewDelta: MotionVector | null + canvasState: EvidenceState + coverageState: EvidenceState + movementState: EvidenceState + areaState: EvidenceState +} + +export interface SequenceReviewEvidence { + complete: boolean + unavailableFrameCount: number + frames: readonly FrameReviewEvidence[] + findings: readonly QualityFinding[] + summary: { + footDrift: number | null + heightDrift: number | null + medianStep: number | null + maxStep: number | null + movementThreshold: number | null + heightThreshold: number | null + footThreshold: number | null + areaThresholdPercent: number + expectedCanvas: CanvasBaseline | null + maxAreaDeltaPercent: number | null + canvasState: EvidenceState + footState: EvidenceState + heightState: EvidenceState + movementState: EvidenceState + areaState: EvidenceState + } +} + +function medianAbsoluteDeviation(values: readonly number[], center: number | null): number | null { + if (center === null || values.length === 0) return null + return median(values.map((value) => Math.abs(value - center))) +} + +function framesAreIdentical(left: FrameGeometry, right: FrameGeometry): boolean { + return left.contentHash !== undefined && left.contentHash === right.contentHash +} + +function isCropped(geometry: FrameGeometry, margin: { x: number; y: number }): boolean { + return ( + geometry.bounds.left < margin.x || + geometry.bounds.top < margin.y || + geometry.bounds.right >= geometry.width - margin.x || + geometry.bounds.bottom >= geometry.height - margin.y + ) +} + +function median(values: readonly number[]): number | null { + if (values.length === 0) return null + + const sorted = [...values].sort((left, right) => left - right) + const middle = Math.floor(sorted.length / 2) + const upper = sorted[middle] + if (upper === undefined) return null + + if (sorted.length % 2 === 1) return upper + + const lower = sorted[middle - 1] + return lower === undefined ? upper : (lower + upper) / 2 +} + +function spread(values: readonly number[]): number | null { + if (values.length < 2) return null + return Math.max(...values) - Math.min(...values) +} + +function adjacentDelta(previous: FrameGeometry, current: FrameGeometry): AdjacentFrameDelta { + const dx = current.centroid.x - previous.centroid.x + const dy = current.centroid.y - previous.centroid.y + + return { + dx, + dy, + distance: Math.hypot(dx, dy), + areaDeltaPercent: + (Math.abs(current.opaquePixels - previous.opaquePixels) / + Math.max(current.opaquePixels, previous.opaquePixels)) * + 100, + } +} + +function motionVector(dx: number, dy: number): MotionVector { + return { dx, dy, distance: Math.hypot(dx, dy) } +} + +function rootMotion(frame: FrameEvidenceInput): { dx: number; dy: number } { + return frame.rootMotion ?? { dx: 0, dy: 0 } +} + +export function buildSequenceEvidence( + inputs: readonly FrameEvidenceInput[], + actionType: PlaytestActionType, + expectedCanvas: CanvasBaseline | null = null, +): SequenceReviewEvidence { + const results = inputs.map((input) => input.geometry) + const readyGeometries = results.flatMap((result) => + result.status === 'ready' ? [result.geometry] : [], + ) + const policy = deriveLocalQualityPolicy(readyGeometries, actionType, expectedCanvas) + const isBaselineGeometry = (geometry: FrameGeometry): boolean => + policy.expectedCanvas !== null && + geometry.width === policy.expectedCanvas.width && + geometry.height === policy.expectedCanvas.height + const deltas = results.map((result, index): AdjacentFrameDelta | null => { + const previous = results[index - 1] + if (index === 0 || previous?.status !== 'ready' || result.status !== 'ready') return null + if (!isBaselineGeometry(previous.geometry) || !isBaselineGeometry(result.geometry)) return null + return adjacentDelta(previous.geometry, result.geometry) + }) + const checksLoopBoundary = actionType === 'idle' || actionType === 'walk' + const firstResult = results[0] + const lastResult = results.at(-1) + const closingDelta = + checksLoopBoundary && + results.length > 1 && + results.every((result) => result.status === 'ready') && + firstResult?.status === 'ready' && + lastResult?.status === 'ready' && + isBaselineGeometry(firstResult.geometry) && + isBaselineGeometry(lastResult.geometry) + ? adjacentDelta(lastResult.geometry, firstResult.geometry) + : null + const rootDeltas = inputs.map((input, index): MotionVector | null => { + if (index === 0) return null + + const increment = rootMotion(input) + return motionVector(increment.dx, increment.dy) + }) + const availableDeltas = deltas.flatMap((delta) => (delta === null ? [] : [delta])) + const steps = availableDeltas.map((delta) => delta.distance) + const areaDeltas = availableDeltas.map((delta) => delta.areaDeltaPercent) + const medianStep = median(steps) + const movementMad = medianAbsoluteDeviation(steps, medianStep) + const relativeMovementThreshold = + medianStep === null + ? null + : Math.max( + medianStep * 2.6 + policy.movementPadding, + medianStep + (movementMad ?? 0) * 3 + policy.movementPadding, + policy.movementFloor, + ) + const movementThreshold = + relativeMovementThreshold === null + ? null + : Math.min(relativeMovementThreshold, policy.movementCeiling) + const maxStep = steps.length === 0 ? null : Math.max(...steps) + const maxAreaDeltaPercent = areaDeltas.length === 0 ? null : Math.max(...areaDeltas) + const baselineGeometries = readyGeometries.filter(isBaselineGeometry) + const footDrift = spread(baselineGeometries.map((geometry) => geometry.footY)) + const heightDrift = spread(baselineGeometries.map((geometry) => geometry.subjectHeight)) + const unavailableFrameCount = results.length - readyGeometries.length + const findings: QualityFinding[] = [] + const heightThreshold = policy.heightDriftThreshold + + const frames = results.map((result, index): FrameReviewEvidence => { + const expectedRootDelta = rootDeltas[index] ?? null + if (result.status === 'unavailable') { + return { + geometry: null, + unavailableReason: result.reason, + previousDelta: null, + expectedRootDelta, + composedPreviewDelta: null, + canvasState: 'not_applicable', + coverageState: 'not_applicable', + movementState: 'not_applicable', + areaState: 'not_applicable', + } + } + + const delta = deltas[index] ?? null + const composedPreviewDelta = + delta === null || expectedRootDelta === null + ? null + : motionVector(delta.dx + expectedRootDelta.dx, expectedRootDelta.dy - delta.dy) + return { + geometry: result.geometry, + unavailableReason: null, + previousDelta: delta, + expectedRootDelta, + composedPreviewDelta, + canvasState: + policy.expectedCanvas !== null && + result.geometry.width === policy.expectedCanvas.width && + result.geometry.height === policy.expectedCanvas.height + ? 'normal' + : 'anomaly', + coverageState: + result.geometry.coverageRatio < policy.minimumCoverageRatio || + result.geometry.coverageRatio > policy.maximumCoverageRatio + ? 'anomaly' + : 'normal', + movementState: + delta === null || movementThreshold === null + ? 'not_applicable' + : delta.distance > movementThreshold + ? 'anomaly' + : 'normal', + areaState: + delta === null + ? 'not_applicable' + : delta.areaDeltaPercent > policy.areaDeltaThresholdPercent + ? 'anomaly' + : 'normal', + } + }) + + results.forEach((result, index) => { + if (result.status === 'unavailable') { + const blank = result.reason.includes('没有可见主体') + findings.push({ + code: blank ? 'blank_subject' : 'image_unavailable', + severity: 'error', + frameIndex: index, + message: blank ? '当前帧没有可见主体' : result.reason, + metrics: {}, + }) + return + } + + const { geometry } = result + if ( + policy.expectedCanvas !== null && + (geometry.width !== policy.expectedCanvas.width || + geometry.height !== policy.expectedCanvas.height) + ) { + findings.push({ + code: 'canvas_size_mismatch', + severity: 'error', + frameIndex: index, + message: '画布尺寸与当前序列基线不一致', + metrics: { + width: geometry.width, + height: geometry.height, + expectedWidth: policy.expectedCanvas.width, + expectedHeight: policy.expectedCanvas.height, + }, + }) + } + if (isCropped(geometry, policy.edgeMargin)) { + findings.push({ + code: 'subject_cropped', + severity: 'error', + frameIndex: index, + message: '主体接触画布边缘,可能发生裁切', + metrics: { + left: geometry.bounds.left, + top: geometry.bounds.top, + right: geometry.bounds.right, + bottom: geometry.bounds.bottom, + }, + }) + } + if (geometry.coverageRatio < policy.minimumCoverageRatio) { + findings.push({ + code: 'coverage_too_low', + severity: 'warning', + frameIndex: index, + message: '主体在画布中的占比过小', + metrics: { coverageRatio: geometry.coverageRatio }, + }) + } else if (geometry.coverageRatio > policy.maximumCoverageRatio) { + findings.push({ + code: 'coverage_too_high', + severity: 'error', + frameIndex: index, + message: '主体在画布中的占比过大', + metrics: { coverageRatio: geometry.coverageRatio }, + }) + } + + const previous = results[index - 1] + if (previous?.status !== 'ready') return + if (framesAreIdentical(previous.geometry, geometry)) { + findings.push({ + code: 'duplicate_frame', + severity: 'warning', + frameIndex: index, + message: '当前帧与上一帧完全相同', + metrics: {}, + }) + } + + const delta = deltas[index] + if (delta !== null && movementThreshold !== null && delta.distance > movementThreshold) { + findings.push({ + code: 'motion_spike', + severity: 'error', + frameIndex: index, + message: '相邻帧出现异常位移突变', + metrics: { distance: delta.distance, threshold: movementThreshold }, + }) + } + if (delta !== null && delta.areaDeltaPercent > policy.areaDeltaThresholdPercent) { + findings.push({ + code: 'area_spike', + severity: 'warning', + frameIndex: index, + message: '相邻帧主体轮廓面积变化过大', + metrics: { percent: delta.areaDeltaPercent }, + }) + } + + const expected = rootDeltas[index] + if ( + delta !== null && + expected !== null && + expected.distance >= policy.rootMotionDirectionMinimum && + delta.distance >= policy.rootMotionDirectionMinimum + ) { + const dot = delta.dx * expected.dx + -delta.dy * expected.dy + if (dot < 0) { + findings.push({ + code: 'root_motion_mismatch', + severity: 'warning', + frameIndex: index, + message: '画面内位移方向与预期根位移矛盾', + metrics: { dotProduct: dot }, + }) + } + } + }) + + if (closingDelta !== null && firstResult?.status === 'ready' && lastResult?.status === 'ready') { + if (framesAreIdentical(lastResult.geometry, firstResult.geometry)) { + findings.push({ + code: 'duplicate_frame', + severity: 'warning', + frameIndex: 0, + message: '循环首帧与尾帧完全相同,可能产生停顿', + metrics: {}, + }) + } + if (movementThreshold !== null && closingDelta.distance > movementThreshold) { + findings.push({ + code: 'motion_spike', + severity: 'error', + frameIndex: 0, + message: '循环首尾出现异常位移突变', + metrics: { distance: closingDelta.distance, threshold: movementThreshold }, + }) + } + if (closingDelta.areaDeltaPercent > policy.areaDeltaThresholdPercent) { + findings.push({ + code: 'area_spike', + severity: 'warning', + frameIndex: 0, + message: '循环首尾轮廓面积变化过大', + metrics: { percent: closingDelta.areaDeltaPercent }, + }) + } + } + + if ( + footDrift !== null && + policy.footDriftThreshold !== null && + footDrift > policy.footDriftThreshold && + actionType !== 'jump' + ) { + findings.push({ + code: 'foot_drift', + severity: 'error', + frameIndex: null, + message: '序列脚底线漂移超过动作允许范围', + metrics: { drift: footDrift, threshold: policy.footDriftThreshold }, + }) + } + if (heightDrift !== null && heightThreshold !== null && heightDrift > heightThreshold) { + findings.push({ + code: 'height_drift', + severity: 'warning', + frameIndex: null, + message: '序列主体高度变化超过动作允许范围', + metrics: { drift: heightDrift, threshold: heightThreshold }, + }) + } + + return { + complete: results.length > 0 && unavailableFrameCount === 0, + unavailableFrameCount, + frames, + findings, + summary: { + footDrift, + heightDrift, + medianStep, + maxStep, + movementThreshold, + heightThreshold, + footThreshold: policy.footDriftThreshold, + areaThresholdPercent: policy.areaDeltaThresholdPercent, + expectedCanvas: policy.expectedCanvas, + maxAreaDeltaPercent, + canvasState: + policy.expectedCanvas === null + ? 'not_applicable' + : readyGeometries.every( + (geometry) => + geometry.width === policy.expectedCanvas?.width && + geometry.height === policy.expectedCanvas?.height, + ) + ? 'normal' + : 'anomaly', + footState: + footDrift === null + ? 'not_applicable' + : policy.footDriftThreshold === null + ? 'not_applicable' + : footDrift <= policy.footDriftThreshold + ? 'normal' + : actionType === 'jump' + ? 'attention' + : 'anomaly', + heightState: + heightDrift === null + ? 'not_applicable' + : heightThreshold === null + ? policy.heightAttentionThreshold !== null && + heightDrift > policy.heightAttentionThreshold + ? 'attention' + : 'normal' + : heightDrift > heightThreshold + ? 'anomaly' + : 'normal', + movementState: + maxStep === null || movementThreshold === null + ? 'not_applicable' + : maxStep > movementThreshold + ? 'anomaly' + : 'normal', + areaState: + maxAreaDeltaPercent === null + ? 'not_applicable' + : maxAreaDeltaPercent > policy.areaDeltaThresholdPercent + ? 'anomaly' + : 'normal', + }, + } +} diff --git a/frontend/src/pages/playtest/workbench/analysis/use-frame-review-evidence.test.tsx b/frontend/src/pages/playtest/workbench/analysis/use-frame-review-evidence.test.tsx new file mode 100644 index 00000000..8edf422f --- /dev/null +++ b/frontend/src/pages/playtest/workbench/analysis/use-frame-review-evidence.test.tsx @@ -0,0 +1,203 @@ +/** @vitest-environment jsdom */ +import { act, cleanup, renderHook, waitFor } from '@testing-library/react' +import { afterEach, describe, expect, it, vi } from 'vitest' + +import type { PreviewSequence } from '../model/types' +import type { FrameGeometry } from './frame-geometry' +import type { FrameGeometryResult } from './sequence-evidence' +import { useFrameReviewEvidence, type ImageGeometryReader } from './use-frame-review-evidence' + +function sequence(...imageUrls: string[]): PreviewSequence { + return { + direction: 'south', + frames: imageUrls.map((imageUrl) => ({ + imageUrl, + durationMs: 100, + rootMotion: null, + keyFrame: false, + })), + } +} + +function geometry(x: number, y = 10): FrameGeometryResult { + const value: FrameGeometry = { + width: 256, + height: 256, + bounds: { left: x, top: 0, right: x + 9, bottom: 19, width: 10, height: 20 }, + centroid: { x, y }, + footY: 19, + subjectHeight: 20, + opaquePixels: 100, + coverageRatio: 100 / (256 * 256), + } + return { status: 'ready', geometry: value } +} + +function deferred(): { + promise: Promise + resolve(value: T): void +} { + let resolvePromise: ((value: T) => void) | null = null + const promise = new Promise((resolve) => { + resolvePromise = resolve + }) + + return { + promise, + resolve(value) { + if (resolvePromise === null) throw new Error('deferred promise is not initialized') + resolvePromise(value) + }, + } +} + +afterEach(cleanup) + +describe('useFrameReviewEvidence', () => { + it('exposes loading before producing real sequence evidence', async () => { + // Catches the Inspector flashing fabricated zeros before image analysis completes. + const pending = deferred() + const reader: ImageGeometryReader = () => pending.promise + const { result } = renderHook(() => + useFrameReviewEvidence(sequence('/frame-1.png'), 'walk', reader), + ) + + expect(result.current).toEqual({ status: 'loading', evidence: null }) + + await act(async () => pending.resolve(geometry(4))) + + await waitFor(() => expect(result.current.status).toBe('ready')) + expect(result.current.evidence?.frames[0]?.geometry?.centroid.x).toBe(4) + }) + + it('ignores a previous sequence result that resolves after a direction switch', async () => { + // Catches a slow old direction replacing the evidence for the currently selected direction. + const south = deferred() + const north = deferred() + const reader: ImageGeometryReader = (imageUrl) => + imageUrl.includes('south') ? south.promise : north.promise + const { result, rerender } = renderHook( + ({ current }) => useFrameReviewEvidence(current, 'walk', reader), + { initialProps: { current: sequence('/south.png') } }, + ) + + rerender({ current: sequence('/north.png') }) + await act(async () => north.resolve(geometry(20))) + await waitFor(() => expect(result.current.evidence?.frames[0]?.geometry?.centroid.x).toBe(20)) + + await act(async () => south.resolve(geometry(2))) + expect(result.current.evidence?.frames[0]?.geometry?.centroid.x).toBe(20) + }) + + it('passes an abort signal to image reads and aborts stale sequence work', () => { + const pending = deferred() + const reader = vi.fn(() => pending.promise) + const { rerender } = renderHook( + ({ current }) => useFrameReviewEvidence(current, 'walk', reader), + { initialProps: { current: sequence('/south.png') } }, + ) + + const firstSignal = reader.mock.calls[0]?.[1] + expect(firstSignal).toBeInstanceOf(AbortSignal) + expect(firstSignal?.aborted).toBe(false) + + rerender({ current: sequence('/north.png') }) + + expect(firstSignal?.aborted).toBe(true) + expect(reader.mock.calls[1]?.[1]).toBeInstanceOf(AbortSignal) + }) + + it('rereads revisited image URLs so regenerated content cannot reuse stale geometry', async () => { + // Catches frame navigation repeatedly decoding every image in the same review session. + const reader = vi.fn(async (imageUrl) => + geometry(imageUrl.includes('one') ? 1 : 2), + ) + const first = sequence('/one.png') + const second = sequence('/two.png') + const { result, rerender } = renderHook( + ({ current }) => useFrameReviewEvidence(current, 'walk', reader), + { initialProps: { current: first } }, + ) + + await waitFor(() => expect(result.current.status).toBe('ready')) + rerender({ current: first }) + expect(reader).toHaveBeenCalledTimes(1) + + rerender({ current: second }) + await waitFor(() => expect(result.current.evidence?.frames[0]?.geometry?.centroid.x).toBe(2)) + rerender({ current: first }) + await waitFor(() => expect(result.current.evidence?.frames[0]?.geometry?.centroid.x).toBe(1)) + expect(reader).toHaveBeenCalledTimes(3) + }) + + it('retries an unavailable image when its sequence is revisited', async () => { + // A transient image failure must not become a permanent session-level cache entry. + let failedOnce = false + const reader = vi.fn(async (imageUrl) => { + if (imageUrl.includes('retry') && !failedOnce) { + failedOnce = true + return { status: 'unavailable', reason: 'temporary failure' } + } + return geometry(imageUrl.includes('retry') ? 9 : 2) + }) + const retrySequence = sequence('/retry.png') + const otherSequence = sequence('/other.png') + const { result, rerender } = renderHook( + ({ current }) => useFrameReviewEvidence(current, 'walk', reader), + { initialProps: { current: retrySequence } }, + ) + + await waitFor(() => expect(result.current.status).toBe('ready')) + expect(result.current.evidence?.complete).toBe(false) + + rerender({ current: otherSequence }) + await waitFor(() => expect(result.current.evidence?.frames[0]?.geometry?.centroid.x).toBe(2)) + rerender({ current: retrySequence }) + await waitFor(() => expect(result.current.evidence?.frames[0]?.geometry?.centroid.x).toBe(9)) + + expect(reader).toHaveBeenCalledTimes(3) + }) + + it('stays idle without a review sequence and does not read an image', () => { + // Catches direct-control mode starting hidden Canvas work. + const reader = vi.fn() + const { result } = renderHook(() => useFrameReviewEvidence(null, null, reader)) + + expect(result.current).toEqual({ status: 'idle', evidence: null }) + expect(reader).not.toHaveBeenCalled() + }) + + it('does not update React state after the consumer unmounts', async () => { + // Catches an image completion writing into a removed Playtest workbench. + const pending = deferred() + const reader: ImageGeometryReader = () => pending.promise + const consoleError = vi.spyOn(console, 'error').mockImplementation(() => undefined) + const mounted = renderHook(() => useFrameReviewEvidence(sequence('/slow.png'), 'walk', reader)) + + mounted.unmount() + await act(async () => pending.resolve(geometry(1))) + + expect(consoleError).not.toHaveBeenCalled() + }) + + it('keeps each preview frame root motion beside its measured geometry', async () => { + // Catches asynchronous image results losing their matching motion contract before aggregation. + const base = sequence('/first.png', '/second.png') + const motionSequence: PreviewSequence = { + ...base, + frames: [ + { ...base.frames[0]!, rootMotion: null }, + { ...base.frames[1]!, rootMotion: { dx: 10, dy: 6 } }, + ], + } + const reader: ImageGeometryReader = async (imageUrl) => + imageUrl.includes('first') ? geometry(0, 10) : geometry(3, 14) + const { result } = renderHook(() => useFrameReviewEvidence(motionSequence, 'walk', reader)) + + await waitFor(() => expect(result.current.status).toBe('ready')) + expect(result.current.evidence?.frames[1]).toMatchObject({ + expectedRootDelta: { dx: 10, dy: 6 }, + composedPreviewDelta: { dx: 13, dy: 2 }, + }) + }) +}) diff --git a/frontend/src/pages/playtest/workbench/analysis/use-frame-review-evidence.ts b/frontend/src/pages/playtest/workbench/analysis/use-frame-review-evidence.ts new file mode 100644 index 00000000..b57fbe87 --- /dev/null +++ b/frontend/src/pages/playtest/workbench/analysis/use-frame-review-evidence.ts @@ -0,0 +1,106 @@ +import { useEffect, useState } from 'react' + +import type { PlaytestActionType, PreviewFrame, PreviewSequence } from '../model/types' +import { readImageGeometry } from './image-geometry' +import type { CanvasBaseline } from './quality-policy' +import { + buildSequenceEvidence, + type FrameGeometryResult, + type SequenceReviewEvidence, +} from './sequence-evidence' + +export type FrameReviewEvidenceState = + | { status: 'idle'; evidence: null } + | { status: 'loading'; evidence: null } + | { status: 'ready'; evidence: SequenceReviewEvidence } + +export type ImageGeometryReader = ( + imageUrl: string, + signal?: AbortSignal, +) => Promise + +interface ResolvedState { + key: string | null + value: FrameReviewEvidenceState +} + +const IDLE_STATE: FrameReviewEvidenceState = { status: 'idle', evidence: null } +const LOADING_STATE: FrameReviewEvidenceState = { status: 'loading', evidence: null } + +export function useFrameReviewEvidence( + sequence: PreviewSequence | null, + actionType: PlaytestActionType | null, + reader: ImageGeometryReader = readImageGeometry, + expectedCanvas: CanvasBaseline | null = null, +): FrameReviewEvidenceState { + const sequenceKey = + sequence === null || actionType === null + ? null + : JSON.stringify([ + actionType, + expectedCanvas, + ...sequence.frames.map((frame) => ({ + imageUrl: frame.imageUrl, + rootMotion: frame.rootMotion, + })), + ]) + const [resolved, setResolved] = useState({ key: null, value: IDLE_STATE }) + + useEffect(() => { + if (sequenceKey === null || actionType === null) { + setResolved({ key: null, value: IDLE_STATE }) + return + } + + const [, , ...frameDescriptors] = JSON.parse(sequenceKey) as [ + PlaytestActionType, + CanvasBaseline | null, + ...Array<{ imageUrl: string; rootMotion: PreviewFrame['rootMotion'] }>, + ] + const imageUrls = frameDescriptors.map((frame) => frame.imageUrl) + + const controller = new AbortController() + let active = true + const inFlight = new Map>() + + setResolved({ key: sequenceKey, value: LOADING_STATE }) + + const results = imageUrls.map((imageUrl) => { + const existing = inFlight.get(imageUrl) + if (existing !== undefined) return existing + + const pending = reader(imageUrl, controller.signal).catch( + (): FrameGeometryResult => ({ status: 'unavailable', reason: '图片分析失败' }), + ) + inFlight.set(imageUrl, pending) + return pending + }) + + void Promise.all(results).then((frameResults) => { + if (!active || controller.signal.aborted) return + + setResolved({ + key: sequenceKey, + value: { + status: 'ready', + evidence: buildSequenceEvidence( + frameResults.map((geometry, index) => ({ + geometry, + rootMotion: frameDescriptors[index]?.rootMotion ?? null, + })), + actionType, + expectedCanvas, + ), + }, + }) + }) + + return () => { + active = false + controller.abort() + } + }, [actionType, expectedCanvas, reader, sequenceKey]) + + if (sequenceKey === null) return IDLE_STATE + return resolved.key === sequenceKey ? resolved.value : LOADING_STATE +} diff --git a/frontend/src/pages/playtest/workbench/audit/audit-session.test.ts b/frontend/src/pages/playtest/workbench/audit/audit-session.test.ts new file mode 100644 index 00000000..61198a0b --- /dev/null +++ b/frontend/src/pages/playtest/workbench/audit/audit-session.test.ts @@ -0,0 +1,53 @@ +import { describe, expect, it } from 'vitest' + +import { reduceAuditSession, type ManualAuditIssue } from './audit-session' + +const issue: ManualAuditIssue = { + id: 'issue-1', + category: 'subject_cropped', + actionId: 'walk', + direction: 'south', + frameIndex: 2, + imageUrl: '/walk-03.png', + note: '右侧被裁切', +} + +describe('reduceAuditSession', () => { + it('adds, updates and removes manual issues without mutating identity fields', () => { + const added = reduceAuditSession([], { type: 'add', issue }) + const updated = reduceAuditSession(added, { + type: 'update', + id: issue.id, + category: 'style_inconsistent', + note: '衣服颜色跳变', + }) + const removed = reduceAuditSession(updated, { type: 'remove', id: issue.id }) + + expect(added).toEqual([issue]) + expect(updated[0]).toEqual({ + ...issue, + category: 'style_inconsistent', + note: '衣服颜色跳变', + }) + expect(updated[0]).toMatchObject({ + actionId: 'walk', + direction: 'south', + frameIndex: 2, + imageUrl: '/walk-03.png', + }) + expect(removed).toEqual([]) + expect(added).not.toBe(updated) + }) + + it('ignores updates and removals for unknown issue ids', () => { + expect( + reduceAuditSession([issue], { + type: 'update', + id: 'missing', + category: 'other', + note: '无效更新', + }), + ).toEqual([issue]) + expect(reduceAuditSession([issue], { type: 'remove', id: 'missing' })).toEqual([issue]) + }) +}) diff --git a/frontend/src/pages/playtest/workbench/audit/audit-session.ts b/frontend/src/pages/playtest/workbench/audit/audit-session.ts new file mode 100644 index 00000000..fd0eab5e --- /dev/null +++ b/frontend/src/pages/playtest/workbench/audit/audit-session.ts @@ -0,0 +1,41 @@ +import type { PlaytestDirection } from '../model/types' + +export type ManualIssueCategory = + | 'subject_cropped' + | 'transparency' + | 'image_unavailable' + | 'duplicate_frame' + | 'motion_discontinuity' + | 'motion_direction' + | 'style_inconsistent' + | 'other' + +export interface ManualAuditIssue { + id: string + category: ManualIssueCategory + actionId: string + direction: PlaytestDirection + frameIndex: number + imageUrl: string + note: string +} + +export type AuditSessionAction = + | { type: 'add'; issue: ManualAuditIssue } + | { type: 'update'; id: string; category: ManualIssueCategory; note: string } + | { type: 'remove'; id: string } + +export function reduceAuditSession( + state: readonly ManualAuditIssue[], + action: AuditSessionAction, +): readonly ManualAuditIssue[] { + if (action.type === 'add') return [...state, action.issue] + if (action.type === 'remove') { + if (!state.some((issue) => issue.id === action.id)) return state + return state.filter((issue) => issue.id !== action.id) + } + if (!state.some((issue) => issue.id === action.id)) return state + return state.map((issue) => + issue.id === action.id ? { ...issue, category: action.category, note: action.note } : issue, + ) +} diff --git a/frontend/src/pages/playtest/workbench/model/create-preview-model.test.ts b/frontend/src/pages/playtest/workbench/model/create-preview-model.test.ts new file mode 100644 index 00000000..2726d472 --- /dev/null +++ b/frontend/src/pages/playtest/workbench/model/create-preview-model.test.ts @@ -0,0 +1,124 @@ +import { describe, expect, it } from 'vitest' + +import type { Character } from '../../../../entities' +import { createPreviewModel } from './create-preview-model' + +const character: Character = { + id: 'character-1', + projectId: 'project-1', + createdAt: '2026-07-30T00:00:00.000Z', + updatedAt: '2026-07-30T00:00:00.000Z', + outfits: [ + { + id: 'outfit-1', + characterId: 'character-1', + name: 'Explorer', + candidateCharacterTemplates: [], + characterTemplateUrl: 'https://cdn.example.test/aster.png', + baseFrames: [ + { imageUrl: 'https://cdn.example.test/base-1.png' }, + { imageUrl: 'https://cdn.example.test/base-2.png' }, + ], + actions: [ + { + id: 'walk', + outfitId: 'outfit-1', + name: 'Walk', + kind: 'preset', + type: 'walk', + fps: 5, + keyFrameIndex: 1, + frames: [ + { + imageUrl: 'https://cdn.example.test/walk-0.png', + durationMs: 125, + rootMotion: { dx: 2, dy: 0 }, + }, + { + imageUrl: 'https://cdn.example.test/walk-1.png', + durationMs: null, + rootMotion: { dx: 5, dy: 1 }, + }, + { + imageUrl: 'https://cdn.example.test/walk-2.png', + durationMs: null, + rootMotion: null, + }, + ], + }, + { + id: 'idle', + outfitId: 'outfit-1', + name: 'Idle', + kind: 'custom', + type: 'idle', + fps: 0, + keyFrameIndex: 0, + frames: [ + { + imageUrl: 'https://cdn.example.test/idle-0.png', + durationMs: null, + rootMotion: null, + }, + ], + }, + ], + }, + ], +} + +describe('createPreviewModel', () => { + it('reports a missing outfit without constructing a preview model', () => { + expect(createPreviewModel(character, 'missing-outfit')).toEqual({ + ok: false, + reason: 'outfit_not_found', + }) + }) + + it('maps the standard skeleton single-frame list to one default preview direction', () => { + const result = createPreviewModel(character, 'outfit-1') + if (!result.ok) throw new Error('expected preview model') + + expect(result.model.actions.map((action) => action.id)).toEqual(['walk', 'idle']) + expect(result.model.actions[0].sequences.map((sequence) => sequence.direction)).toEqual([ + 'default', + ]) + expect(result.model).toMatchObject({ + characterId: 'character-1', + characterName: 'character-1', + outfitId: 'outfit-1', + outfitName: 'Explorer', + characterTemplateUrl: 'https://cdn.example.test/aster.png', + baseFrameCount: 2, + }) + }) + + it('uses frame duration before fps fallback and maps the action key frame', () => { + const result = createPreviewModel(character, 'outfit-1') + if (!result.ok) throw new Error('expected preview model') + + const frames = result.model.actions[0].sequences[0].frames + expect(frames.map((frame) => frame.durationMs)).toEqual([125, 200, 200]) + expect(frames.map((frame) => frame.keyFrame)).toEqual([false, true, false]) + }) + + it('converts absolute-from-first root motion into playback increments', () => { + const result = createPreviewModel(character, 'outfit-1') + if (!result.ok) throw new Error('expected preview model') + + expect(result.model.actions[0].sequences[0].frames.map((frame) => frame.rootMotion)).toEqual([ + { dx: 2, dy: 0 }, + { dx: 3, dy: 1 }, + null, + ]) + }) + + it('uses a safe display fallback for invalid fps without mutating the character', () => { + const before = JSON.stringify(character) + const result = createPreviewModel(character, 'outfit-1') + if (!result.ok) throw new Error('expected preview model') + + expect(result.model.actions[1].sequences[0].frames[0].durationMs).toBe(100) + expect(JSON.stringify(character)).toBe(before) + }) +}) diff --git a/frontend/src/pages/playtest/workbench/model/create-preview-model.ts b/frontend/src/pages/playtest/workbench/model/create-preview-model.ts new file mode 100644 index 00000000..806a0e74 --- /dev/null +++ b/frontend/src/pages/playtest/workbench/model/create-preview-model.ts @@ -0,0 +1,94 @@ +import type { Action, Character, Frame } from '@/entities/character' + +import type { + PlaytestPreviewModel, + PreviewAction, + PreviewFrame, + PreviewModelResult, + PreviewSequence, +} from './types' + +const SAFE_DURATION_MS = 100 + +function getFallbackDurationMs(fps: Action['fps']): number { + if (!Number.isFinite(fps) || fps <= 0) return SAFE_DURATION_MS + + const durationMs = Math.round(1000 / fps) + return durationMs > 0 ? durationMs : SAFE_DURATION_MS +} + +function createPreviewFrame( + frame: Frame, + frameIndex: number, + keyFrameIndex: number | null, + fallbackDurationMs: number, + previousAbsoluteRootMotion: Frame['rootMotion'], +): PreviewFrame { + const rootMotion = + frame.rootMotion === null + ? null + : { + dx: frame.rootMotion.dx - (previousAbsoluteRootMotion?.dx ?? 0), + dy: frame.rootMotion.dy - (previousAbsoluteRootMotion?.dy ?? 0), + } + + return { + imageUrl: frame.imageUrl, + durationMs: + frame.durationMs !== null && frame.durationMs > 0 ? frame.durationMs : fallbackDurationMs, + rootMotion, + keyFrame: frameIndex === keyFrameIndex, + } +} + +function createPreviewAction(action: Action): PreviewAction { + const fallbackDurationMs = getFallbackDurationMs(action.fps) + let previousAbsoluteRootMotion: Frame['rootMotion'] = null + const frames = action.frames.map((frame, frameIndex) => { + const previewFrame = createPreviewFrame( + frame, + frameIndex, + action.keyFrameIndex, + fallbackDurationMs, + previousAbsoluteRootMotion, + ) + if (frame.rootMotion !== null) previousAbsoluteRootMotion = frame.rootMotion + return previewFrame + }) + const sequences: readonly PreviewSequence[] = [{ direction: 'default', frames }] + + return { + id: action.id, + name: action.name, + // #70 用 custom 承载尚未进入内置枚举的动作;Playtest 只在本地识别下蹲控制语义。 + type: + action.type === 'custom' && /(?:crouch|下蹲|蹲伏)/iu.test(action.name) + ? 'crouch' + : action.type, + fps: action.fps, + sequences, + } +} + +function createModel( + character: Character, + outfit: Character['outfits'][number], +): PlaytestPreviewModel { + return { + characterId: character.id, + characterName: character.id, + outfitId: outfit.id, + outfitName: outfit.name, + characterTemplateUrl: outfit.characterTemplateUrl, + baseFrameCount: outfit.baseFrames.length, + actions: outfit.actions.map(createPreviewAction), + } +} + +export function createPreviewModel(character: Character, outfitId: string): PreviewModelResult { + const outfit = character.outfits.find((candidate) => candidate.id === outfitId) + + if (!outfit) return { ok: false, reason: 'outfit_not_found' } + + return { ok: true, model: createModel(character, outfit) } +} diff --git a/frontend/src/pages/playtest/workbench/model/types.ts b/frontend/src/pages/playtest/workbench/model/types.ts new file mode 100644 index 00000000..0a2d0633 --- /dev/null +++ b/frontend/src/pages/playtest/workbench/model/types.ts @@ -0,0 +1,45 @@ +import type { ActionType, Frame } from '@/entities/character' + +/** + * Playtest 需要识别的控制动作。 + * + * `crouch` 暂时只属于预览控制语义,不借此修改 Character 的公共数据合同。 + */ +export type PlaytestActionType = ActionType | 'crouch' +/** #70 暂未定义多方向存储;正式资产进入预览时统一映射为 default。 */ +export type PlaytestDirection = string + +export interface PreviewFrame { + imageUrl: string + durationMs: number + /** 从实体的“相对首帧位移”换算出的逐帧增量,仅供预览运动使用。 */ + rootMotion: Frame['rootMotion'] + keyFrame: boolean +} + +export interface PreviewSequence { + direction: PlaytestDirection + frames: readonly PreviewFrame[] +} + +export interface PreviewAction { + id: string + name: string + type: PlaytestActionType + fps: number + sequences: readonly PreviewSequence[] +} + +export interface PlaytestPreviewModel { + characterId: string + characterName: string + outfitId: string + outfitName: string + characterTemplateUrl: string | null + baseFrameCount: number + actions: readonly PreviewAction[] +} + +export type PreviewModelResult = + | { ok: true; model: PlaytestPreviewModel } + | { ok: false; reason: 'outfit_not_found' } diff --git a/frontend/src/pages/playtest/workbench/playback/playback-state.test.ts b/frontend/src/pages/playtest/workbench/playback/playback-state.test.ts new file mode 100644 index 00000000..b8bffe36 --- /dev/null +++ b/frontend/src/pages/playtest/workbench/playback/playback-state.test.ts @@ -0,0 +1,342 @@ +import { describe, expect, it } from 'vitest' + +import type { PreviewAction } from '../model/types' +import { + createPlaybackState, + firstFrame, + lastFrame, + nextFrame, + playActionType, + previousFrame, + selectAction, + selectDirection, + selectFrame, + toggleLoop, +} from './playback-state' +import * as playbackState from './playback-state' + +const actions: readonly PreviewAction[] = [ + { + id: 'walk', + name: 'Walk', + type: 'walk', + fps: 12, + sequences: [ + { + direction: 'south', + frames: [ + { + imageUrl: '/walk-s-0.png', + durationMs: 80, + rootMotion: null, + keyFrame: true, + }, + { + imageUrl: '/walk-s-1.png', + durationMs: 160, + rootMotion: null, + keyFrame: false, + }, + { + imageUrl: '/walk-s-2.png', + durationMs: 120, + rootMotion: null, + keyFrame: false, + }, + ], + }, + { + direction: 'north', + frames: [ + { + imageUrl: '/walk-n-0.png', + durationMs: 100, + rootMotion: null, + keyFrame: true, + }, + { + imageUrl: '/walk-n-1.png', + durationMs: 100, + rootMotion: null, + keyFrame: false, + }, + ], + }, + ], + }, + { + id: 'idle', + name: 'Idle', + type: 'idle', + fps: 10, + sequences: [ + { + direction: 'default', + frames: [ + { + imageUrl: '/idle-0.png', + durationMs: 100, + rootMotion: null, + keyFrame: true, + }, + ], + }, + ], + }, + { + id: 'jump', + name: 'Jump', + type: 'jump', + fps: 10, + sequences: [ + { + direction: 'default', + frames: [ + { + imageUrl: '/jump-0.png', + durationMs: 100, + rootMotion: { dx: 0, dy: 12 }, + keyFrame: true, + }, + ], + }, + ], + }, + { + id: 'crouch', + name: 'Crouch', + type: 'crouch', + fps: 10, + sequences: [ + { + direction: 'default', + frames: [ + { + imageUrl: '/crouch-0.png', + durationMs: 100, + rootMotion: null, + keyFrame: true, + }, + ], + }, + ], + }, +] + +describe('playback state', () => { + it('initializes the requested action at its first direction and frame', () => { + // Catches a regression where initial selection uses a later action, direction, or frame. + expect(createPlaybackState(actions, 'walk')).toEqual({ + actionId: 'walk', + direction: 'south', + frameIndex: 0, + playing: false, + loop: true, + }) + }) + + it('switches action or direction by pausing and returning to frame zero', () => { + // Catches selection preserving stale playback position or playing state. + const playingWalk = { ...createPlaybackState(actions, 'walk'), frameIndex: 2, playing: true } + const selectedAction = selectAction(playingWalk, actions, 'idle') + const selectedDirection = selectDirection({ ...playingWalk, playing: true }, actions, 'north') + + expect(selectedAction).toMatchObject({ + actionId: 'idle', + direction: 'default', + frameIndex: 0, + playing: false, + }) + expect(selectedDirection).toMatchObject({ + actionId: 'walk', + direction: 'north', + frameIndex: 0, + playing: false, + }) + }) + + it('pauses when a frame is selected manually', () => { + // Catches a manual scrub continuing the old timer-driven animation. + const state = selectFrame( + { ...createPlaybackState(actions, 'walk'), playing: true }, + actions, + 2, + ) + + expect(state).toMatchObject({ frameIndex: 2, playing: false }) + }) + + it('clamps at non-looping frame boundaries', () => { + // Catches non-loop playback wrapping around instead of stopping at the selected edge. + const start = { ...createPlaybackState(actions, 'walk'), loop: false } + const end = { ...start, frameIndex: 2, playing: true } + + expect(previousFrame(start, actions)).toMatchObject({ frameIndex: 0, playing: false }) + expect(nextFrame(end, actions)).toMatchObject({ frameIndex: 2, playing: false }) + expect(firstFrame(end, actions)).toMatchObject({ frameIndex: 0, playing: false }) + expect(lastFrame(start, actions)).toMatchObject({ frameIndex: 2, playing: false }) + }) + + it('wraps at looping frame boundaries', () => { + // Catches looping playback sticking on the first or last frame. + const looped = toggleLoop({ ...createPlaybackState(actions, 'walk'), loop: false }) + + expect(previousFrame(looped, actions)).toMatchObject({ frameIndex: 2, playing: false }) + expect(nextFrame({ ...looped, frameIndex: 2 }, actions)).toMatchObject({ + frameIndex: 0, + playing: false, + }) + }) + + it('returns a safe inactive state for an empty action collection', () => { + // Catches empty preview data dereferencing a missing action or sequence. + const state = createPlaybackState([], 'walk') + + expect(state).toEqual({ + actionId: null, + direction: null, + frameIndex: 0, + playing: false, + loop: true, + }) + expect(nextFrame(state, [])).toEqual(state) + expect(selectAction(state, [], 'walk')).toEqual(state) + }) + + it('starts a playable action type at frame zero without changing the loop setting', () => { + // Catches W/S preserving a stale frame, pausing the target action, or silently changing loop. + const walk = { ...createPlaybackState(actions, 'walk'), frameIndex: 2, loop: false } + const jumping = playActionType(walk, actions, 'jump') + const crouching = playActionType(jumping, actions, 'crouch') + + expect(jumping).toMatchObject({ + actionId: 'jump', + direction: 'default', + frameIndex: 0, + playing: true, + loop: false, + }) + expect(crouching).toMatchObject({ + actionId: 'crouch', + direction: 'default', + frameIndex: 0, + playing: true, + loop: false, + }) + }) + + it('continues a playable current walk without resetting its direction, frame, or loop', () => { + // Catches A/D restarting an already selected walk instead of only resuming its playback. + const current = { + ...createPlaybackState(actions, 'walk'), + direction: 'north' as const, + frameIndex: 1, + playing: false, + loop: false, + } + const continueActionType = ( + playbackState as typeof playbackState & { + continueActionType?: typeof playActionType + } + ).continueActionType + + expect(continueActionType).toBeTypeOf('function') + expect(continueActionType?.(current, actions, 'walk')).toEqual({ ...current, playing: true }) + }) + + it('starts the first playable walk from its first frame when the current action is not walk', () => { + // Catches A/D trying to resume a non-walk action or selecting a walk sequence with no frames. + const current = { ...createPlaybackState(actions, 'idle'), frameIndex: 0, loop: false } + const continueActionType = ( + playbackState as typeof playbackState & { + continueActionType?: typeof playActionType + } + ).continueActionType + + expect(continueActionType?.(current, actions, 'walk')).toMatchObject({ + actionId: 'walk', + direction: 'south', + frameIndex: 0, + playing: true, + loop: false, + }) + }) + + it('starts the first non-empty sequence of the first playable walk', () => { + // Catches A/D selecting an empty first direction even though the chosen walk has playable frames. + const actionsWithEmptyFirstWalkSequence: readonly PreviewAction[] = [ + { + ...actions[0], + sequences: [{ direction: 'south', frames: [] }, actions[0].sequences[1]], + }, + ...actions.slice(1), + ] + const continueActionType = ( + playbackState as typeof playbackState & { + continueActionType?: typeof playActionType + } + ).continueActionType + + expect( + continueActionType?.( + createPlaybackState(actionsWithEmptyFirstWalkSequence, 'idle'), + actionsWithEmptyFirstWalkSequence, + 'walk', + ), + ).toMatchObject({ actionId: 'walk', direction: 'north', frameIndex: 0, playing: true }) + }) + + it('keeps the exact playback state when an action type has no playable frames', () => { + // Catches a missing W/S target clearing or replacing the action currently under inspection. + const current = { ...createPlaybackState(actions, 'walk'), frameIndex: 1 } + + expect(playActionType(current, actions, 'attack')).toBe(current) + }) + + it('keeps the exact playback state when a matching action has only empty sequences', () => { + // Catches type lookup selecting an action that exists but cannot show any frame. + const current = { ...createPlaybackState(actions, 'walk'), frameIndex: 1, playing: true } + const actionsWithEmptyAttack: readonly PreviewAction[] = [ + ...actions, + { + id: 'attack-empty', + name: 'Attack', + type: 'attack', + fps: 10, + sequences: [{ direction: 'default', frames: [] }], + }, + ] + + expect(playActionType(current, actionsWithEmptyAttack, 'attack')).toBe(current) + }) + + it('chooses the first playable action when several actions share a type', () => { + // Catches type lookup choosing a later matching action instead of the source-order first match. + const actionsWithSecondJump: readonly PreviewAction[] = [ + ...actions, + { + id: 'jump-second', + name: 'Second jump', + type: 'jump', + fps: 10, + sequences: [ + { + direction: 'default', + frames: [ + { + imageUrl: '/jump-second-0.png', + durationMs: 100, + rootMotion: null, + keyFrame: true, + }, + ], + }, + ], + }, + ] + + expect( + playActionType(createPlaybackState(actions, 'walk'), actionsWithSecondJump, 'jump'), + ).toMatchObject({ actionId: 'jump', direction: 'default', frameIndex: 0, playing: true }) + }) +}) diff --git a/frontend/src/pages/playtest/workbench/playback/playback-state.ts b/frontend/src/pages/playtest/workbench/playback/playback-state.ts new file mode 100644 index 00000000..dfb35b37 --- /dev/null +++ b/frontend/src/pages/playtest/workbench/playback/playback-state.ts @@ -0,0 +1,155 @@ +import type { + PlaytestActionType, + PlaytestDirection, + PreviewAction, + PreviewSequence, +} from '../model/types' + +export interface PlaybackState { + actionId: string | null + direction: PlaytestDirection | null + frameIndex: number + playing: boolean + loop: boolean +} + +export function selectedAction( + actions: readonly PreviewAction[], + actionId: string | null, +): PreviewAction | null { + return actions.find((action) => action.id === actionId) ?? null +} + +export function selectedSequence( + actions: readonly PreviewAction[], + state: PlaybackState, +): PreviewSequence | null { + const action = selectedAction(actions, state.actionId) + return action?.sequences.find((sequence) => sequence.direction === state.direction) ?? null +} + +function initialStateFor(action: PreviewAction | null, loop: boolean): PlaybackState { + return { + actionId: action?.id ?? null, + direction: action?.sequences[0]?.direction ?? null, + frameIndex: 0, + playing: false, + loop, + } +} + +function initialPlayableStateFor(action: PreviewAction, loop: boolean): PlaybackState { + const sequence = action.sequences.find((candidate) => candidate.frames.length > 0) + return { + actionId: action.id, + direction: sequence?.direction ?? null, + frameIndex: 0, + playing: true, + loop, + } +} + +function frameCount(actions: readonly PreviewAction[], state: PlaybackState): number { + return selectedSequence(actions, state)?.frames.length ?? 0 +} + +export function createPlaybackState( + actions: readonly PreviewAction[], + initialActionId: string | null, +): PlaybackState { + return initialStateFor(selectedAction(actions, initialActionId) ?? actions[0] ?? null, true) +} + +export function selectAction( + state: PlaybackState, + actions: readonly PreviewAction[], + actionId: string, +): PlaybackState { + const action = selectedAction(actions, actionId) + return action === null ? state : initialStateFor(action, state.loop) +} + +export function playActionType( + state: PlaybackState, + actions: readonly PreviewAction[], + type: PlaytestActionType, +): PlaybackState { + const action = actions.find( + (candidate) => + candidate.type === type && candidate.sequences.some((sequence) => sequence.frames.length > 0), + ) + return action === undefined ? state : initialPlayableStateFor(action, state.loop) +} + +export function continueActionType( + state: PlaybackState, + actions: readonly PreviewAction[], + type: PlaytestActionType, +): PlaybackState { + const action = selectedAction(actions, state.actionId) + if (action?.type === type && frameCount(actions, state) > 0) { + return { ...state, playing: true } + } + + return playActionType(state, actions, type) +} + +export function selectDirection( + state: PlaybackState, + actions: readonly PreviewAction[], + direction: PlaytestDirection, +): PlaybackState { + const action = selectedAction(actions, state.actionId) + if (action?.sequences.some((sequence) => sequence.direction === direction) !== true) return state + + return { ...state, direction, frameIndex: 0, playing: false } +} + +export function selectFrame( + state: PlaybackState, + actions: readonly PreviewAction[], + index: number, +): PlaybackState { + const count = frameCount(actions, state) + if (count === 0) return { ...state, frameIndex: 0, playing: false } + + return { ...state, frameIndex: Math.min(Math.max(index, 0), count - 1), playing: false } +} + +export function previousFrame( + state: PlaybackState, + actions: readonly PreviewAction[], +): PlaybackState { + const count = frameCount(actions, state) + if (count === 0) return { ...state, frameIndex: 0, playing: false } + if (state.frameIndex > 0) return { ...state, frameIndex: state.frameIndex - 1, playing: false } + return { ...state, frameIndex: state.loop ? count - 1 : 0, playing: false } +} + +export function nextFrame(state: PlaybackState, actions: readonly PreviewAction[]): PlaybackState { + const count = frameCount(actions, state) + if (count === 0) return { ...state, frameIndex: 0, playing: false } + if (state.frameIndex < count - 1) + return { ...state, frameIndex: state.frameIndex + 1, playing: false } + return { ...state, frameIndex: state.loop ? 0 : count - 1, playing: false } +} + +export function firstFrame(state: PlaybackState, actions: readonly PreviewAction[]): PlaybackState { + return selectFrame(state, actions, 0) +} + +export function lastFrame(state: PlaybackState, actions: readonly PreviewAction[]): PlaybackState { + return selectFrame(state, actions, frameCount(actions, state) - 1) +} + +export function togglePlaying( + state: PlaybackState, + actions: readonly PreviewAction[], +): PlaybackState { + if (frameCount(actions, state) === 0) return { ...state, playing: false } + return { ...state, playing: !state.playing } +} + +export function toggleLoop(state: PlaybackState): PlaybackState { + return { ...state, loop: !state.loop } +} diff --git a/frontend/src/pages/playtest/workbench/playback/use-playback-controller.test.tsx b/frontend/src/pages/playtest/workbench/playback/use-playback-controller.test.tsx new file mode 100644 index 00000000..309ca3bf --- /dev/null +++ b/frontend/src/pages/playtest/workbench/playback/use-playback-controller.test.tsx @@ -0,0 +1,300 @@ +/** @vitest-environment jsdom */ +import { act } from 'react' +import { createRoot, type Root } from 'react-dom/client' +import { afterEach, describe, expect, it, vi } from 'vitest' + +import type { PreviewAction } from '../model/types' +import { type PlaybackController, usePlaybackController } from './use-playback-controller' + +Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true }) + +const actions: readonly PreviewAction[] = [ + { + id: 'walk', + name: 'Walk', + type: 'walk', + fps: 12, + sequences: [ + { + direction: 'south', + frames: [ + { + imageUrl: '/walk-0.png', + durationMs: 80, + rootMotion: null, + keyFrame: true, + }, + { + imageUrl: '/walk-1.png', + durationMs: 160, + rootMotion: null, + keyFrame: false, + }, + { + imageUrl: '/walk-2.png', + durationMs: 120, + rootMotion: null, + keyFrame: false, + }, + ], + }, + ], + }, + { + id: 'idle', + name: 'Idle', + type: 'idle', + fps: 10, + sequences: [ + { + direction: 'default', + frames: [ + { + imageUrl: '/idle-0.png', + durationMs: 100, + rootMotion: null, + keyFrame: true, + }, + ], + }, + ], + }, + { + id: 'jump', + name: 'Jump', + type: 'jump', + fps: 10, + sequences: [ + { + direction: 'default', + frames: [ + { + imageUrl: '/jump-0.png', + durationMs: 100, + rootMotion: { dx: 0, dy: 12 }, + keyFrame: true, + }, + ], + }, + ], + }, +] + +interface MountedController { + readonly controller: PlaybackController + unmount(): void +} + +function mountController(initialActionId = 'walk'): MountedController { + const host = document.createElement('div') + const root: Root = createRoot(host) + let controller: PlaybackController | null = null + + function Probe() { + controller = usePlaybackController(actions, initialActionId) + return null + } + + act(() => { + root.render() + }) + + return { + get controller() { + if (controller === null) throw new Error('controller was not rendered') + return controller + }, + unmount() { + act(() => { + root.unmount() + }) + }, + } +} + +afterEach(() => { + vi.useRealTimers() +}) + +describe('usePlaybackController', () => { + it('plays the first playable action of a requested type without changing loop mode', () => { + // Catches typed shortcuts preserving stale position, pausing the target, or changing loop mode. + const mounted = mountController() + + act(() => { + mounted.controller.toggleLoop() + mounted.controller.selectFrame(2) + mounted.controller.playActionType('jump') + }) + + expect(mounted.controller.state).toMatchObject({ + actionId: 'jump', + direction: 'default', + frameIndex: 0, + playing: true, + loop: false, + }) + + const current = mounted.controller.state + act(() => { + mounted.controller.playActionType('attack') + }) + expect(mounted.controller.state).toBe(current) + mounted.unmount() + }) + + it('exposes walk continuation that resumes the current walk without resetting playback position', () => { + // Catches keyboard direction controls being forced through the restart-only action command. + const mounted = mountController() + + act(() => { + mounted.controller.selectFrame(2) + mounted.controller.toggleLoop() + }) + + const controller = mounted.controller as PlaybackController & { + continueActionType?: (type: 'walk') => void + } + expect(controller.continueActionType).toBeTypeOf('function') + act(() => { + controller.continueActionType?.('walk') + }) + + expect(mounted.controller.state).toMatchObject({ + actionId: 'walk', + direction: 'south', + frameIndex: 2, + playing: true, + loop: false, + }) + mounted.unmount() + }) + + it('increments frameTick for every timed advance, including a single-frame loop', () => { + // Catches stage motion missing a loop iteration because its visible frame index remains zero. + vi.useFakeTimers() + const mounted = mountController() + const controller = mounted.controller as PlaybackController & { frameTick?: number } + + expect(controller.frameTick).toBe(0) + act(() => { + mounted.controller.selectAction('idle') + mounted.controller.togglePlaying() + }) + act(() => { + vi.advanceTimersByTime(100) + }) + + expect(mounted.controller.state).toMatchObject({ + actionId: 'idle', + frameIndex: 0, + playing: true, + }) + expect((mounted.controller as PlaybackController & { frameTick?: number }).frameTick).toBe(1) + mounted.unmount() + }) + + it('waits for each current-frame duration before advancing', () => { + // Catches fixed-interval scheduling that skips the current frame's actual duration. + vi.useFakeTimers() + const mounted = mountController() + + act(() => { + mounted.controller.togglePlaying() + }) + act(() => { + vi.advanceTimersByTime(79) + }) + expect(mounted.controller.state.frameIndex).toBe(0) + + act(() => { + vi.advanceTimersByTime(1) + }) + expect(mounted.controller.state.frameIndex).toBe(1) + + act(() => { + vi.advanceTimersByTime(159) + }) + expect(mounted.controller.state.frameIndex).toBe(1) + + act(() => { + vi.advanceTimersByTime(1) + }) + expect(mounted.controller.state.frameIndex).toBe(2) + mounted.unmount() + }) + + it('clears playback when paused or when its action changes', () => { + // Catches old timers advancing a manually paused or newly selected action. + vi.useFakeTimers() + const mounted = mountController() + + act(() => { + mounted.controller.togglePlaying() + mounted.controller.togglePlaying() + vi.advanceTimersByTime(500) + }) + expect(mounted.controller.state).toMatchObject({ + actionId: 'walk', + frameIndex: 0, + playing: false, + }) + + act(() => { + mounted.controller.togglePlaying() + mounted.controller.selectAction('idle') + vi.advanceTimersByTime(500) + }) + expect(mounted.controller.state).toMatchObject({ + actionId: 'idle', + frameIndex: 0, + playing: false, + }) + mounted.unmount() + }) + + it('restarts the current-frame timeout when loop changes', () => { + // Catches a pending timeout surviving a loop-mode change near the frame boundary. + vi.useFakeTimers() + const mounted = mountController() + + act(() => { + mounted.controller.togglePlaying() + }) + act(() => { + vi.advanceTimersByTime(80) + }) + act(() => { + vi.advanceTimersByTime(159) + }) + expect(mounted.controller.state.frameIndex).toBe(1) + + act(() => { + mounted.controller.toggleLoop() + }) + act(() => { + vi.advanceTimersByTime(1) + }) + expect(mounted.controller.state.frameIndex).toBe(1) + + act(() => { + vi.advanceTimersByTime(159) + }) + expect(mounted.controller.state.frameIndex).toBe(2) + mounted.unmount() + }) + + it('cleans up a scheduled timeout when unmounted', () => { + // Catches a timeout retaining the controller after its React tree is gone. + vi.useFakeTimers() + const mounted = mountController() + + act(() => { + mounted.controller.togglePlaying() + }) + expect(vi.getTimerCount()).toBe(1) + + mounted.unmount() + expect(vi.getTimerCount()).toBe(0) + }) +}) diff --git a/frontend/src/pages/playtest/workbench/playback/use-playback-controller.ts b/frontend/src/pages/playtest/workbench/playback/use-playback-controller.ts new file mode 100644 index 00000000..c909e2c4 --- /dev/null +++ b/frontend/src/pages/playtest/workbench/playback/use-playback-controller.ts @@ -0,0 +1,155 @@ +import { useCallback, useEffect, useState } from 'react' + +import type { + PlaytestActionType, + PlaytestDirection, + PreviewAction, + PreviewFrame, + PreviewSequence, +} from '../model/types' +import { + createPlaybackState, + continueActionType, + firstFrame, + lastFrame, + nextFrame, + playActionType, + previousFrame, + selectAction, + selectDirection, + selectFrame, + selectedAction, + selectedSequence, + toggleLoop, + togglePlaying, + type PlaybackState, +} from './playback-state' + +export interface PlaybackController { + state: PlaybackState + frameTick: number + action: PreviewAction | null + sequence: PreviewSequence | null + frame: PreviewFrame | null + playActionType(type: PlaytestActionType): void + continueActionType(type: PlaytestActionType): void + selectAction(actionId: string): void + selectDirection(direction: PlaytestDirection): void + selectFrame(index: number): void + previousFrame(): void + nextFrame(): void + firstFrame(): void + lastFrame(): void + togglePlaying(): void + toggleLoop(): void +} + +export function usePlaybackController( + actions: readonly PreviewAction[], + initialActionId: string | null, +): PlaybackController { + const [state, setState] = useState(() => createPlaybackState(actions, initialActionId)) + const [frameTick, setFrameTick] = useState(0) + const action = selectedAction(actions, state.actionId) + const sequence = selectedSequence(actions, state) + const frame = sequence?.frames[state.frameIndex] ?? null + + useEffect(() => { + if (!state.playing || frame === null) return + + const timeoutId = window.setTimeout(() => { + const finalFrameIndex = (sequence?.frames.length ?? 1) - 1 + if (state.loop || state.frameIndex < finalFrameIndex) { + setFrameTick((tick) => tick + 1) + } + setState((current) => { + const currentSequence = selectedSequence(actions, current) + const currentFinalFrameIndex = (currentSequence?.frames.length ?? 1) - 1 + const advanced = nextFrame(current, actions) + + if (!current.loop && current.frameIndex >= currentFinalFrameIndex) return advanced + return { ...advanced, playing: true } + }) + }, frame.durationMs) + + return () => window.clearTimeout(timeoutId) + }, [actions, frame, sequence, state.frameIndex, state.loop, state.playing]) + + const chooseAction = useCallback( + (actionId: string) => { + setState((current) => selectAction(current, actions, actionId)) + }, + [actions], + ) + + const chooseActionType = useCallback( + (type: PlaytestActionType) => { + setState((current) => playActionType(current, actions, type)) + }, + [actions], + ) + + const continueAction = useCallback( + (type: PlaytestActionType) => { + setState((current) => continueActionType(current, actions, type)) + }, + [actions], + ) + + const chooseDirection = useCallback( + (direction: PlaytestDirection) => { + setState((current) => selectDirection(current, actions, direction)) + }, + [actions], + ) + + const chooseFrame = useCallback( + (index: number) => { + setState((current) => selectFrame(current, actions, index)) + }, + [actions], + ) + + const choosePreviousFrame = useCallback(() => { + setState((current) => previousFrame(current, actions)) + }, [actions]) + + const chooseNextFrame = useCallback(() => { + setState((current) => nextFrame(current, actions)) + }, [actions]) + + const chooseFirstFrame = useCallback(() => { + setState((current) => firstFrame(current, actions)) + }, [actions]) + + const chooseLastFrame = useCallback(() => { + setState((current) => lastFrame(current, actions)) + }, [actions]) + + const choosePlaying = useCallback(() => { + setState((current) => togglePlaying(current, actions)) + }, [actions]) + + const chooseLoop = useCallback(() => { + setState((current) => toggleLoop(current)) + }, []) + + return { + state, + frameTick, + action, + sequence, + frame, + playActionType: chooseActionType, + continueActionType: continueAction, + selectAction: chooseAction, + selectDirection: chooseDirection, + selectFrame: chooseFrame, + previousFrame: choosePreviousFrame, + nextFrame: chooseNextFrame, + firstFrame: chooseFirstFrame, + lastFrame: chooseLastFrame, + togglePlaying: choosePlaying, + toggleLoop: chooseLoop, + } +} diff --git a/frontend/src/pages/playtest/workbench/stage-motion.test.tsx b/frontend/src/pages/playtest/workbench/stage-motion.test.tsx new file mode 100644 index 00000000..85bbd317 --- /dev/null +++ b/frontend/src/pages/playtest/workbench/stage-motion.test.tsx @@ -0,0 +1,250 @@ +/** @vitest-environment jsdom */ +import { cleanup, fireEvent, render, screen } from '@testing-library/react' +import { afterEach, describe, expect, it } from 'vitest' + +import type { PreviewFrame } from './model/types' +import { createStageMotionState, reduceStageMotion, useStageMotion } from './stage-motion' + +const movingFrame: PreviewFrame = { + imageUrl: 'https://cdn.example.test/walk.png', + durationMs: 100, + rootMotion: { dx: 4, dy: 3 }, + keyFrame: false, +} + +afterEach(cleanup) + +function MotionProbe({ + frame, + playing, + frameTick, + resetKey, +}: { + frame: PreviewFrame | null + playing: boolean + frameTick: number + resetKey: string +}) { + const motion = useStageMotion({ frame, playing, frameTick, resetKey }) + + return ( + <> + {`${motion.offset.x},${motion.offset.y}`} + + + + ) +} + +describe('stage motion', () => { + it('clamps at the right edge, turns left, and keeps moving inward', () => { + const bounded = reduceStageMotion(createStageMotionState(0), { + type: 'set-bounds', + bounds: { minX: -10, maxX: 10 }, + }) + const nearEdge = reduceStageMotion(bounded, { + type: 'consume-frame', + tick: 1, + rootMotion: { dx: 8, dy: 0 }, + }) + const hitEdge = reduceStageMotion(nearEdge, { + type: 'consume-frame', + tick: 2, + rootMotion: { dx: 4, dy: 0 }, + }) + const movedInward = reduceStageMotion(hitEdge, { + type: 'consume-frame', + tick: 3, + rootMotion: { dx: 3, dy: 0 }, + }) + + expect(hitEdge).toMatchObject({ offset: { x: 10, y: 0 }, mirrored: true }) + expect(movedInward).toMatchObject({ offset: { x: 7, y: 0 }, mirrored: true }) + }) + + it('clamps at the left edge, turns right, and keeps moving inward', () => { + const bounded = reduceStageMotion(createStageMotionState(0), { + type: 'set-bounds', + bounds: { minX: -10, maxX: 10 }, + }) + const facingLeft = reduceStageMotion(bounded, { type: 'set-mirrored', mirrored: true }) + const nearEdge = reduceStageMotion(facingLeft, { + type: 'consume-frame', + tick: 1, + rootMotion: { dx: 8, dy: 0 }, + }) + const hitEdge = reduceStageMotion(nearEdge, { + type: 'consume-frame', + tick: 2, + rootMotion: { dx: 4, dy: 0 }, + }) + const movedInward = reduceStageMotion(hitEdge, { + type: 'consume-frame', + tick: 3, + rootMotion: { dx: 3, dy: 0 }, + }) + + expect(hitEdge).toMatchObject({ offset: { x: -10, y: 0 }, mirrored: false }) + expect(movedInward).toMatchObject({ offset: { x: -7, y: 0 }, mirrored: false }) + }) + + it('reclamps an existing position when the measured stage becomes narrower', () => { + const wide = reduceStageMotion(createStageMotionState(0), { + type: 'set-bounds', + bounds: { minX: -20, maxX: 20 }, + }) + const moved = reduceStageMotion(wide, { + type: 'consume-frame', + tick: 1, + rootMotion: { dx: 16, dy: 0 }, + }) + const narrowed = reduceStageMotion(moved, { + type: 'set-bounds', + bounds: { minX: -5, maxX: 5 }, + }) + + expect(narrowed).toMatchObject({ offset: { x: 5, y: 0 }, mirrored: true }) + }) + + it('adds each newly advanced playback tick once', () => { + // Catches a rerender applying a frame's incremental root motion more than once. + const first = reduceStageMotion(createStageMotionState(0), { + type: 'consume-frame', + tick: 1, + rootMotion: { dx: 4, dy: 3 }, + }) + const duplicate = reduceStageMotion(first, { + type: 'consume-frame', + tick: 1, + rootMotion: { dx: 4, dy: 3 }, + }) + const second = reduceStageMotion(duplicate, { + type: 'consume-frame', + tick: 2, + rootMotion: { dx: -1, dy: 2 }, + }) + + expect(second.offset).toEqual({ x: 3, y: 5 }) + expect(second.consumedTick).toBe(2) + }) + + it('uses a fresh loop tick to continue accumulating when playback returns to the first frame', () => { + // Catches loop wraps being mistaken for a duplicate frame and dropping their root-motion increment. + const afterFirstPass = reduceStageMotion(createStageMotionState(0), { + type: 'consume-frame', + tick: 1, + rootMotion: { dx: 2, dy: 0 }, + }) + const afterLoop = reduceStageMotion(afterFirstPass, { + type: 'consume-frame', + tick: 2, + rootMotion: { dx: 2, dy: 0 }, + }) + + expect(afterLoop.offset).toEqual({ x: 4, y: 0 }) + }) + + it('sets mirrored direction idempotently without resetting position and reverses future horizontal increments', () => { + // Catches repeated A/D presses flipping back to the opposite direction, resetting placement, or leaving mirrored locomotion moving right. + const movedRight = reduceStageMotion(createStageMotionState(0), { + type: 'consume-frame', + tick: 1, + rootMotion: { dx: 5, dy: 1 }, + }) + const mirrored = reduceStageMotion(movedRight, { type: 'set-mirrored', mirrored: true }) + const mirroredAgain = reduceStageMotion(mirrored, { type: 'set-mirrored', mirrored: true }) + const movedLeft = reduceStageMotion(mirroredAgain, { + type: 'consume-frame', + tick: 2, + rootMotion: { dx: 2, dy: 0 }, + }) + const restored = reduceStageMotion(movedLeft, { type: 'set-mirrored', mirrored: false }) + + expect(mirrored).toMatchObject({ offset: { x: 5, y: 1 }, mirrored: true }) + expect(mirroredAgain).toMatchObject({ offset: { x: 5, y: 1 }, mirrored: true }) + expect(movedLeft.offset).toEqual({ x: 3, y: 1 }) + expect(restored).toMatchObject({ offset: { x: 3, y: 1 }, mirrored: false }) + }) + + it('treats absent root motion as stationary', () => { + // Catches missing rootMotion producing a non-zero offset or erasing the accumulated position. + const moved = reduceStageMotion(createStageMotionState(0), { + type: 'consume-frame', + tick: 1, + rootMotion: movingFrame.rootMotion, + }) + const stationary = reduceStageMotion(moved, { + type: 'consume-frame', + tick: 2, + rootMotion: null, + }) + + expect(stationary.offset).toEqual({ x: 4, y: 3 }) + }) + + it('does not consume on start, resume, direction change, or same-tick rerenders', () => { + // Catches playback state changes applying the current frame before an automatic tick reaches its target frame. + const { rerender } = render( + , + ) + + expect(screen.getByRole('status').textContent).toBe('0,0') + + rerender( + , + ) + expect(screen.getByRole('status').textContent).toBe('0,0') + + fireEvent.click(screen.getByRole('button', { name: '向左' })) + expect(screen.getByRole('status').textContent).toBe('0,0') + + rerender( + , + ) + expect(screen.getByRole('status').textContent).toBe('-4,3') + + rerender( + , + ) + rerender( + , + ) + expect(screen.getByRole('status').textContent).toBe('-4,3') + }) + + it('uses the current tick as a reset baseline for a new character or outfit', () => { + // Catches a reset immediately consuming the new preview's current frame or retaining the old preview position. + const { rerender } = render( + , + ) + + rerender( + , + ) + expect(screen.getByRole('status').textContent).toBe('4,3') + + rerender( + , + ) + expect(screen.getByRole('status').textContent).toBe('0,0') + }) +}) diff --git a/frontend/src/pages/playtest/workbench/stage-motion.ts b/frontend/src/pages/playtest/workbench/stage-motion.ts new file mode 100644 index 00000000..9d60ea17 --- /dev/null +++ b/frontend/src/pages/playtest/workbench/stage-motion.ts @@ -0,0 +1,136 @@ +import { useCallback, useEffect, useReducer } from 'react' + +import type { FrameRootMotion } from '@/entities/character' + +import type { PreviewFrame } from './model/types' + +export interface StageOffset { + x: number + /** Positive values move the character upward in world coordinates. */ + y: number +} + +export interface HorizontalStageBounds { + minX: number + maxX: number +} + +export interface StageMotionState { + offset: StageOffset + /** The latest automatic playback tick already reflected in offset. */ + consumedTick: number + mirrored: boolean + bounds: HorizontalStageBounds | null +} + +export type StageMotionAction = + | { type: 'consume-frame'; tick: number; rootMotion: FrameRootMotion | null } + | { type: 'set-mirrored'; mirrored: boolean } + | { type: 'set-bounds'; bounds: HorizontalStageBounds | null } + | { type: 'reset'; baselineTick: number } + +export function createStageMotionState(baselineTick = 0): StageMotionState { + return { + offset: { x: 0, y: 0 }, + consumedTick: baselineTick, + mirrored: false, + bounds: null, + } +} + +function clamp(value: number, minimum: number, maximum: number): number { + return Math.min(Math.max(value, minimum), maximum) +} + +export function reduceStageMotion( + state: StageMotionState, + action: StageMotionAction, +): StageMotionState { + if (action.type === 'reset') { + return { ...createStageMotionState(action.baselineTick), bounds: state.bounds } + } + if (action.type === 'set-mirrored') return { ...state, mirrored: action.mirrored } + if (action.type === 'set-bounds') { + if (action.bounds === null) return { ...state, bounds: null } + + const minX = Math.min(action.bounds.minX, action.bounds.maxX) + const maxX = Math.max(action.bounds.minX, action.bounds.maxX) + const x = clamp(state.offset.x, minX, maxX) + const mirrored = state.offset.x > maxX ? true : state.offset.x < minX ? false : state.mirrored + return { ...state, offset: { ...state.offset, x }, mirrored, bounds: { minX, maxX } } + } + if (action.tick <= state.consumedTick) return state + + const rootMotion = action.rootMotion ?? { dx: 0, dy: 0 } + const horizontalIncrement = state.mirrored ? -rootMotion.dx : rootMotion.dx + const candidateX = state.offset.x + horizontalIncrement + const nextX = + state.bounds === null ? candidateX : clamp(candidateX, state.bounds.minX, state.bounds.maxX) + const mirrored = + state.bounds === null || horizontalIncrement === 0 + ? state.mirrored + : horizontalIncrement > 0 && candidateX >= state.bounds.maxX + ? true + : horizontalIncrement < 0 && candidateX <= state.bounds.minX + ? false + : state.mirrored + return { + ...state, + offset: { + x: nextX, + y: state.offset.y + rootMotion.dy, + }, + mirrored, + consumedTick: action.tick, + } +} + +export interface UseStageMotionInput { + frame: PreviewFrame | null + /** Only timed playback contributes root-motion increments. */ + playing: boolean + /** Monotonically increases only when timed playback advances to a frame. */ + frameTick: number + /** A character/outfit identity supplied by the owner of the preview. */ + resetKey: string +} + +export interface StageMotion { + offset: StageOffset + mirrored: boolean + setMirrored(mirrored: boolean): void + setBounds(bounds: HorizontalStageBounds | null): void +} + +export function useStageMotion({ + frame, + playing, + frameTick, + resetKey, +}: UseStageMotionInput): StageMotion { + const [state, dispatch] = useReducer(reduceStageMotion, frameTick, createStageMotionState) + + useEffect(() => { + dispatch({ type: 'reset', baselineTick: frameTick }) + // resetKey deliberately captures the tick at the identity transition only. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [resetKey]) + + useEffect(() => { + if (!playing || frame === null) return + dispatch({ type: 'consume-frame', tick: frameTick, rootMotion: frame.rootMotion }) + }, [frame, frameTick, playing]) + + return { + offset: state.offset, + mirrored: state.mirrored, + setMirrored: useCallback( + (mirrored: boolean) => dispatch({ type: 'set-mirrored', mirrored }), + [], + ), + setBounds: useCallback( + (bounds: HorizontalStageBounds | null) => dispatch({ type: 'set-bounds', bounds }), + [], + ), + } +} diff --git a/frontend/src/pages/playtest/workbench/use-playtest-keyboard.test.tsx b/frontend/src/pages/playtest/workbench/use-playtest-keyboard.test.tsx new file mode 100644 index 00000000..cea1a32d --- /dev/null +++ b/frontend/src/pages/playtest/workbench/use-playtest-keyboard.test.tsx @@ -0,0 +1,265 @@ +/** @vitest-environment jsdom */ +import { act } from 'react' +import { createRoot, type Root } from 'react-dom/client' +import { afterEach, describe, expect, it, vi } from 'vitest' + +import { type PlaytestKeyboardCommands, usePlaytestKeyboard } from './use-playtest-keyboard' + +Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true }) + +const mountedRoots = new Set() + +function createCommands(): PlaytestKeyboardCommands { + return { + togglePlaying: vi.fn(), + previousFrame: vi.fn(), + nextFrame: vi.fn(), + firstFrame: vi.fn(), + lastFrame: vi.fn(), + toggleLoop: vi.fn(), + playLeft: vi.fn(), + playRight: vi.fn(), + stopHorizontal: vi.fn(), + playJump: vi.fn(), + playCrouch: vi.fn(), + } +} + +const shortcutKeys = [' ', 'ArrowLeft', 'ArrowRight', 'Home', 'End', 'L'] as const +const interactiveShortcutKeys = [...shortcutKeys, 'a', 'D', 'w', 'S'] as const + +function dispatchKey( + key: string, + target: EventTarget = window, + options: KeyboardEventInit = {}, +): KeyboardEvent { + const event = new KeyboardEvent('keydown', { + bubbles: true, + cancelable: true, + key, + ...options, + }) + target.dispatchEvent(event) + return event +} + +function mountKeyboard(commands: PlaytestKeyboardCommands, enabled = true): () => void { + const host = document.createElement('div') + const root: Root = createRoot(host) + + function Probe() { + usePlaytestKeyboard(commands, enabled) + return null + } + + act(() => { + root.render() + }) + mountedRoots.add(root) + + return () => { + if (!mountedRoots.delete(root)) return + + act(() => { + root.unmount() + }) + } +} + +afterEach(() => { + for (const root of mountedRoots) { + act(() => { + root.unmount() + }) + } + mountedRoots.clear() + document.body.replaceChildren() +}) + +describe('usePlaytestKeyboard', () => { + it('maps the playback shortcuts to their commands', () => { + // Catches an incomplete or swapped shortcut map while the workbench is focused. + const commands = createCommands() + const unmount = mountKeyboard(commands) + + const events = shortcutKeys.map((key) => dispatchKey(key)) + + expect(events.every((event) => event.defaultPrevented)).toBe(true) + + expect(commands.togglePlaying).toHaveBeenCalledOnce() + expect(commands.previousFrame).toHaveBeenCalledOnce() + expect(commands.nextFrame).toHaveBeenCalledOnce() + expect(commands.firstFrame).toHaveBeenCalledOnce() + expect(commands.lastFrame).toHaveBeenCalledOnce() + expect(commands.toggleLoop).toHaveBeenCalledOnce() + unmount() + }) + + it('maps A/D to directional walk commands and W/S to action commands', () => { + // Catches A/D retaining their old frame-navigation semantics instead of selecting a walk direction. + const commands = createCommands() + const unmount = mountKeyboard(commands) + + dispatchKey('a') + dispatchKey('D') + dispatchKey('w') + dispatchKey('S') + + expect(commands.playLeft).toHaveBeenCalledOnce() + expect(commands.playRight).toHaveBeenCalledOnce() + expect(commands.playJump).toHaveBeenCalledOnce() + expect(commands.playCrouch).toHaveBeenCalledOnce() + unmount() + }) + + it('handles shortcuts dispatched from the body and ordinary elements', () => { + // Catches a missing editable ancestor being mistaken for an interactive editing surface. + const commands = createCommands() + const target = document.createElement('div') + document.body.append(target) + const unmount = mountKeyboard(commands) + + const arrowEvent = dispatchKey('ArrowRight', document.body) + const spaceEvent = dispatchKey(' ', target) + + expect(arrowEvent.defaultPrevented).toBe(true) + expect(spaceEvent.defaultPrevented).toBe(true) + expect(commands.nextFrame).toHaveBeenCalledOnce() + expect(commands.togglePlaying).toHaveBeenCalledOnce() + unmount() + }) + + it.each([ + ['input', () => document.createElement('input')], + ['textarea', () => document.createElement('textarea')], + ['select', () => document.createElement('select')], + [ + 'contenteditable', + () => { + const element = document.createElement('div') + element.setAttribute('contenteditable', 'true') + return element + }, + ], + ])('does not intercept any shortcut when focus is in %s', (_label, createElement) => { + // Catches global shortcuts stealing native control or text-editing input. + const commands = createCommands() + const target = createElement() + document.body.append(target) + const unmount = mountKeyboard(commands) + + const events = interactiveShortcutKeys.map((key) => dispatchKey(key, target)) + + expect(events.every((event) => !event.defaultPrevented)).toBe(true) + expect(Object.values(commands).every((command) => !vi.mocked(command).mock.calls.length)).toBe( + true, + ) + unmount() + }) + + it('keeps A/D/W/S active when a workbench button still owns focus', () => { + // Catches clicking Play/Pause leaving focus on its button and disabling the movement controls. + const commands = createCommands() + const button = document.createElement('button') + document.body.append(button) + const unmount = mountKeyboard(commands) + + const actionEvents = ['a', 'D', 'w', 'S'].map((key) => dispatchKey(key, button)) + const spaceEvent = dispatchKey(' ', button) + + expect(actionEvents.every((event) => event.defaultPrevented)).toBe(true) + expect(spaceEvent.defaultPrevented).toBe(false) + expect(commands.playLeft).toHaveBeenCalledOnce() + expect(commands.playRight).toHaveBeenCalledOnce() + expect(commands.playJump).toHaveBeenCalledOnce() + expect(commands.playCrouch).toHaveBeenCalledOnce() + expect(commands.togglePlaying).not.toHaveBeenCalled() + unmount() + }) + + it('ignores repeated toggle shortcuts while keeping frame navigation repeatable', () => { + // Catches a held Space or L key repeatedly flipping state while arrow/home/end retain key repeat. + const commands = createCommands() + const unmount = mountKeyboard(commands) + + expect(dispatchKey(' ', window, { repeat: true }).defaultPrevented).toBe(true) + expect(dispatchKey('L', window, { repeat: true }).defaultPrevented).toBe(true) + dispatchKey('ArrowLeft', window, { repeat: true }) + dispatchKey('ArrowRight', window, { repeat: true }) + dispatchKey('Home', window, { repeat: true }) + dispatchKey('End', window, { repeat: true }) + + expect(commands.togglePlaying).not.toHaveBeenCalled() + expect(commands.toggleLoop).not.toHaveBeenCalled() + expect(commands.previousFrame).toHaveBeenCalledOnce() + expect(commands.nextFrame).toHaveBeenCalledOnce() + expect(commands.firstFrame).toHaveBeenCalledOnce() + expect(commands.lastFrame).toHaveBeenCalledOnce() + unmount() + }) + + it('keeps A/D repeatable while ignoring repeated W/S action shortcuts', () => { + // Catches held action keys replaying while held navigation keys remain repeatable. + const commands = createCommands() + const unmount = mountKeyboard(commands) + + dispatchKey('a', window, { repeat: true }) + dispatchKey('D', window, { repeat: true }) + expect(dispatchKey('w', window, { repeat: true }).defaultPrevented).toBe(true) + expect(dispatchKey('S', window, { repeat: true }).defaultPrevented).toBe(true) + + expect(commands.playLeft).toHaveBeenCalledOnce() + expect(commands.playRight).toHaveBeenCalledOnce() + expect(commands.playJump).not.toHaveBeenCalled() + expect(commands.playCrouch).not.toHaveBeenCalled() + unmount() + }) + + it('ends transient horizontal playback after the last held A/D key is released', () => { + // Catches held movement starting Walk without restoring the previous paused state on keyup. + const commands = createCommands() + const unmount = mountKeyboard(commands) + + dispatchKey('d') + window.dispatchEvent(new KeyboardEvent('keyup', { bubbles: true, key: 'd' })) + + expect(commands.playRight).toHaveBeenCalledOnce() + expect(commands.stopHorizontal).toHaveBeenCalledOnce() + unmount() + }) + + it('handles shortcuts when contenteditable is explicitly false', () => { + // Catches the attribute selector treating a non-editable element as an editor. + const commands = createCommands() + const target = document.createElement('div') + target.setAttribute('contenteditable', 'false') + document.body.append(target) + const unmount = mountKeyboard(commands) + + const events = shortcutKeys.map((key) => dispatchKey(key, target)) + + expect(events.every((event) => event.defaultPrevented)).toBe(true) + expect(commands.togglePlaying).toHaveBeenCalledOnce() + expect(commands.previousFrame).toHaveBeenCalledOnce() + expect(commands.nextFrame).toHaveBeenCalledOnce() + expect(commands.firstFrame).toHaveBeenCalledOnce() + expect(commands.lastFrame).toHaveBeenCalledOnce() + expect(commands.toggleLoop).toHaveBeenCalledOnce() + unmount() + }) + + it('does not listen while disabled and removes its listener when unmounted', () => { + // Catches shortcuts escaping a disabled workbench or a detached React tree. + const commands = createCommands() + const disabledUnmount = mountKeyboard(commands, false) + + expect(dispatchKey('ArrowRight').defaultPrevented).toBe(false) + expect(commands.nextFrame).not.toHaveBeenCalled() + disabledUnmount() + + const unmount = mountKeyboard(commands) + unmount() + expect(dispatchKey('ArrowRight').defaultPrevented).toBe(false) + expect(commands.nextFrame).not.toHaveBeenCalled() + }) +}) diff --git a/frontend/src/pages/playtest/workbench/use-playtest-keyboard.ts b/frontend/src/pages/playtest/workbench/use-playtest-keyboard.ts new file mode 100644 index 00000000..5a5240c3 --- /dev/null +++ b/frontend/src/pages/playtest/workbench/use-playtest-keyboard.ts @@ -0,0 +1,91 @@ +import { useEffect, useRef } from 'react' + +export interface PlaytestKeyboardCommands { + togglePlaying(): void + previousFrame(): void + nextFrame(): void + firstFrame(): void + lastFrame(): void + toggleLoop(): void + playLeft(): void + playRight(): void + stopHorizontal(): void + playJump(): void + playCrouch(): void +} + +function isTextEditingTarget(target: EventTarget | null): boolean { + if (!(target instanceof HTMLElement)) return false + + if (target.closest('input, textarea, select') !== null) return true + + const editableAncestor = target.closest('[contenteditable]') + return ( + editableAncestor !== null && + editableAncestor.getAttribute('contenteditable')?.toLowerCase() !== 'false' + ) +} + +function isButtonTarget(target: EventTarget | null): boolean { + return target instanceof HTMLElement && target.closest('button') !== null +} + +export function usePlaytestKeyboard(commands: PlaytestKeyboardCommands, enabled: boolean): void { + const heldHorizontalKeys = useRef(new Set<'a' | 'd'>()) + + useEffect(() => { + if (!enabled) return + + const onKeyDown = (event: KeyboardEvent) => { + const key = event.key.length === 1 ? event.key.toLowerCase() : event.key + if (isTextEditingTarget(event.target)) return + if (isButtonTarget(event.target) && key !== 'a' && key !== 'd' && key !== 'w' && key !== 's') + return + + const command = { + ' ': commands.togglePlaying, + ArrowLeft: commands.previousFrame, + ArrowRight: commands.nextFrame, + Home: commands.firstFrame, + End: commands.lastFrame, + l: commands.toggleLoop, + a: commands.playLeft, + d: commands.playRight, + w: commands.playJump, + s: commands.playCrouch, + }[key] + + if (command === undefined) return + + event.preventDefault() + if (event.repeat && (key === ' ' || key === 'l' || key === 'w' || key === 's')) { + return + } + if (key === 'a' || key === 'd') heldHorizontalKeys.current.add(key) + command() + } + + const stopHorizontal = () => { + if (heldHorizontalKeys.current.size === 0) return + heldHorizontalKeys.current.clear() + commands.stopHorizontal() + } + + const onKeyUp = (event: KeyboardEvent) => { + const key = event.key.length === 1 ? event.key.toLowerCase() : event.key + if (key !== 'a' && key !== 'd') return + if (!heldHorizontalKeys.current.delete(key)) return + event.preventDefault() + if (heldHorizontalKeys.current.size === 0) commands.stopHorizontal() + } + + window.addEventListener('keydown', onKeyDown) + window.addEventListener('keyup', onKeyUp) + window.addEventListener('blur', stopHorizontal) + return () => { + window.removeEventListener('keydown', onKeyDown) + window.removeEventListener('keyup', onKeyUp) + window.removeEventListener('blur', stopHorizontal) + } + }, [commands, enabled]) +} From 8f9abeb48d7607d83c318565b0c82a2174780bcf Mon Sep 17 00:00:00 2001 From: xyh202131 <246811510+xyh202131@users.noreply.github.com> Date: Tue, 4 Aug 2026 14:41:22 +0800 Subject: [PATCH 3/5] feat(playtest): integrate catalog and review workbench --- frontend/src/app/app.test.tsx | 20 + frontend/src/app/app.tsx | 28 +- frontend/src/app/layout/index.tsx | 3 + frontend/src/pages/playtest/catalog.test.tsx | 279 ++++++++ frontend/src/pages/playtest/catalog.tsx | 659 ++++++++++++++++++ frontend/src/pages/playtest/index.test.tsx | 180 +++++ frontend/src/pages/playtest/index.tsx | 173 ++++- frontend/src/pages/playtest/path.test.ts | 15 + frontend/src/pages/playtest/path.ts | 16 + .../playtest/playtest-boundaries.test.ts | 116 +++ .../pages/playtest/workbench/acceptance.tsx | 81 +++ .../playtest/workbench/action-selector.tsx | 208 ++++++ .../workbench/animation-stage.test.tsx | 184 +++++ .../playtest/workbench/animation-stage.tsx | 189 +++++ .../workbench/audit/audit-panel.test.tsx | 106 +++ .../playtest/workbench/audit/audit-panel.tsx | 189 +++++ .../workbench/frame-review-evidence.test.tsx | 244 +++++++ .../workbench/frame-review-evidence.tsx | 263 +++++++ .../playtest/workbench/frame-timeline.tsx | 58 ++ .../pages/playtest/workbench/index.test.tsx | 227 ++++++ .../src/pages/playtest/workbench/index.tsx | 426 +++++++++++ .../pages/playtest/workbench/inspector.tsx | 64 ++ .../playtest/workbench/playback-controls.tsx | 94 +++ .../pages/playtest/workbench/status-panel.tsx | 26 + .../workbench/use-playtest-inspection.ts | 89 +++ 25 files changed, 3927 insertions(+), 10 deletions(-) create mode 100644 frontend/src/app/app.test.tsx create mode 100644 frontend/src/pages/playtest/catalog.test.tsx create mode 100644 frontend/src/pages/playtest/catalog.tsx create mode 100644 frontend/src/pages/playtest/index.test.tsx create mode 100644 frontend/src/pages/playtest/path.test.ts create mode 100644 frontend/src/pages/playtest/path.ts create mode 100644 frontend/src/pages/playtest/playtest-boundaries.test.ts create mode 100644 frontend/src/pages/playtest/workbench/acceptance.tsx create mode 100644 frontend/src/pages/playtest/workbench/action-selector.tsx create mode 100644 frontend/src/pages/playtest/workbench/animation-stage.test.tsx create mode 100644 frontend/src/pages/playtest/workbench/animation-stage.tsx create mode 100644 frontend/src/pages/playtest/workbench/audit/audit-panel.test.tsx create mode 100644 frontend/src/pages/playtest/workbench/audit/audit-panel.tsx create mode 100644 frontend/src/pages/playtest/workbench/frame-review-evidence.test.tsx create mode 100644 frontend/src/pages/playtest/workbench/frame-review-evidence.tsx create mode 100644 frontend/src/pages/playtest/workbench/frame-timeline.tsx create mode 100644 frontend/src/pages/playtest/workbench/index.test.tsx create mode 100644 frontend/src/pages/playtest/workbench/index.tsx create mode 100644 frontend/src/pages/playtest/workbench/inspector.tsx create mode 100644 frontend/src/pages/playtest/workbench/playback-controls.tsx create mode 100644 frontend/src/pages/playtest/workbench/status-panel.tsx create mode 100644 frontend/src/pages/playtest/workbench/use-playtest-inspection.ts diff --git a/frontend/src/app/app.test.tsx b/frontend/src/app/app.test.tsx new file mode 100644 index 00000000..a1301f0b --- /dev/null +++ b/frontend/src/app/app.test.tsx @@ -0,0 +1,20 @@ +// @vitest-environment jsdom +import { cleanup, render, screen } from '@testing-library/react' +import { afterEach, describe, expect, it } from 'vitest' + +import { App } from './app' + +afterEach(() => { + cleanup() + window.history.replaceState({}, '', '/') +}) + +describe('App Playtest route', () => { + it('routes /playtest to the standalone Playtest catalog', () => { + window.history.replaceState({}, '', '/playtest') + + render() + + expect(screen.getByRole('heading', { name: 'Playtest' })).toBeTruthy() + }) +}) diff --git a/frontend/src/app/app.tsx b/frontend/src/app/app.tsx index 06268ead..60093f48 100644 --- a/frontend/src/app/app.tsx +++ b/frontend/src/app/app.tsx @@ -1,21 +1,46 @@ +import { useMemo } from 'react' import { BrowserRouter, Route, Routes } from 'react-router' +import { createCharacterApis, createPlaytestInspectionApis, createProjectApis } from '@/entities' import { AssetLibraryPage } from '@/pages/asset-library' import { HomePage } from '@/pages/home' import { NotFoundPage } from '@/pages/not-found' import { PlaytestPage } from '@/pages/playtest' +import { PlaytestCatalogPage } from '@/pages/playtest/catalog' import { ProjectDetailPage } from '@/pages/project-detail' import { ProjectsPage } from '@/pages/projects' import { QuickStartPage } from '@/pages/quick-start' import { WorkflowEditorPage } from '@/pages/workflow-editor' import { AppShellRoute } from './layout' +/** + * 详情页使用后端适配器读取角色、项目画布和当前核验结论。 + * 适配器只在路由边界组装一次,Playtest 页面不需要知道 HTTP 客户端的创建方式。 + */ +function PlaytestFromBackend() { + const apis = useMemo( + () => ({ + characters: createCharacterApis(), + projects: createProjectApis(), + inspections: createPlaytestInspectionApis(), + }), + [], + ) + + return +} + /** * 路由表与全局外壳。 * 页面自己获取所需数据,不再由 app 层构造服务后逐层传入。 * 外壳的边界画在这张表上:根路由是满幅首屏,自带入口卡片,不进外壳;其余页面共用常驻导航。 */ export function App() { + const catalogApis = useMemo( + () => ({ projects: createProjectApis(), characters: createCharacterApis() }), + [], + ) + return ( @@ -28,7 +53,8 @@ export function App() { } /> } /> } /> - } /> + } /> + } /> } /> diff --git a/frontend/src/app/layout/index.tsx b/frontend/src/app/layout/index.tsx index b797b339..8da4c469 100644 --- a/frontend/src/app/layout/index.tsx +++ b/frontend/src/app/layout/index.tsx @@ -24,6 +24,9 @@ export function AppShell({ children }: AppShellProps) { 项目 + + Playtest + {/* 外壳只管顶栏。页面自己决定宽度与留白,不在这里统一夹到屏幕中间。 */} diff --git a/frontend/src/pages/playtest/catalog.test.tsx b/frontend/src/pages/playtest/catalog.test.tsx new file mode 100644 index 00000000..f4155085 --- /dev/null +++ b/frontend/src/pages/playtest/catalog.test.tsx @@ -0,0 +1,279 @@ +/** @vitest-environment jsdom */ +import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react' +import { MemoryRouter } from 'react-router' +import { afterEach, describe, expect, it, vi } from 'vitest' + +import type { Character } from '@/entities/character' +import type { Project } from '@/entities/project' + +import { PlaytestCatalogPage, type PlaytestCatalogApis } from './catalog' + +const project: Project = { + id: '37', + ownerId: '1', + name: '灯笼守夜人', + perspective: 'side', + directionalMovement: 'single', + spriteSize: { width: 256, height: 256 }, + gameStyle: null, + sampleImageUrl: null, + createdAt: '2026-08-03T00:00:00.000Z', + updatedAt: '2026-08-03T00:00:00.000Z', +} + +const targetProject: Project = { + ...project, + id: '38', + name: '第二个项目', +} + +const character: Character = { + id: '25', + projectId: project.id, + createdAt: '', + updatedAt: '', + outfits: [ + { + id: 'outfit-25-default', + characterId: '25', + name: '默认造型', + candidateCharacterTemplates: [], + characterTemplateUrl: 'https://cdn.example.test/character.png', + baseFrames: [], + actions: [ + { + id: '25-custom', + outfitId: 'outfit-25-default', + name: '挥舞灯笼', + kind: 'custom', + type: 'custom', + fps: 8, + keyFrameIndex: 0, + frames: [ + { imageUrl: 'https://cdn.example.test/frame.png', durationMs: 125, rootMotion: null }, + ], + }, + ], + }, + ], +} + +function renderCatalog(apis: PlaytestCatalogApis) { + render( + + + , + ) +} + +afterEach(() => { + cleanup() + vi.restoreAllMocks() +}) + +describe('PlaytestCatalogPage', () => { + it('loads published assets across projects and links directly to each action', async () => { + const apis: PlaytestCatalogApis = { + projects: { + list: vi.fn().mockResolvedValue({ items: [project], total: 1, page: 1, pageSize: 1 }), + remove: vi.fn(), + }, + characters: { + listByProject: vi.fn().mockResolvedValue([character]), + update: vi.fn(), + remove: vi.fn(), + }, + } + + renderCatalog(apis) + + expect(await screen.findByRole('heading', { name: 'Playtest' })).toBeTruthy() + expect(screen.getByRole('link', { name: '返回项目' }).getAttribute('href')).toBe('/projects') + expect(screen.getByRole('heading', { name: '灯笼守夜人' })).toBeTruthy() + expect(screen.getByText('挥舞灯笼')).toBeTruthy() + expect(screen.getByRole('link', { name: '载入挥舞灯笼' }).getAttribute('href')).toBe( + '/playtest/25/outfit-25-default?actionId=25-custom', + ) + }) + + it('does not treat an outfit without actions as an importable asset', async () => { + const emptyCharacter: Character = { + ...character, + outfits: [{ ...character.outfits[0]!, actions: [] }], + } + renderCatalog({ + projects: { + list: vi.fn().mockResolvedValue({ items: [project], total: 1, page: 1, pageSize: 1 }), + remove: vi.fn(), + }, + characters: { + listByProject: vi.fn().mockResolvedValue([emptyCharacter]), + update: vi.fn(), + remove: vi.fn(), + }, + }) + + expect(await screen.findByText('空文件夹')).toBeTruthy() + }) + + it('opens projects as folders and persists a dragged character in the target project', async () => { + const update = vi.fn().mockImplementation(async (next: Character) => next) + renderCatalog({ + projects: { + list: vi.fn().mockResolvedValue({ + items: [project, targetProject], + total: 2, + page: 1, + pageSize: 2, + }), + remove: vi.fn(), + }, + characters: { + listByProject: vi + .fn() + .mockImplementation(async (projectId: string) => + projectId === project.id ? [character] : [], + ), + update, + remove: vi.fn(), + }, + }) + + const cardTitle = await screen.findByText('默认造型') + const card = cardTitle.closest('article') + const targetFolder = screen.getByRole('region', { name: '第二个项目项目文件夹' }) + const transfer = { + effectAllowed: '', + setData: vi.fn(), + getData: vi.fn().mockReturnValue(character.id), + } + + fireEvent.dragStart(card!, { dataTransfer: transfer }) + fireEvent.dragEnter(targetFolder, { dataTransfer: transfer }) + fireEvent.drop(targetFolder, { dataTransfer: transfer }) + + await waitFor(() => expect(update).toHaveBeenCalled()) + expect(update.mock.calls[0]?.[0].projectId).toBe(targetProject.id) + expect(await screen.findByText('已将角色资产移动到“第二个项目”')).toBeTruthy() + }) + + it('does not report a move as successful when the backend keeps the old project', async () => { + renderCatalog({ + projects: { + list: vi.fn().mockResolvedValue({ + items: [project, targetProject], + total: 2, + page: 1, + pageSize: 2, + }), + remove: vi.fn(), + }, + characters: { + listByProject: vi + .fn() + .mockImplementation(async (projectId: string) => + projectId === project.id ? [character] : [], + ), + update: vi.fn().mockResolvedValue(character), + remove: vi.fn(), + }, + }) + + const card = (await screen.findByText('默认造型')).closest('article') + const targetFolder = screen.getByRole('region', { name: '第二个项目项目文件夹' }) + const transfer = { + effectAllowed: '', + setData: vi.fn(), + getData: vi.fn().mockReturnValue(character.id), + } + + fireEvent.dragStart(card!, { dataTransfer: transfer }) + fireEvent.drop(targetFolder, { dataTransfer: transfer }) + + expect(await screen.findByText('后端未保存新的项目归属')).toBeTruthy() + expect(screen.queryByText('已将角色资产移动到“第二个项目”')).toBeNull() + expect(screen.getByRole('heading', { name: project.name })).toBeTruthy() + }) + + it('renames an asset and an action through the character update API', async () => { + const update = vi.fn().mockImplementation(async (next: Character) => next) + renderCatalog({ + projects: { + list: vi.fn().mockResolvedValue({ items: [project], total: 1, page: 1, pageSize: 1 }), + remove: vi.fn(), + }, + characters: { + listByProject: vi.fn().mockResolvedValue([character]), + update, + remove: vi.fn(), + }, + }) + + await screen.findByText('默认造型') + fireEvent.click(screen.getByRole('button', { name: '重命名资产名称 默认造型' })) + fireEvent.change(screen.getByRole('textbox', { name: '资产名称' }), { + target: { value: '夜巡造型' }, + }) + fireEvent.click(screen.getByRole('button', { name: '保存资产名称' })) + + await waitFor(() => expect(update).toHaveBeenCalledTimes(1)) + expect(update.mock.calls[0]?.[0].outfits[0].name).toBe('夜巡造型') + + fireEvent.click(screen.getByRole('button', { name: '重命名动作名称 挥舞灯笼' })) + fireEvent.change(screen.getByRole('textbox', { name: '动作名称' }), { + target: { value: '举起灯笼' }, + }) + fireEvent.click(screen.getByRole('button', { name: '保存动作名称' })) + + await waitFor(() => expect(update).toHaveBeenCalledTimes(2)) + expect(update.mock.calls[1]?.[0].outfits[0].actions[0].name).toBe('举起灯笼') + }) + + it('deletes a single asset after confirmation', async () => { + vi.spyOn(window, 'confirm').mockReturnValue(true) + const remove = vi.fn().mockResolvedValue(undefined) + renderCatalog({ + projects: { + list: vi.fn().mockResolvedValue({ items: [project], total: 1, page: 1, pageSize: 1 }), + remove: vi.fn(), + }, + characters: { + listByProject: vi.fn().mockResolvedValue([character]), + update: vi.fn(), + remove, + }, + }) + + await screen.findByText('默认造型') + fireEvent.click(screen.getByRole('button', { name: '删除资产 默认造型' })) + + await waitFor(() => expect(remove).toHaveBeenCalledWith(character.id)) + expect(await screen.findByText('已删除资产“默认造型”')).toBeTruthy() + expect(screen.queryByText('默认造型')).toBeNull() + }) + + it('delegates atomic project cleanup to one backend request', async () => { + vi.spyOn(window, 'confirm').mockReturnValue(true) + const removeCharacter = vi.fn().mockResolvedValue(undefined) + const removeProject = vi.fn().mockResolvedValue(undefined) + renderCatalog({ + projects: { + list: vi.fn().mockResolvedValue({ items: [project], total: 1, page: 1, pageSize: 1 }), + remove: removeProject, + }, + characters: { + listByProject: vi.fn().mockResolvedValue([character]), + update: vi.fn(), + remove: removeCharacter, + }, + }) + + await screen.findByText('默认造型') + fireEvent.click(screen.getByRole('button', { name: `删除项目文件夹 ${project.name}` })) + + await waitFor(() => expect(removeProject).toHaveBeenCalledWith(project.id)) + expect(removeCharacter).not.toHaveBeenCalled() + expect(await screen.findByText(`已删除项目“${project.name}”`)).toBeTruthy() + expect(screen.queryByRole('region', { name: `${project.name}项目文件夹` })).toBeNull() + }) +}) diff --git a/frontend/src/pages/playtest/catalog.tsx b/frontend/src/pages/playtest/catalog.tsx new file mode 100644 index 00000000..d027d96a --- /dev/null +++ b/frontend/src/pages/playtest/catalog.tsx @@ -0,0 +1,659 @@ +import { type DragEvent, type FormEvent, type ReactNode, useEffect, useState } from 'react' +import { Link } from 'react-router' + +import type { Action, Character, CharacterApis, Outfit } from '@/entities/character' +import type { Project, ProjectApis } from '@/entities/project' +import { buildPlaytestPath } from './path' + +export interface PlaytestCatalogApis { + projects: Pick + characters: Pick +} + +interface CatalogAsset { + project: Project + character: Character + outfit: Outfit +} + +interface CatalogState { + projects: Project[] + assets: CatalogAsset[] + error: string | null + loading: boolean +} + +interface CatalogData { + projects: Project[] + assets: CatalogAsset[] +} + +const INITIAL_STATE: CatalogState = { projects: [], assets: [], error: null, loading: true } + +/** Playtest 入口按项目组织可核验角色,移动和改名通过 Character 整树接口持久化。 */ +export function PlaytestCatalogPage({ apis }: { apis: PlaytestCatalogApis }) { + const [state, setState] = useState(INITIAL_STATE) + const [reloadKey, setReloadKey] = useState(0) + const [selectedProjectId, setSelectedProjectId] = useState(null) + const [busyCharacterIds, setBusyCharacterIds] = useState>(new Set()) + const [busyProjectIds, setBusyProjectIds] = useState>(new Set()) + const [draggedCharacterId, setDraggedCharacterId] = useState(null) + const [dropProjectId, setDropProjectId] = useState(null) + const [operationMessage, setOperationMessage] = useState(null) + + useEffect(() => { + let active = true + setState(INITIAL_STATE) + + void loadCatalog(apis).then( + ({ projects, assets }) => { + if (!active) return + setState({ projects, assets, error: null, loading: false }) + setSelectedProjectId((current) => + current && projects.some((project) => project.id === current) + ? current + : (projects[0]?.id ?? null), + ) + }, + (cause: unknown) => + active && + setState({ + projects: [], + assets: [], + error: errorMessage(cause, '资产加载失败'), + loading: false, + }), + ) + + return () => { + active = false + } + }, [apis, reloadKey]) + + const setCharacterBusy = (characterId: string, busy: boolean) => { + setBusyCharacterIds((current) => { + const next = new Set(current) + if (busy) next.add(characterId) + else next.delete(characterId) + return next + }) + } + + const persistCharacter = async (character: Character, successMessage: string) => { + setCharacterBusy(character.id, true) + setOperationMessage(null) + try { + const saved = await apis.characters.update(character) + if (saved.projectId !== character.projectId) { + throw new Error('后端未保存新的项目归属') + } + setState((current) => replaceCharacter(current, saved)) + setOperationMessage(successMessage) + } catch (cause) { + setOperationMessage(errorMessage(cause, '保存失败,请重试')) + throw cause + } finally { + setCharacterBusy(character.id, false) + } + } + + const moveCharacter = async (characterId: string, projectId: string) => { + const source = state.assets.find((asset) => asset.character.id === characterId) + const target = state.projects.find((project) => project.id === projectId) + if (!source || !target || source.character.projectId === projectId) return + + try { + await persistCharacter( + { ...source.character, projectId }, + `已将角色资产移动到“${target.name}”`, + ) + setSelectedProjectId(projectId) + } catch { + // persistCharacter 已在页面上保留具体错误。 + } + } + + const renameOutfit = async (character: Character, outfitId: string, name: string) => { + await persistCharacter( + { + ...character, + outfits: character.outfits.map((outfit) => + outfit.id === outfitId ? { ...outfit, name } : outfit, + ), + }, + `资产已改名为“${name}”`, + ) + } + + const renameAction = async ( + character: Character, + outfitId: string, + actionId: string, + name: string, + ) => { + await persistCharacter( + { + ...character, + outfits: character.outfits.map((outfit) => + outfit.id === outfitId + ? { + ...outfit, + actions: outfit.actions.map((action) => + action.id === actionId ? { ...action, name } : action, + ), + } + : outfit, + ), + }, + `动作已改名为“${name}”`, + ) + } + + const deleteAsset = async (character: Character, outfit: Outfit) => { + if (!window.confirm(`确定删除资产“${outfit.name}”吗?此操作无法撤销。`)) return + + setCharacterBusy(character.id, true) + setOperationMessage(null) + try { + if (character.outfits.length === 1) { + await apis.characters.remove(character.id) + setState((current) => ({ + ...current, + assets: current.assets.filter((asset) => asset.character.id !== character.id), + })) + } else { + const saved = await apis.characters.update({ + ...character, + outfits: character.outfits.filter((candidate) => candidate.id !== outfit.id), + }) + setState((current) => replaceCharacter(current, saved)) + } + setOperationMessage(`已删除资产“${outfit.name}”`) + } catch (cause) { + setOperationMessage(errorMessage(cause, '资产删除失败,请重试')) + } finally { + setCharacterBusy(character.id, false) + } + } + + const deleteProject = async (project: Project) => { + if (!window.confirm(`确定删除项目“${project.name}”及其中全部角色资产吗?此操作无法撤销。`)) { + return + } + + setBusyProjectIds((current) => new Set(current).add(project.id)) + setOperationMessage(null) + try { + await apis.projects.remove(project.id) + setState((current) => ({ + ...current, + projects: current.projects.filter((candidate) => candidate.id !== project.id), + assets: current.assets.filter((asset) => asset.project.id !== project.id), + })) + setSelectedProjectId((current) => + current === project.id + ? (state.projects.find((candidate) => candidate.id !== project.id)?.id ?? null) + : current, + ) + setOperationMessage(`已删除项目“${project.name}”`) + } catch (cause) { + setOperationMessage(errorMessage(cause, '项目删除失败,请重试')) + } finally { + setBusyProjectIds((current) => { + const next = new Set(current) + next.delete(project.id) + return next + }) + } + } + + const selectedProject = state.projects.find((project) => project.id === selectedProjectId) ?? null + const selectedAssets = selectedProject + ? state.assets.filter((asset) => asset.project.id === selectedProject.id) + : [] + + return ( +
+ + + 返回项目 + + +
+
+

+ PLAYTEST +

+

Playtest

+

选择项目和角色动作进行核验。

+
+ +
+ + {state.error ? {state.error} : null} + {operationMessage ? {operationMessage} : null} + + {!state.error && !state.loading && state.projects.length === 0 ? ( +
+

还没有项目。

+
+ ) : null} + +
+ + +
+ {selectedProject ? ( + <> +
+

当前文件夹

+
+

{selectedProject.name}

+ + {selectedAssets.length} 项资产 + +
+
+
+ {selectedAssets.length === 0 ? ( +

空文件夹

+ ) : ( + selectedAssets.map((asset) => ( + { + event.dataTransfer.effectAllowed = 'move' + event.dataTransfer.setData( + 'application/x-windup-character', + asset.character.id, + ) + setDraggedCharacterId(asset.character.id) + }} + onDragEnd={() => { + setDraggedCharacterId(null) + setDropProjectId(null) + }} + onRenameOutfit={(name) => + renameOutfit(asset.character, asset.outfit.id, name) + } + onRenameAction={(actionId, name) => + renameAction(asset.character, asset.outfit.id, actionId, name) + } + onDelete={() => deleteAsset(asset.character, asset.outfit)} + /> + )) + )} +
+ + ) : ( +

请选择项目文件夹

+ )} +
+
+
+ ) +} + +function AssetCard({ + asset, + busy, + onDragStart, + onDragEnd, + onRenameOutfit, + onRenameAction, + onDelete, +}: { + asset: CatalogAsset + busy: boolean + onDragStart(event: DragEvent): void + onDragEnd(): void + onRenameOutfit(name: string): Promise + onRenameAction(actionId: string, name: string): Promise + onDelete(): Promise +}) { + const { character, outfit } = asset + + return ( +
+
+ {outfit.characterTemplateUrl ? ( + {`${outfit.name} + ) : ( + 暂无角色图 + )} +
+
+
+

角色 {character.id}

+ +
+ +

{outfit.name}

+
+

{outfit.actions.length} 个动作

+
+ {outfit.actions.map((action) => ( + onRenameAction(action.id, name)} + /> + ))} +
+
+
+ ) +} + +function ActionRow({ + character, + outfit, + action, + disabled, + onRename, +}: { + character: Character + outfit: Outfit + action: Action + disabled: boolean + onRename(name: string): Promise +}) { + return ( + + + {action.name} + + {action.frames.length} 帧 + + + + ) +} + +function EditableName({ + value, + label, + disabled, + onSave, + className = '', + children, +}: { + value: string + label: string + disabled: boolean + onSave(value: string): Promise + className?: string + children: ReactNode +}) { + const [editing, setEditing] = useState(false) + const [draft, setDraft] = useState(value) + const [saving, setSaving] = useState(false) + const [error, setError] = useState(null) + + useEffect(() => setDraft(value), [value]) + + const submit = async (event: FormEvent) => { + event.preventDefault() + const name = draft.trim() + if (!name) { + setError('名称不能为空') + return + } + if (name === value) { + setEditing(false) + return + } + + setSaving(true) + setError(null) + try { + await onSave(name) + setEditing(false) + } catch (cause) { + setError(errorMessage(cause, '保存失败')) + } finally { + setSaving(false) + } + } + + if (editing) { + return ( +
void submit(event)} + onDragStart={(event) => event.stopPropagation()} + className={`grid min-w-0 grid-cols-[minmax(0,1fr)_32px_32px] gap-1 ${className}`} + > + setDraft(event.target.value)} + disabled={saving} + className="h-8 min-w-0 rounded-md border border-[#829087] px-2 text-xs outline-none focus:border-[#35583f]" + /> + + + {error ? {error} : null} +
+ ) + } + + return ( +
+ {children} + +
+ ) +} + +function CatalogAlert({ children, tone }: { children: string; tone: 'error' | 'status' }) { + return ( +
+

{children}

+
+ ) +} + +function replaceCharacter(state: CatalogState, character: Character): CatalogState { + const project = state.projects.find((candidate) => candidate.id === character.projectId) + if (!project) return state + + const otherAssets = state.assets.filter((asset) => asset.character.id !== character.id) + const updatedAssets = character.outfits + .filter((outfit) => outfit.actions.length > 0) + .map((outfit) => ({ project, character, outfit })) + + return { ...state, assets: [...otherAssets, ...updatedAssets] } +} + +function errorMessage(cause: unknown, fallback: string): string { + return cause instanceof Error && cause.message.trim() ? cause.message : fallback +} + +async function loadCatalog(apis: PlaytestCatalogApis): Promise { + const page = await apis.projects.list({ page: 1, pageSize: 100 }) + const charactersByProject = await Promise.all( + page.items.map(async (project) => ({ + project, + characters: await apis.characters.listByProject(project.id), + })), + ) + + return { + projects: page.items, + assets: charactersByProject.flatMap(({ project, characters }) => + characters.flatMap((character) => + character.outfits + .filter((outfit) => outfit.actions.length > 0) + .map((outfit) => ({ project, character, outfit })), + ), + ), + } +} diff --git a/frontend/src/pages/playtest/index.test.tsx b/frontend/src/pages/playtest/index.test.tsx new file mode 100644 index 00000000..c661dbad --- /dev/null +++ b/frontend/src/pages/playtest/index.test.tsx @@ -0,0 +1,180 @@ +/** @vitest-environment jsdom */ +import { act, cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react' +import { MemoryRouter, Route, Routes, useNavigate, type NavigateFunction } from 'react-router' +import { afterEach, describe, expect, it, vi } from 'vitest' + +import type { Character } from '@/entities/character' + +import { PlaytestPage, type PlaytestPageApis } from './index' + +const character: Character = { + id: 'character-1', + projectId: 'project-1', + createdAt: '2026-07-30T00:00:00.000Z', + updatedAt: '2026-07-30T00:00:00.000Z', + outfits: [ + { + id: 'outfit-1', + characterId: 'character-1', + name: 'Explorer', + candidateCharacterTemplates: [], + characterTemplateUrl: 'https://cdn.example.test/aster.png', + baseFrames: [{ imageUrl: 'https://cdn.example.test/base.png' }], + actions: [ + { + id: 'idle', + outfitId: 'outfit-1', + name: 'Idle', + kind: 'preset', + type: 'idle', + fps: 8, + keyFrameIndex: 0, + frames: [ + { + imageUrl: 'https://cdn.example.test/idle.png', + durationMs: 125, + rootMotion: null, + }, + ], + }, + ], + }, + ], +} + +function renderPage(apis?: PlaytestPageApis, initialEntry = '/playtest/character-1/outfit-1') { + render( + + + } /> + + , + ) +} + +afterEach(() => cleanup()) + +describe('PlaytestPage', () => { + it('shows an explicit unconfigured boundary instead of inventing character data', () => { + renderPage() + + expect(screen.getByText('Playtest 角色接口尚未配置')).toBeTruthy() + }) + + it('loads the requested character through the standard skeleton API only', async () => { + const apis: PlaytestPageApis = { + characters: { + get: vi.fn().mockResolvedValue(character), + listByProject: vi.fn().mockResolvedValue([character]), + }, + } + + renderPage(apis, '/playtest/character-1/outfit-1?actionId=idle') + + expect(screen.getByText('加载 Playtest 数据中')).toBeTruthy() + expect(await screen.findByRole('heading', { name: 'character-1 · Explorer' })).toBeTruthy() + expect(apis.characters.get).toHaveBeenCalledExactlyOnceWith('character-1') + }) + + it.each([{ code: 404 }, { status: 404 }])( + 'maps a missing character response to a stable message', + async (error) => { + renderPage({ + characters: { get: vi.fn().mockRejectedValue(error), listByProject: vi.fn() }, + }) + + expect(await screen.findByText('角色不存在')).toBeTruthy() + }, + ) + + it('does not mislabel a transport failure as not found', async () => { + renderPage({ + characters: { + get: vi.fn().mockRejectedValue(new Error('network unavailable')), + listByProject: vi.fn(), + }, + }) + + expect(await screen.findByText('角色读取失败')).toBeTruthy() + }) + + it('ignores a stale character response after the route identity changes', async () => { + let resolveFirst: ((value: Character) => void) | undefined + const firstRequest = new Promise((resolve) => { + resolveFirst = resolve + }) + const secondCharacter: Character = { + ...character, + id: 'character-2', + outfits: [{ ...character.outfits[0], characterId: 'character-2' }], + } + const get = vi.fn().mockReturnValueOnce(firstRequest).mockResolvedValueOnce(secondCharacter) + const listByProject = vi.fn().mockResolvedValue([character, secondCharacter]) + let navigate: NavigateFunction | undefined + + function NavigationProbe() { + navigate = useNavigate() + return null + } + + render( + + + + } + /> + + , + ) + + await act(async () => navigate?.('/playtest/character-2/outfit-1')) + expect(await screen.findByRole('heading', { name: 'character-2 · Explorer' })).toBeTruthy() + + await act(async () => resolveFirst?.(character)) + await waitFor(() => + expect(screen.getByRole('heading', { name: 'character-2 · Explorer' })).toBeTruthy(), + ) + expect(get).toHaveBeenNthCalledWith(1, 'character-1') + expect(get).toHaveBeenNthCalledWith(2, 'character-2') + }) + + it('switches assets from the action sidebar without returning to the catalog', async () => { + const secondCharacter: Character = { + ...character, + id: 'character-2', + outfits: [ + { + ...character.outfits[0]!, + id: 'outfit-2', + characterId: 'character-2', + name: 'Knight', + actions: character.outfits[0]!.actions.map((action) => ({ + ...action, + outfitId: 'outfit-2', + name: 'Guard', + })), + }, + ], + } + const get = vi + .fn() + .mockImplementation(async (id: string) => + id === secondCharacter.id ? secondCharacter : character, + ) + const listByProject = vi.fn().mockResolvedValue([character, secondCharacter]) + + renderPage({ characters: { get, listByProject } }) + + const selector = await screen.findByRole('combobox', { name: '同项目资产' }) + fireEvent.click(selector) + const options = screen.getAllByRole('option') + expect(options).toHaveLength(2) + fireEvent.click(screen.getByRole('option', { name: /Knight/ })) + + expect(await screen.findByRole('heading', { name: 'character-2 · Knight' })).toBeTruthy() + expect(screen.getByRole('button', { name: /Guard/ })).toBeTruthy() + expect(get).toHaveBeenLastCalledWith('character-2') + }) +}) diff --git a/frontend/src/pages/playtest/index.tsx b/frontend/src/pages/playtest/index.tsx index 8e5827d3..6c29cac8 100644 --- a/frontend/src/pages/playtest/index.tsx +++ b/frontend/src/pages/playtest/index.tsx @@ -1,13 +1,168 @@ -import { PageContainer } from '@/shared/ui' +import { useEffect, useState } from 'react' +import { Link, useNavigate, useParams, useSearchParams } from 'react-router' -/** 核验台。 */ -export function PlaytestPage() { +import type { Character, CharacterApis } from '@/entities/character' +import type { PlaytestInspectionApis } from '@/entities/playtest-inspection' +import type { ProjectApis } from '@/entities/project' +import { buildPlaytestPath } from './path' +import { PlaytestWorkbench, type PlaytestAssetOption } from './workbench' + +export interface PlaytestPageApis { + characters: Pick + projects?: Pick + inspections?: Pick +} + +export interface PlaytestPageProps { + apis?: PlaytestPageApis +} + +interface PageData { + character: Character | null + projectCharacters: Character[] + expectedCanvas: { width: number; height: number } | null + error: string | null + loading: boolean +} + +const initialPageData: PageData = { + character: null, + projectCharacters: [], + expectedCanvas: null, + error: null, + loading: false, +} + +function isNotFoundError(error: unknown): boolean { + if (typeof error !== 'object' || error === null) return false + const identifiable = error as { code?: unknown; status?: unknown } + return ( + identifiable.code === 404 || + identifiable.code === '404' || + identifiable.status === 404 || + identifiable.status === '404' + ) +} + +/** + * 正式 Playtest 页面只读取 #70 已定义的 Character 接口。 + * 当接口未配置时,自动回退到内置 demo 角色素材。 + * 核验与自动分析结果均停留在页面会话,不写回资产树。 + */ +export function PlaytestPage({ apis }: PlaytestPageProps) { + const { characterId, outfitId } = useParams() + const [searchParams] = useSearchParams() + const navigate = useNavigate() + const initialActionId = searchParams.get('actionId') + const [data, setData] = useState(initialPageData) + + useEffect(() => { + // 正式入口未配置角色接口时明确提示,不加载也不回退到 Demo 少年数据 + if (apis === undefined) { + setData({ + character: null, + projectCharacters: [], + expectedCanvas: null, + error: 'Playtest 角色接口尚未配置', + loading: false, + }) + return + } + if (characterId === undefined || outfitId === undefined) { + setData({ ...initialPageData, error: 'Playtest 路由参数不完整' }) + return + } + + let cancelled = false + setData({ ...initialPageData, loading: true }) + void apis.characters.get(characterId).then( + async (character) => { + let projectCharacters = [character] + let expectedCanvas: PageData['expectedCanvas'] = null + const [charactersResult, projectResult] = await Promise.allSettled([ + apis.characters.listByProject(character.projectId), + apis.projects?.get(character.projectId) ?? Promise.resolve(null), + ]) + if (charactersResult.status === 'fulfilled') projectCharacters = charactersResult.value + if (projectResult.status === 'fulfilled' && projectResult.value !== null) { + expectedCanvas = projectResult.value.spriteSize + } + if (!cancelled) { + setData({ character, projectCharacters, expectedCanvas, error: null, loading: false }) + } + }, + (error: unknown) => { + if (!cancelled) { + setData({ + ...initialPageData, + error: isNotFoundError(error) ? '角色不存在' : '角色读取失败', + }) + } + }, + ) + + return () => { + cancelled = true + } + }, [apis, characterId, outfitId]) + + if (data.error !== null) return {data.error} + if (data.loading || data.character === null) + return 加载 Playtest 数据中 + + const assetOptions = buildAssetOptions(data.projectCharacters) + + return ( +
+
+ + + 全部 Playtest + +
+ + navigate( + buildPlaytestPath({ + characterId: asset.characterId, + outfitId: asset.outfitId, + }), + ) + } + /> +
+ ) +} + +function buildAssetOptions(characters: Character[]): PlaytestAssetOption[] { + return characters.flatMap((character) => + character.outfits + .filter((outfit) => outfit.actions.length > 0) + .map((outfit) => ({ + key: `${character.id}:${outfit.id}`, + characterId: character.id, + outfitId: outfit.id, + name: `${outfit.name}(角色 ${character.id})`, + actionCount: outfit.actions.length, + })), + ) +} + +function PlaytestPageMessage({ children }: { children: string }) { return ( - -
-

核验台

-

本次只提交模块划分与接口,页面实现进后续 PR。

-
-
+
+

{children}

+
) } diff --git a/frontend/src/pages/playtest/path.test.ts b/frontend/src/pages/playtest/path.test.ts new file mode 100644 index 00000000..5615463e --- /dev/null +++ b/frontend/src/pages/playtest/path.test.ts @@ -0,0 +1,15 @@ +import { describe, expect, it } from 'vitest' + +import { buildPlaytestPath } from './path' + +describe('buildPlaytestPath', () => { + it('encodes resource identities and keeps the selected action in the query string', () => { + expect( + buildPlaytestPath({ + characterId: 'character/1', + outfitId: 'default outfit', + actionId: 'walk left', + }), + ).toBe('/playtest/character%2F1/default%20outfit?actionId=walk+left') + }) +}) diff --git a/frontend/src/pages/playtest/path.ts b/frontend/src/pages/playtest/path.ts new file mode 100644 index 00000000..dc0bd3ac --- /dev/null +++ b/frontend/src/pages/playtest/path.ts @@ -0,0 +1,16 @@ +interface PlaytestPathTarget { + characterId: string + outfitId: string + actionId?: string +} + +/** + * 统一生成 Playtest 地址,避免目录页和工作台各自拼接路径。 + * actionId 放在查询参数中,因为它只决定当前预览动作,不改变角色和造型的资源身份。 + */ +export function buildPlaytestPath({ characterId, outfitId, actionId }: PlaytestPathTarget): string { + const pathname = `/playtest/${encodeURIComponent(characterId)}/${encodeURIComponent(outfitId)}` + if (actionId === undefined) return pathname + + return `${pathname}?${new URLSearchParams({ actionId }).toString()}` +} 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..c4ad9f6b --- /dev/null +++ b/frontend/src/pages/playtest/playtest-boundaries.test.ts @@ -0,0 +1,116 @@ +/// + +import { readdirSync, readFileSync } from 'node:fs' +import { fileURLToPath } from 'node:url' +import { dirname, isAbsolute, join, relative, resolve } from 'node:path' +import { describe, expect, it } from 'vitest' + +const playtestDirectory = fileURLToPath(new URL('.', import.meta.url)) +const entitiesDirectory = fileURLToPath(new URL('../../entities/', import.meta.url)) +const prohibitedImports = [ + 'live-demo-ui-components', + 'entities/workflow-run', + 'entities/generation', +] as const +const allowedEntityImports = [ + '@/entities/character', + '@/entities/playtest-inspection', + '@/entities/project', +] as const + +function sourceFiles(directory: string): readonly string[] { + return readdirSync(directory, { withFileTypes: true }).flatMap((entry) => { + const path = join(directory, entry.name) + if (entry.isDirectory()) return sourceFiles(path) + if (!entry.isFile() || !/\.(?:ts|tsx)$/.test(entry.name) || entry.name.includes('.test.')) { + return [] + } + return [path] + }) +} + +function allTypeScriptFiles(directory: string): readonly string[] { + return readdirSync(directory, { withFileTypes: true }).flatMap((entry) => { + const path = join(directory, entry.name) + if (entry.isDirectory()) return allTypeScriptFiles(path) + if (!entry.isFile() || !/\.(?:ts|tsx)$/.test(entry.name)) return [] + return [path] + }) +} + +function moduleSpecifiers(source: string): readonly string[] { + return [...source.matchAll(/(?:from\s+|import\s*)["']([^"']+)["']/g)].map( + (match) => match[1] ?? '', + ) +} + +function entityEntry(file: string, dependency: string): string | null { + if (dependency === '@/entities') return '' + if (dependency.startsWith('@/entities/')) return dependency.slice('@/entities/'.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 architecture boundaries', () => { + it('does not import forbidden application or live-demo implementation layers', () => { + // Catches the isolated Playtest slice reaching into unrelated product layers. + for (const file of sourceFiles(playtestDirectory)) { + const source = readFileSync(file, 'utf8') + for (const fragment of prohibitedImports) { + expect(source, `${file} must not contain ${fragment}`).not.toContain(fragment) + } + } + }) + + it('does not import application or capabilities roots or subpaths', () => { + // Catches bypassing the isolated Page through either a barrel or a deep implementation path. + for (const file of sourceFiles(playtestDirectory)) { + const dependencies = moduleSpecifiers(readFileSync(file, 'utf8')) + expect( + dependencies.filter( + (dependency) => + dependency === '@/application' || + dependency.startsWith('@/application/') || + dependency === '@/capabilities' || + dependency.startsWith('@/capabilities/'), + ), + `${file} must not import application or capabilities`, + ).toEqual([]) + } + }) + + it('imports Entity contracts only from the approved direct entrypoints', () => { + // Catches alias or relative root barrels and deep paths that hide Playtest's dependencies. + for (const file of sourceFiles(playtestDirectory)) { + const entityImports = moduleSpecifiers(readFileSync(file, 'utf8')) + .map((dependency) => ({ dependency, entry: entityEntry(file, dependency) })) + .filter((candidate) => candidate.entry !== null) + + for (const { dependency, entry } of entityImports) { + expect(allowedEntityImports, `${file} imports ${dependency}`).toContain( + `@/entities/${entry}`, + ) + } + } + }) + + it('keeps the demo fixture out of formal Playtest source', () => { + // Catches a formal route silently importing the demo character as an API fallback. + const importers = allTypeScriptFiles(playtestDirectory).filter((file) => + readFileSync(file, 'utf8').includes('testing/demo-character'), + ) + + expect( + importers.every( + (file) => file === join(playtestDirectory, 'demo-page.tsx') || file.includes('.test.'), + ), + ).toBe(true) + expect(readFileSync(join(playtestDirectory, 'index.tsx'), 'utf8')).not.toContain( + 'testing/demo-character', + ) + }) +}) diff --git a/frontend/src/pages/playtest/workbench/acceptance.tsx b/frontend/src/pages/playtest/workbench/acceptance.tsx new file mode 100644 index 00000000..24649c7b --- /dev/null +++ b/frontend/src/pages/playtest/workbench/acceptance.tsx @@ -0,0 +1,81 @@ +import { StatusPanel } from './status-panel' + +import type { PlaytestInspectionStatus } from '@/entities/playtest-inspection' + +export interface AcceptanceProps { + inspectionStatus: PlaytestInspectionStatus | null + available: boolean + canPass: boolean + loading: boolean + saving: boolean + error: string | null + onRecordStatus(status: PlaytestInspectionStatus): Promise +} + +function statusText(status: PlaytestInspectionStatus | null): string { + if (status === 'passed') return '通过' + if (status === 'issues_found') return '发现问题' + return '尚未核验' +} + +/** Playtest 自己的动作核验结论,不写回 Character 或创作历史。 */ +export function Acceptance({ + inspectionStatus, + available, + canPass, + loading, + saving, + error, + onRecordStatus, +}: AcceptanceProps) { + const detail = !available + ? '当前预览未连接核验记录' + : loading + ? '正在读取核验记录' + : saving + ? '正在保存核验记录' + : error + ? error + : inspectionStatus === null + ? canPass + ? '尚未保存核验结论' + : '当前帧尚未成功加载,不能标记通过' + : '已保存到 Playtest 核验记录' + + return ( +
+
+

ACCEPTANCE

+

本次核验

+
+ +

{statusText(inspectionStatus)}

+

{detail}

+
+
+ + +
+
+ ) +} diff --git a/frontend/src/pages/playtest/workbench/action-selector.tsx b/frontend/src/pages/playtest/workbench/action-selector.tsx new file mode 100644 index 00000000..b7930a31 --- /dev/null +++ b/frontend/src/pages/playtest/workbench/action-selector.tsx @@ -0,0 +1,208 @@ +import { useId, useState } from 'react' + +import type { PreviewAction } from './model/types' + +export interface PlaytestAssetOption { + key: string + characterId: string + outfitId: string + name: string + actionCount: number +} + +export interface ActionSelectorProps { + assets: readonly PlaytestAssetOption[] + selectedAssetKey: string + actions: readonly PreviewAction[] + selectedActionId: string | null + onSelectAsset(asset: PlaytestAssetOption): void + onSelectAction(actionId: string): void +} + +function frameCount(action: PreviewAction): number { + return action.sequences.reduce((total, sequence) => total + sequence.frames.length, 0) +} + +function assetDisplayName(asset: PlaytestAssetOption): string { + const generatedSuffix = `(角色 ${asset.characterId})` + return asset.name.endsWith(generatedSuffix) + ? asset.name.slice(0, -generatedSuffix.length) + : asset.name +} + +function AssetIdentity({ + asset, + menuOpen = false, + showCaret = false, +}: { + asset: PlaytestAssetOption | null + menuOpen?: boolean + showCaret?: boolean +}) { + return ( + <> + + + + {asset === null ? '没有可预览角色' : assetDisplayName(asset)} + + {asset !== null ? ( + + 角色 {asset.characterId} · {asset.actionCount} 个动作 + + ) : null} + + + + ) +} + +export function ActionSelector({ + assets, + selectedAssetKey, + actions, + selectedActionId, + onSelectAsset, + onSelectAction, +}: ActionSelectorProps) { + const [assetMenuOpen, setAssetMenuOpen] = useState(false) + const assetMenuId = useId() + const selectedAsset = + assets.find((candidate) => candidate.key === selectedAssetKey) ?? assets[0] ?? null + const canSwitchAsset = assets.length > 1 + + return ( +
+
{ + if (!event.currentTarget.contains(event.relatedTarget)) setAssetMenuOpen(false) + }} + > +
+ + {canSwitchAsset ? '角色 / 造型' : '当前角色'} + + {canSwitchAsset ? ( + {assets.length} ITEMS + ) : null} +
+ {canSwitchAsset ? ( + + ) : ( +
+ +
+ )} + {canSwitchAsset && assetMenuOpen ? ( +
+ {assets.map((asset) => { + const selected = asset.key === selectedAssetKey + + return ( + + ) + })} +
+ ) : null} +
+
+
+

动作

+ {actions.length} TOTAL +
+
+ {actions.map((action) => { + const count = frameCount(action) + const selected = action.id === selectedActionId + + return ( + + ) + })} +
+
+ ) +} diff --git a/frontend/src/pages/playtest/workbench/animation-stage.test.tsx b/frontend/src/pages/playtest/workbench/animation-stage.test.tsx new file mode 100644 index 00000000..b3d6d808 --- /dev/null +++ b/frontend/src/pages/playtest/workbench/animation-stage.test.tsx @@ -0,0 +1,184 @@ +/** @vitest-environment jsdom */ +import { cleanup, fireEvent, render, screen } from '@testing-library/react' +import { afterEach, describe, expect, it, vi } from 'vitest' + +import { ActionSelector } from './action-selector' +import { AnimationStage } from './animation-stage' +import { FrameTimeline } from './frame-timeline' +import { PlaybackControls } from './playback-controls' +import type { PreviewAction, PreviewFrame, PreviewSequence } from './model/types' + +const currentFrame: PreviewFrame = { + imageUrl: 'https://cdn.example.test/current.png', + durationMs: 100, + rootMotion: { dx: 12, dy: 5 }, + keyFrame: true, +} + +const nextFrame: PreviewFrame = { + ...currentFrame, + imageUrl: 'https://cdn.example.test/next.png', + keyFrame: false, +} + +const sequence: PreviewSequence = { + direction: 'south', + frames: [currentFrame, nextFrame], +} + +const actions: readonly PreviewAction[] = [ + { + id: 'walk', + name: '行走', + type: 'walk', + fps: 12, + sequences: [sequence], + }, + { + id: 'empty', + name: '空动作', + type: 'idle', + fps: 12, + sequences: [], + }, +] + +afterEach(cleanup) + +describe('playtest visual primitives', () => { + it('renders prop-supplied action names, FPS and actual frame counts while disabling empty actions', () => { + // Catches a selector replacing supplied actions with demo data or permitting a non-playable action. + const onSelectAction = vi.fn() + + render( + , + ) + + expect(screen.getByRole('button', { name: /行走/ }).textContent).toContain('12 FPS') + expect(screen.getByRole('button', { name: /行走/ }).textContent).toContain('2 帧') + expect((screen.getByRole('button', { name: /空动作/ }) as HTMLButtonElement).disabled).toBe( + true, + ) + + fireEvent.click(screen.getByRole('button', { name: /行走/ })) + expect(onSelectAction).toHaveBeenCalledWith('walk') + }) + + it('uses the real frame URL and reports image failure with the accumulated mirrored transform', () => { + // Catches the stage reading per-frame root motion instead of the accumulated owner state, inverting y incorrectly, or hiding load errors. + render( + , + ) + + const image = screen.getByRole('img', { name: '角色动画预览' }) + expect(image.getAttribute('src')).toBe(currentFrame.imageUrl) + expect(image.getAttribute('style')).toContain('translate(18px, -7px) scaleX(-1)') + expect(screen.getAllByRole('img')).toHaveLength(1) + + fireEvent.error(image) + expect(screen.getByText('当前帧图片加载失败')).toBeTruthy() + }) + + it('reports horizontal travel from the measured stage and actor widths', () => { + const onHorizontalBoundsChange = vi.fn() + const rect = vi + .spyOn(HTMLElement.prototype, 'getBoundingClientRect') + .mockImplementation(function (this: HTMLElement) { + const width = this.getAttribute('aria-label') === '动画预览舞台' ? 600 : 200 + return { + width, + height: 400, + x: 0, + y: 0, + top: 0, + right: width, + bottom: 400, + left: 0, + toJSON: () => ({}), + } + }) + + render( + , + ) + + fireEvent.load(screen.getByRole('img', { name: '角色动画预览' })) + expect(onHorizontalBoundsChange).toHaveBeenLastCalledWith({ minX: -200, maxX: 200 }) + rect.mockRestore() + }) + + it('surfaces key-frame markers and delegates timeline selection without its own playback behavior', () => { + // Catches a timeline dropping key-frame annotations or mutating playback rather than using its selection callback. + const onSelectFrame = vi.fn() + + render( + , + ) + + expect(screen.getByText('关键帧')).toBeTruthy() + + fireEvent.click(screen.getByRole('button', { name: '第 2 帧' })) + expect(onSelectFrame).toHaveBeenCalledWith(1) + }) + + it('only relays playback controller callbacks', () => { + // Catches controls owning local play state instead of reporting user intent to the controller. + const onTogglePlaying = vi.fn() + const onNextFrame = vi.fn() + + render( + , + ) + + fireEvent.click(screen.getByRole('button', { name: '播放' })) + fireEvent.click(screen.getByRole('button', { name: '下一帧' })) + expect(onTogglePlaying).toHaveBeenCalledTimes(1) + expect(onNextFrame).toHaveBeenCalledTimes(1) + expect(screen.getByText('跳跃不可用,下蹲可用')).toBeTruthy() + expect(screen.getByRole('button', { name: '循环播放' }).getAttribute('aria-pressed')).toBe( + 'true', + ) + }) +}) diff --git a/frontend/src/pages/playtest/workbench/animation-stage.tsx b/frontend/src/pages/playtest/workbench/animation-stage.tsx new file mode 100644 index 00000000..5860fdee --- /dev/null +++ b/frontend/src/pages/playtest/workbench/animation-stage.tsx @@ -0,0 +1,189 @@ +import { useCallback, useEffect, useRef, useState } from 'react' + +import type { PreviewFrame } from './model/types' +import type { HorizontalStageBounds, StageOffset } from './stage-motion' + +const ZOOM_LEVELS = [0.5, 0.75, 1, 1.5, 2, 3, 4, 6, 8] as const +const DEFAULT_ZOOM = 4 // 64px sprite at 4x = 256px on screen +/** 高分辨率精灵(>=192px)首次加载时用 1x,避免 256px 素材被默认放大到 1024px。 */ +const HIGH_RES_ZOOM = 1 +const HIGH_RES_THRESHOLD_PX = 192 + +export interface AnimationStageProps { + currentFrame: PreviewFrame | null + /** Accumulated playback position in world coordinates (positive y is up). */ + motionOffset: StageOffset + mirrored: boolean + showGrid: boolean + showChecker: boolean + onHorizontalBoundsChange?(bounds: HorizontalStageBounds | null): void + onFrameAvailabilityChange?(available: boolean): void +} + +export function AnimationStage({ + currentFrame, + motionOffset, + mirrored, + showGrid, + showChecker, + onHorizontalBoundsChange, + onFrameAvailabilityChange, +}: AnimationStageProps) { + const [failedImageUrl, setFailedImageUrl] = useState(null) + const [zoomIndex, setZoomIndex] = useState(() => ZOOM_LEVELS.indexOf(DEFAULT_ZOOM)) + const stageRef = useRef(null) + const imageRef = useRef(null) + + const zoom = ZOOM_LEVELS[zoomIndex] ?? DEFAULT_ZOOM + const zoomIn = useCallback(() => { + setZoomIndex((i) => Math.min(i + 1, ZOOM_LEVELS.length - 1)) + }, []) + const zoomOut = useCallback(() => { + setZoomIndex((i) => Math.max(i - 1, 0)) + }, []) + const resetZoom = useCallback(() => { + setZoomIndex(ZOOM_LEVELS.indexOf(DEFAULT_ZOOM)) + }, []) + + const reportHorizontalBounds = useCallback(() => { + if (onHorizontalBoundsChange === undefined) return + const stage = stageRef.current + const image = imageRef.current + if (stage === null || image === null) { + onHorizontalBoundsChange(null) + return + } + + const stageWidth = stage.getBoundingClientRect().width + const actorWidth = image.getBoundingClientRect().width + if (stageWidth <= 0 || actorWidth <= 0) { + onHorizontalBoundsChange(null) + return + } + const travel = Math.max(0, (stageWidth - actorWidth) / 2) + onHorizontalBoundsChange({ minX: -travel, maxX: travel }) + }, [onHorizontalBoundsChange]) + + useEffect(() => { + setFailedImageUrl(null) + onFrameAvailabilityChange?.(false) + }, [currentFrame?.imageUrl, onFrameAvailabilityChange]) + + // Mouse wheel zoom on stage + useEffect(() => { + const stage = stageRef.current + if (stage === null) return + const handleWheel = (e: WheelEvent) => { + if (!e.ctrlKey && !e.metaKey) return + e.preventDefault() + setZoomIndex((i) => { + if (e.deltaY < 0) return Math.min(i + 1, ZOOM_LEVELS.length - 1) + if (e.deltaY > 0) return Math.max(i - 1, 0) + return i + }) + } + stage.addEventListener('wheel', handleWheel, { passive: false }) + return () => stage.removeEventListener('wheel', handleWheel) + }, []) + + useEffect(() => { + reportHorizontalBounds() + if (typeof ResizeObserver === 'undefined') { + window.addEventListener('resize', reportHorizontalBounds) + return () => window.removeEventListener('resize', reportHorizontalBounds) + } + + const observer = new ResizeObserver(reportHorizontalBounds) + if (stageRef.current !== null) observer.observe(stageRef.current) + if (imageRef.current !== null) observer.observe(imageRef.current) + return () => observer.disconnect() + }, [currentFrame?.imageUrl, reportHorizontalBounds]) + + const imageFailed = currentFrame !== null && failedImageUrl === currentFrame.imageUrl + + return ( +
+ {showGrid ? ( +
+ ) +} diff --git a/frontend/src/pages/playtest/workbench/audit/audit-panel.test.tsx b/frontend/src/pages/playtest/workbench/audit/audit-panel.test.tsx new file mode 100644 index 00000000..76d98cf7 --- /dev/null +++ b/frontend/src/pages/playtest/workbench/audit/audit-panel.test.tsx @@ -0,0 +1,106 @@ +/** @vitest-environment jsdom */ +import { cleanup, fireEvent, render, screen } from '@testing-library/react' +import { afterEach, describe, expect, it, vi } from 'vitest' + +import type { PreviewFrame } from '../model/types' +import { AuditPanel } from './audit-panel' + +const frame: PreviewFrame = { + imageUrl: '/walk-03.png', + durationMs: 100, + rootMotion: { dx: 4, dy: 0 }, + keyFrame: false, +} + +afterEach(cleanup) + +describe('AuditPanel', () => { + it('marks the current frame and keeps automatic findings read-only', () => { + const onAdd = vi.fn() + render( + , + ) + + expect(screen.getByText('自动')).toBeTruthy() + expect(screen.getByText('主体接触画布边缘,可能发生裁切')).toBeTruthy() + fireEvent.change(screen.getByLabelText('问题类型'), { + target: { value: 'style_inconsistent' }, + }) + fireEvent.change(screen.getByLabelText('问题说明'), { + target: { value: '衣服颜色跳变' }, + }) + fireEvent.click(screen.getByRole('button', { name: '标记当前帧问题' })) + + expect(onAdd).toHaveBeenCalledWith( + expect.objectContaining({ + category: 'style_inconsistent', + actionId: 'walk', + direction: 'south', + frameIndex: 2, + imageUrl: '/walk-03.png', + note: '衣服颜色跳变', + }), + ) + }) + + it('edits and removes an existing manual issue', () => { + const onUpdate = vi.fn() + const onRemove = vi.fn() + render( + , + ) + + expect(screen.getByText('人工')).toBeTruthy() + fireEvent.change(screen.getByLabelText('人工问题类型'), { + target: { value: 'motion_direction' }, + }) + fireEvent.change(screen.getByLabelText('人工问题说明'), { + target: { value: '移动方向错误' }, + }) + fireEvent.click(screen.getByRole('button', { name: '删除人工问题' })) + + expect(onUpdate).toHaveBeenCalledWith('manual-1', 'motion_direction', '步幅突然变化') + expect(onUpdate).toHaveBeenCalledWith('manual-1', 'motion_discontinuity', '移动方向错误') + expect(onRemove).toHaveBeenCalledWith('manual-1') + }) +}) diff --git a/frontend/src/pages/playtest/workbench/audit/audit-panel.tsx b/frontend/src/pages/playtest/workbench/audit/audit-panel.tsx new file mode 100644 index 00000000..6241e5dd --- /dev/null +++ b/frontend/src/pages/playtest/workbench/audit/audit-panel.tsx @@ -0,0 +1,189 @@ +import { useState } from 'react' + +import type { QualityFinding } from '../analysis/sequence-evidence' +import type { PlaytestDirection, PreviewFrame } from '../model/types' +import type { ManualAuditIssue, ManualIssueCategory } from './audit-session' + +const CATEGORY_LABELS: Readonly> = { + subject_cropped: '主体裁切', + transparency: '透明背景异常', + image_unavailable: '空白或加载失败', + duplicate_frame: '重复帧', + motion_discontinuity: '动作抖动或不连续', + motion_direction: '位移或方向错误', + style_inconsistent: '风格不一致', + other: '其他', +} + +let fallbackId = 0 + +function createIssueId(): string { + if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') { + return crypto.randomUUID() + } + fallbackId += 1 + return `manual-${Date.now()}-${fallbackId}` +} + +export interface AuditPanelProps { + actionId: string | null + actionName: string | null + direction: PlaytestDirection | null + frameIndex: number + frame: PreviewFrame | null + automaticFindings: readonly QualityFinding[] + issues: readonly ManualAuditIssue[] + onAdd(issue: ManualAuditIssue): void + onUpdate(id: string, category: ManualIssueCategory, note: string): void + onRemove(id: string): void +} + +export function AuditPanel({ + actionId, + actionName, + direction, + frameIndex, + frame, + automaticFindings, + issues, + onAdd, + onUpdate, + onRemove, +}: AuditPanelProps) { + const [category, setCategory] = useState('subject_cropped') + const [note, setNote] = useState('') + const canMark = actionId !== null && direction !== null && frame !== null + + return ( +
+
+

QUALITY ISSUES

+

问题记录

+

仅保留在当前 Playtest 会话

+
+ +
+

自动发现

+ {automaticFindings.length === 0 ? ( +

当前序列没有自动问题

+ ) : ( +
    + {automaticFindings.map((finding, index) => ( +
  • +
    + {finding.message} + 自动 +
    + + {finding.frameIndex === null ? '整段序列' : `第 ${finding.frameIndex + 1} 帧`} + +
  • + ))} +
+ )} +
+ +
+

+ 标记当前帧{actionName === null ? '' : ` · ${actionName} #${frameIndex + 1}`} +

+ +