From 911e4bff2b3225f2d65a84202416528e610a3242 Mon Sep 17 00:00:00 2001 From: sganggs Date: Thu, 8 Oct 2026 19:09:55 +0800 Subject: [PATCH] =?UTF-8?q?feedback7=20UIBACK:=20a=20=E8=AE=BE=E7=BD=AE=20?= =?UTF-8?q?button=20left=20of=20=E7=8E=A9=E6=B3=95=E8=AF=B4=E6=98=8E=20in?= =?UTF-8?q?=20the=20lobby's=20and=20the=20room's=20top=20bar=20(GitHub=20#?= =?UTF-8?q?238=20by=20RushFTK)=20=E2=80=94=20ui/settings.js=20SettingsButt?= =?UTF-8?q?on=20opens=20the=20same=20settings=20modal=20as=20the=20title?= =?UTF-8?q?=20screen=20and=20the=20match;=20no=20background-music=20option?= =?UTF-8?q?=20and=20no=20board-style=20option=20(the=20owner's=20decision?= =?UTF-8?q?=20of=202026-10-07:=20neither=20is=20official);=20test/ui/setti?= =?UTF-8?q?ngs-entry.e2e.test.js;=20golden:=20unchanged=20(client=20only)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/PLAYING.md | 2 +- public/css/screens/game.css | 4 ++ public/js/screens/lobby.js | 2 + public/js/screens/room.js | 2 + public/js/ui/settings.js | 17 +++++- test/ui/settings-entry.e2e.test.js | 86 ++++++++++++++++++++++++++++++ 6 files changed, 111 insertions(+), 2 deletions(-) create mode 100644 test/ui/settings-entry.e2e.test.js diff --git a/docs/PLAYING.md b/docs/PLAYING.md index b963941a..fd7f1e49 100644 --- a/docs/PLAYING.md +++ b/docs/PLAYING.md @@ -170,4 +170,4 @@ 刷新、冻结、升级、撤退、出售和准备只在休整期有效,撤退 / 出售要先点选一名干员。方向轮盘(方向键预览、`Enter` 确认、`Esc` 取消)、「玩法说明」和「干员调配」的翻页键是这些界面自己的按键,不能修改。 -**修改快捷键**:标题画面右上角或对局左下角的「设置」→「快捷键」。点一下要改的按键(只用键盘时按 Tab 移到它上面再按 Enter),出现「按下新按键…」后按新的键;按 `Esc` 取消,设置窗口不会关闭。可以用字母、数字、空格、标点符号键和 Delete / Insert / Home / End / PageUp / PageDown;`Esc`、`Tab`、`Enter`、方向键和 F1–F12 留给界面使用,也不能搭配 Ctrl / Alt / ⌘。新按键已被另一个快捷键使用时两者互换,列表下方会写明(例如把「撤退选中干员」改成 `X`,「出售选中干员」就换成原来的 `Q`)。「恢复默认」改回上表的按键。修改立即生效,商店栏、准备按钮和单位底部「撤退 / 出售」按钮上显示的按键也跟着变;设置和音量等一起保存在当前浏览器里(换浏览器或清除网站数据后回到默认)。按键按键帽上印的字符识别(AZERTY 等键盘按印着的字母),俄文等非拉丁布局按键位识别。手机等纯触屏设备要连接键盘才能用快捷键,设置里的快捷键列表默认折叠。 +**修改快捷键**:标题画面的「设置」按钮、大厅和房间顶栏的「设置」(玩法说明左边)或对局左下角的「设置」→「快捷键」。点一下要改的按键(只用键盘时按 Tab 移到它上面再按 Enter),出现「按下新按键…」后按新的键;按 `Esc` 取消,设置窗口不会关闭。可以用字母、数字、空格、标点符号键和 Delete / Insert / Home / End / PageUp / PageDown;`Esc`、`Tab`、`Enter`、方向键和 F1–F12 留给界面使用,也不能搭配 Ctrl / Alt / ⌘。新按键已被另一个快捷键使用时两者互换,列表下方会写明(例如把「撤退选中干员」改成 `X`,「出售选中干员」就换成原来的 `Q`)。「恢复默认」改回上表的按键。修改立即生效,商店栏、准备按钮和单位底部「撤退 / 出售」按钮上显示的按键也跟着变;设置和音量等一起保存在当前浏览器里(换浏览器或清除网站数据后回到默认)。按键按键帽上印的字符识别(AZERTY 等键盘按印着的字母),俄文等非拉丁布局按键位识别。手机等纯触屏设备要连接键盘才能用快捷键,设置里的快捷键列表默认折叠。 diff --git a/public/css/screens/game.css b/public/css/screens/game.css index be580299..6283006e 100644 --- a/public/css/screens/game.css +++ b/public/css/screens/game.css @@ -501,6 +501,10 @@ @keyframes spin { to { transform: rotate(360deg); } } /* ---- settings modal ------------------------------------------------------------------------------------------------ */ +/* the 设置 trigger of the lobby and the room (ui/settings.js SettingsButton, GitHub #238): the 玩法说明 button's twin; its gear sits + inside the label, so the label lays glyph and text out like the button itself */ +.settings-btn .btn__label { display: inline-flex; align-items: center; gap: .1rem; } +.settings-btn .btn__icon { color: var(--mint-500); } .set-list { display: flex; flex-direction: column; gap: .16rem; padding-top: .06rem; } .set-row { display: grid; grid-template-columns: 3.3rem 1fr auto; align-items: center; gap: .18rem; min-height: .44rem; } .set-row__label { display: flex; align-items: center; gap: .1rem; font-size: .18rem; color: var(--text-hi); white-space: nowrap; } diff --git a/public/js/screens/lobby.js b/public/js/screens/lobby.js index 5274507f..3a2c5042 100644 --- a/public/js/screens/lobby.js +++ b/public/js/screens/lobby.js @@ -16,6 +16,7 @@ import { DIFFICULTIES, DIFFICULTY_NAMES, DIFFICULTY_COLORS, ROOM_CODE_LEN, MAX_S import { html, Button, Icon, MicroLabel, Panel, TextField, PingPill, AvatarFrame, Tooltip, Spinner, DifficultyIcon, doctorNo } from '../ui/components.js'; import { toast, toastError } from '../ui/toasts.js'; import { GuideButton } from '../ui/guide.js'; +import { SettingsButton } from '../ui/settings.js'; import { LoadoutButton } from './loadout.js'; import { net, identity } from '../net.js'; import { store, useStore, shallowEqual, loadPref, savePref } from '../store.js'; @@ -311,6 +312,7 @@ export function LobbyScreen() {

${t('选择模拟协议')}

+ <${SettingsButton} class="lobby-settings" variant="secondary" label=${t('设置')} /> <${GuideButton} class="lobby-guide" variant="secondary" label=${t('玩法说明')} /> <${LoadoutButton} from="lobby" size="sm" class="lobby-loadout" label=${t('干员调配')} />
diff --git a/public/js/screens/room.js b/public/js/screens/room.js index 26fe4a2a..c144b9a6 100644 --- a/public/js/screens/room.js +++ b/public/js/screens/room.js @@ -19,6 +19,7 @@ import { import { toast, toastError } from '../ui/toasts.js'; import { copyText } from '../ui/clipboard.js'; import { GuideButton } from '../ui/guide.js'; +import { SettingsButton } from '../ui/settings.js'; import { LoadoutButton } from './loadout.js'; import { net } from '../net.js'; import { store, useStore, shallowEqual, emptyMatch, isSpectating } from '../store.js'; @@ -277,6 +278,7 @@ export function RoomScreen() { <${PingPill} ms=${conn.ping} online=${online} /> <${MicroLabel}>${t('当前延迟')}
+ <${SettingsButton} class="room-settings" variant="secondary" label=${t('设置')} /> <${GuideButton} class="room-guide" variant="secondary" label=${t('玩法说明')} />
diff --git a/public/js/ui/settings.js b/public/js/ui/settings.js index d7db406d..23d4f7c1 100644 --- a/public/js/ui/settings.js +++ b/public/js/ui/settings.js @@ -3,10 +3,12 @@ // the settings modal — which also holds the language switch (ui/lang.js; kept apart in `sp.pref.lang`; under it a note // while the current language's pack is a machine translation, `_meta.machineTranslated`) and the 快捷键 // section that rebinds the in-match shortcuts (the key map: ui/gameLogic/shortcuts.js; the community request -// 「快捷键可不可以自己设置」, the owner's decision of 2026-10-07). +// 「快捷键可不可以自己设置」, the owner's decision of 2026-10-07). The lobby and the room open it from a 设置 button next to +// 玩法说明 (SettingsButton, GitHub #238); the title screen and the match have their own gear. import { useLayoutEffect, useState } from '../../vendor/hooks.module.js'; import { html, Modal, Button, Icon, MicroLabel } from './components.js'; +import { GIcon } from './gameComponents.js'; import { createStore, useStore, loadPref, savePref } from '../store.js'; import { sanitizeSettings, HOTKEY_ACTIONS, DEFAULT_HOTKEYS, hotkeyLabel, rebindHotkey, isDefaultHotkeys, captureHotkey } from './gameLogic.js'; import { audio } from '../audio.js'; @@ -180,3 +182,16 @@ export function SettingsModal({ open, onClose }) {
`; } + +/** + * The 设置 button of the lobby and the room (GitHub #238 — before it the settings were reachable only from the title + * screen and a running match): the twin of the 玩法说明 button (ui/guide.js GuideButton), with the settings modal behind it + * (mounted only while open). The same modal as the title screen's and the match's: nothing in it is match-only. + * @param {{ class?: string, size?: 'sm'|'md'|'lg'|'xl', variant?: string, label?: string }} props + */ +export function SettingsButton({ class: cls, size = 'sm', variant = 'ghost', label = t('设置') }) { + const [open, setOpen] = useState(false); + return html`<${Button} variant=${variant} size=${size} class=${cx('settings-btn', cls)} onClick=${() => setOpen(true)} + title=${t('设置')} aria-label=${t('设置')} data-testid="settings-btn"><${GIcon} name="gear" class="btn__icon" />${label} + ${open ? html`<${SettingsModal} open=${true} onClose=${() => setOpen(false)} />` : null}`; +} diff --git a/test/ui/settings-entry.e2e.test.js b/test/ui/settings-entry.e2e.test.js new file mode 100644 index 00000000..0820ca34 --- /dev/null +++ b/test/ui/settings-entry.e2e.test.js @@ -0,0 +1,86 @@ +// Browser check of the settings entry of the lobby and the room (GitHub #238 — 设置 was reachable only from the title +// screen and a running match; opt-in, ~15 s): +// SP_E2E=1 CHROME_PATH=… node --test test/ui/settings-entry.e2e.test.js +// +// After 开始 the lobby's top bar carries a 设置 button left of 玩法说明 (ui/settings.js SettingsButton), and so does the +// room's after 创建同盟; each opens the very settings modal of the title screen (language, sound, 快捷键 …) and closes +// with Esc or 完成, leaving the screen as it was. No console / page errors. The modal itself and its contents are covered +// by the title screen's tests; this file is about the entry. Not in the modal, deliberately: background music in a +// hidden tab and the board style (the owner's decision of 2026-10-07 on #238). + +import { test, describe, before, after } from 'node:test'; +import assert from 'node:assert/strict'; +import { existsSync } from 'node:fs'; + +const CHROME = process.env.CHROME_PATH || '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome'; +const ENABLED = process.env.SP_E2E === '1' && existsSync(CHROME); +const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); + +describe('the settings entry of the lobby and the room', { skip: !ENABLED && 'set SP_E2E=1 (and have Chrome) to run' }, () => { + let srv; + let browser; + let base; + before(async () => { + const { startServer } = await import('../../server/index.js'); + const puppeteer = (await import('puppeteer-core')).default; + srv = await startServer({ port: 0, host: '127.0.0.1', quiet: true }); + base = `http://127.0.0.1:${srv.port}`; + browser = await puppeteer.launch({ executablePath: CHROME, headless: true, args: ['--no-sandbox', '--force-device-scale-factor=1'] }); + }); + after(async () => { + await browser?.close(); + await srv?.close(); + }); + + /** Is the 设置 button the sibling right before the 玩法说明 button of `screen`'s top bar? */ + const settingsBeforeGuide = (page, screen, guideClass) => page.evaluate((s, g) => { + const btn = document.querySelector(`${s} [data-testid="settings-btn"]`); + const guide = document.querySelector(`${s} .${g}`); + return !!btn && !!guide && btn.nextElementSibling === guide && /设置/.test(btn.textContent) && btn.querySelector('svg.btn__icon') != null; + }, screen, guideClass); + const modalText = (page) => page.evaluate(() => document.querySelector('.modal')?.textContent.replace(/\s+/g, ' ').trim() ?? null); + + test('the lobby: 设置 left of 玩法说明 opens the settings modal; Esc closes it; the room has the same entry', async () => { + const page = await browser.newPage(); + await page.setViewport({ width: 1600, height: 900 }); + const problems = []; + page.on('console', (m) => { if (m.type() === 'error') problems.push(`console: ${m.text()}`); }); + page.on('pageerror', (e) => problems.push(`pageerror: ${e.message}`)); + await page.goto(`${base}/`, { waitUntil: 'networkidle0', timeout: 60000 }); + await page.waitForSelector('input', { timeout: 15000 }); + await page.type('input', '测试博士'); + await page.evaluate(() => [...document.querySelectorAll('button')].find((b) => /开始/.test(b.textContent))?.click()); + await page.waitForSelector('.lobby-screen', { timeout: 15000 }); + await sleep(500); + + assert.ok(await settingsBeforeGuide(page, '.lobby-screen .topbar__right', 'lobby-guide'), '设置 sits right before 玩法说明 in the lobby\'s top bar'); + assert.equal(await modalText(page), null, 'closed until asked'); + await page.click('.lobby-screen [data-testid="settings-btn"]'); + await page.waitForSelector('.modal', { timeout: 5000 }); + const text = await modalText(page); + assert.match(text, /设置/); + assert.match(text, /背景音乐/); + assert.match(text, /快捷键/); + assert.doesNotMatch(text, /棋盘/, 'no board-style option (declined)'); + assert.doesNotMatch(text, /后台/, 'no background-music option (declined)'); + await page.keyboard.press('Escape'); + await sleep(300); + assert.equal(await modalText(page), null, 'Esc closes it'); + assert.ok(await page.$('.lobby-screen'), 'the lobby is still there'); + + // the room: 创建同盟 → the same button, closed with 完成 + await page.evaluate(() => [...document.querySelectorAll('button')].find((b) => /创建同盟/.test(b.textContent))?.click()); + await page.waitForSelector('.room-screen', { timeout: 15000 }); + await sleep(500); + assert.ok(await settingsBeforeGuide(page, '.room-screen .topbar__left', 'room-guide'), '设置 sits right before 玩法说明 in the room\'s top bar'); + await page.click('.room-screen [data-testid="settings-btn"]'); + await page.waitForSelector('.modal', { timeout: 5000 }); + assert.match(await modalText(page), /背景音乐/); + await page.evaluate(() => [...document.querySelectorAll('.modal button')].find((b) => /完成/.test(b.textContent))?.click()); + await sleep(300); + assert.equal(await modalText(page), null, '完成 closes it'); + assert.ok(await page.$('.room-screen'), 'the room is still there'); + assert.deepEqual(problems, []); + await page.close(); + }); +});