225 lines
6.8 KiB
TypeScript
225 lines
6.8 KiB
TypeScript
import { useCallback, useMemo, useState } from 'react';
|
|
|
|
import type {
|
|
BigFishAssetSlotResponse,
|
|
BigFishRuntimeEntityResponse,
|
|
BigFishRuntimeSnapshotResponse,
|
|
SubmitBigFishInputRequest,
|
|
} from '../packages/shared/src/contracts/bigFish';
|
|
import { BigFishRuntimeShell } from './components/big-fish-runtime/BigFishRuntimeShell';
|
|
|
|
const BIG_FISH_BACKGROUND_IMAGE =
|
|
'data:image/svg+xml;utf8,' +
|
|
encodeURIComponent(`
|
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 720 1280">
|
|
<defs>
|
|
<linearGradient id="water" x1="0" y1="0" x2="0" y2="1">
|
|
<stop offset="0" stop-color="#38bdf8" />
|
|
<stop offset="0.52" stop-color="#0f766e" />
|
|
<stop offset="1" stop-color="#020617" />
|
|
</linearGradient>
|
|
<radialGradient id="light" cx="50%" cy="12%" r="52%">
|
|
<stop offset="0" stop-color="#ecfeff" stop-opacity="0.72" />
|
|
<stop offset="1" stop-color="#ecfeff" stop-opacity="0" />
|
|
</radialGradient>
|
|
</defs>
|
|
<rect width="720" height="1280" fill="url(#water)" />
|
|
<rect width="720" height="1280" fill="url(#light)" />
|
|
<path d="M70 324 C164 268 256 384 362 320 C492 242 582 330 656 282" fill="none" stroke="#a7f3d0" stroke-width="16" stroke-linecap="round" opacity="0.28" />
|
|
<path d="M34 760 C156 700 238 806 372 724 C520 634 606 746 704 682" fill="none" stroke="#bae6fd" stroke-width="18" stroke-linecap="round" opacity="0.18" />
|
|
<circle cx="120" cy="210" r="18" fill="#ecfeff" opacity="0.36" />
|
|
<circle cx="548" cy="410" r="12" fill="#ecfeff" opacity="0.28" />
|
|
<circle cx="304" cy="590" r="10" fill="#ecfeff" opacity="0.24" />
|
|
<path d="M0 1060 C128 1010 244 1096 366 1030 C492 962 612 1026 720 976 V1280 H0 Z" fill="#022c22" opacity="0.62" />
|
|
</svg>`);
|
|
|
|
const WORLD_MIN_X = 60;
|
|
const WORLD_MAX_X = 780;
|
|
const WORLD_MIN_Y = 80;
|
|
const WORLD_MAX_Y = 1240;
|
|
const PLAYER_SPEED = 20;
|
|
|
|
function clamp(value: number, min: number, max: number) {
|
|
return Math.max(min, Math.min(max, value));
|
|
}
|
|
|
|
function buildEntity(
|
|
entityId: string,
|
|
level: number,
|
|
x: number,
|
|
y: number,
|
|
): BigFishRuntimeEntityResponse {
|
|
return {
|
|
entityId,
|
|
level,
|
|
position: { x, y },
|
|
radius: 12 + level * 5,
|
|
offscreenSeconds: 0,
|
|
};
|
|
}
|
|
|
|
function buildInitialRun(): BigFishRuntimeSnapshotResponse {
|
|
const leader = buildEntity('player-leader', 1, 360, 640);
|
|
return {
|
|
runId: `local-big-fish-run-${Date.now()}`,
|
|
sessionId: 'local-big-fish-session',
|
|
status: 'running',
|
|
tick: 0,
|
|
playerLevel: 1,
|
|
winLevel: 5,
|
|
leaderEntityId: leader.entityId,
|
|
ownedEntities: [leader],
|
|
wildEntities: [
|
|
buildEntity('wild-small-1', 1, 250, 560),
|
|
buildEntity('wild-small-2', 1, 470, 760),
|
|
buildEntity('wild-mid-1', 2, 560, 520),
|
|
buildEntity('wild-mid-2', 3, 210, 820),
|
|
buildEntity('wild-boss-1', 5, 610, 930),
|
|
],
|
|
cameraCenter: { ...leader.position },
|
|
lastInput: { x: 0, y: 0 },
|
|
eventLog: ['按住屏幕任意位置,再拖动控制方向。'],
|
|
updatedAt: new Date().toISOString(),
|
|
};
|
|
}
|
|
|
|
function distanceBetween(
|
|
first: BigFishRuntimeEntityResponse,
|
|
second: BigFishRuntimeEntityResponse,
|
|
) {
|
|
return Math.hypot(
|
|
first.position.x - second.position.x,
|
|
first.position.y - second.position.y,
|
|
);
|
|
}
|
|
|
|
function respawnWildEntity(entity: BigFishRuntimeEntityResponse, tick: number) {
|
|
const offset = tick * 37 + entity.level * 53;
|
|
return {
|
|
...entity,
|
|
position: {
|
|
x: WORLD_MIN_X + (offset % Math.floor(WORLD_MAX_X - WORLD_MIN_X)),
|
|
y: WORLD_MIN_Y + ((offset * 7) % Math.floor(WORLD_MAX_Y - WORLD_MIN_Y)),
|
|
},
|
|
};
|
|
}
|
|
|
|
function moveWildEntity(entity: BigFishRuntimeEntityResponse, tick: number) {
|
|
const phase = tick * 0.32 + entity.level * 1.7;
|
|
const speed = 6 + entity.level * 0.8;
|
|
const nextX = entity.position.x + Math.cos(phase) * speed;
|
|
const nextY = entity.position.y + Math.sin(phase * 0.73) * speed;
|
|
return {
|
|
...entity,
|
|
position: {
|
|
x: clamp(nextX, WORLD_MIN_X, WORLD_MAX_X),
|
|
y: clamp(nextY, WORLD_MIN_Y, WORLD_MAX_Y),
|
|
},
|
|
};
|
|
}
|
|
|
|
function applyLocalInput(
|
|
run: BigFishRuntimeSnapshotResponse,
|
|
input: SubmitBigFishInputRequest,
|
|
): BigFishRuntimeSnapshotResponse {
|
|
if (run.status !== 'running') {
|
|
return run;
|
|
}
|
|
|
|
const leader = run.ownedEntities.find(
|
|
(entity) => entity.entityId === run.leaderEntityId,
|
|
);
|
|
if (!leader) {
|
|
return run;
|
|
}
|
|
|
|
const nextLeader = {
|
|
...leader,
|
|
position: {
|
|
x: clamp(leader.position.x + input.x * PLAYER_SPEED, WORLD_MIN_X, WORLD_MAX_X),
|
|
y: clamp(leader.position.y + input.y * PLAYER_SPEED, WORLD_MIN_Y, WORLD_MAX_Y),
|
|
},
|
|
};
|
|
|
|
let nextPlayerLevel = run.playerLevel;
|
|
const nextEvents = [...run.eventLog];
|
|
const nextWildEntities = run.wildEntities.map((entity) => {
|
|
const movedEntity = moveWildEntity(entity, run.tick + 1);
|
|
const touched = distanceBetween(nextLeader, movedEntity) <= nextLeader.radius + movedEntity.radius;
|
|
if (!touched) {
|
|
return movedEntity;
|
|
}
|
|
|
|
if (movedEntity.level <= nextPlayerLevel) {
|
|
nextPlayerLevel = Math.min(run.winLevel, nextPlayerLevel + 1);
|
|
nextEvents.push(`吞噬 Lv.${movedEntity.level},成长到 Lv.${nextPlayerLevel}`);
|
|
return respawnWildEntity(movedEntity, run.tick + nextPlayerLevel);
|
|
}
|
|
|
|
nextEvents.push(`撞上 Lv.${movedEntity.level},暂时避开更大的鱼。`);
|
|
return movedEntity;
|
|
});
|
|
|
|
const scaledLeader = {
|
|
...nextLeader,
|
|
level: nextPlayerLevel,
|
|
radius: 12 + nextPlayerLevel * 5,
|
|
};
|
|
const status = nextPlayerLevel >= run.winLevel ? 'won' : 'running';
|
|
if (status === 'won' && run.status !== 'won') {
|
|
nextEvents.push('已经成长为海域霸主。');
|
|
}
|
|
|
|
return {
|
|
...run,
|
|
status,
|
|
tick: run.tick + 1,
|
|
playerLevel: nextPlayerLevel,
|
|
ownedEntities: [scaledLeader],
|
|
wildEntities: nextWildEntities,
|
|
cameraCenter: { ...scaledLeader.position },
|
|
lastInput: input,
|
|
eventLog: nextEvents.slice(-5),
|
|
updatedAt: new Date().toISOString(),
|
|
};
|
|
}
|
|
|
|
export default function BigFishPlaygroundApp() {
|
|
const [run, setRun] = useState(buildInitialRun);
|
|
const assetSlots = useMemo<BigFishAssetSlotResponse[]>(
|
|
() => [
|
|
{
|
|
slotId: 'local-big-fish-background',
|
|
assetKind: 'stage_background',
|
|
status: 'ready',
|
|
assetUrl: BIG_FISH_BACKGROUND_IMAGE,
|
|
promptSnapshot: '本地直达入口占位海域背景',
|
|
updatedAt: new Date(0).toISOString(),
|
|
},
|
|
],
|
|
[],
|
|
);
|
|
|
|
const handleSubmitInput = useCallback((payload: SubmitBigFishInputRequest) => {
|
|
setRun((currentRun) => applyLocalInput(currentRun, payload));
|
|
}, []);
|
|
|
|
const handleRestart = useCallback(() => {
|
|
setRun(buildInitialRun());
|
|
}, []);
|
|
|
|
const handleExit = useCallback(() => {
|
|
window.location.assign('/');
|
|
}, []);
|
|
|
|
return (
|
|
<BigFishRuntimeShell
|
|
run={run}
|
|
assetSlots={assetSlots}
|
|
onBack={handleExit}
|
|
onRestart={handleRestart}
|
|
onSubmitInput={handleSubmitInput}
|
|
/>
|
|
);
|
|
}
|