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.
This commit is contained in:
1 parent
94b6f6ac14
commit
ede8e7e773
5 files changed
+127
-3
No files matched your search
@@ -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).
|
||||
|
||||
@@ -94,6 +94,8 @@
|
||||
|
||||
干员属于 1–2 个盟约。场上**不同名**的成员达到人数后激活(大多只数棋盘上的,「远见」「奇迹」「投资人」连整备区也算)。
|
||||
|
||||
盟约栏挡住棋盘时,点左侧的「收起」隐藏列表,再点「盟约」展开。收起会关闭从盟约栏打开的详情,原来图标覆盖的位置可以直接点选或拖动棋子;本局切换回合、查看队友时保留收起状态。
|
||||
|
||||
- **核心盟约**(8 个势力:炎、萨尔贡、维多利亚、谢拉格、拉特兰、阿戈尔、叙拉古、卡西米尔):3 名激活,6 名获得强力的额外效果。
|
||||
- **附加盟约**(精准、迅捷、灵巧、奥术、坚守、助力、远见、奇迹、投资人、突袭、不屈、调和、协防干员、独行、绝技):多为 2 / 3 名激活;「独行」需要场上**恰好 1 名**,「绝技」按场上的精锐数量计算,没有势力盟约的干员算「协防干员」。
|
||||
- **本局禁用**:「标准模拟」与官方相同,关闭拉特兰、阿戈尔、卡西米尔、灵巧、奥术、奇迹、投资人、突袭、独行、绝技这 10 个盟约,它们不会激活。你有这些盟约的成员时(奇迹、投资人也算整备区里的成员),盟约栏末尾显示**灰色带 ✕ 的盟约图标和「本局禁用」**,干员卡、盟约标签和盟约详情也这样标注。「远见」「奇迹」「投资人」计入整备区,盟约详情的「成员」人数也一样计入整备区里的成员(临时整备区不算)。
|
||||
|
||||
@@ -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); }
|
||||
|
||||
@@ -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} />
|
||||
|
||||
<div class="gm__bonds">
|
||||
<${BondStrip} bonds=${stripBonds} layersDisabled=${layersDisabled} openId=${bondPop && bondPop.ownerId === strip.ownerId ? bondPop.bondId : null}
|
||||
owner=${strip.name} onOpen=${(id) => openBond(id, strip.ownerId, 'strip')} />
|
||||
<button type="button" class="bonds-toggle" aria-expanded=${!bondsCollapsed} aria-controls="match-bond-strip"
|
||||
aria-label=${bondsCollapsed ? '展开盟约' : '收起盟约'} title=${bondsCollapsed ? '展开盟约' : '收起盟约'}
|
||||
onKeyDown=${(e) => {
|
||||
// Keep native Space activation here without also firing the global ready / pause shortcut.
|
||||
if (e.key === ' ') e.stopPropagation();
|
||||
}}
|
||||
onClick=${() => {
|
||||
if (!bondsCollapsed && bondOpen?.from === 'strip') setBondOpen(null);
|
||||
setBondsCollapsed(!bondsCollapsed);
|
||||
}}><${Icon} name=${bondsCollapsed ? 'chevronRight' : 'chevronLeft'} /><span>${bondsCollapsed ? '盟约' : '收起'}</span></button>
|
||||
<div id="match-bond-strip" class="gm__bond-list" hidden=${bondsCollapsed}>
|
||||
<${BondStrip} bonds=${stripBonds} layersDisabled=${layersDisabled} openId=${bondPop && bondPop.ownerId === strip.ownerId ? bondPop.bondId : null}
|
||||
owner=${strip.name} onOpen=${(id) => openBond(id, strip.ownerId, 'strip')} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<${TeamPanel} pub=${pub} myId=${myId} watching=${watchingNow} bubbles=${bubbles} onWatch=${watchPlayer} cap=${gd.config?.lpCapPerRound ?? 10} uniteLocal=${uniteLocal}
|
||||
|
||||
@@ -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(); }
|
||||
});
|
||||
}
|
||||
});
|
||||
Reference in new issue
Block a user