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.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(/