import { ArrowLeft, Loader2 } from 'lucide-react'; import { useEffect, useRef, useState, type PointerEvent } from 'react'; import type { BigFishAssetSlotResponse, BigFishRuntimeEntityResponse, BigFishRuntimeSnapshotResponse, SubmitBigFishInputRequest, } from '../../../packages/shared/src/contracts/bigFish'; import { ResolvedAssetImage } from '../ResolvedAssetImage'; type TouchOrigin = { pointerId: number; x: number; y: number; }; type BigFishRuntimeShellProps = { run: BigFishRuntimeSnapshotResponse | null; assetSlots?: BigFishAssetSlotResponse[]; isBusy?: boolean; error?: string | null; onBack: () => void; onSubmitInput: (payload: SubmitBigFishInputRequest) => void; }; function clamp(value: number, min: number, max: number) { return Math.max(min, Math.min(max, value)); } function normalizeVector(x: number, y: number) { const length = Math.hypot(x, y); if (length <= 0.001) { return { x: 0, y: 0 }; } const capped = Math.min(1, length); return { x: (x / length) * capped, y: (y / length) * capped, }; } function resolveDirectionFromOrigin( origin: TouchOrigin, clientX: number, clientY: number, ) { const deadZone = 12; const deltaX = clientX - origin.x; const deltaY = clientY - origin.y; if (Math.hypot(deltaX, deltaY) < deadZone) { return { x: 0, y: 0 }; } return normalizeVector(deltaX, deltaY); } function projectEntity( entity: BigFishRuntimeEntityResponse, run: BigFishRuntimeSnapshotResponse, ) { const viewportWidth = 360; const viewportHeight = 640; const worldWidth = 420; const worldHeight = 760; const x = viewportWidth / 2 + ((entity.position.x - run.cameraCenter.x) / worldWidth) * viewportWidth; const y = viewportHeight / 2 + ((entity.position.y - run.cameraCenter.y) / worldHeight) * viewportHeight; return { left: `${clamp(x, -40, viewportWidth + 40)}px`, top: `${clamp(y, -40, viewportHeight + 40)}px`, width: `${Math.max(22, entity.radius * 2.2)}px`, height: `${Math.max(22, entity.radius * 2.2)}px`, }; } function findBigFishAssetSlot( slots: BigFishAssetSlotResponse[], assetKind: string, level?: number, motionKey?: string, ) { return slots.find((slot) => { if (slot.assetKind !== assetKind || slot.status !== 'ready') { return false; } if (level !== undefined && slot.level !== level) { return false; } if (motionKey !== undefined && slot.motionKey !== motionKey) { return false; } return true; }); } function resolveRuntimeEntityAsset( entity: BigFishRuntimeEntityResponse, assetSlots: BigFishAssetSlotResponse[], ) { return ( findBigFishAssetSlot(assetSlots, 'level_motion', entity.level, 'move_swim') ?? findBigFishAssetSlot(assetSlots, 'level_motion', entity.level, 'idle_float') ?? findBigFishAssetSlot(assetSlots, 'level_main_image', entity.level) ); } function resolveSettlementCopy(run: BigFishRuntimeSnapshotResponse) { if (run.status === 'won') { return { title: '通关完成', message: `已成长到 Lv.${run.playerLevel},本轮生态征服完成。`, tone: 'from-emerald-300/28 via-cyan-300/18 to-white/10', }; } if (run.status === 'failed') { return { title: '本轮失败', message: '己方鱼群已经耗尽,重新调整路线再来一次。', tone: 'from-rose-300/30 via-orange-300/16 to-white/10', }; } return null; } function BigFishEntityDot({ entity, run, owned, assetSlots, }: { entity: BigFishRuntimeEntityResponse; run: BigFishRuntimeSnapshotResponse; owned: boolean; assetSlots: BigFishAssetSlotResponse[]; }) { const projected = projectEntity(entity, run); const isLeader = run.leaderEntityId === entity.entityId; const assetSlot = resolveRuntimeEntityAsset(entity, assetSlots); const entityImageSrc = assetSlot?.assetUrl?.trim() || null; return (
run.playerLevel ? 'border-rose-100/80 shadow-rose-950/28' : 'border-emerald-100/80 shadow-emerald-950/24' : owned ? isLeader ? 'border-cyan-100 bg-cyan-300 shadow-cyan-950/30' : 'border-cyan-100/70 bg-cyan-500/88 shadow-cyan-950/24' : entity.level > run.playerLevel ? 'border-rose-100/70 bg-rose-500/88 shadow-rose-950/24' : 'border-emerald-100/70 bg-emerald-400/88 shadow-emerald-950/20' }`} style={projected} > {entityImageSrc ? ( <>
) : null} {entity.level}
); } export function BigFishRuntimeShell({ run, assetSlots = [], isBusy = false, error = null, onBack, onSubmitInput, }: BigFishRuntimeShellProps) { const stageRef = useRef(null); const [touchOrigin, setTouchOrigin] = useState(null); const [stick, setStick] = useState({ x: 0, y: 0 }); const stickRef = useRef(stick); useEffect(() => { stickRef.current = stick; }, [stick]); useEffect(() => { const timer = window.setInterval(() => { const current = stickRef.current; // 即使没有方向输入也持续回传当前状态,让后端持续推进刷怪、清理与胜负裁决。 onSubmitInput(current); }, 220); return () => { window.clearInterval(timer); }; }, [onSubmitInput]); const submitDirection = (direction: SubmitBigFishInputRequest) => { setStick(direction); onSubmitInput(direction); }; const beginTouchControl = (event: PointerEvent) => { if (event.target instanceof HTMLElement && event.target.closest('button')) { return; } event.currentTarget.setPointerCapture(event.pointerId); setTouchOrigin({ pointerId: event.pointerId, x: event.clientX, y: event.clientY, }); submitDirection({ x: 0, y: 0 }); }; const updateTouchControl = (event: PointerEvent) => { if (!touchOrigin || touchOrigin.pointerId !== event.pointerId) { return; } submitDirection( resolveDirectionFromOrigin(touchOrigin, event.clientX, event.clientY), ); }; const endTouchControl = (event: PointerEvent) => { if (!touchOrigin || touchOrigin.pointerId !== event.pointerId) { return; } setTouchOrigin(null); submitDirection({ x: 0, y: 0 }); }; if (!run) { return (
正在进入玩法
); } const statusLabel = run.status === 'won' ? '通关' : run.status === 'failed' ? '失败' : '进行中'; const settlementCopy = resolveSettlementCopy(run); const backgroundAsset = findBigFishAssetSlot(assetSlots, 'stage_background')?.assetUrl?.trim() || null; return (
{backgroundAsset ? ( ) : null}
Lv.{run.playerLevel}/{run.winLevel} · {statusLabel}
{run.wildEntities.map((entity) => ( ))} {run.ownedEntities.map((entity) => ( ))}
{settlementCopy ? (
{settlementCopy.title}
{settlementCopy.message}
) : null}
{isBusy ?
同步中...
: null} {error ?
{error}
: null} {run.eventLog.slice(-3).map((event) => (
{event}
))}
); } export default BigFishRuntimeShell;