diff --git a/docs/PLAYING.md b/docs/PLAYING.md index fd7f1e49..75ff4ae3 100644 --- a/docs/PLAYING.md +++ b/docs/PLAYING.md @@ -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),击杀仍记在爆条的干员名下;元素脆弱只提高元素伤害,不加快元素条;脆弱不影响元素伤害。已被击杀的敌人不会再被附加元素损伤。 | diff --git a/public/js/screens/game.js b/public/js/screens/game.js index 727f7c67..f836cb23 100644 --- a/public/js/screens/game.js +++ b/public/js/screens/game.js @@ -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 || {} }; diff --git a/public/js/ui/gameLogic.js b/public/js/ui/gameLogic.js index 8af6d7ae..152ee3ee 100644 --- a/public/js/ui/gameLogic.js +++ b/public/js/ui/gameLogic.js @@ -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'; diff --git a/public/js/ui/gameLogic/shop.js b/public/js/ui/gameLogic/shop.js index 3e1e4590..e9231fe4 100644 --- a/public/js/ui/gameLogic/shop.js +++ b/public/js/ui/gameLogic/shop.js @@ -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'; +} diff --git a/test/ui/ready-fold.e2e.test.js b/test/ui/ready-fold.e2e.test.js new file mode 100644 index 00000000..774d5c83 --- /dev/null +++ b/test/ui/ready-fold.e2e.test.js @@ -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(); + }); +}); diff --git a/test/ui/ready-fold.test.js b/test/ui/ready-fold.test.js new file mode 100644 index 00000000..c53abbdc --- /dev/null +++ b/test/ui/ready-fold.test.js @@ -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); +});