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

@@ -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(),
};
}

View File

@@ -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,
};

View File

@@ -1,6 +1,4 @@
export {
bigFishRuntimeClient,
getBigFishRuntimeRun,
startBigFishRuntimeRun,
submitBigFishRuntimeInput,
} from './bigFishRuntimeClient';
advanceLocalBigFishRuntimeRun,
startLocalBigFishRuntimeRun,
} from './bigFishLocalRuntime';