修复在线游玩加载体验:加载期黑屏/空白与发行资源传输开销

- packages/shared 新增共享游戏加载面 PlatformGameLoadingSurface:封面/标题 + 动效进度 + 按时长推进的阶段文案(准备/资源/引擎)+ 慢加载提示,附用例
- 网页游玩页用共享加载面替换近黑壳上的一行小字,加载上限改为共享常量 PLATFORM_GAME_LOADING_TIMEOUT_MS
- 后台审核试玩补加载面、20 秒超时与「重新创建试玩会话」入口,新会话重置加载态;补两条用例
- 发行网关(api-server)对文本类发行资源按 Accept-Encoding 下发 gzip,并下发强 ETag、命中 If-None-Match 返回 304;补 5 条 Rust 用例
- E2E 脚本 check-game-distribution-ops-rollback-e2e 补压缩/Vary/ETag/304/不接受 gzip 四条断言
- 同步 pitfalls、开发运维文档、nginx README 与两份里程碑记录
This commit is contained in:
2026-10-05 17:14:32 +08:00
parent 21c79f54a4
commit 3206f8f49b
19 changed files with 1141 additions and 29 deletions
@@ -464,6 +464,28 @@ describe('GamePlayPage', () => {
expect(recordGamePlayMock).toHaveBeenCalledWith('game-1');
});
it('shows the shared loading surface until the release frame is ready', async () => {
render(<GamePlayPage gameId="game-1" onBack={vi.fn()} />);
await screen.findByRole('button', { name: /开始游戏/u });
fireEvent.click(screen.getByRole('button', { name: /开始游戏/u }));
const iframe = await screen.findByTitle('星轨防线 在线游玩');
// 发行包首屏要下完整个引擎:加载期必须有看得见的加载面,而不是一行小字或黑屏。
const loadingSurface = screen.getByRole('status');
expect(loadingSurface.textContent).toContain('星轨防线');
expect(
loadingSurface.classList.contains('platform-game-loading--player'),
).toBe(true);
expect(
loadingSurface.querySelector('.platform-game-loading__track'),
).not.toBeNull();
fireEvent.load(iframe);
expect(screen.queryByRole('status')).toBeNull();
expect(iframe.className.includes('game-player-frame--ready')).toBe(true);
});
it('rejects an unsafe release entry URL before rendering an iframe', async () => {
getGameMock.mockResolvedValueOnce(
createGame({
@@ -1,5 +1,9 @@
import './gameDistribution.css';
import {
PLATFORM_GAME_LOADING_TIMEOUT_MS,
PlatformGameLoadingSurface,
} from '@genarrative/shared/components';
import {
ArrowLeft,
Expand,
@@ -26,8 +30,11 @@ import { GameCover } from './GameGalleryPage';
type GamePlayPageProps = { gameId: string | null; onBack: () => void };
/** 发行包首屏加载上限:超过后给出显式超时与重试,不用无限期“正在启动”。 */
export const GAME_PLAY_STARTUP_TIMEOUT_MS = 20_000;
/**
* 发行包首屏加载上限:超过后给出显式超时与重试,不用无限期等待。
* 与后台审核试玩共用同一口径(`PLATFORM_GAME_LOADING_TIMEOUT_MS`)。
*/
export const GAME_PLAY_STARTUP_TIMEOUT_MS = PLATFORM_GAME_LOADING_TIMEOUT_MS;
type GameStartupState = 'idle' | 'loading' | 'ready' | 'timeout';
@@ -212,7 +219,12 @@ export function GamePlayPage({ gameId, onBack }: GamePlayPageProps) {
</div>
) : null}
{hasStarted && startupState === 'loading' ? (
<div className="game-player-loading">游戏正在启动…</div>
<PlatformGameLoadingSurface
key={`game-loading-${frameRunId}`}
tone="player"
title={game.title}
cover={<GameCover game={game} compact />}
/>
) : null}
{hasStarted && startupState === 'timeout' ? (
<PlatformStatusMessage
@@ -517,15 +517,6 @@
.game-player-frame--ready {
opacity: 1;
}
.game-player-loading {
position: absolute;
inset: 0;
display: grid;
place-items: center;
color: rgba(255, 241, 227, 0.75);
font-size: 0.88rem;
font-weight: 700;
}
.game-play-safety-note {
margin: 0;
color: var(--platform-text-soft);
@@ -645,7 +636,6 @@
-webkit-box-orient: vertical;
-webkit-line-clamp: 2;
}
.game-player-loading,
.game-player-timeout {
font-size: 0.8rem;
}