Files
Genarrative/src/components/jump-hop-result/JumpHopResultView.tsx

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;