2 Commits
Author SHA1 Message Date
Diuse f2da9bce65 fixed # 重构梦三项目结构 2026-09-30 10:23:58 +08:00
Diuse 3fdaf6d218 fixed # 修补缺失资源,修正已知BUG 2026-09-30 00:33:30 +08:00
57 changed files with 616 additions and 154 deletions
+4 -1
View File
@@ -10,4 +10,7 @@ tools/test_update.mjs
tools/test_intent.mjs
/mengsan/.planning/
/mengsan/verification
/mengsan/verification
/mengsan/assets/sts2/
/Slay the Spire 2
+17
View File
@@ -0,0 +1,17 @@
# 梦三模式目录
`register.js` 是扩展加载入口,`mode.js` 负责组装模式、注册技能并连接各领域模块,`config.js` 提供模式配置。发布器和外部扩展应继续从 `register.js` 加载,避免依赖内部文件路径。
| 目录 | 职责 |
| --- | --- |
| `battle/` | 战斗会话、回合、结算、费用与通用战斗效果 |
| `monsters/` | 怪物阶级、牌堆默认值和各怪物意图选择 |
| `cards/` | 卡牌定义、词缀、生成和个人牌堆 |
| `progression/` | 征程状态、存档、剧情选择和奖励逻辑 |
| `content/` | 章节、遭遇、剧本、角色与内容注册表 |
| `ui/` | 地图、对话、意图、奖励、图鉴及样式 |
| `assets/intent/` | 意图图标 |
| `assets/portraits/` | 角色立绘 |
| `docs/` | 战斗和关卡配置说明 |
新增功能优先放入所属领域;界面代码放在 `ui/`,怪物行动逻辑放在 `monsters/`。跨领域引用使用相对路径,避免新增一个涵盖所有功能的扁平 `runtime/` 目录。模块资源文件随 `tools/generate_manifest.py` 自动收录,发布入口仍为 `mengsan/register.js`。

Before

Width:  |  Height:  |  Size: 1.9 MiB

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

Before

Width:  |  Height:  |  Size: 2.8 MiB

After

Width:  |  Height:  |  Size: 2.8 MiB

@@ -1,4 +1,4 @@
import { cardDefinitions, canAcquireCard } from "../content/card-definitions.js";
import { cardDefinitions, canAcquireCard } from "./card-definitions.js";
const suits = ["spade", "heart", "club", "diamond"];
@@ -3,7 +3,7 @@
import standard from "../../../../card/standard.js";
import extra from "../../../../card/extra.js";
import { _status } from "../../../../noname.js";
import { cardCost, canPayCard, isActiveCardUse, SHA_DAMAGE } from "../runtime/combat-rules.js";
import { cardCost, canPayCard, isActiveCardUse, SHA_DAMAGE } from "../battle/combat-rules.js";
const packs = [standard, extra];
const names = [...new Set(packs.flatMap(pack => pack.list.map(entry => entry[2])))];
@@ -1,7 +1,7 @@
// Mode-owned piles use real Card nodes so native searches/gain/insert operations agree.
// DIY must access ui piles inside the acting player's engine event, never cache global nodes.
import { cardCost } from "./combat-rules.js";
import { AFFIX_INFO } from "../content/affixes.js";
import { cardCost } from "../battle/combat-rules.js";
import { AFFIX_INFO } from "./affixes.js";
export function installPersonalPiles(session, owner, battle, run, resources, env) {
@@ -1,4 +1,4 @@
import { createCardData } from "../runtime/card-data.js";
import { createCardData } from "./card-data.js";
// Mode-owned starting decks. Existing saves always retain their actual player.deck.
export const defaultDeck = Object.freeze([
+1 -1
View File
@@ -1,4 +1,4 @@
import { defaultDeck } from "./content/card-library.js";
import { defaultDeck } from "./cards/starting-deck.js";
import contentRegistry from "./content/registry.js";
const mengsanConfig = {
+1 -1
View File
@@ -20,7 +20,7 @@ const act1Map = {
contentId: "act1.battle.liubeiOpening",
},
],
enemies: ["mengsan_flyconid_shuying"],
enemies: ["mengsan_flyconid_shuying", "mengsan_raider_trio_shuying"],
eliteEnemies: ["re_xiahoudun", "re_zhangliao", "re_xuzhu"],
boss: "re_lvbu",
baseGold: 20,
+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: [],
};
}
+1 -1
View File
@@ -1,4 +1,4 @@
import { validateBattlePlan } from "../runtime/battle-director.js";
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";
+17 -2
View File
@@ -7,14 +7,29 @@ export function createScenarioCharacters(Character) {
mengsan_soldier_shuying: new Character({ sex: "male", group: "qun", hp: 4, maxHp: 4,
skills: [], img: "image/character/shibing1.jpg" }),
mengsan_flyconid_shuying: new Character({ sex: "none", group: "qun", hp: 48, maxHp: 48,
skills: [], img: "extension/术樱包/mengsan/assets/flyconid-portrait.png" }),
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/sushuang-zhangshiping.png", names: "苏|双-张|世平" }),
skills: [], img: "extension/术樱包/mengsan/assets/portraits/sushuang-zhangshiping.png", names: "苏|双-张|世平" }),
};
}
export const scenarioTranslations = {
mengsan_liubei_shuying: "界刘备", mengsan_liubei_shuying_title: "50/50生命 · 无初始技能",
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: "苏双&张世平",
};
+6 -6
View File
@@ -1,20 +1,20 @@
# 梦三战斗规则(测试阶段)
只影响梦三模式,不修改 noname 的标准/军争原始牌包。`content/mode-cards.js` 从标准、军争实体牌列表逐一复制模式专属定义,沿用原牌名以保留响应、装备、转化兼容性;图鉴说明在梦三模式覆盖。新征程的界刘备为 50/50 生命,默认每回合摸 2 张、手牌上限 3、回合费用恢复至 3。主动使用牌消耗费用,转化牌按转化后的牌名计费;响应【闪】、救援【桃】等不消耗费用。默认主动牌 1 费,`runtime/combat-rules.js` 中列出例外。【杀】1 费、可多次使用,牌定义的基础伤害为 4;【桃】及【桃园结义】回复 8 点生命;【南蛮入侵】、【万箭齐发】、【决斗】、【火攻】基础伤害 4,【闪电】命中伤害 12。其余卡牌保留原有效果,并加上梦三费用与图鉴说明。
只影响梦三模式,不修改 noname 的标准/军争原始牌包。`cards/mode-cards.js` 从标准、军争实体牌列表逐一复制模式专属定义,沿用原牌名以保留响应、装备、转化兼容性;图鉴说明在梦三模式覆盖。新征程的界刘备为 50/50 生命,默认每回合摸 2 张、手牌上限 3、回合费用恢复至 3。主动使用牌消耗费用,转化牌按转化后的牌名计费;响应【闪】、救援【桃】等不消耗费用。默认主动牌 1 费,`battle/combat-rules.js` 中列出例外。【杀】1 费、可多次使用,牌定义的基础伤害为 4;【桃】及【桃园结义】回复 8 点生命;【南蛮入侵】、【万箭齐发】、【决斗】、【火攻】基础伤害 4,【闪电】命中伤害 12。其余卡牌保留原有效果,并加上梦三费用与图鉴说明。
## 怪物
`runtime/monster.js` 的 `Monster` 类统一提供阶级默认值。普通/精英/Boss 分别每回合摸 1/2/3 张、手牌上限 3/5/7、回合费用 2/3/4。每名敌人拥有自己的抽牌堆、弃牌堆与手牌;怪物默认使用阶级牌堆,不再从公共牌堆摸牌。友方支援仍用公共牌堆。
`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 中存在。
## 配置位置
- 玩家费用、手牌上限、卡牌费用与【杀】伤害:`runtime/combat-rules.js`
- 梦三模式卡牌复制、内容与图鉴文案:`content/mode-cards.js`
- 怪物阶级、默认牌堆、生命与继承示例:`runtime/monster.js`
- 玩家费用、手牌上限、卡牌费用与【杀】伤害:`battle/combat-rules.js`
- 梦三模式卡牌复制、内容与图鉴文案:`cards/mode-cards.js`
- 怪物阶级、默认牌堆、生命与继承示例:`monsters/monster.js`
- 刘备角色初始生命:`content/scenario-characters.js`
- 刘备专属初始牌组:`content/card-library.js`
- 刘备专属初始牌组:`cards/starting-deck.js`
- 首关小兵覆盖数值:`content/acts/act1/stories/liubei-opening.js`
旧存档不作迁移。源码检查不等于游戏内、十周年 UI 或 AI 行为验收。
+1 -1
View File
@@ -20,7 +20,7 @@
- 提前击败敌人允许正常获胜,此时未到第四次回合的支援不会触发。不强行等待、不添加免死。
## 新角色立绘
`assets/sushuang-zhangshiping.png` 为本次 image_gen 生成的原创双人立绘,不是官方素材。
`assets/portraits/sushuang-zhangshiping.png` 为本次 image_gen 生成的原创双人立绘,不是官方素材。
## 验证边界
隔离脚本检查配置、回合计数、效果顺序及奖励幂等,不等于游戏/十周年UI验收。请开始新征程检查真实引擎的装备、座次、AI和剧情显示。
+88 -90
View File
@@ -1,16 +1,19 @@
import { createScenarioCharacters, scenarioTranslations } from "./content/scenario-characters.js";
import { createMengsanCards } from "./content/mode-cards.js";
import { buildBattlePlan, consumeSupports, createBattleDirector, grantSupport } from "./runtime/battle-director.js";
import { installPersonalPiles } from "./runtime/personal-piles.js";
import { createMonster } from "./runtime/monster.js";
import { mountEnemyIntent } from "./runtime/intent-display.js";
import { attackHitCount, outgoingAttackDamage } from "./runtime/intent-damage.js";
import { createMengsanCards } from "./cards/mode-cards.js";
import { buildBattlePlan, consumeSupports, createBattleDirector, grantSupport } from "./battle/battle-director.js";
import { installPersonalPiles } from "./cards/personal-piles.js";
import { createMonster } from "./monsters/monster.js";
import {
createMonsterIntentActions,
isFlyconid,
isRaider,
} from "./monsters/actions.js";
import { mountEnemyIntent } from "./ui/intent-display.js";
import { mountEnergy } from "./ui/energy-display.js";
import {
beginDeathBlow,
finishDeathBlow,
hasPendingDeathBlow,
isDeathBlowIntent,
} from "./runtime/death-blow.js";
} from "./battle/death-blow.js";
import {
STUN_INTENT,
applyStun,
@@ -18,30 +21,31 @@ import {
finishStunnedTurn,
isStunned,
skipStunnedAction,
} from "./runtime/stun-intent.js";
import { selectFlyconidMove, recordFlyconidAction } from "./runtime/flyconid-intent.js";
import { PLAYER_ENERGY, PLAYER_HAND_LIMIT, canPayCard, payCard, isActiveCardUse } from "./runtime/combat-rules.js";
import { mountBattlePiles } from "./runtime/card-library.js";
import { mountGMManager } from "./runtime/gm-manager.js";
import { createPlayerTeardown } from "./runtime/skill-teardown.js";
import { createBattleResources } from "./runtime/battle-resources.js";
import { createModeStorage } from "./runtime/mode-storage.js";
import { createBattleSession } from "./runtime/battle-session.js";
import { observeFreshEvent } from "./runtime/engine-session.js";
import { runPhaseLoop, stopBattleTurn } from "./runtime/battle-loop.js";
import { createBattleSettlement } from "./runtime/battle-settlement.js";
import { createBattleFlow, quiesceEngine } from "./runtime/battle-flow.js";
} from "./battle/stun-intent.js";
import { recordFlyconidAction } from "./monsters/flyconid-intent.js";
import { recordRaiderAction } from "./monsters/raider-intent.js";
import { PLAYER_ENERGY, PLAYER_HAND_LIMIT, canPayCard, payCard, isActiveCardUse } from "./battle/combat-rules.js";
import { mountBattlePiles } from "./ui/card-library.js";
import { mountGMManager } from "./ui/gm-manager.js";
import { createPlayerTeardown } from "./battle/skill-teardown.js";
import { createBattleResources } from "./battle/battle-resources.js";
import { createModeStorage } from "./progression/mode-storage.js";
import { createBattleSession } from "./battle/battle-session.js";
import { observeFreshEvent } from "./battle/engine-session.js";
import { runPhaseLoop, stopBattleTurn } from "./battle/battle-loop.js";
import { createBattleSettlement } from "./battle/battle-settlement.js";
import { createBattleFlow, quiesceEngine } from "./battle/battle-flow.js";
import { lib, game, ui, get, _status } from "../../../noname.js";
import config from "./config.js";
import { showMap } from "./map/index.js";
import { getRandomRewardChoices } from "./runtime/reward.js";
import { canAcquireCard } from "./content/card-definitions.js";
import { addCardToDeck, createRandomCardData } from "./runtime/card-data.js";
import { chooseBattleReward } from "./runtime/reward-ui.js";
import { applyStoryOutcome, getAvailableStoryChoices } from "./runtime/story.js";
import { playDialogue } from "./runtime/dialogue.js";
import { chooseButtons } from "./runtime/flow-ui.js";
import { openModeSelection } from "./runtime/navigation.js";
import { showMap } from "./ui/map/index.js";
import { getRandomRewardChoices } from "./progression/reward.js";
import { canAcquireCard } from "./cards/card-definitions.js";
import { addCardToDeck, createRandomCardData } from "./cards/card-data.js";
import { chooseBattleReward } from "./ui/reward-ui.js";
import { applyStoryOutcome, getAvailableStoryChoices } from "./progression/story.js";
import { playDialogue } from "./ui/dialogue.js";
import { chooseButtons } from "./ui/flow-ui.js";
import { openModeSelection } from "./ui/navigation.js";
import {
completeNode,
createRun,
@@ -50,7 +54,7 @@ import {
getNodeEncounter,
insertStoryNode,
nextRandom,
} from "./state.js";
} from "./progression/state.js";
const MODE_ID = config.modeId;
const STYLE_PATH = `${lib.assetURL}extension/术樱包/mengsan`;
@@ -277,57 +281,11 @@ const installMonsterPile = (player, monster, current) => {
}));
};
const mountEnergy = (player, session) => {
const badge = document.createElement("div");
badge.className = "mengsan-energy-shuying";
const refresh = () => { badge.textContent = `费用 ${player.storage.mengsanEnergy_shuying}/${player.storage.mengsanMaxEnergy_shuying}`; };
player.appendChild(badge);
session.ownResource(badge, () => badge.remove());
refresh();
return refresh;
};
const isFlyconid = player => player?.name === "mengsan_flyconid_shuying" && player.storage?.mengsanCamp_shuying === "enemy";
const planFlyconidIntent = (player, run) => {
if (!isFlyconid(player) || !player.isAlive() || player.storage.mengsanFlyconidIntent_shuying) return;
const move = selectFlyconidMove(player.storage.mengsanFlyconidState_shuying || {}, () => nextRandom(run));
player.storage.mengsanFlyconidIntent_shuying = move;
game.mengsanSetEnemyIntent_shuying(player, move);
};
const applyFlyconidDebuff = (target, move) => {
if (!move.debuff) return;
const frail = move.id === "frail";
const key = frail ? "mengsanFrail_shuying" : "mengsanVulnerable_shuying";
target.storage[key] = (target.storage[key] || 0) + move.stacks;
target.addSkill(frail ? "mengsan_frail_shuying" : "mengsan_vulnerable_shuying");
target.markSkill(frail ? "mengsan_frail_shuying" : "mengsan_vulnerable_shuying");
};
const executeFlyconidIntent = async player => {
const move = player.storage.mengsanFlyconidIntent_shuying;
if (!move) return;
player.storage.mengsanFlyconidIntent_shuying = null;
game.mengsanSetEnemyIntent_shuying(player, null);
const paid = player.storage.mengsanEnergy_shuying >= 1;
player.storage.mengsanFlyconidState_shuying = recordFlyconidAction(player.storage.mengsanFlyconidState_shuying || {}, move, paid);
if (!paid) { game.log(player, "费用不足,未发动", move.name); return; }
player.storage.mengsanEnergy_shuying--;
activeBattle?.energyUI.get(player)?.();
game.log(player, "消耗1费用发动", move.name);
const target = game.me;
if (!target?.isAlive() || !player.isAlive()) return;
if (isDeathBlowIntent(move)) {
await beginDeathBlow(player, target, move);
return;
}
if (move.damage != null) {
for (let index = 0; index < attackHitCount(move); index++) {
if (!target.isAlive() || !player.isAlive()) break;
const hit = target.damage(outgoingAttackDamage(move, player), player);
hit.mengsanAttack_shuying = true;
await hit;
}
}
if (target.isAlive() && player.isAlive()) applyFlyconidDebuff(target, move);
};
const {
planEnemyIntent,
executeFlyconidIntent,
executeRaiderIntent,
} = createMonsterIntentActions(game, () => activeBattle);
const equipBattleUnit = async (player, spec, resources) => {
for (const info of spec.equipment || []) {
const card = resources.card(game.createCard(info.name, info.suit, info.number, info.nature));
@@ -350,7 +308,8 @@ const createScenario = (plan, current) => {
const monster = initBattleUnit(player, spec);
installMonsterPile(player, monster, current);
if (monster) {
current.energyUI.set(player, mountEnergy(player, current.session));
current.energyUI.set(player,
mountEnergy(player, current.session, document));
current.intentUI.set(player, mountEnemyIntent(player,
current.session, STYLE_PATH, document, () => game.me));
}
@@ -358,7 +317,7 @@ const createScenario = (plan, current) => {
await equipBattleUnit(player, spec, current.resources);
await player.draw(spec.hand ?? 4);
await game.triggerEnter(player);
if (monster && current.session.active) planFlyconidIntent(player, _status.mengsanRun_shuying);
if (monster && current.session.active) planEnemyIntent(player, _status.mengsanRun_shuying);
});
await join;
game.log(player, "作为", spec.camp === "ally" ? "友方支援" : "敌方援军", "加入战斗");
@@ -400,6 +359,8 @@ const prepareBattle = async (run, node, encounter, session, resources) => {
if (effect.type === "skill" && !lib.skill[effect.skill]) throw new Error("关卡增益技能未加载:" + effect.skill);
}
consumeSupports(run);
document.body.classList.add("mengsan-battle-ui-shuying");
session.ownResource({}, () => document.body.classList.remove("mengsan-battle-ui-shuying"));
resources.field(_status, "mengsanRun_shuying", run);
resources.field(_status, "mengsanNode_shuying", node);
resources.field(_status, "mengsanEncounter_shuying", encounter);
@@ -444,7 +405,7 @@ const prepareBattle = async (run, node, encounter, session, resources) => {
me.storage.mengsanPlayer_shuying = true;
me.storage.mengsanMaxEnergy_shuying = PLAYER_ENERGY;
me.storage.mengsanEnergy_shuying = PLAYER_ENERGY;
currentBattle.energyUI.set(me, mountEnergy(me, session));
currentBattle.energyUI.set(me, mountEnergy(me, session, document));
game.zhu = me;
if (run.player.maxHp == null) {
run.player.maxHp = me.maxHp;
@@ -460,7 +421,8 @@ const prepareBattle = async (run, node, encounter, session, resources) => {
"mengsan_draw_shuying", "mengsan_card_use_shuying",
"mengsan_card_affixes_shuying", "mengsan_scenario_shuying",
"mengsan_card_payment_shuying", "mengsan_monster_draw_shuying",
"mengsan_flyconid_action_shuying",
"mengsan_flyconid_action_shuying", "mengsan_raider_action_shuying",
"mengsan_raider_card_strength_shuying",
"mengsan_death_blow_finish_shuying",
"mengsan_stun_skip_shuying", "mengsan_stun_recover_shuying",
"mengsan_stun_clear_shuying",
@@ -496,13 +458,14 @@ const prepareBattle = async (run, node, encounter, session, resources) => {
openingHandChecked = true;
currentBattle.personalPiles.trimOpeningHand(4);
}
if (isRaider(player) && player.hujia > 0) await player.changeHujia(-player.hujia);
if (player.storage.mengsanMaxEnergy_shuying != null) {
player.storage.mengsanEnergy_shuying = player.storage.mengsanMaxEnergy_shuying;
currentBattle.energyUI.get(player)?.();
}
await currentBattle.director.beforeTurn(player);
if (player === me && session.active) {
for (const enemy of game.players) planFlyconidIntent(enemy, run);
for (const enemy of game.players) planEnemyIntent(enemy, run);
}
game.checkResult();
}}));
@@ -586,6 +549,7 @@ const setupBattle = async (run, node, encounter = getNodeEncounter(run, node)) =
await signal; // External controller, never an engine event content await.
if (current.engineError) {
session.requestStop();
document.body.classList.remove("mengsan-battle-ui-shuying");
await chooseButtons("战斗流程异常", [{id:"exit",name:"保留存档并返回模式选择"}], "无法确认旧事件已退出,已停止继续开战。上次成功存档仍保留。");
await openModeSelection(); return;
}
@@ -709,8 +673,10 @@ const createMode = identityMode => {
if (isStunned(player)) return false;
const { resume = "advance", recover } = options;
const flyconid = isFlyconid(player);
const raider = isRaider(player);
const intent = options.intent ?? (flyconid ?
player.storage.mengsanFlyconidIntent_shuying : null);
player.storage.mengsanFlyconidIntent_shuying : raider ?
player.storage.mengsanRaiderIntent_shuying : null);
const restore = recover || (flyconid ?
(choice, original) => {
const state =
@@ -720,6 +686,11 @@ const createMode = identityMode => {
const next = choice === "retry" ? original : null;
player.storage.mengsanFlyconidIntent_shuying = next;
return next;
} : raider ? (choice, original) => {
player.storage.mengsanRaiderState_shuying = recordRaiderAction(player.storage.mengsanRaiderState_shuying || {});
const next = choice === "retry" ? original : null;
player.storage.mengsanRaiderIntent_shuying = next;
return next;
} : null);
const onRecover = async (choice, original) => {
const next = await restore(choice, original);
@@ -818,6 +789,29 @@ const createMode = identityMode => {
event.player.storage.mengsanFlyconidIntent_shuying); },
async content(event, trigger) { await executeFlyconidIntent(trigger.player); },
},
mengsan_raider_action_shuying: {
trigger: { global: "phaseUseBefore" },
forced: true, silent: true, popup: false, priority: 100,
filter(event) { return Boolean(activeBattle?.session.active &&
!isStunned(event.player) && isRaider(event.player) &&
event.player.storage.mengsanRaiderIntent_shuying); },
async content(event, trigger) { await executeRaiderIntent(trigger.player); },
},
mengsan_raider_card_strength_shuying: {
trigger: { source: "damageBegin1" },
forced: true, silent: true, popup: false, priority: 90,
filter(event, player) { return Boolean(activeBattle?.session.active &&
isRaider(player) && player.storage.mengsanStrength_shuying > 0 &&
event.card && !event.mengsanScriptedSkill_shuying); },
async content(event, trigger, player) {
trigger.num += player.storage.mengsanStrength_shuying;
},
},
mengsan_raider_strength_shuying: {
mark: true, marktext: "力",
intro: { content(storage, player) { return `攻击伤害增加${player.storage.mengsanStrength_shuying || 0}点`; } },
onremove(player) { delete player.storage.mengsanStrength_shuying; },
},
mengsan_stun_skip_shuying: {
trigger: { global: "phaseUseBefore" },
forced: true, silent: true, popup: false, priority: 200,
@@ -988,6 +982,10 @@ const createMode = identityMode => {
mengsan_yingyong_shuying: "英勇",
mengsan_yingyong_shuying_info: "锁定技,每回合限一次,你于自己的回合内使用牌造成的伤害+1。",
mengsan_flyconid_action_shuying: "孢子行动",
mengsan_raider_action_shuying: "劫掠行动",
mengsan_raider_card_strength_shuying: "力量加成",
mengsan_raider_strength_shuying: "力量",
mengsan_raider_strength_shuying_info: "攻击造成的伤害按力量层数增加。",
mengsan_frail_shuying: "脆弱",
mengsan_frail_shuying_info: "接下来相应回合获得护甲减少25%(向下取整)。",
mengsan_vulnerable_shuying: "易伤",
@@ -1008,7 +1006,7 @@ export async function createMengsanMode() {
] });
}
await new Promise(resolve => {
const style = lib.init.css(STYLE_PATH, "style", resolve);
const style = lib.init.css(`${STYLE_PATH}/ui`, "style", resolve);
style.addEventListener("error", resolve, { once: true });
});
const identityMode = await game.loadModeAsync("identity");
+131
View File
@@ -0,0 +1,131 @@
import {
attackHitCount,
outgoingAttackDamage,
} from "../battle/intent-damage.js";
import { beginDeathBlow, isDeathBlowIntent } from "../battle/death-blow.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 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 applyIntentDebuff = (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");
};
const dealIntentDamage = async (source, target, move) => {
if (move.damage == null) return;
for (let index = 0; index < attackHitCount(move); index++) {
if (!target.isAlive() || !source.isAlive()) break;
const hit = target.damage(
outgoingAttackDamage(move, source), source);
hit.mengsanAttack_shuying = true;
hit.mengsanScriptedSkill_shuying = true;
await hit;
}
};
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);
const target = game.me;
if (!target?.isAlive() || !player.isAlive()) return;
if (isDeathBlowIntent(move)) {
await beginDeathBlow(player, target, move);
return;
}
await dealIntentDamage(player, target, move);
if (target.isAlive() && player.isAlive()) {
applyIntentDebuff(target, 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);
const target = game.me;
if (!target?.isAlive() || !player.isAlive()) return;
await dealIntentDamage(player, target, move);
if (target.isAlive() && player.isAlive()) {
applyIntentDebuff(target, move);
}
if (!player.isAlive() || !getActiveBattle()?.session.active) return;
if (move.block) await player.changeHujia(move.block);
if (move.strength) {
player.storage.mengsanStrength_shuying =
(player.storage.mengsanStrength_shuying || 0) +
move.strength;
player.addSkill("mengsan_raider_strength_shuying");
player.markSkill("mengsan_raider_strength_shuying");
}
};
return {
planEnemyIntent,
executeFlyconidIntent,
executeRaiderIntent,
};
}
@@ -1,4 +1,4 @@
import { createCardData } from "./card-data.js";
import { createCardData } from "../cards/card-data.js";
// 梦三怪物规则只作用于敌方单位;子类可覆盖构造参数或 createDeck()。
const TIER_RULES = Object.freeze({
@@ -16,6 +16,11 @@ const BASE_DECKS = Object.freeze({
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,
@@ -63,9 +68,25 @@ export class OpeningSoldier extends Monster {
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 });
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;
+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 };
}
@@ -1,6 +1,6 @@
import config from "../config.js";
import { nextRandom } from "../state.js";
import { canAcquireCard } from "../content/card-definitions.js";
import { nextRandom } from "./state.js";
import { canAcquireCard } from "../cards/card-definitions.js";
export const rewardCardName = reward => reward?.card?.name || ({ card_sha: "sha", card_tao: "tao" })[reward?.effectId] || null;
export const canAcquireReward = (run, reward) => {
@@ -1,5 +1,6 @@
import { createStartingDeck } from "./content/card-library.js";
import config from "./config.js";
import { createStartingDeck } from "../cards/starting-deck.js";
import config from "../config.js";
import { RAIDER_TRIO_ENCOUNTER, createRaiderTrioBattlePlan } from "../content/acts/act1/raider-trio.js";
const hashText = text => {
let value = 2166136261;
@@ -245,16 +246,18 @@ export const getNodeEncounter = (run, node, override = null) => {
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 {
name: encounter?.name || "",
name: encounter?.name || (enemy === RAIDER_TRIO_ENCOUNTER ? "劫掠者团伙" : ""),
requiredCharacter: encounter?.requiredCharacter || null,
openingDialogue: encounter?.openingDialogue || [],
fixedRewards: encounter?.fixedRewards || [],
skipRandomReward: encounter?.skipRandomReward === true,
victoryDialogue: encounter?.victoryDialogue || [],
battlePlan: encounter?.battlePlan || null,
battlePlan: encounter?.battlePlan || (enemy === RAIDER_TRIO_ENCOUNTER ?
createRaiderTrioBattlePlan(() => nextRandom(run)) : null),
tier: encounter?.tier || (node.type === "elite" ? "elite" : "normal"),
enemy: randomGet(run, pool.filter(Boolean)),
enemy,
gold: encounter?.gold ?? (node.type == "elite" || node.type == "story" ? act.baseGold * 2 : act.baseGold),
boss: false,
contentId: node.contentId || null,
+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 }
@@ -1,5 +1,5 @@
import { lib, get, ui } from "../../../../noname.js";
import { AFFIX_INFO } from "../content/affixes.js";
import { AFFIX_INFO } from "../cards/affixes.js";
const suits = { spade: "♠ 黑桃", heart: "♥ 红桃", club: "♣ 梅花", diamond: "♦ 方块" };
const types = { basic: "基本牌", trick: "锦囊牌", delay: "延时锦囊", equip: "装备牌" };
@@ -1,6 +1,6 @@
import { mountMenu } from "./navigation.js";
import { ui, get } from "../../../../noname.js";
import { isDialogueLineVisible, applyDialogueChoice, applySkippedDialogueChoices } from "./dialogue-choice.js";
import { isDialogueLineVisible, applyDialogueChoice, applySkippedDialogueChoices } from "../progression/dialogue-choice.js";
const TYPE_NAMES = {
character: "角色",
+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;
}
@@ -1,4 +1,4 @@
import { AFFIX_INFO } from "../content/affixes.js";
import { AFFIX_INFO } from "../cards/affixes.js";
import { describeLibraryCard } from "./card-library.js";
const node = (tag, className, label, parent) => {
@@ -1,7 +1,7 @@
// 只呈现已经选定的预测技能;不从手牌或原生技能猜测意图。
import { attackHitCount, previewAttackDamage } from "./intent-damage.js";
import { isDeathBlowIntent } from "./death-blow.js";
import { isStunIntent } from "./stun-intent.js";
import { attackHitCount, previewAttackDamage } from "../battle/intent-damage.js";
import { isDeathBlowIntent } from "../battle/death-blow.js";
import { isStunIntent } from "../battle/stun-intent.js";
export function attackIconForDamage(damage) {
if (!Number.isInteger(damage) || damage < 0) {
@@ -27,19 +27,37 @@ export function mountEnemyIntent(player, session, assetBase, document,
amount.className = "mengsan-intent-amount-shuying";
const icon = document.createElement("img");
icon.className = "mengsan-intent-icon-shuying";
icon.alt = "Attack";
icon.alt = "";
icon.decoding = "async";
damageLine.append(icon, amount);
const debuffLine = document.createElement("span");
debuffLine.className = "mengsan-intent-debuff-shuying";
const debuffIcon = document.createElement("img");
debuffIcon.className = "mengsan-intent-icon-shuying";
debuffIcon.alt = "Debuff";
debuffIcon.alt = "";
debuffIcon.decoding = "async";
debuffIcon.src = `${assetBase}/assets/intent/Intent_debuff.png`;
const debuffAmount = document.createElement("span");
debuffLine.append(debuffIcon, debuffAmount);
badge.append(damageLine, debuffLine);
const blockLine = document.createElement("span");
blockLine.className = "mengsan-intent-block-shuying";
const blockIcon = document.createElement("img");
blockIcon.className = "mengsan-intent-icon-shuying";
blockIcon.alt = "";
blockIcon.decoding = "async";
blockIcon.src = `${assetBase}/assets/intent/Intent_defend.png`;
const blockAmount = document.createElement("span");
blockLine.append(blockIcon, blockAmount);
const buffLine = document.createElement("span");
buffLine.className = "mengsan-intent-buff-shuying";
const buffIcon = document.createElement("img");
buffIcon.className = "mengsan-intent-icon-shuying";
buffIcon.alt = "";
buffIcon.decoding = "async";
buffIcon.src = `${assetBase}/assets/intent/Intent_buff.png`;
const buffAmount = document.createElement("span");
buffLine.append(buffIcon, buffAmount);
badge.append(damageLine, debuffLine, blockLine, buffLine);
player.appendChild(badge);
let currentIntent = null;
let timer;
@@ -56,10 +74,11 @@ export function mountEnemyIntent(player, session, assetBase, document,
if (isStunIntent(intent)) {
damageLine.hidden = false;
debuffLine.hidden = true;
blockLine.hidden = true;
buffLine.hidden = true;
amount.textContent = "";
const src = `${assetBase}/assets/intent/Intent_stun.png`;
if (icon.getAttribute("src") !== src) icon.src = src;
icon.alt = "Stunned";
if (badge.getAttribute("aria-label") !== "Stunned") {
badge.setAttribute("aria-label", "Stunned");
}
@@ -76,13 +95,20 @@ export function mountEnemyIntent(player, session, assetBase, document,
Number.isInteger(intent.stacks) && intent.stacks > 0;
debuffLine.hidden = !hasDebuff;
if (hasDebuff) {
const label = intent.id === "frail" ? "Frail" :
intent.id === "vulnerable" ? "Vulnerable" : "Debuff";
const debuffText = `${label} ×${intent.stacks}`;
const debuffText = `×${intent.stacks}`;
if (debuffAmount.textContent !== debuffText) {
debuffAmount.textContent = debuffText;
}
}
const hasBlock = Number.isInteger(intent.block) && intent.block > 0;
const hasStrength = Number.isInteger(intent.strength) && intent.strength > 0;
blockLine.hidden = !hasBlock;
buffLine.hidden = !hasStrength;
if (hasBlock) blockAmount.textContent = String(intent.block);
if (hasStrength) {
buffAmount.textContent = `+${intent.strength}`;
}
const effects = [];
if (hasDamage) {
const hits = attackHitCount(intent);
if (deathBlow && hits !== 1) {
@@ -100,20 +126,24 @@ export function mountEnemyIntent(player, session, assetBase, document,
`${assetBase}/assets/intent/Intent_death_blow.png` :
`${assetBase}/assets/intent/Intent_attack_${tier}.png`;
if (icon.getAttribute("src") !== src) icon.src = src;
icon.alt = deathBlow ? "Death Blow" : "Attack";
const label = `${deathBlow ? "Death Blow" : "Attack"}: ` +
`${damage} damage${hits > 1 ?
` × ${hits} hits` : ""}${hasDebuff ?
`, ${debuffAmount.textContent}` : ""}`;
if (badge.getAttribute("aria-label") !== label) {
badge.setAttribute("aria-label", label);
}
} else {
const label = debuffAmount.textContent;
if (badge.getAttribute("aria-label") !== label) {
badge.setAttribute("aria-label", label);
}
effects.push(`${deathBlow ? "Death Blow" : "Attack"}: ${damage} damage${hits > 1 ? ` × ${hits} hits` : ""}`);
}
if (hasDebuff) {
const label = intent.debuff === "脆弱" ? "Frail" :
intent.debuff === "易伤" ? "Vulnerable" : "Debuff";
effects.push(`${label} ×${intent.stacks}`);
}
if (hasBlock) effects.push(`Block ${intent.block}`);
if (hasStrength) effects.push(`Strength +${intent.strength}`);
if (!effects.length) {
damageLine.hidden = false;
amount.textContent = "";
const src = `${assetBase}/assets/intent/Intent_unknown.png`;
if (icon.getAttribute("src") !== src) icon.src = src;
effects.push("Unknown");
}
const label = effects.join(", ");
if (badge.getAttribute("aria-label") !== label) badge.setAttribute("aria-label", label);
badge.hidden = false;
};
timer = setInterval(refresh, 100);
@@ -1,8 +1,8 @@
import { openCardLibrary } from "../runtime/card-library.js";
import { askMenu, openModeSelection } from "../runtime/navigation.js";
import { ui, get } from "../../../../noname.js";
import config from "../config.js";
import { getSelectableNodes } from "../state.js";
import { openCardLibrary } from "../card-library.js";
import { askMenu, openModeSelection } from "../navigation.js";
import { ui, get } from "../../../../../noname.js";
import config from "../../config.js";
import { getSelectableNodes } from "../../progression/state.js";
// Original vector pictograms; no external image/font requests.
const iconPaths = {
@@ -393,7 +393,7 @@ export const showMap = run => new Promise(resolve => {
const floorCount = Math.max(...run.map.nodes.map(node =>
Math.floor(node.floor))) + 1;
map.style.setProperty("--mengsan-map-min-height",
`${floorCount * 100}px`);
`${floorCount * 150}px`);
const landscape = document.createElementNS("http://www.w3.org/2000/svg", "svg");
landscape.classList.add("mengsan-ink-landscape-shuying");
landscape.setAttribute("viewBox", "0 0 800 1200");
@@ -1,5 +1,5 @@
import { lib, get } from "../../../../noname.js";
import { cardCost } from "./combat-rules.js";
import { cardCost } from "../battle/combat-rules.js";
import { chooseButtons } from "./flow-ui.js";
const element = (tag, className, text, parent) => {
+20 -4
View File
@@ -197,7 +197,7 @@
.mengsan-map-shuying {
position: relative;
display: block !important;
width: min(100%, 860px);
width: min(100%, 980px);
height: max(1080px, 160vh,
var(--mengsan-map-min-height, 1080px)) !important;
margin: 0 auto;
@@ -673,7 +673,7 @@
scroll-padding-block: 24px;
}
.mengsan-map-shuying {
width: min(100%, 900px);
width: min(100%, 980px);
border: 1px solid rgba(185, 170, 134, .78);
border-radius: 4px;
background:
@@ -1283,6 +1283,16 @@ dialog.mengsan-deck-dialog-shuying::backdrop { background: #080706b8; }
.player.mengsan-hide-identity-shuying > .seat {
display: none !important;
}
/* 十周年 UI 侧边按钮只在梦三战斗期间隐藏,不影响其他模式。 */
body.mengsan-battle-ui-shuying :is(
.jiluButton_new, .jiluButton_new1,
.huanfuButton_new, .huanfuButton_new1,
.meiguiButton_new, .meiguiButton_new1,
.xiaolianButton_new, .xiaolianButton_new1
) {
display: none !important;
pointer-events: none !important;
}
.player > .mengsan-energy-shuying {
position: absolute !important;
top: -22px !important;
@@ -1326,7 +1336,9 @@ dialog.mengsan-deck-dialog-shuying::backdrop { background: #080706b8; }
}
.player > .mengsan-intent-shuying[hidden],
.mengsan-intent-damage-shuying[hidden],
.mengsan-intent-debuff-shuying[hidden] { display: none !important; }
.mengsan-intent-debuff-shuying[hidden],
.mengsan-intent-block-shuying[hidden],
.mengsan-intent-buff-shuying[hidden] { display: none !important; }
.mengsan-intent-damage-shuying {
display: inline-flex;
flex: none;
@@ -1336,7 +1348,9 @@ dialog.mengsan-deck-dialog-shuying::backdrop { background: #080706b8; }
font-size: 15px;
font-variant-numeric: tabular-nums;
}
.mengsan-intent-debuff-shuying {
.mengsan-intent-debuff-shuying,
.mengsan-intent-block-shuying,
.mengsan-intent-buff-shuying {
display: inline-flex;
flex: none;
align-items: center;
@@ -1344,6 +1358,8 @@ dialog.mengsan-deck-dialog-shuying::backdrop { background: #080706b8; }
color: #d8aee5;
font-size: 11px;
}
.mengsan-intent-block-shuying { color: #b8daf5; }
.mengsan-intent-buff-shuying { color: #f5d6a5; }
.mengsan-intent-icon-shuying {
display: block;
width: 22px;
+1
View File
@@ -12,6 +12,7 @@ EXCLUDE_DIRS = {
".gitea",
".github",
".update_tmp",
".planning",
"dist",
"tools",
"__pycache__",