420 lines
14 KiB
TypeScript
420 lines
14 KiB
TypeScript
import {
|
|
ArrowLeft,
|
|
Loader2,
|
|
Play,
|
|
Send,
|
|
Shuffle,
|
|
} from 'lucide-react';
|
|
import { type CSSProperties, useState } from 'react';
|
|
|
|
import type {
|
|
JumpHopDraftResponse,
|
|
JumpHopPath,
|
|
JumpHopTileAsset,
|
|
JumpHopWorkProfileResponse,
|
|
} from '../../../packages/shared/src/contracts/jumpHop';
|
|
import {
|
|
formatJumpHopDurationLabel,
|
|
selectJumpHopTileAsset,
|
|
} from '../../services/jump-hop/jumpHopRuntimeModel';
|
|
import { useJumpHopLeaderboard } from '../../services/jump-hop/useJumpHopLeaderboard';
|
|
import { ResolvedAssetImage } from '../ResolvedAssetImage';
|
|
|
|
type JumpHopResultViewProps = {
|
|
profile:
|
|
| (JumpHopDraftResponse & {
|
|
characterImageSrc?: string | null;
|
|
tileAtlasImageSrc?: string | null;
|
|
pathPreviewImageSrc?: string | null;
|
|
})
|
|
| (JumpHopWorkProfileResponse & {
|
|
characterImageSrc?: string | null;
|
|
tileAtlasImageSrc?: string | null;
|
|
pathPreviewImageSrc?: string | null;
|
|
});
|
|
isBusy?: boolean;
|
|
error?: string | null;
|
|
onBack: () => void;
|
|
onEdit: () => void;
|
|
onStartTestRun: () => void;
|
|
onPublish: () => void;
|
|
onRegenerateTiles: () => void;
|
|
};
|
|
|
|
function isJumpHopWorkProfile(
|
|
profile: JumpHopResultViewProps['profile'],
|
|
): profile is JumpHopWorkProfileResponse {
|
|
return 'summary' in profile;
|
|
}
|
|
|
|
const tileToneByType: Record<string, string> = {
|
|
accent: '#c4b5fd',
|
|
bonus: '#fde68a',
|
|
finish: '#86efac',
|
|
normal: '#e0f2fe',
|
|
start: '#bae6fd',
|
|
target: '#fecdd3',
|
|
};
|
|
|
|
const JUMP_HOP_TAONIER_CHARACTER_IMAGE_SRC =
|
|
'/branding/jump-hop-taonier-character.png';
|
|
|
|
function JumpHopDefaultCharacterPreview() {
|
|
return (
|
|
<div className="relative grid aspect-[1/1] place-items-center overflow-hidden bg-[linear-gradient(180deg,#eff6ff_0%,#fff7ed_100%)]">
|
|
<div className="absolute inset-x-[18%] bottom-[14%] h-[14%] rounded-full bg-slate-900/12 blur-[2px]" />
|
|
<img
|
|
src={JUMP_HOP_TAONIER_CHARACTER_IMAGE_SRC}
|
|
alt=""
|
|
draggable={false}
|
|
className="relative z-10 h-[78%] w-[78%] object-contain drop-shadow-[0_12px_18px_rgba(146,64,14,0.2)]"
|
|
data-testid="jump-hop-result-character-logo"
|
|
/>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function JumpHopTilePoolPreview({
|
|
tileAssets,
|
|
tileAtlasAsset,
|
|
tileAtlasFallbackSrc,
|
|
}: {
|
|
tileAssets: JumpHopTileAsset[];
|
|
tileAtlasAsset?: JumpHopDraftResponse['tileAtlasAsset'] | null;
|
|
tileAtlasFallbackSrc?: string | null;
|
|
}) {
|
|
const visibleTiles = tileAssets.slice(0, 25);
|
|
const atlasSrc =
|
|
tileAtlasAsset?.imageSrc?.trim() || tileAtlasFallbackSrc?.trim() || '';
|
|
const atlasRefreshKey = tileAtlasAsset?.assetObjectId || atlasSrc;
|
|
if (visibleTiles.length > 0) {
|
|
return (
|
|
<div className="grid aspect-[1/1] grid-cols-5 gap-1 bg-white/78 p-2">
|
|
{visibleTiles.map((tile, index) => (
|
|
<div
|
|
key={tile.tileId ?? `${tile.sourceAtlasCell}-${index}`}
|
|
className="grid min-h-0 place-items-center overflow-hidden rounded-[0.45rem] border border-white/80 bg-slate-50"
|
|
>
|
|
{tile.imageSrc ? (
|
|
<ResolvedAssetImage
|
|
src={tile.imageSrc}
|
|
refreshKey={tile.assetObjectId}
|
|
alt=""
|
|
className="h-full w-full object-contain"
|
|
/>
|
|
) : (
|
|
<span
|
|
className="h-4 w-4 rounded-full"
|
|
style={{
|
|
background:
|
|
tileToneByType[tile.tileType] ?? tileToneByType.normal,
|
|
}}
|
|
/>
|
|
)}
|
|
</div>
|
|
))}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
if (atlasSrc) {
|
|
return (
|
|
<ResolvedAssetImage
|
|
src={atlasSrc}
|
|
refreshKey={atlasRefreshKey}
|
|
alt=""
|
|
className="aspect-[1/1] w-full object-cover"
|
|
/>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div className="grid aspect-[1/1] grid-cols-5 gap-1 bg-white/78 p-2">
|
|
{Array.from({ length: 25 }).map((_, index) => (
|
|
<span
|
|
key={index}
|
|
className="rounded-[0.45rem] border border-white/80"
|
|
style={{
|
|
background:
|
|
Object.values(tileToneByType)[index % Object.values(tileToneByType).length],
|
|
}}
|
|
/>
|
|
))}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function JumpHopFirstPlatformsPreview({
|
|
path,
|
|
tileAssets,
|
|
}: {
|
|
path: JumpHopPath | null | undefined;
|
|
tileAssets: JumpHopTileAsset[];
|
|
}) {
|
|
const platforms = (path?.platforms ?? []).slice(0, 3);
|
|
|
|
return (
|
|
<div className="relative aspect-[1/1] overflow-hidden bg-[linear-gradient(180deg,#f8fbff_0%,#eef8ff_100%)]">
|
|
<div className="absolute inset-0 bg-[radial-gradient(circle_at_50%_30%,rgba(255,255,255,0.92),transparent_34%)]" />
|
|
{platforms.map((platform, index) => {
|
|
const asset = selectJumpHopTileAsset(
|
|
tileAssets,
|
|
path?.seed,
|
|
index,
|
|
platform.platformId,
|
|
);
|
|
const style = {
|
|
left: `${50 + (index - 1) * 24}%`,
|
|
top: `${68 - index * 22}%`,
|
|
width: `${34 - index * 3}%`,
|
|
zIndex: 10 + index,
|
|
} as CSSProperties;
|
|
return (
|
|
<div
|
|
key={platform.platformId || index}
|
|
className="absolute aspect-[1.16/1] -translate-x-1/2 -translate-y-1/2"
|
|
style={style}
|
|
>
|
|
<div className="absolute inset-x-[12%] bottom-[-6%] h-[22%] rounded-full bg-slate-900/14 blur-[3px]" />
|
|
{asset?.imageSrc ? (
|
|
<ResolvedAssetImage
|
|
src={asset.imageSrc}
|
|
refreshKey={asset.assetObjectId}
|
|
alt=""
|
|
className="relative h-full w-full object-contain"
|
|
/>
|
|
) : (
|
|
<div
|
|
className="relative h-full w-full rounded-[18%] border-2 border-white/90 shadow-[0_10px_22px_rgba(15,23,42,0.14)]"
|
|
style={{
|
|
background:
|
|
tileToneByType[platform.tileType] ?? tileToneByType.normal,
|
|
}}
|
|
/>
|
|
)}
|
|
</div>
|
|
);
|
|
})}
|
|
{platforms.length === 0 ? (
|
|
<div className="absolute inset-0 grid place-items-center text-sm font-bold text-[var(--platform-text-soft)]">
|
|
路径
|
|
</div>
|
|
) : null}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function JumpHopResultLeaderboard({
|
|
profileId,
|
|
}: {
|
|
profileId?: string | null;
|
|
}) {
|
|
const { leaderboard, isLoading, error } = useJumpHopLeaderboard(profileId);
|
|
const items = leaderboard?.items ?? [];
|
|
|
|
return (
|
|
<div className="mt-4 rounded-[1rem] border border-[var(--platform-subpanel-border)] bg-white/70 p-3">
|
|
<div className="flex items-center justify-between gap-2">
|
|
<div className="text-sm font-black text-[var(--platform-text-strong)]">
|
|
排行榜
|
|
</div>
|
|
{isLoading ? (
|
|
<Loader2 className="h-4 w-4 animate-spin text-[var(--platform-text-soft)]" />
|
|
) : null}
|
|
</div>
|
|
<div className="mt-3 grid gap-2">
|
|
{items.slice(0, 5).map((entry) => (
|
|
<div
|
|
key={`${entry.rank}-${entry.playerId}`}
|
|
className="grid grid-cols-[1.8rem_minmax(0,1fr)_auto_auto] items-center gap-2 rounded-[0.75rem] bg-white/70 px-2 py-2 text-xs font-bold text-[var(--platform-text-base)]"
|
|
>
|
|
<span className="text-[var(--platform-text-soft)]">
|
|
{entry.rank}
|
|
</span>
|
|
<span className="truncate">{entry.playerId}</span>
|
|
<span>{entry.successfulJumpCount} 跳</span>
|
|
<span>{formatJumpHopDurationLabel(entry.durationMs)}</span>
|
|
</div>
|
|
))}
|
|
{items.length === 0 ? (
|
|
<div className="rounded-[0.75rem] bg-white/60 px-2 py-2 text-xs font-bold text-[var(--platform-text-soft)]">
|
|
{error ?? '暂无成绩'}
|
|
</div>
|
|
) : null}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
export function JumpHopResultView({
|
|
profile,
|
|
isBusy = false,
|
|
error = null,
|
|
onBack,
|
|
onEdit,
|
|
onStartTestRun,
|
|
onPublish,
|
|
onRegenerateTiles,
|
|
}: JumpHopResultViewProps) {
|
|
const [isPublishing, setIsPublishing] = useState(false);
|
|
const isWorkProfile = isJumpHopWorkProfile(profile);
|
|
const draft = isWorkProfile ? profile.draft : profile;
|
|
const safeDraft = draft as JumpHopDraftResponse & {
|
|
characterAsset: NonNullable<JumpHopDraftResponse['characterAsset']>;
|
|
tileAtlasAsset: NonNullable<JumpHopDraftResponse['tileAtlasAsset']>;
|
|
path: NonNullable<JumpHopDraftResponse['path']>;
|
|
};
|
|
const path = isWorkProfile ? profile.path : safeDraft.path;
|
|
const tileAtlasAsset = isWorkProfile
|
|
? profile.tileAtlasAsset
|
|
: safeDraft.tileAtlasAsset;
|
|
const tileAssets = isWorkProfile ? profile.tileAssets : safeDraft.tileAssets;
|
|
const profileId = isWorkProfile
|
|
? profile.summary.profileId
|
|
: safeDraft.profileId;
|
|
const canShowLeaderboard =
|
|
isWorkProfile && profile.summary.publicationStatus === 'published';
|
|
const titleSource = isWorkProfile
|
|
? profile.summary.workTitle
|
|
: profile.workTitle;
|
|
const summarySource = isWorkProfile
|
|
? profile.summary.workDescription
|
|
: profile.workDescription;
|
|
const title = titleSource?.trim() || safeDraft.workTitle.trim() || '跳一跳';
|
|
const summary = summarySource?.trim() || safeDraft.workDescription.trim();
|
|
const hasAssets = Boolean(
|
|
profile.tileAtlasImageSrc?.trim() ||
|
|
profile.pathPreviewImageSrc?.trim() ||
|
|
tileAtlasAsset?.imageSrc?.trim() ||
|
|
tileAssets.length > 0 ||
|
|
path?.platforms.length,
|
|
);
|
|
|
|
const handlePublish = async () => {
|
|
setIsPublishing(true);
|
|
try {
|
|
await Promise.resolve(onPublish());
|
|
} finally {
|
|
setIsPublishing(false);
|
|
}
|
|
};
|
|
|
|
return (
|
|
<div className="platform-remap-surface mx-auto flex h-full min-h-0 w-full max-w-5xl flex-col px-3 pb-3 pt-3 sm:px-4 sm:pt-4">
|
|
<div className="mb-3 flex items-center justify-between gap-3">
|
|
<button
|
|
type="button"
|
|
onClick={onBack}
|
|
className="platform-button platform-button--ghost min-h-0 px-3 py-2 text-sm"
|
|
>
|
|
<ArrowLeft className="h-4 w-4" />
|
|
返回
|
|
</button>
|
|
<div className="flex gap-2">
|
|
<button
|
|
type="button"
|
|
onClick={onRegenerateTiles}
|
|
disabled={isBusy}
|
|
className="platform-button platform-button--ghost min-h-0 px-3 py-2 text-sm"
|
|
>
|
|
<Shuffle className="h-4 w-4" />
|
|
地块
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="grid min-h-0 flex-1 gap-3 lg:grid-cols-[minmax(0,1.05fr)_minmax(0,0.95fr)]">
|
|
<section className="platform-subpanel rounded-[1.25rem] p-4">
|
|
<div className="text-2xl font-black text-[var(--platform-text-strong)]">
|
|
{title}
|
|
</div>
|
|
{summary ? (
|
|
<div className="mt-2 text-sm leading-6 text-[var(--platform-text-base)]">
|
|
{summary}
|
|
</div>
|
|
) : null}
|
|
<div className="mt-4 grid gap-3 sm:grid-cols-3">
|
|
<div className="overflow-hidden rounded-[1rem] border border-[var(--platform-subpanel-border)] bg-white/80">
|
|
<JumpHopDefaultCharacterPreview />
|
|
</div>
|
|
<div className="overflow-hidden rounded-[1rem] border border-[var(--platform-subpanel-border)] bg-white/80">
|
|
<JumpHopTilePoolPreview
|
|
tileAssets={tileAssets}
|
|
tileAtlasAsset={tileAtlasAsset}
|
|
tileAtlasFallbackSrc={
|
|
('tileAtlasImageSrc' in profile
|
|
? profile.tileAtlasImageSrc
|
|
: null) ??
|
|
null
|
|
}
|
|
/>
|
|
</div>
|
|
<div className="overflow-hidden rounded-[1rem] border border-[var(--platform-subpanel-border)] bg-white/80">
|
|
<JumpHopFirstPlatformsPreview
|
|
path={path}
|
|
tileAssets={tileAssets}
|
|
/>
|
|
</div>
|
|
</div>
|
|
{!hasAssets ? (
|
|
<div className="platform-banner platform-banner--neutral mt-3 rounded-2xl text-sm leading-6">
|
|
生成资源尚未准备完成。
|
|
</div>
|
|
) : null}
|
|
</section>
|
|
|
|
<section className="platform-subpanel flex flex-col rounded-[1.25rem] p-4">
|
|
<div className="text-xs font-bold tracking-[0.18em] text-[var(--platform-text-soft)]">
|
|
结果操作
|
|
</div>
|
|
{canShowLeaderboard ? (
|
|
<JumpHopResultLeaderboard profileId={profileId} />
|
|
) : null}
|
|
{error ? (
|
|
<div className="platform-banner platform-banner--danger mt-3 rounded-2xl text-sm leading-6">
|
|
{error}
|
|
</div>
|
|
) : null}
|
|
<div className="mt-auto grid gap-2 pt-3">
|
|
<button
|
|
type="button"
|
|
onClick={onEdit}
|
|
disabled={isBusy}
|
|
className="platform-button platform-button--ghost min-h-11 justify-center gap-2 px-4 py-3 text-sm"
|
|
>
|
|
<ArrowLeft className="h-4 w-4" />
|
|
返回编辑
|
|
</button>
|
|
<button
|
|
type="button"
|
|
onClick={onStartTestRun}
|
|
disabled={isBusy}
|
|
className="platform-button platform-button--secondary min-h-11 justify-center gap-2 px-4 py-3 text-sm"
|
|
>
|
|
<Play className="h-4 w-4" />
|
|
试玩
|
|
</button>
|
|
<button
|
|
type="button"
|
|
onClick={() => {
|
|
void handlePublish();
|
|
}}
|
|
disabled={isBusy || isPublishing}
|
|
className="platform-button platform-button--primary min-h-11 justify-center gap-2 px-4 py-3 text-sm"
|
|
>
|
|
{isPublishing ? (
|
|
<Loader2 className="h-4 w-4 animate-spin" />
|
|
) : (
|
|
<Send className="h-4 w-4" />
|
|
)}
|
|
发布
|
|
</button>
|
|
</div>
|
|
</section>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
export default JumpHopResultView;
|