Files
Genarrative/src/components/big-fish-runtime/BigFishRuntimeShell.tsx
T
kdletters 1ad25e30f8 收口前端平台组件库能力
新增 PlatformUiKit 通用弹窗、按钮、状态、空态、媒体、表单和标签等公共组件
迁移结果页、创作工作台、认证入口、RPG 暗色面板和运行态弹窗的重复 UI chrome
补充组件测试、页面回归测试、技术文档和 Hermes 共享决策记录
2026-06-10 10:24:18 +08:00

521 lines
17 KiB
TypeScript

import { ArrowLeft, CircleHelp, Loader2, RotateCcw, Share2 } from 'lucide-react';
import {
type PointerEvent,
useCallback,
useEffect,
useRef,
useState,
} from 'react';
import type {
BigFishAssetSlotResponse,
BigFishRuntimeEntityResponse,
BigFishRuntimeSnapshotResponse,
SubmitBigFishInputRequest,
} from '../../../packages/shared/src/contracts/bigFish';
import { buildPublicWorkStagePath } from '../../routing/appPageRoutes';
import { CopyFeedbackButton } from '../common/CopyFeedbackButton';
import { UnifiedModal } from '../common/UnifiedModal';
import { useCopyFeedback } from '../common/useCopyFeedback';
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;
embedded?: boolean;
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,
embedded = false,
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 { copyState: shareState, copyText: copyShareText } = useCopyFeedback();
const [stick, setStick] = useState({ x: 0, y: 0 });
const stickRef = useRef(stick);
useEffect(() => {
stickRef.current = stick;
}, [stick]);
const submitDirection = useCallback((direction: SubmitBigFishInputRequest) => {
setStick(direction);
onSubmitInput(direction);
}, [onSubmitInput]);
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, submitDirection, touchOrigin]);
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 copyShareText(shareText);
};
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={`${embedded ? 'relative h-full min-h-0 w-full' : '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={`${embedded ? 'relative h-full min-h-0 w-full' : '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() ? (
<CopyFeedbackButton
state={shareState}
onClick={sharePublicWork}
idleLabel="分享作品"
copiedLabel="分享内容已复制"
failedLabel="分享内容复制失败"
idleIcon={<Share2 className="h-4 w-4" />}
copiedIcon={<Share2 className="h-4 w-4" />}
showLabel={false}
className="pointer-events-auto inline-flex h-10 w-10 items-center justify-center rounded-full bg-black/28 text-white backdrop-blur"
/>
) : 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;