350 lines
11 KiB
TypeScript
350 lines
11 KiB
TypeScript
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 (
|
|
<div
|
|
className={`absolute -translate-x-1/2 -translate-y-1/2 overflow-hidden rounded-full border shadow-lg transition-all ${
|
|
entityImageSrc
|
|
? owned
|
|
? isLeader
|
|
? 'border-cyan-50 shadow-cyan-950/40'
|
|
: 'border-cyan-100/80 shadow-cyan-950/28'
|
|
: entity.level > 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 ? (
|
|
<>
|
|
<ResolvedAssetImage
|
|
src={entityImageSrc}
|
|
alt={`Lv.${entity.level} 实体`}
|
|
className={`h-full w-full object-cover ${
|
|
owned && isLeader ? 'scale-110' : ''
|
|
}`}
|
|
/>
|
|
<div className="absolute inset-0 bg-[radial-gradient(circle_at_50%_35%,transparent_32%,rgba(2,6,23,0.18)_72%,rgba(2,6,23,0.36)_100%)]" />
|
|
</>
|
|
) : null}
|
|
<span className="absolute inset-0 flex items-center justify-center text-[0.62rem] font-black text-white [text-shadow:0_1px_2px_rgba(2,6,23,0.9)]">
|
|
{entity.level}
|
|
</span>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
export function BigFishRuntimeShell({
|
|
run,
|
|
assetSlots = [],
|
|
isBusy = false,
|
|
error = null,
|
|
onBack,
|
|
onSubmitInput,
|
|
}: BigFishRuntimeShellProps) {
|
|
const stageRef = useRef<HTMLDivElement | null>(null);
|
|
const [touchOrigin, setTouchOrigin] = useState<TouchOrigin | null>(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<HTMLDivElement>) => {
|
|
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<HTMLDivElement>) => {
|
|
if (!touchOrigin || touchOrigin.pointerId !== event.pointerId) {
|
|
return;
|
|
}
|
|
submitDirection(
|
|
resolveDirectionFromOrigin(touchOrigin, event.clientX, event.clientY),
|
|
);
|
|
};
|
|
|
|
const endTouchControl = (event: PointerEvent<HTMLDivElement>) => {
|
|
if (!touchOrigin || touchOrigin.pointerId !== event.pointerId) {
|
|
return;
|
|
}
|
|
setTouchOrigin(null);
|
|
submitDirection({ x: 0, y: 0 });
|
|
};
|
|
|
|
if (!run) {
|
|
return (
|
|
<div className="fixed inset-0 z-[100] flex items-center justify-center bg-slate-950 text-white">
|
|
<div className="flex items-center gap-2 rounded-full bg-white/10 px-5 py-3 text-sm">
|
|
<Loader2 className="h-4 w-4 animate-spin" />
|
|
正在进入玩法
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
const statusLabel =
|
|
run.status === 'won' ? '通关' : run.status === 'failed' ? '失败' : '进行中';
|
|
const settlementCopy = resolveSettlementCopy(run);
|
|
const backgroundAsset =
|
|
findBigFishAssetSlot(assetSlots, 'stage_background')?.assetUrl?.trim() || null;
|
|
|
|
return (
|
|
<div className="fixed inset-0 z-[100] flex justify-center bg-slate-950 text-white">
|
|
<div
|
|
ref={stageRef}
|
|
className="relative h-full w-full max-w-[430px] touch-none overflow-hidden bg-[radial-gradient(circle_at_50%_20%,rgba(34,211,238,0.2),transparent_28%),radial-gradient(circle_at_20%_80%,rgba(16,185,129,0.18),transparent_26%),linear-gradient(180deg,#082f49,#020617)]"
|
|
onPointerDown={beginTouchControl}
|
|
onPointerMove={updateTouchControl}
|
|
onPointerUp={endTouchControl}
|
|
onPointerCancel={endTouchControl}
|
|
>
|
|
{backgroundAsset ? (
|
|
<ResolvedAssetImage
|
|
src={backgroundAsset}
|
|
alt="大鱼吃小鱼场地背景"
|
|
className="absolute inset-0 h-full w-full object-cover"
|
|
/>
|
|
) : null}
|
|
<div className="pointer-events-none absolute inset-0 bg-[linear-gradient(180deg,rgba(8,47,73,0.2),rgba(2,6,23,0.6))]" />
|
|
<div className="pointer-events-none absolute inset-0 bg-[linear-gradient(rgba(255,255,255,0.04)_1px,transparent_1px),linear-gradient(90deg,rgba(255,255,255,0.04)_1px,transparent_1px)] bg-[length:32px_32px] opacity-30" />
|
|
|
|
<div className="absolute left-0 top-0 z-20 flex w-full items-center justify-between px-4 py-4">
|
|
<button
|
|
type="button"
|
|
onClick={onBack}
|
|
className="pointer-events-auto inline-flex h-10 w-10 items-center justify-center rounded-full bg-black/28 backdrop-blur"
|
|
>
|
|
<ArrowLeft className="h-4 w-4" />
|
|
</button>
|
|
<div className="rounded-full bg-black/28 px-4 py-2 text-xs font-bold backdrop-blur">
|
|
Lv.{run.playerLevel}/{run.winLevel} · {statusLabel}
|
|
</div>
|
|
</div>
|
|
|
|
<div className="absolute left-1/2 top-1/2 h-[640px] w-[360px] -translate-x-1/2 -translate-y-1/2">
|
|
{run.wildEntities.map((entity) => (
|
|
<BigFishEntityDot
|
|
key={entity.entityId}
|
|
entity={entity}
|
|
run={run}
|
|
owned={false}
|
|
assetSlots={assetSlots}
|
|
/>
|
|
))}
|
|
{run.ownedEntities.map((entity) => (
|
|
<BigFishEntityDot
|
|
key={entity.entityId}
|
|
entity={entity}
|
|
run={run}
|
|
owned
|
|
assetSlots={assetSlots}
|
|
/>
|
|
))}
|
|
</div>
|
|
|
|
{settlementCopy ? (
|
|
<div className="pointer-events-none absolute inset-0 z-40 flex items-center justify-center px-5">
|
|
<div
|
|
className={`w-full max-w-[20rem] rounded-[2rem] border border-white/24 bg-gradient-to-br ${settlementCopy.tone} p-6 text-center shadow-2xl shadow-slate-950/45 backdrop-blur-xl`}
|
|
>
|
|
<div className="text-3xl font-black tracking-[0.22em] text-white [text-shadow:0_2px_12px_rgba(2,6,23,0.6)]">
|
|
{settlementCopy.title}
|
|
</div>
|
|
<div className="mt-3 text-sm font-semibold leading-6 text-white/82">
|
|
{settlementCopy.message}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
) : null}
|
|
|
|
<div className="pointer-events-none absolute bottom-6 right-4 z-30 max-w-[13rem] space-y-2 text-right text-xs text-white/72">
|
|
{isBusy ? <div>同步中...</div> : null}
|
|
{error ? <div className="text-rose-200">{error}</div> : null}
|
|
{run.eventLog.slice(-3).map((event) => (
|
|
<div key={event} className="rounded-full bg-black/22 px-3 py-1">
|
|
{event}
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
export default BigFishRuntimeShell;
|