Move big fish runtime to frontend
Some checks failed
CI / verify (push) Has been cancelled

This commit is contained in:
2026-04-27 21:12:43 +08:00
parent dc619817a1
commit 2792df03a6
42 changed files with 1058 additions and 1895 deletions

View File

@@ -1,7 +1,7 @@
// @vitest-environment jsdom
import { fireEvent, render, screen } from '@testing-library/react';
import { describe, expect, test, vi } from 'vitest';
import { act, fireEvent, render, screen } from '@testing-library/react';
import { afterEach, describe, expect, test, vi } from 'vitest';
import type { BigFishRuntimeSnapshotResponse } from '../../../packages/shared/src/contracts/bigFish';
import { BigFishRuntimeShell } from './BigFishRuntimeShell';
@@ -38,7 +38,21 @@ function createRun(
};
}
function dispatchPointerEvent(
target: HTMLElement,
type: string,
options: { pointerId: number; clientX: number; clientY: number },
) {
const event = new Event(type, { bubbles: true, cancelable: true });
Object.assign(event, options);
target.dispatchEvent(event);
}
describe('BigFishRuntimeShell', () => {
afterEach(() => {
vi.useRealTimers();
});
test('renders restart and exit actions after a failed run', () => {
const onBack = vi.fn();
const onRestart = vi.fn();
@@ -96,4 +110,51 @@ describe('BigFishRuntimeShell', () => {
expect(screen.queryByRole('dialog', { name: '玩法规则' })).toBeNull();
});
test('keeps moving in the last sampled direction after drag ends', () => {
vi.useFakeTimers();
const onSubmitInput = vi.fn();
const { container } = render(
<BigFishRuntimeShell
run={createRun('running')}
onBack={() => {}}
onSubmitInput={onSubmitInput}
/>,
);
const stage = container.querySelector('.touch-none');
if (!(stage instanceof HTMLElement)) {
throw new Error('Missing big fish stage');
}
act(() => {
dispatchPointerEvent(stage, 'pointerdown', {
pointerId: 1,
clientX: 100,
clientY: 100,
});
});
act(() => {
dispatchPointerEvent(stage, 'pointermove', {
pointerId: 1,
clientX: 140,
clientY: 100,
});
});
act(() => {
vi.advanceTimersByTime(100);
});
act(() => {
dispatchPointerEvent(stage, 'pointerup', {
pointerId: 1,
clientX: 140,
clientY: 100,
});
});
act(() => {
vi.advanceTimersByTime(220);
});
expect(onSubmitInput).toHaveBeenLastCalledWith({ x: 1, y: 0 });
});
});

View File

@@ -16,6 +16,8 @@ type TouchOrigin = {
y: number;
};
type TouchSample = TouchOrigin;
type BigFishRuntimeShellProps = {
run: BigFishRuntimeSnapshotResponse | null;
assetSlots?: BigFishAssetSlotResponse[];
@@ -42,14 +44,13 @@ function normalizeVector(x: number, y: number) {
};
}
function resolveDirectionFromOrigin(
origin: TouchOrigin,
clientX: number,
clientY: number,
) {
const deadZone = 12;
const deltaX = clientX - origin.x;
const deltaY = clientY - origin.y;
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 };
}
@@ -147,7 +148,7 @@ function BigFishRuleModal({
>
<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>
@@ -178,15 +179,15 @@ function BigFishEntityDot({
return (
<div
className={`absolute -translate-x-1/2 -translate-y-1/2 overflow-hidden rounded-full border shadow-lg transition-all ${
className={`absolute -translate-x-1/2 -translate-y-1/2 transition-all ${
entityImageSrc
? owned
? isLeader
? 'border-cyan-50 shadow-cyan-950/40'
: 'border-cyan-100/80 shadow-cyan-950/28'
? '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
? 'border-rose-100/80 shadow-rose-950/28'
: 'border-emerald-100/80 shadow-emerald-950/24'
? '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'
@@ -202,11 +203,10 @@ function BigFishEntityDot({
<ResolvedAssetImage
src={entityImageSrc}
alt={`Lv.${entity.level} 实体`}
className={`h-full w-full object-cover ${
className={`h-full w-full object-contain ${
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)]">
@@ -227,6 +227,8 @@ export function BigFishRuntimeShell({
}: 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 [stick, setStick] = useState({ x: 0, y: 0 });
const stickRef = useRef(stick);
@@ -251,6 +253,31 @@ export function BigFishRuntimeShell({
};
}, [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);
@@ -260,22 +287,35 @@ export function BigFishRuntimeShell({
if (event.target instanceof HTMLElement && event.target.closest('button')) {
return;
}
event.currentTarget.setPointerCapture(event.pointerId);
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,
});
submitDirection({ x: 0, y: 0 });
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;
}
submitDirection(
resolveDirectionFromOrigin(touchOrigin, event.clientX, event.clientY),
);
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>) => {
@@ -283,7 +323,8 @@ export function BigFishRuntimeShell({
return;
}
setTouchOrigin(null);
submitDirection({ x: 0, y: 0 });
currentTouchRef.current = null;
lastTouchSampleRef.current = null;
};
if (!run) {

View File

@@ -46,8 +46,8 @@ import {
streamBigFishCreationMessage,
} from '../../services/big-fish-creation';
import {
startBigFishRuntimeRun,
submitBigFishRuntimeInput,
advanceLocalBigFishRuntimeRun,
startLocalBigFishRuntimeRun,
} from '../../services/big-fish-runtime';
import { listBigFishGallery } from '../../services/big-fish-gallery';
import {
@@ -415,7 +415,6 @@ export function PlatformEntryFlowShellImpl({
const [isBigFishLoadingLibrary, setIsBigFishLoadingLibrary] = useState(false);
const [bigFishGenerationState, setBigFishGenerationState] =
useState<MiniGameDraftGenerationState | null>(null);
const bigFishInputInFlightRef = useRef(false);
const [puzzleOperation, setPuzzleOperation] =
useState<PuzzleAgentOperationRecord | null>(null);
const [puzzleWorks, setPuzzleWorks] = useState<PuzzleWorkSummary[]>([]);
@@ -1105,59 +1104,25 @@ export function PlatformEntryFlowShellImpl({
}
}, [bigFishRun, bigFishSession, selectionStage, setSelectionStage]);
const startBigFishRun = useCallback(async () => {
if (!bigFishSession || isBigFishBusy) {
const startBigFishRun = useCallback(() => {
if (!bigFishSession) {
return;
}
setIsBigFishBusy(true);
setBigFishError(null);
setBigFishRun(startLocalBigFishRuntimeRun({ session: bigFishSession }));
setSelectionStage('big-fish-runtime');
}, [bigFishSession, setSelectionStage]);
try {
const { run } = await startBigFishRuntimeRun(bigFishSession.sessionId);
setBigFishRun(run);
setSelectionStage('big-fish-runtime');
} catch (error) {
setBigFishError(
resolveBigFishErrorMessage(error, '启动大鱼吃小鱼测试玩法失败。'),
);
} finally {
setIsBigFishBusy(false);
}
}, [
bigFishSession,
isBigFishBusy,
resolveBigFishErrorMessage,
setSelectionStage,
]);
const restartBigFishRun = useCallback(async () => {
const sessionId = bigFishSession?.sessionId ?? bigFishRun?.sessionId;
if (!sessionId || isBigFishBusy) {
const restartBigFishRun = useCallback(() => {
if (!bigFishSession && !bigFishRun) {
return;
}
setIsBigFishBusy(true);
setBigFishError(null);
try {
const { run } = await startBigFishRuntimeRun(sessionId);
setBigFishRun(run);
setSelectionStage('big-fish-runtime');
} catch (error) {
setBigFishError(
resolveBigFishErrorMessage(error, '重新开始大鱼吃小鱼玩法失败。'),
);
} finally {
setIsBigFishBusy(false);
}
}, [
bigFishRun?.sessionId,
bigFishSession?.sessionId,
isBigFishBusy,
resolveBigFishErrorMessage,
setSelectionStage,
]);
setBigFishRun(startLocalBigFishRuntimeRun({ session: bigFishSession }));
setSelectionStage('big-fish-runtime');
}, [bigFishRun, bigFishSession, setSelectionStage]);
const startPuzzleRunFromProfile = useCallback(
async (profileId: string) => {
@@ -1236,29 +1201,15 @@ export function PlatformEntryFlowShellImpl({
const submitBigFishInput = useCallback(
(payload: SubmitBigFishInputRequest) => {
if (
!bigFishRun ||
bigFishRun.status !== 'running' ||
bigFishInputInFlightRef.current
) {
if (!bigFishRun || bigFishRun.status !== 'running') {
return;
}
bigFishInputInFlightRef.current = true;
void submitBigFishRuntimeInput(bigFishRun.runId, payload)
.then(({ run }) => {
setBigFishRun(run);
})
.catch((error) => {
setBigFishError(
resolveBigFishErrorMessage(error, '同步大鱼吃小鱼输入失败。'),
);
})
.finally(() => {
bigFishInputInFlightRef.current = false;
});
setBigFishRun((currentRun) =>
currentRun ? advanceLocalBigFishRuntimeRun(currentRun, payload) : currentRun,
);
},
[bigFishRun, resolveBigFishErrorMessage],
[bigFishRun],
);
const swapPuzzlePiecesInRun = useCallback(
@@ -1636,30 +1587,19 @@ export function PlatformEntryFlowShellImpl({
);
const startBigFishRunFromWork = useCallback(
async (item: BigFishWorkSummary) => {
(item: BigFishWorkSummary) => {
const sessionId = item.sourceSessionId?.trim();
if (!sessionId) {
setBigFishError('当前作品缺少会话信息,暂时无法进入玩法。');
return;
}
setIsBigFishBusy(true);
setBigFishError(null);
try {
const { run } = await startBigFishRuntimeRun(sessionId);
bigFishFlow.setSession(null);
setBigFishRun(run);
setSelectionStage('big-fish-runtime');
} catch (error) {
setBigFishError(
resolveBigFishErrorMessage(error, '启动大鱼吃小鱼玩法失败。'),
);
} finally {
setIsBigFishBusy(false);
}
bigFishFlow.setSession(null);
setBigFishRun(startLocalBigFishRuntimeRun({ work: item }));
setSelectionStage('big-fish-runtime');
},
[bigFishFlow, resolveBigFishErrorMessage, setSelectionStage],
[bigFishFlow, setSelectionStage],
);
const handlePublicCodeSearch = useCallback(

View File

@@ -17,7 +17,7 @@ import {
getBigFishCreationSession,
} from '../../services/big-fish-creation';
import { listBigFishGallery } from '../../services/big-fish-gallery';
import { startBigFishRuntimeRun } from '../../services/big-fish-runtime';
import { startLocalBigFishRuntimeRun } from '../../services/big-fish-runtime';
import { listBigFishWorks } from '../../services/big-fish-works';
import {
createPuzzleAgentSession,
@@ -153,8 +153,8 @@ vi.mock('../../services/big-fish-gallery', () => ({
}));
vi.mock('../../services/big-fish-runtime', () => ({
startBigFishRuntimeRun: vi.fn(),
submitBigFishRuntimeInput: vi.fn(),
advanceLocalBigFishRuntimeRun: vi.fn((run) => run),
startLocalBigFishRuntimeRun: vi.fn(),
}));
vi.mock('../../services/puzzle-agent', () => ({
@@ -1033,30 +1033,28 @@ beforeEach(() => {
vi.mocked(listBigFishGallery).mockResolvedValue({
items: [],
});
vi.mocked(startBigFishRuntimeRun).mockResolvedValue({
run: {
runId: 'big-fish-run-1',
sessionId: 'big-fish-session-public-1',
status: 'running',
tick: 0,
playerLevel: 1,
winLevel: 8,
leaderEntityId: 'owned-1',
ownedEntities: [
{
entityId: 'owned-1',
level: 1,
position: { x: 0, y: 0 },
radius: 12,
offscreenSeconds: 0,
},
],
wildEntities: [],
cameraCenter: { x: 0, y: 0 },
lastInput: { x: 0, y: 0 },
eventLog: ['机械鱼群开始巡游。'],
updatedAt: '2026-04-25T12:12:00.000Z',
},
vi.mocked(startLocalBigFishRuntimeRun).mockReturnValue({
runId: 'big-fish-run-1',
sessionId: 'big-fish-session-public-1',
status: 'running',
tick: 0,
playerLevel: 1,
winLevel: 8,
leaderEntityId: 'owned-1',
ownedEntities: [
{
entityId: 'owned-1',
level: 1,
position: { x: 0, y: 0 },
radius: 12,
offscreenSeconds: 0,
},
],
wildEntities: [],
cameraCenter: { x: 0, y: 0 },
lastInput: { x: 0, y: 0 },
eventLog: ['机械鱼群开始巡游。'],
updatedAt: '2026-04-25T12:12:00.000Z',
});
vi.mocked(listPuzzleWorks).mockResolvedValue({
items: [],
@@ -1990,9 +1988,11 @@ test('public code search opens a published big fish work by BF code', async () =
await user.click(screen.getByRole('button', { name: '搜索' }));
await waitFor(() => {
expect(startBigFishRuntimeRun).toHaveBeenCalledWith(
'big-fish-session-public-1',
);
expect(startLocalBigFishRuntimeRun).toHaveBeenCalledWith({
work: expect.objectContaining({
sourceSessionId: 'big-fish-session-public-1',
}),
});
});
expect(await screen.findByText('Lv.1/8 · 进行中')).toBeTruthy();
expect(getBigFishCreationSession).not.toHaveBeenCalledWith(