1
This commit is contained in:
230
src/components/big-fish-runtime/BigFishRuntimeShell.tsx
Normal file
230
src/components/big-fish-runtime/BigFishRuntimeShell.tsx
Normal file
@@ -0,0 +1,230 @@
|
||||
import { ArrowLeft, Loader2 } from 'lucide-react';
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
|
||||
import type {
|
||||
BigFishRuntimeEntityResponse,
|
||||
BigFishRuntimeSnapshotResponse,
|
||||
SubmitBigFishInputRequest,
|
||||
} from '../../../packages/shared/src/contracts/bigFish';
|
||||
|
||||
type BigFishRuntimeShellProps = {
|
||||
run: BigFishRuntimeSnapshotResponse | null;
|
||||
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 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 BigFishEntityDot({
|
||||
entity,
|
||||
run,
|
||||
owned,
|
||||
}: {
|
||||
entity: BigFishRuntimeEntityResponse;
|
||||
run: BigFishRuntimeSnapshotResponse;
|
||||
owned: boolean;
|
||||
}) {
|
||||
const projected = projectEntity(entity, run);
|
||||
const isLeader = run.leaderEntityId === entity.entityId;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`absolute -translate-x-1/2 -translate-y-1/2 rounded-full border shadow-lg transition-all ${
|
||||
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}
|
||||
>
|
||||
<span className="absolute inset-0 flex items-center justify-center text-[0.62rem] font-black text-slate-950">
|
||||
{entity.level}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function BigFishRuntimeShell({
|
||||
run,
|
||||
isBusy = false,
|
||||
error = null,
|
||||
onBack,
|
||||
onSubmitInput,
|
||||
}: BigFishRuntimeShellProps) {
|
||||
const padRef = useRef<HTMLDivElement | 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 updateStickFromPointer = (clientX: number, clientY: number) => {
|
||||
const pad = padRef.current;
|
||||
if (!pad) {
|
||||
return;
|
||||
}
|
||||
const rect = pad.getBoundingClientRect();
|
||||
const centerX = rect.left + rect.width / 2;
|
||||
const centerY = rect.top + rect.height / 2;
|
||||
const vector = normalizeVector(
|
||||
(clientX - centerX) / (rect.width / 2),
|
||||
(clientY - centerY) / (rect.height / 2),
|
||||
);
|
||||
setStick(vector);
|
||||
onSubmitInput(vector);
|
||||
};
|
||||
|
||||
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' ? '失败' : '进行中';
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-[100] flex justify-center bg-slate-950 text-white">
|
||||
<div className="relative h-full w-full max-w-[430px] 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)]">
|
||||
<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}
|
||||
/>
|
||||
))}
|
||||
{run.ownedEntities.map((entity) => (
|
||||
<BigFishEntityDot
|
||||
key={entity.entityId}
|
||||
entity={entity}
|
||||
run={run}
|
||||
owned
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="absolute bottom-6 left-4 z-30">
|
||||
<div
|
||||
ref={padRef}
|
||||
role="presentation"
|
||||
className="relative h-28 w-28 rounded-full border border-white/18 bg-black/24 backdrop-blur"
|
||||
onPointerDown={(event) => {
|
||||
event.currentTarget.setPointerCapture(event.pointerId);
|
||||
updateStickFromPointer(event.clientX, event.clientY);
|
||||
}}
|
||||
onPointerMove={(event) => {
|
||||
if (event.buttons <= 0) {
|
||||
return;
|
||||
}
|
||||
updateStickFromPointer(event.clientX, event.clientY);
|
||||
}}
|
||||
onPointerUp={() => {
|
||||
setStick({ x: 0, y: 0 });
|
||||
onSubmitInput({ x: 0, y: 0 });
|
||||
}}
|
||||
onPointerCancel={() => {
|
||||
setStick({ x: 0, y: 0 });
|
||||
onSubmitInput({ x: 0, y: 0 });
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className="absolute left-1/2 top-1/2 h-11 w-11 -translate-x-1/2 -translate-y-1/2 rounded-full bg-cyan-200 shadow-lg shadow-cyan-950/30"
|
||||
style={{
|
||||
transform: `translate(calc(-50% + ${stick.x * 34}px), calc(-50% + ${stick.y * 34}px))`,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="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;
|
||||
Reference in New Issue
Block a user