feat: polish jump hop themed runtime assets
This commit is contained in:
@@ -168,6 +168,7 @@ function buildProfile(
|
||||
tileAssets: [],
|
||||
path: null,
|
||||
coverComposite: null,
|
||||
backButtonAsset: null,
|
||||
generationStatus: 'ready',
|
||||
},
|
||||
path: null as never,
|
||||
@@ -199,5 +200,6 @@ function buildProfile(
|
||||
height: 0,
|
||||
},
|
||||
tileAssets: [],
|
||||
backButtonAsset: null,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -253,6 +253,70 @@ test('跳一跳运行态游玩中只保留得分并隐藏常驻排行榜', () =>
|
||||
expect(screen.queryByRole('button', { name: /^起跳$/ })).toBeNull();
|
||||
});
|
||||
|
||||
test('跳一跳运行态背景和游戏舞台覆盖全部界面且 HUD 使用独立主题按钮和拼图顶部样式', () => {
|
||||
const backButtonAsset = {
|
||||
assetId: 'jump-hop-back-button',
|
||||
imageSrc: '/generated-jump-hop-assets/jump-hop-profile-test/back-button/image.png',
|
||||
imageObjectKey:
|
||||
'generated-jump-hop-assets/jump-hop-profile-test/back-button/image.png',
|
||||
assetObjectId: 'asset-back-button',
|
||||
generationProvider: 'vector-engine-gpt-image-2',
|
||||
prompt: '主题返回按钮',
|
||||
width: 1024,
|
||||
height: 1024,
|
||||
} satisfies NonNullable<JumpHopWorkProfileResponse['backButtonAsset']>;
|
||||
|
||||
render(
|
||||
<JumpHopRuntimeShell
|
||||
profile={buildProfile({ backButtonAsset })}
|
||||
run={buildRun()}
|
||||
onJump={vi.fn().mockResolvedValue(undefined)}
|
||||
onRestart={() => {}}
|
||||
/>,
|
||||
);
|
||||
|
||||
const stage = screen.getByTestId('jump-hop-stage');
|
||||
expect(stage.className).toContain('absolute');
|
||||
expect(stage.className).toContain('inset-0');
|
||||
expect(stage.className).toContain('h-full');
|
||||
expect(stage.className).toContain('w-full');
|
||||
expect(stage.className).not.toContain('rounded-[1.5rem]');
|
||||
|
||||
const backButton = screen.getByRole('button', { name: '返回' });
|
||||
expect(backButton.className).toContain('pointer-events-auto');
|
||||
expect(backButton.className).toContain('jump-hop-runtime__back-button');
|
||||
expect(backButton.className).toContain('h-14');
|
||||
expect(backButton.className).toContain('w-14');
|
||||
expect(backButton.className).toContain('sm:h-[3.875rem]');
|
||||
expect(backButton.className).toContain('sm:w-[3.875rem]');
|
||||
expect(backButton.getAttribute('data-has-asset')).toBe('true');
|
||||
expect(backButton.textContent).toBe('');
|
||||
expect(
|
||||
screen
|
||||
.getByTestId('jump-hop-runtime-back-button-asset')
|
||||
.getAttribute('src'),
|
||||
).toBe(backButtonAsset.imageSrc);
|
||||
|
||||
const header = backButton.closest('header');
|
||||
expect(header?.className).toContain('absolute');
|
||||
expect(header?.className).toContain('top-0');
|
||||
expect(header?.className).toContain('z-[130]');
|
||||
expect(header?.querySelector('.puzzle-runtime-header-card')).toBeTruthy();
|
||||
const titleCard = header?.querySelector('.puzzle-runtime-level-title-card');
|
||||
expect(titleCard).toBeTruthy();
|
||||
expect(titleCard?.className).toContain('jump-hop-runtime__score-title-card');
|
||||
expect(screen.getByTestId('jump-hop-runtime-level-logo')).toBeTruthy();
|
||||
expect(screen.getByText('得分')).toBeTruthy();
|
||||
expect(screen.queryByText('跳一跳')).toBeNull();
|
||||
|
||||
const scoreCard = screen.getByTestId('jump-hop-score-card');
|
||||
expect(scoreCard.className).toContain('puzzle-runtime-timer-card');
|
||||
expect(scoreCard.className).toContain('puzzle-runtime-timer');
|
||||
expect(scoreCard.className).toContain('jump-hop-runtime__score-value-card');
|
||||
expect(scoreCard.className).toContain('justify-center');
|
||||
expect(scoreCard.className).toContain('text-center');
|
||||
});
|
||||
|
||||
test('跳一跳运行态失败后在弹窗中展示排行榜', () => {
|
||||
const runtimeRequestOptions = {
|
||||
runtimeGuestToken: 'runtime-guest-token',
|
||||
@@ -333,7 +397,7 @@ test('跳一跳角色层永远压在地块层之上', () => {
|
||||
);
|
||||
});
|
||||
|
||||
test('跳一跳落点辅助标识会随着拖拽方向和距离实时移动', async () => {
|
||||
test('跳一跳拖拽时隐藏落点辅助标识但保留弹弓拉线', async () => {
|
||||
const onJump = vi.fn().mockResolvedValue(undefined);
|
||||
|
||||
render(
|
||||
@@ -354,8 +418,6 @@ test('跳一跳落点辅助标识会随着拖拽方向和距离实时移动', as
|
||||
});
|
||||
});
|
||||
|
||||
expect(screen.queryByTestId('jump-hop-landing-assist')).toBeNull();
|
||||
|
||||
await act(async () => {
|
||||
dispatchPointerEvent(stage, 'pointermove', {
|
||||
pointerId: 1,
|
||||
@@ -364,11 +426,8 @@ test('跳一跳落点辅助标识会随着拖拽方向和距离实时移动', as
|
||||
});
|
||||
});
|
||||
|
||||
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%');
|
||||
expect(screen.queryByTestId('jump-hop-landing-assist')).toBeNull();
|
||||
expect(stage.querySelector('.jump-hop-runtime__slingshot-guide')).toBeTruthy();
|
||||
|
||||
await act(async () => {
|
||||
dispatchPointerEvent(stage, 'pointermove', {
|
||||
@@ -378,9 +437,8 @@ test('跳一跳落点辅助标识会随着拖拽方向和距离实时移动', as
|
||||
});
|
||||
});
|
||||
|
||||
const secondAssist = screen.getByTestId('jump-hop-landing-assist');
|
||||
expect(secondAssist.style.left).not.toBe(firstLeft);
|
||||
expect(secondAssist.style.top).not.toBe(firstTop);
|
||||
expect(screen.queryByTestId('jump-hop-landing-assist')).toBeNull();
|
||||
expect(stage.querySelector('.jump-hop-runtime__slingshot-guide')).toBeTruthy();
|
||||
});
|
||||
|
||||
test('跳一跳运行态直接渲染生成的地块切片图片', () => {
|
||||
@@ -958,6 +1016,7 @@ function buildProfile(options: {
|
||||
tileAssets?: JumpHopWorkProfileResponse['tileAssets'];
|
||||
coverComposite?: string | null;
|
||||
coverImageSrc?: string | null;
|
||||
backButtonAsset?: JumpHopWorkProfileResponse['backButtonAsset'];
|
||||
publicationStatus?: JumpHopWorkProfileResponse['summary']['publicationStatus'];
|
||||
} = {}): JumpHopWorkProfileResponse {
|
||||
const characterAsset = {
|
||||
@@ -1016,6 +1075,7 @@ function buildProfile(options: {
|
||||
tileAssets: options.tileAssets ?? [],
|
||||
path: buildRun().path,
|
||||
coverComposite: options.coverComposite ?? null,
|
||||
backButtonAsset: options.backButtonAsset ?? null,
|
||||
generationStatus: 'ready',
|
||||
},
|
||||
path: buildRun().path,
|
||||
@@ -1029,6 +1089,7 @@ function buildProfile(options: {
|
||||
characterAsset,
|
||||
tileAtlasAsset: characterAsset,
|
||||
tileAssets: options.tileAssets ?? [],
|
||||
backButtonAsset: options.backButtonAsset ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
useState,
|
||||
} from 'react';
|
||||
|
||||
import jumpHopRuntimeLevelLogo from '../../../media/logo.png';
|
||||
import type {
|
||||
JumpHopRuntimeRunSnapshotResponse,
|
||||
JumpHopTileAsset,
|
||||
@@ -622,6 +623,20 @@ export function JumpHopRuntimeShell({
|
||||
refreshKey: stageBackgroundSource,
|
||||
},
|
||||
);
|
||||
const backButtonAssetSource =
|
||||
profile?.backButtonAsset?.imageSrc?.trim() ||
|
||||
profile?.draft.backButtonAsset?.imageSrc?.trim() ||
|
||||
null;
|
||||
const { resolvedUrl: backButtonAssetUrl } = useResolvedAssetReadUrl(
|
||||
backButtonAssetSource,
|
||||
{
|
||||
refreshKey:
|
||||
profile?.backButtonAsset?.assetObjectId ||
|
||||
profile?.draft.backButtonAsset?.assetObjectId ||
|
||||
backButtonAssetSource ||
|
||||
undefined,
|
||||
},
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
activeRunRef.current = activeRun;
|
||||
@@ -717,8 +732,6 @@ export function JumpHopRuntimeShell({
|
||||
stageRun?.path,
|
||||
visiblePlatforms.length,
|
||||
]);
|
||||
const showLandingAssist =
|
||||
import.meta.env.MODE !== 'production' && isCharging && !isJumpAnimating;
|
||||
const characterPosition = getJumpHopCharacterVisualPosition(
|
||||
stageRun,
|
||||
visiblePlatforms,
|
||||
@@ -829,17 +842,6 @@ export function JumpHopRuntimeShell({
|
||||
landingAssistStageSize.width,
|
||||
visualJump,
|
||||
]);
|
||||
const landingAssistPosition = showLandingAssist
|
||||
? getJumpHopLandingAssistVisualPosition(
|
||||
stageRun,
|
||||
visiblePlatforms,
|
||||
visualCharacterPosition,
|
||||
landingAssistStageSize,
|
||||
dragDistance,
|
||||
dragVector.x,
|
||||
dragVector.y,
|
||||
)
|
||||
: null;
|
||||
const jumpFeedbackForDisplay = getJumpHopJumpFeedbackLabel(stageRun);
|
||||
const isSettled =
|
||||
stageRun?.status === 'failed' || stageRun?.status === 'cleared';
|
||||
@@ -1308,299 +1310,344 @@ export function JumpHopRuntimeShell({
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="platform-remap-surface jump-hop-runtime relative flex h-full min-h-0 w-full flex-col overflow-hidden bg-[#fffdf9] text-slate-950">
|
||||
<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))]" />
|
||||
<div className="platform-remap-surface jump-hop-runtime relative h-full min-h-dvh w-full overflow-hidden bg-[#fffdf9] text-slate-950">
|
||||
<section
|
||||
ref={stageRef}
|
||||
data-testid="jump-hop-stage"
|
||||
data-charging={isCharging ? 'true' : 'false'}
|
||||
data-jump-animating={isJumpAnimating ? 'true' : 'false'}
|
||||
data-platform-advancing={isPlatformAdvancing ? 'true' : 'false'}
|
||||
className="jump-hop-runtime__stage absolute inset-0 h-full w-full touch-none select-none overflow-hidden"
|
||||
onPointerDown={beginCharge}
|
||||
onPointerMove={updateDragVector}
|
||||
onPointerUp={(event) => void finishCharge(event)}
|
||||
onPointerCancel={cancelCharge}
|
||||
>
|
||||
<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'}
|
||||
className="jump-hop-runtime__camera-layer"
|
||||
style={
|
||||
{
|
||||
'--jump-hop-camera-shift-x': `${platformAdvanceCameraOffsetX}%`,
|
||||
'--jump-hop-camera-shift-y': `${-platformAdvanceCameraOffsetY}%`,
|
||||
} as CSSProperties
|
||||
}
|
||||
>
|
||||
<JumpHopThreeScene
|
||||
characterPosition={visualCharacterPosition}
|
||||
chargeRatio={chargeRatio}
|
||||
isJumpAnimating={isJumpAnimating}
|
||||
platformCount={platformRenderItems.length}
|
||||
renderCharacter={false}
|
||||
onCharacterLayerReadyChange={setIsThreeCharacterLayerReady}
|
||||
/>
|
||||
|
||||
<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">
|
||||
{platformRenderItems.map((item) => {
|
||||
const { width, height } = getJumpHopPlatformVisualSize(
|
||||
item.platform,
|
||||
1,
|
||||
);
|
||||
const style = {
|
||||
left: `${item.screenX}%`,
|
||||
top: `${item.screenY}%`,
|
||||
width,
|
||||
height,
|
||||
'--jump-hop-platform-scale': item.scale,
|
||||
zIndex:
|
||||
item.advanceState === 'exiting' ? 12 + item.index : 20 + item.index,
|
||||
} as CSSProperties;
|
||||
const isCurrent =
|
||||
item.advanceState !== 'exiting' &&
|
||||
item.index === stageRun?.currentPlatformIndex;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={item.renderKey}
|
||||
className="jump-hop-runtime__platform"
|
||||
style={style}
|
||||
data-current={isCurrent ? 'true' : 'false'}
|
||||
data-advance-state={item.advanceState}
|
||||
data-platform-id={item.platform.platformId}
|
||||
data-platform-index={item.index}
|
||||
>
|
||||
<div className="jump-hop-runtime__platform-shadow" />
|
||||
<JumpHopTileImage
|
||||
asset={item.asset}
|
||||
platform={item.platform}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
{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"
|
||||
data-charging={isCharging ? 'true' : 'false'}
|
||||
data-jump-animating={isJumpAnimating ? 'true' : 'false'}
|
||||
data-landing-recoil={
|
||||
isLandingRecoilAnimating ? 'true' : 'false'
|
||||
}
|
||||
data-miss={visualCharacterPosition.isMiss ? 'true' : 'false'}
|
||||
style={
|
||||
{
|
||||
left: `${visualCharacterPosition.screenX}%`,
|
||||
top: `${visualCharacterPosition.screenY}%`,
|
||||
'--jump-hop-charge': chargeRatio,
|
||||
'--jump-hop-character-stretch-transform':
|
||||
characterMotionStyle.stretchTransform,
|
||||
'--jump-hop-flight-from-x':
|
||||
characterMotionStyle.flightFromX,
|
||||
'--jump-hop-flight-from-y':
|
||||
characterMotionStyle.flightFromY,
|
||||
'--jump-hop-recoil-x': characterMotionStyle.recoilX,
|
||||
'--jump-hop-recoil-y': characterMotionStyle.recoilY,
|
||||
} as CSSProperties
|
||||
}
|
||||
>
|
||||
<div className="jump-hop-runtime__character-shadow" />
|
||||
<img
|
||||
src={JUMP_HOP_TAONIER_CHARACTER_IMAGE_SRC}
|
||||
alt=""
|
||||
draggable={false}
|
||||
className="jump-hop-runtime__character-image"
|
||||
data-testid="jump-hop-character-logo"
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{isCharging && dragPointerPosition && characterPosition ? (
|
||||
<div
|
||||
aria-hidden="true"
|
||||
className="jump-hop-runtime__slingshot-guide"
|
||||
style={
|
||||
(() => {
|
||||
const anchorX =
|
||||
stageSize.width * (characterPosition.screenX / 100);
|
||||
const anchorY =
|
||||
stageSize.height * (characterPosition.screenY / 100);
|
||||
const deltaX = dragPointerPosition.x - anchorX;
|
||||
const deltaY = dragPointerPosition.y - anchorY;
|
||||
const distance = Math.max(48, Math.hypot(deltaX, deltaY));
|
||||
const angle = (Math.atan2(deltaY, deltaX) * 180) / Math.PI;
|
||||
return {
|
||||
'--jump-hop-anchor-x': `${anchorX}px`,
|
||||
'--jump-hop-anchor-y': `${anchorY}px`,
|
||||
'--jump-hop-aim-x': `${dragPointerPosition.x}px`,
|
||||
'--jump-hop-aim-y': `${dragPointerPosition.y}px`,
|
||||
'--jump-hop-line-angle': `${angle}deg`,
|
||||
'--jump-hop-line-length': `${distance}px`,
|
||||
} as CSSProperties;
|
||||
})()
|
||||
}
|
||||
>
|
||||
<div className="jump-hop-runtime__slingshot-line" />
|
||||
<div className="jump-hop-runtime__slingshot-anchor" />
|
||||
<div className="jump-hop-runtime__slingshot-aim" />
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{jumpFeedbackForDisplay ? (
|
||||
<div
|
||||
key={`${stageRun?.currentPlatformIndex}-${stageRun?.lastJump?.result}`}
|
||||
className="jump-hop-runtime__feedback"
|
||||
>
|
||||
{jumpFeedbackForDisplay}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{!stageRun ? (
|
||||
<div className="absolute inset-0 grid place-items-center bg-white/35 text-sm font-black text-slate-600 backdrop-blur-sm">
|
||||
等待开局
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{isSettled ? (
|
||||
<div className="absolute inset-0 z-[120] grid place-items-center bg-slate-950/28 px-6 backdrop-blur-[2px]">
|
||||
<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">
|
||||
<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"
|
||||
onClick={onRestart}
|
||||
disabled={isBusy}
|
||||
className="platform-button platform-button--primary min-h-11 px-3 py-2 text-sm"
|
||||
>
|
||||
重开
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={exitHandler}
|
||||
className="platform-button platform-button--ghost min-h-11 bg-white px-3 py-2 text-sm"
|
||||
>
|
||||
返回
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</section>
|
||||
|
||||
<header className="pointer-events-none absolute inset-x-0 top-0 z-[130] grid grid-cols-[3.5rem_minmax(0,1fr)_3.5rem] items-start gap-2 px-3 pt-[calc(env(safe-area-inset-top,0px)+0.65rem)] sm:grid-cols-[3.875rem_minmax(0,1fr)_3.875rem] sm:px-4">
|
||||
<button
|
||||
type="button"
|
||||
onClick={exitHandler}
|
||||
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"
|
||||
aria-label="返回"
|
||||
title="返回"
|
||||
data-has-asset={backButtonAssetUrl ? 'true' : 'false'}
|
||||
className="jump-hop-runtime__back-button pointer-events-auto -mt-0.5 inline-flex h-14 w-14 items-center justify-center justify-self-start rounded-full transition hover:-translate-y-px hover:brightness-110 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-white/60 sm:-mt-1 sm:h-[3.875rem] sm:w-[3.875rem]"
|
||||
>
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
返回
|
||||
{backButtonAssetUrl ? (
|
||||
<img
|
||||
src={backButtonAssetUrl}
|
||||
alt=""
|
||||
aria-hidden="true"
|
||||
draggable={false}
|
||||
data-testid="jump-hop-runtime-back-button-asset"
|
||||
className="jump-hop-runtime__back-button-image"
|
||||
/>
|
||||
) : (
|
||||
<ArrowLeft className="h-7 w-7 drop-shadow-[0_1px_2px_rgba(255,255,255,0.74)] sm:h-[2.1rem] sm:w-[2.1rem]" />
|
||||
)}
|
||||
</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>
|
||||
<div className="puzzle-runtime-header-card pointer-events-auto mx-auto flex max-w-[min(18.5rem,calc(100vw_-_8rem))] min-w-0 flex-col items-center text-center sm:max-w-[22rem]">
|
||||
<div className="puzzle-runtime-level-title-card jump-hop-runtime__score-title-card flex max-w-full items-center justify-center gap-2 px-3.5 py-1.5 sm:px-4">
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className="puzzle-runtime-level-logo jump-hop-runtime__score-title-logo"
|
||||
>
|
||||
<img
|
||||
src={jumpHopRuntimeLevelLogo}
|
||||
alt=""
|
||||
data-testid="jump-hop-runtime-level-logo"
|
||||
className="puzzle-runtime-level-logo__image"
|
||||
draggable={false}
|
||||
/>
|
||||
</span>
|
||||
<span className="puzzle-runtime-level-badge jump-hop-runtime__score-title-text text-[0.92rem] font-black sm:text-base">
|
||||
得分
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
data-testid="jump-hop-score-card"
|
||||
className="puzzle-runtime-timer-card puzzle-runtime-timer jump-hop-runtime__score-value-card -mt-px inline-flex items-center justify-center gap-1.5 px-3.5 py-1.5 text-center font-mono text-lg font-black leading-none sm:text-xl"
|
||||
>
|
||||
<span>{successfulJumpCount}</span>
|
||||
</div>
|
||||
</div>
|
||||
<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">
|
||||
<section
|
||||
ref={stageRef}
|
||||
data-testid="jump-hop-stage"
|
||||
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]"
|
||||
onPointerDown={beginCharge}
|
||||
onPointerMove={updateDragVector}
|
||||
onPointerUp={(event) => void finishCharge(event)}
|
||||
onPointerCancel={cancelCharge}
|
||||
>
|
||||
<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}
|
||||
{error ? (
|
||||
<footer className="pointer-events-none absolute inset-x-0 bottom-[max(0.75rem,env(safe-area-inset-bottom))] z-[130] flex items-center justify-center px-3">
|
||||
<div className="pointer-events-auto min-w-0 rounded-full bg-white/82 px-3 py-2 text-sm font-bold text-[var(--platform-button-danger-text)] shadow-sm backdrop-blur">
|
||||
{error}
|
||||
</div>
|
||||
<div
|
||||
data-testid="jump-hop-camera-layer"
|
||||
data-platform-advancing={isPlatformAdvancing ? 'true' : 'false'}
|
||||
className="jump-hop-runtime__camera-layer"
|
||||
style={
|
||||
{
|
||||
'--jump-hop-camera-shift-x': `${platformAdvanceCameraOffsetX}%`,
|
||||
'--jump-hop-camera-shift-y': `${-platformAdvanceCameraOffsetY}%`,
|
||||
} as CSSProperties
|
||||
}
|
||||
>
|
||||
<JumpHopThreeScene
|
||||
characterPosition={visualCharacterPosition}
|
||||
chargeRatio={chargeRatio}
|
||||
isJumpAnimating={isJumpAnimating}
|
||||
platformCount={platformRenderItems.length}
|
||||
renderCharacter={false}
|
||||
onCharacterLayerReadyChange={setIsThreeCharacterLayerReady}
|
||||
/>
|
||||
|
||||
{platformRenderItems.map((item) => {
|
||||
const { width, height } = getJumpHopPlatformVisualSize(
|
||||
item.platform,
|
||||
1,
|
||||
);
|
||||
const style = {
|
||||
left: `${item.screenX}%`,
|
||||
top: `${item.screenY}%`,
|
||||
width,
|
||||
height,
|
||||
'--jump-hop-platform-scale': item.scale,
|
||||
zIndex:
|
||||
item.advanceState === 'exiting' ? 12 + item.index : 20 + item.index,
|
||||
} as CSSProperties;
|
||||
const isCurrent =
|
||||
item.advanceState !== 'exiting' &&
|
||||
item.index === stageRun?.currentPlatformIndex;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={item.renderKey}
|
||||
className="jump-hop-runtime__platform"
|
||||
style={style}
|
||||
data-current={isCurrent ? 'true' : 'false'}
|
||||
data-advance-state={item.advanceState}
|
||||
data-platform-id={item.platform.platformId}
|
||||
data-platform-index={item.index}
|
||||
>
|
||||
<div className="jump-hop-runtime__platform-shadow" />
|
||||
<JumpHopTileImage
|
||||
asset={item.asset}
|
||||
platform={item.platform}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
{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"
|
||||
data-charging={isCharging ? 'true' : 'false'}
|
||||
data-jump-animating={isJumpAnimating ? 'true' : 'false'}
|
||||
data-landing-recoil={
|
||||
isLandingRecoilAnimating ? 'true' : 'false'
|
||||
}
|
||||
data-miss={visualCharacterPosition.isMiss ? 'true' : 'false'}
|
||||
style={
|
||||
{
|
||||
left: `${visualCharacterPosition.screenX}%`,
|
||||
top: `${visualCharacterPosition.screenY}%`,
|
||||
'--jump-hop-charge': chargeRatio,
|
||||
'--jump-hop-character-stretch-transform':
|
||||
characterMotionStyle.stretchTransform,
|
||||
'--jump-hop-flight-from-x':
|
||||
characterMotionStyle.flightFromX,
|
||||
'--jump-hop-flight-from-y':
|
||||
characterMotionStyle.flightFromY,
|
||||
'--jump-hop-recoil-x': characterMotionStyle.recoilX,
|
||||
'--jump-hop-recoil-y': characterMotionStyle.recoilY,
|
||||
} as CSSProperties
|
||||
}
|
||||
>
|
||||
<div className="jump-hop-runtime__character-shadow" />
|
||||
<img
|
||||
src={JUMP_HOP_TAONIER_CHARACTER_IMAGE_SRC}
|
||||
alt=""
|
||||
draggable={false}
|
||||
className="jump-hop-runtime__character-image"
|
||||
data-testid="jump-hop-character-logo"
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{landingAssistPosition ? (
|
||||
(() => {
|
||||
return (
|
||||
<div
|
||||
aria-hidden="true"
|
||||
data-testid="jump-hop-landing-assist"
|
||||
data-target-index={landingAssistPosition.targetPlatformIndex}
|
||||
className="jump-hop-runtime__landing-assist"
|
||||
style={
|
||||
{
|
||||
left: `${landingAssistPosition.screenX}%`,
|
||||
top: `${landingAssistPosition.screenY}%`,
|
||||
width: 28,
|
||||
height: 28,
|
||||
} as CSSProperties
|
||||
}
|
||||
>
|
||||
<div className="jump-hop-runtime__landing-assist-core" />
|
||||
</div>
|
||||
);
|
||||
})()
|
||||
) : null}
|
||||
|
||||
{isCharging && dragPointerPosition && characterPosition ? (
|
||||
<div
|
||||
aria-hidden="true"
|
||||
className="jump-hop-runtime__slingshot-guide"
|
||||
style={
|
||||
(() => {
|
||||
const anchorX =
|
||||
stageSize.width * (characterPosition.screenX / 100);
|
||||
const anchorY =
|
||||
stageSize.height * (characterPosition.screenY / 100);
|
||||
const deltaX = dragPointerPosition.x - anchorX;
|
||||
const deltaY = dragPointerPosition.y - anchorY;
|
||||
const distance = Math.max(48, Math.hypot(deltaX, deltaY));
|
||||
const angle = (Math.atan2(deltaY, deltaX) * 180) / Math.PI;
|
||||
return {
|
||||
'--jump-hop-anchor-x': `${anchorX}px`,
|
||||
'--jump-hop-anchor-y': `${anchorY}px`,
|
||||
'--jump-hop-aim-x': `${dragPointerPosition.x}px`,
|
||||
'--jump-hop-aim-y': `${dragPointerPosition.y}px`,
|
||||
'--jump-hop-line-angle': `${angle}deg`,
|
||||
'--jump-hop-line-length': `${distance}px`,
|
||||
} as CSSProperties;
|
||||
})()
|
||||
}
|
||||
>
|
||||
<div className="jump-hop-runtime__slingshot-line" />
|
||||
<div className="jump-hop-runtime__slingshot-anchor" />
|
||||
<div className="jump-hop-runtime__slingshot-aim" />
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{jumpFeedbackForDisplay ? (
|
||||
<div
|
||||
key={`${stageRun?.currentPlatformIndex}-${stageRun?.lastJump?.result}`}
|
||||
className="jump-hop-runtime__feedback"
|
||||
>
|
||||
{jumpFeedbackForDisplay}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{!stageRun ? (
|
||||
<div className="absolute inset-0 grid place-items-center bg-white/35 text-sm font-black text-slate-600 backdrop-blur-sm">
|
||||
等待开局
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{isSettled ? (
|
||||
<div className="absolute inset-0 z-[120] grid place-items-center bg-slate-950/28 px-6 backdrop-blur-[2px]">
|
||||
<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">
|
||||
<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"
|
||||
onClick={onRestart}
|
||||
disabled={isBusy}
|
||||
className="platform-button platform-button--primary min-h-11 px-3 py-2 text-sm"
|
||||
>
|
||||
重开
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={exitHandler}
|
||||
className="platform-button platform-button--ghost min-h-11 bg-white px-3 py-2 text-sm"
|
||||
>
|
||||
返回
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</section>
|
||||
|
||||
{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>
|
||||
</div>
|
||||
</footer>
|
||||
) : null}
|
||||
</main>
|
||||
</footer>
|
||||
) : null}
|
||||
|
||||
<style>{`
|
||||
.jump-hop-runtime__sky {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
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__stage {
|
||||
min-height: 31rem;
|
||||
isolation: isolate;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.jump-hop-runtime__back-button {
|
||||
border: 2px solid rgba(255, 216, 173, 0.72);
|
||||
background:
|
||||
radial-gradient(circle at 38% 26%, rgba(255, 242, 216, 0.9), transparent 28%),
|
||||
linear-gradient(135deg, #d7803f 0%, #b95527 58%, #8e3e22 100%);
|
||||
color: #fffaf2;
|
||||
box-shadow:
|
||||
inset 0 1px 0 rgba(255, 255, 255, 0.42),
|
||||
0 8px 18px rgba(86, 43, 18, 0.18);
|
||||
}
|
||||
|
||||
.jump-hop-runtime__back-button[data-has-asset='true'] {
|
||||
border-color: transparent;
|
||||
background: transparent;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.jump-hop-runtime__back-button-image {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: contain;
|
||||
pointer-events: none;
|
||||
user-select: none;
|
||||
filter: drop-shadow(0 8px 14px rgba(63, 36, 18, 0.22));
|
||||
}
|
||||
|
||||
.jump-hop-runtime__score-title-card {
|
||||
padding-left: 3.35rem;
|
||||
padding-right: 3.35rem;
|
||||
}
|
||||
|
||||
.jump-hop-runtime__score-title-logo {
|
||||
position: absolute;
|
||||
left: 0.45rem;
|
||||
top: 50%;
|
||||
margin: 0;
|
||||
transform: translateY(-50%);
|
||||
}
|
||||
|
||||
.jump-hop-runtime__score-title-text {
|
||||
display: block;
|
||||
min-width: 2.5rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.jump-hop-runtime__score-value-card {
|
||||
justify-content: center;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.jump-hop-runtime__stage[data-charging='true'] {
|
||||
cursor: grabbing;
|
||||
}
|
||||
@@ -1743,30 +1790,6 @@ export function JumpHopRuntimeShell({
|
||||
height: 1px;
|
||||
}
|
||||
|
||||
.jump-hop-runtime__landing-assist {
|
||||
position: absolute;
|
||||
z-index: 110;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
transform: translate(-50%, -50%);
|
||||
border: 2px dashed rgba(17, 94, 89, 0.82);
|
||||
border-radius: 999px;
|
||||
background:
|
||||
radial-gradient(circle, rgba(16, 185, 129, 0.28) 0 20%, rgba(167, 243, 208, 0.16) 21% 48%, transparent 49%);
|
||||
box-shadow:
|
||||
0 0 0 4px rgba(255, 255, 255, 0.56),
|
||||
0 0 18px rgba(17, 94, 89, 0.18);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.jump-hop-runtime__landing-assist-core {
|
||||
width: 0.68rem;
|
||||
height: 0.68rem;
|
||||
border-radius: 999px;
|
||||
background: #0f766e;
|
||||
box-shadow: 0 0 0 4px rgba(255, 255, 255, 0.78);
|
||||
}
|
||||
|
||||
.jump-hop-runtime__fallback-tile {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
@@ -2070,8 +2093,7 @@ export function JumpHopRuntimeShell({
|
||||
|
||||
@media (max-width: 430px) {
|
||||
.jump-hop-runtime__stage {
|
||||
min-height: min(68vh, 36rem);
|
||||
border-radius: 1.25rem;
|
||||
min-height: 100%;
|
||||
}
|
||||
|
||||
.jump-hop-runtime__character {
|
||||
|
||||
@@ -1634,6 +1634,7 @@ function buildMockJumpHopWork(
|
||||
tileAssets,
|
||||
path,
|
||||
coverComposite: 'data:image/png;base64,cover',
|
||||
backButtonAsset: null,
|
||||
generationStatus: 'ready' as const,
|
||||
};
|
||||
|
||||
@@ -1664,6 +1665,7 @@ function buildMockJumpHopWork(
|
||||
characterAsset,
|
||||
tileAtlasAsset,
|
||||
tileAssets,
|
||||
backButtonAsset: null,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user