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

521 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,
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;