Files
Genarrative/src/components/jump-hop-runtime/JumpHopRuntimeShell.tsx
2026-05-21 13:54:35 +08:00

734 lines
22 KiB
TypeScript

import { ArrowLeft, Hand, Loader2, RotateCcw } from 'lucide-react';
import {
type CSSProperties,
type PointerEvent,
useEffect,
useMemo,
useRef,
useState,
} from 'react';
import type {
JumpHopPlatform,
JumpHopRuntimeRunSnapshotResponse,
JumpHopTileAsset,
JumpHopWorkProfileResponse,
} from '../../../packages/shared/src/contracts/jumpHop';
type JumpHopRuntimeShellProps = {
profile?: JumpHopWorkProfileResponse | null;
run?: JumpHopRuntimeRunSnapshotResponse | null;
snapshot?: JumpHopRuntimeRunSnapshotResponse | null;
isBusy?: boolean;
error?: string | null;
onJump: (payload: { chargeMs: number }) => Promise<unknown>;
onRestart: () => void;
onExit?: () => void;
onBack?: () => void;
};
type VisiblePlatform = {
platform: JumpHopPlatform;
index: number;
screenX: number;
screenY: number;
scale: number;
asset: JumpHopTileAsset | null;
};
const MAX_CHARGE_RATIO = 1;
const DEFAULT_MAX_CHARGE_MS = 1800;
const VISIBLE_FORWARD_COUNT = 6;
const tileToneByType: Record<string, string> = {
accent: '#e0f2fe',
bonus: '#fef3c7',
finish: '#dcfce7',
normal: '#f8fafc',
start: '#e0f2fe',
target: '#fee2e2',
};
function clamp(value: number, min: number, max: number) {
return Math.min(max, Math.max(min, value));
}
function getRun(
run: JumpHopRuntimeRunSnapshotResponse | null | undefined,
snapshot: JumpHopRuntimeRunSnapshotResponse | null | undefined,
) {
return run ?? snapshot ?? null;
}
function buildTileAssetMap(tileAssets: JumpHopTileAsset[] | undefined) {
const map = new Map<string, JumpHopTileAsset>();
for (const asset of tileAssets ?? []) {
if (!map.has(asset.tileType)) {
map.set(asset.tileType, asset);
}
}
return map;
}
function getStatusLabel(
status: JumpHopRuntimeRunSnapshotResponse['status'] | undefined,
) {
if (status === 'cleared') {
return '通关';
}
if (status === 'failed') {
return '失败';
}
return '进行中';
}
function getJumpFeedback(run: JumpHopRuntimeRunSnapshotResponse | null) {
const result = run?.lastJump?.result;
if (result === 'perfect') {
return 'Perfect';
}
if (result === 'finish') {
return 'Finish';
}
if (result === 'hit') {
return 'Hit';
}
if (result === 'miss') {
return 'Miss';
}
return null;
}
function projectPlatformPath(
platforms: JumpHopPlatform[],
currentIndex: number,
tileAssetMap: Map<string, JumpHopTileAsset>,
) {
const current = platforms[currentIndex] ?? platforms[0];
if (!current) {
return [];
}
const start = Math.max(0, currentIndex - 1);
const end = Math.min(platforms.length, currentIndex + VISIBLE_FORWARD_COUNT);
const visible = platforms.slice(start, end);
const worldScale = 0.86;
return visible.map((platform, offset): VisiblePlatform => {
const index = start + offset;
const dx = platform.x - current.x;
const dy = platform.y - current.y;
const isoX = (dx - dy) * worldScale;
const isoY = (dx + dy) * 0.46 * worldScale;
const depth = index - currentIndex;
return {
platform,
index,
screenX: 50 + isoX,
screenY: 58 + isoY - depth * 0.8,
scale: clamp(1 - Math.max(0, depth) * 0.035, 0.78, 1.08),
asset:
tileAssetMap.get(platform.tileType) ??
tileAssetMap.get('normal') ??
tileAssetMap.get('start') ??
null,
};
});
}
function getCharacterPosition(
run: JumpHopRuntimeRunSnapshotResponse | null,
platforms: VisiblePlatform[],
) {
if (!run) {
return null;
}
const landedPlatform = platforms.find(
(item) => item.index === run.currentPlatformIndex,
);
if (landedPlatform) {
return {
x: landedPlatform.screenX,
y: landedPlatform.screenY - 8,
isMiss: false,
};
}
const lastJump = run.lastJump;
if (lastJump && run.status === 'failed') {
const targetPlatform = platforms.find(
(item) => item.index === lastJump.targetPlatformIndex,
);
if (targetPlatform) {
return {
x: targetPlatform.screenX + 8,
y: targetPlatform.screenY - 2,
isMiss: true,
};
}
}
return null;
}
function IsometricFallbackTile({ platform }: { platform: JumpHopPlatform }) {
const tone = tileToneByType[platform.tileType] ?? tileToneByType.normal;
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>
);
}
export function JumpHopRuntimeShell({
profile = null,
run,
snapshot,
isBusy = false,
error = null,
onExit,
onBack,
onRestart,
onJump,
}: JumpHopRuntimeShellProps) {
const activeRun = getRun(run, snapshot);
const [isCharging, setIsCharging] = useState(false);
const [chargeMs, setChargeMs] = useState(0);
const chargeStartRef = useRef<number | null>(null);
const maxChargeMs =
activeRun?.path.scoring.maxChargeMs &&
activeRun.path.scoring.maxChargeMs > 0
? activeRun.path.scoring.maxChargeMs
: DEFAULT_MAX_CHARGE_MS;
const chargeRatio = clamp(chargeMs / maxChargeMs, 0, MAX_CHARGE_RATIO);
const canJump = Boolean(
activeRun && activeRun.status === 'playing' && !isBusy,
);
const exitHandler = onExit ?? onBack;
const tileAssetMap = useMemo(
() => buildTileAssetMap(profile?.tileAssets),
[profile?.tileAssets],
);
const visiblePlatforms = useMemo(
() =>
projectPlatformPath(
activeRun?.path.platforms ?? [],
activeRun?.currentPlatformIndex ?? 0,
tileAssetMap,
),
[activeRun?.currentPlatformIndex, activeRun?.path.platforms, tileAssetMap],
);
const characterPosition = getCharacterPosition(activeRun, visiblePlatforms);
const jumpFeedback = getJumpFeedback(activeRun);
const isSettled =
activeRun?.status === 'failed' || activeRun?.status === 'cleared';
useEffect(() => {
if (!isCharging) {
return undefined;
}
const timer = window.setInterval(() => {
if (chargeStartRef.current == null) {
return;
}
setChargeMs(clamp(Date.now() - chargeStartRef.current, 0, maxChargeMs));
}, 16);
return () => window.clearInterval(timer);
}, [isCharging, maxChargeMs]);
useEffect(() => {
setIsCharging(false);
chargeStartRef.current = null;
setChargeMs(0);
}, [activeRun?.runId, activeRun?.currentPlatformIndex, activeRun?.status]);
const beginCharge = (event: PointerEvent<HTMLElement>) => {
if (!canJump) {
return;
}
event.currentTarget.setPointerCapture?.(event.pointerId);
chargeStartRef.current = Date.now();
setIsCharging(true);
setChargeMs(0);
};
const finishCharge = async () => {
if (!isCharging) {
return;
}
const nextChargeMs = clamp(
chargeStartRef.current ? Date.now() - chargeStartRef.current : chargeMs,
0,
maxChargeMs,
);
chargeStartRef.current = null;
setIsCharging(false);
setChargeMs(nextChargeMs);
await onJump({ chargeMs: nextChargeMs });
};
const cancelCharge = () => {
chargeStartRef.current = null;
setIsCharging(false);
setChargeMs(0);
};
return (
<div className="platform-remap-surface jump-hop-runtime relative flex h-full min-h-0 w-full flex-col overflow-hidden bg-[#fffdf9] text-slate-950">
<div className="jump-hop-runtime__sky" />
<div className="pointer-events-none absolute inset-0 bg-[radial-gradient(circle_at_50%_18%,rgba(255,255,255,0.82),transparent_30%),linear-gradient(180deg,rgba(255,255,255,0.18),rgba(234,204,179,0.24))]" />
<header className="relative z-20 flex items-center justify-between gap-2 px-3 pb-2 pt-[max(0.75rem,env(safe-area-inset-top))] sm:px-4">
<button
type="button"
onClick={exitHandler}
className="platform-button platform-button--ghost min-h-0 rounded-full bg-white/80 px-3 py-2 text-sm shadow-sm backdrop-blur"
>
<ArrowLeft className="h-4 w-4" />
</button>
<div className="flex items-center gap-2 rounded-full border border-white/70 bg-white/82 px-3 py-2 text-sm font-black shadow-sm backdrop-blur">
<span>{activeRun?.score ?? 0}</span>
<span className="h-1 w-1 rounded-full bg-slate-300" />
<span>{activeRun?.combo ?? 0}x</span>
</div>
<button
type="button"
onClick={onRestart}
disabled={isBusy}
className="platform-button platform-button--ghost min-h-0 rounded-full bg-white/80 px-3 py-2 text-sm shadow-sm backdrop-blur"
>
<RotateCcw className="h-4 w-4" />
</button>
</header>
<main className="relative z-10 mx-auto flex w-full max-w-[30rem] flex-1 flex-col px-3 pb-[max(0.75rem,env(safe-area-inset-bottom))] sm:px-4">
<section
className="jump-hop-runtime__stage relative min-h-0 flex-1 touch-none select-none overflow-hidden rounded-[1.5rem] border border-white/70 bg-white/40 shadow-[0_24px_70px_rgba(44,125,182,0.2)]"
onPointerDown={beginCharge}
onPointerUp={() => void finishCharge()}
onPointerCancel={cancelCharge}
onPointerLeave={() => {
if (isCharging) {
void finishCharge();
}
}}
>
<div className="jump-hop-runtime__horizon" />
<div className="jump-hop-runtime__path-shadow" />
{visiblePlatforms.map((item) => {
const width =
clamp(item.platform.width * 0.92, 58, 112) * item.scale;
const height =
clamp(item.platform.height * 0.72, 46, 86) * item.scale;
const style = {
left: `${item.screenX}%`,
top: `${item.screenY}%`,
width,
height,
zIndex: 20 + item.index,
} as CSSProperties;
const isCurrent = item.index === activeRun?.currentPlatformIndex;
return (
<div
key={item.platform.platformId}
className="jump-hop-runtime__platform"
style={style}
data-current={isCurrent ? 'true' : 'false'}
>
<div className="jump-hop-runtime__platform-shadow" />
{item.asset?.imageSrc ? (
<img
src={item.asset.imageSrc}
alt=""
draggable={false}
className="jump-hop-runtime__tile-image"
/>
) : (
<IsometricFallbackTile platform={item.platform} />
)}
</div>
);
})}
{characterPosition ? (
<div
className="jump-hop-runtime__character"
data-charging={isCharging ? 'true' : 'false'}
data-miss={characterPosition.isMiss ? 'true' : 'false'}
style={
{
left: `${characterPosition.x}%`,
top: `${characterPosition.y}%`,
'--jump-hop-charge': chargeRatio,
} as CSSProperties
}
>
<div className="jump-hop-runtime__character-shadow" />
{profile?.characterAsset?.imageSrc ? (
<img
src={profile.characterAsset.imageSrc}
alt=""
draggable={false}
className="jump-hop-runtime__character-image"
/>
) : (
<div className="jump-hop-runtime__character-fallback" />
)}
</div>
) : null}
{jumpFeedback ? (
<div
key={`${activeRun?.currentPlatformIndex}-${activeRun?.lastJump?.result}`}
className="jump-hop-runtime__feedback"
>
{jumpFeedback}
</div>
) : null}
{isCharging ? (
<div className="jump-hop-runtime__charge-orbit" aria-hidden="true">
<div
className="jump-hop-runtime__charge-fill"
style={{ transform: `scaleX(${chargeRatio})` }}
/>
</div>
) : null}
{!activeRun ? (
<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-50 grid place-items-center bg-slate-950/28 px-6 backdrop-blur-[2px]">
<div className="w-full max-w-[18rem] 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">
{getStatusLabel(activeRun?.status)}
</div>
<div className="mt-2 flex justify-center gap-4 text-sm font-bold text-slate-600">
<span>{activeRun?.score ?? 0} </span>
<span>{activeRun?.combo ?? 0}x</span>
</div>
<div className="mt-4 grid grid-cols-2 gap-2">
<button
type="button"
onClick={onRestart}
disabled={isBusy}
className="platform-button platform-button--primary min-h-11 px-3 py-2 text-sm"
>
</button>
<button
type="button"
onClick={exitHandler}
className="platform-button platform-button--ghost min-h-11 bg-white px-3 py-2 text-sm"
>
</button>
</div>
</div>
</div>
) : null}
</section>
<footer className="relative z-20 mt-3 flex items-center justify-between gap-3">
<div className="min-w-0 text-sm font-bold text-slate-700">
{error ? (
<span className="text-[var(--platform-button-danger-text)]">
{error}
</span>
) : (
<span>{getStatusLabel(activeRun?.status)}</span>
)}
</div>
<button
type="button"
disabled={!canJump}
onPointerDown={beginCharge}
onPointerUp={() => void finishCharge()}
onPointerCancel={cancelCharge}
className="platform-button platform-button--primary min-h-12 shrink-0 gap-2 rounded-full px-5 py-3 text-sm shadow-[0_12px_28px_rgba(182,98,63,0.22)]"
>
{isBusy ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<Hand className="h-4 w-4" />
)}
</button>
</footer>
</main>
<style>{`
.jump-hop-runtime__sky {
position: absolute;
inset: 0;
background:
radial-gradient(circle at 18% 18%, rgba(253, 230, 138, 0.36), transparent 24%),
radial-gradient(circle at 82% 22%, rgba(226, 171, 134, 0.34), transparent 28%),
linear-gradient(180deg, #fffdf9 0%, #f8efe7 52%, #f4e5d7 100%);
}
.jump-hop-runtime__stage {
min-height: 31rem;
cursor: pointer;
}
.jump-hop-runtime__horizon {
position: absolute;
inset: 10% 5% 8%;
border-radius: 999px;
background:
radial-gradient(ellipse at center, rgba(255, 255, 255, 0.86) 0%, rgba(255, 255, 255, 0.32) 38%, transparent 70%);
transform: rotate(-8deg);
}
.jump-hop-runtime__path-shadow {
position: absolute;
left: 21%;
right: 16%;
top: 55%;
height: 18%;
border-radius: 999px;
background: radial-gradient(ellipse at center, rgba(45, 118, 170, 0.16), transparent 68%);
transform: rotate(22deg);
}
.jump-hop-runtime__platform {
position: absolute;
transform: translate(-50%, -50%);
display: grid;
place-items: center;
transition:
left 220ms ease,
top 220ms ease,
transform 220ms ease;
}
.jump-hop-runtime__platform[data-current='true'] {
transform: translate(-50%, -50%) scale(1.07);
}
.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__tile-image {
position: relative;
width: 124%;
height: 124%;
object-fit: contain;
image-rendering: auto;
filter: drop-shadow(0 16px 16px rgba(24, 75, 112, 0.18));
}
.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%, -86%)
scaleY(calc(1 - (var(--jump-hop-charge) * 0.16)))
scaleX(calc(1 + (var(--jump-hop-charge) * 0.08)));
transform-origin: 50% 100%;
transition:
left 240ms ease,
top 240ms ease,
transform 120ms ease,
opacity 160ms ease;
}
.jump-hop-runtime__character[data-miss='true'] {
opacity: 0.78;
transform: translate(-50%, -60%) 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(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__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: 23%;
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-orbit {
position: absolute;
left: 1.1rem;
right: 1.1rem;
bottom: 1rem;
z-index: 85;
height: 0.62rem;
overflow: hidden;
border-radius: 999px;
background: rgba(15, 23, 42, 0.16);
box-shadow: inset 0 0 0 1px rgba(255,255,255,0.65);
}
.jump-hop-runtime__charge-fill {
width: 100%;
height: 100%;
transform-origin: left center;
border-radius: inherit;
background: linear-gradient(90deg, #6e8d42, #f0cba9, #c7653d);
}
@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);
}
}
@media (max-width: 430px) {
.jump-hop-runtime__stage {
min-height: min(68vh, 36rem);
border-radius: 1.25rem;
}
.jump-hop-runtime__character {
width: 4.15rem;
height: 4.95rem;
}
}
@media (prefers-reduced-motion: reduce) {
.jump-hop-runtime__feedback {
animation: none;
}
.jump-hop-runtime__platform,
.jump-hop-runtime__character {
transition: none;
}
}
`}</style>
</div>
);
}
export default JumpHopRuntimeShell;