feat(jump-hop): optimize generated assets and runtime background
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
/* @vitest-environment jsdom */
|
||||
|
||||
import { act, fireEvent, render, screen } from '@testing-library/react';
|
||||
import { act, fireEvent, render, screen, within } from '@testing-library/react';
|
||||
import { beforeEach, expect, test, vi } from 'vitest';
|
||||
|
||||
import type {
|
||||
@@ -229,7 +229,31 @@ test('跳一跳蓄力时角色沿拖拽方向拉伸', async () => {
|
||||
);
|
||||
});
|
||||
|
||||
test('跳一跳运行态需要三维场景宿主和排行榜面板', () => {
|
||||
test('跳一跳运行态游玩中只保留得分并隐藏常驻排行榜', () => {
|
||||
const runtimeRequestOptions = {
|
||||
runtimeGuestToken: 'runtime-guest-token',
|
||||
};
|
||||
|
||||
render(
|
||||
<JumpHopRuntimeShell
|
||||
profile={buildProfile()}
|
||||
run={buildRun()}
|
||||
runtimeRequestOptions={runtimeRequestOptions}
|
||||
onJump={vi.fn().mockResolvedValue(undefined)}
|
||||
onRestart={() => {}}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(useJumpHopLeaderboard).not.toHaveBeenCalled();
|
||||
expect(screen.getByTestId('jump-hop-three-scene')).toBeTruthy();
|
||||
expect(screen.queryByTestId('jump-hop-runtime-leaderboard')).toBeNull();
|
||||
expect(screen.queryByRole('button', { name: /重开/ })).toBeNull();
|
||||
expect(screen.queryByText('进行中')).toBeNull();
|
||||
expect(screen.queryByText('00:00')).toBeNull();
|
||||
expect(screen.queryByRole('button', { name: /^起跳$/ })).toBeNull();
|
||||
});
|
||||
|
||||
test('跳一跳运行态失败后在弹窗中展示排行榜', () => {
|
||||
const runtimeRequestOptions = {
|
||||
runtimeGuestToken: 'runtime-guest-token',
|
||||
};
|
||||
@@ -255,7 +279,7 @@ test('跳一跳运行态需要三维场景宿主和排行榜面板', () => {
|
||||
render(
|
||||
<JumpHopRuntimeShell
|
||||
profile={buildProfile()}
|
||||
run={buildRun()}
|
||||
run={buildFailedRun()}
|
||||
runtimeRequestOptions={runtimeRequestOptions}
|
||||
onJump={vi.fn().mockResolvedValue(undefined)}
|
||||
onRestart={() => {}}
|
||||
@@ -266,12 +290,12 @@ test('跳一跳运行态需要三维场景宿主和排行榜面板', () => {
|
||||
'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();
|
||||
expect(screen.getByRole('dialog', { name: '失败' })).toBeTruthy();
|
||||
const leaderboard = screen.getByTestId('jump-hop-runtime-leaderboard');
|
||||
expect(leaderboard).toBeTruthy();
|
||||
expect(within(leaderboard).getByText('player-1')).toBeTruthy();
|
||||
expect(within(leaderboard).getByText('8 跳')).toBeTruthy();
|
||||
expect(within(leaderboard).getByText('00:08')).toBeTruthy();
|
||||
});
|
||||
|
||||
test('跳一跳角色层永远压在地块层之上', () => {
|
||||
@@ -356,6 +380,7 @@ test('跳一跳运行态直接渲染生成的地块切片图片', () => {
|
||||
|
||||
const tileImages = screen.getAllByTestId('jump-hop-tile-image');
|
||||
expect(tileImages).toHaveLength(3);
|
||||
expect(document.querySelector('.jump-hop-runtime__fallback-tile')).toBeNull();
|
||||
const generatedReadUrlCalls = vi
|
||||
.mocked(useResolvedAssetReadUrl)
|
||||
.mock.calls.filter(([source]) =>
|
||||
@@ -379,6 +404,25 @@ test('跳一跳运行态直接渲染生成的地块切片图片', () => {
|
||||
}
|
||||
});
|
||||
|
||||
test('跳一跳运行态提前预加载下一屏地块且不在真实图片加载前露出原型方块', () => {
|
||||
render(
|
||||
<JumpHopRuntimeShell
|
||||
profile={buildProfile({ tileAssets: buildTileAssets() })}
|
||||
run={buildRunWithExtraPreviewPlatform()}
|
||||
onJump={vi.fn().mockResolvedValue(undefined)}
|
||||
onRestart={() => {}}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getAllByTestId('jump-hop-tile-image')).toHaveLength(3);
|
||||
expect(document.querySelector('.jump-hop-runtime__fallback-tile')).toBeNull();
|
||||
const preloadImages = screen.getAllByTestId('jump-hop-tile-preload-image');
|
||||
expect(preloadImages.length).toBeGreaterThan(0);
|
||||
expect(preloadImages[0]?.getAttribute('src')).toContain(
|
||||
'/generated-jump-hop-assets/jump-hop-profile-test/tile-',
|
||||
);
|
||||
});
|
||||
|
||||
test('跳一跳运行态首块地块落在中下方并且后续两块向中央和上方展开', () => {
|
||||
render(
|
||||
<JumpHopRuntimeShell
|
||||
@@ -679,6 +723,14 @@ test('跳一跳松手后先播放飞行动画再切换到下一块地块', async
|
||||
expect(styleText).toMatch(
|
||||
/data-platform-advancing='true'\]\s+\.jump-hop-runtime__platform[\s\S]*transform 1440ms cubic-bezier/,
|
||||
);
|
||||
expect(document.querySelector('.jump-hop-runtime__fallback-tile')).toBeNull();
|
||||
const advancingCharacterRule = styleText.match(
|
||||
/\.jump-hop-runtime__stage\[data-platform-advancing='true'\]\s+\.jump-hop-runtime__character\s*\{(?<body>[\s\S]*?)\}/,
|
||||
)?.groups?.body;
|
||||
expect(advancingCharacterRule).toContain('transform 120ms ease');
|
||||
expect(advancingCharacterRule).toContain('opacity 160ms ease');
|
||||
expect(advancingCharacterRule).not.toContain('left');
|
||||
expect(advancingCharacterRule).not.toContain('top');
|
||||
expect(screen.getByTestId('jump-hop-three-scene').parentElement).toBe(
|
||||
cameraLayer,
|
||||
);
|
||||
@@ -823,6 +875,50 @@ function buildRun(): JumpHopRuntimeRunSnapshotResponse {
|
||||
};
|
||||
}
|
||||
|
||||
function buildFailedRun(): JumpHopRuntimeRunSnapshotResponse {
|
||||
return {
|
||||
...buildRun(),
|
||||
status: 'failed',
|
||||
successfulJumpCount: 8,
|
||||
durationMs: 8123,
|
||||
score: 8,
|
||||
combo: 0,
|
||||
lastJump: {
|
||||
chargeMs: 420,
|
||||
jumpDistance: 1.62,
|
||||
targetPlatformIndex: 1,
|
||||
landedX: 0,
|
||||
landedY: 0,
|
||||
result: 'miss',
|
||||
},
|
||||
finishedAtMs: 9123,
|
||||
};
|
||||
}
|
||||
|
||||
function buildRunWithExtraPreviewPlatform(): JumpHopRuntimeRunSnapshotResponse {
|
||||
const run = buildRun();
|
||||
return {
|
||||
...run,
|
||||
path: {
|
||||
...run.path,
|
||||
platforms: [
|
||||
...run.path.platforms,
|
||||
{
|
||||
platformId: 'p3',
|
||||
tileType: 'normal',
|
||||
x: 0.5,
|
||||
y: 3.6,
|
||||
width: 1,
|
||||
height: 1,
|
||||
landingRadius: 0.5,
|
||||
perfectRadius: 0.2,
|
||||
scoreValue: 1,
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function buildTileAssets() {
|
||||
return Array.from({ length: 25 }, (_, index) => {
|
||||
const tileNumber = String(index + 1).padStart(2, '0');
|
||||
@@ -845,6 +941,8 @@ function buildTileAssets() {
|
||||
|
||||
function buildProfile(options: {
|
||||
tileAssets?: JumpHopWorkProfileResponse['tileAssets'];
|
||||
coverComposite?: string | null;
|
||||
coverImageSrc?: string | null;
|
||||
} = {}): JumpHopWorkProfileResponse {
|
||||
const characterAsset = {
|
||||
assetId: 'builtin',
|
||||
@@ -869,7 +967,7 @@ function buildProfile(options: {
|
||||
themeTags: ['测试'],
|
||||
difficulty: 'standard',
|
||||
stylePreset: 'minimal-blocks',
|
||||
coverImageSrc: null,
|
||||
coverImageSrc: options.coverImageSrc ?? null,
|
||||
publicationStatus: 'draft',
|
||||
playCount: 0,
|
||||
updatedAt: '2026-05-27T00:00:00Z',
|
||||
@@ -901,7 +999,7 @@ function buildProfile(options: {
|
||||
tileAtlasAsset: characterAsset,
|
||||
tileAssets: options.tileAssets ?? [],
|
||||
path: buildRun().path,
|
||||
coverComposite: null,
|
||||
coverComposite: options.coverComposite ?? null,
|
||||
generationStatus: 'ready',
|
||||
},
|
||||
path: buildRun().path,
|
||||
@@ -917,3 +1015,46 @@ function buildProfile(options: {
|
||||
tileAssets: options.tileAssets ?? [],
|
||||
};
|
||||
}
|
||||
|
||||
test('跳一跳运行态使用 image2 背景底图铺满舞台底层', () => {
|
||||
const backgroundSource =
|
||||
'/generated-jump-hop-assets/jump-hop-profile-test/background/image.png';
|
||||
|
||||
render(
|
||||
<JumpHopRuntimeShell
|
||||
profile={buildProfile({ coverComposite: backgroundSource })}
|
||||
run={buildRun()}
|
||||
onJump={vi.fn().mockResolvedValue(undefined)}
|
||||
onRestart={() => {}}
|
||||
/>,
|
||||
);
|
||||
|
||||
const backgroundImage = screen.getByTestId('jump-hop-stage-background-image');
|
||||
expect(backgroundImage.getAttribute('src')).toBe(backgroundSource);
|
||||
const backdrop = document.querySelector('.jump-hop-runtime__scene-backdrop');
|
||||
expect(backdrop?.getAttribute('data-has-background')).toBe('true');
|
||||
expect(useResolvedAssetReadUrl).toHaveBeenCalledWith(
|
||||
backgroundSource,
|
||||
expect.objectContaining({
|
||||
refreshKey: backgroundSource,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
test('跳一跳运行态忽略旧 cover composite 占位背景', () => {
|
||||
render(
|
||||
<JumpHopRuntimeShell
|
||||
profile={buildProfile({
|
||||
coverComposite:
|
||||
'/generated-jump-hop-assets/jump-hop-profile-test/cover-composite.png',
|
||||
})}
|
||||
run={buildRun()}
|
||||
onJump={vi.fn().mockResolvedValue(undefined)}
|
||||
onRestart={() => {}}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.queryByTestId('jump-hop-stage-background-image')).toBeNull();
|
||||
const backdrop = document.querySelector('.jump-hop-runtime__scene-backdrop');
|
||||
expect(backdrop?.getAttribute('data-has-background')).toBe('false');
|
||||
});
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { ArrowLeft, Loader2, RotateCcw } from 'lucide-react';
|
||||
import { ArrowLeft, Loader2 } from 'lucide-react';
|
||||
import {
|
||||
type CSSProperties,
|
||||
type Dispatch,
|
||||
@@ -29,6 +29,7 @@ import {
|
||||
getJumpHopRunDurationMs,
|
||||
getJumpHopStatusLabel,
|
||||
getJumpHopTileTone,
|
||||
selectJumpHopTileAsset,
|
||||
type JumpHopCharacterVisualPosition,
|
||||
type JumpHopVisiblePlatform,
|
||||
resolveJumpHopCharacterCanvasPosition,
|
||||
@@ -66,6 +67,7 @@ const JUMP_HOP_LANDING_RECOIL_DURATION_MS = 560;
|
||||
const JUMP_HOP_PLATFORM_ADVANCE_DURATION_MS = 1440;
|
||||
const JUMP_HOP_TAONIER_CHARACTER_IMAGE_SRC =
|
||||
'/branding/jump-hop-taonier-character.png';
|
||||
const JUMP_HOP_TILE_PRELOAD_LOOKAHEAD_COUNT = 3;
|
||||
|
||||
function clamp(value: number, min: number, max: number) {
|
||||
return Math.min(max, Math.max(min, value));
|
||||
@@ -192,6 +194,21 @@ function IsometricFallbackTile({
|
||||
);
|
||||
}
|
||||
|
||||
function getJumpHopTileAssetRefreshKey(asset: JumpHopTileAsset | null) {
|
||||
return asset?.assetObjectId || asset?.imageObjectKey || asset?.tileId || null;
|
||||
}
|
||||
|
||||
function isJumpHopGeneratedBackgroundSource(source: string | null | undefined) {
|
||||
const value = source?.trim() ?? '';
|
||||
if (!value) {
|
||||
return false;
|
||||
}
|
||||
return !(
|
||||
value.startsWith('/generated-jump-hop-assets/') &&
|
||||
(value.endsWith('/cover-composite.png') || value.includes('/cover-composite-'))
|
||||
);
|
||||
}
|
||||
|
||||
function JumpHopTileImage({
|
||||
asset,
|
||||
platform,
|
||||
@@ -199,8 +216,7 @@ function JumpHopTileImage({
|
||||
asset: JumpHopTileAsset | null;
|
||||
platform: JumpHopVisiblePlatform['platform'];
|
||||
}) {
|
||||
const assetRefreshKey =
|
||||
asset?.assetObjectId || asset?.imageObjectKey || asset?.tileId || null;
|
||||
const assetRefreshKey = getJumpHopTileAssetRefreshKey(asset);
|
||||
const { resolvedUrl } = useResolvedAssetReadUrl(asset?.imageSrc, {
|
||||
refreshKey: assetRefreshKey,
|
||||
});
|
||||
@@ -212,12 +228,13 @@ function JumpHopTileImage({
|
||||
setHasError(false);
|
||||
}, [resolvedUrl]);
|
||||
|
||||
const shouldShowFallback = !resolvedUrl || !isLoaded || hasError;
|
||||
const shouldShowImage = Boolean(resolvedUrl && !hasError);
|
||||
const shouldShowFallback = !shouldShowImage;
|
||||
|
||||
return (
|
||||
<div className="jump-hop-runtime__tile-image-stack">
|
||||
{shouldShowFallback ? <IsometricFallbackTile platform={platform} /> : null}
|
||||
{resolvedUrl && !hasError ? (
|
||||
{shouldShowImage ? (
|
||||
<img
|
||||
src={resolvedUrl}
|
||||
alt=""
|
||||
@@ -225,7 +242,7 @@ function JumpHopTileImage({
|
||||
data-testid="jump-hop-tile-image"
|
||||
data-tile-id={asset?.tileId ?? asset?.sourceAtlasCell}
|
||||
className="jump-hop-runtime__tile-image"
|
||||
data-loaded={isLoaded ? 'true' : 'false'}
|
||||
data-loaded={isLoaded || shouldShowImage ? 'true' : 'false'}
|
||||
onLoad={() => {
|
||||
setIsLoaded(true);
|
||||
}}
|
||||
@@ -238,6 +255,28 @@ function JumpHopTileImage({
|
||||
);
|
||||
}
|
||||
|
||||
function JumpHopTilePreloadImage({ asset }: { asset: JumpHopTileAsset }) {
|
||||
const assetRefreshKey = getJumpHopTileAssetRefreshKey(asset);
|
||||
const { resolvedUrl } = useResolvedAssetReadUrl(asset.imageSrc, {
|
||||
refreshKey: assetRefreshKey,
|
||||
});
|
||||
|
||||
if (!resolvedUrl) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<img
|
||||
src={resolvedUrl}
|
||||
alt=""
|
||||
aria-hidden="true"
|
||||
draggable={false}
|
||||
data-testid="jump-hop-tile-preload-image"
|
||||
className="jump-hop-runtime__tile-preload-image"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function hasJumpHopWebGLSupport() {
|
||||
if (import.meta.env.MODE === 'test') {
|
||||
return false;
|
||||
@@ -573,6 +612,16 @@ export function JumpHopRuntimeShell({
|
||||
const displayRunRef = useRef(displayRun);
|
||||
const visiblePlatformsRef = useRef<JumpHopVisiblePlatform[]>([]);
|
||||
const tileAssetsRef = useRef(profile?.tileAssets);
|
||||
const stageBackgroundSource = [
|
||||
profile?.draft.coverComposite,
|
||||
profile?.summary.coverImageSrc,
|
||||
].find(isJumpHopGeneratedBackgroundSource);
|
||||
const { resolvedUrl: stageBackgroundUrl } = useResolvedAssetReadUrl(
|
||||
stageBackgroundSource,
|
||||
{
|
||||
refreshKey: stageBackgroundSource,
|
||||
},
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
activeRunRef.current = activeRun;
|
||||
@@ -612,7 +661,7 @@ export function JumpHopRuntimeShell({
|
||||
const platformRenderItems = useMemo(() => {
|
||||
const exitingItems = platformAdvanceExitingPlatforms.map((item) => ({
|
||||
...item,
|
||||
renderKey: `${item.platform.platformId}-exiting`,
|
||||
renderKey: item.platform.platformId,
|
||||
advanceState: 'exiting' as const,
|
||||
}));
|
||||
const visibleItems = visiblePlatforms.map((item) => ({
|
||||
@@ -627,6 +676,47 @@ export function JumpHopRuntimeShell({
|
||||
platformAdvanceExitingPlatforms,
|
||||
visiblePlatforms,
|
||||
]);
|
||||
const preloadTileAssets = useMemo(() => {
|
||||
const path = stageRun?.path;
|
||||
const tileAssets = profile?.tileAssets;
|
||||
const platforms = path?.platforms ?? [];
|
||||
const startIndex =
|
||||
(stageRun?.currentPlatformIndex ?? 0) + visiblePlatforms.length;
|
||||
const assets = new Map<string, JumpHopTileAsset>();
|
||||
|
||||
for (
|
||||
let index = startIndex;
|
||||
index <
|
||||
Math.min(
|
||||
platforms.length,
|
||||
startIndex + JUMP_HOP_TILE_PRELOAD_LOOKAHEAD_COUNT,
|
||||
);
|
||||
index += 1
|
||||
) {
|
||||
const platform = platforms[index];
|
||||
if (!platform) {
|
||||
continue;
|
||||
}
|
||||
const asset = selectJumpHopTileAsset(
|
||||
tileAssets,
|
||||
path?.seed ?? null,
|
||||
index,
|
||||
platform.platformId,
|
||||
);
|
||||
if (!asset) {
|
||||
continue;
|
||||
}
|
||||
const key = getJumpHopTileAssetRefreshKey(asset) ?? asset.imageSrc;
|
||||
assets.set(key, asset);
|
||||
}
|
||||
|
||||
return [...assets.values()];
|
||||
}, [
|
||||
profile?.tileAssets,
|
||||
stageRun?.currentPlatformIndex,
|
||||
stageRun?.path,
|
||||
visiblePlatforms.length,
|
||||
]);
|
||||
const showLandingAssist =
|
||||
import.meta.env.MODE !== 'production' && isCharging && !isJumpAnimating;
|
||||
const characterPosition = getJumpHopCharacterVisualPosition(
|
||||
@@ -753,6 +843,7 @@ export function JumpHopRuntimeShell({
|
||||
const jumpFeedbackForDisplay = getJumpHopJumpFeedbackLabel(stageRun);
|
||||
const isSettled =
|
||||
stageRun?.status === 'failed' || stageRun?.status === 'cleared';
|
||||
const shouldShowFailureLeaderboard = stageRun?.status === 'failed';
|
||||
const successfulJumpCount = stageRun?.successfulJumpCount ?? 0;
|
||||
const durationLabel = formatJumpHopDurationLabel(
|
||||
getJumpHopRunDurationMs(stageRun, nowMs),
|
||||
@@ -1219,29 +1310,19 @@ export function JumpHopRuntimeShell({
|
||||
<div className="jump-hop-runtime__sky" />
|
||||
<div className="pointer-events-none absolute inset-0 bg-[radial-gradient(circle_at_50%_18%,rgba(255,255,255,0.82),transparent_30%),linear-gradient(180deg,rgba(255,255,255,0.18),rgba(234,204,179,0.24))]" />
|
||||
|
||||
<header className="relative z-20 flex items-center justify-between gap-2 px-3 pb-2 pt-[max(0.75rem,env(safe-area-inset-top))] sm:px-4">
|
||||
<header className="relative z-20 grid grid-cols-[1fr_auto_1fr] items-center gap-2 px-3 pb-2 pt-[max(0.75rem,env(safe-area-inset-top))] sm:px-4">
|
||||
<button
|
||||
type="button"
|
||||
onClick={exitHandler}
|
||||
className="platform-button platform-button--ghost min-h-0 rounded-full bg-white/80 px-3 py-2 text-sm shadow-sm backdrop-blur"
|
||||
className="platform-button platform-button--ghost min-h-0 justify-self-start rounded-full bg-white/80 px-3 py-2 text-sm shadow-sm backdrop-blur"
|
||||
>
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
返回
|
||||
</button>
|
||||
<div className="flex items-center gap-2 rounded-full border border-white/70 bg-white/82 px-3 py-2 text-sm font-black shadow-sm backdrop-blur">
|
||||
<span>{successfulJumpCount}</span>
|
||||
<span className="h-1 w-1 rounded-full bg-slate-300" />
|
||||
<span>{durationLabel}</span>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onRestart}
|
||||
disabled={isBusy}
|
||||
className="platform-button platform-button--ghost min-h-0 rounded-full bg-white/80 px-3 py-2 text-sm shadow-sm backdrop-blur"
|
||||
>
|
||||
<RotateCcw className="h-4 w-4" />
|
||||
重开
|
||||
</button>
|
||||
<div aria-hidden="true" />
|
||||
</header>
|
||||
|
||||
<main className="relative z-10 mx-auto flex w-full max-w-[30rem] flex-1 flex-col px-3 pb-[max(0.75rem,env(safe-area-inset-bottom))] sm:px-4">
|
||||
@@ -1251,7 +1332,7 @@ export function JumpHopRuntimeShell({
|
||||
data-charging={isCharging ? 'true' : 'false'}
|
||||
data-jump-animating={isJumpAnimating ? 'true' : 'false'}
|
||||
data-platform-advancing={isPlatformAdvancing ? 'true' : 'false'}
|
||||
className="jump-hop-runtime__stage relative min-h-0 flex-1 touch-none select-none overflow-hidden rounded-[1.5rem] border border-white/70 bg-white/40 shadow-[0_24px_70px_rgba(44,125,182,0.2)]"
|
||||
className="jump-hop-runtime__stage relative min-h-0 flex-1 touch-none select-none overflow-hidden rounded-[1.5rem]"
|
||||
onPointerDown={beginCharge}
|
||||
onPointerMove={updateDragVector}
|
||||
onPointerUp={(event) => void finishCharge(event)}
|
||||
@@ -1260,7 +1341,18 @@ export function JumpHopRuntimeShell({
|
||||
<div
|
||||
aria-hidden="true"
|
||||
className="jump-hop-runtime__scene-backdrop"
|
||||
/>
|
||||
data-has-background={stageBackgroundUrl ? 'true' : 'false'}
|
||||
>
|
||||
{stageBackgroundUrl ? (
|
||||
<img
|
||||
src={stageBackgroundUrl}
|
||||
alt=""
|
||||
draggable={false}
|
||||
data-testid="jump-hop-stage-background-image"
|
||||
className="jump-hop-runtime__scene-background-image"
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
<div
|
||||
data-testid="jump-hop-camera-layer"
|
||||
data-platform-advancing={isPlatformAdvancing ? 'true' : 'false'}
|
||||
@@ -1318,6 +1410,17 @@ export function JumpHopRuntimeShell({
|
||||
);
|
||||
})}
|
||||
|
||||
{preloadTileAssets.length > 0 ? (
|
||||
<div className="jump-hop-runtime__tile-preload" aria-hidden="true">
|
||||
{preloadTileAssets.map((asset) => (
|
||||
<JumpHopTilePreloadImage
|
||||
key={getJumpHopTileAssetRefreshKey(asset) ?? asset.imageSrc}
|
||||
asset={asset}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{visualCharacterPosition && !isThreeCharacterLayerReady ? (
|
||||
<div
|
||||
className="jump-hop-runtime__character"
|
||||
@@ -1426,14 +1529,27 @@ export function JumpHopRuntimeShell({
|
||||
|
||||
{isSettled ? (
|
||||
<div className="absolute inset-0 z-[120] grid place-items-center bg-slate-950/28 px-6 backdrop-blur-[2px]">
|
||||
<div className="w-full max-w-[18rem] rounded-[1.25rem] border border-white/70 bg-white/90 p-4 text-center shadow-[0_18px_50px_rgba(15,23,42,0.22)]">
|
||||
<div
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="jump-hop-result-title"
|
||||
className="w-full max-w-[24rem] rounded-[1.25rem] border border-white/70 bg-white/90 p-4 text-center shadow-[0_18px_50px_rgba(15,23,42,0.22)]"
|
||||
>
|
||||
<div className="text-2xl font-black">
|
||||
{getJumpHopStatusLabel(stageRun?.status)}
|
||||
<span id="jump-hop-result-title">
|
||||
{getJumpHopStatusLabel(stageRun?.status)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="mt-2 flex justify-center gap-4 text-sm font-bold text-slate-600">
|
||||
<span>{successfulJumpCount} 跳</span>
|
||||
<span>{durationLabel}</span>
|
||||
</div>
|
||||
{shouldShowFailureLeaderboard ? (
|
||||
<JumpHopLeaderboardPanel
|
||||
profileId={profile?.summary.profileId}
|
||||
runtimeRequestOptions={runtimeRequestOptions}
|
||||
/>
|
||||
) : null}
|
||||
<div className="mt-4 grid grid-cols-2 gap-2">
|
||||
<button
|
||||
type="button"
|
||||
@@ -1456,21 +1572,15 @@ export function JumpHopRuntimeShell({
|
||||
) : null}
|
||||
</section>
|
||||
|
||||
<footer className="relative z-20 mt-3 flex items-center justify-between gap-3">
|
||||
<div className="min-w-0 text-sm font-bold text-slate-700">
|
||||
{error ? (
|
||||
{error ? (
|
||||
<footer className="relative z-20 mt-3 flex items-center justify-between gap-3">
|
||||
<div className="min-w-0 text-sm font-bold text-slate-700">
|
||||
<span className="text-[var(--platform-button-danger-text)]">
|
||||
{error}
|
||||
</span>
|
||||
) : (
|
||||
<span>{getJumpHopStatusLabel(stageRun?.status)}</span>
|
||||
)}
|
||||
</div>
|
||||
</footer>
|
||||
<JumpHopLeaderboardPanel
|
||||
profileId={profile?.summary.profileId}
|
||||
runtimeRequestOptions={runtimeRequestOptions}
|
||||
/>
|
||||
</div>
|
||||
</footer>
|
||||
) : null}
|
||||
</main>
|
||||
|
||||
<style>{`
|
||||
@@ -1498,12 +1608,38 @@ export function JumpHopRuntimeShell({
|
||||
inset: 0;
|
||||
z-index: 0;
|
||||
pointer-events: none;
|
||||
overflow: hidden;
|
||||
background:
|
||||
radial-gradient(circle at 18% 18%, rgba(253, 230, 138, 0.36), transparent 24%),
|
||||
radial-gradient(circle at 82% 22%, rgba(226, 171, 134, 0.34), transparent 28%),
|
||||
linear-gradient(180deg, #fffdf9 0%, #f8efe7 52%, #f4e5d7 100%);
|
||||
}
|
||||
|
||||
.jump-hop-runtime__scene-backdrop[data-has-background='true'] {
|
||||
background: #f8efe7;
|
||||
}
|
||||
|
||||
.jump-hop-runtime__scene-backdrop[data-has-background='true']::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
z-index: 2;
|
||||
background:
|
||||
radial-gradient(circle at 50% 20%, rgba(255, 255, 255, 0.16), transparent 34%),
|
||||
linear-gradient(180deg, rgba(255, 253, 249, 0.08), rgba(50, 34, 24, 0.08));
|
||||
}
|
||||
|
||||
.jump-hop-runtime__scene-background-image {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
z-index: 1;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
object-position: 50% 50%;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.jump-hop-runtime__camera-layer {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
@@ -1550,6 +1686,12 @@ export function JumpHopRuntimeShell({
|
||||
opacity 220ms ease;
|
||||
}
|
||||
|
||||
.jump-hop-runtime__stage[data-platform-advancing='true'] .jump-hop-runtime__character {
|
||||
transition:
|
||||
transform 120ms ease,
|
||||
opacity 160ms ease;
|
||||
}
|
||||
|
||||
.jump-hop-runtime__platform-shadow {
|
||||
position: absolute;
|
||||
left: 8%;
|
||||
@@ -1583,6 +1725,22 @@ export function JumpHopRuntimeShell({
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.jump-hop-runtime__tile-preload {
|
||||
position: absolute;
|
||||
left: -9999px;
|
||||
top: -9999px;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
overflow: hidden;
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.jump-hop-runtime__tile-preload-image {
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
}
|
||||
|
||||
.jump-hop-runtime__landing-assist {
|
||||
position: absolute;
|
||||
z-index: 110;
|
||||
|
||||
@@ -60,12 +60,12 @@ test('jump hop workspace submits theme payload after required field is filled',
|
||||
templateId: 'jump-hop',
|
||||
themeText: '云朵跳台',
|
||||
workTitle: '云朵跳台跳一跳',
|
||||
workDescription: '云朵跳台主题的俯视角平台跳跃作品',
|
||||
workDescription: '云朵跳台主题的俯视角跳跃作品',
|
||||
themeTags: ['云朵跳台', '跳一跳', '休闲'],
|
||||
difficulty: 'standard',
|
||||
stylePreset: 'minimal-blocks',
|
||||
characterPrompt: '内置默认 3D 角色',
|
||||
tilePrompt: '云朵跳台主题的俯视角清爽游戏化立体感平台素材',
|
||||
tilePrompt: '云朵跳台主题的正面30度视角主题物体图集,物体本身作为跳跃落点',
|
||||
endMoodPrompt: null,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -35,12 +35,12 @@ function buildJumpHopWorkspacePayload(
|
||||
templateId: 'jump-hop',
|
||||
themeText,
|
||||
workTitle: `${themeText}跳一跳`,
|
||||
workDescription: `${themeText}主题的俯视角平台跳跃作品`,
|
||||
workDescription: `${themeText}主题的俯视角跳跃作品`,
|
||||
themeTags: [themeText, '跳一跳', '休闲'],
|
||||
difficulty: 'standard',
|
||||
stylePreset: 'minimal-blocks',
|
||||
characterPrompt: '内置默认 3D 角色',
|
||||
tilePrompt: `${themeText}主题的俯视角清爽游戏化立体感平台素材`,
|
||||
tilePrompt: `${themeText}主题的正面30度视角主题物体图集,物体本身作为跳跃落点`,
|
||||
endMoodPrompt: null,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -512,7 +512,7 @@ describe('miniGameDraftGenerationProgress', () => {
|
||||
const entries = buildJumpHopGenerationAnchorEntries(null, {
|
||||
themeText: '云端糖果塔',
|
||||
templateId: 'jump-hop',
|
||||
tilePrompt: '云端糖果塔主题的俯视角清爽游戏化立体感平台素材',
|
||||
tilePrompt: '云端糖果塔主题的正面30度视角主题物体图集,物体本身作为跳跃落点',
|
||||
});
|
||||
|
||||
expect(entries).toEqual([
|
||||
@@ -524,7 +524,7 @@ describe('miniGameDraftGenerationProgress', () => {
|
||||
{
|
||||
id: 'jump-hop-tile-style',
|
||||
label: '地块图集',
|
||||
value: '云端糖果塔主题的俯视角清爽游戏化立体感平台素材',
|
||||
value: '云端糖果塔主题的正面30度视角主题物体图集,物体本身作为跳跃落点',
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user