fixed # 修复手机端地图滑动异常的问题
fixed # 重构更新框架,支持选择是否下载梦三模式 fixed # 补充部分意图的资源图片与逻辑
|
After Width: | Height: | Size: 5.4 KiB |
|
After Width: | Height: | Size: 8.0 KiB |
|
After Width: | Height: | Size: 7.5 KiB |
|
After Width: | Height: | Size: 9.3 KiB |
|
After Width: | Height: | Size: 7.3 KiB |
|
After Width: | Height: | Size: 4.9 KiB |
|
After Width: | Height: | Size: 8.8 KiB |
|
After Width: | Height: | Size: 9.8 KiB |
|
After Width: | Height: | Size: 13 KiB |
|
After Width: | Height: | Size: 10 KiB |
|
After Width: | Height: | Size: 6.3 KiB |
@@ -6,7 +6,7 @@ const mengsanConfig = {
|
||||
saveKey: "mengsan_run_shuying",
|
||||
profileKey: "mengsan_profile_shuying",
|
||||
schemaVersion: 1,
|
||||
mapLayoutVersion: 7,
|
||||
mapLayoutVersion: 8,
|
||||
routeCount: 4,
|
||||
characters: ["mengsan_liubei_shuying"],
|
||||
acts: contentRegistry.acts,
|
||||
@@ -16,6 +16,7 @@ const mengsanConfig = {
|
||||
elite: "精英",
|
||||
event: "事件",
|
||||
story: "剧情",
|
||||
chest: "宝箱",
|
||||
rest: "休息",
|
||||
shop: "商店",
|
||||
boss: "Boss",
|
||||
|
||||
@@ -1,9 +1,17 @@
|
||||
const act1Map = {
|
||||
id: "act1",
|
||||
name: "第一关·初梦",
|
||||
floorNodes: [1, 4, 4, 4, 1],
|
||||
minFloorNodes: [1, 3, 3, 3, 1],
|
||||
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,
|
||||
|
||||
@@ -1,4 +1,9 @@
|
||||
export const rewardPools = {
|
||||
"act1.pool.chest": [
|
||||
"shared.reward.item.handCharm",
|
||||
"shared.reward.maxHp",
|
||||
"shared.reward.support.scout",
|
||||
],
|
||||
"act1.pool.story.scoutGift": [
|
||||
"shared.reward.support.scout",
|
||||
"shared.reward.card.sha",
|
||||
|
||||
@@ -10,6 +10,8 @@ const iconPaths = {
|
||||
elite: "M16 3l10 5v9c0 6-10 12-10 12S6 23 6 17V8zM10 13l6 4 6-4M16 17v7M11 5l5-3 5 3",
|
||||
event: "M10 10c0-9 15-8 15 0 0 5-9 5-9 10M16 25v1",
|
||||
story: "M8 5h18v20H10c-5 0-5-6 0-6h16M8 5c-5 0-5 6 0 6h3V5M12 10h10M12 14h8",
|
||||
chest: "M4 13h24v15H4zM2 8h28v6H2zM16 8v20" +
|
||||
"M9 8c-4-3-1-7 3-5l4 5M23 8c4-3 1-7-3-5l-4 5",
|
||||
rest: "M3 26L16 5l13 21zM10 26l6-11 6 11M16 5V2M7 28h19",
|
||||
shop: "M4 12l3-7h18l3 7M4 12v4h24v-4M7 16v12h18V16M12 28v-8h8v8M3 28h26M11 6v6M21 6v6",
|
||||
boss: "M6 28V5M6 6c8-8 12 6 22-1v16c-10 7-14-7-22 1M11 10l11 7M22 10l-11 7M3 28h9",
|
||||
@@ -60,6 +62,7 @@ const nodeColor = {
|
||||
elite: "#7e3f79",
|
||||
event: "#477aa6",
|
||||
story: "#8c6a34",
|
||||
chest: "#a27b35",
|
||||
rest: "#3f8560",
|
||||
shop: "#a27b35",
|
||||
boss: "#a62727",
|
||||
@@ -353,6 +356,7 @@ export const showMap = run => new Promise(resolve => {
|
||||
viewport.addEventListener("pointerdown", event => {
|
||||
if (!event.isPrimary || event.button !== 0) return;
|
||||
suppressClick = false;
|
||||
if (event.pointerType !== "mouse") return;
|
||||
drag = { id: event.pointerId, y: event.clientY, top: viewport.scrollTop, moved: false };
|
||||
});
|
||||
viewport.addEventListener("pointermove", event => {
|
||||
@@ -386,6 +390,10 @@ export const showMap = run => new Promise(resolve => {
|
||||
}
|
||||
}, true);
|
||||
const map = ui.create.div(".mengsan-map-shuying", viewport);
|
||||
const floorCount = Math.max(...run.map.nodes.map(node =>
|
||||
Math.floor(node.floor))) + 1;
|
||||
map.style.setProperty("--mengsan-map-min-height",
|
||||
`${floorCount * 100}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");
|
||||
|
||||
@@ -4,6 +4,21 @@ import { buildBattlePlan, consumeSupports, createBattleDirector, grantSupport }
|
||||
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 {
|
||||
beginDeathBlow,
|
||||
finishDeathBlow,
|
||||
hasPendingDeathBlow,
|
||||
isDeathBlowIntent,
|
||||
} from "./runtime/death-blow.js";
|
||||
import {
|
||||
STUN_INTENT,
|
||||
applyStun,
|
||||
clearStun,
|
||||
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";
|
||||
@@ -172,6 +187,13 @@ const finishUtilityNode = async (run, node) => {
|
||||
]);
|
||||
applyReward(run, choice);
|
||||
}
|
||||
else if (node.type == "chest") {
|
||||
await chooseReward(run, {
|
||||
rewardPool: config.acts[run.actIndex].chestRewardPool ||
|
||||
"shared.pool.boss.premium",
|
||||
rewardTitle: "宝箱奖励(三选一)",
|
||||
});
|
||||
}
|
||||
else if (node.type == "shop") {
|
||||
const price = 20;
|
||||
const choice = await chooseButtons("商店 Demo", [
|
||||
@@ -186,12 +208,18 @@ const finishUtilityNode = async (run, node) => {
|
||||
}
|
||||
}
|
||||
else {
|
||||
const choice = await chooseButtons("随机事件", [
|
||||
{ id: "branch", name: "调查异象", description: "在当前路线后插入一个额外剧情战斗节点" },
|
||||
const choices = [
|
||||
{ id: "gold", name: "收下钱袋", description: "获得 15 金币" },
|
||||
]);
|
||||
];
|
||||
if (run.actIndex !== 0) {
|
||||
choices.unshift({
|
||||
id: "branch", name: "调查异象",
|
||||
description: "在当前路线后插入一个额外剧情战斗节点",
|
||||
});
|
||||
}
|
||||
const choice = await chooseButtons("随机事件", choices);
|
||||
if (choice == "branch") insertStoryNode(run, node.id);
|
||||
else {
|
||||
else if (choice == "gold") {
|
||||
run.player.gold += 15;
|
||||
run.statistics.goldEarned += 15;
|
||||
}
|
||||
@@ -286,10 +314,17 @@ const executeFlyconidIntent = async 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) {
|
||||
const hit = target.damage(move.damage, player);
|
||||
hit.mengsanAttack_shuying = true;
|
||||
await hit;
|
||||
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);
|
||||
};
|
||||
@@ -316,7 +351,8 @@ const createScenario = (plan, current) => {
|
||||
installMonsterPile(player, monster, current);
|
||||
if (monster) {
|
||||
current.energyUI.set(player, mountEnergy(player, current.session));
|
||||
current.intentUI.set(player, mountEnemyIntent(player, current.session, STYLE_PATH, document));
|
||||
current.intentUI.set(player, mountEnemyIntent(player,
|
||||
current.session, STYLE_PATH, document, () => game.me));
|
||||
}
|
||||
for (const participant of [...game.players, ...game.dead]) participant.setSeatNum(Number(participant.dataset.position) + 1);
|
||||
await equipBattleUnit(player, spec, current.resources);
|
||||
@@ -398,7 +434,9 @@ const prepareBattle = async (run, node, encounter, session, resources) => {
|
||||
const monsters = plan.units.map((spec, index) => initBattleUnit(participants[index], spec));
|
||||
plan.units.forEach((spec, index) => installMonsterPile(participants[index], monsters[index], currentBattle));
|
||||
participants.forEach((player, index) => {
|
||||
if (monsters[index]) currentBattle.intentUI.set(player, mountEnemyIntent(player, session, STYLE_PATH, document));
|
||||
if (monsters[index]) currentBattle.intentUI.set(player,
|
||||
mountEnemyIntent(player, session, STYLE_PATH, document,
|
||||
() => game.me));
|
||||
});
|
||||
activeBattle.director = createScenario(plan, currentBattle);
|
||||
activeBattle.director.bind("player", me);
|
||||
@@ -418,7 +456,15 @@ const prepareBattle = async (run, node, encounter, session, resources) => {
|
||||
run.player.permanentSkills.forEach(skill => {
|
||||
if (lib.skill[skill] && !me.hasSkill(skill)) me.addSkill(skill);
|
||||
});
|
||||
for (const skill of ["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"]) {
|
||||
for (const skill of [
|
||||
"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_death_blow_finish_shuying",
|
||||
"mengsan_stun_skip_shuying", "mengsan_stun_recover_shuying",
|
||||
"mengsan_stun_clear_shuying",
|
||||
]) {
|
||||
if (!lib.skill.global.includes(skill)) {
|
||||
game.addGlobalSkill(skill);
|
||||
session.ownResource({}, () => game.removeGlobalSkill(skill));
|
||||
@@ -552,7 +598,10 @@ const routeRun = async (run, selected = null, supplied = false) => {
|
||||
const node = supplied ? selected : await showMap(run);
|
||||
supplied = false;
|
||||
if (!node) { await openModeSelection(); return; }
|
||||
if (["event", "rest", "shop"].includes(node.type)) { await finishUtilityNode(run, node); continue; }
|
||||
if (["event", "rest", "shop", "chest"].includes(node.type)) {
|
||||
await finishUtilityNode(run, node);
|
||||
continue;
|
||||
}
|
||||
if (node.type === "story" && config.nodeContents?.[node.contentId]?.kind === "dialogue") {
|
||||
if (await finishStoryNode(run, node)) continue;
|
||||
return;
|
||||
@@ -650,6 +699,42 @@ const createMode = identityMode => {
|
||||
update(intent);
|
||||
return true;
|
||||
},
|
||||
mengsanStunEnemy_shuying(player, options = {}) {
|
||||
const update = activeBattle?.intentUI.get(player);
|
||||
if (!activeBattle?.session.active || !update ||
|
||||
!player?.isAlive() ||
|
||||
player.storage?.mengsanCamp_shuying !== "enemy") {
|
||||
return false;
|
||||
}
|
||||
if (isStunned(player)) return false;
|
||||
const { resume = "advance", recover } = options;
|
||||
const flyconid = isFlyconid(player);
|
||||
const intent = options.intent ?? (flyconid ?
|
||||
player.storage.mengsanFlyconidIntent_shuying : null);
|
||||
const restore = recover || (flyconid ?
|
||||
(choice, original) => {
|
||||
const state =
|
||||
player.storage.mengsanFlyconidState_shuying || {};
|
||||
player.storage.mengsanFlyconidState_shuying =
|
||||
recordFlyconidAction(state, null, false);
|
||||
const next = choice === "retry" ? original : null;
|
||||
player.storage.mengsanFlyconidIntent_shuying = next;
|
||||
return next;
|
||||
} : null);
|
||||
const onRecover = async (choice, original) => {
|
||||
const next = await restore(choice, original);
|
||||
update(next === undefined ?
|
||||
(choice === "retry" ? original : null) : next);
|
||||
};
|
||||
if (typeof restore !== "function") {
|
||||
throw new TypeError("此怪物需要击晕恢复回调");
|
||||
}
|
||||
if (!applyStun(player, intent, { resume, recover: onRecover })) {
|
||||
return false;
|
||||
}
|
||||
update(STUN_INTENT);
|
||||
return true;
|
||||
},
|
||||
async mengsanPersistRun_shuying(run) {
|
||||
await saveRun(run);
|
||||
},
|
||||
@@ -720,14 +805,60 @@ const createMode = identityMode => {
|
||||
trigger: { player: "phaseDrawBegin2" },
|
||||
forced: true, silent: true, popup: false,
|
||||
filter(event, player) { return Boolean(player.storage?.mengsanMonster_shuying); },
|
||||
async content(event, trigger, player) { trigger.num = player.storage.mengsanMonster_shuying.draw; },
|
||||
async content(event, trigger, player) {
|
||||
trigger.num = isStunned(player) ? 0 :
|
||||
player.storage.mengsanMonster_shuying.draw;
|
||||
},
|
||||
},
|
||||
mengsan_flyconid_action_shuying: {
|
||||
trigger: { global: "phaseUseBefore" },
|
||||
forced: true, silent: true, popup: false, priority: 100,
|
||||
filter(event) { return Boolean(activeBattle?.session.active && isFlyconid(event.player) && event.player.storage.mengsanFlyconidIntent_shuying); },
|
||||
filter(event) { return Boolean(activeBattle?.session.active &&
|
||||
!isStunned(event.player) && isFlyconid(event.player) &&
|
||||
event.player.storage.mengsanFlyconidIntent_shuying); },
|
||||
async content(event, trigger) { await executeFlyconidIntent(trigger.player); },
|
||||
},
|
||||
mengsan_stun_skip_shuying: {
|
||||
trigger: { global: "phaseUseBefore" },
|
||||
forced: true, silent: true, popup: false, priority: 200,
|
||||
filter(event) {
|
||||
return Boolean(activeBattle?.session.active &&
|
||||
isStunned(event.player));
|
||||
},
|
||||
async content(event, trigger) {
|
||||
skipStunnedAction(trigger.player, trigger);
|
||||
},
|
||||
},
|
||||
mengsan_stun_recover_shuying: {
|
||||
trigger: { global: "phaseAfter" },
|
||||
forced: true, silent: true, popup: false, priority: 200,
|
||||
filter(event) {
|
||||
return Boolean(activeBattle?.session.active &&
|
||||
isStunned(event.player));
|
||||
},
|
||||
async content(event, trigger) {
|
||||
await finishStunnedTurn(trigger.player);
|
||||
},
|
||||
},
|
||||
mengsan_stun_clear_shuying: {
|
||||
trigger: { global: "dieAfter" },
|
||||
forced: true, silent: true, popup: false,
|
||||
filter(event) { return isStunned(event.player); },
|
||||
async content(event, trigger) {
|
||||
clearStun(trigger.player);
|
||||
},
|
||||
},
|
||||
mengsan_death_blow_finish_shuying: {
|
||||
trigger: { global: "phaseUseAfter" },
|
||||
forced: true, silent: true, popup: false,
|
||||
filter(event) {
|
||||
return Boolean(activeBattle?.session.active &&
|
||||
hasPendingDeathBlow(event.player));
|
||||
},
|
||||
async content(event, trigger) {
|
||||
await finishDeathBlow(trigger.player);
|
||||
},
|
||||
},
|
||||
mengsan_frail_shuying: {
|
||||
mark: true, marktext: "脆",
|
||||
onremove(player) { delete player.storage.mengsanFrail_shuying; },
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -1,6 +1,12 @@
|
||||
// 只呈现已经选定的预测技能;不从手牌或原生技能猜测意图。
|
||||
import { attackHitCount, previewAttackDamage } from "./intent-damage.js";
|
||||
import { isDeathBlowIntent } from "./death-blow.js";
|
||||
import { isStunIntent } from "./stun-intent.js";
|
||||
|
||||
export function attackIconForDamage(damage) {
|
||||
if (!Number.isInteger(damage) || damage < 0) throw new RangeError("预测伤害必须是非负整数");
|
||||
if (!Number.isInteger(damage) || damage < 0) {
|
||||
throw new RangeError("预测伤害必须是非负整数");
|
||||
}
|
||||
if (damage < 5) return 1;
|
||||
if (damage < 10) return 2;
|
||||
if (damage < 20) return 3;
|
||||
@@ -8,56 +14,111 @@ export function attackIconForDamage(damage) {
|
||||
return 5;
|
||||
}
|
||||
|
||||
export function mountEnemyIntent(player, session, assetBase, document) {
|
||||
export function mountEnemyIntent(player, session, assetBase, document,
|
||||
getTarget = () => null) {
|
||||
const badge = document.createElement("div");
|
||||
badge.className = "mengsan-intent-shuying";
|
||||
badge.hidden = true;
|
||||
badge.setAttribute("aria-live", "polite");
|
||||
const name = document.createElement("span");
|
||||
name.className = "mengsan-intent-name-shuying";
|
||||
badge.setAttribute("aria-atomic", "true");
|
||||
const damageLine = document.createElement("span");
|
||||
damageLine.className = "mengsan-intent-damage-shuying";
|
||||
const amount = document.createElement("span");
|
||||
amount.className = "mengsan-intent-amount-shuying";
|
||||
const icon = document.createElement("img");
|
||||
icon.className = "mengsan-intent-icon-shuying";
|
||||
icon.alt = "预计伤害";
|
||||
icon.alt = "Attack";
|
||||
icon.decoding = "async";
|
||||
damageLine.append(amount, icon);
|
||||
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 = "预计施加负面状态";
|
||||
debuffIcon.alt = "Debuff";
|
||||
debuffIcon.decoding = "async";
|
||||
debuffIcon.src = `${assetBase}/assets/intent/Intent_debuff.webp`;
|
||||
const debuffAmount = document.createElement("span");
|
||||
debuffLine.append(debuffIcon, debuffAmount);
|
||||
badge.append(name, damageLine, debuffLine);
|
||||
badge.append(damageLine, debuffLine);
|
||||
player.appendChild(badge);
|
||||
session.ownResource(badge, () => badge.remove());
|
||||
return intent => {
|
||||
let currentIntent = null;
|
||||
let timer;
|
||||
session.ownResource(badge, () => {
|
||||
clearInterval(timer);
|
||||
badge.remove();
|
||||
});
|
||||
const refresh = () => {
|
||||
const intent = currentIntent;
|
||||
if (!intent) {
|
||||
badge.hidden = true;
|
||||
badge.removeAttribute("title");
|
||||
return;
|
||||
}
|
||||
if (typeof intent.name !== "string" || !intent.name.trim()) throw new TypeError("预测技能必须有名称");
|
||||
name.textContent = intent.name;
|
||||
const hasDamage = intent.damage != null;
|
||||
damageLine.hidden = !hasDamage;
|
||||
const hasDebuff = typeof intent.debuff === "string" && Number.isInteger(intent.stacks) && intent.stacks > 0;
|
||||
debuffLine.hidden = !hasDebuff;
|
||||
if (hasDebuff) debuffAmount.textContent = `${intent.debuff}×${intent.stacks}`;
|
||||
if (hasDamage) {
|
||||
const tier = attackIconForDamage(intent.damage);
|
||||
amount.textContent = String(intent.damage);
|
||||
const src = `${assetBase}/assets/intent/Intent_attack_${tier}.webp`;
|
||||
if (isStunIntent(intent)) {
|
||||
damageLine.hidden = false;
|
||||
debuffLine.hidden = true;
|
||||
amount.textContent = "";
|
||||
const src = `${assetBase}/assets/intent/Intent_stun.png`;
|
||||
if (icon.getAttribute("src") !== src) icon.src = src;
|
||||
badge.title = `${intent.name}:预计造成 ${intent.damage} 点伤害(非最终伤害)${hasDebuff ? `,施加${intent.stacks}层${intent.debuff}` : ""}`;
|
||||
icon.alt = "Stunned";
|
||||
if (badge.getAttribute("aria-label") !== "Stunned") {
|
||||
badge.setAttribute("aria-label", "Stunned");
|
||||
}
|
||||
badge.hidden = false;
|
||||
return;
|
||||
}
|
||||
const hasDamage = intent.damage != null;
|
||||
const deathBlow = isDeathBlowIntent(intent);
|
||||
if (deathBlow && !hasDamage) {
|
||||
throw new TypeError("濒死一击必须有伤害数值");
|
||||
}
|
||||
damageLine.hidden = !hasDamage;
|
||||
const hasDebuff = typeof intent.debuff === "string" &&
|
||||
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}`;
|
||||
if (debuffAmount.textContent !== debuffText) {
|
||||
debuffAmount.textContent = debuffText;
|
||||
}
|
||||
}
|
||||
if (hasDamage) {
|
||||
const hits = attackHitCount(intent);
|
||||
if (deathBlow && hits !== 1) {
|
||||
throw new RangeError("濒死一击只能造成一次伤害");
|
||||
}
|
||||
const damage = previewAttackDamage(intent, player, getTarget());
|
||||
const tier = deathBlow ? null :
|
||||
attackIconForDamage(damage * hits);
|
||||
const damageText = hits > 1 ? `${damage}×${hits}` :
|
||||
String(damage);
|
||||
if (amount.textContent !== damageText) {
|
||||
amount.textContent = damageText;
|
||||
}
|
||||
const src = deathBlow ?
|
||||
`${assetBase}/assets/intent/Intent_death_blow.png` :
|
||||
`${assetBase}/assets/intent/Intent_attack_${tier}.webp`;
|
||||
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 {
|
||||
badge.title = hasDebuff ? `${intent.name}:施加${intent.stacks}层${intent.debuff}` : intent.name;
|
||||
const label = debuffAmount.textContent;
|
||||
if (badge.getAttribute("aria-label") !== label) {
|
||||
badge.setAttribute("aria-label", label);
|
||||
}
|
||||
}
|
||||
badge.hidden = false;
|
||||
};
|
||||
timer = setInterval(refresh, 100);
|
||||
return intent => {
|
||||
currentIntent = intent;
|
||||
refresh();
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -110,6 +110,9 @@ export const generateActMap = (run, actIndex) => {
|
||||
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)}`;
|
||||
|
||||
@@ -188,7 +188,7 @@
|
||||
overflow-y: auto;
|
||||
overscroll-behavior: contain;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
touch-action: none;
|
||||
touch-action: pan-y;
|
||||
cursor: grab;
|
||||
user-select: none;
|
||||
scrollbar-width: none;
|
||||
@@ -198,7 +198,8 @@
|
||||
position: relative;
|
||||
display: block !important;
|
||||
width: min(100%, 860px);
|
||||
height: max(1080px, 160vh) !important;
|
||||
height: max(1080px, 160vh,
|
||||
var(--mengsan-map-min-height, 1080px)) !important;
|
||||
margin: 0 auto;
|
||||
overflow: hidden;
|
||||
isolation: isolate;
|
||||
@@ -520,7 +521,6 @@
|
||||
|
||||
.mengsan-map-viewport-shuying::-webkit-scrollbar { display: none; width: 0; height: 0; }
|
||||
.mengsan-map-viewport-shuying.dragging, .mengsan-map-viewport-shuying.dragging * { cursor: grabbing !important; }
|
||||
.mengsan-map-shuying .mengsan-node-shuying { touch-action: none; }
|
||||
/* 梦三行军图视觉系统:深墨舞台、宣纸路线、朱砂交互、铜金层级 */
|
||||
.mengsan-map-overlay-shuying {
|
||||
--ms-ink-950: #111713;
|
||||
@@ -1301,7 +1301,7 @@ dialog.mengsan-deck-dialog-shuying::backdrop { background: #080706b8; }
|
||||
text-align: center;
|
||||
pointer-events: none;
|
||||
}
|
||||
/* The selected skill appears below an enemy's frame; number precedes the weapon icon. */
|
||||
/* The selected intent appears below an enemy's frame. */
|
||||
.player > .mengsan-intent-shuying {
|
||||
position: absolute !important;
|
||||
bottom: -31px !important;
|
||||
@@ -1327,10 +1327,6 @@ 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-name-shuying {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
.mengsan-intent-damage-shuying {
|
||||
display: inline-flex;
|
||||
flex: none;
|
||||
|
||||