diff --git a/.gitignore b/.gitignore index c418696..375028a 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,5 @@ log.txt dist/manifest.json tools/ + +/mengsan/.planning/ diff --git a/extension.js b/extension.js index 95c674b..8e20757 100644 --- a/extension.js +++ b/extension.js @@ -247,10 +247,10 @@ shuYing.precontent = async () => { shuYing.initCSS(); shuYing.initFunction(); shuYing.initConfig(); - // const initMengsan = await shuYing.loadModule("./mengsan/mode.js", "梦三模式", true); - // if (typeof initMengsan == "function") { - // await initMengsan(); - // } + const registerMengsan = await shuYing.loadModule("./mengsan/register.js", "梦三模式", true); + if (typeof registerMengsan == "function") { + registerMengsan(); + } initShuYingMenu({ lib, game, ui, shuYing, updater }); shuYing.getVersion(); await shuYing.initCharacter(); diff --git a/mengsan/assets/sushuang-zhangshiping.png b/mengsan/assets/sushuang-zhangshiping.png new file mode 100644 index 0000000..619addb Binary files /dev/null and b/mengsan/assets/sushuang-zhangshiping.png differ diff --git a/mengsan/config.js b/mengsan/config.js index f8f886c..45ae2e1 100644 --- a/mengsan/config.js +++ b/mengsan/config.js @@ -1,68 +1,28 @@ +import { defaultDeck } from "./content/card-library.js"; +import contentRegistry from "./content/registry.js"; + const mengsanConfig = { modeId: "mengsan_shuying", saveKey: "mengsan_run_shuying", profileKey: "mengsan_profile_shuying", schemaVersion: 1, - mapLayoutVersion: 3, + mapLayoutVersion: 7, routeCount: 4, - characters: ["re_zhaoyun", "sunquan", "re_huangyueying", "re_zhangfei"], - acts: [ - { - id: "act1", - name: "第一关·初梦", - floorNodes: [1, 4, 4, 4, 1], - minFloorNodes: [1, 3, 3, 3, 1], - nodeWeights: { battle: 65, event: 15, rest: 10, shop: 10 }, - enemies: ["re_xiahoudun", "re_zhangliao", "re_xuzhu"], - boss: "re_lvbu", - baseGold: 20, - }, - { - id: "act2", - name: "第二关·迷梦", - floorNodes: [1, 4, 4, 4, 4, 1], - minFloorNodes: [1, 3, 2, 3, 3, 1], - nodeWeights: { battle: 55, elite: 15, event: 12, rest: 9, shop: 9 }, - enemies: ["re_ganning", "re_zhangfei", "re_huangzhong", "re_weiyan"], - eliteEnemies: ["re_guanyu", "dianwei"], - boss: "shen_guanyu", - baseGold: 30, - }, - { - id: "act3", - name: "第三关·终梦", - floorNodes: [1, 4, 4, 4, 4, 4, 1], - minFloorNodes: [1, 3, 2, 3, 2, 3, 1], - nodeWeights: { battle: 45, elite: 20, event: 15, rest: 10, shop: 10 }, - enemies: ["re_simayi", "re_sunben", "dongzhuo", "re_yuanshao"], - eliteEnemies: ["shen_zhaoyun", "shen_simayi"], - boss: "shen_caocao", - baseGold: 40, - }, - ], - startingDeck: [ - ["spade", 7, "sha"], - ["heart", 10, "sha"], - ["club", 4, "sha"], - ["diamond", 6, "sha"], - ["heart", 2, "shan"], - ["diamond", 7, "shan"], - ["club", 2, "shan"], - ["heart", 3, "tao"], - ["spade", 3, "guohe"], - ["heart", 7, "wuzhong"], - ["spade", 2, "bagua"], - ["club", 6, "sha", null, ["annihilate"]], - ], + characters: ["mengsan_liubei_shuying"], + acts: contentRegistry.acts, + startingDeck: defaultDeck, nodeNames: { battle: "战斗", elite: "精英", event: "事件", - story: "剧情战斗", + story: "剧情", rest: "休息", shop: "商店", boss: "Boss", }, + nodeContents: contentRegistry.nodeContents, + rewardPools: contentRegistry.rewardPools, + rewards: contentRegistry.rewards, }; export default mengsanConfig; diff --git a/mengsan/content/acts/act1/index.js b/mengsan/content/acts/act1/index.js new file mode 100644 index 0000000..47792ad --- /dev/null +++ b/mengsan/content/acts/act1/index.js @@ -0,0 +1,14 @@ +import liubeiOpening, { openingRewards } from "./stories/liubei-opening.js"; +import actMap from "./map.js"; +import { rewardPools } from "./rewards.js"; +import forestAmbush from "./stories/forest-ambush.js"; + +export default { + map: actMap, + nodeContents: { + [forestAmbush.id]: forestAmbush, + [liubeiOpening.id]: liubeiOpening, + }, + rewardPools, + rewards: openingRewards, +}; diff --git a/mengsan/content/acts/act1/map.js b/mengsan/content/acts/act1/map.js new file mode 100644 index 0000000..1bd95b9 --- /dev/null +++ b/mengsan/content/acts/act1/map.js @@ -0,0 +1,20 @@ +const act1Map = { + id: "act1", + name: "第一关·初梦", + floorNodes: [1, 4, 4, 4, 1], + minFloorNodes: [1, 3, 3, 3, 1], + nodeWeights: { battle: 65, event: 15, rest: 10, shop: 10 }, + fixedNodes: [ + { + floor: 0, + position: "start", + type: "battle", + contentId: "act1.battle.liubeiOpening", + }, + ], + enemies: ["re_xiahoudun", "re_zhangliao", "re_xuzhu"], + boss: "re_lvbu", + baseGold: 20, +}; + +export default act1Map; diff --git a/mengsan/content/acts/act1/rewards.js b/mengsan/content/acts/act1/rewards.js new file mode 100644 index 0000000..0374d05 --- /dev/null +++ b/mengsan/content/acts/act1/rewards.js @@ -0,0 +1,16 @@ +export const rewardPools = { + "act1.pool.story.scoutGift": [ + "shared.reward.support.scout", + "shared.reward.card.sha", + "shared.reward.card.tao", + "shared.reward.card.upgrade", + "shared.reward.item.handCharm", + ], + "act1.pool.story.ambushLoot": [ + "shared.reward.card.sha", + "shared.reward.card.tao", + "shared.reward.card.upgrade", + "shared.reward.heal", + "shared.reward.item.handCharm", + ], +}; diff --git a/mengsan/content/acts/act1/stories/forest-ambush.js b/mengsan/content/acts/act1/stories/forest-ambush.js new file mode 100644 index 0000000..32bfc1e --- /dev/null +++ b/mengsan/content/acts/act1/stories/forest-ambush.js @@ -0,0 +1,112 @@ +const forestAmbush = { + id: "act1.story.forestAmbush", + kind: "dialogue", + name: "林中伏兵", + mapName: "林中伏兵", + description: "你在密林中遇到一名负伤的斥候。他声称前方有伏兵,并请求你决定下一步行动。", + dialogue: [ + { + type: "narrator", + text: "暮色压进密林,归巢的鸟雀忽然一齐噤声。", + }, + { + type: "sound", + speaker: "林间异响", + text: "沙沙……咔嚓!", + speed: 45, + }, + { + type: "character", + character: "re_zhangliao", + speaker: "负伤斥候", + text: "且慢!前方埋伏着一队精兵。我侥幸逃出,已无力再探。", + }, + { + type: "character", + character: "$player", + speaker: "$player", + text: "先把你知道的情形说清楚,我再决定如何行动。", + }, + ], + choices: [ + { + id: "listen_and_leave", + name: "听完警告后离开", + description: "结束对话,不发生战斗,也不获得奖励。", + outcome: { + result: "你记下了斥候的警告,绕开了危险区域。", + dialogue: [ + { type: "narrator", text: "你记下警告,沿着一条隐蔽的小径绕开了危险区域。" }, + ], + flags: { + "story.act1.heardAmbushWarning": true, + "story.act1.ambushResult": "left", + }, + }, + }, + { + id: "help_scout", + name: "帮助斥候处理伤势", + description: "不进入战斗,从随机出现的三个谢礼中选择一个。", + outcome: { + result: "斥候恢复了行动能力,并拿出随身物资作为谢礼。", + dialogue: [ + { + type: "character", + character: "re_zhangliao", + speaker: "负伤斥候", + text: "多谢相助。这些随身物资,请务必收下。", + }, + ], + rewardPool: "act1.pool.story.scoutGift", + rewardTitle: "斥候的谢礼(三选一)", + flags: { + "story.act1.helpedScout": true, + "story.act1.ambushResult": "helped", + }, + }, + }, + { + id: "hunt_ambushers", + name: "主动追击伏兵", + description: "迎战两名伏兵,第 3 轮还有敌军增援;胜利后获得金币并选择战利品。", + outcome: { + result: "你循着林间留下的痕迹追了上去。", + dialogue: [ + { type: "character", character: "$player", speaker: "$player", text: "与其绕路,不如趁他们尚未察觉,先发制人。" }, + { type: "sound", text: "刀剑出鞘之声划破寂静。" }, + ], + battle: { + enemies: ["re_xiahoudun", "re_zhangliao", "re_xuzhu"], + gold: 30, + // Two initial enemies; a third-round reinforcement demonstrates the rule system. + battlePlan: { + units: [ + { id: "captain", character: "re_xiahoudun", camp: "enemy", hand: 4 }, + { id: "guard", character: "re_xuzhu", camp: "enemy", hand: 2 }, + ], + rules: [ + { id: "enemy_reinforcement", name: "伏兵增援", blocksVictory: true, when: { event: "roundStart", round: 3 }, + effects: [{ type: "spawn", unit: { id: "reinforcement", character: "re_zhangliao", camp: "enemy", hand: 3 } }] }, + { id: "captain_rally", name: "困兽犹斗", when: { event: "state", all: [ + { unit: "captain", field: "alive", value: true }, { unit: "captain", field: "hp", op: "lte", value: 2 }, + ] }, effects: [{ type: "draw", target: "captain", amount: 2 }] }, + { id: "player_supply", name: "临阵补给", when: { event: "state", all: [ + { unit: "player", field: "hand", op: "lte", value: 1 }, + ] }, effects: [{ type: "draw", target: "player", amount: 2 }] }, + ], + }, + rewardPool: "act1.pool.story.ambushLoot", + rewardTitle: "伏兵战利品(三选一)", + description: "击败伏兵后,从随机出现的三个奖励中选择一个。", + }, + flags: { + "story.act1.huntedAmbushers": true, + "story.act1.ambushResult": "fought", + }, + }, + }, + ], +}; + +export default forestAmbush; diff --git a/mengsan/content/acts/act1/stories/liubei-opening.js b/mengsan/content/acts/act1/stories/liubei-opening.js new file mode 100644 index 0000000..948f22e --- /dev/null +++ b/mengsan/content/acts/act1/stories/liubei-opening.js @@ -0,0 +1,72 @@ +export const openingDialogue = [ + { type: "narrator", text: "你是21世纪的一名普通牛马。" }, + { type: "narrator", text: "某天你晚上9点照常下班,刚出公司大门,就撞大运了。" }, + { type: "narrator", text: "等你睁开眼发现自己竟已身在三国,成为桃园结义后的刘备了。" }, + { type: "character", character: "$player", text: "(内心)我叫刘备。眼前这两位,便是与我结为兄弟的关羽和张飞。此刻,三弟正与二弟谈论黄巾之乱。" }, + { type: "character", character: "zhangfei", text: "大哥!如今黄巾作乱,官府正在招募义兵。俺们既已结为兄弟,何不召集乡勇,起兵讨伐黄巾贼?" }, + { type: "character", character: "guanyu", text: "三弟所言有理。只是起兵讨贼,须备足钱粮、兵器,方能成事。此事如何安排,还请大哥定夺。" }, + { + type: "choice", text: "还好我历史满分!但是我不想干活啊。", flag: "story.act1.liubeiOpening.reply", defaultChoice: "gather", choices: [ + { id: "gather", text: "眼下先召集乡亲,整备人手,再从长计议。待我上山巡视一番,说不定有一张SSR呢。" }, + ] + }, + { type: "character", character: "zhangfei", text: "SSR为何物?" }, + { type: "narrator", text: "你不等张飞吐槽,转身拂袖而去。" }, + { type: "narrator", text: "乡间小道之上,忽听前方有人高声喝道:“黄巾军押运辎重,闲人速速避让,莫要挡路!”" }, + { type: "narrator", text: "只见一人一马疾驰而来。" }, + { type: "character", character: "$player", text: "(内心)这就开始刷怪了?白送的物资来了。" }, + { type: "narrator", text: "只见你伸腿一拦,只见对方勒马拔剑,进入战斗。" }, + +]; +export const reinforcementDialogue = [ + { type: "character", character: "$player", text: "(内心)我靠,对面有马我没马啊。" }, + { type: "character", character: "mengsan_sushuang_zhangshiping_shuying", text: "将军莫急,我等前来助战。" }, +]; +export const victoryDialogue = [ + { type: "character", character: "$player", text: "方才多谢二位出手相助,尚未请教二位尊姓大名?" }, + { type: "character", character: "mengsan_sushuang_zhangshiping_shuying", text: "在下苏双,这位是张世平。我二人皆是中山商贾。" }, + { type: "character", character: "$player", text: "原来是苏兄、张兄。不知二位为何来到此处?" }, + { type: "character", character: "mengsan_sushuang_zhangshiping_shuying", text: "我二人贩马经商,途经涿郡。久闻玄德素有豪侠之名,特来拜会。" }, + { type: "character", character: "mengsan_sushuang_zhangshiping_shuying", text: "入城打听之时,恰遇张壮士。听闻玄德正在此处,我二人便一路寻来。" }, + { type: "character", character: "mengsan_sushuang_zhangshiping_shuying", text: "不料刚到此地,便见玄德与黄巾贼交战。今日一见,果然名不虚传。" }, + { type: "character", character: "$player", text: "二位过誉了。既然远道而来,不如随我回寒舍一叙?" }, + { type: "character", character: "mengsan_sushuang_zhangshiping_shuying", text: "玄德盛情,我二人心领了。只是商队不日便要启程,实不便久留。" }, + { type: "character", character: "mengsan_sushuang_zhangshiping_shuying", text: "听闻玄德正欲招募乡勇、讨伐黄巾。我二人虽是商贾,也愿略尽绵薄之力。" }, + { type: "character", character: "mengsan_sushuang_zhangshiping_shuying", text: "我二人常年贩马,别的或许帮不上忙,良马倒有一些,愿赠与玄德,助义兵一臂之力。" }, + { type: "character", character: "mengsan_sushuang_zhangshiping_shuying", text: "至于甲胄兵器,我二人并不经营。听闻蜀郡有一年轻匠人,名唤蒲元,颇善锻造,如今在刘焉麾下。玄德若欲寻他,不妨前往投靠刘焉。" } +]; + +export const openingRewards = { + "act1.reward.dilu": { + id: "act1.reward.dilu", name: "的卢", description: "+1坐骑,加入本次征程的个人牌库。", + card: { name: "dilu", suit: "club", number: 5, nature: null }, + }, +}; + +export default { + id: "act1.battle.liubeiOpening", kind: "battle", name: "刘备初始剧情", mapName: "初识的卢", + requiredCharacter: "mengsan_liubei_shuying", + enemies: ["mengsan_soldier_shuying"], gold: 20, + skipRandomReward: true, fixedRewards: ["act1.reward.dilu"], openingDialogue, victoryDialogue, + battlePlan: { + units: [{ + id: "soldier", character: "mengsan_soldier_shuying", camp: "enemy", + inheritSkills: false, skills: [], hand: 4, hp: 24, maxHp: 24, + equipment: [{ name: "dilu", suit: "club", number: 5 }], + }], + rules: [{ + id: "merchants_arrive", name: "苏双与张世平支援", + when: { event: "turnEnd", actor: "player", actorTurns: 4 }, + effects: [ + { type: "dialogue", lines: reinforcementDialogue }, + { + type: "spawn", unit: { + id: "merchants", character: "mengsan_sushuang_zhangshiping_shuying", camp: "ally", + after: "player", inheritSkills: false, skills: [], hand: 4, hp: 4, maxHp: 4, + } + }, + { type: "discardEquipment", target: "soldier", name: "dilu" }, + ], + }], + }, +}; diff --git a/mengsan/content/acts/act2/index.js b/mengsan/content/acts/act2/index.js new file mode 100644 index 0000000..e41f363 --- /dev/null +++ b/mengsan/content/acts/act2/index.js @@ -0,0 +1,8 @@ +import actMap from "./map.js"; + +export default { + map: actMap, + nodeContents: {}, + rewardPools: {}, + rewards: {}, +}; diff --git a/mengsan/content/acts/act2/map.js b/mengsan/content/acts/act2/map.js new file mode 100644 index 0000000..d28fbad --- /dev/null +++ b/mengsan/content/acts/act2/map.js @@ -0,0 +1,13 @@ +const act2Map = { + id: "act2", + name: "第二关·迷梦", + floorNodes: [1, 4, 4, 4, 4, 1], + minFloorNodes: [1, 3, 2, 3, 3, 1], + nodeWeights: { battle: 55, elite: 15, event: 12, rest: 9, shop: 9 }, + enemies: ["re_ganning", "re_zhangfei", "re_huangzhong", "re_weiyan"], + eliteEnemies: ["re_guanyu", "dianwei"], + boss: "shen_guanyu", + baseGold: 30, +}; + +export default act2Map; diff --git a/mengsan/content/acts/act3/index.js b/mengsan/content/acts/act3/index.js new file mode 100644 index 0000000..e41f363 --- /dev/null +++ b/mengsan/content/acts/act3/index.js @@ -0,0 +1,8 @@ +import actMap from "./map.js"; + +export default { + map: actMap, + nodeContents: {}, + rewardPools: {}, + rewards: {}, +}; diff --git a/mengsan/content/acts/act3/map.js b/mengsan/content/acts/act3/map.js new file mode 100644 index 0000000..973b9bd --- /dev/null +++ b/mengsan/content/acts/act3/map.js @@ -0,0 +1,13 @@ +const act3Map = { + id: "act3", + name: "第三关·终梦", + floorNodes: [1, 4, 4, 4, 4, 4, 1], + minFloorNodes: [1, 3, 2, 3, 2, 3, 1], + nodeWeights: { battle: 45, elite: 20, event: 15, rest: 10, shop: 10 }, + enemies: ["re_simayi", "re_sunben", "dongzhuo", "re_yuanshao"], + eliteEnemies: ["shen_zhaoyun", "shen_simayi"], + boss: "shen_caocao", + baseGold: 40, +}; + +export default act3Map; diff --git a/mengsan/content/card-library.js b/mengsan/content/card-library.js new file mode 100644 index 0000000..6e69b74 --- /dev/null +++ b/mengsan/content/card-library.js @@ -0,0 +1,31 @@ +// Mode-owned starting decks. Existing saves always retain their actual player.deck. +export const defaultDeck = Object.freeze([ + ["spade", 7, "sha"], ["heart", 10, "sha"], + ["club", 4, "sha"], ["diamond", 6, "sha"], + ["heart", 2, "shan"], ["diamond", 7, "shan"], ["club", 2, "shan"], + ["heart", 3, "tao"], ["spade", 3, "guohe"], ["heart", 7, "wuzhong"], + ["spade", 2, "bagua"], ["club", 6, "sha", null, Object.freeze(["annihilate"])], +].map(Object.freeze)); + +// Character ID -> [suit, number, card name, optional nature, optional affixes]. +// Example: edit these twelve entries to change only this private Liu Bei's new-run deck. +export const characterDecks = { + mengsan_liubei_shuying: [ + ["spade", 7, "sha"], ["heart", 10, "sha"], ["club", 4, "sha"], ["diamond", 6, "sha"], + ["heart", 2, "shan"], ["diamond", 7, "shan"], ["club", 2, "shan"], + ["heart", 3, "tao"], ["heart", 7, "wuzhong"], ["spade", 1, "juedou"], + ["spade", 2, "bagua"], ["club", 6, "sha", null, ["annihilate"]], + ], +}; + +export function getStartingDeck(character) { + const override = Object.hasOwn(characterDecks, character) ? characterDecks[character] : null; + return Array.isArray(override) && override.length ? override : defaultDeck; +} + +export function createStartingDeck(character, prefix = `mengsan_card_${Date.now()}`) { + return getStartingDeck(character).map((entry, index) => ({ + id: `${prefix}_${index}`, suit: entry[0], number: entry[1], name: entry[2], + nature: entry[3] || null, affixes: entry[4] ? entry[4].slice() : [], upgrade: 0, + })); +} diff --git a/mengsan/content/mode-cards.js b/mengsan/content/mode-cards.js new file mode 100644 index 0000000..2923711 --- /dev/null +++ b/mengsan/content/mode-cards.js @@ -0,0 +1,81 @@ +// 梦三的模式牌定义:复制标准/军争的实体牌定义,不改动原牌包。 +// 保留原牌名,以兼容原有牌的响应、装备技能及转化牌引用。 +import standard from "../../../../card/standard.js"; +import extra from "../../../../card/extra.js"; +import { _status } from "../../../../noname.js"; +import { cardCost, canPayCard, isActiveCardUse, SHA_DAMAGE } from "../runtime/combat-rules.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() { + const card = {}; + const translate = {}; + for (const name of names) { + const source = sourceCard(name); + if (!source) throw new Error(`梦三牌定义缺失:${name}`); + const copy = { ...source, mengsanCost_shuying: cardCost({ name }) }; + // 卡牌本身校验费用;响应和濒死救援不属于主动出牌。 + if (source.enable || source.type === "equip" || source.type === "delay") { + copy.enable = function (used, player, event) { + if (isActiveCardUse(event || _status.event, player) && !canPayCard(player, used)) return false; + return typeof source.enable === "function" ? source.enable.call(this, used, player, event) : source.enable ?? true; + }; + } + const prompt = source.cardPrompt; + const costLabel = name === "shan" || name === "wuxie" + ? "响应不消耗费用" : `主动使用消耗${copy.mengsanCost_shuying}费用`; + copy.cardPrompt = function (used) { + const detail = typeof prompt === "function" ? prompt.call(this, used) : prompt; + return `梦三:${costLabel}。${detail || sourceText(name) || ""}`; + }; + const nativeText = sourceText(name); + if (nativeText) translate[`${name}_info`] = `梦三:${costLabel}。${nativeText}`; + card[name] = copy; + } + // 【杀】的基础伤害由牌内容决定,而不是事后追加伤害的角色技能。 + const nativeSha = sourceCard("sha"); + card.sha.baseDamage = SHA_DAMAGE; + card.sha.content = async function (event, trigger, player) { + if (typeof event.baseDamage !== "number") event.baseDamage = SHA_DAMAGE; + return nativeSha.content.call(this, event, trigger, player); + }; + card.sha.usable = Infinity; + card.sha.cardPrompt = () => `梦三:主动使用消耗1费用;目标须使用【闪】,否则受到${SHA_DAMAGE}点伤害。`; + translate.sha_info = `梦三:主动使用消耗1费用。出牌阶段,对攻击范围内的一名角色使用;其须使用【闪】,否则受到${SHA_DAMAGE}点伤害。每回合使用次数不限。`; + // 【桃】的回复量也由梦三牌本体提供;救援仍不消耗费用。 + card.tao.content = async function (event) { await event.target.recover(8); }; + translate.tao_info = "梦三:主动使用消耗1费用,回复8点生命;濒死救援不消耗费用,同样回复8点生命。"; + card.taoyuan.content = async function (event) { await event.target.recover(8); }; + translate.taoyuan_info = "梦三:主动使用消耗2费用。所有角色各回复8点生命。"; + card.taoyuan.cardPrompt = () => translate.taoyuan_info; + for (const name of ["nanman", "wanjian", "juedou"]) { + const nativeContent = sourceCard(name).content; + card[name].baseDamage = SHA_DAMAGE; + card[name].content = async function (event, trigger, player) { + if (typeof event.baseDamage !== "number") event.baseDamage = SHA_DAMAGE; + return nativeContent.call(this, event, trigger, player); + }; + const detail = { + nanman: "所有其他角色须打出【杀】,否则受到4点伤害。", + wanjian: "所有其他角色须打出【闪】,否则受到4点伤害。", + juedou: "目标与你轮流打出【杀】;未能打出的一方受到4点伤害。", + }[name]; + translate[`${name}_info`] = `梦三:主动使用消耗${cardCost({ name })}费用。${detail}`; + card[name].cardPrompt = () => translate[`${name}_info`]; + } + // 火攻通过当前牌事件的 baseDamage 传入 damage("fire")。 + card.huogong.baseDamage = SHA_DAMAGE; + translate.huogong_info = "梦三:主动使用消耗1费用。令一名有手牌的角色展示一张手牌;若你弃置同花色牌,对其造成4点火焰伤害。"; + card.huogong.cardPrompt = () => translate.huogong_info; + // 闪电属于延时锦囊,其命中伤害在 effect 中显式写死为3,需要独立覆写。 + card.shandian.effect = async function (event, trigger, player, result) { + if (result.bool === false) await player.damage(12, "thunder", "nosource"); + else player.addJudgeNext(event.card); + }; + translate.shandian_info = "梦三:主动使用消耗1费用。若判定为黑桃2~9,目标受到12点雷电伤害;否则移至下家的判定区。"; + card.shandian.cardPrompt = () => translate.shandian_info; + return { card, translate, names }; +} diff --git a/mengsan/content/registry.js b/mengsan/content/registry.js new file mode 100644 index 0000000..aaa817a --- /dev/null +++ b/mengsan/content/registry.js @@ -0,0 +1,99 @@ +import { validateBattlePlan } from "../runtime/battle-director.js"; +import act1 from "./acts/act1/index.js"; +import act2 from "./acts/act2/index.js"; +import act3 from "./acts/act3/index.js"; +import shared from "./shared/index.js"; + +const addEntries = (target, source, category) => { + Object.entries(source || {}).forEach(([id, value]) => { + if (Object.prototype.hasOwnProperty.call(target, id)) throw new Error(`梦三${category} ID重复:${id}`); + target[id] = value; + }); +}; + +const packages = [shared, act1, act2, act3]; +const nodeContents = {}; +const rewardPools = {}; +const rewards = {}; +const dialogueTypes = new Set(["character", "narrator", "sound", "choice"]); + +const validateDialogue = (dialogue, location) => { + if (dialogue == null) return; + if (!Array.isArray(dialogue)) throw new Error(`梦三剧情台词必须为数组:${location}`); + dialogue.forEach((line, index) => { + const lineLocation = `${location} #${index + 1}`; + if (!line || !dialogueTypes.has(line.type)) throw new Error(`梦三剧情台词类型无效:${lineLocation}`); + if (typeof line.text != "string" || !line.text.trim()) throw new Error(`梦三剧情台词内容为空:${lineLocation}`); + if (line.type == "character" && !line.character) throw new Error(`梦三角色台词缺少武将ID:${lineLocation}`); + if (line.when && (!/^story\.[\w.]+$/.test(line.when.flag || "") || typeof line.when.equals !== "string")) throw new Error(`梦三剧情条件无效:${lineLocation}`); + if (line.type == "choice" && (!/^story\.[\w.]+$/.test(line.flag || "") || !Array.isArray(line.choices) || line.choices.length < 1 || + new Set(line.choices.map(choice => choice.id)).size !== line.choices.length || + line.choices.some(choice => !/^[\w.-]+$/.test(choice.id || "") || typeof choice.text !== "string" || !choice.text.trim()) || + (line.defaultChoice != null && !line.choices.some(choice => choice.id === line.defaultChoice)))) { + throw new Error(`梦三发言选项无效:${lineLocation}`); + } + if (line.speed != null && (!Number.isFinite(line.speed) || line.speed < 0)) { + throw new Error(`梦三剧情台词速度无效:${lineLocation}`); + } + }); +}; +packages.forEach(content => { + addEntries(nodeContents, content.nodeContents, "节点内容"); + addEntries(rewardPools, content.rewardPools, "奖励池"); + addEntries(rewards, content.rewards, "奖励"); +}); + +const acts = [act1.map, act2.map, act3.map]; +if (new Set(acts.map(act => act.id)).size != acts.length) throw new Error("梦三大关ID存在重复"); +acts.forEach(act => { + (act.fixedNodes || []).forEach(node => { + if (!nodeContents[node.contentId]) throw new Error(`梦三固定节点内容不存在:${node.contentId}`); + if (!Number.isInteger(node.floor) || node.floor < 0 || node.floor >= act.floorNodes.length) { + throw new Error(`梦三固定节点楼层越界:${node.contentId}`); + } + }); +}); +Object.entries(rewardPools).forEach(([poolId, rewardIds]) => { + if (!Array.isArray(rewardIds) || rewardIds.length < 3) throw new Error(`梦三奖励池至少需要三项:${poolId}`); + if (new Set(rewardIds).size != rewardIds.length) throw new Error(`梦三奖励池存在重复项:${poolId}`); + rewardIds.forEach(rewardId => { + if (!rewards[rewardId]) throw new Error(`梦三奖励不存在:${poolId} -> ${rewardId}`); + }); +}); +Object.entries(nodeContents).forEach(([contentId, content]) => { + if (content.id != contentId) throw new Error(`梦三节点内容ID不一致:${contentId}`); + validateDialogue(content.dialogue, contentId); + validateDialogue(content.victoryDialogue, contentId + " 战后"); + validateDialogue(content.openingDialogue, contentId + " 开场"); + for (const id of content.fixedRewards || []) if (!rewards[id]) throw new Error("固定奖励不存在:" + id); + if (content.battlePlan) validateBattlePlan(content.battlePlan); + (content.choices || []).forEach(choice => { + if (!choice.id) throw new Error(`梦三剧情选项缺少ID:${contentId}`); + const outcome = choice.outcome || {}; + if (outcome.battle?.battlePlan) validateBattlePlan(outcome.battle.battlePlan); + validateDialogue(outcome.dialogue, `${contentId} -> ${choice.id}`); + const referencedPools = [outcome.rewardPool, outcome.battle?.rewardPool].filter(Boolean); + referencedPools.forEach(poolId => { + if (!rewardPools[poolId]) throw new Error(`梦三剧情奖励池不存在:${contentId} -> ${poolId}`); + }); + if (outcome.battle && !outcome.battle.enemies?.length && !outcome.battle.battlePlan) { + throw new Error(`梦三剧情战斗敌人池为空:${contentId} -> ${choice.id}`); + } + if (outcome.confirmResult != null && typeof outcome.confirmResult != "boolean") { + throw new Error(`梦三剧情confirmResult必须为布尔值:${contentId} -> ${choice.id}`); + } + if (choice.requires && !Array.isArray(choice.requires)) throw new Error(`梦三剧情requires必须为数组:${contentId}`); + if (choice.excludes && !Array.isArray(choice.excludes)) throw new Error(`梦三剧情excludes必须为数组:${contentId}`); + }); +}); + +Object.values(rewards).forEach(reward => { + if (!reward.support) return; + validateBattlePlan({ units: [{ ...reward.support.unit, id: "reward_support" }], rules: [] }); + const battles = reward.support.battles ?? -1; + if (battles !== -1 && (!Number.isInteger(battles) || battles < 1 || battles > 99)) throw new Error("梦三支援持续场数无效"); +}); + +const contentRegistry = { acts, nodeContents, rewardPools, rewards }; + +export default contentRegistry; diff --git a/mengsan/content/scenario-characters.js b/mengsan/content/scenario-characters.js new file mode 100644 index 0000000..d44f70b --- /dev/null +++ b/mengsan/content/scenario-characters.js @@ -0,0 +1,17 @@ +// 梦三私有武将:不改动原版界刘备,也不导入整套国战技能。 +export function createScenarioCharacters(Character) { + return { + mengsan_liubei_shuying: new Character({ sex: "male", group: "han", hp: 50, maxHp: 50, + skills: [], img: "image/character/re_liubei.jpg" }), + // 国战男性普通小兵使用原生 shibing1 立绘;实际战斗生命由怪物类或关卡配置决定。 + mengsan_soldier_shuying: new Character({ sex: "male", group: "qun", hp: 4, maxHp: 4, + skills: [], img: "image/character/shibing1.jpg" }), + mengsan_sushuang_zhangshiping_shuying: new Character({ sex: "male", group: "shu", hp: 4, maxHp: 4, + skills: [], img: "extension/术樱包/mengsan/assets/sushuang-zhangshiping.png", names: "苏|双-张|世平" }), + }; +} +export const scenarioTranslations = { + mengsan_liubei_shuying: "界刘备", mengsan_liubei_shuying_title: "50/50生命 · 无初始技能", + mengsan_soldier_shuying: "士兵", + mengsan_sushuang_zhangshiping_shuying: "苏双&张世平", +}; diff --git a/mengsan/content/shared/index.js b/mengsan/content/shared/index.js new file mode 100644 index 0000000..4518b5d --- /dev/null +++ b/mengsan/content/shared/index.js @@ -0,0 +1,7 @@ +import { rewardPools, rewards } from "./rewards.js"; + +export default { + nodeContents: {}, + rewardPools, + rewards, +}; diff --git a/mengsan/content/shared/rewards.js b/mengsan/content/shared/rewards.js new file mode 100644 index 0000000..21a414a --- /dev/null +++ b/mengsan/content/shared/rewards.js @@ -0,0 +1,26 @@ +export const rewards = { + "shared.reward.support.scout": { + name: "道具·斥候援令", description: "本次征程后续每场战斗,友方张辽携 4 张手牌参战;重复获得不叠加人数。", + support: { category: "item", battles: -1, unit: { character: "re_zhangliao", camp: "ally", hand: 4 } }, + }, + "shared.reward.card.sha": { effectId: "card_sha", name: "获得一张【杀】", description: "加入独立永久牌组" }, + "shared.reward.card.tao": { effectId: "card_tao", 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" }, + "shared.reward.skill.heroic": { effectId: "skill_yingyong", name: "极品技能·英勇", description: "本次征程永久获得【英勇】" }, + "shared.reward.maxHp": { effectId: "max_hp", name: "极品强化·生机", description: "生命上限与当前生命各 +5" }, +}; + +export const rewardPools = { + "shared.pool.battle.normal": [ + "shared.reward.card.sha", + "shared.reward.card.tao", + "shared.reward.card.upgrade", + ], + "shared.pool.boss.premium": [ + "shared.reward.skill.heroic", + "shared.reward.item.handCharm", + "shared.reward.maxHp", + ], +}; diff --git a/mengsan/docs/battle-config.md b/mengsan/docs/battle-config.md new file mode 100644 index 0000000..c60543f --- /dev/null +++ b/mengsan/docs/battle-config.md @@ -0,0 +1,98 @@ +# 梦三:关卡条件与战斗支援 + +## 约定 +- 本模块是梦三玩法规则,不批量修改武将技能源码。 +- 主角固定单位 ID:`player`;`ally` 为友方,`enemy` 为敌方。 +- `neutral` 仅保留预设,目前配置为中立会报错,不启用中立行为。 +- 主角使用个人牌堆;友方支援和敌人仍使用公共牌堆。 +- 没有 `battlePlan` 的旧关卡继续使用原来随机的一名敌人。 +- 本版单场累计最多 8 个席位,包含主角、初始单位、奖励支援、以后出现的援军。 + +## 配置位置 +节点内容或剧情选项的 `outcome.battle` 中增加 `battlePlan`。 +Boss 也可以在大关配置的 `bossBattlePlan` 中定义。 +`units` 中每一项是一名初始角色,因此数组长度就是初始非主角人数。 + +```js +battlePlan: { + units: [ + { id: "captain", character: "re_xiahoudun", camp: "enemy", hand: 4 }, + { id: "guard", character: "re_xuzhu", camp: "enemy", hand: 2 }, + ], + rules: [ + { + id: "rescue", name: "友军驰援", + when: { event: "roundStart", round: 3 }, + effects: [{ type: "spawn", unit: { + id: "helper", character: "re_zhangliao", camp: "ally", hand: 4, + } }], + }, + { + id: "low_hp", name: "背水一战", + when: { event: "state", all: [ + { unit: "captain", field: "alive", value: true }, + { unit: "captain", field: "hp", op: "lte", value: 2 }, + ] }, + effects: [{ type: "draw", target: "captain", amount: 2 }], + }, + { + id: "supply", name: "主角补给", + when: { event: "state", all: [ + { unit: "player", field: "hand", op: "lte", value: 1 }, + ] }, + effects: [{ type: "draw", target: "player", amount: 2 }], + }, + ], +} +``` + +## 字段 +初始/援军单位:`id`、`character`、`camp`;可选 `hand`(默认 4)、`hp`、`maxHp`、`skills`。 +每个单位 ID 必须唯一,引用 ID 而不是武将名称,允许同武将的不同单位。 + +每条规则每场仅触发一次,效果按书写顺序执行;不支持任意脚本、eval 或无限重复规则。 +- `when.event`:`battleStart` 开场、`roundStart` 新轮、`turnStart` 每名角色主循环回合开始、`state` 状态变化。 +- `when.round`:至少到第 N 轮。主角第一次进入主循环为第 1 轮;额外技能回合不另计轮。 +- `when.turn`:至少到主循环第 N 个回合;所有角色一起计数,额外技能回合不计。 +- `when.actor`:限定当前主循环行动单位,可用于 turnStart/roundStart。 +- `when.all`:所有条件同时满足。`hp`、`hand` 使用 `eq`/`gte`/`lte` 比较。 +- `alive`、`linked`、`turnedOver` 比较布尔值;`camp` 比较 `ally`/`enemy`。 +- state 在开场、回合边界及体力/手牌/死亡/横置/翻面事件后检查,不轮询。 + +效果:`spawn`(提供 unit)、`draw`/`recover`/`maxHp`(target + amount)、`skill`(target + skill)、`camp`(target + camp)。 +主角不能改阵营;死亡或尚未出现的目标不会执行数值/技能增益。规则不会复活角色。 +`skill` 为本场增加技能,战后随角色统一释放,不写入永久技能。 + +`blocksVictory: true` 用于必须等待的敌方援军波次;未触发时不会提前胜利。 +默认 false,避免可选援军条件永久不成立导致战斗无法结束。 +若设为 true,作者必须保证该条件可以达成。条件同样适用于玩家方。 + +## 奖励支援 +在奖励注册表中配置 support,不设置旧的 effectId: +```js +"shared.reward.support.example": { + name: "道具·援令", description: "本次征程后续每场战斗获得友方支援。", + support: { + category: "item", // item 道具、bond 羁绊来源(仅数据预留)、buff 其他增益 + battles: -1, // -1 或省略:本征程持续;正整数 N:持续 N 场战斗 + unit: { character: "re_zhangliao", camp: "ally", hand: 4 }, + }, +}, +``` +再将奖励 ID 加入指定 rewardPools。相同奖励来源不叠加人数;临时次数可累加,永久来源保持永久。 +数据保存在 `run.player.supports`,旧存档没有该字段时按空列表处理。 +有限次数只随结算事务提交;重载未完成战斗不会额外扣次,重试结算不会重复发放。 +行囊的“战斗支援”显示来源名称和持续场数。此版本未实现羁绊系统本身。 + +## 已接入的示例 +- 林中伏兵“主动追击”:两名初始敌人,第 3 轮必定出现一名敌军援军。 +- 敌军队长体力不高于 2 时摸两张;主角手牌不多于 1 时补两张,各限一次。 +- 斥候谢礼奖励池加入“道具·斥候援令”,本征程后续每场由友方张辽参战。 + +## 结算和清理 +仅主角死亡判负;存活友军不阻碍胜利。清空敌军且没有必须等待的敌军规则后判胜。 +援军使用原生加入座次/enterGame,登记玩家 ID、技能释放和战斗资源清理。 +身份模式原有的杀反贼摸三张、误杀忠臣弃牌惩罚不沿用;奖励由梦三结算和上述规则决定。 +统计记录实际战败敌军数量,而非固定每场加一。 + +已做隔离配置/规则/事务检查;未启动游戏、未进行实际战斗或界面验收。 diff --git a/mengsan/docs/combat-rules.md b/mengsan/docs/combat-rules.md new file mode 100644 index 0000000..b7b6725 --- /dev/null +++ b/mengsan/docs/combat-rules.md @@ -0,0 +1,20 @@ +# 梦三战斗规则(测试阶段) + +只影响梦三模式,不修改 noname 的标准/军争原始牌包。`content/mode-cards.js` 从标准、军争实体牌列表逐一复制模式专属定义,沿用原牌名以保留响应、装备、转化兼容性;图鉴说明在梦三模式覆盖。新征程的界刘备为 50/50 生命,默认每回合摸 2 张、手牌上限 3、回合费用恢复至 3。主动使用牌消耗费用,转化牌按转化后的牌名计费;响应【闪】、救援【桃】等不消耗费用。默认主动牌 1 费,`runtime/combat-rules.js` 中列出例外。【杀】1 费、可多次使用,牌定义的基础伤害为 4;【桃】及【桃园结义】回复 8 点生命;【南蛮入侵】、【万箭齐发】、【决斗】、【火攻】基础伤害 4,【闪电】命中伤害 12。其余卡牌保留原有效果,并加上梦三费用与图鉴说明。 + +## 怪物 + +`runtime/monster.js` 的 `Monster` 类统一提供阶级默认值。普通/精英/Boss 分别每回合摸 1/2/3 张、手牌上限 3/5/7、回合费用 2/3/4。每名敌人拥有自己的抽牌堆、弃牌堆与手牌;怪物默认使用阶级牌堆,不再从公共牌堆摸牌。友方支援仍用公共牌堆。 + +`HEALTH` 为目前各敌人的独立生命值;新怪物未配置时使用阶级兜底生命值。`OpeningSoldier extends Monster` 是继承示例;新子类在 `MONSTER_TYPES` 注册。关卡单位可以写 `tier`、`hp/maxHp`、`draw`、`handLimit`、`energy`、`deck` 覆盖默认值;`deck` 可为牌名数组(如 `deck:["sha","shan","tao"]`),也可用 `{name,suit,number}` 指定单张牌。牌名必须在 noname 中存在。 + +## 配置位置 + +- 玩家费用、手牌上限、卡牌费用与【杀】伤害:`runtime/combat-rules.js` +- 梦三模式卡牌复制、内容与图鉴文案:`content/mode-cards.js` +- 怪物阶级、默认牌堆、生命与继承示例:`runtime/monster.js` +- 刘备角色初始生命:`content/scenario-characters.js` +- 刘备专属初始牌组:`content/card-library.js` +- 首关小兵覆盖数值:`content/acts/act1/stories/liubei-opening.js` + +旧存档不作迁移。源码检查不等于游戏内、十周年 UI 或 AI 行为验收。 diff --git a/mengsan/docs/liubei-opening.md b/mengsan/docs/liubei-opening.md new file mode 100644 index 0000000..8a0fe4a --- /dev/null +++ b/mengsan/docs/liubei-opening.md @@ -0,0 +1,26 @@ +# 刘备首关配置 + +新征程仅显示梦三私有界刘备:4/4、无初始技能。原版界刘备和其他模式不变;旧征程不强行换主角。 + +## 编辑入口 +`content/acts/act1/stories/liubei-opening.js`: +- `openingDialogue`:开战前播放。原先填写的穿越与关张对话误放在第四回合支援台词里,现已移到这里。 +- `openingDialogue` 中的 `type:"choice"` 可让玩家选择刘备发言;`flag` 指定征程存档的 `storyFlags` 键,选中后存入选项 `id`。后续台词可用 `when:{flag:"...",equals:"选项id"}` 控制是否显示。选项会随本关胜利结算写入征程存档,后续关卡可据此分支;中途退出战斗不会单独保存选择。 +- 每段对话左上角可点“跳过对话”。尚未选择的发言会采用该行的 `defaultChoice`;未配置时采用 `choices` 第一项,并照常写入 `storyFlags`。已手动选择的发言不会被跳过操作覆盖。一个选项的发言也有效。 +- `reinforcementDialogue`:刘备第四次完整回合结束时的台词,空数组跳过。 +- `victoryDialogue`:胜利后、奖励结算前台词;失败不播放,不给马。 +- `battlePlan.units`:敌人;`skills` 添加技能,`inheritSkills:false` 不加载原武将技能。 +- `hand` 初始手牌数(可为0)、`hp` 当前体力、`maxHp` 上限;`equipment` 初始装备,指定 name/suit/number,可选nature。同一普通装备槽不可重复。 +- `when:{event:"turnEnd",actor:"player",actorTurns:4}`:主角本人第四次回合结束,不是全场第四回合,也不是第四轮开始。额外完整回合也计数。 +- 支援单位 `after:"player"` 插入刘备下家;该支援仅当前战斗有效,不写入永久道具/羁绊。 +- 苏双&张世平为一名男性蜀势力武将,暂设无技能、4/4、4张初始手牌,可自行调整。 +- `discardEquipment` 弃置指定单位装备区的指定牌;已不在装备区则不重复弃置,不影响其他装备。 +- `fixedRewards` 固定获得梅花5的卢一张,加入征程个人牌库;此关关闭随机三选一。奖励与节点完成一起事务提交,重试不会重复发牌。 +- 初始士兵/支援手牌仍来自公共牌堆;弃掉的敌方的卢走原生弃牌流程,胜利所得的卢是持久牌库新卡,并非再从弃牌区拾取。 +- 提前击败敌人允许正常获胜,此时未到第四次回合的支援不会触发。不强行等待、不添加免死。 + +## 新角色立绘 +`assets/sushuang-zhangshiping.png` 为本次 image_gen 生成的原创双人立绘,不是官方素材。 + +## 验证边界 +隔离脚本检查配置、回合计数、效果顺序及奖励幂等,不等于游戏/十周年UI验收。请开始新征程检查真实引擎的装备、座次、AI和剧情显示。 diff --git a/mengsan/map/index.js b/mengsan/map/index.js new file mode 100644 index 0000000..764d1bd --- /dev/null +++ b/mengsan/map/index.js @@ -0,0 +1 @@ +export { showMap as default, showMap } from "./renderer.js"; diff --git a/mengsan/map/renderer.js b/mengsan/map/renderer.js new file mode 100644 index 0000000..afcfcf7 --- /dev/null +++ b/mengsan/map/renderer.js @@ -0,0 +1,464 @@ +import { openCardLibrary } from "../runtime/card-library.js"; +import { askMenu, openModeSelection } from "../runtime/navigation.js"; +import { ui, get } from "../../../../noname.js"; +import config from "../config.js"; +import { getSelectableNodes } from "../state.js"; + +// Original vector pictograms; no external image/font requests. +const iconPaths = { + battle: "M8 5l19 22M24 4l4 4L9 27M5 23l5 5M22 7l5 5M6 4l5 2-3 4z", + elite: "M16 3l10 5v9c0 6-10 12-10 12S6 23 6 17V8zM10 13l6 4 6-4M16 17v7M11 5l5-3 5 3", + event: "M10 10c0-9 15-8 15 0 0 5-9 5-9 10M16 25v1", + story: "M8 5h18v20H10c-5 0-5-6 0-6h16M8 5c-5 0-5 6 0 6h3V5M12 10h10M12 14h8", + rest: "M3 26L16 5l13 21zM10 26l6-11 6 11M16 5V2M7 28h19", + shop: "M4 12l3-7h18l3 7M4 12v4h24v-4M7 16v12h18V16M12 28v-8h8v8M3 28h26M11 6v6M21 6v6", + boss: "M6 28V5M6 6c8-8 12 6 22-1v16c-10 7-14-7-22 1M11 10l11 7M22 10l-11 7M3 28h9", +}; +const controlIconPaths = { + menu: "M5 8h22M5 16h22M5 24h22", + map: "M4 7l7-3 10 4 7-3v20l-7 3-10-4-7 3zM11 4v20M21 8v20", + bag: "M9 10V8a7 7 0 0114 0v2M6 10h20l2 18H4zM12 16v3M20 16v3", + book: "M5 5h9a5 5 0 015 5v17a5 5 0 00-5-5H5zM27 5h-9a5 5 0 00-5 5v17a5 5 0 015-5h9z", + record: "M8 4h16v24H8zM12 10h8M12 16h8M12 22h5", + settings: "M16 11a5 5 0 100 10 5 5 0 000-10zM16 3v4M16 25v4M3 16h4M25 16h4M7 7l3 3M22 22l3 3M25 7l-3 3M10 22l-3 3", + exit: "M13 5H6v22h7M20 10l6 6-6 6M26 16H11", + back: "M20 7l-9 9 9 9M11 16h16", + close: "M8 8l16 16M24 8L8 24", +}; +const makeIcon = type => { + const icon = document.createElementNS("http://www.w3.org/2000/svg", "svg"); + icon.setAttribute("viewBox", "0 0 32 32"); + icon.setAttribute("aria-hidden", "true"); + icon.classList.add("mengsan-ink-icon-shuying"); + const path = document.createElementNS("http://www.w3.org/2000/svg", "path"); + path.setAttribute("d", iconPaths[type] || iconPaths.event); + icon.appendChild(path); + return icon; +}; + +const makeControlIcon = type => { + const icon = document.createElementNS("http://www.w3.org/2000/svg", "svg"); + icon.setAttribute("viewBox", "0 0 32 32"); + icon.setAttribute("aria-hidden", "true"); + icon.classList.add("mengsan-control-icon-shuying"); + const path = document.createElementNS("http://www.w3.org/2000/svg", "path"); + path.setAttribute("d", controlIconPaths[type]); + icon.appendChild(path); + return icon; +}; + +const createText = (className, text, parent) => { + const node = document.createElement("div"); + node.className = className; + node.textContent = text; + parent.appendChild(node); + return node; +}; + +const nodeColor = { + battle: "#9d4937", + elite: "#7e3f79", + event: "#477aa6", + story: "#8c6a34", + rest: "#3f8560", + shop: "#a27b35", + boss: "#a62727", +}; + +const createMapOverlay = title => { + const overlay = ui.create.div(".mengsan-overlay-shuying.mengsan-map-overlay-shuying", ui.window); + const panel = ui.create.div(".mengsan-panel-shuying.mengsan-map-panel-shuying", overlay); + const header = ui.create.div(".mengsan-map-header-shuying", panel); + const titleGroup = ui.create.div(".mengsan-map-title-group-shuying", header); + createText("mengsan-map-eyebrow-shuying", "MENGSAN EXPEDITION", titleGroup); + const heading = ui.create.div(".mengsan-title-shuying", titleGroup); + heading.textContent = `${title} · 行军图`; + return { overlay, panel, header }; +}; + +const getTraversedEdgeIds = map => { + const traversed = new Set(); + const completed = map.completedNodeIds || []; + for (let index = 1; index < completed.length; index++) { + traversed.add(`${completed[index - 1]}\u0000${completed[index]}`); + } + return traversed; +}; + +export const showMap = run => new Promise(resolve => { + const act = config.acts[run.actIndex]; + const selectable = getSelectableNodes(run); + const selectableIds = new Set(selectable.map(node => node.id)); + const traversedEdgeIds = getTraversedEdgeIds(run.map); + const { overlay, panel, header } = createMapOverlay(act.name); + let closeCardLibrary = null; + const finish = result => { + if (overlay.dataset.resolved) return; + overlay.dataset.resolved = "true"; + document.removeEventListener("keydown", handleEscape); + closeCardLibrary?.(); + overlay.remove(); + resolve(result); + }; + + const menuTrigger = document.createElement("button"); + menuTrigger.type = "button"; + menuTrigger.className = "mengsan-map-menu-trigger-shuying"; + menuTrigger.appendChild(makeControlIcon("menu")); + const menuTriggerLabel = document.createElement("span"); + menuTriggerLabel.textContent = "菜单"; + menuTrigger.appendChild(menuTriggerLabel); + menuTrigger.setAttribute("aria-haspopup", "dialog"); + menuTrigger.setAttribute("aria-expanded", "false"); + menuTrigger.setAttribute("aria-controls", "mengsan-map-menu-shuying"); + header.insertBefore(menuTrigger, header.firstChild); + + const status = ui.create.div(".mengsan-map-status-shuying", header); + const statusItems = [ + ["生命", run.player.hp == null ? "未初始化" : `${run.player.hp}/${run.player.maxHp}`], + ["金币", String(run.player.gold)], + ["牌组", String(run.player.deck.length)], + ]; + statusItems.forEach(([label, value]) => { + const item = ui.create.div(".mengsan-map-stat-shuying", status); + createText("mengsan-map-stat-label-shuying", label, item); + createText("mengsan-map-stat-value-shuying", value, item); + }); + + const menuLayer = ui.create.div(".mengsan-map-menu-layer-shuying", panel); + menuLayer.id = "mengsan-map-menu-shuying"; + menuLayer.hidden = true; + const menuScrim = ui.create.div(".mengsan-map-menu-scrim-shuying", menuLayer); + const menuSheet = document.createElement("div"); + menuSheet.className = "mengsan-map-menu-sheet-shuying"; + menuSheet.setAttribute("role", "dialog"); + menuSheet.setAttribute("aria-modal", "true"); + menuSheet.setAttribute("aria-labelledby", "mengsan-map-menu-title-shuying"); + menuLayer.appendChild(menuSheet); + const menuHeader = document.createElement("div"); + menuHeader.className = "mengsan-map-menu-header-shuying"; + menuSheet.appendChild(menuHeader); + const menuHeadingGroup = document.createElement("div"); + menuHeadingGroup.className = "mengsan-map-menu-heading-group-shuying"; + menuHeader.appendChild(menuHeadingGroup); + createText("mengsan-map-menu-kicker-shuying", "征程管理", menuHeadingGroup); + const menuTitle = createText("mengsan-map-menu-title-shuying", "行军菜单", menuHeadingGroup); + menuTitle.id = "mengsan-map-menu-title-shuying"; + const closeMenu = document.createElement("button"); + closeMenu.type = "button"; + closeMenu.className = "mengsan-map-icon-button-shuying"; + closeMenu.appendChild(makeControlIcon("close")); + closeMenu.setAttribute("aria-label", "关闭菜单"); + menuHeader.appendChild(closeMenu); + const menuContent = document.createElement("div"); + menuContent.className = "mengsan-map-menu-content-shuying"; + menuSheet.appendChild(menuContent); + const menuFooter = document.createElement("div"); + menuFooter.className = "mengsan-map-menu-footer-shuying"; + menuSheet.appendChild(menuFooter); + + const exitButton = document.createElement("button"); + exitButton.type = "button"; + exitButton.className = "mengsan-map-exit-fixed-shuying"; + exitButton.appendChild(makeControlIcon("exit")); + const exitCopy = document.createElement("span"); + exitCopy.className = "mengsan-map-exit-copy-shuying"; + const exitName = document.createElement("strong"); + exitName.textContent = "返回模式选择"; + const exitDescription = document.createElement("small"); + exitDescription.textContent = "保留已保存征程,选择其他模式"; + exitCopy.append(exitName, exitDescription); + exitButton.appendChild(exitCopy); + exitButton.addEventListener("click", async () => { + if (exitButton.disabled) return; + exitButton.disabled = true; + try { + const result = await askMenu("返回模式选择?", "保留上次完成节点的存档,未完成内容不会提交。", [ + { id: "cancel", name: "留在行军图" }, + { id: "exit", name: "确认返回模式选择", danger: true }, + ]); + if (result === "exit") await openModeSelection(); + } catch (error) { + console.error(error); + await askMenu("暂时无法返回", "征程未被清除,请重试。", [{ id: "close", name: "关闭提示" }]); + } finally { exitButton.disabled = false; } + }); + menuFooter.appendChild(exitButton); + + const setMenuOpen = open => { + if (open) renderMainMenu(); + menuLayer.hidden = !open; + menuTrigger.setAttribute("aria-expanded", String(open)); + panel.classList.toggle("menu-open", open); + if (open) requestAnimationFrame(() => menuSheet.querySelector("button")?.focus()); + else menuTrigger.focus(); + }; + + const createMenuAction = ({ icon, name, description, danger = false, disabled = false, onClick }) => { + const button = document.createElement("button"); + button.type = "button"; + button.className = "mengsan-map-menu-action-shuying"; + if (danger) button.classList.add("danger"); + button.disabled = disabled; + button.appendChild(makeControlIcon(icon)); + const copy = document.createElement("div"); + copy.className = "mengsan-map-menu-action-copy-shuying"; + button.appendChild(copy); + createText("mengsan-map-menu-action-name-shuying", name, copy); + createText("mengsan-map-menu-action-description-shuying", description, copy); + button.addEventListener("click", onClick); + menuContent.appendChild(button); + return button; + }; + + const showMenuPage = (title, subtitle, render) => { + menuTitle.textContent = title; + menuContent.replaceChildren(); + const intro = ui.create.div(".mengsan-map-menu-page-intro-shuying", menuContent); + intro.textContent = subtitle; + render(menuContent); + const back = document.createElement("button"); + back.type = "button"; + back.className = "mengsan-map-menu-back-shuying"; + back.appendChild(makeControlIcon("back")); + const label = document.createElement("span"); + label.textContent = "返回菜单"; + back.appendChild(label); + back.addEventListener("click", renderMainMenu); + menuContent.appendChild(back); + requestAnimationFrame(() => back.focus()); + }; + + const renderBackpack = parent => { + const summary = ui.create.div(".mengsan-map-inventory-summary-shuying", parent); + [["卡牌", run.player.deck.length], ["道具", run.player.items?.length || 0], ["技能", run.player.permanentSkills?.length || 0]].forEach(([label, value]) => { + const item = ui.create.div(".mengsan-map-inventory-count-shuying", summary); + createText("", String(value), item); + createText("", label, item); + }); + const library = document.createElement("button"); + library.type = "button"; + library.className = "mengsan-map-menu-action-shuying"; + library.setAttribute("aria-haspopup", "dialog"); + library.appendChild(makeControlIcon("book")); + const copy = document.createElement("div"); + copy.className = "mengsan-map-menu-action-copy-shuying"; + createText("mengsan-map-menu-action-name-shuying", "卡牌库", copy); + createText("mengsan-map-menu-action-description-shuying", `共 ${run.player.deck.length} 张 · 查看花色、点数、属性和卡牌详情`, copy); + library.appendChild(copy); + library.addEventListener("click", () => { closeCardLibrary = openCardLibrary(run); }); + parent.appendChild(library); + const appendNames = (title, values, emptyText) => { + createText("mengsan-map-inventory-section-title-shuying", title, parent); + const list = ui.create.div(".mengsan-map-inventory-list-shuying", parent); + if (!values?.length) { + createText("mengsan-map-empty-shuying", emptyText, list); + return; + } + values.forEach(value => { + const row = ui.create.div(".mengsan-map-inventory-row-shuying", list); + createText("", get.translation(value) || value, row); + createText("", "已获得", row); + }); + }; + const supports = run.player.supports || []; + createText("mengsan-map-inventory-section-title-shuying", "战斗支援", parent); + const supportList = ui.create.div(".mengsan-map-inventory-list-shuying", parent); + if (!supports.length) createText("mengsan-map-empty-shuying", "暂无战斗支援。", supportList); + for (const support of supports) { + const row = ui.create.div(".mengsan-map-inventory-row-shuying", supportList); + createText("", config.rewards[support.rewardId]?.name || get.translation(support.unit.character), row); + createText("", support.battles === -1 ? "本次征程持续生效" : `剩余 ${support.battles} 场`, row); + } + appendNames("永久道具", run.player.items, "尚未获得永久道具。"); + appendNames("永久技能", run.player.permanentSkills, "尚未获得永久技能。"); + }; + + const renderPlaceholder = (parent, message) => { + const empty = ui.create.div(".mengsan-map-placeholder-shuying", parent); + empty.appendChild(makeControlIcon("record")); + createText("mengsan-map-placeholder-title-shuying", "功能开发中", empty); + createText("mengsan-map-placeholder-text-shuying", message, empty); + }; + + function renderMainMenu() { + menuTitle.textContent = "行军菜单"; + menuContent.replaceChildren(); + createMenuAction({ icon: "map", name: "继续行军", description: "关闭菜单并返回当前路线", onClick: () => setMenuOpen(false) }); + createMenuAction({ icon: "bag", name: "行囊", description: `查看牌组、道具与永久技能 · ${run.player.deck.length}张牌`, onClick: () => showMenuPage("行囊", "本次征程携带的资源", renderBackpack) }); + createMenuAction({ icon: "book", name: "图鉴", description: "查看已发现的敌人、事件与奖励 · 开发中", onClick: () => showMenuPage("图鉴", "征程见闻与收集记录", parent => renderPlaceholder(parent, "后续将收录已发现的节点、敌人和奖励。")) }); + 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]) => { + const row = ui.create.div(".mengsan-map-inventory-row-shuying", list); + createText("", label, row); + createText("", String(value), row); + }); + }) }); + createMenuAction({ icon: "settings", name: "设置与说明", description: "显示、音效与玩法说明 · 开发中", onClick: () => showMenuPage("设置与说明", "个性化与帮助", parent => renderPlaceholder(parent, "后续将在这里提供显示、音效和操作设置。")) }); + } + + function handleEscape(event) { + if (window.inSplash) return; + if (document.querySelector("dialog.mengsan-deck-dialog-shuying[open]")) return; + if (document.querySelector(".mengsan-menu-dialog-shuying[open]")) return; + if (event.key == "Escape" && !menuLayer.hidden) { + event.preventDefault(); + setMenuOpen(false); + } + else if (event.key == "Tab" && !menuLayer.hidden) { + const focusable = Array.from(menuSheet.querySelectorAll("button:not(:disabled)")); + if (!focusable.length) return; + const first = focusable[0]; + const last = focusable[focusable.length - 1]; + if (event.shiftKey && document.activeElement === first) { + event.preventDefault(); + last.focus(); + } + else if (!event.shiftKey && document.activeElement === last) { + event.preventDefault(); + first.focus(); + } + } + } + + renderMainMenu(); + menuTrigger.addEventListener("click", () => setMenuOpen(true)); + closeMenu.addEventListener("click", () => setMenuOpen(false)); + menuScrim.addEventListener("click", () => setMenuOpen(false)); + document.addEventListener("keydown", handleEscape); + + const legend = document.createElement("details"); + legend.className = "mengsan-ink-legend-shuying"; + legend.open = window.matchMedia("(min-width: 900px)").matches; + const summary = document.createElement("summary"); + summary.textContent = "行军图例"; + legend.appendChild(summary); + Object.keys(iconPaths).forEach(type => { + const item = document.createElement("div"); + item.appendChild(makeIcon(type)); + const label = document.createElement("span"); + label.textContent = type === "boss" ? "关底主将" : config.nodeNames[type]; + item.appendChild(label); + legend.appendChild(item); + }); + const hint = document.createElement("p"); + hint.textContent = "朱圈可行 · 墨线已行"; + legend.appendChild(hint); + panel.appendChild(legend); + + const viewport = ui.create.div(".mengsan-map-viewport-shuying", panel); + let drag = null; + let suppressClick = false; + viewport.addEventListener("pointerdown", event => { + if (!event.isPrimary || event.button !== 0) return; + suppressClick = false; + drag = { id: event.pointerId, y: event.clientY, top: viewport.scrollTop, moved: false }; + }); + viewport.addEventListener("pointermove", event => { + if (!drag || drag.id !== event.pointerId) return; + const distance = event.clientY - drag.y; + if (!drag.moved && Math.abs(distance) < 6) return; + drag.moved = true; + suppressClick = true; + if (!viewport.hasPointerCapture(event.pointerId)) viewport.setPointerCapture(event.pointerId); + viewport.classList.add("dragging"); + viewport.scrollTop = drag.top - distance; + event.preventDefault(); + }); + const endDrag = event => { + if (!drag || drag.id !== event.pointerId) return; + drag = null; + viewport.classList.remove("dragging"); + if (viewport.hasPointerCapture(event.pointerId)) viewport.releasePointerCapture(event.pointerId); + }; + viewport.addEventListener("pointerup", endDrag); + viewport.addEventListener("pointercancel", endDrag); + viewport.addEventListener("lostpointercapture", endDrag); + viewport.addEventListener("pointerleave", event => { + if (drag && !drag.moved) endDrag(event); + }); + viewport.addEventListener("click", event => { + if (suppressClick && event.detail !== 0) { + event.preventDefault(); + event.stopImmediatePropagation(); + suppressClick = false; + } + }, true); + const map = ui.create.div(".mengsan-map-shuying", viewport); + const landscape = document.createElementNS("http://www.w3.org/2000/svg", "svg"); + landscape.classList.add("mengsan-ink-landscape-shuying"); + landscape.setAttribute("viewBox", "0 0 800 1200"); + landscape.setAttribute("preserveAspectRatio", "none"); + landscape.setAttribute("aria-hidden", "true"); + ["M0 580L45 380 80 420 145 210 204 340 242 310 295 590Z", "M510 950l80-240 30 40 60-280 42 110 32-70 46 330v110Z", "M0 1140q150-75 320-5t480-30v95H0Z"].forEach(d => { + const mountain = document.createElementNS("http://www.w3.org/2000/svg", "path"); + mountain.setAttribute("d", d); + landscape.appendChild(mountain); + }); + map.appendChild(landscape); + const svg = document.createElementNS("http://www.w3.org/2000/svg", "svg"); + svg.classList.add("mengsan-lines-shuying"); + svg.setAttribute("viewBox", "0 0 100 100"); + svg.setAttribute("preserveAspectRatio", "none"); + svg.setAttribute("aria-hidden", "true"); + map.appendChild(svg); + + const nodeMap = new Map(run.map.nodes.map(node => [node.id, node])); + run.map.edges.forEach(edge => { + const source = nodeMap.get(edge[0]); + const target = nodeMap.get(edge[1]); + if (!source || !target) return; + const line = document.createElementNS("http://www.w3.org/2000/svg", "path"); + const midY = (source.y + target.y) / 2; + line.setAttribute("d", `M ${source.x} ${source.y} C ${source.x} ${midY}, ${target.x} ${midY}, ${target.x} ${target.y}`); + if (source.id === run.map.currentNodeId && selectableIds.has(target.id)) line.classList.add("available"); + if (traversedEdgeIds.has(`${source.id}\u0000${target.id}`)) line.classList.add("completed"); + if (source.dynamic || target.dynamic) line.classList.add("dynamic"); + svg.appendChild(line); + }); + + run.map.nodes.forEach(node => { + const button = document.createElement("button"); + button.type = "button"; + button.className = "mengsan-node-shuying"; + map.appendChild(button); + button.style.left = `${node.x}%`; + button.style.top = `${node.y}%`; + button.style.setProperty("--node-color", nodeColor[node.type] || "#666"); + const content = node.contentId ? config.nodeContents?.[node.contentId] : null; + const label = content?.mapName || (node.type === "boss" ? "关底主将" : config.nodeNames[node.type]) || node.type; + button.appendChild(makeIcon(node.type)); + const caption = document.createElement("span"); + caption.textContent = label; + button.appendChild(caption); + button.title = label; + button.setAttribute("aria-label", `${label},${node.completed ? "已完成" : selectableIds.has(node.id) ? "可前往" : "未开放"}`); + button.setAttribute("aria-disabled", String(!selectableIds.has(node.id))); + button.tabIndex = selectableIds.has(node.id) ? 0 : -1; + button.dataset.type = node.type; + if (node.id === run.map.currentNodeId) { + button.classList.add("current"); + button.setAttribute("aria-current", "step"); + } + button.dataset.nodeId = node.id; + if (node.completed) button.classList.add("completed"); + else if (selectableIds.has(node.id)) button.classList.add("selectable"); + else button.classList.add("locked"); + button.addEventListener("click", event => { + event.preventDefault(); + if (!selectableIds.has(node.id) || overlay.dataset.resolved) return; + finish(node); + }); + }); + + requestAnimationFrame(() => { + if (!overlay.isConnected) return; + const current = nodeMap.get(run.map.currentNodeId) || selectable[0]; + viewport.scrollTop = current + ? Math.max(0, map.offsetHeight * current.y / 100 - viewport.clientHeight * 0.76) + : viewport.scrollHeight; + }); +}); + +export default showMap; diff --git a/mengsan/mode.js b/mengsan/mode.js index 03b4700..08f29e5 100644 --- a/mengsan/mode.js +++ b/mengsan/mode.js @@ -1,12 +1,32 @@ +import { createScenarioCharacters, scenarioTranslations } from "./content/scenario-characters.js"; +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 { PLAYER_ENERGY, PLAYER_HAND_LIMIT, payCard, isActiveCardUse } from "./runtime/combat-rules.js"; +import { mountBattlePiles } from "./runtime/card-library.js"; +import { createPlayerTeardown } from "./runtime/skill-teardown.js"; +import { createBattleResources } from "./runtime/battle-resources.js"; +import { createModeStorage } from "./runtime/mode-storage.js"; +import { createBattleSession } from "./runtime/battle-session.js"; +import { observeFreshEvent } from "./runtime/engine-session.js"; +import { runPhaseLoop, stopBattleTurn } from "./runtime/battle-loop.js"; +import { createBattleSettlement } from "./runtime/battle-settlement.js"; +import { createBattleFlow, quiesceEngine } from "./runtime/battle-flow.js"; 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 { applyStoryOutcome, getAvailableStoryChoices } from "./runtime/story.js"; +import { playDialogue } from "./runtime/dialogue.js"; +import { chooseButtons } from "./runtime/flow-ui.js"; +import { openModeSelection } from "./runtime/navigation.js"; import { completeNode, createRun, enterNextAct, generateActMap, getNodeEncounter, - getSelectableNodes, insertStoryNode, nextRandom, } from "./state.js"; @@ -16,78 +36,29 @@ const STYLE_PATH = `${lib.assetURL}extension/术樱包/mengsan`; const copy = value => JSON.parse(JSON.stringify(value)); -const persistModeStorage = async () => { - lib.storage.version = lib.version; - if (lib.db) { - await game.putDB("data", MODE_ID, copy(lib.storage)); - } - else { - localStorage.setItem(`${lib.configprefix}${MODE_ID}`, JSON.stringify(lib.storage)); - } -}; - +const modeStorage = createModeStorage({lib, game, config, localStorage}); const saveRun = async run => { - try { - lib.storage[config.saveKey] = copy(run); - await persistModeStorage(); - } - catch (error) { - delete lib.storage[config.saveKey]; - try { - await persistModeStorage(); + const snapshot = copy(run); + while (true) { + try { await modeStorage.saveRun(snapshot); return; } + catch (error) { + await chooseButtons("存档未完成", [{id:"retry",name:"重试保存"}], "尚未提交本次结果;保留旧存档,不会重新计算奖励或扣款。"); } - catch {} - alert("梦三存档失败,当前征程存档已清空。请查看控制台错误。"); - console.error("梦三存档失败:", error); - throw error; } }; - -const clearRun = async () => { - delete lib.storage[config.saveKey]; - await persistModeStorage(); -}; - -const createOverlay = (title, className = "") => { - const overlay = ui.create.div(`.mengsan-overlay-shuying${className ? `.${className}` : ""}`, ui.window); - const panel = ui.create.div(".mengsan-panel-shuying", overlay); - ui.create.div(".mengsan-title-shuying", title, panel); - return { overlay, panel }; -}; - -const chooseButtons = (title, choices, description = "") => new Promise(resolve => { - const { overlay, panel } = createOverlay(title); - if (description) ui.create.div(".mengsan-description-shuying", description, panel); - const buttons = ui.create.div(".mengsan-choice-list-shuying", panel); - choices.forEach(choice => { - const button = ui.create.div(".mengsan-choice-shuying", buttons); - button.innerHTML = `
${choice.name}
${choice.description ? `
${choice.description}
` : ""}`; - if (choice.disabled) button.classList.add("disabled"); - button.addEventListener(lib.config.touchscreen ? "touchend" : "click", event => { - event.preventDefault(); - if (choice.disabled || overlay.dataset.resolved) return; - overlay.dataset.resolved = "true"; - overlay.remove(); - resolve(choice.id); - }); - }); -}); +const clearRun = modeStorage.clearRun; +let battleSequence = 0; +let activeBattle = null; +const reportFlowError = error => { console.error("梦三流程暂停,未自动清档:", error); }; const chooseRun = async savedRun => { - if (!savedRun || savedRun.status != "running") return "new"; - const act = config.acts[savedRun.actIndex]; + const available = savedRun?.status === "running"; + const act = available ? config.acts[savedRun.actIndex] : null; return chooseButtons("梦三", [ - { - id: "continue", - name: "继续征程", - description: `${act?.name || "未知区域"} · 已通过 ${savedRun.statistics?.completedNodes || 0} 个节点`, - }, - { - id: "new", - name: "新的开始", - description: "覆盖当前征程并重新生成第一关地图", - }, - ], "Demo 采用单一自动存档。节点完成后才会覆盖检查点。" ); + { id: "continue", name: "继续征程", disabled: !available, + description: available ? `${get.translation(savedRun.player.character)} · ${act?.name || "未知区域"} · 已完成 ${savedRun.statistics?.completedNodes || 0} 个节点` : "暂无进行中的征程" }, + { id: "new", name: "开始新征程", description: available ? "选择角色,确认后替换当前自动存档" : "选择一名武将,踏入第一关" }, + ], "单一自动存档 · 完成节点后记录进度", { eyebrow: "水墨行军 · 存档选择" }); }; const chooseCharacter = async () => { @@ -96,68 +67,9 @@ const chooseCharacter = async () => { name: get.translation(name), description: lib.translate[`${name}_title`] || "作为本次征程的角色", })); - return chooseButtons("选择角色", choices.length ? choices : [{ id: "re_zhaoyun", name: "赵云" }]); + return chooseButtons("选择出征武将", [...(choices.length ? choices : [{ id: "mengsan_liubei_shuying", name: "界刘备" }]), { id: "back", name: "返回存档选择", description: "不会修改现有征程" }], "此武将将陪伴你完成本次征程。", { back: "back", eyebrow: "出征准备" }); }; -const nodeColor = { - battle: "#9d4937", - elite: "#7e3f79", - event: "#477aa6", - story: "#8c6a34", - rest: "#3f8560", - shop: "#a27b35", - boss: "#a62727", -}; - -const showMap = run => new Promise(resolve => { - const act = config.acts[run.actIndex]; - const selectable = getSelectableNodes(run); - const selectableIds = new Set(selectable.map(node => node.id)); - const { overlay, panel } = createOverlay(act.name, "mengsan-map-overlay-shuying"); - panel.classList.add("mengsan-map-panel-shuying"); - const status = ui.create.div(".mengsan-map-status-shuying", panel); - status.innerHTML = `体力:${run.player.hp == null ? "未初始化" : `${run.player.hp}/${run.player.maxHp}`} 金币:${run.player.gold} 牌组:${run.player.deck.length}`; - const map = ui.create.div(".mengsan-map-shuying", panel); - const svg = document.createElementNS("http://www.w3.org/2000/svg", "svg"); - svg.classList.add("mengsan-lines-shuying"); - svg.setAttribute("viewBox", "0 0 100 100"); - svg.setAttribute("preserveAspectRatio", "none"); - svg.setAttribute("aria-hidden", "true"); - map.appendChild(svg); - const nodeMap = new Map(run.map.nodes.map(node => [node.id, node])); - run.map.edges.forEach(edge => { - const source = nodeMap.get(edge[0]); - const target = nodeMap.get(edge[1]); - if (!source || !target) return; - const line = document.createElementNS("http://www.w3.org/2000/svg", "line"); - line.setAttribute("x1", source.x); - line.setAttribute("y1", source.y); - line.setAttribute("x2", target.x); - line.setAttribute("y2", target.y); - if (source.completed) line.classList.add("completed"); - if (source.dynamic || target.dynamic) line.classList.add("dynamic"); - svg.appendChild(line); - }); - run.map.nodes.forEach(node => { - const button = ui.create.div(".mengsan-node-shuying", map); - button.style.left = `${node.x}%`; - button.style.top = `${node.y}%`; - button.style.setProperty("--node-color", nodeColor[node.type] || "#666"); - button.innerHTML = `${config.nodeNames[node.type] || node.type}`; - button.dataset.nodeId = node.id; - if (node.completed) button.classList.add("completed"); - else if (selectableIds.has(node.id)) button.classList.add("selectable"); - else button.classList.add("locked"); - button.addEventListener(lib.config.touchscreen ? "touchend" : "click", event => { - event.preventDefault(); - if (!selectableIds.has(node.id) || overlay.dataset.resolved) return; - overlay.dataset.resolved = "true"; - overlay.remove(); - resolve(node); - }); - }); -}); - 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)], @@ -170,6 +82,13 @@ const createCardInstance = (run, name = "sha", upgrade = 0, 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) }); + 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")); @@ -183,11 +102,11 @@ const applyReward = (run, rewardId) => { break; } case "heal": - player.hp = Math.min(player.maxHp, player.hp + 2); + player.hp = Math.min(player.maxHp, player.hp + 8); break; case "max_hp": - player.maxHp++; - player.hp++; + player.maxHp += 5; + player.hp += 5; break; case "item_hand": if (!player.items.includes("mengsan_hand_charm_shuying")) { @@ -203,24 +122,51 @@ const applyReward = (run, rewardId) => { } }; -const chooseBattleReward = async (run, boss) => { - const choices = boss ? [ - { id: "skill_yingyong", name: "极品技能·英勇", description: "本次征程永久获得【英勇】" }, - { id: "item_hand", name: "极品道具·束带", description: "基础手牌上限额外 +1" }, - { id: "max_hp", name: "极品强化·生机", description: "体力上限与当前体力各 +1" }, - ] : [ - { id: "card_sha", name: "获得一张【杀】", description: "加入独立永久牌组" }, - { id: "card_tao", name: "获得一张【桃】", description: "加入独立永久牌组" }, - { id: "upgrade", name: "随机强化", description: "随机一张牌强化一级" }, - ]; - const reward = await chooseButtons(boss ? "极品奖励(三选一)" : "战斗奖励(三选一)", choices); - applyReward(run, reward); +const chooseReward = async (run, rewardConfig = {}) => { + const choices = getRandomRewardChoices(run, rewardConfig.rewardPool).filter(choice => choice.name); + const title = rewardConfig.rewardTitle || (rewardConfig.boss ? "极品奖励(三选一)" : "战斗奖励(三选一)"); + const rewardId = await chooseButtons(title, choices, rewardConfig.description); + const reward = choices.find(choice => choice.id == rewardId); + if (reward) applyReward(run, reward.effectId || reward.id); +}; + +const finishStoryNode = async (run, node) => { + const content = config.nodeContents?.[node.contentId]; + if (!content || content.kind != "dialogue") { + await setupBattle(run, node); + return false; + } + await playDialogue(content.dialogue, { run, title: content.name }); + const choices = getAvailableStoryChoices(run, content); + if (!choices.length) { + choices.push({ id: "continue", name: "继续", description: "当前没有其他可用选项。", outcome: {} }); + } + const choiceId = await chooseButtons(content.name, choices, content.description); + const choice = choices.find(current => current.id == choiceId); + const outcome = applyStoryOutcome(run, choice?.outcome); + await playDialogue(outcome.dialogue, { run, title: content.name }); + if (outcome.confirmResult && outcome.result) { + await chooseButtons(content.name, [{ id: "continue", name: "继续" }], outcome.result); + } + if (outcome.battle) { + await setupBattle(run, node, getNodeEncounter(run, node, outcome.battle)); + return false; + } + if (outcome.rewardPool) { + await chooseReward(run, { + ...outcome, + description: outcome.rewardDescription || (!outcome.confirmResult ? outcome.result : ""), + }); + } + completeNode(run, node.id); + await saveRun(run); + return true; }; const finishUtilityNode = async (run, node) => { if (node.type == "rest") { const choice = await chooseButtons("休息节点", [ - { id: "heal", name: "休息", description: "回复 2 点体力" }, + { id: "heal", name: "休息", description: "回复 8 点生命" }, { id: "upgrade", name: "磨砺", description: "随机强化一张卡牌" }, ]); applyReward(run, choice); @@ -229,7 +175,7 @@ const finishUtilityNode = async (run, node) => { const price = 20; const choice = await chooseButtons("商店 Demo", [ { id: "card_sha", name: `购买【杀】(${price}金币)`, description: "加入个人牌组", disabled: run.player.gold < price }, - { id: "heal", name: `恢复体力(${price}金币)`, description: "回复 2 点体力", disabled: run.player.gold < price }, + { id: "heal", name: `恢复生命(${price}金币)`, description: "回复 8 点生命", disabled: run.player.gold < price }, { id: "leave", name: "离开", description: "暂不购买" }, ]); if (choice != "leave") { @@ -250,7 +196,7 @@ const finishUtilityNode = async (run, node) => { } completeNode(run, node.id); await saveRun(run); - game.reload(); + return true; }; const shuffleBattlePile = (run, cards) => { @@ -260,33 +206,161 @@ const shuffleBattlePile = (run, cards) => { } }; -const setupBattle = async (run, node) => { - const encounter = getNodeEncounter(run, node); - _status.mengsanRun_shuying = run; - _status.mengsanNode_shuying = node; - _status.mengsanEncounter_shuying = encounter; - _status.mengsanBattle_shuying = { +const setBattleCamp = (player, camp) => { + player.storage.mengsanCamp_shuying = camp; + player.side = camp === "enemy"; + player.identity = player === game.me ? "zhu" : camp === "ally" ? "zhong" : "fan"; + player.setIdentity(player.identity); + player.identityShown = true; + player.ai.shown = 1; + player.classList.add("mengsan-hide-identity-shuying"); +}; +const initBattleUnit = (player, spec) => { + const monster = spec.camp === "enemy" ? createMonster(spec) : null; + player.storage.mengsanUnitId_shuying = spec.id; + setBattleCamp(player, spec.camp); + player.init(spec.character, null, spec.inheritSkills !== false); + setBattleCamp(player, spec.camp); + player.storage.mengsanUnitId_shuying = spec.id; + if (monster) { + player.storage.mengsanMonster_shuying = { tier: monster.tier, draw: monster.draw, handLimit: monster.handLimit }; + player.storage.mengsanMaxEnergy_shuying = monster.energy; + player.storage.mengsanEnergy_shuying = monster.energy; + } + if (spec.maxHp != null || monster) player.maxHp = spec.maxHp ?? monster.hp; + player.hp = Math.min(spec.hp ?? monster?.hp ?? player.hp, player.maxHp); + player.storage.mengsanInitialHand_shuying = spec.hand ?? 4; + for (const skill of spec.skills || []) player.addSkill(skill); + player.update(); + return monster; +}; + +const installMonsterPile = (player, monster, current) => { + if (!monster) return; + const run = _status.mengsanRun_shuying; + const battle = { drawPile: monster.createDeck(`${run.runId}_${player.playerid}`), discardPile: [] }; + 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, + strict: false, refresh() {}, + })); +}; + +const mountEnergy = (player, session) => { + const badge = document.createElement("div"); + badge.className = "mengsan-energy-shuying"; + const refresh = () => { badge.textContent = `费用 ${player.storage.mengsanEnergy_shuying}/${player.storage.mengsanMaxEnergy_shuying}`; }; + player.appendChild(badge); + session.ownResource(badge, () => badge.remove()); + refresh(); + return refresh; +}; +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)); + await player.equip(card); + } +}; +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)); + 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); + }); + await join; + game.log(player, "作为", spec.camp === "ally" ? "友方支援" : "敌方援军", "加入战斗"); + return player; + }, + async effect(player, effect) { + if (effect.type === "draw") await player.draw(effect.amount); + else if (effect.type === "recover") await player.recover(effect.amount); + else if (effect.type === "maxHp") await player.gainMaxHp(effect.amount); + else if (effect.type === "skill") await player.addSkills(effect.skill); + else if (effect.type === "camp") setBattleCamp(player, effect.camp); + else if (effect.type === "discardEquipment") { + const cards = player.getCards("e", card => card.name === effect.name); + if (cards.length) await player.discard(cards); + } + }, + log: name => game.log("关卡条件触发:", name), + }); + current.session.ownResource(director, () => director.dispose()); + return director; +}; + +const prepareBattle = async (run, node, encounter, session, resources) => { + if (encounter.requiredCharacter && run.player.character !== encounter.requiredCharacter) throw new Error("该关卡仅限指定主角,请开始刘备的新征程"); + const plan = buildBattlePlan(encounter, run); + for (const spec of [...plan.units, ...plan.rules.flatMap(r => r.effects.filter(e => e.type === "spawn").map(e => e.unit))]) { + if (!lib.character[spec.character]) throw new Error("关卡武将未加载:" + spec.character); + for (const skill of spec.skills || []) if (!lib.skill[skill]) throw new Error("关卡技能未加载:" + skill); + const slots = new Set(); + for (const card of spec.equipment || []) { + const info = lib.card[card.name]; + if (!info || info.type !== "equip" || !info.subtype || slots.has(info.subtype)) throw new Error("初始装备不存在、不是装备或槽位重复:" + card.name); + slots.add(info.subtype); + } + } + for (const rule of plan.rules) for (const effect of rule.effects) { + if (effect.type === "skill" && !lib.skill[effect.skill]) throw new Error("关卡增益技能未加载:" + effect.skill); + } + consumeSupports(run); + resources.field(_status, "mengsanRun_shuying", run); + resources.field(_status, "mengsanNode_shuying", node); + resources.field(_status, "mengsanEncounter_shuying", encounter); + resources.field(_status, "mengsanBattle_shuying", { resolving: false, drawPile: copy(run.player.deck), discardPile: [], exhaustPile: [], - }; + }); shuffleBattlePile(run, _status.mengsanBattle_shuying.drawPile); - game.prepareArena(2); + game.prepareArena(1 + plan.units.length); + resources.capturePreparedArena(); + // Register runtime IDs before card ownership transfers (gain/lose). + for (const current of game.players) current.getId(); + registerPlayers(session, game.players.slice()); + resources.bindPlayers(game.players); + const currentBattle = activeBattle; + let teardown; + activeBattle.releaseSkills = () => (teardown ||= createPlayerTeardown([...currentBattle.players], {lib,game,get,_status}))(); const me = game.me; - const enemy = game.players.find(current => current != me); - me.init(run.player.character); - enemy.init(lib.character[encounter.enemy] ? encounter.enemy : "re_lvbu"); - me.identity = "zhu"; - enemy.identity = "fan"; - me.side = false; - enemy.side = true; - me.setIdentity("zhu"); - enemy.setIdentity("fan"); - me.ai.shown = 1; - enemy.ai.shown = 1; + + activeBattle.personalPiles = installPersonalPiles(session, me, _status.mengsanBattle_shuying, run, resources, { + game, ui, get, _status, document, MutationObserver, shuffle: shuffleBattlePile, + showCosts: true, + refresh: () => activeBattle?.session === session && activeBattle.pilesUI?.refresh(), + }); + activeBattle.personalPiles.withOwner(() => me.init(run.player.character)); + me.storage.mengsanUnitId_shuying = "player"; + setBattleCamp(me, "ally"); + 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)); + activeBattle.director = createScenario(plan, currentBattle); + activeBattle.director.bind("player", me); + plan.units.forEach((spec, index) => activeBattle.director.bind(spec.id, participants[index])); me.storage.mengsanPlayer_shuying = true; + me.storage.mengsanMaxEnergy_shuying = PLAYER_ENERGY; + me.storage.mengsanEnergy_shuying = PLAYER_ENERGY; + currentBattle.energyUI.set(me, mountEnergy(me, session)); game.zhu = me; if (run.player.maxHp == null) { run.player.maxHp = me.maxHp; @@ -298,71 +372,207 @@ const setupBattle = async (run, node) => { run.player.permanentSkills.forEach(skill => { if (lib.skill[skill] && !me.hasSkill(skill)) me.addSkill(skill); }); - game.addGlobalSkill("mengsan_draw_shuying"); - game.addGlobalSkill("mengsan_card_use_shuying"); + for (const skill of ["mengsan_draw_shuying", "mengsan_card_use_shuying", "mengsan_scenario_shuying", "mengsan_card_payment_shuying", "mengsan_monster_draw_shuying"]) { + if (!lib.skill.global.includes(skill)) { + game.addGlobalSkill(skill); + session.ownResource({}, () => game.removeGlobalSkill(skill)); + } + } + activeBattle.pilesUI = mountBattlePiles(session, _status.mengsanBattle_shuying); + for (let index = 0; index < plan.units.length; index++) await equipBattleUnit(participants[index], plan.units[index], resources); game.syncState(); _status.event.trigger("gameStart"); + for (const participant of participants) await participant.draw(participant.storage.mengsanInitialHand_shuying ?? 4); + if (!session.active) return; + activeBattle.personalPiles.resetOpeningHand(); await game.mengsanDraw_shuying(me, 4); - game.gameDraw(me, current => current == me ? 0 : 4); - game.phaseLoop(me); + if (!session.active) return; + await playDialogue(encounter.openingDialogue, { run, title: encounter.name || "开场剧情" }); + if (!session.active) return; + await currentBattle.director.start(); + if (!session.active) return; + game.checkResult(); + const loop = game.createEvent("phaseLoop", false); + loop.player = me; + loop._isStandardLoop = true; + let openingHandChecked = false; + loop.setContent(async event => runPhaseLoop(event, me, session, {game, lib, _status, get, beforeTurn: async player => { + if (player === me && !openingHandChecked) { + openingHandChecked = true; + currentBattle.personalPiles.trimOpeningHand(4); + } + if (player.storage.mengsanMaxEnergy_shuying != null) { + player.storage.mengsanEnergy_shuying = player.storage.mengsanMaxEnergy_shuying; + currentBattle.energyUI.get(player)?.(); + } + await currentBattle.director.beforeTurn(player); + game.checkResult(); + }})); +}; + + +const registerPlayers = (session, players) => { + for (const player of players) { + activeBattle.players.add(player); + const id = player.playerid; + session.ownResource(player, () => { + if (game.playerMap[id] === player) delete game.playerMap[id]; + for (const list of [game.players, game.dead]) { + const index = list.indexOf(player); if (index >= 0) list.splice(index, 1); + } + if (game.me === player) game.me = null; + if (game.zhu === player) game.zhu = null; + player.remove(); + }); + } +}; +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), + showMap, + showEnding:async route => { game.over(route === "victory"); }, + quiesce:() => quiesceEngine(_status), +}); +const requestBattleFinish = outcome => { + const current = activeBattle; + if (!current) return false; + const accepted = current.flow.requestFinish({session:current.session, hp:game.me?.hp || 0, outcome, defeatedEnemies:current.director?.defeatedEnemies ?? 1}); + if (accepted) { + current.pilesUI?.dispose(); + stopBattleTurn(current.session, _status.eventManager); + if (_status.mengsanBattle_shuying) _status.mengsanBattle_shuying.resolving = true; + current.finished(); + } + return accepted; +}; +const awaitSettlement = async (flow, initial = flow.wait()) => { + let operation = initial; + while (true) { + try { return await operation; } + catch (error) { + if (flow.state !== "retryable") throw error; + const action = await chooseButtons("结算未完成", [ + {id:"retry",name:"重试保存与结算"}, + {id:"exit",name:"保留已保存进度并返回模式选择"}, + ], "已提交的奖励不会重发。技能释放失败时请返回模式选择,避免重复调用释放回调。"); + if (action === "exit") { await openModeSelection(); throw error; } + operation = flow.retry(); + } + } +}; +const setupBattle = async (run, node, encounter = getNodeEncounter(run, node)) => { + // 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); + const session = createBattleSession(`${run.runId}:${++battleSequence}`); + const resources = createBattleResources(session, {game,ui,_status}); + let current; + 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()}; + activeBattle = current; + const root = new lib.element.GameEvent("mengsanBattle", false, _status.eventManager); + const previousRoot = _status.eventManager.rootEvent; + session.ownResource({}, () => { + if (_status.eventManager.rootEvent === root) _status.eventManager.rootEvent = previousRoot; + }); + root.setContent(async () => { await prepareBattle(run, node, encounter, session, resources); }); + observeFreshEvent(session, root); + await flow.enterBattle({session,run,node,encounter,start() { + root.start().catch(error => { current.engineError = error; finished(); }); + }}); + await signal; // External controller, never an engine event content await. + if (current.engineError) { + session.requestStop(); + await chooseButtons("战斗流程异常", [{id:"exit",name:"保留存档并返回模式选择"}], "无法确认旧事件已退出,已停止继续开战。上次成功存档仍保留。"); + await openModeSelection(); return; + } + const result = await awaitSettlement(flow); + if (activeBattle === current) activeBattle = null; + if (result.route === "map") await routeRun(result.run, result.nextNode, true); +}; +const routeRun = async (run, selected = null, supplied = false) => { + while (true) { + const node = supplied ? selected : await showMap(run); + supplied = false; + if (!node) { await openModeSelection(); return; } + if (["event", "rest", "shop"].includes(node.type)) { await finishUtilityNode(run, node); continue; } + if (node.type === "story" && config.nodeContents?.[node.contentId]?.kind === "dialogue") { + if (await finishStoryNode(run, node)) continue; + return; + } + await setupBattle(run, node); return; + } +}; +const startJourney = async () => { + lib.translate.restart = "返回"; + let run = copy(lib.storage[config.saveKey] || null); + while (true) { + const action = await chooseRun(run); + if (action === "continue") break; + const character = await chooseCharacter(); + if (character === "back") continue; + if (run) { + const decision = await chooseButtons("替换当前存档?", [ + { id: "cancel", name: "保留原征程", description: "返回存档选择,不作任何修改" }, + { id: "confirm", name: "确认覆盖并出征", danger: true, description: `以${get.translation(character)}开始新征程,原进度无法撤销` }, + ], "只有确认后才会创建并写入新征程。", { back: "cancel", eyebrow: "覆盖确认" }); + if (decision !== "confirm") continue; + } + run = createRun(character); + await saveRun(run); + break; + } + if (run.battleFlow?.pending) { + 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); + return; + } + const canRefreshOldMap = config.characters.includes(run.player.character) && !run.map?.completedNodeIds?.length && run.map?.layoutVersion != config.mapLayoutVersion; + if (!run.map || run.map.actIndex != run.actIndex || canRefreshOldMap) { + run.map = generateActMap(run, run.actIndex); + await saveRun(run); + } + await routeRun(run); }; const createMode = identityMode => { const identityElement = identityMode.element || {}; const identityPlayer = identityElement.player || {}; + const modeCards = createMengsanCards(); return { ...identityMode, name: MODE_ID, + character: { ...(identityMode.character || {}), ...createScenarioCharacters(lib.element.Character) }, + card: { ...(identityMode.card || {}), ...modeCards.card }, connect: false, start: [ async () => { - lib.translate.restart = "返回"; - let run = copy(lib.storage[config.saveKey] || null); - const action = await chooseRun(run); - if (action == "new") { - if (run) { - const confirmNew = await chooseButtons("确认新的开始", [ - { id: "cancel", name: "返回", description: "保留当前征程" }, - { id: "confirm", name: "覆盖存档", description: "当前征程将被清除" }, - ]); - if (confirmNew == "cancel") { - run = copy(lib.storage[config.saveKey]); - } - else { - const character = await chooseCharacter(); - run = createRun(character); - await saveRun(run); - } - } - else { - const character = await chooseCharacter(); - run = createRun(character); - await saveRun(run); - } - } - const canRefreshOldMap = !run.map?.completedNodeIds?.length && run.map?.layoutVersion != config.mapLayoutVersion; - if (!run.map || run.map.actIndex != run.actIndex || canRefreshOldMap) { - run.map = generateActMap(run, run.actIndex); - await saveRun(run); - } - const node = await showMap(run); - if (["event", "rest", "shop"].includes(node.type)) { - await finishUtilityNode(run, node); - return; - } - await setupBattle(run, node); + // Let the engine start event finish before starting the external controller. + setTimeout(() => startJourney().catch(reportFlowError), 0); }, ], element: { ...identityElement, player: { ...identityPlayer, + dieAfter() { game.checkResult(); }, + dieAfter2() {}, // Identity-mode kill rewards/loyalist penalties do not apply here. + isFriendOf(player) { return this === player || (this.storage.mengsanCamp_shuying === player?.storage?.mengsanCamp_shuying); }, + 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))); }, getHandcardLimit() { if (this.storage?.mengsanPlayer_shuying) { const run = _status.mengsanRun_shuying; const bonus = Number(run?.player?.handLimitBonus || 0); - return Math.max(0, Math.min(8, this.hp) + bonus); + return PLAYER_HAND_LIMIT + bonus; } + if (this.storage?.mengsanMonster_shuying) return this.storage.mengsanMonster_shuying.handLimit; let number = Math.max(this.hp, 0); number = game.checkMod(this, number, "maxHandcardBase", this); number = game.checkMod(this, number, "maxHandcard", this); @@ -379,12 +589,9 @@ const createMode = identityMode => { async mengsanClearRun_shuying() { await clearRun(); }, - async mengsanFailRun_shuying(reason = "征程失败") { - if (_status.mengsanEnding_shuying) return; - _status.mengsanEnding_shuying = true; - await clearRun(); + mengsanFailRun_shuying(reason = "征程失败") { game.log(reason); - game.over(false); + return requestBattleFinish("defeat"); }, async mengsanDraw_shuying(player, number = 1) { if (!player?.storage?.mengsanPlayer_shuying || number <= 0) return []; @@ -394,80 +601,70 @@ const createMode = identityMode => { await game.mengsanFailRun_shuying("个人牌堆与弃牌堆不足,挑战失败"); return []; } - const result = []; - while (result.length < number) { - if (!battle.drawPile.length) { - battle.drawPile = battle.discardPile.splice(0); - shuffleBattlePile(run, battle.drawPile); - game.log(player, "洗切了个人弃牌堆"); - } - const data = battle.drawPile.shift(); - const card = game.createCard(data.name, data.suit, data.number, data.nature); - card.storage.mengsanCard_shuying = copy(data); - card.destroyLog = false; - card.destroyed = (current, position) => { - if (position != "discardPile") return false; - const info = copy(current.storage.mengsanCard_shuying); - if (info.affixes?.includes("annihilate") && current.storage.mengsanUsed_shuying) battle.exhaustPile.push(info); - else battle.discardPile.push(info); - return true; - }; - if (data.affixes?.includes("annihilate")) card.addGaintag("湮灭"); - if (data.upgrade) card.addGaintag(`强化+${data.upgrade}`); - result.push(card); - } + const result = activeBattle.personalPiles.take(number); + if (!result.length) return []; player.directgain(result); player.$draw(result.length); game.log(player, "从个人牌堆摸了", get.cnNumber(result.length), "张牌"); return result; }, - async mengsanFinishBattle_shuying() { - const battle = _status.mengsanBattle_shuying; - if (!battle || battle.resolving || _status.mengsanEnding_shuying) return; - battle.resolving = true; - game.pause(); - const run = _status.mengsanRun_shuying; - const node = _status.mengsanNode_shuying; - const encounter = _status.mengsanEncounter_shuying; - run.player.hp = Math.max(1, game.me.hp); - run.player.gold += encounter.gold; - run.statistics.goldEarned += encounter.gold; - run.statistics.defeatedEnemies++; - await chooseBattleReward(run, encounter.boss); - completeNode(run, node.id); - if (encounter.boss) { - if (!enterNextAct(run)) { - const profile = copy(lib.storage[config.profileKey] || { wins: 0, completedRuns: [] }); - profile.wins++; - profile.completedRuns.push({ - runId: run.runId, - character: run.player.character, - completedNodes: run.statistics.completedNodes, - goldEarned: run.statistics.goldEarned, - completedAt: Date.now(), - }); - lib.storage[config.profileKey] = profile; - delete lib.storage[config.saveKey]; - await persistModeStorage(); - game.over(true); - return; - } - } - await saveRun(run); - game.reload(); + mengsanFinishBattle_shuying() { + return requestBattleFinish("victory"); }, checkResult() { - if (_status.mengsanEnding_shuying || _status.mengsanBattle_shuying?.resolving) return; - if (!game.me || game.me.isDead()) { - game.mengsanFailRun_shuying("角色死亡,征程结束"); - return; - } - const hasEnemy = game.players.some(current => current != game.me && current.side === true && current.isAlive()); - if (!hasEnemy) game.mengsanFinishBattle_shuying(); + const current = activeBattle; + if (!current?.session.active || !current.director?.ready || current.director.busy || current.resultCheckQueued) return; + current.resultCheckQueued = true; + const next = game.createEvent("mengsanScenarioResult", false); + next.setContent(async () => { + try { + 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(); + } finally { current.resultCheckQueued = false; } + }); }, }, + get: { + ...(identityMode.get || {}), + rawAttitude(from, to) { return from === to || from.storage.mengsanCamp_shuying === to.storage.mengsanCamp_shuying ? 8 : -8; }, + }, skill: { ...(identityMode.skill || {}), + mengsan_card_payment_shuying: { + forced: true, silent: true, popup: false, + firstDo: true, priority: 10000, + trigger: { global: "useCard0" }, + filter(event) { return event.player?.storage?.mengsanMaxEnergy_shuying != null && + 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)) { + trigger.cancel(); + game.log(trigger.player, "费用不足,不能使用", trigger.card); + return; + } + trigger.mengsanPaid_shuying = true; + activeBattle?.energyUI.get(trigger.player)?.(); + }, + }, + mengsan_monster_draw_shuying: { + trigger: { player: "phaseDrawBegin2" }, + forced: true, silent: true, popup: false, + filter(event, player) { return Boolean(player.storage?.mengsanMonster_shuying); }, + async content(event, trigger, player) { trigger.num = player.storage.mengsanMonster_shuying.draw; }, + }, + mengsan_scenario_shuying: { + trigger: { global: ["changeHpAfter", "gainAfter", "loseAfter", "dieAfter", "turnOverAfter", "linkAfter", "phaseAfter"] }, + forced: true, silent: true, popup: false, priority: -100, + filter() { return Boolean(activeBattle?.session.active && activeBattle.director?.ready); }, + async content(event, trigger) { + if (event.triggername === "phaseAfter") await activeBattle.director.afterTurn(trigger.player, trigger); + else await activeBattle.director.evaluate("state"); + game.checkResult(); + }, + }, mengsan_draw_shuying: { trigger: { player: "drawBegin" }, firstDo: true, @@ -511,6 +708,8 @@ const createMode = identityMode => { }, translate: { ...(identityMode.translate || {}), + ...modeCards.translate, + ...scenarioTranslations, mengsan_draw_shuying: "梦三牌组", mengsan_card_use_shuying: "梦三词缀", mengsan_yingyong_shuying: "英勇", @@ -518,13 +717,18 @@ const createMode = identityMode => { }, config: {}, help: { - 梦三: "梦三 Demo:每次完成节点后保存检查点;玩家使用个人牌组,敌人继续使用 noname 公共牌堆。", + 梦三: "梦三:玩家和敌方怪物各有独立牌堆与弃牌堆,友方支援仍使用公共牌堆。主动出牌消耗费用,转化牌按转化后牌名计费;响应不耗费。DIY须在技能所属角色事件内访问牌堆,不可缓存公共牌堆节点。", }, }; }; -export default async function initMengsan() { - if (lib.config.all.mode.includes(MODE_ID)) return; +export async function createMengsanMode() { + if (!lib.group.includes("han")) { + game.addGroup("han", "汉", "汉", { color: [ + [255, 252, 250, 1], [255, 241, 238, 1], + [245, 196, 193, 1], [192, 85, 91, 1], + ] }); + } await new Promise(resolve => { const style = lib.init.css(STYLE_PATH, "style", resolve); style.addEventListener("error", resolve, { once: true }); @@ -532,6 +736,12 @@ export default async function initMengsan() { const identityMode = await game.loadModeAsync("identity"); const mode = createMode(identityMode); mode.splash = "ext:术樱包/pve/images/tianshu.jpg"; + return mode; +} + +export default async function initMengsan() { + if (lib.config.all.mode.includes(MODE_ID)) return; + const mode = await createMengsanMode(); game.addMode(MODE_ID, mode, { translate: "梦三", config: {}, diff --git a/mengsan/register.js b/mengsan/register.js new file mode 100644 index 0000000..9b009c8 --- /dev/null +++ b/mengsan/register.js @@ -0,0 +1,23 @@ +import { lib, game } from "../../../noname.js"; + +const MODE_ID = "mengsan_shuying"; + +export default function registerMengsan() { + if (lib.config.all.mode.includes(MODE_ID)) return; + + game.addMode(MODE_ID, { + splash: "ext:术樱包/pve/images/tianshu.jpg", + }, { + translate: "梦三", + config: {}, + extension: "术樱包", + }); + + // game.addMode 默认会闭包保存传入的完整模式对象;这里替换为延迟加载器, + // 确保只有用户真正进入梦三时才解析剧情、地图和奖励等内容模块。 + lib.init[`setMode_${MODE_ID}`] = async () => { + const { createMengsanMode } = await import("./mode.js"); + const mode = await createMengsanMode(); + await game.import("mode", () => mode); + }; +} diff --git a/mengsan/runtime/battle-director.js b/mengsan/runtime/battle-director.js new file mode 100644 index 0000000..000b236 --- /dev/null +++ b/mengsan/runtime/battle-director.js @@ -0,0 +1,180 @@ +const copy = value => JSON.parse(JSON.stringify(value)); +const camps = new Set(["ally", "enemy", "neutral"]); +export const campPresets = Object.freeze({ ally: { enabled: true }, enemy: { enabled: true }, neutral: { enabled: false } }); +const events = new Set(["battleStart", "roundStart", "turnStart", "turnEnd", "state"]); +const checks = new Set(["hp", "hand", "alive", "linked", "turnedOver", "camp"]); +const numeric = value => Number.isInteger(value) && value >= 0 && value <= 99; +const assert = (condition, message) => { if (!condition) throw new Error("梦三关卡配置:" + message); }; + +export function validateBattlePlan(plan) { + assert(plan && Array.isArray(plan.units) && Array.isArray(plan.rules), "units/rules 必须为数组"); + assert(plan.units.length > 0 && plan.units.length <= 7 && plan.rules.length <= 32, "初始单位或规则数量越界"); + const ids = new Set(["player"]), ruleIds = new Set(); + const unit = spec => { + assert(spec && typeof spec.id === "string" && /^[\w.-]+$/.test(spec.id) && !ids.has(spec.id), "单位 ID 无效或重复"); + ids.add(spec.id); + assert(ids.size <= 8, "单场累计最多 8 个席位(含主角及后续援军)"); + assert(typeof spec.character === "string" && spec.character.length > 0 && camps.has(spec.camp), "武将或阵营无效"); + assert(spec.camp !== "neutral", "中立预设尚未启用"); + assert(spec.tier == null || ["normal", "elite", "boss"].includes(spec.tier), "怪物阶级无效"); + assert(spec.deck == null || (Array.isArray(spec.deck) && spec.deck.length > 0 && spec.deck.every(entry => + typeof entry === "string" && Boolean(entry) || entry && typeof entry.name === "string" && Boolean(entry.name) && + (entry.suit == null || ["spade", "heart", "club", "diamond"].includes(entry.suit)) && + (entry.number == null || Number.isInteger(entry.number) && entry.number >= 1 && entry.number <= 13))), "怪物专属牌堆无效"); + for (const key of ["hand", "hp", "maxHp"]) if (spec[key] != null) assert(numeric(spec[key]), key + " 越界"); + if (spec.hp != null) assert(spec.hp > 0, "初始体力必须大于 0"); + if (spec.maxHp != null) assert(spec.maxHp > 0 && (spec.hp == null || spec.hp <= spec.maxHp), "初始体力上限无效"); + assert(spec.inheritSkills == null || typeof spec.inheritSkills === "boolean", "inheritSkills 必须为布尔值"); + assert(spec.after == null || typeof spec.after === "string", "after 必须是单位 ID"); + assert(spec.equipment == null || (Array.isArray(spec.equipment) && spec.equipment.length <= 5), "equipment 无效"); + for (const card of spec.equipment || []) { + assert(card && typeof card.name === "string" && card.name.length > 0, "初始装备名称无效"); + assert(["spade", "heart", "club", "diamond"].includes(card.suit), "初始装备花色无效"); + assert(Number.isInteger(card.number) && card.number >= 1 && card.number <= 13, "初始装备点数无效"); + } + assert(spec.skills == null || (Array.isArray(spec.skills) && spec.skills.every(s => typeof s === "string")), "skills 无效"); + }; + plan.units.forEach(unit); + for (const rule of plan.rules) { + assert(rule && typeof rule.id === "string" && !ruleIds.has(rule.id), "规则 ID 重复或无效"); ruleIds.add(rule.id); + assert(rule.when && events.has(rule.when.event || "state"), "触发时机无效"); + assert(rule.blocksVictory == null || typeof rule.blocksVictory === "boolean", "blocksVictory 必须为布尔值"); + assert(rule.when.round == null || (numeric(rule.when.round) && rule.when.round > 0), "轮数无效"); + assert(rule.when.actorTurns == null || (rule.when.event === "turnEnd" && rule.when.actor && numeric(rule.when.actorTurns) && rule.when.actorTurns > 0), "actorTurns 仅用于指定角色的回合结束"); + assert(rule.when.turn == null || (numeric(rule.when.turn) && rule.when.turn > 0), "回合数无效"); + assert(Array.isArray(rule.effects) && rule.effects.length > 0 && rule.effects.length <= 16, "效果列表无效"); + for (const effect of rule.effects) { + assert(["spawn", "draw", "recover", "maxHp", "skill", "camp", "dialogue", "discardEquipment"].includes(effect.type), "未知效果"); + if (effect.type === "spawn") unit(effect.unit); + else if (effect.type === "dialogue") { + assert(Array.isArray(effect.lines) && effect.lines.every(line => line && ["character", "narrator", "sound", "choice"].includes(line.type) && typeof line.text === "string" && line.text.trim() && (line.type !== "character" || typeof line.character === "string") && (line.type !== "choice" || (/^story\.[\w.]+$/.test(line.flag || "") && Array.isArray(line.choices) && line.choices.length >= 1 && (line.defaultChoice == null || line.choices.some(choice => choice.id === line.defaultChoice))))), "dialogue 台词无效"); + } + else { + assert(typeof effect.target === "string", "效果缺少目标 ID"); + if (["draw", "recover", "maxHp"].includes(effect.type)) assert(numeric(effect.amount) && effect.amount > 0, "效果数值无效"); + if (effect.type === "discardEquipment") assert(typeof effect.name === "string" && effect.name.length > 0, "弃置装备名称无效"); + if (effect.type === "skill") assert(typeof effect.skill === "string" && effect.skill.length > 0, "技能 ID 无效"); + if (effect.type === "camp") assert(["ally", "enemy"].includes(effect.camp) && effect.target !== "player", "阵营无效或试图改变主角阵营"); + } + } + } + for (const rule of plan.rules) { + assert(rule.when.actor == null || ids.has(rule.when.actor), "行动角色 ID 无效"); + assert(rule.when.all == null || (Array.isArray(rule.when.all) && rule.when.all.length <= 16), "条件列表无效"); + for (const condition of rule.when.all || []) { + assert(ids.has(condition.unit) && checks.has(condition.field), "条件单位或字段无效"); + if (["hp", "hand"].includes(condition.field)) assert(["eq", "gte", "lte"].includes(condition.op) && numeric(condition.value), "数值比较无效"); + else if (condition.field === "camp") assert(camps.has(condition.value), "阵营条件无效"); + else assert(typeof condition.value === "boolean", "状态条件必须为布尔值"); + } + for (const effect of rule.effects) if (!["spawn", "dialogue"].includes(effect.type)) assert(ids.has(effect.target), "效果目标不存在"); + } + for (const spec of [...plan.units, ...plan.rules.flatMap(r => r.effects.filter(e => e.type === "spawn").map(e => e.unit))]) { + assert(spec.after == null || (ids.has(spec.after) && spec.after !== spec.id), "after 席位目标无效"); + } + return plan; +} + +export function grantSupport(run, rewardId, spec) { + const supports = run.player.supports ||= []; + const battles = spec.battles ?? -1; + assert(battles === -1 || (Number.isInteger(battles) && battles > 0 && battles <= 99), "支援持续场数无效"); + // One support per source; repeated permanent items do not clone the same helper. + const existing = supports.find(s => s.rewardId === rewardId); + if (existing) { existing.battles = existing.battles === -1 || battles === -1 ? -1 : Math.min(99, existing.battles + battles); return; } + const number = (run.supportSequence || 0) + 1; + run.supportSequence = number; + supports.push({ id: `support_${number}`, rewardId, category: spec.category || "item", unit: copy(spec.unit), battles }); +} + +export function buildBattlePlan(encounter, run) { + const plan = copy(encounter.battlePlan || { + units: [{ id: "enemy_1", character: encounter.enemy, camp: "enemy", tier: encounter.tier || "normal", hand: 4 }], rules: [], + }); + plan.rules ||= []; + // Consumed only in the battle's run snapshot; the existing settlement commits it atomically. + for (const support of run.player.supports || []) { + if (support.battles === 0) continue; + assert(support.battles === -1 || (Number.isInteger(support.battles) && support.battles > 0), "支援持续场数无效"); + plan.units.push({ ...copy(support.unit), id: support.id }); + } + validateBattlePlan(plan); + return plan; +} + +export function consumeSupports(run) { + run.player.supports = (run.player.supports || []).map(s => ({ ...s, battles: s.battles > 0 ? s.battles - 1 : s.battles })).filter(s => s.battles !== 0); +} + +export function createBattleDirector(plan, adapters) { + validateBattlePlan(plan); + const units = new Map(), fired = new Set(), completedTurns = new Map(), seenPhases = new WeakSet(); + let round = 0, turn = 0, actor = null, ready = false, running = false, disposed = false; + const compare = (actual, op, value) => op === "gte" ? actual >= value : op === "lte" ? actual <= value : actual === value; + function matches(when, event) { + if ((when.event || "state") !== event) return false; + if (when.round != null && round < when.round) return false; + if (when.turn != null && turn < when.turn) return false; + if (when.actorTurns != null && (completedTurns.get(actor) || 0) < when.actorTurns) return false; + if (when.actor != null && units.get(when.actor) !== actor) return false; + return (when.all || []).every(condition => { + const player = units.get(condition.unit); + if (!player) return false; + const state = adapters.state(player); + return compare(state[condition.field], condition.op || "eq", condition.value); + }); + } + async function evaluate(event = "state") { + if (!ready || disposed || running) return; + running = true; + try { + // Every rule runs once per battle; marking before effects prevents recursive triggers. + for (let pass = 0; pass <= plan.rules.length; pass++) { + let changed = false; + for (const rule of plan.rules) { + if (disposed || !adapters.active()) return; + if (fired.has(rule.id) || !matches(rule.when, event)) continue; + fired.add(rule.id); changed = true; + for (const effect of rule.effects) { + if (disposed || !adapters.active()) return; + if (effect.type === "dialogue") await adapters.dialogue(effect.lines); + else if (effect.type === "spawn") { + const anchor = effect.unit.after == null ? null : units.get(effect.unit.after); + if (effect.unit.after != null && !anchor) throw new Error("支援席位目标尚未登场:" + effect.unit.after); + units.set(effect.unit.id, await adapters.spawn(effect.unit, anchor)); + } + else { + const target = units.get(effect.target); + if (target && adapters.state(target).alive) await adapters.effect(target, effect); + } + } + adapters.log(rule.name || rule.id); + } + if (!changed) break; + } + } finally { running = false; } + } + return { + bind(id, player) { if (units.has(id)) throw new Error("Duplicate battle unit: " + id); units.set(id, player); }, + async start() { ready = true; await evaluate("battleStart"); await evaluate("state"); }, + async beforeTurn(player) { + turn++; actor = player; + if (player === units.get("player")) { round++; await evaluate("roundStart"); } + await evaluate("turnStart"); await evaluate("state"); + }, + async afterTurn(player, phase) { + if (!ready || disposed || !adapters.active()) return; + if (phase && seenPhases.has(phase)) return; + if (phase) seenPhases.add(phase); + actor = player; + completedTurns.set(player, (completedTurns.get(player) || 0) + 1); + await evaluate("turnEnd"); await evaluate("state"); + }, + evaluate, + get ready() { return ready; }, + get busy() { return running; }, + get pendingEnemies() { return plan.rules.some(rule => rule.blocksVictory && !fired.has(rule.id) && rule.effects.some(e => (e.type === "spawn" && e.unit.camp === "enemy") || (e.type === "camp" && e.camp === "enemy"))); }, + get defeatedEnemies() { return [...units.values()].filter(p => { const s = adapters.state(p); return s.camp === "enemy" && !s.alive; }).length; }, + dispose() { disposed = true; units.clear(); }, + }; +} diff --git a/mengsan/runtime/battle-flow.js b/mengsan/runtime/battle-flow.js new file mode 100644 index 0000000..d90919f --- /dev/null +++ b/mengsan/runtime/battle-flow.js @@ -0,0 +1,103 @@ +// 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 () => {}}) { + let state = "map", active = null, work = null, job = null, error = null; + async function drive() { + try { + state = "draining"; + await active.session.drain(); + // Allow mode-start/parent frames and foreign pauses to unwind; never resume them. + await quiesce(); + if (!job.pending) job.pending = await settlement.prepare(job.input); + if (job.pending.outcome === "victory" && !job.victoryPresented) { + await presentVictory(copy(job.pending)); + job.victoryPresented = true; + } + 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.hasChoice = true; + } + job.pending = await settlement.choose(job.input.run.runId, job.pending.id, job.choiceId); + } + if (!job.committed) { + state = "committing"; + job.committed = await settlement.commit(job.input.run.runId, job.pending.id); + } + if (!job.skillsReleased) { + state = "releasingSkills"; + await releaseSkills(); + job.skillsReleased = true; + await quiesce(); + } + state = "cleaning"; + await active.session.dispose(); // Failed cleanup retry does NOT replay a committed reward. + await quiesce(); + if (!job.delivered) { + state = "presenting"; + if (job.committed.route === "map") job.nextNode = await showMap(copy(job.committed.run)); + else await showEnding(job.committed.route, copy(job.committed.run)); + job.delivered = true; + } + state = job.committed.route === "map" ? "map" : "ended"; + return {...copy(job.committed), nextNode:copy(job.nextNode ?? null)}; + } catch (cause) { + error = cause; state = "retryable"; throw cause; + } + } + function launch() { + error = null; state = "draining"; + work = Promise.resolve().then(drive); + // The engine callback is fire-and-forget; keep failure observable without unhandled rejection. + work.catch(() => {}); + } + return Object.freeze({ + get state() { return state; }, + get error() { return error; }, + async enterBattle({session, run, node, encounter, start}) { + if (state !== "map") throw new Error("Map handoff not ready"); + if (run.battleFlow?.pending) throw new Error("Resume pending settlement before battle"); + const saved = settlement.readRun(); + if (!saved || saved.runId !== run.runId || saved.revision !== run.revision) throw new Error("Stale battle input"); + state = "starting"; + try { + await quiesce(); + active = {session, 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; } + }, + requestFinish({session, hp, outcome = "victory", defeatedEnemies = 1}) { + 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}}; + state = "stopping"; + active.session.requestStop(); + launch(); + return true; // No game.pause/resume: stop loop, let current body unwind, then open rewards. + }, + resumePending({session}) { + if (state !== "map") throw new Error("Cannot resume during another operation"); + const run = settlement.readRun(), item = run?.battleFlow?.pending; + if (!item) throw new Error("No durable pending settlement"); + active = {session}; + job = {input:{run}, pending:copy(item)}; + state = "stopping"; session.requestStop(); launch(); return work; + }, + retry() { + if (state !== "retryable") throw new Error("No failed settlement to retry"); + launch(); return work; + }, + wait() { if (!work) throw new Error("No settlement requested"); return work; }, + }); +} + +// Run outside engine events. No busy poll, no global queue clearing, no pause ownership guessing. +export async function quiesceEngine({eventManager, pauseManager}, nextTask = () => new Promise(resolve => setTimeout(resolve, 0))) { + await nextTask(); + await pauseManager.waitPause(); + if (eventManager.eventStack.length || eventManager.tempEvent) throw new Error("Engine event stack is not idle"); +} diff --git a/mengsan/runtime/battle-loop.js b/mengsan/runtime/battle-loop.js new file mode 100644 index 0000000..995e6e0 --- /dev/null +++ b/mengsan/runtime/battle-loop.js @@ -0,0 +1,87 @@ +// Track only the turn created by this battle's cooperative loop. +const battleTurns = new WeakMap(); +const turnStages = new Set(["phase", "phaseZhunbei", "phaseJudge", "phaseDraw", "phaseUse", "phaseDiscard", "phaseJieshu"]); + +export function stopBattleTurn(session, eventManager) { + const turn = battleTurns.get(session); + if (!turn) return; + const belongsToTurn = event => { + const seen = new Set(); + while (event && !seen.has(event)) { + if (event === turn) return true; + seen.add(event); + event = event.parent; + } + return false; + }; + // Finish phase controllers, not the card/damage/death events currently resolving. + // Those events must unwind before the session can safely dispose players. + for (const event of eventManager.eventStack.slice().reverse()) { + if (turnStages.has(event.name) && belongsToTurn(event)) event.finish(); + } + turn.finish(); +} + +// Candidate derived from local engine phaseLoop; no global replacement. +export async function runPhaseLoop(event, player, session, { game, lib, _status, get, beforeTurn = async () => {} }) { + let num = 1; + let current = player; + while (session.active && !event.finished && current.getSeatNum() === 0) { + current.setSeatNum(num); + current = current.next; + num++; + } + while (session.active && !event.finished) { + if (game.players.includes(event.player)) { + await beforeTurn(event.player); + if (!session.active || event.finished) return; + lib.onphase.forEach((i) => i()); + if (!session.active || event.finished) return; + const phase = event.player.phase(); + battleTurns.set(session, phase); + try { + event.next.remove(phase); + let isRoundEnd = false; + if (lib.onround.every((i) => i(phase, event.player))) { + isRoundEnd = _status.roundSkipped; + if (_status.isRoundFilter) { + isRoundEnd = _status.isRoundFilter(phase, event.player); + } else if (_status.seatNumSettled) { + const seatNum = event.player.getSeatNum(); + if (seatNum != 0) { + if (get.itemtype(_status.lastPhasedPlayer) != "player" || seatNum < _status.lastPhasedPlayer.getSeatNum()) { + isRoundEnd = true; + } + } + } else if (event.player == _status.roundStart) { + isRoundEnd = true; + } + if (isRoundEnd && _status.globalHistory.some((i) => i.isRound)) { + game.log(); + await event.trigger("roundEnd"); + } + } + if (!session.active || event.finished) { phase.finish(); return; } + event.next.push(phase); + await phase; + } finally { + if (battleTurns.get(session) === phase) battleTurns.delete(session); + } + } + if (!session.active || event.finished) return; + await event.trigger("phaseOver"); + if (!session.active || event.finished) return; + const findNext = (current2) => { + const players = game.players.slice(0).concat(game.dead).sort((a, b) => parseInt(a.dataset.position) - parseInt(b.dataset.position)); + const position = parseInt(current2.dataset.position); + for (const player2 of players) { + if (parseInt(player2.dataset.position) > position) { + return player2; + } + } + return players[0]; + }; + event.player = findNext(event.player); + if (!event.player) return; + } + } diff --git a/mengsan/runtime/battle-resources.js b/mengsan/runtime/battle-resources.js new file mode 100644 index 0000000..6e6d7f3 --- /dev/null +++ b/mengsan/runtime/battle-resources.js @@ -0,0 +1,55 @@ +// Exclusive battle resources. Never clear arbitrary global queues or arbitrary timers. +export function createBattleResources(session, {game, ui, _status}) { + const cards = new Set(), nodes = new Map(), fields = []; + const containers = () => [ui.arena, ui.control, ui.sidebar, ui.cardPile, ui.discardPile, ui.ordering, ui.special].filter(Boolean); + const children = () => new Map(containers().map(parent => [parent, new Set(parent.childNodes)])); + const before = children(); + const uiKeys = ["me","handcards1Container","handcards2Container","handcards1","handcards2"]; + const uiBefore = new Map(uiKeys.map(key => [key,Object.getOwnPropertyDescriptor(ui,key)])); + function field(object, key, value) { + const descriptor = Object.getOwnPropertyDescriptor(object, key); + object[key] = value; + fields.push({object,key,value,descriptor}); + return value; + } + // Install before root.start so global history cannot retain the finished battle tree. + field(_status, "globalHistory", [{cardMove:[],custom:[],useCard:[],changeHp:[],everything:[]}]); + field(_status, "cardtag", Object.fromEntries(Object.keys(_status.cardtag || {}).map(key => [key,[]]))); + const counters = {phaseNumber:game.phaseNumber, roundNumber:game.roundNumber}; + game.phaseNumber = 0; game.roundNumber = 0; + session.ownResource({}, () => { + for (const card of cards) card.remove(); + for (const [node,parent] of nodes) if (node.parentNode === parent) node.remove(); + for (const {object,key,value,descriptor} of fields.reverse()) { + if (object[key] !== value) continue; // A replacement owned by another subsystem survives. + if (descriptor) Object.defineProperty(object,key,descriptor); else delete object[key]; + } + game.phaseNumber = counters.phaseNumber; game.roundNumber = counters.roundNumber; + }); + return Object.freeze({ + field, + card(card) { cards.add(card); return card; }, + capturePreparedArena() { + for (const key of uiKeys) { + const descriptor = uiBefore.get(key); + if (ui[key] !== descriptor?.value) fields.push({object:ui,key,value:ui[key],descriptor}); + } + // Call synchronously after prepareArena, with onfree already drained. + for (const parent of containers()) { + const prior = before.get(parent) || new Set(); + for (const node of parent.childNodes) if (!prior.has(node)) { + if ([ui.cardPile,ui.discardPile,ui.ordering,ui.special].includes(parent)) cards.add(node); + else nodes.set(node,parent); + } + } + }, + bindPlayers(players) { + const owned = new Set(players); + session.ownResource({}, () => { + for (const key of ["currentPhase","lastPhasedPlayer","roundStart"]) { + if (owned.has(_status[key])) delete _status[key]; + } + }); + }, + }); +} diff --git a/mengsan/runtime/battle-session.js b/mengsan/runtime/battle-session.js new file mode 100644 index 0000000..6c41950 --- /dev/null +++ b/mengsan/runtime/battle-session.js @@ -0,0 +1,106 @@ +// Candidate only. No engine globals, event prototype patches, or save writes. +const owners = new WeakMap(); + +export function createBattleSession(id) { + if (typeof id !== "string" || !id.trim()) throw new TypeError("Session ID required"); + const token = Object.freeze({ id }); + let state = "running", disposePromise = null; + const pending = new Set(), events = new Set(), resources = new Map(), errors = []; + const waiters = new Set(); + const notify = () => { for (const wake of waiters) wake(); waiters.clear(); }; + const ensureRunning = () => { if (state !== "running") throw new Error("Session is stopping"); }; + const claim = object => { + if (!object || (typeof object !== "object" && typeof object !== "function")) throw new TypeError("Object required"); + const owner = owners.get(object); + if (owner && owner !== token) throw new Error("Foreign session resource"); + owners.set(object, token); + }; + // Observe a native completion promise; never assimilate GameEvent.then(), which can start queues. + const observe = promise => { + if (!(promise instanceof Promise)) throw new TypeError("Native completion Promise required"); + pending.add(promise); + promise.then(() => { pending.delete(promise); notify(); }, error => { + errors.push(error); pending.delete(promise); notify(); + }); + return promise; + }; + const api = { + id, + get state() { return state; }, + get active() { return state === "running"; }, + get pendingCount() { return pending.size; }, + guard(callback) { + if (typeof callback !== "function") throw new TypeError("Callback required"); + return (...args) => state === "running" ? callback(...args) : undefined; + }, + runTask(factory) { + ensureRunning(); + if (typeof factory !== "function") throw new TypeError("Task factory required"); + // Reserve before invoking user code so a stop request inside it cannot race drain(). + return observe(Promise.resolve().then(() => state === "running" ? factory(api) : undefined)); + }, + ownEvent(event, completion) { + ensureRunning(); + if (typeof event?.finish !== "function" || !(completion instanceof Promise)) throw new TypeError("Event and native completion required"); + claim(event); + if (events.has(event)) throw new Error("Event already registered"); + events.add(event); observe(completion); + return event; + }, + ownResource(resource, release) { + ensureRunning(); + if (typeof release !== "function") throw new TypeError("Release function required"); + claim(resource); + if (resources.has(resource)) throw new Error("Resource already registered"); + resources.set(resource, release); + return resource; + }, + requestStop() { + if (state !== "running") return false; + state = "stopping"; + // Only explicitly registered events. Do not erase global next/after or guess ownership. + for (const event of events) { + try { event.finish(); } catch (error) { errors.push(error); } + } + notify(); + return true; + }, + async drain() { + if (state === "running") throw new Error("requestStop required before drain"); + // Must be awaited by an external coordinator, not from a task tracked by this session. + while (pending.size) await new Promise(resolve => waiters.add(resolve)); + if (errors.length) throw new AggregateError(errors, "Battle tasks failed; cleanup blocked"); + if (state === "stopping") state = "drained"; + }, + dispose() { + if (state === "disposed") return Promise.resolve(); + if (disposePromise) return disposePromise; + if (state !== "drained") return Promise.reject(new Error("Drain must complete before disposal")); + state = "disposing"; + disposePromise = (async () => { + // Reverse acquisition order; stop at first failure to preserve dependent resources. + for (const [resource, release] of [...resources].reverse()) { + await release(resource); + resources.delete(resource); owners.delete(resource); + } + for (const event of events) owners.delete(event); + events.clear(); state = "disposed"; + })().catch(error => { state = "drained"; throw error; }).finally(() => { disposePromise = null; }); + return disposePromise; + }, + }; + return Object.freeze(api); +} + +// A reused ID must never cause old-session cleanup to delete the new player's entry. +export function ownPlayerIndex(session, player, playerMap) { + const id = player.playerid; + if (!id || playerMap[id] !== player) throw new Error("Player must be registered first"); + return session.ownResource(player, () => { if (playerMap[id] === player) delete playerMap[id]; }); +} + +// Engine pause booleans are not ownership tokens. Accept a lease from a coordinator instead. +export function ownPauseLease(session, lease) { + if (typeof lease?.release !== "function") throw new TypeError("Pause lease required"); + return session.ownResource(lease, () => lease.release()); +} diff --git a/mengsan/runtime/battle-settlement.js b/mengsan/runtime/battle-settlement.js new file mode 100644 index 0000000..a64392e --- /dev/null +++ b/mengsan/runtime/battle-settlement.js @@ -0,0 +1,99 @@ +// Candidate settlement adapter. Dependencies are the current mode/state reward functions. +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}) { + const readRun = () => store.read()[config.saveKey]; + function current(storage, runId) { + const run = storage[config.saveKey]; + if (!run || run.runId !== runId) throw new Error("Stale run: settlement refused"); + run.battleFlow ||= { receipts: [], pending: null }; + return run; + } + function pending(storage, runId, id) { + const run = current(storage, runId), item = run.battleFlow.pending; + if (!item || item.id !== id) throw new Error("Settlement checkpoint mismatch"); + return {run, item}; + } + 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"}) { + 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"); + const id = idFor(snapshot, node); + return store.update(storage => { + const run = current(storage, snapshot.runId); + if (run.battleFlow.receipts.includes(id)) throw new Error("Battle already settled"); + if (run.battleFlow.pending) { + if (run.battleFlow.pending.id !== id) throw new Error("Another settlement is pending"); + return run.battleFlow.pending; + } + if (run.actIndex !== snapshot.actIndex || run.revision !== snapshot.revision || + run.map?.nodes.find(n => n.id === node.id)?.completed !== false) { + throw new Error("Stale node or revision"); + } + // Battle may have changed RNG/card state since the last checkpoint. + 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"); + 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; + }); + }, + async choose(runId, id, choiceId) { + return store.update(storage => { + const {item} = pending(storage, runId, id); + if (item.state === "chosen") { + if (item.choiceId !== choiceId) throw new Error("Choice already locked"); + return item; + } + const result = copy(item.base); + let route = "defeat"; + if (item.outcome === "victory") { + const choice = item.choices.find(c => c.id === choiceId); + if (item.choices.length) { + if (!choice) throw new Error("Reward is not a fixed candidate"); + 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"); + route = item.boss && !enterNextAct(result) ? "victory" : "map"; + } else result.status = "failed"; + // Save the exact result (including random card ID/target) before final commit. + item.state = "chosen"; item.choiceId = choiceId; item.result = result; + item.route = route; item.completedAt = now(); + return item; + }); + }, + async commit(runId, id) { + return store.update(storage => { + const saved = storage[config.saveKey]; + const prior = storage[config.profileKey]?.completedRuns?.find(r => r.runId === runId && r.settlementId === id); + if (!saved && prior) return {route:prior.outcome, run:null, duplicate:true}; + if (saved?.runId === runId && saved.battleFlow?.receipts?.includes(id)) return {route:"map", run:saved, duplicate:true}; + const {run, item} = pending(storage, runId, id); + if (item.state !== "chosen") throw new Error("Durable chosen result required"); + const result = copy(item.result); + result.battleFlow = {receipts:[...run.battleFlow.receipts, id], pending:null}; + if (item.route === "map") storage[config.saveKey] = result; + else { + const profile = storage[config.profileKey] ||= {wins:0, completedRuns:[]}; + 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, + completedNodes:result.statistics.completedNodes, goldEarned:result.statistics.goldEarned, completedAt:item.completedAt}); + } + delete storage[config.saveKey]; // Same transaction as profile; never a second clear write. + } + return {route:item.route, run:result, duplicate:false}; + }); + }, + }); +} diff --git a/mengsan/runtime/card-library.js b/mengsan/runtime/card-library.js new file mode 100644 index 0000000..db7fa56 --- /dev/null +++ b/mengsan/runtime/card-library.js @@ -0,0 +1,174 @@ +import { lib, get, ui } from "../../../../noname.js"; + +const suits = { spade: "♠ 黑桃", heart: "♥ 红桃", club: "♣ 梅花", diamond: "♦ 方块" }; +const types = { basic: "基本牌", trick: "锦囊牌", delay: "延时锦囊", equip: "装备牌" }; +const affixes = { annihilate: "湮灭:使用后进入本场湮灭区,不再参与本场个人弃牌堆洗切。" }; +const text = value => { + const template = document.createElement("template"); + // Translation markup is parsed inertly; only plain text is inserted into the UI. + template.innerHTML = String(value ?? "").replace(//gi, "\n"); + return template.content.textContent.trim(); +}; +const translate = value => text(lib.translate[value] || get.translation(value) || value); + +export function describeLibraryCard(card) { + const info = lib.card[card.name]; + const nature = typeof card.nature === "string" ? card.nature.split("|").filter(Boolean).map(translate).join("、") : ""; + return { + name: translate(card.name), suit: suits[card.suit] || "无花色", + number: ({ 1: "A", 11: "J", 12: "Q", 13: "K" })[card.number] || String(card.number ?? "—"), + nature: nature || "无属性", type: types[info?.type] || "未知类别", + red: card.suit === "heart" || card.suit === "diamond", + description: text(lib.translate[`${card.name}_info`] || "暂无卡牌介绍。"), + affixes: (Array.isArray(card.affixes) ? card.affixes : []).map(key => affixes[key] || `词缀:${translate(key)}`), + upgrade: Number.isFinite(card.upgrade) ? card.upgrade : 0, + available: Boolean(info), + }; +} + +function element(tag, className, value, parent) { + const node = document.createElement(tag); + node.className = className; + if (value != null) node.textContent = value; + parent?.appendChild(node); + return node; +} + +function button(label, parent, action, className = "ms-deck-button") { + const node = element("button", className, label, parent); + node.type = "button"; + node.addEventListener("click", action); + return node; +} + +// Read-only DOM previews, never game.createCard or Card.init: no IDs, triggers or pile writes. +function preview(card, details, parent) { + const face = element("div", "ms-deck-face", null, parent); + element("span", `ms-deck-corner${details.red ? " ms-deck-red" : ""}`, `${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; + // Only mode-standard local art paths; unknown or missing art keeps the text fallback. + if (/^[a-zA-Z0-9_]+$/.test(imageName)) { + const image = element("img", "ms-deck-image", null, art); + image.alt = ""; + image.loading = "lazy"; + image.addEventListener("error", () => image.remove(), { once: true }); + image.src = `${lib.assetURL || ""}image/card/${imageName}.png`; + } + element("strong", "ms-deck-name", details.name, face); + element("span", "ms-deck-meta", `${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); + } + return face; +} + +export function openCardLibrary(run, options = {}) { + const existing = document.querySelector("dialog.mengsan-deck-dialog-shuying[open]"); + if (existing) { existing.focus(); return () => {}; } + const previousFocus = document.activeElement; + const dialog = element("dialog", "mengsan-deck-dialog-shuying", null); + dialog.setAttribute("aria-label", options.title || "卡牌库"); + const header = element("header", "ms-deck-header", null, dialog); + const title = element("div", "ms-deck-title-group", null, header); + element("span", "ms-deck-eyebrow", options.eyebrow || "梦三 · 行囊", title); + element("h2", "ms-deck-title", options.title || "卡牌库", title); + let closed = false; + const close = () => { + if (closed) return; + closed = true; + if (dialog.open) dialog.close(); + dialog.remove(); + if (previousFocus?.isConnected) previousFocus.focus(); + }; + button("关闭", header, close); + const summary = element("p", "ms-deck-summary", null, dialog); + 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 : []; + let detail = false, selectedButton = null, scrollTop = 0; + const list = element("div", "ms-deck-grid", null, body); + const detailPanel = element("section", "ms-deck-detail", null, body); + detailPanel.hidden = true; + function back() { + detail = false; detailPanel.hidden = true; list.hidden = false; + detailPanel.replaceChildren(); body.scrollTop = scrollTop; + selectedButton?.focus({ preventScroll: true }); + } + function showDetail(card, details, owner) { + selectedButton = owner; scrollTop = body.scrollTop; + detail = true; list.hidden = true; detailPanel.hidden = false; + detailPanel.replaceChildren(); + const backButton = button("返回牌库", detailPanel, back); + const content = element("div", "ms-deck-detail-content", null, detailPanel); + preview(card, details, content); + 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("h4", "", "卡牌介绍", copy); + element("p", "ms-deck-rules", details.description, copy); + if (!details.available) element("p", "ms-deck-warning", "该卡牌定义未加载;保留存档记录,不删除此牌。", copy); + if (details.upgrade) element("p", "", `强化等级:${details.upgrade}(实际效果以玩法规则为准)`, 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} 张 · 同名不同花色、点数分别展示`; + 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); + 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},查看详情`); + if (section.ordered) element("span", "ms-deck-order", index === 0 ? "1 · 下一张" : `${index + 1}`, tile); + preview(card, details, tile); + element("span", "ms-deck-excerpt", details.description, tile); + }); + } + } + dialog.addEventListener("cancel", event => { event.preventDefault(); if (detail) back(); else close(); }); + dialog.addEventListener("close", close); + // Prevent the map's Escape/Tab handler from acting behind this modal. + dialog.addEventListener("keydown", event => event.stopPropagation()); + render(); + document.body.appendChild(dialog); + try { dialog.showModal(); } catch (error) { close(); throw error; } + header.querySelector("button").focus(); + close.refresh = () => { + if (closed) return; + // Rebuild from live arrays after a pile mutation, including while viewing details. + const wasDetail = detail, offset = body.scrollTop; + render(); body.scrollTop = offset; + if (wasDetail) header.querySelector("button").focus(); + }; + return close; +} + +// Only the player's two mode-owned piles are exposed. No hand/equipment/exhaust/global pile. +export function mountBattlePiles(session, battle) { + let closeDialog = null, disposed = false; + const control = button("牌堆", ui.window, () => { + if (disposed || !session.active || battle.resolving) return; + closeDialog?.(); + closeDialog = openCardLibrary(null, { + title: "战斗牌堆", eyebrow: "梦三 · 个人牌堆", + sections: () => [ + { title: "剩余牌堆", cards: battle.drawPile, ordered: true }, + { title: "弃牌堆", cards: battle.discardPile, ordered: false }, + ], + }); + }, "mengsan-battle-piles-button-shuying"); + control.setAttribute("aria-haspopup", "dialog"); + const dispose = () => { + if (disposed) return; + disposed = true; closeDialog?.(); closeDialog = null; control.remove(); + }; + session.ownResource(control, dispose); + return { dispose, refresh() { if (!disposed) closeDialog?.refresh?.(); } }; +} diff --git a/mengsan/runtime/combat-rules.js b/mengsan/runtime/combat-rules.js new file mode 100644 index 0000000..7add48a --- /dev/null +++ b/mengsan/runtime/combat-rules.js @@ -0,0 +1,33 @@ +// 梦三独有的出牌经济,不修改 noname 的全局牌定义。 +import { _status } from "../../../../noname.js"; +export const PLAYER_ENERGY = 3; +export const PLAYER_HAND_LIMIT = 3; +export const SHA_DAMAGE = 4; + +const COSTS = Object.freeze({ sha: 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 isActiveCardUse = (event, player) => { + if (!event || !player) return false; + const seen = new Set(); + 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 === "chooseToUse" && (current.respondTo || String(current.type || "").startsWith("respond"))) return false; + if (current.name === "phaseUse" && current.player === player) inPhaseUse = true; + } + // 某些扩展创建的主动 useCard 事件不保留 phaseUse 父链。 + return inPhaseUse || (event.name === "useCard" && event.player === player && + _status.currentPhase === player); +}; + +export const canPayCard = (player, card) => (player?.storage?.mengsanEnergy_shuying ?? Infinity) >= cardCost(card); + +export const payCard = (player, card) => { + if (!canPayCard(player, card)) return false; + player.storage.mengsanEnergy_shuying -= cardCost(card); + return true; +}; diff --git a/mengsan/runtime/dialogue-choice.js b/mengsan/runtime/dialogue-choice.js new file mode 100644 index 0000000..e3f9c0c --- /dev/null +++ b/mengsan/runtime/dialogue-choice.js @@ -0,0 +1,17 @@ +export const isDialogueLineVisible = (line, run) => !line.when || run?.storyFlags?.[line.when.flag] === line.when.equals; + +export const applyDialogueChoice = (run, line, choiceId) => { + const choice = line.choices?.find(item => item.id === choiceId); + if (!choice || !run || !/^story\.[\w.]+$/.test(line.flag || "")) throw new Error("梦三剧情发言选项无效"); + run.storyFlags ||= {}; + run.storyFlags[line.flag] = choice.id; + return choice.text; +}; + +export const applySkippedDialogueChoices = (lines, startIndex, run) => { + for (let index = startIndex; index < lines.length; index++) { + const line = lines[index]; + if (!isDialogueLineVisible(line, run) || line.type !== "choice") continue; + applyDialogueChoice(run, line, line.defaultChoice || line.choices[0].id); + } +}; diff --git a/mengsan/runtime/dialogue.js b/mengsan/runtime/dialogue.js new file mode 100644 index 0000000..dfa1bd1 --- /dev/null +++ b/mengsan/runtime/dialogue.js @@ -0,0 +1,192 @@ +import { mountMenu } from "./navigation.js"; +import { ui, get } from "../../../../noname.js"; +import { isDialogueLineVisible, applyDialogueChoice, applySkippedDialogueChoices } from "./dialogue-choice.js"; + +const TYPE_NAMES = { + character: "角色", + narrator: "旁白", + sound: "音效", + choice: "选择发言", +}; + + +const resolveCharacter = (character, run) => character == "$player" ? run?.player?.character : character; + +const resolveSpeaker = (line, character) => { + if (line.speaker == "$player") return character ? get.translation(character) : "你"; + if (line.speaker) return line.speaker; + if (line.type == "character" && character) return get.translation(character); + return TYPE_NAMES[line.type] || "旁白"; +}; + +export const splitDialogueText = text => Array.from(String(text || "")); + +export const playDialogue = (lines, options = {}) => new Promise(resolve => { + if (!Array.isArray(lines) || !lines.length) { + resolve(); + return; + } + + const overlay = ui.create.div(".mengsan-dialogue-overlay-shuying", ui.window); + const stage = ui.create.div(".mengsan-dialogue-stage-shuying", overlay); + ui.create.div(".mengsan-dialogue-chapter-shuying", options.title || "剧情", stage); + const portraitFrame = ui.create.div(".mengsan-dialogue-portrait-frame-shuying", stage); + const textBox = ui.create.div(".mengsan-dialogue-box-shuying", stage); + const speaker = ui.create.div(".mengsan-dialogue-speaker-shuying", textBox); + const text = ui.create.div(".mengsan-dialogue-text-shuying", textBox); + const progress = ui.create.div(".mengsan-dialogue-progress-shuying", textBox); + const indicator = ui.create.div(".mengsan-dialogue-indicator-shuying", "◆", textBox); + const choices = ui.create.div(".mengsan-dialogue-choices-shuying", stage); + const skipButton = document.createElement("button"); + skipButton.type = "button"; + skipButton.className = "mengsan-dialogue-skip-shuying"; + skipButton.textContent = "跳过对话"; + skipButton.setAttribute("aria-label", "跳过对话,未选择的发言使用默认选项"); + stage.appendChild(skipButton); + + let lineIndex = -1; + let timer = null; + let typing = false; + let paused = false; + let currentSpeed = 28; + let characters = []; + let characterIndex = 0; + let currentLine = null; + let awaitingChoice = false; + const portrait = ui.create.div(".mengsan-dialogue-portrait-shuying", portraitFrame); + const reduceMotion = globalThis.matchMedia?.("(prefers-reduced-motion: reduce)")?.matches; + + const stopTimer = () => { + if (timer != null) clearTimeout(timer); + timer = null; + }; + + const finishTyping = () => { + stopTimer(); + text.textContent = characters.join(""); + characterIndex = characters.length; + typing = false; + if (currentLine?.type === "choice" && awaitingChoice) showChoices(currentLine); + else indicator.classList.add("visible"); + }; + + function showChoices(line) { + choices.replaceChildren(); + for (const item of line.choices) { + const button = document.createElement("button"); + button.type = "button"; + button.className = "mengsan-dialogue-choice-shuying"; + button.textContent = item.text; + button.addEventListener("click", event => { + event.stopPropagation(); + if (!awaitingChoice) return; + text.textContent = applyDialogueChoice(options.run, line, item.id); + awaitingChoice = false; + choices.replaceChildren(); + speaker.textContent = get.translation(options.run.player.character); + stage.dataset.lineType = "character"; + const character = options.run.player.character; + portraitFrame.classList.add("visible"); + portrait.setBackground(character, "character"); + indicator.classList.add("visible"); + textBox.focus(); + }); + choices.appendChild(button); + } + choices.querySelector("button")?.focus(); + } + + const scheduleCharacter = speed => { + if (!typing || paused) return; + text.textContent += characters[characterIndex++]; + if (characterIndex >= characters.length) { + finishTyping(); + return; + } + timer = setTimeout(() => scheduleCharacter(speed), speed); + }; + + const close = () => { + stopTimer(); + skipButton.removeEventListener("click", handleSkip); + textBox.removeEventListener("click", handleClick); + textBox.removeEventListener("keydown", handleKeydown); + choices.replaceChildren(); + overlay.remove(); + resolve(); + }; + + const showNextLine = () => { + do { lineIndex++; } while (lineIndex < lines.length && !isDialogueLineVisible(lines[lineIndex], options.run)); + if (lineIndex >= lines.length) { + close(); + return; + } + + const line = lines[lineIndex]; + currentLine = line; + awaitingChoice = line.type === "choice"; + choices.replaceChildren(); + const lineType = TYPE_NAMES[line.type] ? line.type : "narrator"; + const character = resolveCharacter(line.character, options.run); + stage.dataset.lineType = lineType; + speaker.textContent = resolveSpeaker(line, character); + progress.textContent = `${lineIndex + 1} / ${lines.length}`; + indicator.classList.remove("visible"); + + if (lineType == "character" && character) { + portraitFrame.classList.add("visible"); + portrait.setBackground(character, "character"); + portrait.setAttribute("aria-label", `${speaker.textContent}立绘`); + } + else { + portraitFrame.classList.remove("visible"); + portrait.removeAttribute("aria-label"); + } + + characters = splitDialogueText(line.text); + characterIndex = 0; + text.textContent = ""; + typing = characters.length > 0; + const speed = Number.isFinite(line.speed) ? Math.max(0, line.speed) : 28; + currentSpeed = speed; + if (!typing || reduceMotion || speed == 0) finishTyping(); + else scheduleCharacter(speed); + }; + + function handleClick(event) { + event.preventDefault(); + event.stopPropagation(); + if (paused) return; + if (typing) finishTyping(); + else if (awaitingChoice) return; + else showNextLine(); + } + + function handleKeydown(event) { + if (event.key == "Enter" || event.key == " ") handleClick(event); + } + + function handleSkip(event) { + event.preventDefault(); + event.stopPropagation(); + if (paused) return; + applySkippedDialogueChoices(lines, lineIndex + (awaitingChoice ? 0 : 1), options.run); + close(); + } + + textBox.setAttribute("role", "button"); + textBox.setAttribute("tabindex", "0"); + textBox.setAttribute("aria-label", "剧情文本:点击补全文字或继续"); + textBox.addEventListener("click", handleClick); + textBox.addEventListener("keydown", handleKeydown); + skipButton.addEventListener("click", handleSkip); + mountMenu(overlay, { + onOpen() { paused = true; stopTimer(); }, + onClose() { paused = false; if (typing) scheduleCharacter(currentSpeed); }, + }); + const help = ui.create.div(".mengsan-dialogue-help-shuying", stage); + help.textContent = "点击补全文字 · 再次点击继续 · Enter / 空格"; + showNextLine(); + textBox.focus(); +}); diff --git a/mengsan/runtime/engine-session.js b/mengsan/runtime/engine-session.js new file mode 100644 index 0000000..5f62311 --- /dev/null +++ b/mengsan/runtime/engine-session.js @@ -0,0 +1,83 @@ +// Candidate only: attach to fresh, session-owned roots BEFORE scheduling them. +const attached = new WeakSet(); +export function observeFreshEvent(session, event) { + if (!event || typeof event.start !== "function" || !event.manager?.eventStack || + event.finished || event.manager.eventStack.includes(event) || + event.parent?.childEvents?.includes(event) || attached.has(event)) { + throw new Error("Fresh, unobserved engine event required"); + } + const descriptor = Object.getOwnPropertyDescriptor(event, "start"); + if (descriptor && !descriptor.configurable) throw new Error("Event start is not configurable"); + const original = event.start; + let resolve, reject; + const completion = new Promise((yes, no) => { resolve = yes; reject = no; }); + session.ownEvent(event, completion); + let observed = false; + function start(...args) { + let result; + try { result = Reflect.apply(original, this, args); } + catch (error) { reject(error); throw error; } + if (!observed) { + observed = true; + if (!(result instanceof Promise)) { + reject(new TypeError("Native engine start promise required")); + } else { + result.then(() => { + if (event.manager.eventStack.includes(event) || event.manager.tempEvent === event) { + reject(new Error("Event still active after start completion")); + } else resolve(); + }, reject); + } + } + return result; // Never assimilate event.then(), never proactively start a queue. + } + Object.defineProperty(event, "start", { configurable: true, writable: true, value: start }); + attached.add(event); + session.ownResource({}, () => { + if (event.start === start) { + if (descriptor) Object.defineProperty(event, "start", descriptor); + else delete event.start; + } + attached.delete(event); + }); + return completion; +} + +// Independent delay entry: releasing it never calls global resume/pause2/over.resolve. +export function acquirePauseLease(session, pauseManager) { + if (!session.active || typeof pauseManager?.setDelay !== "function") throw new Error("Active session and pause manager required"); + let unlock; + const gate = new Promise(resolve => { unlock = resolve; }); + const settled = pauseManager.setDelay(gate); + let released = false; + const lease = Object.freeze({ + release() { if (!released) { released = true; unlock(); } return settled; }, + get released() { return released; }, + }); + session.ownResource(lease, () => lease.release()); + return lease; +} + +// Call outside tracked event/task bodies. Own pauses must be lifted before drain. +export async function stopAndDrain(session, leases = []) { + session.requestStop(); + await Promise.all(leases.map(lease => lease.release())); + await session.drain(); +} + +// Only remove this exact node from the parent recorded at acquisition. +export function ownNode(session, node) { + const parent = node.parentNode; + if (!parent) throw new Error("Attached node required"); + return session.ownResource(node, () => { + if (node.parentNode === parent) parent.removeChild(node); + }); +} +export function ownListener(session, target, type, listener, options) { + const guarded = session.guard(listener); + const capture = typeof options === "boolean" ? options : !!options?.capture; + const token = {}; + session.ownResource(token, () => target.removeEventListener(type, guarded, capture)); + target.addEventListener(type, guarded, options); + return guarded; +} diff --git a/mengsan/runtime/flow-ui.js b/mengsan/runtime/flow-ui.js new file mode 100644 index 0000000..8b9d83e --- /dev/null +++ b/mengsan/runtime/flow-ui.js @@ -0,0 +1,63 @@ +import { ui } from "../../../../noname.js"; +import { mountMenu } from "./navigation.js"; + +export const chooseButtons = (title, choices, description = "", options = {}) => new Promise(resolve => { + const previousFocus = document.activeElement; + const overlay = ui.create.div(".mengsan-overlay-shuying.mengsan-flow-overlay-shuying", ui.window); + overlay.setAttribute("role", "dialog"); + overlay.setAttribute("aria-modal", "true"); + overlay.setAttribute("aria-label", title); + const panel = ui.create.div(".mengsan-panel-shuying.mengsan-flow-panel-shuying", overlay); + const eyebrow = document.createElement("p"); + eyebrow.className = "mengsan-flow-eyebrow-shuying"; + eyebrow.textContent = options.eyebrow || "梦三 · 征程"; + panel.appendChild(eyebrow); + const heading = ui.create.div(".mengsan-title-shuying", panel); + heading.textContent = title; + heading.setAttribute("role", "heading"); + heading.setAttribute("aria-level", "1"); + if (description) { + const summary = ui.create.div(".mengsan-description-shuying", panel); + summary.textContent = description; + } + const buttons = ui.create.div(".mengsan-choice-list-shuying", panel); + const finish = id => { + if (overlay.dataset.resolved) return; + overlay.dataset.resolved = "true"; + overlay.remove(); + if (previousFocus?.isConnected) previousFocus.focus(); + resolve(id); + }; + choices.forEach(choice => { + const button = document.createElement("button"); + button.type = "button"; + button.className = "mengsan-choice-shuying"; + button.dataset.choice = choice.id; + if (choice.danger) button.classList.add("danger"); + const name = document.createElement("span"); + name.className = "mengsan-choice-name-shuying"; + name.textContent = choice.name; + button.appendChild(name); + if (choice.description) { + const detail = document.createElement("span"); + detail.className = "mengsan-choice-description-shuying"; + detail.textContent = choice.description; + button.appendChild(detail); + } + button.disabled = Boolean(choice.disabled); + button.addEventListener("click", () => finish(choice.id)); + buttons.appendChild(button); + }); + mountMenu(overlay); + overlay.addEventListener("keydown", event => { + if (event.key === "Escape" && options.back) { + event.preventDefault(); finish(options.back); return; + } + if (event.key !== "Tab") return; + const focusable = [...overlay.querySelectorAll("button:not(:disabled)")]; + const first = focusable[0], last = focusable[focusable.length - 1]; + if (event.shiftKey && document.activeElement === first) { event.preventDefault(); last.focus(); } + else if (!event.shiftKey && document.activeElement === last) { event.preventDefault(); first.focus(); } + }); + buttons.querySelector("button:not(:disabled)")?.focus(); +}); diff --git a/mengsan/runtime/mode-storage.js b/mengsan/runtime/mode-storage.js new file mode 100644 index 0000000..437af15 --- /dev/null +++ b/mengsan/runtime/mode-storage.js @@ -0,0 +1,91 @@ +// Candidate factory. Storage writer body reused from batch2 MODIFIED_STORAGE.js. +export function createModeStorage({lib, game, config, localStorage, alert = () => {}, console = globalThis.console}) { +const MODE_ID = config.modeId; + + +const copy = value => JSON.parse(JSON.stringify(value)); + +// Keep one mode-level writer; a failed write must not poison later retries. +let modeStorageQueue = Promise.resolve(); + +const writeModeStorage = storage => { + if (!lib.db) { + // Do not switch backends after an IndexedDB error: that creates two saves. + localStorage.setItem(`${lib.configprefix}${MODE_ID}`, JSON.stringify(storage)); + return Promise.resolve(); + } + return new Promise((resolve, reject) => { + let transaction; + let requestError; + try { + // game.putDB resolves at request success, which is before commit. + transaction = lib.db.transaction(["data"], "readwrite"); + transaction.oncomplete = () => resolve(); + transaction.onerror = event => { + requestError = event.target?.error; + }; + transaction.onabort = () => reject(transaction.error || requestError || new Error("梦三存档事务已中止")); + transaction.objectStore("data").put(storage, MODE_ID); + } + catch (error) { + if (transaction) { + try { transaction.abort(); } + catch {} // Already inactive; keep the original failure. + } + reject(error); + } + }); +}; + +const persistModeStorage = (update = () => {}) => { + // Hold the engine reload guard from enqueue through commit and publication. + // reload2 also drains the engine's queued database operations on release. + lib.status.reload++; + const operation = modeStorageQueue.then(async () => { + const storage = copy(lib.storage); + update(storage); + storage.version = lib.version; + await writeModeStorage(storage); + lib.storage = storage; + }).finally(() => game.reload2()); + modeStorageQueue = operation.catch(() => {}); + return operation; +}; + +const saveRun = async run => { + try { + // Capture before enqueue: subsequent gameplay mutations cannot alter this save. + const snapshot = copy(run); + await persistModeStorage(storage => { + storage[config.saveKey] = snapshot; + }); + } + catch (error) { + // Neither delete the previous save nor attempt a second destructive write. + alert("梦三存档失败,未覆盖上一次成功保存的进度。当前进度尚未保存,请勿刷新,并查看控制台错误。"); + console.error("梦三存档失败:", error); + throw error; + } +}; + +const clearRun = async () => { + await persistModeStorage(storage => { + delete storage[config.saveKey]; + }); +}; + + + + return Object.freeze({ + read: () => copy(lib.storage), + saveRun, clearRun, + async update(mutator) { + let value; + await persistModeStorage(storage => { + value = mutator(storage); + if (value && typeof value.then === "function") throw new TypeError("Storage mutator must be synchronous"); + }); + return value === undefined ? undefined : copy(value); + }, + }); +} diff --git a/mengsan/runtime/monster.js b/mengsan/runtime/monster.js new file mode 100644 index 0000000..54d6d23 --- /dev/null +++ b/mengsan/runtime/monster.js @@ -0,0 +1,69 @@ +// 梦三怪物规则只作用于敌方单位;子类可覆盖构造参数或 createDeck()。 +const TIER_RULES = Object.freeze({ + normal: Object.freeze({ draw: 1, handLimit: 3, energy: 2 }), + elite: Object.freeze({ draw: 2, handLimit: 5, energy: 3 }), + boss: Object.freeze({ draw: 3, handLimit: 7, energy: 4 }), +}); + +const BASE_DECKS = Object.freeze({ + normal: Object.freeze(["sha", "sha", "sha", "sha", "shan", "shan", "tao", "jiu", "guohe", "sha"]), + elite: Object.freeze(["sha", "sha", "sha", "sha", "shan", "shan", "shan", "tao", "jiu", "juedou", "wuzhong", "guohe"]), + boss: Object.freeze(["sha", "sha", "sha", "sha", "sha", "shan", "shan", "shan", "tao", "tao", "jiu", "jiu", "juedou", "wuzhong", "guohe"]), +}); + +const HEALTH = Object.freeze({ + mengsan_soldier_shuying: 24, + 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, + re_simayi: 47, re_sunben: 49, dongzhuo: 55, re_yuanshao: 52, + shen_zhaoyun: 76, shen_simayi: 80, shen_caocao: 210, +}); +const validDeckCard = entry => typeof entry === "string" ? Boolean(entry) : Boolean(entry && + typeof entry.name === "string" && entry.name && + (entry.suit == null || ["spade", "heart", "club", "diamond"].includes(entry.suit)) && + (entry.number == null || Number.isInteger(entry.number) && entry.number >= 1 && entry.number <= 13)); + +export class Monster { + constructor({ character, tier = "normal", hp, deck, draw, handLimit, energy } = {}) { + if (!TIER_RULES[tier]) throw new Error("梦三怪物阶级无效:" + tier); + if (typeof character !== "string" || !character) throw new Error("梦三怪物缺少武将ID"); + const defaults = TIER_RULES[tier]; + this.character = character; + this.tier = tier; + this.hp = hp ?? HEALTH[character] ?? (tier === "boss" ? 110 : tier === "elite" ? 60 : 32); + this.draw = draw ?? defaults.draw; + this.handLimit = handLimit ?? defaults.handLimit; + this.energy = energy ?? defaults.energy; + this.deck = deck || BASE_DECKS[tier]; + if (![this.hp, this.draw, this.handLimit, this.energy].every(value => Number.isInteger(value) && value > 0)) throw new Error("梦三怪物数值无效"); + if (!Array.isArray(this.deck) || !this.deck.length || this.deck.some(entry => !validDeckCard(entry))) throw new Error("梦三怪物牌堆无效"); + } + + createDeck(prefix) { + return this.deck.map((entry, index) => { + const card = typeof entry === "string" ? { name: entry } : entry; + return { + 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, + }; + }); + } +} + +// 专属怪物示例:以后可在子类中覆写任意默认行为。 +export class OpeningSoldier extends Monster { + constructor(options = {}) { super({ character: "mengsan_soldier_shuying", tier: "normal", hp: 24, ...options }); } +} + +// 新怪物子类在这里注册,未注册的角色使用 Monster 的阶级默认值。 +export const MONSTER_TYPES = Object.freeze({ mengsan_soldier_shuying: OpeningSoldier }); + +export function createMonster(spec) { + const Type = Object.hasOwn(MONSTER_TYPES, spec.character) ? MONSTER_TYPES[spec.character] : Monster; + return new Type(spec); +} + +export { TIER_RULES, BASE_DECKS }; diff --git a/mengsan/runtime/navigation.js b/mengsan/runtime/navigation.js new file mode 100644 index 0000000..c423f48 --- /dev/null +++ b/mengsan/runtime/navigation.js @@ -0,0 +1,120 @@ +import { lib, game, ui } from "../../../../noname.js"; +import { save } from "../../../../noname/util/config.js"; + +let selectingMode = false; + +// Reuse the registered engine splash, not reload-to-the-current-mode or a fake mode list. +export const openModeSelection = async () => { + if (selectingMode) return; + const splash = lib.onloadSplashes.find(item => item.id === lib.config.splash_style) || lib.onloadSplashes[0]; + if (!splash) throw new Error("模式选择界面未加载,请稍后重试。"); + selectingMode = true; + const previousSplash = window.inSplash; + const previousFocus = document.activeElement; + const previousInert = ui.window.inert; + const node = ui.create.div("#splash", document.body); + node.style.zIndex = "1000"; + window.inSplash = true; + ui.window.inert = true; + try { + let choose; + const selected = new Promise(resolve => { choose = resolve; }); + await splash.init(node, choose); + const mode = await selected; + if (!lib.config.all.mode.includes(mode)) throw new Error("所选模式不可用。"); + await save("mode", "config", mode); + lib.config.mode = mode; + localStorage.setItem(`${lib.configprefix}directstart`, "true"); + // Reload only AFTER a mode is chosen; retain engine pending-write handling. + node.inert = true; + game.reload(); + // Keep the abandoned flow inert while the engine waits for pending DB writes. + await new Promise(() => {}); + } catch (error) { + // DefaultSplash owns a Vue app. Clean it up if opening/saving failed. + splash.app?.unmount?.(); + throw error; + } finally { + node.remove(); + window.inSplash = previousSplash; + ui.window.inert = previousInert; + selectingMode = false; + if (previousFocus?.isConnected) previousFocus.focus(); + } +}; + +export const askMenu = (title, description, choices) => new Promise(resolve => { + const previousFocus = document.activeElement; + const dialog = document.createElement("dialog"); + dialog.className = "mengsan-menu-dialog-shuying"; + dialog.setAttribute("aria-label", title); + const heading = document.createElement("h2"); + heading.textContent = title; + const body = document.createElement("p"); + body.textContent = description; + dialog.append(heading, body); + let closed = false; + const finish = value => { + if (closed) return; + closed = true; + dialog.close(); + dialog.remove(); + if (previousFocus?.isConnected) previousFocus.focus(); + resolve(value); + }; + for (const choice of choices) { + const button = document.createElement("button"); + button.type = "button"; + button.textContent = choice.name; + if (choice.danger) button.className = "danger"; + button.addEventListener("click", () => finish(choice.id)); + dialog.appendChild(button); + } + dialog.addEventListener("cancel", event => { event.preventDefault(); finish(null); }); + document.body.appendChild(dialog); + dialog.showModal(); + dialog.querySelector("button")?.focus(); +}); + +export const mountMenu = (overlay, options = {}) => { + const button = document.createElement("button"); + button.type = "button"; + button.className = "mengsan-flow-menu-shuying"; + const icon = document.createElementNS("http://www.w3.org/2000/svg", "svg"); + icon.setAttribute("viewBox", "0 0 24 24"); + icon.setAttribute("aria-hidden", "true"); + const path = document.createElementNS("http://www.w3.org/2000/svg", "path"); + path.setAttribute("d", "M4 6h16M4 12h16M4 18h16"); + icon.appendChild(path); + const label = document.createElement("span"); + label.textContent = "菜单"; + button.append(icon, label); + button.setAttribute("aria-haspopup", "dialog"); + let opened = false; + button.addEventListener("click", async event => { + event.stopPropagation(); + if (opened) return; + opened = true; + options.onOpen?.(); + try { + const action = await askMenu("征程菜单", "退出不删除存档。未完成的节点与对话将在下次进入时重新开始。", [ + { id: "resume", name: "继续当前界面" }, + { id: "exit", name: "返回模式选择" }, + ]); + if (action !== "exit") return; + const confirmed = await askMenu("返回模式选择?", "保留上次已完成节点的存档。本次尚未完成的内容不会提交。", [ + { id: "cancel", name: "留在梦三" }, + { id: "confirm", name: "确认返回模式选择", danger: true }, + ]); + if (confirmed === "confirm") await openModeSelection(); + } catch (error) { + console.error("梦三返回模式选择失败:", error); + await askMenu("暂时无法返回", "没有清除征程。请关闭提示后重试。", [{ id: "close", name: "回到当前界面" }]); + } finally { + opened = false; + options.onClose?.(); + } + }); + overlay.appendChild(button); + return button; +}; diff --git a/mengsan/runtime/personal-piles.js b/mengsan/runtime/personal-piles.js new file mode 100644 index 0000000..0035a14 --- /dev/null +++ b/mengsan/runtime/personal-piles.js @@ -0,0 +1,142 @@ +// Mode-owned piles use real Card nodes so native searches/gain/insert operations agree. +// DIY must access ui piles inside the acting player's engine event, never cache global nodes. +import { cardCost } from "./combat-rules.js"; + +export function installPersonalPiles(session, owner, battle, run, resources, env) { + const { game, ui, get, _status, document, MutationObserver, shuffle } = env; + for (const key of ["cardPile", "discardPile"]) { + const descriptor = Object.getOwnPropertyDescriptor(ui, key); + 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"); + // Detached nodes: engine get.position recognises these IDs; no duplicate document IDs. + draw.id = "cardPile"; discard.id = "discardPile"; + const owned = new Set(), restores = []; + const strict = env.strict !== false; + let released = false, forcedOwner = false; + const personal = () => { + if (released) return false; + if (forcedOwner) return true; + const seen = new Set(); + for (let event = _status.event; event && !seen.has(event); event = event.parent) { + seen.add(event); + if (event.player) return event.player === owner; + } + return false; // Never infer ownership from whose turn it is. + }; + const refresh = () => { if (!released) env.refresh(); }; + function adopt(card, data) { + if (owned.has(card)) return; + owned.add(card); resources.card(card); + card.storage ||= {}; + if (data) card.storage.mengsanCard_shuying = JSON.parse(JSON.stringify(data)); + if (data && env.showCosts) { + card.dataset.mengsanCost = String(cardCost(data)); + } + // Existing foreign/generated cards retain their original destruction semantics. + if (!data) return; + card.destroyLog = false; + card.destroyed = (current, position) => { + if (released || position !== "discardPile") return false; + if (data.affixes?.includes("annihilate") && current.storage.mengsanUsed_shuying) { + battle.exhaustPile.push(snapshot(current)); + return true; + } + return false; // Actual discarded cards remain available to native skills. + }; + if (data.affixes?.includes("annihilate")) card.addGaintag("湮灭"); + if (data.upgrade) card.addGaintag(`强化+${data.upgrade}`); + } + function snapshot(card) { + const data = card.storage?.mengsanCard_shuying || {}; + return { ...data, name: card.name, suit: card.suit, number: card.number, + nature: card.nature || null, affixes: data.affixes?.slice() || [], upgrade: data.upgrade || 0 }; + } + for (const data of battle.drawPile) { + const card = game.createCard(data.name, data.suit, data.number, data.nature); + adopt(card, data); draw.appendChild(card); + } + const publicDiscard = ui.discardPile; + for (const [key, node] of [["cardPile", draw], ["discardPile", discard]]) { + const descriptor = Object.getOwnPropertyDescriptor(ui, key); + let publicNode = descriptor.value; + const read = () => personal() ? node : (descriptor.get ? descriptor.get.call(ui) : publicNode); + Object.defineProperty(ui, key, { configurable: true, enumerable: descriptor.enumerable, + get: read, set(value) { if (descriptor.set) descriptor.set.call(ui, value); else publicNode = value; } }); + restores.push(() => { + if (Object.getOwnPropertyDescriptor(ui, key)?.get === read) Object.defineProperty(ui, key, descriptor.get ? descriptor : { ...descriptor, value: publicNode }); + }); + } + for (const [key, node] of [["drawPile", draw], ["discardPile", discard]]) { + Object.defineProperty(battle, key, { configurable: true, enumerable: true, + get: () => Array.from(node.childNodes, snapshot) }); + } + const observer = new MutationObserver(() => { + if (released) return; + // A card from our deck can be discarded during an enemy-owned event. + for (const card of Array.from(publicDiscard.childNodes)) if (owned.has(card)) discard.appendChild(card); + for (const node of [draw, discard]) for (const card of node.childNodes) adopt(card); + refresh(); + }); + for (const node of [draw, discard, publicDiscard]) observer.observe(node, { childList: true }); + function wash() { + const cards = [...draw.childNodes, ...discard.childNodes]; + if (!cards.length) return false; + shuffle(run, cards); + for (const card of cards) { card.fix?.(); draw.appendChild(card); } + refresh(); + return true; + } + function take(number = 1, putBack = false, bottom = false) { + if (!Number.isFinite(number) || number <= 0) return []; + number = Math.ceil(number); + if (draw.childNodes.length + discard.childNodes.length < number) { + if (strict) { game.mengsanFailRun_shuying("个人牌堆与弃牌堆不足,挑战失败"); return []; } + number = draw.childNodes.length + discard.childNodes.length; + } + const cards = []; + while (cards.length < number) { + if (!draw.childNodes.length) wash(); + const card = draw.removeChild(bottom ? draw.lastChild : draw.firstChild); card.original = "c"; cards.push(card); + } + if (putBack) for (let i = cards.length - 1; i >= 0; i--) { + if (bottom) draw.appendChild(cards[i]); else draw.insertBefore(cards[i], draw.firstChild); + } + refresh(); return cards; + } + function wrap(object, key, replacement) { + const descriptor = Object.getOwnPropertyDescriptor(object, key), original = object[key]; + const wrapped = function (...args) { return personal() ? replacement(...args) : original.apply(this, args); }; + object[key] = wrapped; + restores.push(() => { if (object[key] === wrapped) { if (descriptor) Object.defineProperty(object, key, descriptor); else delete object[key]; } }); + } + wrap(get, "cards", (number, putBack) => take(typeof number === "number" ? number : 1, putBack)); + wrap(get, "bottomCards", (number, putBack) => take(typeof number === "number" ? number : 1, putBack, true)); + wrap(game, "washCard", wash); + session.ownResource(draw, () => { + if (released) return; + observer.disconnect(); released = true; + for (const restore of restores.reverse()) restore(); + for (const card of owned) card.remove(); + owned.clear(); draw.remove(); discard.remove(); + }); + return { + take, + // Engine/mod opening draws must not stack with the mode's four-card opening hand. + resetOpeningHand() { + for (const card of owner.getCards("h")) { + if (owned.has(card)) draw.insertBefore(card, draw.firstChild); + else card.remove(); + } + refresh(); + }, + trimOpeningHand(limit = 4) { + for (const card of owner.getCards("h").slice(limit)) { + if (owned.has(card)) draw.insertBefore(card, draw.firstChild); + else card.remove(); + } + refresh(); + }, + withOwner(callback) { const before = forcedOwner; forcedOwner = true; try { return callback(); } finally { forcedOwner = before; } }, + }; +} diff --git a/mengsan/runtime/reward.js b/mengsan/runtime/reward.js new file mode 100644 index 0000000..442b129 --- /dev/null +++ b/mengsan/runtime/reward.js @@ -0,0 +1,12 @@ +import config from "../config.js"; +import { nextRandom } from "../state.js"; + +export const getRandomRewardChoices = (run, poolId, count = 3) => { + const fallbackId = "shared.pool.battle.normal"; + const pool = (config.rewardPools[poolId] || config.rewardPools[fallbackId]).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]]; + } + return pool.slice(0, count).map(id => ({ id, ...config.rewards[id] })); +}; diff --git a/mengsan/runtime/skill-teardown.js b/mengsan/runtime/skill-teardown.js new file mode 100644 index 0000000..9d08d5c --- /dev/null +++ b/mengsan/runtime/skill-teardown.js @@ -0,0 +1,147 @@ +// Candidate-only engine-derived release. Never replaces Player.prototype or lib.skill. +// Restricted: additional/fixed/superCharlotte skills require a separate audited adapter. +export async function removeBattleSkill(player, skill, {lib,game,get,_status}, wholePlayer = false) { + const names = Array.isArray(skill) ? skill.slice() : [skill]; + for (const name of names) { + if (lib.skill[name]?.fixed || lib.skill[name]?.superCharlotte) throw new Error("Unsupported protected skill: " + name); + } + if (!wholePlayer && Object.values(player.additionalSkills || {}).some(list => list?.length)) throw new Error("Additional skill release adapter required"); + const remove = async function removeSkill(skill) { + if (!skill) { + return; + } + _status.event.clearStepCache(); + if (Array.isArray(skill)) { + for (var i = 0; i < skill.length; i++) { + await removeSkill.call(this, skill[i]); + } + } else { + if (skill === "counttrigger") { + this.stat[this.stat.length - 1].triggerSkill = {}; + return; + } else { + var info = lib.skill[skill]; + if (info?.fixed && arguments[1] !== true) { + return skill; + } + this.unmarkSkill(skill); + game.broadcastAll( + function(player, skill2) { + player.skills.remove(skill2); + player.hiddenSkills.remove(skill2); + player.invisibleSkills.remove(skill2); + delete player.tempSkills[skill2]; + for (var i2 in player.additionalSkills) { + player.additionalSkills[i2].remove(skill2); + } + }, + this, + skill + ); + this.checkConflict(skill); + if (info) { + if (info.onremove) { + if (typeof info.onremove == "function") { + await info.onremove(this, skill); + } else if (typeof info.onremove == "string") { + if (info.onremove == "storage") { + delete this.storage[skill]; + } else { + var cards = this.storage[skill]; + if (get.itemtype(cards) == "card") { + cards = [cards]; + } + if (get.itemtype(cards) == "cards") { + if (this.onremove == "discard") { + this.$throw(cards); + } + if (this.onremove == "discard" || this.onremove == "lose") { + game.cardsDiscard(cards); + delete this.storage[skill]; + } + } + } + } else if (Array.isArray(info.onremove)) { + for (var i = 0; i < info.onremove.length; i++) { + delete this.storage[info.onremove[i]]; + } + } else if (info.onremove === true) { + delete this.storage[skill]; + } + } + this.removeSkillTrigger(skill); + if (!wholePlayer && !info.keepSkill) { + this.removeAdditionalSkills(skill); + } + } + this.enableSkill(skill + "_awake"); + game.callHook("removeSkillCheck", [skill, this]); + } + } + return skill; + }; + return remove.call(player, skill); +} + +// Must be called after gameplay root drained, outside all engine event bodies. +export async function runSkillTeardown(releases, {lib,_status}) { + if (_status.eventManager.eventStack.length || _status.eventManager.tempEvent) throw new Error("Gameplay must drain before skill release"); + if (!Array.isArray(releases) || releases.some(fn => typeof fn !== "function")) throw new TypeError("Explicit release callbacks required"); + const previous = _status.eventManager.rootEvent; + const root = new lib.element.GameEvent("mengsanSkillTeardown", false, _status.eventManager); + let releaseError; + root.setContent(async () => { + try { + for (const release of releases) { + await release(); + await root.waitNext(); + } + } catch (error) { + releaseError = error; + // Let the engine drain any queued work and pop this root normally. + } + }); + // Natural start completion includes next/after and native stack pop. Failure stays fail-closed. + await root.start(); + if (_status.eventManager.eventStack.includes(root)) throw new Error("Teardown event still active"); + if (_status.eventManager.rootEvent === root) _status.eventManager.rootEvent = previous; + if (releaseError) throw releaseError; +} + +// Whole-player disposal differs from removing one source during gameplay: every source is ending. +// Each callback runs at most once; failed/partially executed teardown is NOT replayed blindly. +export function createPlayerTeardown(players, env) { + const captured = players.slice(); + let operation; + const names = player => [...new Set([ + ...(player.skills || []), ...(player.hiddenSkills || []), ...(player.invisibleSkills || []), + ...Object.keys(player.tempSkills || {}), + ...Object.values(player.additionalSkills || {}).flatMap(list => typeof list === "string" ? [list] : list || []), + ])]; + return () => { + if (operation) return operation; + operation = (async () => { + // Preflight ALL players before modifying any skill. + for (const player of captured) for (const name of names(player)) { + if (env.lib.skill[name]?.fixed || env.lib.skill[name]?.superCharlotte) throw new Error("Protected skill requires adapter: " + name); + if (Object.values(player.additionalSkills || {}).some(value => !Array.isArray(value))) throw new Error("Unsupported additional skill source shape"); + } + await runSkillTeardown(captured.map(player => async () => { + const released = new Set(); + while (names(player).length) { + if (released.size >= 128) throw new Error("Skill teardown exceeded bounded expansion"); + const name = names(player)[0]; + if (released.has(name)) throw new Error("Released skill was re-added: " + name); + await removeBattleSkill(player, name, env, true); + released.add(name); + // Children can add skills. Drain them before taking the next inventory. + await env._status.event.waitNext(); + } + for (const [key,list] of Object.entries(player.additionalSkills || {})) { + if (Array.isArray(list) && !list.length) delete player.additionalSkills[key]; + } + }), env); + })(); + return operation; + }; +} diff --git a/mengsan/runtime/story.js b/mengsan/runtime/story.js new file mode 100644 index 0000000..6d0b563 --- /dev/null +++ b/mengsan/runtime/story.js @@ -0,0 +1,13 @@ +export const getAvailableStoryChoices = (run, content) => { + const flags = run.storyFlags || {}; + return (content?.choices || []).filter(choice => { + return (choice.requires || []).every(flag => Boolean(flags[flag])) + && (choice.excludes || []).every(flag => !flags[flag]); + }); +}; + +export const applyStoryOutcome = (run, outcome = {}) => { + if (!run.storyFlags) run.storyFlags = {}; + Object.assign(run.storyFlags, outcome.flags || {}); + return outcome; +}; diff --git a/mengsan/state.js b/mengsan/state.js index adae405..13199c1 100644 --- a/mengsan/state.js +++ b/mengsan/state.js @@ -1,3 +1,4 @@ +import { createStartingDeck } from "./content/card-library.js"; import config from "./config.js"; const hashText = text => { @@ -71,7 +72,24 @@ const getFloorRoutes = (run, act, floor, lastFloor) => { const target = Math.floor(nextRandom(run) * (index + 1)); [routes[index], routes[target]] = [routes[target], routes[index]]; } - return routes.slice(0, count).sort((left, right) => left - right); + const selectedRoutes = routes.slice(0, count); + const fixedRoutes = [...new Set((act.fixedNodes || []) + .filter(node => node.floor == floor) + .map(node => node.routeIndex) + .filter(routeIndex => Number.isInteger(routeIndex) && routeIndex >= 0 && routeIndex < routeCount))]; + fixedRoutes.forEach(routeIndex => { + if (selectedRoutes.includes(routeIndex)) return; + let replaceIndex = -1; + for (let index = selectedRoutes.length - 1; index >= 0; index--) { + if (!fixedRoutes.includes(selectedRoutes[index])) { + replaceIndex = index; + break; + } + } + if (replaceIndex >= 0) selectedRoutes[replaceIndex] = routeIndex; + else if (selectedRoutes.length < routeCount) selectedRoutes.push(routeIndex); + }); + return selectedRoutes.sort((left, right) => left - right); }; export const generateActMap = (run, actIndex) => { @@ -84,8 +102,14 @@ export const generateActMap = (run, actIndex) => { const floorNodes = []; const routes = getFloorRoutes(run, act, floor, lastFloor); routes.forEach((routeIndex, index) => { + const fixedNode = act.fixedNodes?.find(node => { + if (node.floor != floor) return false; + if (node.position == "start") return floor == 0; + return node.routeIndex == routeIndex; + }); let type; if (floor == lastFloor) type = "boss"; + else if (fixedNode?.type) type = fixedNode.type; else if (floor == 0) type = "battle"; else type = weightedType(run, act.nodeWeights); const id = `${act.id}_f${floor}_n${index}_${Math.floor(nextRandom(run) * 1e6)}`; @@ -93,6 +117,7 @@ export const generateActMap = (run, actIndex) => { id, floor, type, + contentId: fixedNode?.contentId || null, routeIndex, x: Math.round((routeIndex + 1) * 100 / (config.routeCount + 1)), y: Math.round(90 - floor * 80 / Math.max(1, lastFloor)), @@ -118,16 +143,6 @@ export const generateActMap = (run, actIndex) => { }; }; -const createDeck = () => config.startingDeck.map((entry, index) => ({ - id: `mengsan_card_${Date.now()}_${index}`, - suit: entry[0], - number: entry[1], - name: entry[2], - nature: entry[3] || null, - affixes: entry[4] ? entry[4].slice() : [], - upgrade: 0, -})); - export const createRun = character => { const seed = (Date.now() ^ Math.floor(Math.random() * 0x7fffffff)) >>> 0; const run = { @@ -145,7 +160,7 @@ export const createRun = character => { hp: null, maxHp: null, gold: 0, - deck: createDeck(), + deck: createStartingDeck(character), permanentSkills: [], temporarySkills: [], items: [], @@ -156,6 +171,7 @@ export const createRun = character => { defeatedEnemies: 0, goldEarned: 0, }, + storyFlags: {}, }; run.map = generateActMap(run, 0); return run; @@ -184,21 +200,23 @@ export const completeNode = (run, nodeId) => { export const insertStoryNode = (run, sourceId) => { const map = run.map; const source = map.nodes.find(node => node.id == sourceId); - const edgeIndex = map.edges.findIndex(edge => edge[0] == sourceId); - if (!source || edgeIndex < 0) return null; - const oldEdge = map.edges[edgeIndex]; - const target = map.nodes.find(node => node.id == oldEdge[1]); - if (!target) return null; + const outgoingEdges = map.edges.filter(edge => edge[0] == sourceId); + if (!source || !outgoingEdges.length) return null; + const targets = outgoingEdges.map(edge => map.nodes.find(node => node.id == edge[1])).filter(Boolean); + if (!targets.length) return null; + const averageTargetX = targets.reduce((sum, target) => sum + target.x, 0) / targets.length; + const averageTargetY = targets.reduce((sum, target) => sum + target.y, 0) / targets.length; const node = { id: `story_${hashText(`${run.runId}_${sourceId}_${run.revision}`)}`, floor: source.floor + 0.45, type: "story", - x: Math.max(8, Math.min(92, Math.round((source.x + target.x) / 2 + (nextRandom(run) - 0.5) * 16))), - y: Math.round((source.y + target.y) / 2), + x: Math.max(8, Math.min(92, Math.round((source.x + averageTargetX) / 2 + (nextRandom(run) - 0.5) * 16))), + y: Math.round((source.y + averageTargetY) / 2), completed: false, dynamic: true, }; - map.edges.splice(edgeIndex, 1, [source.id, node.id], [node.id, target.id]); + map.edges = map.edges.filter(edge => edge[0] != sourceId); + map.edges.push([source.id, node.id], ...targets.map(target => [node.id, target.id])); map.nodes.push(node); return node; }; @@ -214,14 +232,31 @@ export const enterNextAct = run => { return true; }; -export const getNodeEncounter = (run, node) => { +export const getNodeEncounter = (run, node, override = null) => { const act = config.acts[run.actIndex]; - let pool = act.enemies; + const content = node.contentId ? config.nodeContents?.[node.contentId] : null; + const encounter = override || content; + let pool = encounter?.enemies || act.enemies; if (node.type == "elite" || node.type == "story") pool = act.eliteEnemies?.length ? act.eliteEnemies : act.enemies; - if (node.type == "boss") return { enemy: act.boss, gold: act.baseGold * 3, boss: true }; + if (encounter?.enemies?.length) pool = encounter.enemies; + if (node.type == "boss") { + return { battlePlan: encounter?.battlePlan || act.bossBattlePlan || null, enemy: act.boss, tier: "boss", gold: act.baseGold * 3, boss: true, rewardPool: "shared.pool.boss.premium" }; + } return { + name: encounter?.name || "", + requiredCharacter: encounter?.requiredCharacter || null, + openingDialogue: encounter?.openingDialogue || [], + fixedRewards: encounter?.fixedRewards || [], + skipRandomReward: encounter?.skipRandomReward === true, + victoryDialogue: encounter?.victoryDialogue || [], + battlePlan: encounter?.battlePlan || null, + tier: encounter?.tier || (node.type === "elite" ? "elite" : "normal"), enemy: randomGet(run, pool.filter(Boolean)), - gold: node.type == "elite" || node.type == "story" ? act.baseGold * 2 : act.baseGold, + gold: encounter?.gold ?? (node.type == "elite" || node.type == "story" ? act.baseGold * 2 : act.baseGold), boss: false, + contentId: node.contentId || null, + description: encounter?.description || "", + rewardPool: encounter?.rewardPool || "shared.pool.battle.normal", + rewardTitle: encounter?.rewardTitle || "战斗奖励(三选一)", }; }; diff --git a/mengsan/style.css b/mengsan/style.css index 9f44f02..7f7126e 100644 --- a/mengsan/style.css +++ b/mengsan/style.css @@ -49,6 +49,7 @@ .mengsan-panel-shuying > .mengsan-description-shuying, .mengsan-panel-shuying > .mengsan-choice-list-shuying, .mengsan-panel-shuying > .mengsan-map-status-shuying, +.mengsan-panel-shuying > .mengsan-map-viewport-shuying, .mengsan-panel-shuying > .mengsan-map-shuying, .mengsan-choice-list-shuying > .mengsan-choice-shuying, .mengsan-choice-shuying > .mengsan-choice-name-shuying, @@ -105,19 +106,28 @@ border: 1px solid rgba(242, 210, 142, 0.34); border-radius: 9px; background: rgba(255, 255, 255, 0.055); + color: inherit; + font: inherit; + text-align: left; + appearance: none; cursor: pointer; - transition: transform 0.16s ease, border-color 0.16s ease, background 0.16s ease; + touch-action: manipulation; + transition: transform 0.16s ease, border-color 0.16s ease, background 0.16s ease, box-shadow 0.16s ease; } .mengsan-choice-shuying:hover { transform: translateY(-2px); border-color: rgba(242, 210, 142, 0.8); background: rgba(242, 210, 142, 0.12); + box-shadow: 0 8px 20px rgba(0, 0, 0, .22); } +.mengsan-choice-shuying:active { transform: translateY(0); background: rgba(242, 210, 142, 0.18); } +.mengsan-choice-shuying:focus-visible { outline: 3px solid #f2d28e; outline-offset: 3px; } + .mengsan-choice-shuying.disabled { - cursor: default; - opacity: 0.35; + cursor: not-allowed; + opacity: 0.45; filter: grayscale(1); } @@ -138,121 +148,1009 @@ font-size: 13px; } +/* 行军图: 宣纸 #ded4b9 / 墨 #303b37 / 朱砂 #963c30 / 苔 #627365 / 绢 #b7a887 */ +.mengsan-map-overlay-shuying { + padding: 0 !important; + background: radial-gradient(ellipse at 50% 30%, #627365, #303b37 76%); + color: #ded4b9; +} .mengsan-map-panel-shuying { position: absolute !important; inset: 0 !important; width: 100% !important; height: 100% !important; - min-width: 100%; - min-height: 100%; max-width: none !important; max-height: none !important; - padding: clamp(12px, 2vw, 24px); + padding: 18px 200px 0 28px; border: 0; border-radius: 0; background: transparent; box-shadow: none; overflow: hidden; } - -.mengsan-map-overlay-shuying { - padding: 0 !important; - background: - radial-gradient(circle at 50% 18%, rgba(67, 59, 82, 0.82), rgba(9, 10, 16, 0.97)), - repeating-linear-gradient(12deg, rgba(255, 255, 255, 0.018) 0 2px, transparent 2px 12px); -} - .mengsan-map-panel-shuying > .mengsan-title-shuying { - margin-bottom: 6px; - font-size: clamp(24px, 3vw, 36px); + font: 30px/1.25 "STKaiti", "KaiTi", "SimSun", serif; + color: #ded4b9; + margin: 0 0 6px; + text-shadow: none; + letter-spacing: .12em; } - .mengsan-map-panel-shuying > .mengsan-map-status-shuying { - margin-bottom: 10px; + color: #ded4b9; + font-size: 13px; + margin: 0 0 14px; +} +.mengsan-map-viewport-shuying { + flex: 1 1 auto !important; + width: 100% !important; + min-height: 0; + overflow-x: hidden; + overflow-y: auto; + overscroll-behavior: contain; + -webkit-overflow-scrolling: touch; + touch-action: none; + cursor: grab; + user-select: none; + scrollbar-width: none; + scrollbar-color: #b7a887 transparent; } - .mengsan-map-shuying { position: relative; display: block !important; - flex: 1 1 auto !important; - width: 100%; - height: auto !important; - min-height: 0; + width: min(100%, 860px); + height: max(1080px, 160vh) !important; + margin: 0 auto; overflow: hidden; - border-radius: 0; - background: transparent; + isolation: isolate; + border-inline: 5px double #b7a887; + background: + repeating-linear-gradient(93deg, #303b3705 0 1px, transparent 1px 7px), + radial-gradient(ellipse at 0 50%, #b7a88799, transparent 50%), + radial-gradient(ellipse at 100% 10%, #b7a88799, transparent 48%), #ded4b9; + box-shadow: 0 0 30px #141d1a66, inset 0 0 50px #b7a887; +} +.mengsan-ink-landscape-shuying { + position: absolute; inset: 0; width: 100%; height: 100%; pointer-events: none; + fill: #627365; stroke: #303b37; stroke-width: 2; opacity: .09; +} +.mengsan-lines-shuying { + position: absolute; inset: 0; width: 100%; height: 100%; pointer-events: none; +} +.mengsan-lines-shuying path { + fill: none; vector-effect: non-scaling-stroke; + stroke: #303b3760; stroke-width: 2; stroke-dasharray: 3 8; stroke-linecap: round; +} +.mengsan-lines-shuying path.dynamic { stroke-dasharray: 2 5; } +.mengsan-lines-shuying path.completed { stroke: #303b37; stroke-dasharray: none; stroke-width: 2.5; } +.mengsan-lines-shuying path.available { stroke: #963c30; stroke-width: 2.5; } +.mengsan-map-shuying .mengsan-node-shuying { + position: absolute; transform: translate(-50%, -50%); + width: 52px; height: 52px; padding: 7px; margin: 0; + display: flex; align-items: center; justify-content: center; + border: 2px solid transparent; border-radius: 45% 51% 43% 49%; + background: #ded4b9; color: #303b37; + box-shadow: none; text-shadow: none; appearance: none; + font: 13px/1.3 "Microsoft YaHei", sans-serif; + cursor: default; touch-action: manipulation; -webkit-tap-highlight-color: transparent; +} +.mengsan-ink-icon-shuying { + display: block; width: 32px; height: 32px; flex: none; + fill: none; stroke: currentColor; stroke-width: 2; stroke-linecap: round; stroke-linejoin: round; +} +.mengsan-node-shuying > span { + position: absolute; top: calc(100% + 3px); left: 50%; transform: translateX(-50%); + width: max-content; max-width: 100px; padding: 1px 4px; + color: #303b37; background: #ded4b9e8; pointer-events: none; + opacity: 0; +} +.mengsan-node-shuying.selectable { border-color: #963c30; color: #963c30; cursor: pointer; } +.mengsan-node-shuying.locked { color: #303b3799; } +.mengsan-node-shuying.completed { color: #627365; } +.mengsan-node-shuying.current::before { + content: "驻"; position: absolute; bottom: calc(100% + 5px); left: 50%; transform: translateX(-50%); + background: #963c30; color: #ded4b9; padding: 2px 5px; font-family: "KaiTi", serif; +} +.mengsan-node-shuying[data-type="boss"] { width: 66px; height: 66px; } +.mengsan-node-shuying[data-type="boss"] .mengsan-ink-icon-shuying { width: 42px; height: 42px; } +.mengsan-node-shuying.selectable > span, +.mengsan-node-shuying.current > span, +.mengsan-node-shuying:hover > span, +.mengsan-node-shuying:focus-visible > span { opacity: 1; } +.mengsan-map-shuying .mengsan-node-shuying:focus-visible { outline: 3px solid #963c30; outline-offset: 4px; } +.mengsan-map-shuying .mengsan-node-shuying.selectable:hover { background: #b7a887; } +.mengsan-ink-legend-shuying { + position: absolute; right: 26px; top: 24%; width: 150px; padding: 16px; + color: #303b37; background: #ded4b9; border-block: 4px double #b7a887; + box-shadow: 3px 6px 18px #141d1a33; z-index: 3; +} +.mengsan-ink-legend-shuying summary { cursor: pointer; font: 20px "KaiTi", "SimSun", serif; } +.mengsan-ink-legend-shuying > div { position: relative; display: flex; align-items: center; gap: 12px; margin-top: 12px; font-size: 13px; } +.mengsan-ink-legend-shuying .mengsan-ink-icon-shuying { width: 24px; height: 24px; } +.mengsan-ink-legend-shuying p { margin: 16px 0 0; font-size: 11px; line-height: 1.7; } +@media (max-width: 899px) { + .mengsan-map-panel-shuying > .mengsan-title-shuying { font-size: 20px; padding-right: 108px; text-align: left; letter-spacing: 0; } + .mengsan-map-panel-shuying > .mengsan-map-status-shuying { font-size: 12px; margin-bottom: 10px; } + .mengsan-ink-legend-shuying { right: 8px; top: 12px; width: auto; padding: 6px 10px; } + .mengsan-ink-legend-shuying[open] { width: 148px; max-height: calc(100% - 24px); overflow-y: auto; } + .mengsan-ink-legend-shuying summary { font-size: 16px; } + .mengsan-map-shuying .mengsan-node-shuying { width: 46px; height: 46px; padding: 6px; } + .mengsan-map-shuying .mengsan-node-shuying[data-type="boss"] { width: 58px; height: 58px; } } -.mengsan-lines-shuying { - position: absolute; - inset: 0; - width: 100%; - height: 100%; - overflow: visible; + +.mengsan-dialogue-overlay-shuying { + position: fixed !important; + inset: 0 !important; + z-index: 90; + overflow: hidden; + color: #f8eedb; + font-family: "Microsoft YaHei", sans-serif; + background: + linear-gradient(180deg, rgba(5, 7, 11, 0.22) 0%, rgba(5, 7, 11, 0.08) 46%, rgba(5, 7, 11, 0.64) 100%), + radial-gradient(circle at 28% 42%, rgba(112, 91, 63, 0.2), rgba(8, 10, 15, 0.68) 72%); +} + +.mengsan-dialogue-overlay-shuying *, +.mengsan-dialogue-overlay-shuying *::before, +.mengsan-dialogue-overlay-shuying *::after { + box-sizing: border-box; +} + +.mengsan-dialogue-stage-shuying { + position: absolute !important; + inset: 0 !important; + width: 100% !important; + height: 100% !important; +} + +.mengsan-dialogue-chapter-shuying { + position: absolute !important; + top: clamp(14px, 3vh, 30px) !important; + right: clamp(18px, 4vw, 54px) !important; + left: auto !important; + width: auto !important; + height: auto !important; + color: rgba(245, 221, 174, 0.66); + font-size: clamp(12px, 1.5vw, 16px); + letter-spacing: 0.22em; + text-shadow: 0 2px 5px #000; +} + +.mengsan-dialogue-portrait-frame-shuying { + position: absolute !important; + left: clamp(5vw, 10vw, 150px) !important; + bottom: clamp(150px, 27vh, 238px) !important; + top: auto !important; + width: 144px !important; + height: 202px !important; + padding: 5px; + border: 2px solid #a97a2c; + border-radius: 7px; + background: linear-gradient(145deg, #d6ad58, #6c4317 34%, #d9b761 66%, #583410); + opacity: 0; + transform: translateX(-18px) scale(0.98); + transition: opacity 0.22s ease, transform 0.22s ease; + box-shadow: 0 14px 38px rgba(0, 0, 0, 0.58), inset 0 0 0 1px rgba(255, 230, 164, 0.48); pointer-events: none; } -.mengsan-lines-shuying line { - vector-effect: non-scaling-stroke; - stroke: rgba(232, 219, 190, 0.68); - stroke-width: 2; - stroke-linecap: round; - stroke-dasharray: 6 5; -} - -.mengsan-lines-shuying line.completed { - stroke: rgba(243, 207, 128, 0.95); -} - -.mengsan-lines-shuying line.dynamic { - stroke: rgba(118, 205, 255, 0.95); - stroke-dasharray: 3 4; -} - -.mengsan-node-shuying { +.mengsan-dialogue-portrait-frame-shuying::before, +.mengsan-dialogue-portrait-frame-shuying::after { + content: ""; position: absolute; - width: 66px; - height: 40px; - transform: translate(-50%, -50%); - display: flex; - align-items: center; - justify-content: center; - box-sizing: border-box; - border: 2px solid var(--node-color); - border-radius: 50%; - background: radial-gradient(circle at 38% 28%, rgba(255, 255, 255, 0.2), transparent 42%), var(--node-color); - box-shadow: 0 5px 15px rgba(0, 0, 0, 0.42); - color: #fff4df; - font-size: 12px; - text-align: center; + z-index: 2; + width: 22px; + height: 22px; + border-color: #efca70; + pointer-events: none; +} + +.mengsan-dialogue-portrait-frame-shuying::before { + left: -5px; + top: -5px; + border-left: 3px solid #efca70; + border-top: 3px solid #efca70; + border-radius: 7px 0 0; +} + +.mengsan-dialogue-portrait-frame-shuying::after { + right: -5px; + bottom: -5px; + border-right: 3px solid #efca70; + border-bottom: 3px solid #efca70; + border-radius: 0 0 7px; +} + +.mengsan-dialogue-portrait-frame-shuying.visible { + opacity: 1; + transform: translateX(0) scale(1); +} + +.mengsan-dialogue-portrait-shuying { + position: relative !important; + inset: auto !important; + width: 100% !important; + height: 100% !important; + overflow: hidden; + border: 1px solid rgba(45, 25, 9, 0.9); + border-radius: 3px; + background-color: #21160f; + background-position: center 18% !important; + background-repeat: no-repeat !important; + background-size: cover !important; + box-shadow: inset 0 0 18px rgba(0, 0, 0, 0.28); + pointer-events: none !important; +} + +.mengsan-dialogue-box-shuying { + position: absolute !important; + left: 50% !important; + right: auto !important; + bottom: clamp(18px, 4vh, 42px) !important; + top: auto !important; + transform: translateX(-50%) !important; + width: min(1120px, 92vw) !important; + height: auto !important; + min-height: clamp(120px, 20vh, 180px); + padding: clamp(32px, 5vh, 46px) clamp(24px, 4vw, 52px) 28px; + border: 1px solid rgba(231, 205, 154, 0.45); + border-top-color: rgba(247, 219, 159, 0.85); + border-radius: 4px 4px 12px 12px; + background: linear-gradient(105deg, rgba(13, 16, 23, 0.91), rgba(23, 24, 31, 0.76)); + box-shadow: 0 18px 60px rgba(0, 0, 0, 0.56), inset 0 1px rgba(255, 255, 255, 0.06); + backdrop-filter: blur(7px); + -webkit-backdrop-filter: blur(7px); + cursor: pointer; user-select: none; } -.mengsan-node-shuying.selectable { - cursor: pointer; - animation: mengsan-node-pulse-shuying 1.6s infinite; +.mengsan-dialogue-speaker-shuying { + position: absolute !important; + left: clamp(20px, 4vw, 50px) !important; + top: -22px !important; + width: auto !important; + height: 44px !important; + min-width: 128px; + padding: 9px 24px; + border: 1px solid rgba(235, 205, 144, 0.65); + border-radius: 4px; + background: rgba(37, 30, 28, 0.94); + color: #f3d69d; + font-size: 18px; + font-weight: 700; + line-height: 24px; + text-align: center; + box-shadow: 0 8px 22px rgba(0, 0, 0, 0.38); } -.mengsan-node-shuying.locked { - opacity: 0.45; - filter: grayscale(0.65); +.mengsan-dialogue-text-shuying { + position: relative !important; + inset: auto !important; + width: 100% !important; + height: auto !important; + min-height: 3.4em; + color: rgba(255, 249, 238, 0.95); + font-size: clamp(17px, 2.1vw, 23px); + line-height: 1.7; + text-align: left; + white-space: pre-wrap; + overflow-wrap: anywhere; + text-shadow: 0 2px 4px rgba(0, 0, 0, 0.8); } -.mengsan-node-shuying.completed { - opacity: 0.72; - box-shadow: 0 0 0 3px rgba(244, 211, 139, 0.3); +.mengsan-dialogue-stage-shuying[data-line-type="narrator"] .mengsan-dialogue-speaker-shuying, +.mengsan-dialogue-stage-shuying[data-line-type="sound"] .mengsan-dialogue-speaker-shuying { + color: #d7dce7; + border-color: rgba(199, 208, 225, 0.42); + background: rgba(24, 29, 39, 0.94); } -@keyframes mengsan-node-pulse-shuying { - 0%, 100% { box-shadow: 0 0 0 0 rgba(242, 207, 132, 0.18), 0 5px 15px rgba(0, 0, 0, 0.42); } - 50% { box-shadow: 0 0 0 8px rgba(242, 207, 132, 0), 0 5px 18px rgba(0, 0, 0, 0.55); } +.mengsan-dialogue-stage-shuying[data-line-type="sound"] .mengsan-dialogue-text-shuying { + color: rgba(208, 218, 233, 0.9); + font-style: italic; + letter-spacing: 0.08em; +} + +.mengsan-dialogue-progress-shuying { + position: absolute !important; + right: 26px !important; + top: 13px !important; + width: auto !important; + height: auto !important; + color: rgba(255, 255, 255, 0.34); + font-size: 11px; +} + +.mengsan-dialogue-indicator-shuying { + position: absolute !important; + right: 24px !important; + bottom: 14px !important; + width: auto !important; + height: auto !important; + color: #e9c77f; + font-size: 12px; + opacity: 0; + transform: translateY(-3px); +} + +.mengsan-dialogue-indicator-shuying.visible { + opacity: 0.9; + animation: mengsan-dialogue-indicator-shuying 1s ease-in-out infinite; +} + +@keyframes mengsan-dialogue-indicator-shuying { + 50% { transform: translateY(3px); } } @media (max-width: 600px) { .mengsan-overlay-shuying { padding: 7px; } .mengsan-panel-shuying { padding: 14px; } .mengsan-title-shuying { font-size: 24px; } - .mengsan-map-panel-shuying { padding: 8px; } - .mengsan-map-shuying { min-height: 0; } - .mengsan-node-shuying { width: 54px; height: 36px; font-size: 11px; } + .mengsan-dialogue-portrait-frame-shuying { + left: 5vw !important; + bottom: clamp(142px, 31vh, 206px) !important; + width: 104px !important; + height: 146px !important; + padding: 4px; + } + .mengsan-dialogue-box-shuying { + bottom: 8px !important; + width: 96vw !important; + min-height: 126px; + padding: 30px 18px 24px; + } + .mengsan-dialogue-speaker-shuying { + left: 14px !important; + min-width: 100px; + height: 38px !important; + padding: 6px 16px; + font-size: 15px; + } + .mengsan-dialogue-text-shuying { font-size: clamp(15px, 4vw, 18px); } + .mengsan-dialogue-chapter-shuying { display: none !important; } +} + +@media (prefers-reduced-motion: reduce) { + .mengsan-dialogue-portrait-frame-shuying, + .mengsan-dialogue-indicator-shuying { + transition: none; + animation: none !important; + } +} + +.mengsan-map-viewport-shuying::-webkit-scrollbar { display: none; width: 0; height: 0; } +.mengsan-map-viewport-shuying.dragging, .mengsan-map-viewport-shuying.dragging * { cursor: grabbing !important; } +.mengsan-map-shuying .mengsan-node-shuying { touch-action: none; } +/* 梦三行军图视觉系统:深墨舞台、宣纸路线、朱砂交互、铜金层级 */ +.mengsan-map-overlay-shuying { + --ms-ink-950: #111713; + --ms-ink-900: #19211c; + --ms-ink-800: #27332c; + --ms-paper-100: #eee5cc; + --ms-paper-200: #ded4b9; + --ms-paper-400: #b9aa86; + --ms-cinnabar-500: #a94438; + --ms-cinnabar-600: #8f342c; + --ms-gold-400: #d2af65; + --ms-gold-500: #aa7d38; + --ms-text-primary: #fff7e5; + --ms-text-secondary: #c9c3b1; + --ms-focus: #f0c86f; + --ms-motion-fast: 140ms; + --ms-motion-base: 220ms; + background: + radial-gradient(circle at 50% -15%, rgba(182, 147, 80, .20), transparent 42%), + radial-gradient(circle at 18% 75%, rgba(87, 111, 91, .20), transparent 36%), + linear-gradient(145deg, var(--ms-ink-800), var(--ms-ink-950) 72%); +} + +.mengsan-map-panel-shuying { + padding: 0 !important; + background: + linear-gradient(90deg, rgba(255,255,255,.018) 1px, transparent 1px), + linear-gradient(rgba(255,255,255,.012) 1px, transparent 1px); + background-size: 32px 32px; +} + +.mengsan-map-header-shuying { + position: relative !important; + z-index: 8; + display: grid !important; + grid-template-columns: minmax(140px, 1fr) auto minmax(260px, 1fr); + align-items: center; + flex: none !important; + width: 100% !important; + min-height: 92px; + padding: 16px max(24px, env(safe-area-inset-right)) 16px max(24px, env(safe-area-inset-left)); + border-bottom: 1px solid rgba(210, 175, 101, .24); + background: linear-gradient(180deg, rgba(17, 23, 19, .96), rgba(17, 23, 19, .84)); + box-shadow: 0 12px 32px rgba(0, 0, 0, .22); + backdrop-filter: blur(10px); + -webkit-backdrop-filter: blur(10px); +} + +.mengsan-map-title-group-shuying { + position: relative !important; + grid-column: 2; + min-width: min(390px, 42vw); + text-align: center; +} + +.mengsan-map-eyebrow-shuying { + position: relative !important; + margin-bottom: 3px; + color: var(--ms-gold-400); + font: 600 10px/1.2 "Microsoft YaHei", sans-serif; + letter-spacing: .28em !important; + opacity: .78; +} + +.mengsan-map-title-group-shuying > .mengsan-title-shuying { + position: relative !important; + width: auto !important; + margin: 0 !important; + padding: 0 !important; + color: var(--ms-text-primary); + font: 700 clamp(22px, 2.4vw, 30px)/1.25 "STKaiti", "KaiTi", "SimSun", serif; + letter-spacing: .12em !important; + text-shadow: 0 2px 14px rgba(0, 0, 0, .55); +} + +.mengsan-map-menu-trigger-shuying { + position: relative; + grid-column: 1; + justify-self: start; + display: inline-flex; + align-items: center; + justify-content: center; + gap: 9px; + min-width: 112px; + min-height: 48px; + padding: 0 18px; + border: 1px solid rgba(210, 175, 101, .48); + border-radius: 6px; + background: linear-gradient(180deg, rgba(238, 229, 204, .10), rgba(238, 229, 204, .04)); + color: var(--ms-text-primary); + font: 600 16px/1 "Microsoft YaHei", sans-serif; + cursor: pointer; + touch-action: manipulation; + transition: background var(--ms-motion-fast), border-color var(--ms-motion-fast), box-shadow var(--ms-motion-fast); +} + +.mengsan-map-menu-trigger-shuying:hover, +.mengsan-map-menu-trigger-shuying[aria-expanded="true"] { + border-color: var(--ms-gold-400); + background: rgba(210, 175, 101, .16); + box-shadow: 0 8px 24px rgba(0, 0, 0, .22); +} + +.mengsan-control-icon-shuying { + display: block; + flex: none; + width: 22px; + height: 22px; + fill: none; + stroke: currentColor; + stroke-width: 2; + stroke-linecap: round; + stroke-linejoin: round; +} + +.mengsan-map-header-shuying > .mengsan-map-status-shuying { + position: relative !important; + grid-column: 3; + justify-self: end; + display: flex !important; + align-items: stretch; + justify-content: flex-end; + gap: 8px; + width: auto !important; + margin: 0 !important; + color: var(--ms-text-primary); +} + +.mengsan-map-stat-shuying { + position: relative !important; + display: flex !important; + flex-direction: column; + justify-content: center; + min-width: 72px; + min-height: 48px; + padding: 6px 12px; + border: 1px solid rgba(255,255,255,.09); + border-radius: 6px; + background: rgba(255,255,255,.045); + text-align: left; +} +.mengsan-map-stat-label-shuying { position: relative !important; color: var(--ms-text-secondary); font-size: 11px; line-height: 1.2; } +.mengsan-map-stat-value-shuying { position: relative !important; margin-top: 3px; color: var(--ms-text-primary); font: 700 15px/1.2 ui-monospace, "Microsoft YaHei", sans-serif; font-variant-numeric: tabular-nums; } + +.mengsan-map-panel-shuying > .mengsan-ink-legend-shuying { top: 116px; } +.mengsan-map-viewport-shuying { + position: relative !important; + flex: 1 1 auto !important; + width: 100% !important; + min-height: 0; + padding: 24px 196px 32px 24px; + scroll-padding-block: 24px; +} +.mengsan-map-shuying { + width: min(100%, 900px); + border: 1px solid rgba(185, 170, 134, .78); + border-radius: 4px; + background: + linear-gradient(90deg, transparent 49.8%, rgba(70, 65, 50, .07) 50%, transparent 50.2%), + repeating-linear-gradient(96deg, rgba(48, 59, 55, .025) 0 1px, transparent 1px 8px), + radial-gradient(ellipse at 8% 52%, rgba(98, 115, 101, .20), transparent 48%), + radial-gradient(ellipse at 92% 12%, rgba(150, 60, 48, .10), transparent 38%), + var(--ms-paper-200); + box-shadow: 0 24px 55px rgba(0,0,0,.30), inset 0 0 72px rgba(91, 75, 47, .18); +} +.mengsan-ink-landscape-shuying { opacity: .12; } +.mengsan-lines-shuying path { stroke: rgba(48, 59, 55, .36); stroke-width: 2.2; } +.mengsan-lines-shuying path.completed { stroke: #516456; stroke-width: 3; } +.mengsan-lines-shuying path.available { stroke: var(--ms-cinnabar-500); stroke-width: 3.2; filter: drop-shadow(0 0 3px rgba(169, 68, 56, .28)); } + +.mengsan-map-shuying .mengsan-node-shuying { + width: 58px; + height: 58px; + padding: 10px; + border: 1px solid var(--node-color); + border-radius: 50%; + background: linear-gradient(145deg, #f3ead3, #d4c7a7); + color: var(--node-color); + box-shadow: 0 5px 14px rgba(49, 44, 31, .18), inset 0 0 0 3px rgba(255,255,255,.34); + transition: color var(--ms-motion-fast), background var(--ms-motion-fast), border-color var(--ms-motion-fast), box-shadow var(--ms-motion-fast), opacity var(--ms-motion-fast); + touch-action: manipulation; +} +.mengsan-map-shuying .mengsan-node-shuying > span { + top: calc(100% + 7px); + min-width: max-content; + padding: 3px 7px; + border: 1px solid rgba(48, 59, 55, .16); + border-radius: 3px; + background: rgba(238, 229, 204, .94); + color: #29332d; + font-size: 12px; + font-weight: 600; + box-shadow: 0 3px 8px rgba(49, 44, 31, .12); +} +.mengsan-node-shuying.selectable { + border: 3px solid var(--ms-cinnabar-500) !important; + color: var(--ms-cinnabar-600) !important; + box-shadow: 0 0 0 5px rgba(169, 68, 56, .13), 0 8px 20px rgba(76, 35, 27, .24) !important; +} +.mengsan-node-shuying.selectable > span, +.mengsan-node-shuying.current > span { opacity: 1; } +.mengsan-node-shuying.locked { opacity: .56; filter: saturate(.45); } +.mengsan-node-shuying.completed { color: #52675a; background: linear-gradient(145deg, #d8decf, #b7c1ad); } +.mengsan-node-shuying.current::before { + min-width: 34px; + padding: 4px 7px; + border-radius: 3px; + background: var(--ms-cinnabar-600); + color: #fff8e7; + box-shadow: 0 4px 10px rgba(81, 29, 24, .28); +} +.mengsan-map-shuying .mengsan-node-shuying.selectable:hover { + background: #fff4d9; + box-shadow: 0 0 0 7px rgba(169, 68, 56, .16), 0 10px 24px rgba(76, 35, 27, .28) !important; +} +.mengsan-map-shuying .mengsan-node-shuying:active { filter: brightness(.92); } +.mengsan-map-shuying .mengsan-node-shuying:focus-visible, +.mengsan-map-menu-trigger-shuying:focus-visible, +.mengsan-map-menu-action-shuying:focus-visible, +.mengsan-map-icon-button-shuying:focus-visible, +.mengsan-map-menu-back-shuying:focus-visible { + outline: 3px solid var(--ms-focus); + outline-offset: 3px; +} + +.mengsan-ink-legend-shuying { + right: 24px; + width: 156px; + padding: 14px; + border: 1px solid rgba(210, 175, 101, .36); + border-radius: 6px; + background: rgba(25, 33, 28, .92); + color: var(--ms-text-secondary); + box-shadow: 0 16px 38px rgba(0,0,0,.25); + backdrop-filter: blur(8px); + -webkit-backdrop-filter: blur(8px); +} +.mengsan-ink-legend-shuying summary { min-height: 28px; color: var(--ms-text-primary); font: 600 17px/1.5 "KaiTi", serif; } +.mengsan-ink-legend-shuying > div { gap: 10px; margin-top: 10px; } +.mengsan-ink-legend-shuying .mengsan-ink-icon-shuying { color: var(--ms-gold-400); } + +.mengsan-map-menu-layer-shuying { + position: absolute !important; + inset: 0 !important; + z-index: 30; + display: flex !important; + align-items: stretch; +} +.mengsan-map-menu-layer-shuying[hidden] { display: none !important; } +.mengsan-map-menu-scrim-shuying { + position: absolute !important; + inset: 0 !important; + background: rgba(5, 9, 7, .72); + backdrop-filter: blur(3px); + -webkit-backdrop-filter: blur(3px); +} +.mengsan-map-menu-sheet-shuying { + position: relative !important; + z-index: 1; + display: block !important; + width: min(420px, calc(100vw - 32px)); + height: 100% !important; + padding: 0 !important; + border: 0; + border-right: 1px solid rgba(210, 175, 101, .34); + background: + radial-gradient(circle at 10% 0, rgba(210, 175, 101, .14), transparent 34%), + linear-gradient(155deg, #222d26, #111713 76%); + color: var(--ms-text-primary); + box-shadow: 24px 0 70px rgba(0,0,0,.45); + animation: mengsan-menu-enter-shuying var(--ms-motion-base) ease-out both; +} +@keyframes mengsan-menu-enter-shuying { from { opacity: 0; transform: translateX(-20px); } } +.mengsan-map-menu-header-shuying { position: absolute !important; z-index: 3; inset: max(20px, env(safe-area-inset-top)) 20px auto 20px !important; display: flex !important; align-items: center; justify-content: space-between; height: 74px !important; padding: 4px 4px 18px; border-bottom: 1px solid rgba(255,255,255,.09); visibility: visible !important; opacity: 1 !important; } +.mengsan-map-menu-heading-group-shuying { position: relative !important; } +.mengsan-map-menu-kicker-shuying { position: relative !important; color: var(--ms-gold-400); font-size: 11px; letter-spacing: .2em !important; } +.mengsan-map-menu-title-shuying { position: relative !important; margin-top: 5px; color: var(--ms-text-primary); font: 700 28px/1.2 "STKaiti", "KaiTi", serif; } +.mengsan-map-icon-button-shuying { + display: grid; + place-items: center; + width: 48px; + height: 48px; + border: 1px solid rgba(255,255,255,.13); + border-radius: 50%; + background: rgba(255,255,255,.055); + color: var(--ms-text-primary); + cursor: pointer; +} +.mengsan-map-menu-content-shuying { position: absolute !important; z-index: 2; inset: calc(max(20px, env(safe-area-inset-top)) + 88px) 20px calc(max(20px, env(safe-area-inset-bottom)) + 98px) 20px !important; display: flex !important; flex-direction: column; gap: 10px; min-height: 0; padding: 0 4px 10px; overflow-y: auto; visibility: visible !important; opacity: 1 !important; } +.mengsan-map-menu-action-shuying { + position: relative; + display: grid; + grid-template-columns: 28px minmax(0, 1fr); + align-items: center; + gap: 14px; + width: 100%; + min-height: 68px; + padding: 11px 14px; + border: 1px solid rgba(255,255,255,.10); + border-radius: 7px; + background: rgba(255,255,255,.045); + color: var(--ms-text-primary); + text-align: left; + cursor: pointer; + touch-action: manipulation; + transition: background var(--ms-motion-fast), border-color var(--ms-motion-fast); +} +.mengsan-map-menu-action-shuying:hover { border-color: rgba(210,175,101,.48); background: rgba(210,175,101,.10); } +.mengsan-map-menu-action-shuying:active { background: rgba(210,175,101,.18); } +.mengsan-map-menu-action-shuying:disabled { cursor: not-allowed; opacity: .42; } +.mengsan-map-menu-action-shuying > .mengsan-control-icon-shuying { width: 26px; height: 26px; color: var(--ms-gold-400); } +.mengsan-map-menu-action-copy-shuying { position: relative !important; min-width: 0; } +.mengsan-map-menu-action-name-shuying { position: relative !important; color: var(--ms-text-primary); font-size: 16px; font-weight: 700; } +.mengsan-map-menu-action-description-shuying { position: relative !important; margin-top: 4px; color: var(--ms-text-secondary); font-size: 12px; line-height: 1.45; overflow-wrap: anywhere; } +.mengsan-map-menu-action-shuying.danger { margin-top: auto; border-color: rgba(189,75,63,.30); color: #ffd7ce; } +.mengsan-map-menu-action-shuying.danger > .mengsan-control-icon-shuying { color: #e58d7e; } +.mengsan-map-menu-action-shuying.danger:hover { border-color: rgba(229,141,126,.65); background: rgba(169,68,56,.16); } +.mengsan-map-menu-divider-shuying { position: relative !important; height: 1px; margin: 6px 0; background: rgba(255,255,255,.09); } +.mengsan-map-menu-footer-shuying { + position: absolute !important; + z-index: 12; + left: 20px !important; + right: 20px !important; + bottom: max(18px, env(safe-area-inset-bottom)) !important; + height: 80px !important; + padding: 14px 4px 0; + border-top: 1px solid rgba(255,255,255,.09); + background: #111713; + visibility: visible !important; + opacity: 1 !important; +} +.mengsan-map-exit-fixed-shuying { + position: relative !important; + inset: auto !important; + display: grid !important; + grid-template-columns: 28px minmax(0, 1fr) !important; + align-items: center !important; + gap: 14px !important; + width: 100% !important; + height: 64px !important; + min-height: 64px !important; + margin: 0 !important; + padding: 10px 14px !important; + border: 1px solid rgba(229,141,126,.58) !important; + border-radius: 7px !important; + background: rgba(169,68,56,.18) !important; + box-shadow: none !important; + color: #ffe2da !important; + font-family: "Microsoft YaHei", sans-serif !important; + text-align: left !important; + visibility: visible !important; + opacity: 1 !important; + cursor: pointer !important; + appearance: none !important; + transform: none !important; + touch-action: manipulation; +} +.mengsan-map-exit-fixed-shuying:hover { border-color: #e58d7e !important; background: rgba(169,68,56,.30) !important; } +.mengsan-map-exit-fixed-shuying:focus-visible { outline: 3px solid var(--ms-focus) !important; outline-offset: 3px !important; } +.mengsan-map-exit-fixed-shuying > .mengsan-control-icon-shuying { position: relative !important; width: 26px !important; height: 26px !important; color: #efa394 !important; } +.mengsan-map-exit-copy-shuying { position: relative !important; display: flex !important; flex-direction: column !important; min-width: 0 !important; color: inherit !important; visibility: visible !important; opacity: 1 !important; } +.mengsan-map-exit-copy-shuying > strong { position: relative !important; display: block !important; color: #ffe2da !important; font-size: 16px !important; line-height: 1.25 !important; } +.mengsan-map-exit-copy-shuying > small { position: relative !important; display: block !important; margin-top: 4px !important; color: #d9aea5 !important; font-size: 12px !important; line-height: 1.3 !important; +} +.mengsan-map-menu-page-intro-shuying { position: relative !important; margin-bottom: 4px; color: var(--ms-text-secondary); font-size: 14px; line-height: 1.6; } +.mengsan-map-menu-back-shuying { display: inline-flex; align-items: center; justify-content: center; gap: 8px; min-height: 48px; margin-top: auto; border: 1px solid rgba(210,175,101,.34); border-radius: 6px; background: rgba(210,175,101,.08); color: var(--ms-text-primary); cursor: pointer; } +.mengsan-map-inventory-summary-shuying { position: relative !important; display: grid !important; grid-template-columns: repeat(3, 1fr); gap: 8px; } +.mengsan-map-inventory-count-shuying { position: relative !important; padding: 13px 8px; border: 1px solid rgba(255,255,255,.09); border-radius: 6px; background: rgba(255,255,255,.04); text-align: center; } +.mengsan-map-inventory-count-shuying > div:first-child { position: relative !important; color: var(--ms-gold-400); font: 700 22px/1.2 ui-monospace, monospace; } +.mengsan-map-inventory-count-shuying > div:last-child { position: relative !important; margin-top: 4px; color: var(--ms-text-secondary); font-size: 12px; } +.mengsan-map-inventory-list-shuying, .mengsan-map-record-list-shuying { position: relative !important; display: grid !important; gap: 8px; margin-top: 4px; } +.mengsan-map-inventory-section-title-shuying { position: relative !important; margin-top: 10px; color: var(--ms-gold-400); font-size: 13px; font-weight: 700; letter-spacing: .08em !important; } +.mengsan-map-inventory-row-shuying { position: relative !important; display: flex !important; justify-content: space-between; gap: 16px; min-height: 44px; padding: 11px 13px; border-bottom: 1px solid rgba(255,255,255,.08); color: var(--ms-text-primary); } +.mengsan-map-inventory-row-shuying > div { position: relative !important; } +.mengsan-map-empty-shuying { position: relative !important; padding: 24px; color: var(--ms-text-secondary); text-align: center; } +.mengsan-map-placeholder-shuying { position: relative !important; display: flex !important; flex-direction: column; align-items: center; justify-content: center; min-height: 220px; padding: 28px; border: 1px dashed rgba(210,175,101,.28); border-radius: 8px; background: rgba(255,255,255,.025); text-align: center; } +.mengsan-map-placeholder-shuying > .mengsan-control-icon-shuying { width: 38px; height: 38px; color: var(--ms-gold-400); opacity: .72; } +.mengsan-map-placeholder-title-shuying { position: relative !important; margin-top: 14px; color: var(--ms-text-primary); font-size: 18px; font-weight: 700; } +.mengsan-map-placeholder-text-shuying { position: relative !important; max-width: 260px; margin-top: 7px; color: var(--ms-text-secondary); font-size: 13px; line-height: 1.6; } + +@media (max-width: 899px) { + .mengsan-map-header-shuying { grid-template-columns: 48px minmax(0, 1fr); min-height: 78px; padding: 10px 12px; gap: 10px; } + .mengsan-map-menu-trigger-shuying { grid-column: 1; min-width: 48px; width: 48px; min-height: 48px; padding: 0; } + .mengsan-map-menu-trigger-shuying > span { position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); } + .mengsan-map-title-group-shuying { grid-column: 2; min-width: 0; text-align: left; } + .mengsan-map-eyebrow-shuying { display: none !important; } + .mengsan-map-title-group-shuying > .mengsan-title-shuying { font-size: clamp(17px, 5vw, 22px); letter-spacing: .05em !important; } + .mengsan-map-header-shuying > .mengsan-map-status-shuying { grid-column: 1 / -1; justify-self: stretch; justify-content: stretch; gap: 6px; } + .mengsan-map-stat-shuying { flex: 1; min-width: 0; min-height: 42px; padding: 5px 9px; } + .mengsan-map-stat-label-shuying { font-size: 10px; } + .mengsan-map-stat-value-shuying { font-size: 13px; } + .mengsan-map-viewport-shuying { padding: 16px 8px 28px; } + .mengsan-map-shuying { width: 100%; border-radius: 3px; } + .mengsan-map-panel-shuying > .mengsan-ink-legend-shuying { top: 12px; right: 12px; } + .mengsan-ink-legend-shuying { display: none; } + .mengsan-map-shuying .mengsan-node-shuying { width: 52px; height: 52px; padding: 9px; } + .mengsan-map-menu-layer-shuying { align-items: flex-end; } + .mengsan-map-menu-sheet-shuying { width: 100%; height: min(82dvh, 680px) !important; border-right: 0; border-top: 1px solid rgba(210,175,101,.34); border-radius: 16px 16px 0 0; animation-name: mengsan-menu-enter-mobile-shuying; } + @keyframes mengsan-menu-enter-mobile-shuying { from { opacity: 0; transform: translateY(24px); } } + .mengsan-map-menu-title-shuying { font-size: 24px; } + .mengsan-map-menu-header-shuying { inset: 14px 14px auto 14px !important; } + .mengsan-map-menu-content-shuying { inset: 102px 14px calc(max(14px, env(safe-area-inset-bottom)) + 92px) 14px !important; padding-top: 0; } + .mengsan-map-menu-footer-shuying { left: 14px !important; right: 14px !important; bottom: max(12px, env(safe-area-inset-bottom)) !important; } + .mengsan-map-menu-action-shuying { min-height: 64px; } +} + +@media (prefers-reduced-motion: reduce) { + .mengsan-map-menu-sheet-shuying { animation: none !important; } + .mengsan-map-menu-trigger-shuying, + .mengsan-map-menu-action-shuying, + .mengsan-map-shuying .mengsan-node-shuying { transition: none !important; } + .mengsan-choice-shuying { transition: none !important; } + .mengsan-choice-shuying:hover, + .mengsan-choice-shuying:active { transform: none !important; } +} + +/* Shared flow tokens: paper, ink, antique gold, cinnabar. Scoped to Mengsan only. */ +.mengsan-flow-overlay-shuying, .mengsan-dialogue-overlay-shuying, .mengsan-menu-dialog-shuying { + --flow-ink: #172724; + --flow-paper: #efe5cf; + --flow-muted: #cbbfa4; + --flow-gold: #c4a568; + --flow-danger: #853d34; + color: var(--flow-paper); + font-family: "Microsoft YaHei", sans-serif; + font-size: 16px; + line-height: 1.6; +} +.mengsan-flow-overlay-shuying { + background: radial-gradient(ellipse at 18% 20%, #53635766, transparent 55%), linear-gradient(120deg, #172724f5, #101b1df5); + padding: 80px 24px 28px; +} +.mengsan-flow-panel-shuying { + width: min(680px, 100%) !important; + max-height: 100% !important; + padding: clamp(24px, 4vw, 48px); + border: 1px solid var(--flow-gold); + border-top: 4px double var(--flow-gold); + border-radius: 3px; + background: var(--flow-ink); + box-shadow: 0 20px 70px #0005; + scrollbar-width: thin; +} +.mengsan-flow-eyebrow-shuying { + position: relative !important; margin: 0 0 12px; color: var(--flow-gold); font-size: 13px; letter-spacing: .16em !important; +} +.mengsan-flow-panel-shuying > .mengsan-title-shuying { + text-align: left; font: 38px/1.3 "KaiTi", "STKaiti", "SimSun", serif; color: var(--flow-paper); margin-bottom: 12px; +} +.mengsan-flow-panel-shuying > .mengsan-description-shuying { text-align: left; color: var(--flow-muted); font-size: 14px; margin-bottom: 28px; } +.mengsan-flow-panel-shuying .mengsan-choice-shuying { + min-height: 76px; padding: 16px 20px; border-radius: 3px; border-color: #c4a56866; background: #ffffff05; transition: background .15s, border-color .15s; +} +.mengsan-flow-panel-shuying .mengsan-choice-shuying:hover { transform: none; background: #c4a56818; box-shadow: none; } +.mengsan-flow-panel-shuying .mengsan-choice-name-shuying { color: var(--flow-paper); font-size: 19px; } +.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; } +.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; + border: 1px solid #c4a568; border-radius: 4px; color: #efe5cf; background: #172724; font: 17px/1.5 "Microsoft YaHei", sans-serif; cursor: pointer; +} +.mengsan-flow-menu-shuying:hover { background: #344840; } +.mengsan-flow-menu-shuying { display: inline-flex; align-items: center; justify-content: center; gap: 8px; } +.mengsan-flow-menu-shuying svg { width: 20px; height: 20px; fill: none; stroke: currentColor; stroke-width: 2; stroke-linecap: round; } +.mengsan-flow-menu-shuying:focus-visible, .mengsan-menu-dialog-shuying button:focus-visible { outline: 3px solid #efe5cf; outline-offset: 3px; } +.mengsan-menu-dialog-shuying { + position: fixed; inset: 0; box-sizing: border-box; width: min(460px, calc(100vw - 32px)); max-height: calc(100dvh - 32px); + margin: auto; padding: 28px; overflow: auto; border: 1px solid var(--flow-gold); border-radius: 4px; background: var(--flow-ink); +} +.mengsan-menu-dialog-shuying::backdrop { background: #08120fe0; } +.mengsan-menu-dialog-shuying h2 { margin: 0 0 12px; font: 28px/1.3 "KaiTi", "SimSun", serif; } +.mengsan-menu-dialog-shuying p { margin: 0 0 24px; color: var(--flow-muted); } +.mengsan-menu-dialog-shuying button { display: block; width: 100%; min-height: 48px; margin-top: 12px; border: 1px solid #c4a56888; border-radius: 3px; padding: 10px; font: inherit; color: var(--flow-paper); background: #ffffff08; cursor: pointer; } +.mengsan-menu-dialog-shuying button.danger { background: var(--flow-danger); border-color: #d89586; } +.mengsan-dialogue-overlay-shuying { background: radial-gradient(ellipse at 35% 25%, #62736544, transparent 60%), linear-gradient(120deg, #172724ee, #101b1dee); } +.mengsan-dialogue-chapter-shuying { top: 26px !important; left: 24px !important; max-width: calc(100% - 160px); color: var(--flow-muted); } +.mengsan-dialogue-box-shuying { border-radius: 4px; border-color: var(--flow-gold); background: #172724f5; padding-bottom: 34px; } +.mengsan-dialogue-speaker-shuying { border-radius: 2px; color: #172724; background: #efe5cf; font-family: "KaiTi", "SimSun", serif; } +.mengsan-dialogue-text-shuying { color: var(--flow-paper); font-size: clamp(17px, 2vw, 22px); line-height: 1.85; max-height: 30vh; overflow: auto; } +.mengsan-dialogue-help-shuying { position: absolute !important; bottom: 8px; left: 16px; right: 16px; text-align: center; font-size: 12px; color: var(--flow-muted); pointer-events: none; } +.mengsan-dialogue-choices-shuying { position: absolute !important; left: 50%; bottom: clamp(210px, 32vh, 290px); transform: translateX(-50%); width: min(580px, 88vw); display: grid; gap: 10px; z-index: 5; } +.mengsan-dialogue-skip-shuying { position: absolute !important; top: max(18px, env(safe-area-inset-top)); left: max(18px, env(safe-area-inset-left)); z-index: 6; padding: 8px 14px; border: 1px solid var(--flow-gold); border-radius: 4px; color: var(--flow-paper); background: #172724e8; font: inherit; cursor: pointer; } +.mengsan-dialogue-skip-shuying:hover, .mengsan-dialogue-skip-shuying:focus-visible { background: #304540; outline: 2px solid var(--flow-gold); outline-offset: 2px; } +.mengsan-dialogue-choice-shuying { padding: 12px 18px; border: 1px solid var(--flow-gold); border-radius: 4px; color: var(--flow-paper); background: #172724f5; text-align: left; font: inherit; cursor: pointer; } +.mengsan-dialogue-choice-shuying:hover, .mengsan-dialogue-choice-shuying:focus-visible { background: #304540; outline: 2px solid var(--flow-gold); outline-offset: 2px; } +@media (max-width: 600px) { + .mengsan-dialogue-chapter-shuying { display: block !important; font-size: 14px; } + .mengsan-flow-overlay-shuying { padding: 78px 12px 16px; } + .mengsan-flow-panel-shuying { padding: 24px 18px; } + .mengsan-flow-panel-shuying > .mengsan-title-shuying { font-size: 30px; } + .mengsan-flow-panel-shuying .mengsan-choice-shuying { padding: 14px; } + .mengsan-dialogue-box-shuying { bottom: 36px !important; } + .mengsan-dialogue-portrait-frame-shuying { bottom: clamp(186px, 36vh, 242px) !important; } +} +@media (max-height: 500px) { + .mengsan-flow-overlay-shuying { padding-top: 70px; padding-bottom: 12px; } + .mengsan-flow-panel-shuying { padding: 18px 24px; } + .mengsan-flow-panel-shuying > .mengsan-description-shuying { margin-bottom: 16px; } + .mengsan-dialogue-box-shuying { bottom: 28px !important; min-height: 100px; max-height: 50vh; padding: 24px 20px 20px; } + .mengsan-dialogue-text-shuying { max-height: 24vh; } + .mengsan-dialogue-portrait-frame-shuying { bottom: 48% !important; height: 110px !important; width: 80px !important; } +} +@media (prefers-reduced-motion: reduce) { + .mengsan-flow-panel-shuying *, .mengsan-flow-menu-shuying { animation: none !important; transition: none !important; } +} + +/* Mode-owned card library: no engine .card nodes or third-party UI dependency. */ +dialog.mengsan-deck-dialog-shuying { + --ms-ink: #f4e7cc; + --ms-muted: #c6b89e; + --ms-gold: #dcb578; + position: fixed !important; + inset: 0 !important; + margin: auto !important; + box-sizing: border-box; + width: min(1020px, calc(100vw - 32px)); + height: min(760px, calc(100dvh - 32px)); + max-height: calc(100dvh - 32px); + padding: 22px; + overflow: hidden; + border: 1px solid #ad8956; + border-radius: 12px; + color: var(--ms-ink); + background: radial-gradient(ellipse at top left, #4b3628, #221d19 70%); + box-shadow: 0 24px 80px #000a, inset 0 0 0 4px #dfbd7320; + font: 15px/1.5 "Microsoft YaHei", sans-serif; + text-align: left; + text-shadow: none; +} +dialog.mengsan-deck-dialog-shuying[open] { display: flex !important; flex-direction: column; gap: 14px; } +dialog.mengsan-deck-dialog-shuying::backdrop { background: #080706b8; } +.mengsan-deck-dialog-shuying *, .mengsan-deck-dialog-shuying *::before, .mengsan-deck-dialog-shuying *::after { + box-sizing: border-box; position: static; float: none; writing-mode: horizontal-tb; + text-shadow: none; letter-spacing: normal; +} +.mengsan-deck-dialog-shuying [hidden] { display: none !important; } +.mengsan-deck-dialog-shuying h2, .mengsan-deck-dialog-shuying h3, .mengsan-deck-dialog-shuying h4, +.mengsan-deck-dialog-shuying p { margin: 0; } +.mengsan-deck-dialog-shuying button { + font: inherit; cursor: pointer; touch-action: manipulation; + border-radius: 6px; transform: none; height: auto; margin: 0; +} +.mengsan-deck-dialog-shuying button:focus-visible { outline: 3px solid #ffe0a1; outline-offset: 3px; } +.mengsan-deck-dialog-shuying .ms-deck-header { display: flex; align-items: center; justify-content: space-between; gap: 16px; flex-shrink: 0; } +.mengsan-deck-dialog-shuying .ms-deck-title { font: 700 28px/1.3 "STKaiti", "KaiTi", serif; } +.mengsan-deck-dialog-shuying .ms-deck-eyebrow { color: var(--ms-gold); font-size: 12px; } +.mengsan-deck-dialog-shuying .ms-deck-tabs { display: flex; gap: 10px; flex-shrink: 0; } +.mengsan-deck-dialog-shuying .ms-deck-button { min-height: 44px; padding: 8px 18px; background: #392c23; color: var(--ms-ink); border: 1px solid #96754e; } +.mengsan-deck-dialog-shuying .ms-deck-button[aria-pressed="true"] { background: #dcb578; color: #241a10; border-color: #f9dda6; } +.mengsan-deck-dialog-shuying .ms-deck-summary, .mengsan-deck-dialog-shuying .ms-deck-footer { color: var(--ms-muted); font-size: 13px; flex-shrink: 0; } +.mengsan-deck-dialog-shuying .ms-deck-body { flex: 1; min-height: 0; overflow: auto; overscroll-behavior: contain; padding: 5px; scrollbar-gutter: stable; } +.mengsan-deck-dialog-shuying .ms-deck-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(150px, 1fr)); gap: 16px; align-items: stretch; } +.mengsan-deck-dialog-shuying .ms-deck-card { display: flex; flex-direction: column; gap: 8px; width: 100%; min-width: 0; padding: 8px; border: 1px solid #9e7b49; background: #e4d0aa; color: #2e2419; text-align: left; box-shadow: 0 4px 10px #0005; } +.mengsan-deck-dialog-shuying .ms-deck-card:hover { border-color: #ffe3a5; background: #f0ddb7; } +.mengsan-deck-dialog-shuying .ms-deck-face { display: flex; flex-direction: column; gap: 5px; min-width: 0; color: #2e2419; background: #e4d0aa; } +.mengsan-deck-dialog-shuying .ms-deck-corner { font-size: 14px; font-weight: 700; } +.mengsan-deck-dialog-shuying .ms-deck-red { color: #9b2920; } +.mengsan-deck-dialog-shuying .ms-deck-art { position: relative; display: grid; place-items: center; height: 116px; overflow: hidden; background: linear-gradient(145deg, #8a765b, #c0aa83); border: 1px solid #8b734f; border-radius: 3px; } +.mengsan-deck-dialog-shuying .ms-deck-image { position: absolute; inset: 0; width: 100%; height: 100%; object-fit: contain; } +.mengsan-deck-dialog-shuying .ms-deck-glyph { font: 700 28px "KaiTi", serif; text-align: center; } +.mengsan-deck-dialog-shuying .ms-deck-name { font: 700 21px/1.4 "KaiTi", serif; } +.mengsan-deck-dialog-shuying .ms-deck-meta, .mengsan-deck-dialog-shuying .ms-deck-tags { font-size: 12px; } +.mengsan-deck-dialog-shuying .ms-deck-tags { color: #813d18; font-weight: 700; } +.mengsan-deck-dialog-shuying .ms-deck-excerpt { display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; font-size: 12px; line-height: 1.5; } +.mengsan-deck-dialog-shuying .ms-deck-detail-content { display: grid; grid-template-columns: 200px minmax(0, 1fr); gap: 28px; margin-top: 18px; } +.mengsan-deck-dialog-shuying .ms-deck-detail-content > .ms-deck-face { padding: 14px; border-radius: 6px; align-self: start; } +.mengsan-deck-dialog-shuying .ms-deck-detail-content .ms-deck-art { height: 190px; } +.mengsan-deck-dialog-shuying .ms-deck-detail-copy { display: flex; flex-direction: column; gap: 14px; min-width: 0; overflow-wrap: anywhere; } +.mengsan-deck-dialog-shuying .ms-deck-detail-copy h3 { font-size: 26px; } +.mengsan-deck-dialog-shuying .ms-deck-rules { white-space: pre-line; line-height: 1.85; } +.mengsan-deck-dialog-shuying .ms-deck-instance { color: var(--ms-muted); font-size: 12px; } +.mengsan-deck-dialog-shuying .ms-deck-warning { color: #ffcf80; } +.mengsan-deck-dialog-shuying .ms-deck-empty { padding: 24px; grid-column: 1 / -1; } +@media (max-width: 600px) { + dialog.mengsan-deck-dialog-shuying { width: calc(100vw - 12px); height: calc(100dvh - 12px); max-height: calc(100dvh - 12px); padding: 14px; gap: 10px !important; } + .mengsan-deck-dialog-shuying .ms-deck-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 10px; } + .mengsan-deck-dialog-shuying .ms-deck-detail-content { grid-template-columns: minmax(0, 1fr); gap: 18px; } + .mengsan-deck-dialog-shuying .ms-deck-detail-content > .ms-deck-face { width: 190px; justify-self: center; } + .mengsan-deck-dialog-shuying .ms-deck-summary { font-size: 12px; } +} + +/* Battle-only control; removed with its owning session. */ +.mengsan-battle-piles-button-shuying { + position: fixed !important; right: max(14px, env(safe-area-inset-right)); top: 110px; + z-index: 35; min-width: 64px; min-height: 44px; padding: 8px 14px; + border: 1px solid #dcb578; border-radius: 8px; background: #30251eee; + color: #f4e7cc; font: 16px/1.4 "Microsoft YaHei", sans-serif; + cursor: pointer; touch-action: manipulation; +} +.mengsan-battle-piles-button-shuying:focus-visible { outline: 3px solid #ffe0a1; outline-offset: 3px; } +.mengsan-deck-dialog-shuying .ms-deck-section { grid-column: 1 / -1; color: var(--ms-gold); font-size: 18px; padding-top: 8px; } +.mengsan-deck-dialog-shuying .ms-deck-order { color: #6b341b; font-weight: 700; font-size: 13px; } +@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. */ +.player.mengsan-hide-identity-shuying > .identity { + display: none !important; + pointer-events: none !important; +} +.player > .mengsan-energy-shuying { + position: absolute !important; + top: -22px !important; + left: 50% !important; + transform: translateX(-50%) !important; + z-index: 8; + width: max-content !important; + min-width: 52px; + padding: 2px 5px; + border: 1px solid #d6b977; + border-radius: 4px; + background: #152b2bef; + color: #fff0c9; + font-size: 12px; + line-height: 1.3; + 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; + pointer-events: none; } diff --git a/tianshu/bondSkills.js b/tianshu/bondSkills.js index 57724ac..a11dd00 100644 --- a/tianshu/bondSkills.js +++ b/tianshu/bondSkills.js @@ -1015,18 +1015,17 @@ const skill = { }, async content(event, trigger, player) { const target = trigger.player; - const loseEvent = target.loseHp(trigger.num); - loseEvent.shuYing_Tianshu_Skill_09_Skill3 = true; - loseEvent.shuYing_source = player; - await loseEvent; + await target.loseHp(trigger.num); if (!target.isIn()) return; - const loseHpNum = target - .getHistory("loseHp", evt => { + // 体力变化记录在全局历史中,只累计本回合该目标流失的体力。 + const loseHpNum = game + .getGlobalHistory("changeHp", evt => { return ( - evt.shuYing_Tianshu_Skill_09_Skill3 && - evt.shuYing_source == player + evt.player == target && + evt.getParent().name == "loseHp" && + evt.num < 0 ); }) .reduce((sum, evt) => { diff --git a/tianshu/extension.js b/tianshu/extension.js index ee5f1b9..69e6f6d 100644 --- a/tianshu/extension.js +++ b/tianshu/extension.js @@ -24,6 +24,7 @@ export default function initTianshu(lib, game, ui, get, ai, _status, shuYing) { corpseChooseCleanup: null, corpseChooseEvent: null, initialBoss: null, + defending: false, virtualIdol: null, running: false, pendingSpawn: false, @@ -100,6 +101,27 @@ export default function initTianshu(lib, game, ui, get, ai, _status, shuYing) { const isVirtualIdol = player => Boolean(player && (getVirtualIdolIds().includes(player.name1) || getVirtualIdolIds().includes(player.name))); // 判断指定角色是否为当前关卡实际召唤的 Boss;玩家变身为同名动态武将时不会被误判。 const isCurrentStageBoss = player => Boolean(player?.storage?.shuYing_tianshu_stageBoss && player.storage.shuYing_tianshu_stageIndex == state.stageIndex); + // 应战身份独立于 game.me/game.boss,避免动态换控后误判阵营和胜负。 + const swapDefendingBoss = target => { + if (!state.defending || !target || !game.players.includes(target) || !isCurrentStageBoss(target)) return; + game.swapControl(target); + game.onSwapControl?.(); + // 应战开局沿用主 Boss UI,会隐藏 fakeme;换成动态 Boss 后必须恢复代理头像。 + // 直接复用已经显示正确的角色头像,不按克隆 ID 拼接图片路径。 + if (ui.fakeme) { + game.singleHandcard = true; + ui.arena?.classList.add("single-handcard"); + ui.window?.classList.add("single-handcard"); + ui.fakeme.style.display = ""; + ui.fakeme.current = target.name; + ui.fakeme.style.backgroundImage = target.node.avatar.style.backgroundImage; + ui.updatehl?.(); + } + }; + const getNextDefendingBoss = dead => { + const bosses = game.players.filter(isCurrentStageBoss).sort((a, b) => Number(a.dataset.position) - Number(b.dataset.position)); + return bosses.find(boss => Number(boss.dataset.position) > Number(dead?.dataset.position ?? -1)) || bosses[0]; + }; // 判断指定角色是否属于开局捕获的玩家方。 const isCapturedPlayerSide = player => Boolean(getPlayerKey(player) && state.playerSideIds[getPlayerKey(player)]); // 获取玩家方角色列表,可按是否存活、是否包含虚拟偶像过滤。 @@ -188,6 +210,20 @@ export default function initTianshu(lib, game, ui, get, ai, _status, shuYing) { // 结束当前 phase 事件,把后续行动权交给指定角色;保留旧版重置轮数与阶段的做法。 const finishCurrentPhaseTo = target => { if (!target) return; + // 清剿把弃牌事件挂在上层队列;finish 不会清空队列,不能让旧关结算弃掉新关奖励。 + let pendingParent = _status.event; + const pendingVisited = new Set(); + while (pendingParent && !pendingVisited.has(pendingParent)) { + pendingVisited.add(pendingParent); + for (const queue of [pendingParent.next, pendingParent.after]) { + if (!Array.isArray(queue)) continue; + for (let i = queue.length - 1; i >= 0; i--) { + if (queue[i]?.name == "qingjiao_discard") queue.splice(i, 1); + } + } + if (pendingParent.name == "phaseLoop") break; + pendingParent = pendingParent.getParent ? pendingParent.getParent() : pendingParent.parent; + } const phase = _status.event?.getParent ? _status.event.getParent("phase") : getParentEvent("phase"); if (phase) { if (typeof game.resetSkills == "function") game.resetSkills(); @@ -314,6 +350,16 @@ export default function initTianshu(lib, game, ui, get, ai, _status, shuYing) { boss.storage.shuYing_tianshu_stageBoss = true; boss.storage.shuYing_tianshu_stageIndex = state.stageIndex; boss.storage.shuYing_tianshu_originalBossName = originalName; + if (state.defending) { + const originalIsUnderControl = boss.isUnderControl; + boss.isUnderControl = function (self, me = game.me) { + if (state.running && state.defending && isCurrentStageBoss(this) && isCurrentStageBoss(me) + && !_status.connectMode && !game.notMe && !this.isMad() && get.config("single_control")) { + return this == me ? Boolean(self) : true; + } + return originalIsUnderControl.call(this, self, me); + }; + } game.addVideo("setIdentity", boss, boss.identity); if (game.playerMap) game.playerMap[boss.dataset.position] = boss; game.arrangePlayers(); @@ -591,6 +637,7 @@ export default function initTianshu(lib, game, ui, get, ai, _status, shuYing) { player._start_cards.addArray(cards); } } + if (state.defending) swapDefendingBoss(getNextDefendingBoss()); }; // 读取玩家当前武将牌上自带的技能,避免过关奖励重复刷出本体技能。 const getCharacterCardSkills = player => { @@ -976,6 +1023,8 @@ export default function initTianshu(lib, game, ui, get, ai, _status, shuYing) { }; // 让指定玩家从 UI 中选择一个奖励技能,并返回刷新请求或最终技能。 const chooseRewardSkill = async (player, choices, canRefresh) => { + // 应战者不能代替敌方选奖励;沿用无人类操作时的自动选择路径。 + if (state.defending) return choices[0] || null; const chooser = getRewardSkillChooser(player); const canChoose = chooser == game.me || chooser?.isUnderControl?.(); if (!canChoose || typeof shuYing.chooseSkillRewardDialog != "function") return choices[0] || null; @@ -1019,7 +1068,7 @@ export default function initTianshu(lib, game, ui, get, ai, _status, shuYing) { if (state.clearingStage) return; state.clearingStage = true; if (state.stageIndex >= tianshuConfig.stages.length - 1) { - game.over(game.me !== game.boss); + game.over(!state.defending); return; } await reviveVirtualIdolIfNeeded(); @@ -1059,6 +1108,7 @@ export default function initTianshu(lib, game, ui, get, ai, _status, shuYing) { if (!isCurrentStageBoss(deadBoss)) return; if (deadBoss.storage.shuYing_tianshu_bossDieHandled) return; deadBoss.storage.shuYing_tianshu_bossDieHandled = true; + if (state.defending && game.me == deadBoss) swapDefendingBoss(getNextDefendingBoss(deadBoss)); const stageCleared = !game.players.some(current => isCurrentStageBoss(current)); const finalCleared = stageCleared && state.stageIndex >= tianshuConfig.stages.length - 1; if (!finalCleared && event.source && event.source.isIn()) { @@ -1073,6 +1123,22 @@ export default function initTianshu(lib, game, ui, get, ai, _status, shuYing) { if (state.running) return; state.running = true; state.initialBoss = game.boss || null; + state.defending = Boolean(state.initialBoss && game.me == state.initialBoss); + if (state.defending) { + // 应战原生布局是 0/2/4/6;先恢复挑战者 1/2/3,再安排 Boss 和偶像,避免重叠。 + game.players.filter(current => current != state.initialBoss && current.side === false) + .sort((a, b) => Number(a.dataset.position) - Number(b.dataset.position)) + .forEach((current, index) => { current.dataset.position = index + 1; }); + game.arrangePlayers(); + const originalModeSwapPlayer = game.modeSwapPlayer; + game.modeSwapPlayer = function (target) { + if (state.running && state.defending && isCurrentStageBoss(target)) { + swapDefendingBoss(target); + return; + } + return originalModeSwapPlayer.apply(this, arguments); + }; + } installHiddenBossCorpseCheckHook(); spawnVirtualIdol(); capturePlayerSide(); @@ -1116,7 +1182,7 @@ export default function initTianshu(lib, game, ui, get, ai, _status, shuYing) { canReceiveOpeningProtection: player => Boolean(player && player.isIn() && !isCurrentStageBoss(player) && (isCapturedPlayerSide(player) || isVirtualIdol(player))), // 玩家死亡后检查是否达到失败条件。 checkPlayerDefeat() { - if (this.isPlayerSideDefeated()) game.over(game.me === game.boss); + if (this.isPlayerSideDefeated()) game.over(state.defending); }, refreshBonds: refreshTianshuBonds, queueBondRefresh: queueTianshuBondRefresh, @@ -1173,9 +1239,25 @@ export default function initTianshu(lib, game, ui, get, ai, _status, shuYing) { game.addGlobalSkill("shuYing_Tianshu_NewSpawnStage"); game.addGlobalSkill("shuYing_Tianshu_NewBossDie"); game.addGlobalSkill("shuYing_Tianshu_NewPlayerDie"); + game.addGlobalSkill("shuYing_Tianshu_DefendingControl"); game.addGlobalSkill("shuYing_Tianshu_BondManager"); }, }, + shuYing_Tianshu_DefendingControl: { + trigger: { global: "phaseBefore" }, + forced: true, + silent: true, + charlotte: true, + forceDie: true, + firstDo: true, + filter(event) { + return state.running && state.defending && !_status.connectMode + && get.config("single_control") && isCurrentStageBoss(event.player); + }, + async content(event, trigger) { + swapDefendingBoss(trigger.player); + }, + }, shuYing_Tianshu_BondManager: { mode: ["boss"], trigger: { global: "changeSkillsAfter" },