import { ArrowLeft, CircleHelp, Loader2, RotateCcw, Share2 } from 'lucide-react'; import { type PointerEvent, useEffect, useRef, useState } from 'react'; import type { BigFishAssetSlotResponse, BigFishRuntimeEntityResponse, BigFishRuntimeSnapshotResponse, SubmitBigFishInputRequest, } from '../../../packages/shared/src/contracts/bigFish'; import { buildPublicWorkStagePath } from '../../routing/appPageRoutes'; import { copyTextToClipboard } from '../../services/clipboard'; import { UnifiedModal } from '../common/UnifiedModal'; import { ResolvedAssetImage } from '../ResolvedAssetImage'; type TouchOrigin = { pointerId: number; x: number; y: number; }; type TouchSample = TouchOrigin; type BigFishRuntimeShellProps = { run: BigFishRuntimeSnapshotResponse | null; assetSlots?: BigFishAssetSlotResponse[]; shareTitle?: string | null; sharePublicWorkCode?: string | null; isBusy?: boolean; error?: string | null; onBack: () => void; onRestart?: () => 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 resolveDirectionFromSample(previous: TouchSample, current: TouchSample) { const deadZone = 4; const deltaX = current.x - previous.x; const deltaY = current.y - previous.y; if (!Number.isFinite(deltaX) || !Number.isFinite(deltaY)) { return { x: 0, y: 0 }; } 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 BigFishRuleModal({ open, onClose, }: { open: boolean; onClose: () => void; }) { return (
拖动屏幕改变方向,角色会按固定速度移动。
低级或同级野生实体会被收编。
更高级野生实体会吃掉碰到的己方实体。
3 个同级己方实体会自动合成更高一级。
拥有最高等级实体后通关,己方实体归零后失败。
); } 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 ? 'drop-shadow-[0_8px_12px_rgba(127,29,29,0.36)]' : 'drop-shadow-[0_8px_12px_rgba(6,78,59,0.3)]' : 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 = [], shareTitle = null, sharePublicWorkCode = null, isBusy = false, error = null, onBack, onRestart, onSubmitInput, }: BigFishRuntimeShellProps) { const stageRef = useRef(null); const [touchOrigin, setTouchOrigin] = useState(null); const currentTouchRef = useRef(null); const lastTouchSampleRef = useRef(null); const [isRuleModalOpen, setIsRuleModalOpen] = useState(false); const [shareState, setShareState] = useState<'idle' | 'copied' | 'failed'>( 'idle', ); const [stick, setStick] = useState({ x: 0, y: 0 }); const stickRef = useRef(stick); useEffect(() => { stickRef.current = stick; }, [stick]); useEffect(() => { if (run?.status !== 'running') { return undefined; } const timer = window.setInterval(() => { const current = stickRef.current; // 即使没有方向输入也持续回传当前状态,让后端持续推进刷怪、清理与胜负裁决。 onSubmitInput(current); }, 220); return () => { window.clearInterval(timer); }; }, [onSubmitInput, run?.status]); useEffect(() => { if (run?.status !== 'running' || !touchOrigin) { return undefined; } const timer = window.setInterval(() => { const current = currentTouchRef.current; const previous = lastTouchSampleRef.current; if (!current || !previous || current.pointerId !== previous.pointerId) { return; } const sampledDirection = resolveDirectionFromSample(previous, current); lastTouchSampleRef.current = { ...current }; if (sampledDirection.x === 0 && sampledDirection.y === 0) { return; } submitDirection(sampledDirection); }, 100); return () => { window.clearInterval(timer); }; }, [run?.status, touchOrigin]); const submitDirection = (direction: SubmitBigFishInputRequest) => { setStick(direction); onSubmitInput(direction); }; const sharePublicWork = () => { const publicWorkCode = sharePublicWorkCode?.trim(); if (!publicWorkCode) { return; } const sharePath = buildPublicWorkStagePath( 'big-fish-runtime', publicWorkCode, ); const shareUrl = typeof window === 'undefined' ? sharePath : new URL(sharePath, window.location.origin).href; const title = shareTitle?.trim() || '大鱼吃小鱼'; const shareText = `邀请你来玩《${title}》\n作品号:${publicWorkCode}\n${shareUrl}`; void copyTextToClipboard(shareText).then((copied) => { setShareState(copied ? 'copied' : 'failed'); window.setTimeout(() => setShareState('idle'), 1400); }); }; const beginTouchControl = (event: PointerEvent) => { if (event.target instanceof HTMLElement && event.target.closest('button')) { return; } if (!Number.isFinite(event.clientX) || !Number.isFinite(event.clientY)) { return; } event.currentTarget.setPointerCapture?.(event.pointerId); setTouchOrigin({ pointerId: event.pointerId, x: event.clientX, y: event.clientY, }); currentTouchRef.current = { pointerId: event.pointerId, x: event.clientX, y: event.clientY, }; lastTouchSampleRef.current = { ...currentTouchRef.current }; }; const updateTouchControl = (event: PointerEvent) => { if (!touchOrigin || touchOrigin.pointerId !== event.pointerId) { return; } if (!Number.isFinite(event.clientX) || !Number.isFinite(event.clientY)) { return; } currentTouchRef.current = { pointerId: event.pointerId, x: event.clientX, y: event.clientY, }; }; const endTouchControl = (event: PointerEvent) => { if (!touchOrigin || touchOrigin.pointerId !== event.pointerId) { return; } setTouchOrigin(null); currentTouchRef.current = null; lastTouchSampleRef.current = null; }; 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}
{sharePublicWorkCode?.trim() ? ( ) : null}
Lv.{run.playerLevel}/{run.winLevel} · {statusLabel}
{run.wildEntities.map((entity) => ( ))} {run.ownedEntities.map((entity) => ( ))}
{settlementCopy ? (
{settlementCopy.title}
{settlementCopy.message}
{run.status === 'failed' && onRestart ? ( ) : null}
) : null}
{isBusy ?
同步中...
: null} {error ?
{error}
: null} {run.eventLog.slice(-3).map((event) => (
{event}
))}
setIsRuleModalOpen(false)} />
); } export default BigFishRuntimeShell;