feedback7 UIBACK: 准备就绪 folds the shop bar and cancelling the ready unfolds it again (GitHub #138 by blackyang40-cmd; the owner's decision of 2026-10-07 — dragging an operator by its body is not part of it): gameLogic readyShopFold over the confirmed m.private.ready, only a change inside one prep round counts (not a reconnect's first state, the new round's reset, a phase outside the prep); the enemy pen keeps the state it returns to; test/ui/ready-fold.test.js + ready-fold.e2e.test.js; golden: unchanged (client only)

This commit is contained in:
sganggs committed 2026-10-08 19:13:11 +08:00
1 parent 911e4bff2b
commit 7fb41f4ff6
6 files changed
+153 -4

No files matched your search

+1 -1
View File
@@ -19,7 +19,7 @@
| 确认本局信息 | 本局地图(标准模拟固定为「战场#01」;险境从 8 张地图中随机,绝境 / 终极从不含战场#01 的 7 张中随机)、敌方派系(三种特训敌人类型,如折射、飞行:和官方一样,每种占全局 15 个回合位中的 3 个,第 14 回合领袖战和第 15 回合隐秘核心也各占一个位,所以险境 / 绝境 / 终极第 1–13 回合中有 7–9 波是这三种类型、每种最多 3 波,其余各波是「特异」类型;每波只有一种类型)、**被禁用的盟约及其干员**、领袖预告。所有人确认后继续;同盟模拟最多 25 秒;只有你一名玩家时(独立模拟,或同盟模拟只带 AI 队友)不限时。 |
| 选择策略 | 40 名策略(分队长)决定**初始目标生命值(20–45)**和一条永久规则。同盟:随机顺序轮流选,**每人 30 秒**,右上角和「决策中」显示同一个倒计时;AI 队友立即选完;队友已选的不能重复,每人可**跳过一次**(排到最后);超时自动选你**当前选中**的策略(已被队友选走则选华法琳)。独立:自由选择、不限时;同盟模拟只有你一名玩家时也不限时。选的时候可以点左侧决策顺序下方的「查看禁用盟约与干员」,再看一遍「确认本局信息」里的盟约(置灰、禁用人数)和本局禁用干员:只能查看,倒计时照常走,底部也显示轮到谁和剩余秒数;点「关闭」、点窗口外面或按 Esc 关闭,轮到下一位时会自动关闭。 |
| 机变 | 部分回合开始前出现:同盟共 6 张卡轮流各取 1 张(首位 30 秒,其余 16 秒;只有你一名玩家时不限时),独立 3 选 1。卡片包括装备、资金、干员、盟约层数、悬赏等;**悬赏决策**为你的作战添加悬赏敌人,卡上用蓝字标出持续多久:「下场作战」/「接下来两场作战」的只出现一场或两场,之后不再出现;官方的「多轮悬赏」(原文写「之后的每场作战」)在这里也按「接下来两场作战」处理,只出现两场(如教鞭的法术大师A2·多轮战术特训)。各回合的悬赏决策按官方对局截图来出:第 3 回合是 6 张「接下来两场作战」的悬赏(3 张 1 资金、2 张 2 资金、1 张 3 资金);第 9 回合是各首领的「下场作战」悬赏和「源石虫·特训」;绝境 / 终极的第 11 回合多半是悬赏决策(也可能是机密商店或战术决策),其中的悬赏通常有 1 只特异巨兽,其余是各阵营的「下场战斗」悬赏。击倒悬赏敌人的人(联防中的队友也算)获得资金;右侧效果栏会写「还剩 N 场作战」。**机密商店**的 6 件装备都免费,和官方一样可能出现两件相同的装备(各算一张卡,可以分别被选走);第 11 回合(绝境 / 终极)的机密商店按官方:2 件 VI 阶、至少 1 件 V 阶和 1 枚盟约之币,标准 / 险境较早回合的机密商店各阶装备都可能出现。**战术决策**也和官方一样可能出现两张相同的卡(官方截图里出现过两张「补给」;各算一张卡,可以分别被选走,每张只生效一次,两人各选一张全队卡则效果叠加);第 11 回合(绝境 / 终极)的战术决策按官方截图只出盟誓、驰援、列装 / 财富 / 补给 / 整备 / 升华等增益卡,不出削弱敌人的卡和战场演变卡。每张卡左上是图标和名称,下面写着完整效果(与详情卡相同的官方描述,手机上也显示);已被选走的卡变暗,右上角显示选走它的玩家头像(悬停可看名字)。选卡要**点两次**:第一次只选中(金框 +「确认选择 · 再次点击」),再点同一张或点右上的「确认选择」才生效;点别的卡换选,按 Esc 或点空白处取消。 |
| 休整期 | 购买、摆阵、配装备、冻结 / 刷新、升级调度中心;同盟有倒计时(65–215 秒,随回合增加);独立模拟、以及只有你一名玩家(只带 AI 队友)的同盟模拟不限时。所有人「准备就绪」即提前开战,全员就绪前可取消。 |
| 休整期 | 购买、摆阵、配装备、冻结 / 刷新、升级调度中心;同盟有倒计时(65–215 秒,随回合增加);独立模拟、以及只有你一名玩家(只带 AI 队友)的同盟模拟不限时。所有人「准备就绪」即提前开战,全员就绪前可取消;点「准备就绪」时商店自动收起、棋盘放大到全场,取消准备时商店再展开(资金旁的「收起」和商店标签「展开商店」随时可以手动切换)。 |
- **特殊地形点一下就有说明**:战场上的**活性源石**、**沼泽**、**排气格栅**、**深水区**,以及**红门 / 蓝门 / 传送点**,点一下就会弹出它自己的说明卡——名称、机制,以及**这一关自己的数值**(比如战场#04 的活性源石:每秒 70 点真实伤害、攻击力 +20%、攻击速度 +20、持续 300 秒)。休整期和作战期都能点。普通地板 / 道路 / 高台点了不会弹东西,所以点一下仍然只是「取消选中、关掉卡片」。
- **场地装置点一下也有说明**:**阻隔工事**(挡路:地面敌人绕着走,无路可走才撞碎它;作战中说明卡上有它的实时血量,被摧毁后点不出来)、**“双眼皮”炮台**(自动法术攻击、命中附加脆弱,按当前层数最高的盟约强化;还没被机械援助或机变卡启用的炮台不显示、点不出来)、**射击台**(远程位可部署的高台位)和**源石流发生装置**(吹风方向,以及对干员攻击力、敌人移动速度的加减)点一下同样弹出说明卡,数值都取自所在关卡的装置数据,装置名称按当前语言显示官方译名;被机变卡移除的箱子同样不显示。休整期和作战期都能点(GitHub #228,PR #229,感谢 @rickylxw)。
| 作战 | 每人在自己的战场各自作战(战斗在你的浏览器里模拟,2 倍速)。干员按**逐列从左到右、每列从上到下**的顺序(官方「从上到下>从左到右」:最左一列最先)自动部署并消耗部署费用(初始 10,+1/秒)。时限 45–115 秒(实际秒数,即屏幕上的倒计时),时间到仍存活的敌人算漏怪。有敌人进入蓝门时,顶栏的目标生命值会**立即**显示扣除后的数值(红字 + 「−N」,每回合至多 10 点),结算时确定;联防阶段标注「联防中」,队友拦下的部分最后不扣。左侧队伍栏同样显示队友的待扣生命值。被击倒的干员**留在倒下的格子上**(倒地姿势、略微变灰),之后也在这一格再部署(与官方一样;倒在其他干员或召唤物初始位置上的,会回到自己的初始位置躺下,召唤物已经消失也一样;唯一的例外是乌尔比安:三技能把他移到船锚处期间被击倒时,他回到自己的部署位躺下、再部署——这是项目维护者 2026-10-07 应社区反馈有意做出的改动,与官方「原地倒下」不同);因自身效果强制退场的干员(史尔特尔「余烬」结束、耀骑士临光 2 技能结束等)同样倒在原地、在原地再部署;倒地干员所在的格子**不会被占用**,突袭盟约的再部署、援军、召唤物都会避开它。头上的圆圈是再部署倒计时:绿色圆环 + 秒数;变成黄色「DP」表示部署费用不够。血条下方的一行是**元素损伤**(与原版一致:元素图标 + 白条):干员为带图标的彩色圆盘,敌人为小号纯色圆盘,右侧白条是剩余元素值,像血条一样从右往左减少,白条耗尽的那一下就是爆条(被大额元素损伤一次打掉一截时白条也会一次少一截,例如掠海漂移体每击约为 25%–50%);爆条后进入爆发冷却(灼燃 / 神经 / 侵蚀 10 秒,敌方侵蚀 8 秒,凋亡 15 秒),这时白条变成**元素颜色**(神经青绿、侵蚀蓝、灼燃橙红、凋亡灰紫)并在冷却期间慢慢回满、图标闪烁——彩色条表示已经爆条、正在承受爆发效果,不是还剩的元素值;期间所有元素都不会累积也无法被治疗,结束后清零。元素爆发造成的是**无来源伤害**,不吃干员的增伤、穿透和装备加成(例如深巡打海嗣时灼燃爆发固定 7000),击杀仍记在爆条的干员名下;元素脆弱只提高元素伤害,不加快元素条;脆弱不影响元素伤害。已被击杀的敌人不会再被附加元素损伤。 |
+13 -1
View File
@@ -99,7 +99,7 @@ import {
terrainInfo, deviceInfo, deviceTipAt, noteDeviceUnits,
countdownState, shopBlockReason, stageOverrides, effectiveStage, watchTarget, dropFailureReason,
previewEnemyKey, prepCamera, prepCameraFor, foldCamera, deployFieldOf, fieldTile, panelSide, panelSlots, bondPopupPlace, unitLoadout, deployedRecord,
mergeTarget, modeOffBonds, readyFundsPrompt, ownerBandId, ownDiyRecord, ownStandIn,
mergeTarget, modeOffBonds, readyFundsPrompt, readyShopFold, ownerBandId, ownDiyRecord, ownStandIn,
} from '../ui/gameLogic.js';
import { toast } from '../ui/toasts.js';
import { BriefingScreen } from './briefing.js';
@@ -289,6 +289,18 @@ function MatchScreen() {
// the shop bar is shown folded (收起; the pen folds it for itself: the player's own state is the one it returns to) —
// the own prep board then takes the official shop-collapsed camera (public issue #5, gameLogic prepCameraFor)
const shopFolded = showShop && (pen ? penRef.current.collapsed : collapsed);
// 准备就绪 folds the shop bar, cancelling it unfolds the bar again (GitHub #138; gameLogic readyShopFold): the board is set,
// the fight is what to look at — the board camera follows the fold like a hand-made one (the effect below). Inside the pen
// the bar is folded for itself: the state it returns to is the one that changes.
const readySeen = useRef(null);
useEffect(() => {
const cur = phase === PHASE.PREP && priv && alive && !watchingOther ? { round: pub?.round, ready: !!priv.ready } : null;
const change = readyShopFold(readySeen.current, cur);
readySeen.current = cur;
if (!change) return;
if (penRef.current.on) penRef.current.collapsed = change === 'fold';
else setCollapsed(change === 'fold');
}, [phase, pub?.round, priv?.ready, !!priv, alive, watchingOther]);
const cancelFacingRef = useRef(() => {});
const setCam = useCallback((kind, opts) => {
camRef.current = { kind, opts: opts || {} };
+1 -1
View File
@@ -33,7 +33,7 @@ export { STATUS_META } from './gameLogic/format.js';
export { sortedPlayers } from './gameLogic/shared.js';
export { ownFieldId, homeFieldId, cycleField, watchTarget, switcherLabel, fieldLabel, activeBubbles } from './gameLogic/watch.js';
export { sortBonds, bondTier, nextThreshold, grantedBonds, pieceBondIds, morphPairings, HARMONY_BOND, harmonyMembers, bondMembers, memberHeadCount, bannedPerBond, disabledBondSets, briefingBondTip, modeOffBonds, bandOffBonds, bandOffLine } from './gameLogic/bonds.js';
export { priceTone, mergeProgress, mergeTarget, handFull, completesMerge, offerHeader, shopBlockReason, readyFundsPrompt } from './gameLogic/shop.js';
export { priceTone, mergeProgress, mergeTarget, handFull, completesMerge, offerHeader, shopBlockReason, readyFundsPrompt, readyShopFold } from './gameLogic/shop.js';
export { deploySets, stageOverrides, deployMap, effectiveStage, indexPieces, placementContext, piecePosition, tileAllows, summonRange, summonExcluded, canPlace, equipReplaces, equipMerges, boardTargets, dropIntent, dropFailureReason } from './gameLogic/placement.js';
export { terrainInfo, deviceInfo, deviceTipAt, noteDeviceUnits } from './gameLogic/terrain.js';
export { normalizeDraft, normalizeSp } from './gameLogic/draft.js';
+17 -1
View File
@@ -1,4 +1,5 @@
// ui/gameLogic/shop.js — shop prices, merges, offers, the ready-funds prompt. Re-exported from ../gameLogic.js.
// ui/gameLogic/shop.js — shop prices, merges, offers, the ready-funds prompt, the shop bar's fold on 准备. Re-exported from
// ../gameLogic.js.
import { GEO } from '../../../../shared/constants.js';
import { isObj } from './shared.js';
@@ -183,3 +184,18 @@ export function readyFundsPrompt(priv, { ready = true, keptBands = null, autopla
text: t('还有 {funds} 资金未使用。休整期结束时,本回合的剩余资金将清零。确定准备就绪吗?', { funds }),
};
}
/**
* The shop bar follows the player's ready state (GitHub #138, the owner's decision of 2026-10-07): pressing 准备就绪 folds
* the bar — the board is set, the fight is what to look at —, cancelling it unfolds the bar again. Read from the own CONFIRMED
* state (m.private.ready — a refused 准备 folds nothing) and only a change inside one prep counts: the first state seen (a
* reconnect in the middle of a prep, the prep's start), the new round's reset to not ready and anything outside a prep are
* not a press. The player can still fold / unfold by hand in between.
* @param {{ round: any, ready: boolean }|null|undefined} prev the own prep state seen last (null: none — outside a prep)
* @param {{ round: any, ready: boolean }|null|undefined} cur the own prep state now (null outside a prep)
* @returns {'fold'|'unfold'|null}
*/
export function readyShopFold(prev, cur) {
if (!isObj(prev) || !isObj(cur) || prev.round !== cur.round || !!prev.ready === !!cur.ready) return null;
return cur.ready ? 'fold' : 'unfold';
}
+89
View File
@@ -0,0 +1,89 @@
// Browser check of the shop bar's fold on 准备就绪 (GitHub #138; opt-in, ~10 s) on the in-match mock harness:
// SP_E2E=1 CHROME_PATH=… node --test test/ui/ready-fold.e2e.test.js
//
// In prep, 准备就绪 folds the shop bar (the 展开商店 tab, the screen's `is-collapsed`), 取消准备 unfolds it again; a fold made
// by hand before the press is not undone by it going the other way round (the player folds, readies, cancels: unfolded
// — the owner's wording is 「取消准备时再展开」); the bar can still be unfolded by hand while ready. The decision itself
// (only a change inside one prep round counts) is unit-tested in test/ui/ready-fold.test.js.
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 shop bar folds on 准备就绪', { 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();
});
const state = (page) => page.evaluate(() => ({
folded: !!document.querySelector('.shopbar-tab'),
cls: document.querySelector('.gm')?.classList.contains('is-collapsed') ?? null,
ready: document.querySelector('.readybtn')?.getAttribute('aria-pressed') === 'true',
label: document.querySelector('.readybtn__label')?.textContent ?? null,
}));
const press = (page, sel) => page.evaluate((s) => document.querySelector(s)?.click(), sel);
/** 准备就绪 with funds left asks first (gameLogic readyFundsPrompt): answer it; 取消准备 asks nothing. */
const ready = async (page) => {
await press(page, '.readybtn');
await sleep(250);
await page.evaluate(() => [...document.querySelectorAll('.modal button')].find((b) => /准备就绪/.test(b.textContent))?.click());
};
test('ready folds the shop bar, cancelling unfolds it; the tab still unfolds it by hand', 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}/dev/game-mock.html?phase=PREP&render=fallback`, { waitUntil: 'networkidle0', timeout: 60000 });
await page.waitForSelector('.readybtn', { timeout: 15000 });
await sleep(500);
let s = await state(page);
assert.deepEqual([s.folded, s.cls, s.ready], [false, false, false], 'the shop starts open');
await ready(page);
await page.waitForSelector('.shopbar-tab', { timeout: 5000 });
s = await state(page);
assert.deepEqual([s.folded, s.cls, s.ready, s.label], [true, true, true, '取消准备'], '准备就绪 folded the shop bar');
// the player unfolds by hand while ready: it stays as they left it
await press(page, '.shopbar-tab__btn');
await sleep(300);
s = await state(page);
assert.deepEqual([s.folded, s.ready], [false, true]);
await ready(page); // 取消准备 with the bar already open: it stays open
await sleep(300);
s = await state(page);
assert.deepEqual([s.folded, s.ready], [false, false]);
// fold by hand, ready, cancel: unfolded again (「取消准备时再展开」)
await press(page, '.funds__collapse');
await page.waitForSelector('.shopbar-tab', { timeout: 5000 });
await ready(page);
await sleep(400);
s = await state(page);
assert.deepEqual([s.folded, s.ready], [true, true], 'still folded after the press');
await ready(page);
await sleep(400);
s = await state(page);
assert.deepEqual([s.folded, s.ready], [false, false], 'cancelling unfolds it');
assert.deepEqual(problems, []);
await page.close();
});
});
+32
View File
@@ -0,0 +1,32 @@
// GitHub #138 (the owner's decision of 2026-10-07: 「点准备后商店自动收起,取消准备时再展开」): pressing 准备就绪 folds the shop
// bar in prep, cancelling the ready unfolds it. The decision is gameLogic readyShopFold(prev, cur) over the own CONFIRMED
// ready state (m.private.ready): only a change inside one prep round counts. The screen's effect applies it
// (screens/game.js; the browser check is test/ui/ready-fold.e2e.test.js).
import { test } from 'node:test';
import assert from 'node:assert/strict';
const { readyShopFold } = await import('../../public/js/ui/gameLogic.js');
test('readyShopFold: ready folds, cancelling unfolds — inside one prep round', () => {
assert.equal(readyShopFold({ round: 6, ready: false }, { round: 6, ready: true }), 'fold');
assert.equal(readyShopFold({ round: 6, ready: true }, { round: 6, ready: false }), 'unfold');
assert.equal(readyShopFold({ round: 6, ready: false }, { round: 6, ready: false }), null, 'nothing changed');
assert.equal(readyShopFold({ round: 6, ready: true }, { round: 6, ready: true }), null, 'nothing changed');
// a truthy / falsy flag counts like a boolean (m.private carries true / false, a missing one is not ready)
assert.equal(readyShopFold({ round: 6, ready: undefined }, { round: 6, ready: 1 }), 'fold');
assert.equal(readyShopFold({ round: 6, ready: 1 }, { round: 6, ready: 0 }), 'unfold');
});
test('readyShopFold: the first state seen, a new round\'s reset and anything outside a prep are no press', () => {
// a reconnect in the middle of a prep (or the prep's start): no state before it
assert.equal(readyShopFold(null, { round: 6, ready: true }), null);
assert.equal(readyShopFold(undefined, { round: 6, ready: false }), null);
// the next round starts not ready (the server resets it): not a 取消准备
assert.equal(readyShopFold({ round: 6, ready: true }, { round: 7, ready: false }), null);
assert.equal(readyShopFold({ round: 6, ready: false }, { round: 7, ready: true }), null, 'a new round never reads as a press either');
// leaving the prep (combat, the draft, the observers): the screen passes null
assert.equal(readyShopFold({ round: 6, ready: true }, null), null);
assert.equal(readyShopFold(null, null), null);
assert.equal(readyShopFold('x', { round: 6, ready: true }), null);
});