feat(jump-hop): redesign sling platform gameplay

This commit is contained in:
2026-06-03 22:21:00 +08:00
parent 40ef89aeb5
commit 7d2d67a3f5
59 changed files with 6930 additions and 1973 deletions

View File

@@ -0,0 +1,60 @@
/* @vitest-environment jsdom */
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
import { beforeEach, expect, test, vi } from 'vitest';
import { jumpHopClient } from '../../services/jump-hop/jumpHopClient';
import { JumpHopWorkspace } from './JumpHopWorkspace';
vi.mock('../../services/jump-hop/jumpHopClient', () => ({
jumpHopClient: {
createSession: vi.fn(),
},
}));
beforeEach(() => {
vi.mocked(jumpHopClient.createSession).mockReset();
vi.mocked(jumpHopClient.createSession).mockResolvedValue({
session: {
sessionId: 'jump-hop-session-test',
ownerUserId: 'user-test',
status: 'draft',
draft: null,
createdAt: '2026-05-27T00:00:00Z',
updatedAt: '2026-05-27T00:00:00Z',
},
});
});
test('跳一跳工作台只保留主题输入并自动派生提交 payload', async () => {
const onSubmitted = vi.fn();
render(
<JumpHopWorkspace onBack={() => {}} onSubmitted={onSubmitted} />,
);
expect(screen.getByLabelText('主题')).toBeTruthy();
expect(screen.queryByLabelText('作品标题')).toBeNull();
expect(screen.queryByLabelText('作品简介')).toBeNull();
expect(screen.queryByLabelText('角色提示词')).toBeNull();
expect(screen.queryByLabelText('地块提示词')).toBeNull();
fireEvent.change(screen.getByLabelText('主题'), {
target: { value: '竹林茶馆' },
});
fireEvent.click(screen.getByRole('button', { name: '生成' }));
await waitFor(() => expect(onSubmitted).toHaveBeenCalledTimes(1));
expect(jumpHopClient.createSession).toHaveBeenCalledWith({
templateId: 'jump-hop',
themeText: '竹林茶馆',
workTitle: '竹林茶馆跳一跳',
workDescription: '竹林茶馆主题的俯视角平台跳跃作品',
themeTags: ['竹林茶馆', '跳一跳', '休闲'],
difficulty: 'standard',
stylePreset: 'minimal-blocks',
characterPrompt: '内置默认 3D 角色',
tilePrompt: '竹林茶馆主题的俯视角清爽游戏化立体感平台素材',
endMoodPrompt: null,
});
});

View File

@@ -2,9 +2,7 @@ import { ArrowLeft, Loader2, Send } from 'lucide-react';
import { useMemo, useState } from 'react';
import type {
JumpHopDifficulty,
JumpHopSessionResponse,
JumpHopStylePreset,
JumpHopWorkspaceCreateRequest,
} from '../../../packages/shared/src/contracts/jumpHop';
import { jumpHopClient } from '../../services/jump-hop/jumpHopClient';
@@ -20,27 +18,31 @@ type JumpHopWorkspaceProps = {
};
type JumpHopWorkspaceFormState = {
workTitle: string;
workDescription: string;
themeTags: string;
difficulty: JumpHopDifficulty;
stylePreset: JumpHopStylePreset;
characterPrompt: string;
tilePrompt: string;
endMoodPrompt: string;
themeText: string;
};
const DEFAULT_FORM_STATE: JumpHopWorkspaceFormState = {
workTitle: '',
workDescription: '',
themeTags: '',
difficulty: 'easy',
stylePreset: 'minimal-blocks',
characterPrompt: '',
tilePrompt: '',
endMoodPrompt: '',
themeText: '',
};
function buildJumpHopWorkspacePayload(
formState: JumpHopWorkspaceFormState,
): JumpHopWorkspaceCreateRequest {
const themeText = formState.themeText.trim();
return {
templateId: 'jump-hop',
themeText,
workTitle: `${themeText}跳一跳`,
workDescription: `${themeText}主题的俯视角平台跳跃作品`,
themeTags: [themeText, '跳一跳', '休闲'],
difficulty: 'standard',
stylePreset: 'minimal-blocks',
characterPrompt: '内置默认 3D 角色',
tilePrompt: `${themeText}主题的俯视角清爽游戏化立体感平台素材`,
endMoodPrompt: null,
};
}
export function JumpHopWorkspace({
isBusy = false,
error = null,
@@ -52,14 +54,7 @@ export function JumpHopWorkspace({
const [isSubmitting, setIsSubmitting] = useState(false);
const canSubmit = useMemo(
() =>
Boolean(
formState.workTitle.trim() &&
formState.workDescription.trim() &&
formState.themeTags.trim() &&
formState.characterPrompt.trim() &&
formState.tilePrompt.trim(),
),
() => Boolean(formState.themeText.trim()),
[formState],
);
@@ -73,20 +68,7 @@ export function JumpHopWorkspace({
setLocalError(null);
try {
const payload: JumpHopWorkspaceCreateRequest = {
templateId: 'jump-hop',
workTitle: formState.workTitle.trim(),
workDescription: formState.workDescription.trim(),
themeTags: formState.themeTags
.split(/[,、\s]+/)
.map((item) => item.trim())
.filter(Boolean),
difficulty: formState.difficulty,
stylePreset: formState.stylePreset,
characterPrompt: formState.characterPrompt.trim(),
tilePrompt: formState.tilePrompt.trim(),
endMoodPrompt: formState.endMoodPrompt.trim() || null,
};
const payload = buildJumpHopWorkspacePayload(formState);
const response = await jumpHopClient.createSession(payload);
onSubmitted(response, payload);
} catch (caughtError) {
@@ -111,143 +93,22 @@ export function JumpHopWorkspace({
</button>
</div>
<div className="grid gap-3 sm:grid-cols-2">
<div className="grid gap-3">
<label className="block sm:col-span-2">
<span className="text-xs font-bold tracking-[0.18em] text-[var(--platform-text-soft)]">
</span>
<input
value={formState.workTitle}
value={formState.themeText}
onChange={(event) =>
setFormState((current) => ({
...current,
workTitle: event.target.value,
themeText: event.target.value,
}))
}
className="mt-2 w-full rounded-[1rem] border border-[var(--platform-subpanel-border)] bg-white/90 px-3 py-3 text-sm font-semibold text-[var(--platform-text-strong)] outline-none"
/>
</label>
<label className="block sm:col-span-2">
<span className="text-xs font-bold tracking-[0.18em] text-[var(--platform-text-soft)]">
</span>
<textarea
value={formState.workDescription}
onChange={(event) =>
setFormState((current) => ({
...current,
workDescription: event.target.value,
}))
}
rows={3}
className="mt-2 w-full resize-none rounded-[1rem] border border-[var(--platform-subpanel-border)] bg-white/90 px-3 py-3 text-sm leading-6 text-[var(--platform-text-strong)] outline-none"
/>
</label>
<label className="block sm:col-span-2">
<span className="text-xs font-bold tracking-[0.18em] text-[var(--platform-text-soft)]">
</span>
<input
value={formState.themeTags}
onChange={(event) =>
setFormState((current) => ({
...current,
themeTags: event.target.value,
}))
}
className="mt-2 w-full rounded-[1rem] border border-[var(--platform-subpanel-border)] bg-white/90 px-3 py-3 text-sm font-semibold text-[var(--platform-text-strong)] outline-none"
/>
</label>
<label className="block">
<span className="text-xs font-bold tracking-[0.18em] text-[var(--platform-text-soft)]">
</span>
<select
value={formState.difficulty}
onChange={(event) =>
setFormState((current) => ({
...current,
difficulty: event.target.value as JumpHopDifficulty,
}))
}
className="mt-2 w-full rounded-[1rem] border border-[var(--platform-subpanel-border)] bg-white/90 px-3 py-3 text-sm font-semibold text-[var(--platform-text-strong)] outline-none"
>
<option value="easy">easy</option>
<option value="standard">standard</option>
<option value="advanced">advanced</option>
<option value="challenge">challenge</option>
</select>
</label>
<label className="block">
<span className="text-xs font-bold tracking-[0.18em] text-[var(--platform-text-soft)]">
</span>
<select
value={formState.stylePreset}
onChange={(event) =>
setFormState((current) => ({
...current,
stylePreset: event.target.value as JumpHopStylePreset,
}))
}
className="mt-2 w-full rounded-[1rem] border border-[var(--platform-subpanel-border)] bg-white/90 px-3 py-3 text-sm font-semibold text-[var(--platform-text-strong)] outline-none"
>
<option value="minimal-blocks">minimal-blocks</option>
<option value="paper-toy">paper-toy</option>
<option value="neon-glass">neon-glass</option>
<option value="forest-stone">forest-stone</option>
<option value="future-metal">future-metal</option>
<option value="custom">custom</option>
</select>
</label>
<label className="block sm:col-span-2">
<span className="text-xs font-bold tracking-[0.18em] text-[var(--platform-text-soft)]">
</span>
<textarea
value={formState.characterPrompt}
onChange={(event) =>
setFormState((current) => ({
...current,
characterPrompt: event.target.value,
}))
}
rows={3}
className="mt-2 w-full resize-none rounded-[1rem] border border-[var(--platform-subpanel-border)] bg-white/90 px-3 py-3 text-sm leading-6 text-[var(--platform-text-strong)] outline-none"
/>
</label>
<label className="block sm:col-span-2">
<span className="text-xs font-bold tracking-[0.18em] text-[var(--platform-text-soft)]">
</span>
<textarea
value={formState.tilePrompt}
onChange={(event) =>
setFormState((current) => ({
...current,
tilePrompt: event.target.value,
}))
}
rows={3}
className="mt-2 w-full resize-none rounded-[1rem] border border-[var(--platform-subpanel-border)] bg-white/90 px-3 py-3 text-sm leading-6 text-[var(--platform-text-strong)] outline-none"
/>
</label>
<label className="block sm:col-span-2">
<span className="text-xs font-bold tracking-[0.18em] text-[var(--platform-text-soft)]">
</span>
<textarea
value={formState.endMoodPrompt}
onChange={(event) =>
setFormState((current) => ({
...current,
endMoodPrompt: event.target.value,
}))
}
rows={2}
className="mt-2 w-full resize-none rounded-[1rem] border border-[var(--platform-subpanel-border)] bg-white/90 px-3 py-3 text-sm leading-6 text-[var(--platform-text-strong)] outline-none"
/>
</label>
</div>
{localError || error ? (

View File

@@ -0,0 +1,180 @@
/* @vitest-environment jsdom */
import { render, screen } from '@testing-library/react';
import { expect, test, vi } from 'vitest';
import type { JumpHopWorkProfileResponse } from '../../../packages/shared/src/contracts/jumpHop';
import { useJumpHopLeaderboard } from '../../services/jump-hop/useJumpHopLeaderboard';
import { JumpHopResultView } from './JumpHopResultView';
vi.mock('../../services/jump-hop/useJumpHopLeaderboard', () => ({
useJumpHopLeaderboard: vi.fn(),
}));
test('跳一跳结果页展示排行榜列表', () => {
vi.mocked(useJumpHopLeaderboard).mockReturnValue({
leaderboard: {
profileId: 'jump-hop-profile-test',
items: [
{
rank: 1,
playerId: 'player-1',
successfulJumpCount: 12,
durationMs: 40123,
updatedAt: '2026-05-27T00:00:00Z',
},
{
rank: 2,
playerId: 'player-2',
successfulJumpCount: 10,
durationMs: 38210,
updatedAt: '2026-05-26T00:00:00Z',
},
],
viewerBest: null,
},
isLoading: false,
error: null,
refresh: vi.fn(),
});
render(
<JumpHopResultView
profile={buildProfile()}
onBack={() => {}}
onEdit={() => {}}
onStartTestRun={() => {}}
onPublish={() => {}}
onRegenerateTiles={() => {}}
/>,
);
expect(screen.getByText('排行榜')).toBeTruthy();
expect(screen.getByText('player-1')).toBeTruthy();
expect(screen.getByText('12 跳')).toBeTruthy();
expect(screen.getByText('00:40')).toBeTruthy();
expect(screen.getByText('player-2')).toBeTruthy();
});
test('跳一跳结果页默认角色预览使用陶泥儿透明 logo', () => {
vi.mocked(useJumpHopLeaderboard).mockReturnValue({
leaderboard: null,
isLoading: false,
error: null,
refresh: vi.fn(),
});
render(
<JumpHopResultView
profile={buildProfile()}
onBack={() => {}}
onEdit={() => {}}
onStartTestRun={() => {}}
onPublish={() => {}}
onRegenerateTiles={() => {}}
/>,
);
expect(screen.getByTestId('jump-hop-result-character-logo').getAttribute('src')).toBe(
'/branding/jump-hop-taonier-character.png',
);
});
function buildProfile(): JumpHopWorkProfileResponse {
return {
summary: {
runtimeKind: 'jump-hop',
workId: 'jump-hop-profile-test',
profileId: 'jump-hop-profile-test',
ownerUserId: 'user-test',
sourceSessionId: 'jump-hop-session-test',
themeText: '测试',
workTitle: '测试',
workDescription: '测试',
themeTags: ['测试'],
difficulty: 'standard',
stylePreset: 'minimal-blocks',
coverImageSrc: null,
publicationStatus: 'draft',
playCount: 0,
updatedAt: '2026-05-27T00:00:00Z',
publishedAt: null,
publishReady: true,
generationStatus: 'ready',
},
draft: {
templateId: 'jump-hop',
templateName: '跳一跳',
profileId: 'jump-hop-profile-test',
themeText: '测试',
workTitle: '测试',
workDescription: '测试',
themeTags: ['测试'],
difficulty: 'standard',
stylePreset: 'minimal-blocks',
defaultCharacter: {
characterId: 'jump-hop-default-runner',
displayName: '默认角色',
modelKind: 'builtin-three',
bodyColor: '#f59e0b',
accentColor: '#2563eb',
},
characterPrompt: '默认角色',
tilePrompt: '地块',
endMoodPrompt: null,
characterAsset: {
assetId: 'builtin',
imageSrc: 'builtin://jump-hop/default-character',
imageObjectKey: '',
assetObjectId: 'builtin',
generationProvider: 'builtin-three',
prompt: '默认角色',
width: 0,
height: 0,
},
tileAtlasAsset: {
assetId: 'builtin',
imageSrc: 'builtin://jump-hop/default-character',
imageObjectKey: '',
assetObjectId: 'builtin',
generationProvider: 'builtin-three',
prompt: '默认角色',
width: 0,
height: 0,
},
tileAssets: [],
path: null,
coverComposite: null,
generationStatus: 'ready',
},
path: null as never,
defaultCharacter: {
characterId: 'jump-hop-default-runner',
displayName: '默认角色',
modelKind: 'builtin-three',
bodyColor: '#f59e0b',
accentColor: '#2563eb',
},
characterAsset: {
assetId: 'builtin',
imageSrc: 'builtin://jump-hop/default-character',
imageObjectKey: '',
assetObjectId: 'builtin',
generationProvider: 'builtin-three',
prompt: '默认角色',
width: 0,
height: 0,
},
tileAtlasAsset: {
assetId: 'builtin',
imageSrc: 'builtin://jump-hop/default-character',
imageObjectKey: '',
assetObjectId: 'builtin',
generationProvider: 'builtin-three',
prompt: '默认角色',
width: 0,
height: 0,
},
tileAssets: [],
};
}

View File

@@ -2,18 +2,22 @@ import {
ArrowLeft,
Loader2,
Play,
RefreshCcw,
Send,
Shuffle,
} from 'lucide-react';
import { type CSSProperties, useMemo, useState } from 'react';
import { type CSSProperties, useState } from 'react';
import type {
JumpHopDraftResponse,
JumpHopPath,
JumpHopPlatform,
JumpHopTileAsset,
JumpHopWorkProfileResponse,
} from '../../../packages/shared/src/contracts/jumpHop';
import {
formatJumpHopDurationLabel,
selectJumpHopTileAsset,
} from '../../services/jump-hop/jumpHopRuntimeModel';
import { useJumpHopLeaderboard } from '../../services/jump-hop/useJumpHopLeaderboard';
import { ResolvedAssetImage } from '../ResolvedAssetImage';
type JumpHopResultViewProps = {
@@ -34,7 +38,6 @@ type JumpHopResultViewProps = {
onEdit: () => void;
onStartTestRun: () => void;
onPublish: () => void;
onRegenerateCharacter: () => void;
onRegenerateTiles: () => void;
};
@@ -44,43 +47,6 @@ function isJumpHopWorkProfile(
return 'summary' in profile;
}
type MiniMapPlatform = {
platform: JumpHopPlatform;
index: number;
x: number;
y: number;
width: number;
height: number;
isStart: boolean;
isFinish: boolean;
};
const difficultyToneByValue: Record<
JumpHopPath['difficulty'],
{ accent: string; soft: string; label: string }
> = {
advanced: {
accent: '#df7f40',
soft: 'rgba(249, 115, 22, 0.16)',
label: '进阶',
},
challenge: {
accent: '#b64a35',
soft: 'rgba(182, 98, 63, 0.16)',
label: '挑战',
},
easy: {
accent: '#14b8a6',
soft: 'rgba(20, 184, 166, 0.16)',
label: '轻松',
},
standard: {
accent: '#2563eb',
soft: 'rgba(37, 99, 235, 0.16)',
label: '标准',
},
};
const tileToneByType: Record<string, string> = {
accent: '#c4b5fd',
bonus: '#fde68a',
@@ -90,155 +56,191 @@ const tileToneByType: Record<string, string> = {
target: '#fecdd3',
};
function isFiniteNumber(value: unknown): value is number {
return typeof value === 'number' && Number.isFinite(value);
const JUMP_HOP_TAONIER_CHARACTER_IMAGE_SRC =
'/branding/jump-hop-taonier-character.png';
function JumpHopDefaultCharacterPreview() {
return (
<div className="relative grid aspect-[1/1] place-items-center overflow-hidden bg-[linear-gradient(180deg,#eff6ff_0%,#fff7ed_100%)]">
<div className="absolute inset-x-[18%] bottom-[14%] h-[14%] rounded-full bg-slate-900/12 blur-[2px]" />
<img
src={JUMP_HOP_TAONIER_CHARACTER_IMAGE_SRC}
alt=""
draggable={false}
className="relative z-10 h-[78%] w-[78%] object-contain drop-shadow-[0_12px_18px_rgba(146,64,14,0.2)]"
data-testid="jump-hop-result-character-logo"
/>
</div>
);
}
function normalizePathPlatforms(path: JumpHopPath | null | undefined) {
const platforms = path?.platforms ?? [];
if (platforms.length === 0) {
return [];
function JumpHopTilePoolPreview({
tileAssets,
tileAtlasAsset,
tileAtlasFallbackSrc,
}: {
tileAssets: JumpHopTileAsset[];
tileAtlasAsset?: JumpHopDraftResponse['tileAtlasAsset'] | null;
tileAtlasFallbackSrc?: string | null;
}) {
const visibleTiles = tileAssets.slice(0, 25);
const atlasSrc =
tileAtlasAsset?.imageSrc?.trim() || tileAtlasFallbackSrc?.trim() || '';
const atlasRefreshKey = tileAtlasAsset?.assetObjectId || atlasSrc;
if (visibleTiles.length > 0) {
return (
<div className="grid aspect-[1/1] grid-cols-5 gap-1 bg-white/78 p-2">
{visibleTiles.map((tile, index) => (
<div
key={tile.tileId ?? `${tile.sourceAtlasCell}-${index}`}
className="grid min-h-0 place-items-center overflow-hidden rounded-[0.45rem] border border-white/80 bg-slate-50"
>
{tile.imageSrc ? (
<ResolvedAssetImage
src={tile.imageSrc}
refreshKey={tile.assetObjectId}
alt=""
className="h-full w-full object-contain"
/>
) : (
<span
className="h-4 w-4 rounded-full"
style={{
background:
tileToneByType[tile.tileType] ?? tileToneByType.normal,
}}
/>
)}
</div>
))}
</div>
);
}
const coordinatePlatforms = platforms.filter(
(platform) => isFiniteNumber(platform.x) && isFiniteNumber(platform.y),
);
const shouldUseCoordinates = coordinatePlatforms.length >= 2;
const xValues = shouldUseCoordinates
? coordinatePlatforms.map((platform) => platform.x)
: [];
const yValues = shouldUseCoordinates
? coordinatePlatforms.map((platform) => platform.y)
: [];
const minX = Math.min(...xValues);
const maxX = Math.max(...xValues);
const minY = Math.min(...yValues);
const maxY = Math.max(...yValues);
const xRange = Math.max(maxX - minX, 1);
const yRange = Math.max(maxY - minY, 1);
const denominator = Math.max(platforms.length - 1, 1);
return platforms.map((platform, index): MiniMapPlatform => {
const sequenceRatio = index / denominator;
const hasCoordinates =
shouldUseCoordinates &&
isFiniteNumber(platform.x) &&
isFiniteNumber(platform.y);
const x = hasCoordinates
? 12 + ((platform.x - minX) / xRange) * 76
: 12 + sequenceRatio * 76;
const y = hasCoordinates
? 14 + ((platform.y - minY) / yRange) * 72
: 50 + Math.sin(sequenceRatio * Math.PI * 2.3) * 18;
return {
platform,
index,
x,
y,
width: Math.min(Math.max(platform.width || 54, 42), 82),
height: Math.min(Math.max(platform.height || 42, 34), 68),
isStart: index === 0 || platform.tileType === 'start',
isFinish:
index === path?.finishIndex ||
platform.tileType === 'finish' ||
platform.tileType === 'target',
};
});
}
function JumpHopPathMiniMap({ path }: { path: JumpHopPath }) {
const platforms = useMemo(() => normalizePathPlatforms(path), [path]);
const tone =
difficultyToneByValue[path.difficulty] ?? difficultyToneByValue.standard;
const pathPoints = platforms
.map((platform) => `${platform.x},${platform.y}`)
.join(' ');
if (platforms.length === 0) {
return null;
if (atlasSrc) {
return (
<ResolvedAssetImage
src={atlasSrc}
refreshKey={atlasRefreshKey}
alt=""
className="aspect-[1/1] w-full object-cover"
/>
);
}
return (
<div
className="relative aspect-[1/1] w-full overflow-hidden bg-[linear-gradient(180deg,#f8fbff_0%,#eef8ff_100%)]"
style={
{
'--jump-hop-path-accent': tone.accent,
'--jump-hop-path-soft': tone.soft,
} as CSSProperties
}
>
<div className="absolute inset-0 bg-[radial-gradient(circle_at_24%_18%,rgba(255,255,255,0.92),transparent_28%),radial-gradient(circle_at_75%_78%,rgba(125,211,252,0.24),transparent_32%)]" />
<svg
viewBox="0 0 100 100"
className="absolute inset-0 h-full w-full"
aria-hidden="true"
>
<polyline
points={pathPoints}
fill="none"
stroke="var(--jump-hop-path-soft)"
strokeWidth="11"
strokeLinecap="round"
strokeLinejoin="round"
<div className="grid aspect-[1/1] grid-cols-5 gap-1 bg-white/78 p-2">
{Array.from({ length: 25 }).map((_, index) => (
<span
key={index}
className="rounded-[0.45rem] border border-white/80"
style={{
background:
Object.values(tileToneByType)[index % Object.values(tileToneByType).length],
}}
/>
<polyline
points={pathPoints}
fill="none"
stroke="var(--jump-hop-path-accent)"
strokeWidth="2.6"
strokeLinecap="round"
strokeLinejoin="round"
strokeDasharray="4 4"
/>
</svg>
{platforms.map((item) => {
const tileTone =
tileToneByType[item.platform.tileType] ?? tileToneByType.normal;
const scoreBoost =
isFiniteNumber(item.platform.scoreValue) &&
item.platform.scoreValue > 1;
const style = {
left: `${item.x}%`,
top: `${item.y}%`,
width: `${item.width}%`,
height: `${item.height}%`,
background: tileTone,
borderColor: item.isFinish ? tone.accent : 'rgba(255,255,255,0.92)',
zIndex: 10 + item.index,
} as CSSProperties;
))}
</div>
);
}
function JumpHopFirstPlatformsPreview({
path,
tileAssets,
}: {
path: JumpHopPath | null | undefined;
tileAssets: JumpHopTileAsset[];
}) {
const platforms = (path?.platforms ?? []).slice(0, 3);
return (
<div className="relative aspect-[1/1] overflow-hidden bg-[linear-gradient(180deg,#f8fbff_0%,#eef8ff_100%)]">
<div className="absolute inset-0 bg-[radial-gradient(circle_at_50%_30%,rgba(255,255,255,0.92),transparent_34%)]" />
{platforms.map((platform, index) => {
const asset = selectJumpHopTileAsset(
tileAssets,
path?.seed,
index,
platform.platformId,
);
const style = {
left: `${50 + (index - 1) * 24}%`,
top: `${68 - index * 22}%`,
width: `${34 - index * 3}%`,
zIndex: 10 + index,
} as CSSProperties;
return (
<div
key={
item.platform.platformId ||
`${item.index}-${item.platform.tileType}`
}
className="absolute grid max-h-9 max-w-11 min-h-6 min-w-7 -translate-x-1/2 -translate-y-1/2 place-items-center rounded-[0.72rem] border-2 shadow-[0_8px_18px_rgba(15,23,42,0.13)]"
key={platform.platformId || index}
className="absolute aspect-[1.16/1] -translate-x-1/2 -translate-y-1/2"
style={style}
>
<span
className="h-2.5 w-2.5 rounded-full"
style={{
background:
item.isStart || item.isFinish ? tone.accent : '#ffffff',
boxShadow: scoreBoost ? `0 0 0 4px ${tone.soft}` : undefined,
}}
/>
{item.isStart || item.isFinish ? (
<span className="absolute -top-2.5 rounded-full bg-slate-950/78 px-1.5 py-0.5 text-[0.58rem] font-black leading-none text-white">
{item.isStart ? '起' : '终'}
</span>
) : null}
<div className="absolute inset-x-[12%] bottom-[-6%] h-[22%] rounded-full bg-slate-900/14 blur-[3px]" />
{asset?.imageSrc ? (
<ResolvedAssetImage
src={asset.imageSrc}
refreshKey={asset.assetObjectId}
alt=""
className="relative h-full w-full object-contain"
/>
) : (
<div
className="relative h-full w-full rounded-[18%] border-2 border-white/90 shadow-[0_10px_22px_rgba(15,23,42,0.14)]"
style={{
background:
tileToneByType[platform.tileType] ?? tileToneByType.normal,
}}
/>
)}
</div>
);
})}
<div className="absolute left-2 top-2 rounded-full border border-white/80 bg-white/82 px-2 py-1 text-[0.62rem] font-black text-[var(--platform-text-strong)] shadow-sm">
{tone.label}
{platforms.length === 0 ? (
<div className="absolute inset-0 grid place-items-center text-sm font-bold text-[var(--platform-text-soft)]">
</div>
) : null}
</div>
);
}
function JumpHopResultLeaderboard({
profileId,
}: {
profileId?: string | null;
}) {
const { leaderboard, isLoading, error } = useJumpHopLeaderboard(profileId);
const items = leaderboard?.items ?? [];
return (
<div className="mt-4 rounded-[1rem] border border-[var(--platform-subpanel-border)] bg-white/70 p-3">
<div className="flex items-center justify-between gap-2">
<div className="text-sm font-black text-[var(--platform-text-strong)]">
</div>
{isLoading ? (
<Loader2 className="h-4 w-4 animate-spin text-[var(--platform-text-soft)]" />
) : null}
</div>
<div className="absolute bottom-2 right-2 rounded-full border border-white/80 bg-white/82 px-2 py-1 text-[0.62rem] font-black text-[var(--platform-text-base)] shadow-sm">
{platforms.length}
<div className="mt-3 grid gap-2">
{items.slice(0, 5).map((entry) => (
<div
key={`${entry.rank}-${entry.playerId}`}
className="grid grid-cols-[1.8rem_minmax(0,1fr)_auto_auto] items-center gap-2 rounded-[0.75rem] bg-white/70 px-2 py-2 text-xs font-bold text-[var(--platform-text-base)]"
>
<span className="text-[var(--platform-text-soft)]">
{entry.rank}
</span>
<span className="truncate">{entry.playerId}</span>
<span>{entry.successfulJumpCount} </span>
<span>{formatJumpHopDurationLabel(entry.durationMs)}</span>
</div>
))}
{items.length === 0 ? (
<div className="rounded-[0.75rem] bg-white/60 px-2 py-2 text-xs font-bold text-[var(--platform-text-soft)]">
{error ?? '暂无成绩'}
</div>
) : null}
</div>
</div>
);
@@ -252,7 +254,6 @@ export function JumpHopResultView({
onEdit,
onStartTestRun,
onPublish,
onRegenerateCharacter,
onRegenerateTiles,
}: JumpHopResultViewProps) {
const [isPublishing, setIsPublishing] = useState(false);
@@ -264,12 +265,13 @@ export function JumpHopResultView({
path: NonNullable<JumpHopDraftResponse['path']>;
};
const path = isWorkProfile ? profile.path : safeDraft.path;
const characterAsset = isWorkProfile
? profile.characterAsset
: safeDraft.characterAsset;
const tileAtlasAsset = isWorkProfile
? profile.tileAtlasAsset
: safeDraft.tileAtlasAsset;
const tileAssets = isWorkProfile ? profile.tileAssets : safeDraft.tileAssets;
const profileId = isWorkProfile
? profile.summary.profileId
: safeDraft.profileId;
const titleSource = isWorkProfile
? profile.summary.workTitle
: profile.workTitle;
@@ -278,15 +280,12 @@ export function JumpHopResultView({
: profile.workDescription;
const title = titleSource?.trim() || safeDraft.workTitle.trim() || '跳一跳';
const summary = summarySource?.trim() || safeDraft.workDescription.trim();
const pathPlatforms = normalizePathPlatforms(path);
const canRenderPathMiniMap = pathPlatforms.length > 0;
const hasAssets = Boolean(
profile.characterImageSrc?.trim() ||
profile.tileAtlasImageSrc?.trim() ||
profile.tileAtlasImageSrc?.trim() ||
profile.pathPreviewImageSrc?.trim() ||
characterAsset?.imageSrc?.trim() ||
tileAtlasAsset?.imageSrc?.trim() ||
canRenderPathMiniMap,
tileAssets.length > 0 ||
path?.platforms.length,
);
const handlePublish = async () => {
@@ -310,15 +309,6 @@ export function JumpHopResultView({
</button>
<div className="flex gap-2">
<button
type="button"
onClick={onRegenerateCharacter}
disabled={isBusy}
className="platform-button platform-button--ghost min-h-0 px-3 py-2 text-sm"
>
<RefreshCcw className="h-4 w-4" />
</button>
<button
type="button"
onClick={onRegenerateTiles}
@@ -343,69 +333,25 @@ export function JumpHopResultView({
) : null}
<div className="mt-4 grid gap-3 sm:grid-cols-3">
<div className="overflow-hidden rounded-[1rem] border border-[var(--platform-subpanel-border)] bg-white/80">
{profile.characterImageSrc || characterAsset?.imageSrc ? (
<ResolvedAssetImage
src={
('characterImageSrc' in profile
? profile.characterImageSrc
: null) ??
characterAsset?.imageSrc ??
''
}
alt="角色图"
className="aspect-[1/1] w-full object-cover"
/>
) : (
<div className="grid aspect-[1/1] place-items-center text-sm text-[var(--platform-text-soft)]">
</div>
)}
<JumpHopDefaultCharacterPreview />
</div>
<div className="overflow-hidden rounded-[1rem] border border-[var(--platform-subpanel-border)] bg-white/80">
{profile.tileAtlasImageSrc || tileAtlasAsset?.imageSrc ? (
<ResolvedAssetImage
src={
('tileAtlasImageSrc' in profile
? profile.tileAtlasImageSrc
: null) ??
tileAtlasAsset?.imageSrc ??
''
}
alt="地块图"
className="aspect-[1/1] w-full object-cover"
/>
) : (
<div className="grid aspect-[1/1] place-items-center text-sm text-[var(--platform-text-soft)]">
</div>
)}
<JumpHopTilePoolPreview
tileAssets={tileAssets}
tileAtlasAsset={tileAtlasAsset}
tileAtlasFallbackSrc={
('tileAtlasImageSrc' in profile
? profile.tileAtlasImageSrc
: null) ??
null
}
/>
</div>
<div className="overflow-hidden rounded-[1rem] border border-[var(--platform-subpanel-border)] bg-white/80">
{path && canRenderPathMiniMap ? (
<JumpHopPathMiniMap path={path} />
) : 'pathPreviewImageSrc' in profile &&
profile.pathPreviewImageSrc ? (
<ResolvedAssetImage
src={profile.pathPreviewImageSrc}
alt="路径预览"
className="aspect-[1/1] w-full object-cover"
/>
) : path ? (
<div className="grid aspect-[1/1] place-items-center px-3 text-center">
<div>
<div className="text-3xl font-black text-[var(--platform-text-strong)]">
{path.platforms.length}
</div>
<div className="mt-1 text-xs font-bold tracking-[0.16em] text-[var(--platform-text-soft)]">
{path.difficulty}
</div>
</div>
</div>
) : (
<div className="grid aspect-[1/1] place-items-center text-sm text-[var(--platform-text-soft)]">
</div>
)}
<JumpHopFirstPlatformsPreview
path={path}
tileAssets={tileAssets}
/>
</div>
</div>
{!hasAssets ? (
@@ -419,6 +365,7 @@ export function JumpHopResultView({
<div className="text-xs font-bold tracking-[0.18em] text-[var(--platform-text-soft)]">
</div>
<JumpHopResultLeaderboard profileId={profileId} />
{error ? (
<div className="platform-banner platform-banner--danger mt-3 rounded-2xl text-sm leading-6">
{error}

View File

@@ -0,0 +1,919 @@
/* @vitest-environment jsdom */
import { act, fireEvent, render, screen } from '@testing-library/react';
import { beforeEach, expect, test, vi } from 'vitest';
import type {
JumpHopRuntimeRunSnapshotResponse,
JumpHopWorkProfileResponse,
} from '../../../packages/shared/src/contracts/jumpHop';
import { useResolvedAssetReadUrl } from '../../hooks/useResolvedAssetReadUrl';
import { buildJumpHopVisiblePlatforms } from '../../services/jump-hop/jumpHopRuntimeModel';
import { useJumpHopLeaderboard } from '../../services/jump-hop/useJumpHopLeaderboard';
import { JumpHopRuntimeShell } from './JumpHopRuntimeShell';
vi.mock('../../hooks/useResolvedAssetReadUrl', () => ({
useResolvedAssetReadUrl: vi.fn((source: string | null | undefined) => ({
resolvedUrl: source?.trim() ?? '',
isResolving: false,
shouldResolve: Boolean(source?.trim().startsWith('/generated-')),
})),
}));
vi.mock('../../services/jump-hop/useJumpHopLeaderboard', () => ({
useJumpHopLeaderboard: vi.fn(),
}));
beforeEach(() => {
vi.clearAllMocks();
vi.mocked(useJumpHopLeaderboard).mockReturnValue({
leaderboard: null,
isLoading: false,
error: null,
refresh: vi.fn(),
});
});
function dispatchPointerEvent(
target: HTMLElement,
type: string,
options: { pointerId: number; clientX: number; clientY: number },
) {
const event = new Event(type, { bubbles: true, cancelable: true });
Object.assign(event, options);
target.dispatchEvent(event);
}
test('跳一跳运行态松手时提交向后拖动向量', async () => {
vi.useFakeTimers();
const onJump = vi.fn().mockResolvedValue(undefined);
const run = buildRun();
const visiblePlatforms = buildJumpHopVisiblePlatforms(run.path, 0, []);
const current = visiblePlatforms[0]!;
const target = visiblePlatforms[1]!;
const stageSize = { width: 320, height: 568 };
const xPixelsPerWorldUnit =
Math.abs(
((target.screenX - current.screenX) / 100) * stageSize.width,
) / Math.abs(target.platform.x - current.platform.x);
const yPixelsPerWorldUnit =
Math.abs(
((target.screenY - current.screenY) / 100) * stageSize.height,
) / Math.abs(target.platform.y - current.platform.y);
render(
<JumpHopRuntimeShell
profile={buildProfile()}
run={run}
onJump={onJump}
onRestart={() => {}}
/>,
);
const stage = screen.getByTestId('jump-hop-stage');
await act(async () => {
dispatchPointerEvent(stage, 'pointerdown', {
pointerId: 1,
clientX: 180,
clientY: 420,
});
});
await act(async () => {
dispatchPointerEvent(stage, 'pointermove', {
pointerId: 1,
clientX: 132,
clientY: 478,
});
});
await act(async () => {
dispatchPointerEvent(stage, 'pointerup', {
pointerId: 1,
clientX: 132,
clientY: 478,
});
});
expect(onJump).toHaveBeenCalledTimes(1);
const jumpPayload = onJump.mock.calls[0]?.[0];
expect(jumpPayload?.dragVectorX).toBeCloseTo(-48 / xPixelsPerWorldUnit, 2);
expect(jumpPayload?.dragVectorY).toBeCloseTo(58 / yPixelsPerWorldUnit, 2);
expect(jumpPayload?.dragDistance).toBeGreaterThan(74);
expect(jumpPayload?.dragDistance).toBeLessThan(76);
vi.useRealTimers();
});
test('跳一跳运行态拖拽方向按手指起点到松手点计算', async () => {
const onJump = vi.fn().mockResolvedValue(undefined);
const run = buildRun();
const visiblePlatforms = buildJumpHopVisiblePlatforms(run.path, 0, []);
const current = visiblePlatforms[0]!;
const target = visiblePlatforms[1]!;
const stageSize = { width: 320, height: 568 };
const xPixelsPerWorldUnit =
Math.abs(
((target.screenX - current.screenX) / 100) * stageSize.width,
) / Math.abs(target.platform.x - current.platform.x);
const yPixelsPerWorldUnit =
Math.abs(
((target.screenY - current.screenY) / 100) * stageSize.height,
) / Math.abs(target.platform.y - current.platform.y);
render(
<JumpHopRuntimeShell
profile={buildProfile()}
run={run}
onJump={onJump}
onRestart={() => {}}
/>,
);
const stage = screen.getByTestId('jump-hop-stage');
await act(async () => {
dispatchPointerEvent(stage, 'pointerdown', {
pointerId: 1,
clientX: 40,
clientY: 40,
});
});
await act(async () => {
dispatchPointerEvent(stage, 'pointermove', {
pointerId: 1,
clientX: 10,
clientY: 20,
});
});
await act(async () => {
dispatchPointerEvent(stage, 'pointerup', {
pointerId: 1,
clientX: 10,
clientY: 20,
});
});
const jumpPayload = onJump.mock.calls[0]?.[0];
expect(jumpPayload?.dragVectorX).toBeLessThan(0);
expect(jumpPayload?.dragVectorY).toBeLessThan(0);
expect(Math.abs(jumpPayload?.dragVectorX ?? 0)).toBeLessThan(30);
expect(Math.abs(jumpPayload?.dragVectorY ?? 0)).toBeLessThan(20);
expect(jumpPayload?.dragVectorX).toBeCloseTo(-30 / xPixelsPerWorldUnit, 2);
expect(jumpPayload?.dragVectorY).toBeCloseTo(-20 / yPixelsPerWorldUnit, 2);
});
test('跳一跳运行态不再显示旧圆弧蓄力条而是显示弹弓拉线', async () => {
const onJump = vi.fn().mockResolvedValue(undefined);
render(
<JumpHopRuntimeShell
profile={buildProfile()}
run={buildRun()}
onJump={onJump}
onRestart={() => {}}
/>,
);
const stage = screen.getByTestId('jump-hop-stage');
await act(async () => {
dispatchPointerEvent(stage, 'pointerdown', {
pointerId: 1,
clientX: 180,
clientY: 420,
});
});
expect(screen.queryByText('起跳')).toBeNull();
expect(stage.querySelector('.jump-hop-runtime__charge-orbit')).toBeNull();
expect(stage.querySelector('.jump-hop-runtime__slingshot-guide')).toBeTruthy();
});
test('跳一跳蓄力时角色沿拖拽方向拉伸', async () => {
render(
<JumpHopRuntimeShell
profile={buildProfile({ tileAssets: buildTileAssets() })}
run={buildRun()}
onJump={vi.fn().mockResolvedValue(undefined)}
onRestart={() => {}}
/>,
);
const stage = screen.getByTestId('jump-hop-stage');
await act(async () => {
dispatchPointerEvent(stage, 'pointerdown', {
pointerId: 1,
clientX: 180,
clientY: 420,
});
});
await act(async () => {
dispatchPointerEvent(stage, 'pointermove', {
pointerId: 1,
clientX: 132,
clientY: 478,
});
});
const character = screen.getByTestId('jump-hop-character-logo')
.parentElement as HTMLElement;
const stretchTransform = character.style.getPropertyValue(
'--jump-hop-character-stretch-transform',
);
const styleText = Array.from(document.querySelectorAll('style'))
.map((style) => style.textContent ?? '')
.join('\n');
expect(stretchTransform).toContain('matrix(');
expect(stretchTransform).not.toBe('matrix(1, 0, 0, 1, 0, 0)');
expect(styleText).toContain('var(--jump-hop-character-stretch-transform)');
expect(styleText).not.toContain(
'scaleY(calc(1 - (var(--jump-hop-charge) * 0.16)))',
);
});
test('跳一跳运行态需要三维场景宿主和排行榜面板', () => {
const runtimeRequestOptions = {
runtimeGuestToken: 'runtime-guest-token',
};
vi.mocked(useJumpHopLeaderboard).mockReturnValue({
leaderboard: {
profileId: 'jump-hop-profile-test',
items: [
{
rank: 1,
playerId: 'player-1',
successfulJumpCount: 8,
durationMs: 8123,
updatedAt: '2026-05-27T00:00:00Z',
},
],
viewerBest: null,
},
isLoading: false,
error: null,
refresh: vi.fn(),
});
render(
<JumpHopRuntimeShell
profile={buildProfile()}
run={buildRun()}
runtimeRequestOptions={runtimeRequestOptions}
onJump={vi.fn().mockResolvedValue(undefined)}
onRestart={() => {}}
/>,
);
expect(useJumpHopLeaderboard).toHaveBeenCalledWith(
'jump-hop-profile-test',
runtimeRequestOptions,
);
expect(screen.getByTestId('jump-hop-three-scene')).toBeTruthy();
expect(screen.getByTestId('jump-hop-runtime-leaderboard')).toBeTruthy();
expect(screen.getByText('player-1')).toBeTruthy();
expect(screen.getByText('8 跳')).toBeTruthy();
expect(screen.getByText('00:08')).toBeTruthy();
expect(screen.queryByRole('button', { name: /^起跳$/ })).toBeNull();
});
test('跳一跳角色层永远压在地块层之上', () => {
render(
<JumpHopRuntimeShell
profile={buildProfile({ tileAssets: buildTileAssets() })}
run={buildRun()}
onJump={vi.fn().mockResolvedValue(undefined)}
onRestart={() => {}}
/>,
);
const threeScene = screen.getByTestId('jump-hop-three-scene');
const firstPlatform = screen.getAllByTestId('jump-hop-tile-image')[0]
?.parentElement?.parentElement as HTMLElement | undefined;
expect(threeScene.style.zIndex).toBe('100');
expect(Number(threeScene.style.zIndex)).toBeGreaterThan(
Number(firstPlatform?.style.zIndex ?? 0),
);
});
test('跳一跳落点辅助标识会随着拖拽方向和距离实时移动', async () => {
const onJump = vi.fn().mockResolvedValue(undefined);
render(
<JumpHopRuntimeShell
profile={buildProfile({ tileAssets: buildTileAssets() })}
run={buildRun()}
onJump={onJump}
onRestart={() => {}}
/>,
);
const stage = screen.getByTestId('jump-hop-stage');
await act(async () => {
dispatchPointerEvent(stage, 'pointerdown', {
pointerId: 1,
clientX: 180,
clientY: 420,
});
});
expect(screen.queryByTestId('jump-hop-landing-assist')).toBeNull();
await act(async () => {
dispatchPointerEvent(stage, 'pointermove', {
pointerId: 1,
clientX: 148,
clientY: 454,
});
});
const firstAssist = screen.getByTestId('jump-hop-landing-assist');
const firstLeft = firstAssist.style.left;
const firstTop = firstAssist.style.top;
expect(firstAssist.getAttribute('data-target-index')).toBe('1');
expect(firstLeft).not.toBe('62.288%');
await act(async () => {
dispatchPointerEvent(stage, 'pointermove', {
pointerId: 1,
clientX: 112,
clientY: 492,
});
});
const secondAssist = screen.getByTestId('jump-hop-landing-assist');
expect(secondAssist.style.left).not.toBe(firstLeft);
expect(secondAssist.style.top).not.toBe(firstTop);
});
test('跳一跳运行态直接渲染生成的地块切片图片', () => {
render(
<JumpHopRuntimeShell
profile={buildProfile({ tileAssets: buildTileAssets() })}
run={buildRun()}
onJump={vi.fn().mockResolvedValue(undefined)}
onRestart={() => {}}
/>,
);
const tileImages = screen.getAllByTestId('jump-hop-tile-image');
expect(tileImages).toHaveLength(3);
const generatedReadUrlCalls = vi
.mocked(useResolvedAssetReadUrl)
.mock.calls.filter(([source]) =>
source?.includes('/generated-jump-hop-assets/'),
);
expect(generatedReadUrlCalls.length).toBeGreaterThanOrEqual(3);
for (const [, options] of generatedReadUrlCalls) {
expect(options).toEqual(
expect.objectContaining({
refreshKey: expect.stringMatching(/^asset-object-/),
}),
);
}
for (const image of tileImages) {
expect(image.getAttribute('src')).toContain(
'/generated-jump-hop-assets/jump-hop-profile-test/tile-',
);
fireEvent.load(image);
expect(image.getAttribute('data-loaded')).toBe('true');
}
});
test('跳一跳运行态首块地块落在中下方并且后续两块向中央和上方展开', () => {
render(
<JumpHopRuntimeShell
profile={buildProfile({ tileAssets: buildTileAssets() })}
run={buildRun()}
onJump={vi.fn().mockResolvedValue(undefined)}
onRestart={() => {}}
/>,
);
const tileImages = screen.getAllByTestId('jump-hop-tile-image');
expect(tileImages).toHaveLength(3);
const first = tileImages[0]?.parentElement?.parentElement as HTMLElement | undefined;
const second = tileImages[1]?.parentElement?.parentElement as HTMLElement | undefined;
const third = tileImages[2]?.parentElement?.parentElement as HTMLElement | undefined;
expect(first?.style.top).toBe('78%');
expect(second?.style.top).toBe('50%');
expect(third?.style.top).toBe('22%');
});
test('跳一跳运行态用固定基准宽高和深度 scale 表达地块尺寸', () => {
render(
<JumpHopRuntimeShell
profile={buildProfile({ tileAssets: buildTileAssets() })}
run={buildRun()}
onJump={vi.fn().mockResolvedValue(undefined)}
onRestart={() => {}}
/>,
);
const firstTile = screen.getAllByTestId('jump-hop-tile-image')[0]
?.parentElement?.parentElement as HTMLElement | undefined;
expect(firstTile?.style.width).toBe('116px');
expect(firstTile?.style.height).toBe('96px');
expect(firstTile?.style.getPropertyValue('--jump-hop-platform-scale')).toBe(
'1.08',
);
});
test('跳一跳运行态使用陶泥儿透明 logo 作为角色形象', () => {
render(
<JumpHopRuntimeShell
profile={buildProfile({ tileAssets: buildTileAssets() })}
run={buildRun()}
onJump={vi.fn().mockResolvedValue(undefined)}
onRestart={() => {}}
/>,
);
const logo = screen.getByTestId('jump-hop-character-logo');
expect(logo.getAttribute('src')).toBe(
'/branding/jump-hop-taonier-character.png',
);
expect(
screen.queryByTestId('jump-hop-character-fallback-shape'),
).toBeNull();
});
test('跳一跳蓄力和计时刷新不会重建三维画布宿主', async () => {
vi.useFakeTimers();
render(
<JumpHopRuntimeShell
profile={buildProfile({ tileAssets: buildTileAssets() })}
run={buildRun()}
onJump={vi.fn().mockResolvedValue(undefined)}
onRestart={() => {}}
/>,
);
const stage = screen.getByTestId('jump-hop-stage');
const canvas = screen.getByTestId('jump-hop-three-canvas');
await act(async () => {
dispatchPointerEvent(stage, 'pointerdown', {
pointerId: 1,
clientX: 180,
clientY: 420,
});
});
await act(async () => {
vi.advanceTimersByTime(520);
});
await act(async () => {
dispatchPointerEvent(stage, 'pointermove', {
pointerId: 1,
clientX: 160,
clientY: 460,
});
});
expect(screen.getByTestId('jump-hop-three-canvas')).toBe(canvas);
vi.useRealTimers();
});
test('跳一跳后端回包较慢时角色停在目标点等待推进', async () => {
vi.useFakeTimers();
const onJump = vi.fn().mockResolvedValue(undefined);
const initialRun = buildRun();
const nextRun: JumpHopRuntimeRunSnapshotResponse = {
...buildRun(),
currentPlatformIndex: 1,
successfulJumpCount: 1,
score: 1,
lastJump: {
chargeMs: 150,
jumpDistance: 1.44,
targetPlatformIndex: 1,
landedX: 0.8,
landedY: 1.2,
result: 'hit',
},
};
const { rerender } = render(
<JumpHopRuntimeShell
profile={buildProfile({ tileAssets: buildTileAssets() })}
run={initialRun}
onJump={onJump}
onRestart={() => {}}
/>,
);
const stage = screen.getByTestId('jump-hop-stage');
await act(async () => {
dispatchPointerEvent(stage, 'pointerdown', {
pointerId: 1,
clientX: 180,
clientY: 420,
});
});
await act(async () => {
dispatchPointerEvent(stage, 'pointermove', {
pointerId: 1,
clientX: 132,
clientY: 478,
});
});
await act(async () => {
dispatchPointerEvent(stage, 'pointerup', {
pointerId: 1,
clientX: 132,
clientY: 478,
});
});
await act(async () => {
await vi.advanceTimersByTimeAsync(580);
});
const character = screen.getByTestId('jump-hop-character-logo')
.parentElement as HTMLElement;
expect(stage.getAttribute('data-jump-animating')).toBe('true');
expect(stage.getAttribute('data-platform-advancing')).toBe('false');
expect(Number.parseFloat(character.style.left)).not.toBeCloseTo(50, 2);
expect(character.style.getPropertyValue('--jump-hop-flight-from-x')).not.toBe(
'0px',
);
expect(character.style.getPropertyValue('--jump-hop-flight-from-y')).not.toBe(
'0px',
);
rerender(
<JumpHopRuntimeShell
profile={buildProfile({ tileAssets: buildTileAssets() })}
run={nextRun}
onJump={onJump}
onRestart={() => {}}
/>,
);
expect(screen.getByTestId('jump-hop-stage').getAttribute('data-jump-animating')).toBe(
'false',
);
expect(screen.getByTestId('jump-hop-stage').getAttribute('data-platform-advancing')).toBe(
'true',
);
vi.useRealTimers();
});
test('跳一跳松手后先播放飞行动画再切换到下一块地块', async () => {
vi.useFakeTimers();
const onJump = vi.fn().mockResolvedValue(undefined);
const initialRun = buildRun();
const nextRun: JumpHopRuntimeRunSnapshotResponse = {
...buildRun(),
currentPlatformIndex: 1,
successfulJumpCount: 1,
score: 1,
lastJump: {
chargeMs: 150,
jumpDistance: 1.44,
targetPlatformIndex: 1,
landedX: 0.8,
landedY: 1.2,
result: 'hit',
},
};
const { rerender } = render(
<JumpHopRuntimeShell
profile={buildProfile({ tileAssets: buildTileAssets() })}
run={initialRun}
onJump={onJump}
onRestart={() => {}}
/>,
);
const stage = screen.getByTestId('jump-hop-stage');
await act(async () => {
dispatchPointerEvent(stage, 'pointerdown', {
pointerId: 1,
clientX: 180,
clientY: 420,
});
});
await act(async () => {
dispatchPointerEvent(stage, 'pointermove', {
pointerId: 1,
clientX: 132,
clientY: 478,
});
});
await act(async () => {
dispatchPointerEvent(stage, 'pointerup', {
pointerId: 1,
clientX: 132,
clientY: 478,
});
});
expect(onJump).toHaveBeenCalledTimes(1);
expect(stage.getAttribute('data-jump-animating')).toBe('true');
expect(screen.getAllByTestId('jump-hop-tile-image')[0]?.parentElement?.parentElement?.style.top).toBe(
'78%',
);
expect(screen.getAllByTestId('jump-hop-tile-image')[0]?.parentElement?.parentElement?.getAttribute('data-current')).toBe(
'true',
);
expect(screen.getAllByTestId('jump-hop-tile-image')[0]?.parentElement?.parentElement?.getAttribute('data-platform-id')).toBe(
'p0',
);
rerender(
<JumpHopRuntimeShell
profile={buildProfile({ tileAssets: buildTileAssets() })}
run={nextRun}
onJump={onJump}
onRestart={() => {}}
/>,
);
expect(screen.getByTestId('jump-hop-stage').getAttribute('data-jump-animating')).toBe(
'true',
);
expect(screen.getAllByTestId('jump-hop-tile-image')[0]?.parentElement?.parentElement?.style.top).toBe(
'78%',
);
expect(screen.getAllByTestId('jump-hop-tile-image')[0]?.parentElement?.parentElement?.getAttribute('data-current')).toBe(
'true',
);
expect(screen.getAllByTestId('jump-hop-tile-image')[0]?.parentElement?.parentElement?.getAttribute('data-platform-id')).toBe(
'p0',
);
await act(async () => {
await vi.advanceTimersByTimeAsync(580);
});
expect(screen.getByTestId('jump-hop-stage').getAttribute('data-jump-animating')).toBe(
'false',
);
expect(screen.getByTestId('jump-hop-stage').getAttribute('data-platform-advancing')).toBe(
'true',
);
const landedCharacter = screen.getByTestId('jump-hop-character-logo')
.parentElement as HTMLElement;
expect(landedCharacter.getAttribute('data-landing-recoil')).toBe('true');
expect(landedCharacter.style.getPropertyValue('--jump-hop-recoil-x')).not.toBe(
'0px',
);
expect(landedCharacter.style.getPropertyValue('--jump-hop-recoil-y')).not.toBe(
'0px',
);
const cameraLayer = screen.getByTestId('jump-hop-camera-layer');
expect(cameraLayer.getAttribute('data-platform-advancing')).toBe('true');
expect(cameraLayer.style.getPropertyValue('--jump-hop-camera-shift-y')).toBe(
'-28%',
);
expect(
Number.parseFloat(
cameraLayer.style.getPropertyValue('--jump-hop-camera-shift-x'),
),
).toBeCloseTo(12.29, 2);
const styleText = Array.from(document.querySelectorAll('style'))
.map((style) => style.textContent ?? '')
.join('\n');
expect(styleText).toContain('@keyframes jump-hop-character-recoil');
expect(styleText).toMatch(
/data-platform-advancing='true'\]\s+\.jump-hop-runtime__platform[\s\S]*transform 1440ms cubic-bezier/,
);
expect(screen.getByTestId('jump-hop-three-scene').parentElement).toBe(
cameraLayer,
);
expect(
screen
.getByTestId('jump-hop-stage')
.querySelector("[data-advance-state='settling']"),
).toBeNull();
expect(
screen
.getByTestId('jump-hop-stage')
.querySelector("[data-advance-state='entering']"),
).toBeNull();
expect(screen.getAllByTestId('jump-hop-tile-image')[0]?.parentElement?.parentElement?.getAttribute('data-platform-id')).toBe(
'p0',
);
expect(screen.getAllByTestId('jump-hop-tile-image')[1]?.parentElement?.parentElement?.getAttribute('data-platform-id')).toBe(
'p1',
);
expect(screen.getAllByTestId('jump-hop-tile-image')[1]?.parentElement?.parentElement?.style.top).toBe(
'78%',
);
expect(screen.getAllByTestId('jump-hop-tile-image')[1]?.parentElement?.parentElement?.style.getPropertyValue('--jump-hop-platform-scale')).toBe(
'1.08',
);
expect(screen.getAllByTestId('jump-hop-tile-image')[2]?.parentElement?.parentElement?.getAttribute('data-platform-id')).toBe(
'p2',
);
expect(screen.getAllByTestId('jump-hop-tile-image')[2]?.parentElement?.parentElement?.style.top).toBe(
'50%',
);
await act(async () => {
vi.advanceTimersByTime(720);
});
expect(screen.getByTestId('jump-hop-stage').getAttribute('data-platform-advancing')).toBe(
'true',
);
expect(
(
screen.getByTestId('jump-hop-character-logo')
.parentElement as HTMLElement
).getAttribute('data-landing-recoil'),
).toBe('false');
await act(async () => {
vi.advanceTimersByTime(660);
});
expect(screen.getByTestId('jump-hop-stage').getAttribute('data-platform-advancing')).toBe(
'true',
);
await act(async () => {
vi.advanceTimersByTime(100);
});
expect(screen.getByTestId('jump-hop-stage').getAttribute('data-platform-advancing')).toBe(
'false',
);
expect(screen.getByTestId('jump-hop-camera-layer').getAttribute('data-platform-advancing')).toBe(
'false',
);
expect(screen.getAllByTestId('jump-hop-tile-image')[0]?.parentElement?.parentElement?.style.top).toBe(
'78%',
);
expect(screen.getAllByTestId('jump-hop-tile-image')[0]?.parentElement?.parentElement?.getAttribute('data-current')).toBe(
'true',
);
expect(screen.getAllByTestId('jump-hop-tile-image')[0]?.parentElement?.parentElement?.getAttribute('data-platform-id')).toBe(
'p1',
);
expect(screen.getAllByTestId('jump-hop-tile-image')[1]?.parentElement?.parentElement?.style.top).toBe(
'50%',
);
vi.useRealTimers();
});
function buildRun(): JumpHopRuntimeRunSnapshotResponse {
return {
runId: 'jump-hop-run-test',
profileId: 'jump-hop-profile-test',
ownerUserId: 'user-test',
status: 'playing',
currentPlatformIndex: 0,
successfulJumpCount: 0,
durationMs: 0,
score: 0,
combo: 0,
path: {
seed: 'test',
difficulty: 'standard',
finishIndex: 4294967295,
cameraPreset: 'portrait-isometric-9x16',
scoring: {
chargeToDistanceRatio: 0.004,
maxChargeMs: 900,
hitBonus: 20,
perfectBonus: 60,
},
platforms: [
{
platformId: 'p0',
tileType: 'start',
x: 0,
y: 0,
width: 1,
height: 1,
landingRadius: 0.5,
perfectRadius: 0.2,
scoreValue: 1,
},
{
platformId: 'p1',
tileType: 'normal',
x: 0.8,
y: 1.2,
width: 1,
height: 1,
landingRadius: 0.5,
perfectRadius: 0.2,
scoreValue: 1,
},
{
platformId: 'p2',
tileType: 'target',
x: -0.2,
y: 2.4,
width: 1,
height: 1,
landingRadius: 0.5,
perfectRadius: 0.2,
scoreValue: 1,
},
],
},
lastJump: null,
startedAtMs: 1000,
finishedAtMs: null,
};
}
function buildTileAssets() {
return Array.from({ length: 25 }, (_, index) => {
const tileNumber = String(index + 1).padStart(2, '0');
return {
tileType: index === 0 ? 'start' : 'normal',
tileId: `tile-${tileNumber}`,
imageSrc: `/generated-jump-hop-assets/jump-hop-profile-test/tile-${tileNumber}/image.png`,
imageObjectKey: `generated-jump-hop-assets/jump-hop-profile-test/tile-${tileNumber}/image.png`,
assetObjectId: `asset-object-${tileNumber}`,
sourceAtlasCell: `row-${Math.floor(index / 5) + 1}-col-${(index % 5) + 1}`,
atlasRow: Math.floor(index / 5) + 1,
atlasCol: (index % 5) + 1,
visualWidth: 256,
visualHeight: 192,
topSurfaceRadius: 42,
landingRadius: 34,
} satisfies JumpHopWorkProfileResponse['tileAssets'][number];
});
}
function buildProfile(options: {
tileAssets?: JumpHopWorkProfileResponse['tileAssets'];
} = {}): JumpHopWorkProfileResponse {
const characterAsset = {
assetId: 'builtin',
imageSrc: 'builtin://jump-hop/default-character',
imageObjectKey: '',
assetObjectId: 'builtin',
generationProvider: 'builtin-three',
prompt: '默认角色',
width: 0,
height: 0,
};
return {
summary: {
runtimeKind: 'jump-hop',
workId: 'jump-hop-profile-test',
profileId: 'jump-hop-profile-test',
ownerUserId: 'user-test',
sourceSessionId: 'jump-hop-session-test',
themeText: '测试',
workTitle: '测试',
workDescription: '测试',
themeTags: ['测试'],
difficulty: 'standard',
stylePreset: 'minimal-blocks',
coverImageSrc: null,
publicationStatus: 'draft',
playCount: 0,
updatedAt: '2026-05-27T00:00:00Z',
publishedAt: null,
publishReady: true,
generationStatus: 'ready',
},
draft: {
templateId: 'jump-hop',
templateName: '跳一跳',
profileId: 'jump-hop-profile-test',
themeText: '测试',
workTitle: '测试',
workDescription: '测试',
themeTags: ['测试'],
difficulty: 'standard',
stylePreset: 'minimal-blocks',
defaultCharacter: {
characterId: 'jump-hop-default-runner',
displayName: '默认角色',
modelKind: 'builtin-three',
bodyColor: '#f59e0b',
accentColor: '#2563eb',
},
characterPrompt: '默认角色',
tilePrompt: '地块',
endMoodPrompt: null,
characterAsset,
tileAtlasAsset: characterAsset,
tileAssets: options.tileAssets ?? [],
path: buildRun().path,
coverComposite: null,
generationStatus: 'ready',
},
path: buildRun().path,
defaultCharacter: {
characterId: 'jump-hop-default-runner',
displayName: '默认角色',
modelKind: 'builtin-three',
bodyColor: '#f59e0b',
accentColor: '#2563eb',
},
characterAsset,
tileAtlasAsset: characterAsset,
tileAssets: options.tileAssets ?? [],
};
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,32 @@
import { describe, expect, test } from 'vitest';
import {
resolveMiniGameGenerationProgressTickState,
} from './PlatformEntryFlowShellImpl';
import { createMiniGameDraftGenerationState } from '../../services/miniGameDraftGenerationProgress';
describe('resolveMiniGameGenerationProgressTickState', () => {
test('returns jump hop and wooden fish generation states for progress ticking', () => {
const jumpHopState = createMiniGameDraftGenerationState('jump-hop');
const woodenFishState = createMiniGameDraftGenerationState('wooden-fish');
expect(
resolveMiniGameGenerationProgressTickState('jump-hop-generating', {
'jump-hop': jumpHopState,
}),
).toBe(jumpHopState);
expect(
resolveMiniGameGenerationProgressTickState('wooden-fish-generating', {
'wooden-fish': woodenFishState,
}),
).toBe(woodenFishState);
});
test('returns null when the stage does not need generation ticking', () => {
expect(
resolveMiniGameGenerationProgressTickState('platform', {
'jump-hop': createMiniGameDraftGenerationState('jump-hop'),
}),
).toBeNull();
});
});

View File

@@ -38,7 +38,10 @@ import type {
BabyObjectMatchDraft,
CreateBabyObjectMatchDraftRequest,
} from '../../../packages/shared/src/contracts/edutainmentBabyObject';
import type { JumpHopWorkSummaryResponse } from '../../../packages/shared/src/contracts/jumpHop';
import type {
JumpHopJumpRequest,
JumpHopWorkSummaryResponse,
} from '../../../packages/shared/src/contracts/jumpHop';
import type {
CreateMatch3DSessionRequest,
ExecuteMatch3DActionRequest,
@@ -107,6 +110,7 @@ import type {
VisualNovelWorkDetail,
VisualNovelWorkSummary,
} from '../../../packages/shared/src/contracts/visualNovel';
import type { WoodenFishWorkSummaryResponse } from '../../../packages/shared/src/contracts/woodenFish';
import { buildCustomWorldPlayableCharacters } from '../../data/characterPresets';
import {
buildPublicWorkStagePath,
@@ -186,6 +190,7 @@ import {
jumpHopClient,
type JumpHopGalleryCardResponse,
type JumpHopRunResponse,
type JumpHopRuntimeRequestOptions,
type JumpHopSessionResponse,
type JumpHopSessionSnapshotResponse,
JumpHopWorkProfileResponse,
@@ -343,7 +348,6 @@ import {
type WoodenFishWorkProfileResponse,
type WoodenFishWorkspaceCreateRequest,
} from '../../services/wooden-fish/woodenFishClient';
import type { WoodenFishWorkSummaryResponse } from '../../../packages/shared/src/contracts/woodenFish';
import type { CustomWorldProfile } from '../../types';
import { useAuthUi } from '../auth/AuthUiContext';
import { PublishShareModal } from '../common/PublishShareModal';
@@ -430,11 +434,11 @@ import {
PlatformErrorDialog,
type PlatformErrorDialogPayload,
} from './PlatformErrorDialog';
import { PlatformFeedbackView } from './PlatformFeedbackView';
import {
PlatformTaskCompletionDialog,
type PlatformTaskCompletionDialogPayload,
} from './PlatformTaskCompletionDialog';
import { PlatformFeedbackView } from './PlatformFeedbackView';
import { PlatformWorkDetailView } from './PlatformWorkDetailView';
import { usePlatformCreationAgentFlowController } from './usePlatformCreationAgentFlowController';
import { usePlatformEntryBootstrap } from './usePlatformEntryBootstrap';
@@ -478,6 +482,30 @@ type PuzzleBackgroundCompileTask = {
error: string | null;
};
type MiniGameGenerationProgressTickStateMap = Partial<
Record<MiniGameDraftGenerationKind, MiniGameDraftGenerationState | null>
>;
export function resolveMiniGameGenerationProgressTickState(
selectionStage: SelectionStage,
states: MiniGameGenerationProgressTickStateMap,
) {
const stageKindMap: Partial<
Record<SelectionStage, MiniGameDraftGenerationKind>
> = {
'puzzle-generating': 'puzzle',
'big-fish-generating': 'big-fish',
'square-hole-generating': 'square-hole',
'match3d-generating': 'match3d',
'baby-object-match-generating': 'baby-object-match',
'jump-hop-generating': 'jump-hop',
'wooden-fish-generating': 'wooden-fish',
};
const kind = stageKindMap[selectionStage];
return kind ? (states[kind] ?? null) : null;
}
type PuzzleDetailReturnTarget = {
tab: PlatformHomeTab;
};
@@ -580,11 +608,11 @@ const AGENT_RESULT_STRUCTURAL_BLOCKER_CODES = new Set([
'publish_missing_main_chapter',
'publish_missing_first_act',
]);
const RECOMMEND_RUNTIME_BACKGROUND_AUTH_OPTIONS =
const RECOMMEND_RUNTIME_BACKGROUND_AUTH_OPTIONS: JumpHopRuntimeRequestOptions =
BACKGROUND_AUTH_REQUEST_OPTIONS;
const RECOMMEND_PUZZLE_BACKGROUND_AUTH_OPTIONS =
const RECOMMEND_PUZZLE_BACKGROUND_AUTH_OPTIONS: JumpHopRuntimeRequestOptions =
RECOMMEND_RUNTIME_BACKGROUND_AUTH_OPTIONS;
async function buildRecommendRuntimeGuestOptions() {
async function buildRecommendRuntimeGuestOptions(): Promise<JumpHopRuntimeRequestOptions> {
const { token } = await ensureRuntimeGuestToken();
return {
...RECOMMEND_RUNTIME_BACKGROUND_AUTH_OPTIONS,
@@ -599,9 +627,9 @@ function shouldUseRecommendRuntimeGuestAuth(
async function buildRecommendRuntimeAuthOptions(
authUi: { user?: { id?: string } | null } | null | undefined,
embedded?: boolean,
) {
): Promise<JumpHopRuntimeRequestOptions> {
if (!embedded) {
return {};
return RECOMMEND_RUNTIME_BACKGROUND_AUTH_OPTIONS;
}
if (shouldUseRecommendRuntimeGuestAuth(authUi)) {
@@ -2517,6 +2545,7 @@ function buildPendingJumpHopWorks(
profileId: `jump-hop-profile-${sessionId}`,
ownerUserId: '',
sourceSessionId: sessionId,
themeText: '跳一跳',
workTitle: '跳一跳草稿',
workDescription: '正在生成跳一跳玩法草稿。',
themeTags: [],
@@ -3233,6 +3262,8 @@ export function PlatformEntryFlowShellImpl({
const [jumpHopRun, setJumpHopRun] = useState<
JumpHopRunResponse['run'] | null
>(null);
const [jumpHopRuntimeRequestOptions, setJumpHopRuntimeRequestOptions] =
useState<JumpHopRuntimeRequestOptions | null>(null);
const [jumpHopWork, setJumpHopWork] =
useState<JumpHopWorkProfileResponse | null>(null);
const [jumpHopGalleryEntries, setJumpHopGalleryEntries] = useState<
@@ -4779,14 +4810,18 @@ export function PlatformEntryFlowShellImpl({
]);
useEffect(() => {
const activeGenerationState =
selectionStage === 'puzzle-generating'
? puzzleGenerationState
: selectionStage === 'match3d-generating'
? match3dGenerationState
: selectionStage === 'baby-object-match-generating'
? babyObjectMatchGenerationState
: null;
const activeGenerationState = resolveMiniGameGenerationProgressTickState(
selectionStage,
{
puzzle: puzzleGenerationState,
'big-fish': bigFishGenerationState,
'square-hole': squareHoleGenerationState,
match3d: match3dGenerationState,
'baby-object-match': babyObjectMatchGenerationState,
'jump-hop': jumpHopGenerationState,
'wooden-fish': woodenFishGenerationState,
},
);
const shouldTickProgress =
selectionStage === 'visual-novel-generating'
? visualNovelGenerationStartedAtMs != null &&
@@ -4808,11 +4843,15 @@ export function PlatformEntryFlowShellImpl({
return () => window.clearInterval(timerId);
}, [
babyObjectMatchGenerationState,
bigFishGenerationState,
jumpHopGenerationState,
match3dGenerationState,
puzzleGenerationState,
selectionStage,
squareHoleGenerationState,
visualNovelGenerationPhase,
visualNovelGenerationStartedAtMs,
woodenFishGenerationState,
]);
const runProtectedAction = useCallback(
@@ -6615,6 +6654,7 @@ export function PlatformEntryFlowShellImpl({
setJumpHopSession(null);
setJumpHopWork(null);
setJumpHopRun(null);
setJumpHopRuntimeRequestOptions(null);
setJumpHopGenerationState(null);
enterCreateTab();
setShowCreationTypeModal(false);
@@ -7629,6 +7669,7 @@ export function PlatformEntryFlowShellImpl({
setJumpHopRuntimeReturnStage('jump-hop-result');
setJumpHopGenerationState(null);
setJumpHopSession(null);
setJumpHopRuntimeRequestOptions(null);
setJumpHopError(null);
returnToCreationFlowSource();
}, [returnToCreationFlowSource]);
@@ -8642,6 +8683,7 @@ export function PlatformEntryFlowShellImpl({
);
setJumpHopWork(null);
setJumpHopRun(null);
setJumpHopRuntimeRequestOptions(null);
setJumpHopGenerationState(generationState);
setIsJumpHopBusy(true);
setSelectionStage('jump-hop-generating');
@@ -8651,6 +8693,8 @@ export function PlatformEntryFlowShellImpl({
created.session.sessionId,
{
actionType: 'compile-draft',
themeText:
payload?.themeText ?? created.session.draft?.themeText,
workTitle: payload?.workTitle ?? created.session.draft?.workTitle,
workDescription:
payload?.workDescription ??
@@ -8742,7 +8786,7 @@ export function PlatformEntryFlowShellImpl({
}, [compileJumpHopSession, jumpHopSession, setSelectionStage]);
const regenerateJumpHopAsset = useCallback(
async (actionType: 'regenerate-character' | 'regenerate-tiles') => {
async (actionType: 'regenerate-tiles') => {
if (!jumpHopSession?.sessionId) {
setSelectionStage('jump-hop-workspace');
return;
@@ -8758,6 +8802,9 @@ export function PlatformEntryFlowShellImpl({
jumpHopSession.sessionId,
{
actionType,
profileId:
jumpHopWork?.summary.profileId ?? jumpHopSession.draft?.profileId,
themeText: jumpHopSession.draft?.themeText,
workTitle: jumpHopSession.draft?.workTitle,
workDescription: jumpHopSession.draft?.workDescription,
themeTags: jumpHopSession.draft?.themeTags,
@@ -8783,9 +8830,7 @@ export function PlatformEntryFlowShellImpl({
} catch (error) {
const errorMessage = resolveRpgCreationErrorMessage(
error,
actionType === 'regenerate-character'
? '重新生成跳一跳角色失败。'
: '重新生成跳一跳地块失败。',
'重新生成跳一跳地块失败。',
);
setJumpHopError(errorMessage);
setJumpHopGenerationState(
@@ -8858,7 +8903,9 @@ export function PlatformEntryFlowShellImpl({
setJumpHopError(null);
setJumpHopRuntimeReturnStage('jump-hop-result');
try {
const response = await jumpHopClient.startRun(profileId);
const response = await jumpHopClient.startRun(profileId, {
runtimeMode: 'draft',
});
setJumpHopRun(response.run);
setSelectionStage('jump-hop-runtime');
} catch (error) {
@@ -8889,13 +8936,30 @@ export function PlatformEntryFlowShellImpl({
setJumpHopError(null);
setJumpHopRuntimeReturnStage(options.returnStage ?? 'work-detail');
try {
const runtimeGuestOptions = await buildRecommendRuntimeAuthOptions(
authUi,
options.embedded,
const runtimeGuestOptions =
options.embedded || shouldUseRecommendRuntimeGuestAuth(authUi)
? await buildRecommendRuntimeAuthOptions(authUi, true)
: RECOMMEND_RUNTIME_BACKGROUND_AUTH_OPTIONS;
setJumpHopRuntimeRequestOptions(
runtimeGuestOptions.runtimeGuestToken?.trim()
? {
runtimeGuestToken: runtimeGuestOptions.runtimeGuestToken,
authImpact: runtimeGuestOptions.authImpact,
skipAuth: runtimeGuestOptions.skipAuth,
skipRefresh: runtimeGuestOptions.skipRefresh,
notifyAuthStateChange:
runtimeGuestOptions.notifyAuthStateChange,
clearAuthOnUnauthorized:
runtimeGuestOptions.clearAuthOnUnauthorized,
}
: null,
);
const [detail, runResponse] = await Promise.all([
jumpHopClient.getWorkDetail(normalizedProfileId).catch(() => null),
jumpHopClient.startRun(normalizedProfileId, runtimeGuestOptions),
jumpHopClient.startRun(normalizedProfileId, {
...runtimeGuestOptions,
runtimeMode: 'published',
}),
]);
if (detail?.item) {
setJumpHopWork(detail.item);
@@ -8933,7 +8997,10 @@ export function PlatformEntryFlowShellImpl({
setIsJumpHopBusy(true);
setJumpHopError(null);
try {
const response = await jumpHopClient.restartRun(runId);
const response = await jumpHopClient.restartRun(
runId,
jumpHopRuntimeRequestOptions ?? undefined,
);
setJumpHopRun(response.run);
} catch (error) {
setJumpHopError(
@@ -8942,16 +9009,29 @@ export function PlatformEntryFlowShellImpl({
} finally {
setIsJumpHopBusy(false);
}
}, [jumpHopRun?.runId, startJumpHopTestRunFromProfile]);
}, [
jumpHopRun?.runId,
jumpHopRuntimeRequestOptions,
startJumpHopTestRunFromProfile,
]);
const submitJumpHopJumpAction = useCallback(
async (payload: { chargeMs: number }) => {
async (
payload: Pick<
JumpHopJumpRequest,
'dragDistance' | 'dragVectorX' | 'dragVectorY'
>,
) => {
const runId = jumpHopRun?.runId;
if (!runId) {
return;
}
try {
const response = await jumpHopClient.submitJump(runId, payload);
const response = await jumpHopClient.submitJump(
runId,
payload,
jumpHopRuntimeRequestOptions ?? undefined,
);
setJumpHopRun(response.run);
} catch (error) {
setJumpHopError(
@@ -8959,7 +9039,7 @@ export function PlatformEntryFlowShellImpl({
);
}
},
[jumpHopRun?.runId],
[jumpHopRun?.runId, jumpHopRuntimeRequestOptions],
);
const compileWoodenFishSession = useCallback(
@@ -13073,6 +13153,14 @@ export function PlatformEntryFlowShellImpl({
return;
}
if (isJumpHopGalleryEntry(selectedPublicWorkDetail)) {
setPublicWorkDetailError(null);
void startJumpHopRunFromProfile(selectedPublicWorkDetail.profileId, {
returnStage: 'work-detail',
});
return;
}
runProtectedAction(() => {
if (isBigFishGalleryEntry(selectedPublicWorkDetail)) {
const work = mapPublicWorkDetailToBigFishWork(selectedPublicWorkDetail);
@@ -13107,14 +13195,6 @@ export function PlatformEntryFlowShellImpl({
return;
}
if (isJumpHopGalleryEntry(selectedPublicWorkDetail)) {
setPublicWorkDetailError(null);
void startJumpHopRunFromProfile(selectedPublicWorkDetail.profileId, {
returnStage: 'work-detail',
});
return;
}
if (isWoodenFishGalleryEntry(selectedPublicWorkDetail)) {
setPublicWorkDetailError(null);
void startWoodenFishRunFromProfile(selectedPublicWorkDetail.profileId, {
@@ -13616,37 +13696,15 @@ export function PlatformEntryFlowShellImpl({
run={jumpHopRun}
isBusy={isJumpHopBusy}
error={jumpHopError}
runtimeRequestOptions={jumpHopRuntimeRequestOptions ?? undefined}
onBack={() => {
setActiveRecommendRuntimeKind(null);
}}
onRestart={() => {
if (!jumpHopRun?.runId || isJumpHopBusy) {
return;
}
setIsJumpHopBusy(true);
setJumpHopError(null);
void jumpHopClient
.restartRun(jumpHopRun.runId)
.then((response) => {
setJumpHopRun(response.run);
})
.catch((error) => {
setJumpHopError(
resolveRpgCreationErrorMessage(error, '重新开始跳一跳失败。'),
);
})
.finally(() => {
setIsJumpHopBusy(false);
});
void restartJumpHopRuntimeRun();
}}
onJump={async (payload) => {
const runId = jumpHopRun?.runId;
if (!runId) {
throw new Error('跳一跳运行态缺少 runId。');
}
const response = await jumpHopClient.submitJump(runId, payload);
setJumpHopRun(response.run);
await submitJumpHopJumpAction(payload);
}}
/>
);
@@ -15516,6 +15574,7 @@ export function PlatformEntryFlowShellImpl({
)}
progress={buildMiniGameDraftGenerationProgress(
bigFishGenerationState,
miniGameGenerationProgressNowMs,
)}
isGenerating={isBigFishBusy}
error={bigFishError}
@@ -16110,6 +16169,7 @@ export function PlatformEntryFlowShellImpl({
)}
progress={buildMiniGameDraftGenerationProgress(
squareHoleGenerationState,
miniGameGenerationProgressNowMs,
)}
isGenerating={isSquareHoleBusy}
error={squareHoleError}
@@ -16320,6 +16380,7 @@ export function PlatformEntryFlowShellImpl({
)}
progress={buildMiniGameDraftGenerationProgress(
jumpHopGenerationState,
miniGameGenerationProgressNowMs,
)}
isGenerating={isJumpHopBusy}
error={jumpHopError}
@@ -16363,9 +16424,6 @@ export function PlatformEntryFlowShellImpl({
}}
onStartTestRun={startJumpHopTestRunFromProfile}
onPublish={publishJumpHopDraft}
onRegenerateCharacter={() => {
void regenerateJumpHopAsset('regenerate-character');
}}
onRegenerateTiles={() => {
void regenerateJumpHopAsset('regenerate-tiles');
}}
@@ -16390,6 +16448,7 @@ export function PlatformEntryFlowShellImpl({
profile={jumpHopWork}
isBusy={isJumpHopBusy}
error={jumpHopError}
runtimeRequestOptions={jumpHopRuntimeRequestOptions ?? undefined}
onBack={() => {
setSelectionStage(jumpHopRuntimeReturnStage);
}}
@@ -16448,6 +16507,7 @@ export function PlatformEntryFlowShellImpl({
)}
progress={buildMiniGameDraftGenerationProgress(
woodenFishGenerationState,
miniGameGenerationProgressNowMs,
)}
isGenerating={isWoodenFishBusy}
error={woodenFishError}

View File

@@ -13,6 +13,10 @@ import type {
CustomWorldAgentSessionSnapshot,
CustomWorldWorkSummary,
} from '../../../packages/shared/src/contracts/customWorldAgent';
import type {
JumpHopRuntimeRunSnapshotResponse,
JumpHopWorkProfileResponse,
} from '../../../packages/shared/src/contracts/jumpHop';
import type {
BabyObjectMatchDraft,
CreateBabyObjectMatchDraftRequest,
@@ -63,6 +67,7 @@ import {
submitBigFishInput,
} from '../../services/big-fish-runtime';
import { listBigFishWorks } from '../../services/big-fish-works';
import { jumpHopClient } from '../../services/jump-hop/jumpHopClient';
import {
type CreationEntryConfig,
fetchCreationEntryConfig,
@@ -167,6 +172,7 @@ import {
} from '../../services/square-hole-works';
import { listVisualNovelGallery } from '../../services/visual-novel-runtime';
import { listVisualNovelWorks } from '../../services/visual-novel-works';
import { woodenFishClient } from '../../services/wooden-fish/woodenFishClient';
import { type CustomWorldProfile, WorldType } from '../../types';
import {
AuthUiContext,
@@ -579,6 +585,42 @@ vi.mock('../../services/puzzle-runtime', () => ({
usePuzzleRuntimeProp: vi.fn(),
}));
vi.mock('../../services/jump-hop/jumpHopClient', () => ({
jumpHopClient: {
getGalleryDetail: vi.fn(),
getLeaderboard: vi.fn(),
getSession: vi.fn(),
getWorkDetail: vi.fn(),
listGallery: vi.fn(),
listWorks: vi.fn(),
publishWork: vi.fn(),
restartRun: vi.fn(),
startRun: vi.fn(),
submitJump: vi.fn(),
},
}));
vi.mock('../../services/wooden-fish/woodenFishClient', () => ({
woodenFishClient: {
checkpointRun: vi.fn(),
createSession: vi.fn(),
executeAction: vi.fn(),
finishRun: vi.fn(),
getGalleryDetail: vi.fn(),
getSession: vi.fn(),
getWorkDetail: vi.fn(),
listGallery: vi.fn(async () => ({
hasMore: false,
items: [],
nextCursor: null,
})),
listWorks: vi.fn(async () => ({ items: [] })),
publishWork: vi.fn(),
restartRun: vi.fn(),
startRun: vi.fn(),
},
}));
vi.mock('../../services/rpg-entry/rpgEntryLibraryClient', () => ({
...rpgEntryLibraryServiceMocks,
}));
@@ -2518,6 +2560,12 @@ beforeEach(() => {
vi.mocked(deleteRpgEntryWorldProfile).mockResolvedValue([]);
vi.mocked(listVisualNovelGallery).mockResolvedValue({ works: [] });
vi.mocked(listVisualNovelWorks).mockResolvedValue({ works: [] });
vi.mocked(woodenFishClient.listGallery).mockResolvedValue({
hasMore: false,
items: [],
nextCursor: null,
});
vi.mocked(woodenFishClient.listWorks).mockResolvedValue({ items: [] });
vi.mocked(listLocalBabyObjectMatchDrafts).mockResolvedValue([]);
vi.mocked(deleteLocalBabyObjectMatchDraft).mockResolvedValue([]);
vi.mocked(saveBabyObjectMatchDraft).mockImplementation(async (payload) => ({
@@ -4557,7 +4605,7 @@ test('match3d result trial passes generated models into first runtime mount', as
await waitFor(() => {
expect(match3dServerRuntimeAdapterMock.startRun).toHaveBeenCalledWith(
'match3d-profile-draft-1',
{},
ISOLATED_RUNTIME_AUTH_OPTIONS,
);
});
expect(
@@ -4650,7 +4698,7 @@ test('match3d result trial passes generated 2D image views into first runtime mo
await waitFor(() => {
expect(match3dServerRuntimeAdapterMock.startRun).toHaveBeenCalledWith(
'match3d-profile-draft-2d-1',
{},
ISOLATED_RUNTIME_AUTH_OPTIONS,
);
});
expect(
@@ -5680,10 +5728,10 @@ test('opening a compiled draft with a missing agent session falls back to draft
await waitFor(() => {
expect(fallbackDraftPanel.getAttribute('aria-hidden')).toBe('false');
expect(
within(fallbackDraftPanel).getByText(
screen.getAllByText(
'这份共创草稿已失效,已为你返回草稿列表,请重新开始创作。',
),
).toBeTruthy();
).length,
).toBeGreaterThan(0);
});
expect(window.location.search).toBe('');
@@ -5799,6 +5847,213 @@ test('logged out public detail gates puzzle start and remix before real actions'
expect(remixPuzzleGalleryWork).not.toHaveBeenCalled();
});
test('logged out public jump-hop detail starts runtime without requireAuth', async () => {
const user = userEvent.setup();
const requireAuth = vi.fn();
const publishedJumpHopWork: JumpHopWorkProfileResponse = {
summary: {
runtimeKind: 'jump-hop',
workId: 'jump-hop-work-public-1',
profileId: 'jump-hop-profile-public-12345678',
ownerUserId: 'user-2',
sourceSessionId: 'jump-hop-session-public-1',
themeText: '云上方块',
workTitle: '云上方块跳一跳',
workDescription: '在云层地块之间连续弹跳。',
themeTags: ['云层', '跳跃'],
difficulty: 'standard',
stylePreset: 'paper-toy',
coverImageSrc: null,
publicationStatus: 'published',
playCount: 3,
updatedAt: '2026-05-29T10:00:00.000Z',
publishedAt: '2026-05-29T10:00:00.000Z',
publishReady: true,
generationStatus: 'ready',
},
draft: {
templateId: 'jump-hop',
templateName: '跳一跳',
profileId: 'jump-hop-profile-public-12345678',
themeText: '云上方块',
workTitle: '云上方块跳一跳',
workDescription: '在云层地块之间连续弹跳。',
themeTags: ['云层', '跳跃'],
difficulty: 'standard',
stylePreset: 'paper-toy',
defaultCharacter: {
characterId: 'builtin-default',
displayName: '默认角色',
modelKind: 'builtin-three',
bodyColor: '#df7f40',
accentColor: '#2563eb',
},
characterPrompt: '',
tilePrompt: '云上方块',
endMoodPrompt: null,
characterAsset: null,
tileAtlasAsset: null,
tileAssets: [],
path: null,
coverComposite: null,
generationStatus: 'ready',
},
path: {
seed: 'jump-hop-public-seed',
difficulty: 'standard',
platforms: [
{
platformId: 'platform-0',
tileType: 'start',
x: 0,
y: 0,
width: 1,
height: 1,
landingRadius: 0.7,
perfectRadius: 0.25,
scoreValue: 1,
},
{
platformId: 'platform-1',
tileType: 'normal',
x: 1,
y: 1,
width: 1,
height: 1,
landingRadius: 0.7,
perfectRadius: 0.25,
scoreValue: 1,
},
{
platformId: 'platform-2',
tileType: 'normal',
x: -1,
y: 2,
width: 1,
height: 1,
landingRadius: 0.7,
perfectRadius: 0.25,
scoreValue: 1,
},
],
finishIndex: 2,
cameraPreset: 'portrait-top-down',
scoring: {
chargeToDistanceRatio: 1,
maxChargeMs: 1800,
hitBonus: 0,
perfectBonus: 0,
},
},
defaultCharacter: {
characterId: 'builtin-default',
displayName: '默认角色',
modelKind: 'builtin-three',
bodyColor: '#df7f40',
accentColor: '#2563eb',
},
characterAsset: {
assetId: 'builtin-character',
imageSrc: '',
imageObjectKey: '',
assetObjectId: '',
generationProvider: 'builtin',
prompt: '',
width: 1,
height: 1,
},
tileAtlasAsset: {
assetId: 'tile-atlas-1',
imageSrc: '/generated-jump-hop-assets/public/atlas.png',
imageObjectKey: 'generated-jump-hop-assets/public/atlas.png',
assetObjectId: 'asset-tile-atlas-1',
generationProvider: 'gpt-image-2',
prompt: '云上方块',
width: 1024,
height: 1024,
},
tileAssets: [],
};
const publishedJumpHopRun: JumpHopRuntimeRunSnapshotResponse = {
runId: 'jump-hop-run-public-1',
profileId: publishedJumpHopWork.summary.profileId,
ownerUserId: '',
status: 'playing',
currentPlatformIndex: 0,
successfulJumpCount: 0,
durationMs: 0,
score: 0,
combo: 0,
path: publishedJumpHopWork.path,
lastJump: null,
startedAtMs: 1_779_999_000_000,
finishedAtMs: null,
};
window.history.replaceState(null, '', '/works/detail?work=JH-12345678');
vi.mocked(jumpHopClient.listGallery).mockResolvedValue({
items: [
{
publicWorkCode: 'JH-12345678',
workId: publishedJumpHopWork.summary.workId,
profileId: publishedJumpHopWork.summary.profileId,
ownerUserId: publishedJumpHopWork.summary.ownerUserId,
authorDisplayName: '跳跃作者',
themeText: publishedJumpHopWork.summary.themeText,
workTitle: publishedJumpHopWork.summary.workTitle,
workDescription: publishedJumpHopWork.summary.workDescription,
coverImageSrc: null,
themeTags: publishedJumpHopWork.summary.themeTags,
difficulty: publishedJumpHopWork.summary.difficulty,
stylePreset: publishedJumpHopWork.summary.stylePreset,
publicationStatus: publishedJumpHopWork.summary.publicationStatus,
playCount: publishedJumpHopWork.summary.playCount,
updatedAt: publishedJumpHopWork.summary.updatedAt,
publishedAt: publishedJumpHopWork.summary.publishedAt,
generationStatus: publishedJumpHopWork.summary.generationStatus,
},
],
hasMore: false,
nextCursor: null,
});
vi.mocked(jumpHopClient.getWorkDetail).mockResolvedValue({
item: publishedJumpHopWork,
});
vi.mocked(jumpHopClient.startRun).mockResolvedValue({
run: publishedJumpHopRun,
});
vi.mocked(jumpHopClient.getLeaderboard).mockResolvedValue({
profileId: publishedJumpHopWork.summary.profileId,
items: [],
viewerBest: null,
});
render(
<TestWrapper
authValue={createAuthValue({
user: null,
canAccessProtectedData: false,
openLoginModal: () => {},
requireAuth,
})}
/>,
);
expect(await screen.findByText('详情')).toBeTruthy();
await user.click(screen.getByRole('button', { name: '启动' }));
await waitFor(() => {
expect(jumpHopClient.startRun).toHaveBeenCalledWith(
publishedJumpHopWork.summary.profileId,
expect.objectContaining({
runtimeGuestToken: 'runtime-guest-token',
runtimeMode: 'published',
}),
);
});
expect(requireAuth).not.toHaveBeenCalled();
});
test('owned public puzzle detail edits original draft instead of remixing', async () => {
const user = userEvent.setup();
const ownedPuzzleWork = {
@@ -7239,8 +7494,8 @@ test('embedded puzzle form maps raw bearer token errors to user-facing auth copy
expect(createPuzzleAgentSession).toHaveBeenCalledTimes(1);
expect(createCreativeAgentSession).not.toHaveBeenCalled();
expect(
await screen.findByText('当前登录状态已失效,请重新登录后继续。'),
).toBeTruthy();
(await screen.findAllByText('当前登录状态已失效,请重新登录后继续。')).length,
).toBeGreaterThan(0);
expect(screen.queryByText('缺少 Authorization Bearer Token')).toBeNull();
});
@@ -8350,7 +8605,7 @@ test('public code search opens a published Match3D work by M3 code and starts ru
await waitFor(() => {
expect(match3dServerRuntimeAdapterMock.startRun).toHaveBeenCalledWith(
'match3d-profile-public-1',
{},
LOGGED_IN_RECOMMEND_RUNTIME_AUTH_OPTIONS,
);
});
expect(
@@ -8422,7 +8677,7 @@ test('published Match3D runtime receives persisted generated models', async () =
await waitFor(() => {
expect(match3dServerRuntimeAdapterMock.startRun).toHaveBeenCalledWith(
'match3d-profile-model-1',
{},
LOGGED_IN_RECOMMEND_RUNTIME_AUTH_OPTIONS,
);
});
expect(
@@ -10697,8 +10952,9 @@ test('creation hub published work card reveals delete action after card action r
publishedCard.focus();
await user.keyboard('{ArrowLeft}');
expect(screen.getByRole('button', { name: '删除' })).toBeTruthy();
await user.click(screen.getByRole('button', { name: '删除' }));
const deleteButtons = screen.getAllByRole('button', { name: '删除' });
expect(deleteButtons.length).toBeGreaterThan(0);
await user.click(deleteButtons[0]!);
const dialog = await screen.findByRole('dialog', { name: '删除作品' });
expect(dialog.parentElement?.className).toContain('platform-theme--light');

View File

@@ -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 {
@@ -35,7 +36,16 @@ const JUMP_HOP_RUNTIME_READ_RETRY: ApiRetryOptions = {
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 +54,7 @@ export type {
JumpHopGalleryCardResponse,
JumpHopGalleryDetailResponse,
JumpHopGalleryResponse,
JumpHopLeaderboardResponse,
JumpHopRunResponse,
JumpHopRuntimeRunSnapshotResponse,
JumpHopSessionResponse,
@@ -51,16 +62,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;
@@ -104,6 +109,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,6 +128,7 @@ function normalizeJumpHopWorkProfile(
summary,
draft: flattened.draft,
path: flattened.path,
defaultCharacter: flattened.defaultCharacter ?? flattened.draft?.defaultCharacter,
characterAsset: flattened.characterAsset,
tileAtlasAsset: flattened.tileAtlasAsset,
tileAssets: flattened.tileAssets,
@@ -232,9 +239,10 @@ export async function publishJumpHopWork(profileId: string) {
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 +251,7 @@ export async function startJumpHopRuntimeRun(
'content-type': 'application/json',
...buildRuntimeGuestHeaders(options),
},
body: JSON.stringify({ profileId }),
body: JSON.stringify({ profileId, runtimeMode }),
},
'启动跳一跳运行态失败',
{
@@ -254,12 +262,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 +288,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 = {},
@@ -309,6 +335,7 @@ export const jumpHopClient = {
listGallery: listJumpHopGallery,
listWorks: listJumpHopWorks,
publishWork: publishJumpHopWork,
getLeaderboard: getJumpHopLeaderboard,
restartRun: restartJumpHopRuntimeRun,
startRun: startJumpHopRuntimeRun,
submitJump: submitJumpHopJump,

View 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,
};
}

View 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];
}

View 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);
});

View 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 };
}

View File

@@ -484,7 +484,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(
@@ -494,23 +494,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: '云端糖果塔主题的俯视角清爽游戏化立体感平台素材',
});
expect(entries).toEqual([
@@ -519,15 +516,10 @@ describe('miniGameDraftGenerationProgress', () => {
label: '主题',
value: '云端糖果塔',
},
{
id: 'jump-hop-character',
label: '角色',
value: '披着星星披风的小旅人',
},
{
id: 'jump-hop-tile-style',
label: '地块',
value: '纸模玩具',
label: '地块图集',
value: '云端糖果塔主题的俯视角清爽游戏化立体感平台素材',
},
]);
});

View File

@@ -63,7 +63,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'
@@ -391,32 +390,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>;
@@ -637,7 +630,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';
}
@@ -1086,21 +1079,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() ||
'',
},