Merge pull request 'Style/game play' (#543) from style/game-play into master
Project CI / AI game creator shell Rust crates (push) Has been cancelled
Project CI / Backend tests (push) Has been cancelled
Project CI / Native shell tests (push) Has been cancelled
Project CI / Frontend tests (push) Has been cancelled
Project CI / Repository checks (push) Has been cancelled
Project CI / AI game creator shell web tests (push) Has been cancelled
Project CI / AI game creator shell Rust lane 1/2 (push) Has been cancelled
Project CI / AI game creator shell Rust lane 2/2 (push) Has been cancelled
Project CI / AI game creator shell Rust smoke (push) Has been cancelled

Reviewed-on: #543
This commit was merged in pull request #543.
This commit is contained in:
2026-09-30 13:55:57 +08:00
16 changed files with 448 additions and 71 deletions
@@ -320,3 +320,16 @@
| 同上路径但带 `Cookie` | **403 Forbidden** + `application/json`(`x-api-version: 2026-06-16`、`x-response-time-ms`)——发行读取**拒绝携带账号凭证**的请求,与本地用例断言一致 |
**生产环境**(`genarrative.world`)同一时刻 `/api/game-distribution/games` 返回 **0 条**:说明当前生产还没有任何已发布的游戏,条目 ④「真实环境整链路」需要在生产发布一个作品后再跑。也就是说此刻能拿到的真实环境证据是「dev 部署的公开读路径 + 发行隔离」,写路径与审核链路的真实环境验收仍需生产账号。
## 网页侧两处交互修正(2026-09-30)
- **广场首屏「立即试玩」不再替用户选中作品**:`src/components/game-distribution/GameGalleryPage.tsx` 原来是 `onClick={() => featuredGame && openDetail(featuredGame.id)}`,点一下等于直接打开第一款游戏的详情。现在按钮只把列表滚进视野(按平台页签面板计算相对偏移,`scrollTo({ behavior: 'smooth' })`,无 `scrollTo` 时退化成直接赋值),不再触发 `onOpenDetail`;`featuredGame` 派生随之退役。回归用例 `GameDistributionPages.test.tsx`「首屏「立即试玩」滚动到列表,而不是打开第一款游戏详情」断言 `onOpenDetail` 未被调用且滚动落在页签面板上。
- **游玩页不再可滚**:移动端此前是整页可滚的半屏播放器。原因是面板仍是 `overflow-auto`,而播放器用 `min-height: calc(100dvh - 8rem)` 撑高页面。现在 `PlatformEntryActiveFlowShell.tsx` 在 `game-play` 舞台给页签容器加 `platform-tab-panel--game-play`(不滚动、去掉多余内边距),`.game-play-page` 改为 `height: 100%; min-height: 0`,播放器 `flex: 1; min-height: 0` 撑满剩余高度;矮视口(`max-height: 26rem`,横屏手机)另给启动面板一套压扁布局,兜底只在极端高度下发生在启动卡片内部,页面与面板本身始终不滚动。启动面板纵向对齐用 `safe center`(`center` 作旧浏览器回退)并把 `overflow-y: auto` 放在基础规则:居中溢出会把顶部标题裁到 `scrollTop = 0` 也够不到的位置,只加内部滚动并不能修掉。回归用例 `PlatformEntryActiveFlowShell.test.tsx`「游玩舞台的页签容器不滚动」「游戏广场等舞台仍保持页签容器滚动」双向锁定。
- **补齐上面这条的空守卫**:`.platform-tab-panel--game-play { overflow: hidden }` 一开始只有单类名 (0,1,0),被文件更靠后的 `.platform-desktop-shell--workbench .platform-tab-panel { overflow: auto }`((0,2,0))压掉,真实 Chromium 里 `overflow-y` 仍是 `auto`——只是当时内容恰好没溢出,测试与肉眼都没发现。现在覆盖规则带上 workbench 祖先一起写,实测 390x844/360x640/844x390 三档 `overflowY=hidden`、面板与整页横竖都不溢出,广场舞台仍是 `overflow: auto` 且内容可滚。详情见 pitfalls 同日条目。
- **「我的游戏」列表与画廊同口径**:`.my-game-list` 的单列 `auto` 轨道会被 `white-space: nowrap` 的简介顶到内容最小宽度(实测 592px),加上 `.my-game-card` 缺 `min-width: 0`,390/360 宽的手机上卡片超出视口、面板横向可滚且看着不居中。现在改成画廊那套口径(`grid-template-columns: minmax(0, 1fr)`、`.my-game-card { min-width: 0; overflow: hidden }`、标题 `min-width: 0; overflow-wrap: anywhere`);真实 Chromium 夹具在 390/360 下量到轨道=卡片=可视宽、面板横向溢出 0(改前 592px 卡片、横向可滚 230/260px)。
- **页签面板左右留白对齐**:`.platform-tab-panel` 原本写死 `padding-right`(并在移动端与 workbench 两处再压成 0),而它是不分层规则、优先级高于 Tailwind 的 `padding-inline`,导致面板左 12/24px、右 0,列表左右留白 28/16。三处 `padding-right` 删除后左右统一由使用处的 `px-3`/`sm:px-6` 决定:390 下 12/12(列表 28/28)、820 下 24/24(列表 44/44),无 px 类的舞台仍是 0/0;Chromium 夹具实测「我的游戏」与画廊两张页面同时恢复对称,且横向溢出保持 0。
- **广场的「发布游戏」「我的游戏」上移到 hero 右上角**:两个入口原本混在 hero 下方的 `.game-toolbar` 动作区(和搜索框同排),现在提到 `.game-page-actions` 一行、右对齐放在 hero 上方,并留 1.1rem 底部间距;工具栏只保留标题与搜索。未登录或灰度未命中时两者都不渲染,该行随之消失。Chromium 夹具实测 390/360/820 三档都是 `aboveHero=true`、与 hero 间距 18px、右边缘与 hero 对齐。
- 真机口径的守卫补在现役 E2E 里:`scripts/check-game-distribution-web-e2e.mjs` 的移动端段新增「移动端游玩页不可滚动(面板与整页都不溢出)」,断言 `.platform-tab-panel` 的 `overflow-y` 为 `hidden`,且面板与整页**横竖两轴**都不溢出(面板自身 `overflow-x: hidden`,横向溢出只会被静默裁掉,只量 `scrollHeight` 会漏掉「内容被裁一半」)。(该脚本需要管理员账号发布一款真实游戏,未在本轮执行;同一批数值用 Chromium 直连 dev 栈的 `/games/play?id=…` 量过,见 pitfalls 同日条目。)
- **详情页返回按来源回到上一页**:`GameDetailPage` 左上角原来是写死的「返回游戏广场」,并且直接 `setSelectionStage('games')`(等于 push 一条新的 `/games`)。从「我的游戏」点进详情再返回就落到广场,从广场进详情返回也会多压一条重复历史。现在文案简化为「返回」,行为改成优先 `window.history.back()`,由 `ActiveApp` 已有的 `popstate` 同步把舞台还原成真正的来源页(我的游戏 / 广场);游玩页的返回同样处理,避免详情↔游玩互相 push。协议侧给应用写入的历史条目补了 `__genarrativeAppHistoryDepth`,新增 `hasAppHistoryBackEntry()` 判断「当前条目是应用内导航写入的且存在上一页」;直接打开详情深链、或原生壳里没有可回退条目时,才 `replaceAppHistoryPath` 兜底到广场(游玩页兜底到自己的详情)。回归用例 `activeAppPageRoutes.test.ts` 锁定深度标记语义,`PlatformEntryActiveFlowShell.test.tsx`「游戏详情返回」两条锁定原生返回与深链兜底。
- **游戏分发的返回文案统一成「返回」**:`GameDetailPage`、`GamePlayPage`(含超时面板里的按钮)、`MyGamesPage`、`GamePublishPage` 四处原本是「返回游戏广场 / 返回详情」,同一套流程里出现三种写法;现在统一成「返回」,目标页仍由各自的 `onBack`(广场 / 我的游戏 / 详情)决定,按钮不再承诺一个可能不对的目的地。`GameDistributionPages.test.tsx` 的断言同步改为 `/^返回$/u`。游玩页工具栏上那个按钮展示的是游戏名(退出开始面板后就靠它认游戏),不属于「返回 xx」文案,保持不动。
- 验证:`npx vitest run src/components/game-distribution/GameDistributionPages.test.tsx`(21 passed)、`npx vitest run src/components/platform-entry/PlatformEntryActiveFlowShell.test.tsx`(22 passed)、`npx vitest run src/routing/activeAppPageRoutes.test.ts`、`npm run typecheck`、`npx eslint`(五个改动文件)、`npm run check:encoding`、`git diff --check` 全部通过;启动面板的溢出可达性、「我的游戏」移动端横向溢出都用真实 Chromium 静态夹具量过(见 pitfalls 同日条目),详情返回链在真实 Chromium(dev 栈 390x844)实测 `/games`→详情→返回=`/games`、深链直开详情→返回=`/games`、详情→立即玩→返回详情=`/games/detail?id=…`。视觉与真机横竖屏走查仍需人工/截图评审(沿用阶段 C 第 8 条未取证口径)。
@@ -6108,6 +6108,39 @@ Cocos Creator 根目录由 `package.json.creator.version` 与普通 `assets/`
- **做法**:`npm run check:agc-direct-execution-fixture -- --agc-exe "<已安装渠道包的主程序绝对路径>" --cases completed`。夹具在临时项目和 loopback Provider 上跑 CLI 执行链路,可直接核对已安装包的随包 Codex。
- **判读**:若报 `Codex app-server JSON-RPC 失败`,sidecar 已启动并返回协议错误,应先检查请求体及已安装包的源码版本;若根本无法启动,再查安装目录 `coding-agent/win-x64/manifest.json` 的组件哈希、`codex-package.json` 的版本和缺失组件。夹具模式中的 `remote_control disabled reason=provider-proxy-auth` 是预期诊断行。
## 2026-09-30 居中溢出叠加内部滚动,会把顶部内容裁到滚不到的地方(游玩页启动面板)
- **现象**:极矮视口(横屏手机,游玩页 `max-height: 26rem` 那一档)里启动面板内容比容器高时,标题与简介被裁在容器上方,`scrollTop` 已经到 0 也够不到,看起来像「标题凭空消失」。
- **根因**:`.game-player-launch` 同时有 `align-content: center`(两列布局下是 `align-items: center`)与 `overflow-y: auto`。溢出量被居中分配成上下各一半,负方向那一半落在滚动范围之外——滚动只能从 0 开始,所以顶部永远不可达。只给 `overflow-y: auto` 或只改居中都修不掉。
- **处理**:纵向对齐改用 `safe center`(先写 `align-content: center` / `align-items: center` 作旧浏览器回退,再写 `safe center`),溢出时退回 start;并把 `overflow-y: auto` 提到基础规则,让启动卡片成为唯一的内部滚动兜底,页面与 `.platform-tab-panel` 本身仍保持 `overflow: hidden`。
- **判据**:真实 Chromium 夹具(`tmp/launch-overflow-check`,把 `gameDistribution.css` 直接链进静态页面)在 `844x200` 下量:改回 `center` 时 `titleTopAtScrollZero = -20`(不可达),改成 `safe center` 后为 `+12`(可达),滚到底按钮同样可达;`390x844` 与 `1280x800` 正常视口不溢出,位置与改前一致。
- **关联**:`src/components/game-distribution/gameDistribution.css` 的 `.game-player-launch` 与两处媒体查询、`src/components/game-distribution/GamePlayPage.tsx`。
## 2026-09-30 单列 grid 的 auto 轨道被 nowrap 文本撑开,移动端面板只剩横向滚动
- **现象**:「我的游戏」页在 390/360 宽的手机上可以横向滑动,卡片比可视区宽(实测卡片 592px、可视 390px、面板横向可滚 230px),看起来也不居中;同一页面的画廊卡片却正常。
- **根因**:三件事叠在一起。① `.my-game-list` 是单列 grid,隐式列宽是 `auto`,而 auto 轨道的最小尺寸是**内容最小宽度**,不会被容器宽度压回去;② `.my-game-card`(flex 行)没有 `min-width: 0`,其最小内容宽度 = 封面 `8.5rem` + gap + 正文最小内容;③ 正文里的 `.my-game-card__summary` 是 `white-space: nowrap`,一条长且不可断行的简介就把最小内容宽度顶到 592px。画廊之所以正常,是因为 `.game-grid` 用 `repeat(n, minmax(0, 1fr))`,`.game-card` 又有 `min-width: 0; overflow: hidden`。
- **处理**:让「我的游戏」与画廊同口径——`.my-game-list` 改 `grid-template-columns: minmax(0, 1fr)`,`.my-game-card` 加 `min-width: 0; overflow: hidden`,标题加 `min-width: 0; overflow-wrap: anywhere`。改后 390/360 实测轨道 = 列表 = 卡片 = 可视宽,面板横向溢出 0。
- **同批修掉左右留白不对称**:面板左留白 12/24px(Tailwind `px-3`/`sm:px-6`),右留白却是 0,列表左右留白因此是 28/16。根因是 `src/index.css` 里 `.platform-tab-panel` 写死了 `padding-right: 0.25rem`——它属于**不分层(unlayered)规则,优先级高于 Tailwind 放进 `@layer utilities` 的 `padding-inline`**,把右内边距钉死在 0;`@media (max-width: 639px) .platform-tab-panel` 与 `.platform-desktop-shell--workbench .platform-tab-panel` 又各写了一次 `padding-right: 0`。三处声明全部删掉,左右统一交给使用处的 `padding-inline`;没有 px 类的舞台(创作主页 / 我的 / 游玩页)维持 0/0。改后实测 390 下 12/12、820 下 24/24,列表左右留白 28/28 与 44/44。
- **判据**:真实 Chromium 静态夹具 `tmp/mygames-check/`(`tmp/` 已 gitignore,由 dev 栈 `http://127.0.0.1:10000/tmp/mygames-check/` 托管)同时渲染「我的游戏」和画廊两组卡片,量面板横向溢出与网格轨道宽度:改前 390/360 下轨道 591.891px、卡片 592px、面板横向可滚 230/260px;改后轨道 = 列表 = 卡片 = 可视宽、溢出 0。布局问题 jsdom 量不出来,这类回归目前靠这个夹具手工复核。
- **关联**:`src/components/game-distribution/gameDistribution.css`、`src/components/game-distribution/MyGamesPage.tsx`、`.game-grid`/`.game-card`。
## 2026-09-30 SPA 的「返回」写死目标页,会丢掉用户真实来源
- **现象**:游戏详情页左上角写死「返回游戏广场」并把舞台设成 `games`。从「我的游戏」点进详情,返回后落在广场而不是我的游戏;从广场进详情,返回还会 push 一条重复的 `/games`,此时浏览器原生后退反而回到刚离开的详情页。
- **根因**:`setSelectionStage(stage, { path })` 一律走 `pushAppHistoryPath`,而返回按钮复用它,语义变成「前进到广场」;来源页信息从未被记录,返回只能靠猜。
- **处理**:`activeAppPageRoutes` 给应用写入的历史条目补内部深度标记(`push` 时 +1、`replace` 保持),新增 `hasAppHistoryBackEntry()`;详情/游玩页的返回改为「有应用内历史就 `window.history.back()`(`popstate` 已由 `ActiveApp` 同步舞台),否则 `replaceAppHistoryPath` 兜底到广场/详情」。深度标记而不是只看「有没有 state」是必要的:直接打开深链、或原生壳通过 host bridge 补写首条目时,`history.back()` 会直接退出应用。
- **判据**:真实 Chromium(dev 栈 390x844)实测 `/games` → 详情 → 返回 = `/games`;深链直开 `/games/detail?id=…` → 返回 = `/games`;详情 → 立即玩 → 返回详情 = `/games/detail?id=…`。jsdom 侧 `activeAppPageRoutes.test.ts` 锁深度标记语义,`PlatformEntryActiveFlowShell.test.tsx`「游戏详情返回」两条锁原生返回与深链兜底。
- **关联**:`src/routing/activeAppPageRoutes.ts`、`src/ActiveApp.tsx`、`src/components/platform-entry/PlatformEntryActiveFlowShell.tsx`、`src/components/game-distribution/GameDetailPage.tsx`。
## 2026-09-30 单类名覆盖被 workbench 祖先规则压掉,游玩页的「不滚动」守卫实际是空的
- **现象**:给游玩页面板加的 `.platform-tab-panel--game-play { overflow: hidden }` 看起来生效(类挂上了、单测断言类名也过),但真实 Chromium 里 `getComputedStyle(panel).overflowY` 是 `auto`——守卫没生效,只是当时内容恰好没溢出(`panelOverflowY = 0`),所以肉眼和 jsdom 都看不出来。
- **根因**:`.platform-desktop-shell--workbench .platform-tab-panel { overflow: auto }`(`src/index.css` 文件末尾附近)特异性是 (0,2,0),而且它在文件里更靠后,把单类名 (0,1,0) 的覆盖压掉了。面板在所有舞台都渲染在这层 workbench 容器里,所以这个覆盖永远轮不到生效。
- **处理**:覆盖规则带上同样的祖先作用域,`overflow: hidden` 用两个选择器(单类名 + `.platform-desktop-shell--workbench .platform-tab-panel.platform-tab-panel--game-play`)一起写,既压得过 workbench 规则也不依赖文件顺序。
- **判据**:真实 Chromium(dev 栈 390x844 / 360x640 / 844x390 横屏矮视口)实测 `overflowY=hidden`、面板与整页横竖都不溢出,同时广场舞台仍是 `overflowY=auto` 且内容可滚(689px)。同类问题排查姿势:改 `.platform-tab-panel` 或任何 workbench 内元素的样式,先在浏览器里读 `getComputedStyle`,别只看类名/源码顺序。
- **关联**:`src/index.css`(`.platform-tab-panel--game-play` 与 `.platform-desktop-shell--workbench .platform-tab-panel`)、`scripts/check-game-distribution-web-e2e.mjs`、`src/components/game-distribution/gameDistribution.css`。
## 2026-09-24 移动端固定底部菜单会压住「内联」弹窗:`z-index` 高也点不到
- **现象**:手机端主站从头像入口上传头像,裁剪弹窗的「取消 / 上传」被底部「游戏 / 我的」菜单盖住,点按钮命中底部菜单;弹窗遮罩、面板本身都正常渲染,只有底部一条区域失去交互。
@@ -72,7 +72,7 @@
- 游戏目录 `/games`、详情 `/games/detail?id=<gameId>`、游玩 `/games/play?id=<gameId>`;作者管理 `/games/mine` 只列当前账号的游戏和发行状态。平台壳及既有账号页提供入口,不建立第二套账号或作品系统。
- 目录使用重点推荐位和紧凑卡片网格,首版推荐位只消费后台明确设置且已公开的游戏;其余列表按发布时间排列。支持关键词、分类与设备筛选,筛选写入 URL,返回列表恢复筛选和滚动位置;没有内容时展示真实空态。
- 详情的主动作是“立即玩”,展示封面、标题、作者、短简介、设备标识、截图和简短操作方式;不虚构评分、玩家数、排名和收藏状态。游客浏览详情及启动游戏均无需登录;上传、更新、撤回和作者管理需要当前平台登录态。
- 游玩页先展示封面与开始按钮,用户点击后加载游戏,避免列表自动加载多个 iframe 或自动播音;提供加载、超时、重试、返回详情和全屏。iframe 的 `load` 事件只表示文档已加载,不宣称已经通过运行验证;首版不把 iframe 消息当平台业务状态。
- 游玩页先展示封面与开始按钮,用户点击后加载游戏,避免列表自动加载多个 iframe 或自动播音;提供加载、超时、重试、返回和全屏。iframe 的 `load` 事件只表示文档已加载,不宣称已经通过运行验证;首版不把 iframe 消息当平台业务状态。
- 移动端优先采用全宽内容、底部主要动作、至少 44px 点击区域,覆盖安全区、横竖屏和键盘焦点。游戏声明仅支持桌面时,手机详情保留可浏览内容并禁用开始,明确显示“请在电脑上游玩”;横屏游戏提供旋转提示,但不能把系统方向锁定成功作为启动前提。
- 视觉延续当前暖色主题 token、米白/浅暖背景、橙色主动作与现有字体;通过封面占比、留白、圆角和清晰状态层级现代化呈现,不另建配色系统。通用卡片动作、弹窗、上传、状态反馈、空态与媒体预览优先复用共享组件。发布打开独立弹窗/抽屉,不在当前页面末尾追加表单;技术信息仅在失败诊断或确需用户决策时展示。
@@ -522,6 +522,32 @@ async function main() {
mobileStartDisabled && rotationHint,
`startDisabled=${mobileStartDisabled} rotationHint=${rotationHint}`,
);
// 游玩页是固定画布:面板必须是 overflow:hidden 且不溢出,整页也不能出现滚动条。
// 面板自身 `overflow-x: hidden`,横向溢出只会被静默裁掉、不会出滚动条,所以两个方向都要量:
// 只量 scrollHeight 会漏掉「卡片被裁一半」这类横向回归。
const playScroll = await mobilePage.evaluate(() => {
const panel = document.querySelector('.platform-tab-panel');
return {
documentOverflowY:
document.documentElement.scrollHeight - window.innerHeight,
documentOverflowX:
document.documentElement.scrollWidth - window.innerWidth,
panelOverflowY: panel ? panel.scrollHeight - panel.clientHeight : 0,
panelOverflowX: panel ? panel.scrollWidth - panel.clientWidth : 0,
panelOverflowStyle: panel
? getComputedStyle(panel).overflowY
: 'missing',
};
});
check(
'移动端游玩页不可滚动(面板与整页都不溢出)',
playScroll.documentOverflowY <= 1 &&
playScroll.documentOverflowX <= 1 &&
playScroll.panelOverflowY <= 1 &&
playScroll.panelOverflowX <= 1 &&
playScroll.panelOverflowStyle === 'hidden',
JSON.stringify(playScroll),
);
await mobile.close();
await context.close();
} finally {
@@ -193,7 +193,7 @@ export function GameDetailPage({
return (
<div className="game-detail-page">
<button type="button" className="game-back-button" onClick={onBack}>
<ArrowLeft /> 返回游戏广场
<ArrowLeft /> 返回
</button>
<PlatformStatusMessage
tone="error"
@@ -214,7 +214,7 @@ export function GameDetailPage({
return (
<div className="game-detail-page">
<button type="button" className="game-back-button" onClick={onBack}>
<ArrowLeft /> 返回游戏广场
<ArrowLeft /> 返回
</button>
<section className="game-detail-hero">
<GameShowcase game={game} />
@@ -375,10 +375,54 @@ describe('GameGalleryPage 筛选与返回上下文', () => {
).toBe('180'),
);
});
it('首屏「立即试玩」滚动到列表,而不是打开第一款游戏详情', async () => {
resetGalleryContext();
const onOpenDetail = vi.fn();
listGamesMock.mockResolvedValue([
createGame({ id: 'game-1', title: '星轨防线' }),
createGame({ id: 'game-2', title: '第二款' }),
]);
const scrollTo = vi.fn();
const originalScrollTo = Object.getOwnPropertyDescriptor(
Element.prototype,
'scrollTo',
);
Object.defineProperty(Element.prototype, 'scrollTo', {
configurable: true,
writable: true,
value: scrollTo,
});
try {
const { container } = render(
<div className="platform-tab-panel">
<GameGalleryPage onOpenDetail={onOpenDetail} />
</div>,
);
expect(await screen.findByText('星轨防线')).toBeTruthy();
fireEvent.click(screen.getByRole('button', { name: /立即试玩/u }));
expect(onOpenDetail).not.toHaveBeenCalled();
expect(scrollTo).toHaveBeenCalledTimes(1);
expect(scrollTo.mock.instances[0]).toBe(
container.querySelector('.platform-tab-panel'),
);
expect(scrollTo.mock.calls[0]?.[0]).toMatchObject({
behavior: 'smooth',
});
} finally {
if (originalScrollTo) {
Object.defineProperty(Element.prototype, 'scrollTo', originalScrollTo);
} else {
delete (Element.prototype as { scrollTo?: unknown }).scrollTo;
}
}
});
});
describe('GamePlayPage 启动超时与旋转提示', () => {
it('启动超时后给出重试与返回详情,重试可恢复可玩', async () => {
it('启动超时后给出重试与返回,重试可恢复可玩', async () => {
vi.useFakeTimers();
try {
const onBack = vi.fn();
@@ -395,7 +439,7 @@ describe('GamePlayPage 启动超时与旋转提示', () => {
vi.advanceTimersByTime(GAME_PLAY_STARTUP_TIMEOUT_MS);
});
expect(screen.getByText(/启动超时/u)).toBeTruthy();
expect(screen.getByRole('button', { name: /返回详情/u })).toBeTruthy();
expect(screen.getByRole('button', { name: /^返回$/u })).toBeTruthy();
fireEvent.click(screen.getByRole('button', { name: /^重试$/u }));
const retriedFrame = screen.getByTitle('星轨防线 在线游玩');
@@ -145,6 +145,7 @@ export function GameGalleryPage({
const [error, setError] = useState('');
const requestSerialRef = useRef(0);
const scrollContainerRef = useRef<HTMLElement | null>(null);
const gameListRef = useRef<HTMLDivElement | null>(null);
const hasRestoredScrollRef = useRef(false);
/**
* 顶栏搜索词只在「真的变了」时覆盖目录筛选。
@@ -167,6 +168,31 @@ export function GameGalleryPage({
container instanceof HTMLElement ? container : null;
}, []);
/**
* 首屏「立即试玩」只把列表滚进视野,不替用户选定某一款游戏。
*
* 滚动容器是平台页签面板而不是窗口,所以按容器相对位置计算,缺少
* `scrollTo` 时退化成直接赋值,保证无动画环境也能生效。
*/
const scrollToGameList = useCallback(() => {
const anchor = gameListRef.current;
if (!anchor) return;
const container = scrollContainerRef.current;
if (!container) {
anchor.scrollIntoView?.({ behavior: 'smooth', block: 'start' });
return;
}
const nextScrollTop =
container.scrollTop +
anchor.getBoundingClientRect().top -
container.getBoundingClientRect().top;
if (typeof container.scrollTo === 'function') {
container.scrollTo({ top: nextScrollTop, behavior: 'smooth' });
return;
}
container.scrollTop = nextScrollTop;
}, []);
const loadGames = useCallback(() => {
const requestSerial = ++requestSerialRef.current;
setIsLoading(true);
@@ -231,7 +257,6 @@ export function GameGalleryPage({
() => games.filter((game) => matchesGameDeviceFilter(game, device)),
[device, games],
);
const featuredGame = useMemo(() => visibleGames[0] ?? null, [visibleGames]);
const hasActiveFilters =
activeKeyword.trim().length > 0 || category !== '全部' || device !== '全部';
const resetFilters = () => {
@@ -250,44 +275,12 @@ export function GameGalleryPage({
[onOpenDetail, scrollKey],
);
const hasPageActions = Boolean(onOpenPublish || onOpenMyGames);
return (
<div className="game-page" ref={attachPageRoot}>
<section className="game-hero">
<div className="game-hero__copy">
<span className="game-eyebrow">
<Sparkles aria-hidden="true" /> 陶泥儿游戏分发
</span>
<h1>
把灵感做成游戏,
<br />
<em>现在就开始玩。</em>
</h1>
<p>
来自 AGC
创作者的轻量游戏集合。打开网页即可游玩,每一次尝试都值得被看见。
</p>
<div className="game-hero__actions">
<PlatformActionButton
size="md"
shape="pill"
onClick={() => featuredGame && openDetail(featuredGame.id)}
>
<Gamepad2 aria-hidden="true" /> 立即试玩
</PlatformActionButton>
<span className="game-hero__hint">支持桌面端与移动端</span>
</div>
</div>
<div className="game-hero__orb" aria-hidden="true">
<span>✦</span>
</div>
</section>
<div className="game-toolbar">
<div className="game-toolbar__title">
<span className="game-eyebrow">发现好玩的</span>
<h2>游戏广场</h2>
</div>
<div className="game-toolbar__actions">
{hasPageActions ? (
<div className="game-page-actions">
{onOpenPublish ? (
<PlatformActionButton
size="sm"
@@ -307,6 +300,44 @@ export function GameGalleryPage({
我的游戏
</PlatformActionButton>
) : null}
</div>
) : null}
<section className="game-hero">
<div className="game-hero__copy">
<span className="game-eyebrow">
<Sparkles aria-hidden="true" /> 陶泥儿游戏分发
</span>
<h1>
把灵感做成游戏,
<br />
<em>现在就开始玩。</em>
</h1>
<p>
来自 AGC
创作者的轻量游戏集合。打开网页即可游玩,每一次尝试都值得被看见。
</p>
<div className="game-hero__actions">
<PlatformActionButton
size="md"
shape="pill"
onClick={scrollToGameList}
>
<Gamepad2 aria-hidden="true" /> 立即试玩
</PlatformActionButton>
<span className="game-hero__hint">支持桌面端与移动端</span>
</div>
</div>
<div className="game-hero__orb" aria-hidden="true">
<span>✦</span>
</div>
</section>
<div className="game-toolbar" ref={gameListRef}>
<div className="game-toolbar__title">
<span className="game-eyebrow">发现好玩的</span>
<h2>游戏广场</h2>
</div>
<div className="game-toolbar__actions">
<label className="game-search">
<Search aria-hidden="true" />
<input
@@ -126,7 +126,7 @@ export function GamePlayPage({ gameId, onBack }: GamePlayPageProps) {
return (
<div className="game-play-page">
<button type="button" className="game-back-button" onClick={onBack}>
<ArrowLeft /> 返回详情
<ArrowLeft /> 返回
</button>
<PlatformStatusMessage
tone="error"
@@ -219,7 +219,7 @@ export function GamePlayPage({ gameId, onBack }: GamePlayPageProps) {
重试
</button>
<button type="button" onClick={onBack}>
返回详情
返回
</button>
</PlatformStatusMessage>
) : null}
@@ -670,7 +670,7 @@ export function GamePublishPage({
return (
<div className="game-page game-publish-page">
<button type="button" className="game-back-button" onClick={onBack}>
<ArrowLeft /> 返回游戏广场
<ArrowLeft /> 返回
</button>
<PlatformStatusMessage tone="info" surface="platform">
<span>正在检查发布灰度…</span>
@@ -683,7 +683,7 @@ export function GamePublishPage({
return (
<div className="game-page game-publish-page">
<button type="button" className="game-back-button" onClick={onBack}>
<ArrowLeft /> 返回游戏广场
<ArrowLeft /> 返回
</button>
<div className="game-toolbar">
<div className="game-toolbar__title">
@@ -709,7 +709,7 @@ export function GamePublishPage({
return (
<div className="game-page game-publish-page">
<button type="button" className="game-back-button" onClick={onBack}>
<ArrowLeft /> 返回游戏广场
<ArrowLeft /> 返回
</button>
<div className="game-toolbar">
<div className="game-toolbar__title">
@@ -160,7 +160,7 @@ export function MyGamesPage({
return (
<div className="game-page">
<button type="button" className="game-back-button" onClick={onBack}>
<ArrowLeft /> 返回游戏广场
<ArrowLeft /> 返回
</button>
<div className="game-toolbar">
<div className="game-toolbar__title">
@@ -13,6 +13,13 @@
margin: 0 auto;
}
.game-page-actions {
display: flex;
justify-content: flex-end;
gap: 0.6rem;
/* 站点级动作放在 hero 上方的右上角,与 hero 之间留出间距。 */
margin-bottom: 1.1rem;
}
.game-hero {
position: relative;
display: flex;
@@ -494,7 +501,9 @@
display: flex;
flex-direction: column;
gap: 0.9rem;
min-height: 100%;
/* 游玩页是固定画布:撑满面板而不是撑高面板,页面本身永不滚动。 */
height: 100%;
min-height: 0;
padding: clamp(0.7rem, 1.8vw, 1.5rem);
}
.game-play-toolbar {
@@ -536,8 +545,8 @@
}
.game-player-shell {
position: relative;
flex: 1;
min-height: min(72vh, 48rem);
flex: 1 1 auto;
min-height: 0;
overflow: hidden;
border: 1px solid rgba(199, 101, 61, 0.24);
border-radius: 1.5rem;
@@ -549,7 +558,14 @@
inset: 0;
display: grid;
grid-template-columns: minmax(8rem, 15rem) minmax(0, 1fr);
/*
* safe:内容比容器高时退回 start,配合下面的兜底滚动,顶部标题与「开始游戏」
* 都不会像居中溢出那样被裁到滚不到的位置。
* 启动卡片是唯一允许内部滚动的兜底,页面与面板本身始终不滚动。
*/
align-items: center;
align-items: safe center;
overflow-y: auto;
gap: clamp(1.1rem, 4vw, 2.8rem);
padding: clamp(1rem, 4vw, 2.5rem);
background: linear-gradient(135deg, #1e1820, #302329);
@@ -581,7 +597,7 @@
display: block;
width: 100%;
height: 100%;
min-height: min(72vh, 48rem);
min-height: 0;
border: 0;
opacity: 0;
transition: opacity 220ms ease;
@@ -698,7 +714,9 @@
}
.game-player-launch {
grid-template-columns: 1fr;
/* safe:内容比容器高时退回 start,避免居中把顶部标题裁到滚不到的地方。 */
align-content: center;
align-content: safe center;
gap: 1rem;
text-align: center;
}
@@ -716,18 +734,50 @@
.game-play-toolbar__actions .game-play-device-hint {
display: none;
}
.game-player-shell,
.game-player-frame {
min-height: calc(100dvh - 8rem);
}
/*
* 矮视口(横屏手机)里播放器只剩很小的高度,压扁启动面板保证
* 「开始游戏」可达;页面依旧不滚动,只有这一块在极端情况下内部兜底。
*/
@media (max-height: 26rem) {
.game-player-launch {
grid-template-columns: minmax(0, 1fr);
/* 单行布局下由 align-content 决定纵向位置,同样必须用 safe。 */
align-content: center;
align-content: safe center;
gap: 0.5rem;
padding: 0.75rem 1rem;
text-align: center;
}
.game-player-launch .game-cover {
display: none;
}
.game-player-launch__copy p {
display: -webkit-box;
margin: 0.25rem 0 0.6rem;
overflow: hidden;
-webkit-box-orient: vertical;
-webkit-line-clamp: 2;
}
.game-player-loading,
.game-player-timeout {
font-size: 0.8rem;
}
}
.my-game-list {
display: grid;
/* 与画廊 .game-grid 同口径:轨道可以被压到内容最小宽度以下,否则长标题/长简介
会把单列轨道撑到视口之外,移动端面板出现横向滚动。 */
grid-template-columns: minmax(0, 1fr);
gap: 0.9rem;
}
.my-game-card {
display: flex;
/* 与 .game-card 同口径:卡片自身可被压缩并裁掉溢出,保证卡片永不宽于所在轨道。 */
min-width: 0;
overflow: hidden;
gap: 1rem;
align-items: stretch;
border: 1px solid var(--platform-surface-border);
@@ -756,6 +806,8 @@
gap: 0.6rem;
}
.my-game-card__title {
min-width: 0;
overflow-wrap: anywhere;
border: 0;
padding: 0;
background: transparent;
@@ -12,6 +12,7 @@ import { type ReactElement, type ReactNode, useState } from 'react';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import type { AuthUser } from '../../../packages/shared/src/contracts/auth';
import { pushAppHistoryPath } from '../../routing/activeAppPageRoutes';
import {
usePlatformWalletLifecycle,
usePlatformWalletStore,
@@ -898,6 +899,35 @@ describe('游戏发布灰度入口', () => {
);
});
it('游玩舞台的页签容器不滚动', async () => {
gameDistributionMock.getGame.mockResolvedValue(null);
const { container } = render(
<StatefulPlatformEntryFlowShell initialStage="game-play" />,
);
const panel = container.querySelector('.platform-tab-panel');
expect(panel?.classList.contains('platform-tab-panel--game-play')).toBe(
true,
);
expect(panel?.className).not.toContain('overflow-auto');
});
it('游戏广场等舞台仍保持页签容器滚动', async () => {
gameDistributionMock.listGames.mockResolvedValue([]);
const { container } = render(
<StatefulPlatformEntryFlowShell initialStage="games" />,
);
expect(await screen.findByText('游戏广场')).toBeTruthy();
const panel = container.querySelector('.platform-tab-panel');
expect(panel?.classList.contains('platform-tab-panel--game-play')).toBe(
false,
);
expect(panel?.className).toContain('overflow-auto');
});
it('灰度命中时广场展示发布入口', async () => {
loginAsAuthor();
gameDistributionMock.listGames.mockResolvedValue([]);
@@ -909,3 +939,76 @@ describe('游戏发布灰度入口', () => {
).not.toBeNull();
});
});
describe('游戏详情返回', () => {
function mockDetailGame() {
gameDistributionMock.getGame.mockResolvedValue({
id: 'game-1',
title: '星轨防线',
summary: '守住轨道城',
description: '一款测试游戏。',
category: '动作',
tags: ['塔防'],
coverColor: '#d77a51',
icon: '✦',
author: { id: 'author-1', name: '测试作者' },
deviceSupport: { desktop: true, mobile: true, touch: true },
status: 'published',
currentVersion: {
id: 'version-1',
version: '1.0.0',
entryUrl: 'https://play.example.test/releases/version-1/index.html',
sha256: 'sha256',
publishedAt: '2026-09-18T00:00:00.000Z',
controls: ['键盘'],
},
playCount: 12,
createdAt: '2026-09-18T00:00:00.000Z',
});
}
async function clickDetailBack() {
fireEvent.click(await screen.findByRole('button', { name: '返回' }));
}
it('返回按钮不再写死广场文案与目标,改走浏览器原生返回', async () => {
mockDetailGame();
window.history.replaceState(null, '', '/games/mine');
pushAppHistoryPath('/games/detail?id=game-1');
const backSpy = vi
.spyOn(window.history, 'back')
.mockImplementation(() => {});
const setSelectionStage = vi.fn();
render(
<PlatformEntryFlowShellImpl
selectionStage="game-detail"
setSelectionStage={setSelectionStage}
/>,
);
await clickDetailBack();
expect(backSpy).toHaveBeenCalledTimes(1);
expect(setSelectionStage).not.toHaveBeenCalled();
backSpy.mockRestore();
});
it('直接打开详情链接时返回兜底回游戏广场', async () => {
mockDetailGame();
window.history.replaceState(null, '', '/games/detail?id=game-1');
const setSelectionStage = vi.fn();
render(
<PlatformEntryFlowShellImpl
selectionStage="game-detail"
setSelectionStage={setSelectionStage}
/>,
);
await clickDetailBack();
expect(setSelectionStage).toHaveBeenCalledWith('games', {
path: '/games',
});
expect(window.location.pathname).toBe('/games');
});
});
@@ -22,6 +22,7 @@ import { PlatformProfileRechargeModal } from '../../../packages/shared/src/compo
import { PlatformProfileWalletLedgerModal } from '../../../packages/shared/src/components/PlatformProfileWalletLedgerModal';
import type { ProfileDashboardSummary } from '../../../packages/shared/src/contracts/runtime';
import {
hasAppHistoryBackEntry,
pushAppHistoryPath,
replaceAppHistoryPath,
} from '../../routing/activeAppPageRoutes';
@@ -457,6 +458,33 @@ export function PlatformEntryFlowShellImpl({
[setSelectionStage],
);
/**
* 详情页可能从广场或「我的游戏」进入,返回优先走浏览器原生返回,这样能回到
* 用户真正的来源页;直接打开详情链接等没有可回退的应用内历史时再兜底回广场。
*/
const backFromGameDetail = useCallback(() => {
if (hasAppHistoryBackEntry()) {
window.history.back();
return;
}
replaceAppHistoryPath('/games');
setSelectionStage('games', { path: '/games' });
}, [setSelectionStage]);
/** 游玩页同理,兜底是它自己的详情页,避免详情↔游玩互相塞历史条目。 */
const backFromGamePlay = useCallback(() => {
if (hasAppHistoryBackEntry()) {
window.history.back();
return;
}
const currentGameId = new URLSearchParams(window.location.search).get('id');
const path = currentGameId
? `/games/detail?id=${encodeURIComponent(currentGameId)}`
: '/games';
replaceAppHistoryPath(path);
setSelectionStage(currentGameId ? 'game-detail' : 'games', { path });
}, [setSelectionStage]);
const openEditorProject = useCallback(
(projectId: string, options?: { guide?: boolean; tool?: string }) => {
if (!isDesktopLayout) {
@@ -540,6 +568,11 @@ export function PlatformEntryFlowShellImpl({
selectionStage === 'game-play' ||
selectionStage === 'game-mine' ||
selectionStage === 'game-publish';
/**
* 游玩页自己撑满剩余高度,面板不能再滚动,否则移动端会出现整页可滚的
* 半屏播放器;其余舞台继续沿用面板滚动。
*/
const isGamePlayStage = selectionStage === 'game-play';
const gameSearchParams = new URLSearchParams(
typeof window === 'undefined' ? '' : window.location.search,
);
@@ -717,7 +750,7 @@ export function PlatformEntryFlowShellImpl({
<main
id={isCreationStage ? 'platform-tab-panel-create' : undefined}
className={`platform-tab-panel min-h-0 min-w-0 flex-1 overflow-auto ${isCreationStage || isProfileStage ? '' : 'px-3 py-4 sm:px-6 sm:py-6'}`}
className={`platform-tab-panel min-h-0 min-w-0 flex-1 ${isGamePlayStage ? 'platform-tab-panel--game-play' : 'overflow-auto'} ${isCreationStage || isProfileStage || isGamePlayStage ? '' : 'px-3 py-4 sm:px-6 sm:py-6'}`}
>
{isProfileStage ? (
<PlatformActiveProfileView
@@ -781,16 +814,11 @@ export function PlatformEntryFlowShellImpl({
) : selectionStage === 'game-detail' ? (
<GameDetailPage
gameId={gameId}
onBack={openGames}
onBack={backFromGameDetail}
onPlay={openGamePlay}
/>
) : (
<GamePlayPage
gameId={gameId}
onBack={() =>
gameId ? openGameDetail(gameId) : openGames()
}
/>
<GamePlayPage gameId={gameId} onBack={backFromGamePlay} />
)}
</Suspense>
) : (
+16 -6
View File
@@ -1173,7 +1173,22 @@ html[data-mobile-keyboard-open='true'] .platform-mobile-bottom-dock {
height: 100%;
overflow-y: auto;
overflow-x: hidden;
padding-right: 0.25rem;
/*
* 左右留白只由使用处的 `px-3` / `sm:px-6`(Tailwind 的 padding-inline)决定。
* 这里以前写死 `padding-right`,会压过 Tailwind 的 inline padding,让右边永远
* 比左边小一截(移动端实测左 12/右 0),页面看着不居中。
*/
}
/*
* 游玩页是固定画布:面板不滚动,播放器撑满剩余高度,移动端也不会整页可滚。
* 必须带上 workbench 祖先一起写:`.platform-desktop-shell--workbench .platform-tab-panel`
* 的 `overflow: auto` 是 (0,2,0),只写一个类会被它压掉(实测 computed overflow-y 仍是 auto)。
*/
.platform-tab-panel--game-play,
.platform-desktop-shell--workbench
.platform-tab-panel.platform-tab-panel--game-play {
overflow: hidden;
}
.platform-tab-panel--hidden {
@@ -11441,10 +11456,6 @@ button.image-canvas-editor__reference-chip:disabled {
font-size: 0.62rem;
}
.platform-tab-panel {
padding-right: 0;
}
.platform-mobile-topbar {
min-height: 2.75rem;
}
@@ -12737,7 +12748,6 @@ button.image-canvas-editor__reference-chip:disabled {
.platform-desktop-shell--workbench .platform-tab-panel {
height: 100%;
overflow: auto;
padding-right: 0;
}
.platform-desktop-shell--workbench .creation-landing {
+13
View File
@@ -3,6 +3,7 @@
import { describe, expect, it } from 'vitest';
import {
hasAppHistoryBackEntry,
isAppHistoryState,
isKnownMainAppPagePath,
pushAppHistoryPath,
@@ -75,4 +76,16 @@ describe('appPageRoutes', () => {
expect(window.location.pathname).toBe('/project');
expect(window.location.search).toContain('hostShell=tauri_desktop');
});
it('只有应用内推送过上一页时才算可原生返回', () => {
window.history.replaceState(null, '', '/games/detail?id=game-1');
expect(hasAppHistoryBackEntry()).toBe(false);
window.history.replaceState(null, '', '/games/mine');
pushAppHistoryPath('/games/detail?id=game-1');
expect(hasAppHistoryBackEntry()).toBe(true);
replaceAppHistoryPath('/games/detail?id=game-2');
expect(hasAppHistoryBackEntry()).toBe(true);
});
});
+26 -2
View File
@@ -26,6 +26,7 @@ const ROUTE_STAGE_BY_PATH = new Map(
const APP_RUNTIME_CONTEXT_QUERY_KEYS =
HOST_BRIDGE_PRESERVED_RUNTIME_CONTEXT_QUERY_KEYS;
export const APP_HISTORY_STATE_KEY = '__genarrativeAppHistoryEntry';
const APP_HISTORY_STATE_DEPTH_KEY = '__genarrativeAppHistoryDepth';
export function normalizeAppPath(pathname: string) {
const trimmedPathname = pathname.trim().toLowerCase();
@@ -92,7 +93,7 @@ function writeAppHistoryPath(path: string, mode: 'push' | 'replace') {
return;
}
const state = buildAppHistoryState();
const state = buildAppHistoryState(mode);
if (mode === 'replace') {
window.history.replaceState(state, '', nextRelativeUrl);
return;
@@ -108,14 +109,37 @@ export function isAppHistoryState(state: unknown) {
);
}
function buildAppHistoryState() {
/**
* 当前历史条目是不是应用内导航写入的、且存在可回退的上一页。
* 直接打开深链时浏览器条目不是应用写入的,此时返回需要走兜底路径,
* 否则 `history.back()` 会直接退出应用。
*/
export function hasAppHistoryBackEntry() {
const state = window.history.state;
if (!isAppHistoryState(state)) {
return false;
}
return readAppHistoryDepth(state as Record<string, unknown>) > 0;
}
function readAppHistoryDepth(state: Record<string, unknown>) {
const depth = state[APP_HISTORY_STATE_DEPTH_KEY];
return typeof depth === 'number' && Number.isFinite(depth) ? depth : 0;
}
function buildAppHistoryState(mode: 'push' | 'replace') {
const currentState =
window.history.state && typeof window.history.state === 'object'
? window.history.state
: {};
const currentDepth = readAppHistoryDepth(
currentState as Record<string, unknown>,
);
return {
...currentState,
[APP_HISTORY_STATE_KEY]: true,
[APP_HISTORY_STATE_DEPTH_KEY]:
mode === 'push' ? currentDepth + 1 : currentDepth,
};
}