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(` `); 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( () => [ { 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 ( ); }