Files
Genarrative/src/components/big-fish-runtime/BigFishRuntimeShell.tsx
kdletters 1348b2e940
Some checks failed
CI / verify (push) Has been cancelled
add public work share links
2026-04-27 22:55:36 +08:00

523 lines
17 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 (
<UnifiedModal
open={open}
title="玩法规则"
onClose={onClose}
size="sm"
zIndexClassName="z-[140]"
panelClassName="rounded-[1.25rem]"
bodyClassName="px-4 py-3 sm:px-5 sm:py-4"
>
<div className="space-y-3 text-sm leading-6 text-[var(--platform-text-base)]">
<div className="rounded-2xl bg-cyan-50 px-4 py-3 text-cyan-950">
</div>
<div className="grid gap-2">
<div></div>
<div></div>
<div>3 </div>
<div></div>
</div>
</div>
</UnifiedModal>
);
}
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 transition-all ${
entityImageSrc
? owned
? isLeader
? 'drop-shadow-[0_10px_16px_rgba(8,47,73,0.45)]'
: 'drop-shadow-[0_8px_12px_rgba(8,47,73,0.32)]'
: entity.level > 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 ? (
<>
<ResolvedAssetImage
src={entityImageSrc}
alt={`Lv.${entity.level} 实体`}
className={`h-full w-full object-contain ${
owned && isLeader ? 'scale-110' : ''
}`}
/>
</>
) : 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 = [],
shareTitle = null,
sharePublicWorkCode = null,
isBusy = false,
error = null,
onBack,
onRestart,
onSubmitInput,
}: BigFishRuntimeShellProps) {
const stageRef = useRef<HTMLDivElement | null>(null);
const [touchOrigin, setTouchOrigin] = useState<TouchOrigin | null>(null);
const currentTouchRef = useRef<TouchSample | null>(null);
const lastTouchSampleRef = useRef<TouchSample | null>(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<HTMLDivElement>) => {
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<HTMLDivElement>) => {
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<HTMLDivElement>) => {
if (!touchOrigin || touchOrigin.pointerId !== event.pointerId) {
return;
}
setTouchOrigin(null);
currentTouchRef.current = null;
lastTouchSampleRef.current = null;
};
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="flex items-center gap-2">
{sharePublicWorkCode?.trim() ? (
<button
type="button"
aria-label={
shareState === 'copied'
? '分享内容已复制'
: shareState === 'failed'
? '分享内容复制失败'
: '分享作品'
}
title={
shareState === 'copied'
? '已复制'
: shareState === 'failed'
? '复制失败'
: '分享作品'
}
onClick={sharePublicWork}
className="pointer-events-auto inline-flex h-10 w-10 items-center justify-center rounded-full bg-black/28 text-white backdrop-blur"
>
<Share2 className="h-4 w-4" />
</button>
) : null}
<button
type="button"
aria-label="查看规则"
title="查看规则"
onClick={() => setIsRuleModalOpen(true)}
className="pointer-events-auto inline-flex h-10 w-10 items-center justify-center rounded-full bg-black/28 text-white backdrop-blur"
>
<CircleHelp 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>
<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="absolute inset-0 z-40 flex items-center justify-center px-5">
<div
className={`w-full max-w-[20rem] rounded-[1.5rem] 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 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 className="mt-5 grid grid-cols-2 gap-2">
{run.status === 'failed' && onRestart ? (
<button
type="button"
disabled={isBusy}
onClick={onRestart}
className="inline-flex h-11 items-center justify-center gap-2 rounded-full bg-white px-4 text-sm font-bold text-slate-950 shadow-lg shadow-slate-950/20 disabled:opacity-45"
>
<RotateCcw className="h-4 w-4" />
</button>
) : null}
<button
type="button"
onClick={onBack}
className={`inline-flex h-11 items-center justify-center gap-2 rounded-full border border-white/30 bg-black/24 px-4 text-sm font-bold text-white backdrop-blur ${
run.status === 'failed' && onRestart ? '' : 'col-span-2'
}`}
>
<ArrowLeft className="h-4 w-4" />
退
</button>
</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>
<BigFishRuleModal
open={isRuleModalOpen}
onClose={() => setIsRuleModalOpen(false)}
/>
</div>
</div>
);
}
export default BigFishRuntimeShell;