From ede8e7e773595339e5e0ab426196582b2aee1bcd Mon Sep 17 00:00:00 2001 From: Alsan Jayson Date: Mon, 5 Oct 2026 22:36:15 +0800 Subject: [PATCH] The bond bar can be folded, so the board stays tappable on phones (#149) The fold state stays for the match; Space does not ready or pause at the same time. Closes #142. Contributed by @CELLOPOI. --- docs/DESIGN.md | 2 + docs/PLAYING.md | 2 + public/css/screens/game.css | 12 +++- public/js/screens/game.js | 17 +++++- test/ui/bond-collapse.e2e.test.js | 97 +++++++++++++++++++++++++++++++ 5 files changed, 127 insertions(+), 3 deletions(-) create mode 100644 test/ui/bond-collapse.e2e.test.js diff --git a/docs/DESIGN.md b/docs/DESIGN.md index bd41ae84..9112a7ee 100644 --- a/docs/DESIGN.md +++ b/docs/DESIGN.md @@ -492,6 +492,8 @@ Look & feel: research 06 §11–§12 (near-black green-grey panels, mint `#4ed8a Screens: **Title** (season art backdrop, 开始 → nickname), **Lobby** (独立模拟 / 同盟模拟 → create or join; recent rooms), **Room** (4 seats, invite code + copy link, difficulty picker (host), add/remove AI, ready, start), **Briefing** (INFO_CHECK: stage, factions, disabled bonds, banned chess, boss silhouette, ready x/4), **Band draft** (grid of 40 bands with icon/name/LP/effect; turn order list; skip; 查看禁用盟约与干员 under the order list opens the briefing's bond rows, legend and banned operators again in a read-only dialog — one component, `ui/matchInfo.js`, §21.28), **Game** (Pixi field + DOM HUD: top bar with round/phase capsule/LP (live during the own battle, §17.5)/timer/ready; bond strip + popups; left team panel with LP/status/emote bubbles, click to watch; bottom shop bar (level card, 3–5 chess cards, item card, funds, refresh, freeze, collapse); detail panel on click/hover (header with bonds, then 特质 — the operator's own effect — right under it, 特性, stats (live: the battle's own numbers / the start-of-battle ones in prep, §18.5), skill, module, equipped items, talents: §17.5); enemy preview drawer (next round enemies with counts, factions; its 本局信息 tab lists the briefing's disabled bonds and banned operators, §21.28); 机变 overlay (the official card: framed icon + name, the effect text — data `descRaw` — under it, the taker's round avatar at the top-right; two taps, §18.2, §20.7); merge reward overlay (the promotion reward, or a special offer — 凯瑟琳's 定向投放 … — as item / chess cards under its source's label, with the count of offers queued behind it, §21.2); phase banners; ticker (by broadcast priority, highest first; a leader-damage line plays only in its round and a player's newer one replaces the older, §21.10); toasts; emote wheel; settings (volume, quality, show damage numbers)), **Result** (victory/defeat, rounds, per-player lineup, stats, titles, back to room). +盟约栏支持「收起/展开盟约」(GitHub #142):收起时隐藏完整列表并关闭由列表打开的浮层,让原图标区域的指针事件到达棋盘,只保留至少 44×44 CSS px 的恢复按钮。折叠状态在本局阶段变化、观看队友时保留;展开后仍按当前观看对象显示最新盟约。按钮支持 Enter / Space,聚焦按钮时 Space 不触发准备或暂停。 + A bond the mode never activates (标准: official `inactiveBondIdList`) reads 本局禁用 on shop / reward cards (struck through), detail chips and the popup — no 在场 count, no 当前效果 (§21.7); a strategy built around one carries a 本局禁用 tag and note in the strategy draft and stays selectable (bands.json `bondIds`, §21.26). Interactions (research 09 §1.2 / §5, which supersede the first draft): shop card, upgrade and 机变 card are two-tap (select → confirm, §18.2) — a tap anywhere on the card, its confirm strip included, is the card's tap (shop cards have no ⓘ corner; the armed bar sits above the detail card; a shop card that cannot be bought right now opens its detail on tap, §20.7); a press / drop goes by the tile under the pointer (§18.1); drag bench↔board — a legal board drop (also onto the piece's own tile) opens the 4-direction deploy wheel (swipe UP/RIGHT/DOWN/LEFT with the rotated range striped orange; release in the centre, ✕ 点击取消 or Esc cancels); tap a unit's tile ⇒ underframe with **出售 +N** (and **撤退** on the board; items/Arts in the hand / temp: **销毁** — equipped items are locked, §6.2) plus its rotated range; no drag-to-sell; drag an item onto a unit's tile ⇒ equip that unit (both slots used ⇒ the replace dialog `ui/equipReplace.js` shows the incoming and the two equipped items; the player picks the one to replace — it is destroyed — `g.equip replaceUid`; cancel changes nothing); Arts are dropped on a tile and get the direction step; right-click or long-press ⇒ detail (or a tap on a shop card while the bar is locked); `R` refresh, `F` freeze, `D` level-up, `Space` ready (in a solo battle: pause / resume, §14 Solo pause). All actions optimistic-free: UI waits for server state (latency is small). diff --git a/docs/PLAYING.md b/docs/PLAYING.md index 1235246c..65754748 100644 --- a/docs/PLAYING.md +++ b/docs/PLAYING.md @@ -94,6 +94,8 @@ 干员属于 1–2 个盟约。场上**不同名**的成员达到人数后激活(大多只数棋盘上的,「远见」「奇迹」「投资人」连整备区也算)。 +盟约栏挡住棋盘时,点左侧的「收起」隐藏列表,再点「盟约」展开。收起会关闭从盟约栏打开的详情,原来图标覆盖的位置可以直接点选或拖动棋子;本局切换回合、查看队友时保留收起状态。 + - **核心盟约**(8 个势力:炎、萨尔贡、维多利亚、谢拉格、拉特兰、阿戈尔、叙拉古、卡西米尔):3 名激活,6 名获得强力的额外效果。 - **附加盟约**(精准、迅捷、灵巧、奥术、坚守、助力、远见、奇迹、投资人、突袭、不屈、调和、协防干员、独行、绝技):多为 2 / 3 名激活;「独行」需要场上**恰好 1 名**,「绝技」按场上的精锐数量计算,没有势力盟约的干员算「协防干员」。 - **本局禁用**:「标准模拟」与官方相同,关闭拉特兰、阿戈尔、卡西米尔、灵巧、奥术、奇迹、投资人、突袭、独行、绝技这 10 个盟约,它们不会激活。你有这些盟约的成员时(奇迹、投资人也算整备区里的成员),盟约栏末尾显示**灰色带 ✕ 的盟约图标和「本局禁用」**,干员卡、盟约标签和盟约详情也这样标注。「远见」「奇迹」「投资人」计入整备区,盟约详情的「成员」人数也一样计入整备区里的成员(临时整备区不算)。 diff --git a/public/css/screens/game.css b/public/css/screens/game.css index cb101c1a..8f58f30b 100644 --- a/public/css/screens/game.css +++ b/public/css/screens/game.css @@ -220,12 +220,22 @@ .readybtn.is-busy { opacity: .7; } /* ---- bond strip ---------------------------------------------------------------------------------------- */ -.gm__bonds { position: absolute; left: 1.56rem; top: 1.36rem; right: 5.2rem; pointer-events: none; } +.gm__bonds { position: absolute; left: 1.56rem; top: 1.36rem; right: 5.2rem; pointer-events: none; + display: flex; align-items: flex-start; gap: max(.1rem, 8px); } /* Only the bond discs (and the +N chip) take the pointer: the strip's box spans the width under the top bar, where the top board row's units stand (row 12 at 16:9 / 21:9 / 720p / phones, the boss field's row 5 in a boss prep) — it used to swallow the taps and drags on them (review of user playtest #2 item 8: every own unit reachable). */ .gm__bonds > *, .gm__bonds .bstrip { pointer-events: none; } .gm__bonds .bslot, .gm__bonds .bstrip__more { pointer-events: auto; } +.gm__bond-list { min-width: 0; } +/* The hidden list leaves no hit area over the board; only this compact tab can reopen it (GitHub #142). */ +.gm__bonds .bonds-toggle { pointer-events: auto; flex: none; display: inline-flex; align-items: center; justify-content: center; + gap: max(.03rem, 2px); min-width: 44px; min-height: 44px; padding: .06rem .08rem; + color: var(--text-hi); background: var(--bg-1); border: 1px solid var(--line-2); cursor: pointer; + font-size: max(.14rem, 11px); line-height: 1; } +.bonds-toggle .icon { width: max(.18rem, 14px); height: max(.18rem, 14px); } +.bonds-toggle:hover { color: var(--mint-500); border-color: var(--mint-500); } +.bonds-toggle:focus-visible { outline: 2px solid var(--mint-500); outline-offset: 2px; } .bstrip { display: flex; align-items: flex-start; gap: .1rem; flex-wrap: nowrap; } .bstrip.is-frozen .bslot { opacity: .5; filter: saturate(.4); } .bstrip--empty { display: inline-flex; align-items: center; gap: .12rem; padding: .08rem .16rem; font-size: .15rem; color: var(--text-dim); background: rgba(8, 11, 10, .6); border: 1px dashed var(--line-2); } diff --git a/public/js/screens/game.js b/public/js/screens/game.js index 47a1d602..8a42291a 100644 --- a/public/js/screens/game.js +++ b/public/js/screens/game.js @@ -200,6 +200,7 @@ function MatchScreen() { const [watchWho, setWatchWho] = useState(null); // { fieldId, playerId }: the teammate picked with 前往查看 const [drawer, setDrawer] = useState(null); // 'enemies' | 'info' | null const [bondOpen, setBondOpen] = useState(null); // { id, ownerId, from }: the bond popup and whose bond it shows + const [bondsCollapsed, setBondsCollapsed] = useState(false); const [detail, setDetail] = useState(null); // detail target const [collapsed, setCollapsed] = useState(false); const [rewardMin, setRewardMin] = useState(false); @@ -1244,8 +1245,20 @@ function MatchScreen() { live=${liveLpNow} spectator=${spectator} />
- <${BondStrip} bonds=${stripBonds} layersDisabled=${layersDisabled} openId=${bondPop && bondPop.ownerId === strip.ownerId ? bondPop.bondId : null} - owner=${strip.name} onOpen=${(id) => openBond(id, strip.ownerId, 'strip')} /> + +
<${TeamPanel} pub=${pub} myId=${myId} watching=${watchingNow} bubbles=${bubbles} onWatch=${watchPlayer} cap=${gd.config?.lpCapPerRound ?? 10} uniteLocal=${uniteLocal} diff --git a/test/ui/bond-collapse.e2e.test.js b/test/ui/bond-collapse.e2e.test.js new file mode 100644 index 00000000..737e148b --- /dev/null +++ b/test/ui/bond-collapse.e2e.test.js @@ -0,0 +1,97 @@ +// Issue #142: the real match HUD in the mock harness, using the DOM field so optional art / WebGL is not required. +// SP_E2E=1 CHROME_PATH=/path/to/chrome node --test test/ui/bond-collapse.e2e.test.js +import { test, describe, before, after } from 'node:test'; +import assert from 'node:assert/strict'; +import { existsSync, mkdirSync } from 'node:fs'; +import { fileURLToPath } from 'node:url'; + +const CHROME = process.env.CHROME_PATH || '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome'; +const ENABLED = process.env.SP_E2E === '1' && existsSync(CHROME); +const OUT = fileURLToPath(new URL('../e2e/out/', import.meta.url)); + +describe('collapsible bond strip (issue #142)', { skip: !ENABLED && 'set SP_E2E=1 and CHROME_PATH to run' }, () => { + let srv, browser; + 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 }); + browser = await puppeteer.launch({ executablePath: CHROME, headless: true, args: ['--no-sandbox', '--no-proxy-server'] }); + mkdirSync(OUT, { recursive: true }); + }); + after(async () => { await browser?.close(); await srv?.close(); }); + + for (const [width, height, touch] of [[1920, 1080, false], [640, 360, true]]) { + test(`collapse releases field input and keeps current bonds at ${width}×${height}`, async () => { + const page = await browser.newPage(); + const errors = []; + page.on('pageerror', (e) => errors.push(e.message)); + const activate = (selector) => touch ? page.tap(selector) : page.click(selector); + const expanded = () => page.$eval('.bonds-toggle', (el) => el.getAttribute('aria-expanded')); + try { + await page.setViewport({ width, height, isMobile: touch, hasTouch: touch }); + await page.goto(`${srv.url}/dev/game-mock.html?shot=1&render=fallback&phase=PREP`, { waitUntil: 'domcontentloaded' }); + await page.waitForSelector('.ff-piece'); + await page.waitForSelector('.bslot .bond'); + assert.equal(await expanded(), 'true'); + const toggle = await page.$eval('.bonds-toggle', (el) => { + const r = el.getBoundingClientRect(); + return { width: r.width, height: r.height, controls: el.getAttribute('aria-controls') }; + }); + assert.ok(toggle.width >= 44 && toggle.height >= 44, 'visible touch target is at least 44×44'); + assert.equal(toggle.controls, 'match-bond-strip'); + const covered = await page.$eval('.bslot .bond', (el) => { + const r = el.getBoundingClientRect(); + return { x: r.x + r.width / 2, y: r.y + r.height / 2 }; + }); + await activate('.bslot .bond'); + await page.waitForSelector('.bpop'); + await activate('.bonds-toggle'); + await page.waitForSelector('#match-bond-strip', { hidden: true }); + assert.equal(await expanded(), 'false'); + assert.equal(await page.$('.bpop'), null, 'collapsing closes the strip popup'); + assert.equal(await page.$eval('.bonds-toggle', (el) => el.getAttribute('aria-label')), '展开盟约'); + assert.ok(await page.evaluate(({ x, y }) => !!document.elementFromPoint(x, y)?.closest('.gm__field'), covered), + 'the former bond position now hits the field'); + await page.evaluate(() => { + globalThis.__fieldPresses = 0; + document.querySelector('.gm__field').addEventListener('pointerdown', () => globalThis.__fieldPresses++); + }); + if (touch) await page.touchscreen.tap(covered.x, covered.y); + else await page.mouse.click(covered.x, covered.y); + assert.equal(await page.evaluate(() => globalThis.__fieldPresses), 1, 'pointer input reaches the field'); + await page.screenshot({ path: `${OUT}/bonds-collapsed-${width}.png` }); + + // Hidden content must keep following state changes, including the empty-bonds state. + await page.evaluate(() => globalThis.__MOCK__.mutate((s) => { s.priv.board = []; s.priv.hand.fill(null); })); + await activate('.bonds-toggle'); + await page.waitForSelector('.bstrip--empty', { visible: true }); + assert.equal(await expanded(), 'true'); + await page.focus('.bonds-toggle'); + await page.keyboard.press('Space'); + await page.waitForSelector('#match-bond-strip', { hidden: true }); + assert.equal(await page.evaluate(() => globalThis.__MOCK__.S().priv.ready), false, 'Space on the toggle does not ready the player'); + assert.equal(await page.$('.modal'), null, 'Space does not open the funds confirmation'); + await page.keyboard.press('Enter'); + await page.waitForSelector('.bstrip--empty', { visible: true }); + + await activate('.bonds-toggle'); + await activate('.team__row:not(.is-self) .team__btn'); + if (await page.$('.team__ob')) await activate('.team__ob'); + await page.waitForSelector('.gm__watching'); + assert.equal(await expanded(), 'false', 'watching a teammate preserves the collapse choice'); + await activate('.bonds-toggle'); + await page.waitForSelector('.bstrip__owner', { visible: true }); + assert.ok(await page.$eval('.bstrip', (el) => el.getAttribute('data-owner')), 'expanded bonds belong to the watched teammate'); + await activate('.bonds-toggle'); + await page.evaluate(() => globalThis.__MOCK__.setPhase('COMBAT')); + await page.waitForSelector('.gm--combat'); + assert.equal(await expanded(), 'false', 'phase changes preserve the collapse choice'); + await activate('.bonds-toggle'); + await page.waitForSelector('.bslot .bond', { visible: true }); + assert.equal(await expanded(), 'true'); + await page.screenshot({ path: `${OUT}/bonds-expanded-${width}.png` }); + assert.deepEqual(errors, []); + } finally { await page.close(); } + }); + } +});