diff --git a/.gitignore b/.gitignore index eadf95c..d687722 100644 --- a/.gitignore +++ b/.gitignore @@ -10,4 +10,7 @@ tools/test_update.mjs tools/test_intent.mjs /mengsan/.planning/ -/mengsan/verification \ No newline at end of file +/mengsan/verification +/mengsan/assets/sts2/ + +/Slay the Spire 2 \ No newline at end of file diff --git a/mengsan/assets/raider-assassin-portrait.png b/mengsan/assets/raider-assassin-portrait.png new file mode 100644 index 0000000..5db828e Binary files /dev/null and b/mengsan/assets/raider-assassin-portrait.png differ diff --git a/mengsan/assets/raider-axe-portrait.png b/mengsan/assets/raider-axe-portrait.png new file mode 100644 index 0000000..140ec8d Binary files /dev/null and b/mengsan/assets/raider-axe-portrait.png differ diff --git a/mengsan/assets/raider-brute-portrait.png b/mengsan/assets/raider-brute-portrait.png new file mode 100644 index 0000000..517c9d5 Binary files /dev/null and b/mengsan/assets/raider-brute-portrait.png differ diff --git a/mengsan/assets/raider-crossbow-portrait.png b/mengsan/assets/raider-crossbow-portrait.png new file mode 100644 index 0000000..c77c43b Binary files /dev/null and b/mengsan/assets/raider-crossbow-portrait.png differ diff --git a/mengsan/assets/raider-tracker-portrait.png b/mengsan/assets/raider-tracker-portrait.png new file mode 100644 index 0000000..f9985e0 Binary files /dev/null and b/mengsan/assets/raider-tracker-portrait.png differ diff --git a/mengsan/content/acts/act1/map.js b/mengsan/content/acts/act1/map.js index 3382917..aeca069 100644 --- a/mengsan/content/acts/act1/map.js +++ b/mengsan/content/acts/act1/map.js @@ -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, diff --git a/mengsan/content/acts/act1/raider-trio.js b/mengsan/content/acts/act1/raider-trio.js new file mode 100644 index 0000000..5bcb6a1 --- /dev/null +++ b/mengsan/content/acts/act1/raider-trio.js @@ -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: [], + }; +} diff --git a/mengsan/content/scenario-characters.js b/mengsan/content/scenario-characters.js index 3fd86f6..f914be3 100644 --- a/mengsan/content/scenario-characters.js +++ b/mengsan/content/scenario-characters.js @@ -8,6 +8,16 @@ export function createScenarioCharacters(Character) { 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" }), + mengsan_raider_brute_shuying: new Character({ sex: "none", group: "qun", hp: 32, maxHp: 32, + skills: [], img: "extension/术樱包/mengsan/assets/raider-brute-portrait.png" }), + mengsan_raider_assassin_shuying: new Character({ sex: "none", group: "qun", hp: 21, maxHp: 21, + skills: [], img: "extension/术樱包/mengsan/assets/raider-assassin-portrait.png" }), + mengsan_raider_axe_shuying: new Character({ sex: "none", group: "qun", hp: 21, maxHp: 21, + skills: [], img: "extension/术樱包/mengsan/assets/raider-axe-portrait.png" }), + mengsan_raider_crossbow_shuying: new Character({ sex: "none", group: "qun", hp: 20, maxHp: 20, + skills: [], img: "extension/术樱包/mengsan/assets/raider-crossbow-portrait.png" }), + mengsan_raider_tracker_shuying: new Character({ sex: "none", group: "qun", hp: 23, maxHp: 23, + skills: [], img: "extension/术樱包/mengsan/assets/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: "苏|双-张|世平" }), }; @@ -16,5 +26,10 @@ 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: "苏双&张世平", }; diff --git a/mengsan/map/renderer.js b/mengsan/map/renderer.js index 3c61682..b6cf5db 100644 --- a/mengsan/map/renderer.js +++ b/mengsan/map/renderer.js @@ -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"); diff --git a/mengsan/mode.js b/mengsan/mode.js index 7aad497..574497a 100644 --- a/mengsan/mode.js +++ b/mengsan/mode.js @@ -20,6 +20,7 @@ import { skipStunnedAction, } from "./runtime/stun-intent.js"; import { selectFlyconidMove, recordFlyconidAction } from "./runtime/flyconid-intent.js"; +import { isRaiderCharacter, selectRaiderMove, recordRaiderAction } from "./runtime/raider-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"; @@ -287,20 +288,39 @@ const mountEnergy = (player, session) => { 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 isRaider = player => player?.storage?.mengsanCamp_shuying === "enemy" && isRaiderCharacter(player.name); +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 applyFlyconidDebuff = (target, move) => { +const applyIntentDebuff = (target, move) => { if (!move.debuff) return; - const frail = move.id === "frail"; + 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; @@ -318,15 +338,33 @@ const executeFlyconidIntent = async player => { 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; - } + 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--; + activeBattle?.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() || !activeBattle?.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"); } - if (target.isAlive() && player.isAlive()) applyFlyconidDebuff(target, move); }; const equipBattleUnit = async (player, spec, resources) => { for (const info of spec.equipment || []) { @@ -358,7 +396,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 +438,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); @@ -460,7 +500,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 +537,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 +628,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 +752,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 +765,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 +868,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 +1061,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: "易伤", diff --git a/mengsan/runtime/intent-display.js b/mengsan/runtime/intent-display.js index 472baaa..e2193e6 100644 --- a/mengsan/runtime/intent-display.js +++ b/mengsan/runtime/intent-display.js @@ -39,7 +39,25 @@ export function mountEnemyIntent(player, session, assetBase, document, 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 = "Block"; + 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 = "Buff"; + 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,6 +74,8 @@ 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; @@ -76,13 +96,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 label = intent.debuff === "脆弱" ? "Frail" : + intent.debuff === "易伤" ? "Vulnerable" : "Debuff"; const debuffText = `${label} ×${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) { @@ -101,19 +128,13 @@ export function mountEnemyIntent(player, session, assetBase, document, `${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) effects.push(debuffAmount.textContent); + if (hasBlock) effects.push(`Block ${intent.block}`); + if (hasStrength) effects.push(`Strength +${intent.strength}`); + const label = effects.join(", ") || intent.name || "Intent"; + if (badge.getAttribute("aria-label") !== label) badge.setAttribute("aria-label", label); badge.hidden = false; }; timer = setInterval(refresh, 100); diff --git a/mengsan/runtime/monster.js b/mengsan/runtime/monster.js index 1a77645..6e090df 100644 --- a/mengsan/runtime/monster.js +++ b/mengsan/runtime/monster.js @@ -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; diff --git a/mengsan/runtime/raider-intent.js b/mengsan/runtime/raider-intent.js new file mode 100644 index 0000000..096e02f --- /dev/null +++ b/mengsan/runtime/raider-intent.js @@ -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 }; +} diff --git a/mengsan/state.js b/mengsan/state.js index aae143c..1f6a21a 100644 --- a/mengsan/state.js +++ b/mengsan/state.js @@ -1,5 +1,6 @@ import { createStartingDeck } from "./content/card-library.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, diff --git a/mengsan/style.css b/mengsan/style.css index f63ed4f..5b11761 100644 --- a/mengsan/style.css +++ b/mengsan/style.css @@ -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; diff --git a/mengsan/tools/crop_godot_atlas_tres.ps1 b/mengsan/tools/crop_godot_atlas_tres.ps1 new file mode 100644 index 0000000..5ed8b7a --- /dev/null +++ b/mengsan/tools/crop_godot_atlas_tres.ps1 @@ -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 }