diff --git a/mengsan/assets/cards/README.md b/mengsan/assets/cards/README.md new file mode 100644 index 0000000..4666188 --- /dev/null +++ b/mengsan/assets/cards/README.md @@ -0,0 +1,41 @@ +# 梦三卡牌立绘规范 + +用户于 2026-10-02 指定以截图 `C:\Users\Diuse\Downloads\Ai\49.png` 中“防御”为同系列基准。 + +- 后续新增及转换的梦三卡牌使用约 5:7 的完整竖幅立绘;手牌常规尺寸 112×156,窄屏 82×114。禁止直接使用横向拆包图作为运行时卡面,也不要拉伸横图。 +- 画风:泛黄宣纸、黑褐水墨、飞白与淡赭色、中国古代人物或兵器;根据卡牌效果独立安排竖幅构图。拆包图只作题材参考,不修改拆包原件。 +- 立绘不绘制名称、费用、花色、强化角标或边框。左上花色点数、左侧中段费用、右下名称由游戏原生节点和梦三样式叠加;左侧信息栏和右下名称需留空间。 +- 卡牌定义使用 `fullimage: true` 与明确的 `image` 路径,不使用横图 `fullskin`。新增卡名须加入 `ui/hand/style.css` 的本地完整卡面选择器;长牌名同时加入长名称窄屏规则。保持 `cover`,不使用 `100% 100%` 拉伸。 +- 费用和强化角标继续使用现有 `data-*` 与伪元素,避免向卡牌子节点写入额外文字而触发十周年 UI 扫描。 +- 不改变卡牌费用、效果、强化、奖励与存档数据;不修改全局标准/军争牌或十周年 UI。 +- 接入后检查完整立绘、花色/费用/名称、扇形叠牌、悬停放大和窄屏。文件检查、隔离行为测试及预览不代表实机验收。 + +现有本地卡面检查:愤怒、飞剑回旋镖、剑柄打击、全身撞击、熔融之拳及晕眩统一竖幅;防御与重杀原有竖幅保留。 + +## 卡牌描述(2026-10-02) + +后续卡牌只描述当前费用与当前效果,不写是否可升级/强化、次数或上限,也不写升级前后对比或升级后变化。普通牌显示普通数值,已强化的牌直接显示其当前数值。此规范同步适用于悬停介绍、静态翻译、奖励摘要、牌库详情及磨砺选择说明;保留强化机制、操作按钮和紧凑 +X 角标。 + +闪电霹雳已按本规范接入竖幅水墨立绘。 + +双重打击:对应拆包 twin_strike.png 的双道斩击主题,使用同规格竖幅水墨立绘。1费,两次独立伤害;描述只显示当前牌数值。 + +铁斩波:对应拆包 iron_wave.png 的钢刃与防护波主题;竖幅水墨立绘。1费,先获得格挡再攻击,沿用护甲、脆弱、力量与易伤规则;描述只显示当前牌数值。 + +头槌:对应拆包 headbutt.png 的额头撞击主题;竖幅水墨立绘。1费,先攻击再选择一张个人弃牌置于个人抽牌堆顶部。移动原实体牌,保留ID、花色、强化与词缀,不新增永久牌。 + +突破:对应拆包 breakthrough.png 的突破包围与群体横扫主题;5:7竖幅宣纸水墨立绘。1费,先通过原生 loseHp 失去1点生命,再对所有存活敌方造成9点伤害。失去生命不抵扣格挡;等待正常濒死/救援结算,若角色死亡或战斗结束则停止攻击。描述只显示当前牌费用与效果。 + +预备打击:对应 setup_strike.png 的挥拳与蓄势主题;5:7竖幅宣纸水墨立绘。1费,先造成7点伤害,再在当前完整回合内获得2点临时力量。按网页主表而非未正式发布的更新历史实现;描述只显示当前效果。临时力量可叠加,独立于永久力量,当前完整回合结束/死亡/战斗退出时清理。 + +完美打击:对应 perfected_strike.png 的精准挥刀主题;全幅5:7竖幅宣纸水墨立绘。2费,基础6点伤害,本场战斗每张当前名称含“打击”的牌增加2点伤害。统计手牌、个人抽牌堆、个人弃牌堆、消耗牌及本人打出区的真实牌,去重并包含此牌自身;生成/复制/变形即时生效,不读取永久牌组、不统计公共或敌方牌。描述只显示当前牌费用与效果。 + +余烬:对应 cinder.png 的灼热弯刀与燃尽主题;5:7完整竖幅宣纸水墨立绘。2费,先造成18点伤害,再从当时自己的剩余手牌中随机消耗一张。遵循网页0.101.0后的手牌规则,不消耗抽牌堆顶部。通过原生失牌事件移入私有消耗区,不进入弃牌堆/洗牌,不更改永久牌组;没有手牌时仍造成伤害。描述只显示当前费用与效果。 + +放血:对应 bloodletting.png 的气血换取力量主题;完整5:7竖幅宣纸水墨立绘。0费,先通过原生 loseHp 失去3点生命,再获得2点当前费用,不增加费用上限。等待濒死救援;死亡、战斗结束或切换后不再获得费用。属于技能牌,缠身不额外增加攻击费用;描述只显示当前费用与效果。 + +坚毅:对应 true_grit.png 的顶住巨岩、防御与坚忍主题;完整5:7竖幅宣纸水墨立绘。1费,先获得7点格挡,再从当时自己的剩余手牌中随机消耗一张;当前选择模式通过原生选牌事件选择自己的手牌。移动原实体牌到私有消耗区,保留ID、花色、当前状态和词缀,不改永久牌组;无手牌时仍获得格挡。等待格挡事件,防止战斗结束、换场、死亡及陈旧选牌继续失牌;沿用脆弱的格挡衰减。描述只显示当前费用与效果。 + +破灭:对应 HAVOC/havoc.png 的宽刃旋斩与黑披风主题;完整5:7竖幅宣纸水墨立绘。普通1费,当前另一状态0费;免费使用自己的抽牌堆顶部实体牌,再仅消耗该原牌。费用豁免绑定原生选择/使用父链和实体身份,不改卡牌费用或词缀,愤怒生成的副本正常进入自己的弃牌堆,不跟随原牌消耗(对应Wiki0.104.0修复)。原生可用性和目标规则保持有效,无法使用的堆顶牌仍消耗;目标由原生使用流程选择。空抽牌堆不洗牌、不触发抽牌不足失败。保留当前数值、ID、花色、词缀和永久牌组,防止换场/死亡期间陈旧使用。描述只显示当前费用和效果。 + +耸肩无视:对应 SHRUG_IT_OFF/shrug_it_off.png 的耸肩摊手、厚重肩甲弹开攻击主题;完整5:7竖幅宣纸水墨立绘。1费,先通过原生 changeHujia 获得8点格挡,再通过原生 draw 抽1张牌;当前牌直接显示其实际格挡值。沿用脆弱对格挡的衰减与个人牌堆抽牌/洗牌/不足失败流程;等待格挡完全结算,战斗结束、死亡、换场或个人牌堆被替换后不再抽牌,不触碰公共和其他角色牌堆,不修改永久牌组;无消耗关键词。描述只显示当前费用与效果。 diff --git a/mengsan/assets/cards/mengsan_slimed_shuying.png b/mengsan/assets/cards/mengsan_slimed_shuying.png new file mode 100644 index 0000000..375f8b1 Binary files /dev/null and b/mengsan/assets/cards/mengsan_slimed_shuying.png differ diff --git a/mengsan/assets/portraits/bygone-effigy-forest-portrait.png b/mengsan/assets/portraits/bygone-effigy-forest-portrait.png new file mode 100644 index 0000000..000d55b Binary files /dev/null and b/mengsan/assets/portraits/bygone-effigy-forest-portrait.png differ diff --git a/mengsan/assets/portraits/bygone-effigy-original.png b/mengsan/assets/portraits/bygone-effigy-original.png new file mode 100644 index 0000000..610641e Binary files /dev/null and b/mengsan/assets/portraits/bygone-effigy-original.png differ diff --git a/mengsan/assets/portraits/byrdonis-forest-portrait.png b/mengsan/assets/portraits/byrdonis-forest-portrait.png new file mode 100644 index 0000000..3d73e5f Binary files /dev/null and b/mengsan/assets/portraits/byrdonis-forest-portrait.png differ diff --git a/mengsan/assets/portraits/byrdonis-original.png b/mengsan/assets/portraits/byrdonis-original.png new file mode 100644 index 0000000..f9334e0 Binary files /dev/null and b/mengsan/assets/portraits/byrdonis-original.png differ diff --git a/mengsan/assets/portraits/cubex-construct-forest-portrait.png b/mengsan/assets/portraits/cubex-construct-forest-portrait.png new file mode 100644 index 0000000..6998d54 Binary files /dev/null and b/mengsan/assets/portraits/cubex-construct-forest-portrait.png differ diff --git a/mengsan/assets/portraits/cubex-construct-original.webp b/mengsan/assets/portraits/cubex-construct-original.webp new file mode 100644 index 0000000..3c8af42 Binary files /dev/null and b/mengsan/assets/portraits/cubex-construct-original.webp differ diff --git a/mengsan/assets/portraits/eye-with-teeth-forest-portrait.png b/mengsan/assets/portraits/eye-with-teeth-forest-portrait.png new file mode 100644 index 0000000..dabdbac Binary files /dev/null and b/mengsan/assets/portraits/eye-with-teeth-forest-portrait.png differ diff --git a/mengsan/assets/portraits/eye-with-teeth.webp b/mengsan/assets/portraits/eye-with-teeth.webp new file mode 100644 index 0000000..ca8d70a Binary files /dev/null and b/mengsan/assets/portraits/eye-with-teeth.webp differ diff --git a/mengsan/assets/portraits/fogmog-and-eye-forest-portrait.png b/mengsan/assets/portraits/fogmog-and-eye-forest-portrait.png new file mode 100644 index 0000000..ba476ff Binary files /dev/null and b/mengsan/assets/portraits/fogmog-and-eye-forest-portrait.png differ diff --git a/mengsan/assets/portraits/fogmog-forest-portrait.png b/mengsan/assets/portraits/fogmog-forest-portrait.png new file mode 100644 index 0000000..fa95564 Binary files /dev/null and b/mengsan/assets/portraits/fogmog-forest-portrait.png differ diff --git a/mengsan/assets/portraits/fogmog.webp b/mengsan/assets/portraits/fogmog.webp new file mode 100644 index 0000000..3b5cedd Binary files /dev/null and b/mengsan/assets/portraits/fogmog.webp differ diff --git a/mengsan/assets/portraits/fuzzy-wurm-crawler-forest-portrait.png b/mengsan/assets/portraits/fuzzy-wurm-crawler-forest-portrait.png new file mode 100644 index 0000000..1482400 Binary files /dev/null and b/mengsan/assets/portraits/fuzzy-wurm-crawler-forest-portrait.png differ diff --git a/mengsan/assets/portraits/fuzzy-wurm-crawler-original.webp b/mengsan/assets/portraits/fuzzy-wurm-crawler-original.webp new file mode 100644 index 0000000..22cb4df Binary files /dev/null and b/mengsan/assets/portraits/fuzzy-wurm-crawler-original.webp differ diff --git a/mengsan/assets/portraits/inklet-forest-portrait.png b/mengsan/assets/portraits/inklet-forest-portrait.png new file mode 100644 index 0000000..3543437 Binary files /dev/null and b/mengsan/assets/portraits/inklet-forest-portrait.png differ diff --git a/mengsan/assets/portraits/inklet-original.png b/mengsan/assets/portraits/inklet-original.png new file mode 100644 index 0000000..105b335 Binary files /dev/null and b/mengsan/assets/portraits/inklet-original.png differ diff --git a/mengsan/assets/portraits/leaf-slime-m-forest-portrait.png b/mengsan/assets/portraits/leaf-slime-m-forest-portrait.png new file mode 100644 index 0000000..ecb1310 Binary files /dev/null and b/mengsan/assets/portraits/leaf-slime-m-forest-portrait.png differ diff --git a/mengsan/assets/portraits/leaf-slime-m-original.png b/mengsan/assets/portraits/leaf-slime-m-original.png new file mode 100644 index 0000000..994629c Binary files /dev/null and b/mengsan/assets/portraits/leaf-slime-m-original.png differ diff --git a/mengsan/assets/portraits/leaf-slime-s-forest-portrait.png b/mengsan/assets/portraits/leaf-slime-s-forest-portrait.png new file mode 100644 index 0000000..5e7e183 Binary files /dev/null and b/mengsan/assets/portraits/leaf-slime-s-forest-portrait.png differ diff --git a/mengsan/assets/portraits/leaf-slime-s-original.png b/mengsan/assets/portraits/leaf-slime-s-original.png new file mode 100644 index 0000000..e16dfd5 Binary files /dev/null and b/mengsan/assets/portraits/leaf-slime-s-original.png differ diff --git a/mengsan/assets/portraits/mawler-forest-portrait.png b/mengsan/assets/portraits/mawler-forest-portrait.png new file mode 100644 index 0000000..479b5e0 Binary files /dev/null and b/mengsan/assets/portraits/mawler-forest-portrait.png differ diff --git a/mengsan/assets/portraits/mawler-original.webp b/mengsan/assets/portraits/mawler-original.webp new file mode 100644 index 0000000..f319629 Binary files /dev/null and b/mengsan/assets/portraits/mawler-original.webp differ diff --git a/mengsan/assets/portraits/nibbit-forest-portrait.png b/mengsan/assets/portraits/nibbit-forest-portrait.png new file mode 100644 index 0000000..c5b94e2 Binary files /dev/null and b/mengsan/assets/portraits/nibbit-forest-portrait.png differ diff --git a/mengsan/assets/portraits/nibbit-original.webp b/mengsan/assets/portraits/nibbit-original.webp new file mode 100644 index 0000000..7008fb3 Binary files /dev/null and b/mengsan/assets/portraits/nibbit-original.webp differ diff --git a/mengsan/assets/portraits/phrog-parasite-forest-portrait.png b/mengsan/assets/portraits/phrog-parasite-forest-portrait.png new file mode 100644 index 0000000..166168c Binary files /dev/null and b/mengsan/assets/portraits/phrog-parasite-forest-portrait.png differ diff --git a/mengsan/assets/portraits/phrog-parasite-original.png b/mengsan/assets/portraits/phrog-parasite-original.png new file mode 100644 index 0000000..27e2976 Binary files /dev/null and b/mengsan/assets/portraits/phrog-parasite-original.png differ diff --git a/mengsan/assets/portraits/shrinker-beetle-forest-portrait.png b/mengsan/assets/portraits/shrinker-beetle-forest-portrait.png new file mode 100644 index 0000000..710b24b Binary files /dev/null and b/mengsan/assets/portraits/shrinker-beetle-forest-portrait.png differ diff --git a/mengsan/assets/portraits/shrinker-beetle-original.png b/mengsan/assets/portraits/shrinker-beetle-original.png new file mode 100644 index 0000000..5f5d361 Binary files /dev/null and b/mengsan/assets/portraits/shrinker-beetle-original.png differ diff --git a/mengsan/assets/portraits/slithering-strangler-forest-portrait.png b/mengsan/assets/portraits/slithering-strangler-forest-portrait.png new file mode 100644 index 0000000..86fb5b1 Binary files /dev/null and b/mengsan/assets/portraits/slithering-strangler-forest-portrait.png differ diff --git a/mengsan/assets/portraits/slithering-strangler-original.png b/mengsan/assets/portraits/slithering-strangler-original.png new file mode 100644 index 0000000..8cce7e9 Binary files /dev/null and b/mengsan/assets/portraits/slithering-strangler-original.png differ diff --git a/mengsan/assets/portraits/snapping-jaxfruit-forest-portrait.png b/mengsan/assets/portraits/snapping-jaxfruit-forest-portrait.png new file mode 100644 index 0000000..13dd92a Binary files /dev/null and b/mengsan/assets/portraits/snapping-jaxfruit-forest-portrait.png differ diff --git a/mengsan/assets/portraits/snapping-jaxfruit-original.png b/mengsan/assets/portraits/snapping-jaxfruit-original.png new file mode 100644 index 0000000..b3d4b29 Binary files /dev/null and b/mengsan/assets/portraits/snapping-jaxfruit-original.png differ diff --git a/mengsan/assets/portraits/twig-slime-m-forest-portrait.png b/mengsan/assets/portraits/twig-slime-m-forest-portrait.png new file mode 100644 index 0000000..7f8766f Binary files /dev/null and b/mengsan/assets/portraits/twig-slime-m-forest-portrait.png differ diff --git a/mengsan/assets/portraits/twig-slime-m-original.png b/mengsan/assets/portraits/twig-slime-m-original.png new file mode 100644 index 0000000..2eaeb82 Binary files /dev/null and b/mengsan/assets/portraits/twig-slime-m-original.png differ diff --git a/mengsan/assets/portraits/twig-slime-s-forest-portrait.png b/mengsan/assets/portraits/twig-slime-s-forest-portrait.png new file mode 100644 index 0000000..c0dfa40 Binary files /dev/null and b/mengsan/assets/portraits/twig-slime-s-forest-portrait.png differ diff --git a/mengsan/assets/portraits/twig-slime-s-original.png b/mengsan/assets/portraits/twig-slime-s-original.png new file mode 100644 index 0000000..aefe90e Binary files /dev/null and b/mengsan/assets/portraits/twig-slime-s-original.png differ diff --git a/mengsan/assets/portraits/vine-shambler-forest-portrait.png b/mengsan/assets/portraits/vine-shambler-forest-portrait.png new file mode 100644 index 0000000..4c7653b Binary files /dev/null and b/mengsan/assets/portraits/vine-shambler-forest-portrait.png differ diff --git a/mengsan/assets/portraits/vine-shambler-original-thumbnail.webp b/mengsan/assets/portraits/vine-shambler-original-thumbnail.webp new file mode 100644 index 0000000..59a8275 Binary files /dev/null and b/mengsan/assets/portraits/vine-shambler-original-thumbnail.webp differ diff --git a/mengsan/assets/portraits/wriggler-forest-portrait.png b/mengsan/assets/portraits/wriggler-forest-portrait.png new file mode 100644 index 0000000..c317e2b Binary files /dev/null and b/mengsan/assets/portraits/wriggler-forest-portrait.png differ diff --git a/mengsan/assets/portraits/wriggler-original.webp b/mengsan/assets/portraits/wriggler-original.webp new file mode 100644 index 0000000..780af96 Binary files /dev/null and b/mengsan/assets/portraits/wriggler-original.webp differ diff --git a/mengsan/battle/battle-flow.js b/mengsan/battle/battle-flow.js index 15ee6cf..bd72928 100644 --- a/mengsan/battle/battle-flow.js +++ b/mengsan/battle/battle-flow.js @@ -1,6 +1,6 @@ // Candidate external coordinator. Finish callbacks return a boolean, NEVER a drain promise. const copy = value => JSON.parse(JSON.stringify(value)); -export function createBattleFlow({settlement, chooseReward, showMap, showEnding, quiesce, releaseSkills = async () => {}, presentVictory = async () => {}}) { +export function createBattleFlow({settlement, chooseReward, chooseDefeat, showMap, showEnding, quiesce, releaseSkills = async () => {}, presentVictory = async () => {}}) { let state = "map", active = null, work = null, job = null, error = null; async function drive() { try { @@ -13,16 +13,20 @@ export function createBattleFlow({settlement, chooseReward, showMap, showEnding, await presentVictory(copy(job.pending)); job.victoryPresented = true; } - if (job.pending.state !== "chosen") { + // Also reopen a legacy chosen defeat: it never had a recovery choice. + if (job.pending.state !== "chosen" || + (job.pending.outcome === "defeat" && !job.pending.failureAction)) { state = "choosing"; if (!job.hasChoice) { - job.choiceId = job.pending.outcome === "defeat" ? null + job.choiceId = job.pending.outcome === "defeat" ? await chooseDefeat() : await chooseReward(copy(job.pending.choices), { boss: job.pending.boss, fixedRewards: copy(job.pending.fixedRewards || []), rewardPackage: copy(job.pending.rewardPackage || null), }); - if (job.pending.outcome !== "defeat" && job.pending.choices.length && job.choiceId !== null && !job.pending.choices.some(c => c.id === job.choiceId)) throw new Error("Invalid reward selection"); + if (job.pending.outcome !== "defeat" && !job.pending.rewardPackage && + job.choiceId !== null && + !job.pending.choices.some(c => c.id === job.choiceId && c.kind !== "gold")) throw new Error("Invalid reward selection"); job.hasChoice = true; } job.pending = await settlement.choose(job.input.run.runId, job.pending.id, job.choiceId); @@ -47,7 +51,9 @@ export function createBattleFlow({settlement, chooseReward, showMap, showEnding, job.nextNode = await showMap(mapRun); job.committed.run = mapRun; } - else await showEnding(job.committed.route, copy(job.committed.run)); + else if (!["retry", "new"].includes(job.committed.route)) { + await showEnding(job.committed.route, copy(job.committed.run)); + } job.delivered = true; } state = job.committed.route === "map" ? "map" : "ended"; @@ -73,7 +79,7 @@ export function createBattleFlow({settlement, chooseReward, showMap, showEnding, state = "starting"; try { await quiesce(); - active = {session, run, node:copy(node), encounter:copy(encounter)};job = null;work = null; + active = {session, run, retryRun:copy(run), node:copy(node), encounter:copy(encounter)};job = null;work = null; // start MUST schedule only; it must not await the battle root's completion. state = "battle"; start(); } catch (cause) { state = "blocked"; error = cause; throw cause; } @@ -82,7 +88,7 @@ export function createBattleFlow({settlement, chooseReward, showMap, showEnding, if (state !== "battle" || session !== active.session) return false; if (!["victory", "defeat"].includes(outcome)) throw new Error("Unknown outcome"); if (!Number.isInteger(defeatedEnemies) || defeatedEnemies < 0) throw new Error("Invalid defeated enemy count"); - job = {input:{run:copy(active.run),node:copy(active.node),encounter:{...copy(active.encounter),defeatedEnemies},hp,outcome}}; + job = {input:{run:copy(active.run),retryRun:copy(active.retryRun),node:copy(active.node),encounter:{...copy(active.encounter),defeatedEnemies},hp,outcome}}; state = "stopping"; active.session.requestStop(); launch(); diff --git a/mengsan/battle/battle-settlement.js b/mengsan/battle/battle-settlement.js index 72aa279..7f92d41 100644 --- a/mengsan/battle/battle-settlement.js +++ b/mengsan/battle/battle-settlement.js @@ -8,7 +8,7 @@ const copy = value => JSON.parse(JSON.stringify(value)); const idFor = (run, node) => JSON.stringify([run.runId, run.actIndex, node.id]); export function createBattleSettlement({ store, config, getRandomRewardChoices, applyReward, completeNode, - enterNextAct, now = Date.now, logRewardPackage = () => {}, + enterNextAct, createRun, now = Date.now, logRewardPackage = () => {}, }) { const readRun = () => store.read()[config.saveKey]; function current(storage, runId) { @@ -25,9 +25,9 @@ export function createBattleSettlement({ return Object.freeze({ readRun, // Persist fixed candidates before opening choice UI. No mutation of the live run. - async prepare({run: snapshot, node, encounter, hp, outcome = "victory"}) { + async prepare({run: snapshot, retryRun, node, encounter, hp, outcome = "victory"}) { if (!["victory", "defeat"].includes(outcome)) throw new Error("Unknown battle outcome"); - if (outcome === "victory" && (!Number.isFinite(hp) || (!encounter.skipRandomReward && (!Number.isSafeInteger(encounter.gold) || encounter.gold < 0)))) throw new Error("Invalid battle result"); + if (outcome === "victory" && (!Number.isFinite(hp) || (!encounter.rewardPackage && (!Number.isSafeInteger(encounter.gold) || encounter.gold < 0)))) throw new Error("Invalid battle result"); const id = idFor(snapshot, node); return store.update(storage => { const run = current(storage, snapshot.runId); @@ -42,7 +42,7 @@ export function createBattleSettlement({ } // Battle may have changed RNG/card state since the last checkpoint. const base = copy(snapshot); delete base.battleFlow; - const bondGrowth = settleBondBattle(base); + const bondGrowth = settleBondBattle(base, node); if (outcome === "victory") { base.player.hp = Math.max(1, Math.min(hp, base.player.maxHp ?? hp)); base.statistics.defeatedEnemies += encounter.defeatedEnemies ?? 1; @@ -50,21 +50,68 @@ export function createBattleSettlement({ const relicRecovery = hp > 0 ? applyBattleEndRelics(base, outcome) : 0; const rewards = outcome === "victory" && !encounter.skipRandomReward ? getRandomRewardChoices(base, encounter.rewardPool).filter(c => c.name) : []; - if (outcome === "victory" && !encounter.skipRandomReward && !rewards.length) throw new Error("Empty battle reward pool"); - // Persist the configured amount as a candidate, never as automatic victory income. - const choices = outcome === "victory" && !encounter.skipRandomReward + // Persist guaranteed gold alongside loot; award it once in the chosen result. + const choices = outcome === "victory" && !encounter.rewardPackage ? [{id:"mengsan.reward.gold.shuying", kind:"gold", name:"金币", amount:encounter.gold}, ...rewards] : []; const rewardPackage = outcome === "victory" && encounter.rewardPackage ? prepareRewardPackage(base, encounter.rewardPackage, getRandomRewardChoices) : null; run.battleFlow.pending = {id, state:"awaitingChoice", nodeId:node.id, base, choices:copy(choices), fixedRewards:copy(encounter.fixedRewards || []), victoryDialogue:copy(encounter.victoryDialogue || []), boss:!!encounter.boss, outcome, relicRecovery, bondGrowth}; run.battleFlow.pending.rewardPackage = copy(rewardPackage); + if (outcome === "victory" && !rewardPackage) { + run.battleFlow.pending.rewardMode = "one-or-skip"; + } + if (outcome === "defeat") { + const beforeBattle = retryRun || run; + if (beforeBattle.runId !== run.runId || + beforeBattle.actIndex !== run.actIndex || + beforeBattle.revision !== run.revision) throw new Error("Stale retry checkpoint"); + run.battleFlow.pending.retryRun = copy(beforeBattle); + run.battleFlow.pending.retryRun.battleFlow = { + receipts:copy(run.battleFlow.receipts), pending:null, + }; + run.battleFlow.pending.retryNode = copy(node); + run.battleFlow.pending.retryEncounter = copy(encounter); + } return run.battleFlow.pending; }); }, async choose(runId, id, choiceId) { return store.update(storage => { - const {item} = pending(storage, runId, id); + const {run, item} = pending(storage, runId, id); + if (item.outcome === "defeat") { + const action = typeof choiceId === "string" ? choiceId : choiceId?.action; + if (!["retry", "new"].includes(action)) throw new Error("Invalid defeat action"); + if (item.failureAction) { + if (item.failureAction !== action || + (action === "new" && item.choiceId.character !== choiceId.character)) { + throw new Error("Defeat choice already locked"); + } + return item; + } + if (action === "new") { + if (!config.characters.includes(choiceId.character) || typeof createRun !== "function") { + throw new Error("Invalid new journey character"); + } + // Generate once in the chosen checkpoint; a commit retry must not reroll it. + item.nextRun = createRun(choiceId.character); + if (!item.nextRun || item.nextRun.status !== "running" || item.nextRun.runId === runId) { + throw new Error("Invalid new journey"); + } + item.result = copy(item.base); + item.result.status = "failed"; + } else { + // Old pending saves have no retryRun: retain their last successful save. + item.result = copy(item.retryRun || run); + item.result.status = "running"; + item.result.battleFlow = {receipts:copy(run.battleFlow.receipts), pending:null}; + item.retryNode ||= copy(run.map?.nodes.find(node => node.id === item.nodeId)); + if (!item.retryNode || item.retryNode.completed) throw new Error("Missing retry battle node"); + } + item.state = "chosen"; item.choiceId = copy(choiceId); + item.failureAction = action; item.route = action; item.completedAt = now(); + return item; + } if (item.state === "chosen") { const sameChoice = item.rewardPackage ? item.choiceId?.cardId === choiceId?.cardId && @@ -80,16 +127,24 @@ export function createBattleSettlement({ applyRewardPackage(result, item.rewardPackage, choiceId, applyReward); } - const choice = item.choices.find(c => c.id === choiceId); - if (!item.rewardPackage && item.choices.length && choiceId !== null) { - if (!choice) throw new Error("Reward is not a fixed candidate"); - if (choice.kind === "gold") { - if (!Number.isSafeInteger(choice.amount) || choice.amount < 0) throw new Error("Invalid pending gold reward"); - result.player.gold += choice.amount; - result.statistics.goldEarned += choice.amount; - } else applyReward(result, choice.effectId || choice.id); - } else if (!item.rewardPackage && choiceId !== null) throw new Error("Unexpected reward selection"); - for (const rewardId of item.fixedRewards || []) applyReward(result, rewardId); + if (!item.rewardPackage) { + const reward = item.choices.find(c => c.id === choiceId && c.kind !== "gold"); + if (choiceId !== null && !reward) { + throw new Error("Choose one pending reward or skip"); + } + // This result is persisted before commit; retries never credit gold twice. + for (const gold of item.choices.filter(c => c.kind === "gold")) { + if (!Number.isSafeInteger(gold.amount) || gold.amount < 0) throw new Error("Invalid pending gold reward"); + result.player.gold += gold.amount; + result.statistics.goldEarned += gold.amount; + } + if (reward) { + applyReward(result, reward.effectId || reward.id); + } + for (const rewardId of item.fixedRewards || []) applyReward(result, rewardId); + } else { + for (const rewardId of item.fixedRewards || []) applyReward(result, rewardId); + } if (!completeNode(result, item.nodeId)) throw new Error("Node completion failed"); route = item.boss && !enterNextAct(result) ? "victory" : "map"; } else result.status = "failed"; @@ -111,6 +166,14 @@ export function createBattleSettlement({ const {run, item} = pending(storage, runId, id); if (item.state !== "chosen") throw new Error("Durable chosen result required"); const result = copy(item.result); + if (item.route === "retry") { + // A failed attempt does not complete this node or add its victory receipt. + result.battleFlow = {receipts:copy(run.battleFlow.receipts), pending:null, + retry:{node:copy(item.retryNode), encounter:copy(item.retryEncounter || null)}}; + storage[config.saveKey] = result; + return {route:"retry", run:result, retryNode:copy(item.retryNode), + retryEncounter:copy(item.retryEncounter || null), duplicate:false}; + } result.battleFlow = {receipts:[...run.battleFlow.receipts, id], pending:null}; if (item.route === "map") storage[config.saveKey] = result; else { @@ -118,12 +181,13 @@ export function createBattleSettlement({ profile.completedRuns ||= []; if (!profile.completedRuns.some(r => r.runId === runId)) { if (item.route === "victory") profile.wins = (profile.wins || 0) + 1; - profile.completedRuns.push({runId, settlementId:id, outcome:item.route, character:result.player.character, + profile.completedRuns.push({runId, settlementId:id, outcome:item.route === "new" ? "defeat" : item.route, character:result.player.character, completedNodes:result.statistics.completedNodes, goldEarned:result.statistics.goldEarned, completedAt:item.completedAt}); } - delete storage[config.saveKey]; // Same transaction as profile; never a second clear write. + if (item.route === "new") storage[config.saveKey] = copy(item.nextRun); + else delete storage[config.saveKey]; // Same transaction as profile; never a second clear write. } - return {route:item.route, run:result, duplicate:false}; + return {route:item.route, run:item.route === "new" ? copy(item.nextRun) : result, duplicate:false}; }); }, }); diff --git a/mengsan/battle/combat-rules.js b/mengsan/battle/combat-rules.js index 4e48d11..974a351 100644 --- a/mengsan/battle/combat-rules.js +++ b/mengsan/battle/combat-rules.js @@ -1,15 +1,22 @@ // 梦三独有的出牌经济,不修改 noname 的全局牌定义。 +import { isAttackCard } from "../monsters/vine-tangled.js"; import { _status } from "../../../../noname.js"; +import { cardUpgradeLevel, cardUpgradeRule } from "../cards/upgrades.js"; export const PLAYER_ENERGY = 3; export const PLAYER_HAND_LIMIT = 3; export const SHA_DAMAGE = 6; export const TRICK_DAMAGE = 4; -const COSTS = Object.freeze({ sha: 1, mengsan_zhongsha: 2, +const COSTS = Object.freeze({ sha: 1, mengsan_zhongsha: 2, mengsan_fangyu: 1, mengsan_fennu: 0, + mengsan_feijianhuixuanbiao: 1, mengsan_jianbingdaji: 1, mengsan_quanshenzhuangji: 1, mengsan_rongrongzhiquan: 1, mengsan_shandianpili: 1, mengsan_shuangchongdaji: 1, mengsan_tiezhanbo: 1, mengsan_touchui: 1, mengsan_tupo: 1, mengsan_yubeidaji: 1, mengsan_wanmeidaji: 2, mengsan_yujin: 2, mengsan_fangxue: 0, mengsan_jianyi: 1, mengsan_pomie: 1, mengsan_songjianwushi: 1, tao: 1, jiu: 1, shan: 0, wuxie: 0, juedou: 2, nanman: 2, wanjian: 2, taoyuan: 2 }); -export const cardCost = card => COSTS[card?.name] ?? 1; +export const cardCost = (card, player = null) => { + const rule = cardUpgradeLevel(card) ? cardUpgradeRule(card.name) : null; + const base = rule?.cost ?? COSTS[card?.name] ?? 1; + return base + (player?.storage?.mengsanTangled_shuying > 0 && isAttackCard(card) ? 1 : 0); +}; export const isActiveCardUse = (event, player) => { if (!event || !player) return false; @@ -17,7 +24,7 @@ export const isActiveCardUse = (event, player) => { let inPhaseUse = false; for (let current = event; current && !seen.has(current); current = current.parent) { seen.add(current); - if (current.name === "dying" || current.name === "chooseToRespond") return false; + if (current.name === "dying" || current.name === "respond" || current.name === "chooseToRespond") return false; if (current.name === "chooseToUse" && (current.respondTo || String(current.type || "").startsWith("respond"))) return false; if (current.name === "phaseUse" && current.player === player) inPhaseUse = true; } @@ -26,10 +33,35 @@ export const isActiveCardUse = (event, player) => { _status.currentPhase === player); }; -export const canPayCard = (player, card) => (player?.storage?.mengsanEnergy_shuying ?? Infinity) >= cardCost(card); +// 仅授权破灭的这一条原生选择/使用父链及同一张实体牌;副本和其他使用不免费。 +const freeUses = new WeakMap(); +export function authorizeFreeCardUse(player, card, choice, valid) { + const entries = freeUses.get(player) || new Set(); + freeUses.set(player, entries); + const entry = { card, choice, valid }; entries.add(entry); + return () => { entries.delete(entry); if (!entries.size) freeUses.delete(player); }; +} +function isFreeCardUse(player, card, event) { + for (const entry of freeUses.get(player) || []) { + if (!entry.valid() || card?.name !== entry.card.name || + !(card === entry.card || card?.cards?.includes(entry.card))) continue; + const seen = new Set(); + for (let current = event; current && !seen.has(current); current = current.parent) { + seen.add(current); + if (current.name === "useCard" && (current.player !== player || + current.cards?.length !== 1 || current.cards[0] !== entry.card)) break; + if (current === entry.choice) return true; + } + } + return false; +} -export const payCard = (player, card) => { - if (!canPayCard(player, card)) return false; - player.storage.mengsanEnergy_shuying -= cardCost(card); +export const canPayCard = (player, card, event = _status.event) => + isFreeCardUse(player, card, event) || (player?.storage?.mengsanEnergy_shuying ?? Infinity) >= cardCost(card, player); + +export const payCard = (player, card, event = _status.event) => { + if (isFreeCardUse(player, card, event)) return true; + if (!canPayCard(player, card, event)) return false; + player.storage.mengsanEnergy_shuying -= cardCost(card, player); return true; }; diff --git a/mengsan/battle/intent-damage.js b/mengsan/battle/intent-damage.js index 039cd6b..ca40852 100644 --- a/mengsan/battle/intent-damage.js +++ b/mengsan/battle/intent-damage.js @@ -1,3 +1,6 @@ +import { slowAttackDamage } from "../monsters/effigy-slow.js"; +import { shrinkAttackDamage } from "../monsters/shrinker-status.js"; + export function attackHitCount(intent) { const hits = intent.hits ?? 1; if (!Number.isInteger(hits) || hits < 1) { @@ -16,7 +19,7 @@ export function outgoingAttackDamage(intent, source) { if (source?.storage?.mengsanWeak_shuying > 0) { damage = Math.floor(damage * 0.75); } - return damage; + return shrinkAttackDamage(damage, source); } export function previewAttackDamage(intent, source, target) { @@ -24,5 +27,5 @@ export function previewAttackDamage(intent, source, target) { if (target?.storage?.mengsanVulnerable_shuying > 0) { damage = Math.ceil(damage * 1.5); } - return damage; + return slowAttackDamage(damage, target); } diff --git a/mengsan/battle/intent-effects.js b/mengsan/battle/intent-effects.js index a0ef15d..55da42e 100644 --- a/mengsan/battle/intent-effects.js +++ b/mengsan/battle/intent-effects.js @@ -1,3 +1,5 @@ +import { applyMengsanDebuff } from "../monsters/artifact-status.js"; +import { applyTangled } from "../monsters/vine-tangled.js"; import { attackHitCount, outgoingAttackDamage } from "./intent-damage.js"; import { beginDeathBlow, isDeathBlowIntent } from "./death-blow.js"; @@ -11,14 +13,8 @@ export function createIntentExecutor(game, getActiveBattle) { Boolean(getActiveBattle()?.session.active); const applyDebuff = (target, move) => { if (!move.debuff) return; - const frail = move.debuff === "脆弱"; - const key = frail ? "mengsanFrail_shuying" : - "mengsanVulnerable_shuying"; - target.storage[key] = (target.storage[key] || 0) + move.stacks; - target.addSkill(frail ? "mengsan_frail_shuying" : - "mengsan_vulnerable_shuying"); - target.markSkill(frail ? "mengsan_frail_shuying" : - "mengsan_vulnerable_shuying"); + if (move.debuff === "缠结") { applyTangled(getActiveBattle(), target, move.stacks); return; } + applyMengsanDebuff(target, move.debuff === "脆弱" ? "frail" : "vulnerable", move.stacks); }; return async (source, move) => { for (const target of getIntentHostiles(game, source)) { diff --git a/mengsan/bonds/state.js b/mengsan/bonds/state.js index 52c0b7a..3ec484d 100644 --- a/mengsan/bonds/state.js +++ b/mengsan/bonds/state.js @@ -43,7 +43,15 @@ export function markBondDown(run, id) { if (npc) npc.down = true; } -export function prepareBondBattle(run, random) { +// 第一章第一层的角色专属开场:不判定助战,也不累计本场羁绊成长。 +const isCharacterOpeningBattle = (run, node) => + run.actIndex === 0 && node?.floor === 0; + +export function prepareBondBattle(run, random, node = null) { + if (isCharacterOpeningBattle(run, node)) { + run.bondBattle = null; + return null; + } const bonds = ensureBonds(run); const id = bonds.selected; const npc = bonds.npcs[id]; @@ -54,7 +62,11 @@ export function prepareBondBattle(run, random) { return combat; } -export function settleBondBattle(run) { +export function settleBondBattle(run, node = null) { + if (isCharacterOpeningBattle(run, node)) { + run.bondBattle = null; + return null; + } const battle = run.bondBattle; if (!battle) return null; const npc = ensureBonds(run).npcs[battle.id]; diff --git a/mengsan/cards/battle-card-copy.js b/mengsan/cards/battle-card-copy.js new file mode 100644 index 0000000..4e536f5 --- /dev/null +++ b/mengsan/cards/battle-card-copy.js @@ -0,0 +1,29 @@ +import { createCardData } from "./card-data.js"; +import { cardUpgradeLevel } from "./upgrades.js"; + +// Route generated copies explicitly to their actor's real pile; never infer a public pile. +export function createBattleCardCopier(game, getActiveBattle) { + return (player, card, originals = []) => { + const current = getActiveBattle(); + if (!current?.session.active) return null; + const pile = player === game.me ? current.personalPiles : current.monsterPiles.get(player); + if (!pile) throw new Error("复制牌的角色没有个人弃牌堆"); + if (!card || typeof card.name !== "string") throw new TypeError("复制牌缺少牌名"); + const physical = Array.isArray(originals) ? originals.find(item => item?.name === card.name) : null; + const source = card.storage?.mengsanCard_shuying || physical?.storage?.mengsanCard_shuying || {}; + const serial = (current.generatedCardSerial || 0) + 1; + if (!Number.isSafeInteger(serial)) throw new RangeError("战斗生成牌序号越界"); + const data = createCardData({ + id: `mengsan_copy_${current.session.id}_${serial}`, + name: card.name, + suit: card.suit || source.suit || "spade", + number: card.number || source.number || 1, + nature: card.nature || source.nature || null, + upgrade: cardUpgradeLevel({name:card.name, storage:{mengsanCard_shuying:source}}), + affixes: (source.affixes || []).filter(key => key !== "annihilate"), + }, { enemy: true }); + // Fresh JSON metadata means no consumed/exhausted flags or source ID are shared. + current.generatedCardSerial = serial; + return pile.addToDiscard(data); + }; +} diff --git a/mengsan/cards/battle-card-reclaim.js b/mengsan/cards/battle-card-reclaim.js new file mode 100644 index 0000000..5ee819a --- /dev/null +++ b/mengsan/cards/battle-card-reclaim.js @@ -0,0 +1,29 @@ +// Actor-local discard recovery. Do not infer ownership from the active global UI pile. +export function createBattleCardReclaimer(game, getActiveBattle, get) { + return async player => { + const current = getActiveBattle(); + if (!current?.session.active || !player.isAlive()) return null; + const getPile = () => player === game.me + ? current.personalPiles : current.monsterPiles.get(player); + const pile = getPile(); + if (!pile) throw new Error("回收牌的角色没有个人牌堆"); + const cards = pile.discardCards(); + if (!cards.length) return null; + let chosen = cards[0]; + if (cards.length > 1) { + const result = await player.chooseButton({ + createDialog: ["头槌:选择一张个人弃牌,放到个人抽牌堆顶部", cards], + forced: true, selectButton: 1, + ai(button) { return get.value(button.link, player); }, + }).forResult(); + if (!result.bool || result.links?.length !== 1) return null; + chosen = result.links[0]; + } + // The selection is asynchronous: reject stale battles, piles and removed cards. + if (getActiveBattle() !== current || !current.session.active || + !player.isAlive() || getPile() !== pile || !cards.includes(chosen)) return null; + if (!pile.moveDiscardToTop(chosen)) return null; + game.log(player, "将", chosen, "放到个人抽牌堆顶部"); + return chosen; + }; +} diff --git a/mengsan/cards/battle-energy.js b/mengsan/cards/battle-energy.js new file mode 100644 index 0000000..fbef18a --- /dev/null +++ b/mengsan/cards/battle-energy.js @@ -0,0 +1,31 @@ +// 只向同一场仍在进行的战斗中的存活角色增加当前费用,不改变上限。 +export function createBattleEnergy(game, getActiveBattle) { + const valid = (player, battle) => Boolean(battle && + battle === getActiveBattle() && battle.session?.active && + battle.players?.has(player) && player?.isAlive?.()); + const validateAmount = (player, amount) => { + const current = player.storage?.mengsanEnergy_shuying; + if (!Number.isSafeInteger(current) || current < 0 || + !Number.isSafeInteger(amount) || amount <= 0 || + !Number.isSafeInteger(current + amount)) { + throw new Error("梦三费用数值无效"); + } + }; + return { + canUse(player) { return valid(player, getActiveBattle()); }, + capture(player, amount) { + const battle = getActiveBattle(); + if (!valid(player, battle)) return null; + validateAmount(player, amount); + return battle; + }, + grant(player, amount, battle) { + if (!valid(player, battle)) return false; + validateAmount(player, amount); + player.storage.mengsanEnergy_shuying += amount; + battle.energyUI.get(player)?.(); + if (player === game.me) battle.handUI?.refresh(); + return true; + }, + }; +} diff --git a/mengsan/cards/block-draw.js b/mengsan/cards/block-draw.js new file mode 100644 index 0000000..9910d40 --- /dev/null +++ b/mengsan/cards/block-draw.js @@ -0,0 +1,19 @@ +// 原生格挡完全结算后才抽牌,固定当前战斗和个人牌堆身份。 +export function createBlockDrawer(game, getActiveBattle) { + const valid = (player, battle) => Boolean(battle && getActiveBattle() === battle && + battle.session?.active && battle.players?.has(player) && player?.isAlive?.()); + const getPile = (player, battle) => player === game.me ? battle.personalPiles : battle.monsterPiles?.get(player); + return async function blockDraw(player, block, draw) { + if (!Number.isSafeInteger(block) || block < 0 || !Number.isSafeInteger(draw) || draw < 0) { + throw new TypeError("格挡抽牌数值无效"); + } + const battle = getActiveBattle(); + if (!valid(player, battle)) return; + const pile = getPile(player, battle); + if (typeof pile?.take !== "function") throw new Error("格挡抽牌缺少个人牌堆接口"); + await player.changeHujia(block); + // 格挡的触发事件中可能结束战斗、死亡或换场,不摸新场次的牌。 + if (!valid(player, battle) || getPile(player, battle) !== pile) return; + if (draw > 0) await player.draw(draw); + }; +} diff --git a/mengsan/cards/card-definitions.js b/mengsan/cards/card-definitions.js index 9ef1db5..c8e12e4 100644 --- a/mengsan/cards/card-definitions.js +++ b/mengsan/cards/card-definitions.js @@ -1,8 +1,26 @@ // 梦三永久牌组的可获得牌注册表。这里登记牌名与归属,不复制引擎中的效果定义。 // 新增武将专属牌时填写 owner 为该武将 ID;通用牌填写 owner: null。 const definitions = [ + { name: "mengsan_songjianwushi", category: "utility", owner: null }, + { name: "mengsan_pomie", category: "utility", owner: null }, + { name: "mengsan_jianyi", category: "utility", owner: null }, + { name: "mengsan_fangxue", category: "utility", owner: null }, + { name: "mengsan_yujin", category: "damage", owner: null }, + { name: "mengsan_wanmeidaji", category: "damage", owner: null }, + { name: "mengsan_yubeidaji", category: "damage", owner: null }, + { name: "mengsan_tupo", category: "damage", owner: null }, + { name: "mengsan_touchui", category: "damage", owner: null }, + { name: "mengsan_tiezhanbo", category: "damage", owner: null }, + { name: "mengsan_shuangchongdaji", category: "damage", owner: null }, + { name: "mengsan_shandianpili", category: "damage", owner: null }, { name: "sha", category: "damage", owner: null }, { name: "mengsan_zhongsha", category: "damage", owner: null }, + { name: "mengsan_fennu", category: "damage", owner: null }, + { name: "mengsan_rongrongzhiquan", category: "damage", owner: null }, + { name: "mengsan_quanshenzhuangji", category: "damage", owner: null }, + { name: "mengsan_jianbingdaji", category: "damage", owner: null }, + { name: "mengsan_feijianhuixuanbiao", category: "damage", owner: null }, + { name: "mengsan_fangyu", category: "utility", owner: null }, { name: "shan", category: "utility", owner: null }, { name: "tao", category: "recovery", owner: null }, { name: "jiu", category: "recovery", owner: null }, diff --git a/mengsan/cards/hand-exhaust.js b/mengsan/cards/hand-exhaust.js new file mode 100644 index 0000000..e406f75 --- /dev/null +++ b/mengsan/cards/hand-exhaust.js @@ -0,0 +1,46 @@ +// 坚毅只消耗使用者当时的手牌:普通牌随机,当前选择模式走原生选牌事件。 +export function createHandExhauster(game, getActiveBattle, get, randomHandExhaust) { + const valid = (player, battle) => Boolean(battle && + getActiveBattle() === battle && battle.session?.active && + battle.players?.has(player) && player?.isAlive?.()); + const getPile = (player, battle) => player === game.me + ? battle.personalPiles : battle.monsterPiles?.get(player); + const handCards = (player, event) => { + const playing = new Set(event?.cards || []); + return player.getCards("h").filter(card => + !playing.has(card) && !card.storage?.mengsanExhausted_shuying); + }; + return { + capture(player) { + const battle = getActiveBattle(); + if (!valid(player, battle)) return null; + if (typeof getPile(player, battle)?.exhaustFromHand !== "function") { + throw new Error("坚毅缺少个人消耗牌堆接口"); + } + if (typeof randomHandExhaust?.exhaust !== "function") throw new Error("坚毅缺少随机手牌消耗接口"); + return { battle, pile: getPile(player, battle) }; + }, + async exhaust(player, event, token, choose) { + const battle = token?.battle, pile = token?.pile; + if (!valid(player, battle) || getPile(player, battle) !== pile) return null; + if (typeof pile?.exhaustFromHand !== "function") throw new Error("坚毅缺少个人消耗牌堆接口"); + if (!choose) return await randomHandExhaust.exhaust(player, event, battle); + const cards = handCards(player, event); + if (!cards.length) return null; + let chosen = cards[0]; + if (cards.length > 1) { + const result = await player.chooseButton({ + createDialog: ["坚毅:选择消耗一张你的手牌", cards], + forced: true, selectButton: 1, + ai(button) { return -get.value(button.link, player); }, + }).forResult(); + if (!result.bool || result.links?.length !== 1) return null; + chosen = result.links[0]; + } + // 选牌期间可能已结束、换场、失去此牌或替换牌堆,不提交陈旧选择。 + if (!valid(player, battle) || getPile(player, battle) !== pile || + !cards.includes(chosen) || !handCards(player, event).includes(chosen)) return null; + return await pile.exhaustFromHand(chosen); + }, + }; +} diff --git a/mengsan/cards/havoc-play.js b/mengsan/cards/havoc-play.js new file mode 100644 index 0000000..513fd4b --- /dev/null +++ b/mengsan/cards/havoc-play.js @@ -0,0 +1,36 @@ +// 破灭通过原生使用事件执行自己的堆顶原牌;授权不写入永久牌数据。 +import { authorizeFreeCardUse } from "../battle/combat-rules.js"; +export function createHavocPlayer(game, getActiveBattle, status) { + const valid = (player, battle) => Boolean(battle && getActiveBattle() === battle && + battle.session?.active && battle.players?.has(player) && player?.isAlive?.()); + const getPile = (player, battle) => player === game.me ? battle.personalPiles : battle.monsterPiles?.get(player); + return async function playDrawTop(player) { + const battle = getActiveBattle(); + if (!valid(player, battle)) return null; + const pile = getPile(player, battle); + if (typeof pile?.beginTopPlay !== "function" || typeof pile?.finishTopPlay !== "function") { + throw new Error("破灭缺少个人牌堆接口"); + } + const original = pile.beginTopPlay(); + if (!original) return null; + let release = () => {}; + try { + const stillValid = () => valid(player, battle) && getPile(player, battle) === pile; + const choice = player.chooseUseTarget({ + card: original, cards: [original], forced: true, addCount: false, + prompt: "破灭:使用抽牌堆顶部的牌", + oncard() { + // 目标选择期间可能已死亡、结束或换场,不执行陈旧的使用。 + if (!stillValid()) status.event?.cancel?.(); + }, + }); + release = authorizeFreeCardUse(player, original, choice, stillValid); + await choice; + } finally { + release(); + // 只认这一张实体原牌,不添加消耗词缀,避免愤怒的副本继承消耗。 + await pile.finishTopPlay(original); + } + return original; + }; +} diff --git a/mengsan/cards/infection-card.js b/mengsan/cards/infection-card.js new file mode 100644 index 0000000..c5628f3 --- /dev/null +++ b/mengsan/cards/infection-card.js @@ -0,0 +1,56 @@ +import { getIntentHostiles } from "../battle/intent-effects.js"; +import { isPhrog, isPhrogActor } from "../monsters/phrog-intent.js"; + +export const INFECTION_NAME = "mengsan_infection_shuying"; +export function createInfectionData(id) { + if (typeof id !== "string" || !id) throw new TypeError("感染牌缺少ID"); + return { id, name: INFECTION_NAME, suit: "spade", number: 1, + nature: null, affixes: ["unplayable"], upgrade: 0 }; +} +export const infectionDefinition = Object.freeze({ + type: "status", fullimage: true, + image: "ext:术樱包/mengsan/assets/cards/mengsan_infection_shuying.png", + enable: false, notarget: true, + ai: { value: -5, useful: -5, order: 0 }, + cardPrompt: () => "不能被打出。回合结束弃牌前,若此牌在你的手牌中,你受到3点非攻击伤害。", +}); +export function applyInfection(game, current, source, count) { + if (!current?.session.active || !source?.isAlive() || !isPhrogActor(source)) return; + if (count !== (isPhrog(source) ? 3 : 1)) throw new RangeError("感染牌数量与怪物不符"); + if (!current.infectionCards) { + const entry = current.infectionCards = { sequence: 0, resolved: new WeakSet() }; + current.session.ownResource(entry, () => { if (current.infectionCards === entry) delete current.infectionCards; }); + } + for (const target of getIntentHostiles(game, source)) { + if (!current.session.active || !source.isAlive()) break; + const pile = target === game.me ? current.personalPiles : current.monsterPiles.get(target); + if (!pile) continue; + for (let i = 0; i < count; i++) { + if (!current.session.active || !source.isAlive()) break; + const sequence = current.infectionCards.sequence + 1; + if (!Number.isSafeInteger(sequence)) throw new RangeError("感染牌序号溢出"); + current.infectionCards.sequence = sequence; + pile.addToDiscard(createInfectionData(`infection_${source.playerid}_${sequence}`)); + } + game.log(target, `的个人弃牌堆加入${count}张【感染】`); + } +} +export const infectionHand = player => [...new Set(player?.getCards("h") || [])].filter(card => + card.name === INFECTION_NAME && card.storage?.mengsanOwnerId_shuying === player.playerid); +const resolutions = new WeakMap(); +export async function resolveInfection(current, player, phase) { + if (!current?.session.active || !player?.isAlive() || !current.players.has(player)) return false; + let resolved = resolutions.get(current); + if (!resolved) { resolved = new WeakSet(); resolutions.set(current, resolved); } + if (resolved.has(phase)) return false; + resolved.add(phase); + const cards = infectionHand(player); + for (const card of cards) { + if (!current.session.active || !player.isAlive()) break; + if (!infectionHand(player).includes(card)) continue; + const damage = player.damage(3, "nosource"); + damage.mengsanInfection_shuying = true; + await damage; + } + return cards.length > 0; +} diff --git a/mengsan/cards/mode-cards.js b/mengsan/cards/mode-cards.js index e208789..fe147dc 100644 --- a/mengsan/cards/mode-cards.js +++ b/mengsan/cards/mode-cards.js @@ -2,17 +2,23 @@ // 保留原牌名,以兼容原有牌的响应、装备技能及转化牌引用。 import standard from "../../../../card/standard.js"; import extra from "../../../../card/extra.js"; -import { _status, game } from "../../../../noname.js"; +import { _status, game, get } from "../../../../noname.js"; import { cardCost, canPayCard, isActiveCardUse, SHA_DAMAGE, TRICK_DAMAGE } from "../battle/combat-rules.js"; import { cardUpgradeLevel, cardUpgradeRule } from "./upgrades.js"; +import { applyMengsanDebuff } from "../monsters/artifact-status.js"; +import { isAttackCard } from "../monsters/vine-tangled.js"; +import { DAZED_NAME, dazedDefinition } from "./status-cards.js"; +import { SLIMED_NAME, slimedDefinition } from "./slimed-card.js"; +import { INFECTION_NAME, infectionDefinition } from "./infection-card.js"; + const packs = [standard, extra]; const names = [...new Set(packs.flatMap(pack => pack.list.map(entry => entry[2])))]; const sourceCard = name => standard.card[name] || extra.card[name]; const sourceText = name => extra.translate[`${name}_info`] || standard.translate[`${name}_info`]; -export function createMengsanCards() { +export function createMengsanCards({ copyToDiscard, reclaimFromDiscard, temporaryStrength, countStrikeCards, randomHandExhaust, battleEnergy, handExhaust, playDrawTop, blockDraw, isBattleActive = () => true } = {}) { const card = {}; const translate = {}; for (const name of names) { @@ -52,14 +58,12 @@ export function createMengsanCards() { const damage = cardUpgradeLevel(used) ? cardUpgradeRule("sha").damage : SHA_DAMAGE; return `梦三:主动使用消耗1费用;目标须使用【闪】,` + - `否则受到${damage}点伤害。每回合使用次数不限。` + - (cardUpgradeLevel(used) ? "已强化(上限1次)。" : - "可强化1次:伤害变为9点。"); + `否则受到${damage}点伤害。每回合使用次数不限。`; }; translate.sha_info = `梦三:主动使用消耗1费用。出牌阶段,对攻击范围内的一名角色使用;其须使用【闪】,否则受到${SHA_DAMAGE}点伤害。每回合使用次数不限。`; card.mengsan_zhongsha = { type: "basic", - fullskin: true, + fullimage: true, image: "ext:术樱包/mengsan/assets/cards/mengsan_zhongsha.png", mengsanCost_shuying: cardCost({ name: "mengsan_zhongsha" }), enable(used, player, event) { @@ -79,12 +83,8 @@ export function createMengsanCards() { damage.mengsanAttack_shuying = true; await damage; if (!target.isAlive()) return; - target.storage.mengsanVulnerable_shuying = - (target.storage.mengsanVulnerable_shuying || 0) + - effect.vulnerable; - target.addSkill("mengsan_vulnerable_shuying"); - target.markSkill("mengsan_vulnerable_shuying"); - game.log(target, `获得${effect.vulnerable}层易伤`); + if (applyMengsanDebuff(target, "vulnerable", effect.vulnerable)) + game.log(target, `获得${effect.vulnerable}层易伤`); }, ai: { order: 4, result: { target: -1.5 }, tag: { damage: 1 } }, @@ -96,9 +96,7 @@ export function createMengsanCards() { `一名其他角色造成${effect.damage}点伤害,然后给予其` + `${effect.vulnerable}层易伤。受到攻击伤害增加50%` + "(向上取整),每个自身回合结束减少1层。" + - "每回合使用次数不限。" + (cardUpgradeLevel(used) - ? "已强化(上限1次)。" : - "可强化1次:10点伤害、3层易伤。"); + "每回合使用次数不限。"; }, }; translate.mengsan_zhongsha = "重杀"; @@ -107,6 +105,648 @@ export function createMengsanCards() { "一名其他角色造成8点伤害,然后给予其2层易伤。" + "易伤使受到的攻击伤害增加50%(向上取整)," + "每个自身回合结束减少1层。每回合使用次数不限。"; + // STS2 Setup Strike: the new temporary strength does not enhance its own hit. + // STS2 Cinder: damage first, then exhaust one random remaining hand card. + card.mengsan_yujin = { + type: "basic", fullimage: true, + image: "ext:术樱包/mengsan/assets/cards/mengsan_yujin.png", + mengsanCost_shuying: cardCost({ name: "mengsan_yujin" }), + usable: Infinity, selectTarget: 1, + enable(used, player, event) { + if (!isBattleActive() || !player.isAlive()) return false; + return !isActiveCardUse(event || _status.event, player) || canPayCard(player, used); + }, + filterTarget(used, player, target) { + return target !== player && target.isAlive() && player.isEnemyOf(target); + }, + async content(event, trigger, player) { + const target = event.target; + if (!isBattleActive() || !player.isAlive() || !target?.isAlive() || + target === player || !player.isEnemyOf(target)) return; + if (!randomHandExhaust?.capture || !randomHandExhaust?.exhaust) throw new Error("余烬缺少随机消耗接口"); + const battle = randomHandExhaust.capture(); + if (!battle) return; + const rule = cardUpgradeRule("mengsan_yujin"); + const damage = target.damage(cardUpgradeLevel(event.card) ? rule.damage : rule.baseDamage, player); + damage.mengsanAttack_shuying = true; + await damage; + if (isBattleActive() && player.isAlive()) await randomHandExhaust.exhaust(player, event, battle); + }, + ai: { order: 6, result: { target: -2 }, tag: { damage: 1 } }, + cardPrompt(used) { + const rule = cardUpgradeRule("mengsan_yujin"); + const amount = cardUpgradeLevel(used) ? rule.damage : rule.baseDamage; + return `梦三:主动使用消耗2费用。对一名敌方角色造成${amount}点伤害,然后随机消耗一张你的手牌(退出本场战斗,不进入弃牌堆或洗牌)。没有手牌时不消耗牌。每回合使用次数不限。`; + }, + }; + translate.mengsan_yujin = "余烬"; + translate.mengsan_yujin_info = card.mengsan_yujin.cardPrompt({ name: "mengsan_yujin" }); + // STS2 Perfected Strike counts current physical combat cards, including itself/exhaust. + card.mengsan_wanmeidaji = { + type: "basic", fullimage: true, + image: "ext:术樱包/mengsan/assets/cards/mengsan_wanmeidaji.png", + mengsanCost_shuying: cardCost({ name: "mengsan_wanmeidaji" }), + usable: Infinity, selectTarget: 1, + enable(used, player, event) { + if (!isBattleActive() || !player.isAlive()) return false; + return !isActiveCardUse(event || _status.event, player) || canPayCard(player, used); + }, + filterTarget(used, player, target) { + return target !== player && target.isAlive() && player.isEnemyOf(target); + }, + async content(event, trigger, player) { + const target = event.target; + if (!isBattleActive() || !player.isAlive() || !target?.isAlive() || + target === player || !player.isEnemyOf(target)) return; + if (typeof countStrikeCards !== "function") throw new Error("完美打击缺少打击牌计数接口"); + const count = countStrikeCards(player, event); + if (!Number.isSafeInteger(count) || count < 0) throw new RangeError("打击牌数量无效"); + const rule = cardUpgradeRule("mengsan_wanmeidaji"); + const amount = rule.baseDamage + count * (cardUpgradeLevel(event.card) ? rule.perStrike : rule.basePerStrike); + if (!Number.isSafeInteger(amount) || amount < 0) throw new RangeError("完美打击伤害无效"); + const damage = target.damage(amount, player); + damage.mengsanAttack_shuying = true; + damage.mengsanStrikeCount_shuying = count; + await damage; + }, + ai: { order: 7, result: { target: -2 }, tag: { damage: 1 } }, + cardPrompt(used) { + const rule = cardUpgradeRule("mengsan_wanmeidaji"); + const bonus = cardUpgradeLevel(used) ? rule.perStrike : rule.basePerStrike; + return `梦三:主动使用消耗2费用。对一名敌方角色造成6点伤害,本场战斗的牌中每有一张名称含“打击”的牌,伤害增加${bonus}点(包括手牌、个人抽牌堆、个人弃牌堆、消耗牌及打出区)。每回合使用次数不限。`; + }, + }; + translate.mengsan_wanmeidaji = "完美打击"; + translate.mengsan_wanmeidaji_info = card.mengsan_wanmeidaji.cardPrompt({ name: "mengsan_wanmeidaji" }); + card.mengsan_yubeidaji = { + type: "basic", fullimage: true, + image: "ext:术樱包/mengsan/assets/cards/mengsan_yubeidaji.png", + mengsanCost_shuying: cardCost({ name: "mengsan_yubeidaji" }), + usable: Infinity, selectTarget: 1, + enable(used, player, event) { + if (!isBattleActive() || !player.isAlive()) return false; + return !isActiveCardUse(event || _status.event, player) || canPayCard(player, used); + }, + filterTarget(used, player, target) { + return target !== player && target.isAlive() && player.isEnemyOf(target); + }, + async content(event, trigger, player) { + const target = event.target; + if (!isBattleActive() || !player.isAlive() || !target?.isAlive() || + target === player || !player.isEnemyOf(target)) return; + if (!temporaryStrength?.capture || !temporaryStrength?.grant) throw new Error("预备打击缺少临时力量接口"); + const battle = temporaryStrength.capture(); + if (!battle) return; + const rule = cardUpgradeRule("mengsan_yubeidaji"); + const upgraded = cardUpgradeLevel(event.card); + const damage = target.damage(upgraded ? rule.damage : rule.baseDamage, player); + damage.mengsanAttack_shuying = true; + await damage; + if (isBattleActive() && player.isAlive()) { + temporaryStrength.grant(player, upgraded ? rule.strength : rule.baseStrength, event, battle); + } + }, + ai: { order: 8, result: { player: 0.8, target: -1.5 }, tag: { damage: 1 } }, + cardPrompt(used) { + const rule = cardUpgradeRule("mengsan_yubeidaji"); + const upgraded = cardUpgradeLevel(used); + return `梦三:主动使用消耗1费用。对一名敌方角色造成${upgraded ? rule.damage : rule.baseDamage}点伤害,然后在本回合内获得${upgraded ? rule.strength : rule.baseStrength}点力量。每回合使用次数不限。`; + }, + }; + translate.mengsan_yubeidaji = "预备打击"; + translate.mengsan_yubeidaji_info = "梦三:主动使用消耗1费用。对一名敌方角色造成7点伤害,然后在本回合内获得2点力量。每回合使用次数不限。"; + // STS2 Breakthrough: native life loss is paid once before the all-enemy attack. + card.mengsan_tupo = { + type: "basic", fullimage: true, + image: "ext:术樱包/mengsan/assets/cards/mengsan_tupo.png", + mengsanCost_shuying: cardCost({ name: "mengsan_tupo" }), + notarget: true, usable: Infinity, + enable(used, player, event) { + if (!isBattleActive() || !player.isAlive()) return false; + if (!game.filterPlayer(target => target !== player && + target.isAlive() && player.isEnemyOf(target)).length) return false; + return !isActiveCardUse(event || _status.event, player) || canPayCard(player, used); + }, + async content(event, trigger, player) { + if (!isBattleActive() || !player.isAlive()) return; + if (!game.filterPlayer(target => target !== player && + target.isAlive() && player.isEnemyOf(target)).length) return; + // loseHp bypasses block and awaits the normal dying/rescue process. + await player.loseHp(1); + if (!isBattleActive() || !player.isAlive()) return; + const rule = cardUpgradeRule("mengsan_tupo"); + const amount = cardUpgradeLevel(event.card) ? rule.damage : rule.baseDamage; + const targets = game.filterPlayer(target => target !== player && + target.isAlive() && player.isEnemyOf(target)); + for (const target of targets) { + if (!isBattleActive() || !player.isAlive()) break; + if (!target.isAlive() || !player.isEnemyOf(target)) continue; + player.line(target, "green"); + const damage = target.damage(amount, player); + damage.mengsanAttack_shuying = true; + await damage; + } + }, + ai: { order: 7, result: { player(player) { return player.hp <= 1 ? -10 : 1; } }, tag: { damage: 1 } }, + cardPrompt(used) { + const rule = cardUpgradeRule("mengsan_tupo"); + const amount = cardUpgradeLevel(used) ? rule.damage : rule.baseDamage; + return `梦三:主动使用消耗1费用。失去1点生命,然后对所有敌方角色造成${amount}点伤害。每回合使用次数不限。`; + }, + }; + translate.mengsan_tupo = "突破"; + translate.mengsan_tupo_info = "梦三:主动使用消耗1费用。失去1点生命,然后对所有敌方角色造成9点伤害。每回合使用次数不限。"; + // STS2 Headbutt: damage first, then choose from the actor's own real discard pile. + card.mengsan_touchui = { + type: "basic", fullimage: true, + image: "ext:术樱包/mengsan/assets/cards/mengsan_touchui.png", + mengsanCost_shuying: cardCost({ name: "mengsan_touchui" }), + usable: Infinity, selectTarget: 1, + enable(used, player, event) { + if (!isBattleActive() || !player.isAlive()) return false; + return !isActiveCardUse(event || _status.event, player) || canPayCard(player, used); + }, + filterTarget(used, player, target) { + return target !== player && target.isAlive() && player.isEnemyOf(target); + }, + async content(event, trigger, player) { + const target = event.target; + if (!isBattleActive() || !player.isAlive() || !target?.isAlive() || + target === player || !player.isEnemyOf(target)) return; + if (typeof reclaimFromDiscard !== "function") throw new Error("头槌缺少个人弃牌回收接口"); + const rule = cardUpgradeRule("mengsan_touchui"); + const amount = cardUpgradeLevel(event.card) ? rule.damage : rule.baseDamage; + const damage = target.damage(amount, player); + damage.mengsanAttack_shuying = true; + await damage; + // Defeating one enemy does not prevent recovery unless combat actually ends. + if (isBattleActive() && player.isAlive()) await reclaimFromDiscard(player); + }, + ai: { order: 6, result: { player: 0.4, target: -1.5 }, tag: { damage: 1 } }, + cardPrompt(used) { + const rule = cardUpgradeRule("mengsan_touchui"); + const amount = cardUpgradeLevel(used) ? rule.damage : rule.baseDamage; + return '梦三:主动使用消耗1费用。对一名敌方角色造成' + amount + + '点伤害,然后选择个人弃牌堆中的一张牌放到个人抽牌堆顶部。每回合使用次数不限。'; + }, + }; + translate.mengsan_touchui = "头槌"; + translate.mengsan_touchui_info = "梦三:主动使用消耗1费用。对一名敌方角色造成9点伤害," + + "然后选择个人弃牌堆中的一张牌放到个人抽牌堆顶部。每回合使用次数不限。"; + // STS2 Iron Wave: obtain block before resolving the attack. + card.mengsan_tiezhanbo = { + type: "basic", fullimage: true, + image: "ext:术樱包/mengsan/assets/cards/mengsan_tiezhanbo.png", + mengsanCost_shuying: cardCost({ name: "mengsan_tiezhanbo" }), + usable: Infinity, selectTarget: 1, + enable(used, player, event) { + if (!isBattleActive() || !player.isAlive()) return false; + return !isActiveCardUse(event || _status.event, player) || canPayCard(player, used); + }, + filterTarget(used, player, target) { + return target !== player && target.isAlive() && player.isEnemyOf(target); + }, + async content(event, trigger, player) { + const target = event.target; + const canResolve = () => isBattleActive() && player.isAlive() && + target?.isAlive() && target !== player && player.isEnemyOf(target); + if (!canResolve()) return; + const rule = cardUpgradeRule("mengsan_tiezhanbo"); + const upgraded = cardUpgradeLevel(event.card) > 0; + await player.changeHujia(upgraded ? rule.block : rule.baseBlock); + // Block triggers may end combat or remove the acting player/target. + if (!canResolve()) return; + const damage = target.damage(upgraded ? rule.damage : rule.baseDamage, player); + damage.mengsanAttack_shuying = true; + await damage; + }, + ai: { order: 7, result: { player: 0.6, target: -1.2 }, tag: { damage: 1 } }, + cardPrompt(used) { + const rule = cardUpgradeRule("mengsan_tiezhanbo"); + const upgraded = cardUpgradeLevel(used) > 0; + const block = upgraded ? rule.block : rule.baseBlock; + const damage = upgraded ? rule.damage : rule.baseDamage; + return '梦三:主动使用消耗1费用。获得' + block + + '点格挡(护甲),然后对一名敌方角色造成' + damage + + '点伤害。每回合使用次数不限。'; + }, + }; + translate.mengsan_tiezhanbo = "铁斩波"; + translate.mengsan_tiezhanbo_info = "梦三:主动使用消耗1费用。获得5点格挡(护甲)," + + "然后对一名敌方角色造成5点伤害。每回合使用次数不限。"; + // STS2 Twin Strike: each hit resolves on the same target separately. + card.mengsan_shuangchongdaji = { + type: "basic", fullimage: true, + image: "ext:术樱包/mengsan/assets/cards/mengsan_shuangchongdaji.png", + mengsanCost_shuying: cardCost({ name: "mengsan_shuangchongdaji" }), + usable: Infinity, selectTarget: 1, + enable(used, player, event) { + if (!isBattleActive() || !player.isAlive()) return false; + return !isActiveCardUse(event || _status.event, player) || canPayCard(player, used); + }, + filterTarget(used, player, target) { + return target !== player && target.isAlive() && player.isEnemyOf(target); + }, + async content(event, trigger, player) { + const target = event.target; + const rule = cardUpgradeRule("mengsan_shuangchongdaji"); + const amount = cardUpgradeLevel(event.card) ? rule.damage : rule.baseDamage; + for (let hitIndex = 0; hitIndex < rule.hits; hitIndex++) { + if (!isBattleActive() || !player.isAlive() || !target?.isAlive() || + target === player || !player.isEnemyOf(target)) break; + const damage = target.damage(amount, player); + damage.mengsanAttack_shuying = true; + await damage; + } + }, + ai: { order: 6, result: { target: -1.5 }, tag: { damage: 1 } }, + cardPrompt(used) { + const rule = cardUpgradeRule("mengsan_shuangchongdaji"); + const amount = cardUpgradeLevel(used) ? rule.damage : rule.baseDamage; + return '梦三:主动使用消耗1费用。对一名敌方角色造成' + + amount + '点伤害两次,两次伤害分别结算。每回合使用次数不限。'; + }, + }; + translate.mengsan_shuangchongdaji = "双重打击"; + translate.mengsan_shuangchongdaji_info = "梦三:主动使用消耗1费用。对一名敌方角色造成5点伤害两次," + + "两次伤害分别结算。每回合使用次数不限。"; + // STS2 Thunderclap: hit each living enemy before adding new vulnerable. + card.mengsan_shandianpili = { + type: "basic", fullimage: true, + image: "ext:术樱包/mengsan/assets/cards/mengsan_shandianpili.png", + mengsanCost_shuying: cardCost({ name: "mengsan_shandianpili" }), + notarget: true, usable: Infinity, + enable(used, player, event) { + if (!isBattleActive() || !player.isAlive()) return false; + if (!game.filterPlayer(target => target !== player && + target.isAlive() && player.isEnemyOf(target)).length) return false; + return !isActiveCardUse(event || _status.event, player) || canPayCard(player, used); + }, + async content(event, trigger, player) { + if (!isBattleActive() || !player.isAlive()) return; + const rule = cardUpgradeRule("mengsan_shandianpili"); + const amount = cardUpgradeLevel(event.card) ? rule.damage : rule.baseDamage; + const targets = game.filterPlayer(target => target !== player && + target.isAlive() && player.isEnemyOf(target)); + for (const target of targets) { + if (!isBattleActive() || !player.isAlive()) break; + if (!target.isAlive() || !player.isEnemyOf(target)) continue; + player.line(target, "green"); + const damage = target.damage(amount, player); + damage.mengsanAttack_shuying = true; + await damage; + } + // 伤害结束后重新取得存活敌人,包含异蛙亡语刚生成的扭动虫。 + for (const target of game.filterPlayer(target => target !== player && target.isAlive() && player.isEnemyOf(target))) { + if (!isBattleActive() || !player.isAlive()) break; + if (applyMengsanDebuff(target, "vulnerable", rule.vulnerable)) + game.log(target, `获得${rule.vulnerable}层易伤`); + } + }, + ai: { order: 7, result: { player: 1 }, tag: { damage: 1 } }, + cardPrompt(used) { + const rule = cardUpgradeRule("mengsan_shandianpili"); + const amount = cardUpgradeLevel(used) ? rule.damage : rule.baseDamage; + return `梦三:主动使用消耗1费用。对所有敌方角色造成${amount}点伤害,然后给予其1层易伤。` + + "易伤使受到的攻击伤害增加50%(向上取整),每个自身回合结束减少1层。每回合使用次数不限。"; + }, + }; + translate.mengsan_shandianpili = "闪电霹雳"; + translate.mengsan_shandianpili_info = "梦三:主动使用消耗1费用。对所有敌方角色造成4点伤害,然后给予其1层易伤。" + + "易伤使受到的攻击伤害增加50%(向上取整),每个自身回合结束减少1层。每回合使用次数不限。"; + // Each throw resolves separately and samples the still-living enemies again. + card.mengsan_feijianhuixuanbiao = { + type: "basic", fullimage: true, + image: "ext:术樱包/mengsan/assets/cards/mengsan_feijianhuixuanbiao.png", + mengsanCost_shuying: cardCost({ name: "mengsan_feijianhuixuanbiao" }), + notarget: true, usable: Infinity, + enable(used, player, event) { + if (!isBattleActive() || !player.isAlive()) return false; + if (!game.filterPlayer(target => target !== player && + target.isAlive() && player.isEnemyOf(target)).length) return false; + return !isActiveCardUse(event || _status.event, player) || canPayCard(player, used); + }, + async content(event, trigger, player) { + const rule = cardUpgradeRule("mengsan_feijianhuixuanbiao"); + const count = cardUpgradeLevel(event.card) ? rule.hits : rule.baseHits; + for (let hitIndex = 0; hitIndex < count; hitIndex++) { + if (!isBattleActive() || !player.isAlive()) break; + const enemies = game.filterPlayer(target => target !== player && + target.isAlive() && player.isEnemyOf(target)); + if (!enemies.length) break; + // Use the engine helper with an inclusive index range. + const target = enemies[get.rand(0, enemies.length - 1)]; + player.line(target, "green"); + const damage = target.damage(rule.damage, player); + damage.mengsanAttack_shuying = true; + await damage; + } + }, + ai: { order: 6, result: { player: 1 }, tag: { damage: 1 } }, + cardPrompt(used) { + const rule = cardUpgradeRule("mengsan_feijianhuixuanbiao"); + const count = cardUpgradeLevel(used) ? rule.hits : rule.baseHits; + return `梦三:主动使用消耗1费用。每次对随机一名敌方角色造成${rule.damage}点伤害,共${count}次。` + + "每次攻击重新随机选择存活的敌人,可以重复命中同一敌人。" + + "每回合使用次数不限。"; + }, + }; + translate.mengsan_feijianhuixuanbiao = "飞剑回旋镖"; + translate.mengsan_feijianhuixuanbiao_info = "梦三:主动使用消耗1费用。随机对敌人造成3点伤害3次。" + + "每次攻击重新随机选择存活的敌人,可以重复命中同一敌人。每回合使用次数不限。"; + // Resolve damage before drawing from the acting player's mode-owned pile. + card.mengsan_jianbingdaji = { + type: "basic", fullimage: true, + image: "ext:术樱包/mengsan/assets/cards/mengsan_jianbingdaji.png", + mengsanCost_shuying: cardCost({ name: "mengsan_jianbingdaji" }), + usable: Infinity, selectTarget: 1, + enable(used, player, event) { + if (!isBattleActive() || !player.isAlive()) return false; + return !isActiveCardUse(event || _status.event, player) || canPayCard(player, used); + }, + filterTarget(used, player, target) { + return target !== player && target.isAlive() && player.isEnemyOf(target); + }, + async content(event, trigger, player) { + if (!isBattleActive() || !player.isAlive()) return; + const rule = cardUpgradeRule("mengsan_jianbingdaji"); + const upgraded = cardUpgradeLevel(event.card) > 0; + const damage = event.target.damage(upgraded ? rule.damage : rule.baseDamage, player); + damage.mengsanAttack_shuying = true; + await damage; + // Defeating the last enemy may dispose the personal pile during damage. + if (isBattleActive() && player.isAlive()) { + await player.draw(upgraded ? rule.draw : rule.baseDraw); + } + }, + ai: { order: 6, result: { player: 0.6, target: -1.5 }, tag: { damage: 1, draw: 1 } }, + cardPrompt(used) { + const rule = cardUpgradeRule("mengsan_jianbingdaji"); + const upgraded = cardUpgradeLevel(used) > 0; + const damage = upgraded ? rule.damage : rule.baseDamage; + const draw = upgraded ? rule.draw : rule.baseDraw; + return `梦三:主动使用消耗1费用。对一名敌方角色造成${damage}点伤害,然后抽${draw}张牌。` + + "每回合使用次数不限。"; + }, + }; + translate.mengsan_jianbingdaji = "剑柄打击"; + translate.mengsan_jianbingdaji_info = "梦三:主动使用消耗1费用。对一名敌方角色造成9点伤害,然后抽1张牌。" + + "每回合使用次数不限。"; + // Block determines base damage without spending the acting player's block. + card.mengsan_quanshenzhuangji = { + type: "basic", fullimage: true, + image: "ext:术樱包/mengsan/assets/cards/mengsan_quanshenzhuangji.png", + mengsanCost_shuying: cardCost({ name: "mengsan_quanshenzhuangji" }), + usable: Infinity, selectTarget: 1, + enable(used, player, event) { + if (!isBattleActive() || !player.isAlive()) return false; + return !isActiveCardUse(event || _status.event, player) || canPayCard(player, used); + }, + filterTarget(used, player, target) { + return target !== player && target.isAlive() && player.isEnemyOf(target); + }, + async content(event, trigger, player) { + if (!isBattleActive() || !player.isAlive()) return; + const block = Number.isFinite(player.hujia) ? Math.max(0, player.hujia) : 0; + const storedStrength = player.storage?.mengsanStrength_shuying; + const strength = Number.isInteger(storedStrength) ? storedStrength : 0; + // Native damage(0) stops before damageBegin1, so strength must be included here. + const temporary = temporaryStrength?.amount(player) || 0; + const damage = event.target.damage(Math.max(0, block + strength + temporary), player); + damage.mengsanAttack_shuying = true; + damage.mengsanCardStrengthApplied_shuying = true; + damage.mengsanSetupStrengthApplied_shuying = true; + await damage; + }, + ai: { + order: 5, + result: { target(used, player) { return player.hujia > 0 ? -1.5 : 0; } }, + tag: { damage: 1 }, + }, + cardPrompt(used) { + const cost = cardCost(used || { name: "mengsan_quanshenzhuangji" }); + return `梦三:主动使用消耗${cost}费用。对一名敌方角色造成等同于你当前格挡(护甲)值的伤害。` + + "不消耗格挡,可以受到力量加成。" + + "每回合使用次数不限。"; + }, + }; + translate.mengsan_quanshenzhuangji = "全身撞击"; + translate.mengsan_quanshenzhuangji_info = "梦三:主动使用消耗1费用。对一名敌方角色造成等同于你当前格挡(护甲)值的伤害。" + + "不消耗格挡,可以受到力量加成。每回合使用次数不限。"; + // Double only the surviving target's existing vulnerable after damage resolves. + card.mengsan_rongrongzhiquan = { + type: "basic", fullimage: true, + image: "ext:术樱包/mengsan/assets/cards/mengsan_rongrongzhiquan.png", + mengsanCost_shuying: cardCost({ name: "mengsan_rongrongzhiquan" }), + mengsanExhaust_shuying: true, + usable: Infinity, selectTarget: 1, + enable(used, player, event) { + if (!isBattleActive() || !player.isAlive()) return false; + return !isActiveCardUse(event || _status.event, player) || canPayCard(player, used); + }, + filterTarget(used, player, target) { + return target !== player && target.isAlive() && player.isEnemyOf(target); + }, + async content(event, trigger, player) { + if (!isBattleActive() || !player.isAlive()) return; + const rule = cardUpgradeRule("mengsan_rongrongzhiquan"); + const target = event.target; + const damage = target.damage(cardUpgradeLevel(event.card) ? rule.damage : rule.baseDamage, player); + damage.mengsanAttack_shuying = true; + await damage; + if (!target.isAlive() || !isBattleActive()) return; + const stacks = target.storage?.mengsanVulnerable_shuying; + if (!Number.isSafeInteger(stacks) || stacks <= 0) return; + const doubled = stacks * 2; + if (!Number.isSafeInteger(doubled)) throw new RangeError("易伤层数越界"); + if (applyMengsanDebuff(target, "vulnerable", stacks)) + game.log(target, `易伤层数从${stacks}翻倍为${doubled}`); + }, + ai: { order: 6, result: { target: -1.5 }, tag: { damage: 1 } }, + cardPrompt(used) { + const rule = cardUpgradeRule("mengsan_rongrongzhiquan"); + const damage = cardUpgradeLevel(used) ? rule.damage : rule.baseDamage; + return `梦三:主动使用消耗1费用。对一名敌方角色造成${damage}点伤害,然后将该目标已有的易伤层数翻倍。` + + "没有易伤时不会新增易伤。消耗:使用后退出本场战斗,不进入弃牌堆或洗牌。"; + }, + }; + translate.mengsan_rongrongzhiquan = "熔融之拳"; + translate.mengsan_rongrongzhiquan_info = "梦三:主动使用消耗1费用。对一名敌方角色造成10点伤害,然后将该目标已有的易伤层数翻倍。" + + "消耗:使用后退出本场战斗。"; + // STS2【愤怒】:攻击后生成独立的战斗复制品,不扩充永久牌组。 + card.mengsan_fennu = { + type: "basic", fullimage: true, + image: "ext:术樱包/mengsan/assets/cards/mengsan_fennu.png", + mengsanCost_shuying: cardCost({ name: "mengsan_fennu" }), + enable(used, player, event) { + return !isActiveCardUse(event || _status.event, player) || + canPayCard(player, used); + }, + usable: Infinity, selectTarget: 1, + filterTarget(used, player, target) { + return target !== player && player.isEnemyOf(target); + }, + async content(event, trigger, player) { + if (typeof copyToDiscard !== "function") throw new Error("愤怒缺少战斗复制接口"); + const amount = cardUpgradeLevel(event.card) + ? cardUpgradeRule("mengsan_fennu").damage : 6; + const damage = event.target.damage(amount, player); + damage.mengsanAttack_shuying = true; + await damage; + if (copyToDiscard(player, event.card, event.cards)) { + game.log(player, "将一张【愤怒】的复制品加入个人弃牌堆"); + } + }, + ai: { order: 6, result: { target: -1 }, tag: { damage: 1 } }, + cardPrompt(used) { + const amount = cardUpgradeLevel(used) + ? cardUpgradeRule("mengsan_fennu").damage : 6; + return `梦三:主动使用消耗0费用。对一名敌方角色造成${amount}点伤害,` + + "然后将一张此牌的复制品加入你的个人弃牌堆。" + + "复制品保留原牌状态和词缀,仅存在于本场战斗。"; + }, + }; + translate.mengsan_fennu = "愤怒"; + translate.mengsan_fennu_info = "梦三:主动使用消耗0费用。对一名敌方角色造成6点伤害," + + "然后将一张此牌的复制品加入你的个人弃牌堆。复制品保留原牌状态和词缀,仅存在于本场战斗。" + + "每回合使用次数不限。"; + // STS2【放血】:先等待原生生命流失及濒死救援,再增加当前战斗费用。 + card.mengsan_fangxue = { + type: "basic", fullimage: true, + image: "ext:术樱包/mengsan/assets/cards/mengsan_fangxue.png", + mengsanCost_shuying: cardCost({ name: "mengsan_fangxue" }), + enable(used, player, event) { + return isBattleActive() && Boolean(battleEnergy?.canUse(player)) && + (!isActiveCardUse(event || _status.event, player) || canPayCard(player, used)); + }, + usable: Infinity, toself: true, selectTarget: -1, + filterTarget(used, player, target) { return target === player; }, + async content(event, trigger, player) { + if (typeof battleEnergy?.capture !== "function" || typeof battleEnergy?.grant !== "function") { + throw new Error("放血缺少战斗费用接口"); + } + const rule = cardUpgradeRule("mengsan_fangxue"); + const amount = cardUpgradeLevel(event.card) ? rule.energy : rule.baseEnergy; + const battle = battleEnergy.capture(player, amount); + if (!battle) return; + await player.loseHp(rule.loseHp); + battleEnergy.grant(player, amount, battle); + }, + ai: { order: 9, result: { player: player => player.hp > 3 ? 1 : -1 } }, + cardPrompt(used) { + const rule = cardUpgradeRule("mengsan_fangxue"); + const amount = cardUpgradeLevel(used) ? rule.energy : rule.baseEnergy; + return "梦三:主动使用消耗0费用。失去" + rule.loseHp + "点生命,然后获得" + amount + "点费用。"; + }, + }; + translate.mengsan_fangxue = "放血"; + translate.mengsan_fangxue_info = "梦三:主动使用消耗0费用。失去3点生命,然后获得2点费用。"; + // STS2【坚毅】:先获得格挡,再消耗一张自身当时的手牌。 + card.mengsan_jianyi = { + type: "basic", fullimage: true, + image: "ext:术樱包/mengsan/assets/cards/mengsan_jianyi.png", + mengsanCost_shuying: cardCost({ name: "mengsan_jianyi" }), + enable(used, player, event) { + return isBattleActive() && Boolean(player?.isAlive?.()) && + (!isActiveCardUse(event || _status.event, player) || canPayCard(player, used)); + }, + usable: Infinity, toself: true, selectTarget: -1, + filterTarget(used, player, target) { return target === player; }, + async content(event, trigger, player) { + if (typeof handExhaust?.capture !== "function" || typeof handExhaust?.exhaust !== "function") { + throw new Error("坚毅缺少手牌消耗接口"); + } + const battle = handExhaust.capture(player); + if (!battle) return; + const rule = cardUpgradeRule("mengsan_jianyi"); + const current = cardUpgradeLevel(event.card); + await player.changeHujia(current ? rule.block : rule.baseBlock); + await handExhaust.exhaust(player, event, battle, Boolean(current && rule.chooseExhaust)); + }, + ai: { order: 7, result: { player: 1 } }, + cardPrompt(used) { + const rule = cardUpgradeRule("mengsan_jianyi"); + const current = cardUpgradeLevel(used); + const block = current ? rule.block : rule.baseBlock; + return "梦三:主动使用消耗1费用。获得" + block + "点格挡(护甲),然后" + + (current && rule.chooseExhaust ? "选择消耗" : "随机消耗") + + "一张你的手牌(退出本场战斗,不进入弃牌堆或洗牌)。没有手牌时不消耗牌。"; + }, + }; + translate.mengsan_jianyi = "坚毅"; + translate.mengsan_jianyi_info = "梦三:主动使用消耗1费用。获得7点格挡(护甲),然后随机消耗一张你的手牌(退出本场战斗,不进入弃牌堆或洗牌)。没有手牌时不消耗牌。"; + // STS2【破灭】:免费使用自己的堆顶原牌,再将这一原牌消耗。 + card.mengsan_pomie = { + type: "basic", fullimage: true, + image: "ext:术樱包/mengsan/assets/cards/mengsan_pomie.png", + mengsanCost_shuying: cardCost({ name: "mengsan_pomie" }), + enable(used, player, event) { + return isBattleActive() && Boolean(player?.isAlive?.()) && + (!isActiveCardUse(event || _status.event, player) || canPayCard(player, used)); + }, + usable: Infinity, toself: true, selectTarget: -1, + filterTarget(used, player, target) { return target === player; }, + async content(event, trigger, player) { + if (typeof playDrawTop !== "function") throw new Error("破灭缺少堆顶使用接口"); + await playDrawTop(player); + }, + ai: { order: 6, result: { player: 1 } }, + cardPrompt(used) { + return "梦三:主动使用消耗" + cardCost(used) + + "费用。免费使用你抽牌堆顶部的牌,然后将该原牌消耗(退出本场战斗,不进入弃牌堆或洗牌)。抽牌堆为空时不生效。"; + }, + }; + translate.mengsan_pomie = "破灭"; + translate.mengsan_pomie_info = "梦三:主动使用消耗1费用。免费使用你抽牌堆顶部的牌,然后将该原牌消耗(退出本场战斗,不进入弃牌堆或洗牌)。抽牌堆为空时不生效。"; + // STS2【耸肩无视】:先获得格挡,再抽一张牌。 + card.mengsan_songjianwushi = { + type: "basic", fullimage: true, + image: "ext:术樱包/mengsan/assets/cards/mengsan_songjianwushi.png", + mengsanCost_shuying: cardCost({ name: "mengsan_songjianwushi" }), + enable(used, player, event) { + return isBattleActive() && Boolean(player?.isAlive?.()) && + (!isActiveCardUse(event || _status.event, player) || canPayCard(player, used)); + }, + usable: Infinity, toself: true, selectTarget: -1, + filterTarget(used, player, target) { return target === player; }, + async content(event, trigger, player) { + if (typeof blockDraw !== "function") throw new Error("耸肩无视缺少格挡抽牌接口"); + const rule = cardUpgradeRule("mengsan_songjianwushi"); + await blockDraw(player, cardUpgradeLevel(event.card) ? rule.block : rule.baseBlock, rule.draw); + }, + ai: { order: 7, result: { player: 1 }, tag: { draw: 1 } }, + cardPrompt(used) { + const rule = cardUpgradeRule("mengsan_songjianwushi"); + const block = cardUpgradeLevel(used) ? rule.block : rule.baseBlock; + return "梦三:主动使用消耗1费用。获得" + block + "点格挡(护甲),然后抽" + rule.draw + "张牌。"; + }, + }; + translate.mengsan_songjianwushi = "耸肩无视"; + translate.mengsan_songjianwushi_info = "梦三:主动使用消耗1费用。获得8点格挡(护甲),然后抽1张牌。"; + // 【防御】沿用梦三护甲事件,保留脆弱与伤害抵扣等既有规则。 + card.mengsan_fangyu = { + type: "basic", + fullimage: true, + image: "ext:术樱包/mengsan/assets/cards/mengsan_fangyu.png", + mengsanCost_shuying: cardCost({ name: "mengsan_fangyu" }), + enable(used, player, event) { + return !isActiveCardUse(event || _status.event, player) || + canPayCard(player, used); + }, + usable: Infinity, + toself: true, + selectTarget: -1, + filterTarget(used, player, target) { return target === player; }, + async content(event, trigger, player) { + await player.changeHujia(5); + }, + ai: { order: 7, result: { target: 1 } }, + cardPrompt() { return translate.mengsan_fangyu_info; }, + }; + translate.mengsan_fangyu = "防御"; + translate.mengsan_fangyu_info = + "梦三:主动使用消耗1费用。出牌阶段,对自己使用,获得5点格挡(护甲)。" + + "每回合使用次数不限。"; // 【桃】的回复量也由梦三牌本体提供;救援仍不消耗费用。 card.tao.content = async function (event) { await event.target.recover(8); }; translate.tao_info = "梦三:主动使用消耗1费用,回复8点生命;濒死救援不消耗费用,同样回复8点生命。"; @@ -139,5 +779,24 @@ export function createMengsanCards() { }; translate.shandian_info = "梦三:主动使用消耗1费用。若判定为黑桃2~9,目标受到12点雷电伤害;否则移至下家的判定区。"; card.shandian.cardPrompt = () => translate.shandian_info; - return { card, translate, names: [...names, "mengsan_zhongsha"] }; + card[INFECTION_NAME] = { ...infectionDefinition }; + translate[INFECTION_NAME] = "感染"; + translate[`${INFECTION_NAME}_info`] = infectionDefinition.cardPrompt(); + card[SLIMED_NAME] = { ...slimedDefinition }; + translate[SLIMED_NAME] = "黏液"; + translate[`${SLIMED_NAME}_info`] = slimedDefinition.cardPrompt(); + card[DAZED_NAME] = { ...dazedDefinition }; + translate[DAZED_NAME] = "晕眩"; + translate[`${DAZED_NAME}_info`] = dazedDefinition.cardPrompt(); + for (const [name, definition] of Object.entries(card)) { + if (!isAttackCard({ name }) || typeof definition.cardPrompt !== "function") continue; + const prompt = definition.cardPrompt; + definition.cardPrompt = function (used) { + const text = prompt.call(this, used); + const owner = used && typeof get.owner === "function" ? get.owner(used) : null; + if (!isBattleActive() || !owner?.storage?.mengsanTangled_shuying || typeof text !== "string") return text; + return text.replace(/主动使用消耗\d+费用/, `主动使用消耗${cardCost(used || { name }, owner)}费用`); + }; + } + return { card, translate, names: [...names, "mengsan_zhongsha", "mengsan_fangyu", "mengsan_fennu", "mengsan_feijianhuixuanbiao", "mengsan_jianbingdaji", "mengsan_quanshenzhuangji", "mengsan_rongrongzhiquan", "mengsan_shandianpili", "mengsan_shuangchongdaji", "mengsan_tiezhanbo", "mengsan_touchui", "mengsan_tupo", "mengsan_yubeidaji", "mengsan_wanmeidaji", "mengsan_yujin", "mengsan_fangxue", "mengsan_jianyi", "mengsan_pomie", "mengsan_songjianwushi"] }; } diff --git a/mengsan/cards/personal-piles.js b/mengsan/cards/personal-piles.js index 379e51e..7aec8c0 100644 --- a/mengsan/cards/personal-piles.js +++ b/mengsan/cards/personal-piles.js @@ -10,9 +10,10 @@ export function installPersonalPiles(session, owner, battle, run, resources, env if (!descriptor || !descriptor.configurable || (!Object.hasOwn(descriptor, "value") && typeof descriptor.get !== "function")) throw new Error("Unsupported pile property: " + key); } const draw = document.createElement("div"), discard = document.createElement("div"); + const exhausted = document.createElement("div"); exhausted.id = "special"; // Detached nodes: engine get.position recognises these IDs; no duplicate document IDs. draw.id = "cardPile"; discard.id = "discardPile"; - const owned = new Set(), restores = []; + const owned = new Set(), topPlaying = new Set(), restores = []; const strict = env.strict !== false; let released = false, forcedOwner = false; const personal = () => { @@ -41,7 +42,17 @@ export function installPersonalPiles(session, owner, battle, run, resources, env card.destroyLog = false; card.destroyed = (current, position) => { if (released || !["discardPile", "equip", "judge"].includes(position)) return false; - if (current.storage.mengsanCard_shuying.affixes.includes("exhaust") && current.storage.mengsanConsumed_shuying) { + if (position === "discardPile" && topPlaying.has(current)) { + if (!current.storage.mengsanExhausted_shuying) { + current.storage.mengsanExhausted_shuying = true; + battle.exhaustPile.push(snapshot(current)); + refresh(); + } + return true; + } + const exhaust = env.lib?.card?.[current.name]?.mengsanExhaust_shuying === true || + current.storage.mengsanCard_shuying.affixes.includes("exhaust"); + if (exhaust && current.storage.mengsanConsumed_shuying) { if (current.storage.mengsanExhausted_shuying) return true; current.storage.mengsanExhausted_shuying = true; battle.exhaustPile.push(snapshot(current)); @@ -51,7 +62,7 @@ export function installPersonalPiles(session, owner, battle, run, resources, env return false; // Actual discarded cards remain available to native skills. }; for (const key of card.storage.mengsanCard_shuying.affixes) card.addGaintag(AFFIX_INFO[key]?.name || key); - if (data.upgrade) card.addGaintag(`强化+${data.upgrade}`); + // 强化仅用手牌 +X 角标显示;数值仍保存在 mengsanCard_shuying.upgrade。 } function snapshot(card) { const data = card.storage?.mengsanCard_shuying || {}; @@ -124,10 +135,95 @@ export function installPersonalPiles(session, owner, battle, run, resources, env observer.disconnect(); released = true; for (const restore of restores.reverse()) restore(); for (const card of owned) card.remove(); - owned.clear(); draw.remove(); discard.remove(); + owned.clear(); topPlaying.clear(); draw.remove(); discard.remove(); exhausted.remove(); }); return { take, + // 取出当前堆顶,不算抽牌、不洗牌,也不触发不足牌堆失败。 + beginTopPlay() { + if (released || !session.active || !owner.isAlive() || !draw.firstChild) return null; + const card = draw.firstChild; + adopt(card); card.original = "c"; card.fix?.(); + exhausted.appendChild(card); topPlaying.add(card); refresh(); + return card; + }, + async finishTopPlay(card) { + if (!topPlaying.has(card)) return null; + try { + if (released || !session.active) return null; + if (card.storage?.mengsanExhausted_shuying) { card.remove(); return card; } + if (owner.getCards("hej").includes(card)) { + const loss = owner.lose(card, exhausted); loss.type = "mengsanExhaust"; + await loss; + if (released || !session.active || card.parentNode !== exhausted) return null; + } + // 被使用后在排序区/弃牌堆,或无法使用而仍在暂存区,均消耗原牌一次。 + if (!owned.has(card)) return null; + card.storage.mengsanExhausted_shuying = true; + battle.exhaustPile.push(snapshot(card)); card.remove(); refresh(); + return card; + } finally { topPlaying.delete(card); } + }, + // Explicit exhaustion is not a discard and must not touch the public piles. + async exhaustFromHand(card) { + if (released || !session.active || !owner.isAlive() || + !owner.getCards("h").includes(card) || card.storage?.mengsanExhausted_shuying) return null; + adopt(card); + const loss = owner.lose(card, exhausted); + loss.type = "mengsanExhaust"; + await loss; + // A cancelled/redirection event must not falsely consume its original. + if (released || !session.active || card.parentNode !== exhausted) return null; + if (!card.storage.mengsanExhausted_shuying) { + card.storage.mengsanExhausted_shuying = true; + battle.exhaustPile.push(snapshot(card)); + } + card.remove(); + refresh(); + return card; + }, + // Current physical combat cards only. Exhausted originals remain in owned; + // never add the serialized exhaust snapshots a second time. + battleCards(playing = []) { + if (released || !session.active) return []; + const cards = new Set([...owner.getCards("h"), ...draw.childNodes, ...discard.childNodes, ...exhausted.childNodes]); + for (const card of owned) { + if (card.storage?.mengsanOwnerId_shuying === owner.playerid && + card.storage?.mengsanExhausted_shuying) cards.add(card); + } + // The caller supplies this actor's native use-card originals, not virtual cards. + for (const card of playing) if (ui.ordering && card?.parentNode === ui.ordering) cards.add(card); + return [...cards]; + }, + addToDiscard(data) { + if (released || !session.active) return null; + if (!data || typeof data.id !== "string" || !data.id || + typeof data.name !== "string" || !/^[a-z][a-z0-9_]*$/.test(data.name) || + !["spade", "heart", "club", "diamond"].includes(data.suit) || + !Number.isInteger(data.number) || data.number < 1 || data.number > 13 || + !Array.isArray(data.affixes) || data.affixes.some(key => !Object.hasOwn(AFFIX_INFO, key))) { + throw new TypeError("战斗生成牌数据无效"); + } + const card = game.createCard(data.name, data.suit, data.number, data.nature); + adopt(card, data); + discard.appendChild(card); + refresh(); + return card; + }, + // Return references only to cards currently in this actor's discard node. + discardCards() { + if (released || !session.active) return []; + return Array.from(discard.childNodes).filter(card => owned.has(card) && + !card.storage?.mengsanExhausted_shuying); + }, + moveDiscardToTop(card) { + if (released || !session.active || !owned.has(card) || + card.parentNode !== discard || card.storage?.mengsanExhausted_shuying) return false; + card.fix?.(); + draw.insertBefore(card, draw.firstChild); + refresh(); + return true; + }, addAffix(card, key) { if (released || !Object.hasOwn(AFFIX_INFO, key) || !owned.has(card) || !owner.getCards("h").includes(card)) return false; const affixes = card.storage?.mengsanCard_shuying?.affixes; diff --git a/mengsan/cards/random-hand-exhaust.js b/mengsan/cards/random-hand-exhaust.js new file mode 100644 index 0000000..13c9dca --- /dev/null +++ b/mengsan/cards/random-hand-exhaust.js @@ -0,0 +1,19 @@ +// STS2 Cinder selects from the actor's hand after damage, never from the draw pile. +export function createRandomHandExhauster(game, getActiveBattle, getRun, nextRandom) { + return { + capture() { const battle = getActiveBattle(); return battle?.session.active ? battle : null; }, + async exhaust(player, event, battle) { + if (!battle || getActiveBattle() !== battle || !battle.session.active || !player?.isAlive()) return null; + const piles = player === game.me ? battle.personalPiles : battle.monsterPiles?.get(player); + if (typeof piles?.exhaustFromHand !== "function") throw new Error("余烬缺少个人消耗牌堆接口"); + const playing = new Set(event?.cards || []); + const hand = player.getCards("h").filter(card => !playing.has(card) && !card.storage?.mengsanExhausted_shuying); + if (!hand.length) return null; + const run = getRun(); + if (!run) throw new Error("余烬缺少征程随机状态"); + const value = nextRandom(run); + if (!Number.isFinite(value) || value < 0 || value >= 1) throw new RangeError("余烬随机值无效"); + return await piles.exhaustFromHand(hand[Math.floor(value * hand.length)]); + }, + }; +} diff --git a/mengsan/cards/slimed-card.js b/mengsan/cards/slimed-card.js new file mode 100644 index 0000000..314430e --- /dev/null +++ b/mengsan/cards/slimed-card.js @@ -0,0 +1,19 @@ +import { _status } from "../../../../noname.js"; +import { canPayCard, isActiveCardUse } from "../battle/combat-rules.js"; +export const SLIMED_NAME = "mengsan_slimed_shuying"; +export function createSlimedData(id) { + if (typeof id !== "string" || !id) throw new TypeError("黏液牌缺少ID"); + return { id, name: SLIMED_NAME, suit: "spade", number: 1, nature: null, affixes: ["exhaust"], upgrade: 0 }; +} +export const slimedDefinition = Object.freeze({ + type: "status", fullimage: true, + image: "ext:术樱包/mengsan/assets/cards/mengsan_slimed_shuying.png", + mengsanCost_shuying: 1, mengsanExhaust_shuying: true, + enable(used, player, event) { + return !isActiveCardUse(event || _status.event, player) || canPayCard(player, used); + }, + notarget: true, + async content(event, trigger, player) { await player.draw(1); }, + ai: { value: -1, useful: 0, order: 1, result: { player: 1 } }, + cardPrompt: () => "梦三:主动使用消耗1费用。抽1张牌。消耗:使用后退出此次战斗。", +}); diff --git a/mengsan/cards/status-cards.js b/mengsan/cards/status-cards.js new file mode 100644 index 0000000..c656163 --- /dev/null +++ b/mengsan/cards/status-cards.js @@ -0,0 +1,14 @@ +// 战斗生成的状态牌不登记到永久可获得牌表,也不写入征程牌组。 +export const DAZED_NAME = "mengsan_dazed_shuying"; +export function createDazedData(id) { + if (typeof id !== "string" || !id) throw new TypeError("晕眩牌缺少ID"); + return { id, name: DAZED_NAME, suit: "spade", number: 1, + nature: null, affixes: ["unplayable", "void"], upgrade: 0 }; +} +export const dazedDefinition = Object.freeze({ + type: "status", fullimage: true, + image: "ext:术樱包/mengsan/assets/cards/mengsan_dazed_shuying.png", + enable: false, notarget: true, + ai: { value: -1, useful: -1, order: 0 }, + cardPrompt: () => "不能被打出。虚无:弃牌阶段开始时移除此牌,退出此次战斗。", +}); diff --git a/mengsan/cards/strike-counter.js b/mengsan/cards/strike-counter.js new file mode 100644 index 0000000..ab0fde7 --- /dev/null +++ b/mengsan/cards/strike-counter.js @@ -0,0 +1,27 @@ +// Count current actor-owned combat cards; permanent save data is deliberately absent. +export function createStrikeCounter(game, getActiveBattle, lib) { + return (player, event) => { + const battle = getActiveBattle(); + if (!battle?.session.active || !player?.isAlive()) return 0; + const piles = player === game.me ? battle.personalPiles : battle.monsterPiles?.get(player); + if (typeof piles?.battleCards !== "function") throw new Error("完美打击缺少个人战斗牌堆接口"); + const playing = new Set(event?.cards || []); + const seen = new Set(); + for (let parent = event?.parent; parent && !seen.has(parent); parent = parent.parent) { + seen.add(parent); + if (parent.name === "useCard" && parent.player === player) { + for (const card of parent.cards || []) playing.add(card); + } + } + // Nested native attacks may leave earlier originals in the playing zone. + for (const used of player.getHistory?.("useCard") || []) { + for (const card of used.cards || []) playing.add(card); + } + let count = 0; + for (const card of piles.battleCards(playing)) { + const name = lib.translate[card.name]; + if (typeof name === "string" && name.includes("打击")) count++; + } + return count; + }; +} diff --git a/mengsan/cards/temporary-strength.js b/mengsan/cards/temporary-strength.js new file mode 100644 index 0000000..d77155e --- /dev/null +++ b/mengsan/cards/temporary-strength.js @@ -0,0 +1,62 @@ +// Temporary combat-only strength. Never alter permanent strength or the run/deck. +const records = new WeakMap(); +const SKILL = "mengsan_setup_strength_shuying"; + +export function forgetTemporaryStrength(player) { + const existed = records.delete(player); + delete player.storage.mengsanSetupStrength_shuying; + return existed; +} + +export function clearTemporaryStrength(player) { + if (!records.has(player)) return false; + forgetTemporaryStrength(player); + player.removeSkill(SKILL); + return true; +} + +export function temporaryStrengthAmount(player, battle) { + const entry = records.get(player); + return entry?.battle === battle && battle?.session.active ? entry.amount : 0; +} + +export function temporaryStrengthExpires(player, phase) { + const entry = records.get(player); + return Boolean(entry && phase.name === "phase" && (!entry.turn || entry.turn === phase)); +} + +export function createTemporaryStrength(game, getActiveBattle) { + return { + amount(player) { return temporaryStrengthAmount(player, getActiveBattle()); }, + capture() { + const battle = getActiveBattle(); + return battle?.session.active ? battle : null; + }, + grant(player, amount, event, battle) { + if (!battle?.session.active || getActiveBattle() !== battle || !player.isAlive()) return false; + if (!Number.isSafeInteger(amount) || amount <= 0) throw new RangeError("临时力量必须是正安全整数"); + const ancestor = event.getParent?.("phase"); + const turn = ancestor?.name === "phase" ? ancestor : null; + let entry = records.get(player); + if (entry && (entry.battle !== battle || entry.turn !== turn)) { + clearTemporaryStrength(player); + entry = null; + } + if (amount > Number.MAX_SAFE_INTEGER - (entry?.amount || 0)) throw new RangeError("临时力量层数越界"); + if (!entry) { + entry = { battle, turn, amount: 0 }; + // Register cleanup before publishing state. A stale release cannot clear a later battle. + battle.session.ownResource(entry, () => { + if (records.get(player) === entry) forgetTemporaryStrength(player); + }); + records.set(player, entry); + } + entry.amount += amount; + player.storage.mengsanSetupStrength_shuying = entry.amount; + player.addSkill(SKILL); + player.markSkill(SKILL); + game.log(player, `在本回合内获得${amount}点力量`); + return true; + }, + }; +} diff --git a/mengsan/cards/upgrades.js b/mengsan/cards/upgrades.js index 389998a..c35f5dc 100644 --- a/mengsan/cards/upgrades.js +++ b/mengsan/cards/upgrades.js @@ -1,4 +1,21 @@ const upgrades = Object.freeze({ + mengsan_songjianwushi: Object.freeze({ maxLevel: 1, baseBlock: 8, block: 11, draw: 1 }), + mengsan_pomie: Object.freeze({ maxLevel: 1, baseCost: 1, cost: 0 }), + mengsan_jianyi: Object.freeze({ maxLevel: 1, baseBlock: 7, block: 9, chooseExhaust: true }), + mengsan_fangxue: Object.freeze({ maxLevel: 1, loseHp: 3, baseEnergy: 2, energy: 3 }), + mengsan_yujin: Object.freeze({ maxLevel: 1, baseDamage: 18, damage: 24, exhaust: 1 }), + mengsan_wanmeidaji: Object.freeze({ maxLevel: 1, baseDamage: 6, damage: 6, basePerStrike: 2, perStrike: 3 }), + mengsan_yubeidaji: Object.freeze({ maxLevel: 1, baseDamage: 7, damage: 9, baseStrength: 2, strength: 3 }), + mengsan_tupo: Object.freeze({ maxLevel: 1, baseDamage: 9, damage: 13, loseHp: 1 }), + mengsan_touchui: Object.freeze({ maxLevel: 1, baseDamage: 9, damage: 12 }), + mengsan_tiezhanbo: Object.freeze({ maxLevel: 1, baseBlock: 5, block: 7, baseDamage: 5, damage: 7 }), + mengsan_shuangchongdaji: Object.freeze({ maxLevel: 1, baseDamage: 5, damage: 7, hits: 2 }), + mengsan_shandianpili: Object.freeze({ maxLevel: 1, baseDamage: 4, damage: 7, vulnerable: 1 }), + mengsan_rongrongzhiquan: Object.freeze({ maxLevel: 1, baseDamage: 10, damage: 14, exhaust: true }), + mengsan_quanshenzhuangji: Object.freeze({ maxLevel: 1, baseCost: 1, cost: 0, damageFromBlock: true }), + mengsan_jianbingdaji: Object.freeze({ maxLevel: 1, baseDamage: 9, damage: 10, baseDraw: 1, draw: 2 }), + mengsan_feijianhuixuanbiao: Object.freeze({ maxLevel: 1, damage: 3, baseHits: 3, hits: 4 }), + mengsan_fennu: Object.freeze({ maxLevel: 1, baseDamage: 6, damage: 8 }), sha: Object.freeze({ maxLevel: 1, damage: 9 }), mengsan_zhongsha: Object.freeze({ maxLevel: 1, damage: 10, vulnerable: 3 }), diff --git a/mengsan/content/acts/act1/inklet-group.js b/mengsan/content/acts/act1/inklet-group.js new file mode 100644 index 0000000..ab331e9 --- /dev/null +++ b/mengsan/content/acts/act1/inklet-group.js @@ -0,0 +1,12 @@ +import { INKLET_CHARACTER } from "../../../monsters/inklet-intent.js"; + +// 网页未指定固定群体数量;梦三适配为三只,前/中/后位置固定,生命在实际入场抽取。 +export function createInkletBattlePlan() { + return { + units: Array.from({ length: 3 }, (_, index) => ({ + id: `inklet_${index + 1}`, character: INKLET_CHARACTER, camp: "enemy", + tier: "normal", inkletPosition: index + 1, hand: 4, inheritSkills: false, + })), + rules: [], + }; +} diff --git a/mengsan/content/acts/act1/map.js b/mengsan/content/acts/act1/map.js index aeca069..7ad1f7b 100644 --- a/mengsan/content/acts/act1/map.js +++ b/mengsan/content/acts/act1/map.js @@ -9,7 +9,7 @@ const act1Map = { 1, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 1, ], - nodeWeights: { battle: 65, event: 15, rest: 10, shop: 10 }, + nodeWeights: { battle: 50, elite: 15, event: 15, rest: 10, shop: 10 }, requiredFloors: { 2: "battle", 10: "chest", 16: "rest" }, chestRewardPool: "act1.pool.chest", fixedNodes: [ @@ -20,8 +20,14 @@ const act1Map = { contentId: "act1.battle.liubeiOpening", }, ], - enemies: ["mengsan_flyconid_shuying", "mengsan_raider_trio_shuying"], - eliteEnemies: ["re_xiahoudun", "re_zhangliao", "re_xuzhu"], + enemies: [ + "mengsan_flyconid_shuying", "mengsan_raider_trio_shuying", + "mengsan_soldier_shuying", "mengsan_fogmog_shuying", + "mengsan_mawler_shuying", "mengsan_vine_shambler_shuying", + "mengsan_nibbit_shuying", "mengsan_nibbit_pair_shuying", "mengsan_cubex_construct_shuying", "mengsan_shrinker_beetle_shuying", "mengsan_twig_slime_m_shuying", "mengsan_twig_slime_s_shuying", "mengsan_leaf_slime_m_shuying", "mengsan_leaf_slime_s_shuying", "mengsan_slithering_strangler_shuying", "mengsan_snapping_jaxfruit_shuying", "mengsan_fuzzy_wurm_crawler_shuying", + "mengsan_inklet_shuying", + ], + eliteEnemies: ["re_xiahoudun", "re_zhangliao", "re_xuzhu", "mengsan_byrdonis_shuying", "mengsan_bygone_effigy_shuying", "mengsan_phrog_parasite_shuying"], boss: "re_lvbu", baseGold: 20, }; diff --git a/mengsan/content/acts/act1/nibbit-pair.js b/mengsan/content/acts/act1/nibbit-pair.js new file mode 100644 index 0000000..0eb92be --- /dev/null +++ b/mengsan/content/acts/act1/nibbit-pair.js @@ -0,0 +1,8 @@ +import { NIBBIT_CHARACTER } from "../../../monsters/nibbit-intent.js"; +export const NIBBIT_PAIR_ENCOUNTER = "mengsan_nibbit_pair_shuying"; +export function createNibbitPairBattlePlan() { + return { units: [ + { id: "nibbit_front", character: NIBBIT_CHARACTER, camp: "enemy", tier: "normal", hand: 4, inheritSkills: false }, + { id: "nibbit_back", character: NIBBIT_CHARACTER, camp: "enemy", tier: "normal", hand: 4, inheritSkills: false }, + ], rules: [] }; +} diff --git a/mengsan/content/scenario-characters.js b/mengsan/content/scenario-characters.js index 73996d8..9a01007 100644 --- a/mengsan/content/scenario-characters.js +++ b/mengsan/content/scenario-characters.js @@ -9,6 +9,44 @@ export function createScenarioCharacters(Character) { skills: [], img: "image/character/shibing1.jpg" }), mengsan_flyconid_shuying: new Character({ sex: "none", group: "qun", hp: 48, maxHp: 48, skills: [], img: "extension/术樱包/mengsan/assets/portraits/flyconid-portrait.png" }), + mengsan_fuzzy_wurm_crawler_shuying: new Character({ sex: "none", group: "qun", hp: 56, maxHp: 56, + skills: [], img: "extension/术樱包/mengsan/assets/portraits/fuzzy-wurm-crawler-forest-portrait.png" }), + mengsan_inklet_shuying: new Character({ sex: "none", group: "qun", hp: 14, maxHp: 14, + skills: [], img: "extension/术樱包/mengsan/assets/portraits/inklet-forest-portrait.png" }), + mengsan_snapping_jaxfruit_shuying: new Character({ sex: "none", group: "qun", hp: 32, maxHp: 32, + skills: [], img: "extension/术樱包/mengsan/assets/portraits/snapping-jaxfruit-forest-portrait.png" }), + mengsan_slithering_strangler_shuying: new Character({ sex: "none", group: "qun", hp: 54, maxHp: 54, + skills: [], img: "extension/术樱包/mengsan/assets/portraits/slithering-strangler-forest-portrait.png" }), + mengsan_leaf_slime_s_shuying: new Character({ sex: "none", group: "qun", hp: 13, maxHp: 13, + skills: [], img: "extension/术樱包/mengsan/assets/portraits/leaf-slime-s-forest-portrait.png" }), + mengsan_leaf_slime_m_shuying: new Character({ sex: "none", group: "qun", hp: 34, maxHp: 34, + skills: [], img: "extension/术樱包/mengsan/assets/portraits/leaf-slime-m-forest-portrait.png" }), + mengsan_twig_slime_s_shuying: new Character({ sex: "none", group: "qun", hp: 9, maxHp: 9, + skills: [], img: "extension/术樱包/mengsan/assets/portraits/twig-slime-s-forest-portrait.png" }), + mengsan_twig_slime_m_shuying: new Character({ sex: "none", group: "qun", hp: 27, maxHp: 27, + skills: [], img: "extension/术樱包/mengsan/assets/portraits/twig-slime-m-forest-portrait.png" }), + mengsan_shrinker_beetle_shuying: new Character({ sex: "none", group: "qun", hp: 39, maxHp: 39, + skills: [], img: "extension/术樱包/mengsan/assets/portraits/shrinker-beetle-forest-portrait.png" }), + mengsan_phrog_parasite_shuying: new Character({ sex: "none", group: "qun", hp: 62, maxHp: 62, + skills: [], img: "extension/术樱包/mengsan/assets/portraits/phrog-parasite-forest-portrait.png" }), + mengsan_wriggler_shuying: new Character({ sex: "none", group: "qun", hp: 19, maxHp: 19, + skills: [], img: "extension/术樱包/mengsan/assets/portraits/wriggler-forest-portrait.png" }), + mengsan_bygone_effigy_shuying: new Character({ sex: "none", group: "qun", hp: 127, maxHp: 127, + skills: [], img: "extension/术樱包/mengsan/assets/portraits/bygone-effigy-forest-portrait.png" }), + mengsan_byrdonis_shuying: new Character({ sex: "none", group: "qun", hp: 82, maxHp: 82, + skills: [], img: "extension/术樱包/mengsan/assets/portraits/byrdonis-forest-portrait.png" }), + mengsan_cubex_construct_shuying: new Character({ sex: "none", group: "qun", hp: 65, maxHp: 65, + skills: [], img: "extension/术樱包/mengsan/assets/portraits/cubex-construct-forest-portrait.png" }), + mengsan_nibbit_shuying: new Character({ sex: "none", group: "qun", hp: 44, maxHp: 44, + skills: [], img: "extension/术樱包/mengsan/assets/portraits/nibbit-forest-portrait.png" }), + mengsan_vine_shambler_shuying: new Character({ sex: "none", group: "qun", hp: 61, maxHp: 61, + skills: [], img: "extension/术樱包/mengsan/assets/portraits/vine-shambler-forest-portrait.png" }), + mengsan_mawler_shuying: new Character({ sex: "none", group: "qun", hp: 72, maxHp: 72, + skills: [], img: "extension/术樱包/mengsan/assets/portraits/mawler-forest-portrait.png" }), + mengsan_fogmog_shuying: new Character({ sex: "none", group: "qun", hp: 74, maxHp: 74, + skills: [], img: "extension/术樱包/mengsan/assets/portraits/fogmog-forest-portrait.png" }), + mengsan_eye_with_teeth_shuying: new Character({ sex: "none", group: "qun", hp: 6, maxHp: 6, + skills: [], img: "extension/术樱包/mengsan/assets/portraits/eye-with-teeth-forest-portrait.png" }), mengsan_raider_brute_shuying: new Character({ sex: "none", group: "qun", hp: 32, maxHp: 32, skills: [], img: "extension/术樱包/mengsan/assets/portraits/raider-brute-portrait.png" }), mengsan_raider_assassin_shuying: new Character({ sex: "none", group: "qun", hp: 21, maxHp: 21, @@ -30,6 +68,26 @@ export const scenarioTranslations = { mengsan_taoyuan_bond_shuying_info: "先天:本征程初始结识关羽、张飞,二人羁绊均为8级。可在行军菜单中指定一名助战角色;战斗结束,到场增加50%升级进度,未到场增加25%。", mengsan_soldier_shuying: "士兵", mengsan_flyconid_shuying: "飞蝇菌子", + mengsan_fogmog_shuying: "雾菇", + mengsan_mawler_shuying: "蛮兽", + mengsan_vine_shambler_shuying: "藤蔓蹒跚者", + mengsan_nibbit_shuying: "小啃兽", + mengsan_cubex_construct_shuying: "立柱构造体", + mengsan_byrdonis_shuying: "多尼斯异鸟", + mengsan_bygone_effigy_shuying: "旧日雕像", + mengsan_phrog_parasite_shuying: "异蛙寄生虫", + mengsan_wriggler_shuying: "扭动虫", + mengsan_nibbit_pair_shuying: "小啃兽双只", + mengsan_shrinker_beetle_shuying: "缩小甲虫", + mengsan_twig_slime_m_shuying: "树枝史莱姆(中)", + mengsan_twig_slime_s_shuying: "树枝史莱姆(小)", + mengsan_leaf_slime_m_shuying: "树叶史莱姆(中)", + mengsan_leaf_slime_s_shuying: "树叶史莱姆(小)", + mengsan_slithering_strangler_shuying: "蛇行扼杀者", + mengsan_snapping_jaxfruit_shuying: "闪光贾克斯果", + mengsan_inklet_shuying: "墨宝", + mengsan_fuzzy_wurm_crawler_shuying: "毛绒伏地虫", + mengsan_eye_with_teeth_shuying: "利齿之眼", mengsan_raider_brute_shuying: "劫掠者暴徒", mengsan_raider_assassin_shuying: "劫掠者刺客", mengsan_raider_axe_shuying: "劫掠者斧手", diff --git a/mengsan/content/shared/rewards.js b/mengsan/content/shared/rewards.js index da9a244..87703ab 100644 --- a/mengsan/content/shared/rewards.js +++ b/mengsan/content/shared/rewards.js @@ -11,6 +11,78 @@ export const rewards = { card: { name: "mengsan_zhongsha" }, name: "获得一张【重杀】", description: "2费:造成8点伤害,然后给予目标2层易伤。", }, + "shared.reward.card.songjianwushi": { + card: { name: "mengsan_songjianwushi" }, name: "获得一张【耸肩无视】", + description: "1费:获得8点格挡,然后抽1张牌。", + }, + "shared.reward.card.pomie": { + card: { name: "mengsan_pomie" }, name: "获得一张【破灭】", + description: "1费:免费使用抽牌堆顶部的牌,然后将该原牌消耗。", + }, + "shared.reward.card.jianyi": { + card: { name: "mengsan_jianyi" }, name: "获得一张【坚毅】", + description: "1费:获得7点格挡,然后随机消耗一张你的手牌。", + }, + "shared.reward.card.fangxue": { + card: { name: "mengsan_fangxue" }, name: "获得一张【放血】", + description: "0费:失去3点生命,然后获得2点费用。", + }, + "shared.reward.card.yujin": { + card: { name: "mengsan_yujin" }, name: "获得一张【余烬】", + description: "2费:造成18点伤害,然后随机消耗一张你的手牌。", + }, + "shared.reward.card.wanmeidaji": { + card: { name: "mengsan_wanmeidaji" }, name: "获得一张【完美打击】", + description: "2费:造成6点伤害,本场战斗的牌中每有一张名称含“打击”的牌,伤害增加2点。", + }, + "shared.reward.card.yubeidaji": { + card: { name: "mengsan_yubeidaji" }, name: "获得一张【预备打击】", + description: "1费:造成7点伤害,然后在本回合内获得2点力量。", + }, + "shared.reward.card.tupo": { + card: { name: "mengsan_tupo" }, name: "获得一张【突破】", + description: "1费:失去1点生命,然后对所有敌人造成9点伤害。", + }, + "shared.reward.card.touchui": { + card: { name: "mengsan_touchui" }, name: "获得一张【头槌】", + description: "1费:造成9点伤害,将个人弃牌堆中的一张牌放到个人抽牌堆顶部。", + }, + "shared.reward.card.tiezhanbo": { + card: { name: "mengsan_tiezhanbo" }, name: "获得一张【铁斩波】", + description: "1费:获得5点格挡,然后对一名敌人造成5点伤害。", + }, + "shared.reward.card.shuangchongdaji": { + card: { name: "mengsan_shuangchongdaji" }, name: "获得一张【双重打击】", + description: "1费:对一名敌人造成5点伤害两次,两次分别结算。", + }, + "shared.reward.card.shandianpili": { + card: { name: "mengsan_shandianpili" }, name: "获得一张【闪电霹雳】", + description: "1费:对所有敌人造成4点伤害,给予1层易伤。", + }, + "shared.reward.card.feijianhuixuanbiao": { + card: { name: "mengsan_feijianhuixuanbiao" }, name: "获得一张【飞剑回旋镖】", + description: "1费:每次随机对一名敌人造成3点伤害,共3次。", + }, + "shared.reward.card.jianbingdaji": { + card: { name: "mengsan_jianbingdaji" }, name: "获得一张【剑柄打击】", + description: "1费:造成9点伤害,然后抽1张牌。", + }, + "shared.reward.card.quanshenzhuangji": { + card: { name: "mengsan_quanshenzhuangji" }, name: "获得一张【全身撞击】", + description: "1费:造成当前格挡值的伤害,不消耗格挡。", + }, + "shared.reward.card.rongrongzhiquan": { + card: { name: "mengsan_rongrongzhiquan" }, name: "获得一张【熔融之拳】", + description: "1费:造成10点伤害,再将目标已有易伤层数翻倍。消耗。", + }, + "shared.reward.card.fennu": { + card: { name: "mengsan_fennu" }, name: "获得一张【愤怒】", + description: "0费:造成6点伤害,将一张自身复制品加入个人弃牌堆。", + }, + "shared.reward.card.fangyu": { + card: { name: "mengsan_fangyu" }, name: "获得一张【防御】", + description: "1费:获得5点格挡。", + }, "shared.reward.card.tao": { effectId: "card_tao", name: "获得一张【桃】", description: "加入独立永久牌组" }, "shared.reward.card.wuzhong": { card: { name: "wuzhong" }, name: "获得一张【无中生有】", description: "加入独立永久牌组" }, "shared.reward.card.upgrade": { effectId: "upgrade", name: "随机强化", description: "随机强化一张尚未满级的可强化牌" }, @@ -26,6 +98,22 @@ export const rewardPools = { "shared.pool.battle.normal": [ "shared.reward.card.sha", "shared.reward.card.zhongsha", + "shared.reward.card.fangyu", + "shared.reward.card.fennu", + "shared.reward.card.rongrongzhiquan", + "shared.reward.card.quanshenzhuangji", + "shared.reward.card.jianbingdaji", + "shared.reward.card.feijianhuixuanbiao", + "shared.reward.card.shandianpili", + "shared.reward.card.shuangchongdaji", + "shared.reward.card.tiezhanbo", + "shared.reward.card.touchui", + "shared.reward.card.tupo", + "shared.reward.card.yujin", + "shared.reward.card.fangxue", + "shared.reward.card.jianyi", + "shared.reward.card.pomie", + "shared.reward.card.songjianwushi", "shared.reward.card.wanmeidaji", "shared.reward.card.yubeidaji", "shared.reward.card.tao", "shared.reward.card.wuzhong", ], diff --git a/mengsan/docs/bygone-effigy.md b/mengsan/docs/bygone-effigy.md new file mode 100644 index 0000000..e80727d --- /dev/null +++ b/mengsan/docs/bygone-effigy.md @@ -0,0 +1,14 @@ +# 旧日雕像 BYGONE_EFFIGY + +2026-10-03:https://sts2.huijiwiki.com/wiki/旧日雕像;https://sts2.huijiwiki.com/wiki/缓慢 +注册mengsan_bygone_effigy_shuying;第一层精英池,普通生命127,无随机生命抽取。A8生命132、A9斩击15只记录,不混入当前普通数值。 +SVG开始→沉睡→唤醒→斩击(自循环)。第1回合沉睡,取消原生出牌阶段,不耗费用;第2回合苏醒10力量,仅一次;第3回合起斩击13基础伤害,叠加力量后23。 +其余意图消耗1费用,之后保留梦三精英原生出牌,抽2、上限5、费用3;原生攻击牌也受力量修正。首回合不是单纯隐藏意图后仍可以出杀。 +缓慢初始能力1层,图标数值显示本回合额外伤害百分比,不是能力层数。友方共用每只雕像上的计数,每次完成使用一张牌后(useCardAfter)增加10%;攻击本身结算时用之前已完成牌数,虚拟牌/多目标牌只算一次;防御、治疗也计数。纯响应respond不计数;敌方出牌、取消使用、战斗外事件不计数。 +回合边界phaseBefore/phaseAfter清零,状态仍在,不仅雕像自己回合结束才清零。友方主动回合的支援使用牌也计入相同敌方状态。 +伤害倍率只作用于攻击类别卡牌或明确标记mengsanAttack_shuying的脚本攻击。非攻击遗物伤害不放大,护甲不影响计数。梦三整数伤害适配:力量/虚弱/易伤后乘(1+10%×牌数),向下取整,再由引擎抵扣护甲;意图预测使用同一计算。 +费用不足与击晕沿用现有advance/retry,不重新沉睡;死亡、会话结束中止后续攻击/增益。缓慢、循环、力量会话清理,旧会话不误删新会话状态。永久牌堆不变。 +原画:https://huiji-public.huijistatic.com/sts2/uploads/b/b5/Bygone_effigy.png +内置image_gen基于原画和密林风格重绘独立3:4不透明立绘;完整雕像、镰刀、盾牌、石台,无UI文字。 +提示词:D:\noname\resources\app\.codex-artifacts\mengsan-hand-fan-20261001\effigy-prompts.json +原画版权归原权利方,网页文本CC BY-NC-SA 4.0。隔离测试不是实机验收。 diff --git a/mengsan/docs/byrdonis.md b/mengsan/docs/byrdonis.md new file mode 100644 index 0000000..036bbf2 --- /dev/null +++ b/mengsan/docs/byrdonis.md @@ -0,0 +1,16 @@ +# 多尼斯异鸟 BYRDONIS + +2026-10-03读取:https://sts2.huijiwiki.com/wiki/多尼斯异鸟 +密林精英,注册mengsan_byrdonis_shuying,加入act1Map.eliteEnemies,不加入普通池。现有精英保留。 +普通生命81~84,每次实际敌方入场使用征程随机源均匀抽取;资料默认82不消耗随机数,显式hp优先。 +初始领地意识1层;自身phaseAfter获得层数对应的力量,不是意图攻击后立刻获得。击晕或费用不足仍在活着的回合结束时触发。非自身回合、死亡或会话结束不结算。 +行动SVG:开始→俯冲→啄击→俯冲,严格交替,无随机决策。网页表格称俯冲为“飞扑(Swoop)”,为同一行动。 +俯冲17基础攻击伤害;啄击3基础攻击伤害×3,每段受力量/虚弱/易伤/缩小修正。无其他效果,正常前三回合17、4×3、19;力量在各回合结束后变为1、2、3。 +进阶只记录不混入当前普通数值:A8生命90,A9俯冲19、啄击4×3。 +使用梦三精英原生抽2、手牌上限5、费用3和精英牌堆。意图消耗1费用,原生出牌阶段保留;原生牌也获得既有力量修正,脚本意图不重复加力量。 +费用不足跳过并推进;击晕retry保留招式、advance推进;眩晕恢复不移除领地意识。多只实例独立,不因重复规划、席位或死亡恢复重新抽生命。 +循环、领地意识和力量随战斗会话清理;旧会话清理不能误删新会话的状态。 +网页原画来源:https://huiji-public.huijistatic.com/sts2/uploads/d/d5/Byrdonis.png +立绘参考原画及既有密林风格由内置image_gen重绘,单只异鸟独立3:4不透明背景,无UI文字。 +提示词:D:\noname\resources\app\.codex-artifacts\mengsan-hand-fan-20261001\byrdonis-prompts.json +原画版权归原权利方;网页文本CC BY-NC-SA 4.0。隔离检查不代表游戏实机验收。 diff --git a/mengsan/docs/cubex-construct.md b/mengsan/docs/cubex-construct.md new file mode 100644 index 0000000..7e68391 --- /dev/null +++ b/mengsan/docs/cubex-construct.md @@ -0,0 +1,18 @@ +# 立柱构造体 CUBEX_CONSTRUCT + +2026-10-03读取:https://sts2.huijiwiki.com/wiki/立柱构造体 +普通密林怪物;生命65,开场13格挡与1层人工制品。注册mengsan_cubex_construct_shuying,加入第一层普通怪物池。 +蓄能:2力量,仅开场。以后重复轰击→重复轰击→排出循环。重复轰击先7基础攻击伤害后2力量;排出5基础伤害×2。每段受力量/虚弱/易伤/缩小修正。 +SVG路径:A→蓄能→重复轰击→重复轰击2→排出→重复轰击,明确不回到蓄能。 +初次命中预期(无其他修正):9、11、11×2,之后13、15、15×2。 +进阶仅记录不混入正常难度:A8生命70;A9重复轰击8、排出6×2,蓄能仍2力量。 +人工制品:https://sts2.huijiwiki.com/wiki/人工制品,页面提示“免疫负面效果”,正面可叠加不允许负数。 +计次规则参考该页所链接前作人工制品:https://sts.huijiwiki.com/wiki/人工制品(免疫X次负面效果)。在梦三按每层抵消一次负面状态施加适配;一次施加多层也只消耗1层。 +接入易伤/虚弱/脆弱/缠结/紧缠/缩小,包括重杀、闪电霹雳、融融之拳及开场遗物。伤害先结算、正面力量不消耗人工制品;无效/零层申请、已有状态自然衰减不消耗。 +击晕是梦三既有意图控制而非叠层状态,维持既有retry/advance规则;不拦截原生三国杀乐不思蜀等原生技能。 +意图消耗1费用,随后可用原生牌。费用不足跳过并推进;眩晕retry保留当前招式、advance推进。死亡与战斗结束中止后续段及力量增益。 +人工制品及循环状态仅保留于本场战斗,使用会话资源清理与旧会话防误删保护,不写入永久牌堆。 +立绘:网页原画Cubex_construct.png实际WebP,来源https://huiji-public.huijistatic.com/sts2/uploads/3/3a/Cubex_construct.png;内置图像工具参考原画及密林风格重绘独立不透明3:4背景立绘。 +原画版权归原权利方;页面文本CC BY-NC-SA 4.0。 +提示词:D:\noname\resources\app\.codex-artifacts\mengsan-hand-fan-20261001\cubex-prompts.json +隔离测试不代表实机验收。 diff --git a/mengsan/docs/fogmog.md b/mengsan/docs/fogmog.md new file mode 100644 index 0000000..25beb47 --- /dev/null +++ b/mengsan/docs/fogmog.md @@ -0,0 +1,14 @@ +# 雾菇与利齿之眼 + +来源:[雾菇](https://sts2.huijiwiki.com/wiki/雾菇)、[晕眩](https://sts2.huijiwiki.com/wiki/晕眩),读取日期2026-10-02。 +网页立绘保留原始字节;Wiki标注CC BY-NC-SA 4.0。晕眩卡图复用项目现有STS2资源。 + +- 默认使用网页普通难度:雾菇74生命;重击8伤害并在结算后获得1力量;头槌14伤害。 +- 第一自身行动使用虚幻孢子,召唤一个6生命的利齿之眼。之后先重击;一次重击后40%再重击、60%头槌;两次连续重击后必定头槌;头槌后必定重击。 +- 利齿之眼只发动牵制:向敌对角色的个人弃牌堆加入3张晕眩。无个人牌堆的剧情援军不污染公共牌堆。 +- 晕眩仅存在于本场战斗,不能使用或打出,弃牌阶段开始按既有虚无机制移除;不进入永久牌组或奖励池,没有费用角标。 +- 利齿之眼死亡后,在下一次主角回合开始前满血复活,复用原席位;雾菇死亡后不再复活或行动,爪牙不阻止胜利。 +- 第一关普通遭遇池增加雾菇;利齿之眼不单独进入遭遇池。其他关卡和刘备专属开场不变。 +- 沿用梦三行动技能消耗1费用、雾菇随后照常出牌的既有规则;爪牙不额外抽牌或出牌。本次未新增STS2进阶难度开关。 + +检查属于隔离验证,不代表完整游戏或十周年UI实机验收。 diff --git a/mengsan/docs/fuzzy-wurm-crawler.md b/mengsan/docs/fuzzy-wurm-crawler.md new file mode 100644 index 0000000..e66467a --- /dev/null +++ b/mengsan/docs/fuzzy-wurm-crawler.md @@ -0,0 +1,27 @@ +# 毛绒伏地虫(Fuzzy Wurm Crawler) + +来源:https://sts2.huijiwiki.com/wiki/毛绒伏地虫 ,读取日期2026-10-02。 +原立绘:https://huiji-public.huijistatic.com/sts2/uploads/2/21/Fuzzy_wurm_crawler.png 。 +Wiki页面标示CC BY-NC-SA 4.0,原游戏美术权利归对应权利人;保留来源说明。 + +## 普通配置 + +- ID:`mengsan_fuzzy_wurm_crawler_shuying`;加入第一层普通战斗池,不影响角色专属开局。 +- 实际入场时使用征程随机源,从55、56、57等概率抽取生命;资料展示和未指定随机源的怪物定义为56。显式关卡生命配置优先。 +- 三步循环:酸液黏球 -> 吸入 -> 酸液黏球 -> 回到开头;周期相邻边界会出现两次酸液攻击。 +- 酸液黏球基础伤害4,受累计力量影响。 +- 吸入获得7力量;力量累积到战斗结束,不设置次数上限。 +- 未受到其他修正时,前几次酸液实际伤害为4、11、11、18、18;不是永久固定4。 +- 每招消耗1费用,敌方保留个人牌堆使用阶段,酸液作用于所有存活敌对阵营角色。 +- 无费用则跳过并推进三步循环,不获得力量。击晕advance推进;retry保持原循环位置,恢复原意图后再尝试,不重复授予力量。 +- 网页进阶8+生命58~59、9+酸液基础6,本次只记录,沿用现有普通数值,不混入普通关卡。 + +## 立绘 + +- `assets/portraits/fuzzy-wurm-crawler-original.webp`是网页透明图,按实际WebP格式保存。 +- `assets/portraits/fuzzy-wurm-crawler-forest-portrait.png`由内置图像工具基于原图、参照已有密林立绘独立绘制,紫色毛绒身体、圆形利齿口、浅色分节腹部和侧向触须保留,背景完全不透明。 +- 只包含本怪物,不与其他怪物共用立绘。 + +## 验证边界 + +隔离检查覆盖生命抽取和真实入场初始化函数、三步循环、累计力量与意图显示、费用、击晕、死亡/停止会话和旧怪物回归。不等同游戏实机运行或十周年UI视觉验收。 diff --git a/mengsan/docs/inklet.md b/mengsan/docs/inklet.md new file mode 100644 index 0000000..fb1f102 --- /dev/null +++ b/mengsan/docs/inklet.md @@ -0,0 +1,24 @@ +# 墨宝(Inklet) + +来源:https://sts2.huijiwiki.com/wiki/墨宝 、https://sts2.huijiwiki.com/wiki/滑溜 ,读取日期2026-10-02。 +网页原画:https://huiji-public.huijistatic.com/sts2/uploads/7/7b/Inklet.png 。 +Wiki标示CC BY-NC-SA 4.0;原游戏美术权利归对应权利人。保留原图及来源记录。 + +## 配置 + +- ID `mengsan_inklet_shuying`,普通怪物;加入第一层普通战斗池。角色专属开局不变。 +- 普通生命11~17;实际入场通过征程随机源等概率抽取,显式关卡生命优先,资料默认14。 +- 网页描述成群且前/中/后起手不同,未指定固定数量;梦三适配为三只群体关卡,不声称三只是网页固定数量。 +- 各只固定出生位置1/2/3,不因同伴死亡或支援插队重排。奇数位刺击3伤害起手;偶数位旋风2×3起手后刺击。 +- 起手后进入循环:等概率选择锐利凝视10或旋风2×3 -> 刺击3 -> 等概率选择 -> 刺击。随机招不是每回合任意三选一。 +- 各次意图仅生成一次;预览、重复计划、击晕retry不会重抽。每招1费用,无费用跳过并推进;击晕advance推进,retry保留。 +- 沿用梦三怪物适配:意图攻击所有存活敌对阵营角色;保留个人牌堆;死亡或会话关闭及时停止多段攻击。 +- 初始滑溜1:下次实际生命损失限制为1点;伤害完全被护甲抵挡不消耗;直接失去生命也适用。每段分别结算,第一段消耗后其他段正常。 +- 梦三模式局部changeHp包装保留原生事件、护甲抵挡及消耗,滑溜层数仅在实际changeHp负向通知时减少,不改全局引擎。正向恢复、零伤害、关闭会话和其他怪物不受影响。斩杀直接死亡不拦截。 +- 网页进阶8+生命12~18,9+刺击4、旋风3×3、凝视11,本次只记录,不混入普通配置。 + +## 立绘与检查 + +- 原图保存为 `assets/portraits/inklet-original.png`;独立立绘为 `assets/portraits/inklet-forest-portrait.png`。 +- 内置图像工具以原图为造型参考、飞蝇菌子立绘为画风参考,生成单只墨黑身体、发光眼缝、不透明密林背景竖图;不与其他怪物共用立绘。 +- 隔离验证覆盖实际入场初始化、群体计划、站位、随机循环、真实本地引擎changeHp内容的护甲/生命结算、滑溜消耗、费用与击晕、会话停止、旧怪物回归。不是实机或十周年UI验收。 diff --git a/mengsan/docs/leaf-slime-m.md b/mengsan/docs/leaf-slime-m.md new file mode 100644 index 0000000..8127d39 --- /dev/null +++ b/mengsan/docs/leaf-slime-m.md @@ -0,0 +1,13 @@ +# 树叶史莱姆(中) + +来源:https://sts2.huijiwiki.com/wiki/树叶史莱姆(中) + +普通配置:生命32~35(展示默认34);无初始能力。第1回合黏糊射击,向敌对角色个人弃牌堆加入2张黏液;随后团块射击(8点攻击伤害)与黏糊射击严格交替。 +进阶参考:8+生命33~36,9+攻击9;当前未混入普通配置。 + +怪物ID:`mengsan_leaf_slime_m_shuying`,加入第一幕普通怪物池;首层专属开局及后续幕不变。每次意图耗1费用,保留原生用牌阶段;眩晕retry保留意图、advance进入下一行动。复用已有黏液卡(耗1费用,抽1张,消耗),不进入永久卡组和奖励注册表。中型状态与小型独立,后者仍等权随机冲撞/黏液。 + +原立绘:https://huiji-public.huijistatic.com/sts2/uploads/9/96/Leaf_slime_m.png +使用内置image_gen参照原立绘、密林风格二绘,单独不透明3:4立绘。网页与资源依据灰机Wiki所示许可,保留来源。 + +验证为隔离执行真实项目模块及引擎边界夹具,不是实机验收。 diff --git a/mengsan/docs/leaf-slime-s.md b/mengsan/docs/leaf-slime-s.md new file mode 100644 index 0000000..d6ccde4 --- /dev/null +++ b/mengsan/docs/leaf-slime-s.md @@ -0,0 +1,17 @@ +# 树叶史莱姆(小) + +资料:https://sts2.huijiwiki.com/wiki/树叶史莱姆(小) ,状态卡:https://sts2.huijiwiki.com/wiki/黏液 。读取日期2026-10-02。 + +普通数值:生命11~15,资料默认13,无初始能力。仅实际入场从征程随机源抽取生命;进阶8+生命12~16、9+冲撞4伤害不混入普通配置。 + +每次预测行动在冲撞(3伤害)和黏液(给各存活敌对角色的个人弃牌堆加入1张黏液)之间等权重随机。网页未标注冷却/连续次数限制,不额外添加。无个人牌堆的剧情援军不污染公共牌堆。每个预测行动耗1费用,保留原生出牌阶段。 + +黏液是可使用的状态牌:耗1费用、抽1张牌、消耗。不同于晕眩,不具不能打出或虚无。未使用时可正常弃置并重新抽到;使用后通过既有个人牌堆消耗机制退出本场战斗。状态牌不进入永久可获得牌表、奖励、强化表或存档牌组。 + +击晕advance推进一次、retry保留原预测行动;费用不足跳过效果并推进。重复意图刷新不重复抽随机数,死亡或战斗结束不再产生效果。 + +已接入怪物类、角色注册、第一层普通关卡池、行动/意图UI、击晕和力量修正;不改专属首关、第二/第三层、全局十周年UI。 + +网页透明立绘作为身份参考、飞蝇菌子密林立绘作为画风参考,由内置图像工具生成单独3:4不透明密林背景立绘。黏液卡图使用网页原始PNG,按现有全幅卡面呈现,不改写图片。完整提示词和来源位于验证目录leafslime-prompts.json。 + +验证为隔离状态机及实际源码执行,不代表实机/十周年UI验收。 diff --git a/mengsan/docs/mawler.md b/mengsan/docs/mawler.md new file mode 100644 index 0000000..500735e --- /dev/null +++ b/mengsan/docs/mawler.md @@ -0,0 +1,26 @@ +# 蛮兽(Mawler) + +资料来源:https://sts2.huijiwiki.com/wiki/蛮兽 (2026-10-02读取)。 +网页原图:https://huiji-thumb.huijistatic.com/sts2/uploads/thumb/a/a6/Mawler.png/360px-Mawler.png 。 +Wiki 页面标明 CC BY-NC-SA 4.0;角色及美术的原始权利归对应权利人,本项目保留来源说明。 + +## 配置与行动 + +- 角色ID:`mengsan_mawler_shuying`;第一层普通关卡随机池,不替换角色专属开局。 +- 普通数值:72生命;网页进阶8+为76生命,进阶9+爪击5×2、狂乱撕扯16,仅记录,不映射到现有普通难度。 +- 爪击:4点伤害,独立结算2次。首个回合必定选择;后续不能连续发动。 +- 狂乱撕扯:14点伤害,不能连续发动。 +- 怒吼:给予3层易伤,整场战斗最多成功发动一次。 +- 首回合后从符合条件的招式中等权重选择;怒吼成功发动后永久移出该战斗的池。 +- 规则沿用梦三:每招消耗1费用,作用于所有存活敌对阵营角色;仍可继续使用个人牌堆中的牌。 +- 无费用和击晕不消耗怒吼次数;击晕支持推进或重试,意图不重复抽取随机数,不跨战斗共享状态。 + +## 立绘 + +- `assets/portraits/mawler-original.webp`:网页透明原图,按实际WebP格式保存。 +- `assets/portraits/mawler-forest-portrait.png`:基于网页原形象,参照已有飞蝇菌子密林画风,经内置图像工具独立重绘;完整不透明背景、竖版3:4,头部置中供头像裁切。 +- 本图只包含蛮兽,不与其他怪物共用。 + +## 验证范围 + +隔离验证覆盖注册、普通关卡池、冷却/等权重/单次怒吼、双段伤害/易伤、费用、击晕、死亡和会话停止、意图显示与旧怪物回归;不等同真实游戏运行或十周年UI视觉验收。 diff --git a/mengsan/docs/nibbit.md b/mengsan/docs/nibbit.md new file mode 100644 index 0000000..31eedcf --- /dev/null +++ b/mengsan/docs/nibbit.md @@ -0,0 +1,14 @@ +# 小啃兽 NIBBIT + +来源:https://sts2.huijiwiki.com/wiki/小啃兽 (2026-10-02读取网页与SVG箭头) +普通怪物,密林;生命42~46,实际入场从征程随机源均匀抽取;资料默认44,不消耗随机数。无初始能力。 +正常行动:顶撞12伤害 → 犹豫斩击6伤害并获得5格挡 → 哈气获得2力量 → 顶撞。 +单只顶撞起手;双只按关卡units前后顺序固定为斩击/哈气起手,各自进入同一循环。开战时固定状态,不因同伴死亡或席位变化重置。 +第一层普通池包括单只与双只遭遇;双只不占用永久牌堆/支援数据。超过两只的非标准自定义配置采用单只起手作为兼容回退,非Wiki定义。 +意图消耗1费用、随后仍可使用原生卡牌;费用不足跳过本次动作并推进,眩晕支持retry/advance,遵循既有梦三适配。 +力量影响脚本攻击与原生牌伤害;斩击先攻击后获格挡。既有伤害执行器继续处理虚弱、易伤及死亡/战斗结束。 +进阶数据仅记录不启用:A8生命44~48、斩击格挡6;A9顶撞13、斩击7、哈气力量3。 +立绘:网页原画Nibbit.png(实际WebP)仅作参考;内置图像工具参考原画与密林立绘重绘独立不透明3:4背景图。 +原画署名/来源:https://huiji-public.huijistatic.com/sts2/uploads/f/f6/Nibbit.png;页面文本CC BY-NC-SA 4.0,原画版权归原权利方。 +完整提示词:D:\noname\resources\app\.codex-artifacts\mengsan-hand-fan-20261001\nibbit-prompts.json +验证属于隔离运行与实际文件检查,不代表实机验收。 diff --git a/mengsan/docs/phrog-parasite.md b/mengsan/docs/phrog-parasite.md new file mode 100644 index 0000000..9b5b3a5 --- /dev/null +++ b/mengsan/docs/phrog-parasite.md @@ -0,0 +1,15 @@ +# 异蛙寄生虫 / 扭动虫 / 感染 + +2026-10-03依据 https://sts2.huijiwiki.com/wiki/异蛙寄生虫 与 https://sts2.huijiwiki.com/wiki/感染。 +普通数值:异蛙61~64生命,第一层精英池;固定感染(3张感染进个人弃牌)→甩动(4×4)循环。 +寄生物4,死亡即召唤4只扭动虫。主网页正文以四只描述衍生怪,X未显式显示数值,交叉核对 https://slaythespire.wiki.gg/wiki/Slay_the_Spire_2:Phrog_Parasite 明确死亡生成4只。 +扭动虫17~21生命,不单独进入精英关卡随机池;不是雾菇幻象,必须全部消灭才算胜利。 +各组站位固定1~4,奇数位污秽啃咬6→扭动(1感染、2力量)循环,偶数位反向开局。 +亡语生成的扭动虫完整首回合取消,不抽牌、不发动意图、不出原生牌、不耗费用,恢复后按原始奇偶站位起手。直接配置的扭动虫不强制跳过。 +其余意图消耗1费用,保留梦三精英原生出牌;抽2,上限5,费用3。力量也作用于原生攻击。 +死亡钩子原生引擎会await;等待4只入场后再继续伤害/卡牌,召唤期间阻止胜利结算。开战预留4个额外席位,计入死亡保留席位和所有后续剧情召唤,超过8总席位提前报错,不静默减少数量。 +飞剑回旋镖原来逐击重新取存活敌人,后续击可以命中新生扭动虫。闪电霹雳改为先完整伤害再向当前存活敌人施加易伤,新生扭动虫也获得易伤,不重复吃刚结束的范围伤害。 +感染不能被打出,无默认虚无;只生成到战斗个人弃牌,不进入永久奖励、商店或征程牌组。持有者结束出牌后的弃牌阶段开始时,每张仍在手牌的感染造成3点非攻击无来源伤害,走正常护甲及濒死救援;优先于虚无消耗,事件去重,死亡/换场中止后续结算。此时机沿用梦三现有虚无/弃牌实现,不是结束阶段之后重新读取空手牌。 +A8异蛙66~68、虫18~22;A9甩动5×4、啃咬7只记录,不混入普通数值。按用户给定网页主表感染3,不混入其他网站较新测试补丁的4。 +两种怪物分别采用基于网页原画的3:4不透明密林重绘,感染为5:7宣纸黑褐水墨完整卡面。无UI文字或角标。内置image_gen提示词保存于 D:\noname\resources\app\.codex-artifacts\mengsan-hand-fan-20261001\phrog-prompts.json。 +原画版权归原权利方,网页文本CC BY-NC-SA 4.0;隔离验证不等于实机验收。 diff --git a/mengsan/docs/shrinker-beetle.md b/mengsan/docs/shrinker-beetle.md new file mode 100644 index 0000000..ac85203 --- /dev/null +++ b/mengsan/docs/shrinker-beetle.md @@ -0,0 +1,14 @@ +# 缩小甲虫 + +来源:https://sts2.huijiwiki.com/wiki/缩小甲虫 +普通生命38~40(展示默认39),无初始能力。首回合缩小:给予存活敌对角色缩小状态;随后大啃7点、践踏13点攻击伤害严格交替。页面SVG行为箭头已逐条核对。 +进阶8+生命40~42、9+攻击8/14,仅参考,不混入普通数值。 + +ID:`mengsan_shrinker_beetle_shuying`,第一幕普通怪物池,专属开局和后续幕不变。意图耗1费用、保留原生用牌阶段;眩晕retry保留动作、advance推进。HP沿用种子随机,行动不使用随机数。 + +缩小令攻击伤害减少30%,非攻击伤害不变。灰机状态页未列出持续与叠加规则,交叉核对 https://slaythespire.wiki.gg/wiki/Slay_the_Spire_2:Shrinker_Beetle 的怪物描述:不叠加、来源死亡移除;甲虫汁的限时效果不是本怪物技能。本地采用既有伤害管线:力量→虚弱→缩小向下取整→易伤向上取整→原生护甲。来源引用保存在本场战斗,死亡/战斗清理移除,复活不恢复旧缩小;多来源减伤仍只30%。不改角色整体尺寸或十周年UI,状态以“缩”标记显示。 + +原画:https://huiji-public.huijistatic.com/sts2/uploads/a/ab/Shrinker_beetle.png +网页原画为透明立绘,内置image_gen按原画身份与已有密林风格二绘独立不透明3:4立绘;保留来源及网页所示CC BY-NC-SA 4.0许可提示。生成提示保存于验证记录。 + +模块隔离与边界夹具检查不是实机验收。 diff --git a/mengsan/docs/slithering-strangler.md b/mengsan/docs/slithering-strangler.md new file mode 100644 index 0000000..169fd06 --- /dev/null +++ b/mengsan/docs/slithering-strangler.md @@ -0,0 +1,17 @@ +# 蛇行扼杀者 + +资料:https://sts2.huijiwiki.com/wiki/蛇行扼杀者 ,紧缠:https://sts2.huijiwiki.com/wiki/紧缠 + +实现普通数值,不将进阶8+/9+数值混入普通关卡。生命53~55,资料默认54;仅实际入场从征程随机源抽取生命。无初始能力。 + +- 缠身:给予所有存活敌对角色3层紧缠。 +- 缠身后等权重随机重击(7伤害、结算攻击后获得5护甲)或甩动(12伤害);攻击后必定回到缠身。 +- 紧缠可叠加,每个受缠角色自己的结束阶段开始时受到层数对应的非攻击伤害,原生护甲正常抵扣;不自动衰减,不受力量、虚弱和易伤的攻击修正。 +- 按施加来源独立记录。同名多怪不会互相覆盖;来源死亡立即移除其贡献,复活不会恢复旧紧缠;受缠角色死亡及战斗资源释放时清除。本状态只保存在本场战斗,不写入征程存档。 +- 预测行动每次耗1费用;费用不足则跳过效果并推进一次。击晕advance推进一次、retry保留原意图,随机攻击不会因重复刷新或retry重新抽取。保留原生出牌阶段。 + +已注册角色、怪物类型、普通关卡随机列表、行动执行器、意图UI、击晕与力量适配。角色专属首关及后续章节不改。 + +立绘:网页透明原图作为身份参考,飞蝇菌子密林立绘作为画风参考,经内置图像生成工具二次绘制为独立3:4不透明背景图片。完整提示词位于同一验证目录的strangler-prompts.json。 + +验证仅为隔离状态机/实际源码执行检查,不代表用户实机或十周年UI验收。 diff --git a/mengsan/docs/snapping-jaxfruit.md b/mengsan/docs/snapping-jaxfruit.md new file mode 100644 index 0000000..6ee1c99 --- /dev/null +++ b/mengsan/docs/snapping-jaxfruit.md @@ -0,0 +1,28 @@ +# 闪光贾克斯果(Snapping Jaxfruit) + +来源:https://sts2.huijiwiki.com/wiki/闪光贾克斯果 ,读取日期2026-10-02。 +网页原画:https://huiji-public.huijistatic.com/sts2/uploads/5/59/Snapping_jaxfruit.png 。 +Wiki标示CC BY-NC-SA 4.0;原游戏美术权利归对应权利人,保留原图和来源。 + +## 普通配置 + +- ID `mengsan_snapping_jaxfruit_shuying`;第一层普通关卡池,不修改角色专属开局及后续层。 +- 普通生命31~33,实际入场使用征程随机源等概率抽取。显式关卡生命优先;资料默认32且不消耗随机数。 +- 没有初始能力。每次使用能量球:以基础伤害3加当前力量攻击,之后自身获得2力量,持续叠加至战斗结束,不设成长上限。 +- 无其他修正时能量球伤害序列为3、5、7、9、11……;本次获得的力量不提前加到本次攻击。 +- 沿用梦三敌方适配:意图攻击所有存活敌对角色;对多个目标攻击完成后只获得一次2力量,不按目标数叠加;保留个人牌堆使用阶段。 +- 每招消耗1费用,无费用跳过且不攻击、不获得力量。击晕advance推进记录,retry保留原意图,不重复发动或增益;重复计划不消耗行动随机数。 +- 力量也作用于原生卡牌攻击;已应用力量的脚本攻击不重复加成。虚弱、易伤与预览一致。 +- 攻击中若自身因反伤死亡或会话停止,则停止后续目标且不再获得力量。全部目标死亡后,如果自身仍存活且会话仍活跃,能力仍可正常增加力量。 +- 新战斗不继承上场力量或行动缓存,持久玩家牌堆不变。 +- 网页进阶8+生命34~36,9+能量球基础伤害4:仅记录,不混入普通配置。 +- 网页0.108.0“不再连续遇到两场都包含该怪物”标为未进入正式版,本次不实现该待发布规则。 + +## 立绘 + +- `assets/portraits/snapping-jaxfruit-original.png`保留网页原图。 +- `assets/portraits/snapping-jaxfruit-forest-portrait.png`为内置图像工具独立绘制,保留红紫色发光果实、绿色叶片、暗红尖萼和三根分叉芽触须,添加不透明手绘密林背景。仅包含本怪物。 + +## 验证边界 + +隔离验证覆盖真实入场函数、31~33生命、固定单招、攻击先于力量增加、多目标只增益一次、费用、击晕、虚弱/易伤、死亡及会话停止、原生卡牌力量及旧怪物回归。不是完整游戏或十周年UI实机验收。 diff --git a/mengsan/docs/twig-slime-m.md b/mengsan/docs/twig-slime-m.md new file mode 100644 index 0000000..e6576ea --- /dev/null +++ b/mengsan/docs/twig-slime-m.md @@ -0,0 +1,11 @@ +# 树枝史莱姆(中) + +来源:https://sts2.huijiwiki.com/wiki/树枝史莱姆(中) +普通生命26~28(注册展示默认27),无初始能力。首回合黏糊射击,向敌对角色个人弃牌堆加入1张黏液;黏糊射击不可连续使用,必接戳刺扑击(11点攻击伤害)。允许的随机分支等权;戳刺扑击最多连用两次,达到上限后必接黏糊射击。 +进阶8+生命27~29、9+攻击12,仅参考,未混入普通配置。 + +ID:`mengsan_twig_slime_m_shuying`,第一幕普通怪物池;专属开局及后续幕不变。独立lastMove/consecutive状态,计划意图不推进,实际行动或advance眩晕推进,retry保持动作与连续计数。每次意图消耗1费用,保留原生用牌阶段。复用已有黏液卡:耗1费用、抽1张牌、消耗,不进入永久卡组或奖励。 + +原立绘:https://huiji-public.huijistatic.com/sts2/uploads/c/c5/Twig_slime_m.png +内置image_gen按网页原图、密林画风二绘独立不透明3:4立绘,不替换小型图。保留灰机Wiki来源及其所示许可信息。 +隔离模块和边界夹具验证不是实机验收。 diff --git a/mengsan/docs/twig-slime-s.md b/mengsan/docs/twig-slime-s.md new file mode 100644 index 0000000..e3f6b1f --- /dev/null +++ b/mengsan/docs/twig-slime-s.md @@ -0,0 +1,11 @@ +# 树枝史莱姆(小) + +来源:https://sts2.huijiwiki.com/wiki/树枝史莱姆(小) +普通怪物,密林;无初始能力。普通生命7~11(注册默认9),每回合冲撞造成4点攻击伤害。进阶8+生命8~12、9+攻击5,仅记录参考,未混入普通配置。 + +ID:`mengsan_twig_slime_s_shuying`。第一幕普通怪物池包含此怪物,专属开局及其他幕不变。行动耗1费用,保留原生卡牌阶段;retry眩晕保留意图,advance推进记录。行动不消耗随机数,初始生命沿用种子随机。没有黏液、分裂或其他网页未列出的能力。 + +原图:https://huiji-public.huijistatic.com/sts2/uploads/8/8e/Twig_slime_s.png +内置image_gen参考网页原图与密林风格二绘,不透明3:4独立立绘,不替换树叶史莱姆。保留灰机Wiki来源及所示许可信息。 + +隔离验证真实项目模块,不是实机验收。 diff --git a/mengsan/docs/ui-hand-drag-fix.md b/mengsan/docs/ui-hand-drag-fix.md new file mode 100644 index 0000000..82ceecd --- /dev/null +++ b/mengsan/docs/ui-hand-drag-fix.md @@ -0,0 +1,11 @@ +# 梦三手牌:41、42 图交互修正 + +本次基线是用户已应用的扇形手牌版本,修改限于 `ui/hand/`,不改十周年源码或设置。 + +- `showAim()` 将鼠标及卡牌的视口坐标,经 SVG `getScreenCTM().inverse()` 换算到 SVG 局部坐标;兼容 body zoom、父层缩放与平移,不重复套用 documentZoom。 +- 自动单目标/无目标拖动期间,临时隐藏 `ui.control`、`ui.confirm` 和当前事件的 `endButton`。取消、松手、换事件及退出恢复;多目标维持原确认按钮。 +- 合法自动提交时,沿原生鼠标提交流程先关闭旧确认及结束控件,再调用 `ui.click.ok()`。关闭动画中的旧控件不再残留显示;不关闭下一次新创建的控件。 +- 新挂载标记将阶段图片隐藏范围限制在梦三手牌生命周期。仅匹配十周年 `lbtn/tips/` 的阶段图片,不隐藏响应提示图片。 +- 短点击、复杂/技能/响应选择仍走原生路径;自定义确认替换的事件不接管。 + +验证是隔离浏览器测试,不等同于所有 UI 扩展组合及实际游戏验收。应用脚本直接写入对应文件,不生成安装目录备份;交付修正副本与安装状态须区分。 diff --git a/mengsan/docs/ui-hand-fan.md b/mengsan/docs/ui-hand-fan.md new file mode 100644 index 0000000..523c48a --- /dev/null +++ b/mengsan/docs/ui-hand-fan.md @@ -0,0 +1,38 @@ +# 梦三扇形手牌:本次候选变更 + +## 参考与范围 + +依据用户本地 36.png(扇形)、37.png(悬停)、38.png(目标牌拖动)与39.png(无目标牌拖动)。 +仅扩展现有梦三 `ui/hand/` 子模块;不改十周年UI源码,不复制真实卡面,不搬动卡牌父容器,不自行调用 `useCard()`。 +卡面仍沿用当前引擎/主题;不是尖塔卡面的重绘。 + +## 排列与悬停 + +- `fanSlots()` 将全部可见真实手牌以中心对称展开;中心最高,两端降低并旋转,最多左右13度。 +- 桌面卡牌112×156,小屏82×114;张数增加时收窄间距,不依赖横向滚动。 +- 悬停时归正、上抬62像素并放大1.35倍,置于手牌最上层;保持原生选中/可用状态。 +- 原生布局写入的 transform 不被删除;梦三CSS仅在启用时覆盖,退出恢复。 + +## 输入 + +- 主动出牌阶段、真实单张手牌且费用足够:位移9像素进入拖动。 +- 单目标牌:卡牌自动置于手牌横向中心,指针拖动一条金色细线;指向合法目标后松手,经 `game.check()` 再调用 `ui.click.ok()`。 +- 金线参照本地十周年 `effects/config.js` 的 rgb(250,220,140) 与2.6线宽;独立SVG,不加载其私有动画接口,不用尖塔箭头/骨节。 +- 无需手动指定目标(包括引擎自动分配全部目标)的牌:卡牌随指针,上移至少60像素且越过手牌区上边缘后松手确认。没有在移动途中扣费或使用。 +- 手动多目标:用户确认保留原来的选目标与确认;本模块只保留选中卡牌,不自动替用户选齐目标或提交。 +- 技能虚拟牌、响应、复杂选择、自定义替换处理、已存在选择等不接管,继续走原引擎交互。 +- 短点击保留原生行为;无合法目标、费用不足、Escape、右键、失焦、指针取消回收拖动,不能误出牌。 + +## 生命周期与安全 + +新 `fan.js` 负责命名空间变量、悬停和拖动位置;`input.js` 负责局部输入转接。 +`mode.js` 向挂载函数传入现有引擎的 get/_status/canPayCard/isActiveCardUse,未改变出牌经济。 +选牌和试选目标调用期间同步暂设 touchnocheck,避免移动途中自动确认;finally恢复原值及属性是否存在。 +只在当前事件身份未改变时取消自身选择;换事件、换容器、会话结束后不提交旧事件。 +卸载移除监听、SVG、观察器、标记与变量,保留真实卡牌和其他扩展的属性;重复卸载安全。 + +## 证据边界与交付 + +已运行独立浏览器场景,使用本次真实子模块、原生 `ui.click.card` 函数体;目标选择、确认及 game.check 服务仍为测试替身。 +这不等于完整游戏、十周年布局、技能、真实触屏或AI实测通过。需用户进入梦三核对真实卡面尺寸与目标点击。 +交付为候选源码包和可应用补丁;已安装源码保持原字节未改动。回滚脚本只接受候选副本ZIP,恢复本次基线覆盖包,不会触碰游戏安装目录。 diff --git a/mengsan/docs/vine-shambler.md b/mengsan/docs/vine-shambler.md new file mode 100644 index 0000000..7b2abfb --- /dev/null +++ b/mengsan/docs/vine-shambler.md @@ -0,0 +1,13 @@ +# 藤蔓蹒跚者 + +来源:https://sts2.huijiwiki.com/wiki/藤蔓蹒跚者 与 https://sts2.huijiwiki.com/wiki/缠结 +普通生命固定61,无初始能力;挥击(6点攻击×2)→紧绕藤蔓(8点攻击,之后施加1回合缠结)→大啃(16点攻击)循环。已核对网页SVG箭头。进阶8+生命64,9+伤害7×2/9/18,仅参考,不混入普通配置。 + +ID `mengsan_vine_shambler_shuying`;第一幕普通怪物池,角色专属开局和后续幕不变。每次意图耗1费用、保留原生用牌阶段;retry不推进、advance推进;固定生命和行动均不额外消耗种子随机数。 + +缠结持续回合可叠加,但攻击牌始终只加1费用,自身回合结束减1;状态不与来源死亡绑定。梦三攻击牌按现有damage类别识别,另外涵盖南蛮、万箭、火攻;防御、回复、状态牌不加费。加费按实际使用的最终牌名和当前强化费用计算(0费攻击变1),响应和濒死救援沿用不付费规则。费用校验、支付、玩家手牌角标、实体手牌提示一致,永久牌库和奖励展示保持基础费用。状态引用仅存在本场战斗,结束清理,不写入永久卡组或存档。 + +原尺寸图片下载失败,使用页面已加载的360px原画缩略图作为身份参考(实际格式WebP):https://huiji-thumb.huijistatic.com/sts2/uploads/thumb/1/1c/Vine_shambler.png/360px-Vine_shambler.png +内置image_gen按原画身份和已有密林风格生成不透明3:4独立立绘。保留灰机Wiki来源和所示CC BY-NC-SA 4.0许可提示。提示词在本次验证记录中。 + +隔离真实模块及边界夹具验证不是游戏实机验收。 diff --git a/mengsan/mode.js b/mengsan/mode.js index d623ddc..9126dbb 100644 --- a/mengsan/mode.js +++ b/mengsan/mode.js @@ -1,5 +1,14 @@ import { createScenarioCharacters, scenarioTranslations } from "./content/scenario-characters.js"; import { createMengsanCards } from "./cards/mode-cards.js"; +import { createStrikeCounter } from "./cards/strike-counter.js"; +import { createBlockDrawer } from "./cards/block-draw.js"; +import { createHavocPlayer } from "./cards/havoc-play.js"; +import { createHandExhauster } from "./cards/hand-exhaust.js"; +import { createBattleEnergy } from "./cards/battle-energy.js"; +import { createRandomHandExhauster } from "./cards/random-hand-exhaust.js"; +import { createBattleCardCopier } from "./cards/battle-card-copy.js"; +import { createBattleCardReclaimer } from "./cards/battle-card-reclaim.js"; +import { createTemporaryStrength, temporaryStrengthAmount, temporaryStrengthExpires, clearTemporaryStrength, forgetTemporaryStrength } from "./cards/temporary-strength.js"; import { createRelicBattle, createRelicSkills } from "./relics/battle.js"; import { grantRelic, getRelic, initializeRelicMaxHp } from "./relics/definitions.js"; @@ -20,7 +29,32 @@ import { isFlyconid, isRaider, } from "./monsters/actions.js"; +import { constrictTotal, resolveConstrict, clearConstrictSource } from "./monsters/strangler-constrict.js"; +import { isShrunk, clearShrinkSource, shrinkAttackDamage } from "./monsters/shrinker-status.js"; +import { advanceTangled } from "./monsters/vine-tangled.js"; +import { isMawler, recordMawlerAction } from "./monsters/mawler-intent.js"; +import { isVine, recordVineAction } from "./monsters/vine-intent.js"; +import { isCubex, recordCubexAction, initializeCubex } from "./monsters/cubex-intent.js"; +import { isEffigy, recordEffigyAction, initializeEffigy } from "./monsters/effigy-intent.js"; +import { slowPercent, canCountSlowCard, recordSlowCard, resetSlow, slowApplies, slowAttackDamage } from "./monsters/effigy-slow.js"; +import { BYRDONIS_CHARACTER, isByrdonis, rollByrdonisHp, recordByrdonisAction, initializeByrdonis, resolveTerritorial } from "./monsters/byrdonis-intent.js"; +import { NIBBIT_CHARACTER, isNibbit, rollNibbitHp, recordNibbitAction, bindNibbitOpenings } from "./monsters/nibbit-intent.js"; +import { SHRINKER_CHARACTER, isShrinker, rollShrinkerHp, recordShrinkerAction } from "./monsters/shrinker-intent.js"; +import { TWIGMEDIUM_CHARACTER, isTwigmedium, rollTwigmediumHp, recordTwigmediumAction } from "./monsters/twigmedium-intent.js"; +import { TWIGSLIME_CHARACTER, isTwigslime, rollTwigslimeHp, recordTwigslimeAction } from "./monsters/twigslime-intent.js"; +import { LEAFMEDIUM_CHARACTER, isLeafslimeMedium, rollLeafslimeMediumHp, recordLeafslimeMediumAction } from "./monsters/leafmedium-intent.js"; +import { LEAFSLIME_CHARACTER, isLeafslime, rollLeafslimeHp, recordLeafslimeAction } from "./monsters/leafslime-intent.js"; +import { STRANGLER_CHARACTER, isStrangler, rollStranglerHp, recordStranglerAction } from "./monsters/strangler-intent.js"; +import { JAXFRUIT_CHARACTER, isJaxfruit, rollJaxfruitHp, recordJaxfruitAction } from "./monsters/jaxfruit-intent.js"; +import { CRAWLER_CHARACTER, isCrawler, rollCrawlerHp, recordCrawlerAction } from "./monsters/crawler-intent.js"; +import { INKLET_CHARACTER, isInklet, rollInkletHp, recordInkletAction, capInkletHpLoss } from "./monsters/inklet-intent.js"; import { mountEnemyIntent } from "./ui/intent-display.js"; +import { createFogmogBattle } from "./monsters/fogmog-battle.js"; +import { createPhrogBattle } from "./monsters/phrog-battle.js"; +import { PHROG_CHARACTER, WRIGGLER_CHARACTER, INFESTED_COUNT, isPhrogActor, isWriggler, rollPhrogHp, rollWrigglerHp, recordPhrogAction, initializePhrog, skipSpawnedWriggler } from "./monsters/phrog-intent.js"; +import { infectionHand, resolveInfection } from "./cards/infection-card.js"; +import { isFogmog, isToothedEye, isFogmogActor, isEncounterEnemy, + recordFogmogAction } from "./monsters/fogmog-intent.js"; import { mountEnergy } from "./ui/energy-display.js"; import { mountHandUI } from "./ui/hand/index.js"; import { @@ -105,13 +139,26 @@ const chooseRun = async savedRun => { ], "单一自动存档 · 完成节点后记录进度", { eyebrow: "水墨行军 · 存档选择" }); }; -const chooseCharacter = async () => { +const chooseCharacter = async (backLabel = "返回存档选择") => { const choices = config.characters.filter(name => lib.character[name]).map(name => ({ id: name, name: get.translation(name), description: lib.translate[`${name}_title`] || "作为本次征程的角色", })); - return chooseButtons("选择出征武将", [...(choices.length ? choices : [{ id: "mengsan_liubei_shuying", name: "界刘备" }]), { id: "back", name: "返回存档选择", description: "不会修改现有征程" }], "此武将将陪伴你完成本次征程。", { back: "back", eyebrow: "出征准备" }); + return chooseButtons("选择出征武将", [...(choices.length ? choices : [{ id: "mengsan_liubei_shuying", name: "界刘备" }]), { id: "back", name: backLabel, description: "不会修改现有征程" }], "此武将将陪伴你完成本次征程。", { back: "back", eyebrow: "出征准备" }); +}; + +const chooseDefeatAction = async () => { + while (true) { + const action = await chooseButtons("战斗失败", [ + {id:"retry", name:"重新尝试", description:"恢复战前生命、牌组和征程状态,重新挑战当前战斗。"}, + {id:"new", name:"新开征程", danger:true, description:"选择出征武将,确认选择后替换本次征程。"}, + ], "本次失败不会发放战斗奖励。请选择接下来要做什么。", {eyebrow:"败而不馁"}); + if (action === "retry") return "retry"; + if (action !== "new") throw new Error("未知的失败流程选择"); + const character = await chooseCharacter("返回失败选择"); + if (character !== "back") return {action:"new", character}; + } }; const createCardInstance = (run, name = "sha", upgrade = 0, affixes = []) => @@ -288,7 +335,35 @@ const setBattleCamp = (player, camp) => { player.classList.add("mengsan-hide-identity-shuying"); }; const initBattleUnit = (player, spec) => { - const monster = spec.camp === "enemy" ? createMonster(spec) : null; + // 只在实际入场且未指定生命时,用征程随机源抽取各怪物的普通生命范围;资料列表不消耗随机数。 + const monsterSpec = spec.camp === "enemy" && spec.hp == null ? + (spec.character === CRAWLER_CHARACTER ? + { ...spec, hp: rollCrawlerHp(() => nextRandom(_status.mengsanRun_shuying)) } : + spec.character === JAXFRUIT_CHARACTER ? + { ...spec, hp: rollJaxfruitHp(() => nextRandom(_status.mengsanRun_shuying)) } : + spec.character === STRANGLER_CHARACTER ? + { ...spec, hp: rollStranglerHp(() => nextRandom(_status.mengsanRun_shuying)) } : + spec.character === LEAFSLIME_CHARACTER ? + { ...spec, hp: rollLeafslimeHp(() => nextRandom(_status.mengsanRun_shuying)) } : + spec.character === LEAFMEDIUM_CHARACTER ? + { ...spec, hp: rollLeafslimeMediumHp(() => nextRandom(_status.mengsanRun_shuying)) } : + spec.character === TWIGSLIME_CHARACTER ? + { ...spec, hp: rollTwigslimeHp(() => nextRandom(_status.mengsanRun_shuying)) } : + spec.character === TWIGMEDIUM_CHARACTER ? + { ...spec, hp: rollTwigmediumHp(() => nextRandom(_status.mengsanRun_shuying)) } : + spec.character === SHRINKER_CHARACTER ? + { ...spec, hp: rollShrinkerHp(() => nextRandom(_status.mengsanRun_shuying)) } : + spec.character === PHROG_CHARACTER ? + { ...spec, hp: rollPhrogHp(() => nextRandom(_status.mengsanRun_shuying)) } : + spec.character === WRIGGLER_CHARACTER ? + { ...spec, hp: rollWrigglerHp(() => nextRandom(_status.mengsanRun_shuying)) } : + spec.character === BYRDONIS_CHARACTER ? + { ...spec, hp: rollByrdonisHp(() => nextRandom(_status.mengsanRun_shuying)) } : + spec.character === NIBBIT_CHARACTER ? + { ...spec, hp: rollNibbitHp(() => nextRandom(_status.mengsanRun_shuying)) } : + spec.character === INKLET_CHARACTER ? + { ...spec, hp: rollInkletHp(() => nextRandom(_status.mengsanRun_shuying)) } : spec) : spec; + const monster = spec.camp === "enemy" ? createMonster(monsterSpec) : null; player.storage.mengsanUnitId_shuying = spec.id; setBattleCamp(player, spec.camp); player.init(spec.character, null, spec.inheritSkills !== false); @@ -301,6 +376,19 @@ const initBattleUnit = (player, spec) => { } if (spec.maxHp != null || monster) player.maxHp = spec.maxHp ?? monster.hp; player.hp = Math.min(spec.hp ?? monster?.hp ?? player.hp, player.maxHp); + initializeCubex(player, activeBattle); + initializeByrdonis(player, activeBattle); + initializeEffigy(player, activeBattle); + initializePhrog(player, spec, activeBattle); + if (isInklet(player)) { + const position = spec.inkletPosition ?? 1; + if (!Number.isInteger(position) || position < 1 || position > 7) throw new RangeError("墨宝站位必须为1~7"); + player.storage.mengsanInkletState_shuying = { position, stage: "opening", turnsTaken: 0 }; + player.storage.mengsanInkletIntent_shuying = null; + player.storage.mengsanSlippery_shuying = 1; + player.addSkill("mengsan_slippery_shuying"); + player.markSkill("mengsan_slippery_shuying"); + } player.storage.mengsanInitialHand_shuying = spec.hand ?? 4; for (const skill of spec.skills || []) player.addSkill(skill); player.update(); @@ -315,7 +403,7 @@ const installMonsterPile = (player, monster, current) => { for (const card of battle.drawPile) if (!lib.card[card.name]) throw new Error("怪物牌堆中的牌未加载:" + card.name); shuffleBattlePile(run, battle.drawPile); current.monsterPiles.set(player, installPersonalPiles(current.session, player, battle, run, current.resources, { - game, ui, get, _status, document, MutationObserver, shuffle: shuffleBattlePile, + game, ui, get, lib, _status, document, MutationObserver, shuffle: shuffleBattlePile, strict: false, refresh() {}, })); }; @@ -324,6 +412,23 @@ const { planEnemyIntent, executeFlyconidIntent, executeRaiderIntent, + executeFogmogIntent, + executeMawlerIntent, + executeVineIntent, + executeCubexIntent, + executeByrdonisIntent, + executeEffigyIntent, + executePhrogIntent, + executeNibbitIntent, + executeShrinkerIntent, + executeTwigmediumIntent, + executeTwigslimeIntent, + executeLeafslimeMediumIntent, + executeLeafslimeIntent, + executeStranglerIntent, + executeJaxfruitIntent, + executeCrawlerIntent, + executeInkletIntent, } = createMonsterIntentActions(game, () => activeBattle); const { planBondIntent, executeBondIntent } = createBondIntentActions(game, () => activeBattle); @@ -333,41 +438,43 @@ const equipBattleUnit = async (player, spec, resources) => { await player.equip(card); } }; +const spawnBattleUnit = async (current, spec, anchor) => { + const player = game.addPlayer(anchor ? Number(anchor.dataset.position) + 1 : game.players.length + game.dead.length); + registerPlayers(current.session, [player]); + current.resources.bindPlayers([player]); + const join = game.createEvent("mengsanJoinBattle", false); + join.player = player; + join.setContent(async () => { + const monster = initBattleUnit(player, spec); + current.fogmog?.register(player, spec); + installMonsterPile(player, monster, current); + if (monster) { + current.energyUI.set(player, + mountEnergy(player, current.session, document)); + current.intentUI.set(player, mountEnemyIntent(player, + current.session, STYLE_PATH, document, + () => getIntentHostiles(game, player))); + } + for (const participant of [...game.players, ...game.dead]) participant.setSeatNum(Number(participant.dataset.position) + 1); + await equipBattleUnit(player, spec, current.resources); + await player.draw(spec.hand ?? 4); + await game.triggerEnter(player); + if (monster && current.session.active) { + planEnemyIntent(player, _status.mengsanRun_shuying); + planBondIntent(player); + } + }); + await join; + game.log(player, "作为", spec.camp === "ally" ? "友方支援" : "敌方援军", "加入战斗"); + return player; +}; const createScenario = (plan, current) => { const director = createBattleDirector(plan, { active: () => current.session.active, state: player => ({ hp: player.hp, hand: player.countCards("h"), alive: player.isAlive(), camp: player.storage.mengsanCamp_shuying, linked: player.isLinked(), turnedOver: player.isTurnedOver() }), dialogue: lines => playDialogue(lines, { run: _status.mengsanRun_shuying, title: "关卡剧情" }), - async spawn(spec, anchor) { - const player = game.addPlayer(anchor ? Number(anchor.dataset.position) + 1 : game.players.length + game.dead.length); - registerPlayers(current.session, [player]); - current.resources.bindPlayers([player]); - const join = game.createEvent("mengsanJoinBattle", false); - join.player = player; - join.setContent(async () => { - const monster = initBattleUnit(player, spec); - installMonsterPile(player, monster, current); - if (monster) { - current.energyUI.set(player, - mountEnergy(player, current.session, document)); - current.intentUI.set(player, mountEnemyIntent(player, - current.session, STYLE_PATH, document, - () => getIntentHostiles(game, player))); - } - for (const participant of [...game.players, ...game.dead]) participant.setSeatNum(Number(participant.dataset.position) + 1); - await equipBattleUnit(player, spec, current.resources); - await player.draw(spec.hand ?? 4); - await game.triggerEnter(player); - if (monster && current.session.active) { - planEnemyIntent(player, _status.mengsanRun_shuying); - planBondIntent(player); - } - }); - await join; - game.log(player, "作为", spec.camp === "ally" ? "友方支援" : "敌方援军", "加入战斗"); - return player; - }, + spawn: (spec, anchor) => spawnBattleUnit(current, spec, anchor), async effect(player, effect) { if (effect.type === "draw") await player.draw(effect.amount); else if (effect.type === "recover") await player.recover(effect.amount); @@ -389,8 +496,13 @@ const prepareBattle = async (run, node, encounter, session, resources) => { // Old saves keep their deck instances, but the retired affix no longer exists. for (const card of run.player.deck) if (Array.isArray(card.affixes)) card.affixes = card.affixes.filter(key => key !== "annihilate"); if (encounter.requiredCharacter && run.player.character !== encounter.requiredCharacter) throw new Error("该关卡仅限指定主角,请开始刘备的新征程"); - const bondUnit = prepareBondBattle(run, () => nextRandom(run)); + const bondUnit = prepareBondBattle(run, () => nextRandom(run), node); const plan = buildBattlePlan(encounter, run, bondUnit); + const scheduledUnits = [...plan.units, ...plan.rules.flatMap(rule => + rule.effects.filter(effect => effect.type === "spawn").map(effect => effect.unit))]; + const fogmogCount = scheduledUnits.filter(spec => spec.character === "mengsan_fogmog_shuying").length; + const phrogCount = scheduledUnits.filter(spec => spec.character === PHROG_CHARACTER).length; + if (scheduledUnits.length + fogmogCount + phrogCount * INFESTED_COUNT > 7) throw new Error("召唤需要预留空席位;请减少本场支援数量"); if (run.bondBattle) { game.log(`羁绊助战:${bondDefinitions[run.bondBattle.id].name},${ bondUnit ? "本场将到场" : "本场未到场"}`); @@ -429,12 +541,16 @@ const prepareBattle = async (run, node, encounter, session, resources) => { registerPlayers(session, game.players.slice()); resources.bindPlayers(game.players); const currentBattle = activeBattle; + currentBattle.phrog = createPhrogBattle(game, currentBattle, + (spec, anchor) => spawnBattleUnit(currentBattle, spec, anchor)); + currentBattle.fogmog = createFogmogBattle(game, currentBattle, + (spec, anchor) => spawnBattleUnit(currentBattle, spec, anchor)); let teardown; activeBattle.releaseSkills = () => (teardown ||= createPlayerTeardown([...currentBattle.players], {lib,game,get,_status}))(); const me = game.me; activeBattle.personalPiles = installPersonalPiles(session, me, _status.mengsanBattle_shuying, run, resources, { - game, ui, get, _status, document, MutationObserver, shuffle: shuffleBattlePile, + game, ui, get, lib, _status, document, MutationObserver, shuffle: shuffleBattlePile, refresh: () => activeBattle?.session === session && activeBattle.pilesUI?.refresh(), }); activeBattle.personalPiles.withOwner(() => me.init(run.player.character)); @@ -442,6 +558,7 @@ const prepareBattle = async (run, node, encounter, session, resources) => { setBattleCamp(me, "ally"); const participants = game.players.filter(player => player !== me); const monsters = plan.units.map((spec, index) => initBattleUnit(participants[index], spec)); + bindNibbitOpenings(participants); plan.units.forEach((spec, index) => installMonsterPile(participants[index], monsters[index], currentBattle)); participants.forEach((player, index) => { if (monsters[index]) currentBattle.intentUI.set(player, @@ -462,7 +579,8 @@ const prepareBattle = async (run, node, encounter, session, resources) => { me.storage.mengsanEnergy_shuying = PLAYER_ENERGY; currentBattle.energyUI.set(me, mountEnergy(me, session, document)); currentBattle.handUI = await mountHandUI(me, session, { - game, ui, document, window, cardCost, + game, ui, get, lib, _status, document, window, cardCost, + canPayCard, isActiveCardUse, styleURL: `${STYLE_PATH}/ui/hand/style.css`, log: message => game.log(message), }); @@ -494,6 +612,16 @@ const prepareBattle = async (run, node, encounter, session, resources) => { "mengsan_card_affixes_shuying", "mengsan_scenario_shuying", "mengsan_card_payment_shuying", "mengsan_monster_draw_shuying", "mengsan_flyconid_action_shuying", "mengsan_raider_action_shuying", + "mengsan_fogmog_action_shuying", "mengsan_mawler_action_shuying", + "mengsan_phrog_action_shuying", "mengsan_wriggler_spawned_shuying", "mengsan_infection_damage_shuying", "mengsan_effigy_action_shuying", "mengsan_byrdonis_action_shuying", "mengsan_cubex_action_shuying", "mengsan_vine_action_shuying", "mengsan_nibbit_action_shuying", + "mengsan_shrinker_action_shuying", + "mengsan_twigmedium_action_shuying", + "mengsan_twigslime_action_shuying", + "mengsan_leafmedium_action_shuying", + "mengsan_leafslime_action_shuying", + "mengsan_strangler_action_shuying", + "mengsan_jaxfruit_action_shuying", + "mengsan_crawler_action_shuying", "mengsan_inklet_action_shuying", "mengsan_bond_action_shuying", "mengsan_raider_card_strength_shuying", "mengsan_death_blow_finish_shuying", @@ -543,7 +671,9 @@ const prepareBattle = async (run, node, encounter, session, resources) => { player.storage.mengsanEnergy_shuying = player.storage.mengsanMaxEnergy_shuying; currentBattle.energyUI.get(player)?.(); } + if (player === me) currentBattle.fogmog.beforeRound(); await currentBattle.director.beforeTurn(player); + if (isFogmogActor(player) || isPhrogActor(player)) planEnemyIntent(player, run); if (player === me && session.active) { for (const participant of game.players) { planEnemyIntent(participant, run); @@ -585,12 +715,12 @@ const makeFlow = (releaseSkills = async () => {}) => createBattleFlow({ { run: pending.base, title: "战后剧情" }); }, settlement:createBattleSettlement({store:modeStorage, config, - getRandomRewardChoices, applyReward, completeNode, enterNextAct, + getRandomRewardChoices, applyReward, completeNode, enterNextAct, createRun, logRewardPackage: (pack, selection) => { const card = pack.upgradeChoices.find(card => card.id === selection.cardId); game.log(`梦三:过关奖励 ${pack.gold} 金币,` + - (card ? `强化【${get.translation(card.name)}】` : "无可强化牌")); + (card ? `强化【${get.translation(card.name)}】` : "未强化卡牌")); }, }), chooseReward: (choices, options) => (options.rewardPackage @@ -600,6 +730,7 @@ const makeFlow = (releaseSkills = async () => {}) => createBattleFlow({ ...config.rewards[id], id, })), }), + chooseDefeat:chooseDefeatAction, showMap, showEnding:async route => { game.over(route === "victory"); }, quiesce:() => quiesceEngine(_status), @@ -638,7 +769,7 @@ const awaitSettlement = async (flow, initial = flow.wait()) => { } } }; -const setupBattle = async (run, node, encounter = getNodeEncounter(run, node)) => { +const playBattle = async (run, node, encounter) => { // Wait for engine boot callbacks BEFORE arena creation; cardsAsync then builds synchronously. if (lib.onfree) await new Promise(resolve => lib.onfree.push(resolve)); await quiesceEngine(_status); @@ -670,7 +801,23 @@ const setupBattle = async (run, node, encounter = getNodeEncounter(run, node)) = } const result = await awaitSettlement(flow); if (activeBattle === current) activeBattle = null; - if (result.route === "map") await routeRun(result.run, result.nextNode, true); + return result; +}; +const setupBattle = async (run, node, encounter = getNodeEncounter(run, node)) => { + // Retry iteratively: repeated defeats must not grow a recursive battle promise chain. + while (true) { + const result = await playBattle(run, node, encounter); + if (!result) return; + if (result.route === "retry") { + run = result.run; + node = result.retryNode; + encounter = result.retryEncounter || getNodeEncounter(run, node); + continue; + } + if (result.route === "map") await routeRun(result.run, result.nextNode, true); + else if (result.route === "new") await routeRun(result.run); + return; + } }; const routeRun = async (run, selected = null, supplied = false) => { while (true) { @@ -711,6 +858,14 @@ const startJourney = async () => { const flow = makeFlow(); const result = await awaitSettlement(flow, flow.resumePending({session:createBattleSession(`recovery-${++battleSequence}`)})); if (result.route === "map") await routeRun(result.run, result.nextNode, true); + else if (result.route === "retry") await setupBattle(result.run, result.retryNode, + result.retryEncounter || getNodeEncounter(result.run, result.retryNode)); + else if (result.route === "new") await routeRun(result.run); + return; + } + if (run.battleFlow?.retry) { + const retry = run.battleFlow.retry; + await setupBattle(run, retry.node, retry.encounter || getNodeEncounter(run, retry.node)); return; } const canRefreshOldMap = config.characters.includes(run.player.character) && !run.map?.completedNodeIds?.length && run.map?.layoutVersion != config.mapLayoutVersion; @@ -725,7 +880,20 @@ const createMode = identityMode => { const identityElement = identityMode.element || {}; const identityPlayer = identityElement.player || {}; const nativeChangeHujia = identityPlayer.changeHujia || lib.element.Player.prototype.changeHujia; - const modeCards = createMengsanCards(); + const nativeChangeHp = identityPlayer.changeHp || lib.element.Player.prototype.changeHp; + const modeCards = createMengsanCards({ + copyToDiscard: createBattleCardCopier(game, () => activeBattle), + reclaimFromDiscard: createBattleCardReclaimer(game, () => activeBattle, get), + temporaryStrength: createTemporaryStrength(game, () => activeBattle), + countStrikeCards: createStrikeCounter(game, () => activeBattle, lib), + blockDraw: createBlockDrawer(game, () => activeBattle), + playDrawTop: createHavocPlayer(game, () => activeBattle, _status), + handExhaust: createHandExhauster(game, () => activeBattle, get, + createRandomHandExhauster(game, () => activeBattle, () => _status.mengsanRun_shuying, nextRandom)), + battleEnergy: createBattleEnergy(game, () => activeBattle), + randomHandExhaust: createRandomHandExhauster(game, () => activeBattle, () => _status.mengsanRun_shuying, nextRandom), + isBattleActive: () => Boolean(activeBattle?.session.active), + }); return { ...identityMode, name: MODE_ID, @@ -742,7 +910,11 @@ const createMode = identityMode => { ...identityElement, player: { ...identityPlayer, - dieAfter() { + async dieAfter() { + clearConstrictSource(activeBattle, this); + clearShrinkSource(activeBattle, this); + activeBattle?.fogmog?.onDeath(this); + if (activeBattle?.phrog) await activeBattle.phrog.onDeath(this); recordBondDeath(this, _status.mengsanRun_shuying, message => game.log(message)); game.checkResult(); @@ -752,6 +924,11 @@ const createMode = identityMode => { isEnemyOf(player) { return Boolean(player && this.storage.mengsanCamp_shuying !== player.storage?.mengsanCamp_shuying); }, getEnemies(filter, includeDie) { return game[includeDie ? "filterPlayer2" : "filterPlayer"](p => this.isEnemyOf(p) && (!filter || filter(p))); }, getFriends(filter, includeDie) { const self = filter === true; return game[includeDie ? "filterPlayer2" : "filterPlayer"](p => (p !== this || self) && this.isFriendOf(p) && (typeof filter !== "function" || filter(p))); }, + changeHp(num, popup) { + const next = nativeChangeHp.call(this, num, popup); + if (activeBattle?.session.active) capInkletHpLoss(this, next); + return next; + }, changeHujia(num, type) { // 脆弱只削减正向获得的护甲,不改变受伤时消耗护甲的数值。 if (this.storage?.mengsanFrail_shuying > 0 && (num == null || num > 0) && type !== "damage") { @@ -796,9 +973,43 @@ const createMode = identityMode => { const { resume = "advance", recover } = options; const flyconid = isFlyconid(player); const raider = isRaider(player); + const fogmog = isFogmogActor(player); + const mawler = isMawler(player); + const vine = isVine(player); + const cubex = isCubex(player); + const byrdonis = isByrdonis(player); + const effigy = isEffigy(player); + const phrog = isPhrogActor(player); + const nibbit = isNibbit(player); + const shrinker = isShrinker(player); + const twigmedium = isTwigmedium(player); + const twigslime = isTwigslime(player); + const leafmedium = isLeafslimeMedium(player); + const leafslime = isLeafslime(player); + const strangler = isStrangler(player); + const jaxfruit = isJaxfruit(player); + const crawler = isCrawler(player); + const inklet = isInklet(player); const intent = options.intent ?? (flyconid ? player.storage.mengsanFlyconidIntent_shuying : raider ? - player.storage.mengsanRaiderIntent_shuying : null); + player.storage.mengsanRaiderIntent_shuying : fogmog ? + player.storage.mengsanFogmogIntent_shuying : mawler ? + player.storage.mengsanMawlerIntent_shuying : crawler ? + player.storage.mengsanCrawlerIntent_shuying : inklet ? + player.storage.mengsanInkletIntent_shuying : jaxfruit ? + player.storage.mengsanJaxfruitIntent_shuying : strangler ? + player.storage.mengsanStranglerIntent_shuying : leafslime ? + player.storage.mengsanLeafslimeIntent_shuying : leafmedium ? + player.storage.mengsanLeafslimeMediumIntent_shuying : twigslime ? + player.storage.mengsanTwigslimeIntent_shuying : twigmedium ? + player.storage.mengsanTwigmediumIntent_shuying : shrinker ? + player.storage.mengsanShrinkerIntent_shuying : vine ? + player.storage.mengsanVineIntent_shuying : nibbit ? + player.storage.mengsanNibbitIntent_shuying : cubex ? + player.storage.mengsanCubexIntent_shuying : byrdonis ? + player.storage.mengsanByrdonisIntent_shuying : effigy ? + player.storage.mengsanEffigyIntent_shuying : phrog ? + player.storage.mengsanPhrogIntent_shuying : null); const restore = recover || (flyconid ? (choice, original) => { const state = @@ -813,6 +1024,108 @@ const createMode = identityMode => { const next = choice === "retry" ? original : null; player.storage.mengsanRaiderIntent_shuying = next; return next; + } : fogmog ? (choice, original) => { + if (isFogmog(player)) player.storage.mengsanFogmogState_shuying = + recordFogmogAction(player.storage.mengsanFogmogState_shuying || {}, null, false); + const next = choice === "retry" ? original : null; + player.storage.mengsanFogmogIntent_shuying = next; + return next; + } : mawler ? (choice, original) => { + player.storage.mengsanMawlerState_shuying = + recordMawlerAction(player.storage.mengsanMawlerState_shuying || {}, null, false); + const next = choice === "retry" ? original : null; + player.storage.mengsanMawlerIntent_shuying = next; + return next; + } : crawler ? (choice, original) => { + player.storage.mengsanCrawlerState_shuying = + recordCrawlerAction(player.storage.mengsanCrawlerState_shuying || {}, choice !== "retry"); + const next = choice === "retry" ? original : null; + player.storage.mengsanCrawlerIntent_shuying = next; + return next; + } : inklet ? (choice, original) => { + player.storage.mengsanInkletState_shuying = + recordInkletAction(player.storage.mengsanInkletState_shuying || {}, choice !== "retry"); + const next = choice === "retry" ? original : null; + player.storage.mengsanInkletIntent_shuying = next; + return next; + } : jaxfruit ? (choice, original) => { + player.storage.mengsanJaxfruitState_shuying = + recordJaxfruitAction(player.storage.mengsanJaxfruitState_shuying || {}, choice !== "retry"); + const next = choice === "retry" ? original : null; + player.storage.mengsanJaxfruitIntent_shuying = next; + return next; + } : strangler ? (choice, original) => { + player.storage.mengsanStranglerState_shuying = + recordStranglerAction(player.storage.mengsanStranglerState_shuying || {}, choice !== "retry"); + const next = choice === "retry" ? original : null; + player.storage.mengsanStranglerIntent_shuying = next; + return next; + } : leafslime ? (choice, original) => { + player.storage.mengsanLeafslimeState_shuying = + recordLeafslimeAction(player.storage.mengsanLeafslimeState_shuying || {}, choice !== "retry"); + const next = choice === "retry" ? original : null; + player.storage.mengsanLeafslimeIntent_shuying = next; + return next; + } : leafmedium ? (choice, original) => { + player.storage.mengsanLeafslimeMediumState_shuying = + recordLeafslimeMediumAction(player.storage.mengsanLeafslimeMediumState_shuying || {}, choice !== "retry"); + const next = choice === "retry" ? original : null; + player.storage.mengsanLeafslimeMediumIntent_shuying = next; + return next; + } : twigslime ? (choice, original) => { + player.storage.mengsanTwigslimeState_shuying = + recordTwigslimeAction(player.storage.mengsanTwigslimeState_shuying || {}, choice !== "retry"); + const next = choice === "retry" ? original : null; + player.storage.mengsanTwigslimeIntent_shuying = next; + return next; + } : twigmedium ? (choice, original) => { + player.storage.mengsanTwigmediumState_shuying = + recordTwigmediumAction(player.storage.mengsanTwigmediumState_shuying || {}, original, choice !== "retry"); + const next = choice === "retry" ? original : null; + player.storage.mengsanTwigmediumIntent_shuying = next; + return next; + } : shrinker ? (choice, original) => { + player.storage.mengsanShrinkerState_shuying = + recordShrinkerAction(player.storage.mengsanShrinkerState_shuying || {}, choice !== "retry"); + const next = choice === "retry" ? original : null; + player.storage.mengsanShrinkerIntent_shuying = next; + return next; + } : vine ? (choice, original) => { + player.storage.mengsanVineState_shuying = + recordVineAction(player.storage.mengsanVineState_shuying || {}, choice !== "retry"); + const next = choice === "retry" ? original : null; + player.storage.mengsanVineIntent_shuying = next; + return next; + } : nibbit ? (choice, original) => { + player.storage.mengsanNibbitState_shuying = + recordNibbitAction(player.storage.mengsanNibbitState_shuying || {}, choice !== "retry"); + const next = choice === "retry" ? original : null; + player.storage.mengsanNibbitIntent_shuying = next; + return next; + } : cubex ? (choice, original) => { + player.storage.mengsanCubexState_shuying = + recordCubexAction(player.storage.mengsanCubexState_shuying || {}, choice !== "retry"); + const next = choice === "retry" ? original : null; + player.storage.mengsanCubexIntent_shuying = next; + return next; + } : byrdonis ? (choice, original) => { + player.storage.mengsanByrdonisState_shuying = + recordByrdonisAction(player.storage.mengsanByrdonisState_shuying || {}, choice !== "retry"); + const next = choice === "retry" ? original : null; + player.storage.mengsanByrdonisIntent_shuying = next; + return next; + } : phrog ? (choice, original) => { + player.storage.mengsanPhrogState_shuying = + recordPhrogAction(player.storage.mengsanPhrogState_shuying || {}, choice !== "retry"); + const next = choice === "retry" ? original : null; + player.storage.mengsanPhrogIntent_shuying = next; + return next; + } : effigy ? (choice, original) => { + player.storage.mengsanEffigyState_shuying = + recordEffigyAction(player.storage.mengsanEffigyState_shuying || {}, choice !== "retry"); + const next = choice === "retry" ? original : null; + player.storage.mengsanEffigyIntent_shuying = next; + return next; } : null); const onRecover = async (choice, original) => { const next = await restore(choice, original); @@ -858,7 +1171,7 @@ const createMode = identityMode => { }, checkResult() { const current = activeBattle; - if (!current?.session.active || !current.director?.ready || current.director.busy || current.resultCheckQueued) return; + if (!current?.session.active || !current.director?.ready || current.director.busy || current.phrog?.pending || current.resultCheckQueued) return; current.resultCheckQueued = true; const next = game.createEvent("mengsanScenarioResult", false); next.setContent(async () => { @@ -866,7 +1179,7 @@ const createMode = identityMode => { await current.director.evaluate("state"); if (!current.session.active) return; if (!game.me || game.me.isDead()) game.mengsanFailRun_shuying("角色死亡,征程结束"); - else if (!game.players.some(p => p.storage.mengsanCamp_shuying === "enemy" && p.isAlive()) && !current.director.pendingEnemies) game.mengsanFinishBattle_shuying(); + else if (!game.players.some(isEncounterEnemy) && !current.director.pendingEnemies && !current.phrog?.pending) game.mengsanFinishBattle_shuying(); } finally { current.resultCheckQueued = false; } }); }, @@ -891,7 +1204,7 @@ const createMode = identityMode => { lib.card[event.card?.name]?.mengsanCost_shuying != null && isActiveCardUse(event, event.player); }, async content(event, trigger) { if (trigger.mengsanPaid_shuying) return; - if (!payCard(trigger.player, trigger.card)) { + if (!payCard(trigger.player, trigger.card, trigger)) { trigger.cancel(); game.log(trigger.player, "费用不足,不能使用", trigger.card); return; @@ -908,7 +1221,7 @@ const createMode = identityMode => { player.storage?.mengsanBond_shuying); }, async content(event, trigger, player) { - trigger.num = isStunned(player) ? 0 : + trigger.num = isStunned(player) || isToothedEye(player) ? 0 : (player.storage.mengsanMonster_shuying || player.storage.mengsanBond_shuying).draw; }, @@ -929,6 +1242,162 @@ const createMode = identityMode => { event.player.storage.mengsanRaiderIntent_shuying); }, async content(event, trigger) { await executeRaiderIntent(trigger.player); }, }, + mengsan_inklet_action_shuying: { + trigger: { global: "phaseUseBefore" }, + forced: true, silent: true, popup: false, priority: 100, + filter(event) { return Boolean(activeBattle?.session.active && + event.player?.isAlive() && !isStunned(event.player) && isInklet(event.player) && + event.player.storage.mengsanInkletIntent_shuying); }, + async content(event, trigger) { await executeInkletIntent(trigger.player); }, + }, + mengsan_slippery_shuying: { + mark: true, marktext: "滑", + onremove(player) { delete player.storage.mengsanSlippery_shuying; }, + intro: { content(storage, player) { return `滑溜${player.storage.mengsanSlippery_shuying || 0}:下一次实际生命损失限制为1,完全格挡不消耗`; } }, + trigger: { player: "changeHp" }, + forced: true, silent: true, popup: false, + filter(event, player) { return Boolean(activeBattle?.session.active && isInklet(player) && + player.storage.mengsanSlippery_shuying > 0 && event.num < 0 && event.changedHp < 0); }, + async content(event, trigger, player) { + player.storage.mengsanSlippery_shuying = Math.max(0, player.storage.mengsanSlippery_shuying - 1); + if (player.storage.mengsanSlippery_shuying) player.markSkill("mengsan_slippery_shuying"); + else player.removeSkill("mengsan_slippery_shuying"); + }, + }, + mengsan_crawler_action_shuying: { + trigger: { global: "phaseUseBefore" }, + forced: true, silent: true, popup: false, priority: 100, + filter(event) { return Boolean(activeBattle?.session.active && + event.player?.isAlive() && !isStunned(event.player) && isCrawler(event.player) && + event.player.storage.mengsanCrawlerIntent_shuying); }, + async content(event, trigger) { await executeCrawlerIntent(trigger.player); }, + }, + mengsan_jaxfruit_action_shuying: { + trigger: { global: "phaseUseBefore" }, + forced: true, silent: true, popup: false, priority: 100, + filter(event) { return Boolean(activeBattle?.session.active && + event.player?.isAlive() && !isStunned(event.player) && isJaxfruit(event.player) && + event.player.storage.mengsanJaxfruitIntent_shuying); }, + async content(event, trigger) { await executeJaxfruitIntent(trigger.player); }, + }, + mengsan_strangler_action_shuying: { + trigger: { global: "phaseUseBefore" }, + forced: true, silent: true, popup: false, priority: 100, + filter(event) { return Boolean(activeBattle?.session.active && + event.player?.isAlive() && !isStunned(event.player) && isStrangler(event.player) && + event.player.storage.mengsanStranglerIntent_shuying); }, + async content(event, trigger) { await executeStranglerIntent(trigger.player); }, + }, + mengsan_leafslime_action_shuying: { + trigger: { global: "phaseUseBefore" }, + forced: true, silent: true, popup: false, priority: 100, + filter(event) { return Boolean(activeBattle?.session.active && + event.player?.isAlive() && !isStunned(event.player) && isLeafslime(event.player) && + event.player.storage.mengsanLeafslimeIntent_shuying); }, + async content(event, trigger) { await executeLeafslimeIntent(trigger.player); }, + }, + mengsan_leafmedium_action_shuying: { + trigger: { global: "phaseUseBefore" }, + forced: true, silent: true, popup: false, priority: 100, + filter(event) { return Boolean(activeBattle?.session.active && + event.player?.isAlive() && !isStunned(event.player) && isLeafslimeMedium(event.player) && + event.player.storage.mengsanLeafslimeMediumIntent_shuying); }, + async content(event, trigger) { await executeLeafslimeMediumIntent(trigger.player); }, + }, + mengsan_twigslime_action_shuying: { + trigger: { global: "phaseUseBefore" }, + forced: true, silent: true, popup: false, priority: 100, + filter(event) { return Boolean(activeBattle?.session.active && + event.player?.isAlive() && !isStunned(event.player) && isTwigslime(event.player) && + event.player.storage.mengsanTwigslimeIntent_shuying); }, + async content(event, trigger) { await executeTwigslimeIntent(trigger.player); }, + }, + mengsan_twigmedium_action_shuying: { + trigger: { global: "phaseUseBefore" }, + forced: true, silent: true, popup: false, priority: 100, + filter(event) { return Boolean(activeBattle?.session.active && + event.player?.isAlive() && !isStunned(event.player) && isTwigmedium(event.player) && + event.player.storage.mengsanTwigmediumIntent_shuying); }, + async content(event, trigger) { await executeTwigmediumIntent(trigger.player); }, + }, + mengsan_shrinker_action_shuying: { + trigger: { global: "phaseUseBefore" }, + forced: true, silent: true, popup: false, priority: 100, + filter(event) { return Boolean(activeBattle?.session.active && + event.player?.isAlive() && !isStunned(event.player) && isShrinker(event.player) && + event.player.storage.mengsanShrinkerIntent_shuying); }, + async content(event, trigger) { await executeShrinkerIntent(trigger.player); }, + }, + mengsan_vine_action_shuying: { + trigger: { global: "phaseUseBefore" }, + forced: true, silent: true, popup: false, priority: 100, + filter(event) { return Boolean(activeBattle?.session.active && + event.player?.isAlive() && !isStunned(event.player) && isVine(event.player) && + event.player.storage.mengsanVineIntent_shuying); }, + async content(event, trigger) { await executeVineIntent(trigger.player); }, + }, + mengsan_cubex_action_shuying: { + trigger: { global: "phaseUseBefore" }, + forced: true, silent: true, popup: false, priority: 100, + filter(event) { return Boolean(activeBattle?.session.active && + event.player?.isAlive() && !isStunned(event.player) && isCubex(event.player) && + event.player.storage.mengsanCubexIntent_shuying); }, + async content(event, trigger) { await executeCubexIntent(trigger.player); }, + }, + mengsan_byrdonis_action_shuying: { + trigger: { global: "phaseUseBefore" }, + forced: true, silent: true, popup: false, priority: 100, + filter(event) { return Boolean(activeBattle?.session.active && + event.player?.isAlive() && !isStunned(event.player) && isByrdonis(event.player) && + event.player.storage.mengsanByrdonisIntent_shuying); }, + async content(event, trigger) { await executeByrdonisIntent(trigger.player); }, + }, + mengsan_phrog_action_shuying: { + trigger: { global: "phaseUseBefore" }, + forced: true, silent: true, popup: false, priority: 100, + filter(event) { return Boolean(activeBattle?.session.active && + event.player?.isAlive() && !isStunned(event.player) && !event.player?.storage?.mengsanPhrogState_shuying?.spawned && isPhrogActor(event.player) && + event.player.storage.mengsanPhrogIntent_shuying); }, + async content(event, trigger) { await executePhrogIntent(trigger.player); }, + }, + mengsan_effigy_action_shuying: { + trigger: { global: "phaseUseBefore" }, + forced: true, silent: true, popup: false, priority: 100, + filter(event) { return Boolean(activeBattle?.session.active && + event.player?.isAlive() && !isStunned(event.player) && isEffigy(event.player) && + event.player.storage.mengsanEffigyIntent_shuying); }, + async content(event, trigger) { await executeEffigyIntent(trigger.player, trigger); }, + }, + mengsan_nibbit_action_shuying: { + trigger: { global: "phaseUseBefore" }, + forced: true, silent: true, popup: false, priority: 100, + filter(event) { return Boolean(activeBattle?.session.active && + event.player?.isAlive() && !isStunned(event.player) && isNibbit(event.player) && + event.player.storage.mengsanNibbitIntent_shuying); }, + async content(event, trigger) { await executeNibbitIntent(trigger.player); }, + }, + mengsan_mawler_action_shuying: { + trigger: { global: "phaseUseBefore" }, + forced: true, silent: true, popup: false, priority: 100, + filter(event) { return Boolean(activeBattle?.session.active && + event.player?.isAlive() && !isStunned(event.player) && isMawler(event.player) && + event.player.storage.mengsanMawlerIntent_shuying); }, + async content(event, trigger) { await executeMawlerIntent(trigger.player); }, + }, + mengsan_fogmog_action_shuying: { + trigger: { global: "phaseUseBefore" }, + forced: true, silent: true, popup: false, priority: 100, + filter(event) { return Boolean(activeBattle?.session.active && + !isStunned(event.player) && isFogmogActor(event.player)); }, + async content(event, trigger) { + await executeFogmogIntent(trigger.player); + if (isToothedEye(trigger.player)) trigger.cancel(); + }, + }, + mengsan_fogmog_illusion_shuying: { + mark: true, marktext: "幻", + intro: { content: "爪牙:雾菇存活时,死亡后的下一轮以6点生命复活;不单独阻止战斗胜利。" }, + }, mengsan_bond_action_shuying: { trigger: { global: "phaseUseBefore" }, forced: true, silent: true, popup: false, priority: 100, @@ -941,13 +1410,36 @@ const createMode = identityMode => { await executeBondIntent(trigger.player); }, }, + mengsan_setup_strength_shuying: { + trigger: { source: "damageBegin1", global: ["phaseAfter", "dieAfter"] }, + forced: true, silent: true, popup: false, priority: 85, forceDie: true, forceOut: true, + mark: true, marktext: "力", + intro: { content(storage, player) { return `本回合攻击伤害增加${player.storage.mengsanSetupStrength_shuying || 0}点`; } }, + onremove(player) { forgetTemporaryStrength(player); }, + filter(event, player) { + if (event.name === "damage") { + return Boolean(temporaryStrengthAmount(player, activeBattle) > 0 && + (event.card || event.mengsanAttack_shuying) && + !event.mengsanScriptedSkill_shuying && !event.mengsanSetupStrengthApplied_shuying); + } + return event.name === "die" ? event.player === player && + Boolean(player.storage.mengsanSetupStrength_shuying) : temporaryStrengthExpires(player, event); + }, + async content(event, trigger, player) { + if (event.triggername === "damageBegin1") { + trigger.num += temporaryStrengthAmount(player, activeBattle); + trigger.mengsanSetupStrengthApplied_shuying = true; + } else clearTemporaryStrength(player); + }, + }, mengsan_raider_card_strength_shuying: { trigger: { source: "damageBegin1" }, forced: true, silent: true, popup: false, priority: 90, filter(event, player) { return Boolean(activeBattle?.session.active && - (isRaider(player) || player.storage.mengsanBond_shuying) && + (isRaider(player) || isFogmog(player) || isCrawler(player) || isInklet(player) || isJaxfruit(player) || isStrangler(player) || isLeafslime(player) || isLeafslimeMedium(player) || isTwigslime(player) || isTwigmedium(player) || isShrinker(player) || isVine(player) || isNibbit(player) || isCubex(player) || isByrdonis(player) || isEffigy(player) || isPhrogActor(player) || player.storage.mengsanBond_shuying) && player.storage.mengsanStrength_shuying > 0 && - event.card && !event.mengsanScriptedSkill_shuying); }, + event.card && !event.mengsanScriptedSkill_shuying && + !event.mengsanCardStrengthApplied_shuying); }, async content(event, trigger, player) { trigger.num += player.storage.mengsanStrength_shuying; }, @@ -1011,6 +1503,78 @@ const createMode = identityMode => { else player.removeSkill("mengsan_frail_shuying"); }, }, + mengsan_constrict_shuying: { + mark: true, marktext: "缠", + intro: { content(storage, player) { return `紧缠${player.storage.mengsanConstrict_shuying || 0}层:自身回合结束时受到等量非攻击伤害,来源死亡解除。`; } }, + onremove(player) { delete player.storage.mengsanConstrict_shuying; activeBattle?.stranglerConstrict?.delete(player); }, + trigger: { player: "phaseJieshuBegin" }, + forced: true, silent: true, popup: false, + filter(event, player) { return constrictTotal(activeBattle, player) > 0; }, + async content(event, trigger, player) { await resolveConstrict(activeBattle, player); }, + }, + mengsan_slow_shuying: { + mark: true, marktext: "缓", + intro: { + markcount(storage, player) { return slowPercent(player); }, + content(storage, player) { return `缓慢${player.storage.mengsanSlow_shuying || 0}层:本回合攻击伤害增加${slowPercent(player)}%。友方每使用一张牌增加10%,换回合清零。`; }, + }, + onremove(player) { delete player.storage.mengsanSlow_shuying; delete player.storage.mengsanSlowCards_shuying; }, + trigger: { player: "damageBegin3", global: ["useCardAfter", "phaseBefore", "phaseAfter"] }, + forced: true, silent: true, popup: false, priority: 20, + filter(event, player) { + if (!activeBattle?.session.active || !isEffigy(player) || !player.isAlive() || !player.storage.mengsanSlow_shuying) return false; + if (event.name === "damage") return slowApplies(player, event); + if (event.name === "useCard") return canCountSlowCard(player, activeBattle, event, _status.currentPhase); + return event.name === "phase"; + }, + async content(event, trigger, player) { + if (event.triggername === "damageBegin3") { + trigger.num = slowAttackDamage(trigger.num, player); + trigger.mengsanSlowApplied_shuying = true; + } else if (event.triggername === "useCardAfter") recordSlowCard(player, activeBattle, trigger, _status.currentPhase); + else resetSlow(player); + }, + }, + mengsan_territorial_shuying: { + mark: true, marktext: "领", + intro: { content(storage, player) { return `多尼斯异鸟的领地意识${player.storage.mengsanTerritorial_shuying || 0}层:自身回合结束时获得等量力量。`; } }, + onremove(player) { delete player.storage.mengsanTerritorial_shuying; }, + trigger: { player: "phaseAfter" }, + forced: true, silent: true, popup: false, + filter(event, player) { return Boolean(activeBattle?.session.active && isByrdonis(player) && + event.player === player && player.isAlive() && player.storage.mengsanTerritorial_shuying > 0); }, + async content(event, trigger, player) { resolveTerritorial(player, activeBattle); }, + }, + mengsan_artifact_shuying: { + mark: true, marktext: "制", + intro: { content(storage, player) { return `人工制品${player.storage.mengsanArtifact_shuying || 0}层:每层抵消一次梦三负面状态施加,不抵消伤害。`; } }, + onremove(player) { delete player.storage.mengsanArtifact_shuying; }, + }, + mengsan_tangled_shuying: { + mark: true, marktext: "藤", + intro: { content(storage, player) { return `剩余${player.storage.mengsanTangled_shuying || 0}回合:攻击牌费用增加1(持续回合可叠加,加费不叠加)。`; } }, + onremove(player) { delete player.storage.mengsanTangled_shuying; activeBattle?.tangledTargets?.delete(player); if (activeBattle?.session.active) activeBattle.handUI?.refresh(); }, + trigger: { player: "phaseAfter" }, + forced: true, silent: true, popup: false, + filter(event, player) { return Boolean(activeBattle?.session.active && player.storage.mengsanTangled_shuying > 0); }, + async content(event, trigger, player) { advanceTangled(activeBattle, player); }, + }, + mengsan_shrink_shuying: { + mark: true, marktext: "缩", + intro: { content: "攻击伤害减少30%(向下取整),不重复叠加;来源死亡解除。" }, + onremove(player) { delete player.storage.mengsanShrink_shuying; activeBattle?.shrinkerSources?.delete(player); }, + trigger: { source: "damageBegin1" }, + forced: true, silent: true, popup: false, priority: 70, + filter(event, player) { + return Boolean(activeBattle?.session.active && !event.mengsanScriptedSkill_shuying && + !event.mengsanShrinkApplied_shuying && (event.card || event.mengsanAttack_shuying) && + isShrunk(activeBattle, player)); + }, + async content(event, trigger, player) { + trigger.num = shrinkAttackDamage(trigger.num, player); + trigger.mengsanShrinkApplied_shuying = true; + }, + }, mengsan_vulnerable_shuying: { mark: true, marktext: "易", onremove(player) { delete player.storage.mengsanVulnerable_shuying; }, @@ -1071,6 +1635,30 @@ const createMode = identityMode => { }); }, }, + mengsan_infested_shuying: { + mark: true, marktext: "寄", + intro: { markcount(storage, player) { return player.storage.mengsanInfested_shuying || 0; }, + content: "死亡后立即召唤4只扭动虫,扭动虫出场的首回合跳过;须消灭扭动虫才能获胜。" }, + onremove(player) { delete player.storage.mengsanInfested_shuying; }, + }, + mengsan_wriggler_spawned_shuying: { + trigger: { global: "phaseBefore" }, + forced: true, silent: true, popup: false, priority: 1000, + filter(event) { return Boolean(activeBattle?.session.active && isWriggler(event.player) && event.player.storage.mengsanPhrogState_shuying?.spawned); }, + async content(event, trigger) { + if (skipSpawnedWriggler(trigger.player, trigger)) { + clearStun(trigger.player); + game.mengsanSetEnemyIntent_shuying(trigger.player, null); + game.log(trigger.player, "生成眩晕,跳过首回合"); + } + }, + }, + mengsan_infection_damage_shuying: { + trigger: { global: "phaseDiscardBegin" }, + forced: true, silent: true, popup: false, priority: 200, + filter(event) { return Boolean(activeBattle?.session.active && event.player?.isAlive() && infectionHand(event.player).length); }, + async content(event, trigger) { await resolveInfection(activeBattle, trigger.player, trigger); }, + }, mengsan_card_affixes_shuying: { forced: true, silent: true, popup: false, priority: 100, trigger: { global: ["phaseDiscardBegin", "cardsDiscardAfter"] }, @@ -1128,15 +1716,52 @@ const createMode = identityMode => { mengsan_yingyong_shuying_info: "锁定技,每回合限一次,你于自己的回合内使用牌造成的伤害+1。", mengsan_flyconid_action_shuying: "孢子行动", mengsan_raider_action_shuying: "劫掠行动", + mengsan_fogmog_action_shuying: "雾菇行动", + mengsan_mawler_action_shuying: "蛮兽行动", + mengsan_vine_action_shuying: "藤蔓蹒跚者行动", + mengsan_nibbit_action_shuying: "小啃兽行动", + mengsan_cubex_action_shuying: "立柱构造体行动", + mengsan_byrdonis_action_shuying: "多尼斯异鸟行动", + mengsan_effigy_action_shuying: "旧日雕像行动", + mengsan_phrog_action_shuying: "异蛙寄生行动", + mengsan_wriggler_spawned_shuying: "生成眩晕", + mengsan_infection_damage_shuying: "感染结算", + mengsan_infested_shuying: "寄生物", + mengsan_slow_shuying: "缓慢", + mengsan_slow_shuying_info: "本回合友方每使用一张牌,旧日雕像受到的攻击伤害增加10%,回合结束清零。", + mengsan_territorial_shuying: "领地意识", + mengsan_territorial_shuying_info: "自身回合结束时,每层领地意识获得1点力量。", + mengsan_shrinker_action_shuying: "缩小甲虫行动", + mengsan_twigmedium_action_shuying: "树枝史莱姆(中)行动", + mengsan_twigslime_action_shuying: "树枝史莱姆(小)行动", + mengsan_leafmedium_action_shuying: "树叶史莱姆(中)行动", + mengsan_leafslime_action_shuying: "树叶史莱姆(小)行动", + mengsan_strangler_action_shuying: "蛇行扼杀者行动", + mengsan_jaxfruit_action_shuying: "闪光贾克斯果行动", + mengsan_crawler_action_shuying: "毛绒伏地虫行动", + mengsan_inklet_action_shuying: "墨宝行动", + mengsan_slippery_shuying: "滑溜", + mengsan_slippery_shuying_info: "下一次实际失去生命时只失去1点生命;完全格挡不消耗。", + mengsan_fogmog_illusion_shuying: "幻象·爪牙", mengsan_weak_shuying: "虚弱", mengsan_weak_shuying_info: "攻击伤害减少25%(向下取整),自身回合结束减少1层。", mengsan_relic_attack_shuying: "遗物攻击修正", + mengsan_setup_strength_shuying: "临时力量", + mengsan_setup_strength_shuying_info: "本回合内增加攻击伤害,回合结束时移除。", mengsan_raider_card_strength_shuying: "力量加成", mengsan_raider_strength_shuying: "力量", mengsan_raider_strength_shuying_info: "攻击造成的伤害按力量层数增加。", mengsan_frail_shuying: "脆弱", mengsan_frail_shuying_info: "接下来相应回合获得护甲减少25%(向下取整)。", + mengsan_constrict_shuying: "紧缠", + mengsan_constrict_shuying_info: "自身回合结束受到层数对应的非攻击伤害;可叠加,来源死亡解除。", + mengsan_artifact_shuying: "人工制品", + mengsan_artifact_shuying_info: "每层抵消一次梦三负面状态施加,不抵消伤害、正面增益或既有状态的自然结算。", + mengsan_tangled_shuying: "缠结", + mengsan_tangled_shuying_info: "攻击牌费用增加1,持续对应回合,自身回合结束减少1回合;不影响响应。", + mengsan_shrink_shuying: "缩小", + mengsan_shrink_shuying_info: "攻击伤害减少30%(向下取整),不重复叠加;来源死亡解除。", mengsan_vulnerable_shuying: "易伤", mengsan_vulnerable_shuying_info: "接下来相应回合受到的攻击伤害增加50%(向上取整)。", }, diff --git a/mengsan/monsters/actions.js b/mengsan/monsters/actions.js index 737a8d1..ef1a448 100644 --- a/mengsan/monsters/actions.js +++ b/mengsan/monsters/actions.js @@ -1,14 +1,37 @@ +import { addConstrict } from "./strangler-constrict.js"; +import { applyShrink } from "./shrinker-status.js"; +import { getIntentHostiles } from "../battle/intent-effects.js"; +import { applyLeafslimeGoop } from "./leafslime-goop.js"; import { createIntentExecutor } from "../battle/intent-effects.js"; export { getIntentHostiles } from "../battle/intent-effects.js"; import { isStunned } from "../battle/stun-intent.js"; import { nextRandom } from "../progression/state.js"; import { selectFlyconidMove, recordFlyconidAction } from "./flyconid-intent.js"; +import { isMawler, selectMawlerMove, recordMawlerAction } from "./mawler-intent.js"; +import { isVine, selectVineMove, recordVineAction } from "./vine-intent.js"; +import { isCubex, selectCubexMove, recordCubexAction } from "./cubex-intent.js"; +import { isByrdonis, selectByrdonisMove, recordByrdonisAction } from "./byrdonis-intent.js"; +import { isEffigy, selectEffigyMove, recordEffigyAction } from "./effigy-intent.js"; +import { isPhrog, isPhrogActor, selectPhrogMove, selectWrigglerMove, recordPhrogAction } from "./phrog-intent.js"; +import { applyInfection } from "../cards/infection-card.js"; +import { isNibbit, selectNibbitMove, recordNibbitAction, bindNibbitOpenings } from "./nibbit-intent.js"; +import { isShrinker, selectShrinkerMove, recordShrinkerAction } from "./shrinker-intent.js"; +import { isTwigmedium, selectTwigmediumMove, recordTwigmediumAction } from "./twigmedium-intent.js"; +import { isTwigslime, selectTwigslimeMove, recordTwigslimeAction } from "./twigslime-intent.js"; +import { isLeafslimeMedium, selectLeafslimeMediumMove, recordLeafslimeMediumAction } from "./leafmedium-intent.js"; +import { isLeafslime, selectLeafslimeMove, recordLeafslimeAction } from "./leafslime-intent.js"; +import { isStrangler, selectStranglerMove, recordStranglerAction } from "./strangler-intent.js"; +import { isJaxfruit, selectJaxfruitMove, recordJaxfruitAction } from "./jaxfruit-intent.js"; +import { isCrawler, selectCrawlerMove, recordCrawlerAction } from "./crawler-intent.js"; +import { isInklet, selectInkletMove, recordInkletAction } from "./inklet-intent.js"; import { isRaiderCharacter, selectRaiderMove, recordRaiderAction, } from "./raider-intent.js"; +import { isFogmog, isFogmogActor, selectFogmogMove, recordFogmogAction, EYE_INTENT } from "./fogmog-intent.js"; + export const isFlyconid = player => player?.name === "mengsan_flyconid_shuying" && player.storage?.mengsanCamp_shuying === "enemy"; @@ -28,6 +51,94 @@ export function createMonsterIntentActions(game, getActiveBattle) { () => nextRandom(run)); player.storage.mengsanFlyconidIntent_shuying = move; game.mengsanSetEnemyIntent_shuying(player, move); + } else if (isInklet(player)) { + if (!getActiveBattle()?.session.active || player.storage.mengsanInkletIntent_shuying) return; + const move = selectInkletMove(player.storage.mengsanInkletState_shuying || {}, () => nextRandom(run)); + player.storage.mengsanInkletIntent_shuying = move; + game.mengsanSetEnemyIntent_shuying(player, move); + } else if (isCrawler(player)) { + if (!getActiveBattle()?.session.active || player.storage.mengsanCrawlerIntent_shuying) return; + const move = selectCrawlerMove(player.storage.mengsanCrawlerState_shuying || {}); + player.storage.mengsanCrawlerIntent_shuying = move; + game.mengsanSetEnemyIntent_shuying(player, move); + } else if (isJaxfruit(player)) { + if (!getActiveBattle()?.session.active || player.storage.mengsanJaxfruitIntent_shuying) return; + const move = selectJaxfruitMove(player.storage.mengsanJaxfruitState_shuying || {}); + player.storage.mengsanJaxfruitIntent_shuying = move; + game.mengsanSetEnemyIntent_shuying(player, move); + } else if (isStrangler(player)) { + if (!getActiveBattle()?.session.active || player.storage.mengsanStranglerIntent_shuying) return; + const move = selectStranglerMove(player.storage.mengsanStranglerState_shuying || {}, () => nextRandom(run)); + player.storage.mengsanStranglerIntent_shuying = move; + game.mengsanSetEnemyIntent_shuying(player, move); + } else if (isLeafslime(player)) { + if (!getActiveBattle()?.session.active || player.storage.mengsanLeafslimeIntent_shuying) return; + const move = selectLeafslimeMove(player.storage.mengsanLeafslimeState_shuying || {}, () => nextRandom(run)); + player.storage.mengsanLeafslimeIntent_shuying = move; + game.mengsanSetEnemyIntent_shuying(player, move); + } else if (isLeafslimeMedium(player)) { + if (!getActiveBattle()?.session.active || player.storage.mengsanLeafslimeMediumIntent_shuying) return; + const move = selectLeafslimeMediumMove(player.storage.mengsanLeafslimeMediumState_shuying || {}); + player.storage.mengsanLeafslimeMediumIntent_shuying = move; + game.mengsanSetEnemyIntent_shuying(player, move); + } else if (isTwigslime(player)) { + if (!getActiveBattle()?.session.active || player.storage.mengsanTwigslimeIntent_shuying) return; + const move = selectTwigslimeMove(player.storage.mengsanTwigslimeState_shuying || {}); + player.storage.mengsanTwigslimeIntent_shuying = move; + game.mengsanSetEnemyIntent_shuying(player, move); + } else if (isTwigmedium(player)) { + if (!getActiveBattle()?.session.active || player.storage.mengsanTwigmediumIntent_shuying) return; + const move = selectTwigmediumMove(player.storage.mengsanTwigmediumState_shuying || {}, () => nextRandom(run)); + player.storage.mengsanTwigmediumIntent_shuying = move; + game.mengsanSetEnemyIntent_shuying(player, move); + } else if (isShrinker(player)) { + if (!getActiveBattle()?.session.active || player.storage.mengsanShrinkerIntent_shuying) return; + const move = selectShrinkerMove(player.storage.mengsanShrinkerState_shuying || {}); + player.storage.mengsanShrinkerIntent_shuying = move; + game.mengsanSetEnemyIntent_shuying(player, move); + } else if (isNibbit(player)) { + if (!getActiveBattle()?.session.active || player.storage.mengsanNibbitIntent_shuying) return; + bindNibbitOpenings(game.players); + const move = selectNibbitMove(player.storage.mengsanNibbitState_shuying || {}); + player.storage.mengsanNibbitIntent_shuying = move; + game.mengsanSetEnemyIntent_shuying(player, move); + } else if (isEffigy(player)) { + if (!getActiveBattle()?.session.active || player.storage.mengsanEffigyIntent_shuying) return; + const move = selectEffigyMove(player.storage.mengsanEffigyState_shuying || {}); + player.storage.mengsanEffigyIntent_shuying = move; + game.mengsanSetEnemyIntent_shuying(player, move); + } else if (isPhrogActor(player)) { + if (!getActiveBattle()?.session.active || player.storage.mengsanPhrogIntent_shuying) return; + const move = (isPhrog(player) ? selectPhrogMove : selectWrigglerMove)(player.storage.mengsanPhrogState_shuying || {}); + player.storage.mengsanPhrogIntent_shuying = move; + game.mengsanSetEnemyIntent_shuying(player, move); + } else if (isByrdonis(player)) { + if (!getActiveBattle()?.session.active || player.storage.mengsanByrdonisIntent_shuying) return; + const move = selectByrdonisMove(player.storage.mengsanByrdonisState_shuying || {}); + player.storage.mengsanByrdonisIntent_shuying = move; + game.mengsanSetEnemyIntent_shuying(player, move); + } else if (isCubex(player)) { + if (!getActiveBattle()?.session.active || player.storage.mengsanCubexIntent_shuying) return; + const move = selectCubexMove(player.storage.mengsanCubexState_shuying || {}); + player.storage.mengsanCubexIntent_shuying = move; + game.mengsanSetEnemyIntent_shuying(player, move); + } else if (isVine(player)) { + if (!getActiveBattle()?.session.active || player.storage.mengsanVineIntent_shuying) return; + const move = selectVineMove(player.storage.mengsanVineState_shuying || {}); + player.storage.mengsanVineIntent_shuying = move; + game.mengsanSetEnemyIntent_shuying(player, move); + } else if (isMawler(player)) { + if (!getActiveBattle()?.session.active || player.storage.mengsanMawlerIntent_shuying) return; + const move = selectMawlerMove(player.storage.mengsanMawlerState_shuying || {}, + () => nextRandom(run)); + player.storage.mengsanMawlerIntent_shuying = move; + game.mengsanSetEnemyIntent_shuying(player, move); + } else if (isFogmogActor(player)) { + if (player.storage.mengsanFogmogIntent_shuying || !getActiveBattle()?.fogmog?.canAct(player)) return; + const move = isFogmog(player) ? selectFogmogMove( + player.storage.mengsanFogmogState_shuying || {}, () => nextRandom(run)) : EYE_INTENT; + player.storage.mengsanFogmogIntent_shuying = move; + game.mengsanSetEnemyIntent_shuying(player, move); } else if (isRaider(player)) { if (player.storage.mengsanRaiderIntent_shuying) return; const move = selectRaiderMove(player.name, @@ -74,7 +185,341 @@ export function createMonsterIntentActions(game, getActiveBattle) { await executeEffects(player, move); }; + const executeInkletIntent = async player => { + const current = getActiveBattle(); + if (!isInklet(player) || !player.isAlive() || isStunned(player) || !current?.session.active) return; + const move = player.storage.mengsanInkletIntent_shuying; + if (!move) return; + player.storage.mengsanInkletIntent_shuying = null; + game.mengsanSetEnemyIntent_shuying(player, null); + player.storage.mengsanInkletState_shuying = recordInkletAction(player.storage.mengsanInkletState_shuying || {}); + if (player.storage.mengsanEnergy_shuying < 1) { + game.log(player, "费用不足,未发动", move.name); return; + } + player.storage.mengsanEnergy_shuying--; + current.energyUI.get(player)?.(); + game.log(player, "消耗1费用发动", move.name); + await executeEffects(player, move); + }; + + const executeCrawlerIntent = async player => { + const current = getActiveBattle(); + if (!isCrawler(player) || !player.isAlive() || isStunned(player) || !current?.session.active) return; + const move = player.storage.mengsanCrawlerIntent_shuying; + if (!move) return; + player.storage.mengsanCrawlerIntent_shuying = null; + game.mengsanSetEnemyIntent_shuying(player, null); + player.storage.mengsanCrawlerState_shuying = + recordCrawlerAction(player.storage.mengsanCrawlerState_shuying || {}); + if (player.storage.mengsanEnergy_shuying < 1) { + game.log(player, "费用不足,未发动", move.name); return; + } + player.storage.mengsanEnergy_shuying--; + current.energyUI.get(player)?.(); + game.log(player, "消耗1费用发动", move.name); + await executeEffects(player, move); + }; + + const executeJaxfruitIntent = async player => { + const current = getActiveBattle(); + if (!isJaxfruit(player) || !player.isAlive() || isStunned(player) || !current?.session.active) return; + const move = player.storage.mengsanJaxfruitIntent_shuying; + if (!move) return; + player.storage.mengsanJaxfruitIntent_shuying = null; + game.mengsanSetEnemyIntent_shuying(player, null); + player.storage.mengsanJaxfruitState_shuying = + recordJaxfruitAction(player.storage.mengsanJaxfruitState_shuying || {}); + if (player.storage.mengsanEnergy_shuying < 1) { + game.log(player, "费用不足,未发动", move.name); return; + } + player.storage.mengsanEnergy_shuying--; + current.energyUI.get(player)?.(); + game.log(player, "消耗1费用发动", move.name); + await executeEffects(player, move); + }; + + const executeStranglerIntent = async player => { + const current = getActiveBattle(); + if (!isStrangler(player) || !player.isAlive() || isStunned(player) || !current?.session.active) return; + const move = player.storage.mengsanStranglerIntent_shuying; + if (!move) return; + player.storage.mengsanStranglerIntent_shuying = null; + game.mengsanSetEnemyIntent_shuying(player, null); + player.storage.mengsanStranglerState_shuying = + recordStranglerAction(player.storage.mengsanStranglerState_shuying || {}); + if (player.storage.mengsanEnergy_shuying < 1) { + game.log(player, "费用不足,未发动", move.name); return; + } + player.storage.mengsanEnergy_shuying--; + current.energyUI.get(player)?.(); + game.log(player, "消耗1费用发动", move.name); + if (move.constrict) { + for (const target of getIntentHostiles(game, player)) addConstrict(current, player, target, move.constrict); + } else await executeEffects(player, move); + }; + + const executeLeafslimeIntent = async player => { + const current = getActiveBattle(); + if (!isLeafslime(player) || !player.isAlive() || isStunned(player) || !current?.session.active) return; + const move = player.storage.mengsanLeafslimeIntent_shuying; + if (!move) return; + player.storage.mengsanLeafslimeIntent_shuying = null; + game.mengsanSetEnemyIntent_shuying(player, null); + player.storage.mengsanLeafslimeState_shuying = + recordLeafslimeAction(player.storage.mengsanLeafslimeState_shuying || {}); + if (player.storage.mengsanEnergy_shuying < 1) { + game.log(player, "费用不足,未发动", move.name); return; + } + player.storage.mengsanEnergy_shuying--; + current.energyUI.get(player)?.(); + game.log(player, "消耗1费用发动", move.name); + if (move.slimed) applyLeafslimeGoop(game, current, player); + else await executeEffects(player, move); + }; + + const executeLeafslimeMediumIntent = async player => { + const current = getActiveBattle(); + if (!isLeafslimeMedium(player) || !player.isAlive() || isStunned(player) || !current?.session.active) return; + const move = player.storage.mengsanLeafslimeMediumIntent_shuying; + if (!move) return; + player.storage.mengsanLeafslimeMediumIntent_shuying = null; + game.mengsanSetEnemyIntent_shuying(player, null); + player.storage.mengsanLeafslimeMediumState_shuying = + recordLeafslimeMediumAction(player.storage.mengsanLeafslimeMediumState_shuying || {}); + if (player.storage.mengsanEnergy_shuying < 1) { + game.log(player, "费用不足,未发动", move.name); return; + } + player.storage.mengsanEnergy_shuying--; + current.energyUI.get(player)?.(); + game.log(player, "消耗1费用发动", move.name); + if (move.slimed) applyLeafslimeGoop(game, current, player, move.slimed); + else await executeEffects(player, move); + }; + + const executeTwigslimeIntent = async player => { + const current = getActiveBattle(); + if (!isTwigslime(player) || !player.isAlive() || isStunned(player) || !current?.session.active) return; + const move = player.storage.mengsanTwigslimeIntent_shuying; + if (!move) return; + player.storage.mengsanTwigslimeIntent_shuying = null; + game.mengsanSetEnemyIntent_shuying(player, null); + player.storage.mengsanTwigslimeState_shuying = + recordTwigslimeAction(player.storage.mengsanTwigslimeState_shuying || {}); + if (player.storage.mengsanEnergy_shuying < 1) { + game.log(player, "费用不足,未发动", move.name); return; + } + player.storage.mengsanEnergy_shuying--; + current.energyUI.get(player)?.(); + game.log(player, "消耗1费用发动", move.name); + await executeEffects(player, move); + }; + + const executeTwigmediumIntent = async player => { + const current = getActiveBattle(); + if (!isTwigmedium(player) || !player.isAlive() || isStunned(player) || !current?.session.active) return; + const move = player.storage.mengsanTwigmediumIntent_shuying; + if (!move) return; + player.storage.mengsanTwigmediumIntent_shuying = null; + game.mengsanSetEnemyIntent_shuying(player, null); + player.storage.mengsanTwigmediumState_shuying = + recordTwigmediumAction(player.storage.mengsanTwigmediumState_shuying || {}, move); + if (player.storage.mengsanEnergy_shuying < 1) { + game.log(player, "费用不足,未发动", move.name); return; + } + player.storage.mengsanEnergy_shuying--; + current.energyUI.get(player)?.(); + game.log(player, "消耗1费用发动", move.name); + if (move.slimed) applyLeafslimeGoop(game, current, player, move.slimed); + else await executeEffects(player, move); + }; + + const executeShrinkerIntent = async player => { + const current = getActiveBattle(); + if (!isShrinker(player) || !player.isAlive() || isStunned(player) || !current?.session.active) return; + const move = player.storage.mengsanShrinkerIntent_shuying; + if (!move) return; + player.storage.mengsanShrinkerIntent_shuying = null; + game.mengsanSetEnemyIntent_shuying(player, null); + player.storage.mengsanShrinkerState_shuying = + recordShrinkerAction(player.storage.mengsanShrinkerState_shuying || {}); + if (player.storage.mengsanEnergy_shuying < 1) { + game.log(player, "费用不足,未发动", move.name); return; + } + player.storage.mengsanEnergy_shuying--; + current.energyUI.get(player)?.(); + game.log(player, "消耗1费用发动", move.name); + if (move.shrink) { + for (const target of getIntentHostiles(game, player)) applyShrink(current, player, target); + } else await executeEffects(player, move); + }; + + const executeNibbitIntent = async player => { + const current = getActiveBattle(); + if (!isNibbit(player) || !player.isAlive() || isStunned(player) || !current?.session.active) return; + const move = player.storage.mengsanNibbitIntent_shuying; + if (!move) return; + player.storage.mengsanNibbitIntent_shuying = null; + game.mengsanSetEnemyIntent_shuying(player, null); + player.storage.mengsanNibbitState_shuying = + recordNibbitAction(player.storage.mengsanNibbitState_shuying || {}); + if (player.storage.mengsanEnergy_shuying < 1) { + game.log(player, "费用不足,未发动", move.name); return; + } + player.storage.mengsanEnergy_shuying--; + current.energyUI.get(player)?.(); + game.log(player, "消耗1费用发动", move.name); + await executeEffects(player, move); + }; + + const executeEffigyIntent = async (player, phase) => { + const current = getActiveBattle(); + if (!isEffigy(player) || !player.isAlive() || isStunned(player) || !current?.session.active) return; + const move = player.storage.mengsanEffigyIntent_shuying; + if (!move) return; + player.storage.mengsanEffigyIntent_shuying = null; + game.mengsanSetEnemyIntent_shuying(player, null); + player.storage.mengsanEffigyState_shuying = + recordEffigyAction(player.storage.mengsanEffigyState_shuying || {}); + if (move.sleep) { + phase?.cancel(); + game.log(player, "沉睡,本回合不行动"); return; + } + if (player.storage.mengsanEnergy_shuying < 1) { + game.log(player, "费用不足,未发动", move.name); return; + } + player.storage.mengsanEnergy_shuying--; + current.energyUI.get(player)?.(); + game.log(player, "消耗1费用发动", move.name); + await executeEffects(player, move); + }; + + const executePhrogIntent = async player => { + const current = getActiveBattle(); + if (!isPhrogActor(player) || !player.isAlive() || isStunned(player) || !current?.session.active) return; + const move = player.storage.mengsanPhrogIntent_shuying; + if (!move || move.id === "spawned") return; + player.storage.mengsanPhrogIntent_shuying = null; + game.mengsanSetEnemyIntent_shuying(player, null); + player.storage.mengsanPhrogState_shuying = + recordPhrogAction(player.storage.mengsanPhrogState_shuying || {}); + if (player.storage.mengsanEnergy_shuying < 1) { + game.log(player, "费用不足,未发动", move.name); return; + } + player.storage.mengsanEnergy_shuying--; + current.energyUI.get(player)?.(); + game.log(player, "消耗1费用发动", move.name); + if (move.infection) applyInfection(game, current, player, move.infection); + await executeEffects(player, move); + }; + + const executeByrdonisIntent = async player => { + const current = getActiveBattle(); + if (!isByrdonis(player) || !player.isAlive() || isStunned(player) || !current?.session.active) return; + const move = player.storage.mengsanByrdonisIntent_shuying; + if (!move) return; + player.storage.mengsanByrdonisIntent_shuying = null; + game.mengsanSetEnemyIntent_shuying(player, null); + player.storage.mengsanByrdonisState_shuying = + recordByrdonisAction(player.storage.mengsanByrdonisState_shuying || {}); + if (player.storage.mengsanEnergy_shuying < 1) { + game.log(player, "费用不足,未发动", move.name); return; + } + player.storage.mengsanEnergy_shuying--; + current.energyUI.get(player)?.(); + game.log(player, "消耗1费用发动", move.name); + await executeEffects(player, move); + }; + + const executeCubexIntent = async player => { + const current = getActiveBattle(); + if (!isCubex(player) || !player.isAlive() || isStunned(player) || !current?.session.active) return; + const move = player.storage.mengsanCubexIntent_shuying; + if (!move) return; + player.storage.mengsanCubexIntent_shuying = null; + game.mengsanSetEnemyIntent_shuying(player, null); + player.storage.mengsanCubexState_shuying = + recordCubexAction(player.storage.mengsanCubexState_shuying || {}); + if (player.storage.mengsanEnergy_shuying < 1) { + game.log(player, "费用不足,未发动", move.name); return; + } + player.storage.mengsanEnergy_shuying--; + current.energyUI.get(player)?.(); + game.log(player, "消耗1费用发动", move.name); + await executeEffects(player, move); + }; + + const executeVineIntent = async player => { + const current = getActiveBattle(); + if (!isVine(player) || !player.isAlive() || isStunned(player) || !current?.session.active) return; + const move = player.storage.mengsanVineIntent_shuying; + if (!move) return; + player.storage.mengsanVineIntent_shuying = null; + game.mengsanSetEnemyIntent_shuying(player, null); + player.storage.mengsanVineState_shuying = + recordVineAction(player.storage.mengsanVineState_shuying || {}); + if (player.storage.mengsanEnergy_shuying < 1) { + game.log(player, "费用不足,未发动", move.name); return; + } + player.storage.mengsanEnergy_shuying--; + current.energyUI.get(player)?.(); + game.log(player, "消耗1费用发动", move.name); + await executeEffects(player, move); + }; + + const executeMawlerIntent = async player => { + const current = getActiveBattle(); + if (!isMawler(player) || !player.isAlive() || isStunned(player) || !current?.session.active) return; + const move = player.storage.mengsanMawlerIntent_shuying; + if (!move) return; + player.storage.mengsanMawlerIntent_shuying = null; + game.mengsanSetEnemyIntent_shuying(player, null); + const paid = player.storage.mengsanEnergy_shuying >= 1; + player.storage.mengsanMawlerState_shuying = + recordMawlerAction(player.storage.mengsanMawlerState_shuying || {}, move, paid); + if (!paid) { game.log(player, "费用不足,未发动", move.name); return; } + player.storage.mengsanEnergy_shuying--; + current.energyUI.get(player)?.(); + game.log(player, "消耗1费用发动", move.name); + await executeEffects(player, move); + }; + + const executeFogmogIntent = async player => { + const current = getActiveBattle(); + if (!isFogmogActor(player) || isStunned(player) || !current?.fogmog?.canAct(player)) return; + const move = player.storage.mengsanFogmogIntent_shuying; + if (!move) return; + player.storage.mengsanFogmogIntent_shuying = null; + game.mengsanSetEnemyIntent_shuying(player, null); + const paid = player.storage.mengsanEnergy_shuying >= 1; + if (isFogmog(player)) player.storage.mengsanFogmogState_shuying = + recordFogmogAction(player.storage.mengsanFogmogState_shuying || {}, move, paid); + if (!paid) { game.log(player, "费用不足,未发动", move.name); return; } + player.storage.mengsanEnergy_shuying--; + current.energyUI.get(player)?.(); + game.log(player, "消耗1费用发动", move.name); + if (move.summon) await current.fogmog.summon(player); + else if (move.dazed) current.fogmog.distract(player); + else await executeEffects(player, move); + }; + return { + executeFogmogIntent, + executeMawlerIntent, + executeVineIntent, + executeCubexIntent, + executeByrdonisIntent, + executeEffigyIntent, + executePhrogIntent, + executeNibbitIntent, + executeShrinkerIntent, + executeTwigmediumIntent, + executeTwigslimeIntent, + executeLeafslimeMediumIntent, + executeLeafslimeIntent, + executeStranglerIntent, + executeJaxfruitIntent, + executeCrawlerIntent, + executeInkletIntent, planEnemyIntent, executeFlyconidIntent, executeRaiderIntent, diff --git a/mengsan/monsters/artifact-status.js b/mengsan/monsters/artifact-status.js new file mode 100644 index 0000000..a88d34b --- /dev/null +++ b/mengsan/monsters/artifact-status.js @@ -0,0 +1,39 @@ +const KEY = "mengsanArtifact_shuying", SKILL = "mengsan_artifact_shuying"; +const bindings = new WeakMap(); +const DEBUFFS = Object.freeze({ + vulnerable: ["mengsanVulnerable_shuying", "mengsan_vulnerable_shuying"], + weak: ["mengsanWeak_shuying", "mengsan_weak_shuying"], + frail: ["mengsanFrail_shuying", "mengsan_frail_shuying"], +}); +export const clearArtifact = player => { delete player.storage[KEY]; player.removeSkill(SKILL); }; +export function setArtifact(player, layers, current = null) { + if (!Number.isSafeInteger(layers) || layers < 0) throw new RangeError("人工制品层数无效"); + if (!layers) { clearArtifact(player); return; } + if (current?.session.active && bindings.get(player)?.battle !== current) { + const entry = { battle: current }; + current.session.ownResource(entry, () => { + if (bindings.get(player) !== entry) return; + bindings.delete(player); clearArtifact(player); + }); + bindings.set(player, entry); + } + player.storage[KEY] = layers; player.addSkill(SKILL); player.markSkill(SKILL); +} +export function consumeArtifact(player) { + if (!player?.isAlive() || !player.storage) return false; + const layers = player.storage[KEY] ?? 0; + if (!Number.isSafeInteger(layers) || layers < 0) throw new RangeError("人工制品层数无效"); + if (!layers) return false; + if (layers === 1) clearArtifact(player); + else { player.storage[KEY] = layers - 1; player.markSkill(SKILL); } + return true; +} +// 一次施加抵消一次,不按负面层数逐层消耗;伤害和正面增益不走此入口。 +export function applyMengsanDebuff(player, kind, amount) { + if (!Object.hasOwn(DEBUFFS, kind) || !Number.isSafeInteger(amount) || amount < 0) throw new RangeError("负面状态参数无效"); + if (!amount || !player?.isAlive()) return false; + const [key, skill] = DEBUFFS[kind], old = player.storage[key] ?? 0; + if (!Number.isSafeInteger(old) || old < 0 || amount > Number.MAX_SAFE_INTEGER - old) throw new RangeError("负面状态层数越界"); + if (consumeArtifact(player)) return false; + player.storage[key] = old + amount; player.addSkill(skill); player.markSkill(skill); return true; +} diff --git a/mengsan/monsters/byrdonis-intent.js b/mengsan/monsters/byrdonis-intent.js new file mode 100644 index 0000000..3e3a401 --- /dev/null +++ b/mengsan/monsters/byrdonis-intent.js @@ -0,0 +1,57 @@ +// 普通数值;网页SVG:开始→俯冲→啄击→俯冲。领地意识在回合结束结算。 +export const BYRDONIS_CHARACTER = "mengsan_byrdonis_shuying"; +export const BYRDONIS_MOVES = Object.freeze({ + swoop: Object.freeze({ id: "swoop", name: "俯冲", damage: 17 }), + peck: Object.freeze({ id: "peck", name: "啄击", damage: 3, hits: 3 }), +}); +const turnsOf = state => { + const turns = state.turnsTaken ?? 0; + if (!Number.isSafeInteger(turns) || turns < 0) throw new RangeError("多尼斯异鸟行动记录无效"); + return turns; +}; +export const selectByrdonisMove = (state = {}) => turnsOf(state) % 2 ? BYRDONIS_MOVES.peck : BYRDONIS_MOVES.swoop; +export function recordByrdonisAction(state = {}, advance = true) { + const turns = turnsOf(state); + if (typeof advance !== "boolean" || advance && turns === Number.MAX_SAFE_INTEGER) throw new RangeError("多尼斯异鸟行动推进无效"); + return { turnsTaken: turns + (advance ? 1 : 0) }; +} +export function rollByrdonisHp(random) { + const value = random(); + if (!Number.isFinite(value) || value < 0 || value >= 1) throw new RangeError("多尼斯异鸟随机数必须在[0,1)内"); + return 81 + Math.floor(value * 4); +} +export const isByrdonis = player => player?.name === BYRDONIS_CHARACTER && player.storage?.mengsanCamp_shuying === "enemy"; +const bindings = new WeakMap(); +export function initializeByrdonis(player, current = null) { + if (!isByrdonis(player)) return false; + if (current?.session.active && bindings.get(player)?.battle !== current) { + const entry = { battle: current }; + current.session.ownResource(entry, () => { + if (bindings.get(player) !== entry) return; + bindings.delete(player); + delete player.storage.mengsanByrdonisState_shuying; + delete player.storage.mengsanByrdonisIntent_shuying; + player.removeSkill("mengsan_territorial_shuying"); + player.removeSkill("mengsan_raider_strength_shuying"); + }); + bindings.set(player, entry); + } + player.storage.mengsanByrdonisState_shuying = { turnsTaken: 0 }; + player.storage.mengsanByrdonisIntent_shuying = null; + player.storage.mengsanTerritorial_shuying = 1; + player.addSkill("mengsan_territorial_shuying"); + player.markSkill("mengsan_territorial_shuying"); + return true; +} +export function resolveTerritorial(player, current) { + if (!isByrdonis(player) || !player.isAlive() || !current?.session.active) return false; + const layers = player.storage.mengsanTerritorial_shuying ?? 0; + const strength = player.storage.mengsanStrength_shuying ?? 0; + if (!Number.isSafeInteger(layers) || layers < 0 || !Number.isSafeInteger(strength) || + !Number.isSafeInteger(strength + layers)) throw new RangeError("领地意识或力量数值无效"); + if (!layers) return false; + player.storage.mengsanStrength_shuying = strength + layers; + player.addSkill("mengsan_raider_strength_shuying"); + player.markSkill("mengsan_raider_strength_shuying"); + return true; +} diff --git a/mengsan/monsters/crawler-intent.js b/mengsan/monsters/crawler-intent.js new file mode 100644 index 0000000..9f6b806 --- /dev/null +++ b/mengsan/monsters/crawler-intent.js @@ -0,0 +1,32 @@ +// 毛绒伏地虫:酸液 -> 吸入 -> 酸液 -> 回到开头,不是两招交替。 +export const CRAWLER_CHARACTER = "mengsan_fuzzy_wurm_crawler_shuying"; +export const CRAWLER_MOVES = Object.freeze({ + acid: Object.freeze({ id: "acid", name: "酸液黏球", damage: 4 }), + inhale: Object.freeze({ id: "inhale", name: "吸入", strength: 7 }), +}); +export const CRAWLER_CYCLE = Object.freeze([ + CRAWLER_MOVES.acid, CRAWLER_MOVES.inhale, CRAWLER_MOVES.acid, +]); +const cycleIndex = state => { + const index = state.cycleIndex ?? 0; + if (!Number.isInteger(index) || index < 0 || index >= CRAWLER_CYCLE.length) { + throw new RangeError("毛绒伏地虫循环位置无效"); + } + return index; +}; +export const selectCrawlerMove = (state = {}) => CRAWLER_CYCLE[cycleIndex(state)]; +export function recordCrawlerAction(state = {}, advance = true) { + const index = cycleIndex(state), turn = state.turnsTaken ?? 0; + if (!Number.isSafeInteger(turn) || turn < 0 || turn === Number.MAX_SAFE_INTEGER || typeof advance !== "boolean") { + throw new RangeError("毛绒伏地虫行动记录无效"); + } + return { turnsTaken: turn + 1, + cycleIndex: (index + (advance ? 1 : 0)) % CRAWLER_CYCLE.length }; +} +export function rollCrawlerHp(random) { + const roll = random(); + if (!Number.isFinite(roll) || roll < 0 || roll >= 1) throw new RangeError("怪物随机数必须在 [0,1) 内"); + return 55 + Math.floor(roll * 3); +} +export const isCrawler = player => player?.name === CRAWLER_CHARACTER && + player.storage?.mengsanCamp_shuying === "enemy"; diff --git a/mengsan/monsters/cubex-intent.js b/mengsan/monsters/cubex-intent.js new file mode 100644 index 0000000..a8a30c0 --- /dev/null +++ b/mengsan/monsters/cubex-intent.js @@ -0,0 +1,43 @@ +import { setArtifact } from "./artifact-status.js"; +// SVG明确:蓄能只在开场一次,排出回到重复轰击,不回到蓄能。 +export const CUBEX_CHARACTER = "mengsan_cubex_construct_shuying"; +export const CUBEX_MOVES = Object.freeze({ + charge: Object.freeze({ id: "charge", name: "蓄能", strength: 2 }), + repeater: Object.freeze({ id: "repeater", name: "重复轰击", damage: 7, strength: 2 }), + expel: Object.freeze({ id: "expel", name: "排出", damage: 5, hits: 2 }), +}); +const turnsOf = state => { + const turns = state.turnsTaken ?? 0; + if (!Number.isSafeInteger(turns) || turns < 0) throw new RangeError("立柱构造体行动记录无效"); + return turns; +}; +const CYCLE = Object.freeze([CUBEX_MOVES.repeater, CUBEX_MOVES.repeater, CUBEX_MOVES.expel]); +export const selectCubexMove = (state = {}) => { + const turns = turnsOf(state); + return turns === 0 ? CUBEX_MOVES.charge : CYCLE[(turns - 1) % CYCLE.length]; +}; +export function recordCubexAction(state = {}, advance = true) { + const turns = turnsOf(state); + if (typeof advance !== "boolean" || advance && turns === Number.MAX_SAFE_INTEGER) throw new RangeError("立柱构造体行动推进无效"); + return { turnsTaken: turns + (advance ? 1 : 0) }; +} +export const isCubex = player => player?.name === CUBEX_CHARACTER && player.storage?.mengsanCamp_shuying === "enemy"; +const bindings = new WeakMap(); +export function initializeCubex(player, current = null) { + if (!isCubex(player)) return false; + if (current?.session.active && bindings.get(player)?.battle !== current) { + const entry = { battle: current }; + current.session.ownResource(entry, () => { + if (bindings.get(player) !== entry) return; + bindings.delete(player); + delete player.storage.mengsanCubexState_shuying; + delete player.storage.mengsanCubexIntent_shuying; + }); + bindings.set(player, entry); + } + player.hujia = 13; + player.storage.mengsanCubexState_shuying = { turnsTaken: 0 }; + player.storage.mengsanCubexIntent_shuying = null; + setArtifact(player, 1, current); + return true; +} diff --git a/mengsan/monsters/effigy-intent.js b/mengsan/monsters/effigy-intent.js new file mode 100644 index 0000000..23eda53 --- /dev/null +++ b/mengsan/monsters/effigy-intent.js @@ -0,0 +1,29 @@ +import { initializeSlow } from "./effigy-slow.js"; +export const EFFIGY_CHARACTER = "mengsan_bygone_effigy_shuying"; +export const EFFIGY_MOVES = Object.freeze({ + sleep: Object.freeze({ id: "sleep", name: "沉睡", sleep: true }), + wake: Object.freeze({ id: "wake", name: "苏醒", strength: 10 }), + slashes: Object.freeze({ id: "slashes", name: "斩击", damage: 13 }), +}); +const turnsOf = state => { + const turns = state.turnsTaken ?? 0; + if (!Number.isSafeInteger(turns) || turns < 0) throw new RangeError("旧日雕像行动记录无效"); + return turns; +}; +export const selectEffigyMove = (state = {}) => { + const turns = turnsOf(state); + return turns === 0 ? EFFIGY_MOVES.sleep : turns === 1 ? EFFIGY_MOVES.wake : EFFIGY_MOVES.slashes; +}; +export function recordEffigyAction(state = {}, advance = true) { + const turns = turnsOf(state); + if (typeof advance !== "boolean" || advance && turns === Number.MAX_SAFE_INTEGER) throw new RangeError("旧日雕像行动推进无效"); + return { turnsTaken: turns + (advance ? 1 : 0) }; +} +export const isEffigy = player => player?.name === EFFIGY_CHARACTER && player.storage?.mengsanCamp_shuying === "enemy"; +export function initializeEffigy(player, current = null) { + if (!isEffigy(player)) return false; + initializeSlow(player, current); + player.storage.mengsanEffigyState_shuying = { turnsTaken: 0 }; + player.storage.mengsanEffigyIntent_shuying = null; + return true; +} diff --git a/mengsan/monsters/effigy-slow.js b/mengsan/monsters/effigy-slow.js new file mode 100644 index 0000000..0692088 --- /dev/null +++ b/mengsan/monsters/effigy-slow.js @@ -0,0 +1,62 @@ +import { isAttackCard } from "./vine-tangled.js"; +const bindings = new WeakMap(); +const layersOf = player => { + const value = player?.storage?.mengsanSlow_shuying ?? 0; + if (!Number.isSafeInteger(value) || value < 0) throw new RangeError("缓慢层数无效"); + return value; +}; +export function slowPercent(player) { + const count = player?.storage?.mengsanSlowCards_shuying ?? 0; + const percent = count * layersOf(player) * 10; + if (!Number.isSafeInteger(count) || count < 0 || !Number.isSafeInteger(percent)) throw new RangeError("缓慢计数溢出"); + return percent; +} +export function initializeSlow(player, current) { + if (current?.session.active && bindings.get(player)?.battle !== current) { + const entry = { battle: current, seen: new WeakSet() }; + current.session.ownResource(entry, () => { + if (bindings.get(player) !== entry) return; + bindings.delete(player); + delete player.storage.mengsanEffigyState_shuying; + delete player.storage.mengsanEffigyIntent_shuying; + player.removeSkill("mengsan_slow_shuying"); + player.removeSkill("mengsan_raider_strength_shuying"); + }); + bindings.set(player, entry); + } + player.storage.mengsanSlow_shuying = 1; + player.storage.mengsanSlowCards_shuying = 0; + player.addSkill("mengsan_slow_shuying"); + player.markSkill("mengsan_slow_shuying"); +} +export function canCountSlowCard(player, current, event, phasePlayer) { + const binding = bindings.get(player); + return Boolean(current?.session.active && binding?.battle === current && player.isAlive() && + layersOf(player) > 0 && event?.name === "useCard" && event.card && + !event.cancelled && !event._cancelled && event.player?.storage?.mengsanCamp_shuying === "ally" && + phasePlayer?.storage?.mengsanCamp_shuying === "ally" && current.players?.has(event.player) && + !binding.seen.has(event)); +} +// AfterCardPlayed: the attack being resolved does not count itself. A virtual card is one play. +export function recordSlowCard(player, current, event, phasePlayer) { + if (!canCountSlowCard(player, current, event, phasePlayer)) return false; + const count = (player.storage.mengsanSlowCards_shuying ?? 0) + 1; + if (!Number.isSafeInteger(count) || !Number.isSafeInteger(count * layersOf(player) * 10)) throw new RangeError("缓慢计数溢出"); + bindings.get(player).seen.add(event); + player.storage.mengsanSlowCards_shuying = count; + player.markSkill("mengsan_slow_shuying"); + return true; +} +export function resetSlow(player) { + player.storage.mengsanSlowCards_shuying = 0; + player.markSkill("mengsan_slow_shuying"); +} +export const slowApplies = (player, event) => Boolean(layersOf(player) > 0 && slowPercent(player) > 0 && + (event?.mengsanAttack_shuying || isAttackCard(event?.card)) && !event.mengsanSlowApplied_shuying); +// 整数伤害适配:力量/虚弱/易伤之后乘(1+10%×已使用牌数),向下取整,护甲之后由引擎处理。 +export function slowAttackDamage(damage, target) { + if (!Number.isSafeInteger(damage) || damage < 0) throw new RangeError("缓慢攻击伤害无效"); + const value = damage * (100 + slowPercent(target)); + if (!Number.isSafeInteger(value)) throw new RangeError("缓慢攻击伤害溢出"); + return Math.floor(value / 100); +} diff --git a/mengsan/monsters/flyconid-intent.js b/mengsan/monsters/flyconid-intent.js index 93e0284..4f15f84 100644 --- a/mengsan/monsters/flyconid-intent.js +++ b/mengsan/monsters/flyconid-intent.js @@ -8,10 +8,11 @@ export const FLYCONID_MOVES = Object.freeze({ export function eligibleFlyconidMoves(state = {}) { const upcoming = (state.turnsTaken || 0) + 1; const lastUsed = state.lastUsed || {}; + // 行动间隔达到冷却值即恢复;额外等待一轮会使三招同时不可用。 const moves = []; if (state.lastMove !== "smash") moves.push(FLYCONID_MOVES.smash); - if (lastUsed.frail == null || upcoming - lastUsed.frail > 2) moves.push(FLYCONID_MOVES.frail); - if (upcoming >= 2 && (lastUsed.vulnerable == null || upcoming - lastUsed.vulnerable > 3)) moves.push(FLYCONID_MOVES.vulnerable); + if (lastUsed.frail == null || upcoming - lastUsed.frail >= 2) moves.push(FLYCONID_MOVES.frail); + if (upcoming >= 2 && (lastUsed.vulnerable == null || upcoming - lastUsed.vulnerable >= 3)) moves.push(FLYCONID_MOVES.vulnerable); return moves; } diff --git a/mengsan/monsters/fogmog-battle.js b/mengsan/monsters/fogmog-battle.js new file mode 100644 index 0000000..992f3e8 --- /dev/null +++ b/mengsan/monsters/fogmog-battle.js @@ -0,0 +1,75 @@ +import { EYE_CHARACTER, isFogmog, isToothedEye } from "./fogmog-intent.js"; +import { createDazedData } from "../cards/status-cards.js"; +import { getIntentHostiles } from "../battle/intent-effects.js"; + +// 生命周期属于单场战斗;复活复用原席位,不追加玩家或定时器。 +export function createFogmogBattle(game, current, spawn) { + const owners = new Map(), eyes = new Map(), pending = new Map(); + let round = 0, sequence = 0, cardSequence = 0; + const active = () => current.session.active; + const canAct = player => active() && player.isAlive() && + (isFogmog(player) || isToothedEye(player) && eyes.get(player)?.isAlive()); + current.session.ownResource(owners, () => { owners.clear(); eyes.clear(); pending.clear(); }); + return { + canAct, + register(player, spec) { + if (!isToothedEye(player)) return; + const owner = game.players.find(p => p.playerid === spec.fogmogOwner && isFogmog(p)); + if (!owner || owners.has(owner)) throw new Error("利齿之眼缺少唯一雾菇召唤者"); + owners.set(owner, player); eyes.set(player, owner); + player.storage.mengsanFogmogOwner_shuying = owner.playerid; + player.addSkill("mengsan_fogmog_illusion_shuying"); + player.markSkill("mengsan_fogmog_illusion_shuying"); + }, + async summon(owner) { + if (!canAct(owner) || !isFogmog(owner) || owners.has(owner)) return; + if (game.players.length + game.dead.length >= 8) throw new Error("雾菇召唤需要一个空席位"); + await spawn({ id: `fogmog_eye_${++sequence}`, character: EYE_CHARACTER, + camp: "enemy", tier: "normal", hp: 6, maxHp: 6, hand: 0, + inheritSkills: false, fogmogOwner: owner.playerid }, owner); + }, + distract(source) { + if (!canAct(source)) return; + for (const target of getIntentHostiles(game, source)) { + const pile = target === game.me ? current.personalPiles : current.monsterPiles.get(target); + if (!pile) continue; // 无个人牌堆的剧情援军不向公共牌堆塞入状态牌。 + for (let index = 0; index < 3 && canAct(source); index++) { + pile.addToDiscard(createDazedData(`dazed_${source.playerid}_${++cardSequence}`)); + } + game.log(target, "的个人弃牌堆加入3张【晕眩】"); + } + }, + onDeath(player) { + if (eyes.has(player)) { + if (eyes.get(player).isAlive()) pending.set(player, round + 1); + player.storage.mengsanFogmogIntent_shuying = null; + game.mengsanSetEnemyIntent_shuying(player, null); + } + if (owners.has(player)) { + const eye = owners.get(player); + pending.delete(eye); + eye.storage.mengsanFogmogIntent_shuying = null; + game.mengsanSetEnemyIntent_shuying(eye, null); + } + }, + beforeRound() { + if (!active()) return; + round++; + for (const [eye, due] of pending) { + const owner = eyes.get(eye); + if (!owner?.isAlive()) { pending.delete(eye); continue; } + if (round < due) continue; + pending.delete(eye); + if (!eye.isAlive()) { + eye.revive(6, false); + eye.hujia = 0; + eye.storage.mengsanEnergy_shuying = eye.storage.mengsanMaxEnergy_shuying; + eye.addSkill("mengsan_fogmog_illusion_shuying"); + eye.markSkill("mengsan_fogmog_illusion_shuying"); + eye.update(); + game.log(eye, "以6点生命复活"); + } + } + }, + }; +} diff --git a/mengsan/monsters/fogmog-intent.js b/mengsan/monsters/fogmog-intent.js new file mode 100644 index 0000000..e25d9f4 --- /dev/null +++ b/mengsan/monsters/fogmog-intent.js @@ -0,0 +1,35 @@ +// 灰机 Wiki 雾菇页的普通难度:74生命,重击8、头槌14。 +export const FOGMOG_CHARACTER = "mengsan_fogmog_shuying"; +export const EYE_CHARACTER = "mengsan_eye_with_teeth_shuying"; +export const FOGMOG_MOVES = Object.freeze({ + spores: Object.freeze({ id: "spores", name: "虚幻孢子", summon: EYE_CHARACTER }), + thwack: Object.freeze({ id: "thwack", name: "重击", damage: 8, strength: 1 }), + headbutt: Object.freeze({ id: "headbutt", name: "头槌", damage: 14 }), +}); +export const EYE_INTENT = Object.freeze({ id: "distract", name: "牵制", dazed: 3 }); +export function selectFogmogMove(state = {}, random) { + const turn = state.turnsTaken || 0; + if (!Number.isSafeInteger(turn) || turn < 0) throw new RangeError("雾菇行动次数无效"); + if (!turn) return FOGMOG_MOVES.spores; + if (state.lastMove !== "thwack") return FOGMOG_MOVES.thwack; + if ((state.thwacks || 0) >= 2) return FOGMOG_MOVES.headbutt; + const roll = random(); + if (!Number.isFinite(roll) || roll < 0 || roll >= 1) throw new RangeError("怪物随机数必须在 [0,1) 内"); + return roll < 0.4 ? FOGMOG_MOVES.thwack : FOGMOG_MOVES.headbutt; +} +export function recordFogmogAction(state = {}, move, paid) { + return { + turnsTaken: (state.turnsTaken || 0) + 1, + lastMove: paid && move ? move.id : state.lastMove || null, + thwacks: paid && move ? (move.id === "thwack" ? + (state.lastMove === "thwack" ? (state.thwacks || 0) + 1 : 1) : 0) : state.thwacks || 0, + }; +} +export const isFogmog = player => player?.name === FOGMOG_CHARACTER && + player.storage?.mengsanCamp_shuying === "enemy"; +export const isToothedEye = player => player?.name === EYE_CHARACTER && + player.storage?.mengsanCamp_shuying === "enemy"; +export const isFogmogActor = player => isFogmog(player) || isToothedEye(player); +// 爪牙不单独构成胜利目标;雾菇死亡后无需继续杀死或等待其幻象。 +export const isEncounterEnemy = player => player?.isAlive() && + player.storage?.mengsanCamp_shuying === "enemy" && !isToothedEye(player); diff --git a/mengsan/monsters/inklet-intent.js b/mengsan/monsters/inklet-intent.js new file mode 100644 index 0000000..75ce85b --- /dev/null +++ b/mengsan/monsters/inklet-intent.js @@ -0,0 +1,52 @@ +// 灰机 Wiki 墨宝:奇数位刺击起手;偶数位旋风起手后刺击;以后随机招与刺击交替。 +export const INKLET_CHARACTER = "mengsan_inklet_shuying"; +export const INKLET_MOVES = Object.freeze({ + jab: Object.freeze({ id: "jab", name: "刺击", damage: 3 }), + whirlwind: Object.freeze({ id: "whirlwind", name: "旋风斩", damage: 2, hits: 3 }), + gaze: Object.freeze({ id: "gaze", name: "锐利凝视", damage: 10 }), +}); +const roll = random => { + const value = random(); + if (!Number.isFinite(value) || value < 0 || value >= 1) throw new RangeError("墨宝随机数必须在[0,1)内"); + return value; +}; +const positionOf = state => { + const position = state.position ?? 1; + if (!Number.isInteger(position) || position < 1 || position > 7) throw new RangeError("墨宝站位必须为1~7"); + return position; +}; +const stageOf = state => { + const stage = state.stage ?? "opening"; + if (!["opening", "jab", "random"].includes(stage)) throw new RangeError("墨宝行动节点无效"); + return stage; +}; +export function selectInkletMove(state = {}, random) { + const position = positionOf(state), stage = stageOf(state); + if (stage === "opening") return position % 2 ? INKLET_MOVES.jab : INKLET_MOVES.whirlwind; + if (stage === "jab") return INKLET_MOVES.jab; + return roll(random) < 0.5 ? INKLET_MOVES.gaze : INKLET_MOVES.whirlwind; +} +export function recordInkletAction(state = {}, advance = true) { + const position = positionOf(state), stage = stageOf(state), turns = state.turnsTaken ?? 0; + if (!Number.isSafeInteger(turns) || turns < 0 || turns === Number.MAX_SAFE_INTEGER || typeof advance !== "boolean") { + throw new RangeError("墨宝行动记录无效"); + } + const next = stage === "opening" ? (position % 2 ? "random" : "jab") : stage === "jab" ? "random" : "jab"; + return { position, stage: advance ? next : stage, turnsTaken: turns + 1 }; +} +export const rollInkletHp = random => 11 + Math.floor(roll(random) * 7); +export const isInklet = player => player?.name === INKLET_CHARACTER && + player.storage?.mengsanCamp_shuying === "enemy"; + +// 在梦三的原生changeHp事件创建后限幅;保留护甲消耗量,只将穿透后的生命损失限制为1。 +// 层数由实际changeHp通知消耗,完全格挡、零伤害及取消事件不消耗。 +export function capInkletHpLoss(player, event) { + if (!isInklet(player) || !(player.storage.mengsanSlippery_shuying > 0) || + !Number.isFinite(event.num) || event.num >= 0) return event; + const parent = event.getParent(); + if (parent?.unreal) return event; + const armor = parent?.name === "damage" && !parent.nohujia && !player.hasSkillTag("nohujia") ? + Math.max(0, player.hujia || 0) : 0; + if (-event.num > armor) event.num = -Math.min(-event.num, armor + 1); + return event; +} diff --git a/mengsan/monsters/jaxfruit-intent.js b/mengsan/monsters/jaxfruit-intent.js new file mode 100644 index 0000000..2f8f12d --- /dev/null +++ b/mengsan/monsters/jaxfruit-intent.js @@ -0,0 +1,26 @@ +// 闪光贾克斯果:每回合能量球,伤害结算完成后获得2力量;不随机选择行动。 +export const JAXFRUIT_CHARACTER = "mengsan_snapping_jaxfruit_shuying"; +export const JAXFRUIT_MOVE = Object.freeze({ id: "energyOrb", name: "能量球", damage: 3, strength: 2 }); +const turnsOf = state => { + const turns = state.turnsTaken ?? 0; + if (!Number.isSafeInteger(turns) || turns < 0) throw new RangeError("贾克斯果行动记录无效"); + return turns; +}; +export function selectJaxfruitMove(state = {}) { + turnsOf(state); + return JAXFRUIT_MOVE; +} +export function recordJaxfruitAction(state = {}, advance = true) { + const turns = turnsOf(state); + if (typeof advance !== "boolean" || advance && turns === Number.MAX_SAFE_INTEGER) { + throw new RangeError("贾克斯果行动推进无效"); + } + return { turnsTaken: turns + (advance ? 1 : 0) }; +} +export function rollJaxfruitHp(random) { + const value = random(); + if (!Number.isFinite(value) || value < 0 || value >= 1) throw new RangeError("贾克斯果随机数必须在[0,1)内"); + return 31 + Math.floor(value * 3); +} +export const isJaxfruit = player => player?.name === JAXFRUIT_CHARACTER && + player.storage?.mengsanCamp_shuying === "enemy"; diff --git a/mengsan/monsters/leafmedium-intent.js b/mengsan/monsters/leafmedium-intent.js new file mode 100644 index 0000000..e209173 --- /dev/null +++ b/mengsan/monsters/leafmedium-intent.js @@ -0,0 +1,26 @@ +// 灰机Wiki普通数值:第一回合黏糊射击,之后与团块射击严格交替。 +export const LEAFMEDIUM_CHARACTER = "mengsan_leaf_slime_m_shuying"; +export const LEAFMEDIUM_MOVES = Object.freeze({ + clump: Object.freeze({ id: "clump", name: "团块射击", damage: 8 }), + sticky: Object.freeze({ id: "sticky", name: "黏糊射击", slimed: 2 }), +}); +const turnsOf = state => { + const turns = state.turnsTaken ?? 0; + if (!Number.isSafeInteger(turns) || turns < 0) throw new RangeError("树叶史莱姆行动记录无效"); + return turns; +}; +const roll = random => { + const value = random(); + if (!Number.isFinite(value) || value < 0 || value >= 1) throw new RangeError("树叶史莱姆随机数必须在[0,1)内"); + return value; +}; +export function selectLeafslimeMediumMove(state = {}) { + return turnsOf(state) % 2 === 0 ? LEAFMEDIUM_MOVES.sticky : LEAFMEDIUM_MOVES.clump; +} +export function recordLeafslimeMediumAction(state = {}, advance = true) { + const turns = turnsOf(state); + if (typeof advance !== "boolean" || advance && turns === Number.MAX_SAFE_INTEGER) throw new RangeError("树叶史莱姆行动推进无效"); + return { turnsTaken: turns + (advance ? 1 : 0) }; +} +export const rollLeafslimeMediumHp = random => 32 + Math.floor(roll(random) * 4); +export const isLeafslimeMedium = player => player?.name === LEAFMEDIUM_CHARACTER && player.storage?.mengsanCamp_shuying === "enemy"; diff --git a/mengsan/monsters/leafslime-goop.js b/mengsan/monsters/leafslime-goop.js new file mode 100644 index 0000000..eb1aefd --- /dev/null +++ b/mengsan/monsters/leafslime-goop.js @@ -0,0 +1,26 @@ +import { createSlimedData } from "../cards/slimed-card.js"; +import { getIntentHostiles } from "../battle/intent-effects.js"; +import { isLeafslime } from "./leafslime-intent.js"; +import { isLeafslimeMedium } from "./leafmedium-intent.js"; +import { isTwigmedium } from "./twigmedium-intent.js"; +export function applyLeafslimeGoop(game, current, source, count = 1) { + if (!current?.session.active || !source.isAlive() || !(isLeafslime(source) || isLeafslimeMedium(source) || isTwigmedium(source))) return; + const expected = isLeafslimeMedium(source) ? 2 : 1; + if (count !== expected) throw new RangeError("黏液牌数量与怪物配置不符"); + if (!current.leafslimeCards) { + const counter = current.leafslimeCards = { sequence: 0 }; + current.session.ownResource(counter, () => { delete current.leafslimeCards; }); + } + for (const target of getIntentHostiles(game, source)) { + if (!current.session.active || !source.isAlive()) break; + const pile = target === game.me ? current.personalPiles : current.monsterPiles.get(target); + if (!pile) continue; // 无个人牌堆的剧情援军不污染公共牌堆。 + for (let index = 0; index < count; index++) { + if (!current.session.active || !source.isAlive()) break; + const sequence = ++current.leafslimeCards.sequence; + if (!Number.isSafeInteger(sequence)) throw new RangeError("黏液牌序号溢出"); + pile.addToDiscard(createSlimedData(`slimed_${source.playerid}_${sequence}`)); + } + game.log(target, `的个人弃牌堆加入${count}张【黏液】`); + } +} diff --git a/mengsan/monsters/leafslime-intent.js b/mengsan/monsters/leafslime-intent.js new file mode 100644 index 0000000..499adfe --- /dev/null +++ b/mengsan/monsters/leafslime-intent.js @@ -0,0 +1,27 @@ +// 灰机Wiki普通数值:每次行动在冲撞/黏液之间等权重随机。 +export const LEAFSLIME_CHARACTER = "mengsan_leaf_slime_s_shuying"; +export const LEAFSLIME_MOVES = Object.freeze({ + tackle: Object.freeze({ id: "tackle", name: "冲撞", damage: 3 }), + goop: Object.freeze({ id: "goop", name: "黏液", slimed: 1 }), +}); +const turnsOf = state => { + const turns = state.turnsTaken ?? 0; + if (!Number.isSafeInteger(turns) || turns < 0) throw new RangeError("树叶史莱姆行动记录无效"); + return turns; +}; +const roll = random => { + const value = random(); + if (!Number.isFinite(value) || value < 0 || value >= 1) throw new RangeError("树叶史莱姆随机数必须在[0,1)内"); + return value; +}; +export function selectLeafslimeMove(state = {}, random) { + turnsOf(state); + return roll(random) < 0.5 ? LEAFSLIME_MOVES.tackle : LEAFSLIME_MOVES.goop; +} +export function recordLeafslimeAction(state = {}, advance = true) { + const turns = turnsOf(state); + if (typeof advance !== "boolean" || advance && turns === Number.MAX_SAFE_INTEGER) throw new RangeError("树叶史莱姆行动推进无效"); + return { turnsTaken: turns + (advance ? 1 : 0) }; +} +export const rollLeafslimeHp = random => 11 + Math.floor(roll(random) * 5); +export const isLeafslime = player => player?.name === LEAFSLIME_CHARACTER && player.storage?.mengsanCamp_shuying === "enemy"; diff --git a/mengsan/monsters/mawler-intent.js b/mengsan/monsters/mawler-intent.js new file mode 100644 index 0000000..602ecd5 --- /dev/null +++ b/mengsan/monsters/mawler-intent.js @@ -0,0 +1,38 @@ +// 灰机 Wiki 蛮兽页普通数值;冷却1表示攻击不能连续使用。 +export const MAWLER_CHARACTER = "mengsan_mawler_shuying"; +export const MAWLER_MOVES = Object.freeze({ + claw: Object.freeze({ id: "claw", name: "爪击", damage: 4, hits: 2 }), + rip: Object.freeze({ id: "rip", name: "狂乱撕扯", damage: 14 }), + roar: Object.freeze({ id: "roar", name: "怒吼", debuff: "易伤", stacks: 3 }), +}); +export function eligibleMawlerMoves(state = {}) { + const turn = state.turnsTaken ?? 0; + if (!Number.isSafeInteger(turn) || turn < 0) throw new RangeError("蛮兽行动次数无效"); + if (turn === 0) return [MAWLER_MOVES.claw]; + const moves = []; + if (state.lastMove !== "claw") moves.push(MAWLER_MOVES.claw); + if (state.lastMove !== "rip") moves.push(MAWLER_MOVES.rip); + if (!state.roarUsed) moves.push(MAWLER_MOVES.roar); + return moves; +} +export function selectMawlerMove(state = {}, random) { + const moves = eligibleMawlerMoves(state); + if (moves.length === 1) return moves[0]; + const roll = random(); + if (!Number.isFinite(roll) || roll < 0 || roll >= 1) throw new RangeError("怪物随机数必须在 [0,1) 内"); + return moves[Math.floor(roll * moves.length)]; +} +export function recordMawlerAction(state = {}, move, paid) { + const turn = state.turnsTaken ?? 0; + if (!Number.isSafeInteger(turn) || turn < 0 || turn === Number.MAX_SAFE_INTEGER) { + throw new RangeError("蛮兽行动次数无效"); + } + if (paid && (!move || !Object.hasOwn(MAWLER_MOVES, move.id))) throw new TypeError("蛮兽行动无效"); + return { + turnsTaken: turn + 1, + lastMove: paid ? move.id : state.lastMove || null, + roarUsed: Boolean(state.roarUsed || paid && move.id === "roar"), + }; +} +export const isMawler = player => player?.name === MAWLER_CHARACTER && + player.storage?.mengsanCamp_shuying === "enemy"; diff --git a/mengsan/monsters/monster.js b/mengsan/monsters/monster.js index a214665..2d01c61 100644 --- a/mengsan/monsters/monster.js +++ b/mengsan/monsters/monster.js @@ -16,6 +16,24 @@ const BASE_DECKS = Object.freeze({ const HEALTH = Object.freeze({ mengsan_soldier_shuying: 24, mengsan_flyconid_shuying: 48, + mengsan_fogmog_shuying: 74, mengsan_eye_with_teeth_shuying: 6, + mengsan_mawler_shuying: 72, + mengsan_vine_shambler_shuying: 61, + mengsan_nibbit_shuying: 44, + mengsan_cubex_construct_shuying: 65, + mengsan_byrdonis_shuying: 82, + mengsan_bygone_effigy_shuying: 127, + mengsan_phrog_parasite_shuying: 62, + mengsan_wriggler_shuying: 19, + mengsan_shrinker_beetle_shuying: 39, + mengsan_twig_slime_m_shuying: 27, + mengsan_twig_slime_s_shuying: 9, + mengsan_leaf_slime_m_shuying: 34, + mengsan_leaf_slime_s_shuying: 13, + mengsan_slithering_strangler_shuying: 54, + mengsan_snapping_jaxfruit_shuying: 32, + mengsan_inklet_shuying: 14, + mengsan_fuzzy_wurm_crawler_shuying: 56, mengsan_raider_brute_shuying: 32, mengsan_raider_assassin_shuying: 21, mengsan_raider_axe_shuying: 21, @@ -68,6 +86,95 @@ export class OpeningSoldier extends Monster { export class Flyconid extends Monster { constructor(options = {}) { super({ ...options, character: "mengsan_flyconid_shuying", tier: "normal", hp: 48 }); } } +export class FuzzyWurmCrawler extends Monster { + constructor(options = {}) { + super({ ...options, character: "mengsan_fuzzy_wurm_crawler_shuying", tier: "normal", hp: options.hp ?? 56 }); + } +} +export class Inklet extends Monster { + constructor(options = {}) { + super({ ...options, character: "mengsan_inklet_shuying", tier: "normal", hp: options.hp ?? 14 }); + } +} +export class SnappingJaxfruit extends Monster { + constructor(options = {}) { + super({ ...options, character: "mengsan_snapping_jaxfruit_shuying", tier: "normal", hp: options.hp ?? 32 }); + } +} +export class SlitheringStrangler extends Monster { + constructor(options = {}) { + super({ ...options, character: "mengsan_slithering_strangler_shuying", tier: "normal", hp: options.hp ?? 54 }); + } +} +export class LeafSlimeSmall extends Monster { + constructor(options = {}) { + super({ ...options, character: "mengsan_leaf_slime_s_shuying", tier: "normal", hp: options.hp ?? 13 }); + } +} +export class LeafSlimeMedium extends Monster { + constructor(options = {}) { + super({ ...options, character: "mengsan_leaf_slime_m_shuying", tier: "normal", hp: options.hp ?? 34 }); + } +} +export class TwigSlimeSmall extends Monster { + constructor(options = {}) { + super({ ...options, character: "mengsan_twig_slime_s_shuying", tier: "normal", hp: options.hp ?? 9 }); + } +} +export class TwigSlimeMedium extends Monster { + constructor(options = {}) { + super({ ...options, character: "mengsan_twig_slime_m_shuying", tier: "normal", hp: options.hp ?? 27 }); + } +} +export class ShrinkerBeetle extends Monster { + constructor(options = {}) { + super({ ...options, character: "mengsan_shrinker_beetle_shuying", tier: "normal", hp: options.hp ?? 39 }); + } +} +export class PhrogParasite extends Monster { + constructor(options = {}) { super({ ...options, character: "mengsan_phrog_parasite_shuying", tier: "elite", hp: options.hp ?? 62 }); } +} +export class Wriggler extends Monster { + constructor(options = {}) { super({ ...options, character: "mengsan_wriggler_shuying", tier: "elite", hp: options.hp ?? 19 }); } +} +export class BygoneEffigy extends Monster { + constructor(options = {}) { + super({ ...options, character: "mengsan_bygone_effigy_shuying", tier: "elite", hp: options.hp ?? 127 }); + } +} +export class Byrdonis extends Monster { + constructor(options = {}) { + super({ ...options, character: "mengsan_byrdonis_shuying", tier: "elite", hp: options.hp ?? 82 }); + } +} +export class CubexConstruct extends Monster { + constructor(options = {}) { + super({ ...options, character: "mengsan_cubex_construct_shuying", tier: "normal", hp: options.hp ?? 65 }); + } +} +export class Nibbit extends Monster { + constructor(options = {}) { + super({ ...options, character: "mengsan_nibbit_shuying", tier: "normal", hp: options.hp ?? 44 }); + } +} +export class VineShambler extends Monster { + constructor(options = {}) { + super({ ...options, character: "mengsan_vine_shambler_shuying", tier: "normal", hp: options.hp ?? 61 }); + } +} +export class Mawler extends Monster { + constructor(options = {}) { super({ ...options, character: "mengsan_mawler_shuying", tier: "normal", hp: 72 }); } +} +export class Fogmog extends Monster { + constructor(options = {}) { super({ ...options, character: "mengsan_fogmog_shuying", tier: "normal", hp: 74 }); } +} +export class EyeWithTeeth extends Monster { + constructor(options = {}) { + super({ ...options, character: "mengsan_eye_with_teeth_shuying", tier: "normal", hp: 6, energy: 1, handLimit: 1 }); + this.draw = 0; + } + createDeck() { return []; } // 爪牙仅发动牵制,不额外抽取或打出三国杀牌。 +} const raiderClass = (character, hp) => class extends Monster { constructor(options = {}) { super({ ...options, character, tier: "normal", hp }); } }; @@ -81,6 +188,25 @@ export const RaiderTracker = raiderClass("mengsan_raider_tracker_shuying", 23); export const MONSTER_TYPES = Object.freeze({ mengsan_soldier_shuying: OpeningSoldier, mengsan_flyconid_shuying: Flyconid, + mengsan_fogmog_shuying: Fogmog, + mengsan_mawler_shuying: Mawler, + mengsan_vine_shambler_shuying: VineShambler, + mengsan_nibbit_shuying: Nibbit, + mengsan_cubex_construct_shuying: CubexConstruct, + mengsan_byrdonis_shuying: Byrdonis, + mengsan_bygone_effigy_shuying: BygoneEffigy, + mengsan_phrog_parasite_shuying: PhrogParasite, + mengsan_wriggler_shuying: Wriggler, + mengsan_shrinker_beetle_shuying: ShrinkerBeetle, + mengsan_twig_slime_m_shuying: TwigSlimeMedium, + mengsan_twig_slime_s_shuying: TwigSlimeSmall, + mengsan_leaf_slime_m_shuying: LeafSlimeMedium, + mengsan_leaf_slime_s_shuying: LeafSlimeSmall, + mengsan_slithering_strangler_shuying: SlitheringStrangler, + mengsan_snapping_jaxfruit_shuying: SnappingJaxfruit, + mengsan_inklet_shuying: Inklet, + mengsan_fuzzy_wurm_crawler_shuying: FuzzyWurmCrawler, + mengsan_eye_with_teeth_shuying: EyeWithTeeth, mengsan_raider_brute_shuying: RaiderBrute, mengsan_raider_assassin_shuying: RaiderAssassin, mengsan_raider_axe_shuying: RaiderAxe, diff --git a/mengsan/monsters/nibbit-intent.js b/mengsan/monsters/nibbit-intent.js new file mode 100644 index 0000000..4c0d11c --- /dev/null +++ b/mengsan/monsters/nibbit-intent.js @@ -0,0 +1,44 @@ +// 普通难度:顶撞→犹豫斩击→哈气。双只分别从斩击、哈气进入同一循环。 +export const NIBBIT_CHARACTER = "mengsan_nibbit_shuying"; +export const NIBBIT_MOVES = Object.freeze({ + butt: Object.freeze({ id: "butt", name: "顶撞", damage: 12 }), + slice: Object.freeze({ id: "slice", name: "犹豫斩击", damage: 6, block: 5 }), + hiss: Object.freeze({ id: "hiss", name: "哈气", strength: 2 }), +}); +const CYCLE = Object.freeze([NIBBIT_MOVES.butt, NIBBIT_MOVES.slice, NIBBIT_MOVES.hiss]); +const openingOf = state => { + const opening = state.opening ?? "butt"; + if (!Object.hasOwn(NIBBIT_MOVES, opening)) throw new RangeError("小啃兽起手无效"); + return opening; +}; +const turnsOf = state => { + const turns = state.turnsTaken ?? 0; + if (!Number.isSafeInteger(turns) || turns < 0) throw new RangeError("小啃兽行动记录无效"); + return turns; +}; +export const selectNibbitMove = (state = {}) => { + const offset = openingOf(state) === "slice" ? 1 : openingOf(state) === "hiss" ? 2 : 0; + return CYCLE[(turnsOf(state) % CYCLE.length + offset) % CYCLE.length]; +}; +export function recordNibbitAction(state = {}, advance = true) { + const opening = openingOf(state), turns = turnsOf(state); + if (typeof advance !== "boolean" || advance && turns === Number.MAX_SAFE_INTEGER) throw new RangeError("小啃兽行动推进无效"); + return { opening, turnsTaken: turns + (advance ? 1 : 0) }; +} +export function rollNibbitHp(random) { + const roll = random(); + if (!Number.isFinite(roll) || roll < 0 || roll >= 1) throw new RangeError("怪物随机数必须在[0,1)内"); + return 42 + Math.floor(roll * 5); +} +export const isNibbit = player => player?.name === NIBBIT_CHARACTER && player.storage?.mengsanCamp_shuying === "enemy"; +export function bindNibbitOpenings(players) { + // 开战时按关卡前后顺序固定起手;死亡、席位变更或重复规划不重置循环。 + const group = players.filter(isNibbit); + group.forEach((player, index) => { + if (player.storage.mengsanNibbitState_shuying != null) return; + player.storage.mengsanNibbitState_shuying = { + opening: group.length === 2 ? (index === 0 ? "slice" : "hiss") : "butt", + turnsTaken: 0, + }; + }); +} diff --git a/mengsan/monsters/phrog-battle.js b/mengsan/monsters/phrog-battle.js new file mode 100644 index 0000000..2e90dda --- /dev/null +++ b/mengsan/monsters/phrog-battle.js @@ -0,0 +1,38 @@ +import { INFESTED_COUNT, isPhrog, WRIGGLER_CHARACTER } from "./phrog-intent.js"; + +// 死亡钩子被原生die事件await;所有衍生物入场后才继续原伤害/卡牌结算。 +export function createPhrogBattle(game, current, spawn) { + const consumed = new WeakSet(); + let pending = 0, sequence = 0; + const controller = { + get pending() { + // 原生die先移入dead,再调整负生命至0,最后才调用dieAfter。 + // changeHpAfter可能在这段间隙检查胜利;死体本身也须预留亡语。 + return pending > 0 || current.session.active && game.dead.some(player => + current.players.has(player) && isPhrog(player) && !consumed.has(player) && + player.storage.mengsanInfested_shuying === INFESTED_COUNT); + }, + async onDeath(player) { + if (!current.session.active || !isPhrog(player) || player.isAlive() || + !current.players.has(player) || consumed.has(player)) return false; + const count = player.storage.mengsanInfested_shuying ?? 0; + if (count !== INFESTED_COUNT) throw new RangeError("寄生物召唤配置无效"); + if (game.players.length + game.dead.length + count > 8) throw new Error("异蛙寄生虫召唤席位不足"); + if (!Number.isSafeInteger(sequence + 1)) throw new RangeError("寄生物召唤序号溢出"); + consumed.add(player); pending++; + const group = ++sequence; + try { + let anchor = player; + for (let position = 1; position <= count; position++) { + if (!current.session.active) break; + anchor = await spawn({ id: `wriggler_${player.playerid}_${group}_${position}`, + character: WRIGGLER_CHARACTER, camp: "enemy", inheritSkills: false, + hand: 4, wrigglerPosition: position, wrigglerSpawned: true }, anchor); + } + return true; + } finally { pending--; } + }, + }; + current.session.ownResource(controller, () => { if (current.phrog === controller) delete current.phrog; }); + return controller; +} diff --git a/mengsan/monsters/phrog-intent.js b/mengsan/monsters/phrog-intent.js new file mode 100644 index 0000000..b38eaf2 --- /dev/null +++ b/mengsan/monsters/phrog-intent.js @@ -0,0 +1,75 @@ +// 灰机Wiki普通数值;不混入A8生命和A9攻击。 +export const PHROG_CHARACTER = "mengsan_phrog_parasite_shuying"; +export const WRIGGLER_CHARACTER = "mengsan_wriggler_shuying"; +export const INFESTED_COUNT = 4; +export const PHROG_MOVES = Object.freeze({ + infect: Object.freeze({ id: "infect", name: "感染", infection: 3 }), + lash: Object.freeze({ id: "lash", name: "甩动", damage: 4, hits: 4 }), +}); +export const WRIGGLER_MOVES = Object.freeze({ + spawned: Object.freeze({ id: "spawned", name: "生成", intentType: "stun" }), + bite: Object.freeze({ id: "bite", name: "污秽啃咬", damage: 6 }), + wriggle: Object.freeze({ id: "wriggle", name: "扭动", infection: 1, strength: 2 }), +}); +const turnsOf = state => { + const value = state.turnsTaken ?? 0; + if (!Number.isSafeInteger(value) || value < 0) throw new RangeError("异蛙行动次数无效"); + return value; +}; +const positionOf = state => { + const position = state.position ?? 1; + if (!Number.isInteger(position) || position < 1 || position > 4) throw new RangeError("扭动虫站位必须为1~4"); + return position; +}; +export const selectPhrogMove = (state = {}) => turnsOf(state) % 2 ? PHROG_MOVES.lash : PHROG_MOVES.infect; +export function selectWrigglerMove(state = {}) { + const position = positionOf(state), turn = turnsOf(state); + if (state.spawned) return WRIGGLER_MOVES.spawned; + return (turn + (position % 2 ? 0 : 1)) % 2 ? WRIGGLER_MOVES.wriggle : WRIGGLER_MOVES.bite; +} +export function recordPhrogAction(state = {}, advance = true) { + const turn = turnsOf(state); + if (typeof advance !== "boolean" || advance && turn === Number.MAX_SAFE_INTEGER) throw new RangeError("异蛙行动推进无效"); + return { ...state, turnsTaken: turn + (advance ? 1 : 0) }; +} +const rollHp = (random, base, range) => { + const value = random(); + if (!Number.isFinite(value) || value < 0 || value >= 1) throw new RangeError("异蛙随机数必须在[0,1)内"); + return base + Math.floor(value * range); +}; +export const rollPhrogHp = random => rollHp(random, 61, 4); +export const rollWrigglerHp = random => rollHp(random, 17, 5); +export const isPhrog = player => player?.name === PHROG_CHARACTER && player.storage?.mengsanCamp_shuying === "enemy"; +export const isWriggler = player => player?.name === WRIGGLER_CHARACTER && player.storage?.mengsanCamp_shuying === "enemy"; +export const isPhrogActor = player => isPhrog(player) || isWriggler(player); +const bindings = new WeakMap(); +export function initializePhrog(player, spec, current = null) { + if (!isPhrogActor(player)) return false; + const position = isWriggler(player) ? positionOf({ position: spec.wrigglerPosition }) : 1; + if (current?.session.active && bindings.get(player)?.battle !== current) { + const entry = { battle: current }; + current.session.ownResource(entry, () => { + if (bindings.get(player) !== entry) return; + bindings.delete(player); + delete player.storage.mengsanPhrogState_shuying; + delete player.storage.mengsanPhrogIntent_shuying; + player.removeSkill("mengsan_infested_shuying"); + player.removeSkill("mengsan_raider_strength_shuying"); + }); + bindings.set(player, entry); + } + player.storage.mengsanPhrogState_shuying = { turnsTaken: 0, position, spawned: isWriggler(player) && spec.wrigglerSpawned === true }; + player.storage.mengsanPhrogIntent_shuying = null; + if (isPhrog(player)) { + player.storage.mengsanInfested_shuying = INFESTED_COUNT; + player.addSkill("mengsan_infested_shuying"); player.markSkill("mengsan_infested_shuying"); + } + return true; +} +export function skipSpawnedWriggler(player, phase) { + if (!isWriggler(player) || !player.isAlive() || !player.storage.mengsanPhrogState_shuying?.spawned) return false; + phase.cancel(); + player.storage.mengsanPhrogState_shuying = { ...player.storage.mengsanPhrogState_shuying, spawned: false }; + player.storage.mengsanPhrogIntent_shuying = null; + return true; +} diff --git a/mengsan/monsters/shrinker-intent.js b/mengsan/monsters/shrinker-intent.js new file mode 100644 index 0000000..2a1f42e --- /dev/null +++ b/mengsan/monsters/shrinker-intent.js @@ -0,0 +1,27 @@ +// 缩小甲虫,灰机Wiki普通数值;缩小后大啃、践踏严格交替。 +export const SHRINKER_CHARACTER = "mengsan_shrinker_beetle_shuying"; +export const SHRINKER_MOVES = Object.freeze({ + shrink: Object.freeze({ id: "shrink", name: "缩小", debuff: "缩小", stacks: 1, shrink: true }), + chomp: Object.freeze({ id: "chomp", name: "大啃", damage: 7 }), + stomp: Object.freeze({ id: "stomp", name: "践踏", damage: 13 }), +}); +const turnsOf = state => { + const turns = state.turnsTaken ?? 0; + if (!Number.isSafeInteger(turns) || turns < 0) throw new RangeError("缩小甲虫行动记录无效"); + return turns; +}; +export function selectShrinkerMove(state = {}) { + const turns = turnsOf(state); + return turns === 0 ? SHRINKER_MOVES.shrink : turns % 2 ? SHRINKER_MOVES.chomp : SHRINKER_MOVES.stomp; +} +export function recordShrinkerAction(state = {}, advance = true) { + const turns = turnsOf(state); + if (typeof advance !== "boolean" || advance && turns === Number.MAX_SAFE_INTEGER) throw new RangeError("缩小甲虫行动推进无效"); + return { turnsTaken: turns + (advance ? 1 : 0) }; +} +export function rollShrinkerHp(random) { + const value = random(); + if (!Number.isFinite(value) || value < 0 || value >= 1) throw new RangeError("缩小甲虫随机数必须在[0,1)内"); + return 38 + Math.floor(value * 3); +} +export const isShrinker = player => player?.name === SHRINKER_CHARACTER && player.storage?.mengsanCamp_shuying === "enemy"; diff --git a/mengsan/monsters/shrinker-status.js b/mengsan/monsters/shrinker-status.js new file mode 100644 index 0000000..127bf86 --- /dev/null +++ b/mengsan/monsters/shrinker-status.js @@ -0,0 +1,45 @@ +import { consumeArtifact } from "./artifact-status.js"; +import { isShrinker } from "./shrinker-intent.js"; +const SKILL = "mengsan_shrink_shuying"; +const KEY = "mengsanShrink_shuying"; +const clear = target => { delete target.storage[KEY]; target.removeSkill(SKILL); }; +// 来源只保留在战斗对象中;多只甲虫不累乘减伤,死亡逐个解除来源。 +export function isShrunk(current, target) { + const records = current?.shrinkerSources; + const sources = records?.get(target); + if (!sources) return false; + if (!current.session.active || !target.isAlive()) { + records.delete(target); clear(target); return false; + } + for (const source of sources) { + if (!source.isAlive() || !isShrinker(source) || !current.players.has(source) || + source.storage.mengsanCamp_shuying === target.storage.mengsanCamp_shuying) sources.delete(source); + } + if (sources.size) { target.storage[KEY] = true; return true; } + records.delete(target); clear(target); return false; +} +export function applyShrink(current, source, target) { + if (!current?.session.active || !isShrinker(source) || !source.isAlive() || + !current.players.has(source) || !target.isAlive() || + source.storage.mengsanCamp_shuying === target.storage.mengsanCamp_shuying) return false; + if (consumeArtifact(target)) return false; + if (!current.shrinkerSources) { + const records = current.shrinkerSources = new Map(); + current.session.ownResource(records, () => { + for (const target of records.keys()) clear(target); + records.clear(); delete current.shrinkerSources; + }); + } + isShrunk(current, target); + const sources = current.shrinkerSources.get(target) || new Set(); + sources.add(source); current.shrinkerSources.set(target, sources); + target.storage[KEY] = true; target.addSkill(SKILL); target.markSkill(SKILL); + return true; +} +export function clearShrinkSource(current, source) { + const records = current?.shrinkerSources; + if (!records) return; + if (records.has(source)) { records.delete(source); clear(source); } + for (const [target, sources] of records) { sources.delete(source); isShrunk(current, target); } +} +export const shrinkAttackDamage = (damage, source) => source?.storage?.mengsanShrink_shuying ? Math.floor(damage * 7 / 10) : damage; diff --git a/mengsan/monsters/strangler-constrict.js b/mengsan/monsters/strangler-constrict.js new file mode 100644 index 0000000..67ed2b7 --- /dev/null +++ b/mengsan/monsters/strangler-constrict.js @@ -0,0 +1,62 @@ +import { consumeArtifact } from "./artifact-status.js"; +import { isStrangler } from "./strangler-intent.js"; +const SKILL = "mengsan_constrict_shuying"; +const KEY = "mengsanConstrict_shuying"; +const clear = target => { delete target.storage[KEY]; target.removeSkill(SKILL); }; +// 来源引用仅保存在本场战斗中,不进入永久牌堆/存档;同名多只怪物分别计层。 +export function constrictTotal(current, target) { + const records = current?.stranglerConstrict; + const sources = records?.get(target); + if (!sources) return 0; + if (!current.session.active || !target.isAlive()) { + records.delete(target); clear(target); return 0; + } + let total = 0; + for (const [source, layers] of sources) { + if (!source.isAlive() || !isStrangler(source) || !current.players.has(source) || + source.storage.mengsanCamp_shuying === target.storage.mengsanCamp_shuying) sources.delete(source); + else total += layers; + } + if (total) { target.storage[KEY] = total; target.markSkill(SKILL); } + else { records.delete(target); clear(target); } + return total; +} +export function addConstrict(current, source, target, layers) { + if (!current?.session.active || !isStrangler(source) || !source.isAlive() || + !current.players.has(source) || !target.isAlive() || + target.storage.mengsanCamp_shuying === source.storage.mengsanCamp_shuying) return false; + if (!Number.isSafeInteger(layers) || layers <= 0) throw new RangeError("紧缠层数无效"); + if (!current.stranglerConstrict) { + const records = current.stranglerConstrict = new Map(); + current.session.ownResource(records, () => { + for (const target of records.keys()) clear(target); + records.clear(); delete current.stranglerConstrict; + }); + } + constrictTotal(current, target); + const records = current.stranglerConstrict; + const sources = records.get(target) || new Map(); + const total = (target.storage[KEY] || 0) + layers; + if (!Number.isSafeInteger(total)) throw new RangeError("紧缠层数溢出"); + if (consumeArtifact(target)) return false; + sources.set(source, (sources.get(source) || 0) + layers); records.set(target, sources); + target.storage[KEY] = total; target.addSkill(SKILL); target.markSkill(SKILL); + return true; +} +export function clearConstrictSource(current, source) { + const records = current?.stranglerConstrict; + if (!records) return; + if (records.has(source)) { records.delete(source); clear(source); } + for (const [target, sources] of records) { + sources.delete(source); constrictTotal(current, target); + } +} +export async function resolveConstrict(current, target) { + const amount = constrictTotal(current, target); + if (!amount) return; + // 非攻击伤害:可由原生护甲抵扣,不受力量/虚弱/易伤的攻击修正。 + const hit = target.damage(amount, "nosource"); + hit.mengsanConstrictDamage_shuying = true; + hit.mengsanScriptedSkill_shuying = true; + await hit; +} diff --git a/mengsan/monsters/strangler-intent.js b/mengsan/monsters/strangler-intent.js new file mode 100644 index 0000000..0d0c084 --- /dev/null +++ b/mengsan/monsters/strangler-intent.js @@ -0,0 +1,31 @@ +// 蛇行扼杀者,普通数值:缠身与等权重随机攻击严格交替。 +export const STRANGLER_CHARACTER = "mengsan_slithering_strangler_shuying"; +export const STRANGLER_MOVES = Object.freeze({ + constrict: Object.freeze({ id: "constrict", name: "缠身", debuff: "紧缠", stacks: 3, constrict: 3 }), + thwack: Object.freeze({ id: "thwack", name: "重击", damage: 7, block: 5 }), + lash: Object.freeze({ id: "lash", name: "甩动", damage: 12 }), +}); +const turnsOf = state => { + const turns = state.turnsTaken ?? 0; + if (!Number.isSafeInteger(turns) || turns < 0) throw new RangeError("扼杀者行动记录无效"); + return turns; +}; +const roll = random => { + const value = random(); + if (!Number.isFinite(value) || value < 0 || value >= 1) throw new RangeError("扼杀者随机数必须在[0,1)内"); + return value; +}; +export function selectStranglerMove(state = {}, random) { + return turnsOf(state) % 2 === 0 ? STRANGLER_MOVES.constrict : + roll(random) < 0.5 ? STRANGLER_MOVES.thwack : STRANGLER_MOVES.lash; +} +export function recordStranglerAction(state = {}, advance = true) { + const turns = turnsOf(state); + if (typeof advance !== "boolean" || advance && turns === Number.MAX_SAFE_INTEGER) { + throw new RangeError("扼杀者行动推进无效"); + } + return { turnsTaken: turns + (advance ? 1 : 0) }; +} +export const rollStranglerHp = random => 53 + Math.floor(roll(random) * 3); +export const isStrangler = player => player?.name === STRANGLER_CHARACTER && + player.storage?.mengsanCamp_shuying === "enemy"; diff --git a/mengsan/monsters/twigmedium-intent.js b/mengsan/monsters/twigmedium-intent.js new file mode 100644 index 0000000..80530de --- /dev/null +++ b/mengsan/monsters/twigmedium-intent.js @@ -0,0 +1,47 @@ +// 灰机Wiki普通数值:首回合黏糊射击;随机分支等权,黏液不连用,扑击最多连用两次。 +export const TWIGMEDIUM_CHARACTER = "mengsan_twig_slime_m_shuying"; +export const TWIGMEDIUM_MOVES = Object.freeze({ + sticky: Object.freeze({ id: "sticky", name: "黏糊射击", slimed: 1 }), + pounce: Object.freeze({ id: "pounce", name: "戳刺扑击", damage: 11 }), +}); +const stateOf = state => { + const turnsTaken = state.turnsTaken ?? 0; + const lastMove = state.lastMove ?? null; + const consecutive = state.consecutive ?? 0; + if (!Number.isSafeInteger(turnsTaken) || turnsTaken < 0 || + !Number.isSafeInteger(consecutive) || consecutive < 0 || + (turnsTaken === 0 ? lastMove !== null || consecutive !== 0 : + !Object.hasOwn(TWIGMEDIUM_MOVES, lastMove) || consecutive < 1 || + consecutive > turnsTaken || consecutive > (lastMove === "sticky" ? 1 : 2))) { + throw new RangeError("中型树枝史莱姆行动记录无效"); + } + return { turnsTaken, lastMove, consecutive }; +}; +const roll = random => { + const value = random(); + if (!Number.isFinite(value) || value < 0 || value >= 1) throw new RangeError("随机数必须在[0,1)内"); + return value; +}; +export function selectTwigmediumMove(state = {}, random) { + const { turnsTaken, lastMove, consecutive } = stateOf(state); + if (turnsTaken === 0) return TWIGMEDIUM_MOVES.sticky; + if (lastMove === "sticky") return TWIGMEDIUM_MOVES.pounce; + if (consecutive === 2) return TWIGMEDIUM_MOVES.sticky; + return roll(random) < 0.5 ? TWIGMEDIUM_MOVES.sticky : TWIGMEDIUM_MOVES.pounce; +} +export function recordTwigmediumAction(state = {}, move, advance = true) { + const previous = stateOf(state); + if (typeof advance !== "boolean") throw new RangeError("行动推进参数无效"); + if (!advance) return previous; + const id = move?.id; + if (previous.turnsTaken === Number.MAX_SAFE_INTEGER || !Object.hasOwn(TWIGMEDIUM_MOVES, id) || + (previous.turnsTaken === 0 && id !== "sticky") || + (previous.lastMove === id && previous.consecutive >= (id === "sticky" ? 1 : 2))) { + throw new RangeError("中型树枝史莱姆行动推进无效"); + } + return { turnsTaken: previous.turnsTaken + 1, lastMove: id, + consecutive: previous.lastMove === id ? previous.consecutive + 1 : 1 }; +} +export const rollTwigmediumHp = random => 26 + Math.floor(roll(random) * 3); +export const isTwigmedium = player => player?.name === TWIGMEDIUM_CHARACTER && + player.storage?.mengsanCamp_shuying === "enemy"; diff --git a/mengsan/monsters/twigslime-intent.js b/mengsan/monsters/twigslime-intent.js new file mode 100644 index 0000000..804cdb4 --- /dev/null +++ b/mengsan/monsters/twigslime-intent.js @@ -0,0 +1,26 @@ +// 灰机Wiki普通数值:每回合冲撞,造成4点攻击伤害。 +export const TWIGSLIME_CHARACTER = "mengsan_twig_slime_s_shuying"; +export const TWIGSLIME_MOVES = Object.freeze({ + tackle: Object.freeze({ id: "tackle", name: "冲撞", damage: 4 }), +}); +const turnsOf = state => { + const turns = state.turnsTaken ?? 0; + if (!Number.isSafeInteger(turns) || turns < 0) throw new RangeError("树叶史莱姆行动记录无效"); + return turns; +}; +const roll = random => { + const value = random(); + if (!Number.isFinite(value) || value < 0 || value >= 1) throw new RangeError("树叶史莱姆随机数必须在[0,1)内"); + return value; +}; +export function selectTwigslimeMove(state = {}) { + turnsOf(state); + return TWIGSLIME_MOVES.tackle; +} +export function recordTwigslimeAction(state = {}, advance = true) { + const turns = turnsOf(state); + if (typeof advance !== "boolean" || advance && turns === Number.MAX_SAFE_INTEGER) throw new RangeError("树叶史莱姆行动推进无效"); + return { turnsTaken: turns + (advance ? 1 : 0) }; +} +export const rollTwigslimeHp = random => 7 + Math.floor(roll(random) * 5); +export const isTwigslime = player => player?.name === TWIGSLIME_CHARACTER && player.storage?.mengsanCamp_shuying === "enemy"; diff --git a/mengsan/monsters/vine-intent.js b/mengsan/monsters/vine-intent.js new file mode 100644 index 0000000..862b5ee --- /dev/null +++ b/mengsan/monsters/vine-intent.js @@ -0,0 +1,20 @@ +// 灰机Wiki普通数值,SVG箭头:挥击→紧绕藤蔓→大啃→挥击。 +export const VINE_CHARACTER = "mengsan_vine_shambler_shuying"; +export const VINE_MOVES = Object.freeze({ + swipe: Object.freeze({ id: "swipe", name: "挥击", damage: 6, hits: 2 }), + vines: Object.freeze({ id: "vines", name: "紧绕藤蔓", damage: 8, debuff: "缠结", stacks: 1 }), + chomp: Object.freeze({ id: "chomp", name: "大啃", damage: 16 }), +}); +const turnsOf = state => { + const turns = state.turnsTaken ?? 0; + if (!Number.isSafeInteger(turns) || turns < 0) throw new RangeError("藤蔓蹒跚者行动记录无效"); + return turns; +}; +const CYCLE = Object.freeze([VINE_MOVES.swipe, VINE_MOVES.vines, VINE_MOVES.chomp]); +export const selectVineMove = (state = {}) => CYCLE[turnsOf(state) % CYCLE.length]; +export function recordVineAction(state = {}, advance = true) { + const turns = turnsOf(state); + if (typeof advance !== "boolean" || advance && turns === Number.MAX_SAFE_INTEGER) throw new RangeError("藤蔓蹒跚者行动推进无效"); + return { turnsTaken: turns + (advance ? 1 : 0) }; +} +export const isVine = player => player?.name === VINE_CHARACTER && player.storage?.mengsanCamp_shuying === "enemy"; diff --git a/mengsan/monsters/vine-tangled.js b/mengsan/monsters/vine-tangled.js new file mode 100644 index 0000000..84623f8 --- /dev/null +++ b/mengsan/monsters/vine-tangled.js @@ -0,0 +1,37 @@ +import { consumeArtifact } from "./artifact-status.js"; +import { cardDefinitions } from "../cards/card-definitions.js"; +const KEY = "mengsanTangled_shuying"; +const SKILL = "mengsan_tangled_shuying"; +// 梦三伤害牌类别;未进永久奖励注册表的原生攻击锦囊也适用。 +const nativeAttacks = new Set(["nanman", "wanjian", "huogong"]); +export const isAttackCard = card => Boolean(card && (nativeAttacks.has(card.name) || + Object.hasOwn(cardDefinitions, card.name) && cardDefinitions[card.name].category === "damage")); +const clear = target => { delete target.storage[KEY]; target.removeSkill(SKILL); }; +const turnsOf = target => { + const turns = target.storage?.[KEY] ?? 0; + if (!Number.isSafeInteger(turns) || turns < 0) throw new RangeError("缠结持续回合无效"); + return turns; +}; +export function applyTangled(current, target, turns) { + if (!current?.session.active || !target.isAlive() || !current.players.has(target)) return false; + if (!Number.isSafeInteger(turns) || turns < 1) throw new RangeError("缠结回合数必须为正整数"); + const total = turnsOf(target) + turns; + if (!Number.isSafeInteger(total)) throw new RangeError("缠结回合数溢出"); + if (consumeArtifact(target)) return false; + if (!current.tangledTargets) { + const targets = current.tangledTargets = new Set(); + current.session.ownResource(targets, () => { + for (const target of targets) clear(target); + targets.clear(); delete current.tangledTargets; + }); + } + current.tangledTargets.add(target); target.storage[KEY] = total; + target.addSkill(SKILL); target.markSkill(SKILL); current.handUI?.refresh(); return true; +} +export function advanceTangled(current, target) { + if (!current?.session.active || !current.tangledTargets?.has(target)) return; + const remaining = Math.max(0, turnsOf(target) - 1); + if (remaining) { target.storage[KEY] = remaining; target.markSkill(SKILL); } + else { current.tangledTargets.delete(target); clear(target); } + current.handUI?.refresh(); +} diff --git a/mengsan/progression/reward-package.js b/mengsan/progression/reward-package.js index 9bac913..d64e11a 100644 --- a/mengsan/progression/reward-package.js +++ b/mengsan/progression/reward-package.js @@ -23,17 +23,17 @@ export function applyRewardPackage(run, pack, selection, applyReward) { const card = run.player.deck.find(card => card.id === selection.cardId); const relic = pack.relicChoices.find(choice => choice.id === selection.relicId); - if (pack.upgradeChoices.length) { + if (selection.cardId !== null) { if (!pack.upgradeChoices.some(card => card.id === selection.cardId) || !canUpgradeCard(card)) throw new Error("强化目标无效"); - } else if (selection.cardId !== null) throw new Error("无可强化卡牌"); - if (pack.relicChoices.length) { + } + if (selection.relicId !== null) { if (!relic || !canAcquireRelic(run, relic.relic)) { throw new Error("遗物选择无效"); } - } else if (selection.relicId !== null) throw new Error("无可选遗物"); + } run.player.gold += pack.gold; run.statistics.goldEarned += pack.gold; - if (pack.upgradeChoices.length) card.upgrade = (card.upgrade || 0) + 1; + if (selection.cardId !== null) card.upgrade = (card.upgrade || 0) + 1; if (relic) applyReward(run, relic.id); } diff --git a/mengsan/progression/state.js b/mengsan/progression/state.js index 1e2c0ff..ba9a78e 100644 --- a/mengsan/progression/state.js +++ b/mengsan/progression/state.js @@ -2,6 +2,9 @@ import { createStartingDeck } from "../cards/starting-deck.js"; import config from "../config.js"; import { initializeInnateBonds } from "../bonds/state.js"; import { RAIDER_TRIO_ENCOUNTER, createRaiderTrioBattlePlan } from "../content/acts/act1/raider-trio.js"; +import { INKLET_CHARACTER } from "../monsters/inklet-intent.js"; +import { createInkletBattlePlan } from "../content/acts/act1/inklet-group.js"; +import { NIBBIT_PAIR_ENCOUNTER, createNibbitPairBattlePlan } from "../content/acts/act1/nibbit-pair.js"; const hashText = text => { let value = 2166136261; @@ -250,7 +253,7 @@ export const getNodeEncounter = (run, node, override = null) => { } const enemy = randomGet(run, pool.filter(Boolean)); return { - name: encounter?.name || (enemy === RAIDER_TRIO_ENCOUNTER ? "劫掠者团伙" : ""), + name: encounter?.name || (enemy === RAIDER_TRIO_ENCOUNTER ? "劫掠者团伙" : enemy === INKLET_CHARACTER ? "墨宝群" : enemy === NIBBIT_PAIR_ENCOUNTER ? "小啃兽双只" : ""), requiredCharacter: encounter?.requiredCharacter || null, openingDialogue: encounter?.openingDialogue || [], fixedRewards: encounter?.fixedRewards || [], @@ -258,7 +261,8 @@ export const getNodeEncounter = (run, node, override = null) => { skipRandomReward: encounter?.skipRandomReward === true, victoryDialogue: encounter?.victoryDialogue || [], battlePlan: encounter?.battlePlan || (enemy === RAIDER_TRIO_ENCOUNTER ? - createRaiderTrioBattlePlan(() => nextRandom(run)) : null), + createRaiderTrioBattlePlan(() => nextRandom(run)) : enemy === INKLET_CHARACTER ? + createInkletBattlePlan() : enemy === NIBBIT_PAIR_ENCOUNTER ? createNibbitPairBattlePlan() : null), tier: encounter?.tier || (node.type === "elite" ? "elite" : "normal"), enemy, gold: encounter?.gold ?? (node.type == "elite" || node.type == "story" ? act.baseGold * 2 : act.baseGold), diff --git a/mengsan/relics/combat.js b/mengsan/relics/combat.js index 9a935eb..7502e44 100644 --- a/mengsan/relics/combat.js +++ b/mengsan/relics/combat.js @@ -1,8 +1,12 @@ +import { applyMengsanDebuff } from "../monsters/artifact-status.js"; + export function createOpeningEffects(game, owner, refreshEnergy) { const enemies = () => game.players.filter(player => player !== owner && player.isAlive() && player.storage?.mengsanCamp_shuying === "enemy"); const addStatus = (player, key, skill, amount) => { + if (key === "mengsanVulnerable_shuying" || key === "mengsanWeak_shuying") + return applyMengsanDebuff(player, key === "mengsanWeak_shuying" ? "weak" : "vulnerable", amount); player.storage[key] = (player.storage[key] || 0) + amount; player.addSkill(skill); player.markSkill(skill); diff --git a/mengsan/ui/card-library.js b/mengsan/ui/card-library.js index d93f97d..818c5ba 100644 --- a/mengsan/ui/card-library.js +++ b/mengsan/ui/card-library.js @@ -1,9 +1,13 @@ import { lib, get, ui } from "../../../../noname.js"; +import { cardDefinitions } from "../cards/card-definitions.js"; +import { DAZED_NAME } from "../cards/status-cards.js"; +import { SLIMED_NAME } from "../cards/slimed-card.js"; +import { cardCost } from "../battle/combat-rules.js"; import { AFFIX_INFO } from "../cards/affixes.js"; import { cardUpgradeLevel, cardUpgradeRule } from "../cards/upgrades.js"; const suits = { spade: "♠ 黑桃", heart: "♥ 红桃", club: "♣ 梅花", diamond: "♦ 方块" }; -const types = { basic: "基本牌", trick: "锦囊牌", delay: "延时锦囊", equip: "装备牌" }; +const types = { basic: "基本牌", trick: "锦囊牌", delay: "延时锦囊", equip: "装备牌", status: "状态牌" }; const affixes = Object.fromEntries(Object.entries(AFFIX_INFO).map(([key, info]) => [key, `${info.name}:${info.description}`])); const text = value => { const template = document.createElement("template"); @@ -31,6 +35,28 @@ export function describeLibraryCard(card) { }; } +// 只读图鉴数据,不创建实体牌、伪造花色点数或写入永久牌组。 +export function getCardCatalog() { + const states = Object.entries(lib.card).filter(([name, info]) => + /^mengsan_[a-z0-9_]+$/.test(name) && info?.type === "status" && + /^ext:术樱包\/mengsan\/assets\/cards\/[a-z0-9_]+\.png$/.test(info.image)).map(([name]) => name); + return [...new Set([...Object.keys(cardDefinitions), DAZED_NAME, SLIMED_NAME, ...states])] + .map(name => ({ name, nature: null, affixes: [], upgrade: 0 })); +} + +const categories = { damage: "攻击", utility: "辅助", recovery: "恢复", status: "状态" }; +function describeCatalogCard(card) { + const details = describeLibraryCard(card), info = lib.card[card.name]; + const definition = cardDefinitions[card.name]; + const category = info?.type === "status" || card.name === DAZED_NAME || card.name === SLIMED_NAME + ? "status" : definition?.category || "utility"; + const unplayable = info?.enable === false; + const cost = info && !unplayable ? cardCost(card) : null; + return { ...details, category, categoryName: categories[category], cost, unplayable, + costLabel: !info ? "未加载" : unplayable ? "不可打出" : `${cost}费`, + exclusive: definition?.owner ? translate(definition.owner) : "" }; +} + function element(tag, className, value, parent) { const node = document.createElement(tag); node.className = className; @@ -47,9 +73,9 @@ function button(label, parent, action, className = "ms-deck-button") { } // Read-only DOM previews, never game.createCard or Card.init: no IDs, triggers or pile writes. -function preview(card, details, parent) { +function preview(card, details, parent, catalog = false) { const face = element("div", "ms-deck-face", null, parent); - element("span", `ms-deck-corner${details.red ? " ms-deck-red" : ""}`, `${details.suit} ${details.number}`, face); + element("span", `ms-deck-corner${details.red ? " ms-deck-red" : ""}`, catalog ? details.costLabel : `${details.suit} ${details.number}`, face); const art = element("div", "ms-deck-art", null, face); element("span", "ms-deck-glyph", details.name, art); const imageName = lib.card[card.name]?.cardimage || card.name; @@ -66,9 +92,9 @@ function preview(card, details, parent) { image.src = `${lib.assetURL || ""}${localArt || `image/card/${imageName}.png`}`; } element("strong", "ms-deck-name", details.name, face); - element("span", "ms-deck-meta", `${details.type} · ${details.nature}`, face); + element("span", "ms-deck-meta", catalog ? `${details.categoryName} · ${details.exclusive ? "专属:" + details.exclusive : details.category === "status" ? "战斗生成" : "通用"}` : `${details.type} · ${details.nature}`, face); if (details.upgrade || details.affixes.length) { - element("span", "ms-deck-tags", [details.upgrade ? `强化 +${details.upgrade}` : "", ...details.affixes.map(value => value.split(":")[0])].filter(Boolean).join(" · "), face); + element("span", "ms-deck-tags", [details.upgrade ? `+${details.upgrade}` : "", ...details.affixes.map(value => value.split(":")[0])].filter(Boolean).join(" · "), face); } return face; } @@ -92,7 +118,39 @@ export function openCardLibrary(run, options = {}) { if (previousFocus?.isConnected) previousFocus.focus(); }; button("关闭", header, close); + let view = !options.sections && options.view === "catalog" ? "catalog" : "deck"; + const navigation = element("nav", "ms-deck-tabs", null, dialog); + navigation.setAttribute("aria-label", "卡牌列表视图"); + navigation.hidden = Boolean(options.sections); + const deckTab = button("持有牌", navigation, () => { view = "deck"; render(); }); + const catalogTab = button("图鉴", navigation, () => { view = "catalog"; render(); }); + const filters = element("div", "ms-catalog-filters", null, dialog); + function field(label) { + const wrapper = element("label", "ms-catalog-field", null, filters); + element("span", "", label, wrapper); return wrapper; + } + const search = element("input", "ms-catalog-input", null, field("名称 / 效果")); + search.type = "search"; search.maxLength = 120; search.placeholder = "搜索卡牌"; + search.setAttribute("aria-label", "搜索图鉴卡牌"); + const categoryFilter = element("select", "ms-catalog-select", null, field("类型")); + categoryFilter.setAttribute("aria-label", "图鉴类型"); + for (const [key, label] of Object.entries({ "": "全部类型", ...categories })) { + const option = element("option", "", label, categoryFilter); option.value = key; + } + const costFilter = element("select", "ms-catalog-select", null, field("费用")); + costFilter.setAttribute("aria-label", "图鉴费用"); + for (const [key, label] of [["", "全部费用"], ["0", "0费"], ["1", "1费"], ["2", "2费"], ["3+", "3费及以上"], ["unplayable", "不可打出"]]) { + const option = element("option", "", label, costFilter); option.value = key; + } + button("清空筛选", filters, () => { + search.value = ""; categoryFilter.value = ""; costFilter.value = ""; + render(); search.focus(); + }); + search.addEventListener("input", render); + categoryFilter.addEventListener("change", render); + costFilter.addEventListener("change", render); const summary = element("p", "ms-deck-summary", null, dialog); + summary.setAttribute("aria-live", "polite"); const body = element("div", "ms-deck-body", null, dialog); const footer = element("p", "ms-deck-footer", "点击卡牌查看完整介绍 · 仅供查看,不改变牌组或抽牌顺序", dialog); const current = Array.isArray(run?.player?.deck) ? run.player.deck : []; @@ -105,40 +163,64 @@ export function openCardLibrary(run, options = {}) { detailPanel.replaceChildren(); body.scrollTop = scrollTop; selectedButton?.focus({ preventScroll: true }); } - function showDetail(card, details, owner) { + function showDetail(card, details, owner, catalog = false) { selectedButton = owner; scrollTop = body.scrollTop; detail = true; list.hidden = true; detailPanel.hidden = false; detailPanel.replaceChildren(); - const backButton = button("返回牌库", detailPanel, back); + const backButton = button(catalog ? "返回图鉴" : "返回牌库", detailPanel, back); const content = element("div", "ms-deck-detail-content", null, detailPanel); - preview(card, details, content); + preview(card, details, content, catalog); const copy = element("div", "ms-deck-detail-copy", null, content); element("h3", "", details.name, copy); - element("p", "", `${details.suit} ${details.number} · ${details.type} · ${details.nature}`, copy); + element("p", "", catalog ? `${details.costLabel} · ${details.categoryName} · ${details.type}` : + `${details.suit} ${details.number} · ${details.type} · ${details.nature}`, copy); + if (catalog && details.exclusive) element("p", "ms-deck-rules", `专属角色:${details.exclusive}`, copy); element("h4", "", "卡牌介绍", copy); element("p", "ms-deck-rules", details.description, copy); if (!details.available) element("p", "ms-deck-warning", "该卡牌定义未加载;保留存档记录,不删除此牌。", copy); - element("p", "", details.upgradeLimit - ? `强化:${details.upgrade}/${details.upgradeLimit}` - : "此牌不可强化", copy); for (const affix of details.affixes) element("p", "ms-deck-rules", affix, copy); body.scrollTop = 0; backButton.focus(); } function render() { detail = false; list.hidden = false; detailPanel.hidden = true; detailPanel.replaceChildren(); list.replaceChildren(); body.scrollTop = 0; - const sections = options.sections ? options.sections() : [{ title: "", cards: current }]; - summary.textContent = options.sections ? "剩余牌堆按从左到右、从上到下的顺序摸取;第 1 张为下一张。" : `本次征程实际携带 · 共 ${current.length} 张 · 同名不同花色、点数分别展示`; + selectedButton = null; + const catalog = !options.sections && view === "catalog"; + filters.hidden = !catalog; + deckTab.setAttribute("aria-pressed", String(!catalog)); + catalogTab.setAttribute("aria-pressed", String(catalog)); + list.classList.toggle("ms-catalog-grid", catalog); + let records = []; + if (catalog) { + const query = search.value.trim().toLocaleLowerCase(); + records = getCardCatalog().map(card => ({ card, details: describeCatalogCard(card) })); + const total = records.length; + records = records.filter(({ card, details }) => + (!query || `${details.name} ${card.name} ${details.description}`.toLocaleLowerCase().includes(query)) && + (!categoryFilter.value || details.category === categoryFilter.value) && + (!costFilter.value || (costFilter.value === "unplayable" ? details.unplayable : + costFilter.value === "3+" ? details.cost !== null && details.cost >= 3 : + details.cost !== null && details.cost === Number(costFilter.value)))); + summary.textContent = `梦三卡牌图鉴 · 共 ${total} 种 · 当前显示 ${records.length} 种`; + } else { + summary.textContent = options.sections ? "剩余牌堆按从左到右、从上到下的顺序摸取;第 1 张为下一张。" : + `本次征程实际携带 · 共 ${current.length} 张 · 同名不同花色、点数分别展示`; + } + footer.textContent = catalog ? "图鉴仅供查阅,不会获得卡牌或改变征程牌组 · 点击卡牌查看完整介绍" : + "点击卡牌查看完整介绍 · 仅供查看,不改变牌组或抽牌顺序"; + const sections = catalog ? [{ title: "", cards: records.map(record => record.card) }] : + options.sections ? options.sections() : [{ title: "", cards: current }]; for (const section of sections) { const cards = section.cards; if (section.title) element("h3", "ms-deck-section", `${section.title} · ${cards.length} 张`, list); - if (!cards.length) element("p", "ms-deck-empty", section.ordered ? "剩余牌堆为空;下一次摸牌需按规则洗切弃牌堆,洗切后的顺序尚未确定。" : "此处暂无卡牌。", list); + if (!cards.length) element("p", "ms-deck-empty", catalog ? "没有符合筛选的卡牌,可清空筛选后查看全部图鉴。" : section.ordered ? "剩余牌堆为空;下一次摸牌需按规则洗切弃牌堆,洗切后的顺序尚未确定。" : "此处暂无卡牌。", list); cards.forEach((card, index) => { - const details = describeLibraryCard(card); - const tile = button("", list, () => showDetail(card, details, tile), "ms-deck-card"); - tile.setAttribute("aria-label", `${section.ordered ? `第 ${index + 1} 张,` : ""}${details.name},${details.suit}${details.number},${details.nature},查看详情`); + const details = catalog ? records[index].details : describeLibraryCard(card); + const tile = button("", list, () => showDetail(card, details, tile, catalog), "ms-deck-card"); + tile.setAttribute("aria-label", catalog ? `${details.name},${details.categoryName},${details.costLabel},查看详情` : + `${section.ordered ? `第 ${index + 1} 张,` : ""}${details.name},${details.suit}${details.number},${details.nature},查看详情`); if (section.ordered) element("span", "ms-deck-order", index === 0 ? "1 · 下一张" : `${index + 1}`, tile); - preview(card, details, tile); + preview(card, details, tile, catalog); element("span", "ms-deck-excerpt", details.description, tile); }); } diff --git a/mengsan/ui/hand/fan.js b/mengsan/ui/hand/fan.js new file mode 100644 index 0000000..bfc1ea5 --- /dev/null +++ b/mengsan/ui/hand/fan.js @@ -0,0 +1,89 @@ +// 只覆盖命名空间内的 CSS 变量,不移动或复制真实卡牌。 +export function fanSlots(count, width, cardWidth = 112, cardHeight = 156) { + if (!Number.isSafeInteger(count) || count < 0 || !Number.isFinite(width) || width <= 0) return []; + const step = count > 1 ? Math.min(cardWidth * 0.68, + Math.max(0, width - cardWidth - 32) / (count - 1)) : 0; + const half = (count - 1) / 2; + const angleStep = count > 1 ? Math.min(4, 13 / half) : 0; + return Array.from({ length: count }, (_, index) => { + const offset = index - half; + return { x: width / 2 + offset * step - cardWidth / 2, + y: -cardHeight + 28 + (half ? Math.pow(offset / half, 2) * 20 : 0), + angle: offset * angleStep, z: index + 1 }; + }); +} + +export function createHandFan(adapter, env) { + const { window } = env; + const owned = new Map(); + let enabled = false, hover = null, dragging = null; + function variable(card, key, value) { + let record = owned.get(card); + if (!record) { record = new Map(); owned.set(card, record); } + if (!record.has(key)) record.set(key, { + before: card.style.getPropertyValue(key), priority: card.style.getPropertyPriority(key), last: null, + }); + const entry = record.get(key); + entry.last = value; + if (card.style.getPropertyValue(key) !== value) card.style.setProperty(key, value); + } + function release(card) { + for (const name of ["mengsan-hand-hover-shuying", "mengsan-hand-drag-shuying", "mengsan-hand-free-drag-shuying"]) card.classList.remove(name); + for (const [key, record] of owned.get(card) || []) { + if (card.style.getPropertyValue(key) !== record.last) continue; + if (record.before) card.style.setProperty(key, record.before, record.priority); + else card.style.removeProperty(key); + } + owned.delete(card); + } + function setHover(card) { + if (hover === card) return; + hover?.classList.remove("mengsan-hand-hover-shuying"); + hover = enabled && !dragging ? card : null; + hover?.classList.add("mengsan-hand-hover-shuying"); + } + function sync(active) { + enabled = active; + const cards = adapter.cards().filter(card => !card.classList.contains("display-none")); + const current = new Set(cards); + for (const card of owned.keys()) if (!active || !current.has(card)) release(card); + if (!active) { setHover(null); return; } + const small = window.innerWidth <= 640 || window.innerHeight <= 500; + const width = adapter.containers[0]?.clientWidth || adapter.root.clientWidth; + const slots = fanSlots(cards.length, width, small ? 82 : 112, small ? 114 : 156); + cards.forEach((card, index) => { + const slot = slots[index]; + if (!slot) return; + variable(card, "--mengsan-fan-x", `${slot.x}px`); + variable(card, "--mengsan-fan-y", `${slot.y}px`); + variable(card, "--mengsan-fan-angle", `${slot.angle}deg`); + variable(card, "--mengsan-fan-z", `${slot.z}`); + }); + if (hover && !current.has(hover)) setHover(null); + } + function drag(card, point, targeted) { + if (dragging && dragging !== card) stopDrag(); + setHover(null); + dragging = card; + card.classList.add("mengsan-hand-drag-shuying"); + card.classList.toggle("mengsan-hand-free-drag-shuying", !targeted); + const zone = card.parentNode; + const rect = zone.getBoundingClientRect(); + // CSS 像素相对于真实父容器:兼容 documentZoom/缩放,不能直接拿屏幕坐标当布局坐标。 + const scaleX = rect.width / zone.offsetWidth || 1; + const scaleY = rect.height / zone.offsetHeight || scaleX; + const rootRect = adapter.root.getBoundingClientRect(); + const small = window.innerWidth <= 640 || window.innerHeight <= 500; + const width = small ? 82 : 112, height = small ? 114 : 156; + const x = targeted ? rootRect.left + rootRect.width / 2 : point.x; + const y = targeted ? rootRect.bottom - height * scaleY / 2 - 6 : point.y; + variable(card, "--mengsan-drag-x", `${(x - rect.left) / scaleX - width / 2}px`); + variable(card, "--mengsan-drag-y", `${(y - rect.top) / scaleY - height / 2}px`); + } + function stopDrag() { + dragging?.classList.remove("mengsan-hand-drag-shuying", "mengsan-hand-free-drag-shuying"); + dragging = null; + } + return { sync, setHover, drag, stopDrag, + dispose() { stopDrag(); setHover(null); for (const card of owned.keys()) release(card); } }; +} diff --git a/mengsan/ui/hand/index.js b/mengsan/ui/hand/index.js index 0d3e59d..7deaf68 100644 --- a/mengsan/ui/hand/index.js +++ b/mengsan/ui/hand/index.js @@ -1,11 +1,15 @@ import { createHandAdapter } from "./adapter.js"; import { createHandOverlays } from "./overlays.js"; import { createHandDiagnostics } from "./diagnostics.js"; +import { createHandFan } from "./fan.js"; +import { createHandInput } from "./input.js"; export async function mountHandUI(player, session, env) { const { ui, document, window, cardCost, styleURL } = env; const adapter = createHandAdapter(player, ui); - const overlays = createHandOverlays(document, cardCost); + const overlays = createHandOverlays(document, card => cardCost(card, player)); + const fan = createHandFan(adapter, env); + let input = null; const restores = []; const diagnose = createHandDiagnostics(env.game, session.id); const style = document.createElement("link"); @@ -58,6 +62,8 @@ export async function mountHandUI(player, session, env) { } const supported = adapter.supported(); setLayout(supported && !nativeInput); + fan.sync(supported && !nativeInput); + input?.reconcile(); if (!supported && !warned) { warned = true; log("当前容器不支持独立排列,已保留原排列与费用/强化标记"); @@ -68,17 +74,35 @@ export async function mountHandUI(player, session, env) { target.addEventListener(type, listener, options); restores.push(() => target.removeEventListener(type, listener, options)); } + // 不可使用的真实手牌不能触发旧手势的横排回退;选中牌保留原生交互。 + function canBeginNativeInput(card) { + if (!adapter.zones.includes(card?.parentNode) || + card.classList.contains("noclick") || + (!card.classList.contains("selectable") && + !card.classList.contains("selected"))) return false; + const current = env._status?.event; + return !(current?.name === "chooseToUse" && current.player === player && + env.isActiveCardUse?.(current, player) && + env.canPayCard?.(player, card) === false); + } function beginInput(event) { + if (input?.owns()) return; const card = event.target.closest?.(".card"); if (event.button !== 0 || pointer || - !adapter.zones.includes(card?.parentNode)) { + !canBeginNativeInput(card)) { return; } - pointer = { id: event.pointerId, x: event.clientX, y: event.clientY, + pointer = { card, id: event.pointerId, x: event.clientX, y: event.clientY, touch: event.pointerType === "touch" }; } function moveInput(event) { - if (!pointer || pointer.id !== event.pointerId || nativeInput) return; + if (input?.owns()) return; + if (!pointer || pointer.id !== event.pointerId) return; + if (!canBeginNativeInput(pointer.card)) { endInput(event); return; } + if (nativeInput) return; + // 未接管的上拖(包括响应牌或已选牌)不属于手牌排序,不退回横排。 + const dx = event.clientX - pointer.x, dy = event.clientY - pointer.y; + if (dy < 0 && Math.abs(dy) >= Math.abs(dx)) return; if (Math.hypot(event.clientX - pointer.x, event.clientY - pointer.y) < 8) { return; } @@ -131,6 +155,8 @@ export async function mountHandUI(player, session, env) { restoreTouchScroll(); observer?.disconnect(); resizeObserver?.disconnect(); + input?.dispose(); + fan.dispose(); overlays.dispose(); setLayout(false); for (const restore of restores.reverse()) restore(); @@ -157,6 +183,8 @@ export async function mountHandUI(player, session, env) { document.head.appendChild(style); }); if (!session.active || disposed) { dispose(); return { refresh, dispose }; } + ownClass(adapter.root, "mengsan-hand-root-shuying"); + ownClass(document.body, "mengsan-hand-ui-shuying"); for (const zone of adapter.zones) { ownClass(zone, "mengsan-hand-zone-shuying"); } @@ -164,7 +192,7 @@ export async function mountHandUI(player, session, env) { if (!container.hasAttribute("tabindex")) { ownAttribute(container, "tabindex", "0"); } - ownAttribute(container, "aria-label", "梦三手牌,可横向滚动"); + ownAttribute(container, "aria-label", "梦三扇形手牌,悬停查看;上拖出牌,拖向目标后松手确认"); listen(container, "wheel", scrollWheel, { capture: true, passive: false }); listen(container, "touchmove", allowTouchScroll, true); @@ -173,8 +201,10 @@ export async function mountHandUI(player, session, env) { } observer = new window.MutationObserver(refresh); for (const zone of adapter.zones) { - observer.observe(zone, { childList: true }); + observer.observe(zone, { childList: true, subtree: true, + attributes: true, attributeFilter: ["class"] }); } + input = createHandInput(player, session, adapter, fan, env, refresh, log); if (adapter.root?.parentNode) { observer.observe(adapter.root.parentNode, { childList: true }); } diff --git a/mengsan/ui/hand/input.js b/mengsan/ui/hand/input.js new file mode 100644 index 0000000..8c56373 --- /dev/null +++ b/mengsan/ui/hand/input.js @@ -0,0 +1,255 @@ +// 梦三局部输入适配:使用真实 ui.click/card/target 和 game.check 提交,不自行 useCard。 +export function createHandInput(player, session, adapter, fan, env, refresh, log) { + const { ui, game, get, _status: status, document, window } = env; + const listeners = []; + const maskedControls = new Set(), submittedControls = new Set(); + const maskClass = "mengsan-hand-controls-hidden-shuying"; + function maskControls(hidden) { + const wanted = hidden ? new Set([ui.control, ui.confirm, gesture?.event.endButton]) : new Set(); + for (const node of maskedControls) { + if (wanted.has(node)) continue; + node.classList.remove(maskClass); maskedControls.delete(node); + } + // ui.control 是不同 UI 共同的控件入口;独立确认/结束按钮也可能挂在外部。 + for (const node of wanted) { + if (!node?.classList || node.classList.contains(maskClass)) continue; + node.classList.add(maskClass); maskedControls.add(node); + } + } + function closeSubmittedControls(state) { + for (const node of new Set([ui.confirm, state.event.endButton])) { + if (typeof node?.close !== "function") continue; + if (node.classList && !node.classList.contains("mengsan-hand-submitted-control-shuying")) { + node.classList.add("mengsan-hand-submitted-control-shuying"); submittedControls.add(node); + } + // 与本体 windowmouseup 的提交路径一致:关闭旧确认后再调用 ui.click.ok。 + node.close(); + } + } + let gesture = null, suppressed = null, aim = null, frame = null, lastPoint = null; + const listen = (node, type, handler, options = true) => { + const safeHandler = event => { + try { handler(event); } + catch (error) { + log("手牌局部输入异常,取消本次拖动", error); + try { cleanup(true); } + catch (cause) { log("取消输入时引擎异常", cause); } + } + }; + node.addEventListener(type, safeHandler, options); + listeners.push(() => node.removeEventListener(type, safeHandler, options)); + }; + const swallow = event => { event.preventDefault(); event.stopImmediatePropagation(); }; + const live = state => session.active && adapter.supported() && state?.card.isConnected && + adapter.zones.includes(state.card.parentNode) && status.event === state.event && + !state.event.skill && state.event.isMine?.() && player.isAlive?.() !== false; + // 引擎 checkEnd 的自动确认必须在选牌/试选目标期间禁用;仅在本次同步调用内改标志。 + function engineCall(callback) { + const previous = {}; + for (const key of ["touchnocheck", "clicked", "dragged"]) previous[key] = { own: Object.hasOwn(status, key), value: status[key] }; + status.touchnocheck = true; status.clicked = false; status.dragged = false; + try { return callback(); } + finally { + for (const [key, old] of Object.entries(previous)) { + if (old.own) status[key] = old.value; + else delete status[key]; + } + } + } + function range(event) { + const value = get.select(event.selectTarget); + if (!Array.isArray(value) || value.length < 2 || value.some(n => !Number.isFinite(n))) return null; + return value; + } + function eligible(card) { + const event = status?.event; + if (!event || typeof ui.click?.card !== "function" || typeof ui.click?.ok !== "function" || + typeof get?.select !== "function" || typeof env.isActiveCardUse !== "function" || + !session.active || !adapter.supported() || ui.intro || game.online || + event.name !== "chooseToUse" || event.player !== player || !event.isMine?.() || + event.skill || event.complexSelect || event.custom?.replace?.card || event.custom?.replace?.target || event.custom?.replace?.confirm || + !env.isActiveCardUse(event, player) || !env.canPayCard?.(player, card) || + ui.selected.cards.length || ui.selected.targets.length || ui.selected.buttons.length || + !card.classList.contains("selectable") || card.classList.contains("noclick") || + status.mousedragging || status.dragged || status.paused2) return false; + const cards = get.select(event.selectCard); + return cards?.[0] === 1 && cards?.[1] === 1; + } + function removeAim() { aim?.remove(); aim = null; } + function showAim(card, point) { + if (!aim) { + aim = document.createElementNS("http://www.w3.org/2000/svg", "svg"); + aim.classList.add("mengsan-hand-aim-shuying"); + aim.setAttribute("aria-hidden", "true"); + const path = document.createElementNS(aim.namespaceURI, "path"); + aim.appendChild(path); document.body.appendChild(aim); + } + const rect = card.getBoundingClientRect(); + const x = rect.left + rect.width / 2, y = rect.top + 12; + aim.setAttribute("viewBox", `0 0 ${window.innerWidth} ${window.innerHeight}`); + // clientX/Y 与卡牌矩形都是视口坐标,SVG 本身却可能受到 body zoom/transform 影响。 + // 用 SVG 实际屏幕矩阵反变换,不能重复除 documentZoom 或假定 SVG 没有偏移。 + const matrix = aim.getScreenCTM(); + if (!matrix) { removeAim(); return; } + const inverse = matrix.inverse(); + const start = new window.DOMPoint(x, y).matrixTransform(inverse); + const end = new window.DOMPoint(point.x, point.y).matrixTransform(inverse); + if (![start.x, start.y, end.x, end.y].every(Number.isFinite)) { removeAim(); return; } + // 十周年金色细线(本地 CONFIG.LINE_COLOR/LINE_WIDTH),不采用尖塔箭头/骨节造型。 + aim.firstElementChild.setAttribute("d", `M ${start.x} ${start.y} L ${end.x} ${end.y}`); + aim.firstElementChild.setAttribute("vector-effect", "non-scaling-stroke"); + } + function clearSelection(state) { + if (!live(state) || !state.selected) return; + engineCall(() => { game.uncheck(); game.check(); }); + } + function cleanup(cancel = false) { + const state = gesture; + gesture = null; + if (frame != null) window.cancelAnimationFrame(frame); + frame = null; lastPoint = null; + removeAim(); fan.stopDrag(); + maskControls(false); + if (cancel && state) clearSelection(state); + if (state) suppressed = { card: state.card, until: Date.now() + 450, + x: state.endX ?? state.x, y: state.endY ?? state.y }; + refresh(); + } + function start(event) { + if (event.button !== 0 || !event.isPrimary || gesture) return; + const card = event.target.closest?.(".card"); + if (!card || !adapter.zones.includes(card.parentNode) || !eligible(card)) return; + // 在 window 捕获,早于十周年的手牌排序和本体的 windowmousedown;不改全局处理器。 + gesture = { card, event: status.event, id: event.pointerId, + x: event.clientX, y: event.clientY, moved: false, selected: false, targeted: false, manual: false }; + swallow(event); + } + function targetAt(point) { + const node = document.elementFromPoint(point.x, point.y)?.closest?.(".player"); + return game.players?.includes(node) ? node : null; + } + function chooseTarget(state, point) { + if (!state.targeted || state.manual) return; + const target = targetAt(point); + for (const previous of ui.selected.targets.slice()) { + if (previous !== target) engineCall(() => ui.click.target.call(previous)); + } + if (target?.classList.contains("selectable") && !ui.selected.targets.includes(target)) { + engineCall(() => ui.click.target.call(target)); + } + } + function paint() { + frame = null; + const state = gesture, point = lastPoint; + if (!state || !point || !state.moved) return; + if (!live(state) || !env.canPayCard(player, state.card)) { cleanup(true); return; } + fan.drag(state.card, point, state.targeted); + if (state.targeted) showAim(state.card, point); + chooseTarget(state, point); + maskControls(!state.manual); + } + function move(event) { + const state = gesture; + if (!state || event.pointerId !== state.id) return; + swallow(event); + if (!live(state)) { cleanup(true); return; } + const point = { x: event.clientX, y: event.clientY }; + if (!state.moved && Math.hypot(point.x - state.x, point.y - state.y) < 9) return; + if (!state.moved) { + state.moved = true; + engineCall(() => ui.click.card.call(state.card)); + state.selected = ui.selected.cards.includes(state.card); + const targets = range(state.event); + if (!state.selected || !live(state) || !targets) { cleanup(true); return; } + state.targeted = targets[1] > 0; + // 多目标保留选牌和原生确认,不擅自把一条线当作已选全部目标。 + state.manual = state.targeted && (targets[0] !== 1 || targets[1] !== 1); + } + lastPoint = point; + if (frame == null) frame = window.requestAnimationFrame(() => { + try { paint(); } + catch (error) { log("手牌拖动绘制失败", error); cleanup(true); } + }); + } + function end(event) { + const state = gesture; + if (!state || state.id !== event.pointerId) return; + swallow(event); + state.endX = event.clientX; state.endY = event.clientY; + if (!live(state) || event.type === "pointercancel") { cleanup(true); return; } + if (!state.moved) { + cleanup(); + // 短点击保持原生选牌/自动确认行为,不强制进入拖动。 + status.clicked = false; + ui.click.card.call(state.card); + return; + } + lastPoint = { x: event.clientX, y: event.clientY }; + paint(); + if (!gesture) return; + const targets = range(state.event); + if (targets && targets[1] > 0 && (targets[0] !== 1 || targets[1] !== 1)) state.manual = true; + const ready = env.canPayCard(player, state.card) && ui.selected.cards.length === 1 && + ui.selected.cards[0] === state.card && targets && + (!state.targeted ? state.y - event.clientY >= 60 && + event.clientY < adapter.root.getBoundingClientRect().top : + !state.manual && targets[0] === 1 && targets[1] === 1 && + targetAt(lastPoint) === ui.selected.targets[0] && ui.selected.targets.length === 1); + if (ready && engineCall(() => game.check()) && live(state)) { + closeSubmittedControls(state); + cleanup(); + ui.click.ok(); + } else { + cleanup(!state.manual); + } + } + function compatibility(event) { + if (gesture || (event.type === "click" && suppressed && Date.now() < suppressed.until && + (suppressed.card.contains(event.target) || Math.hypot(event.clientX - suppressed.x, event.clientY - suppressed.y) < 3))) swallow(event); + } + function hover(event) { + if (gesture || event.pointerType === "touch") return; + const card = event.target.closest?.(".card"); + fan.setHover(adapter.zones.includes(card?.parentNode) ? card : null); + } + function cancel(event) { + if (!gesture) return; + if (event?.type === "keydown" && event.key !== "Escape") return; + if (event?.cancelable) swallow(event); + cleanup(true); + } + if (window.PointerEvent && adapter.root && status && get) { + listen(window, "pointerdown", start); + listen(window, "pointermove", move); + listen(window, "pointerup", end); + listen(window, "pointercancel", cancel); + listen(window, "blur", cancel); + listen(window, "keydown", cancel); + listen(window, "contextmenu", cancel); + for (const type of ["mousedown", "mousemove", "mouseup", "click", "touchstart", "touchmove", "touchend"]) { + listen(window, type, compatibility, { capture: true, passive: false }); + } + } + listen(adapter.root, "pointerover", hover); + listen(adapter.root, "pointerleave", () => { if (!gesture) fan.setHover(null); }); + return { + owns: () => Boolean(gesture), + reconcile() { + if (gesture && (!live(gesture) || !gesture.card.isConnected)) cleanup(true); + else if (gesture?.moved) maskControls(!gesture.manual); + for (const node of submittedControls) { + if (node.isConnected) continue; + node.classList.remove("mengsan-hand-submitted-control-shuying"); submittedControls.delete(node); + } + }, + dispose() { + try { cleanup(true); } + catch (error) { log("手牌拖动取消失败", error); } + for (const release of listeners.reverse()) release(); + maskControls(false); + for (const node of submittedControls) node.classList.remove("mengsan-hand-submitted-control-shuying"); + submittedControls.clear(); + suppressed = null; + }, + }; +} diff --git a/mengsan/ui/hand/overlays.js b/mengsan/ui/hand/overlays.js index f23cea8..1159701 100644 --- a/mengsan/ui/hand/overlays.js +++ b/mengsan/ui/hand/overlays.js @@ -21,7 +21,8 @@ export function createHandOverlays(document, cardCost) { const cost = String(cardCost(card)); setData(entry.cost, "mengsanCost", cost); entry.cost.setAttribute("aria-label", `费用 ${cost}`); - if (entry.cost.parentNode !== card) card.appendChild(entry.cost); + if (card.name === "mengsan_dazed_shuying") entry.cost.remove(); + else if (entry.cost.parentNode !== card) card.appendChild(entry.cost); const level = cardUpgradeLevel(card); if (level) { setData(entry.upgrade, "mengsanUpgrade", String(level)); diff --git a/mengsan/ui/hand/style.css b/mengsan/ui/hand/style.css index c7291aa..ae11509 100644 --- a/mengsan/ui/hand/style.css +++ b/mengsan/ui/hand/style.css @@ -4,12 +4,21 @@ --mengsan-hand-ink: #fff1cb; } .mengsan-hand-scroll-shuying { - overflow-x: auto !important; - overflow-y: hidden !important; + overflow: visible !important; + left: 0 !important; + top: 0 !important; + bottom: 0 !important; + width: 100% !important; + height: 100% !important; + padding: 0 !important; + pointer-events: none; scrollbar-width: thin; scrollbar-color: #d8ba83 #234844; overscroll-behavior-x: contain; - touch-action: pan-x; + touch-action: none; +} +.mengsan-hand-root-shuying { + overflow: visible !important; } .mengsan-hand-scroll-shuying:focus-visible { outline: 2px solid #d8ba83; @@ -17,37 +26,88 @@ } .mengsan-hand-zone-shuying.mengsan-hand-layout-shuying { position: relative !important; - display: flex !important; - align-items: flex-end; - gap: 8px; - width: max-content !important; - min-width: 100% !important; + display: block !important; + width: 100% !important; height: 100% !important; left: 0 !important; top: auto !important; bottom: 0 !important; margin: 0 !important; - padding: 24px 8px 6px !important; + padding: 0 !important; box-sizing: border-box; + pointer-events: none; + overflow: visible !important; } .mengsan-hand-zone-shuying.mengsan-hand-layout-shuying > .card { - position: relative !important; - flex: 0 0 auto; - width: 90px !important; - height: 110px !important; - left: auto !important; + position: absolute !important; + width: 112px !important; + height: 156px !important; + left: 0 !important; right: auto !important; - top: auto !important; + top: 100% !important; bottom: auto !important; margin: 0 !important; - transform: none !important; - transition: transform 120ms ease-out !important; + transform-origin: 50% 100% !important; + transform: translate(var(--mengsan-fan-x, 0px), var(--mengsan-fan-y, -128px)) rotate(var(--mengsan-fan-angle, 0deg)) !important; + z-index: var(--mengsan-fan-z, 1) !important; + transition: transform 160ms cubic-bezier(.2,.7,.3,1), box-shadow 160ms ease-out !important; + pointer-events: auto; + touch-action: none; } .mengsan-hand-zone-shuying.mengsan-hand-layout-shuying > .card.selected { - transform: translateY(-18px) !important; + transform: translate(var(--mengsan-fan-x, 0px), calc(var(--mengsan-fan-y, -128px) - 20px)) rotate(var(--mengsan-fan-angle, 0deg)) !important; outline: 2px solid var(--mengsan-hand-gold); outline-offset: -2px; } +.mengsan-hand-zone-shuying.mengsan-hand-layout-shuying > .card.mengsan-hand-hover-shuying { + transform: translate(var(--mengsan-fan-x, 0px), calc(var(--mengsan-fan-y, -128px) - 62px)) rotate(0deg) scale(1.35) !important; + z-index: 220 !important; + box-shadow: 0 0 0 2px #b6e8de, 0 0 14px #68b6a58c, 0 12px 22px #0009; +} +.mengsan-hand-zone-shuying.mengsan-hand-layout-shuying > .card.mengsan-hand-drag-shuying { + top: 0 !important; + transform: translate(var(--mengsan-drag-x), var(--mengsan-drag-y)) rotate(0deg) !important; + z-index: 240 !important; + pointer-events: none !important; + transition: none !important; + box-shadow: 0 0 0 2px #b6e8de, 0 0 14px #68b6a58c, 0 12px 22px #0009; +} +.mengsan-hand-zone-shuying.mengsan-hand-layout-shuying > .card.mengsan-hand-free-drag-shuying { + transform: translate(var(--mengsan-drag-x), var(--mengsan-drag-y)) rotate(0deg) scale(1.22) !important; + transform-origin: 50% 50% !important; +} +.mengsan-hand-aim-shuying { + position: fixed; + inset: 0; + width: 100vw; + height: 100vh; + z-index: 1000; + pointer-events: none; + overflow: visible; +} +.mengsan-hand-aim-shuying > path { + fill: none; + stroke: rgb(250, 220, 140); + stroke-width: 2.6; + stroke-linecap: round; + filter: drop-shadow(0 0 2px #f5d25b); +} +/* 只在梦三手牌挂载期间隐藏十周年阶段、等待响应和对方思考图片。 */ +body.mengsan-hand-ui-shuying [style*="extension/十周年UI/ui/assets/lbtn/tips/"]:is( + [style*="/tips/hhks."], [style*="/tips/zbjd."], [style*="/tips/pdjd."], + [style*="/tips/mpjd."], [style*="/tips/cpjd."], [style*="/tips/qpjd."], + [style*="/tips/jsjd."], [style*="/tips/hhjs."], + [style*="/tips/ddxy."], [style*="/tips/dfsk."] +) { + display: none !important; +} +body.mengsan-hand-ui-shuying :is( + .mengsan-hand-controls-hidden-shuying, + .mengsan-hand-submitted-control-shuying +) { + visibility: hidden !important; + pointer-events: none !important; +} .mengsan-hand-zone-shuying.mengsan-hand-layout-shuying > .card.removing, .mengsan-hand-zone-shuying.mengsan-hand-layout-shuying > .card.display-none { display: none !important; @@ -86,8 +146,11 @@ content: attr(data-mengsan-cost); } .mengsan-hand-zone-shuying > .card > .mengsan-hand-upgrade-shuying { - top: 4px !important; - right: 4px !important; + /* 扇形叠牌露出左侧:角标排在花色下、费用上,不抬高整张牌的层级。 */ + top: calc(max(48%, 64px) - 25px) !important; + left: 3px !important; + right: auto !important; + white-space: nowrap; width: 26px; height: 20px; border: 1px solid #86bd85; @@ -95,13 +158,27 @@ background: #214f36; color: #f5ffea; } +/* 只为带强化角标的真实手牌预留左侧信息栏,避免窄屏挤压。 */ +.mengsan-hand-zone-shuying > .card:has(> .mengsan-hand-upgrade-shuying) > .mengsan-hand-cost-shuying { + top: max(48%, 64px) !important; +} +.mengsan-hand-zone-shuying > .card:has(> .mengsan-hand-upgrade-shuying) > .suit-num { + top: 6px !important; +} +@media (max-width: 640px), (max-height: 500px) { + .mengsan-hand-zone-shuying > .card:has(> .mengsan-hand-upgrade-shuying) > .suit-num { + top: 4px !important; + font-size: 20px !important; + line-height: 16px !important; + } +} .mengsan-hand-zone-shuying > .card > .mengsan-hand-upgrade-shuying::before { content: "+" attr(data-mengsan-upgrade); } @media (max-width: 640px), (max-height: 500px) { .mengsan-hand-zone-shuying.mengsan-hand-layout-shuying > .card { - width: 80px !important; - height: 100px !important; + width: 82px !important; + height: 114px !important; } } @media (prefers-reduced-motion: reduce) { @@ -109,3 +186,88 @@ transition: none !important; } } + +/* 梦三本地竖幅立绘使用完整卡面;只协调梦三手牌里的原生显示节点。 */ +.mengsan-hand-zone-shuying > .card:is([data-card-name="mengsan_zhongsha"], [data-card-name="mengsan_fangyu"], [data-card-name="mengsan_fennu"], [data-card-name="mengsan_feijianhuixuanbiao"], [data-card-name="mengsan_jianbingdaji"], [data-card-name="mengsan_quanshenzhuangji"], [data-card-name="mengsan_rongrongzhiquan"], [data-card-name="mengsan_shandianpili"], [data-card-name="mengsan_shuangchongdaji"], [data-card-name="mengsan_tiezhanbo"], [data-card-name="mengsan_touchui"], [data-card-name="mengsan_tupo"], [data-card-name="mengsan_yubeidaji"], [data-card-name="mengsan_wanmeidaji"], [data-card-name="mengsan_yujin"], [data-card-name="mengsan_fangxue"], [data-card-name="mengsan_jianyi"], [data-card-name="mengsan_pomie"], [data-card-name="mengsan_songjianwushi"], [data-card-name="mengsan_dazed_shuying"], [data-card-name="mengsan_slimed_shuying"], [data-card-name="mengsan_infection_shuying"]):not(.infohidden):not(.infoflip) { + background-size: cover !important; + background-position: center !important; +} +.mengsan-hand-zone-shuying > .card:is([data-card-name="mengsan_zhongsha"], [data-card-name="mengsan_fangyu"], [data-card-name="mengsan_fennu"], [data-card-name="mengsan_feijianhuixuanbiao"], [data-card-name="mengsan_jianbingdaji"], [data-card-name="mengsan_quanshenzhuangji"], [data-card-name="mengsan_rongrongzhiquan"], [data-card-name="mengsan_shandianpili"], [data-card-name="mengsan_shuangchongdaji"], [data-card-name="mengsan_tiezhanbo"], [data-card-name="mengsan_touchui"], [data-card-name="mengsan_tupo"], [data-card-name="mengsan_yubeidaji"], [data-card-name="mengsan_wanmeidaji"], [data-card-name="mengsan_yujin"], [data-card-name="mengsan_fangxue"], [data-card-name="mengsan_jianyi"], [data-card-name="mengsan_pomie"], [data-card-name="mengsan_songjianwushi"], [data-card-name="mengsan_dazed_shuying"], [data-card-name="mengsan_slimed_shuying"], [data-card-name="mengsan_infection_shuying"]):not(.infohidden):not(.infoflip) > :is(.name, .top-name) { + display: block !important; + top: auto !important; + bottom: 8px !important; + left: auto !important; + right: 8px !important; + width: auto !important; + height: auto !important; + padding: 3px 6px !important; + margin: 0 !important; + writing-mode: horizontal-tb !important; + -webkit-writing-mode: horizontal-tb !important; + font-size: 16px !important; + line-height: 20px !important; + letter-spacing: 1px !important; + color: #fff1cb !important; + text-shadow: 0 1px 2px #000 !important; + background: #231b17e6 !important; + border-radius: 3px; + z-index: 7 !important; + pointer-events: none; +} +.mengsan-hand-zone-shuying > .card:is([data-card-name="mengsan_zhongsha"], [data-card-name="mengsan_fangyu"], [data-card-name="mengsan_fennu"], [data-card-name="mengsan_feijianhuixuanbiao"], [data-card-name="mengsan_jianbingdaji"], [data-card-name="mengsan_quanshenzhuangji"], [data-card-name="mengsan_rongrongzhiquan"], [data-card-name="mengsan_shandianpili"], [data-card-name="mengsan_shuangchongdaji"], [data-card-name="mengsan_tiezhanbo"], [data-card-name="mengsan_touchui"], [data-card-name="mengsan_tupo"], [data-card-name="mengsan_yubeidaji"], [data-card-name="mengsan_wanmeidaji"], [data-card-name="mengsan_yujin"], [data-card-name="mengsan_fangxue"], [data-card-name="mengsan_jianyi"], [data-card-name="mengsan_pomie"], [data-card-name="mengsan_songjianwushi"], [data-card-name="mengsan_dazed_shuying"], [data-card-name="mengsan_slimed_shuying"], [data-card-name="mengsan_infection_shuying"]):not(.infohidden):not(.infoflip) > :is(.name, .top-name) br { + display: none; +} +/* 十周年同时保留竖排名称与 top-name 时,只显示后者。 */ +.mengsan-hand-zone-shuying > .card:is([data-card-name="mengsan_zhongsha"], [data-card-name="mengsan_fangyu"], [data-card-name="mengsan_fennu"], [data-card-name="mengsan_feijianhuixuanbiao"], [data-card-name="mengsan_jianbingdaji"], [data-card-name="mengsan_quanshenzhuangji"], [data-card-name="mengsan_rongrongzhiquan"], [data-card-name="mengsan_shandianpili"], [data-card-name="mengsan_shuangchongdaji"], [data-card-name="mengsan_tiezhanbo"], [data-card-name="mengsan_touchui"], [data-card-name="mengsan_tupo"], [data-card-name="mengsan_yubeidaji"], [data-card-name="mengsan_wanmeidaji"], [data-card-name="mengsan_yujin"], [data-card-name="mengsan_fangxue"], [data-card-name="mengsan_jianyi"], [data-card-name="mengsan_pomie"], [data-card-name="mengsan_songjianwushi"], [data-card-name="mengsan_dazed_shuying"], [data-card-name="mengsan_slimed_shuying"], [data-card-name="mengsan_infection_shuying"]):not(.infohidden):not(.infoflip):has(> .top-name) > .name { + display: none !important; +} +.mengsan-hand-zone-shuying > .card:is([data-card-name="mengsan_zhongsha"], [data-card-name="mengsan_fangyu"], [data-card-name="mengsan_fennu"], [data-card-name="mengsan_feijianhuixuanbiao"], [data-card-name="mengsan_jianbingdaji"], [data-card-name="mengsan_quanshenzhuangji"], [data-card-name="mengsan_rongrongzhiquan"], [data-card-name="mengsan_shandianpili"], [data-card-name="mengsan_shuangchongdaji"], [data-card-name="mengsan_tiezhanbo"], [data-card-name="mengsan_touchui"], [data-card-name="mengsan_tupo"], [data-card-name="mengsan_yubeidaji"], [data-card-name="mengsan_wanmeidaji"], [data-card-name="mengsan_yujin"], [data-card-name="mengsan_fangxue"], [data-card-name="mengsan_jianyi"], [data-card-name="mengsan_pomie"], [data-card-name="mengsan_songjianwushi"], [data-card-name="mengsan_dazed_shuying"], [data-card-name="mengsan_slimed_shuying"], [data-card-name="mengsan_infection_shuying"]):not(.infohidden):not(.infoflip) > :is(.info:not(.gaintag), .suit-num) { + top: 6px !important; + left: 6px !important; + right: auto !important; + bottom: auto !important; + width: auto !important; + height: auto !important; + margin: 0 !important; + padding: 2px 4px !important; + font-size: 18px !important; + line-height: 18px !important; + letter-spacing: 0 !important; + color: #231b17 !important; + text-shadow: none !important; + background: #fff1cbe6 !important; + border-radius: 3px; + z-index: 7 !important; + pointer-events: none; +} + +.mengsan-hand-zone-shuying > .card:is([data-card-name="mengsan_zhongsha"], [data-card-name="mengsan_fangyu"], [data-card-name="mengsan_fennu"], [data-card-name="mengsan_feijianhuixuanbiao"], [data-card-name="mengsan_jianbingdaji"], [data-card-name="mengsan_quanshenzhuangji"], [data-card-name="mengsan_rongrongzhiquan"], [data-card-name="mengsan_shandianpili"], [data-card-name="mengsan_shuangchongdaji"], [data-card-name="mengsan_tiezhanbo"], [data-card-name="mengsan_touchui"], [data-card-name="mengsan_tupo"], [data-card-name="mengsan_yubeidaji"], [data-card-name="mengsan_wanmeidaji"], [data-card-name="mengsan_yujin"], [data-card-name="mengsan_fangxue"], [data-card-name="mengsan_jianyi"], [data-card-name="mengsan_pomie"], [data-card-name="mengsan_songjianwushi"], [data-card-name="mengsan_dazed_shuying"], [data-card-name="mengsan_slimed_shuying"], [data-card-name="mengsan_infection_shuying"]) > .image, +.mengsan-hand-zone-shuying > .card:is([data-card-name="mengsan_zhongsha"], [data-card-name="mengsan_fangyu"], [data-card-name="mengsan_fennu"], [data-card-name="mengsan_feijianhuixuanbiao"], [data-card-name="mengsan_jianbingdaji"], [data-card-name="mengsan_quanshenzhuangji"], [data-card-name="mengsan_rongrongzhiquan"], [data-card-name="mengsan_shandianpili"], [data-card-name="mengsan_shuangchongdaji"], [data-card-name="mengsan_tiezhanbo"], [data-card-name="mengsan_touchui"], [data-card-name="mengsan_tupo"], [data-card-name="mengsan_yubeidaji"], [data-card-name="mengsan_wanmeidaji"], [data-card-name="mengsan_yujin"], [data-card-name="mengsan_fangxue"], [data-card-name="mengsan_jianyi"], [data-card-name="mengsan_pomie"], [data-card-name="mengsan_songjianwushi"], [data-card-name="mengsan_dazed_shuying"], [data-card-name="mengsan_slimed_shuying"], [data-card-name="mengsan_infection_shuying"]) > .info:empty { + display: none !important; +} +.mengsan-hand-zone-shuying > .card:is([data-card-name="mengsan_zhongsha"], [data-card-name="mengsan_fangyu"], [data-card-name="mengsan_fennu"], [data-card-name="mengsan_feijianhuixuanbiao"], [data-card-name="mengsan_jianbingdaji"], [data-card-name="mengsan_quanshenzhuangji"], [data-card-name="mengsan_rongrongzhiquan"], [data-card-name="mengsan_shandianpili"], [data-card-name="mengsan_shuangchongdaji"], [data-card-name="mengsan_tiezhanbo"], [data-card-name="mengsan_touchui"], [data-card-name="mengsan_tupo"], [data-card-name="mengsan_yubeidaji"], [data-card-name="mengsan_wanmeidaji"], [data-card-name="mengsan_yujin"], [data-card-name="mengsan_fangxue"], [data-card-name="mengsan_jianyi"], [data-card-name="mengsan_pomie"], [data-card-name="mengsan_songjianwushi"], [data-card-name="mengsan_dazed_shuying"], [data-card-name="mengsan_slimed_shuying"], [data-card-name="mengsan_infection_shuying"]):is([data-suit="heart"], [data-suit="diamond"]):not(.infohidden):not(.infoflip) > :is(.info:not(.gaintag), .suit-num) { + color: #a52219 !important; +} + +/* 强化卡牌的完整卡面样式优先级更高,窄屏同样为角标留出空隙。 */ +@media (max-width: 640px), (max-height: 500px) { + .mengsan-hand-zone-shuying > .card:is([data-card-name="mengsan_zhongsha"], [data-card-name="mengsan_fangyu"], [data-card-name="mengsan_fennu"], [data-card-name="mengsan_feijianhuixuanbiao"], [data-card-name="mengsan_jianbingdaji"], [data-card-name="mengsan_quanshenzhuangji"], [data-card-name="mengsan_rongrongzhiquan"], [data-card-name="mengsan_shandianpili"], [data-card-name="mengsan_shuangchongdaji"], [data-card-name="mengsan_tiezhanbo"], [data-card-name="mengsan_touchui"], [data-card-name="mengsan_tupo"], [data-card-name="mengsan_yubeidaji"], [data-card-name="mengsan_wanmeidaji"], [data-card-name="mengsan_yujin"], [data-card-name="mengsan_fangxue"], [data-card-name="mengsan_jianyi"], [data-card-name="mengsan_pomie"], [data-card-name="mengsan_songjianwushi"], [data-card-name="mengsan_dazed_shuying"], [data-card-name="mengsan_slimed_shuying"], [data-card-name="mengsan_infection_shuying"]):not(.infohidden):not(.infoflip):has(> .mengsan-hand-upgrade-shuying) > .suit-num { + top: 4px !important; + padding: 0 2px !important; + font-size: 18px !important; + line-height: 16px !important; + } +} + +/* 长牌名完整显示,不挤出卡面;只作用于梦三手牌。 */ +.mengsan-hand-zone-shuying > .card:is([data-card-name="mengsan_feijianhuixuanbiao"], [data-card-name="mengsan_jianbingdaji"], [data-card-name="mengsan_quanshenzhuangji"], [data-card-name="mengsan_rongrongzhiquan"], [data-card-name="mengsan_shandianpili"], [data-card-name="mengsan_shuangchongdaji"], [data-card-name="mengsan_tiezhanbo"], [data-card-name="mengsan_touchui"], [data-card-name="mengsan_tupo"], [data-card-name="mengsan_yubeidaji"], [data-card-name="mengsan_wanmeidaji"], [data-card-name="mengsan_yujin"], [data-card-name="mengsan_fangxue"], [data-card-name="mengsan_jianyi"], [data-card-name="mengsan_pomie"], [data-card-name="mengsan_songjianwushi"]):not(.infohidden):not(.infoflip) > :is(.name, .top-name) { + font-size: 12px !important; + letter-spacing: 0 !important; + white-space: nowrap !important; +} +@media (max-width: 640px), (max-height: 500px) { + .mengsan-hand-zone-shuying > .card:is([data-card-name="mengsan_feijianhuixuanbiao"], [data-card-name="mengsan_jianbingdaji"], [data-card-name="mengsan_quanshenzhuangji"], [data-card-name="mengsan_rongrongzhiquan"], [data-card-name="mengsan_shandianpili"], [data-card-name="mengsan_shuangchongdaji"], [data-card-name="mengsan_tiezhanbo"], [data-card-name="mengsan_touchui"], [data-card-name="mengsan_tupo"], [data-card-name="mengsan_yubeidaji"], [data-card-name="mengsan_wanmeidaji"], [data-card-name="mengsan_yujin"], [data-card-name="mengsan_fangxue"], [data-card-name="mengsan_jianyi"], [data-card-name="mengsan_pomie"], [data-card-name="mengsan_songjianwushi"]):not(.infohidden):not(.infoflip) > :is(.name, .top-name) { + font-size: 10px !important; + padding: 2px 4px !important; + } +} diff --git a/mengsan/ui/intent-display.js b/mengsan/ui/intent-display.js index 79ed9dc..83f97d0 100644 --- a/mengsan/ui/intent-display.js +++ b/mengsan/ui/intent-display.js @@ -91,9 +91,18 @@ export function mountEnemyIntent(player, session, assetBase, document, throw new TypeError("濒死一击必须有伤害数值"); } damageLine.hidden = !hasDamage; + const hasSummon = typeof intent.summon === "string"; + const hasSlimed = Number.isInteger(intent.slimed) && intent.slimed > 0; + const hasInfection = Number.isInteger(intent.infection) && intent.infection > 0; + const hasDazed = Number.isInteger(intent.dazed) && intent.dazed > 0; const hasDebuff = typeof intent.debuff === "string" && Number.isInteger(intent.stacks) && intent.stacks > 0; - debuffLine.hidden = !hasDebuff; + debuffLine.hidden = !(hasDebuff || hasDazed || hasSlimed || hasInfection); + const debuffSrc = `${assetBase}/assets/intent/${hasDazed || hasSlimed || hasInfection ? "Intent_status_card.png" : "Intent_debuff.png"}`; + if (debuffIcon.getAttribute("src") !== debuffSrc) debuffIcon.src = debuffSrc; + if (hasInfection) debuffAmount.textContent = `×${intent.infection}`; + if (hasSlimed) debuffAmount.textContent = `×${intent.slimed}`; + if (hasDazed) debuffAmount.textContent = `×${intent.dazed}`; if (hasDebuff) { const debuffText = `×${intent.stacks}`; if (debuffAmount.textContent !== debuffText) { @@ -102,8 +111,11 @@ export function mountEnemyIntent(player, session, assetBase, document, } const hasBlock = Number.isInteger(intent.block) && intent.block > 0; const hasStrength = Number.isInteger(intent.strength) && intent.strength > 0; + const buffSrc = `${assetBase}/assets/intent/${hasSummon ? "Intent_summon.png" : "Intent_buff.png"}`; + if (buffIcon.getAttribute("src") !== buffSrc) buffIcon.src = buffSrc; blockLine.hidden = !hasBlock; - buffLine.hidden = !hasStrength; + buffLine.hidden = !(hasStrength || hasSummon); + if (hasSummon) buffAmount.textContent = "×1"; if (hasBlock) blockAmount.textContent = String(intent.block); if (hasStrength) { buffAmount.textContent = `+${intent.strength}`; @@ -136,20 +148,26 @@ export function mountEnemyIntent(player, session, assetBase, document, } if (hasDebuff) { const label = intent.debuff === "脆弱" ? "Frail" : - intent.debuff === "易伤" ? "Vulnerable" : "Debuff"; + intent.debuff === "易伤" ? "Vulnerable" : intent.debuff === "缩小" ? "Shrink" : intent.debuff === "缠结" ? "Tangled" : "Debuff"; effects.push(`${label} ×${intent.stacks}`); } + if (hasSummon) effects.push("Summon: 利齿之眼 ×1"); + if (hasDazed) effects.push(`Dazed ×${intent.dazed}`); + if (hasInfection) effects.push(`Infection ×${intent.infection}`); + if (hasSlimed) effects.push(`Slimed ×${intent.slimed}`); if (hasBlock) effects.push(`Block ${intent.block}`); if (hasStrength) effects.push(`Strength +${intent.strength}`); if (!effects.length) { damageLine.hidden = false; amount.textContent = ""; - const src = `${assetBase}/assets/intent/Intent_unknown.png`; + const src = `${assetBase}/assets/intent/${intent.sleep ? "Intent_sleep.png" : "Intent_unknown.png"}`; if (icon.getAttribute("src") !== src) icon.src = src; - effects.push("Unknown"); + effects.push(intent.sleep ? "Sleep" : "Unknown"); } const label = effects.join(", "); - badge.title = "攻击与负面效果作用于所有存活敌对角色;伤害按各目标状态计算,护甲另行抵扣。"; + badge.title = hasInfection ? `向敌对角色的个人弃牌堆加入${intent.infection}张感染;不能被打出,回合结束在手牌中每张造成3点非攻击伤害。` : intent.sleep ? "沉睡:本回合不行动,不消耗费用。" : intent.debuff === "缠结" ? "紧绕藤蔓:攻击后施加1回合缠结,攻击牌费用增加1;自身回合结束减少1回合。" : intent.shrink ? "缩小:攻击伤害减少30%,不重复叠加;来源死亡解除。" : hasSlimed ? `黏液:向敌对角色的个人弃牌堆加入${intent.slimed}张黏液;耗1费用、抽1张牌、消耗。` : hasSummon ? "虚幻孢子:召唤1只6生命的利齿之眼。" : + hasDazed ? "牵制:向敌对角色的个人弃牌堆加入3张晕眩;晕眩不能被打出,具有虚无。" : + "攻击与负面效果作用于所有存活敌对角色;伤害按各目标状态计算,护甲另行抵扣。"; if (badge.getAttribute("aria-label") !== label) badge.setAttribute("aria-label", label); badge.hidden = false; }; diff --git a/mengsan/ui/map/renderer.js b/mengsan/ui/map/renderer.js index 27a1aae..3aadbad 100644 --- a/mengsan/ui/map/renderer.js +++ b/mengsan/ui/map/renderer.js @@ -305,7 +305,9 @@ export const showMap = (run, { saveRun } = {}) => new Promise(resolve => { const back = menuContent.querySelector(".mengsan-map-menu-back-shuying"); if (back) back.disabled = busy; }), ".mengsan-bonds-shuying button") }); - createMenuAction({ icon: "book", name: "图鉴", description: "查看已发现的敌人、事件与奖励 · 开发中", onClick: () => showMenuPage("图鉴", "征程见闻与收集记录", parent => renderPlaceholder(parent, "后续将收录已发现的节点、敌人和奖励。")) }); + createMenuAction({ icon: "book", name: "图鉴", description: "卡牌图鉴 · 查看全部梦三卡牌、效果与费用", + onClick: () => { closeCardLibrary = openCardLibrary(run, { view: "catalog", title: "卡牌图鉴", eyebrow: "梦三 · 图鉴" }); } + }).setAttribute("aria-haspopup", "dialog"); createMenuAction({ icon: "record", name: "征程记录", description: `已通过 ${run.statistics?.completedNodes || 0} 个节点`, onClick: () => showMenuPage("征程记录", "本次征程的阶段统计", parent => { const list = ui.create.div(".mengsan-map-record-list-shuying", parent); [["已通过节点", run.statistics?.completedNodes || 0], ["击败敌人", run.statistics?.defeatedEnemies || 0], ["累计金币", run.statistics?.goldEarned || 0]].forEach(([label, value]) => { diff --git a/mengsan/ui/reward-package.js b/mengsan/ui/reward-package.js index 165ad37..0c6ebbd 100644 --- a/mengsan/ui/reward-package.js +++ b/mengsan/ui/reward-package.js @@ -1,5 +1,4 @@ -import { get } from "../../../../noname.js"; -import { cardUpgradeRule } from "../cards/upgrades.js"; +import { lib, get } from "../../../../noname.js"; import { getRelic } from "../relics/definitions.js"; import { chooseCardDialog, chooseRelicDialog, chooseVictoryOptions, @@ -8,12 +7,12 @@ import { const upgradeDescription = choice => { const card = choice.card; - const rule = cardUpgradeRule(card.name); - const damage = card.name === "sha" ? 6 : 8; + const info = lib.card[card.name]; const suit = get.translation(card.suit); - return `${suit}${card.number} · 伤害 ${damage} → ${rule.damage}` + - (rule.vulnerable ? `;易伤 2 → ${rule.vulnerable} 层` : "") + - "。费用不变,最多强化一次。"; + // Display this card's current rules, not eligibility or upgrade comparisons. + const description = typeof info?.cardPrompt === "function" + ? info.cardPrompt(card) : lib.translate[`${card.name}_info`] || "暂无卡牌介绍。"; + return `${suit}${card.number} · ${description}`; }; export async function chooseRewardPackage(choices, { @@ -23,59 +22,70 @@ export async function chooseRewardPackage(choices, { const upgradeChoices = pack.upgradeChoices.map(card => ({ id: card.id, card, })); - const ready = () => (!upgradeChoices.length || cardId !== null) && - (!pack.relicChoices.length || relicId !== null); const upgrade = { id: "upgrade", label: "强化一张牌", description: upgradeChoices.length - ? "从个人牌库中择一磨砺,查看强化前后的变化。" + ? "从个人牌库中选择一张牌进行磨砺。" : "当前没有可强化卡牌,此项无需选择。", isDisabled: () => !upgradeChoices.length, async choose() { const selected = await chooseCardDialog(upgradeChoices, { - title: "磨砺 · 强化一张牌", skipLabel: "返回奖励", + title: "磨砺 · 强化一张牌", skipLabel: "跳过强化", description: "择一磨砺,锋芒更盛。只强化你选中的一张牌。", describeChoice: upgradeDescription, actionLabel: "强化此牌", }); + cardId = selected; if (selected !== null) { - cardId = selected; const choice = upgradeChoices.find(card => card.id === selected); upgrade.label = `已选强化 · ${get.translation(choice.card.name)}`; upgrade.description = upgradeDescription(choice); + } else { + upgrade.label = "已跳过强化"; + upgrade.description = "不强化卡牌;点击可重新选择。"; } return RETURN_TO_VICTORY; }, }; const relic = { - id: "relic", label: "遗物 · 三选一", + id: "relic", label: pack.relicChoices.length + ? `遗物奖励 · ${pack.relicChoices.length}选1` : "遗物奖励 · 暂无候选", description: pack.relicChoices.length ? "检视本次随机珍藏,择一伴你踏上下一段征途。" : "已持有奖励池中的全部遗物,此项无需选择。", isDisabled: () => !pack.relicChoices.length, async choose() { const selected = await chooseRelicDialog(pack.relicChoices, { - skipLabel: "返回奖励", actionLabel: "携此遗物", + skipLabel: "跳过遗物", actionLabel: "携此遗物", }); + relicId = selected; if (selected !== null) { - relicId = selected; const choice = pack.relicChoices.find(item => item.id === selected); const definition = getRelic(choice.relic); relic.label = `已选遗物 · ${definition.name}`; relic.description = definition.description; + } else { + relic.label = "已跳过遗物"; + relic.description = "不领取遗物;点击可重新选择。"; } return RETURN_TO_VICTORY; }, }; return chooseVictoryOptions([upgrade, relic, { - id: "claim-package", label: "领取全部奖励", - description: "完成以上选择后,金币、强化、遗物与固定卡牌一并入囊。", - isDisabled: () => !ready(), + id: "claim-package", label: "领取奖励并继续", + description: "领取必得奖励与已选奖励,未选择的项目视为跳过。", choose: () => ({ cardId, relicId }), + }, { + id: "skip-package", label: "跳过", + description: "跳过强化与遗物,金币与固定卡牌仍会获得。", + choose: () => ({ cardId: null, relicId: null }), }], { title: "初识的卢", allowSkip: false, - rewardLabel: "固定收获 · 完成两项选择后领取", + rewardLabel: "搜刮 · 战利品列表", + rewardItems: [{ name: `金币 · ${pack.gold}(必得)`, + description: "无论选择还是跳过,金币都会结算。" }, + ...fixedRewards.map(reward => ({ ...reward, name: `${reward.name}(必得)` }))], description: `固定获得 ${pack.gold} 金币与${fixedRewards.map( reward => `【${reward.name}】`).join("、")}。` + - "另可强化一张牌,并从随机遗物中择一件。", + "强化与遗物点击后选择,也可跳过。", }); } diff --git a/mengsan/ui/reward-ui.js b/mengsan/ui/reward-ui.js index 52e69f2..4e70616 100644 --- a/mengsan/ui/reward-ui.js +++ b/mengsan/ui/reward-ui.js @@ -2,7 +2,6 @@ import { lib, get } from "../../../../noname.js"; import { cardCost } from "../battle/combat-rules.js"; import { cardUpgradeRule } from "../cards/upgrades.js"; import { getRelic } from "../relics/definitions.js"; -import { chooseButtons } from "./flow-ui.js"; const element = (tag, className, text, parent) => { const node = document.createElement(tag); @@ -34,7 +33,7 @@ const viewCard = choice => { ? `${lib.assetURL || ""}image/card/${imageName}.png` : null; const type = { basic: "基本牌", trick: "锦囊牌", delay: "延时锦囊", equip: "装备牌" }[info?.type] || "卡牌"; return { - name: get.translation(name), type, image, cost: cardCost({ name }), + name: get.translation(name), type, image, cost: cardCost(choice.card || { name }), description: plainText(cardUpgradeRule(name) && info?.cardPrompt ? info.cardPrompt(choice.card || { name }) : lib.translate[`${name}_info`] || choice.description || @@ -71,6 +70,22 @@ export function chooseRelicDialog(choices, options = {}) { }); } +// Mixed reward pools use the same selector, with exactly one candidate awarded. +function chooseRewardDialog(choices) { + if (choices.every(isCardReward)) return chooseCardDialog(choices); + if (choices.every(choice => getRelic(choice.relic))) return chooseRelicDialog(choices); + return chooseTilesDialog(choices, { + title: "选择一项奖励", actionLabel: "领取此奖励", + description: "从本次候选中选择一项,也可以跳过。", + }, choice => { + if (isCardReward(choice)) return viewCard(choice); + const relic = getRelic(choice.relic); + return { name: relic?.name || choice.name, type: relic ? "遗物" : "奖励", + image: null, cost: null, + description: plainText(relic?.description || choice.description) }; + }); +} + function chooseTilesDialog(choices, { title = "选择一张牌", allowSkip = true, skipLabel = "跳过", description = "择一入阵,余者留于身后", describeChoice, @@ -137,7 +152,7 @@ function chooseTilesDialog(choices, { export function chooseVictoryOptions(options, { title = "战后抉择", allowSkip = true, description = "此役已定,选择下一段征途的收获。", - rewardLabel = "可选战利品", + rewardLabel = "可选战利品", rewardItems = [], } = {}) { if (!Array.isArray(options) || !options.length || options.some(option => !option?.id || !option?.label)) { return Promise.reject(new TypeError("chooseVictoryOptions requires nonempty options with IDs and labels")); @@ -154,6 +169,12 @@ export function chooseVictoryOptions(options, { element("p", "", description, intro); const board = element("div", "mengsan-victory-options-shuying", null, stage); element("span", "mengsan-victory-eyebrow-shuying", rewardLabel, board); + // Read-only loot list: inspecting a reward must not select or award it. + for (const reward of rewardItems) { + const item = element("p", "mengsan-victory-option-copy-shuying", null, board); + element("strong", "", reward.name || reward.id, item); + if (reward.description) element("small", "", plainText(reward.description), item); + } let settled = false, choosing = false; const entries = []; const refresh = () => { @@ -218,54 +239,34 @@ export function chooseVictoryOptions(options, { }); } -// Gold is a persisted candidate, not an automatic reward. The card pool remains -// a separate Dialog; skipping that Dialog returns to the victory choices. +// Gold and fixed rewards are guaranteed; random loot opens a separate one-of-N selector. export function chooseBattleReward(choices, { fixedRewards = [] } = {}) { if (!Array.isArray(choices)) return Promise.resolve(null); - const fixedDescription = fixedRewards.map(reward => - `${reward.name || reward.id}${reward.description - ? `:${plainText(reward.description)}` : ""}` - ).join(";"); - if (!choices.length) { - if (!fixedRewards.length) return Promise.resolve(null); - return chooseVictoryOptions([{ - id: "claim-fixed-rewards", - label: "领取固定奖励", - description: fixedDescription, - choose: () => null, - }], { - title: "过关奖励", - description: "此役已定,以下固定奖励将全部获得。", - rewardLabel: "固定战利品", - allowSkip: false, - }); - } - const gold = choices.find(choice => choice.kind === "gold"); + const gold = choices.filter(choice => choice.kind === "gold") + .reduce((amount, choice) => amount + choice.amount, 0); const rewards = choices.filter(choice => choice.kind !== "gold"); const options = []; - if (gold) options.push({ - id: gold.id, - label: `金币 · ${gold.amount}`, - description: "将此关金币收入征程钱袋。", - }); if (rewards.length) { - const cardOnly = rewards.every(isCardReward); + const kind = rewards.every(isCardReward) ? "卡牌" + : rewards.every(choice => getRelic(choice.relic)) ? "遗物" : "战利品"; options.push({ - id: "choose-reward", - label: cardOnly ? "择一张牌" : "挑选一项战利品", - description: cardOnly ? "从本次候选卡牌中选择一张,加入牌组。" : "检视本次战利品,再决定带走哪一项。", - async choose() { - const selected = cardOnly - ? await chooseCardDialog(rewards, {skipLabel:"跳过选卡"}) - : await chooseButtons("选择一项奖励", rewards); - return selected ?? RETURN_TO_VICTORY; - }, + id: "choose-reward", label: `${kind}奖励 · ${rewards.length}选1`, + description: `点击查看候选${kind},选择一项领取,或点击跳过。`, + choose: () => chooseRewardDialog(rewards), }); } + options.push({ + id: "skip-loot", label: "跳过", + description: rewards.length ? "跳过本次可选奖励,必得奖励照常领取。" + : "本次没有可选奖励,领取必得奖励后继续。", + choose: () => null, + }); return chooseVictoryOptions(options, { - title: "过关奖励", - description: fixedRewards.length - ? `固定奖励:${fixedDescription}。另可选择一项战利品。` - : "此役已定,可选择一项战利品,也可放弃本次选择。", + title: "过关奖励", allowSkip: false, + description: `本关必得 ${gold} 金币。点击奖励选项后择一领取,也可跳过。`, + rewardLabel: "搜刮 · 战利品列表", + rewardItems: [{name: `金币 · ${gold}(必得)`, + description: "无论选择还是跳过,金币都会结算。"}, + ...fixedRewards.map(reward => ({ ...reward, name: `${reward.name}(必得)` }))], }); } diff --git a/mengsan/ui/style.css b/mengsan/ui/style.css index 33572d9..bf54a6e 100644 --- a/mengsan/ui/style.css +++ b/mengsan/ui/style.css @@ -1427,3 +1427,21 @@ body.mengsan-battle-ui-shuying :is( outline: 2px solid #8c6a34; outline-offset: 3px; } + +/* Read-only encyclopedia uses the existing wood, aged paper and gold palette. */ +.mengsan-deck-dialog-shuying .ms-deck-tabs { flex-wrap: wrap; } +.mengsan-deck-dialog-shuying .ms-catalog-filters { display: flex; flex-wrap: wrap; align-items: end; gap: 12px; flex-shrink: 0; } +.mengsan-deck-dialog-shuying .ms-catalog-field { display: flex; flex-direction: column; gap: 5px; min-width: 0; color: var(--ms-muted); font-size: 13px; } +.mengsan-deck-dialog-shuying .ms-catalog-field:first-child { flex: 1 1 220px; } +.mengsan-deck-dialog-shuying .ms-catalog-input, +.mengsan-deck-dialog-shuying .ms-catalog-select { box-sizing: border-box; position: static; min-width: 0; min-height: 44px; width: 100%; margin: 0; padding: 8px 10px; border: 1px solid #96754e; border-radius: 6px; background: #392c23; color: var(--ms-ink); font: 15px/1.5 "Microsoft YaHei", sans-serif; } +.mengsan-deck-dialog-shuying .ms-catalog-input::placeholder { color: var(--ms-muted); opacity: 1; } +.mengsan-deck-dialog-shuying .ms-catalog-input:focus-visible, +.mengsan-deck-dialog-shuying .ms-catalog-select:focus-visible { outline: 3px solid #ffe0a1; outline-offset: 3px; } +.mengsan-deck-dialog-shuying .ms-catalog-grid .ms-deck-art { height: auto; aspect-ratio: 5 / 7; } +@media (max-width: 600px) { + .mengsan-deck-dialog-shuying .ms-catalog-filters { gap: 8px; } + .mengsan-deck-dialog-shuying .ms-catalog-field:first-child { flex-basis: 100%; } + .mengsan-deck-dialog-shuying .ms-catalog-field:not(:first-child) { flex: 1 1 90px; } + .mengsan-deck-dialog-shuying .ms-catalog-filters > button { padding: 8px 10px; } +} diff --git a/tianshu/extension.js b/tianshu/extension.js index 69e6f6d..4092c45 100644 --- a/tianshu/extension.js +++ b/tianshu/extension.js @@ -18,6 +18,7 @@ export default function initTianshu(lib, game, ui, get, ai, _status, shuYing) { roundStartSeat: 0, originalIsRoundFilter: null, roundFilterInstalled: false, + pendingStageRoundHistory: false, originalGameCheck: null, checkHookInstalled: false, bondHooksInstalled: false, @@ -207,6 +208,20 @@ export default function initTianshu(lib, game, ui, get, ai, _status, shuYing) { installTianshuRoundFilter(); if (game.bossinfo) game.bossinfo.loopType = 1; }; + // 轮数回退时同步清除技能的旧轮次;也覆盖阵亡玩家,避免复活后沿用上一关冷却。 + const resetStageRoundSkills = () => { + for (const player of getPlayerSidePlayers({ aliveOnly: false, includeVirtual: true })) { + for (const key of Object.keys(player.storage)) { + if (!key.endsWith("_roundcount")) continue; + const info = lib.skill[key.slice(0, -"_roundcount".length)]; + if (!info?.round) continue; + // 与引擎 roundcount 初始化值一致,不能使用旧关卡的绝对轮数。 + player.storage[key] = 1 - info.round; + player.unmarkSkill(key); + player.syncStorage(key); + } + } + }; // 结束当前 phase 事件,把后续行动权交给指定角色;保留旧版重置轮数与阶段的做法。 const finishCurrentPhaseTo = target => { if (!target) return; @@ -249,6 +264,8 @@ export default function initTianshu(lib, game, ui, get, ai, _status, shuYing) { installTianshuRoundFilter(); game.phaseNumber = 1; game.roundNumber = isTianshuRoundStartTrigger(target) ? 0 : 1; + resetStageRoundSkills(); + state.pendingStageRoundHistory = true; _status.paused = false; if (phase) { game.broadcastAll?.(() => { @@ -1240,6 +1257,7 @@ export default function initTianshu(lib, game, ui, get, ai, _status, shuYing) { game.addGlobalSkill("shuYing_Tianshu_NewBossDie"); game.addGlobalSkill("shuYing_Tianshu_NewPlayerDie"); game.addGlobalSkill("shuYing_Tianshu_DefendingControl"); + game.addGlobalSkill("shuYing_Tianshu_StageRoundHistory"); game.addGlobalSkill("shuYing_Tianshu_BondManager"); }, }, @@ -1258,6 +1276,27 @@ export default function initTianshu(lib, game, ui, get, ai, _status, shuYing) { swapDefendingBoss(trigger.player); }, }, + shuYing_Tianshu_StageRoundHistory: { + trigger: { global: "phaseBeforeStart" }, + forced: true, + silent: true, + charlotte: true, + forceDie: true, + firstDo: true, + priority: 1000, + filter() { + return state.running && state.pendingStageRoundHistory; + }, + async content() { + state.pendingStageRoundHistory = false; + // 此时引擎已创建新回合的历史和计数;即使首回合不是1号位,也应隔开旧关历史。 + for (const player of game.players.concat(game.dead)) { + player.getHistory().isRound = true; + player.getStat().isRound = true; + } + game.getGlobalHistory().isRound = true; + }, + }, shuYing_Tianshu_BondManager: { mode: ["boss"], trigger: { global: "changeSkillsAfter" },