mirror of
https://github.com/Shu-Ying/noname-shuying.git
synced 2026-10-07 17:10:02 +08:00
整合
This commit is contained in:
@@ -1,3 +1,5 @@
|
||||
log.txt
|
||||
dist/manifest.json
|
||||
tools/
|
||||
|
||||
/mengsan/.planning/
|
||||
|
||||
+4
-4
@@ -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();
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 2.8 MiB |
+11
-51
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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;
|
||||
@@ -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",
|
||||
],
|
||||
};
|
||||
@@ -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;
|
||||
@@ -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" },
|
||||
],
|
||||
}],
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,8 @@
|
||||
import actMap from "./map.js";
|
||||
|
||||
export default {
|
||||
map: actMap,
|
||||
nodeContents: {},
|
||||
rewardPools: {},
|
||||
rewards: {},
|
||||
};
|
||||
@@ -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;
|
||||
@@ -0,0 +1,8 @@
|
||||
import actMap from "./map.js";
|
||||
|
||||
export default {
|
||||
map: actMap,
|
||||
nodeContents: {},
|
||||
rewardPools: {},
|
||||
rewards: {},
|
||||
};
|
||||
@@ -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;
|
||||
@@ -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,
|
||||
}));
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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: "苏双&张世平",
|
||||
};
|
||||
@@ -0,0 +1,7 @@
|
||||
import { rewardPools, rewards } from "./rewards.js";
|
||||
|
||||
export default {
|
||||
nodeContents: {},
|
||||
rewardPools,
|
||||
rewards,
|
||||
};
|
||||
@@ -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",
|
||||
],
|
||||
};
|
||||
@@ -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、技能释放和战斗资源清理。
|
||||
身份模式原有的杀反贼摸三张、误杀忠臣弃牌惩罚不沿用;奖励由梦三结算和上述规则决定。
|
||||
统计记录实际战败敌军数量,而非固定每场加一。
|
||||
|
||||
已做隔离配置/规则/事务检查;未启动游戏、未进行实际战斗或界面验收。
|
||||
@@ -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 行为验收。
|
||||
@@ -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和剧情显示。
|
||||
@@ -0,0 +1 @@
|
||||
export { showMap as default, showMap } from "./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;
|
||||
+484
-274
@@ -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 = `<div class="mengsan-choice-name-shuying">${choice.name}</div>${choice.description ? `<div class="mengsan-choice-description-shuying">${choice.description}</div>` : ""}`;
|
||||
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 = `<span>${config.nodeNames[node.type] || node.type}</span>`;
|
||||
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: {},
|
||||
|
||||
@@ -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);
|
||||
};
|
||||
}
|
||||
@@ -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(); },
|
||||
};
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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];
|
||||
}
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
@@ -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};
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -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(/<br\s*\/?\s*>/gi, "\n");
|
||||
return template.content.textContent.trim();
|
||||
};
|
||||
const translate = value => text(lib.translate[value] || get.translation(value) || value);
|
||||
|
||||
export function describeLibraryCard(card) {
|
||||
const info = lib.card[card.name];
|
||||
const nature = typeof card.nature === "string" ? card.nature.split("|").filter(Boolean).map(translate).join("、") : "";
|
||||
return {
|
||||
name: translate(card.name), suit: suits[card.suit] || "无花色",
|
||||
number: ({ 1: "A", 11: "J", 12: "Q", 13: "K" })[card.number] || String(card.number ?? "—"),
|
||||
nature: nature || "无属性", type: types[info?.type] || "未知类别",
|
||||
red: card.suit === "heart" || card.suit === "diamond",
|
||||
description: text(lib.translate[`${card.name}_info`] || "暂无卡牌介绍。"),
|
||||
affixes: (Array.isArray(card.affixes) ? card.affixes : []).map(key => affixes[key] || `词缀:${translate(key)}`),
|
||||
upgrade: Number.isFinite(card.upgrade) ? card.upgrade : 0,
|
||||
available: Boolean(info),
|
||||
};
|
||||
}
|
||||
|
||||
function element(tag, className, value, parent) {
|
||||
const node = document.createElement(tag);
|
||||
node.className = className;
|
||||
if (value != null) node.textContent = value;
|
||||
parent?.appendChild(node);
|
||||
return node;
|
||||
}
|
||||
|
||||
function button(label, parent, action, className = "ms-deck-button") {
|
||||
const node = element("button", className, label, parent);
|
||||
node.type = "button";
|
||||
node.addEventListener("click", action);
|
||||
return node;
|
||||
}
|
||||
|
||||
// Read-only DOM previews, never game.createCard or Card.init: no IDs, triggers or pile writes.
|
||||
function preview(card, details, parent) {
|
||||
const face = element("div", "ms-deck-face", null, parent);
|
||||
element("span", `ms-deck-corner${details.red ? " ms-deck-red" : ""}`, `${details.suit} ${details.number}`, face);
|
||||
const art = element("div", "ms-deck-art", null, face);
|
||||
element("span", "ms-deck-glyph", details.name, art);
|
||||
const imageName = lib.card[card.name]?.cardimage || card.name;
|
||||
// Only mode-standard local art paths; unknown or missing art keeps the text fallback.
|
||||
if (/^[a-zA-Z0-9_]+$/.test(imageName)) {
|
||||
const image = element("img", "ms-deck-image", null, art);
|
||||
image.alt = "";
|
||||
image.loading = "lazy";
|
||||
image.addEventListener("error", () => image.remove(), { once: true });
|
||||
image.src = `${lib.assetURL || ""}image/card/${imageName}.png`;
|
||||
}
|
||||
element("strong", "ms-deck-name", details.name, face);
|
||||
element("span", "ms-deck-meta", `${details.type} · ${details.nature}`, face);
|
||||
if (details.upgrade || details.affixes.length) {
|
||||
element("span", "ms-deck-tags", [details.upgrade ? `强化 +${details.upgrade}` : "", ...details.affixes.map(value => value.split(":")[0])].filter(Boolean).join(" · "), face);
|
||||
}
|
||||
return face;
|
||||
}
|
||||
|
||||
export function openCardLibrary(run, options = {}) {
|
||||
const existing = document.querySelector("dialog.mengsan-deck-dialog-shuying[open]");
|
||||
if (existing) { existing.focus(); return () => {}; }
|
||||
const previousFocus = document.activeElement;
|
||||
const dialog = element("dialog", "mengsan-deck-dialog-shuying", null);
|
||||
dialog.setAttribute("aria-label", options.title || "卡牌库");
|
||||
const header = element("header", "ms-deck-header", null, dialog);
|
||||
const title = element("div", "ms-deck-title-group", null, header);
|
||||
element("span", "ms-deck-eyebrow", options.eyebrow || "梦三 · 行囊", title);
|
||||
element("h2", "ms-deck-title", options.title || "卡牌库", title);
|
||||
let closed = false;
|
||||
const close = () => {
|
||||
if (closed) return;
|
||||
closed = true;
|
||||
if (dialog.open) dialog.close();
|
||||
dialog.remove();
|
||||
if (previousFocus?.isConnected) previousFocus.focus();
|
||||
};
|
||||
button("关闭", header, close);
|
||||
const summary = element("p", "ms-deck-summary", null, dialog);
|
||||
const body = element("div", "ms-deck-body", null, dialog);
|
||||
const footer = element("p", "ms-deck-footer", "点击卡牌查看完整介绍 · 仅供查看,不改变牌组或抽牌顺序", dialog);
|
||||
const current = Array.isArray(run?.player?.deck) ? run.player.deck : [];
|
||||
let detail = false, selectedButton = null, scrollTop = 0;
|
||||
const list = element("div", "ms-deck-grid", null, body);
|
||||
const detailPanel = element("section", "ms-deck-detail", null, body);
|
||||
detailPanel.hidden = true;
|
||||
function back() {
|
||||
detail = false; detailPanel.hidden = true; list.hidden = false;
|
||||
detailPanel.replaceChildren(); body.scrollTop = scrollTop;
|
||||
selectedButton?.focus({ preventScroll: true });
|
||||
}
|
||||
function showDetail(card, details, owner) {
|
||||
selectedButton = owner; scrollTop = body.scrollTop;
|
||||
detail = true; list.hidden = true; detailPanel.hidden = false;
|
||||
detailPanel.replaceChildren();
|
||||
const backButton = button("返回牌库", detailPanel, back);
|
||||
const content = element("div", "ms-deck-detail-content", null, detailPanel);
|
||||
preview(card, details, content);
|
||||
const copy = element("div", "ms-deck-detail-copy", null, content);
|
||||
element("h3", "", details.name, copy);
|
||||
element("p", "", `${details.suit} ${details.number} · ${details.type} · ${details.nature}`, copy);
|
||||
element("h4", "", "卡牌介绍", copy);
|
||||
element("p", "ms-deck-rules", details.description, copy);
|
||||
if (!details.available) element("p", "ms-deck-warning", "该卡牌定义未加载;保留存档记录,不删除此牌。", copy);
|
||||
if (details.upgrade) element("p", "", `强化等级:${details.upgrade}(实际效果以玩法规则为准)`, copy);
|
||||
for (const affix of details.affixes) element("p", "ms-deck-rules", affix, copy);
|
||||
body.scrollTop = 0; backButton.focus();
|
||||
}
|
||||
function render() {
|
||||
detail = false; list.hidden = false; detailPanel.hidden = true;
|
||||
detailPanel.replaceChildren(); list.replaceChildren(); body.scrollTop = 0;
|
||||
const sections = options.sections ? options.sections() : [{ title: "", cards: current }];
|
||||
summary.textContent = options.sections ? "剩余牌堆按从左到右、从上到下的顺序摸取;第 1 张为下一张。" : `本次征程实际携带 · 共 ${current.length} 张 · 同名不同花色、点数分别展示`;
|
||||
for (const section of sections) {
|
||||
const cards = section.cards;
|
||||
if (section.title) element("h3", "ms-deck-section", `${section.title} · ${cards.length} 张`, list);
|
||||
if (!cards.length) element("p", "ms-deck-empty", section.ordered ? "剩余牌堆为空;下一次摸牌需按规则洗切弃牌堆,洗切后的顺序尚未确定。" : "此处暂无卡牌。", list);
|
||||
cards.forEach((card, index) => {
|
||||
const details = describeLibraryCard(card);
|
||||
const tile = button("", list, () => showDetail(card, details, tile), "ms-deck-card");
|
||||
tile.setAttribute("aria-label", `${section.ordered ? `第 ${index + 1} 张,` : ""}${details.name},${details.suit}${details.number},${details.nature},查看详情`);
|
||||
if (section.ordered) element("span", "ms-deck-order", index === 0 ? "1 · 下一张" : `${index + 1}`, tile);
|
||||
preview(card, details, tile);
|
||||
element("span", "ms-deck-excerpt", details.description, tile);
|
||||
});
|
||||
}
|
||||
}
|
||||
dialog.addEventListener("cancel", event => { event.preventDefault(); if (detail) back(); else close(); });
|
||||
dialog.addEventListener("close", close);
|
||||
// Prevent the map's Escape/Tab handler from acting behind this modal.
|
||||
dialog.addEventListener("keydown", event => event.stopPropagation());
|
||||
render();
|
||||
document.body.appendChild(dialog);
|
||||
try { dialog.showModal(); } catch (error) { close(); throw error; }
|
||||
header.querySelector("button").focus();
|
||||
close.refresh = () => {
|
||||
if (closed) return;
|
||||
// Rebuild from live arrays after a pile mutation, including while viewing details.
|
||||
const wasDetail = detail, offset = body.scrollTop;
|
||||
render(); body.scrollTop = offset;
|
||||
if (wasDetail) header.querySelector("button").focus();
|
||||
};
|
||||
return close;
|
||||
}
|
||||
|
||||
// Only the player's two mode-owned piles are exposed. No hand/equipment/exhaust/global pile.
|
||||
export function mountBattlePiles(session, battle) {
|
||||
let closeDialog = null, disposed = false;
|
||||
const control = button("牌堆", ui.window, () => {
|
||||
if (disposed || !session.active || battle.resolving) return;
|
||||
closeDialog?.();
|
||||
closeDialog = openCardLibrary(null, {
|
||||
title: "战斗牌堆", eyebrow: "梦三 · 个人牌堆",
|
||||
sections: () => [
|
||||
{ title: "剩余牌堆", cards: battle.drawPile, ordered: true },
|
||||
{ title: "弃牌堆", cards: battle.discardPile, ordered: false },
|
||||
],
|
||||
});
|
||||
}, "mengsan-battle-piles-button-shuying");
|
||||
control.setAttribute("aria-haspopup", "dialog");
|
||||
const dispose = () => {
|
||||
if (disposed) return;
|
||||
disposed = true; closeDialog?.(); closeDialog = null; control.remove();
|
||||
};
|
||||
session.ownResource(control, dispose);
|
||||
return { dispose, refresh() { if (!disposed) closeDialog?.refresh?.(); } };
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
// 梦三独有的出牌经济,不修改 noname 的全局牌定义。
|
||||
import { _status } from "../../../../noname.js";
|
||||
export const PLAYER_ENERGY = 3;
|
||||
export const PLAYER_HAND_LIMIT = 3;
|
||||
export const SHA_DAMAGE = 4;
|
||||
|
||||
const COSTS = Object.freeze({ sha: 1, tao: 1, jiu: 1, shan: 0, wuxie: 0,
|
||||
juedou: 2, nanman: 2, wanjian: 2, taoyuan: 2 });
|
||||
|
||||
export const cardCost = card => COSTS[card?.name] ?? 1;
|
||||
|
||||
export const isActiveCardUse = (event, player) => {
|
||||
if (!event || !player) return false;
|
||||
const seen = new Set();
|
||||
let inPhaseUse = false;
|
||||
for (let current = event; current && !seen.has(current); current = current.parent) {
|
||||
seen.add(current);
|
||||
if (current.name === "dying" || current.name === "chooseToRespond") return false;
|
||||
if (current.name === "chooseToUse" && (current.respondTo || String(current.type || "").startsWith("respond"))) return false;
|
||||
if (current.name === "phaseUse" && current.player === player) inPhaseUse = true;
|
||||
}
|
||||
// 某些扩展创建的主动 useCard 事件不保留 phaseUse 父链。
|
||||
return inPhaseUse || (event.name === "useCard" && event.player === player &&
|
||||
_status.currentPhase === player);
|
||||
};
|
||||
|
||||
export const canPayCard = (player, card) => (player?.storage?.mengsanEnergy_shuying ?? Infinity) >= cardCost(card);
|
||||
|
||||
export const payCard = (player, card) => {
|
||||
if (!canPayCard(player, card)) return false;
|
||||
player.storage.mengsanEnergy_shuying -= cardCost(card);
|
||||
return true;
|
||||
};
|
||||
@@ -0,0 +1,17 @@
|
||||
export const isDialogueLineVisible = (line, run) => !line.when || run?.storyFlags?.[line.when.flag] === line.when.equals;
|
||||
|
||||
export const applyDialogueChoice = (run, line, choiceId) => {
|
||||
const choice = line.choices?.find(item => item.id === choiceId);
|
||||
if (!choice || !run || !/^story\.[\w.]+$/.test(line.flag || "")) throw new Error("梦三剧情发言选项无效");
|
||||
run.storyFlags ||= {};
|
||||
run.storyFlags[line.flag] = choice.id;
|
||||
return choice.text;
|
||||
};
|
||||
|
||||
export const applySkippedDialogueChoices = (lines, startIndex, run) => {
|
||||
for (let index = startIndex; index < lines.length; index++) {
|
||||
const line = lines[index];
|
||||
if (!isDialogueLineVisible(line, run) || line.type !== "choice") continue;
|
||||
applyDialogueChoice(run, line, line.defaultChoice || line.choices[0].id);
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,192 @@
|
||||
import { mountMenu } from "./navigation.js";
|
||||
import { ui, get } from "../../../../noname.js";
|
||||
import { isDialogueLineVisible, applyDialogueChoice, applySkippedDialogueChoices } from "./dialogue-choice.js";
|
||||
|
||||
const TYPE_NAMES = {
|
||||
character: "角色",
|
||||
narrator: "旁白",
|
||||
sound: "音效",
|
||||
choice: "选择发言",
|
||||
};
|
||||
|
||||
|
||||
const resolveCharacter = (character, run) => character == "$player" ? run?.player?.character : character;
|
||||
|
||||
const resolveSpeaker = (line, character) => {
|
||||
if (line.speaker == "$player") return character ? get.translation(character) : "你";
|
||||
if (line.speaker) return line.speaker;
|
||||
if (line.type == "character" && character) return get.translation(character);
|
||||
return TYPE_NAMES[line.type] || "旁白";
|
||||
};
|
||||
|
||||
export const splitDialogueText = text => Array.from(String(text || ""));
|
||||
|
||||
export const playDialogue = (lines, options = {}) => new Promise(resolve => {
|
||||
if (!Array.isArray(lines) || !lines.length) {
|
||||
resolve();
|
||||
return;
|
||||
}
|
||||
|
||||
const overlay = ui.create.div(".mengsan-dialogue-overlay-shuying", ui.window);
|
||||
const stage = ui.create.div(".mengsan-dialogue-stage-shuying", overlay);
|
||||
ui.create.div(".mengsan-dialogue-chapter-shuying", options.title || "剧情", stage);
|
||||
const portraitFrame = ui.create.div(".mengsan-dialogue-portrait-frame-shuying", stage);
|
||||
const textBox = ui.create.div(".mengsan-dialogue-box-shuying", stage);
|
||||
const speaker = ui.create.div(".mengsan-dialogue-speaker-shuying", textBox);
|
||||
const text = ui.create.div(".mengsan-dialogue-text-shuying", textBox);
|
||||
const progress = ui.create.div(".mengsan-dialogue-progress-shuying", textBox);
|
||||
const indicator = ui.create.div(".mengsan-dialogue-indicator-shuying", "◆", textBox);
|
||||
const choices = ui.create.div(".mengsan-dialogue-choices-shuying", stage);
|
||||
const skipButton = document.createElement("button");
|
||||
skipButton.type = "button";
|
||||
skipButton.className = "mengsan-dialogue-skip-shuying";
|
||||
skipButton.textContent = "跳过对话";
|
||||
skipButton.setAttribute("aria-label", "跳过对话,未选择的发言使用默认选项");
|
||||
stage.appendChild(skipButton);
|
||||
|
||||
let lineIndex = -1;
|
||||
let timer = null;
|
||||
let typing = false;
|
||||
let paused = false;
|
||||
let currentSpeed = 28;
|
||||
let characters = [];
|
||||
let characterIndex = 0;
|
||||
let currentLine = null;
|
||||
let awaitingChoice = false;
|
||||
const portrait = ui.create.div(".mengsan-dialogue-portrait-shuying", portraitFrame);
|
||||
const reduceMotion = globalThis.matchMedia?.("(prefers-reduced-motion: reduce)")?.matches;
|
||||
|
||||
const stopTimer = () => {
|
||||
if (timer != null) clearTimeout(timer);
|
||||
timer = null;
|
||||
};
|
||||
|
||||
const finishTyping = () => {
|
||||
stopTimer();
|
||||
text.textContent = characters.join("");
|
||||
characterIndex = characters.length;
|
||||
typing = false;
|
||||
if (currentLine?.type === "choice" && awaitingChoice) showChoices(currentLine);
|
||||
else indicator.classList.add("visible");
|
||||
};
|
||||
|
||||
function showChoices(line) {
|
||||
choices.replaceChildren();
|
||||
for (const item of line.choices) {
|
||||
const button = document.createElement("button");
|
||||
button.type = "button";
|
||||
button.className = "mengsan-dialogue-choice-shuying";
|
||||
button.textContent = item.text;
|
||||
button.addEventListener("click", event => {
|
||||
event.stopPropagation();
|
||||
if (!awaitingChoice) return;
|
||||
text.textContent = applyDialogueChoice(options.run, line, item.id);
|
||||
awaitingChoice = false;
|
||||
choices.replaceChildren();
|
||||
speaker.textContent = get.translation(options.run.player.character);
|
||||
stage.dataset.lineType = "character";
|
||||
const character = options.run.player.character;
|
||||
portraitFrame.classList.add("visible");
|
||||
portrait.setBackground(character, "character");
|
||||
indicator.classList.add("visible");
|
||||
textBox.focus();
|
||||
});
|
||||
choices.appendChild(button);
|
||||
}
|
||||
choices.querySelector("button")?.focus();
|
||||
}
|
||||
|
||||
const scheduleCharacter = speed => {
|
||||
if (!typing || paused) return;
|
||||
text.textContent += characters[characterIndex++];
|
||||
if (characterIndex >= characters.length) {
|
||||
finishTyping();
|
||||
return;
|
||||
}
|
||||
timer = setTimeout(() => scheduleCharacter(speed), speed);
|
||||
};
|
||||
|
||||
const close = () => {
|
||||
stopTimer();
|
||||
skipButton.removeEventListener("click", handleSkip);
|
||||
textBox.removeEventListener("click", handleClick);
|
||||
textBox.removeEventListener("keydown", handleKeydown);
|
||||
choices.replaceChildren();
|
||||
overlay.remove();
|
||||
resolve();
|
||||
};
|
||||
|
||||
const showNextLine = () => {
|
||||
do { lineIndex++; } while (lineIndex < lines.length && !isDialogueLineVisible(lines[lineIndex], options.run));
|
||||
if (lineIndex >= lines.length) {
|
||||
close();
|
||||
return;
|
||||
}
|
||||
|
||||
const line = lines[lineIndex];
|
||||
currentLine = line;
|
||||
awaitingChoice = line.type === "choice";
|
||||
choices.replaceChildren();
|
||||
const lineType = TYPE_NAMES[line.type] ? line.type : "narrator";
|
||||
const character = resolveCharacter(line.character, options.run);
|
||||
stage.dataset.lineType = lineType;
|
||||
speaker.textContent = resolveSpeaker(line, character);
|
||||
progress.textContent = `${lineIndex + 1} / ${lines.length}`;
|
||||
indicator.classList.remove("visible");
|
||||
|
||||
if (lineType == "character" && character) {
|
||||
portraitFrame.classList.add("visible");
|
||||
portrait.setBackground(character, "character");
|
||||
portrait.setAttribute("aria-label", `${speaker.textContent}立绘`);
|
||||
}
|
||||
else {
|
||||
portraitFrame.classList.remove("visible");
|
||||
portrait.removeAttribute("aria-label");
|
||||
}
|
||||
|
||||
characters = splitDialogueText(line.text);
|
||||
characterIndex = 0;
|
||||
text.textContent = "";
|
||||
typing = characters.length > 0;
|
||||
const speed = Number.isFinite(line.speed) ? Math.max(0, line.speed) : 28;
|
||||
currentSpeed = speed;
|
||||
if (!typing || reduceMotion || speed == 0) finishTyping();
|
||||
else scheduleCharacter(speed);
|
||||
};
|
||||
|
||||
function handleClick(event) {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
if (paused) return;
|
||||
if (typing) finishTyping();
|
||||
else if (awaitingChoice) return;
|
||||
else showNextLine();
|
||||
}
|
||||
|
||||
function handleKeydown(event) {
|
||||
if (event.key == "Enter" || event.key == " ") handleClick(event);
|
||||
}
|
||||
|
||||
function handleSkip(event) {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
if (paused) return;
|
||||
applySkippedDialogueChoices(lines, lineIndex + (awaitingChoice ? 0 : 1), options.run);
|
||||
close();
|
||||
}
|
||||
|
||||
textBox.setAttribute("role", "button");
|
||||
textBox.setAttribute("tabindex", "0");
|
||||
textBox.setAttribute("aria-label", "剧情文本:点击补全文字或继续");
|
||||
textBox.addEventListener("click", handleClick);
|
||||
textBox.addEventListener("keydown", handleKeydown);
|
||||
skipButton.addEventListener("click", handleSkip);
|
||||
mountMenu(overlay, {
|
||||
onOpen() { paused = true; stopTimer(); },
|
||||
onClose() { paused = false; if (typing) scheduleCharacter(currentSpeed); },
|
||||
});
|
||||
const help = ui.create.div(".mengsan-dialogue-help-shuying", stage);
|
||||
help.textContent = "点击补全文字 · 再次点击继续 · Enter / 空格";
|
||||
showNextLine();
|
||||
textBox.focus();
|
||||
});
|
||||
@@ -0,0 +1,83 @@
|
||||
// Candidate only: attach to fresh, session-owned roots BEFORE scheduling them.
|
||||
const attached = new WeakSet();
|
||||
export function observeFreshEvent(session, event) {
|
||||
if (!event || typeof event.start !== "function" || !event.manager?.eventStack ||
|
||||
event.finished || event.manager.eventStack.includes(event) ||
|
||||
event.parent?.childEvents?.includes(event) || attached.has(event)) {
|
||||
throw new Error("Fresh, unobserved engine event required");
|
||||
}
|
||||
const descriptor = Object.getOwnPropertyDescriptor(event, "start");
|
||||
if (descriptor && !descriptor.configurable) throw new Error("Event start is not configurable");
|
||||
const original = event.start;
|
||||
let resolve, reject;
|
||||
const completion = new Promise((yes, no) => { resolve = yes; reject = no; });
|
||||
session.ownEvent(event, completion);
|
||||
let observed = false;
|
||||
function start(...args) {
|
||||
let result;
|
||||
try { result = Reflect.apply(original, this, args); }
|
||||
catch (error) { reject(error); throw error; }
|
||||
if (!observed) {
|
||||
observed = true;
|
||||
if (!(result instanceof Promise)) {
|
||||
reject(new TypeError("Native engine start promise required"));
|
||||
} else {
|
||||
result.then(() => {
|
||||
if (event.manager.eventStack.includes(event) || event.manager.tempEvent === event) {
|
||||
reject(new Error("Event still active after start completion"));
|
||||
} else resolve();
|
||||
}, reject);
|
||||
}
|
||||
}
|
||||
return result; // Never assimilate event.then(), never proactively start a queue.
|
||||
}
|
||||
Object.defineProperty(event, "start", { configurable: true, writable: true, value: start });
|
||||
attached.add(event);
|
||||
session.ownResource({}, () => {
|
||||
if (event.start === start) {
|
||||
if (descriptor) Object.defineProperty(event, "start", descriptor);
|
||||
else delete event.start;
|
||||
}
|
||||
attached.delete(event);
|
||||
});
|
||||
return completion;
|
||||
}
|
||||
|
||||
// Independent delay entry: releasing it never calls global resume/pause2/over.resolve.
|
||||
export function acquirePauseLease(session, pauseManager) {
|
||||
if (!session.active || typeof pauseManager?.setDelay !== "function") throw new Error("Active session and pause manager required");
|
||||
let unlock;
|
||||
const gate = new Promise(resolve => { unlock = resolve; });
|
||||
const settled = pauseManager.setDelay(gate);
|
||||
let released = false;
|
||||
const lease = Object.freeze({
|
||||
release() { if (!released) { released = true; unlock(); } return settled; },
|
||||
get released() { return released; },
|
||||
});
|
||||
session.ownResource(lease, () => lease.release());
|
||||
return lease;
|
||||
}
|
||||
|
||||
// Call outside tracked event/task bodies. Own pauses must be lifted before drain.
|
||||
export async function stopAndDrain(session, leases = []) {
|
||||
session.requestStop();
|
||||
await Promise.all(leases.map(lease => lease.release()));
|
||||
await session.drain();
|
||||
}
|
||||
|
||||
// Only remove this exact node from the parent recorded at acquisition.
|
||||
export function ownNode(session, node) {
|
||||
const parent = node.parentNode;
|
||||
if (!parent) throw new Error("Attached node required");
|
||||
return session.ownResource(node, () => {
|
||||
if (node.parentNode === parent) parent.removeChild(node);
|
||||
});
|
||||
}
|
||||
export function ownListener(session, target, type, listener, options) {
|
||||
const guarded = session.guard(listener);
|
||||
const capture = typeof options === "boolean" ? options : !!options?.capture;
|
||||
const token = {};
|
||||
session.ownResource(token, () => target.removeEventListener(type, guarded, capture));
|
||||
target.addEventListener(type, guarded, options);
|
||||
return guarded;
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
import { ui } from "../../../../noname.js";
|
||||
import { mountMenu } from "./navigation.js";
|
||||
|
||||
export const chooseButtons = (title, choices, description = "", options = {}) => new Promise(resolve => {
|
||||
const previousFocus = document.activeElement;
|
||||
const overlay = ui.create.div(".mengsan-overlay-shuying.mengsan-flow-overlay-shuying", ui.window);
|
||||
overlay.setAttribute("role", "dialog");
|
||||
overlay.setAttribute("aria-modal", "true");
|
||||
overlay.setAttribute("aria-label", title);
|
||||
const panel = ui.create.div(".mengsan-panel-shuying.mengsan-flow-panel-shuying", overlay);
|
||||
const eyebrow = document.createElement("p");
|
||||
eyebrow.className = "mengsan-flow-eyebrow-shuying";
|
||||
eyebrow.textContent = options.eyebrow || "梦三 · 征程";
|
||||
panel.appendChild(eyebrow);
|
||||
const heading = ui.create.div(".mengsan-title-shuying", panel);
|
||||
heading.textContent = title;
|
||||
heading.setAttribute("role", "heading");
|
||||
heading.setAttribute("aria-level", "1");
|
||||
if (description) {
|
||||
const summary = ui.create.div(".mengsan-description-shuying", panel);
|
||||
summary.textContent = description;
|
||||
}
|
||||
const buttons = ui.create.div(".mengsan-choice-list-shuying", panel);
|
||||
const finish = id => {
|
||||
if (overlay.dataset.resolved) return;
|
||||
overlay.dataset.resolved = "true";
|
||||
overlay.remove();
|
||||
if (previousFocus?.isConnected) previousFocus.focus();
|
||||
resolve(id);
|
||||
};
|
||||
choices.forEach(choice => {
|
||||
const button = document.createElement("button");
|
||||
button.type = "button";
|
||||
button.className = "mengsan-choice-shuying";
|
||||
button.dataset.choice = choice.id;
|
||||
if (choice.danger) button.classList.add("danger");
|
||||
const name = document.createElement("span");
|
||||
name.className = "mengsan-choice-name-shuying";
|
||||
name.textContent = choice.name;
|
||||
button.appendChild(name);
|
||||
if (choice.description) {
|
||||
const detail = document.createElement("span");
|
||||
detail.className = "mengsan-choice-description-shuying";
|
||||
detail.textContent = choice.description;
|
||||
button.appendChild(detail);
|
||||
}
|
||||
button.disabled = Boolean(choice.disabled);
|
||||
button.addEventListener("click", () => finish(choice.id));
|
||||
buttons.appendChild(button);
|
||||
});
|
||||
mountMenu(overlay);
|
||||
overlay.addEventListener("keydown", event => {
|
||||
if (event.key === "Escape" && options.back) {
|
||||
event.preventDefault(); finish(options.back); return;
|
||||
}
|
||||
if (event.key !== "Tab") return;
|
||||
const focusable = [...overlay.querySelectorAll("button:not(:disabled)")];
|
||||
const first = focusable[0], last = focusable[focusable.length - 1];
|
||||
if (event.shiftKey && document.activeElement === first) { event.preventDefault(); last.focus(); }
|
||||
else if (!event.shiftKey && document.activeElement === last) { event.preventDefault(); first.focus(); }
|
||||
});
|
||||
buttons.querySelector("button:not(:disabled)")?.focus();
|
||||
});
|
||||
@@ -0,0 +1,91 @@
|
||||
// Candidate factory. Storage writer body reused from batch2 MODIFIED_STORAGE.js.
|
||||
export function createModeStorage({lib, game, config, localStorage, alert = () => {}, console = globalThis.console}) {
|
||||
const MODE_ID = config.modeId;
|
||||
|
||||
|
||||
const copy = value => JSON.parse(JSON.stringify(value));
|
||||
|
||||
// Keep one mode-level writer; a failed write must not poison later retries.
|
||||
let modeStorageQueue = Promise.resolve();
|
||||
|
||||
const writeModeStorage = storage => {
|
||||
if (!lib.db) {
|
||||
// Do not switch backends after an IndexedDB error: that creates two saves.
|
||||
localStorage.setItem(`${lib.configprefix}${MODE_ID}`, JSON.stringify(storage));
|
||||
return Promise.resolve();
|
||||
}
|
||||
return new Promise((resolve, reject) => {
|
||||
let transaction;
|
||||
let requestError;
|
||||
try {
|
||||
// game.putDB resolves at request success, which is before commit.
|
||||
transaction = lib.db.transaction(["data"], "readwrite");
|
||||
transaction.oncomplete = () => resolve();
|
||||
transaction.onerror = event => {
|
||||
requestError = event.target?.error;
|
||||
};
|
||||
transaction.onabort = () => reject(transaction.error || requestError || new Error("梦三存档事务已中止"));
|
||||
transaction.objectStore("data").put(storage, MODE_ID);
|
||||
}
|
||||
catch (error) {
|
||||
if (transaction) {
|
||||
try { transaction.abort(); }
|
||||
catch {} // Already inactive; keep the original failure.
|
||||
}
|
||||
reject(error);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const persistModeStorage = (update = () => {}) => {
|
||||
// Hold the engine reload guard from enqueue through commit and publication.
|
||||
// reload2 also drains the engine's queued database operations on release.
|
||||
lib.status.reload++;
|
||||
const operation = modeStorageQueue.then(async () => {
|
||||
const storage = copy(lib.storage);
|
||||
update(storage);
|
||||
storage.version = lib.version;
|
||||
await writeModeStorage(storage);
|
||||
lib.storage = storage;
|
||||
}).finally(() => game.reload2());
|
||||
modeStorageQueue = operation.catch(() => {});
|
||||
return operation;
|
||||
};
|
||||
|
||||
const saveRun = async run => {
|
||||
try {
|
||||
// Capture before enqueue: subsequent gameplay mutations cannot alter this save.
|
||||
const snapshot = copy(run);
|
||||
await persistModeStorage(storage => {
|
||||
storage[config.saveKey] = snapshot;
|
||||
});
|
||||
}
|
||||
catch (error) {
|
||||
// Neither delete the previous save nor attempt a second destructive write.
|
||||
alert("梦三存档失败,未覆盖上一次成功保存的进度。当前进度尚未保存,请勿刷新,并查看控制台错误。");
|
||||
console.error("梦三存档失败:", error);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
const clearRun = async () => {
|
||||
await persistModeStorage(storage => {
|
||||
delete storage[config.saveKey];
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
|
||||
return Object.freeze({
|
||||
read: () => copy(lib.storage),
|
||||
saveRun, clearRun,
|
||||
async update(mutator) {
|
||||
let value;
|
||||
await persistModeStorage(storage => {
|
||||
value = mutator(storage);
|
||||
if (value && typeof value.then === "function") throw new TypeError("Storage mutator must be synchronous");
|
||||
});
|
||||
return value === undefined ? undefined : copy(value);
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
// 梦三怪物规则只作用于敌方单位;子类可覆盖构造参数或 createDeck()。
|
||||
const TIER_RULES = Object.freeze({
|
||||
normal: Object.freeze({ draw: 1, handLimit: 3, energy: 2 }),
|
||||
elite: Object.freeze({ draw: 2, handLimit: 5, energy: 3 }),
|
||||
boss: Object.freeze({ draw: 3, handLimit: 7, energy: 4 }),
|
||||
});
|
||||
|
||||
const BASE_DECKS = Object.freeze({
|
||||
normal: Object.freeze(["sha", "sha", "sha", "sha", "shan", "shan", "tao", "jiu", "guohe", "sha"]),
|
||||
elite: Object.freeze(["sha", "sha", "sha", "sha", "shan", "shan", "shan", "tao", "jiu", "juedou", "wuzhong", "guohe"]),
|
||||
boss: Object.freeze(["sha", "sha", "sha", "sha", "sha", "shan", "shan", "shan", "tao", "tao", "jiu", "jiu", "juedou", "wuzhong", "guohe"]),
|
||||
});
|
||||
|
||||
const HEALTH = Object.freeze({
|
||||
mengsan_soldier_shuying: 24,
|
||||
re_xiahoudun: 30, re_zhangliao: 28, re_xuzhu: 34, re_lvbu: 120,
|
||||
re_ganning: 39, re_zhangfei: 42, re_huangzhong: 37, re_weiyan: 40,
|
||||
re_guanyu: 65, dianwei: 62, shen_guanyu: 165,
|
||||
re_simayi: 47, re_sunben: 49, dongzhuo: 55, re_yuanshao: 52,
|
||||
shen_zhaoyun: 76, shen_simayi: 80, shen_caocao: 210,
|
||||
});
|
||||
const validDeckCard = entry => typeof entry === "string" ? Boolean(entry) : Boolean(entry &&
|
||||
typeof entry.name === "string" && entry.name &&
|
||||
(entry.suit == null || ["spade", "heart", "club", "diamond"].includes(entry.suit)) &&
|
||||
(entry.number == null || Number.isInteger(entry.number) && entry.number >= 1 && entry.number <= 13));
|
||||
|
||||
export class Monster {
|
||||
constructor({ character, tier = "normal", hp, deck, draw, handLimit, energy } = {}) {
|
||||
if (!TIER_RULES[tier]) throw new Error("梦三怪物阶级无效:" + tier);
|
||||
if (typeof character !== "string" || !character) throw new Error("梦三怪物缺少武将ID");
|
||||
const defaults = TIER_RULES[tier];
|
||||
this.character = character;
|
||||
this.tier = tier;
|
||||
this.hp = hp ?? HEALTH[character] ?? (tier === "boss" ? 110 : tier === "elite" ? 60 : 32);
|
||||
this.draw = draw ?? defaults.draw;
|
||||
this.handLimit = handLimit ?? defaults.handLimit;
|
||||
this.energy = energy ?? defaults.energy;
|
||||
this.deck = deck || BASE_DECKS[tier];
|
||||
if (![this.hp, this.draw, this.handLimit, this.energy].every(value => Number.isInteger(value) && value > 0)) throw new Error("梦三怪物数值无效");
|
||||
if (!Array.isArray(this.deck) || !this.deck.length || this.deck.some(entry => !validDeckCard(entry))) throw new Error("梦三怪物牌堆无效");
|
||||
}
|
||||
|
||||
createDeck(prefix) {
|
||||
return this.deck.map((entry, index) => {
|
||||
const card = typeof entry === "string" ? { name: entry } : entry;
|
||||
return {
|
||||
id: `${prefix}_${index}`, name: card.name,
|
||||
suit: card.suit || ["spade", "heart", "club", "diamond"][index % 4],
|
||||
number: card.number || index % 13 + 1,
|
||||
nature: card.nature || null, affixes: Array.isArray(card.affixes) ? card.affixes.slice() : [], upgrade: card.upgrade || 0,
|
||||
};
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// 专属怪物示例:以后可在子类中覆写任意默认行为。
|
||||
export class OpeningSoldier extends Monster {
|
||||
constructor(options = {}) { super({ character: "mengsan_soldier_shuying", tier: "normal", hp: 24, ...options }); }
|
||||
}
|
||||
|
||||
// 新怪物子类在这里注册,未注册的角色使用 Monster 的阶级默认值。
|
||||
export const MONSTER_TYPES = Object.freeze({ mengsan_soldier_shuying: OpeningSoldier });
|
||||
|
||||
export function createMonster(spec) {
|
||||
const Type = Object.hasOwn(MONSTER_TYPES, spec.character) ? MONSTER_TYPES[spec.character] : Monster;
|
||||
return new Type(spec);
|
||||
}
|
||||
|
||||
export { TIER_RULES, BASE_DECKS };
|
||||
@@ -0,0 +1,120 @@
|
||||
import { lib, game, ui } from "../../../../noname.js";
|
||||
import { save } from "../../../../noname/util/config.js";
|
||||
|
||||
let selectingMode = false;
|
||||
|
||||
// Reuse the registered engine splash, not reload-to-the-current-mode or a fake mode list.
|
||||
export const openModeSelection = async () => {
|
||||
if (selectingMode) return;
|
||||
const splash = lib.onloadSplashes.find(item => item.id === lib.config.splash_style) || lib.onloadSplashes[0];
|
||||
if (!splash) throw new Error("模式选择界面未加载,请稍后重试。");
|
||||
selectingMode = true;
|
||||
const previousSplash = window.inSplash;
|
||||
const previousFocus = document.activeElement;
|
||||
const previousInert = ui.window.inert;
|
||||
const node = ui.create.div("#splash", document.body);
|
||||
node.style.zIndex = "1000";
|
||||
window.inSplash = true;
|
||||
ui.window.inert = true;
|
||||
try {
|
||||
let choose;
|
||||
const selected = new Promise(resolve => { choose = resolve; });
|
||||
await splash.init(node, choose);
|
||||
const mode = await selected;
|
||||
if (!lib.config.all.mode.includes(mode)) throw new Error("所选模式不可用。");
|
||||
await save("mode", "config", mode);
|
||||
lib.config.mode = mode;
|
||||
localStorage.setItem(`${lib.configprefix}directstart`, "true");
|
||||
// Reload only AFTER a mode is chosen; retain engine pending-write handling.
|
||||
node.inert = true;
|
||||
game.reload();
|
||||
// Keep the abandoned flow inert while the engine waits for pending DB writes.
|
||||
await new Promise(() => {});
|
||||
} catch (error) {
|
||||
// DefaultSplash owns a Vue app. Clean it up if opening/saving failed.
|
||||
splash.app?.unmount?.();
|
||||
throw error;
|
||||
} finally {
|
||||
node.remove();
|
||||
window.inSplash = previousSplash;
|
||||
ui.window.inert = previousInert;
|
||||
selectingMode = false;
|
||||
if (previousFocus?.isConnected) previousFocus.focus();
|
||||
}
|
||||
};
|
||||
|
||||
export const askMenu = (title, description, choices) => new Promise(resolve => {
|
||||
const previousFocus = document.activeElement;
|
||||
const dialog = document.createElement("dialog");
|
||||
dialog.className = "mengsan-menu-dialog-shuying";
|
||||
dialog.setAttribute("aria-label", title);
|
||||
const heading = document.createElement("h2");
|
||||
heading.textContent = title;
|
||||
const body = document.createElement("p");
|
||||
body.textContent = description;
|
||||
dialog.append(heading, body);
|
||||
let closed = false;
|
||||
const finish = value => {
|
||||
if (closed) return;
|
||||
closed = true;
|
||||
dialog.close();
|
||||
dialog.remove();
|
||||
if (previousFocus?.isConnected) previousFocus.focus();
|
||||
resolve(value);
|
||||
};
|
||||
for (const choice of choices) {
|
||||
const button = document.createElement("button");
|
||||
button.type = "button";
|
||||
button.textContent = choice.name;
|
||||
if (choice.danger) button.className = "danger";
|
||||
button.addEventListener("click", () => finish(choice.id));
|
||||
dialog.appendChild(button);
|
||||
}
|
||||
dialog.addEventListener("cancel", event => { event.preventDefault(); finish(null); });
|
||||
document.body.appendChild(dialog);
|
||||
dialog.showModal();
|
||||
dialog.querySelector("button")?.focus();
|
||||
});
|
||||
|
||||
export const mountMenu = (overlay, options = {}) => {
|
||||
const button = document.createElement("button");
|
||||
button.type = "button";
|
||||
button.className = "mengsan-flow-menu-shuying";
|
||||
const icon = document.createElementNS("http://www.w3.org/2000/svg", "svg");
|
||||
icon.setAttribute("viewBox", "0 0 24 24");
|
||||
icon.setAttribute("aria-hidden", "true");
|
||||
const path = document.createElementNS("http://www.w3.org/2000/svg", "path");
|
||||
path.setAttribute("d", "M4 6h16M4 12h16M4 18h16");
|
||||
icon.appendChild(path);
|
||||
const label = document.createElement("span");
|
||||
label.textContent = "菜单";
|
||||
button.append(icon, label);
|
||||
button.setAttribute("aria-haspopup", "dialog");
|
||||
let opened = false;
|
||||
button.addEventListener("click", async event => {
|
||||
event.stopPropagation();
|
||||
if (opened) return;
|
||||
opened = true;
|
||||
options.onOpen?.();
|
||||
try {
|
||||
const action = await askMenu("征程菜单", "退出不删除存档。未完成的节点与对话将在下次进入时重新开始。", [
|
||||
{ id: "resume", name: "继续当前界面" },
|
||||
{ id: "exit", name: "返回模式选择" },
|
||||
]);
|
||||
if (action !== "exit") return;
|
||||
const confirmed = await askMenu("返回模式选择?", "保留上次已完成节点的存档。本次尚未完成的内容不会提交。", [
|
||||
{ id: "cancel", name: "留在梦三" },
|
||||
{ id: "confirm", name: "确认返回模式选择", danger: true },
|
||||
]);
|
||||
if (confirmed === "confirm") await openModeSelection();
|
||||
} catch (error) {
|
||||
console.error("梦三返回模式选择失败:", error);
|
||||
await askMenu("暂时无法返回", "没有清除征程。请关闭提示后重试。", [{ id: "close", name: "回到当前界面" }]);
|
||||
} finally {
|
||||
opened = false;
|
||||
options.onClose?.();
|
||||
}
|
||||
});
|
||||
overlay.appendChild(button);
|
||||
return button;
|
||||
};
|
||||
@@ -0,0 +1,142 @@
|
||||
// Mode-owned piles use real Card nodes so native searches/gain/insert operations agree.
|
||||
// DIY must access ui piles inside the acting player's engine event, never cache global nodes.
|
||||
import { cardCost } from "./combat-rules.js";
|
||||
|
||||
export function installPersonalPiles(session, owner, battle, run, resources, env) {
|
||||
const { game, ui, get, _status, document, MutationObserver, shuffle } = env;
|
||||
for (const key of ["cardPile", "discardPile"]) {
|
||||
const descriptor = Object.getOwnPropertyDescriptor(ui, key);
|
||||
if (!descriptor || !descriptor.configurable || (!Object.hasOwn(descriptor, "value") && typeof descriptor.get !== "function")) throw new Error("Unsupported pile property: " + key);
|
||||
}
|
||||
const draw = document.createElement("div"), discard = document.createElement("div");
|
||||
// Detached nodes: engine get.position recognises these IDs; no duplicate document IDs.
|
||||
draw.id = "cardPile"; discard.id = "discardPile";
|
||||
const owned = new Set(), restores = [];
|
||||
const strict = env.strict !== false;
|
||||
let released = false, forcedOwner = false;
|
||||
const personal = () => {
|
||||
if (released) return false;
|
||||
if (forcedOwner) return true;
|
||||
const seen = new Set();
|
||||
for (let event = _status.event; event && !seen.has(event); event = event.parent) {
|
||||
seen.add(event);
|
||||
if (event.player) return event.player === owner;
|
||||
}
|
||||
return false; // Never infer ownership from whose turn it is.
|
||||
};
|
||||
const refresh = () => { if (!released) env.refresh(); };
|
||||
function adopt(card, data) {
|
||||
if (owned.has(card)) return;
|
||||
owned.add(card); resources.card(card);
|
||||
card.storage ||= {};
|
||||
if (data) card.storage.mengsanCard_shuying = JSON.parse(JSON.stringify(data));
|
||||
if (data && env.showCosts) {
|
||||
card.dataset.mengsanCost = String(cardCost(data));
|
||||
}
|
||||
// Existing foreign/generated cards retain their original destruction semantics.
|
||||
if (!data) return;
|
||||
card.destroyLog = false;
|
||||
card.destroyed = (current, position) => {
|
||||
if (released || position !== "discardPile") return false;
|
||||
if (data.affixes?.includes("annihilate") && current.storage.mengsanUsed_shuying) {
|
||||
battle.exhaustPile.push(snapshot(current));
|
||||
return true;
|
||||
}
|
||||
return false; // Actual discarded cards remain available to native skills.
|
||||
};
|
||||
if (data.affixes?.includes("annihilate")) card.addGaintag("湮灭");
|
||||
if (data.upgrade) card.addGaintag(`强化+${data.upgrade}`);
|
||||
}
|
||||
function snapshot(card) {
|
||||
const data = card.storage?.mengsanCard_shuying || {};
|
||||
return { ...data, name: card.name, suit: card.suit, number: card.number,
|
||||
nature: card.nature || null, affixes: data.affixes?.slice() || [], upgrade: data.upgrade || 0 };
|
||||
}
|
||||
for (const data of battle.drawPile) {
|
||||
const card = game.createCard(data.name, data.suit, data.number, data.nature);
|
||||
adopt(card, data); draw.appendChild(card);
|
||||
}
|
||||
const publicDiscard = ui.discardPile;
|
||||
for (const [key, node] of [["cardPile", draw], ["discardPile", discard]]) {
|
||||
const descriptor = Object.getOwnPropertyDescriptor(ui, key);
|
||||
let publicNode = descriptor.value;
|
||||
const read = () => personal() ? node : (descriptor.get ? descriptor.get.call(ui) : publicNode);
|
||||
Object.defineProperty(ui, key, { configurable: true, enumerable: descriptor.enumerable,
|
||||
get: read, set(value) { if (descriptor.set) descriptor.set.call(ui, value); else publicNode = value; } });
|
||||
restores.push(() => {
|
||||
if (Object.getOwnPropertyDescriptor(ui, key)?.get === read) Object.defineProperty(ui, key, descriptor.get ? descriptor : { ...descriptor, value: publicNode });
|
||||
});
|
||||
}
|
||||
for (const [key, node] of [["drawPile", draw], ["discardPile", discard]]) {
|
||||
Object.defineProperty(battle, key, { configurable: true, enumerable: true,
|
||||
get: () => Array.from(node.childNodes, snapshot) });
|
||||
}
|
||||
const observer = new MutationObserver(() => {
|
||||
if (released) return;
|
||||
// A card from our deck can be discarded during an enemy-owned event.
|
||||
for (const card of Array.from(publicDiscard.childNodes)) if (owned.has(card)) discard.appendChild(card);
|
||||
for (const node of [draw, discard]) for (const card of node.childNodes) adopt(card);
|
||||
refresh();
|
||||
});
|
||||
for (const node of [draw, discard, publicDiscard]) observer.observe(node, { childList: true });
|
||||
function wash() {
|
||||
const cards = [...draw.childNodes, ...discard.childNodes];
|
||||
if (!cards.length) return false;
|
||||
shuffle(run, cards);
|
||||
for (const card of cards) { card.fix?.(); draw.appendChild(card); }
|
||||
refresh();
|
||||
return true;
|
||||
}
|
||||
function take(number = 1, putBack = false, bottom = false) {
|
||||
if (!Number.isFinite(number) || number <= 0) return [];
|
||||
number = Math.ceil(number);
|
||||
if (draw.childNodes.length + discard.childNodes.length < number) {
|
||||
if (strict) { game.mengsanFailRun_shuying("个人牌堆与弃牌堆不足,挑战失败"); return []; }
|
||||
number = draw.childNodes.length + discard.childNodes.length;
|
||||
}
|
||||
const cards = [];
|
||||
while (cards.length < number) {
|
||||
if (!draw.childNodes.length) wash();
|
||||
const card = draw.removeChild(bottom ? draw.lastChild : draw.firstChild); card.original = "c"; cards.push(card);
|
||||
}
|
||||
if (putBack) for (let i = cards.length - 1; i >= 0; i--) {
|
||||
if (bottom) draw.appendChild(cards[i]); else draw.insertBefore(cards[i], draw.firstChild);
|
||||
}
|
||||
refresh(); return cards;
|
||||
}
|
||||
function wrap(object, key, replacement) {
|
||||
const descriptor = Object.getOwnPropertyDescriptor(object, key), original = object[key];
|
||||
const wrapped = function (...args) { return personal() ? replacement(...args) : original.apply(this, args); };
|
||||
object[key] = wrapped;
|
||||
restores.push(() => { if (object[key] === wrapped) { if (descriptor) Object.defineProperty(object, key, descriptor); else delete object[key]; } });
|
||||
}
|
||||
wrap(get, "cards", (number, putBack) => take(typeof number === "number" ? number : 1, putBack));
|
||||
wrap(get, "bottomCards", (number, putBack) => take(typeof number === "number" ? number : 1, putBack, true));
|
||||
wrap(game, "washCard", wash);
|
||||
session.ownResource(draw, () => {
|
||||
if (released) return;
|
||||
observer.disconnect(); released = true;
|
||||
for (const restore of restores.reverse()) restore();
|
||||
for (const card of owned) card.remove();
|
||||
owned.clear(); draw.remove(); discard.remove();
|
||||
});
|
||||
return {
|
||||
take,
|
||||
// Engine/mod opening draws must not stack with the mode's four-card opening hand.
|
||||
resetOpeningHand() {
|
||||
for (const card of owner.getCards("h")) {
|
||||
if (owned.has(card)) draw.insertBefore(card, draw.firstChild);
|
||||
else card.remove();
|
||||
}
|
||||
refresh();
|
||||
},
|
||||
trimOpeningHand(limit = 4) {
|
||||
for (const card of owner.getCards("h").slice(limit)) {
|
||||
if (owned.has(card)) draw.insertBefore(card, draw.firstChild);
|
||||
else card.remove();
|
||||
}
|
||||
refresh();
|
||||
},
|
||||
withOwner(callback) { const before = forcedOwner; forcedOwner = true; try { return callback(); } finally { forcedOwner = before; } },
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import config from "../config.js";
|
||||
import { nextRandom } from "../state.js";
|
||||
|
||||
export const getRandomRewardChoices = (run, poolId, count = 3) => {
|
||||
const fallbackId = "shared.pool.battle.normal";
|
||||
const pool = (config.rewardPools[poolId] || config.rewardPools[fallbackId]).slice();
|
||||
for (let index = pool.length - 1; index > 0; index--) {
|
||||
const target = Math.floor(nextRandom(run) * (index + 1));
|
||||
[pool[index], pool[target]] = [pool[target], pool[index]];
|
||||
}
|
||||
return pool.slice(0, count).map(id => ({ id, ...config.rewards[id] }));
|
||||
};
|
||||
@@ -0,0 +1,147 @@
|
||||
// Candidate-only engine-derived release. Never replaces Player.prototype or lib.skill.
|
||||
// Restricted: additional/fixed/superCharlotte skills require a separate audited adapter.
|
||||
export async function removeBattleSkill(player, skill, {lib,game,get,_status}, wholePlayer = false) {
|
||||
const names = Array.isArray(skill) ? skill.slice() : [skill];
|
||||
for (const name of names) {
|
||||
if (lib.skill[name]?.fixed || lib.skill[name]?.superCharlotte) throw new Error("Unsupported protected skill: " + name);
|
||||
}
|
||||
if (!wholePlayer && Object.values(player.additionalSkills || {}).some(list => list?.length)) throw new Error("Additional skill release adapter required");
|
||||
const remove = async function removeSkill(skill) {
|
||||
if (!skill) {
|
||||
return;
|
||||
}
|
||||
_status.event.clearStepCache();
|
||||
if (Array.isArray(skill)) {
|
||||
for (var i = 0; i < skill.length; i++) {
|
||||
await removeSkill.call(this, skill[i]);
|
||||
}
|
||||
} else {
|
||||
if (skill === "counttrigger") {
|
||||
this.stat[this.stat.length - 1].triggerSkill = {};
|
||||
return;
|
||||
} else {
|
||||
var info = lib.skill[skill];
|
||||
if (info?.fixed && arguments[1] !== true) {
|
||||
return skill;
|
||||
}
|
||||
this.unmarkSkill(skill);
|
||||
game.broadcastAll(
|
||||
function(player, skill2) {
|
||||
player.skills.remove(skill2);
|
||||
player.hiddenSkills.remove(skill2);
|
||||
player.invisibleSkills.remove(skill2);
|
||||
delete player.tempSkills[skill2];
|
||||
for (var i2 in player.additionalSkills) {
|
||||
player.additionalSkills[i2].remove(skill2);
|
||||
}
|
||||
},
|
||||
this,
|
||||
skill
|
||||
);
|
||||
this.checkConflict(skill);
|
||||
if (info) {
|
||||
if (info.onremove) {
|
||||
if (typeof info.onremove == "function") {
|
||||
await info.onremove(this, skill);
|
||||
} else if (typeof info.onremove == "string") {
|
||||
if (info.onremove == "storage") {
|
||||
delete this.storage[skill];
|
||||
} else {
|
||||
var cards = this.storage[skill];
|
||||
if (get.itemtype(cards) == "card") {
|
||||
cards = [cards];
|
||||
}
|
||||
if (get.itemtype(cards) == "cards") {
|
||||
if (this.onremove == "discard") {
|
||||
this.$throw(cards);
|
||||
}
|
||||
if (this.onremove == "discard" || this.onremove == "lose") {
|
||||
game.cardsDiscard(cards);
|
||||
delete this.storage[skill];
|
||||
}
|
||||
}
|
||||
}
|
||||
} else if (Array.isArray(info.onremove)) {
|
||||
for (var i = 0; i < info.onremove.length; i++) {
|
||||
delete this.storage[info.onremove[i]];
|
||||
}
|
||||
} else if (info.onremove === true) {
|
||||
delete this.storage[skill];
|
||||
}
|
||||
}
|
||||
this.removeSkillTrigger(skill);
|
||||
if (!wholePlayer && !info.keepSkill) {
|
||||
this.removeAdditionalSkills(skill);
|
||||
}
|
||||
}
|
||||
this.enableSkill(skill + "_awake");
|
||||
game.callHook("removeSkillCheck", [skill, this]);
|
||||
}
|
||||
}
|
||||
return skill;
|
||||
};
|
||||
return remove.call(player, skill);
|
||||
}
|
||||
|
||||
// Must be called after gameplay root drained, outside all engine event bodies.
|
||||
export async function runSkillTeardown(releases, {lib,_status}) {
|
||||
if (_status.eventManager.eventStack.length || _status.eventManager.tempEvent) throw new Error("Gameplay must drain before skill release");
|
||||
if (!Array.isArray(releases) || releases.some(fn => typeof fn !== "function")) throw new TypeError("Explicit release callbacks required");
|
||||
const previous = _status.eventManager.rootEvent;
|
||||
const root = new lib.element.GameEvent("mengsanSkillTeardown", false, _status.eventManager);
|
||||
let releaseError;
|
||||
root.setContent(async () => {
|
||||
try {
|
||||
for (const release of releases) {
|
||||
await release();
|
||||
await root.waitNext();
|
||||
}
|
||||
} catch (error) {
|
||||
releaseError = error;
|
||||
// Let the engine drain any queued work and pop this root normally.
|
||||
}
|
||||
});
|
||||
// Natural start completion includes next/after and native stack pop. Failure stays fail-closed.
|
||||
await root.start();
|
||||
if (_status.eventManager.eventStack.includes(root)) throw new Error("Teardown event still active");
|
||||
if (_status.eventManager.rootEvent === root) _status.eventManager.rootEvent = previous;
|
||||
if (releaseError) throw releaseError;
|
||||
}
|
||||
|
||||
// Whole-player disposal differs from removing one source during gameplay: every source is ending.
|
||||
// Each callback runs at most once; failed/partially executed teardown is NOT replayed blindly.
|
||||
export function createPlayerTeardown(players, env) {
|
||||
const captured = players.slice();
|
||||
let operation;
|
||||
const names = player => [...new Set([
|
||||
...(player.skills || []), ...(player.hiddenSkills || []), ...(player.invisibleSkills || []),
|
||||
...Object.keys(player.tempSkills || {}),
|
||||
...Object.values(player.additionalSkills || {}).flatMap(list => typeof list === "string" ? [list] : list || []),
|
||||
])];
|
||||
return () => {
|
||||
if (operation) return operation;
|
||||
operation = (async () => {
|
||||
// Preflight ALL players before modifying any skill.
|
||||
for (const player of captured) for (const name of names(player)) {
|
||||
if (env.lib.skill[name]?.fixed || env.lib.skill[name]?.superCharlotte) throw new Error("Protected skill requires adapter: " + name);
|
||||
if (Object.values(player.additionalSkills || {}).some(value => !Array.isArray(value))) throw new Error("Unsupported additional skill source shape");
|
||||
}
|
||||
await runSkillTeardown(captured.map(player => async () => {
|
||||
const released = new Set();
|
||||
while (names(player).length) {
|
||||
if (released.size >= 128) throw new Error("Skill teardown exceeded bounded expansion");
|
||||
const name = names(player)[0];
|
||||
if (released.has(name)) throw new Error("Released skill was re-added: " + name);
|
||||
await removeBattleSkill(player, name, env, true);
|
||||
released.add(name);
|
||||
// Children can add skills. Drain them before taking the next inventory.
|
||||
await env._status.event.waitNext();
|
||||
}
|
||||
for (const [key,list] of Object.entries(player.additionalSkills || {})) {
|
||||
if (Array.isArray(list) && !list.length) delete player.additionalSkills[key];
|
||||
}
|
||||
}), env);
|
||||
})();
|
||||
return operation;
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
export const getAvailableStoryChoices = (run, content) => {
|
||||
const flags = run.storyFlags || {};
|
||||
return (content?.choices || []).filter(choice => {
|
||||
return (choice.requires || []).every(flag => Boolean(flags[flag]))
|
||||
&& (choice.excludes || []).every(flag => !flags[flag]);
|
||||
});
|
||||
};
|
||||
|
||||
export const applyStoryOutcome = (run, outcome = {}) => {
|
||||
if (!run.storyFlags) run.storyFlags = {};
|
||||
Object.assign(run.storyFlags, outcome.flags || {});
|
||||
return outcome;
|
||||
};
|
||||
+59
-24
@@ -1,3 +1,4 @@
|
||||
import { createStartingDeck } from "./content/card-library.js";
|
||||
import config from "./config.js";
|
||||
|
||||
const hashText = text => {
|
||||
@@ -71,7 +72,24 @@ const getFloorRoutes = (run, act, floor, lastFloor) => {
|
||||
const target = Math.floor(nextRandom(run) * (index + 1));
|
||||
[routes[index], routes[target]] = [routes[target], routes[index]];
|
||||
}
|
||||
return routes.slice(0, count).sort((left, right) => left - right);
|
||||
const selectedRoutes = routes.slice(0, count);
|
||||
const fixedRoutes = [...new Set((act.fixedNodes || [])
|
||||
.filter(node => node.floor == floor)
|
||||
.map(node => node.routeIndex)
|
||||
.filter(routeIndex => Number.isInteger(routeIndex) && routeIndex >= 0 && routeIndex < routeCount))];
|
||||
fixedRoutes.forEach(routeIndex => {
|
||||
if (selectedRoutes.includes(routeIndex)) return;
|
||||
let replaceIndex = -1;
|
||||
for (let index = selectedRoutes.length - 1; index >= 0; index--) {
|
||||
if (!fixedRoutes.includes(selectedRoutes[index])) {
|
||||
replaceIndex = index;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (replaceIndex >= 0) selectedRoutes[replaceIndex] = routeIndex;
|
||||
else if (selectedRoutes.length < routeCount) selectedRoutes.push(routeIndex);
|
||||
});
|
||||
return selectedRoutes.sort((left, right) => left - right);
|
||||
};
|
||||
|
||||
export const generateActMap = (run, actIndex) => {
|
||||
@@ -84,8 +102,14 @@ export const generateActMap = (run, actIndex) => {
|
||||
const floorNodes = [];
|
||||
const routes = getFloorRoutes(run, act, floor, lastFloor);
|
||||
routes.forEach((routeIndex, index) => {
|
||||
const fixedNode = act.fixedNodes?.find(node => {
|
||||
if (node.floor != floor) return false;
|
||||
if (node.position == "start") return floor == 0;
|
||||
return node.routeIndex == routeIndex;
|
||||
});
|
||||
let type;
|
||||
if (floor == lastFloor) type = "boss";
|
||||
else if (fixedNode?.type) type = fixedNode.type;
|
||||
else if (floor == 0) type = "battle";
|
||||
else type = weightedType(run, act.nodeWeights);
|
||||
const id = `${act.id}_f${floor}_n${index}_${Math.floor(nextRandom(run) * 1e6)}`;
|
||||
@@ -93,6 +117,7 @@ export const generateActMap = (run, actIndex) => {
|
||||
id,
|
||||
floor,
|
||||
type,
|
||||
contentId: fixedNode?.contentId || null,
|
||||
routeIndex,
|
||||
x: Math.round((routeIndex + 1) * 100 / (config.routeCount + 1)),
|
||||
y: Math.round(90 - floor * 80 / Math.max(1, lastFloor)),
|
||||
@@ -118,16 +143,6 @@ export const generateActMap = (run, actIndex) => {
|
||||
};
|
||||
};
|
||||
|
||||
const createDeck = () => config.startingDeck.map((entry, index) => ({
|
||||
id: `mengsan_card_${Date.now()}_${index}`,
|
||||
suit: entry[0],
|
||||
number: entry[1],
|
||||
name: entry[2],
|
||||
nature: entry[3] || null,
|
||||
affixes: entry[4] ? entry[4].slice() : [],
|
||||
upgrade: 0,
|
||||
}));
|
||||
|
||||
export const createRun = character => {
|
||||
const seed = (Date.now() ^ Math.floor(Math.random() * 0x7fffffff)) >>> 0;
|
||||
const run = {
|
||||
@@ -145,7 +160,7 @@ export const createRun = character => {
|
||||
hp: null,
|
||||
maxHp: null,
|
||||
gold: 0,
|
||||
deck: createDeck(),
|
||||
deck: createStartingDeck(character),
|
||||
permanentSkills: [],
|
||||
temporarySkills: [],
|
||||
items: [],
|
||||
@@ -156,6 +171,7 @@ export const createRun = character => {
|
||||
defeatedEnemies: 0,
|
||||
goldEarned: 0,
|
||||
},
|
||||
storyFlags: {},
|
||||
};
|
||||
run.map = generateActMap(run, 0);
|
||||
return run;
|
||||
@@ -184,21 +200,23 @@ export const completeNode = (run, nodeId) => {
|
||||
export const insertStoryNode = (run, sourceId) => {
|
||||
const map = run.map;
|
||||
const source = map.nodes.find(node => node.id == sourceId);
|
||||
const edgeIndex = map.edges.findIndex(edge => edge[0] == sourceId);
|
||||
if (!source || edgeIndex < 0) return null;
|
||||
const oldEdge = map.edges[edgeIndex];
|
||||
const target = map.nodes.find(node => node.id == oldEdge[1]);
|
||||
if (!target) return null;
|
||||
const outgoingEdges = map.edges.filter(edge => edge[0] == sourceId);
|
||||
if (!source || !outgoingEdges.length) return null;
|
||||
const targets = outgoingEdges.map(edge => map.nodes.find(node => node.id == edge[1])).filter(Boolean);
|
||||
if (!targets.length) return null;
|
||||
const averageTargetX = targets.reduce((sum, target) => sum + target.x, 0) / targets.length;
|
||||
const averageTargetY = targets.reduce((sum, target) => sum + target.y, 0) / targets.length;
|
||||
const node = {
|
||||
id: `story_${hashText(`${run.runId}_${sourceId}_${run.revision}`)}`,
|
||||
floor: source.floor + 0.45,
|
||||
type: "story",
|
||||
x: Math.max(8, Math.min(92, Math.round((source.x + target.x) / 2 + (nextRandom(run) - 0.5) * 16))),
|
||||
y: Math.round((source.y + target.y) / 2),
|
||||
x: Math.max(8, Math.min(92, Math.round((source.x + averageTargetX) / 2 + (nextRandom(run) - 0.5) * 16))),
|
||||
y: Math.round((source.y + averageTargetY) / 2),
|
||||
completed: false,
|
||||
dynamic: true,
|
||||
};
|
||||
map.edges.splice(edgeIndex, 1, [source.id, node.id], [node.id, target.id]);
|
||||
map.edges = map.edges.filter(edge => edge[0] != sourceId);
|
||||
map.edges.push([source.id, node.id], ...targets.map(target => [node.id, target.id]));
|
||||
map.nodes.push(node);
|
||||
return node;
|
||||
};
|
||||
@@ -214,14 +232,31 @@ export const enterNextAct = run => {
|
||||
return true;
|
||||
};
|
||||
|
||||
export const getNodeEncounter = (run, node) => {
|
||||
export const getNodeEncounter = (run, node, override = null) => {
|
||||
const act = config.acts[run.actIndex];
|
||||
let pool = act.enemies;
|
||||
const content = node.contentId ? config.nodeContents?.[node.contentId] : null;
|
||||
const encounter = override || content;
|
||||
let pool = encounter?.enemies || act.enemies;
|
||||
if (node.type == "elite" || node.type == "story") pool = act.eliteEnemies?.length ? act.eliteEnemies : act.enemies;
|
||||
if (node.type == "boss") return { enemy: act.boss, gold: act.baseGold * 3, boss: true };
|
||||
if (encounter?.enemies?.length) pool = encounter.enemies;
|
||||
if (node.type == "boss") {
|
||||
return { battlePlan: encounter?.battlePlan || act.bossBattlePlan || null, enemy: act.boss, tier: "boss", gold: act.baseGold * 3, boss: true, rewardPool: "shared.pool.boss.premium" };
|
||||
}
|
||||
return {
|
||||
name: encounter?.name || "",
|
||||
requiredCharacter: encounter?.requiredCharacter || null,
|
||||
openingDialogue: encounter?.openingDialogue || [],
|
||||
fixedRewards: encounter?.fixedRewards || [],
|
||||
skipRandomReward: encounter?.skipRandomReward === true,
|
||||
victoryDialogue: encounter?.victoryDialogue || [],
|
||||
battlePlan: encounter?.battlePlan || null,
|
||||
tier: encounter?.tier || (node.type === "elite" ? "elite" : "normal"),
|
||||
enemy: randomGet(run, pool.filter(Boolean)),
|
||||
gold: node.type == "elite" || node.type == "story" ? act.baseGold * 2 : act.baseGold,
|
||||
gold: encounter?.gold ?? (node.type == "elite" || node.type == "story" ? act.baseGold * 2 : act.baseGold),
|
||||
boss: false,
|
||||
contentId: node.contentId || null,
|
||||
description: encounter?.description || "",
|
||||
rewardPool: encounter?.rewardPool || "shared.pool.battle.normal",
|
||||
rewardTitle: encounter?.rewardTitle || "战斗奖励(三选一)",
|
||||
};
|
||||
};
|
||||
|
||||
+976
-78
File diff suppressed because it is too large
Load Diff
@@ -1015,18 +1015,17 @@ const skill = {
|
||||
},
|
||||
async content(event, trigger, player) {
|
||||
const target = trigger.player;
|
||||
const loseEvent = target.loseHp(trigger.num);
|
||||
loseEvent.shuYing_Tianshu_Skill_09_Skill3 = true;
|
||||
loseEvent.shuYing_source = player;
|
||||
await loseEvent;
|
||||
await target.loseHp(trigger.num);
|
||||
|
||||
if (!target.isIn()) return;
|
||||
|
||||
const loseHpNum = target
|
||||
.getHistory("loseHp", evt => {
|
||||
// 体力变化记录在全局历史中,只累计本回合该目标流失的体力。
|
||||
const loseHpNum = game
|
||||
.getGlobalHistory("changeHp", evt => {
|
||||
return (
|
||||
evt.shuYing_Tianshu_Skill_09_Skill3 &&
|
||||
evt.shuYing_source == player
|
||||
evt.player == target &&
|
||||
evt.getParent().name == "loseHp" &&
|
||||
evt.num < 0
|
||||
);
|
||||
})
|
||||
.reduce((sum, evt) => {
|
||||
|
||||
+84
-2
@@ -24,6 +24,7 @@ export default function initTianshu(lib, game, ui, get, ai, _status, shuYing) {
|
||||
corpseChooseCleanup: null,
|
||||
corpseChooseEvent: null,
|
||||
initialBoss: null,
|
||||
defending: false,
|
||||
virtualIdol: null,
|
||||
running: false,
|
||||
pendingSpawn: false,
|
||||
@@ -100,6 +101,27 @@ export default function initTianshu(lib, game, ui, get, ai, _status, shuYing) {
|
||||
const isVirtualIdol = player => Boolean(player && (getVirtualIdolIds().includes(player.name1) || getVirtualIdolIds().includes(player.name)));
|
||||
// 判断指定角色是否为当前关卡实际召唤的 Boss;玩家变身为同名动态武将时不会被误判。
|
||||
const isCurrentStageBoss = player => Boolean(player?.storage?.shuYing_tianshu_stageBoss && player.storage.shuYing_tianshu_stageIndex == state.stageIndex);
|
||||
// 应战身份独立于 game.me/game.boss,避免动态换控后误判阵营和胜负。
|
||||
const swapDefendingBoss = target => {
|
||||
if (!state.defending || !target || !game.players.includes(target) || !isCurrentStageBoss(target)) return;
|
||||
game.swapControl(target);
|
||||
game.onSwapControl?.();
|
||||
// 应战开局沿用主 Boss UI,会隐藏 fakeme;换成动态 Boss 后必须恢复代理头像。
|
||||
// 直接复用已经显示正确的角色头像,不按克隆 ID 拼接图片路径。
|
||||
if (ui.fakeme) {
|
||||
game.singleHandcard = true;
|
||||
ui.arena?.classList.add("single-handcard");
|
||||
ui.window?.classList.add("single-handcard");
|
||||
ui.fakeme.style.display = "";
|
||||
ui.fakeme.current = target.name;
|
||||
ui.fakeme.style.backgroundImage = target.node.avatar.style.backgroundImage;
|
||||
ui.updatehl?.();
|
||||
}
|
||||
};
|
||||
const getNextDefendingBoss = dead => {
|
||||
const bosses = game.players.filter(isCurrentStageBoss).sort((a, b) => Number(a.dataset.position) - Number(b.dataset.position));
|
||||
return bosses.find(boss => Number(boss.dataset.position) > Number(dead?.dataset.position ?? -1)) || bosses[0];
|
||||
};
|
||||
// 判断指定角色是否属于开局捕获的玩家方。
|
||||
const isCapturedPlayerSide = player => Boolean(getPlayerKey(player) && state.playerSideIds[getPlayerKey(player)]);
|
||||
// 获取玩家方角色列表,可按是否存活、是否包含虚拟偶像过滤。
|
||||
@@ -188,6 +210,20 @@ export default function initTianshu(lib, game, ui, get, ai, _status, shuYing) {
|
||||
// 结束当前 phase 事件,把后续行动权交给指定角色;保留旧版重置轮数与阶段的做法。
|
||||
const finishCurrentPhaseTo = target => {
|
||||
if (!target) return;
|
||||
// 清剿把弃牌事件挂在上层队列;finish 不会清空队列,不能让旧关结算弃掉新关奖励。
|
||||
let pendingParent = _status.event;
|
||||
const pendingVisited = new Set();
|
||||
while (pendingParent && !pendingVisited.has(pendingParent)) {
|
||||
pendingVisited.add(pendingParent);
|
||||
for (const queue of [pendingParent.next, pendingParent.after]) {
|
||||
if (!Array.isArray(queue)) continue;
|
||||
for (let i = queue.length - 1; i >= 0; i--) {
|
||||
if (queue[i]?.name == "qingjiao_discard") queue.splice(i, 1);
|
||||
}
|
||||
}
|
||||
if (pendingParent.name == "phaseLoop") break;
|
||||
pendingParent = pendingParent.getParent ? pendingParent.getParent() : pendingParent.parent;
|
||||
}
|
||||
const phase = _status.event?.getParent ? _status.event.getParent("phase") : getParentEvent("phase");
|
||||
if (phase) {
|
||||
if (typeof game.resetSkills == "function") game.resetSkills();
|
||||
@@ -314,6 +350,16 @@ export default function initTianshu(lib, game, ui, get, ai, _status, shuYing) {
|
||||
boss.storage.shuYing_tianshu_stageBoss = true;
|
||||
boss.storage.shuYing_tianshu_stageIndex = state.stageIndex;
|
||||
boss.storage.shuYing_tianshu_originalBossName = originalName;
|
||||
if (state.defending) {
|
||||
const originalIsUnderControl = boss.isUnderControl;
|
||||
boss.isUnderControl = function (self, me = game.me) {
|
||||
if (state.running && state.defending && isCurrentStageBoss(this) && isCurrentStageBoss(me)
|
||||
&& !_status.connectMode && !game.notMe && !this.isMad() && get.config("single_control")) {
|
||||
return this == me ? Boolean(self) : true;
|
||||
}
|
||||
return originalIsUnderControl.call(this, self, me);
|
||||
};
|
||||
}
|
||||
game.addVideo("setIdentity", boss, boss.identity);
|
||||
if (game.playerMap) game.playerMap[boss.dataset.position] = boss;
|
||||
game.arrangePlayers();
|
||||
@@ -591,6 +637,7 @@ export default function initTianshu(lib, game, ui, get, ai, _status, shuYing) {
|
||||
player._start_cards.addArray(cards);
|
||||
}
|
||||
}
|
||||
if (state.defending) swapDefendingBoss(getNextDefendingBoss());
|
||||
};
|
||||
// 读取玩家当前武将牌上自带的技能,避免过关奖励重复刷出本体技能。
|
||||
const getCharacterCardSkills = player => {
|
||||
@@ -976,6 +1023,8 @@ export default function initTianshu(lib, game, ui, get, ai, _status, shuYing) {
|
||||
};
|
||||
// 让指定玩家从 UI 中选择一个奖励技能,并返回刷新请求或最终技能。
|
||||
const chooseRewardSkill = async (player, choices, canRefresh) => {
|
||||
// 应战者不能代替敌方选奖励;沿用无人类操作时的自动选择路径。
|
||||
if (state.defending) return choices[0] || null;
|
||||
const chooser = getRewardSkillChooser(player);
|
||||
const canChoose = chooser == game.me || chooser?.isUnderControl?.();
|
||||
if (!canChoose || typeof shuYing.chooseSkillRewardDialog != "function") return choices[0] || null;
|
||||
@@ -1019,7 +1068,7 @@ export default function initTianshu(lib, game, ui, get, ai, _status, shuYing) {
|
||||
if (state.clearingStage) return;
|
||||
state.clearingStage = true;
|
||||
if (state.stageIndex >= tianshuConfig.stages.length - 1) {
|
||||
game.over(game.me !== game.boss);
|
||||
game.over(!state.defending);
|
||||
return;
|
||||
}
|
||||
await reviveVirtualIdolIfNeeded();
|
||||
@@ -1059,6 +1108,7 @@ export default function initTianshu(lib, game, ui, get, ai, _status, shuYing) {
|
||||
if (!isCurrentStageBoss(deadBoss)) return;
|
||||
if (deadBoss.storage.shuYing_tianshu_bossDieHandled) return;
|
||||
deadBoss.storage.shuYing_tianshu_bossDieHandled = true;
|
||||
if (state.defending && game.me == deadBoss) swapDefendingBoss(getNextDefendingBoss(deadBoss));
|
||||
const stageCleared = !game.players.some(current => isCurrentStageBoss(current));
|
||||
const finalCleared = stageCleared && state.stageIndex >= tianshuConfig.stages.length - 1;
|
||||
if (!finalCleared && event.source && event.source.isIn()) {
|
||||
@@ -1073,6 +1123,22 @@ export default function initTianshu(lib, game, ui, get, ai, _status, shuYing) {
|
||||
if (state.running) return;
|
||||
state.running = true;
|
||||
state.initialBoss = game.boss || null;
|
||||
state.defending = Boolean(state.initialBoss && game.me == state.initialBoss);
|
||||
if (state.defending) {
|
||||
// 应战原生布局是 0/2/4/6;先恢复挑战者 1/2/3,再安排 Boss 和偶像,避免重叠。
|
||||
game.players.filter(current => current != state.initialBoss && current.side === false)
|
||||
.sort((a, b) => Number(a.dataset.position) - Number(b.dataset.position))
|
||||
.forEach((current, index) => { current.dataset.position = index + 1; });
|
||||
game.arrangePlayers();
|
||||
const originalModeSwapPlayer = game.modeSwapPlayer;
|
||||
game.modeSwapPlayer = function (target) {
|
||||
if (state.running && state.defending && isCurrentStageBoss(target)) {
|
||||
swapDefendingBoss(target);
|
||||
return;
|
||||
}
|
||||
return originalModeSwapPlayer.apply(this, arguments);
|
||||
};
|
||||
}
|
||||
installHiddenBossCorpseCheckHook();
|
||||
spawnVirtualIdol();
|
||||
capturePlayerSide();
|
||||
@@ -1116,7 +1182,7 @@ export default function initTianshu(lib, game, ui, get, ai, _status, shuYing) {
|
||||
canReceiveOpeningProtection: player => Boolean(player && player.isIn() && !isCurrentStageBoss(player) && (isCapturedPlayerSide(player) || isVirtualIdol(player))),
|
||||
// 玩家死亡后检查是否达到失败条件。
|
||||
checkPlayerDefeat() {
|
||||
if (this.isPlayerSideDefeated()) game.over(game.me === game.boss);
|
||||
if (this.isPlayerSideDefeated()) game.over(state.defending);
|
||||
},
|
||||
refreshBonds: refreshTianshuBonds,
|
||||
queueBondRefresh: queueTianshuBondRefresh,
|
||||
@@ -1173,9 +1239,25 @@ export default function initTianshu(lib, game, ui, get, ai, _status, shuYing) {
|
||||
game.addGlobalSkill("shuYing_Tianshu_NewSpawnStage");
|
||||
game.addGlobalSkill("shuYing_Tianshu_NewBossDie");
|
||||
game.addGlobalSkill("shuYing_Tianshu_NewPlayerDie");
|
||||
game.addGlobalSkill("shuYing_Tianshu_DefendingControl");
|
||||
game.addGlobalSkill("shuYing_Tianshu_BondManager");
|
||||
},
|
||||
},
|
||||
shuYing_Tianshu_DefendingControl: {
|
||||
trigger: { global: "phaseBefore" },
|
||||
forced: true,
|
||||
silent: true,
|
||||
charlotte: true,
|
||||
forceDie: true,
|
||||
firstDo: true,
|
||||
filter(event) {
|
||||
return state.running && state.defending && !_status.connectMode
|
||||
&& get.config("single_control") && isCurrentStageBoss(event.player);
|
||||
},
|
||||
async content(event, trigger) {
|
||||
swapDefendingBoss(trigger.player);
|
||||
},
|
||||
},
|
||||
shuYing_Tianshu_BondManager: {
|
||||
mode: ["boss"],
|
||||
trigger: { global: "changeSkillsAfter" },
|
||||
|
||||
Reference in New Issue
Block a user