15 Commits
Author SHA1 Message Date
Diuse b005a7ff15 同上 2026-09-30 16:59:10 +08:00
Diuse 12e9ac181f fixed # 新增UI子模块,服务梦三 2026-09-30 16:58:13 +08:00
Diuse 0ccb14cecd fixed # 更新刘备配置和奖励 2026-09-30 15:43:07 +08:00
Diuse 82d80397b6 fixed # 新增羁绊系统
fixed # 新增遗物系统
fixed # 梦三模式下移除护甲上限限制
2026-09-30 14:33:17 +08:00
Diuse f2da9bce65 fixed # 重构梦三项目结构 2026-09-30 10:23:58 +08:00
Diuse 3fdaf6d218 fixed # 修补缺失资源,修正已知BUG 2026-09-30 00:33:30 +08:00
Diuse 98a55dd3d6 fixed # 更新资源文件
fixed # 修复部分手机适配问题
2026-09-29 18:15:38 +08:00
Diuse dd562ae627 fixed # 修复手机端地图滑动异常的问题
fixed # 重构更新框架,支持选择是否下载梦三模式
fixed # 补充部分意图的资源图片与逻辑
2026-09-29 17:44:21 +08:00
Diuse c9c09582c1 新增更新通道选择 2026-09-29 15:07:35 +08:00
Diuse a368dfae48 merge 2026-09-29 13:04:13 +08:00
Diuse 6699cd16ba 改动更新逻辑,更新目标指向Gitea 2026-09-29 11:02:17 +08:00
Diuse 2e552cf17d 改动整体底层系统 2026-09-29 00:29:00 +08:00
Diuse ced1b28cc2 同步更新STS2 词缀 2026-09-28 21:49:47 +08:00
Diuse e3913f8969 merge 2026-09-28 20:59:56 +08:00
Diuse 8045e3c63f 整合 2026-09-28 20:58:29 +08:00
123 changed files with 10139 additions and 1070 deletions
+16 -6
View File
@@ -92,10 +92,13 @@ jobs:
) as archive:
for name in manifest["files"]:
archive.write(root / name, folder + name)
archive.write(
root / "dist/manifest.json",
folder + "manifest.json"
)
for descriptor in manifest.get("modules", {}).values():
component = json.loads(
(root / descriptor["manifest"]).read_text(encoding="utf-8")
)
for name in component["files"]:
archive.write(root / name, folder + name)
archive.write(root / "dist/manifest.json", folder + "manifest.json")
'@ | python - $stage $package
if ($LASTEXITCODE -ne 0) { throw '打包失败' }
@@ -135,7 +138,14 @@ jobs:
-Body $body
$assetApi = "$api/$($release.id)/assets"
foreach ($file in @($manifestPath, $package)) {
$moduleManifests = @(
Get-ChildItem (Join-Path $stage 'dist/modules') `
-Filter '*.json' -ErrorAction SilentlyContinue
)
$releaseFiles = @($manifestPath)
$releaseFiles += $moduleManifests
$releaseFiles += $package
foreach ($file in $releaseFiles) {
curl.exe --fail --silent --show-error `
-H "Authorization: token $env:GITEA_TOKEN" `
-F "attachment=@$file" `
@@ -151,4 +161,4 @@ jobs:
-ContentType 'application/json' `
-Body '{"draft":false}' | Out-Null
Write-Host "发布完成:$tag"
Write-Host "发布完成:$tag"
+16 -4
View File
@@ -90,10 +90,19 @@ jobs:
Copy-Item -LiteralPath $manifestPath `
-Destination (Join-Path $env:GITHUB_WORKSPACE `
'dist/manifest.json') -Force
$moduleManifestDir = Join-Path $source 'dist/modules'
if (Test-Path $moduleManifestDir) {
New-Item -ItemType Directory -Force `
(Join-Path $env:GITHUB_WORKSPACE 'dist/modules') | Out-Null
Copy-Item -Path (Join-Path $moduleManifestDir '*.json') `
-Destination (Join-Path $env:GITHUB_WORKSPACE 'dist/modules') `
-Force
}
git config user.name 'github-actions[bot]'
git config user.email `
'41898282+github-actions[bot]@users.noreply.github.com'
git add dist/manifest.json
git add dist/modules
if ($LASTEXITCODE -ne 0) { throw '暂存清单失败' }
git commit -m "Prepare manifest for $tag"
if ($LASTEXITCODE -ne 0) { throw '提交清单失败' }
@@ -143,10 +152,13 @@ jobs:
) as archive:
for name in manifest["files"]:
archive.write(root / name, folder + name)
archive.write(
root / "dist/manifest.json",
folder + "manifest.json"
)
for descriptor in manifest.get("modules", {}).values():
component = json.loads(
(root / descriptor["manifest"]).read_text(encoding="utf-8")
)
for name in component["files"]:
archive.write(root / name, folder + name)
archive.write(root / "dist/manifest.json", folder + "manifest.json")
'@ | python - $stage $package
if ($LASTEXITCODE -ne 0) { throw '打包失败' }
+15
View File
@@ -1,3 +1,18 @@
log.txt
findings.md
progress.md
task_plan.md
dist/manifest.json
tools/__pycache__/
tools/*.mjs
/mengsan/.planning/
/mengsan/verification
/mengsan/assets/sts2/
/Slay the Spire 2
*.mjs
*.cjs
+303 -303
View File
File diff suppressed because it is too large Load Diff
+26 -10
View File
@@ -5,7 +5,7 @@ import initShuYingMenu from "./menu.js";
export const type = "extension";
export let shuYing = new Object();
const shuYingLocalVersion = "2.0.1.1";
const shuYingLocalVersion = "2.0.1.1-rc.1";
shuYing.name = "术樱包";
shuYing.editable = false;
@@ -49,12 +49,10 @@ shuYing.initFunction = () => {
};
shuYing.getOnlineVersionText = () => `最新版本:${lib.config.shuYing_online_version || "检测中..."}`;
shuYing.updateOnlineVersionMenu = (version) => {
if (!version) return;
shuYing.updateOnlineVersionMenu = (version, status = "检测中...") => {
if (version) game.saveConfig("shuYing_online_version", version);
game.saveConfig("shuYing_online_version", version);
const text = `最新版本:${version}`;
const text = `最新版本:${version || status}`;
if (lib.extensionMenu.extension_术樱包?.online_version) {
lib.extensionMenu.extension_术樱包.online_version.name = text;
}
@@ -113,8 +111,11 @@ shuYing.mergeCharacterPack = function (target, source, sourceName = "附加模
};
shuYing.getVersion = (callback) => {
const requestId = (shuYing.versionRequestId || 0) + 1;
shuYing.versionRequestId = requestId;
return updater.getOnlineVersion()
.then(version => {
if (requestId != shuYing.versionRequestId) return null;
shuYing.updateOnlineVersionMenu(version?.online_version);
if (typeof callback == "function") {
callback(version);
@@ -122,7 +123,9 @@ shuYing.getVersion = (callback) => {
return version;
})
.catch(error => {
if (requestId != shuYing.versionRequestId) return null;
console.error("术樱包获取在线版本失败:", error);
shuYing.updateOnlineVersionMenu(null, "获取失败");
return null;
});
}
@@ -247,10 +250,23 @@ 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 mengsanState = lib.config.shuYing_mengsan_installed_version;
let hasMengsan = !!mengsanState && mengsanState != "disabled";
if (!mengsanState && typeof game.checkFile != "function") {
hasMengsan = true;
}
else if (!mengsanState && typeof game.checkFile == "function") {
hasMengsan = await new Promise(resolve => {
game.checkFile("extension/术樱包/mengsan/register.js",
result => resolve(result == 1), () => resolve(false));
});
}
if (hasMengsan) {
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();
+38
View File
@@ -0,0 +1,38 @@
# 梦三模式目录
`register.js` 是扩展加载入口,`mode.js` 负责组装模式、注册技能并连接各领域模块,`config.js` 提供模式配置。发布器和外部扩展应继续从 `register.js` 加载,避免依赖内部文件路径。
| 目录 | 职责 |
| --- | --- |
| `battle/` | 战斗会话、回合、结算、费用与通用战斗效果 |
| `monsters/` | 怪物阶级、牌堆默认值和各怪物意图选择 |
| `cards/` | 卡牌定义、词缀、生成和个人牌堆 |
| `relics/` | 遗物(道具)定义、持有规则、战斗触发及资源待补清单 |
| `bonds/` | NPC 羁绊、助战选择、成长与意图配置、默认牌库及濒死状态 |
| `progression/` | 征程状态、存档、剧情选择和奖励逻辑 |
| `content/` | 章节、遭遇、剧本、角色与内容注册表 |
| `ui/` | 地图、对话、意图、奖励、图鉴及样式 |
| `ui/hand/` | 梦三必需手牌 UI 子扩展:真实卡牌原位排列、覆盖层及生命周期 |
| `assets/intent/` | 意图图标 |
| `assets/cards/` | 梦三专属卡面 |
| `assets/portraits/` | 角色立绘 |
| `docs/` | 战斗和关卡配置说明 |
新增功能优先放入所属领域;界面代码放在 `ui/`,怪物行动逻辑放在 `monsters/`。跨领域引用使用相对路径,避免新增一个涵盖所有功能的扁平 `runtime/` 目录。模块资源文件随 `tools/generate_manifest.py` 自动收录,发布入口仍为 `mengsan/register.js`。
手牌 UI 子扩展没有单独关闭开关;实现边界、日志位置和验证结果见
[手牌 UI 子扩展说明](docs/ui-hand-subextension.md)。
梦三【杀】消耗 1 费用,基础伤害为 6;其他锦囊的基础伤害仍为 4。
【重杀】(`mengsan_zhongsha`)是普通战斗奖励池中的通用基本牌:
消耗 2 费用,对攻击范围内的一名其他角色直接造成 8 点伤害,
然后给予存活目标 2 层易伤,每回合使用次数不限。
易伤沿用既有规则:受到攻击伤害增加 50%(向上取整),
目标每个自身回合结束减少 1 层;本次新施加的易伤不放大本次伤害。
强化规则集中在 `cards/upgrades.js`,未配置的牌不可强化。
【杀】最多强化一次,伤害由 6 变为 9;【重杀】最多强化一次,
伤害由 8 变为 10,易伤由 2 层变为 3 层,费用均不变。
随机强化只选尚未满级、可强化且没有“永恒”词缀的牌。
没有候选牌时禁用休息节点的磨砺,并从随机奖励中排除强化。
旧存档不重写:历史超限等级按当前上限结算,未配置牌的历史等级不生效。
Binary file not shown.

After

Width:  |  Height:  |  Size: 2.5 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.6 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.5 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 MiB

+181
View File
@@ -0,0 +1,181 @@
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, bondUnit = null) {
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 });
}
if (bondUnit) plan.units.unshift(copy(bondUnit));
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(); },
};
}
+112
View File
@@ -0,0 +1,112 @@
// 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" ? null
: await chooseReward(copy(job.pending.choices), {
boss: job.pending.boss,
fixedRewards: copy(job.pending.fixedRewards || []),
rewardPackage: copy(job.pending.rewardPackage || null),
});
if (job.pending.outcome !== "defeat" && job.pending.choices.length && job.choiceId !== null && !job.pending.choices.some(c => c.id === job.choiceId)) throw new Error("Invalid reward selection");
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") {
const mapRun = copy(job.committed.run);
job.nextNode = await showMap(mapRun);
job.committed.run = mapRun;
}
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");
}
+87
View File
@@ -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;
}
}
+55
View File
@@ -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];
}
});
},
});
}
+106
View File
@@ -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());
}
+130
View File
@@ -0,0 +1,130 @@
// Candidate settlement adapter. Dependencies are the current mode/state reward functions.
import { applyBattleEndRelics } from "../relics/battle.js";
import { settleBondBattle } from "../bonds/state.js";
import { prepareRewardPackage, applyRewardPackage }
from "../progression/reward-package.js";
const copy = value => JSON.parse(JSON.stringify(value));
const idFor = (run, node) => JSON.stringify([run.runId, run.actIndex, node.id]);
export function createBattleSettlement({
store, config, getRandomRewardChoices, applyReward, completeNode,
enterNextAct, now = Date.now, logRewardPackage = () => {},
}) {
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) || (!encounter.skipRandomReward && (!Number.isSafeInteger(encounter.gold) || encounter.gold < 0)))) throw new Error("Invalid battle result");
const id = idFor(snapshot, node);
return store.update(storage => {
const run = current(storage, snapshot.runId);
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;
const bondGrowth = settleBondBattle(base);
if (outcome === "victory") {
base.player.hp = Math.max(1, Math.min(hp, base.player.maxHp ?? hp));
base.statistics.defeatedEnemies += encounter.defeatedEnemies ?? 1;
}
const relicRecovery = hp > 0
? applyBattleEndRelics(base, outcome) : 0;
const rewards = outcome === "victory" && !encounter.skipRandomReward ? getRandomRewardChoices(base, encounter.rewardPool).filter(c => c.name) : [];
if (outcome === "victory" && !encounter.skipRandomReward && !rewards.length) throw new Error("Empty battle reward pool");
// Persist the configured amount as a candidate, never as automatic victory income.
const choices = outcome === "victory" && !encounter.skipRandomReward
? [{id:"mengsan.reward.gold.shuying", kind:"gold", name:"金币", amount:encounter.gold}, ...rewards] : [];
const rewardPackage = outcome === "victory" && encounter.rewardPackage
? prepareRewardPackage(base, encounter.rewardPackage,
getRandomRewardChoices) : null;
run.battleFlow.pending = {id, state:"awaitingChoice", nodeId:node.id, base, choices:copy(choices), fixedRewards:copy(encounter.fixedRewards || []), victoryDialogue:copy(encounter.victoryDialogue || []), boss:!!encounter.boss, outcome, relicRecovery, bondGrowth};
run.battleFlow.pending.rewardPackage = copy(rewardPackage);
return run.battleFlow.pending;
});
},
async choose(runId, id, choiceId) {
return store.update(storage => {
const {item} = pending(storage, runId, id);
if (item.state === "chosen") {
const sameChoice = item.rewardPackage
? item.choiceId?.cardId === choiceId?.cardId &&
item.choiceId?.relicId === choiceId?.relicId
: item.choiceId === choiceId;
if (!sameChoice) throw new Error("Choice already locked");
return item;
}
const result = copy(item.base);
let route = "defeat";
if (item.outcome === "victory") {
if (item.rewardPackage) {
applyRewardPackage(result, item.rewardPackage, choiceId,
applyReward);
}
const choice = item.choices.find(c => c.id === choiceId);
if (!item.rewardPackage && item.choices.length && choiceId !== null) {
if (!choice) throw new Error("Reward is not a fixed candidate");
if (choice.kind === "gold") {
if (!Number.isSafeInteger(choice.amount) || choice.amount < 0) throw new Error("Invalid pending gold reward");
result.player.gold += choice.amount;
result.statistics.goldEarned += choice.amount;
} else applyReward(result, choice.effectId || choice.id);
} else if (!item.rewardPackage && choiceId !== null) throw new Error("Unexpected reward selection");
for (const rewardId of item.fixedRewards || []) applyReward(result, rewardId);
if (!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();
if (item.rewardPackage && item.outcome === "victory") {
logRewardPackage(item.rewardPackage, choiceId);
}
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};
});
},
});
}
+35
View File
@@ -0,0 +1,35 @@
// 梦三独有的出牌经济,不修改 noname 的全局牌定义。
import { _status } from "../../../../noname.js";
export const PLAYER_ENERGY = 3;
export const PLAYER_HAND_LIMIT = 3;
export const SHA_DAMAGE = 6;
export const TRICK_DAMAGE = 4;
const COSTS = Object.freeze({ sha: 1, mengsan_zhongsha: 2,
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;
};
+38
View File
@@ -0,0 +1,38 @@
import {
attackHitCount,
outgoingAttackDamage,
} from "./intent-damage.js";
const pendingDeathBlows = new WeakSet();
export function isDeathBlowIntent(intent) {
return intent?.intentType === "death_blow";
}
export function hasPendingDeathBlow(source) {
return pendingDeathBlows.has(source);
}
export async function beginDeathBlow(source, target, intent) {
if (!isDeathBlowIntent(intent)) {
throw new TypeError("濒死一击需要 death_blow 意图类型");
}
if (attackHitCount(intent) !== 1) {
throw new RangeError("濒死一击只能造成一次伤害");
}
const damage = outgoingAttackDamage(intent, source);
if (!source?.isAlive() || !target?.isAlive()) return false;
const hit = target.damage(damage, source);
hit.mengsanAttack_shuying = true;
await hit;
if (source.isAlive()) pendingDeathBlows.add(source);
return true;
}
export async function finishDeathBlow(source) {
if (!hasPendingDeathBlow(source)) return false;
pendingDeathBlows.delete(source);
if (source.isAlive()) await source.die();
return true;
}
+83
View File
@@ -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;
}
+28
View File
@@ -0,0 +1,28 @@
export function attackHitCount(intent) {
const hits = intent.hits ?? 1;
if (!Number.isInteger(hits) || hits < 1) {
throw new RangeError("攻击段数必须是正整数");
}
return hits;
}
export function outgoingAttackDamage(intent, source) {
if (!Number.isInteger(intent.damage) || intent.damage < 0) {
throw new RangeError("预测伤害必须是非负整数");
}
const storedStrength = source?.storage?.mengsanStrength_shuying;
const strength = Number.isInteger(storedStrength) ? storedStrength : 0;
let damage = Math.max(0, intent.damage + strength);
if (source?.storage?.mengsanWeak_shuying > 0) {
damage = Math.floor(damage * 0.75);
}
return damage;
}
export function previewAttackDamage(intent, source, target) {
let damage = outgoingAttackDamage(intent, source);
if (target?.storage?.mengsanVulnerable_shuying > 0) {
damage = Math.ceil(damage * 1.5);
}
return damage;
}
+53
View File
@@ -0,0 +1,53 @@
import { attackHitCount, outgoingAttackDamage } from "./intent-damage.js";
import { beginDeathBlow, isDeathBlowIntent } from "./death-blow.js";
export const getIntentHostiles = (game, source) =>
game.players.filter(target => target !== source && target.isAlive() &&
target.storage?.mengsanCamp_shuying !==
source.storage?.mengsanCamp_shuying);
export function createIntentExecutor(game, getActiveBattle) {
const active = source => source.isAlive() &&
Boolean(getActiveBattle()?.session.active);
const applyDebuff = (target, move) => {
if (!move.debuff) return;
const frail = move.debuff === "脆弱";
const key = frail ? "mengsanFrail_shuying" :
"mengsanVulnerable_shuying";
target.storage[key] = (target.storage[key] || 0) + move.stacks;
target.addSkill(frail ? "mengsan_frail_shuying" :
"mengsan_vulnerable_shuying");
target.markSkill(frail ? "mengsan_frail_shuying" :
"mengsan_vulnerable_shuying");
};
return async (source, move) => {
for (const target of getIntentHostiles(game, source)) {
if (!active(source)) break;
if (!target.isAlive()) continue;
if (isDeathBlowIntent(move)) {
await beginDeathBlow(source, target, move);
continue;
}
if (move.damage != null) {
for (let hitIndex = 0; hitIndex < attackHitCount(move); hitIndex++) {
if (!target.isAlive() || !active(source)) break;
const hit = target.damage(
outgoingAttackDamage(move, source), source);
hit.mengsanAttack_shuying = true;
hit.mengsanScriptedSkill_shuying = true;
await hit;
}
}
if (target.isAlive() && active(source)) applyDebuff(target, move);
}
if (!active(source)) return;
if (move.block) await source.changeHujia(move.block);
if (!active(source)) return;
if (move.strength) {
source.storage.mengsanStrength_shuying =
(source.storage.mengsanStrength_shuying || 0) + move.strength;
source.addSkill("mengsan_raider_strength_shuying");
source.markSkill("mengsan_raider_strength_shuying");
}
};
}
+147
View File
@@ -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;
};
}
+45
View File
@@ -0,0 +1,45 @@
const stunned = new WeakMap();
export const STUN_INTENT = Object.freeze({ intentType: "stun" });
export function isStunIntent(intent) {
return intent?.intentType === "stun";
}
export function isStunned(player) {
return stunned.has(player);
}
export function applyStun(player, intent, options = {}) {
if (stunned.has(player)) return false;
const { resume = "advance", recover } = options;
if (resume !== "advance" && resume !== "retry") {
throw new RangeError("击晕恢复方式无效");
}
if (typeof recover !== "function") {
throw new TypeError("击晕需要恢复回调");
}
if (!player?.isAlive()) return false;
stunned.set(player, { intent, resume, recover, skipped: false });
return true;
}
export function skipStunnedAction(player, phaseUse) {
const state = stunned.get(player);
if (!state || state.skipped) return false;
phaseUse.cancel();
state.skipped = true;
return true;
}
export async function finishStunnedTurn(player) {
const state = stunned.get(player);
if (!state?.skipped) return false;
stunned.delete(player);
if (player.isAlive()) await state.recover(state.resume, state.intent);
return true;
}
export function clearStun(player) {
return stunned.delete(player);
}
+42
View File
@@ -0,0 +1,42 @@
import { createCardData } from "../cards/card-data.js";
import { bondDefinitions } from "./definitions.js";
import { ensureBonds, markBondDown } from "./state.js";
import { getBondIntents } from "./intents.js";
export function initializeBondUnit(player, spec, run) {
if (!spec.bondId) return null;
const portrait = bondDefinitions[spec.bondId].portrait;
if (player.node?.avatar) {
player.node.avatar.style.backgroundImage = portrait ?
`url("${portrait}")` : "none";
}
player.storage.mengsanBond_shuying = {
id: spec.bondId, draw: spec.draw, handLimit: spec.handLimit,
intents: getBondIntents(spec).map(move => ({ ...move })),
turnsTaken: 0, intent: null,
};
player.storage.mengsanMaxEnergy_shuying = spec.energy;
player.storage.mengsanEnergy_shuying = spec.energy;
if (run.bondBattle?.id === spec.bondId) run.bondBattle.arrived = true;
return {
createDeck(prefix) {
return spec.deck.map((entry, index) => {
const card = typeof entry === "string" ? { name: entry } : entry;
return createCardData({
...card, id: `${prefix}_${index}`,
suit: card.suit ||
["spade", "heart", "club", "diamond"][index % 4],
number: card.number || index % 13 + 1,
}, { enemy: true });
});
},
};
}
export function recordBondDeath(player, run, log) {
const id = player.storage?.mengsanBond_shuying?.id;
if (!id || !run) return;
if (ensureBonds(run).npcs[id]?.down) return;
markBondDown(run, id);
log(`${bondDefinitions[id].name}已退出战斗,羁绊状态:濒死`);
}
+59
View File
@@ -0,0 +1,59 @@
import { getBondIntents } from "./intents.js";
export const DEFAULT_BOND_DECK = Object.freeze([
"sha", "sha", "sha", "shan", "shan", "tao", "wuzhong",
]);
export function getBondDeck(definition) {
const deck = definition.deck;
return deck == null || Array.isArray(deck) && !deck.length ?
[...DEFAULT_BOND_DECK] : deck;
}
const emptyGrowth = () => Array.from({ length: 10 }, () => ({
maxHp: null,
hand: null,
draw: null,
handLimit: null,
energy: null,
skills: null,
}));
export const bondDefinitions = {
guanyu: {
name: "关羽", character: "guanyu", portrait: null,
deck: null, intents: null, growth: emptyGrowth(),
},
zhangfei: {
name: "张飞", character: "zhangfei", portrait: null,
deck: null, intents: null, growth: emptyGrowth(),
},
};
export function getBondCombat(id, level) {
const definition = bondDefinitions[id];
if (!definition || !Number.isInteger(level) || level < 1 || level > 10) {
return null;
}
const stats = definition.growth[level - 1];
if (!stats || !["maxHp", "hand", "draw", "handLimit", "energy"]
.every(key => Number.isInteger(stats[key]) && stats[key] >= 0)
|| stats.maxHp < 1 || stats.energy < 1
|| !Array.isArray(stats.skills)
|| !stats.skills.every(skill => typeof skill === "string" && skill)) {
return null;
}
const skills = new Set();
for (const growth of definition.growth.slice(0, level)) {
for (const skill of growth.skills || []) skills.add(skill);
}
return {
id: `bond_${id}`, bondId: id, character: definition.character,
camp: "ally", after: "player", inheritSkills: false,
hp: stats.maxHp, maxHp: stats.maxHp, hand: stats.hand,
draw: stats.draw, handLimit: stats.handLimit, energy: stats.energy,
skills: [...skills],
deck: getBondDeck(definition),
intents: getBondIntents(definition).map(move => ({ ...move })),
};
}
+64
View File
@@ -0,0 +1,64 @@
import { createIntentExecutor } from "../battle/intent-effects.js";
import { isStunned } from "../battle/stun-intent.js";
export const DEFAULT_BOND_INTENTS = Object.freeze([
Object.freeze({ id: "assist_strike", name: "援击", damage: 6 }),
Object.freeze({ id: "assist_combo", name: "连击", damage: 3, hits: 2 }),
Object.freeze({ id: "assist_guard", name: "整备", block: 5 }),
]);
export function getBondIntents(definition) {
const moves = definition.intents;
if (moves == null || Array.isArray(moves) && !moves.length) {
return DEFAULT_BOND_INTENTS;
}
if (!Array.isArray(moves) || moves.some(move =>
!move || typeof move.id !== "string" || !move.id ||
typeof move.name !== "string" || !move.name ||
!["damage", "block", "strength"].every(key => move[key] == null ||
Number.isInteger(move[key]) && move[key] >= 0) ||
move.hits != null && (!Number.isInteger(move.hits) || move.hits < 1) ||
move.debuff != null && (!["脆弱", "易伤"].includes(move.debuff) ||
!Number.isInteger(move.stacks) || move.stacks < 1) ||
move.intentType != null && move.intentType !== "death_blow" ||
move.intentType === "death_blow" &&
(move.damage == null || (move.hits ?? 1) !== 1) ||
move.damage == null && !move.block && !move.strength && !move.debuff)) {
throw new Error("羁绊 NPC 意图组配置无效");
}
return moves;
}
export function selectBondIntent(combat) {
return combat.intents[combat.turnsTaken % combat.intents.length];
}
export function createBondIntentActions(game, getActiveBattle) {
const executeEffects = createIntentExecutor(game, getActiveBattle);
const planBondIntent = player => {
const combat = player.storage?.mengsanBond_shuying;
if (!combat || !player.isAlive() || isStunned(player) || combat.intent) {
return;
}
combat.intent = selectBondIntent(combat);
game.mengsanSetEnemyIntent_shuying(player, combat.intent);
};
const executeBondIntent = async player => {
const combat = player.storage?.mengsanBond_shuying;
if (!combat?.intent || !player.isAlive() || isStunned(player) ||
!getActiveBattle()?.session.active) return;
const move = combat.intent;
combat.intent = null;
combat.turnsTaken++;
game.mengsanSetEnemyIntent_shuying(player, null);
if (player.storage.mengsanEnergy_shuying < 1) {
game.log(player, "费用不足,未发动羁绊意图", move.name);
return;
}
player.storage.mengsanEnergy_shuying--;
getActiveBattle()?.energyUI.get(player)?.();
game.log(player, "消耗1费用发动羁绊意图", move.name);
await executeEffects(player, move);
};
return { planBondIntent, executeBondIntent };
}
+73
View File
@@ -0,0 +1,73 @@
import { bondDefinitions, getBondCombat } from "./definitions.js";
export const bondProbability = level => (level - 1) / 9;
export function ensureBonds(run) {
return run.player.bonds ||= { selected: null, npcs: {} };
}
export function meetBond(run, id, level = 1) {
if (!Object.hasOwn(bondDefinitions, id) || !Number.isInteger(level)
|| level < 1 || level > 10) throw new Error("羁绊 NPC 或等级无效");
const bonds = ensureBonds(run);
if (bonds.npcs[id]) return false;
bonds.npcs[id] = { level, progress: 0, down: false };
return true;
}
export function initializeInnateBonds(run) {
ensureBonds(run);
if (run.player.character === "mengsan_liubei_shuying") {
meetBond(run, "guanyu", 8);
meetBond(run, "zhangfei", 8);
}
}
export function selectBond(run, id) {
const bonds = ensureBonds(run);
if (id !== null && !Object.hasOwn(bonds.npcs, id)) {
throw new Error("尚未结识该 NPC");
}
bonds.selected = id;
}
export function rescueBond(run, id) {
const npc = ensureBonds(run).npcs[id];
if (!npc?.down) return false;
npc.down = false;
return true;
}
export function markBondDown(run, id) {
const npc = ensureBonds(run).npcs[id];
if (npc) npc.down = true;
}
export function prepareBondBattle(run, random) {
const bonds = ensureBonds(run);
const id = bonds.selected;
const npc = bonds.npcs[id];
run.bondBattle = npc ? { id, arrived: false } : null;
if (!npc || npc.down) return null;
const combat = getBondCombat(id, npc.level);
if (!combat || random() >= bondProbability(npc.level)) return null;
return combat;
}
export function settleBondBattle(run) {
const battle = run.bondBattle;
if (!battle) return null;
const npc = ensureBonds(run).npcs[battle.id];
if (!npc) throw new Error("助战羁绊记录丢失");
const gain = battle.arrived ? 50 : 25;
if (npc.level < 10) {
npc.progress += gain;
while (npc.progress >= 100 && npc.level < 10) {
npc.progress -= 100;
npc.level++;
}
}
if (npc.level === 10) npc.progress = 0;
run.bondBattle = null;
return { id: battle.id, gain, level: npc.level, progress: npc.progress };
}
+10
View File
@@ -0,0 +1,10 @@
// Shared mode-owned affix registry; adding a new entry also exposes it in GM.
export const AFFIX_INFO = Object.freeze({
void: Object.freeze({ name: "虚无", description: "弃牌阶段开始时移除此牌,退出此次战斗;即使同时具有保留也会移除。" }),
retain: Object.freeze({ name: "保留", description: "弃牌阶段开始时,此牌不计入手牌上限;若同时具有虚无,仍会被移除。" }),
unplayable: Object.freeze({ name: "不能被打出", description: "此牌不能使用或打出,但可以被弃置。" }),
ingenious: Object.freeze({ name: "奇巧", description: "不因弃牌阶段进入弃牌堆时,若可以使用,则使用此牌。" }),
eternal: Object.freeze({ name: "永恒", description: "此牌不能从你的牌组移除或变化。" }),
innate: Object.freeze({ name: "固有", description: "战斗开始时移至手牌;若已在手牌中则不移动。" }),
exhaust: Object.freeze({ name: "消耗", description: "使用或打出后移出此次战斗,并可在消耗牌堆查看;消耗牌堆不参与洗牌。" }),
});
+48
View File
@@ -0,0 +1,48 @@
import { cardDefinitions, canAcquireCard } from "./card-definitions.js";
import { cardUpgradeRule } from "./upgrades.js";
const suits = ["spade", "heart", "club", "diamond"];
// 仅创建可序列化的牌组数据;战斗中的实体牌仍由 game.createCard 创建。
export function createCardData(spec, { character, enemy = false, registry = cardDefinitions } = {}) {
if (!spec || typeof spec !== "object" || !Object.hasOwn(registry, spec.name)) {
throw new Error(`梦三牌未注册:${spec?.name}`);
}
if (!enemy && !canAcquireCard(character, spec.name, registry)) {
throw new Error(`武将 ${character || "未知"} 无法获得牌:${spec.name}`);
}
if (typeof spec.id !== "string" || !spec.id || !suits.includes(spec.suit) ||
!Number.isInteger(spec.number) || spec.number < 1 || spec.number > 13 ||
(spec.nature != null && typeof spec.nature !== "string") ||
!Number.isInteger(spec.upgrade ?? 0) || (spec.upgrade ?? 0) < 0 ||
(spec.upgrade ?? 0) > (cardUpgradeRule(spec.name)?.maxLevel || 0) ||
!Array.isArray(spec.affixes ?? []) || !(spec.affixes ?? []).every(value => typeof value === "string")) {
throw new Error(`梦三牌数据无效:${spec.name}`);
}
return {
id: spec.id, suit: spec.suit, number: spec.number, name: spec.name,
nature: spec.nature ?? null, affixes: (spec.affixes ?? []).slice(), upgrade: spec.upgrade ?? 0,
};
}
export function createRandomCardData(run, name, { random, now = Date.now, upgrade = 0, affixes = [] } = {}) {
if (!run?.player || !Array.isArray(run.player.deck) || typeof random !== "function") throw new Error("梦三随机牌参数无效");
// 先检查资格,再消耗随机数;失败的获得操作不改变后续奖励序列。
if (!canAcquireCard(run.player.character, name)) throw new Error(`武将 ${run.player.character} 无法获得牌:${name}`);
const stamp = now();
let id;
do { id = `mengsan_card_${stamp}_${Math.floor(random() * 1e6)}`; }
while (run.player.deck.some(card => card.id === id));
return createCardData({
id, suit: suits[Math.floor(random() * suits.length)], number: 1 + Math.floor(random() * 13),
name, nature: null, upgrade, affixes,
}, { character: run.player.character });
}
export function addCardToDeck(run, spec) {
const card = createCardData(spec, { character: run?.player?.character });
if (!Array.isArray(run?.player?.deck)) throw new Error("梦三牌组无效");
if (run.player.deck.some(existing => existing.id === card.id)) throw new Error(`梦三牌 ID 重复:${card.id}`);
run.player.deck.push(card);
return card;
}
+36
View File
@@ -0,0 +1,36 @@
// 梦三永久牌组的可获得牌注册表。这里登记牌名与归属,不复制引擎中的效果定义。
// 新增武将专属牌时填写 owner 为该武将 ID;通用牌填写 owner: null。
const definitions = [
{ name: "sha", category: "damage", owner: null },
{ name: "mengsan_zhongsha", category: "damage", owner: null },
{ name: "shan", category: "utility", owner: null },
{ name: "tao", category: "recovery", owner: null },
{ name: "jiu", category: "recovery", owner: null },
{ name: "guohe", category: "utility", owner: null },
{ name: "wuzhong", category: "utility", owner: null },
{ name: "juedou", category: "damage", owner: null },
{ name: "bagua", category: "utility", owner: null },
// 当前仅刘备专属首关的固定奖励会永久获得【的卢】。
{ name: "dilu", category: "utility", owner: "mengsan_liubei_shuying" },
];
export function createCardRegistry(entries) {
const registry = Object.create(null);
for (const entry of entries) {
if (!entry || typeof entry.name !== "string" || !/^[a-z][a-z0-9_]*$/.test(entry.name) ||
!["damage", "recovery", "utility"].includes(entry.category) ||
!(entry.owner === null || typeof entry.owner === "string" && entry.owner.length > 0) ||
Object.hasOwn(registry, entry.name)) {
throw new Error(`梦三牌定义无效或重复:${entry?.name}`);
}
registry[entry.name] = Object.freeze({ name: entry.name, category: entry.category, owner: entry.owner });
}
return Object.freeze(registry);
}
export const cardDefinitions = createCardRegistry(definitions);
export function canAcquireCard(character, name, registry = cardDefinitions) {
const definition = Object.hasOwn(registry, name) ? registry[name] : null;
return !!definition && (definition.owner === null || definition.owner === character);
}
+143
View File
@@ -0,0 +1,143 @@
// 梦三的模式牌定义:复制标准/军争的实体牌定义,不改动原牌包。
// 保留原牌名,以兼容原有牌的响应、装备技能及转化牌引用。
import standard from "../../../../card/standard.js";
import extra from "../../../../card/extra.js";
import { _status, game } from "../../../../noname.js";
import { cardCost, canPayCard, isActiveCardUse, SHA_DAMAGE,
TRICK_DAMAGE } from "../battle/combat-rules.js";
import { cardUpgradeLevel, cardUpgradeRule } from "./upgrades.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;
if (cardUpgradeLevel(event.card)) {
event.baseDamage += cardUpgradeRule("sha").damage - SHA_DAMAGE;
}
return nativeSha.content.call(this, event, trigger, player);
};
card.sha.usable = Infinity;
card.sha.cardPrompt = used => {
const damage = cardUpgradeLevel(used)
? cardUpgradeRule("sha").damage : SHA_DAMAGE;
return `梦三:主动使用消耗1费用;目标须使用【闪】,` +
`否则受到${damage}点伤害。每回合使用次数不限。` +
(cardUpgradeLevel(used) ? "已强化(上限1次)。" :
"可强化1次:伤害变为9点。");
};
translate.sha_info = `梦三:主动使用消耗1费用。出牌阶段,对攻击范围内的一名角色使用;其须使用【闪】,否则受到${SHA_DAMAGE}点伤害。每回合使用次数不限。`;
card.mengsan_zhongsha = {
type: "basic",
fullskin: true,
image: "ext:术樱包/mengsan/assets/cards/mengsan_zhongsha.png",
mengsanCost_shuying: cardCost({ name: "mengsan_zhongsha" }),
enable(used, player, event) {
return !isActiveCardUse(event || _status.event, player) ||
canPayCard(player, used);
},
usable: Infinity,
selectTarget: 1,
range: nativeSha.range,
filterTarget: nativeSha.filterTarget,
async content(event, trigger, player) {
const target = event.target;
const effect = cardUpgradeLevel(event.card)
? cardUpgradeRule("mengsan_zhongsha")
: { damage: 8, vulnerable: 2 };
const damage = target.damage(effect.damage, player);
damage.mengsanAttack_shuying = true;
await damage;
if (!target.isAlive()) return;
target.storage.mengsanVulnerable_shuying =
(target.storage.mengsanVulnerable_shuying || 0) +
effect.vulnerable;
target.addSkill("mengsan_vulnerable_shuying");
target.markSkill("mengsan_vulnerable_shuying");
game.log(target, `获得${effect.vulnerable}层易伤`);
},
ai: { order: 4, result: { target: -1.5 },
tag: { damage: 1 } },
cardPrompt(used) {
const effect = cardUpgradeLevel(used)
? cardUpgradeRule("mengsan_zhongsha")
: { damage: 8, vulnerable: 2 };
return "梦三:主动使用消耗2费用。对攻击范围内的" +
`一名其他角色造成${effect.damage}点伤害,然后给予其` +
`${effect.vulnerable}层易伤。受到攻击伤害增加50%` +
"(向上取整),每个自身回合结束减少1层。" +
"每回合使用次数不限。" + (cardUpgradeLevel(used)
? "已强化(上限1次)。" :
"可强化1次:10点伤害、3层易伤。");
},
};
translate.mengsan_zhongsha = "重杀";
translate.mengsan_zhongsha_info =
"梦三:主动使用消耗2费用。出牌阶段,对攻击范围内的" +
"一名其他角色造成8点伤害,然后给予其2层易伤。" +
"易伤使受到的攻击伤害增加50%(向上取整)," +
"每个自身回合结束减少1层。每回合使用次数不限。";
// 【桃】的回复量也由梦三牌本体提供;救援仍不消耗费用。
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 = TRICK_DAMAGE;
card[name].content = async function (event, trigger, player) {
if (typeof event.baseDamage !== "number") event.baseDamage = TRICK_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 = TRICK_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: [...names, "mengsan_zhongsha"] };
}
+163
View File
@@ -0,0 +1,163 @@
// 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 { AFFIX_INFO } from "./affixes.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 ||= {};
card.storage.mengsanOwnerId_shuying = owner.playerid;
if (data) {
card.storage.mengsanCard_shuying = JSON.parse(JSON.stringify(data));
// Retired affixes from old saves must not acquire new battle behaviour.
card.storage.mengsanCard_shuying.affixes = (card.storage.mengsanCard_shuying.affixes || []).filter(key => key !== "annihilate");
}
// Existing foreign/generated cards retain their original destruction semantics.
if (!data) return;
card.destroyLog = false;
card.destroyed = (current, position) => {
if (released || !["discardPile", "equip", "judge"].includes(position)) return false;
if (current.storage.mengsanCard_shuying.affixes.includes("exhaust") && current.storage.mengsanConsumed_shuying) {
if (current.storage.mengsanExhausted_shuying) return true;
current.storage.mengsanExhausted_shuying = true;
battle.exhaustPile.push(snapshot(current));
refresh();
return true;
}
return false; // Actual discarded cards remain available to native skills.
};
for (const key of card.storage.mengsanCard_shuying.affixes) card.addGaintag(AFFIX_INFO[key]?.name || key);
if (data.upgrade) card.addGaintag(`强化+${data.upgrade}`);
}
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,
addAffix(card, key) {
if (released || !Object.hasOwn(AFFIX_INFO, key) || !owned.has(card) || !owner.getCards("h").includes(card)) return false;
const affixes = card.storage?.mengsanCard_shuying?.affixes;
if (!Array.isArray(affixes) || affixes.includes(key)) return false;
affixes.push(key);
card.addGaintag(AFFIX_INFO[key].name);
refresh();
return true;
},
// 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").filter(card => !card.storage?.mengsanCard_shuying?.affixes?.includes("innate")).slice(limit)) {
if (owned.has(card)) draw.insertBefore(card, draw.firstChild);
else card.remove();
}
refresh();
},
drawInnate() {
const cards = Array.from(draw.childNodes).filter(card => card.storage?.mengsanCard_shuying?.affixes?.includes("innate") && !owner.getCards("h").includes(card));
if (cards.length) owner.directgain(cards);
refresh();
return cards;
},
withOwner(callback) { const before = forcedOwner; forcedOwner = true; try { return callback(); } finally { forcedOwner = before; } },
};
}
+33
View File
@@ -0,0 +1,33 @@
import { createCardData } from "./card-data.js";
// Mode-owned starting decks. Existing saves always retain their actual player.deck.
export const defaultDeck = Object.freeze([
["spade", 7, "sha"], ["heart", 10, "sha"],
["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"],
].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"],
],
};
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) => createCardData({
id: `${prefix}_${index}`, suit: entry[0], number: entry[1], name: entry[2],
nature: entry[3] || null, affixes: entry[4] ? entry[4].slice() : [], upgrade: 0,
}, { character }));
}
+38
View File
@@ -0,0 +1,38 @@
const upgrades = Object.freeze({
sha: Object.freeze({ maxLevel: 1, damage: 9 }),
mengsan_zhongsha: Object.freeze({ maxLevel: 1, damage: 10,
vulnerable: 3 }),
});
export const cardUpgradeRule = name =>
Object.hasOwn(upgrades, name) ? upgrades[name] : null;
export function cardUpgradeLevel(card) {
const data = card?.storage?.mengsanCard_shuying || card;
if (!data || data.name !== card?.name) return 0;
const limit = cardUpgradeRule(card.name)?.maxLevel || 0;
return Number.isInteger(data.upgrade) && data.upgrade > 0
? Math.min(data.upgrade, limit) : 0;
}
export function canUpgradeCard(card) {
const rule = cardUpgradeRule(card?.name);
const level = card?.upgrade ?? 0;
return Boolean(rule && Number.isInteger(level) && level >= 0 &&
level < rule.maxLevel && !card.affixes?.includes("eternal"));
}
export const hasUpgradeableCard = deck =>
Array.isArray(deck) && deck.some(canUpgradeCard);
export function upgradeRandomCard(deck, random) {
const eligible = Array.isArray(deck) ? deck.filter(canUpgradeCard) : [];
if (!eligible.length) return null;
const value = random();
if (!Number.isFinite(value) || value < 0 || value >= 1) {
throw new RangeError("梦三强化随机数必须位于[0, 1)");
}
const card = eligible[Math.floor(value * eligible.length)];
card.upgrade = (card.upgrade || 0) + 1;
return card;
}
+12 -51
View File
@@ -1,68 +1,29 @@
import { defaultDeck } from "./cards/starting-deck.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: 8,
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: "剧情",
chest: "宝箱",
rest: "休息",
shop: "商店",
boss: "Boss",
},
nodeContents: contentRegistry.nodeContents,
rewardPools: contentRegistry.rewardPools,
rewards: contentRegistry.rewards,
};
export default mengsanConfig;
+14
View File
@@ -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,
};
+29
View File
@@ -0,0 +1,29 @@
const act1Map = {
id: "act1",
name: "第一关·初梦",
floorNodes: [
1, 4, 4, 4, 4, 4, 4, 4, 4,
4, 4, 4, 4, 4, 4, 4, 1,
],
minFloorNodes: [
1, 3, 3, 3, 3, 3, 3, 3, 3,
3, 3, 3, 3, 3, 3, 3, 1,
],
nodeWeights: { battle: 65, event: 15, rest: 10, shop: 10 },
requiredFloors: { 2: "battle", 10: "chest", 16: "rest" },
chestRewardPool: "act1.pool.chest",
fixedNodes: [
{
floor: 0,
position: "start",
type: "battle",
contentId: "act1.battle.liubeiOpening",
},
],
enemies: ["mengsan_flyconid_shuying", "mengsan_raider_trio_shuying"],
eliteEnemies: ["re_xiahoudun", "re_zhangliao", "re_xuzhu"],
boss: "re_lvbu",
baseGold: 20,
};
export default act1Map;
+32
View File
@@ -0,0 +1,32 @@
// 灰机 Wiki:五名劫掠者中随机抽取三名,且同场不重复。
export const RAIDER_TRIO_ENCOUNTER = "mengsan_raider_trio_shuying";
export const RAIDER_MEMBERS = Object.freeze([
Object.freeze({ character: "mengsan_raider_brute_shuying", minHp: 30, maxHp: 33 }),
Object.freeze({ character: "mengsan_raider_assassin_shuying", minHp: 18, maxHp: 23 }),
Object.freeze({ character: "mengsan_raider_axe_shuying", minHp: 20, maxHp: 22 }),
Object.freeze({ character: "mengsan_raider_crossbow_shuying", minHp: 18, maxHp: 21 }),
Object.freeze({ character: "mengsan_raider_tracker_shuying", minHp: 21, maxHp: 25 }),
]);
export function createRaiderTrioBattlePlan(random) {
if (typeof random !== "function") throw new TypeError("劫掠者团伙需要随机数生成器");
const members = RAIDER_MEMBERS.slice();
const pick = count => {
const value = random();
if (!Number.isFinite(value) || value < 0 || value >= 1) throw new RangeError("随机数必须在 [0,1) 内");
return Math.floor(value * count);
};
for (let index = members.length - 1; index > 0; index--) {
const other = pick(index + 1);
[members[index], members[other]] = [members[other], members[index]];
}
return {
units: members.slice(0, 3).map((member, index) => {
const hp = member.minHp + pick(member.maxHp - member.minHp + 1);
return { id: `raider_${index + 1}`, character: member.character,
camp: "enemy", tier: "normal", hp, maxHp: hp, hand: 4 };
}),
rules: [],
};
}
+25
View File
@@ -0,0 +1,25 @@
import { relicRewardIds } from "../../../relics/definitions.js";
export const rewardPools = {
"act1.pool.liubeiOpening.relics": [...relicRewardIds],
"act1.pool.chest": [
...relicRewardIds,
"shared.reward.item.handCharm",
"shared.reward.maxHp",
"shared.reward.support.scout",
],
"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,73 @@
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: 400,
skipRandomReward: true, fixedRewards: ["act1.reward.dilu"], openingDialogue, victoryDialogue,
rewardPackage: { gold: 400, relicPool: "act1.pool.liubeiOpening.relics" },
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" },
],
}],
},
};
+8
View File
@@ -0,0 +1,8 @@
import actMap from "./map.js";
export default {
map: actMap,
nodeContents: {},
rewardPools: {},
rewards: {},
};
+13
View File
@@ -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;
+8
View File
@@ -0,0 +1,8 @@
import actMap from "./map.js";
export default {
map: actMap,
nodeContents: {},
rewardPools: {},
rewards: {},
};
+13
View File
@@ -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;
+106
View File
@@ -0,0 +1,106 @@
import { validateBattlePlan } from "../battle/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.rewardPackage) {
const pack = content.rewardPackage;
if (!Number.isSafeInteger(pack.gold) || pack.gold < 0 ||
!rewardPools[pack.relicPool]?.every(id => rewards[id].relic)) {
throw new Error(`梦三奖励包配置无效:${contentId}`);
}
}
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;
+39
View File
@@ -0,0 +1,39 @@
// 梦三私有武将:不改动原版界刘备,也不导入整套国战技能。
export function createScenarioCharacters(Character) {
return {
mengsan_liubei_shuying: new Character({ sex: "male", group: "han", hp: 50, maxHp: 50,
skills: ["mengsan_taoyuan_bond_shuying"],
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_flyconid_shuying: new Character({ sex: "none", group: "qun", hp: 48, maxHp: 48,
skills: [], img: "extension/术樱包/mengsan/assets/portraits/flyconid-portrait.png" }),
mengsan_raider_brute_shuying: new Character({ sex: "none", group: "qun", hp: 32, maxHp: 32,
skills: [], img: "extension/术樱包/mengsan/assets/portraits/raider-brute-portrait.png" }),
mengsan_raider_assassin_shuying: new Character({ sex: "none", group: "qun", hp: 21, maxHp: 21,
skills: [], img: "extension/术樱包/mengsan/assets/portraits/raider-assassin-portrait.png" }),
mengsan_raider_axe_shuying: new Character({ sex: "none", group: "qun", hp: 21, maxHp: 21,
skills: [], img: "extension/术樱包/mengsan/assets/portraits/raider-axe-portrait.png" }),
mengsan_raider_crossbow_shuying: new Character({ sex: "none", group: "qun", hp: 20, maxHp: 20,
skills: [], img: "extension/术樱包/mengsan/assets/portraits/raider-crossbow-portrait.png" }),
mengsan_raider_tracker_shuying: new Character({ sex: "none", group: "qun", hp: 23, maxHp: 23,
skills: [], img: "extension/术樱包/mengsan/assets/portraits/raider-tracker-portrait.png" }),
mengsan_sushuang_zhangshiping_shuying: new Character({ sex: "male", group: "shu", hp: 4, maxHp: 4,
skills: [], img: "extension/术樱包/mengsan/assets/portraits/sushuang-zhangshiping.png", names: "苏|双-张|世平" }),
};
}
export const scenarioTranslations = {
mengsan_liubei_shuying: "界刘备",
mengsan_liubei_shuying_title: "50/50生命 · 先天桃园羁绊",
mengsan_taoyuan_bond_shuying: "桃园羁绊",
mengsan_taoyuan_bond_shuying_info: "先天:本征程初始结识关羽、张飞,二人羁绊均为8级。可在行军菜单中指定一名助战角色;战斗结束,到场增加50%升级进度,未到场增加25%。",
mengsan_soldier_shuying: "士兵",
mengsan_flyconid_shuying: "飞蝇菌子",
mengsan_raider_brute_shuying: "劫掠者暴徒",
mengsan_raider_assassin_shuying: "劫掠者刺客",
mengsan_raider_axe_shuying: "劫掠者斧手",
mengsan_raider_crossbow_shuying: "劫掠者弩手",
mengsan_raider_tracker_shuying: "劫掠者追踪手",
mengsan_sushuang_zhangshiping_shuying: "苏双&张世平",
};
+7
View File
@@ -0,0 +1,7 @@
import { rewardPools, rewards } from "./rewards.js";
export default {
nodeContents: {},
rewardPools,
rewards,
};
+38
View File
@@ -0,0 +1,38 @@
import { relicRewards, relicRewardIds } from "../../relics/definitions.js";
export const rewards = {
...relicRewards,
"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.zhongsha": {
card: { name: "mengsan_zhongsha" }, name: "获得一张【重杀】",
description: "2费:造成8点伤害,然后给予目标2层易伤。",
},
"shared.reward.card.tao": { effectId: "card_tao", name: "获得一张【桃】", description: "加入独立永久牌组" },
"shared.reward.card.wuzhong": { card: { name: "wuzhong" }, name: "获得一张【无中生有】", description: "加入独立永久牌组" },
"shared.reward.card.upgrade": { effectId: "upgrade", name: "随机强化", description: "随机强化一张尚未满级的可强化牌" },
"shared.reward.heal": { effectId: "heal", name: "整顿伤势", description: "回复 8 点生命" },
"shared.reward.item.handCharm": { effectId: "item_hand",
relic: "mengsan_hand_charm_shuying", 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.zhongsha",
"shared.reward.card.tao",
"shared.reward.card.wuzhong",
],
"shared.pool.boss.premium": [
...relicRewardIds,
"shared.reward.skill.heroic",
"shared.reward.item.handCharm",
"shared.reward.maxHp",
],
};
+99
View File
@@ -0,0 +1,99 @@
# 梦三:关卡条件与战斗支援
## 约定
- 本模块是梦三玩法规则,不批量修改武将技能源码。
- 主角固定单位 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`,旧存档没有该字段时按空列表处理。
有限次数只随结算事务提交;重载未完成战斗不会额外扣次,重试结算不会重复发放。
行囊的“战斗支援”显示来源名称和持续场数。羁绊助战单独在行军菜单
“羁绊”中管理,规则和配置见 [bonds.md](bonds.md)。
## 已接入的示例
- 林中伏兵“主动追击”:两名初始敌人,第 3 轮必定出现一名敌军援军。
- 敌军队长体力不高于 2 时摸两张;主角手牌不多于 1 时补两张,各限一次。
- 斥候谢礼奖励池加入“道具·斥候援令”,本征程后续每场由友方张辽参战。
## 结算和清理
仅主角死亡判负;存活友军不阻碍胜利。清空敌军且没有必须等待的敌军规则后判胜。
援军使用原生加入座次/enterGame,登记玩家 ID、技能释放和战斗资源清理。
身份模式原有的杀反贼摸三张、误杀忠臣弃牌惩罚不沿用;奖励由梦三结算和上述规则决定。
统计记录实际战败敌军数量,而非固定每场加一。
已做隔离配置/规则/事务检查;未启动游戏、未进行实际战斗或界面验收。
+115
View File
@@ -0,0 +1,115 @@
# 梦三羁绊与助战
## 当前规则
- 行军菜单 → 羁绊:可结识多名 NPC,但只能指定一名助战;允许不指定。
- 羁绊仅随本次征程保存,新征程重置。刘备先天技能“桃园羁绊”
初始结识 `guanyu`、`zhangfei`,均为 8 级、升级进度 0%,默认未指定助战。
- 普通事件结识默认 1 级、进度 0%。每级助战概率为 `(level - 1) / 9`,
1 级为 0%,10 级为 100%。
- 战前在地图切换助战;开战只判定一次。成功后占玩家下家,AI 自动行动。
原有剧情与道具支援保留,原有 8 席校验和座次代码保留。
- 完成战斗结算:指定 NPC 到场 +50%,未到场 +25%。
濒死、战斗配置未补齐也属于未到场。不要求 NPC 存活,不限定胜利。
退出或重载未完成战斗不增长;重复结算不重复增长。
- 100% 进度升一级并保留溢出,最高 10 级,满级不再积累进度。
升级数值与新增技能从下一场生效。
- 存活 NPC 下一场恢复满血、重建自己的抽牌堆/弃牌堆/消耗牌堆。
抽牌堆耗尽后洗回自身弃牌堆,无可用牌时不使玩家征程失败。
- NPC 在自身出牌阶段前消耗 1 费用执行当前意图,然后照常使用手牌。
意图在开场及玩家新一轮行动时预告,按 NPC 自身已执行次数循环。
NPC 获得的护甲在其下次主循环回合开始时清除,与劫掠者一致。
- 原生死亡结算后离场,存档标记 `down: true`,界面显示“濒死”。
未救助前不能参战,但仍可指定助战并获得未到场的 25% 进度。
- 怪物意图攻击、负面效果作用于全部存活敌对单位,含剧情与道具友军。
每个目标承受完整段数,各自结算易伤、护甲与技能;怪物自身增益只执行一次。
意图伤害数字在目标状态不同时显示伤害范围,护甲仍由伤害流程抵扣。
## 手动补充 NPC
配置位置:`bonds/definitions.js` 的 `bondDefinitions`。
关羽、张飞的 `portrait` 以及 10 个 `growth` 行目前均预留。
成长数值与技能未填写完整前可以结识、选择与成长,但不能参战;
不继承原版武将技能。`deck` 和 `intents` 无需预先填写,已有默认兜底。
`portrait` 为空时不显示原版立绘。
每个成长行的字段:
| 字段 | 含义 |
| --- | --- |
| `maxHp` | 该等级的最大生命,参战时满血,正整数 |
| `hand` | 初始手牌数量,非负整数 |
| `draw` | 自身摸牌阶段摸牌数,非负整数 |
| `handLimit` | 手牌上限,非负整数 |
| `energy` | 每回合费用,正整数 |
| `skills` | 本等级新解锁技能 ID 数组;无新增时填写 `[]`,低等级已解锁技能累计保留 |
`growth[0]` 对应 1 级,`growth[9]` 对应 10 级。数值逐行填写,不自动插值。
当前关卡校验要求生命及初始手牌不超过 99。
`deck` 为该 NPC 独立的基础牌组,填写牌名数组,或 `{name,suit,number}` 数组。
牌名必须已在游戏注册。每场创建新的卡牌实例,不与其他 NPC 或玩家共享实例。
## 默认牌库和意图
`deck` 未填写、为 `null` 或 `[]` 时,使用 `DEFAULT_BOND_DECK`:
3 张【杀】、2 张【闪】、1 张【桃】、1 张【无中生有】,每场独立生成并洗牌。
NPC 的 `intents` 未填写、为 `null` 或 `[]` 时,使用
`bonds/intents.js` 的 `DEFAULT_BOND_INTENTS`,按下列顺序循环:
| 意图 | 效果 |
| --- | --- |
| 援击 | 对每名存活敌对单位造成 6 点攻击伤害 |
| 连击 | 对每名存活敌对单位造成 2 段攻击伤害,每段 3 点 |
| 整备 | 自身获得 5 点护甲 |
默认意图数值不随等级自动插值。NPC 可以在 `bondDefinitions` 中
填写独立的 `intents` 数组来覆盖默认组,例如:
```js
intents: [
{ id: "strike", name: "重击", damage: 8 },
{ id: "break", name: "破阵", damage: 3, hits: 2,
debuff: "易伤", stacks: 1 },
{ id: "guard", name: "坚守", block: 6 },
],
```
每项需要 `id`、`name`,可配置 `damage`、`hits`、`debuff`、`stacks`、
`block`、`strength`。伤害和增益为非负整数,攻击段数为正整数;
负面效果目前支持“脆弱”“易伤”。`intentType: "death_blow"`
可选,但只允许单段攻击,并沿用出牌阶段结束后自身死亡的规则。
显式填写的无效意图会报错,避免将错误配置静默替换成默认值。
所有攻击目标各自结算护甲、易伤及技能;自身护甲、力量增益只执行一次。
费用不足时该项不产生效果,轮次仍向后推进;每场重新从第一项开始。
## 事件结识接口
剧情选项的 `outcome` 可填写:
```js
outcome: {
bonds: ["guanyu", { id: "zhangfei", level: 3 }],
}
```
已结识 NPC 再次结识不会重置等级、进度或濒死状态。
也可从 `bonds/state.js` 导入 `meetBond(run, id, level)`,
在现有事件结算中调用并使用现有征程存档流程保存。
## 救助接口预留
`bonds/state.js` 导出 `rescueBond(run, id)`:
仅解除该 NPC 的濒死标记,返回是否发生状态变化。
当前没有绑定道具或取得途径,菜单仅显示禁用的“救助道具待补充”按钮。
以后接入地图道具时,应在同一次存档中完成道具扣除与状态解除;
不能把此接口作为免费救助按钮。救助后下一场按当前等级满血参战。
## 验证
`tools/test_bonds.mjs` 覆盖初始化、事件重复结识、单选、概率边界、
成长溢出/上限、濒死/救助、独立牌组、结算重复提交和多目标意图。
另覆盖默认牌库与意图兜底、独立循环、费用不足、自定义意图和击晕期间禁止执行。
这些检查不等同于游戏原生战斗运行验证;空缺战斗配置待补充后再实机验收。
+25
View File
@@ -0,0 +1,25 @@
# 梦三战斗规则(测试阶段)
只影响梦三模式,不修改 noname 的标准/军争原始牌包。`cards/mode-cards.js` 从标准、军争实体牌列表逐一复制模式专属定义,沿用原牌名以保留响应、装备、转化兼容性;图鉴说明在梦三模式覆盖。新征程的界刘备为 50/50 生命,默认每回合摸 2 张、手牌上限 3、回合费用恢复至 3。主动使用牌消耗费用,转化牌按转化后的牌名计费;响应【闪】、救援【桃】等不消耗费用。默认主动牌 1 费,`battle/combat-rules.js` 中列出例外。【杀】1 费、可多次使用,牌定义的基础伤害为 6;【桃】及【桃园结义】回复 8 点生命;【南蛮入侵】、【万箭齐发】、【决斗】、【火攻】基础伤害 4,【闪电】命中伤害 12。其余卡牌保留原有效果,并加上梦三费用与图鉴说明。
## 怪物
梦三模式护甲不设数值上限,玩家、敌人和友方单位均适用;
调用 `changeHujia` 时传入的上限参数不生效。
护甲增减、伤害抵扣与日志仍沿用引擎流程;脆弱仍使获得护甲
减少 25%(向下取整),劫掠者仍在自身回合开始前清空剩余护甲。
`monsters/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 中存在。
## 配置位置
- 玩家费用、手牌上限、卡牌费用与【杀】伤害:`battle/combat-rules.js`
- 梦三模式卡牌复制、内容与图鉴文案:`cards/mode-cards.js`
- 怪物阶级、默认牌堆、生命与继承示例:`monsters/monster.js`
- 刘备角色初始生命:`content/scenario-characters.js`
- 刘备专属初始牌组:`cards/starting-deck.js`
- 首关小兵覆盖数值:`content/acts/act1/stories/liubei-opening.js`
旧存档不作迁移。源码检查不等于游戏内、十周年 UI 或 AI 行为验收。
+28
View File
@@ -0,0 +1,28 @@
# 刘备首关配置
新征程仅显示梦三私有界刘备: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的卢一张,加入征程个人牌库;此关关闭随机三选一。奖励与节点完成一起事务提交,重试不会重复发牌。
- `rewardPackage` 额外固定获得400金币、手动强化1张牌、随机遗物三选一。遗物候选复用当前新遗物奖励池,排除已持有项,随机结果在打开界面前保存;不足三件时显示剩余候选。强化只允许选择尚未满级且没有永恒词缀的可强化牌;没有候选时明确提示并跳过该项。两项选择完成后,与金币、的卢及节点完成一起提交,重试或恢复存档不会重复领取。遗物池耗尽时同样提示并跳过遗物选择。
- 固定奖励与选择奖励共用过关奖励界面,以“固定战利品”/“可选战利品”等文字区分;固定奖励显示名称和说明,点击“领取固定奖励”后继续结算,不能放弃。若同时配置两类奖励,固定奖励全部获得,候选奖励仍择一领取或放弃。
- 初始士兵/支援手牌仍来自公共牌堆;弃掉的敌方的卢走原生弃牌流程,胜利所得的卢是持久牌库新卡,并非再从弃牌区拾取。
- 提前击败敌人允许正常获胜,此时未到第四次回合的支援不会触发。不强行等待、不添加免死。
## 新角色立绘
`assets/portraits/sushuang-zhangshiping.png` 为本次 image_gen 生成的原创双人立绘,不是官方素材。
## 验证边界
隔离脚本检查配置、回合计数、效果顺序及奖励幂等,不等于游戏/十周年UI验收。请开始新征程检查真实引擎的装备、座次、AI和剧情显示。
+98
View File
@@ -0,0 +1,98 @@
# 梦三手牌 UI 子扩展
## 模块边界
这是术樱包内部的必需子模块,由梦三战斗加载,不是另一个可安装扩展。
没有单独关闭开关。JS 或样式加载失败时进入既有战斗异常处理流程,
保留存档并提供返回模式选择入口;不会静默略过必需模块。
| 位置 | 职责 |
| --- | --- |
| `ui/hand/index.js` | 挂载、同步、局部输入适配、回退和幂等卸载 |
| `ui/hand/adapter.js` | 校验引擎容器并读取真实卡牌,识别第二容器离线结构 |
| `ui/hand/overlays.js` | 费用与强化覆盖层,仅维护当前手牌的显示节点 |
| `ui/hand/style.css` | 独立排列、尺寸、间距、选中标记及隔离样式 |
| `ui/hand/diagnostics.js` | 生命周期、回退和加载失败诊断日志 |
| `tools/test-hand-ui.cjs` | 浏览器隔离验证,非发布资源 |
| `verification/hand-ui/` | 本地验证报告、截图和清单,非发布资源 |
| `logs/log.txt` | 游戏内生成的最近 100 条诊断,非发布资源 |
`cards/personal-piles.js` 只负责个人牌堆业务,原先的手牌 DOM 显示逻辑
已移入本模块。旧的 `ui/hand-upgrade.js` 和通用样式文件中的对应规则
已移除,避免两套覆盖层并行维护。发布入口仍为 `register.js`。
## 交互方案与证据
本体 `noname/ui/click/index.js` 的 `card()` 使用真实 `this` 操作
`ui.selected.cards` 并调用 `game.check()`。本体 `card.updateTransform()`
检查卡牌祖先是否为 `ui.me`。十周年 `core/layout.js`、`cardDragSort.js`
也依赖真实节点的父容器和变换。因此首期选用真实节点原位复用,
不复制卡面,不移动手牌容器,不替换全局原型或布局函数。
排列使用独立命名空间的 flex 样式;默认卡牌 90×110、间距 8,
小屏卡牌 80×100,牌多时横向滚动。具体尺寸集中在本模块 CSS。
卡面继续采用当前引擎/主题生成的内容。选择、取消、出牌、响应和
选目标继续交给引擎,模块不另行计算卡牌可用性或提交出牌事件。
引擎仍维护节点上的 `selected`、`selectable` 等状态。本模块通过这些
原有类显示选择状态,保留 DOM 顺序作为引擎排序的依据。
检测到移动手势时临时退回既有排列,释放或取消后恢复独立排列;
触屏水平滚动保留独立排列。首期没有另做手动拖动排序系统。
费用调用既有 `cardCost`,强化读取既有 `cardUpgradeLevel`。
覆盖层只在自己的子节点 dataset 上保存显示值,使用伪元素显示文字,
避免十周年对出牌子节点 innerText 的扫描误识别费用数字。
覆盖层不接收指针事件;离开手牌区即回收,返回手牌区重新生成。
若业务直接修改卡牌 storage,修改者应调用返回控制器的 `refresh()`。
十周年 `uiCreateMe()` 的第二手牌容器默认离线。
适配器允许“主容器连接、第二容器离线且为空”的结构。
未知或被替换的容器退回原排列,保留覆盖层,并通过游戏日志和
诊断文件报告,不依赖 `decadeUI` 的内部坐标接口。
## 生命周期与日志
`mode.js` 在玩家初始化后、开局摸牌前等待 `mountHandUI()`。
模块资源登记到 battle-session,逆序释放早于个人牌堆和玩家资源。
正常战斗结束和流程异常另有显式卸载入口;DOM 根节点被移除时
观察器会触发卸载。加载中取消会结束等待并取消计时器。
卸载移除覆盖层、观察器、局部监听、样式链接、自己的类名,并恢复
自己改过的属性;多次调用安全。它不销毁真实卡牌或重置选择状态。
支持引擎文件接口时,日志写入安装目录下
`extension/术樱包/mengsan/logs/log.txt`,包含时间、战斗 session ID、
挂载、回退、卸载与加载错误。最近 100 条覆盖写入,串行提交。
没有文件接口时保留控制台日志;布局回退也写入游戏日志。
## 验证结果及边界
本次参考安装源码:本体 `game/update.js` 为 1.11.5.2;
十周年 `info.json` 为 1.3.1。
十周年 `STYLE_TO_SKIN` 将 off/on/othersOff 映射到
shousha/shizhounian/xinsha,配置菜单显示名与规划文档略有不同。
浏览器验证加载安装目录中的本体 CSS、十周年实际基础样式组合及
手杀/十周年/新杀的按钮和技能样式,以匹配 `uiCreateMe()` 的 DOM 结构
构造测试场景;点击测试使用安装源码中的真实 `ui.click.card` 函数体,
其 `game.check` 等引擎服务为测试替身,不代表完整游戏事件链验证。
| 场景 | 桌面 1280×720 | 小屏 540×360 | 完整游戏实测 |
| --- | --- | --- | --- |
| 原生 UI | 隔离验证通过 | 隔离验证通过 | 待验证 |
| 手杀 off/shousha | 隔离验证通过 | 隔离验证通过 | 待验证 |
| 十周年 on/shizhounian | 隔离验证通过 | 隔离验证通过 | 待验证 |
| 新杀 othersOff/xinsha | 隔离验证通过 | 隔离验证通过 | 待验证 |
已验证真实节点父级及点击身份、选择/取消、费用与强化移除、
手牌离开/返回、横向滚动处理、手势布局退让、会话结束、重复卸载、
根节点移除、加载中取消、CSS 加载失败、回退覆盖层及属性恢复。
已有梦三逻辑回归 44 项通过。发布清单包含本模块全部 JS/CSS,
不包含 tools、verification、.planning 和 log.txt。
尚需真实游戏验证出牌/响应、技能虚拟牌、选目标/取消、引擎自动排序、
十周年拖拽排序插件及真实手机触屏。本次没有改动已安装的游戏文件,
也没有把隔离场景结果标记为游戏内实测通过。
运行浏览器测试:`node mengsan/tools/test-hand-ui.cjs`。
可用 NONAME_APP 指向本体目录、CODEX_NODE_MODULES 指向提供 Playwright
的依赖目录;脚本使用本机 Chrome 的无界面独立实例。
+145
View File
@@ -0,0 +1,145 @@
# 梦三手牌 UI 子系统规划大纲
首期实现已归入 `ui/hand/`,是梦三必须依赖的包内子模块,
没有单独关闭开关。实现及验证状态见
[手牌 UI 子扩展说明](ui-hand-subextension.md)。
下文保留规划与完整游戏验收项;隔离测试不等同于游戏内实测。
## 1. 背景与目标
- 当前手牌 UI 与无名杀真实卡牌节点的关系
- 十周年 UI 不同风格可能造成的布局差异
- 建立梦三独立控制的手牌显示层
- 兼容原生 UI 与目标 UI 风格
## 2. 范围与非目标
### 2.1 本次范围
- 手牌布局、卡牌尺寸与间距
- 梦三费用、强化等卡牌覆盖层
- 卡牌显示与无名杀游戏状态同步
- 战斗期间挂载、退出时清理
### 2.2 非目标
- 不改写无名杀全局卡牌系统
- 不改变其他模式或扩展的全局样式
- 不承诺兼容所有未知 UI 扩展
- 不在验证交互方案前替换真实卡牌对象
## 3. 设计原则
- 无名杀真实卡牌对象是游戏状态的唯一来源
- 梦三 UI 只负责显示与输入转接
- 梦三样式使用独立根节点和命名空间
- 不依赖十周年 UI 未公开的内部结构作为硬性前提
- 适配失败时可回退到现有手牌显示
## 4. 分层设计
### 4.1 引擎卡牌与游戏状态层
- 真实卡牌对象的所有权
- 摸牌、出牌、响应、选目标、排序的现有流程
### 4.2 梦三手牌视图层
- 卡牌视觉表示
- 手牌排列与响应式布局
- 费用、强化等梦三标记
- 选中、可用、禁用等状态呈现
### 4.3 适配与生命周期层
- 真实卡牌到梦三视图的对象映射
- 卡牌进入、离开、更新时的同步
- 战斗开始时挂载、退出时完整清理
- 未知或不支持状态的回退行为
## 5. 关键技术决策
### 5.1 真实卡牌节点与自绘视图的关系
- 评估复用真实节点与代理视图两种方案
- 确认节点移动是否影响引擎及其他 UI 扩展
- 验证代理视图如何调用引擎既有选择和出牌流程
- 明确决策门槛与回退方案
### 5.2 输入与交互
- 点击与拖动
- 选中、取消选中
- 目标选择与确认
- 出牌与响应
- 自动排序与手动排序
- 卡牌移出手牌区后的视图回收
### 5.3 样式隔离
- 梦三根节点和 CSS 类命名规范
- 避免全局选择器、全局原型覆盖和全局事件劫持
- 层级、缩放、窗口尺寸和移动端布局策略
## 6. 分阶段实施
### 阶段一:现有交互链路调查与可行性验证
- 记录引擎对真实卡牌节点的依赖
- 验证核心操作和 UI 扩展的节点行为
- 产出方案比较与 go/no-go 决策
### 阶段二:状态映射与适配接口
- 定义卡牌身份映射
- 定义视图更新与交互回调
- 明确缺少能力时的降级路径
### 阶段三:梦三手牌视图原型
- 创建隔离的 UI 根节点
- 实现布局、卡牌外观和梦三覆盖层
- 验证视图随真实卡牌状态更新
### 阶段四:完整交互接入
- 接入选择、出牌、响应、取消与排序
- 保证卡牌身份和事件目标始终对应
### 阶段五:生命周期、回退与兼容验证
- 验证进入战斗、正常结束、异常退出时的清理
- 验证原生 UI 与十周年主要风格
- 记录未支持的扩展和限制
## 7. 兼容性验证矩阵
| 环境 | 布局显示 | 点击/拖动 | 出牌/响应 | 选目标/取消 | 排序 | 退出清理 |
|---|---|---|---|---|---|---|
| 无名杀原生 UI | 待验证 | 待验证 | 待验证 | 待验证 | 待验证 | 待验证 |
| 十周年 UI:手杀风格 | 待验证 | 待验证 | 待验证 | 待验证 | 待验证 | 待验证 |
| 十周年 UI:十周年风格 | 待验证 | 待验证 | 待验证 | 待验证 | 待验证 | 待验证 |
| 十周年 UI:新杀风格 | 待验证 | 待验证 | 待验证 | 待验证 | 待验证 | 待验证 |
## 8. 风险与应对
- 真实卡牌节点移动导致引擎或其他扩展状态不同步
- 代理卡牌视图未继承引擎交互语义
- 不同 UI 修改同一容器或卡牌样式
- 监听器、观察器或 DOM 节点在退出后残留
- 引擎升级改变内部行为
## 9. 验收标准
- 不修改梦三以外的全局 UI 样式或原型
- 核心卡牌交互在目标环境中完整可用
- 梦三覆盖层与布局不依赖特定主题的坐标假设
- 战斗结束或异常退出后无梦三 UI 残留
- 不支持的环境能安全回退并有明确诊断信息
## 10. 待确认事项
- 首期支持哪些无名杀版本与 UI 风格
- 是否要求手动拖动排序
- 小屏幕和触屏设备的交互要求
- 不支持某种 UI 时是否提示玩家切回原生手牌显示
+940 -305
View File
File diff suppressed because it is too large Load Diff
+82
View File
@@ -0,0 +1,82 @@
import { createIntentExecutor } from "../battle/intent-effects.js";
export { getIntentHostiles } from "../battle/intent-effects.js";
import { isStunned } from "../battle/stun-intent.js";
import { nextRandom } from "../progression/state.js";
import { selectFlyconidMove, recordFlyconidAction } from "./flyconid-intent.js";
import {
isRaiderCharacter,
selectRaiderMove,
recordRaiderAction,
} from "./raider-intent.js";
export const isFlyconid = player =>
player?.name === "mengsan_flyconid_shuying" &&
player.storage?.mengsanCamp_shuying === "enemy";
export const isRaider = player =>
player?.storage?.mengsanCamp_shuying === "enemy" &&
isRaiderCharacter(player.name);
export function createMonsterIntentActions(game, getActiveBattle) {
const executeEffects = createIntentExecutor(game, getActiveBattle);
const planEnemyIntent = (player, run) => {
if (!player?.isAlive() || isStunned(player)) return;
if (isFlyconid(player)) {
if (player.storage.mengsanFlyconidIntent_shuying) return;
const move = selectFlyconidMove(
player.storage.mengsanFlyconidState_shuying || {},
() => nextRandom(run));
player.storage.mengsanFlyconidIntent_shuying = move;
game.mengsanSetEnemyIntent_shuying(player, move);
} else if (isRaider(player)) {
if (player.storage.mengsanRaiderIntent_shuying) return;
const move = selectRaiderMove(player.name,
player.storage.mengsanRaiderState_shuying || {});
player.storage.mengsanRaiderIntent_shuying = move;
game.mengsanSetEnemyIntent_shuying(player, move);
}
};
const executeFlyconidIntent = async player => {
const move = player.storage.mengsanFlyconidIntent_shuying;
if (!move) return;
player.storage.mengsanFlyconidIntent_shuying = null;
game.mengsanSetEnemyIntent_shuying(player, null);
const paid = player.storage.mengsanEnergy_shuying >= 1;
player.storage.mengsanFlyconidState_shuying =
recordFlyconidAction(
player.storage.mengsanFlyconidState_shuying || {},
move, paid);
if (!paid) {
game.log(player, "费用不足,未发动", move.name);
return;
}
player.storage.mengsanEnergy_shuying--;
getActiveBattle()?.energyUI.get(player)?.();
game.log(player, "消耗1费用发动", move.name);
await executeEffects(player, move);
};
const executeRaiderIntent = async player => {
const move = player.storage.mengsanRaiderIntent_shuying;
if (!move) return;
player.storage.mengsanRaiderIntent_shuying = null;
game.mengsanSetEnemyIntent_shuying(player, null);
player.storage.mengsanRaiderState_shuying = recordRaiderAction(
player.storage.mengsanRaiderState_shuying || {});
if (player.storage.mengsanEnergy_shuying < 1) {
game.log(player, "费用不足,未发动", move.name);
return;
}
player.storage.mengsanEnergy_shuying--;
getActiveBattle()?.energyUI.get(player)?.();
game.log(player, "消耗1费用发动", move.name);
await executeEffects(player, move);
};
return {
planEnemyIntent,
executeFlyconidIntent,
executeRaiderIntent,
};
}
+37
View File
@@ -0,0 +1,37 @@
// 冷却按飞蝇菌子自己的行动次数计算,不受玩家回合或增援数量影响。
export const FLYCONID_MOVES = Object.freeze({
smash: Object.freeze({ id: "smash", name: "猛砸", damage: 11 }),
frail: Object.freeze({ id: "frail", name: "脆弱孢子", damage: 8, debuff: "脆弱", stacks: 2 }),
vulnerable: Object.freeze({ id: "vulnerable", name: "易伤孢子", debuff: "易伤", stacks: 2 }),
});
export function eligibleFlyconidMoves(state = {}) {
const upcoming = (state.turnsTaken || 0) + 1;
const lastUsed = state.lastUsed || {};
const moves = [];
if (state.lastMove !== "smash") moves.push(FLYCONID_MOVES.smash);
if (lastUsed.frail == null || upcoming - lastUsed.frail > 2) moves.push(FLYCONID_MOVES.frail);
if (upcoming >= 2 && (lastUsed.vulnerable == null || upcoming - lastUsed.vulnerable > 3)) moves.push(FLYCONID_MOVES.vulnerable);
return moves;
}
export function selectFlyconidMove(state, random) {
const moves = eligibleFlyconidMoves(state);
if (!moves.length) return null;
const roll = random();
if (!Number.isFinite(roll) || roll < 0 || roll >= 1) throw new RangeError("怪物随机数必须在 [0,1) 内");
return moves[Math.floor(roll * moves.length)];
}
export function recordFlyconidAction(state, move, paid) {
const next = {
turnsTaken: (state.turnsTaken || 0) + 1,
lastMove: state.lastMove || null,
lastUsed: { ...(state.lastUsed || {}) },
};
if (paid && move) {
next.lastMove = move.id;
next.lastUsed[move.id] = next.turnsTaken;
}
return next;
}
+96
View File
@@ -0,0 +1,96 @@
import { createCardData } from "../cards/card-data.js";
// 梦三怪物规则只作用于敌方单位;子类可覆盖构造参数或 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", "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", "jiu", "juedou", "wuzhong", "guohe"]),
});
const HEALTH = Object.freeze({
mengsan_soldier_shuying: 24,
mengsan_flyconid_shuying: 48,
mengsan_raider_brute_shuying: 32,
mengsan_raider_assassin_shuying: 21,
mengsan_raider_axe_shuying: 21,
mengsan_raider_crossbow_shuying: 20,
mengsan_raider_tracker_shuying: 23,
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 createCardData({
id: `${prefix}_${index}`, name: card.name,
suit: card.suit || ["spade", "heart", "club", "diamond"][index % 4],
number: card.number || index % 13 + 1,
nature: card.nature || null, affixes: Array.isArray(card.affixes) ? card.affixes.slice() : [], upgrade: card.upgrade || 0,
}, { enemy: true });
});
}
}
// 专属怪物示例:以后可在子类中覆写任意默认行为。
export class OpeningSoldier extends Monster {
constructor(options = {}) { super({ character: "mengsan_soldier_shuying", tier: "normal", hp: 24, ...options }); }
}
export class Flyconid extends Monster {
constructor(options = {}) { super({ ...options, character: "mengsan_flyconid_shuying", tier: "normal", hp: 48 }); }
}
const raiderClass = (character, hp) => class extends Monster {
constructor(options = {}) { super({ ...options, character, tier: "normal", hp }); }
};
export const RaiderBrute = raiderClass("mengsan_raider_brute_shuying", 32);
export const RaiderAssassin = raiderClass("mengsan_raider_assassin_shuying", 21);
export const RaiderAxe = raiderClass("mengsan_raider_axe_shuying", 21);
export const RaiderCrossbow = raiderClass("mengsan_raider_crossbow_shuying", 20);
export const RaiderTracker = raiderClass("mengsan_raider_tracker_shuying", 23);
// 新怪物子类在这里注册,未注册的角色使用 Monster 的阶级默认值。
export const MONSTER_TYPES = Object.freeze({
mengsan_soldier_shuying: OpeningSoldier,
mengsan_flyconid_shuying: Flyconid,
mengsan_raider_brute_shuying: RaiderBrute,
mengsan_raider_assassin_shuying: RaiderAssassin,
mengsan_raider_axe_shuying: RaiderAxe,
mengsan_raider_crossbow_shuying: RaiderCrossbow,
mengsan_raider_tracker_shuying: RaiderTracker,
});
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 };
+39
View File
@@ -0,0 +1,39 @@
// 普通难度数值;团伙每名成员仅预测并执行自己的一项技能,随后照常出牌。
export const RAIDER_MOVES = Object.freeze({
beat: Object.freeze({ id: "beat", name: "殴打", damage: 7 }),
roar: Object.freeze({ id: "roar", name: "怒吼", strength: 3 }),
killshot: Object.freeze({ id: "killshot", name: "致命射击", damage: 10 }),
swing: Object.freeze({ id: "swing", name: "挥击", damage: 5, block: 5 }),
bigSwing: Object.freeze({ id: "bigSwing", name: "大力挥舞", damage: 12 }),
reload: Object.freeze({ id: "reload", name: "装填", block: 3 }),
fire: Object.freeze({ id: "fire", name: "射击!", damage: 14 }),
track: Object.freeze({ id: "track", name: "追踪", debuff: "脆弱", stacks: 2 }),
hounds: Object.freeze({ id: "hounds", name: "放狗", damage: 1, hits: 8 }),
});
export const RAIDER_CYCLES = Object.freeze({
mengsan_raider_brute_shuying: Object.freeze([RAIDER_MOVES.beat, RAIDER_MOVES.roar]),
mengsan_raider_assassin_shuying: Object.freeze([RAIDER_MOVES.killshot]),
mengsan_raider_axe_shuying: Object.freeze([RAIDER_MOVES.swing, RAIDER_MOVES.swing, RAIDER_MOVES.bigSwing]),
mengsan_raider_crossbow_shuying: Object.freeze([RAIDER_MOVES.reload, RAIDER_MOVES.fire]),
mengsan_raider_tracker_shuying: Object.freeze([RAIDER_MOVES.track, RAIDER_MOVES.hounds]),
});
export function isRaiderCharacter(character) {
return Object.hasOwn(RAIDER_CYCLES, character);
}
export function selectRaiderMove(character, state = {}) {
const cycle = RAIDER_CYCLES[character];
if (!cycle) return null;
const turn = state.turnsTaken || 0;
if (!Number.isInteger(turn) || turn < 0) throw new RangeError("劫掠者行动次数无效");
if (character === "mengsan_raider_tracker_shuying") {
return turn === 0 ? RAIDER_MOVES.track : RAIDER_MOVES.hounds;
}
return cycle[turn % cycle.length];
}
export function recordRaiderAction(state = {}) {
return { turnsTaken: (state.turnsTaken || 0) + 1 };
}
+17
View File
@@ -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);
}
};
+91
View File
@@ -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);
},
});
}
+39
View File
@@ -0,0 +1,39 @@
import { canUpgradeCard } from "../cards/upgrades.js";
import { canAcquireRelic } from "../relics/definitions.js";
export function prepareRewardPackage(run, spec, randomChoices) {
if (!Number.isSafeInteger(spec.gold) || spec.gold < 0) {
throw new Error("奖励包金币无效");
}
const relicChoices = randomChoices(run, spec.relicPool, 3);
if (relicChoices.some(reward => !reward.relic)) {
throw new Error("奖励包候选必须是遗物");
}
return {
gold: spec.gold,
upgradeChoices: run.player.deck.filter(canUpgradeCard),
relicChoices,
};
}
export function applyRewardPackage(run, pack, selection, applyReward) {
if (!selection || typeof selection !== "object") {
throw new Error("奖励包选择无效");
}
const card = run.player.deck.find(card => card.id === selection.cardId);
const relic = pack.relicChoices.find(choice =>
choice.id === selection.relicId);
if (pack.upgradeChoices.length) {
if (!pack.upgradeChoices.some(card => card.id === selection.cardId) ||
!canUpgradeCard(card)) throw new Error("强化目标无效");
} else if (selection.cardId !== null) throw new Error("无可强化卡牌");
if (pack.relicChoices.length) {
if (!relic || !canAcquireRelic(run, relic.relic)) {
throw new Error("遗物选择无效");
}
} else if (selection.relicId !== null) throw new Error("无可选遗物");
run.player.gold += pack.gold;
run.statistics.goldEarned += pack.gold;
if (pack.upgradeChoices.length) card.upgrade = (card.upgrade || 0) + 1;
if (relic) applyReward(run, relic.id);
}
+29
View File
@@ -0,0 +1,29 @@
import config from "../config.js";
import { nextRandom } from "./state.js";
import { canAcquireCard } from "../cards/card-definitions.js";
import { hasUpgradeableCard } from "../cards/upgrades.js";
import { canAcquireRelic } from "../relics/definitions.js";
export const rewardCardName = reward => reward?.card?.name || ({ card_sha: "sha", card_tao: "tao" })[reward?.effectId] || null;
export const canAcquireReward = (run, reward) => {
if (reward?.relic) return canAcquireRelic(run, reward.relic);
if (reward?.effectId === "item_hand") {
return canAcquireRelic(run, "mengsan_hand_charm_shuying");
}
if (reward?.effectId === "upgrade") {
return hasUpgradeableCard(run?.player?.deck);
}
const name = rewardCardName(reward);
return !name || canAcquireCard(run?.player?.character, name);
};
export const getRandomRewardChoices = (run, poolId, count = 3) => {
const fallbackId = "shared.pool.battle.normal";
const pool = (config.rewardPools[poolId] || config.rewardPools[fallbackId])
.filter(id => canAcquireReward(run, config.rewards[id])).slice();
for (let index = pool.length - 1; index > 0; index--) {
const target = Math.floor(nextRandom(run) * (index + 1));
[pool[index], pool[target]] = [pool[target], pool[index]];
}
return pool.slice(0, count).map(id => ({ id, ...config.rewards[id] }));
};
@@ -1,4 +1,7 @@
import config from "./config.js";
import { createStartingDeck } from "../cards/starting-deck.js";
import config from "../config.js";
import { initializeInnateBonds } from "../bonds/state.js";
import { RAIDER_TRIO_ENCOUNTER, createRaiderTrioBattlePlan } from "../content/acts/act1/raider-trio.js";
const hashText = text => {
let value = 2166136261;
@@ -71,7 +74,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 +104,17 @@ 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 (act.requiredFloors?.[floor + 1]) {
type = act.requiredFloors[floor + 1];
}
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 +122,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 +148,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 +165,7 @@ export const createRun = character => {
hp: null,
maxHp: null,
gold: 0,
deck: createDeck(),
deck: createStartingDeck(character),
permanentSkills: [],
temporarySkills: [],
items: [],
@@ -156,7 +176,9 @@ export const createRun = character => {
defeatedEnemies: 0,
goldEarned: 0,
},
storyFlags: {},
};
initializeInnateBonds(run);
run.map = generateActMap(run, 0);
return run;
};
@@ -184,21 +206,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 +238,34 @@ 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" };
}
const enemy = randomGet(run, pool.filter(Boolean));
return {
enemy: randomGet(run, pool.filter(Boolean)),
gold: node.type == "elite" || node.type == "story" ? act.baseGold * 2 : act.baseGold,
name: encounter?.name || (enemy === RAIDER_TRIO_ENCOUNTER ? "劫掠者团伙" : ""),
requiredCharacter: encounter?.requiredCharacter || null,
openingDialogue: encounter?.openingDialogue || [],
fixedRewards: encounter?.fixedRewards || [],
rewardPackage: encounter?.rewardPackage || null,
skipRandomReward: encounter?.skipRandomReward === true,
victoryDialogue: encounter?.victoryDialogue || [],
battlePlan: encounter?.battlePlan || (enemy === RAIDER_TRIO_ENCOUNTER ?
createRaiderTrioBattlePlan(() => nextRandom(run)) : null),
tier: encounter?.tier || (node.type === "elite" ? "elite" : "normal"),
enemy,
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 || "战斗奖励(三选一)",
};
};
+19
View File
@@ -0,0 +1,19 @@
import { meetBond } from "../bonds/state.js";
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 || {});
for (const bond of outcome.bonds || []) {
meetBond(run, typeof bond === "string" ? bond : bond.id,
typeof bond === "string" ? 1 : bond.level ?? 1);
}
return outcome;
};
+23
View File
@@ -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);
};
}
+25
View File
@@ -0,0 +1,25 @@
# 遗物资源待补清单
当前所有遗物的 `image` 均为 `null`,使用文字名称和效果说明,
不会加载不存在的图片。后续图片统一放入 `mengsan/assets/relics/`,
补齐时更新 `definitions.js` 并接入展示;本次不生成占位图片。
| 遗物 | 稳定 ID | 建议图片文件名 | 状态 |
| --- | --- | --- | --- |
| 燃烧之血 | `mengsan_burning_blood_shuying` | `burning_blood.png` | 待提供 |
| 蛇之戒指 | `mengsan_snake_ring_shuying` | `snake_ring.png` | 待提供 |
| 长蛇戒指 | `mengsan_long_snake_ring_shuying` | `long_snake_ring.png` | 待提供 |
| 千年积木 | `mengsan_millennium_puzzle_shuying` | `millennium_puzzle.png` | 待提供 |
| 摆动球 | `mengsan_pendulum_shuying` | `pendulum.png` | 待提供 |
| 束带(既有道具) | `mengsan_hand_charm_shuying` | `hand_charm.png` | 待提供 |
| 餐券 | `mengsan_meal_ticket_shuying` | `meal_ticket.png` | 待提供 |
| 草莓 | `mengsan_strawberry_shuying` | `strawberry.png` | 待提供 |
| 弹珠袋 | `mengsan_bag_of_marbles_shuying` | `bag_of_marbles.png` | 待提供 |
| 灯笼 | `mengsan_lantern_shuying` | `lantern.png` | 待提供 |
| 红面具 | `mengsan_red_mask_shuying` | `red_mask.png` | 待提供 |
| 皇家枕头 | `mengsan_regal_pillow_shuying` | `regal_pillow.png` | 待提供 |
| 节日拉炮 | `mengsan_festive_poppers_shuying` | `festive_poppers.png` | 待提供 |
| 金刚杵 | `mengsan_vajra_shuying` | `vajra.png` | 待提供 |
建议使用统一尺寸的透明背景 PNG,实际尺寸和视觉风格在资源制作时确定。
图片仅负责外观,不承载名称、效果或触发计数。
+105
View File
@@ -0,0 +1,105 @@
# 梦三遗物(道具)
本目录负责遗物定义、持有规则与战斗效果管理。
已实现十三件新遗物,并接入既有“束带”。图片暂缺,清单见 [ASSETS.md](ASSETS.md)。
## 已实现的遗物
| 遗物 | 效果 |
| --- | --- |
| 燃烧之血 | 胜利结算后回复 6 点生命,不超过上限;失败不回血 |
| 蛇之戒指 | 每场战斗开始额外摸 2 张,开场手牌整理不会移除这两张 |
| 长蛇戒指 | 每场战斗第 1~3 个自身回合开始,各额外摸 2 张 |
| 千年积木 | 每场战斗首次实际生命减少且仍存活时摸 3 张;纯护甲损失不触发 |
| 摆动球 | 每场战斗第 3、6、9……个自身回合开始额外摸 1 张 |
| 束带 | 原有手牌上限 +1,重复获得不叠加 |
| 餐券 | 进入商店时回复 15 点生命,每个商店节点一次 |
| 草莓 | 获得时生命上限 +7,当前生命不变,重复获得不叠加 |
| 弹珠袋 | 开战时给予当时所有存活敌人 1 层易伤 |
| 灯笼 | 首个自身回合可用费用 +1,费用上限不变,下回合恢复正常 |
| 红面具 | 开战时给予当时所有存活敌人 1 层虚弱 |
| 皇家枕头 | 选择休息回血时额外恢复 15;现有基础 8,总计 23 |
| 节日拉炮 | 开战时对当时所有存活敌人造成 9 点伤害 |
| 金刚杵 | 开战时获得 1 点力量,只在当前战斗有效 |
虚弱使攻击伤害减少 25%(向下取整),自身回合结束减少 1 层;
力量使玩家攻击牌伤害增加对应点数,意图攻击沿用原有力量/虚弱计算。
节日拉炮是非卡牌伤害,正常消耗目标护甲,不享受力量、易伤、虚弱的攻击修正。
开场效果先摸牌和施加状态,再结算节日拉炮;仅影响开战时的敌人,不影响后续援军。
餐券在打开商店前保存回血和节点记录,读档不会重复触发;
皇家枕头不影响磨砺、商店治疗或其他回复效果。所有回复均不超过生命上限。
草莓若在角色生命尚未初始化时获得,会保存待应用加成,初始化后只应用一次。
遗物互相独立:同时持有两种戒指时,开场和前三回合效果分别触发。
所有回合计数按玩家自身实际开始的回合计算,不计敌方和支援回合,
不使用界面显示的全局轮数。每场战斗重置计数及“首次损失生命”标记。
额外摸牌沿用个人牌堆规则;牌堆与弃牌堆不足时仍按原规则挑战失败。
### 获得、保存和查看
- 新遗物通过自动生成的遗物奖励列表加入共享 Boss 和第一章宝箱奖励池;
后续章节使用共享 Boss 奖励池的宝箱也可出现它们。
- 角色默认不携带新遗物;未新增商店价格或精英掉落规则。
- 持有记录沿用 `run.player.items`,已持有遗物会从随机奖励中排除。
- `definitions.js` 提供注册、资格检查和去重获得;旧“束带”ID 与效果保留。
- `battle.js` 管理战斗计数、摸牌效果及战后回血;战斗运行状态不写入征程。
- 战后回血写入结算候选快照,之后才领取奖励,重试、重复提交和读档
复用快照,不再次回血;刚从本场奖励获得的燃烧之血从下一场生效。
- 战斗中的“遗物”按钮显示名称、效果与触发状态;地图行囊显示持有详情。
- 获得和效果触发使用游戏日志反馈;没有图片时只显示文字。
## 参考来源
- [杀戮尖塔 2 中文百科:遗物](https://sts2.huijiwiki.com/wiki/%E9%81%97%E7%89%A9)
- 查阅日期:2026-09-30。
- 页面直连返回 403;以下概述依据搜索索引返回的该页正文整理。
本文概述机制,不复制完整遗物列表、具体效果或图片。
## 网页中的遗物概念
遗物是单局游戏中获得并持续持有的物品,会影响牌组构筑、
手牌管理和其他玩法。这里的“永久”指当前这一局,
不代表跨新征程永久保留。
- 每个角色拥有自己的初始遗物,此外也有通用和角色专属遗物。
- 获得途径包括击败精英或 Boss、宝箱、商店、部分事件和先古之民。
- 已经持有的遗物不会再次出现。
- 通常不能主动移除;部分事件可以交换或移除遗物。
- 部分特殊遗物可以失效,但失效后仍保留在持有列表中。
- 页面按稀有度或获取类别区分初始、普通、罕见、稀有、
商店、先古之民、事件及其他遗物。
## 接入前的项目背景
接入前,项目已有以下零散道具逻辑:
| 位置 | 现有职责 |
| --- | --- |
| `../progression/state.js` | 新征程创建 `player.items` 和手牌上限加成数据 |
| `../mode.js` | 获得“束带”时记录道具 ID,手牌上限加成增加 1,重复获得不叠加 |
| `../content/shared/rewards.js` | 配置“束带”和“斥候援令”等奖励 |
| `../battle/battle-director.js` | 管理援令带来的支援单位与重复来源处理 |
| `../ui/map/renderer.js` | 行囊展示永久道具,数量读取 `player.items` |
“斥候援令”走支援管理流程,不能仅凭奖励名中的“道具”
认定它已经进入 `player.items` 或统一遗物系统。
## 设计约定与后续扩展
1. 将“遗物”作为本征程持续持有道具的统一概念,
与手牌、装备牌、消耗品及临时战斗状态分别管理。
2. 每个遗物定义稳定 ID、名称、说明、图标、角色限制、
获取类别及效果规则;具体三国题材名称与数值另行配置。
3. 根据效果需要提供获得时、战斗开始、回合开始、出牌后、
战斗结束等触发时机;不能假定所有遗物只提供固定属性加成。
4. 获得前统一检查角色资格和是否已持有,
默认不重复获取;失效、交换和移除规则由具体遗物明确声明。
5. 将持有记录和需要跨战斗保存的计数写入征程存档,
战斗内计数由战斗会话管理,读档不重复执行“获得时”效果。
6. 先以“束带”作为接入现有机制的候选,保留它当前的效果与去重行为;
“斥候援令”需要明确与支援系统的关系,再决定如何接入。
7. 获得提示、持有列表、说明与触发反馈放在 `../ui/`,
图片建议放在 `../assets/relics/`;本目录负责遗物自身规则。
资源、稀有度、精英掉落、商店价格、角色初始遗物以及特殊交换或移除
规则仍待后续配置;本次保留原道具与存档字段,不引入独立遗物存档副本。
+122
View File
@@ -0,0 +1,122 @@
import { heldRelics } from "./definitions.js";
export function createRelicBattle(run, { active, draw, log, effect }) {
const relics = heldRelics(run);
let started = false, turns = 0, firstLoss = false;
let openingHandBonus = 0, pendingHpDraw = null;
const seenTurns = new WeakSet();
const usable = () => started && active();
const drawEffect = async relic => {
if (!usable()) return;
if (turns === 0) openingHandBonus += relic.amount;
log(relic, `额外摸${relic.amount}张牌`);
await draw(relic.amount);
};
return {
get openingHandBonus() { return openingHandBonus; },
get openingDraw() {
return relics.filter(relic => relic.effect === "openingDraw")
.reduce((sum, relic) => sum + relic.amount, 0);
},
async start() {
if (started || !active()) return;
started = true;
for (const relic of relics) {
if (relic.effect === "openingDraw") await drawEffect(relic);
}
for (const relic of relics.filter(item =>
["openingVulnerable", "openingWeak", "openingStrength",
"openingDamage"].includes(item.effect))
.sort((a, b) => Number(a.effect === "openingDamage") -
Number(b.effect === "openingDamage"))) {
if (!usable()) break;
log(relic, relic.description);
await effect(relic);
}
if (pendingHpDraw) {
const relic = pendingHpDraw;
pendingHpDraw = null;
await drawEffect(relic);
}
},
async turnStart(event) {
if (!usable() || seenTurns.has(event)) return;
seenTurns.add(event);
turns++;
for (const relic of relics) {
if (relic.effect === "firstTurnEnergy" && turns === 1 &&
usable()) {
log(relic, `获得${relic.amount}点临时费用`);
await effect(relic);
}
if ((relic.effect === "earlyTurnDraw" &&
turns <= relic.turns) ||
(relic.effect === "periodicTurnDraw" &&
turns % relic.turns === 0)) await drawEffect(relic);
}
},
async loseHp(event) {
if (!active() || firstLoss || !(event.num < 0)) return;
const relic = relics.find(item =>
item.effect === "firstHpLossDraw");
if (!relic) return;
firstLoss = true;
if (!started) {
pendingHpDraw = relic;
return;
}
await drawEffect(relic);
},
status(relic) {
if (relic.effect === "firstHpLossDraw") {
return firstLoss ? "本场已触发" : "本场尚未触发";
}
if (relic.effect === "earlyTurnDraw") {
return `本场已开始${turns}个自身回合`;
}
if (relic.effect === "periodicTurnDraw") {
return `回合计数:${turns % relic.turns}/${relic.turns}`;
}
if (relic.effect.startsWith("opening")) {
return started ? "本场已触发" : "等待战斗开始";
}
if (relic.effect === "firstTurnEnergy") {
return turns ? "本场已触发" : "等待首个自身回合";
}
return "持续持有";
},
};
}
export function applyBattleEndRelics(run, outcome) {
if (outcome !== "victory" || !(run.player.hp > 0)) return 0;
const amount = heldRelics(run).filter(relic =>
relic.effect === "battleHeal").reduce((sum, relic) =>
sum + relic.amount, 0);
const before = run.player.hp;
run.player.hp = Math.min(run.player.maxHp ?? before,
before + amount);
return run.player.hp - before;
}
export function createRelicSkills(getBattle, getOwner) {
return {
mengsan_relics_shuying: {
trigger: { global: ["phaseBegin", "changeHp"] },
forced: true, silent: true, popup: false, priority: 100,
filter(event) {
const battle = getBattle(), owner = getOwner();
return Boolean(battle?.session.active && battle.relics &&
owner?.hp > 0 && event.player === owner &&
(event.name === "phase" || event.num < 0));
},
async content(event, trigger) {
const battle = getBattle();
if (!battle?.session.active) return;
if (event.triggername === "phaseBegin") {
await battle.relics.turnStart(trigger);
} else await battle.relics.loseHp(trigger);
},
},
};
}
+78
View File
@@ -0,0 +1,78 @@
export function createOpeningEffects(game, owner, refreshEnergy) {
const enemies = () => game.players.filter(player =>
player !== owner && player.isAlive() &&
player.storage?.mengsanCamp_shuying === "enemy");
const addStatus = (player, key, skill, amount) => {
player.storage[key] = (player.storage[key] || 0) + amount;
player.addSkill(skill);
player.markSkill(skill);
};
return async relic => {
if (relic.effect === "firstTurnEnergy") {
owner.storage.mengsanEnergy_shuying += relic.amount;
refreshEnergy();
} else if (relic.effect === "openingStrength") {
addStatus(owner, "mengsanStrength_shuying",
"mengsan_raider_strength_shuying", relic.amount);
} else {
for (const target of enemies()) {
if (!owner.isAlive()) break;
if (!target.isAlive()) continue;
if (relic.effect === "openingVulnerable") {
addStatus(target, "mengsanVulnerable_shuying",
"mengsan_vulnerable_shuying", relic.amount);
} else if (relic.effect === "openingWeak") {
addStatus(target, "mengsanWeak_shuying",
"mengsan_weak_shuying", relic.amount);
} else if (relic.effect === "openingDamage") {
const damage = target.damage(relic.amount, owner, "nocard");
damage.mengsanScriptedSkill_shuying = true;
await damage;
}
}
}
};
}
export function createRelicCombatSkills(getBattle, getOwner) {
return {
mengsan_relic_attack_shuying: {
trigger: { global: "damageBegin1" },
forced: true, silent: true, popup: false, priority: 80,
filter(event) {
const source = event.source;
return Boolean(getBattle()?.session.active && event.card &&
!event.mengsanScriptedSkill_shuying &&
((source === getOwner() &&
source?.storage?.mengsanStrength_shuying > 0) ||
source?.storage?.mengsanWeak_shuying > 0));
},
async content(event, trigger) {
const source = trigger.source;
if (source === getOwner()) {
trigger.num += source.storage.mengsanStrength_shuying || 0;
}
if (source.storage.mengsanWeak_shuying > 0) {
trigger.num = Math.floor(trigger.num * 0.75);
}
},
},
mengsan_weak_shuying: {
mark: true, marktext: "弱",
onremove(player) { delete player.storage.mengsanWeak_shuying; },
intro: { content(storage, player) {
return `剩余${player.storage.mengsanWeak_shuying || 0}回合:` +
"攻击伤害减少25%(向下取整)";
} },
trigger: { player: "phaseAfter" },
forced: true, silent: true, popup: false,
async content(event, trigger, player) {
player.storage.mengsanWeak_shuying = Math.max(0,
(player.storage.mengsanWeak_shuying || 0) - 1);
if (player.storage.mengsanWeak_shuying) {
player.markSkill("mengsan_weak_shuying");
} else player.removeSkill("mengsan_weak_shuying");
},
},
};
}
+99
View File
@@ -0,0 +1,99 @@
const entries = [
{ id: "mengsan_burning_blood_shuying", name: "燃烧之血",
description: "战斗结束后回复6点生命,不能超过生命上限。",
effect: "battleHeal", amount: 6 },
{ id: "mengsan_snake_ring_shuying", name: "蛇之戒指",
description: "每场战斗开始时,额外摸2张牌。",
effect: "openingDraw", amount: 2 },
{ id: "mengsan_long_snake_ring_shuying", name: "长蛇戒指",
description: "每场战斗的前3个自身回合开始时,额外摸2张牌。",
effect: "earlyTurnDraw", amount: 2, turns: 3 },
{ id: "mengsan_millennium_puzzle_shuying", name: "千年积木",
description: "每场战斗首次实际损失生命且仍存活时,摸3张牌。",
effect: "firstHpLossDraw", amount: 3 },
{ id: "mengsan_pendulum_shuying", name: "摆动球",
description: "每场战斗每到第3个自身回合开始时,额外摸1张牌。",
effect: "periodicTurnDraw", amount: 1, turns: 3 },
{ id: "mengsan_hand_charm_shuying", name: "束带",
description: "基础手牌上限额外+1;重复获得不叠加。",
effect: "handLimit", amount: 1 },
{ id: "mengsan_meal_ticket_shuying", name: "餐券",
description: "进入商店房间时回复15点生命。",
effect: "shopHeal", amount: 15 },
{ id: "mengsan_strawberry_shuying", name: "草莓",
description: "获得时最大生命值提升7;不额外回复生命。",
effect: "gainMaxHp", amount: 7 },
{ id: "mengsan_bag_of_marbles_shuying", name: "弹珠袋",
description: "每场战斗开始时给予所有敌人1层易伤。",
effect: "openingVulnerable", amount: 1 },
{ id: "mengsan_lantern_shuying", name: "灯笼",
description: "每场战斗首个自身回合额外获得1点临时费用。",
effect: "firstTurnEnergy", amount: 1 },
{ id: "mengsan_red_mask_shuying", name: "红面具",
description: "每场战斗开始时给予所有敌人1层虚弱。",
effect: "openingWeak", amount: 1 },
{ id: "mengsan_regal_pillow_shuying", name: "皇家枕头",
description: "选择休息回血时额外回复15点生命。",
effect: "restHeal", amount: 15 },
{ id: "mengsan_festive_poppers_shuying", name: "节日拉炮",
description: "每场战斗开始时对所有敌人造成9点伤害。",
effect: "openingDamage", amount: 9 },
{ id: "mengsan_vajra_shuying", name: "金刚杵",
description: "每场战斗开始时获得1点力量。",
effect: "openingStrength", amount: 1 },
];
export const relicDefinitions = Object.freeze(Object.fromEntries(
entries.map(entry => [entry.id, Object.freeze({ ...entry,
owner: null, image: null })])));
export const getRelic = id => Object.hasOwn(relicDefinitions, id)
? relicDefinitions[id] : null;
export const heldRelics = run => [...new Set(run?.player?.items || [])]
.map(getRelic).filter(Boolean);
export function canAcquireRelic(run, id) {
const relic = getRelic(id);
return Boolean(run?.player && relic &&
(relic.owner === null || relic.owner === run.player.character) &&
!run.player.items?.includes(id));
}
export function grantRelic(run, id) {
if (!getRelic(id)) throw new Error(`梦三遗物未注册:${id}`);
if (!canAcquireRelic(run, id)) return false;
const relic = getRelic(id);
run.player.items ||= [];
run.player.items.push(id);
if (relic.effect === "handLimit") {
run.player.handLimitBonus = (run.player.handLimitBonus || 0) +
relic.amount;
}
if (relic.effect === "gainMaxHp") {
if (Number.isFinite(run.player.maxHp)) {
run.player.maxHp += relic.amount;
} else {
run.player.pendingRelicMaxHp =
(run.player.pendingRelicMaxHp || 0) + relic.amount;
}
}
return true;
}
export function initializeRelicMaxHp(run) {
const amount = run.player.pendingRelicMaxHp || 0;
if (!amount || !Number.isFinite(run.player.maxHp)) return;
run.player.maxHp += amount;
delete run.player.pendingRelicMaxHp;
}
export const relicRewardIds = entries.filter(entry =>
entry.effect !== "handLimit").map(entry => `shared.reward.relic.${entry.id}`);
export const relicRewards = Object.fromEntries(relicRewardIds.map(id => {
const relicId = id.slice("shared.reward.relic.".length);
const relic = getRelic(relicId);
return [id, { relic: relicId, name: `遗物·${relic.name}`,
description: relic.description }];
}));
+22
View File
@@ -0,0 +1,22 @@
import { heldRelics } from "./definitions.js";
export const restRecovery = run => 8 + heldRelics(run)
.filter(relic => relic.effect === "restHeal")
.reduce((sum, relic) => sum + relic.amount, 0);
export function applyRoomRelics(run, node, action) {
const resting = node.type === "rest" && action === "heal";
if (node.type !== "shop" && !resting) return null;
const receipt = `${node.type}:${node.id}`;
const receipts = run.player.relicRoomReceipts || [];
if (receipts.includes(receipt)) return null;
const effect = resting ? "restHeal" : "shopHeal";
const relics = heldRelics(run).filter(relic => relic.effect === effect);
const amount = (resting ? 8 : 0) + relics.reduce((sum, relic) =>
sum + relic.amount, 0);
if (!amount) return null;
const before = run.player.hp;
run.player.hp = Math.min(run.player.maxHp, before + amount);
run.player.relicRoomReceipts = [...receipts, receipt];
return { recovered: run.player.hp - before, relics };
}
-258
View File
@@ -1,258 +0,0 @@
.mengsan-overlay-shuying {
position: fixed;
inset: 0;
z-index: 80;
display: flex;
align-items: center;
justify-content: center;
box-sizing: border-box;
padding: 18px;
background: radial-gradient(circle at 50% 20%, rgba(68, 62, 83, 0.72), rgba(8, 10, 15, 0.96));
color: #f6ead0;
font-family: "Microsoft YaHei", sans-serif;
}
.mengsan-overlay-shuying *,
.mengsan-overlay-shuying *::before,
.mengsan-overlay-shuying *::after {
box-sizing: border-box;
float: none !important;
writing-mode: horizontal-tb !important;
-webkit-writing-mode: horizontal-tb !important;
text-orientation: mixed !important;
letter-spacing: normal;
}
.mengsan-panel-shuying {
position: relative !important;
left: auto !important;
top: auto !important;
right: auto !important;
bottom: auto !important;
display: flex !important;
flex-direction: column !important;
flex: none !important;
width: min(720px, 94vw);
height: auto !important;
min-height: 160px;
max-height: 92vh;
overflow: auto;
box-sizing: border-box;
padding: 22px;
border: 1px solid rgba(232, 202, 140, 0.55);
border-radius: 14px;
background: linear-gradient(145deg, rgba(31, 28, 38, 0.98), rgba(17, 19, 26, 0.98));
box-shadow: 0 20px 70px rgba(0, 0, 0, 0.58), inset 0 1px rgba(255, 255, 255, 0.08);
}
.mengsan-panel-shuying > .mengsan-title-shuying,
.mengsan-panel-shuying > .mengsan-description-shuying,
.mengsan-panel-shuying > .mengsan-choice-list-shuying,
.mengsan-panel-shuying > .mengsan-map-status-shuying,
.mengsan-panel-shuying > .mengsan-map-shuying,
.mengsan-choice-list-shuying > .mengsan-choice-shuying,
.mengsan-choice-shuying > .mengsan-choice-name-shuying,
.mengsan-choice-shuying > .mengsan-choice-description-shuying {
position: relative !important;
left: auto !important;
top: auto !important;
right: auto !important;
bottom: auto !important;
transform: none !important;
}
.mengsan-title-shuying {
display: block !important;
flex: none !important;
width: 100% !important;
height: auto !important;
margin-bottom: 14px;
color: #f2d28e;
font-size: 30px;
font-weight: 700;
text-align: center;
letter-spacing: 4px;
line-height: 1.35;
white-space: normal !important;
}
.mengsan-description-shuying,
.mengsan-map-status-shuying {
display: block !important;
flex: none !important;
width: 100% !important;
height: auto !important;
margin-bottom: 14px;
color: rgba(255, 255, 255, 0.78);
text-align: center;
line-height: 1.6;
}
.mengsan-choice-list-shuying {
display: grid;
flex: none !important;
width: 100% !important;
height: auto !important;
gap: 12px;
}
.mengsan-choice-shuying {
display: block !important;
width: 100% !important;
height: auto !important;
min-height: 64px;
padding: 14px 16px;
border: 1px solid rgba(242, 210, 142, 0.34);
border-radius: 9px;
background: rgba(255, 255, 255, 0.055);
cursor: pointer;
transition: transform 0.16s ease, border-color 0.16s ease, background 0.16s ease;
}
.mengsan-choice-shuying:hover {
transform: translateY(-2px);
border-color: rgba(242, 210, 142, 0.8);
background: rgba(242, 210, 142, 0.12);
}
.mengsan-choice-shuying.disabled {
cursor: default;
opacity: 0.35;
filter: grayscale(1);
}
.mengsan-choice-name-shuying {
display: block !important;
width: 100% !important;
height: auto !important;
color: #f6dca5;
font-size: 18px;
}
.mengsan-choice-description-shuying {
display: block !important;
width: 100% !important;
height: auto !important;
margin-top: 5px;
color: rgba(255, 255, 255, 0.65);
font-size: 13px;
}
.mengsan-map-panel-shuying {
position: absolute !important;
inset: 0 !important;
width: 100% !important;
height: 100% !important;
min-width: 100%;
min-height: 100%;
max-width: none !important;
max-height: none !important;
padding: clamp(12px, 2vw, 24px);
border: 0;
border-radius: 0;
background: transparent;
box-shadow: none;
overflow: hidden;
}
.mengsan-map-overlay-shuying {
padding: 0 !important;
background:
radial-gradient(circle at 50% 18%, rgba(67, 59, 82, 0.82), rgba(9, 10, 16, 0.97)),
repeating-linear-gradient(12deg, rgba(255, 255, 255, 0.018) 0 2px, transparent 2px 12px);
}
.mengsan-map-panel-shuying > .mengsan-title-shuying {
margin-bottom: 6px;
font-size: clamp(24px, 3vw, 36px);
}
.mengsan-map-panel-shuying > .mengsan-map-status-shuying {
margin-bottom: 10px;
}
.mengsan-map-shuying {
position: relative;
display: block !important;
flex: 1 1 auto !important;
width: 100%;
height: auto !important;
min-height: 0;
overflow: hidden;
border-radius: 0;
background: transparent;
}
.mengsan-lines-shuying {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
overflow: visible;
pointer-events: none;
}
.mengsan-lines-shuying line {
vector-effect: non-scaling-stroke;
stroke: rgba(232, 219, 190, 0.68);
stroke-width: 2;
stroke-linecap: round;
stroke-dasharray: 6 5;
}
.mengsan-lines-shuying line.completed {
stroke: rgba(243, 207, 128, 0.95);
}
.mengsan-lines-shuying line.dynamic {
stroke: rgba(118, 205, 255, 0.95);
stroke-dasharray: 3 4;
}
.mengsan-node-shuying {
position: absolute;
width: 66px;
height: 40px;
transform: translate(-50%, -50%);
display: flex;
align-items: center;
justify-content: center;
box-sizing: border-box;
border: 2px solid var(--node-color);
border-radius: 50%;
background: radial-gradient(circle at 38% 28%, rgba(255, 255, 255, 0.2), transparent 42%), var(--node-color);
box-shadow: 0 5px 15px rgba(0, 0, 0, 0.42);
color: #fff4df;
font-size: 12px;
text-align: center;
user-select: none;
}
.mengsan-node-shuying.selectable {
cursor: pointer;
animation: mengsan-node-pulse-shuying 1.6s infinite;
}
.mengsan-node-shuying.locked {
opacity: 0.45;
filter: grayscale(0.65);
}
.mengsan-node-shuying.completed {
opacity: 0.72;
box-shadow: 0 0 0 3px rgba(244, 211, 139, 0.3);
}
@keyframes mengsan-node-pulse-shuying {
0%, 100% { box-shadow: 0 0 0 0 rgba(242, 207, 132, 0.18), 0 5px 15px rgba(0, 0, 0, 0.42); }
50% { box-shadow: 0 0 0 8px rgba(242, 207, 132, 0), 0 5px 18px rgba(0, 0, 0, 0.55); }
}
@media (max-width: 600px) {
.mengsan-overlay-shuying { padding: 7px; }
.mengsan-panel-shuying { padding: 14px; }
.mengsan-title-shuying { font-size: 24px; }
.mengsan-map-panel-shuying { padding: 8px; }
.mengsan-map-shuying { min-height: 0; }
.mengsan-node-shuying { width: 54px; height: 36px; font-size: 11px; }
}
+143
View File
@@ -0,0 +1,143 @@
<#
Batch-crop Godot AtlasTexture .tres files into PNGs from their referenced atlas images.
Requires a FULL RECOVERY output where atlas sources are PNG/WebP. This script does not
unpack PCK files or decode .ctex; it only reads .tres/.tpsheet metadata and crops images.
#>
[CmdletBinding()]
param(
[Parameter(Mandatory = $true)]
[string]$ProjectRoot,
[Parameter(Mandatory = $true)]
[string]$OutputDirectory,
[string]$SpriteDirectory = 'images/atlases/compressed.sprites/card_template',
[switch]$Overwrite
)
$ErrorActionPreference = 'Stop'
Add-Type -AssemblyName System.Drawing
$root = [System.IO.Path]::GetFullPath($ProjectRoot).TrimEnd('\')
$outRoot = [System.IO.Path]::GetFullPath($OutputDirectory).TrimEnd('\')
$relativeSpriteDir = $SpriteDirectory.TrimStart('\', '/').Replace('/', '\')
$sourceDir = [System.IO.Path]::GetFullPath((Join-Path $root $relativeSpriteDir))
if (-not (Test-Path -LiteralPath $root -PathType Container)) {
throw "ProjectRoot not found: $root"
}
if (-not (Test-Path -LiteralPath $sourceDir -PathType Container)) {
throw "SpriteDirectory not found: $sourceDir"
}
if ($outRoot.Equals($root, [System.StringComparison]::OrdinalIgnoreCase) -or
$outRoot.StartsWith($root + '\', [System.StringComparison]::OrdinalIgnoreCase)) {
throw 'OutputDirectory must be outside ProjectRoot so recovered source files stay untouched.'
}
$invariant = [System.Globalization.CultureInfo]::InvariantCulture
$files = @(Get-ChildItem -LiteralPath $sourceDir -Filter '*.tres' -File -Recurse)
$written = 0
$skipped = 0
$errors = [System.Collections.Generic.List[string]]::new()
foreach ($tres in $files) {
$sourceImage = $null
$crop = $null
try {
$text = [System.IO.File]::ReadAllText($tres.FullName)
if ($text -notmatch 'type="AtlasTexture"') {
$skipped++
continue
}
$atlasIdMatch = [regex]::Match($text, '(?m)^\s*atlas\s*=\s*ExtResource\("([^"]+)"\)')
$regionMatch = [regex]::Match($text, '(?m)^\s*region\s*=\s*Rect2i?\(\s*(-?[0-9.]+)\s*,\s*(-?[0-9.]+)\s*,\s*(-?[0-9.]+)\s*,\s*(-?[0-9.]+)\s*\)')
if (-not $atlasIdMatch.Success -or -not $regionMatch.Success) {
$skipped++
$errors.Add("Missing atlas/region fields: $($tres.FullName)")
continue
}
$atlasPath = $null
foreach ($blockMatch in [regex]::Matches($text, '(?m)^\s*\[ext_resource[^\]]*\]')) {
$block = $blockMatch.Value
$idMatch = [regex]::Match($block, '\bid="([^"]+)"')
$pathMatch = [regex]::Match($block, '\bpath="([^"]+)"')
if ($idMatch.Success -and $pathMatch.Success -and
$idMatch.Groups[1].Value -eq $atlasIdMatch.Groups[1].Value) {
$atlasPath = $pathMatch.Groups[1].Value
break
}
}
if (-not $atlasPath) {
$skipped++
$errors.Add("Could not resolve atlas ExtResource: $($tres.FullName)")
continue
}
if ($atlasPath.StartsWith('res://', [System.StringComparison]::OrdinalIgnoreCase)) {
$atlasRelative = $atlasPath.Substring(6).Replace('/', '\')
$atlasFile = Join-Path $root $atlasRelative
} else {
$atlasFile = Join-Path $tres.DirectoryName $atlasPath.Replace('/', '\')
}
if (-not (Test-Path -LiteralPath $atlasFile -PathType Leaf)) {
$skipped++
$errors.Add("Atlas image missing (Full Recovery should restore it as PNG): $atlasFile ; referenced by $($tres.FullName)")
continue
}
$values = @(
[double]::Parse($regionMatch.Groups[1].Value, $invariant),
[double]::Parse($regionMatch.Groups[2].Value, $invariant),
[double]::Parse($regionMatch.Groups[3].Value, $invariant),
[double]::Parse($regionMatch.Groups[4].Value, $invariant)
)
$rounded = @($values | ForEach-Object { [int][Math]::Round($_) })
for ($i = 0; $i -lt 4; $i++) {
if ([Math]::Abs($values[$i] - $rounded[$i]) -gt 0.001) {
throw "Non-integer pixel region in $($tres.FullName)"
}
}
$x = $rounded[0]; $y = $rounded[1]; $width = $rounded[2]; $height = $rounded[3]
if ($x -lt 0 -or $y -lt 0 -or $width -le 0 -or $height -le 0) {
throw "Invalid crop region Rect2($x, $y, $width, $height) in $($tres.FullName)"
}
$sourceImage = [System.Drawing.Bitmap]::FromFile($atlasFile)
if (($x + $width) -gt $sourceImage.Width -or ($y + $height) -gt $sourceImage.Height) {
throw "Crop exceeds atlas bounds ($($sourceImage.Width)x$($sourceImage.Height)): $($tres.FullName)"
}
$rect = [System.Drawing.Rectangle]::new($x, $y, $width, $height)
$crop = $sourceImage.Clone($rect, $sourceImage.PixelFormat)
$relativeTres = $tres.FullName.Substring($root.Length).TrimStart('\')
$relativeOutput = [System.IO.Path]::ChangeExtension($relativeTres, '.png')
$target = Join-Path $outRoot $relativeOutput
$targetDir = Split-Path -Parent $target
New-Item -ItemType Directory -Path $targetDir -Force | Out-Null
if ((Test-Path -LiteralPath $target) -and -not $Overwrite) {
$skipped++
continue
}
if (Test-Path -LiteralPath $target) {
Remove-Item -LiteralPath $target -Force
}
$crop.Save($target, [System.Drawing.Imaging.ImageFormat]::Png)
$written++
}
catch {
$skipped++
$errors.Add("$($tres.FullName): $($_.Exception.Message)")
}
finally {
if ($null -ne $crop) { $crop.Dispose() }
if ($null -ne $sourceImage) { $sourceImage.Dispose() }
}
}
Write-Output "TRES scanned: $($files.Count)"
Write-Output "PNG written: $written"
Write-Output "Skipped: $skipped"
foreach ($message in $errors) { Write-Warning $message }
+91
View File
@@ -0,0 +1,91 @@
import { bondDefinitions, getBondCombat, getBondDeck }
from "../bonds/definitions.js";
import { getBondIntents } from "../bonds/intents.js";
import { bondProbability, ensureBonds, selectBond } from "../bonds/state.js";
export function renderBonds(parent, run, saveRun, onBusy = () => {}) {
const bonds = ensureBonds(run);
const root = document.createElement("div");
root.className = "mengsan-bonds-shuying";
parent.appendChild(root);
const text = (tag, content, target = root) => {
const node = document.createElement(tag);
node.textContent = content;
target.appendChild(node);
return node;
};
const status = text("p", "");
status.setAttribute("role", "status");
const buttons = [];
let saving = false;
const choose = async id => {
if (saving) return;
saving = true;
onBusy(true);
const previous = bonds.selected;
for (const button of buttons) button.disabled = true;
status.textContent = "正在保存助战选择…";
try {
selectBond(run, id);
await saveRun(run);
const updated = renderBonds(parent, run, saveRun, onBusy);
root.replaceWith(updated);
updated.querySelector('button[aria-pressed="true"]')
?.focus({ preventScroll: true });
} catch (error) {
selectBond(run, previous);
status.textContent = "保存失败,请重试。";
console.error("梦三助战选择保存失败", error);
for (const button of buttons) button.disabled = false;
} finally {
saving = false;
onBusy(false);
}
};
const clear = text("button", "不指定助战");
clear.type = "button";
clear.setAttribute("aria-pressed", String(bonds.selected === null));
clear.addEventListener("click", () => choose(null));
buttons.push(clear);
if (!Object.keys(bonds.npcs).length) text("p", "尚未结识羁绊 NPC。");
for (const [id, npc] of Object.entries(bonds.npcs)) {
const definition = bondDefinitions[id];
if (!definition) continue;
const card = document.createElement("section");
card.className = "mengsan-bond-card-shuying";
root.appendChild(card);
text("h3", definition.name, card);
const ready = Boolean(getBondCombat(id, npc.level));
const state = npc.down ? "濒死 · 等待救助" :
ready ? "可助战" : "战斗配置待补";
text("p", `${state}${npc.down && !ready ?
" · 战斗配置待补" : ""}`, card);
text("p", `羁绊 ${npc.level} 级 · 助战概率 ${
(bondProbability(npc.level) * 100).toFixed(2)}%`, card);
const progress = text("progress", "", card);
progress.max = 100;
progress.value = npc.level === 10 ? 100 : npc.progress;
progress.setAttribute("aria-label", `${definition.name}升级进度`);
text("p", npc.level === 10 ? "羁绊已满级" :
`升级进度 ${npc.progress}% / 100%`, card);
const button = text("button", bonds.selected === id ?
"已指定助战" : "指定助战", card);
button.type = "button";
button.setAttribute("aria-pressed", String(bonds.selected === id));
button.addEventListener("click", () => choose(id));
buttons.push(button);
const combat = getBondCombat(id, npc.level);
text("p", combat ? `生命 ${combat.maxHp} · 初始手牌 ${combat.hand}` :
"生命、初始手牌及技能等待配置。", card);
text("p", `独立牌库 ${getBondDeck(definition).length} 张 · 意图 ${
getBondIntents(definition).map(move => move.name).join(" → ")}`,
card);
if (npc.down) {
const rescue = text("button", "救助道具待补充", card);
rescue.type = "button";
rescue.disabled = true;
}
}
text("p", "指定 NPC 后,每场战斗结束:到场 +50%,未到场 +25%。");
return root;
}
+186
View File
@@ -0,0 +1,186 @@
import { lib, get, ui } from "../../../../noname.js";
import { AFFIX_INFO } from "../cards/affixes.js";
import { cardUpgradeLevel, cardUpgradeRule } from "../cards/upgrades.js";
const suits = { spade: "♠ 黑桃", heart: "♥ 红桃", club: "♣ 梅花", diamond: "♦ 方块" };
const types = { basic: "基本牌", trick: "锦囊牌", delay: "延时锦囊", equip: "装备牌" };
const affixes = Object.fromEntries(Object.entries(AFFIX_INFO).map(([key, info]) => [key, `${info.name}:${info.description}`]));
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(cardUpgradeRule(card.name) && info?.cardPrompt
? info.cardPrompt(card)
: lib.translate[`${card.name}_info`] || "暂无卡牌介绍。"),
affixes: (Array.isArray(card.affixes) ? card.affixes : []).filter(key => key !== "annihilate").map(key => affixes[key] || `词缀:${translate(key)}`),
upgrade: cardUpgradeLevel(card),
upgradeLimit: cardUpgradeRule(card.name)?.maxLevel || 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;
const modeImage = lib.card[card.name]?.image;
const localArt = typeof modeImage === "string" &&
/^ext:术樱包\/mengsan\/assets\/cards\/[a-z0-9_]+\.png$/.test(modeImage)
? modeImage.replace(/^ext:/, "extension/") : null;
// 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 || ""}${localArt || `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);
element("p", "", details.upgradeLimit
? `强化:${details.upgrade}/${details.upgradeLimit}`
: "此牌不可强化", copy);
for (const affix of details.affixes) element("p", "ms-deck-rules", affix, copy);
body.scrollTop = 0; backButton.focus();
}
function render() {
detail = false; list.hidden = false; detailPanel.hidden = true;
detailPanel.replaceChildren(); list.replaceChildren(); body.scrollTop = 0;
const sections = options.sections ? options.sections() : [{ title: "", cards: current }];
summary.textContent = options.sections ? "剩余牌堆按从左到右、从上到下的顺序摸取;第 1 张为下一张。" : `本次征程实际携带 · 共 ${current.length} 张 · 同名不同花色、点数分别展示`;
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;
}
// The battle view exposes only this player's own piles, including consumed cards.
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 },
{ title: "消耗牌堆", cards: battle.exhaustPile, 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?.(); } };
}
+193
View File
@@ -0,0 +1,193 @@
import { mountMenu } from "./navigation.js";
import { ui, get } from "../../../../noname.js";
import { isDialogueLineVisible, applyDialogueChoice, applySkippedDialogueChoices } from "../progression/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 textBox = ui.create.div(".mengsan-dialogue-box-shuying", stage);
const portraitFrame = ui.create.div(
".mengsan-dialogue-portrait-frame-shuying", textBox);
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();
});
+13
View File
@@ -0,0 +1,13 @@
export function mountEnergy(player, session, document) {
const badge = document.createElement("div");
badge.className = "mengsan-energy-shuying";
const refresh = () => {
const current = player.storage.mengsanEnergy_shuying;
const maximum = player.storage.mengsanMaxEnergy_shuying;
badge.textContent = `费用 ${current}/${maximum}`;
};
player.appendChild(badge);
session.ownResource(badge, () => badge.remove());
refresh();
return refresh;
}

Some files were not shown because too many files have changed in this diff Show More