Merge branch 'master' into codex/puzzle-clear-template-runtime-fixes
This commit is contained in:
@@ -31,6 +31,7 @@ import {
|
||||
getCurrentAuthUser,
|
||||
getPublicAuthUserById,
|
||||
liftAuthRiskBlock,
|
||||
isWechatMiniProgramWebViewRuntime,
|
||||
loginWithPhoneCode,
|
||||
logoutAllAuthSessions,
|
||||
redeemRegistrationInviteCode,
|
||||
@@ -80,6 +81,7 @@ function createWindowMock(overrides: Record<string, unknown> = {}) {
|
||||
|
||||
describe('authService', () => {
|
||||
beforeEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
vi.clearAllMocks();
|
||||
vi.stubGlobal('window', createWindowMock());
|
||||
clearStoredAccessToken({ emit: false });
|
||||
@@ -428,6 +430,26 @@ describe('authService', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('detects mini program user agent before the WeChat bridge is ready', () => {
|
||||
vi.stubGlobal('navigator', {
|
||||
userAgent:
|
||||
'Mozilla/5.0 iPhone MicroMessenger/8.0.49 NetType/WIFI Language/zh_CN miniProgram',
|
||||
});
|
||||
vi.stubGlobal(
|
||||
'window',
|
||||
createWindowMock({
|
||||
location: {
|
||||
pathname: '/',
|
||||
hash: '',
|
||||
search: '',
|
||||
assign: vi.fn(),
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
expect(isWechatMiniProgramWebViewRuntime()).toBe(true);
|
||||
});
|
||||
|
||||
it('waits for an existing WeChat JS SDK script before opening the native auth page', async () => {
|
||||
const navigateTo = vi.fn((options: { url: string; success?: () => void }) => {
|
||||
options.success?.();
|
||||
|
||||
@@ -90,9 +90,15 @@ export function isWechatMiniProgramWebViewRuntime() {
|
||||
}
|
||||
|
||||
const params = new URLSearchParams(window.location.search || '');
|
||||
const userAgent =
|
||||
typeof navigator === 'undefined' ? '' : navigator.userAgent || '';
|
||||
const normalizedUserAgent = userAgent.toLowerCase();
|
||||
|
||||
return (
|
||||
params.get('clientRuntime') === 'wechat_mini_program' ||
|
||||
params.get('clientType') === 'mini_program' ||
|
||||
(normalizedUserAgent.includes('micromessenger') &&
|
||||
normalizedUserAgent.includes('miniprogram')) ||
|
||||
Boolean(window.wx?.miniProgram?.postMessage)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import {
|
||||
createBarkBattleDraft,
|
||||
deleteBarkBattleWork,
|
||||
generateAllBarkBattleImageAssets,
|
||||
publishBarkBattleWork,
|
||||
regenerateBarkBattleImageAsset,
|
||||
@@ -73,6 +74,21 @@ describe('barkBattleCreationClient', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('deletes a draft or published work through runtime works API', async () => {
|
||||
requestJsonMock.mockResolvedValueOnce({ items: [] });
|
||||
|
||||
await deleteBarkBattleWork('bark-battle-work-1');
|
||||
|
||||
expect(requestJsonMock).toHaveBeenCalledWith(
|
||||
'/api/runtime/bark-battle/works/bark-battle-work-1',
|
||||
{ method: 'DELETE' },
|
||||
'删除汪汪声浪作品失败',
|
||||
expect.objectContaining({
|
||||
retry: expect.objectContaining({ retryUnsafeMethods: true }),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('persists generated image slots into an existing draft config', async () => {
|
||||
requestJsonMock.mockResolvedValueOnce({ draftId: 'draft-1' });
|
||||
|
||||
|
||||
@@ -290,6 +290,17 @@ export function listBarkBattleWorks(
|
||||
);
|
||||
}
|
||||
|
||||
export function deleteBarkBattleWork(workId: string) {
|
||||
return requestJson<BarkBattleWorksResponse>(
|
||||
`${BARK_BATTLE_RUNTIME_API_BASE}/works/${encodeURIComponent(workId)}`,
|
||||
{ method: 'DELETE' },
|
||||
'删除汪汪声浪作品失败',
|
||||
{
|
||||
retry: BARK_BATTLE_CREATION_WRITE_RETRY,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
export function listBarkBattleGallery() {
|
||||
return requestJson<BarkBattleWorksResponse>(
|
||||
`${BARK_BATTLE_RUNTIME_API_BASE}/gallery`,
|
||||
@@ -441,6 +452,7 @@ export async function generateAllBarkBattleImageAssets(payload: {
|
||||
|
||||
export const barkBattleCreationClient = {
|
||||
createDraft: createBarkBattleDraft,
|
||||
deleteWork: deleteBarkBattleWork,
|
||||
generateAllImageAssets: generateAllBarkBattleImageAssets,
|
||||
listGallery: listBarkBattleGallery,
|
||||
listWorks: listBarkBattleWorks,
|
||||
|
||||
@@ -7,6 +7,7 @@ export {
|
||||
type BarkBattleImageGenerationFailures,
|
||||
type BarkBattleUploadedAsset,
|
||||
createBarkBattleDraft,
|
||||
deleteBarkBattleWork,
|
||||
generateAllBarkBattleImageAssets,
|
||||
listBarkBattleGallery,
|
||||
listBarkBattleWorks,
|
||||
|
||||
142
src/services/jump-hop/jumpHopClient.test.ts
Normal file
142
src/services/jump-hop/jumpHopClient.test.ts
Normal file
@@ -0,0 +1,142 @@
|
||||
import { beforeEach, expect, test, vi } from 'vitest';
|
||||
|
||||
const requestJsonMock = vi.hoisted(() => vi.fn());
|
||||
|
||||
const { createCreationAgentClientMock } = vi.hoisted(() => ({
|
||||
createCreationAgentClientMock: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('../creation-agent', () => ({
|
||||
createCreationAgentClient: createCreationAgentClientMock,
|
||||
}));
|
||||
|
||||
vi.mock('../apiClient', () => ({
|
||||
requestJson: requestJsonMock,
|
||||
}));
|
||||
|
||||
beforeEach(() => {
|
||||
vi.resetModules();
|
||||
createCreationAgentClientMock.mockReset();
|
||||
createCreationAgentClientMock.mockReturnValue({
|
||||
createSession: vi.fn(),
|
||||
getSession: vi.fn(),
|
||||
sendMessage: vi.fn(),
|
||||
streamMessage: vi.fn(),
|
||||
executeAction: vi.fn(),
|
||||
});
|
||||
requestJsonMock.mockReset();
|
||||
});
|
||||
|
||||
test('jump hop delete work uses creation works endpoint', async () => {
|
||||
const { jumpHopClient } = await import('./jumpHopClient');
|
||||
requestJsonMock.mockResolvedValueOnce({ items: [] });
|
||||
|
||||
await jumpHopClient.deleteWork('jump-hop-profile-1');
|
||||
|
||||
expect(requestJsonMock).toHaveBeenCalledWith(
|
||||
'/api/creation/jump-hop/works/jump-hop-profile-1',
|
||||
{ method: 'DELETE' },
|
||||
'删除跳一跳作品失败',
|
||||
);
|
||||
});
|
||||
|
||||
test('jump hop creation keeps image2 generation requests alive long enough', async () => {
|
||||
await import('./jumpHopClient');
|
||||
|
||||
expect(createCreationAgentClientMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
createSessionTimeoutMs: 20 * 60 * 1000,
|
||||
executeActionTimeoutMs: 20 * 60 * 1000,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
test('jump hop work detail preserves flattened back button asset', async () => {
|
||||
const backButtonAsset = {
|
||||
assetId: 'back-button-1',
|
||||
imageSrc: '/generated-jump-hop-assets/back-button-1.png',
|
||||
imageObjectKey: 'jump-hop/back-button-1.png',
|
||||
assetObjectId: 'asset-object-back-button-1',
|
||||
generationProvider: 'image2',
|
||||
prompt: '主题返回按钮',
|
||||
width: 1024,
|
||||
height: 1024,
|
||||
};
|
||||
const characterAsset = {
|
||||
assetId: 'character-1',
|
||||
imageSrc: 'builtin://jump-hop/default-character',
|
||||
imageObjectKey: '',
|
||||
assetObjectId: 'character-object-1',
|
||||
generationProvider: 'builtin-three',
|
||||
prompt: '内置默认角色',
|
||||
width: 0,
|
||||
height: 0,
|
||||
};
|
||||
const draft = {
|
||||
templateId: 'jump-hop',
|
||||
templateName: '跳一跳',
|
||||
profileId: 'profile-1',
|
||||
themeText: '森林茶馆',
|
||||
workTitle: '森林茶馆跳一跳',
|
||||
workDescription: '森林茶馆主题',
|
||||
themeTags: ['森林茶馆', '跳一跳'],
|
||||
difficulty: 'standard',
|
||||
stylePreset: 'minimal-blocks',
|
||||
defaultCharacter: null,
|
||||
characterPrompt: '内置默认角色',
|
||||
tilePrompt: '森林茶馆主题地块',
|
||||
endMoodPrompt: null,
|
||||
characterAsset,
|
||||
tileAtlasAsset: characterAsset,
|
||||
tileAssets: [],
|
||||
path: {
|
||||
seed: 'profile-1',
|
||||
difficulty: 'standard',
|
||||
platforms: [],
|
||||
scoring: {
|
||||
perfectRadiusRatio: 0.24,
|
||||
hitRadiusRatio: 0.52,
|
||||
maxChargeMs: 1200,
|
||||
minChargeMs: 80,
|
||||
maxJumpDistance: 5,
|
||||
},
|
||||
},
|
||||
coverComposite: null,
|
||||
backButtonAsset: null,
|
||||
generationStatus: 'ready',
|
||||
};
|
||||
requestJsonMock.mockResolvedValue({
|
||||
item: {
|
||||
runtimeKind: 'jump-hop',
|
||||
workId: 'work-1',
|
||||
profileId: 'profile-1',
|
||||
ownerUserId: 'owner-1',
|
||||
sourceSessionId: 'session-1',
|
||||
themeText: '森林茶馆',
|
||||
workTitle: '森林茶馆跳一跳',
|
||||
workDescription: '森林茶馆主题',
|
||||
themeTags: ['森林茶馆', '跳一跳'],
|
||||
difficulty: 'standard',
|
||||
stylePreset: 'minimal-blocks',
|
||||
coverImageSrc: null,
|
||||
publicationStatus: 'published',
|
||||
playCount: 0,
|
||||
updatedAt: '2026-06-05T00:00:00Z',
|
||||
publishedAt: '2026-06-05T00:00:00Z',
|
||||
publishReady: true,
|
||||
generationStatus: 'ready',
|
||||
draft,
|
||||
path: draft.path,
|
||||
defaultCharacter: null,
|
||||
characterAsset,
|
||||
tileAtlasAsset: characterAsset,
|
||||
tileAssets: [],
|
||||
backButtonAsset,
|
||||
},
|
||||
});
|
||||
|
||||
const { jumpHopClient } = await import('./jumpHopClient');
|
||||
const response = await jumpHopClient.getWorkDetail('profile-1');
|
||||
|
||||
expect(response.item.backButtonAsset).toEqual(backButtonAsset);
|
||||
});
|
||||
@@ -5,6 +5,7 @@ import type {
|
||||
JumpHopGalleryCardResponse,
|
||||
JumpHopGalleryDetailResponse,
|
||||
JumpHopGalleryResponse,
|
||||
JumpHopLeaderboardResponse,
|
||||
JumpHopRunResponse,
|
||||
JumpHopRuntimeRunSnapshotResponse,
|
||||
JumpHopSessionResponse,
|
||||
@@ -12,8 +13,8 @@ import type {
|
||||
JumpHopWorkDetailResponse,
|
||||
JumpHopWorkMutationResponse,
|
||||
JumpHopWorkProfileResponse,
|
||||
JumpHopWorksResponse,
|
||||
JumpHopWorkspaceCreateRequest,
|
||||
JumpHopWorksResponse,
|
||||
JumpHopWorkSummaryResponse,
|
||||
} from '../../../packages/shared/src/contracts/jumpHop';
|
||||
import {
|
||||
@@ -30,12 +31,23 @@ import {
|
||||
const JUMP_HOP_API_BASE = '/api/creation/jump-hop/sessions';
|
||||
const JUMP_HOP_WORKS_API_BASE = '/api/creation/jump-hop/works';
|
||||
const JUMP_HOP_RUNTIME_API_BASE = '/api/runtime/jump-hop';
|
||||
// 中文注释:跳一跳创作会等待背景图、25 格图集、切片和 OSS 写入,不能沿用共创会话默认 15 秒超时。
|
||||
const JUMP_HOP_GENERATION_TIMEOUT_MS = 20 * 60 * 1000;
|
||||
const JUMP_HOP_RUNTIME_READ_RETRY: ApiRetryOptions = {
|
||||
maxRetries: 1,
|
||||
baseDelayMs: 120,
|
||||
maxDelayMs: 360,
|
||||
};
|
||||
type JumpHopRuntimeRequestOptions = RuntimeGuestRequestOptions;
|
||||
export type JumpHopRuntimeRequestOptions = RuntimeGuestRequestOptions;
|
||||
type JumpHopRuntimeMode = 'draft' | 'published';
|
||||
type JumpHopStartRunOptions = JumpHopRuntimeRequestOptions & {
|
||||
runtimeMode?: JumpHopRuntimeMode;
|
||||
};
|
||||
type JumpHopJumpPayload = {
|
||||
dragDistance: number;
|
||||
dragVectorX?: number;
|
||||
dragVectorY?: number;
|
||||
};
|
||||
|
||||
export type {
|
||||
JumpHopActionRequest,
|
||||
@@ -44,6 +56,7 @@ export type {
|
||||
JumpHopGalleryCardResponse,
|
||||
JumpHopGalleryDetailResponse,
|
||||
JumpHopGalleryResponse,
|
||||
JumpHopLeaderboardResponse,
|
||||
JumpHopRunResponse,
|
||||
JumpHopRuntimeRunSnapshotResponse,
|
||||
JumpHopSessionResponse,
|
||||
@@ -51,16 +64,10 @@ export type {
|
||||
JumpHopWorkDetailResponse,
|
||||
JumpHopWorkMutationResponse,
|
||||
JumpHopWorkProfileResponse,
|
||||
JumpHopWorksResponse,
|
||||
JumpHopWorkspaceCreateRequest,
|
||||
JumpHopWorksResponse,
|
||||
};
|
||||
export type CreateJumpHopSessionRequest = {
|
||||
themeText: string;
|
||||
characterDescription: string;
|
||||
tileStyle: string;
|
||||
difficulty: string;
|
||||
rhythmPreference: string;
|
||||
};
|
||||
export type CreateJumpHopSessionRequest = JumpHopWorkspaceCreateRequest;
|
||||
export type ExecuteJumpHopActionRequest = JumpHopActionRequest;
|
||||
export type JumpHopSessionSnapshot = JumpHopSessionSnapshotResponse;
|
||||
|
||||
@@ -82,6 +89,8 @@ const jumpHopCreationClient = createCreationAgentClient<
|
||||
streamIncomplete: '跳一跳共创消息流式结果不完整',
|
||||
executeAction: '执行跳一跳共创操作失败',
|
||||
},
|
||||
createSessionTimeoutMs: JUMP_HOP_GENERATION_TIMEOUT_MS,
|
||||
executeActionTimeoutMs: JUMP_HOP_GENERATION_TIMEOUT_MS,
|
||||
});
|
||||
|
||||
type FlattenedJumpHopWorkProfileResponse = Omit<
|
||||
@@ -104,6 +113,7 @@ function normalizeJumpHopWorkProfile(
|
||||
profileId: flattened.profileId,
|
||||
ownerUserId: flattened.ownerUserId,
|
||||
sourceSessionId: flattened.sourceSessionId ?? null,
|
||||
themeText: flattened.themeText || flattened.workTitle,
|
||||
workTitle: flattened.workTitle,
|
||||
workDescription: flattened.workDescription,
|
||||
themeTags: flattened.themeTags,
|
||||
@@ -122,9 +132,12 @@ function normalizeJumpHopWorkProfile(
|
||||
summary,
|
||||
draft: flattened.draft,
|
||||
path: flattened.path,
|
||||
defaultCharacter: flattened.defaultCharacter ?? flattened.draft?.defaultCharacter,
|
||||
characterAsset: flattened.characterAsset,
|
||||
tileAtlasAsset: flattened.tileAtlasAsset,
|
||||
tileAssets: flattened.tileAssets,
|
||||
backButtonAsset:
|
||||
flattened.backButtonAsset ?? flattened.draft?.backButtonAsset ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -230,11 +243,20 @@ export async function publishJumpHopWork(profileId: string) {
|
||||
return normalizeJumpHopWorkMutationResponse(response);
|
||||
}
|
||||
|
||||
export async function deleteJumpHopWork(profileId: string) {
|
||||
return requestJson<JumpHopWorksResponse>(
|
||||
`${JUMP_HOP_WORKS_API_BASE}/${encodeURIComponent(profileId)}`,
|
||||
{ method: 'DELETE' },
|
||||
'删除跳一跳作品失败',
|
||||
);
|
||||
}
|
||||
|
||||
export async function startJumpHopRuntimeRun(
|
||||
profileId: string,
|
||||
options: JumpHopRuntimeRequestOptions = {},
|
||||
options: JumpHopStartRunOptions = {},
|
||||
) {
|
||||
const requestOptions = buildRuntimeGuestAuthOptions(options);
|
||||
const runtimeMode = options.runtimeMode ?? 'published';
|
||||
return requestJson<JumpHopRunResponse>(
|
||||
`${JUMP_HOP_RUNTIME_API_BASE}/runs`,
|
||||
{
|
||||
@@ -243,7 +265,7 @@ export async function startJumpHopRuntimeRun(
|
||||
'content-type': 'application/json',
|
||||
...buildRuntimeGuestHeaders(options),
|
||||
},
|
||||
body: JSON.stringify({ profileId }),
|
||||
body: JSON.stringify({ profileId, runtimeMode }),
|
||||
},
|
||||
'启动跳一跳运行态失败',
|
||||
{
|
||||
@@ -254,12 +276,14 @@ export async function startJumpHopRuntimeRun(
|
||||
|
||||
export async function submitJumpHopJump(
|
||||
runId: string,
|
||||
payload: { chargeMs: number },
|
||||
payload: JumpHopJumpPayload,
|
||||
options: JumpHopRuntimeRequestOptions = {},
|
||||
) {
|
||||
const requestOptions = buildRuntimeGuestAuthOptions(options);
|
||||
const requestPayload = {
|
||||
chargeMs: payload.chargeMs,
|
||||
dragDistance: payload.dragDistance,
|
||||
dragVectorX: payload.dragVectorX,
|
||||
dragVectorY: payload.dragVectorY,
|
||||
clientEventId: `jump-${runId}-${Date.now()}`,
|
||||
};
|
||||
|
||||
@@ -278,6 +302,22 @@ export async function submitJumpHopJump(
|
||||
);
|
||||
}
|
||||
|
||||
export async function getJumpHopLeaderboard(
|
||||
profileId: string,
|
||||
options: JumpHopRuntimeRequestOptions = {},
|
||||
) {
|
||||
const requestOptions = buildRuntimeGuestAuthOptions(options);
|
||||
return requestJson<JumpHopLeaderboardResponse>(
|
||||
`${JUMP_HOP_RUNTIME_API_BASE}/works/${encodeURIComponent(profileId)}/leaderboard`,
|
||||
{
|
||||
method: 'GET',
|
||||
headers: buildRuntimeGuestHeaders(options),
|
||||
},
|
||||
'读取跳一跳排行榜失败',
|
||||
requestOptions,
|
||||
);
|
||||
}
|
||||
|
||||
export async function restartJumpHopRuntimeRun(
|
||||
runId: string,
|
||||
options: JumpHopRuntimeRequestOptions = {},
|
||||
@@ -302,6 +342,7 @@ export async function restartJumpHopRuntimeRun(
|
||||
|
||||
export const jumpHopClient = {
|
||||
createSession: createJumpHopCreationSession,
|
||||
deleteWork: deleteJumpHopWork,
|
||||
getSession: getJumpHopCreationSession,
|
||||
executeAction: executeJumpHopCreationAction,
|
||||
getGalleryDetail: getJumpHopGalleryDetail,
|
||||
@@ -309,6 +350,7 @@ export const jumpHopClient = {
|
||||
listGallery: listJumpHopGallery,
|
||||
listWorks: listJumpHopWorks,
|
||||
publishWork: publishJumpHopWork,
|
||||
getLeaderboard: getJumpHopLeaderboard,
|
||||
restartRun: restartJumpHopRuntimeRun,
|
||||
startRun: startJumpHopRuntimeRun,
|
||||
submitJump: submitJumpHopJump,
|
||||
|
||||
498
src/services/jump-hop/jumpHopRuntimeModel.test.ts
Normal file
498
src/services/jump-hop/jumpHopRuntimeModel.test.ts
Normal file
@@ -0,0 +1,498 @@
|
||||
import { expect, test } from 'vitest';
|
||||
|
||||
import type {
|
||||
JumpHopPath,
|
||||
JumpHopTileAsset,
|
||||
} from '../../../packages/shared/src/contracts/jumpHop';
|
||||
import {
|
||||
buildJumpHopVisiblePlatforms,
|
||||
getJumpHopBackendDragVector,
|
||||
getJumpHopCharacterVisualPosition,
|
||||
getJumpHopJumpFeedbackLabel,
|
||||
getJumpHopLandingAssistVisualPosition,
|
||||
getJumpHopPlatformVisualSize,
|
||||
getJumpHopStatusLabel,
|
||||
resolveJumpHopCharacterCanvasPosition,
|
||||
selectJumpHopTileAsset,
|
||||
} from './jumpHopRuntimeModel';
|
||||
|
||||
test('跳一跳地块池按平台编号从 25 个素材中抽取而不是按类型压扁', () => {
|
||||
const tileAssets = Array.from({ length: 25 }, (_, index) => ({
|
||||
tileType: 'normal',
|
||||
tileId: `tile-${String(index + 1).padStart(2, '0')}`,
|
||||
imageSrc: `asset-${index + 1}`,
|
||||
imageObjectKey: `key-${index + 1}`,
|
||||
assetObjectId: `object-${index + 1}`,
|
||||
sourceAtlasCell: `row-1-col-${index + 1}`,
|
||||
atlasRow: 1,
|
||||
atlasCol: index + 1,
|
||||
visualWidth: 256,
|
||||
visualHeight: 192,
|
||||
topSurfaceRadius: 42,
|
||||
landingRadius: 34,
|
||||
})) satisfies JumpHopTileAsset[];
|
||||
|
||||
const first = selectJumpHopTileAsset(tileAssets, '森林茶馆', 1, 'platform-1');
|
||||
const second = selectJumpHopTileAsset(tileAssets, '森林茶馆', 2, 'platform-2');
|
||||
|
||||
expect(first?.imageSrc).not.toBe(second?.imageSrc);
|
||||
expect(first?.imageSrc).toMatch(/^asset-/);
|
||||
expect(second?.imageSrc).toMatch(/^asset-/);
|
||||
});
|
||||
|
||||
test('跳一跳可见平台窗口固定为 3 个并携带选中的地块素材', () => {
|
||||
const path: JumpHopPath = {
|
||||
seed: 'forest-tea',
|
||||
difficulty: 'standard',
|
||||
finishIndex: 999,
|
||||
cameraPreset: 'portrait-isometric-9x16',
|
||||
scoring: {
|
||||
chargeToDistanceRatio: 0.004,
|
||||
maxChargeMs: 900,
|
||||
hitBonus: 20,
|
||||
perfectBonus: 60,
|
||||
},
|
||||
platforms: [
|
||||
platform(0, 0, 'start'),
|
||||
platform(1.2, 1.8, 'normal'),
|
||||
platform(-0.3, 3.5, 'target'),
|
||||
platform(0.8, 5.1, 'normal'),
|
||||
],
|
||||
};
|
||||
const tileAssets = Array.from({ length: 25 }, (_, index) => ({
|
||||
tileType: 'normal',
|
||||
tileId: `tile-${String(index + 1).padStart(2, '0')}`,
|
||||
imageSrc: `asset-${index + 1}`,
|
||||
imageObjectKey: `key-${index + 1}`,
|
||||
assetObjectId: `object-${index + 1}`,
|
||||
sourceAtlasCell: `row-1-col-${index + 1}`,
|
||||
visualWidth: 256,
|
||||
visualHeight: 192,
|
||||
topSurfaceRadius: 42,
|
||||
landingRadius: 34,
|
||||
})) satisfies JumpHopTileAsset[];
|
||||
|
||||
const visible = buildJumpHopVisiblePlatforms(path, 1, tileAssets);
|
||||
|
||||
expect(visible).toHaveLength(3);
|
||||
expect(visible[0]?.asset?.imageSrc).toMatch(/^asset-/);
|
||||
expect(visible[1]?.asset?.imageSrc).toMatch(/^asset-/);
|
||||
expect(visible[2]?.asset?.imageSrc).toMatch(/^asset-/);
|
||||
});
|
||||
|
||||
test('跳一跳三块可见地块按下方中部上方展开且角色落在当前地块上', () => {
|
||||
const path: JumpHopPath = {
|
||||
seed: 'forest-tea',
|
||||
difficulty: 'standard',
|
||||
finishIndex: 999,
|
||||
cameraPreset: 'portrait-isometric-9x16',
|
||||
scoring: {
|
||||
chargeToDistanceRatio: 0.004,
|
||||
maxChargeMs: 900,
|
||||
hitBonus: 20,
|
||||
perfectBonus: 60,
|
||||
},
|
||||
platforms: [
|
||||
platform(0, 0, 'start'),
|
||||
platform(0.8, 1.2, 'normal'),
|
||||
platform(-0.2, 2.4, 'target'),
|
||||
],
|
||||
};
|
||||
|
||||
const visible = buildJumpHopVisiblePlatforms(path, 0, []);
|
||||
const character = getJumpHopCharacterVisualPosition(
|
||||
{
|
||||
runId: 'run-1',
|
||||
profileId: 'profile-1',
|
||||
ownerUserId: 'user-1',
|
||||
status: 'playing',
|
||||
currentPlatformIndex: 0,
|
||||
successfulJumpCount: 0,
|
||||
durationMs: 0,
|
||||
score: 0,
|
||||
combo: 0,
|
||||
path,
|
||||
lastJump: null,
|
||||
startedAtMs: 1000,
|
||||
finishedAtMs: null,
|
||||
},
|
||||
visible,
|
||||
);
|
||||
|
||||
expect(visible[0]?.screenY).toBeGreaterThanOrEqual(68);
|
||||
expect(visible[0]?.screenY).toBeLessThanOrEqual(80);
|
||||
expect(visible[1]?.screenY).toBeGreaterThanOrEqual(40);
|
||||
expect(visible[1]?.screenY).toBeLessThan(visible[0]?.screenY ?? 0);
|
||||
expect(visible[2]?.screenY).toBeLessThan(visible[1]?.screenY ?? 0);
|
||||
expect(visible[2]?.screenY).toBeLessThanOrEqual(26);
|
||||
expect(Math.abs((visible[1]?.screenX ?? 0) - (visible[0]?.screenX ?? 0))).toBeGreaterThan(5);
|
||||
expect(Math.abs((visible[2]?.screenX ?? 0) - (visible[1]?.screenX ?? 0))).toBeGreaterThan(5);
|
||||
expect(character?.screenX).toBeCloseTo(visible[0]?.screenX ?? 0, 1);
|
||||
expect(character?.screenY).toBeCloseTo((visible[0]?.screenY ?? 0) - 3, 1);
|
||||
});
|
||||
|
||||
test('跳一跳可见地块按深度保留不同视觉尺寸', () => {
|
||||
const path: JumpHopPath = {
|
||||
seed: 'forest-tea',
|
||||
difficulty: 'standard',
|
||||
finishIndex: 999,
|
||||
cameraPreset: 'portrait-isometric-9x16',
|
||||
scoring: {
|
||||
chargeToDistanceRatio: 0.004,
|
||||
maxChargeMs: 900,
|
||||
hitBonus: 20,
|
||||
perfectBonus: 60,
|
||||
},
|
||||
platforms: [
|
||||
platform(0, 0, 'start'),
|
||||
platform(0.8, 1.2, 'normal'),
|
||||
platform(-0.2, 2.4, 'target'),
|
||||
],
|
||||
};
|
||||
|
||||
const visible = buildJumpHopVisiblePlatforms(path, 0, []);
|
||||
const currentSize = getJumpHopPlatformVisualSize(
|
||||
visible[0]!.platform,
|
||||
visible[0]!.scale,
|
||||
);
|
||||
const targetSize = getJumpHopPlatformVisualSize(
|
||||
visible[1]!.platform,
|
||||
visible[1]!.scale,
|
||||
);
|
||||
const previewSize = getJumpHopPlatformVisualSize(
|
||||
visible[2]!.platform,
|
||||
visible[2]!.scale,
|
||||
);
|
||||
|
||||
expect(currentSize.width).toBeGreaterThan(targetSize.width);
|
||||
expect(targetSize.width).toBeGreaterThan(previewSize.width);
|
||||
expect(currentSize.height).toBeGreaterThan(targetSize.height);
|
||||
expect(targetSize.height).toBeGreaterThan(previewSize.height);
|
||||
});
|
||||
|
||||
test('跳一跳三维角色画布坐标与屏幕坐标同向映射到下方起始地块', () => {
|
||||
const path: JumpHopPath = {
|
||||
seed: 'forest-tea',
|
||||
difficulty: 'standard',
|
||||
finishIndex: 999,
|
||||
cameraPreset: 'portrait-isometric-9x16',
|
||||
scoring: {
|
||||
chargeToDistanceRatio: 0.004,
|
||||
maxChargeMs: 900,
|
||||
hitBonus: 20,
|
||||
perfectBonus: 60,
|
||||
},
|
||||
platforms: [
|
||||
platform(0, 0, 'start'),
|
||||
platform(0.8, 1.2, 'normal'),
|
||||
platform(-0.2, 2.4, 'target'),
|
||||
],
|
||||
};
|
||||
|
||||
const visible = buildJumpHopVisiblePlatforms(path, 0, []);
|
||||
const character = getJumpHopCharacterVisualPosition(
|
||||
{
|
||||
runId: 'run-1',
|
||||
profileId: 'profile-1',
|
||||
ownerUserId: 'user-1',
|
||||
status: 'playing',
|
||||
currentPlatformIndex: 0,
|
||||
successfulJumpCount: 0,
|
||||
durationMs: 0,
|
||||
score: 0,
|
||||
combo: 0,
|
||||
path,
|
||||
lastJump: null,
|
||||
startedAtMs: 1000,
|
||||
finishedAtMs: null,
|
||||
},
|
||||
visible,
|
||||
);
|
||||
|
||||
const canvasPosition = resolveJumpHopCharacterCanvasPosition(character, {
|
||||
width: 320,
|
||||
height: 568,
|
||||
});
|
||||
|
||||
expect(canvasPosition?.x).toBeGreaterThan(140);
|
||||
expect(canvasPosition?.x).toBeLessThan(180);
|
||||
expect(canvasPosition?.y).toBeGreaterThan(380);
|
||||
expect(canvasPosition?.y).toBeLessThan(450);
|
||||
});
|
||||
|
||||
test('跳一跳运行态当前地块视觉尺寸按原调参结果放大一倍', () => {
|
||||
const size = getJumpHopPlatformVisualSize(platform(0, 0, 'start'), 1.08);
|
||||
|
||||
expect(size.width).toBeCloseTo(125.28, 2);
|
||||
expect(size.height).toBeCloseTo(103.68, 2);
|
||||
});
|
||||
|
||||
test('跳一跳落点辅助标识按后端落点规则随拖拽方向和距离投影', () => {
|
||||
const path: JumpHopPath = {
|
||||
seed: 'forest-tea',
|
||||
difficulty: 'standard',
|
||||
finishIndex: 999,
|
||||
cameraPreset: 'portrait-isometric-9x16',
|
||||
scoring: {
|
||||
chargeToDistanceRatio: 0.004,
|
||||
maxChargeMs: 900,
|
||||
hitBonus: 20,
|
||||
perfectBonus: 60,
|
||||
},
|
||||
platforms: [
|
||||
platform(0, 0, 'start'),
|
||||
platform(0.8, 1.2, 'normal'),
|
||||
platform(-0.2, 2.4, 'target'),
|
||||
],
|
||||
};
|
||||
const run = {
|
||||
runId: 'run-1',
|
||||
profileId: 'profile-1',
|
||||
ownerUserId: 'user-1',
|
||||
status: 'playing',
|
||||
currentPlatformIndex: 0,
|
||||
successfulJumpCount: 0,
|
||||
durationMs: 0,
|
||||
score: 0,
|
||||
combo: 0,
|
||||
path,
|
||||
lastJump: null,
|
||||
startedAtMs: 1000,
|
||||
finishedAtMs: null,
|
||||
} as const;
|
||||
const visible = buildJumpHopVisiblePlatforms(path, 0, []);
|
||||
const character = getJumpHopCharacterVisualPosition(run, visible);
|
||||
const current = visible[0]!;
|
||||
const target = visible[1]!;
|
||||
const stageSize = { width: 320, height: 568 };
|
||||
const currentCanvasPosition = {
|
||||
x: (current.screenX / 100) * stageSize.width,
|
||||
y: (current.screenY / 100) * stageSize.height,
|
||||
};
|
||||
const targetCanvasPosition = {
|
||||
x: (target.screenX / 100) * stageSize.width,
|
||||
y: (target.screenY / 100) * stageSize.height,
|
||||
};
|
||||
const targetWorldDistance = Math.hypot(
|
||||
target.platform.x - current.platform.x,
|
||||
target.platform.y - current.platform.y,
|
||||
);
|
||||
const fullDragDistance =
|
||||
targetWorldDistance / path.scoring.chargeToDistanceRatio;
|
||||
const dragVectorX = -(targetCanvasPosition.x - currentCanvasPosition.x);
|
||||
const dragVectorY = -(targetCanvasPosition.y - currentCanvasPosition.y);
|
||||
|
||||
const fullAssist = getJumpHopLandingAssistVisualPosition(
|
||||
run,
|
||||
visible,
|
||||
character,
|
||||
stageSize,
|
||||
fullDragDistance,
|
||||
dragVectorX,
|
||||
dragVectorY,
|
||||
);
|
||||
const halfAssist = getJumpHopLandingAssistVisualPosition(
|
||||
run,
|
||||
visible,
|
||||
character,
|
||||
stageSize,
|
||||
fullDragDistance / 2,
|
||||
dragVectorX,
|
||||
dragVectorY,
|
||||
);
|
||||
|
||||
expect(fullAssist?.screenX).toBeCloseTo(target.screenX, 1);
|
||||
expect(fullAssist?.screenY).toBeCloseTo(target.screenY, 1);
|
||||
expect(halfAssist?.screenX).toBeCloseTo(
|
||||
current.screenX + (target.screenX - current.screenX) / 2,
|
||||
1,
|
||||
);
|
||||
expect(halfAssist?.screenY).toBeCloseTo(
|
||||
current.screenY + (target.screenY - current.screenY) / 2,
|
||||
1,
|
||||
);
|
||||
});
|
||||
|
||||
test('跳一跳落点辅助标识使用屏幕坐标向后拖拽并投向上方目标地块', () => {
|
||||
const path: JumpHopPath = {
|
||||
seed: 'forest-tea',
|
||||
difficulty: 'standard',
|
||||
finishIndex: 999,
|
||||
cameraPreset: 'portrait-isometric-9x16',
|
||||
scoring: {
|
||||
chargeToDistanceRatio: 0.004,
|
||||
maxChargeMs: 900,
|
||||
hitBonus: 20,
|
||||
perfectBonus: 60,
|
||||
},
|
||||
platforms: [
|
||||
platform(0, 0, 'start'),
|
||||
platform(0.8, 1.2, 'normal'),
|
||||
platform(-0.2, 2.4, 'target'),
|
||||
],
|
||||
};
|
||||
const run = {
|
||||
runId: 'run-1',
|
||||
profileId: 'profile-1',
|
||||
ownerUserId: 'user-1',
|
||||
status: 'playing',
|
||||
currentPlatformIndex: 0,
|
||||
successfulJumpCount: 0,
|
||||
durationMs: 0,
|
||||
score: 0,
|
||||
combo: 0,
|
||||
path,
|
||||
lastJump: null,
|
||||
startedAtMs: 1000,
|
||||
finishedAtMs: null,
|
||||
} as const;
|
||||
const visible = buildJumpHopVisiblePlatforms(path, 0, []);
|
||||
const character = getJumpHopCharacterVisualPosition(run, visible);
|
||||
const current = visible[0]!;
|
||||
const target = visible[1]!;
|
||||
const stageSize = { width: 320, height: 568 };
|
||||
const currentCanvasPosition = {
|
||||
x: (current.screenX / 100) * stageSize.width,
|
||||
y: (current.screenY / 100) * stageSize.height,
|
||||
};
|
||||
const targetCanvasPosition = {
|
||||
x: (target.screenX / 100) * stageSize.width,
|
||||
y: (target.screenY / 100) * stageSize.height,
|
||||
};
|
||||
const dragVectorX = -(targetCanvasPosition.x - currentCanvasPosition.x);
|
||||
const dragVectorY = currentCanvasPosition.y - targetCanvasPosition.y;
|
||||
const targetWorldDistance = Math.hypot(
|
||||
target.platform.x - current.platform.x,
|
||||
target.platform.y - current.platform.y,
|
||||
);
|
||||
const fullDragDistance =
|
||||
targetWorldDistance / path.scoring.chargeToDistanceRatio;
|
||||
|
||||
const assist = getJumpHopLandingAssistVisualPosition(
|
||||
run,
|
||||
visible,
|
||||
character,
|
||||
stageSize,
|
||||
fullDragDistance,
|
||||
dragVectorX,
|
||||
dragVectorY,
|
||||
);
|
||||
|
||||
expect(dragVectorY).toBeGreaterThan(0);
|
||||
expect(assist?.screenX).toBeCloseTo(target.screenX, 1);
|
||||
expect(assist?.screenY).toBeCloseTo(target.screenY, 1);
|
||||
});
|
||||
|
||||
test('跳一跳后端落点向量会把屏幕拖拽换算为世界尺度一致的反向弹射', () => {
|
||||
const path: JumpHopPath = {
|
||||
seed: 'forest-tea',
|
||||
difficulty: 'standard',
|
||||
finishIndex: 999,
|
||||
cameraPreset: 'portrait-isometric-9x16',
|
||||
scoring: {
|
||||
chargeToDistanceRatio: 0.004,
|
||||
maxChargeMs: 900,
|
||||
hitBonus: 20,
|
||||
perfectBonus: 60,
|
||||
},
|
||||
platforms: [
|
||||
platform(0, 0, 'start'),
|
||||
platform(0.8, 1.2, 'normal'),
|
||||
platform(-0.2, 2.4, 'target'),
|
||||
],
|
||||
};
|
||||
const run = {
|
||||
runId: 'run-1',
|
||||
profileId: 'profile-1',
|
||||
ownerUserId: 'user-1',
|
||||
status: 'playing',
|
||||
currentPlatformIndex: 0,
|
||||
successfulJumpCount: 0,
|
||||
durationMs: 0,
|
||||
score: 0,
|
||||
combo: 0,
|
||||
path,
|
||||
lastJump: null,
|
||||
startedAtMs: 1000,
|
||||
finishedAtMs: null,
|
||||
} as const;
|
||||
const visible = buildJumpHopVisiblePlatforms(path, 0, []);
|
||||
const current = visible[0]!;
|
||||
const target = visible[1]!;
|
||||
const stageSize = { width: 320, height: 568 };
|
||||
const currentCanvasPosition = {
|
||||
x: (current.screenX / 100) * stageSize.width,
|
||||
y: (current.screenY / 100) * stageSize.height,
|
||||
};
|
||||
const targetCanvasPosition = {
|
||||
x: (target.screenX / 100) * stageSize.width,
|
||||
y: (target.screenY / 100) * stageSize.height,
|
||||
};
|
||||
const dragVectorX = -(targetCanvasPosition.x - currentCanvasPosition.x);
|
||||
const dragVectorY = currentCanvasPosition.y - targetCanvasPosition.y;
|
||||
const backendVector = getJumpHopBackendDragVector(
|
||||
run,
|
||||
visible,
|
||||
stageSize,
|
||||
dragVectorX,
|
||||
dragVectorY,
|
||||
);
|
||||
|
||||
expect(backendVector.dragVectorX).toBeLessThan(0);
|
||||
expect(backendVector.dragVectorY).toBeGreaterThan(0);
|
||||
expect(Math.abs(backendVector.dragVectorY)).toBeLessThan(Math.abs(dragVectorY));
|
||||
});
|
||||
|
||||
test('跳一跳运行态公开反馈不再展示旧 perfect 和通关语义', () => {
|
||||
expect(getJumpHopStatusLabel('cleared')).toBe('结束');
|
||||
expect(
|
||||
getJumpHopJumpFeedbackLabel({
|
||||
runId: 'run-1',
|
||||
profileId: 'profile-1',
|
||||
ownerUserId: 'user-1',
|
||||
status: 'playing',
|
||||
currentPlatformIndex: 1,
|
||||
successfulJumpCount: 1,
|
||||
durationMs: 0,
|
||||
score: 1,
|
||||
combo: 0,
|
||||
path: {
|
||||
seed: 'forest-tea',
|
||||
difficulty: 'standard',
|
||||
finishIndex: 999,
|
||||
cameraPreset: 'portrait-isometric-9x16',
|
||||
scoring: {
|
||||
chargeToDistanceRatio: 0.004,
|
||||
maxChargeMs: 900,
|
||||
hitBonus: 20,
|
||||
perfectBonus: 60,
|
||||
},
|
||||
platforms: [platform(0, 0, 'start'), platform(1.2, 1.8, 'normal')],
|
||||
},
|
||||
lastJump: {
|
||||
chargeMs: 300,
|
||||
jumpDistance: 1.2,
|
||||
targetPlatformIndex: 1,
|
||||
landedX: 1.2,
|
||||
landedY: 1.8,
|
||||
result: 'perfect',
|
||||
},
|
||||
startedAtMs: 1000,
|
||||
finishedAtMs: null,
|
||||
}),
|
||||
).toBe('落地');
|
||||
});
|
||||
|
||||
function platform(x: number, y: number, tileType: 'start' | 'normal' | 'target') {
|
||||
return {
|
||||
platformId: `platform-${x}-${y}`,
|
||||
tileType,
|
||||
x,
|
||||
y,
|
||||
width: 1,
|
||||
height: 1,
|
||||
landingRadius: 0.5,
|
||||
perfectRadius: 0.2,
|
||||
scoreValue: 1,
|
||||
};
|
||||
}
|
||||
479
src/services/jump-hop/jumpHopRuntimeModel.ts
Normal file
479
src/services/jump-hop/jumpHopRuntimeModel.ts
Normal file
@@ -0,0 +1,479 @@
|
||||
import type {
|
||||
JumpHopPath,
|
||||
JumpHopPlatform,
|
||||
JumpHopRunStatus,
|
||||
JumpHopRuntimeRunSnapshotResponse,
|
||||
JumpHopTileAsset,
|
||||
JumpHopTileType,
|
||||
} from '../../../packages/shared/src/contracts/jumpHop';
|
||||
|
||||
export type JumpHopVisiblePlatform = {
|
||||
platform: JumpHopPlatform;
|
||||
index: number;
|
||||
screenX: number;
|
||||
screenY: number;
|
||||
sceneX: number;
|
||||
sceneY: number;
|
||||
sceneZ: number;
|
||||
scale: number;
|
||||
asset: JumpHopTileAsset | null;
|
||||
};
|
||||
|
||||
export type JumpHopCharacterVisualPosition = {
|
||||
screenX: number;
|
||||
screenY: number;
|
||||
sceneX: number;
|
||||
sceneY: number;
|
||||
sceneZ: number;
|
||||
isMiss: boolean;
|
||||
};
|
||||
|
||||
export type JumpHopCanvasSize = {
|
||||
width: number;
|
||||
height: number;
|
||||
};
|
||||
|
||||
export type JumpHopPlatformVisualSize = {
|
||||
width: number;
|
||||
height: number;
|
||||
};
|
||||
|
||||
export type JumpHopLandingAssistVisualPosition = {
|
||||
screenX: number;
|
||||
screenY: number;
|
||||
targetPlatformIndex: number;
|
||||
};
|
||||
|
||||
export type JumpHopBackendDragVector = {
|
||||
dragVectorX: number;
|
||||
dragVectorY: number;
|
||||
};
|
||||
|
||||
const VISIBLE_PLATFORM_COUNT = 3;
|
||||
const JUMP_HOP_STAGE_WORLD_SCALE = 4.2;
|
||||
const JUMP_HOP_STAGE_FORWARD_SCALE = 3;
|
||||
const JUMP_HOP_VISIBLE_PLATFORM_SCREEN_Y = [78, 50, 22] as const;
|
||||
const JUMP_HOP_PLATFORM_VISUAL_SIZE_MULTIPLIER = 2;
|
||||
const JUMP_HOP_SCREEN_X_WORLD_PERCENT = 16 * 0.96;
|
||||
|
||||
const tileToneByType: Record<JumpHopTileType, string> = {
|
||||
accent: '#e0f2fe',
|
||||
bonus: '#fef3c7',
|
||||
finish: '#dcfce7',
|
||||
normal: '#f8fafc',
|
||||
start: '#e0f2fe',
|
||||
target: '#fee2e2',
|
||||
};
|
||||
|
||||
function clamp(value: number, min: number, max: number) {
|
||||
return Math.min(max, Math.max(min, value));
|
||||
}
|
||||
|
||||
function hashJumpHopString(value: string) {
|
||||
let hash = 0x811c9dc5;
|
||||
for (const character of value) {
|
||||
hash ^= character.codePointAt(0) ?? 0;
|
||||
hash = Math.imul(hash, 0x01000193);
|
||||
}
|
||||
return hash >>> 0;
|
||||
}
|
||||
|
||||
export function selectJumpHopTileAsset(
|
||||
tileAssets: JumpHopTileAsset[] | null | undefined,
|
||||
seedText: string | null | undefined,
|
||||
platformIndex: number,
|
||||
platformId: string,
|
||||
) {
|
||||
const pool = (tileAssets ?? []).filter(Boolean);
|
||||
if (pool.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const normalizedSeed = seedText?.trim() || 'jump-hop';
|
||||
const signature = `${normalizedSeed}:${platformIndex}:${platformId}`;
|
||||
const selectedIndex = hashJumpHopString(signature) % pool.length;
|
||||
return pool[selectedIndex] ?? null;
|
||||
}
|
||||
|
||||
export function buildJumpHopVisiblePlatforms(
|
||||
path: JumpHopPath | null | undefined,
|
||||
currentPlatformIndex: number,
|
||||
tileAssets: JumpHopTileAsset[] | null | undefined,
|
||||
) {
|
||||
const platforms = path?.platforms ?? [];
|
||||
const current = platforms[currentPlatformIndex] ?? platforms[0];
|
||||
if (!current) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const start = Math.max(0, currentPlatformIndex);
|
||||
const end = Math.min(platforms.length, currentPlatformIndex + VISIBLE_PLATFORM_COUNT);
|
||||
const visible = platforms.slice(start, end);
|
||||
const worldScale = 0.96;
|
||||
|
||||
return visible.map((platform, offset): JumpHopVisiblePlatform => {
|
||||
const index = start + offset;
|
||||
const dx = platform.x - current.x;
|
||||
const dy = platform.y - current.y;
|
||||
const depth = index - currentPlatformIndex;
|
||||
const asset = selectJumpHopTileAsset(
|
||||
tileAssets,
|
||||
path?.seed ?? null,
|
||||
index,
|
||||
platform.platformId,
|
||||
);
|
||||
const screenY =
|
||||
depth <= 0
|
||||
? JUMP_HOP_VISIBLE_PLATFORM_SCREEN_Y[0]
|
||||
: depth === 1
|
||||
? JUMP_HOP_VISIBLE_PLATFORM_SCREEN_Y[1]
|
||||
: JUMP_HOP_VISIBLE_PLATFORM_SCREEN_Y[2];
|
||||
const screenX = clamp(50 + dx * 16 * worldScale, 14, 86);
|
||||
|
||||
return {
|
||||
platform,
|
||||
index,
|
||||
screenX,
|
||||
screenY,
|
||||
sceneX: dx * JUMP_HOP_STAGE_WORLD_SCALE,
|
||||
sceneY: 0,
|
||||
sceneZ: dy * JUMP_HOP_STAGE_FORWARD_SCALE,
|
||||
scale: clamp(1.08 - Math.max(0, depth) * 0.12, 0.8, 1.1),
|
||||
asset,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
export function getJumpHopPlatformVisualSize(
|
||||
platform: JumpHopPlatform,
|
||||
scale: number,
|
||||
): JumpHopPlatformVisualSize {
|
||||
return {
|
||||
width:
|
||||
clamp(platform.width * 0.96, 58, 118) *
|
||||
scale *
|
||||
JUMP_HOP_PLATFORM_VISUAL_SIZE_MULTIPLIER,
|
||||
height:
|
||||
clamp(platform.height * 0.78, 48, 92) *
|
||||
scale *
|
||||
JUMP_HOP_PLATFORM_VISUAL_SIZE_MULTIPLIER,
|
||||
};
|
||||
}
|
||||
|
||||
function getJumpHopCurrentTargetPlatforms(
|
||||
run: JumpHopRuntimeRunSnapshotResponse | null,
|
||||
platforms: JumpHopVisiblePlatform[],
|
||||
) {
|
||||
if (!run) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const currentIndex = run.currentPlatformIndex;
|
||||
const currentPlatform =
|
||||
platforms.find((item) => item.index === currentIndex) ??
|
||||
platforms[0] ??
|
||||
null;
|
||||
const targetPlatform =
|
||||
platforms.find((item) => item.index === currentIndex + 1) ??
|
||||
platforms[1] ??
|
||||
null;
|
||||
|
||||
if (!currentPlatform || !targetPlatform) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
currentPlatform,
|
||||
targetPlatform,
|
||||
};
|
||||
}
|
||||
|
||||
function getJumpHopCanvasPosition(
|
||||
platform: JumpHopVisiblePlatform,
|
||||
stageSize: JumpHopCanvasSize,
|
||||
) {
|
||||
return {
|
||||
x: (platform.screenX / 100) * stageSize.width,
|
||||
y: (platform.screenY / 100) * stageSize.height,
|
||||
};
|
||||
}
|
||||
|
||||
function getJumpHopScreenWorldScales(
|
||||
currentPlatform: JumpHopVisiblePlatform,
|
||||
targetPlatform: JumpHopVisiblePlatform,
|
||||
stageSize: JumpHopCanvasSize,
|
||||
) {
|
||||
const currentCanvasPosition = getJumpHopCanvasPosition(
|
||||
currentPlatform,
|
||||
stageSize,
|
||||
);
|
||||
const targetCanvasPosition = getJumpHopCanvasPosition(
|
||||
targetPlatform,
|
||||
stageSize,
|
||||
);
|
||||
const targetWorldDeltaX =
|
||||
targetPlatform.platform.x - currentPlatform.platform.x;
|
||||
const targetWorldDeltaY =
|
||||
targetPlatform.platform.y - currentPlatform.platform.y;
|
||||
const targetScreenDeltaX = targetCanvasPosition.x - currentCanvasPosition.x;
|
||||
const targetScreenDeltaY = targetCanvasPosition.y - currentCanvasPosition.y;
|
||||
const targetWorldDistance = Math.hypot(targetWorldDeltaX, targetWorldDeltaY);
|
||||
const targetScreenDistance = Math.hypot(
|
||||
targetScreenDeltaX,
|
||||
targetScreenDeltaY,
|
||||
);
|
||||
const fallbackPixelsPerWorldUnit =
|
||||
targetWorldDistance > 0.0001 && targetScreenDistance > 0.0001
|
||||
? targetScreenDistance / targetWorldDistance
|
||||
: stageSize.height * 0.18;
|
||||
const xPixelsPerWorldUnit =
|
||||
Math.abs(targetWorldDeltaX) > 0.0001 &&
|
||||
Math.abs(targetScreenDeltaX) > 0.0001
|
||||
? Math.abs(targetScreenDeltaX / targetWorldDeltaX)
|
||||
: Math.max(stageSize.width * (JUMP_HOP_SCREEN_X_WORLD_PERCENT / 100), 1);
|
||||
const yPixelsPerWorldUnit =
|
||||
Math.abs(targetWorldDeltaY) > 0.0001 &&
|
||||
Math.abs(targetScreenDeltaY) > 0.0001
|
||||
? Math.abs(targetScreenDeltaY / targetWorldDeltaY)
|
||||
: fallbackPixelsPerWorldUnit;
|
||||
const signedXScreenPerWorld =
|
||||
Math.abs(targetWorldDeltaX) > 0.0001 &&
|
||||
Math.abs(targetScreenDeltaX) > 0.0001
|
||||
? targetScreenDeltaX / targetWorldDeltaX
|
||||
: xPixelsPerWorldUnit;
|
||||
const signedYScreenPerWorld =
|
||||
Math.abs(targetWorldDeltaY) > 0.0001 &&
|
||||
Math.abs(targetScreenDeltaY) > 0.0001
|
||||
? targetScreenDeltaY / targetWorldDeltaY
|
||||
: -yPixelsPerWorldUnit;
|
||||
|
||||
return {
|
||||
currentCanvasPosition,
|
||||
targetPlatform,
|
||||
xPixelsPerWorldUnit,
|
||||
yPixelsPerWorldUnit: Math.max(yPixelsPerWorldUnit, 1),
|
||||
signedXScreenPerWorld,
|
||||
signedYScreenPerWorld,
|
||||
};
|
||||
}
|
||||
|
||||
export function getJumpHopBackendDragVector(
|
||||
run: JumpHopRuntimeRunSnapshotResponse | null,
|
||||
platforms: JumpHopVisiblePlatform[],
|
||||
stageSize: JumpHopCanvasSize,
|
||||
dragVectorX: number,
|
||||
dragVectorY: number,
|
||||
): JumpHopBackendDragVector {
|
||||
const pair = getJumpHopCurrentTargetPlatforms(run, platforms);
|
||||
if (!pair || stageSize.width <= 0 || stageSize.height <= 0) {
|
||||
return {
|
||||
dragVectorX,
|
||||
dragVectorY,
|
||||
};
|
||||
}
|
||||
|
||||
const scales = getJumpHopScreenWorldScales(
|
||||
pair.currentPlatform,
|
||||
pair.targetPlatform,
|
||||
stageSize,
|
||||
);
|
||||
|
||||
return {
|
||||
dragVectorX: dragVectorX / scales.xPixelsPerWorldUnit,
|
||||
dragVectorY: dragVectorY / scales.yPixelsPerWorldUnit,
|
||||
};
|
||||
}
|
||||
|
||||
export function getJumpHopLandingAssistVisualPosition(
|
||||
run: JumpHopRuntimeRunSnapshotResponse | null,
|
||||
platforms: JumpHopVisiblePlatform[],
|
||||
characterPosition: JumpHopCharacterVisualPosition | null,
|
||||
stageSize: JumpHopCanvasSize,
|
||||
dragDistance: number,
|
||||
dragVectorX: number | null,
|
||||
dragVectorY: number | null,
|
||||
) {
|
||||
if (
|
||||
!run ||
|
||||
run.status !== 'playing' ||
|
||||
!characterPosition ||
|
||||
stageSize.width <= 0 ||
|
||||
stageSize.height <= 0 ||
|
||||
dragDistance <= 0
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const pair = getJumpHopCurrentTargetPlatforms(run, platforms);
|
||||
if (!pair) {
|
||||
return null;
|
||||
}
|
||||
const { currentPlatform, targetPlatform } = pair;
|
||||
|
||||
const dragX = dragVectorX ?? 0;
|
||||
const dragY = dragVectorY ?? 0;
|
||||
const dragLength = Math.hypot(dragX, dragY);
|
||||
if (dragLength < 0.0001) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const scales = getJumpHopScreenWorldScales(
|
||||
currentPlatform,
|
||||
targetPlatform,
|
||||
stageSize,
|
||||
);
|
||||
const backendDragVector = getJumpHopBackendDragVector(
|
||||
run,
|
||||
platforms,
|
||||
stageSize,
|
||||
dragX,
|
||||
dragY,
|
||||
);
|
||||
const jumpWorldX = -backendDragVector.dragVectorX;
|
||||
const jumpWorldY = backendDragVector.dragVectorY;
|
||||
const jumpWorldLength = Math.hypot(jumpWorldX, jumpWorldY);
|
||||
if (jumpWorldLength < 0.0001) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const maxDragDistance =
|
||||
run.path.scoring.maxChargeMs > 0 ? run.path.scoring.maxChargeMs : 180;
|
||||
const chargeToDistanceRatio =
|
||||
run.path.scoring.chargeToDistanceRatio > 0
|
||||
? run.path.scoring.chargeToDistanceRatio
|
||||
: 0.008;
|
||||
const projectedWorldDistance =
|
||||
clamp(dragDistance, 0, maxDragDistance) * chargeToDistanceRatio;
|
||||
const landedWorldDeltaX =
|
||||
(jumpWorldX / jumpWorldLength) * projectedWorldDistance;
|
||||
const landedWorldDeltaY =
|
||||
(jumpWorldY / jumpWorldLength) * projectedWorldDistance;
|
||||
const landedPixelX =
|
||||
scales.currentCanvasPosition.x +
|
||||
landedWorldDeltaX * scales.signedXScreenPerWorld;
|
||||
const landedPixelY =
|
||||
scales.currentCanvasPosition.y +
|
||||
landedWorldDeltaY * scales.signedYScreenPerWorld;
|
||||
|
||||
return {
|
||||
screenX: clamp((landedPixelX / stageSize.width) * 100, 6, 94),
|
||||
screenY: clamp((landedPixelY / stageSize.height) * 100, 10, 92),
|
||||
targetPlatformIndex: targetPlatform.index,
|
||||
};
|
||||
}
|
||||
|
||||
export function resolveJumpHopCharacterCanvasPosition(
|
||||
characterPosition: JumpHopCharacterVisualPosition | null,
|
||||
size: JumpHopCanvasSize,
|
||||
) {
|
||||
if (!characterPosition) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
x: (characterPosition.screenX / 100) * size.width,
|
||||
y: (characterPosition.screenY / 100) * size.height,
|
||||
};
|
||||
}
|
||||
|
||||
export function getJumpHopCharacterVisualPosition(
|
||||
run: JumpHopRuntimeRunSnapshotResponse | null,
|
||||
platforms: JumpHopVisiblePlatform[],
|
||||
) {
|
||||
if (!run) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const landedPlatform = platforms.find(
|
||||
(item) => item.index === run.currentPlatformIndex,
|
||||
);
|
||||
if (landedPlatform) {
|
||||
return {
|
||||
screenX: landedPlatform.screenX,
|
||||
screenY: landedPlatform.screenY - 3,
|
||||
sceneX: landedPlatform.sceneX,
|
||||
sceneY: landedPlatform.sceneY + 0.84,
|
||||
sceneZ: landedPlatform.sceneZ,
|
||||
isMiss: false,
|
||||
};
|
||||
}
|
||||
|
||||
const lastJump = run.lastJump;
|
||||
if (lastJump && run.status === 'failed') {
|
||||
const targetPlatform = platforms.find(
|
||||
(item) => item.index === lastJump.targetPlatformIndex,
|
||||
);
|
||||
if (targetPlatform) {
|
||||
return {
|
||||
screenX: targetPlatform.screenX + 8,
|
||||
screenY: targetPlatform.screenY - 2,
|
||||
sceneX: targetPlatform.sceneX + 0.7,
|
||||
sceneY: targetPlatform.sceneY + 0.48,
|
||||
sceneZ: targetPlatform.sceneZ - 0.4,
|
||||
isMiss: true,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export function getJumpHopRunDurationMs(
|
||||
run: JumpHopRuntimeRunSnapshotResponse | null,
|
||||
nowMs: number,
|
||||
) {
|
||||
if (!run) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
if (run.status === 'playing' && run.startedAtMs > 0) {
|
||||
return Math.max(0, nowMs - run.startedAtMs);
|
||||
}
|
||||
|
||||
return run.durationMs;
|
||||
}
|
||||
|
||||
export function formatJumpHopDurationLabel(durationMs: number) {
|
||||
const safeDuration = Math.max(0, Math.floor(durationMs));
|
||||
const totalSeconds = Math.floor(safeDuration / 1000);
|
||||
const minutes = Math.floor(totalSeconds / 60)
|
||||
.toString()
|
||||
.padStart(2, '0');
|
||||
const seconds = (totalSeconds % 60).toString().padStart(2, '0');
|
||||
return `${minutes}:${seconds}`;
|
||||
}
|
||||
|
||||
export function getJumpHopStatusLabel(
|
||||
status: JumpHopRunStatus | undefined,
|
||||
) {
|
||||
if (status === 'cleared') {
|
||||
return '结束';
|
||||
}
|
||||
if (status === 'failed') {
|
||||
return '失败';
|
||||
}
|
||||
return '进行中';
|
||||
}
|
||||
|
||||
export function getJumpHopJumpFeedbackLabel(
|
||||
run: JumpHopRuntimeRunSnapshotResponse | null,
|
||||
) {
|
||||
const result = run?.lastJump?.result;
|
||||
if (result === 'perfect') {
|
||||
return '落地';
|
||||
}
|
||||
if (result === 'finish') {
|
||||
return '落地';
|
||||
}
|
||||
if (result === 'hit') {
|
||||
return '落地';
|
||||
}
|
||||
if (result === 'miss') {
|
||||
return '落空';
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function getJumpHopTileTone(tileType: JumpHopTileType) {
|
||||
return tileToneByType[tileType];
|
||||
}
|
||||
86
src/services/jump-hop/useJumpHopLeaderboard.test.tsx
Normal file
86
src/services/jump-hop/useJumpHopLeaderboard.test.tsx
Normal file
@@ -0,0 +1,86 @@
|
||||
/* @vitest-environment jsdom */
|
||||
|
||||
import { renderHook, waitFor } from '@testing-library/react';
|
||||
import { beforeEach, expect, test, vi } from 'vitest';
|
||||
|
||||
import {
|
||||
getStoredAccessToken,
|
||||
setStoredAccessToken,
|
||||
} from '../apiClient';
|
||||
import { ensureRuntimeGuestToken } from '../authService';
|
||||
import {
|
||||
jumpHopClient,
|
||||
type JumpHopLeaderboardResponse,
|
||||
} from './jumpHopClient';
|
||||
import { useJumpHopLeaderboard } from './useJumpHopLeaderboard';
|
||||
|
||||
vi.mock('../authService', () => ({
|
||||
ensureRuntimeGuestToken: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('./jumpHopClient', () => ({
|
||||
jumpHopClient: {
|
||||
getLeaderboard: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
const leaderboardResponse: JumpHopLeaderboardResponse = {
|
||||
profileId: 'jump-hop-profile-test',
|
||||
items: [
|
||||
{
|
||||
rank: 1,
|
||||
playerId: 'player-1',
|
||||
successfulJumpCount: 10,
|
||||
durationMs: 3210,
|
||||
updatedAt: '2026-05-27T00:00:00Z',
|
||||
},
|
||||
],
|
||||
viewerBest: null,
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
setStoredAccessToken('', { emit: false });
|
||||
vi.mocked(ensureRuntimeGuestToken).mockResolvedValue({
|
||||
token: 'runtime-guest-token',
|
||||
expiresAt: '2099-01-01T00:10:00Z',
|
||||
subject: 'guest-runtime-test',
|
||||
scope: 'public-play',
|
||||
});
|
||||
vi.mocked(jumpHopClient.getLeaderboard).mockResolvedValue(
|
||||
leaderboardResponse,
|
||||
);
|
||||
});
|
||||
|
||||
test('跳一跳排行榜在已有登录态时使用本地账号请求,不再额外申请 guest token', async () => {
|
||||
setStoredAccessToken('stored-access-token', { emit: false });
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useJumpHopLeaderboard('jump-hop-profile-test'),
|
||||
);
|
||||
|
||||
await waitFor(() => expect(result.current.isLoading).toBe(false));
|
||||
expect(getStoredAccessToken()).toBe('stored-access-token');
|
||||
expect(ensureRuntimeGuestToken).not.toHaveBeenCalled();
|
||||
expect(jumpHopClient.getLeaderboard).toHaveBeenCalledWith(
|
||||
'jump-hop-profile-test',
|
||||
expect.objectContaining({
|
||||
authImpact: 'local',
|
||||
skipRefresh: true,
|
||||
}),
|
||||
);
|
||||
expect(result.current.leaderboard).toEqual(leaderboardResponse);
|
||||
});
|
||||
test('跳一跳排行榜在匿名模式下会申请 guest token', async () => {
|
||||
const { result } = renderHook(() =>
|
||||
useJumpHopLeaderboard('jump-hop-profile-test'),
|
||||
);
|
||||
|
||||
await waitFor(() => expect(result.current.isLoading).toBe(false));
|
||||
expect(ensureRuntimeGuestToken).toHaveBeenCalledTimes(1);
|
||||
expect(jumpHopClient.getLeaderboard).toHaveBeenCalledWith(
|
||||
'jump-hop-profile-test',
|
||||
{ runtimeGuestToken: 'runtime-guest-token' },
|
||||
);
|
||||
expect(result.current.leaderboard).toEqual(leaderboardResponse);
|
||||
});
|
||||
85
src/services/jump-hop/useJumpHopLeaderboard.ts
Normal file
85
src/services/jump-hop/useJumpHopLeaderboard.ts
Normal file
@@ -0,0 +1,85 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
|
||||
import {
|
||||
BACKGROUND_AUTH_REQUEST_OPTIONS,
|
||||
getStoredAccessToken,
|
||||
} from '../apiClient';
|
||||
import { ensureRuntimeGuestToken } from '../authService';
|
||||
import {
|
||||
jumpHopClient,
|
||||
type JumpHopLeaderboardResponse,
|
||||
type JumpHopRuntimeRequestOptions,
|
||||
} from './jumpHopClient';
|
||||
|
||||
type JumpHopLeaderboardState = {
|
||||
leaderboard: JumpHopLeaderboardResponse | null;
|
||||
isLoading: boolean;
|
||||
error: string | null;
|
||||
refresh: () => Promise<void>;
|
||||
};
|
||||
|
||||
export function useJumpHopLeaderboard(
|
||||
profileId: string | null | undefined,
|
||||
runtimeRequestOptions?: JumpHopRuntimeRequestOptions,
|
||||
): JumpHopLeaderboardState {
|
||||
const normalizedProfileId = profileId?.trim() ?? '';
|
||||
const [leaderboard, setLeaderboard] =
|
||||
useState<JumpHopLeaderboardResponse | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const refresh = useMemo(
|
||||
() => async () => {
|
||||
if (!normalizedProfileId) {
|
||||
setLeaderboard(null);
|
||||
setError(null);
|
||||
setIsLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
if (runtimeRequestOptions) {
|
||||
const response = await jumpHopClient.getLeaderboard(
|
||||
normalizedProfileId,
|
||||
runtimeRequestOptions,
|
||||
);
|
||||
setLeaderboard(response);
|
||||
return;
|
||||
}
|
||||
|
||||
if (getStoredAccessToken()) {
|
||||
const response = await jumpHopClient.getLeaderboard(
|
||||
normalizedProfileId,
|
||||
BACKGROUND_AUTH_REQUEST_OPTIONS,
|
||||
);
|
||||
setLeaderboard(response);
|
||||
return;
|
||||
}
|
||||
|
||||
const runtimeGuest = await ensureRuntimeGuestToken();
|
||||
const response = await jumpHopClient.getLeaderboard(
|
||||
normalizedProfileId,
|
||||
{ runtimeGuestToken: runtimeGuest.token },
|
||||
);
|
||||
setLeaderboard(response);
|
||||
} catch (caughtError) {
|
||||
setError(
|
||||
caughtError instanceof Error
|
||||
? caughtError.message
|
||||
: '读取排行榜失败。',
|
||||
);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
},
|
||||
[normalizedProfileId, runtimeRequestOptions],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
void refresh();
|
||||
}, [refresh]);
|
||||
|
||||
return { leaderboard, isLoading, error, refresh };
|
||||
}
|
||||
@@ -490,7 +490,7 @@ describe('miniGameDraftGenerationProgress', () => {
|
||||
]);
|
||||
});
|
||||
|
||||
test('jump hop draft generation exposes character and tile atlas pipeline', () => {
|
||||
test('jump hop draft generation exposes theme and tile atlas pipeline', () => {
|
||||
const state = createMiniGameDraftGenerationState('jump-hop');
|
||||
|
||||
const progress = buildMiniGameDraftGenerationProgress(
|
||||
@@ -500,23 +500,20 @@ describe('miniGameDraftGenerationProgress', () => {
|
||||
|
||||
expect(progress?.steps.map((step) => step.id)).toEqual([
|
||||
'jump-hop-draft',
|
||||
'jump-hop-character',
|
||||
'jump-hop-tile-atlas',
|
||||
'jump-hop-slice-tiles',
|
||||
'jump-hop-write-draft',
|
||||
]);
|
||||
expect(progress?.phaseId).toBe('jump-hop-character');
|
||||
expect(progress?.phaseLabel).toBe('生成角色形象');
|
||||
expect(progress?.phaseId).toBe('jump-hop-tile-atlas');
|
||||
expect(progress?.phaseLabel).toBe('生成 5x5 地块图集');
|
||||
expect(progress?.estimatedRemainingMs).toBe(265_000);
|
||||
});
|
||||
|
||||
test('jump hop generation anchors expose theme, character and tile style', () => {
|
||||
test('jump hop generation anchors expose theme and tile atlas', () => {
|
||||
const entries = buildJumpHopGenerationAnchorEntries(null, {
|
||||
themeText: '云端糖果塔',
|
||||
characterDescription: '披着星星披风的小旅人',
|
||||
tileStyle: '纸模玩具',
|
||||
difficulty: '标准',
|
||||
rhythmPreference: '轻快',
|
||||
templateId: 'jump-hop',
|
||||
tilePrompt: '云端糖果塔主题的正面30度视角主题物体图集,物体本身作为跳跃落点',
|
||||
});
|
||||
|
||||
expect(entries).toEqual([
|
||||
@@ -525,15 +522,10 @@ describe('miniGameDraftGenerationProgress', () => {
|
||||
label: '主题',
|
||||
value: '云端糖果塔',
|
||||
},
|
||||
{
|
||||
id: 'jump-hop-character',
|
||||
label: '角色',
|
||||
value: '披着星星披风的小旅人',
|
||||
},
|
||||
{
|
||||
id: 'jump-hop-tile-style',
|
||||
label: '地块',
|
||||
value: '纸模玩具',
|
||||
label: '地块图集',
|
||||
value: '云端糖果塔主题的正面30度视角主题物体图集,物体本身作为跳跃落点',
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
@@ -68,7 +68,6 @@ export type MiniGameDraftGenerationPhase =
|
||||
| 'baby-object-images'
|
||||
| 'baby-object-ready'
|
||||
| 'jump-hop-draft'
|
||||
| 'jump-hop-character'
|
||||
| 'jump-hop-tile-atlas'
|
||||
| 'jump-hop-slice-tiles'
|
||||
| 'jump-hop-write-draft'
|
||||
@@ -404,32 +403,26 @@ const JUMP_HOP_STEPS = [
|
||||
{
|
||||
id: 'jump-hop-draft',
|
||||
label: '整理玩法草稿',
|
||||
detail: '建立主题、难度和路径基础数据。',
|
||||
weight: 10,
|
||||
},
|
||||
{
|
||||
id: 'jump-hop-character',
|
||||
label: '生成角色形象',
|
||||
detail: '生成可进入运行态的俯视角角色图。',
|
||||
weight: 34,
|
||||
detail: '保存主题并派生作品信息和默认角色配置。',
|
||||
weight: 12,
|
||||
},
|
||||
{
|
||||
id: 'jump-hop-tile-atlas',
|
||||
label: '生成地块图集',
|
||||
detail: '生成起点、普通、目标和终点地块图集。',
|
||||
weight: 34,
|
||||
label: '生成 5x5 地块图集',
|
||||
detail: '调用 image2 生成 25 个主题地块素材。',
|
||||
weight: 54,
|
||||
},
|
||||
{
|
||||
id: 'jump-hop-slice-tiles',
|
||||
label: '切分地块素材',
|
||||
detail: '切分透明地块 PNG 并校验落点半径。',
|
||||
weight: 14,
|
||||
label: '切分 25 个地块',
|
||||
detail: '按 5 行 5 列切分透明地块 PNG。',
|
||||
weight: 24,
|
||||
},
|
||||
{
|
||||
id: 'jump-hop-write-draft',
|
||||
label: '写入正式草稿',
|
||||
detail: '保存角色、地块、路径和封面合成结果。',
|
||||
weight: 8,
|
||||
detail: '保存地块池、无限路径缓冲和运行态配置。',
|
||||
weight: 10,
|
||||
},
|
||||
] as const satisfies ReadonlyArray<MiniGameStepDefinition>;
|
||||
|
||||
@@ -709,7 +702,7 @@ function resolveJumpHopPhaseByElapsedMs(
|
||||
return 'jump-hop-tile-atlas';
|
||||
}
|
||||
if (elapsedMs >= 12_000) {
|
||||
return 'jump-hop-character';
|
||||
return 'jump-hop-tile-atlas';
|
||||
}
|
||||
return 'jump-hop-draft';
|
||||
}
|
||||
@@ -1188,21 +1181,12 @@ export function buildJumpHopGenerationAnchorEntries(
|
||||
draft?.workTitle?.trim() ||
|
||||
'',
|
||||
},
|
||||
{
|
||||
key: 'jump-hop-character',
|
||||
label: '角色',
|
||||
value:
|
||||
formPayload?.characterDescription?.trim() ||
|
||||
config?.characterDescription?.trim() ||
|
||||
draft?.characterPrompt?.trim() ||
|
||||
'',
|
||||
},
|
||||
{
|
||||
key: 'jump-hop-tile-style',
|
||||
label: '地块',
|
||||
label: '地块图集',
|
||||
value:
|
||||
formPayload?.tileStyle?.trim() ||
|
||||
config?.tileStyle?.trim() ||
|
||||
formPayload?.tilePrompt?.trim() ||
|
||||
config?.tilePrompt?.trim() ||
|
||||
draft?.stylePreset?.trim() ||
|
||||
'',
|
||||
},
|
||||
|
||||
@@ -50,3 +50,16 @@ test('wooden fish list works uses creation works endpoint', async () => {
|
||||
'读取敲木鱼作品列表失败',
|
||||
);
|
||||
});
|
||||
|
||||
test('wooden fish delete work uses creation works endpoint', async () => {
|
||||
const { woodenFishClient } = await import('./woodenFishClient');
|
||||
requestJsonMock.mockResolvedValueOnce({ items: [] });
|
||||
|
||||
await woodenFishClient.deleteWork('wooden-fish-profile-1');
|
||||
|
||||
expect(requestJsonMock).toHaveBeenCalledWith(
|
||||
'/api/creation/wooden-fish/works/wooden-fish-profile-1',
|
||||
{ method: 'DELETE' },
|
||||
'删除敲木鱼作品失败',
|
||||
);
|
||||
});
|
||||
|
||||
@@ -233,6 +233,14 @@ export async function publishWoodenFishWork(profileId: string) {
|
||||
return normalizeWoodenFishWorkMutationResponse(response);
|
||||
}
|
||||
|
||||
export async function deleteWoodenFishWork(profileId: string) {
|
||||
return requestJson<WoodenFishWorksResponse>(
|
||||
`${WOODEN_FISH_WORKS_API_BASE}/${encodeURIComponent(profileId)}`,
|
||||
{ method: 'DELETE' },
|
||||
'删除敲木鱼作品失败',
|
||||
);
|
||||
}
|
||||
|
||||
export async function startWoodenFishRuntimeRun(
|
||||
profileId: string,
|
||||
options: WoodenFishRuntimeRequestOptions = {},
|
||||
@@ -317,6 +325,7 @@ export async function finishWoodenFishRun(
|
||||
export const woodenFishClient = {
|
||||
checkpointRun: checkpointWoodenFishRun,
|
||||
createSession: createWoodenFishCreationSession,
|
||||
deleteWork: deleteWoodenFishWork,
|
||||
executeAction: executeWoodenFishCreationAction,
|
||||
finishRun: finishWoodenFishRun,
|
||||
getGalleryDetail: getWoodenFishGalleryDetail,
|
||||
|
||||
Reference in New Issue
Block a user