Files
Genarrative/src/components/jump-hop-runtime/JumpHopRuntimeShell.tsx
T
kdletters 43c66d31a2 合并最新 master 到组件库收口分支
合入 origin/master 的 VectorEngine、后端主干和跳一跳更新
保留 PlatformUiKit 组件库收口提交并处理跳一跳运行态冲突
合并 Hermes 决策记录中的前端组件库、微信能力和后端流程决策
2026-06-10 10:34:52 +08:00

2945 lines
91 KiB
TypeScript

import { ArrowLeft, Loader2 } from 'lucide-react';
import {
type CSSProperties,
type Dispatch,
type PointerEvent,
type SetStateAction,
useCallback,
useEffect,
useMemo,
useRef,
useState,
} from 'react';
import jumpHopRuntimeLevelLogo from '../../../media/logo.png';
import type {
JumpHopRuntimeRunSnapshotResponse,
JumpHopTileFaceAsset,
JumpHopTileAsset,
JumpHopWorkProfileResponse,
} from '../../../packages/shared/src/contracts/jumpHop';
import { useResolvedAssetReadUrl } from '../../hooks/useResolvedAssetReadUrl';
import {
isGeneratedLegacyPath,
readAssetBytes,
} from '../../services/assetReadUrlService';
import type { JumpHopRuntimeRequestOptions } from '../../services/jump-hop/jumpHopClient';
import {
buildJumpHopVisiblePlatforms,
formatJumpHopDurationLabel,
getJumpHopCharacterVisualPosition,
getJumpHopJumpFeedbackLabel,
getJumpHopBackendDragVector,
getJumpHopLandingAssistVisualPosition,
getJumpHopPlatformVisualSize,
getJumpHopRunDurationMs,
getJumpHopStatusLabel,
getJumpHopTileTone,
type JumpHopCharacterVisualPosition,
type JumpHopVisiblePlatform,
resolveJumpHopCharacterCanvasPosition,
selectJumpHopTileAsset,
} from '../../services/jump-hop/jumpHopRuntimeModel';
import { useJumpHopLeaderboard } from '../../services/jump-hop/useJumpHopLeaderboard';
import { PlatformActionButton } from '../common/PlatformActionButton';
import { RuntimeResourcePendingMarker } from '../common/RuntimeResourcePendingMarker';
type JumpHopRuntimeJumpPayload = {
dragDistance: number;
dragVectorX: number;
dragVectorY: number;
};
type JumpHopVisualJump = {
from: JumpHopCharacterVisualPosition;
to: JumpHopCharacterVisualPosition;
};
type JumpHopPlatformRenderItem = JumpHopVisiblePlatform & {
renderKey: string;
advanceState: 'exiting' | 'camera' | 'idle';
};
type JumpHopTilePreloadItem = {
textureKey: string;
asset: JumpHopTileAsset;
};
type JumpHopTileFaceKey = 'top' | 'front' | 'right' | 'back' | 'left' | 'bottom';
type JumpHopTileTextureSource = {
imageSrc: string;
imageObjectKey?: string;
assetObjectId?: string;
tileId?: string;
};
type JumpHopRuntimeShellProps = {
profile?: JumpHopWorkProfileResponse | null;
run?: JumpHopRuntimeRunSnapshotResponse | null;
snapshot?: JumpHopRuntimeRunSnapshotResponse | null;
isBusy?: boolean;
error?: string | null;
runtimeRequestOptions?: JumpHopRuntimeRequestOptions;
onJump: (payload: JumpHopRuntimeJumpPayload) => Promise<unknown>;
onRestart: () => void;
onExit?: () => void;
onBack?: () => void;
};
const MAX_CHARGE_RATIO = 1;
const DEFAULT_MAX_DRAG_DISTANCE_PX = 180;
const JUMP_HOP_ANIMATION_DURATION_MS = 560;
const JUMP_HOP_LANDING_RECOIL_DURATION_MS = 560;
const JUMP_HOP_PLATFORM_ADVANCE_DURATION_MS = 1440;
const JUMP_HOP_PLATFORM_RETAIN_OFFSCREEN_SCREEN_Y = 122;
const JUMP_HOP_CAMERA_ZOOM = 1.3;
const JUMP_HOP_TAONIER_CHARACTER_IMAGE_SRC =
'/branding/jump-hop-taonier-character.png';
const JUMP_HOP_TILE_PRELOAD_LOOKAHEAD_COUNT = 3;
const JUMP_HOP_TILE_FACE_KEYS: JumpHopTileFaceKey[] = [
'top',
'front',
'right',
'back',
'left',
'bottom',
];
const JUMP_HOP_THREE_CAMERA_PITCH_RAD = Math.PI / 4;
const JUMP_HOP_THREE_CAMERA_PITCH_COS = Math.cos(
JUMP_HOP_THREE_CAMERA_PITCH_RAD,
);
export const JUMP_HOP_THREE_CAMERA_UP_Y = 1;
const JUMP_HOP_THREE_CAMERA_DISTANCE_MULTIPLIER = 1.34;
function clamp(value: number, min: number, max: number) {
return Math.min(max, Math.max(min, value));
}
function formatJumpHopCssNumber(value: number) {
if (!Number.isFinite(value)) {
return '0';
}
return value.toFixed(4).replace(/\.?0+$/, '');
}
function getRun(
run: JumpHopRuntimeRunSnapshotResponse | null | undefined,
snapshot: JumpHopRuntimeRunSnapshotResponse | null | undefined,
) {
return run ?? snapshot ?? null;
}
function hasJumpHopRunDisplayChange(
current: JumpHopRuntimeRunSnapshotResponse,
next: JumpHopRuntimeRunSnapshotResponse,
) {
return (
current.currentPlatformIndex !== next.currentPlatformIndex ||
current.status !== next.status ||
current.successfulJumpCount !== next.successfulJumpCount ||
current.durationMs !== next.durationMs ||
current.score !== next.score ||
current.combo !== next.combo ||
current.finishedAtMs !== next.finishedAtMs ||
current.lastJump?.targetPlatformIndex !== next.lastJump?.targetPlatformIndex ||
current.lastJump?.result !== next.lastJump?.result ||
current.lastJump?.chargeMs !== next.lastJump?.chargeMs
);
}
function shouldAnimateJumpHopPlatformAdvance(
current: JumpHopRuntimeRunSnapshotResponse,
next: JumpHopRuntimeRunSnapshotResponse,
) {
return (
current.runId === next.runId &&
next.currentPlatformIndex > current.currentPlatformIndex &&
next.status === 'playing'
);
}
function buildJumpHopCharacterVisualPositionFromPlatform(
platform: JumpHopVisiblePlatform,
isMiss = false,
): JumpHopCharacterVisualPosition {
if (isMiss) {
return {
screenX: platform.screenX + 8,
screenY: platform.screenY - 2,
sceneX: platform.sceneX + 0.7,
sceneY: platform.sceneY + 0.48,
sceneZ: platform.sceneZ - 0.4,
isMiss: true,
};
}
return {
screenX: platform.screenX,
screenY: platform.screenY - 3,
sceneX: platform.sceneX,
sceneY: platform.sceneY + 0.84,
sceneZ: platform.sceneZ,
isMiss: false,
};
}
function getJumpHopRunLandingVisualPosition({
run,
platforms,
stageSize,
}: {
run: JumpHopRuntimeRunSnapshotResponse;
platforms: JumpHopVisiblePlatform[];
stageSize: { width: number; height: number };
}) {
const lastJump = run.lastJump;
if (!lastJump || stageSize.width <= 0 || stageSize.height <= 0) {
return null;
}
return getJumpHopCharacterVisualPosition(run, platforms, stageSize);
}
function getJumpHopThreeCubeSide(
platform: JumpHopVisiblePlatform['platform'],
scale: number,
) {
const platformSize = getJumpHopPlatformVisualSize(platform, scale);
return Math.max(56, Math.min(platformSize.width, platformSize.height) * 0.86);
}
export function getJumpHopThreeProjectedY(
screenY: number,
viewportHeight: number,
) {
return (
viewportHeight / 2 +
(viewportHeight / 2 - screenY) / JUMP_HOP_THREE_CAMERA_PITCH_COS
);
}
function IsometricFallbackTile({
platform,
}: {
platform: JumpHopVisiblePlatform['platform'];
}) {
const tone = getJumpHopTileTone(platform.tileType);
const style = {
'--jump-hop-tile-tone': tone,
} as CSSProperties;
return (
<div
className="jump-hop-runtime__fallback-tile"
style={style}
aria-hidden="true"
>
<div className="jump-hop-runtime__fallback-top" />
<div className="jump-hop-runtime__fallback-side jump-hop-runtime__fallback-side--left" />
<div className="jump-hop-runtime__fallback-side jump-hop-runtime__fallback-side--right" />
</div>
);
}
function getJumpHopTileAssetRefreshKey(
asset: JumpHopTileTextureSource | null | undefined,
) {
return asset?.assetObjectId || asset?.imageObjectKey || asset?.tileId || null;
}
function getJumpHopTileFaceAsset(
asset: JumpHopTileAsset | null | undefined,
face: JumpHopTileFaceKey,
): JumpHopTileFaceAsset | null {
return asset?.faceAssets?.[face] ?? null;
}
function getJumpHopTileTextureSource(
asset: JumpHopTileAsset | null | undefined,
face?: JumpHopTileFaceKey,
): JumpHopTileTextureSource | null {
const faceAsset = face ? getJumpHopTileFaceAsset(asset, face) : null;
if (faceAsset?.imageSrc) {
return {
imageSrc: faceAsset.imageSrc,
imageObjectKey: faceAsset.imageObjectKey,
assetObjectId: faceAsset.assetObjectId,
tileId: `${asset?.tileId ?? asset?.sourceAtlasCell ?? 'tile'}-${face}`,
};
}
if (!asset?.imageSrc) {
return null;
}
return {
imageSrc: asset.imageSrc,
imageObjectKey: asset.imageObjectKey,
assetObjectId: asset.assetObjectId,
tileId: asset.tileId ?? asset.sourceAtlasCell,
};
}
function getJumpHopTileTextureKey(renderKey: string, face: JumpHopTileFaceKey) {
return `${renderKey}::${face}`;
}
function getJumpHopTileTextureUrl(
textureUrls: Record<string, string>,
renderKey: string,
face: JumpHopTileFaceKey,
) {
return textureUrls[getJumpHopTileTextureKey(renderKey, face)] ?? textureUrls[renderKey] ?? '';
}
export function getJumpHopTileTextureSignature(
textureUrls: Record<string, string>,
renderKey: string,
asset: JumpHopTileAsset | null | undefined,
) {
if (!asset?.faceAssets) {
return textureUrls[renderKey] ?? '';
}
return JUMP_HOP_TILE_FACE_KEYS.map((face) =>
getJumpHopTileTextureUrl(textureUrls, renderKey, face),
).join('|');
}
function hasJumpHopTileTexturesReady(
textureUrls: Record<string, string>,
renderKey: string,
asset: JumpHopTileAsset | null | undefined,
) {
if (!asset?.imageSrc) {
return true;
}
if (!asset.faceAssets) {
return Boolean(textureUrls[renderKey]);
}
return JUMP_HOP_TILE_FACE_KEYS.every((face) =>
Boolean(textureUrls[getJumpHopTileTextureKey(renderKey, face)]),
);
}
function getJumpHopActiveTextureKeys(
renderKey: string,
asset: JumpHopTileAsset | null | undefined,
) {
if (!asset?.faceAssets) {
return [renderKey];
}
return [
renderKey,
...JUMP_HOP_TILE_FACE_KEYS.map((face) =>
getJumpHopTileTextureKey(renderKey, face),
),
];
}
function buildJumpHopTileTextureEntries(
asset: JumpHopTileAsset,
textureKey: string,
) {
if (!asset.faceAssets) {
const source = getJumpHopTileTextureSource(asset);
return source ? [{ textureKey, source }] : [];
}
return JUMP_HOP_TILE_FACE_KEYS.map((face) => ({
textureKey: getJumpHopTileTextureKey(textureKey, face),
source: getJumpHopTileTextureSource(asset, face),
})).filter(
(item): item is { textureKey: string; source: JumpHopTileTextureSource } =>
Boolean(item.source?.imageSrc),
);
}
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,
textureKey,
onResolvedTextureUrl,
}: {
asset: JumpHopTileAsset | null;
platform: JumpHopVisiblePlatform['platform'];
textureKey?: string;
onResolvedTextureUrl?: (
textureKey: string,
resolvedUrl: string,
options?: { parentOwnedObjectUrl?: boolean },
) => void;
}) {
const textureSource = getJumpHopTileTextureSource(asset, 'top');
const assetRefreshKey = getJumpHopTileAssetRefreshKey(textureSource);
const { resolvedUrl, isResolving } = useResolvedAssetReadUrl(
textureSource?.imageSrc,
{
refreshKey: assetRefreshKey,
},
);
const [isLoaded, setIsLoaded] = useState(false);
const [hasError, setHasError] = useState(false);
useEffect(() => {
setIsLoaded(false);
setHasError(false);
}, [resolvedUrl]);
const shouldShowImage = Boolean(resolvedUrl && !hasError);
const shouldShowFallback = !shouldShowImage;
useEffect(() => {
if (!textureKey || !onResolvedTextureUrl) {
return;
}
let disposed = false;
const assetSource = textureSource?.imageSrc?.trim() ?? '';
const publishTextureUrl = (
url: string,
options?: { parentOwnedObjectUrl?: boolean },
) => {
if (!disposed) {
onResolvedTextureUrl(textureKey, url, options);
}
};
publishTextureUrl('');
if (!assetSource || !shouldShowImage || !isLoaded) {
return () => {
disposed = true;
};
}
if (!isGeneratedLegacyPath(assetSource)) {
publishTextureUrl(resolvedUrl ?? '');
return () => {
disposed = true;
};
}
// 中文注释:Three.js 纹理不能直接依赖跨域 OSS 签名 URL;转同源字节为 blob,避免 bucket CORS 导致 WebGL 贴图失败。
void readAssetBytes(assetSource)
.then((response) => response.blob())
.then((blob) => {
if (disposed) {
return;
}
publishTextureUrl(URL.createObjectURL(blob), {
parentOwnedObjectUrl: true,
});
})
.catch(() => {
publishTextureUrl('');
});
return () => {
disposed = true;
onResolvedTextureUrl(textureKey, '');
};
}, [
isLoaded,
onResolvedTextureUrl,
resolvedUrl,
shouldShowImage,
textureSource?.imageSrc,
textureKey,
]);
return (
<div className="jump-hop-runtime__tile-image-stack">
<RuntimeResourcePendingMarker
source={textureSource?.imageSrc}
kind="image"
isPending={isResolving}
/>
{shouldShowFallback ? <IsometricFallbackTile platform={platform} /> : null}
{asset?.faceAssets && textureKey
? buildJumpHopTileTextureEntries(asset, textureKey).map((item) => (
<JumpHopTileTextureImage
key={item.textureKey}
source={item.source}
textureKey={item.textureKey}
onResolvedTextureUrl={onResolvedTextureUrl}
/>
))
: null}
{shouldShowImage ? (
<img
src={resolvedUrl}
alt=""
draggable={false}
data-testid="jump-hop-tile-image"
data-tile-id={asset?.tileId ?? asset?.sourceAtlasCell}
className="jump-hop-runtime__tile-image"
data-loaded={isLoaded || shouldShowImage ? 'true' : 'false'}
onLoad={() => {
setIsLoaded(true);
}}
onError={() => {
setHasError(true);
}}
/>
) : null}
</div>
);
}
function JumpHopTileTextureImage({
source,
textureKey,
onResolvedTextureUrl,
}: {
source: JumpHopTileTextureSource;
textureKey: string;
onResolvedTextureUrl?: (
textureKey: string,
resolvedUrl: string,
options?: { parentOwnedObjectUrl?: boolean },
) => void;
}) {
const assetRefreshKey = getJumpHopTileAssetRefreshKey(source);
const { resolvedUrl, isResolving } = useResolvedAssetReadUrl(source.imageSrc, {
refreshKey: assetRefreshKey,
});
useEffect(() => {
if (!textureKey || !onResolvedTextureUrl) {
return undefined;
}
let disposed = false;
const assetSource = source.imageSrc?.trim() ?? '';
const publishTextureUrl = (
url: string,
options?: { parentOwnedObjectUrl?: boolean },
) => {
if (!disposed) {
onResolvedTextureUrl(textureKey, url, options);
}
};
if (!assetSource || !resolvedUrl) {
return () => {
disposed = true;
};
}
if (!isGeneratedLegacyPath(assetSource)) {
publishTextureUrl(resolvedUrl);
return () => {
disposed = true;
};
}
void readAssetBytes(assetSource)
.then((response) => response.blob())
.then((blob) => {
if (disposed) {
return;
}
publishTextureUrl(URL.createObjectURL(blob), {
parentOwnedObjectUrl: true,
});
})
.catch(() => {});
return () => {
disposed = true;
};
}, [
onResolvedTextureUrl,
resolvedUrl,
source.imageSrc,
textureKey,
]);
if (!resolvedUrl) {
return (
<RuntimeResourcePendingMarker
source={source.imageSrc}
kind="image"
isPending={isResolving}
/>
);
}
return (
<>
<RuntimeResourcePendingMarker
source={source.imageSrc}
kind="image"
isPending={isResolving}
/>
<img
src={resolvedUrl}
alt=""
aria-hidden="true"
draggable={false}
data-testid="jump-hop-tile-preload-image"
className="jump-hop-runtime__tile-preload-image"
/>
</>
);
}
function JumpHopTilePreloadImage({
asset,
textureKey,
onResolvedTextureUrl,
}: {
asset: JumpHopTileAsset;
textureKey: string;
onResolvedTextureUrl?: (
textureKey: string,
resolvedUrl: string,
options?: { parentOwnedObjectUrl?: boolean },
) => void;
}) {
const sources = buildJumpHopTileTextureEntries(asset, textureKey);
return (
<>
{sources.map((item) => (
<JumpHopTileTextureImage
key={item.textureKey}
source={item.source}
textureKey={item.textureKey}
onResolvedTextureUrl={onResolvedTextureUrl}
/>
))}
</>
);
}
function hasJumpHopWebGLSupport() {
if (import.meta.env.MODE === 'test') {
return false;
}
try {
const canvas = document.createElement('canvas');
return Boolean(canvas.getContext('webgl2') ?? canvas.getContext('webgl'));
} catch {
return false;
}
}
function applyJumpHopCanvasLayout(canvas: HTMLCanvasElement) {
canvas.style.display = 'block';
canvas.style.height = '100%';
canvas.style.inset = '0';
canvas.style.position = 'absolute';
canvas.style.width = '100%';
}
function disposeJumpHopThreeObject(object: import('three').Object3D) {
object.traverse((child) => {
const mesh = child as import('three').Mesh;
mesh.geometry?.dispose();
const material = mesh.material;
if (Array.isArray(material)) {
material.forEach((item) => item.dispose());
} else {
material?.dispose();
}
});
}
function JumpHopThreeScene({
characterPosition,
chargeRatio,
isJumpAnimating,
platforms,
platformCount,
renderCharacter,
textureUrlsByRenderKey,
onCharacterLayerReadyChange,
onPlatformLayerReadyChange,
}: {
characterPosition: JumpHopCharacterVisualPosition | null;
chargeRatio: number;
isJumpAnimating: boolean;
platforms: JumpHopPlatformRenderItem[];
platformCount: number;
renderCharacter: boolean;
textureUrlsByRenderKey: Record<string, string>;
onCharacterLayerReadyChange: Dispatch<SetStateAction<boolean>>;
onPlatformLayerReadyChange: Dispatch<SetStateAction<boolean>>;
}) {
const hostRef = useRef<HTMLDivElement | null>(null);
const characterPositionRef = useRef(characterPosition);
const chargeRatioRef = useRef(chargeRatio);
const isJumpAnimatingRef = useRef(isJumpAnimating);
const platformsRef = useRef(platforms);
const textureUrlsByRenderKeyRef = useRef(textureUrlsByRenderKey);
useEffect(() => {
characterPositionRef.current = characterPosition;
}, [characterPosition]);
useEffect(() => {
chargeRatioRef.current = chargeRatio;
}, [chargeRatio]);
useEffect(() => {
isJumpAnimatingRef.current = isJumpAnimating;
}, [isJumpAnimating]);
useEffect(() => {
platformsRef.current = platforms;
}, [platforms]);
useEffect(() => {
textureUrlsByRenderKeyRef.current = textureUrlsByRenderKey;
}, [textureUrlsByRenderKey]);
useEffect(() => {
const host = hostRef.current;
if (!host) {
return undefined;
}
onCharacterLayerReadyChange(false);
onPlatformLayerReadyChange(false);
host.replaceChildren();
const fallbackCanvas = document.createElement('canvas');
applyJumpHopCanvasLayout(fallbackCanvas);
fallbackCanvas.setAttribute('data-testid', 'jump-hop-three-canvas');
host.appendChild(fallbackCanvas);
if (!hasJumpHopWebGLSupport()) {
return () => {
onCharacterLayerReadyChange(false);
onPlatformLayerReadyChange(false);
fallbackCanvas.remove();
};
}
let disposed = false;
let animationId: number | null = null;
let cleanup: (() => void) | null = null;
const setup = async () => {
const [three, roundedBoxModule] = await Promise.all([
import('three'),
import('three/examples/jsm/geometries/RoundedBoxGeometry.js'),
]);
if (disposed || !hostRef.current) {
return;
}
const renderer = new three.WebGLRenderer({
alpha: true,
antialias: true,
canvas: fallbackCanvas,
});
renderer.setPixelRatio(Math.min(window.devicePixelRatio || 1, 1.8));
renderer.outputColorSpace = three.SRGBColorSpace;
renderer.sortObjects = true;
const scene = new three.Scene();
scene.background = null;
const camera = new three.OrthographicCamera(
-160,
160,
284,
-284,
1,
2400,
);
// 中文注释:保持 Three 平台层和 DOM 角色层的屏幕 X 轴同向,避免 WebGL 地块左右镜像后让跳跃看起来反向。
camera.up.set(0, JUMP_HOP_THREE_CAMERA_UP_Y, 0);
scene.add(new three.AmbientLight(0xffffff, 1.22));
const keyLight = new three.DirectionalLight(0xffffff, 2.45);
keyLight.position.set(-90, 105, 110);
scene.add(keyLight);
const fillLight = new three.DirectionalLight(0xfef3c7, 0.82);
fillLight.position.set(110, 96, 70);
scene.add(fillLight);
const rimLight = new three.DirectionalLight(0xffedd5, 0.64);
rimLight.position.set(120, 44, 120);
scene.add(rimLight);
const character = renderCharacter ? new three.Group() : null;
if (character) {
const body = new three.Mesh(
new three.CapsuleGeometry(10, 22, 8, 18),
new three.MeshStandardMaterial({
color: 0xdf7f40,
roughness: 0.74,
}),
);
body.position.y = -28;
const head = new three.Mesh(
new three.SphereGeometry(11, 28, 20),
new three.MeshStandardMaterial({
color: 0xf59e0b,
roughness: 0.7,
}),
);
head.position.y = -62;
const accent = new three.Mesh(
new three.BoxGeometry(15, 7, 7),
new three.MeshStandardMaterial({
color: 0x2563eb,
roughness: 0.64,
}),
);
accent.position.set(0, -36, 10);
character.add(body, head, accent);
scene.add(character);
}
const platformGroup = new three.Group();
platformGroup.renderOrder = 20;
scene.add(platformGroup);
// 中文注释:平台几何只创建一份,运行态只做等比缩放,保持标准 1x1x1 立方体规格。
const platformGeometry = new roundedBoxModule.RoundedBoxGeometry(
1,
1,
1,
2,
0.035,
);
const shadowGeometry = new three.CircleGeometry(1, 48);
const shadowMaterial = new three.MeshBasicMaterial({
color: 0x0f172a,
depthWrite: false,
opacity: 0.16,
transparent: true,
});
const textureLoader = new three.TextureLoader();
textureLoader.setCrossOrigin('anonymous');
const textureCache = new Map<string, import('three').Texture>();
const materialCache = new Map<
string,
import('three').Material | import('three').Material[]
>();
const fallbackMaterialCache = new Map<string, import('three').Material>();
let platformSignature = '';
const getTexture = (url: string) => {
const cached = textureCache.get(url);
if (cached) {
return cached;
}
const texture = textureLoader.load(url, () => {
renderer.render(scene, camera);
});
texture.colorSpace = three.SRGBColorSpace;
texture.wrapS = three.ClampToEdgeWrapping;
texture.wrapT = three.ClampToEdgeWrapping;
texture.anisotropy = Math.min(renderer.capabilities.getMaxAnisotropy(), 6);
textureCache.set(url, texture);
return texture;
};
const getPlatformMaterial = (
item: JumpHopPlatformRenderItem,
textureUrls: Record<string, string>,
) => {
const textureUrl = getJumpHopTileTextureUrl(
textureUrls,
item.renderKey,
'top',
);
if (item.asset?.faceAssets && textureUrl) {
const cacheKey = JUMP_HOP_TILE_FACE_KEYS.map((face) =>
getJumpHopTileTextureUrl(textureUrls, item.renderKey, face),
).join('|');
const cached = materialCache.get(cacheKey);
if (cached) {
return cached;
}
// 中文注释:Three.js Box/RoundedBox 材质顺序为 right, left, top, bottom, front, back。
const materials = [
'right',
'left',
'top',
'bottom',
'front',
'back',
].map((face) => {
const faceUrl =
getJumpHopTileTextureUrl(
textureUrls,
item.renderKey,
face as JumpHopTileFaceKey,
) || textureUrl;
return new three.MeshStandardMaterial({
alphaTest: 0.04,
map: getTexture(faceUrl),
metalness: 0,
roughness: 0.76,
transparent: true,
});
});
materialCache.set(cacheKey, materials);
return materials;
}
if (textureUrl) {
const cached = materialCache.get(textureUrl);
if (cached) {
return cached;
}
const material = new three.MeshStandardMaterial({
alphaTest: 0.04,
map: getTexture(textureUrl),
metalness: 0,
roughness: 0.76,
transparent: true,
});
materialCache.set(textureUrl, material);
return material;
}
const tone = getJumpHopTileTone(item.platform.tileType);
const cached = fallbackMaterialCache.get(tone);
if (cached) {
return cached;
}
const material = new three.MeshStandardMaterial({
color: new three.Color(tone),
metalness: 0,
roughness: 0.82,
});
fallbackMaterialCache.set(tone, material);
return material;
};
const viewportSize = {
width: 320,
height: 568,
};
const syncCamera = () => {
const distance =
Math.max(viewportSize.width, viewportSize.height) *
JUMP_HOP_THREE_CAMERA_DISTANCE_MULTIPLIER;
const targetX = viewportSize.width / 2;
const targetY = viewportSize.height / 2;
camera.left = -viewportSize.width / 2;
camera.right = viewportSize.width / 2;
camera.top = viewportSize.height / 2;
camera.bottom = -viewportSize.height / 2;
camera.position.set(
targetX,
targetY -
Math.cos(JUMP_HOP_THREE_CAMERA_PITCH_RAD) * distance,
Math.sin(JUMP_HOP_THREE_CAMERA_PITCH_RAD) * distance,
);
camera.lookAt(targetX, targetY, 0);
camera.updateProjectionMatrix();
camera.updateMatrixWorld();
};
const syncPlatformMeshes = () => {
const nextPlatforms = platformsRef.current;
const textureUrls = textureUrlsByRenderKeyRef.current;
const nextSignature = nextPlatforms
.map((item) => {
const cubeSide = getJumpHopThreeCubeSide(
item.platform,
item.scale,
);
return [
item.renderKey,
item.platform.platformId,
item.screenX.toFixed(3),
item.screenY.toFixed(3),
item.scale.toFixed(3),
cubeSide.toFixed(2),
getJumpHopTileTextureSignature(
textureUrls,
item.renderKey,
item.asset,
),
item.advanceState,
].join(':');
})
.join('|');
if (nextSignature === platformSignature) {
return;
}
platformSignature = nextSignature;
platformGroup.clear();
nextPlatforms.forEach((item) => {
const cubeSide = getJumpHopThreeCubeSide(item.platform, item.scale);
const root = new three.Group();
const rootBaseX = (item.screenX / 100) * viewportSize.width;
const rootBaseY = getJumpHopThreeProjectedY(
(item.screenY / 100) * viewportSize.height,
viewportSize.height,
);
root.position.set(rootBaseX, rootBaseY, 0);
root.renderOrder = 20 + item.index;
root.userData = {
advanceState: item.advanceState,
baseX: rootBaseX,
baseY: rootBaseY,
};
const shadow = new three.Mesh(shadowGeometry, shadowMaterial);
shadow.position.set(0, cubeSide * 0.32, -9);
shadow.scale.set(
Math.max(24, cubeSide * 0.48),
Math.max(7, cubeSide * 0.13),
1,
);
shadow.renderOrder = 10 + item.index;
const mesh = new three.Mesh(
platformGeometry,
getPlatformMaterial(item, textureUrls),
);
mesh.position.set(0, 0, 0);
mesh.rotation.set(0, 0, 0);
mesh.scale.setScalar(cubeSide);
mesh.renderOrder = 30 + item.index;
root.add(shadow, mesh);
platformGroup.add(root);
});
};
const resize = () => {
const rect = host.getBoundingClientRect();
const width = Math.max(1, rect.width || host.clientWidth || 320);
const height = Math.max(1, rect.height || host.clientHeight || 568);
viewportSize.width = width;
viewportSize.height = height;
renderer.setSize(width, height, false);
syncCamera();
platformSignature = '';
syncPlatformMeshes();
renderer.render(scene, camera);
};
const resizeObserver = window.ResizeObserver
? new window.ResizeObserver(resize)
: null;
resizeObserver?.observe(host);
resize();
onCharacterLayerReadyChange(Boolean(character));
onPlatformLayerReadyChange(true);
const animate = () => {
syncPlatformMeshes();
const nextCharacterPosition = characterPositionRef.current;
if (character && nextCharacterPosition) {
const nextChargeRatio = chargeRatioRef.current;
const canvasPosition = resolveJumpHopCharacterCanvasPosition(
nextCharacterPosition,
viewportSize,
);
character.visible = true;
character.position.set(canvasPosition?.x ?? 0, canvasPosition?.y ?? 0, 0);
if (isJumpAnimatingRef.current) {
const now = window.performance.now();
character.rotation.z = Math.sin(now / 42) * 1.22;
character.rotation.x = Math.sin(now / 28) * 0.28;
character.rotation.y = Math.sin(now / 34) * 0.2;
character.position.y += Math.sin(now / 26) * 8 - 14;
} else {
character.rotation.z = nextCharacterPosition.isMiss ? -0.32 : 0;
character.rotation.x = 0;
character.rotation.y = 0;
}
character.scale.set(
1 + nextChargeRatio * 0.08,
1 - nextChargeRatio * 0.12,
1 + nextChargeRatio * 0.08,
);
} else if (character) {
character.visible = false;
}
renderer.render(scene, camera);
animationId = window.requestAnimationFrame(animate);
};
animate();
cleanup = () => {
if (animationId != null) {
window.cancelAnimationFrame(animationId);
}
resizeObserver?.disconnect();
disposeJumpHopThreeObject(scene);
textureCache.forEach((texture) => texture.dispose());
materialCache.forEach((material) => {
if (Array.isArray(material)) {
material.forEach((item) => item.dispose());
} else {
material.dispose();
}
});
fallbackMaterialCache.forEach((material) => material.dispose());
shadowMaterial.dispose();
platformGeometry.dispose();
shadowGeometry.dispose();
renderer.dispose();
onCharacterLayerReadyChange(false);
onPlatformLayerReadyChange(false);
};
};
void setup();
return () => {
disposed = true;
cleanup?.();
fallbackCanvas.remove();
host.replaceChildren();
};
}, [
onCharacterLayerReadyChange,
onPlatformLayerReadyChange,
renderCharacter,
]);
return (
<div
ref={hostRef}
data-testid="jump-hop-three-scene"
className="jump-hop-runtime__three-scene"
style={{ pointerEvents: 'none', zIndex: 42 }}
data-platform-count={platformCount}
/>
);
}
function JumpHopLeaderboardPanel({
profileId,
runtimeRequestOptions,
}: {
profileId?: string | null;
runtimeRequestOptions?: JumpHopRuntimeRequestOptions;
}) {
const { leaderboard, isLoading, error } = useJumpHopLeaderboard(
profileId,
runtimeRequestOptions,
);
const items = leaderboard?.items ?? [];
return (
<aside
data-testid="jump-hop-runtime-leaderboard"
className="jump-hop-runtime__leaderboard"
>
<div className="flex items-center justify-between gap-2">
<div className="text-xs font-black text-slate-700">排行榜</div>
{isLoading ? (
<Loader2 className="h-3.5 w-3.5 animate-spin text-slate-500" />
) : null}
</div>
<div className="mt-2 grid gap-1.5">
{items.slice(0, 3).map((entry) => (
<div
key={`${entry.rank}-${entry.playerId}`}
className="grid grid-cols-[1.5rem_minmax(0,1fr)_auto_auto] items-center gap-2 text-xs font-bold text-slate-700"
>
<span className="text-slate-400">{entry.rank}</span>
<span className="truncate">
{entry.displayName?.trim() || '玩家'}
</span>
<span>{entry.successfulJumpCount} </span>
<span>{formatJumpHopDurationLabel(entry.durationMs)}</span>
</div>
))}
{items.length === 0 ? (
<div className="text-xs font-bold text-slate-500">
{error ?? '暂无成绩'}
</div>
) : null}
</div>
</aside>
);
}
export function JumpHopRuntimeShell({
profile = null,
run,
snapshot,
isBusy = false,
error = null,
runtimeRequestOptions,
onExit,
onBack,
onRestart,
onJump,
}: JumpHopRuntimeShellProps) {
const activeRun = getRun(run, snapshot);
const [displayRun, setDisplayRun] = useState(activeRun);
const [isJumpAnimating, setIsJumpAnimating] = useState(false);
const [isLandingRecoilAnimating, setIsLandingRecoilAnimating] =
useState(false);
const [isCharging, setIsCharging] = useState(false);
const [dragDistance, setDragDistance] = useState(0);
const [visualJump, setVisualJump] = useState<JumpHopVisualJump | null>(null);
const [nowMs, setNowMs] = useState(() => Date.now());
const [isThreeCharacterLayerReady, setIsThreeCharacterLayerReady] =
useState(false);
const [isThreePlatformLayerReady, setIsThreePlatformLayerReady] =
useState(false);
const [platformTextureUrlsByRenderKey, setPlatformTextureUrlsByRenderKey] =
useState<Record<string, string>>({});
const platformTextureParentObjectUrlsRef = useRef<Set<string>>(new Set());
const [dragVector, setDragVector] = useState({ x: 0, y: 0 });
const [jumpAnimationProgress, setJumpAnimationProgress] = useState(0);
const [isPlatformAdvancing, setIsPlatformAdvancing] = useState(false);
const [platformAdvanceExitingPlatforms, setPlatformAdvanceExitingPlatforms] =
useState<JumpHopVisiblePlatform[]>([]);
const [platformAdvanceCameraOffsetX, setPlatformAdvanceCameraOffsetX] =
useState(0);
const [platformAdvanceCameraOffsetY, setPlatformAdvanceCameraOffsetY] =
useState(0);
const [stageSize, setStageSize] = useState({ width: 0, height: 0 });
const stageRef = useRef<HTMLElement | null>(null);
const chargeStartedAtRef = useRef<number | null>(null);
const chargeFrameRef = useRef<number | null>(null);
const animationFrameRef = useRef<number | null>(null);
const animationEndTimerRef = useRef<number | null>(null);
const landingRecoilEndTimerRef = useRef<number | null>(null);
const animationStartAtRef = useRef(0);
const hasJumpAnimationReachedTargetRef = useRef(false);
const platformAdvanceEndTimerRef = useRef<number | null>(null);
const activeRunRef = useRef(activeRun);
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,
isResolving: isStageBackgroundResolving,
} = useResolvedAssetReadUrl(stageBackgroundSource, {
refreshKey: stageBackgroundSource,
});
const backButtonAssetSource =
profile?.backButtonAsset?.imageSrc?.trim() ||
profile?.draft.backButtonAsset?.imageSrc?.trim() ||
null;
const {
resolvedUrl: backButtonAssetUrl,
isResolving: isBackButtonAssetResolving,
} = useResolvedAssetReadUrl(backButtonAssetSource, {
refreshKey:
profile?.backButtonAsset?.assetObjectId ||
profile?.draft.backButtonAsset?.assetObjectId ||
backButtonAssetSource ||
undefined,
});
useEffect(() => {
activeRunRef.current = activeRun;
}, [activeRun]);
useEffect(() => {
displayRunRef.current = displayRun;
}, [displayRun]);
const stageRun = displayRun ?? activeRun;
const maxDragDistancePx =
stageRun?.path.scoring.maxChargeMs && stageRun.path.scoring.maxChargeMs > 0
? stageRun.path.scoring.maxChargeMs
: DEFAULT_MAX_DRAG_DISTANCE_PX;
const chargeRatio = clamp(
dragDistance / maxDragDistancePx,
0,
MAX_CHARGE_RATIO,
);
const canJump = Boolean(
activeRun &&
activeRun.status === 'playing' &&
!isBusy &&
!isJumpAnimating &&
!isPlatformAdvancing,
);
const exitHandler = onExit ?? onBack;
const visiblePlatforms = useMemo(
() =>
buildJumpHopVisiblePlatforms(
stageRun?.path,
stageRun?.currentPlatformIndex ?? 0,
profile?.tileAssets,
),
[profile?.tileAssets, stageRun?.currentPlatformIndex, stageRun?.path],
);
const platformRenderItems = useMemo(() => {
const exitingItems = platformAdvanceExitingPlatforms.map((item) => ({
...item,
renderKey: item.platform.platformId,
advanceState: 'exiting' as const,
}));
const visibleItems = visiblePlatforms.map((item) => ({
...item,
renderKey: item.platform.platformId,
advanceState: isPlatformAdvancing ? ('camera' as const) : ('idle' as const),
}));
return [...exitingItems, ...visibleItems];
}, [
isPlatformAdvancing,
platformAdvanceExitingPlatforms,
visiblePlatforms,
]);
const platformRenderKeySignature = useMemo(
() => platformRenderItems.map((item) => item.renderKey).join('|'),
[platformRenderItems],
);
const shouldUseThreePlatformLayer = useMemo(
() =>
isThreePlatformLayerReady &&
platformRenderItems.every((item) =>
hasJumpHopTileTexturesReady(
platformTextureUrlsByRenderKey,
item.renderKey,
item.asset,
),
),
[
isThreePlatformLayerReady,
platformRenderItems,
platformTextureUrlsByRenderKey,
],
);
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, JumpHopTilePreloadItem>();
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 = platform.platformId;
assets.set(key, {
textureKey: platform.platformId,
asset,
});
}
return [...assets.values()];
}, [
profile?.tileAssets,
stageRun?.currentPlatformIndex,
stageRun?.path,
visiblePlatforms.length,
]);
const landingAssistStageSize =
stageSize.width > 0 && stageSize.height > 0
? stageSize
: { width: 320, height: 568 };
const characterPosition = getJumpHopCharacterVisualPosition(
stageRun,
visiblePlatforms,
landingAssistStageSize,
);
const currentPlatformOriginPosition = useMemo(() => {
if (!stageRun) {
return null;
}
const currentPlatform = visiblePlatforms.find(
(item) => item.index === stageRun.currentPlatformIndex,
);
return currentPlatform
? buildJumpHopCharacterVisualPositionFromPlatform(currentPlatform)
: null;
}, [stageRun, visiblePlatforms]);
const jumpTargetPlatform = useMemo(() => {
if (!stageRun) {
return null;
}
return (
visiblePlatforms.find(
(item) => item.index === stageRun.currentPlatformIndex + 1,
) ?? null
);
}, [stageRun, visiblePlatforms]);
const targetDirection = useMemo(() => {
const directionOrigin = currentPlatformOriginPosition ?? characterPosition;
if (!directionOrigin || !jumpTargetPlatform) {
return null;
}
const targetCharacterPosition =
buildJumpHopCharacterVisualPositionFromPlatform(jumpTargetPlatform);
const directionX = targetCharacterPosition.screenX - directionOrigin.screenX;
const directionY = targetCharacterPosition.screenY - directionOrigin.screenY;
const distance = Math.hypot(directionX, directionY);
if (distance < 0.0001) {
return null;
}
return {
screenX: directionX,
screenY: directionY,
unitScreenX: directionX / distance,
unitScreenY: directionY / distance,
};
}, [characterPosition, currentPlatformOriginPosition, jumpTargetPlatform]);
const visualCharacterPosition = useMemo(() => {
if (!characterPosition) {
return null;
}
if (isJumpAnimating && visualJump) {
return visualJump.to;
}
return characterPosition;
}, [
characterPosition,
isJumpAnimating,
visualJump,
]);
const characterMotionStyle = useMemo(() => {
const idleTransform = 'matrix(1, 0, 0, 1, 0, 0)';
const recoilDistance = Math.hypot(dragVector.x, dragVector.y);
const recoilUnitX =
recoilDistance > 0
? dragVector.x / recoilDistance
: targetDirection
? -targetDirection.unitScreenX
: 0;
const recoilUnitY =
recoilDistance > 0
? dragVector.y / recoilDistance
: targetDirection
? -targetDirection.unitScreenY
: 0;
let stretchTransform = idleTransform;
if (isCharging) {
const squashY = 1 - chargeRatio * 0.32;
const squashX = 1 + chargeRatio * 0.1;
stretchTransform = `scale(${formatJumpHopCssNumber(
squashX,
)}, ${formatJumpHopCssNumber(squashY)})`;
}
return {
stretchTransform,
flightFromX: visualJump
? `${formatJumpHopCssNumber(
((visualJump.from.screenX - visualJump.to.screenX) / 100) *
landingAssistStageSize.width,
)}px`
: '0px',
flightFromY: visualJump
? `${formatJumpHopCssNumber(
((visualJump.from.screenY - visualJump.to.screenY) / 100) *
landingAssistStageSize.height,
)}px`
: '0px',
recoilX: `${formatJumpHopCssNumber(recoilUnitX * 11)}px`,
recoilY: `${formatJumpHopCssNumber(recoilUnitY * 11)}px`,
};
}, [
chargeRatio,
dragVector.x,
dragVector.y,
isCharging,
landingAssistStageSize.height,
landingAssistStageSize.width,
targetDirection,
visualJump,
]);
const jumpFeedbackForDisplay = getJumpHopJumpFeedbackLabel(stageRun);
const isSettled =
stageRun?.status === 'failed' || stageRun?.status === 'cleared';
const shouldShowFailureLeaderboard =
stageRun?.status === 'failed' &&
profile?.summary.publicationStatus === 'published';
const successfulJumpCount = stageRun?.successfulJumpCount ?? 0;
const durationLabel = formatJumpHopDurationLabel(
getJumpHopRunDurationMs(stageRun, nowMs),
);
useEffect(() => {
visiblePlatformsRef.current = visiblePlatforms;
}, [visiblePlatforms]);
useEffect(() => {
const activeKeys = new Set([
...platformRenderItems.flatMap((item) =>
getJumpHopActiveTextureKeys(item.renderKey, item.asset),
),
...preloadTileAssets.flatMap((item) =>
getJumpHopActiveTextureKeys(item.textureKey, item.asset),
),
]);
setPlatformTextureUrlsByRenderKey((current) => {
let changed = false;
const next: Record<string, string> = {};
for (const [key, value] of Object.entries(current)) {
if (activeKeys.has(key)) {
next[key] = value;
} else {
changed = true;
if (
value.startsWith('blob:') &&
platformTextureParentObjectUrlsRef.current.has(value)
) {
URL.revokeObjectURL(value);
platformTextureParentObjectUrlsRef.current.delete(value);
}
}
}
return changed ? next : current;
});
}, [platformRenderItems, platformRenderKeySignature, preloadTileAssets]);
const handleResolvedPlatformTextureUrl = useCallback(
(
textureKey: string,
resolvedUrl: string,
options?: { parentOwnedObjectUrl?: boolean },
) => {
setPlatformTextureUrlsByRenderKey((current) => {
const previousUrl = current[textureKey];
if (!resolvedUrl) {
return current;
}
if (previousUrl === resolvedUrl) {
return current;
}
if (
previousUrl &&
previousUrl.startsWith('blob:') &&
platformTextureParentObjectUrlsRef.current.has(previousUrl)
) {
URL.revokeObjectURL(previousUrl);
platformTextureParentObjectUrlsRef.current.delete(previousUrl);
}
if (options?.parentOwnedObjectUrl && resolvedUrl.startsWith('blob:')) {
platformTextureParentObjectUrlsRef.current.add(resolvedUrl);
}
return {
...current,
[textureKey]: resolvedUrl,
};
});
},
[],
);
useEffect(() => {
return () => {
platformTextureParentObjectUrlsRef.current.forEach((url) => {
URL.revokeObjectURL(url);
});
platformTextureParentObjectUrlsRef.current.clear();
};
}, []);
useEffect(() => {
tileAssetsRef.current = profile?.tileAssets;
}, [profile?.tileAssets]);
const clearPlatformAdvanceState = useCallback(() => {
if (platformAdvanceEndTimerRef.current != null) {
window.clearTimeout(platformAdvanceEndTimerRef.current);
platformAdvanceEndTimerRef.current = null;
}
setIsPlatformAdvancing(false);
setPlatformAdvanceExitingPlatforms([]);
setPlatformAdvanceCameraOffsetX(0);
setPlatformAdvanceCameraOffsetY(0);
}, []);
const clearLandingRecoilState = useCallback(() => {
if (landingRecoilEndTimerRef.current != null) {
window.clearTimeout(landingRecoilEndTimerRef.current);
landingRecoilEndTimerRef.current = null;
}
setIsLandingRecoilAnimating(false);
}, []);
const stopChargeFrame = useCallback(() => {
if (chargeFrameRef.current != null) {
window.cancelAnimationFrame(chargeFrameRef.current);
chargeFrameRef.current = null;
}
}, []);
const beginPlatformAdvance = useCallback(
(
fromRun: JumpHopRuntimeRunSnapshotResponse,
toRun: JumpHopRuntimeRunSnapshotResponse,
) => {
if (!shouldAnimateJumpHopPlatformAdvance(fromRun, toRun)) {
clearPlatformAdvanceState();
return;
}
const fromVisiblePlatforms = visiblePlatformsRef.current;
const toVisiblePlatforms = buildJumpHopVisiblePlatforms(
toRun.path,
toRun.currentPlatformIndex,
tileAssetsRef.current,
);
const toPlatformIds = new Set(
toVisiblePlatforms.map((item) => item.platform.platformId),
);
const fromLandingPosition =
getJumpHopRunLandingVisualPosition({
run: toRun,
platforms: fromVisiblePlatforms,
stageSize: landingAssistStageSize,
}) ??
(() => {
const fromLandingPlatform = fromVisiblePlatforms.find(
(item) => item.index === toRun.currentPlatformIndex,
);
return fromLandingPlatform
? buildJumpHopCharacterVisualPositionFromPlatform(
fromLandingPlatform,
)
: null;
})();
const toLandingPosition =
getJumpHopRunLandingVisualPosition({
run: toRun,
platforms: toVisiblePlatforms,
stageSize: landingAssistStageSize,
}) ??
(() => {
const toCurrentPlatform = toVisiblePlatforms.find(
(item) => item.index === toRun.currentPlatformIndex,
);
return toCurrentPlatform
? buildJumpHopCharacterVisualPositionFromPlatform(
toCurrentPlatform,
)
: null;
})();
const toCurrentPlatform = toVisiblePlatforms.find(
(item) => item.index === toRun.currentPlatformIndex,
);
const fromLandingPlatform = fromVisiblePlatforms.find(
(item) => item.index === toRun.currentPlatformIndex,
);
const cameraOffsetX =
(fromLandingPosition?.screenX ?? fromLandingPlatform?.screenX ?? 0) -
(toLandingPosition?.screenX ?? toCurrentPlatform?.screenX ?? 0);
const cameraOffsetY = Math.max(
0,
(toLandingPosition?.screenY ?? toCurrentPlatform?.screenY ?? 0) -
(fromLandingPosition?.screenY ?? fromLandingPlatform?.screenY ?? 0),
);
const movePlatformBehindCamera = (item: JumpHopVisiblePlatform) => ({
...item,
screenX: item.screenX - cameraOffsetX,
screenY: item.screenY + cameraOffsetY,
});
setPlatformAdvanceExitingPlatforms((currentRetainedPlatforms) => {
const retainedPlatforms = currentRetainedPlatforms
.filter((item) => !toPlatformIds.has(item.platform.platformId))
.map(movePlatformBehindCamera);
const newlyRetainedPlatforms = fromVisiblePlatforms
.filter((item) => !toPlatformIds.has(item.platform.platformId))
.map(movePlatformBehindCamera);
const byPlatformId = new Map<string, JumpHopVisiblePlatform>();
[...retainedPlatforms, ...newlyRetainedPlatforms].forEach((item) => {
if (item.screenY < JUMP_HOP_PLATFORM_RETAIN_OFFSCREEN_SCREEN_Y) {
byPlatformId.set(item.platform.platformId, item);
}
});
return [...byPlatformId.values()];
});
setPlatformAdvanceCameraOffsetX(cameraOffsetX);
setPlatformAdvanceCameraOffsetY(cameraOffsetY);
setIsPlatformAdvancing(true);
if (platformAdvanceEndTimerRef.current != null) {
window.clearTimeout(platformAdvanceEndTimerRef.current);
}
platformAdvanceEndTimerRef.current = window.setTimeout(() => {
platformAdvanceEndTimerRef.current = null;
setIsPlatformAdvancing(false);
setPlatformAdvanceCameraOffsetX(0);
setPlatformAdvanceCameraOffsetY(0);
}, JUMP_HOP_PLATFORM_ADVANCE_DURATION_MS);
},
[clearPlatformAdvanceState, landingAssistStageSize],
);
const finishJumpHopFlightAnimation = useCallback(
(
fromRun: JumpHopRuntimeRunSnapshotResponse,
toRun: JumpHopRuntimeRunSnapshotResponse,
) => {
if (
fromRun.runId === toRun.runId &&
hasJumpHopRunDisplayChange(fromRun, toRun)
) {
beginPlatformAdvance(fromRun, toRun);
setDisplayRun(toRun);
}
const shouldPlayLandingRecoil =
toRun.lastJump && toRun.lastJump.result !== 'miss';
if (shouldPlayLandingRecoil) {
if (landingRecoilEndTimerRef.current != null) {
window.clearTimeout(landingRecoilEndTimerRef.current);
}
setIsLandingRecoilAnimating(true);
landingRecoilEndTimerRef.current = window.setTimeout(() => {
landingRecoilEndTimerRef.current = null;
setIsLandingRecoilAnimating(false);
}, JUMP_HOP_LANDING_RECOIL_DURATION_MS);
} else {
clearLandingRecoilState();
}
setIsJumpAnimating(false);
setJumpAnimationProgress(0);
setVisualJump(null);
hasJumpAnimationReachedTargetRef.current = false;
setNowMs(Date.now());
},
[beginPlatformAdvance, clearLandingRecoilState],
);
useEffect(() => {
if (stageRun?.status !== 'playing') {
return undefined;
}
const timer = window.setInterval(() => {
setNowMs(Date.now());
}, 250);
return () => window.clearInterval(timer);
}, [stageRun?.runId, stageRun?.status]);
useEffect(() => {
const stage = stageRef.current;
if (!stage) {
return undefined;
}
const updateStageSize = () => {
const rect = stage.getBoundingClientRect();
setStageSize({
width: rect.width,
height: rect.height,
});
};
updateStageSize();
const resizeObserver = window.ResizeObserver
? new window.ResizeObserver(updateStageSize)
: null;
resizeObserver?.observe(stage);
return () => {
resizeObserver?.disconnect();
};
}, []);
useEffect(() => {
if (!activeRun) {
if (animationFrameRef.current != null) {
window.cancelAnimationFrame(animationFrameRef.current);
animationFrameRef.current = null;
}
if (animationEndTimerRef.current != null) {
window.clearTimeout(animationEndTimerRef.current);
animationEndTimerRef.current = null;
}
clearPlatformAdvanceState();
clearLandingRecoilState();
hasJumpAnimationReachedTargetRef.current = false;
setDisplayRun(null);
setVisualJump(null);
setIsJumpAnimating(false);
setJumpAnimationProgress(0);
setIsCharging(false);
chargeStartedAtRef.current = null;
stopChargeFrame();
setDragDistance(0);
setDragVector({ x: 0, y: 0 });
setNowMs(Date.now());
return;
}
if (!displayRun || displayRun.runId !== activeRun.runId) {
if (animationFrameRef.current != null) {
window.cancelAnimationFrame(animationFrameRef.current);
animationFrameRef.current = null;
}
if (animationEndTimerRef.current != null) {
window.clearTimeout(animationEndTimerRef.current);
animationEndTimerRef.current = null;
}
clearPlatformAdvanceState();
clearLandingRecoilState();
hasJumpAnimationReachedTargetRef.current = false;
setDisplayRun(activeRun);
setVisualJump(null);
setIsJumpAnimating(false);
setJumpAnimationProgress(0);
setIsCharging(false);
chargeStartedAtRef.current = null;
stopChargeFrame();
setDragDistance(0);
setDragVector({ x: 0, y: 0 });
setNowMs(Date.now());
return;
}
if (isJumpAnimating) {
if (
(jumpAnimationProgress >= 1 ||
hasJumpAnimationReachedTargetRef.current) &&
displayRun &&
displayRun.runId === activeRun.runId &&
hasJumpHopRunDisplayChange(displayRun, activeRun)
) {
finishJumpHopFlightAnimation(displayRun, activeRun);
}
return;
}
if (hasJumpHopRunDisplayChange(displayRun, activeRun)) {
clearPlatformAdvanceState();
clearLandingRecoilState();
setDisplayRun(activeRun);
}
}, [
activeRun,
clearLandingRecoilState,
clearPlatformAdvanceState,
displayRun,
finishJumpHopFlightAnimation,
isJumpAnimating,
jumpAnimationProgress,
stopChargeFrame,
]);
useEffect(() => {
return () => {
if (animationFrameRef.current != null) {
window.cancelAnimationFrame(animationFrameRef.current);
}
if (animationEndTimerRef.current != null) {
window.clearTimeout(animationEndTimerRef.current);
}
if (platformAdvanceEndTimerRef.current != null) {
window.clearTimeout(platformAdvanceEndTimerRef.current);
}
if (landingRecoilEndTimerRef.current != null) {
window.clearTimeout(landingRecoilEndTimerRef.current);
}
if (chargeFrameRef.current != null) {
window.cancelAnimationFrame(chargeFrameRef.current);
}
};
}, []);
useEffect(() => {
if (!isJumpAnimating) {
hasJumpAnimationReachedTargetRef.current = false;
setJumpAnimationProgress(0);
if (animationFrameRef.current != null) {
window.cancelAnimationFrame(animationFrameRef.current);
animationFrameRef.current = null;
}
if (animationEndTimerRef.current != null) {
window.clearTimeout(animationEndTimerRef.current);
animationEndTimerRef.current = null;
}
return undefined;
}
animationStartAtRef.current = window.performance.now();
hasJumpAnimationReachedTargetRef.current = false;
animationEndTimerRef.current = window.setTimeout(() => {
animationEndTimerRef.current = null;
hasJumpAnimationReachedTargetRef.current = true;
setJumpAnimationProgress(1);
const latestDisplayRun = displayRunRef.current;
const latestActiveRun = activeRunRef.current;
if (
latestDisplayRun &&
latestActiveRun &&
latestDisplayRun.runId === latestActiveRun.runId &&
hasJumpHopRunDisplayChange(latestDisplayRun, latestActiveRun)
) {
finishJumpHopFlightAnimation(latestDisplayRun, latestActiveRun);
}
}, JUMP_HOP_ANIMATION_DURATION_MS);
const tick = (now: number) => {
if (hasJumpAnimationReachedTargetRef.current) {
animationFrameRef.current = null;
return;
}
const elapsed = now - animationStartAtRef.current;
const progress = clamp(
elapsed / JUMP_HOP_ANIMATION_DURATION_MS,
0,
1,
);
setJumpAnimationProgress(progress);
if (progress < 1) {
animationFrameRef.current = window.requestAnimationFrame(tick);
} else {
animationFrameRef.current = null;
}
};
animationFrameRef.current = window.requestAnimationFrame(tick);
return () => {
if (animationFrameRef.current != null) {
window.cancelAnimationFrame(animationFrameRef.current);
animationFrameRef.current = null;
}
if (animationEndTimerRef.current != null) {
window.clearTimeout(animationEndTimerRef.current);
animationEndTimerRef.current = null;
}
};
}, [finishJumpHopFlightAnimation, isJumpAnimating]);
const beginCharge = (event: PointerEvent<HTMLElement>) => {
if (!canJump) {
return;
}
event.currentTarget.setPointerCapture?.(event.pointerId);
chargeStartedAtRef.current = Date.now();
stopChargeFrame();
clearLandingRecoilState();
setIsCharging(true);
setDragDistance(0);
setDragVector({ x: 0, y: 0 });
const tick = () => {
const chargeStartedAt = chargeStartedAtRef.current;
if (chargeStartedAt == null) {
chargeFrameRef.current = null;
return;
}
const nextDragDistance = clamp(
Date.now() - chargeStartedAt,
0,
maxDragDistancePx,
);
setDragDistance(nextDragDistance);
if (nextDragDistance < maxDragDistancePx) {
chargeFrameRef.current = window.requestAnimationFrame(tick);
return;
}
chargeFrameRef.current = null;
};
chargeFrameRef.current = window.requestAnimationFrame(tick);
};
const finishCharge = async (event?: PointerEvent<HTMLElement>) => {
if (!isCharging) {
return;
}
if (event) {
event.currentTarget.releasePointerCapture?.(event.pointerId);
}
const chargeStartedAt = chargeStartedAtRef.current;
const nextDragDistance =
chargeStartedAt == null
? 0
: clamp(
Date.now() - chargeStartedAt,
0,
maxDragDistancePx,
);
const predictionRun = stageRun ?? activeRun;
const predictedLandingPosition =
predictionRun && characterPosition
? getJumpHopLandingAssistVisualPosition(
predictionRun,
visiblePlatforms,
characterPosition,
landingAssistStageSize,
nextDragDistance,
)
: null;
if (characterPosition) {
const predictionOrigin =
currentPlatformOriginPosition ?? characterPosition;
const visualDeltaX = predictedLandingPosition
? predictedLandingPosition.screenX - predictionOrigin.screenX
: 0;
const visualDeltaY = predictedLandingPosition
? predictedLandingPosition.screenY - predictionOrigin.screenY
: 0;
setVisualJump({
from: characterPosition,
to: predictedLandingPosition
? {
...characterPosition,
screenX: clamp(characterPosition.screenX + visualDeltaX, 6, 94),
screenY: clamp(characterPosition.screenY + visualDeltaY, 10, 92),
isMiss: !predictedLandingPosition.isOnTargetPlatform,
}
: characterPosition,
});
} else {
setVisualJump(null);
}
chargeStartedAtRef.current = null;
stopChargeFrame();
clearLandingRecoilState();
setIsCharging(false);
setJumpAnimationProgress(0);
hasJumpAnimationReachedTargetRef.current = false;
setIsJumpAnimating(true);
setDragDistance(nextDragDistance);
setDragVector({
x: targetDirection ? -targetDirection.unitScreenX : 0,
y: targetDirection ? -targetDirection.unitScreenY : 0,
});
const backendDragVector = getJumpHopBackendDragVector(
predictionRun ?? activeRun,
visiblePlatforms,
landingAssistStageSize,
targetDirection ? -targetDirection.unitScreenX : 0,
targetDirection ? -targetDirection.unitScreenY : 0,
);
await onJump({
dragDistance: nextDragDistance,
dragVectorX: backendDragVector.dragVectorX,
dragVectorY: backendDragVector.dragVectorY,
});
};
const cancelCharge = () => {
chargeStartedAtRef.current = null;
stopChargeFrame();
clearLandingRecoilState();
hasJumpAnimationReachedTargetRef.current = false;
setVisualJump(null);
setIsCharging(false);
setDragDistance(0);
setDragVector({ x: 0, y: 0 });
};
return (
<div className="platform-remap-surface jump-hop-runtime relative h-full min-h-dvh w-full overflow-hidden bg-[#fffdf9] text-slate-950">
<RuntimeResourcePendingMarker
source={stageBackgroundSource}
kind="image"
isPending={isStageBackgroundResolving}
/>
<RuntimeResourcePendingMarker
source={backButtonAssetSource}
kind="image"
isPending={isBackButtonAssetResolving}
/>
<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}
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'}
data-three-platform-ready={shouldUseThreePlatformLayer ? 'true' : 'false'}
className="jump-hop-runtime__camera-layer"
style={
{
'--jump-hop-camera-shift-x': `${platformAdvanceCameraOffsetX}%`,
'--jump-hop-camera-shift-y': `${-platformAdvanceCameraOffsetY}%`,
'--jump-hop-camera-zoom': JUMP_HOP_CAMERA_ZOOM,
} as CSSProperties
}
>
<JumpHopThreeScene
characterPosition={visualCharacterPosition}
chargeRatio={chargeRatio}
isJumpAnimating={isJumpAnimating}
platforms={platformRenderItems}
platformCount={platformRenderItems.length}
renderCharacter={false}
textureUrlsByRenderKey={platformTextureUrlsByRenderKey}
onCharacterLayerReadyChange={setIsThreeCharacterLayerReady}
onPlatformLayerReadyChange={setIsThreePlatformLayerReady}
/>
{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}
textureKey={item.renderKey}
onResolvedTextureUrl={handleResolvedPlatformTextureUrl}
/>
</div>
);
})}
{preloadTileAssets.length > 0 ? (
<div className="jump-hop-runtime__tile-preload" aria-hidden="true">
{preloadTileAssets.map((item) => (
<JumpHopTilePreloadImage
key={item.textureKey}
asset={item.asset}
textureKey={item.textureKey}
onResolvedTextureUrl={handleResolvedPlatformTextureUrl}
/>
))}
</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 && characterPosition ? (
<div
aria-hidden="true"
className="jump-hop-runtime__charge-guide"
style={
(() => {
const anchorX =
stageSize.width * (characterPosition.screenX / 100);
const anchorY =
stageSize.height * (characterPosition.screenY / 100);
const lineLength = 42 + chargeRatio * 84;
const ringSize = 56 + chargeRatio * 24;
const angle =
targetDirection == null
? -90
: (Math.atan2(
targetDirection.screenY,
targetDirection.screenX,
) *
180) /
Math.PI;
return {
'--jump-hop-anchor-x': `${anchorX}px`,
'--jump-hop-anchor-y': `${anchorY}px`,
'--jump-hop-charge': chargeRatio,
'--jump-hop-charge-ring-size': `${ringSize}px`,
'--jump-hop-charge-turn': `${chargeRatio}turn`,
'--jump-hop-guide-angle': `${angle}deg`,
'--jump-hop-guide-length': `${lineLength}px`,
} as CSSProperties;
})()
}
>
<div className="jump-hop-runtime__charge-ring" />
<div className="jump-hop-runtime__charge-line" />
<div className="jump-hop-runtime__charge-core" />
</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">
<PlatformActionButton
onClick={onRestart}
disabled={isBusy}
className="min-h-11 px-3 py-2 text-sm"
>
重开
</PlatformActionButton>
<PlatformActionButton
onClick={exitHandler}
tone="ghost"
className="min-h-11 bg-white px-3 py-2 text-sm"
>
返回
</PlatformActionButton>
</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}
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]"
>
{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="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>
{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>
</footer>
) : null}
<style>{`
.jump-hop-runtime__stage {
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;
}
.jump-hop-runtime__scene-backdrop {
position: absolute;
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;
z-index: 2;
pointer-events: none;
transform: translate(0, 0) scale(var(--jump-hop-camera-zoom, 1));
transform-origin: 50% 56%;
}
.jump-hop-runtime__camera-layer[data-platform-advancing='true'] {
animation: jump-hop-camera-advance 1440ms cubic-bezier(0.2, 0.78, 0.24, 1) both;
}
.jump-hop-runtime__three-scene {
position: absolute;
inset: 0;
z-index: 42;
pointer-events: none;
overflow: hidden;
background: transparent;
opacity: 1;
transition: opacity 120ms ease;
}
.jump-hop-runtime__camera-layer[data-three-platform-ready='false'] .jump-hop-runtime__three-scene {
opacity: 0;
}
.jump-hop-runtime__platform {
position: absolute;
z-index: 8;
transform: translate(-50%, -50%) scale(var(--jump-hop-platform-scale, 1));
transform-origin: 50% 50%;
display: grid;
place-items: center;
transition:
left 480ms cubic-bezier(0.2, 0.78, 0.24, 1),
top 480ms cubic-bezier(0.2, 0.78, 0.24, 1),
transform 480ms cubic-bezier(0.2, 0.78, 0.24, 1),
opacity 220ms ease;
}
.jump-hop-runtime__platform[data-advance-state='exiting'] {
pointer-events: none;
}
.jump-hop-runtime__stage[data-platform-advancing='true'] .jump-hop-runtime__platform {
transition:
transform 1440ms cubic-bezier(0.2, 0.78, 0.24, 1),
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%;
right: 8%;
bottom: -16%;
height: 32%;
border-radius: 999px;
background: rgba(15, 82, 126, 0.18);
filter: blur(7px);
}
.jump-hop-runtime__camera-layer[data-three-platform-ready='true'] .jump-hop-runtime__platform-shadow,
.jump-hop-runtime__camera-layer[data-three-platform-ready='true'] .jump-hop-runtime__tile-image-stack {
opacity: 0;
}
.jump-hop-runtime__tile-image {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
object-fit: contain;
image-rendering: auto;
opacity: 0;
transition: opacity 120ms ease;
}
.jump-hop-runtime__tile-image[data-loaded='true'] {
opacity: 1;
}
.jump-hop-runtime__tile-image-stack {
position: relative;
width: 100%;
height: 100%;
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__fallback-tile {
position: relative;
width: 100%;
height: 100%;
transform: rotateX(58deg) rotateZ(45deg);
transform-style: preserve-3d;
}
.jump-hop-runtime__fallback-top {
position: absolute;
inset: 8%;
border-radius: 14%;
background:
linear-gradient(135deg, rgba(255,255,255,0.8), transparent 44%),
var(--jump-hop-tile-tone);
border: 2px solid rgba(255, 255, 255, 0.86);
box-shadow: inset -8px -8px 14px rgba(15, 23, 42, 0.08);
}
.jump-hop-runtime__fallback-side {
position: absolute;
background: color-mix(in srgb, var(--jump-hop-tile-tone) 72%, #0f766e);
opacity: 0.88;
}
.jump-hop-runtime__fallback-side--left {
left: 8%;
bottom: -9%;
width: 84%;
height: 18%;
transform: skewX(45deg);
transform-origin: top;
}
.jump-hop-runtime__fallback-side--right {
right: -9%;
top: 8%;
width: 18%;
height: 84%;
transform: skewY(45deg);
transform-origin: left;
}
.jump-hop-runtime__character {
position: absolute;
z-index: 80;
width: 4.7rem;
height: 5.5rem;
transform:
translate(-50%, -100%)
var(--jump-hop-character-stretch-transform);
transform-origin: 50% 78%;
transition:
left 240ms ease,
top 240ms ease,
transform 120ms ease,
opacity 160ms ease;
}
.jump-hop-runtime__character[data-jump-animating='true'] {
animation: jump-hop-character-flight 560ms cubic-bezier(0.18, 0.9, 0.24, 1) both;
transition: opacity 160ms ease;
}
.jump-hop-runtime__character[data-jump-animating='true'] .jump-hop-runtime__character-image {
animation: jump-hop-character-flip 560ms ease-out both;
}
.jump-hop-runtime__character[data-landing-recoil='true'] .jump-hop-runtime__character-image {
animation: jump-hop-character-recoil 560ms cubic-bezier(0.18, 0.88, 0.24, 1) both;
}
.jump-hop-runtime__character[data-miss='true'] {
opacity: 0.78;
transform: translate(-50%, -72%) rotate(18deg) scale(0.88);
}
.jump-hop-runtime__character-shadow {
position: absolute;
left: 20%;
right: 20%;
bottom: 0.16rem;
height: 0.86rem;
border-radius: 999px;
background: rgba(15, 23, 42, 0.18);
filter: blur(3px);
transform: scaleX(1);
transition: transform 120ms ease;
}
.jump-hop-runtime__character[data-charging='true'] .jump-hop-runtime__character-shadow {
transform: scaleX(calc(1 + (var(--jump-hop-charge) * 0.42)));
}
.jump-hop-runtime__character-image {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
object-fit: contain;
filter: drop-shadow(0 12px 10px rgba(15, 23, 42, 0.18));
}
.jump-hop-runtime__leaderboard {
position: relative;
z-index: 20;
margin-top: 0.65rem;
border: 1px solid rgba(255, 255, 255, 0.72);
border-radius: 1rem;
background: rgba(255, 255, 255, 0.72);
box-shadow: 0 10px 24px rgba(15, 23, 42, 0.08);
padding: 0.75rem;
backdrop-filter: blur(12px);
}
.jump-hop-runtime__character-fallback {
position: absolute;
left: 50%;
bottom: 0.68rem;
width: 2.3rem;
height: 3.3rem;
transform: translateX(-50%);
border-radius: 999px 999px 0.9rem 0.9rem;
background:
radial-gradient(circle at 50% 22%, #fff 0 17%, transparent 18%),
linear-gradient(180deg, #c7653d 0%, #df7f40 100%);
box-shadow: inset 0 0 0 2px rgba(255,255,255,0.72), 0 12px 18px rgba(190, 80, 40, 0.24);
}
.jump-hop-runtime__feedback {
position: absolute;
left: 50%;
top: 20%;
z-index: 90;
transform: translateX(-50%);
border-radius: 999px;
background: rgba(15, 23, 42, 0.74);
color: white;
padding: 0.42rem 0.86rem;
font-size: 0.85rem;
font-weight: 900;
letter-spacing: 0;
animation: jump-hop-feedback 900ms ease both;
}
.jump-hop-runtime__charge-guide {
position: absolute;
inset: 0;
z-index: 85;
pointer-events: none;
}
.jump-hop-runtime__charge-ring {
position: absolute;
left: var(--jump-hop-anchor-x);
top: var(--jump-hop-anchor-y);
width: var(--jump-hop-charge-ring-size);
height: var(--jump-hop-charge-ring-size);
margin-left: calc(var(--jump-hop-charge-ring-size) / -2);
margin-top: calc(var(--jump-hop-charge-ring-size) / -2);
border-radius: 999px;
border: 2px solid rgba(250, 204, 21, 0.82);
background:
radial-gradient(circle, rgba(250, 204, 21, 0.2) 0 38%, transparent 39%),
conic-gradient(
from -90deg,
rgba(194, 101, 64, 0.88) var(--jump-hop-charge-turn),
rgba(255, 255, 255, 0.34) 0
);
box-shadow:
0 0 0 5px rgba(255, 255, 255, 0.36),
0 8px 18px rgba(194, 101, 64, 0.2);
opacity: 0.95;
}
.jump-hop-runtime__charge-line {
position: absolute;
left: var(--jump-hop-anchor-x);
top: var(--jump-hop-anchor-y);
width: var(--jump-hop-guide-length);
height: 3px;
transform-origin: left center;
transform: translateY(-1px) rotate(var(--jump-hop-guide-angle));
border-radius: 999px;
background: linear-gradient(90deg, rgba(250, 204, 21, 0.12), rgba(194, 101, 64, 0.82));
box-shadow: 0 0 10px rgba(194, 101, 64, 0.2);
}
.jump-hop-runtime__charge-core {
position: absolute;
left: var(--jump-hop-anchor-x);
top: var(--jump-hop-anchor-y);
width: 0.82rem;
height: 0.82rem;
margin-left: -0.41rem;
margin-top: -0.41rem;
border-radius: 999px;
background: #c7653d;
box-shadow:
0 0 0 3px rgba(255, 255, 255, 0.72),
0 0 16px rgba(250, 204, 21, 0.48);
}
@keyframes jump-hop-feedback {
0% {
opacity: 0;
transform: translate(-50%, 0.8rem) scale(0.96);
}
20%, 72% {
opacity: 1;
transform: translate(-50%, 0) scale(1);
}
100% {
opacity: 0;
transform: translate(-50%, -0.6rem) scale(1.02);
}
}
@keyframes jump-hop-character-flip {
0% {
transform: translateY(0) rotate(0deg) scale(1);
}
40% {
transform: translateY(-1.35rem) rotate(180deg) scale(1.12);
}
100% {
transform: translateY(0.16rem) rotate(360deg) scale(1);
}
}
@keyframes jump-hop-character-flight {
0% {
transform:
translate(-50%, -100%)
translate(
var(--jump-hop-flight-from-x),
var(--jump-hop-flight-from-y)
)
scale(0.98);
}
42% {
transform:
translate(-50%, -100%)
translate(
calc(var(--jump-hop-flight-from-x) * 0.48),
calc(var(--jump-hop-flight-from-y) * 0.48 - 30px)
)
scale(1.04);
}
74% {
transform:
translate(-50%, -100%)
translate(
calc(var(--jump-hop-flight-from-x) * 0.14),
calc(var(--jump-hop-flight-from-y) * 0.14 - 10px)
)
scale(1.015);
}
100% {
transform:
translate(-50%, -100%)
translate(0, 0)
scale(1);
}
}
@keyframes jump-hop-character-recoil {
0% {
transform: translate(0, 0) scale(1);
}
18% {
transform:
translate(var(--jump-hop-recoil-x), var(--jump-hop-recoil-y))
scale(1.05, 0.96);
}
34% {
transform:
translate(
calc(var(--jump-hop-recoil-x) * -0.34),
calc(var(--jump-hop-recoil-y) * -0.34)
)
scale(0.98, 1.03);
}
54% {
transform:
translate(
calc(var(--jump-hop-recoil-x) * 0.62),
calc(var(--jump-hop-recoil-y) * 0.62)
)
scale(1.03, 0.98);
}
76% {
transform:
translate(
calc(var(--jump-hop-recoil-x) * -0.16),
calc(var(--jump-hop-recoil-y) * -0.16)
)
scale(0.995, 1.01);
}
100% {
transform: translate(0, 0) scale(1);
}
}
@keyframes jump-hop-camera-advance {
0% {
transform:
translate(
var(--jump-hop-camera-shift-x),
var(--jump-hop-camera-shift-y)
)
scale(var(--jump-hop-camera-zoom, 1));
}
100% {
transform: translate(0, 0) scale(var(--jump-hop-camera-zoom, 1));
}
}
@media (max-width: 430px) {
.jump-hop-runtime__stage {
min-height: 100%;
}
.jump-hop-runtime__character {
width: 4.15rem;
height: 4.95rem;
}
}
@media (prefers-reduced-motion: reduce) {
.jump-hop-runtime__feedback {
animation: none;
}
.jump-hop-runtime__character-image {
animation: none !important;
}
.jump-hop-runtime__camera-layer {
animation: none;
}
.jump-hop-runtime__platform,
.jump-hop-runtime__character {
transition: none;
}
}
`}</style>
</div>
);
}
export default JumpHopRuntimeShell;