feat: polish jump hop themed runtime assets

This commit is contained in:
2026-06-05 22:55:40 +08:00
parent a215852381
commit cd8088d1fd
22 changed files with 719 additions and 354 deletions

View File

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

View File

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

View File

@@ -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 {

View File

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