Add big fish settlement actions and publish feedback

This commit is contained in:
2026-04-26 21:28:02 +08:00
parent 09d3fe59b3
commit c81305f2e6
11 changed files with 326 additions and 15 deletions

View File

@@ -0,0 +1,80 @@
// @vitest-environment jsdom
import { fireEvent, render, screen } from '@testing-library/react';
import { describe, expect, test, vi } from 'vitest';
import type { BigFishRuntimeSnapshotResponse } from '../../../packages/shared/src/contracts/bigFish';
import { BigFishRuntimeShell } from './BigFishRuntimeShell';
vi.mock('../ResolvedAssetImage', () => ({
ResolvedAssetImage: ({
src,
alt,
className,
}: {
src?: string | null;
alt?: string;
className?: string;
}) => (src ? <img src={src} alt={alt} className={className} /> : null),
}));
function createRun(
status: BigFishRuntimeSnapshotResponse['status'],
): BigFishRuntimeSnapshotResponse {
return {
runId: 'big-fish-run-1',
sessionId: 'big-fish-session-1',
status,
tick: 18,
playerLevel: 2,
winLevel: 5,
leaderEntityId: null,
ownedEntities: [],
wildEntities: [],
cameraCenter: { x: 0, y: 0 },
lastInput: { x: 0, y: 0 },
eventLog: ['己方鱼群已经耗尽'],
updatedAt: '2026-04-26T12:00:00.000Z',
};
}
describe('BigFishRuntimeShell', () => {
test('renders restart and exit actions after a failed run', () => {
const onBack = vi.fn();
const onRestart = vi.fn();
render(
<BigFishRuntimeShell
run={createRun('failed')}
onBack={onBack}
onRestart={onRestart}
onSubmitInput={() => {}}
/>,
);
fireEvent.click(screen.getByRole('button', { name: '重来' }));
fireEvent.click(screen.getByRole('button', { name: '退出' }));
expect(screen.getByText('本轮失败')).toBeTruthy();
expect(onRestart).toHaveBeenCalledTimes(1);
expect(onBack).toHaveBeenCalledTimes(1);
});
test('keeps an exit action after a won run', () => {
const onBack = vi.fn();
render(
<BigFishRuntimeShell
run={createRun('won')}
onBack={onBack}
onSubmitInput={() => {}}
/>,
);
expect(screen.getByText('通关完成')).toBeTruthy();
expect(screen.queryByRole('button', { name: '重来' })).toBeNull();
fireEvent.click(screen.getByRole('button', { name: '退出' }));
expect(onBack).toHaveBeenCalledTimes(1);
});
});

View File

@@ -1,5 +1,5 @@
import { ArrowLeft, Loader2 } from 'lucide-react';
import { useEffect, useRef, useState, type PointerEvent } from 'react';
import { ArrowLeft, Loader2, RotateCcw } from 'lucide-react';
import { type PointerEvent, useEffect, useRef, useState } from 'react';
import type {
BigFishAssetSlotResponse,
@@ -21,6 +21,7 @@ type BigFishRuntimeShellProps = {
isBusy?: boolean;
error?: string | null;
onBack: () => void;
onRestart?: () => void;
onSubmitInput: (payload: SubmitBigFishInputRequest) => void;
};
@@ -188,6 +189,7 @@ export function BigFishRuntimeShell({
isBusy = false,
error = null,
onBack,
onRestart,
onSubmitInput,
}: BigFishRuntimeShellProps) {
const stageRef = useRef<HTMLDivElement | null>(null);
@@ -200,6 +202,10 @@ export function BigFishRuntimeShell({
}, [stick]);
useEffect(() => {
if (run?.status !== 'running') {
return undefined;
}
const timer = window.setInterval(() => {
const current = stickRef.current;
// 即使没有方向输入也持续回传当前状态,让后端持续推进刷怪、清理与胜负裁决。
@@ -209,7 +215,7 @@ export function BigFishRuntimeShell({
return () => {
window.clearInterval(timer);
};
}, [onSubmitInput]);
}, [onSubmitInput, run?.status]);
const submitDirection = (direction: SubmitBigFishInputRequest) => {
setStick(direction);
@@ -318,16 +324,39 @@ export function BigFishRuntimeShell({
</div>
{settlementCopy ? (
<div className="pointer-events-none absolute inset-0 z-40 flex items-center justify-center px-5">
<div className="absolute inset-0 z-40 flex items-center justify-center px-5">
<div
className={`w-full max-w-[20rem] rounded-[2rem] border border-white/24 bg-gradient-to-br ${settlementCopy.tone} p-6 text-center shadow-2xl shadow-slate-950/45 backdrop-blur-xl`}
className={`w-full max-w-[20rem] rounded-[1.5rem] border border-white/24 bg-gradient-to-br ${settlementCopy.tone} p-6 text-center shadow-2xl shadow-slate-950/45 backdrop-blur-xl`}
>
<div className="text-3xl font-black tracking-[0.22em] text-white [text-shadow:0_2px_12px_rgba(2,6,23,0.6)]">
<div className="text-3xl font-black text-white [text-shadow:0_2px_12px_rgba(2,6,23,0.6)]">
{settlementCopy.title}
</div>
<div className="mt-3 text-sm font-semibold leading-6 text-white/82">
{settlementCopy.message}
</div>
<div className="mt-5 grid grid-cols-2 gap-2">
{run.status === 'failed' && onRestart ? (
<button
type="button"
disabled={isBusy}
onClick={onRestart}
className="inline-flex h-11 items-center justify-center gap-2 rounded-full bg-white px-4 text-sm font-bold text-slate-950 shadow-lg shadow-slate-950/20 disabled:opacity-45"
>
<RotateCcw className="h-4 w-4" />
</button>
) : null}
<button
type="button"
onClick={onBack}
className={`inline-flex h-11 items-center justify-center gap-2 rounded-full border border-white/30 bg-black/24 px-4 text-sm font-bold text-white backdrop-blur ${
run.status === 'failed' && onRestart ? '' : 'col-span-2'
}`}
>
<ArrowLeft className="h-4 w-4" />
退
</button>
</div>
</div>
</div>
) : null}