43c66d31a2
合入 origin/master 的 VectorEngine、后端主干和跳一跳更新 保留 PlatformUiKit 组件库收口提交并处理跳一跳运行态冲突 合并 Hermes 决策记录中的前端组件库、微信能力和后端流程决策
2945 lines
91 KiB
TypeScript
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;
|