This commit is contained in:
2026-04-25 11:22:03 +08:00
parent 31f350d499
commit a6029639b0
12 changed files with 518 additions and 63 deletions

View File

@@ -0,0 +1,219 @@
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());
}, []);
return (
<BigFishRuntimeShell
run={run}
assetSlots={assetSlots}
onBack={handleRestart}
onSubmitInput={handleSubmitInput}
/>
);
}

View File

@@ -0,0 +1,89 @@
import { useMemo, useState } from 'react';
import type {
DragPuzzlePieceRequest,
SwapPuzzlePiecesRequest,
} from '../packages/shared/src/contracts/puzzleRuntimeSession';
import type { PuzzleWorkSummary } from '../packages/shared/src/contracts/puzzleWorkSummary';
import { PuzzleRuntimeShell } from './components/puzzle-runtime/PuzzleRuntimeShell';
import {
advanceLocalPuzzleLevel,
dragLocalPuzzlePiece,
startLocalPuzzleRun,
swapLocalPuzzlePieces,
} from './services/puzzle-runtime/puzzleLocalRuntime';
const PLACEHOLDER_PUZZLE_IMAGE =
'data:image/svg+xml;utf8,' +
encodeURIComponent(`
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 960 960">
<defs>
<linearGradient id="sky" x1="0" y1="0" x2="1" y2="1">
<stop offset="0" stop-color="#fef3c7" />
<stop offset="0.45" stop-color="#fb7185" />
<stop offset="1" stop-color="#312e81" />
</linearGradient>
<radialGradient id="glow" cx="42%" cy="34%" r="46%">
<stop offset="0" stop-color="#ffffff" stop-opacity="0.78" />
<stop offset="1" stop-color="#ffffff" stop-opacity="0" />
</radialGradient>
</defs>
<rect width="960" height="960" fill="url(#sky)" />
<circle cx="312" cy="282" r="210" fill="url(#glow)" />
<path d="M0 680 C170 610 278 724 424 650 C574 574 704 612 960 512 V960 H0 Z" fill="#1e1b4b" opacity="0.9" />
<path d="M0 766 C178 710 320 794 492 732 C642 678 780 708 960 652 V960 H0 Z" fill="#111827" opacity="0.78" />
<path d="M160 356 C238 298 326 304 388 376 C456 456 550 436 626 374 C710 306 824 330 882 410" fill="none" stroke="#fff7ed" stroke-width="18" stroke-linecap="round" opacity="0.72" />
<path d="M204 502 h552" stroke="#ffffff" stroke-width="16" stroke-linecap="round" opacity="0.3" />
<path d="M268 566 h424" stroke="#ffffff" stroke-width="12" stroke-linecap="round" opacity="0.22" />
</svg>`);
function buildPlaceholderPuzzleWork(): PuzzleWorkSummary {
return {
workId: 'placeholder-puzzle-work',
profileId: 'placeholder-puzzle-profile',
ownerUserId: 'placeholder-user',
sourceSessionId: null,
authorDisplayName: '占位作者',
levelName: '暮色群山',
summary: '用于直达玩法调试的本地占位拼图。',
themeTags: ['占位', '风景', '调试'],
coverImageSrc: PLACEHOLDER_PUZZLE_IMAGE,
coverAssetId: null,
publicationStatus: 'published',
updatedAt: new Date(0).toISOString(),
publishedAt: new Date(0).toISOString(),
playCount: 0,
publishReady: true,
};
}
export default function PuzzlePlaygroundApp() {
const placeholderWork = useMemo(() => buildPlaceholderPuzzleWork(), []);
const [run, setRun] = useState(() => startLocalPuzzleRun(placeholderWork));
const handleSwapPieces = (payload: SwapPuzzlePiecesRequest) => {
setRun((currentRun) => swapLocalPuzzlePieces(currentRun, payload));
};
const handleDragPiece = (payload: DragPuzzlePieceRequest) => {
setRun((currentRun) => dragLocalPuzzlePiece(currentRun, payload));
};
const handleRestart = () => {
setRun(startLocalPuzzleRun(placeholderWork));
};
const handleAdvanceNextLevel = () => {
setRun((currentRun) => advanceLocalPuzzleLevel(currentRun));
};
return (
<PuzzleRuntimeShell
run={run}
onBack={handleRestart}
onSwapPieces={handleSwapPieces}
onDragPiece={handleDragPiece}
onAdvanceNextLevel={handleAdvanceNextLevel}
/>
);
}

View File

@@ -1,5 +1,5 @@
import { ArrowLeft, Loader2 } from 'lucide-react';
import { useEffect, useRef, useState } from 'react';
import { useEffect, useRef, useState, type PointerEvent } from 'react';
import type {
BigFishAssetSlotResponse,
@@ -9,6 +9,12 @@ import type {
} from '../../../packages/shared/src/contracts/bigFish';
import { ResolvedAssetImage } from '../ResolvedAssetImage';
type TouchOrigin = {
pointerId: number;
x: number;
y: number;
};
type BigFishRuntimeShellProps = {
run: BigFishRuntimeSnapshotResponse | null;
assetSlots?: BigFishAssetSlotResponse[];
@@ -34,6 +40,20 @@ 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;
if (Math.hypot(deltaX, deltaY) < deadZone) {
return { x: 0, y: 0 };
}
return normalizeVector(deltaX, deltaY);
}
function projectEntity(
entity: BigFishRuntimeEntityResponse,
run: BigFishRuntimeSnapshotResponse,
@@ -152,7 +172,8 @@ export function BigFishRuntimeShell({
onBack,
onSubmitInput,
}: BigFishRuntimeShellProps) {
const padRef = useRef<HTMLDivElement | null>(null);
const stageRef = useRef<HTMLDivElement | null>(null);
const [touchOrigin, setTouchOrigin] = useState<TouchOrigin | null>(null);
const [stick, setStick] = useState({ x: 0, y: 0 });
const stickRef = useRef(stick);
@@ -163,7 +184,7 @@ export function BigFishRuntimeShell({
useEffect(() => {
const timer = window.setInterval(() => {
const current = stickRef.current;
// 即使摇杆静止也持续回传当前输入,让后端持续推进刷怪、清理与胜负裁决。
// 即使没有方向输入也持续回传当前状态,让后端持续推进刷怪、清理与胜负裁决。
onSubmitInput(current);
}, 220);
@@ -172,20 +193,39 @@ export function BigFishRuntimeShell({
};
}, [onSubmitInput]);
const updateStickFromPointer = (clientX: number, clientY: number) => {
const pad = padRef.current;
if (!pad) {
const submitDirection = (direction: SubmitBigFishInputRequest) => {
setStick(direction);
onSubmitInput(direction);
};
const beginTouchControl = (event: PointerEvent<HTMLDivElement>) => {
if (event.target instanceof HTMLElement && event.target.closest('button')) {
return;
}
const rect = pad.getBoundingClientRect();
const centerX = rect.left + rect.width / 2;
const centerY = rect.top + rect.height / 2;
const vector = normalizeVector(
(clientX - centerX) / (rect.width / 2),
(clientY - centerY) / (rect.height / 2),
event.currentTarget.setPointerCapture(event.pointerId);
setTouchOrigin({
pointerId: event.pointerId,
x: event.clientX,
y: event.clientY,
});
submitDirection({ x: 0, y: 0 });
};
const updateTouchControl = (event: PointerEvent<HTMLDivElement>) => {
if (!touchOrigin || touchOrigin.pointerId !== event.pointerId) {
return;
}
submitDirection(
resolveDirectionFromOrigin(touchOrigin, event.clientX, event.clientY),
);
setStick(vector);
onSubmitInput(vector);
};
const endTouchControl = (event: PointerEvent<HTMLDivElement>) => {
if (!touchOrigin || touchOrigin.pointerId !== event.pointerId) {
return;
}
setTouchOrigin(null);
submitDirection({ x: 0, y: 0 });
};
if (!run) {
@@ -206,7 +246,14 @@ export function BigFishRuntimeShell({
return (
<div className="fixed inset-0 z-[100] flex justify-center bg-slate-950 text-white">
<div className="relative h-full w-full max-w-[430px] overflow-hidden bg-[radial-gradient(circle_at_50%_20%,rgba(34,211,238,0.2),transparent_28%),radial-gradient(circle_at_20%_80%,rgba(16,185,129,0.18),transparent_26%),linear-gradient(180deg,#082f49,#020617)]">
<div
ref={stageRef}
className="relative h-full w-full max-w-[430px] touch-none overflow-hidden bg-[radial-gradient(circle_at_50%_20%,rgba(34,211,238,0.2),transparent_28%),radial-gradient(circle_at_20%_80%,rgba(16,185,129,0.18),transparent_26%),linear-gradient(180deg,#082f49,#020617)]"
onPointerDown={beginTouchControl}
onPointerMove={updateTouchControl}
onPointerUp={endTouchControl}
onPointerCancel={endTouchControl}
>
{backgroundAsset ? (
<ResolvedAssetImage
src={backgroundAsset}
@@ -251,40 +298,7 @@ export function BigFishRuntimeShell({
))}
</div>
<div className="absolute bottom-6 left-4 z-30">
<div
ref={padRef}
role="presentation"
className="relative h-28 w-28 rounded-full border border-white/18 bg-black/24 backdrop-blur"
onPointerDown={(event) => {
event.currentTarget.setPointerCapture(event.pointerId);
updateStickFromPointer(event.clientX, event.clientY);
}}
onPointerMove={(event) => {
if (event.buttons <= 0) {
return;
}
updateStickFromPointer(event.clientX, event.clientY);
}}
onPointerUp={() => {
setStick({ x: 0, y: 0 });
onSubmitInput({ x: 0, y: 0 });
}}
onPointerCancel={() => {
setStick({ x: 0, y: 0 });
onSubmitInput({ x: 0, y: 0 });
}}
>
<div
className="absolute left-1/2 top-1/2 h-11 w-11 -translate-x-1/2 -translate-y-1/2 rounded-full bg-cyan-200 shadow-lg shadow-cyan-950/30"
style={{
transform: `translate(calc(-50% + ${stick.x * 34}px), calc(-50% + ${stick.y * 34}px))`,
}}
/>
</div>
</div>
<div className="absolute bottom-6 right-4 z-30 max-w-[13rem] space-y-2 text-right text-xs text-white/72">
<div className="pointer-events-none absolute bottom-6 right-4 z-30 max-w-[13rem] space-y-2 text-right text-xs text-white/72">
{isBusy ? <div>...</div> : null}
{error ? <div className="text-rose-200">{error}</div> : null}
{run.eventLog.slice(-3).map((event) => (

View File

@@ -9,6 +9,18 @@ describe('matchAppRoute', () => {
});
});
it('routes puzzle playground path to the standalone puzzle runtime', () => {
expect(matchAppRoute('/puzzle')).toEqual({
kind: 'puzzle-playground',
});
});
it('routes big fish playground path to the standalone big fish runtime', () => {
expect(matchAppRoute('/BIG-FISH/')).toEqual({
kind: 'big-fish-playground',
});
});
it('routes former standalone editor paths back to the main game', () => {
expect(matchAppRoute('/item-editor/tools')).toEqual({
kind: 'game',

View File

@@ -7,6 +7,12 @@ type AppRouteComponent = LazyExoticComponent<
>;
export type AppRouteMatch =
| {
kind: 'puzzle-playground';
}
| {
kind: 'big-fish-playground';
}
| {
kind: 'game';
};
@@ -20,6 +26,8 @@ export type ResolvedAppRoute = {
};
const GameApp = lazy(() => import('../AuthenticatedApp')) as AppRouteComponent;
const BigFishPlaygroundApp = lazy(() => import('../BigFishPlaygroundApp')) as AppRouteComponent;
const PuzzlePlaygroundApp = lazy(() => import('../PuzzlePlaygroundApp')) as AppRouteComponent;
function normalizeRoutePath(pathname: string) {
const trimmedPathname = pathname.trim().toLowerCase();
@@ -32,7 +40,19 @@ function normalizeRoutePath(pathname: string) {
}
export function matchAppRoute(pathname: string): AppRouteMatch {
void normalizeRoutePath(pathname);
const normalizedPath = normalizeRoutePath(pathname);
if (normalizedPath === '/puzzle') {
return {
kind: 'puzzle-playground',
};
}
if (normalizedPath === '/big-fish') {
return {
kind: 'big-fish-playground',
};
}
return {
kind: 'game',
@@ -42,6 +62,24 @@ export function matchAppRoute(pathname: string): AppRouteMatch {
export function resolveAppRoute(pathname: string): ResolvedAppRoute {
const matchedRoute = matchAppRoute(pathname);
if (matchedRoute.kind === 'puzzle-playground') {
return {
kind: 'puzzle-playground',
loadingEyebrow: '正在载入拼图',
loadingText: '正在进入拼图关卡...',
Component: PuzzlePlaygroundApp,
};
}
if (matchedRoute.kind === 'big-fish-playground') {
return {
kind: 'big-fish-playground',
loadingEyebrow: '正在载入大鱼',
loadingText: '正在进入玩法...',
Component: BigFishPlaygroundApp,
};
}
return {
kind: 'game',
loadingEyebrow: '正在载入游戏',

View File

@@ -68,7 +68,7 @@ function buildInitialBoard(gridSize: PuzzleGridSize): PuzzleBoardSnapshot {
const pieces = Array.from({ length: gridSize * gridSize }, (_, index) => {
const correctRow = Math.floor(index / gridSize);
const correctCol = index % gridSize;
const current = shuffledPositions[index];
const current = shuffledPositions[index] ?? { row: correctRow, col: correctCol };
return {
pieceId: `piece-${index}`,
correctRow,