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 (