diff --git a/.gitignore b/.gitignore index 2a8e807..1f6e0dd 100644 --- a/.gitignore +++ b/.gitignore @@ -2,3 +2,4 @@ log.txt dist/manifest.json tools/__pycache__/ /mengsan/.planning/ +/mengsan/verification \ No newline at end of file diff --git a/mengsan/assets/flyconid-portrait.png b/mengsan/assets/flyconid-portrait.png new file mode 100644 index 0000000..03d97b1 Binary files /dev/null and b/mengsan/assets/flyconid-portrait.png differ diff --git a/mengsan/assets/intent/Intent_attack_1.webp b/mengsan/assets/intent/Intent_attack_1.webp new file mode 100644 index 0000000..aff1c5f Binary files /dev/null and b/mengsan/assets/intent/Intent_attack_1.webp differ diff --git a/mengsan/assets/intent/Intent_attack_2.webp b/mengsan/assets/intent/Intent_attack_2.webp new file mode 100644 index 0000000..9ea0c8a Binary files /dev/null and b/mengsan/assets/intent/Intent_attack_2.webp differ diff --git a/mengsan/assets/intent/Intent_attack_3.webp b/mengsan/assets/intent/Intent_attack_3.webp new file mode 100644 index 0000000..c1bd034 Binary files /dev/null and b/mengsan/assets/intent/Intent_attack_3.webp differ diff --git a/mengsan/assets/intent/Intent_attack_4.webp b/mengsan/assets/intent/Intent_attack_4.webp new file mode 100644 index 0000000..c1fa417 Binary files /dev/null and b/mengsan/assets/intent/Intent_attack_4.webp differ diff --git a/mengsan/assets/intent/Intent_attack_5.webp b/mengsan/assets/intent/Intent_attack_5.webp new file mode 100644 index 0000000..d20f23e Binary files /dev/null and b/mengsan/assets/intent/Intent_attack_5.webp differ diff --git a/mengsan/assets/intent/Intent_debuff.webp b/mengsan/assets/intent/Intent_debuff.webp new file mode 100644 index 0000000..66bf4ba Binary files /dev/null and b/mengsan/assets/intent/Intent_debuff.webp differ diff --git a/mengsan/content/acts/act1/map.js b/mengsan/content/acts/act1/map.js index 1bd95b9..9650b20 100644 --- a/mengsan/content/acts/act1/map.js +++ b/mengsan/content/acts/act1/map.js @@ -12,7 +12,8 @@ const act1Map = { contentId: "act1.battle.liubeiOpening", }, ], - enemies: ["re_xiahoudun", "re_zhangliao", "re_xuzhu"], + enemies: ["mengsan_flyconid_shuying"], + eliteEnemies: ["re_xiahoudun", "re_zhangliao", "re_xuzhu"], boss: "re_lvbu", baseGold: 20, }; diff --git a/mengsan/content/card-definitions.js b/mengsan/content/card-definitions.js new file mode 100644 index 0000000..eb929db --- /dev/null +++ b/mengsan/content/card-definitions.js @@ -0,0 +1,35 @@ +// 梦三永久牌组的可获得牌注册表。这里登记牌名与归属,不复制引擎中的效果定义。 +// 新增武将专属牌时填写 owner 为该武将 ID;通用牌填写 owner: null。 +const definitions = [ + { name: "sha", category: "damage", owner: null }, + { name: "shan", category: "utility", owner: null }, + { name: "tao", category: "recovery", owner: null }, + { name: "jiu", category: "recovery", owner: null }, + { name: "guohe", category: "utility", owner: null }, + { name: "wuzhong", category: "utility", owner: null }, + { name: "juedou", category: "damage", owner: null }, + { name: "bagua", category: "utility", owner: null }, + // 当前仅刘备专属首关的固定奖励会永久获得【的卢】。 + { name: "dilu", category: "utility", owner: "mengsan_liubei_shuying" }, +]; + +export function createCardRegistry(entries) { + const registry = Object.create(null); + for (const entry of entries) { + if (!entry || typeof entry.name !== "string" || !/^[a-z][a-z0-9_]*$/.test(entry.name) || + !["damage", "recovery", "utility"].includes(entry.category) || + !(entry.owner === null || typeof entry.owner === "string" && entry.owner.length > 0) || + Object.hasOwn(registry, entry.name)) { + throw new Error(`梦三牌定义无效或重复:${entry?.name}`); + } + registry[entry.name] = Object.freeze({ name: entry.name, category: entry.category, owner: entry.owner }); + } + return Object.freeze(registry); +} + +export const cardDefinitions = createCardRegistry(definitions); + +export function canAcquireCard(character, name, registry = cardDefinitions) { + const definition = Object.hasOwn(registry, name) ? registry[name] : null; + return !!definition && (definition.owner === null || definition.owner === character); +} diff --git a/mengsan/content/card-library.js b/mengsan/content/card-library.js index abd2c7c..60ce97a 100644 --- a/mengsan/content/card-library.js +++ b/mengsan/content/card-library.js @@ -1,3 +1,5 @@ +import { createCardData } from "../runtime/card-data.js"; + // Mode-owned starting decks. Existing saves always retain their actual player.deck. export const defaultDeck = Object.freeze([ ["spade", 7, "sha"], ["heart", 10, "sha"], @@ -24,8 +26,8 @@ export function getStartingDeck(character) { } export function createStartingDeck(character, prefix = `mengsan_card_${Date.now()}`) { - return getStartingDeck(character).map((entry, index) => ({ + return getStartingDeck(character).map((entry, index) => createCardData({ id: `${prefix}_${index}`, suit: entry[0], number: entry[1], name: entry[2], nature: entry[3] || null, affixes: entry[4] ? entry[4].slice() : [], upgrade: 0, - })); + }, { character })); } diff --git a/mengsan/content/scenario-characters.js b/mengsan/content/scenario-characters.js index d44f70b..3fd86f6 100644 --- a/mengsan/content/scenario-characters.js +++ b/mengsan/content/scenario-characters.js @@ -6,6 +6,8 @@ export function createScenarioCharacters(Character) { // 国战男性普通小兵使用原生 shibing1 立绘;实际战斗生命由怪物类或关卡配置决定。 mengsan_soldier_shuying: new Character({ sex: "male", group: "qun", hp: 4, maxHp: 4, skills: [], img: "image/character/shibing1.jpg" }), + mengsan_flyconid_shuying: new Character({ sex: "none", group: "qun", hp: 48, maxHp: 48, + skills: [], img: "extension/术樱包/mengsan/assets/flyconid-portrait.png" }), mengsan_sushuang_zhangshiping_shuying: new Character({ sex: "male", group: "shu", hp: 4, maxHp: 4, skills: [], img: "extension/术樱包/mengsan/assets/sushuang-zhangshiping.png", names: "苏|双-张|世平" }), }; @@ -13,5 +15,6 @@ export function createScenarioCharacters(Character) { export const scenarioTranslations = { mengsan_liubei_shuying: "界刘备", mengsan_liubei_shuying_title: "50/50生命 · 无初始技能", mengsan_soldier_shuying: "士兵", + mengsan_flyconid_shuying: "飞蝇菌子", mengsan_sushuang_zhangshiping_shuying: "苏双&张世平", }; diff --git a/mengsan/content/shared/rewards.js b/mengsan/content/shared/rewards.js index 21a414a..0545867 100644 --- a/mengsan/content/shared/rewards.js +++ b/mengsan/content/shared/rewards.js @@ -5,6 +5,7 @@ export const rewards = { }, "shared.reward.card.sha": { effectId: "card_sha", name: "获得一张【杀】", description: "加入独立永久牌组" }, "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: "随机一张牌强化一级" }, "shared.reward.heal": { effectId: "heal", name: "整顿伤势", description: "回复 8 点生命" }, "shared.reward.item.handCharm": { effectId: "item_hand", name: "道具·束带", description: "基础手牌上限额外 +1" }, @@ -16,7 +17,7 @@ export const rewardPools = { "shared.pool.battle.normal": [ "shared.reward.card.sha", "shared.reward.card.tao", - "shared.reward.card.upgrade", + "shared.reward.card.wuzhong", ], "shared.pool.boss.premium": [ "shared.reward.skill.heroic", diff --git a/mengsan/mode.js b/mengsan/mode.js index 7432007..e62e2af 100644 --- a/mengsan/mode.js +++ b/mengsan/mode.js @@ -3,6 +3,8 @@ import { createMengsanCards } from "./content/mode-cards.js"; import { buildBattlePlan, consumeSupports, createBattleDirector, grantSupport } from "./runtime/battle-director.js"; import { installPersonalPiles } from "./runtime/personal-piles.js"; import { createMonster } from "./runtime/monster.js"; +import { mountEnemyIntent } from "./runtime/intent-display.js"; +import { selectFlyconidMove, recordFlyconidAction } from "./runtime/flyconid-intent.js"; import { PLAYER_ENERGY, PLAYER_HAND_LIMIT, canPayCard, payCard, isActiveCardUse } from "./runtime/combat-rules.js"; import { mountBattlePiles } from "./runtime/card-library.js"; import { mountGMManager } from "./runtime/gm-manager.js"; @@ -18,6 +20,9 @@ import { lib, game, ui, get, _status } from "../../../noname.js"; import config from "./config.js"; import { showMap } from "./map/index.js"; import { getRandomRewardChoices } from "./runtime/reward.js"; +import { canAcquireCard } from "./content/card-definitions.js"; +import { addCardToDeck, createRandomCardData } from "./runtime/card-data.js"; +import { chooseBattleReward } from "./runtime/reward-ui.js"; import { applyStoryOutcome, getAvailableStoryChoices } from "./runtime/story.js"; import { playDialogue } from "./runtime/dialogue.js"; import { chooseButtons } from "./runtime/flow-ui.js"; @@ -71,31 +76,25 @@ const chooseCharacter = async () => { return chooseButtons("选择出征武将", [...(choices.length ? choices : [{ id: "mengsan_liubei_shuying", name: "界刘备" }]), { id: "back", name: "返回存档选择", description: "不会修改现有征程" }], "此武将将陪伴你完成本次征程。", { back: "back", eyebrow: "出征准备" }); }; -const createCardInstance = (run, name = "sha", upgrade = 0, affixes = []) => ({ - id: `mengsan_card_${Date.now()}_${Math.floor(nextRandom(run) * 1e6)}`, - suit: ["spade", "heart", "club", "diamond"][Math.floor(nextRandom(run) * 4)], - number: 1 + Math.floor(nextRandom(run) * 13), - name, - nature: null, - upgrade, - affixes: affixes.slice(), -}); +const createCardInstance = (run, name = "sha", upgrade = 0, affixes = []) => + createRandomCardData(run, name, { random: () => nextRandom(run), upgrade, affixes }); const applyReward = (run, rewardId) => { const player = run.player; const cardReward = config.rewards[rewardId]?.card; if (cardReward) { - player.deck.push({ ...createCardInstance(run, cardReward.name), ...copy(cardReward) }); + if (!canAcquireCard(player.character, cardReward.name)) throw new Error(`武将 ${player.character} 无法获得牌:${cardReward.name}`); + addCardToDeck(run, { ...createCardInstance(run, cardReward.name), ...copy(cardReward) }); return; } const support = config.rewards[rewardId]?.support; if (support) { grantSupport(run, rewardId, support); return; } switch (rewardId) { case "card_sha": - player.deck.push(createCardInstance(run, "sha")); + addCardToDeck(run, createCardInstance(run, "sha")); break; case "card_tao": - player.deck.push(createCardInstance(run, "tao")); + addCardToDeck(run, createCardInstance(run, "tao")); break; case "upgrade": { const eligible = player.deck.filter(card => !card.affixes?.includes("eternal")); @@ -176,13 +175,14 @@ const finishUtilityNode = async (run, node) => { else if (node.type == "shop") { const price = 20; const choice = await chooseButtons("商店 Demo", [ - { id: "card_sha", name: `购买【杀】(${price}金币)`, description: "加入个人牌组", disabled: run.player.gold < price }, + { id: "card_sha", name: `购买【杀】(${price}金币)`, description: "加入个人牌组", disabled: run.player.gold < price || !canAcquireCard(run.player.character, "sha") }, { id: "heal", name: `恢复生命(${price}金币)`, description: "回复 8 点生命", disabled: run.player.gold < price }, { id: "leave", name: "离开", description: "暂不购买" }, ]); - if (choice != "leave") { - run.player.gold -= price; + if (choice != "leave" && choice != null) { + if (run.player.gold < price || (choice === "card_sha" && !canAcquireCard(run.player.character, "sha"))) throw new Error("梦三商店购买资格无效"); applyReward(run, choice); + run.player.gold -= price; } } else { @@ -258,6 +258,41 @@ const mountEnergy = (player, session) => { refresh(); return refresh; }; +const isFlyconid = player => player?.name === "mengsan_flyconid_shuying" && player.storage?.mengsanCamp_shuying === "enemy"; +const planFlyconidIntent = (player, run) => { + if (!isFlyconid(player) || !player.isAlive() || player.storage.mengsanFlyconidIntent_shuying) return; + const move = selectFlyconidMove(player.storage.mengsanFlyconidState_shuying || {}, () => nextRandom(run)); + player.storage.mengsanFlyconidIntent_shuying = move; + game.mengsanSetEnemyIntent_shuying(player, move); +}; +const applyFlyconidDebuff = (target, move) => { + if (!move.debuff) return; + const frail = move.id === "frail"; + 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"); +}; +const executeFlyconidIntent = async player => { + const move = player.storage.mengsanFlyconidIntent_shuying; + if (!move) return; + player.storage.mengsanFlyconidIntent_shuying = null; + game.mengsanSetEnemyIntent_shuying(player, null); + const paid = player.storage.mengsanEnergy_shuying >= 1; + player.storage.mengsanFlyconidState_shuying = recordFlyconidAction(player.storage.mengsanFlyconidState_shuying || {}, move, paid); + if (!paid) { game.log(player, "费用不足,未发动", move.name); return; } + player.storage.mengsanEnergy_shuying--; + activeBattle?.energyUI.get(player)?.(); + game.log(player, "消耗1费用发动", move.name); + const target = game.me; + if (!target?.isAlive() || !player.isAlive()) return; + if (move.damage != null) { + const hit = target.damage(move.damage, player); + hit.mengsanAttack_shuying = true; + await hit; + } + if (target.isAlive() && player.isAlive()) applyFlyconidDebuff(target, move); +}; const equipBattleUnit = async (player, spec, resources) => { for (const info of spec.equipment || []) { const card = resources.card(game.createCard(info.name, info.suit, info.number, info.nature)); @@ -279,11 +314,15 @@ const createScenario = (plan, current) => { join.setContent(async () => { const monster = initBattleUnit(player, spec); installMonsterPile(player, monster, current); - if (monster) current.energyUI.set(player, mountEnergy(player, current.session)); + if (monster) { + current.energyUI.set(player, mountEnergy(player, current.session)); + current.intentUI.set(player, mountEnemyIntent(player, current.session, STYLE_PATH, document)); + } 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) planFlyconidIntent(player, _status.mengsanRun_shuying); }); await join; game.log(player, "作为", spec.camp === "ally" ? "友方支援" : "敌方援军", "加入战斗"); @@ -358,6 +397,9 @@ const prepareBattle = async (run, node, encounter, session, resources) => { const participants = game.players.filter(player => player !== me); const monsters = plan.units.map((spec, index) => initBattleUnit(participants[index], spec)); plan.units.forEach((spec, index) => installMonsterPile(participants[index], monsters[index], currentBattle)); + participants.forEach((player, index) => { + if (monsters[index]) currentBattle.intentUI.set(player, mountEnemyIntent(player, session, STYLE_PATH, document)); + }); activeBattle.director = createScenario(plan, currentBattle); activeBattle.director.bind("player", me); plan.units.forEach((spec, index) => activeBattle.director.bind(spec.id, participants[index])); @@ -376,7 +418,7 @@ const prepareBattle = async (run, node, encounter, session, resources) => { run.player.permanentSkills.forEach(skill => { if (lib.skill[skill] && !me.hasSkill(skill)) me.addSkill(skill); }); - for (const skill of ["mengsan_draw_shuying", "mengsan_card_use_shuying", "mengsan_card_affixes_shuying", "mengsan_scenario_shuying", "mengsan_card_payment_shuying", "mengsan_monster_draw_shuying"]) { + for (const skill of ["mengsan_draw_shuying", "mengsan_card_use_shuying", "mengsan_card_affixes_shuying", "mengsan_scenario_shuying", "mengsan_card_payment_shuying", "mengsan_monster_draw_shuying", "mengsan_flyconid_action_shuying"]) { if (!lib.skill.global.includes(skill)) { game.addGlobalSkill(skill); session.ownResource({}, () => game.removeGlobalSkill(skill)); @@ -413,6 +455,9 @@ const prepareBattle = async (run, node, encounter, session, resources) => { currentBattle.energyUI.get(player)?.(); } await currentBattle.director.beforeTurn(player); + if (player === me && session.active) { + for (const enemy of game.players) planFlyconidIntent(enemy, run); + } game.checkResult(); }})); }; @@ -437,7 +482,7 @@ const makeFlow = (releaseSkills = async () => {}) => createBattleFlow({ releaseSkills, presentVictory: pending => playDialogue(pending.victoryDialogue, { run: pending.base, title: "战后剧情" }), settlement:createBattleSettlement({store:modeStorage, config, getRandomRewardChoices, applyReward, completeNode, enterNextAct}), - chooseReward:choices => chooseButtons("战斗奖励(三选一)", choices), + chooseReward:chooseBattleReward, showMap, showEnding:async route => { game.over(route === "victory"); }, quiesce:() => quiesceEngine(_status), @@ -480,7 +525,7 @@ const setupBattle = async (run, node, encounter = getNodeEncounter(run, node)) = const flow = makeFlow(() => current.releaseSkills?.()); let finished; const signal = new Promise(resolve => { finished = resolve; }); - current = {session, flow, finished, resources, players: new Set(), monsterPiles: new Map(), energyUI: new Map()}; + current = {session, flow, finished, resources, players: new Set(), monsterPiles: new Map(), energyUI: new Map(), intentUI: new Map()}; activeBattle = current; const root = new lib.element.GameEvent("mengsanBattle", false, _status.eventManager); const previousRoot = _status.eventManager.rootEvent; @@ -551,6 +596,7 @@ const startJourney = async () => { const createMode = identityMode => { const identityElement = identityMode.element || {}; const identityPlayer = identityElement.player || {}; + const nativeChangeHujia = identityPlayer.changeHujia || lib.element.Player.prototype.changeHujia; const modeCards = createMengsanCards(); return { ...identityMode, @@ -574,6 +620,13 @@ 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))); }, + changeHujia(num, type, limit) { + // 脆弱只削减正向获得的护甲,不改变受伤时消耗护甲的数值。 + if (this.storage?.mengsanFrail_shuying > 0 && (num == null || num > 0) && type !== "damage") { + num = Math.floor((num ?? 1) * 0.75); + } + return nativeChangeHujia.call(this, num, type, limit); + }, getHandcardLimit() { if (this.storage?.mengsanPlayer_shuying) { const run = _status.mengsanRun_shuying; @@ -591,6 +644,12 @@ const createMode = identityMode => { }, game: { ...(identityMode.game || {}), + mengsanSetEnemyIntent_shuying(player, intent) { + const update = activeBattle?.intentUI.get(player); + if (!update) return false; + update(intent); + return true; + }, async mengsanPersistRun_shuying(run) { await saveRun(run); }, @@ -663,6 +722,46 @@ const createMode = identityMode => { filter(event, player) { return Boolean(player.storage?.mengsanMonster_shuying); }, async content(event, trigger, player) { trigger.num = player.storage.mengsanMonster_shuying.draw; }, }, + mengsan_flyconid_action_shuying: { + trigger: { global: "phaseUseBefore" }, + forced: true, silent: true, popup: false, priority: 100, + filter(event) { return Boolean(activeBattle?.session.active && isFlyconid(event.player) && event.player.storage.mengsanFlyconidIntent_shuying); }, + async content(event, trigger) { await executeFlyconidIntent(trigger.player); }, + }, + mengsan_frail_shuying: { + mark: true, marktext: "脆", + onremove(player) { delete player.storage.mengsanFrail_shuying; }, + intro: { content(storage, player) { return `剩余${player.storage.mengsanFrail_shuying || 0}回合:获得护甲减少25%(向下取整)`; } }, + trigger: { player: "phaseAfter" }, + forced: true, silent: true, popup: false, + async content(event, trigger, player) { + const remaining = Math.max(0, (player.storage.mengsanFrail_shuying || 0) - 1); + player.storage.mengsanFrail_shuying = remaining; + if (remaining) player.markSkill("mengsan_frail_shuying"); + else player.removeSkill("mengsan_frail_shuying"); + }, + }, + mengsan_vulnerable_shuying: { + mark: true, marktext: "易", + onremove(player) { delete player.storage.mengsanVulnerable_shuying; }, + intro: { content(storage, player) { return `剩余${player.storage.mengsanVulnerable_shuying || 0}回合:受到攻击伤害增加50%(向上取整)`; } }, + trigger: { player: ["damageBegin1", "phaseAfter"] }, + forced: true, silent: true, popup: false, + filter(event, player) { + if (!player.storage?.mengsanVulnerable_shuying) return false; + return event.name !== "damage" || Boolean(event.card || event.mengsanAttack_shuying); + }, + async content(event, trigger, player) { + if (event.triggername === "damageBegin1") { + trigger.num = Math.ceil(trigger.num * 1.5); + return; + } + const remaining = Math.max(0, (player.storage.mengsanVulnerable_shuying || 0) - 1); + player.storage.mengsanVulnerable_shuying = remaining; + if (remaining) player.markSkill("mengsan_vulnerable_shuying"); + else player.removeSkill("mengsan_vulnerable_shuying"); + }, + }, mengsan_scenario_shuying: { trigger: { global: ["changeHpAfter", "gainAfter", "loseAfter", "dieAfter", "turnOverAfter", "linkAfter", "phaseAfter"] }, forced: true, silent: true, popup: false, priority: -100, @@ -757,6 +856,11 @@ const createMode = identityMode => { mengsan_card_use_shuying: "梦三词缀", mengsan_yingyong_shuying: "英勇", mengsan_yingyong_shuying_info: "锁定技,每回合限一次,你于自己的回合内使用牌造成的伤害+1。", + mengsan_flyconid_action_shuying: "孢子行动", + mengsan_frail_shuying: "脆弱", + mengsan_frail_shuying_info: "接下来相应回合获得护甲减少25%(向下取整)。", + mengsan_vulnerable_shuying: "易伤", + mengsan_vulnerable_shuying_info: "接下来相应回合受到的攻击伤害增加50%(向上取整)。", }, config: {}, help: { diff --git a/mengsan/runtime/battle-flow.js b/mengsan/runtime/battle-flow.js index d90919f..63791b4 100644 --- a/mengsan/runtime/battle-flow.js +++ b/mengsan/runtime/battle-flow.js @@ -16,8 +16,8 @@ export function createBattleFlow({settlement, chooseReward, showMap, showEnding, if (job.pending.state !== "chosen") { state = "choosing"; if (!job.hasChoice) { - job.choiceId = job.pending.outcome === "defeat" || !job.pending.choices.length ? null : await chooseReward(copy(job.pending.choices)); - if (job.pending.outcome !== "defeat" && job.pending.choices.length && !job.pending.choices.some(c => c.id === job.choiceId)) throw new Error("Invalid reward selection"); + job.choiceId = job.pending.outcome === "defeat" || !job.pending.choices.length ? null : await chooseReward(copy(job.pending.choices), { boss: job.pending.boss }); + 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"); job.hasChoice = true; } job.pending = await settlement.choose(job.input.run.runId, job.pending.id, job.choiceId); diff --git a/mengsan/runtime/battle-settlement.js b/mengsan/runtime/battle-settlement.js index a64392e..6ec1e01 100644 --- a/mengsan/runtime/battle-settlement.js +++ b/mengsan/runtime/battle-settlement.js @@ -19,7 +19,7 @@ export function createBattleSettlement({store, config, getRandomRewardChoices, a // Persist fixed candidates before opening choice UI. No mutation of the live run. async prepare({run: snapshot, node, encounter, hp, outcome = "victory"}) { if (!["victory", "defeat"].includes(outcome)) throw new Error("Unknown battle outcome"); - if (outcome === "victory" && (!Number.isFinite(hp) || !Number.isFinite(encounter.gold) || encounter.gold < 0)) throw new Error("Invalid battle result"); + if (outcome === "victory" && (!Number.isFinite(hp) || (!encounter.skipRandomReward && (!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); @@ -36,12 +36,13 @@ export function createBattleSettlement({store, config, getRandomRewardChoices, a const base = copy(snapshot); delete base.battleFlow; if (outcome === "victory") { base.player.hp = Math.max(1, Math.min(hp, base.player.maxHp ?? hp)); - base.player.gold += encounter.gold; - base.statistics.goldEarned += encounter.gold; base.statistics.defeatedEnemies += encounter.defeatedEnemies ?? 1; } - const choices = outcome === "victory" && !encounter.skipRandomReward ? getRandomRewardChoices(base, encounter.rewardPool).filter(c => c.name) : []; - if (outcome === "victory" && !encounter.skipRandomReward && !choices.length) throw new Error("Empty battle reward pool"); + 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 + ? [{id:"mengsan.reward.gold.shuying", kind:"gold", name:"金币", amount:encounter.gold}, ...rewards] : []; 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}; return run.battleFlow.pending; }); @@ -57,9 +58,13 @@ export function createBattleSettlement({store, config, getRandomRewardChoices, a let route = "defeat"; if (item.outcome === "victory") { const choice = item.choices.find(c => c.id === choiceId); - if (item.choices.length) { + if (item.choices.length && choiceId !== null) { if (!choice) throw new Error("Reward is not a fixed candidate"); - applyReward(result, choice.effectId || choice.id); + 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 (choiceId !== null) throw new Error("Unexpected reward selection"); for (const rewardId of item.fixedRewards || []) applyReward(result, rewardId); if (!completeNode(result, item.nodeId)) throw new Error("Node completion failed"); diff --git a/mengsan/runtime/card-data.js b/mengsan/runtime/card-data.js new file mode 100644 index 0000000..8aa29fa --- /dev/null +++ b/mengsan/runtime/card-data.js @@ -0,0 +1,46 @@ +import { cardDefinitions, canAcquireCard } from "../content/card-definitions.js"; + +const suits = ["spade", "heart", "club", "diamond"]; + +// 仅创建可序列化的牌组数据;战斗中的实体牌仍由 game.createCard 创建。 +export function createCardData(spec, { character, enemy = false, registry = cardDefinitions } = {}) { + if (!spec || typeof spec !== "object" || !Object.hasOwn(registry, spec.name)) { + throw new Error(`梦三牌未注册:${spec?.name}`); + } + if (!enemy && !canAcquireCard(character, spec.name, registry)) { + throw new Error(`武将 ${character || "未知"} 无法获得牌:${spec.name}`); + } + if (typeof spec.id !== "string" || !spec.id || !suits.includes(spec.suit) || + !Number.isInteger(spec.number) || spec.number < 1 || spec.number > 13 || + (spec.nature != null && typeof spec.nature !== "string") || + !Number.isInteger(spec.upgrade ?? 0) || (spec.upgrade ?? 0) < 0 || + !Array.isArray(spec.affixes ?? []) || !(spec.affixes ?? []).every(value => typeof value === "string")) { + throw new Error(`梦三牌数据无效:${spec.name}`); + } + return { + id: spec.id, suit: spec.suit, number: spec.number, name: spec.name, + nature: spec.nature ?? null, affixes: (spec.affixes ?? []).slice(), upgrade: spec.upgrade ?? 0, + }; +} + +export function createRandomCardData(run, name, { random, now = Date.now, upgrade = 0, affixes = [] } = {}) { + if (!run?.player || !Array.isArray(run.player.deck) || typeof random !== "function") throw new Error("梦三随机牌参数无效"); + // 先检查资格,再消耗随机数;失败的获得操作不改变后续奖励序列。 + if (!canAcquireCard(run.player.character, name)) throw new Error(`武将 ${run.player.character} 无法获得牌:${name}`); + const stamp = now(); + let id; + do { id = `mengsan_card_${stamp}_${Math.floor(random() * 1e6)}`; } + while (run.player.deck.some(card => card.id === id)); + return createCardData({ + id, suit: suits[Math.floor(random() * suits.length)], number: 1 + Math.floor(random() * 13), + name, nature: null, upgrade, affixes, + }, { character: run.player.character }); +} + +export function addCardToDeck(run, spec) { + const card = createCardData(spec, { character: run?.player?.character }); + if (!Array.isArray(run?.player?.deck)) throw new Error("梦三牌组无效"); + if (run.player.deck.some(existing => existing.id === card.id)) throw new Error(`梦三牌 ID 重复:${card.id}`); + run.player.deck.push(card); + return card; +} diff --git a/mengsan/runtime/flyconid-intent.js b/mengsan/runtime/flyconid-intent.js new file mode 100644 index 0000000..93e0284 --- /dev/null +++ b/mengsan/runtime/flyconid-intent.js @@ -0,0 +1,37 @@ +// 冷却按飞蝇菌子自己的行动次数计算,不受玩家回合或增援数量影响。 +export const FLYCONID_MOVES = Object.freeze({ + smash: Object.freeze({ id: "smash", name: "猛砸", damage: 11 }), + frail: Object.freeze({ id: "frail", name: "脆弱孢子", damage: 8, debuff: "脆弱", stacks: 2 }), + vulnerable: Object.freeze({ id: "vulnerable", name: "易伤孢子", debuff: "易伤", stacks: 2 }), +}); + +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); + return moves; +} + +export function selectFlyconidMove(state, random) { + const moves = eligibleFlyconidMoves(state); + if (!moves.length) return null; + 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 recordFlyconidAction(state, move, paid) { + const next = { + turnsTaken: (state.turnsTaken || 0) + 1, + lastMove: state.lastMove || null, + lastUsed: { ...(state.lastUsed || {}) }, + }; + if (paid && move) { + next.lastMove = move.id; + next.lastUsed[move.id] = next.turnsTaken; + } + return next; +} diff --git a/mengsan/runtime/intent-display.js b/mengsan/runtime/intent-display.js new file mode 100644 index 0000000..403bd8f --- /dev/null +++ b/mengsan/runtime/intent-display.js @@ -0,0 +1,63 @@ +// 只呈现已经选定的预测技能;不从手牌或原生技能猜测意图。 +export function attackIconForDamage(damage) { + if (!Number.isInteger(damage) || damage < 0) throw new RangeError("预测伤害必须是非负整数"); + if (damage < 5) return 1; + if (damage < 10) return 2; + if (damage < 20) return 3; + if (damage < 40) return 4; + return 5; +} + +export function mountEnemyIntent(player, session, assetBase, document) { + const badge = document.createElement("div"); + badge.className = "mengsan-intent-shuying"; + badge.hidden = true; + badge.setAttribute("aria-live", "polite"); + const name = document.createElement("span"); + name.className = "mengsan-intent-name-shuying"; + const damageLine = document.createElement("span"); + damageLine.className = "mengsan-intent-damage-shuying"; + const amount = document.createElement("span"); + amount.className = "mengsan-intent-amount-shuying"; + const icon = document.createElement("img"); + icon.className = "mengsan-intent-icon-shuying"; + icon.alt = "预计伤害"; + icon.decoding = "async"; + damageLine.append(amount, icon); + const debuffLine = document.createElement("span"); + debuffLine.className = "mengsan-intent-debuff-shuying"; + const debuffIcon = document.createElement("img"); + debuffIcon.className = "mengsan-intent-icon-shuying"; + debuffIcon.alt = "预计施加负面状态"; + debuffIcon.decoding = "async"; + debuffIcon.src = `${assetBase}/assets/intent/Intent_debuff.webp`; + const debuffAmount = document.createElement("span"); + debuffLine.append(debuffIcon, debuffAmount); + badge.append(name, damageLine, debuffLine); + player.appendChild(badge); + session.ownResource(badge, () => badge.remove()); + return intent => { + if (!intent) { + badge.hidden = true; + badge.removeAttribute("title"); + return; + } + if (typeof intent.name !== "string" || !intent.name.trim()) throw new TypeError("预测技能必须有名称"); + name.textContent = intent.name; + const hasDamage = intent.damage != null; + damageLine.hidden = !hasDamage; + const hasDebuff = typeof intent.debuff === "string" && Number.isInteger(intent.stacks) && intent.stacks > 0; + debuffLine.hidden = !hasDebuff; + if (hasDebuff) debuffAmount.textContent = `${intent.debuff}×${intent.stacks}`; + if (hasDamage) { + const tier = attackIconForDamage(intent.damage); + amount.textContent = String(intent.damage); + const src = `${assetBase}/assets/intent/Intent_attack_${tier}.webp`; + if (icon.getAttribute("src") !== src) icon.src = src; + badge.title = `${intent.name}:预计造成 ${intent.damage} 点伤害(非最终伤害)${hasDebuff ? `,施加${intent.stacks}层${intent.debuff}` : ""}`; + } else { + badge.title = hasDebuff ? `${intent.name}:施加${intent.stacks}层${intent.debuff}` : intent.name; + } + badge.hidden = false; + }; +} diff --git a/mengsan/runtime/monster.js b/mengsan/runtime/monster.js index 54d6d23..1a77645 100644 --- a/mengsan/runtime/monster.js +++ b/mengsan/runtime/monster.js @@ -1,3 +1,5 @@ +import { createCardData } from "./card-data.js"; + // 梦三怪物规则只作用于敌方单位;子类可覆盖构造参数或 createDeck()。 const TIER_RULES = Object.freeze({ normal: Object.freeze({ draw: 1, handLimit: 3, energy: 2 }), @@ -13,6 +15,7 @@ const BASE_DECKS = Object.freeze({ const HEALTH = Object.freeze({ mengsan_soldier_shuying: 24, + mengsan_flyconid_shuying: 48, re_xiahoudun: 30, re_zhangliao: 28, re_xuzhu: 34, re_lvbu: 120, re_ganning: 39, re_zhangfei: 42, re_huangzhong: 37, re_weiyan: 40, re_guanyu: 65, dianwei: 62, shen_guanyu: 165, @@ -43,12 +46,12 @@ export class Monster { createDeck(prefix) { return this.deck.map((entry, index) => { const card = typeof entry === "string" ? { name: entry } : entry; - return { + return createCardData({ id: `${prefix}_${index}`, name: card.name, suit: card.suit || ["spade", "heart", "club", "diamond"][index % 4], number: card.number || index % 13 + 1, nature: card.nature || null, affixes: Array.isArray(card.affixes) ? card.affixes.slice() : [], upgrade: card.upgrade || 0, - }; + }, { enemy: true }); }); } } @@ -57,9 +60,12 @@ export class Monster { export class OpeningSoldier extends Monster { constructor(options = {}) { super({ character: "mengsan_soldier_shuying", tier: "normal", hp: 24, ...options }); } } +export class Flyconid extends Monster { + constructor(options = {}) { super({ ...options, character: "mengsan_flyconid_shuying", tier: "normal", hp: 48 }); } +} // 新怪物子类在这里注册,未注册的角色使用 Monster 的阶级默认值。 -export const MONSTER_TYPES = Object.freeze({ mengsan_soldier_shuying: OpeningSoldier }); +export const MONSTER_TYPES = Object.freeze({ mengsan_soldier_shuying: OpeningSoldier, mengsan_flyconid_shuying: Flyconid }); export function createMonster(spec) { const Type = Object.hasOwn(MONSTER_TYPES, spec.character) ? MONSTER_TYPES[spec.character] : Monster; diff --git a/mengsan/runtime/personal-piles.js b/mengsan/runtime/personal-piles.js index 7e24616..13ad60c 100644 --- a/mengsan/runtime/personal-piles.js +++ b/mengsan/runtime/personal-piles.js @@ -13,7 +13,7 @@ export function installPersonalPiles(session, owner, battle, run, resources, env const draw = document.createElement("div"), discard = document.createElement("div"); // 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(), restores = [], costBadges = new Map(); const strict = env.strict !== false; let released = false, forcedOwner = false; const personal = () => { @@ -27,6 +27,26 @@ export function installPersonalPiles(session, owner, battle, run, resources, env return false; // Never infer ownership from whose turn it is. }; const refresh = () => { if (!released) env.refresh(); }; + function showCost(card) { + if (!env.showCosts || !card?.name) return; + const cost = String(cardCost(card)); + card.dataset.mengsanCost = cost; + let badge = costBadges.get(card); + if (!badge) { + badge = Array.from(card.children || []).find(child => child.classList?.contains("mengsan-hand-cost-shuying")); + if (!badge) { + badge = document.createElement("span"); + badge.className = "mengsan-hand-cost-shuying"; + badge.setAttribute("aria-hidden", "true"); + card.appendChild(badge); + } + costBadges.set(card, badge); + } + // 十周年 UI scans thrown-card children via innerText when adding “刘备使用”. + // Keep this child text-free so its numeric cost cannot match that scan. + badge.textContent = ""; + badge.dataset.mengsanCost = cost; + } function adopt(card, data) { if (owned.has(card)) return; owned.add(card); resources.card(card); @@ -37,9 +57,7 @@ export function installPersonalPiles(session, owner, battle, run, resources, env // Retired affixes from old saves must not acquire new battle behaviour. card.storage.mengsanCard_shuying.affixes = (card.storage.mengsanCard_shuying.affixes || []).filter(key => key !== "annihilate"); } - if (data && env.showCosts) { - card.dataset.mengsanCost = String(cardCost(data)); - } + if (data) showCost(card); // Existing foreign/generated cards retain their original destruction semantics. if (!data) return; card.destroyLog = false; @@ -66,6 +84,27 @@ export function installPersonalPiles(session, owner, battle, run, resources, env const card = game.createCard(data.name, data.suit, data.number, data.nature); adopt(card, data); draw.appendChild(card); } + // Native and 十周年 UI both move real card nodes into these hand containers. + // Decorate newly gained cards without patching either UI's card factory or layout. + const handZones = [owner.node?.handcards1, owner.node?.handcards2].filter(Boolean); + const handObserver = new MutationObserver(mutations => { + if (released) return; + for (const mutation of mutations) for (const card of mutation.addedNodes) { + if (card.classList?.contains("card")) showCost(card); + } + }); + for (const zone of handZones) { + for (const card of zone.childNodes) if (card.classList?.contains("card")) showCost(card); + handObserver.observe(zone, { childList: true }); + } + session.ownResource(handObserver, () => { + handObserver.disconnect(); + for (const [card, badge] of costBadges) { + if (badge.parentNode === card) badge.remove(); + delete card.dataset.mengsanCost; + } + costBadges.clear(); + }); const publicDiscard = ui.discardPile; for (const [key, node] of [["cardPile", draw], ["discardPile", discard]]) { const descriptor = Object.getOwnPropertyDescriptor(ui, key); diff --git a/mengsan/runtime/reward-ui.js b/mengsan/runtime/reward-ui.js new file mode 100644 index 0000000..a3a9561 --- /dev/null +++ b/mengsan/runtime/reward-ui.js @@ -0,0 +1,190 @@ +import { lib, get } from "../../../../noname.js"; +import { cardCost } from "./combat-rules.js"; +import { chooseButtons } from "./flow-ui.js"; + +const element = (tag, className, text, parent) => { + const node = document.createElement(tag); + node.className = className; + if (text != null) node.textContent = text; + parent?.appendChild(node); + return node; +}; + +const plainText = value => { + const template = document.createElement("template"); + template.innerHTML = String(value ?? "").replace(//gi, "\n"); + return template.content.textContent.trim(); +}; + +const cardNameOf = choice => choice?.card?.name || ({ card_sha: "sha", card_tao: "tao" })[choice?.effectId] || null; +export const isCardReward = choice => Boolean(cardNameOf(choice)); + +const viewCard = choice => { + const name = cardNameOf(choice); + const info = lib.card[name]; + const imageName = info?.cardimage || name; + const image = /^[a-zA-Z0-9_]+$/.test(imageName) ? `${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 }), + description: plainText(lib.translate[`${name}_info`] || choice.description || "暂无卡牌介绍。"), + }; +}; + +const restoreFocus = previous => { if (previous?.isConnected) previous.focus(); }; +const RETURN_TO_VICTORY = Symbol("return to victory choice"); + +// Reusable card-only selector. It accepts three cards today and can lay out four or five later. +// Callers supply stable choice IDs; this UI never creates or awards cards itself. +export function chooseCardDialog(choices, { title = "选择一张牌", allowSkip = true, skipLabel = "跳过" } = {}) { + if (!Array.isArray(choices) || !choices.length || choices.some(choice => !choice?.id || !isCardReward(choice))) { + return Promise.reject(new TypeError("chooseCardDialog requires nonempty card choices with IDs")); + } + return new Promise((resolve, reject) => { + const previousFocus = document.activeElement; + const dialog = element("dialog", "mengsan-card-choice-dialog-shuying", null); + dialog.setAttribute("aria-label", title); + const stage = element("div", "mengsan-card-choice-stage-shuying", null, dialog); + const banner = element("header", "mengsan-card-choice-heading-shuying", null, stage); + element("span", "mengsan-reward-overline-shuying", "梦三 · 战利品", banner); + element("h2", "", title, banner); + element("p", "", "择一入阵,余者留于身后", banner); + const cards = element("div", "mengsan-card-choice-grid-shuying", null, stage); + cards.style.setProperty("--choice-count", String(Math.min(choices.length, 5))); + let settled = false; + const finish = id => { + if (settled) return; + settled = true; + if (dialog.open) dialog.close(); + dialog.remove(); + restoreFocus(previousFocus); + resolve(id); + }; + for (const choice of choices) { + const view = viewCard(choice); + const card = element("button", "mengsan-card-choice-tile-shuying", null, cards); + card.type = "button"; + card.setAttribute("aria-label", `${view.name},${view.type},${view.description}。选择此牌`); + card.addEventListener("click", () => finish(choice.id)); + element("span", "mengsan-card-choice-cost-shuying", String(view.cost), card); + element("span", "mengsan-card-choice-name-shuying", view.name, card); + const artwork = element("span", "mengsan-card-choice-art-shuying", null, card); + if (view.image) { + const image = element("img", "", null, artwork); + image.src = view.image; image.alt = ""; image.loading = "lazy"; + image.addEventListener("error", () => image.remove(), { once: true }); + } + element("span", "mengsan-card-choice-glyph-shuying", view.name.slice(0, 1), artwork); + element("span", "mengsan-card-choice-kind-shuying", view.type, card); + element("span", "mengsan-card-choice-description-shuying", view.description, card); + } + if (allowSkip) { + const skip = element("button", "mengsan-card-choice-skip-shuying", skipLabel, stage); + skip.type = "button"; + skip.addEventListener("click", () => finish(null)); + } + dialog.addEventListener("cancel", event => event.preventDefault()); + dialog.addEventListener("keydown", event => event.stopPropagation()); + // The engine scales ; attach to so viewport-sized modals stay full-screen. + document.documentElement.appendChild(dialog); + try { dialog.showModal(); cards.querySelector("button")?.focus(); } + catch (error) { dialog.remove(); restoreFocus(previousFocus); reject(error); } + }); +} + +// Reusable post-battle choice surface. Each option owns its follow-up; the surface +// only resolves a stable candidate ID (or null) and never awards a reward itself. +export function chooseVictoryOptions(options, { title = "战后抉择", allowSkip = true } = {}) { + 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")); + } + return new Promise((resolve, reject) => { + const previousFocus = document.activeElement; + const dialog = element("dialog", "mengsan-victory-dialog-shuying", null); + dialog.setAttribute("aria-label", title); + const stage = element("div", "mengsan-victory-stage-shuying", null, dialog); + const intro = element("header", "mengsan-victory-intro-shuying", null, stage); + element("span", "mengsan-reward-overline-shuying", "梦三 · 战后", intro); + element("span", "mengsan-victory-seal-shuying", "胜", intro); + element("h2", "", title, intro); + element("p", "", "此役已定,选择下一段征途的收获。", intro); + const board = element("div", "mengsan-victory-options-shuying", null, stage); + element("span", "mengsan-victory-eyebrow-shuying", "可选战利品", board); + let settled = false, choosing = false; + const finish = id => { + if (settled) return; + settled = true; + if (dialog.open) dialog.close(); + dialog.remove(); + restoreFocus(previousFocus); + resolve(id); + }; + for (const [index, option] of options.entries()) { + const entry = element("button", "mengsan-victory-option-shuying", null, board); + entry.type = "button"; + entry.setAttribute("aria-label", `${option.label}。${option.description || ""}`); + element("span", "mengsan-victory-option-index-shuying", String(index + 1).padStart(2, "0"), entry); + const copy = element("span", "mengsan-victory-option-copy-shuying", null, entry); + element("strong", "", option.label, copy); + if (option.description) element("small", "", option.description, copy); + element("span", "mengsan-victory-option-arrow-shuying", "›", entry).setAttribute("aria-hidden", "true"); + entry.addEventListener("click", async () => { + if (settled || choosing) return; + choosing = true; + entry.disabled = true; + if (dialog.open) dialog.close(); + try { + const selected = option.choose ? await option.choose() : option.id; + if (selected === RETURN_TO_VICTORY) { + dialog.showModal(); + entry.disabled = false; + choosing = false; + entry.focus(); + } else finish(selected); + } + catch (error) { + if (!settled) { settled = true; dialog.remove(); restoreFocus(previousFocus); reject(error); } + } + }); + } + if (allowSkip) { + const skip = element("button", "mengsan-victory-skip-shuying", "放弃本次选择", stage); + skip.type = "button"; + skip.addEventListener("click", () => { if (!choosing) finish(null); }); + } + dialog.addEventListener("cancel", event => event.preventDefault()); + dialog.addEventListener("keydown", event => event.stopPropagation()); + document.documentElement.appendChild(dialog); + try { dialog.showModal(); board.querySelector("button")?.focus(); } + catch (error) { dialog.remove(); restoreFocus(previousFocus); reject(error); } + }); +} + +// Gold is a persisted candidate, not an automatic reward. The card pool remains +// a separate Dialog; skipping that Dialog returns to the victory choices. +export function chooseBattleReward(choices) { + if (!Array.isArray(choices) || !choices.length) return Promise.resolve(null); + const gold = choices.find(choice => choice.kind === "gold"); + 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); + 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; + }, + }); + } + return chooseVictoryOptions(options); +} diff --git a/mengsan/runtime/reward.js b/mengsan/runtime/reward.js index 442b129..f52e202 100644 --- a/mengsan/runtime/reward.js +++ b/mengsan/runtime/reward.js @@ -1,9 +1,17 @@ import config from "../config.js"; import { nextRandom } from "../state.js"; +import { canAcquireCard } from "../content/card-definitions.js"; + +export const rewardCardName = reward => reward?.card?.name || ({ card_sha: "sha", card_tao: "tao" })[reward?.effectId] || null; +export const canAcquireReward = (run, reward) => { + const name = rewardCardName(reward); + return !name || canAcquireCard(run?.player?.character, name); +}; export const getRandomRewardChoices = (run, poolId, count = 3) => { const fallbackId = "shared.pool.battle.normal"; - const pool = (config.rewardPools[poolId] || config.rewardPools[fallbackId]).slice(); + const pool = (config.rewardPools[poolId] || config.rewardPools[fallbackId]) + .filter(id => canAcquireReward(run, config.rewards[id])).slice(); for (let index = pool.length - 1; index > 0; index--) { const target = Math.floor(nextRandom(run) * (index + 1)); [pool[index], pool[target]] = [pool[target], pool[index]]; diff --git a/mengsan/style.css b/mengsan/style.css index aab0897..df2fb27 100644 --- a/mengsan/style.css +++ b/mengsan/style.css @@ -979,6 +979,158 @@ .mengsan-flow-panel-shuying .mengsan-choice-description-shuying { display: block; margin-top: 6px; color: var(--flow-muted); font-size: 14px; line-height: 1.6; } .mengsan-flow-panel-shuying .mengsan-choice-shuying:disabled { opacity: .5; cursor: not-allowed; } .mengsan-flow-panel-shuying .mengsan-choice-shuying.danger { border-color: #d89586; background: #853d3440; } + +/* Victory choices and card choices are independent, reusable ink-and-jade surfaces. */ +dialog.mengsan-victory-dialog-shuying, dialog.mengsan-card-choice-dialog-shuying { + --reward-ink: #101c20; --reward-jade: #32736c; --reward-celadon: #8db4a5; + --reward-bronze: #c7a477; --reward-paper: #eee2c9; --reward-muted: #cabda5; + position: fixed !important; inset: 0 !important; width: 100vw !important; height: 100dvh !important; + max-width: none !important; max-height: none !important; margin: 0 !important; padding: 0 !important; + border: 0 !important; overflow: auto !important; transform: none !important; background: transparent !important; + color: var(--reward-paper); font: 16px/1.5 "Microsoft YaHei", sans-serif; text-align: left; +} +dialog.mengsan-victory-dialog-shuying::backdrop, dialog.mengsan-card-choice-dialog-shuying::backdrop { + background: rgba(4, 10, 13, .88); +} +.mengsan-victory-dialog-shuying *, .mengsan-card-choice-dialog-shuying *, +.mengsan-victory-dialog-shuying *::before, .mengsan-card-choice-dialog-shuying *::before, +.mengsan-victory-dialog-shuying *::after, .mengsan-card-choice-dialog-shuying *::after { box-sizing: border-box; } +.mengsan-reward-overline-shuying { + display: block; color: var(--reward-celadon); font: 700 12px/1.4 "Microsoft YaHei",sans-serif; + letter-spacing: .22em; +} +.mengsan-victory-stage-shuying { + position: relative !important; display: grid !important; grid-template-columns: minmax(210px,260px) minmax(320px,520px); + grid-template-rows: auto auto; align-content: center; justify-content: center; gap: 0 32px; + min-height: 100%; width: 100%; padding: 48px 28px; +} +.mengsan-victory-stage-shuying::before { + content: ""; position: absolute !important; z-index: -1; inset: 0; + background: radial-gradient(ellipse at 48% 48%,rgba(46,88,80,.25),transparent 62%); + pointer-events: none; +} +.mengsan-victory-intro-shuying { + position: relative !important; grid-column: 1; grid-row: 1 / 3; align-self: center; + padding: 26px 26px 28px 0; border-right: 1px solid rgba(199,164,119,.45); +} +.mengsan-victory-intro-shuying::after { + content: ""; display: block; width: 66px; height: 3px; margin-top: 28px; background: var(--reward-bronze); +} +.mengsan-victory-seal-shuying { + display: grid !important; place-items: center; width: 88px; height: 88px; margin: 29px 0 24px; + border: 3px double var(--reward-bronze); border-radius: 8px; transform: rotate(-7deg); + color: var(--reward-paper); background: #6b302b; box-shadow: 0 8px 24px #0007; + font: 700 52px/1 "KaiTi","STKaiti",serif; +} +.mengsan-victory-intro-shuying h2 { + margin: 0 0 8px; color: var(--reward-paper); font: 700 clamp(32px,3.5vw,46px)/1.15 "KaiTi","STKaiti",serif; + letter-spacing: .09em; +} +.mengsan-victory-intro-shuying p { max-width: 17em; margin: 0; color: var(--reward-muted); font-size: 15px; line-height: 1.8; } +.mengsan-victory-options-shuying { + position: relative !important; grid-column: 2; grid-row: 1; display: flex !important; flex-direction: column !important; + gap: 12px; max-height: min(66dvh,560px); padding: 24px; overflow-y: auto; + border: 1px solid rgba(199,164,119,.65); border-radius: 7px; + background: linear-gradient(145deg,#1e3435,#111f24 68%); + box-shadow: inset 0 0 0 5px rgba(9,19,22,.8),0 22px 50px #0008; +} +.mengsan-victory-eyebrow-shuying { color: var(--reward-bronze); font: 700 13px/1.4 "Microsoft YaHei",sans-serif; letter-spacing: .16em; margin: 0 0 5px; } +button.mengsan-victory-option-shuying { + position: relative !important; display: flex !important; align-items: center; gap: 18px; + width: 100%; min-height: 94px; padding: 17px 18px; border: 1px solid #60857a; + border-left: 4px solid var(--reward-bronze); border-radius: 3px; color: var(--reward-paper); + background: linear-gradient(110deg,#254b49,#1a3238); text-align: left; cursor: pointer; + transition: background .18s ease, border-color .18s ease, box-shadow .18s ease; +} +button.mengsan-victory-option-shuying:hover, button.mengsan-victory-option-shuying:focus-visible { + border-color: var(--reward-paper); background: linear-gradient(110deg,#346a60,#24434a); + box-shadow: inset 0 0 0 1px #9dbca9,0 6px 20px #0005; +} +button.mengsan-victory-option-shuying:focus-visible, button.mengsan-victory-skip-shuying:focus-visible, +button.mengsan-card-choice-tile-shuying:focus-visible, button.mengsan-card-choice-skip-shuying:focus-visible { + outline: 3px solid #d9e9bb; outline-offset: 4px; +} +button.mengsan-victory-option-shuying:disabled { opacity: .6; cursor: wait; } +.mengsan-victory-option-index-shuying { flex: 0 0 44px; color: var(--reward-bronze); font: 700 24px/1 "KaiTi",serif; } +.mengsan-victory-option-copy-shuying { display: flex !important; flex: 1; min-width: 0; flex-direction: column; gap: 4px; } +.mengsan-victory-option-copy-shuying strong { color: #fff1d5; font: 700 23px/1.2 "KaiTi",serif; } +.mengsan-victory-option-copy-shuying small { color: #d3e0d4; font: 14px/1.5 "Microsoft YaHei",sans-serif; } +.mengsan-victory-option-arrow-shuying { flex: 0 0 28px; color: var(--reward-bronze); font: 34px/1 "KaiTi",serif; text-align: center; } +button.mengsan-victory-skip-shuying { + grid-column: 2; grid-row: 2; justify-self: end; min-height: 48px; margin-top: 16px; padding: 8px 4px; + border: 0; border-bottom: 1px solid var(--reward-muted); color: var(--reward-muted); + background: transparent; font: 16px/1.4 "Microsoft YaHei",sans-serif; cursor: pointer; +} +button.mengsan-victory-skip-shuying:hover { color: #fff1d5; border-color: #fff1d5; } +.mengsan-card-choice-stage-shuying { + position: relative !important; display: flex !important; flex-direction: column !important; align-items: center; + justify-content: center; gap: clamp(20px,4vh,38px); min-height: 100%; width: 100%; padding: 36px 22px 48px; + background: radial-gradient(ellipse at 50% 48%,rgba(47,80,73,.21),transparent 68%); +} +.mengsan-card-choice-heading-shuying { width: min(620px,100%); text-align: center; } +.mengsan-card-choice-heading-shuying::after { content: ""; display: block; width: 132px; height: 2px; margin: 15px auto 0; background: linear-gradient(90deg,transparent,var(--reward-bronze),transparent); } +.mengsan-card-choice-heading-shuying h2 { margin: 7px 0 4px; color: var(--reward-paper); font: 700 clamp(28px,3vw,38px)/1.2 "KaiTi","STKaiti",serif; letter-spacing: .13em; } +.mengsan-card-choice-heading-shuying p { margin: 0; color: var(--reward-muted); font-size: 14px; } +.mengsan-card-choice-grid-shuying { + position: relative !important; display: grid !important; grid-template-columns: repeat(var(--choice-count),minmax(0,210px)); + justify-content: center; align-items: stretch; gap: 18px; width: max-content; max-width: 100%; +} +button.mengsan-card-choice-tile-shuying { + position: relative !important; display: flex !important; flex-direction: column !important; align-items: stretch; + min-width: 0; min-height: 322px; padding: 11px; border: 1px solid #9e8261; border-radius: 6px; + color: var(--reward-paper); background: linear-gradient(155deg,#355550,#1b3034 48%,#182429); + box-shadow: inset 0 0 0 4px #1a282a,inset 0 0 0 5px #57746c,0 12px 25px #0009; + font: inherit; text-align: center; appearance: none; cursor: pointer; + transition: border-color .18s ease, box-shadow .18s ease, transform .18s ease; +} +button.mengsan-card-choice-tile-shuying:hover, button.mengsan-card-choice-tile-shuying:focus-visible { + border-color: #f4d7a0; box-shadow: inset 0 0 0 4px #1a282a,inset 0 0 0 5px #b59667,0 16px 28px #000a; + transform: translateY(-4px); +} +.mengsan-card-choice-cost-shuying { + position: absolute !important; z-index: 2; top: -13px; left: -12px; display: grid !important; place-items: center; + width: 38px; height: 38px; border: 2px solid #e2c38c; border-radius: 50%; color: #fff1d5; + background: #7a3b31; font: 700 19px/1 "KaiTi",serif; box-shadow: 0 3px 9px #0009; +} +.mengsan-card-choice-name-shuying { display: block !important; min-height: 32px; padding: 2px 6px 5px; color: #f4e6c5; border-bottom: 1px solid #b69a6b; font: 700 20px/1.3 "KaiTi",serif; } +.mengsan-card-choice-art-shuying { + position: relative !important; display: grid !important; place-items: center; height: 128px; margin: 10px 0 7px; + overflow: hidden; border: 1px solid #a98d68; border-radius: 3px; + background: radial-gradient(circle,#4f5f55,#192629 70%); +} +.mengsan-card-choice-art-shuying img { position: absolute !important; z-index: 1; inset: 0; width: 100%; height: 100%; object-fit: contain; background: #263333; } +.mengsan-card-choice-glyph-shuying { color: #e9d4ae; font: 700 52px/1 "KaiTi",serif; } +.mengsan-card-choice-kind-shuying { align-self: center; margin-bottom: 6px; padding: 1px 12px; border: 1px solid #b99a6b; color: #f0dfba; font-size: 12px; } +.mengsan-card-choice-description-shuying { display: block !important; flex: 1; overflow: hidden; padding: 2px 3px 0; color: #e8e5d6; font-size: 13px; line-height: 1.55; } +button.mengsan-card-choice-skip-shuying { + min-height: 48px; padding: 9px 28px; border: 1px solid #82978b; border-radius: 3px; + color: var(--reward-muted); background: #1a2b2f; font: 16px/1.4 "Microsoft YaHei",sans-serif; cursor: pointer; +} +button.mengsan-card-choice-skip-shuying:hover { color: #fff1d5; border-color: #d4bc91; background: #27433f; } +@media (max-width: 1100px) { .mengsan-card-choice-grid-shuying { grid-template-columns: repeat(var(--choice-count),minmax(0,180px)); gap: 14px; } } +@media (max-width: 700px) { + .mengsan-victory-stage-shuying { grid-template-columns: minmax(0,520px); gap: 18px; align-content: start; padding: 30px 18px; } + .mengsan-victory-intro-shuying { grid-column: 1; grid-row: 1; padding: 0 0 15px; border-right: 0; border-bottom: 1px solid rgba(199,164,119,.45); } + .mengsan-victory-seal-shuying { display: none !important; } + .mengsan-victory-intro-shuying::after { display: none; } + .mengsan-victory-intro-shuying h2 { margin-top: 8px; } + .mengsan-victory-options-shuying { grid-column: 1; grid-row: 2; max-height: none; padding: 18px; } + button.mengsan-victory-skip-shuying { grid-column: 1; grid-row: 3; } + .mengsan-card-choice-stage-shuying { justify-content: flex-start; } + .mengsan-card-choice-grid-shuying { grid-template-columns: repeat(3,minmax(0,1fr)); width: min(620px,100%); } +} +@media (max-width: 560px) { + .mengsan-card-choice-grid-shuying { grid-template-columns: repeat(2,minmax(0,1fr)); gap: 15px; } + button.mengsan-card-choice-tile-shuying { min-height: 292px; } + .mengsan-card-choice-art-shuying { height: 105px; } + .mengsan-victory-option-index-shuying { flex-basis: 30px; font-size: 20px; } + button.mengsan-victory-option-shuying { gap: 10px; padding: 13px; } +} +@media (max-width: 350px) { .mengsan-card-choice-grid-shuying { grid-template-columns: minmax(0,210px); } } +@media (prefers-reduced-motion: reduce) { + button.mengsan-victory-option-shuying, button.mengsan-card-choice-tile-shuying { transition: none; } + button.mengsan-card-choice-tile-shuying:hover, button.mengsan-card-choice-tile-shuying:focus-visible { transform: none; } +} .mengsan-flow-menu-shuying { position: absolute !important; top: max(16px, env(safe-area-inset-top)); right: max(20px, env(safe-area-inset-right)); z-index: 4; min-width: 96px; min-height: 48px; padding: 8px 16px; @@ -1123,11 +1275,14 @@ dialog.mengsan-deck-dialog-shuying::backdrop { background: #080706b8; } .mengsan-gm-dialog-shuying .ms-gm-affixes button:disabled { opacity: .55; cursor: not-allowed; } @media (max-height: 500px) { .mengsan-battle-piles-button-shuying { top: 70px; right: max(8px, env(safe-area-inset-right)); } } -/* Only session-created Mengsan players; retain internal identity and side. */ +/* Only session-created Mengsan players; retain internal identity and seat order. */ .player.mengsan-hide-identity-shuying > .identity { display: none !important; pointer-events: none !important; } +.player.mengsan-hide-identity-shuying > .seat { + display: none !important; +} .player > .mengsan-energy-shuying { position: absolute !important; top: -22px !important; @@ -1146,17 +1301,77 @@ dialog.mengsan-deck-dialog-shuying::backdrop { background: #080706b8; } text-align: center; pointer-events: none; } -.card[data-mengsan-cost]::after { - content: attr(data-mengsan-cost) "费"; - position: absolute; - top: 1px; - left: 1px; - z-index: 5; - padding: 1px 3px; - border-radius: 3px; - background: #173b43e8; - color: #fff1c5; - font-size: 11px; - line-height: 1.2; +/* The selected skill appears below an enemy's frame; number precedes the weapon icon. */ +.player > .mengsan-intent-shuying { + position: absolute !important; + bottom: -31px !important; + left: 50% !important; + transform: translateX(-50%) !important; + z-index: 9; + display: flex !important; + align-items: center; + gap: 5px; + width: max-content !important; + max-width: 145px; + min-height: 25px; + padding: 2px 5px; + border: 1px solid #a87967; + border-radius: 6px; + background: #1b1719ef; + box-shadow: 0 2px 7px #0009; + color: #fff0e4; + font: 700 12px/1.2 "Microsoft YaHei", sans-serif; + white-space: nowrap; pointer-events: none; } +.player > .mengsan-intent-shuying[hidden], +.mengsan-intent-damage-shuying[hidden], +.mengsan-intent-debuff-shuying[hidden] { display: none !important; } +.mengsan-intent-name-shuying { + overflow: hidden; + text-overflow: ellipsis; +} +.mengsan-intent-damage-shuying { + display: inline-flex; + flex: none; + align-items: center; + gap: 1px; + color: #ffe4d1; + font-size: 15px; + font-variant-numeric: tabular-nums; +} +.mengsan-intent-debuff-shuying { + display: inline-flex; + flex: none; + align-items: center; + gap: 1px; + color: #d8aee5; + font-size: 11px; +} +.mengsan-intent-icon-shuying { + display: block; + width: 22px; + height: 22px; + object-fit: contain; +} +/* Own a child badge, not card::after (which 十周年 UI reserves and hides). */ +.handcards > .card[data-mengsan-cost] > .mengsan-hand-cost-shuying { + position: absolute !important; + top: 55px !important; + left: 3px !important; + z-index: 7 !important; + display: grid !important; + place-items: center; + width: 24px; + height: 24px; + border: 1px solid #d8ba83; + border-radius: 50%; + background: #234844f2; + box-shadow: 0 2px 4px #0008; + color: #fff1cb; + font: 700 14px/1 "Microsoft YaHei", sans-serif; + pointer-events: none; +} +.handcards > .card[data-mengsan-cost] > .mengsan-hand-cost-shuying::before { + content: attr(data-mengsan-cost); +}