This commit is contained in:
@@ -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 });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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(
|
||||
|
||||
395
src/services/big-fish-runtime/bigFishLocalRuntime.ts
Normal file
395
src/services/big-fish-runtime/bigFishLocalRuntime.ts
Normal file
@@ -0,0 +1,395 @@
|
||||
import type {
|
||||
BigFishGameDraftResponse,
|
||||
BigFishRuntimeEntityResponse,
|
||||
BigFishRuntimeSnapshotResponse,
|
||||
BigFishSessionSnapshotResponse,
|
||||
SubmitBigFishInputRequest,
|
||||
} from '../../../packages/shared/src/contracts/bigFish';
|
||||
import type { BigFishWorkSummary } from '../../../packages/shared/src/contracts/bigFishWorkSummary';
|
||||
|
||||
const VIEW_WIDTH = 720;
|
||||
const VIEW_HEIGHT = 1280;
|
||||
const WORLD_HALF_WIDTH = 1400;
|
||||
const WORLD_HALF_HEIGHT = 2400;
|
||||
const DEFAULT_LEVEL_COUNT = 8;
|
||||
const DEFAULT_WILD_COUNT = 28;
|
||||
const LEADER_SPEED = 210;
|
||||
const FOLLOWER_SPEED = 170;
|
||||
const WILD_SPEED = 74;
|
||||
const MERGE_COUNT = 3;
|
||||
|
||||
function clamp(value: number, min: number, max: number) {
|
||||
return Math.max(min, Math.min(max, value));
|
||||
}
|
||||
|
||||
function entityRadius(level: number) {
|
||||
return 18 + level * 4;
|
||||
}
|
||||
|
||||
function normalizeVector(x: number, y: number) {
|
||||
const length = Math.hypot(x, y);
|
||||
if (length <= 0.001) {
|
||||
return { x: 0, y: 0 };
|
||||
}
|
||||
return { x: x / length, y: y / length };
|
||||
}
|
||||
|
||||
function distance(
|
||||
first: BigFishRuntimeEntityResponse,
|
||||
second: BigFishRuntimeEntityResponse,
|
||||
) {
|
||||
return Math.hypot(
|
||||
first.position.x - second.position.x,
|
||||
first.position.y - second.position.y,
|
||||
);
|
||||
}
|
||||
|
||||
function buildEntity(
|
||||
entityId: string,
|
||||
level: number,
|
||||
x: number,
|
||||
y: number,
|
||||
): BigFishRuntimeEntityResponse {
|
||||
return {
|
||||
entityId,
|
||||
level,
|
||||
position: { x, y },
|
||||
radius: entityRadius(level),
|
||||
offscreenSeconds: 0,
|
||||
};
|
||||
}
|
||||
|
||||
function resolveWinLevel(
|
||||
draft?: BigFishGameDraftResponse | null,
|
||||
work?: BigFishWorkSummary | null,
|
||||
) {
|
||||
return draft?.runtimeParams.winLevel ?? work?.levelCount ?? DEFAULT_LEVEL_COUNT;
|
||||
}
|
||||
|
||||
function resolveWildTargetCount(draft?: BigFishGameDraftResponse | null) {
|
||||
return Math.max(DEFAULT_WILD_COUNT, draft?.runtimeParams.spawnTargetCount ?? 0);
|
||||
}
|
||||
|
||||
function spawnLevel(playerLevel: number, winLevel: number, index: number) {
|
||||
if (playerLevel <= 1 && index % 4 < 2) {
|
||||
return 1;
|
||||
}
|
||||
const deltas = [-2, -1, 1, 2];
|
||||
const delta = deltas[index % deltas.length] ?? 1;
|
||||
return clamp(playerLevel + delta, 1, winLevel);
|
||||
}
|
||||
|
||||
function spawnPosition(center: { x: number; y: number }, index: number) {
|
||||
const side = index % 4;
|
||||
const offset = ((index * 97) % 980) - 490;
|
||||
if (side === 0) {
|
||||
return { x: center.x - VIEW_WIDTH * 0.72, y: center.y + offset };
|
||||
}
|
||||
if (side === 1) {
|
||||
return { x: center.x + VIEW_WIDTH * 0.72, y: center.y + offset };
|
||||
}
|
||||
if (side === 2) {
|
||||
return { x: center.x + offset, y: center.y - VIEW_HEIGHT * 0.64 };
|
||||
}
|
||||
return { x: center.x + offset, y: center.y + VIEW_HEIGHT * 0.64 };
|
||||
}
|
||||
|
||||
function buildWildEntity(
|
||||
tick: number,
|
||||
index: number,
|
||||
playerLevel: number,
|
||||
winLevel: number,
|
||||
center: { x: number; y: number },
|
||||
) {
|
||||
const level = spawnLevel(playerLevel, winLevel, index);
|
||||
const position = spawnPosition(center, index);
|
||||
return buildEntity(`wild-${tick}-${index}`, level, position.x, position.y);
|
||||
}
|
||||
|
||||
export function startLocalBigFishRuntimeRun({
|
||||
session,
|
||||
work,
|
||||
}: {
|
||||
session?: BigFishSessionSnapshotResponse | null;
|
||||
work?: BigFishWorkSummary | null;
|
||||
}): BigFishRuntimeSnapshotResponse {
|
||||
const winLevel = resolveWinLevel(session?.draft, work);
|
||||
const wildCount = resolveWildTargetCount(session?.draft);
|
||||
const leader = buildEntity('owned-1', 1, 0, 0);
|
||||
const wildEntities = [
|
||||
buildEntity('wild-open-1', 1, 92, 0),
|
||||
buildEntity('wild-open-2', 1, -118, 46),
|
||||
];
|
||||
while (wildEntities.length < wildCount) {
|
||||
wildEntities.push(
|
||||
buildWildEntity(0, wildEntities.length, 1, winLevel, leader.position),
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
runId: `local-big-fish-run-${Date.now()}`,
|
||||
sessionId: session?.sessionId ?? work?.sourceSessionId ?? 'local-big-fish-session',
|
||||
status: 'running',
|
||||
tick: 0,
|
||||
playerLevel: 1,
|
||||
winLevel,
|
||||
leaderEntityId: leader.entityId,
|
||||
ownedEntities: [leader],
|
||||
wildEntities,
|
||||
cameraCenter: { ...leader.position },
|
||||
lastInput: { x: 0, y: 0 },
|
||||
eventLog: ['开局生成同级可收编目标'],
|
||||
updatedAt: new Date().toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
function moveLeader(
|
||||
leader: BigFishRuntimeEntityResponse,
|
||||
input: SubmitBigFishInputRequest,
|
||||
) {
|
||||
return {
|
||||
...leader,
|
||||
position: {
|
||||
x: clamp(
|
||||
leader.position.x + input.x * LEADER_SPEED * 0.1,
|
||||
-WORLD_HALF_WIDTH,
|
||||
WORLD_HALF_WIDTH,
|
||||
),
|
||||
y: clamp(
|
||||
leader.position.y + input.y * LEADER_SPEED * 0.1,
|
||||
-WORLD_HALF_HEIGHT,
|
||||
WORLD_HALF_HEIGHT,
|
||||
),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function moveFollower(
|
||||
follower: BigFishRuntimeEntityResponse,
|
||||
leader: BigFishRuntimeEntityResponse,
|
||||
index: number,
|
||||
) {
|
||||
const slotY = Math.sin(index * 0.7) * 42;
|
||||
const target = {
|
||||
x: leader.position.x - 52 - index * 10,
|
||||
y: leader.position.y + slotY,
|
||||
};
|
||||
const delta = {
|
||||
x: target.x - follower.position.x,
|
||||
y: target.y - follower.position.y,
|
||||
};
|
||||
const direction = normalizeVector(delta.x, delta.y);
|
||||
const step = Math.min(FOLLOWER_SPEED * 0.1, Math.hypot(delta.x, delta.y));
|
||||
return {
|
||||
...follower,
|
||||
position: {
|
||||
x: follower.position.x + direction.x * step,
|
||||
y: follower.position.y + direction.y * step,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function moveWildEntity(entity: BigFishRuntimeEntityResponse, tick: number) {
|
||||
const phase = tick * 0.23 + entity.level * 0.91 + entity.entityId.length * 0.13;
|
||||
return {
|
||||
...entity,
|
||||
position: {
|
||||
x: clamp(
|
||||
entity.position.x + Math.cos(phase) * (WILD_SPEED + entity.level * 3) * 0.1,
|
||||
-WORLD_HALF_WIDTH,
|
||||
WORLD_HALF_WIDTH,
|
||||
),
|
||||
y: clamp(
|
||||
entity.position.y + Math.sin(phase * 0.72) * (WILD_SPEED + entity.level * 3) * 0.1,
|
||||
-WORLD_HALF_HEIGHT,
|
||||
WORLD_HALF_HEIGHT,
|
||||
),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function mergeOwnedEntities(
|
||||
ownedEntities: BigFishRuntimeEntityResponse[],
|
||||
tick: number,
|
||||
) {
|
||||
let nextOwned = [...ownedEntities];
|
||||
const events: string[] = [];
|
||||
let changed = true;
|
||||
|
||||
while (changed) {
|
||||
changed = false;
|
||||
for (let level = 1; level < 32; level += 1) {
|
||||
const sameLevel = nextOwned
|
||||
.map((entity, index) => ({ entity, index }))
|
||||
.filter(({ entity }) => entity.level === level)
|
||||
.slice(0, MERGE_COUNT);
|
||||
if (sameLevel.length < MERGE_COUNT) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const center = sameLevel.reduce(
|
||||
(acc, { entity }) => ({
|
||||
x: acc.x + entity.position.x / MERGE_COUNT,
|
||||
y: acc.y + entity.position.y / MERGE_COUNT,
|
||||
}),
|
||||
{ x: 0, y: 0 },
|
||||
);
|
||||
const removeSet = new Set(sameLevel.map(({ index }) => index));
|
||||
nextOwned = nextOwned.filter((_, index) => !removeSet.has(index));
|
||||
nextOwned.push(
|
||||
buildEntity(`owned-merge-${level + 1}-${tick}`, level + 1, center.x, center.y),
|
||||
);
|
||||
events.push(`3 个 ${level} 级实体合成 ${level + 1} 级`);
|
||||
changed = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return { ownedEntities: nextOwned, events };
|
||||
}
|
||||
|
||||
function isOffscreen(
|
||||
entity: BigFishRuntimeEntityResponse,
|
||||
cameraCenter: { x: number; y: number },
|
||||
) {
|
||||
return (
|
||||
entity.position.x + entity.radius < cameraCenter.x - VIEW_WIDTH / 2 ||
|
||||
entity.position.x - entity.radius > cameraCenter.x + VIEW_WIDTH / 2 ||
|
||||
entity.position.y + entity.radius < cameraCenter.y - VIEW_HEIGHT / 2 ||
|
||||
entity.position.y - entity.radius > cameraCenter.y + VIEW_HEIGHT / 2
|
||||
);
|
||||
}
|
||||
|
||||
function refreshLeader(ownedEntities: BigFishRuntimeEntityResponse[]) {
|
||||
return [...ownedEntities].sort((left, right) => {
|
||||
if (right.level !== left.level) {
|
||||
return right.level - left.level;
|
||||
}
|
||||
return left.entityId.localeCompare(right.entityId);
|
||||
});
|
||||
}
|
||||
|
||||
export function advanceLocalBigFishRuntimeRun(
|
||||
run: BigFishRuntimeSnapshotResponse,
|
||||
input: SubmitBigFishInputRequest,
|
||||
): BigFishRuntimeSnapshotResponse {
|
||||
if (run.status !== 'running') {
|
||||
return run;
|
||||
}
|
||||
|
||||
const nextTick = run.tick + 1;
|
||||
const normalizedInput = normalizeVector(input.x, input.y);
|
||||
const sortedOwned = refreshLeader(run.ownedEntities);
|
||||
const currentLeader = sortedOwned[0];
|
||||
if (!currentLeader) {
|
||||
return { ...run, status: 'failed', eventLog: ['己方实体归零,本局失败'] };
|
||||
}
|
||||
|
||||
const nextLeader = moveLeader(currentLeader, normalizedInput);
|
||||
let ownedEntities = [
|
||||
nextLeader,
|
||||
...sortedOwned.slice(1).map((entity, index) =>
|
||||
moveFollower(entity, nextLeader, index + 1),
|
||||
),
|
||||
];
|
||||
let wildEntities = run.wildEntities.map((entity) =>
|
||||
moveWildEntity(entity, nextTick),
|
||||
);
|
||||
const events = [...run.eventLog];
|
||||
const removedWild = new Set<string>();
|
||||
const removedOwned = new Set<string>();
|
||||
const newlyOwned: BigFishRuntimeEntityResponse[] = [];
|
||||
|
||||
for (const owned of ownedEntities) {
|
||||
if (removedOwned.has(owned.entityId)) {
|
||||
continue;
|
||||
}
|
||||
for (const wild of wildEntities) {
|
||||
if (removedWild.has(wild.entityId)) {
|
||||
continue;
|
||||
}
|
||||
if (distance(owned, wild) > owned.radius + wild.radius) {
|
||||
continue;
|
||||
}
|
||||
if (owned.level >= wild.level) {
|
||||
removedWild.add(wild.entityId);
|
||||
newlyOwned.push(
|
||||
buildEntity(
|
||||
`owned-from-${wild.entityId}-${nextTick}`,
|
||||
wild.level,
|
||||
wild.position.x,
|
||||
wild.position.y,
|
||||
),
|
||||
);
|
||||
events.push(`收编 ${wild.level} 级实体`);
|
||||
} else {
|
||||
removedOwned.add(owned.entityId);
|
||||
events.push(`${owned.level} 级己方实体被 ${wild.level} 级野生实体吃掉`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
ownedEntities = ownedEntities
|
||||
.filter((entity) => !removedOwned.has(entity.entityId))
|
||||
.concat(newlyOwned);
|
||||
wildEntities = wildEntities.filter((entity) => !removedWild.has(entity.entityId));
|
||||
|
||||
const mergeResult = mergeOwnedEntities(ownedEntities, nextTick);
|
||||
ownedEntities = refreshLeader(mergeResult.ownedEntities);
|
||||
events.push(...mergeResult.events);
|
||||
|
||||
const playerLevel = Math.max(...ownedEntities.map((entity) => entity.level), 0);
|
||||
const leader = ownedEntities[0] ?? null;
|
||||
const cameraCenter = leader ? { ...leader.position } : run.cameraCenter;
|
||||
wildEntities = wildEntities
|
||||
.map((entity) => {
|
||||
const shouldCull =
|
||||
entity.level === playerLevel ||
|
||||
entity.level >= playerLevel + 3 ||
|
||||
entity.level + 3 <= playerLevel;
|
||||
const offscreenSeconds =
|
||||
shouldCull && isOffscreen(entity, cameraCenter)
|
||||
? entity.offscreenSeconds + 0.1
|
||||
: 0;
|
||||
return { ...entity, offscreenSeconds };
|
||||
})
|
||||
.filter((entity) => entity.offscreenSeconds < 3);
|
||||
|
||||
while (wildEntities.length < DEFAULT_WILD_COUNT) {
|
||||
wildEntities.push(
|
||||
buildWildEntity(
|
||||
nextTick,
|
||||
wildEntities.length + nextTick,
|
||||
Math.max(playerLevel, 1),
|
||||
run.winLevel,
|
||||
cameraCenter,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
const status =
|
||||
ownedEntities.length === 0
|
||||
? 'failed'
|
||||
: playerLevel >= run.winLevel
|
||||
? 'won'
|
||||
: 'running';
|
||||
if (status === 'failed') {
|
||||
events.push('己方实体归零,本局失败');
|
||||
} else if (status === 'won') {
|
||||
events.push('获得最高等级实体,通关');
|
||||
}
|
||||
|
||||
return {
|
||||
...run,
|
||||
status,
|
||||
tick: nextTick,
|
||||
playerLevel,
|
||||
leaderEntityId: leader?.entityId ?? null,
|
||||
ownedEntities,
|
||||
wildEntities,
|
||||
cameraCenter,
|
||||
lastInput: normalizedInput,
|
||||
eventLog: events.slice(-5),
|
||||
updatedAt: new Date().toISOString(),
|
||||
};
|
||||
}
|
||||
@@ -1,68 +0,0 @@
|
||||
import type {
|
||||
BigFishRunResponse,
|
||||
SubmitBigFishInputRequest,
|
||||
} from '../../../packages/shared/src/contracts/bigFish';
|
||||
import { type ApiRetryOptions, requestJson } from '../apiClient';
|
||||
|
||||
const BIG_FISH_RUNTIME_API_BASE = '/api/runtime/big-fish';
|
||||
const BIG_FISH_RUNTIME_READ_RETRY: ApiRetryOptions = {
|
||||
maxRetries: 1,
|
||||
baseDelayMs: 120,
|
||||
maxDelayMs: 360,
|
||||
};
|
||||
const BIG_FISH_RUNTIME_WRITE_RETRY: ApiRetryOptions = {
|
||||
maxRetries: 1,
|
||||
baseDelayMs: 120,
|
||||
maxDelayMs: 360,
|
||||
retryUnsafeMethods: true,
|
||||
};
|
||||
|
||||
export async function startBigFishRuntimeRun(sessionId: string) {
|
||||
return requestJson<BigFishRunResponse>(
|
||||
`${BIG_FISH_RUNTIME_API_BASE}/sessions/${encodeURIComponent(sessionId)}/runs`,
|
||||
{
|
||||
method: 'POST',
|
||||
},
|
||||
'启动大鱼吃小鱼测试玩法失败',
|
||||
{
|
||||
retry: BIG_FISH_RUNTIME_WRITE_RETRY,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
export async function getBigFishRuntimeRun(runId: string) {
|
||||
return requestJson<BigFishRunResponse>(
|
||||
`${BIG_FISH_RUNTIME_API_BASE}/runs/${encodeURIComponent(runId)}`,
|
||||
{
|
||||
method: 'GET',
|
||||
},
|
||||
'读取大鱼吃小鱼运行快照失败',
|
||||
{
|
||||
retry: BIG_FISH_RUNTIME_READ_RETRY,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
export async function submitBigFishRuntimeInput(
|
||||
runId: string,
|
||||
payload: SubmitBigFishInputRequest,
|
||||
) {
|
||||
return requestJson<BigFishRunResponse>(
|
||||
`${BIG_FISH_RUNTIME_API_BASE}/runs/${encodeURIComponent(runId)}/input`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(payload),
|
||||
},
|
||||
'提交大鱼吃小鱼移动输入失败',
|
||||
{
|
||||
retry: BIG_FISH_RUNTIME_WRITE_RETRY,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
export const bigFishRuntimeClient = {
|
||||
startRun: startBigFishRuntimeRun,
|
||||
getRun: getBigFishRuntimeRun,
|
||||
submitInput: submitBigFishRuntimeInput,
|
||||
};
|
||||
@@ -1,6 +1,4 @@
|
||||
export {
|
||||
bigFishRuntimeClient,
|
||||
getBigFishRuntimeRun,
|
||||
startBigFishRuntimeRun,
|
||||
submitBigFishRuntimeInput,
|
||||
} from './bigFishRuntimeClient';
|
||||
advanceLocalBigFishRuntimeRun,
|
||||
startLocalBigFishRuntimeRun,
|
||||
} from './bigFishLocalRuntime';
|
||||
|
||||
Reference in New Issue
Block a user