5 Commits

Author SHA1 Message Date
panFD
be6af4d157 chore(map): lower inter-wave countdown duration to 1s
调整节间过场倒计时的固定时长,从2.5秒改为1秒,加快关卡节间推进节奏
2026-09-13 11:50:12 +08:00
panFD
1754e0d408 fix(map,hero): 完善怪物入场排队逻辑
1. 给怪物刷出生成方法添加rank参数,用于计算出生位置
2. 新增怪物入场归位逻辑,替换原有的闲置状态处理
3. 更新注释描述,修正准备阶段怪物行为说明
2026-09-13 11:50:05 +08:00
panFD
d3e7438bca feat: 新增预设库管理与一键创建英雄向导
1.  新增服务端模板持久化接口,支持触发/光环预设的读写
2.  前端新增预设库管理页面,支持增删改查触发/光环预设
3.  新增英雄创建向导,支持按职业快速生成英雄并套用预设
4.  调整本地开发端口与代理地址
5.  补充职业默认配置与攻速映射等常量定义
2026-09-13 11:43:18 +08:00
panFD
d86f54e7a4 feat(game): add elite monster kill statistics and related fields
1. add is_elite field to HeroAttrsComp for elite monster mark
2. set elite monster flag when spawning mission monsters
3. implement monster kill counting logic with priority: Boss > elite > normal monsters
4. add kill count statistics for different monster types for settlement calculation
2026-09-13 10:29:23 +08:00
panFD
da2caa6576 refactor(mission): 重构关卡结算与刷怪统计逻辑
1. 新增整局跨回合刷怪统计与重置能力,替换原小节统计
2. 修改奖励配置为整局5回合累计结算逻辑
3. 调整失败奖励判定为整局维度统计
4. 重构胜利/失败奖励计算逻辑,适配多回合关卡
2026-09-13 10:28:55 +08:00
15 changed files with 1137 additions and 77 deletions

View File

@@ -219,38 +219,40 @@ export function getLvColor(lv: number): Color {
export const RARITY_BG_COLORS: string[] = ["green", "blue", "purple", "yellow", "red"];
/**
* 战斗结算奖励配置(每节结束后发放:英雄碎片 + 金币 + 天赋经验)。
* 胜利奖励随关卡进度(怪物等级 = 大关号)线性增长,整体乘以 victory_multiplier。
* 战斗结算奖励配置(一关 5 回合累计,整关结算时一次性发放:英雄碎片 + 金币 + 天赋经验)。
* 通关奖励 = 每回合奖励 × 胜利回合数(wave_win_count),全员存活回合再 × per_wave 加成。
* Why: 一关一局 5 回合后,通关不再是单场,改按"5 回合累计"发放,
* 每回合基础奖励随关卡进度(怪物等级 = 大关号)增长。
*/
export const VICTORY_REWARD_SET = {
/** 金币:base + monLv × per_lv */
/** 金币:每回合 base + monLv × per_lv,× 胜利回合数累计 */
gold: { base: 100, per_lv: 50 },
/** 天赋经验:base + monLv × per_lv */
/** 天赋经验:每回合 base + monLv × per_lv,× 胜利回合数累计 */
talent_exp: { base: 20, per_lv: 10 },
/** 每个出场英雄保底碎片数 */
/** 每个出场英雄保底碎片数(× 胜利回合数累计) */
played_fragment: 1,
/** 随机英雄碎片数量(发放给随机抽中的 1 个英雄) */
/** 随机英雄碎片数量(发放给随机抽中的 1 个英雄,× 胜利回合数累计) */
random_fragment: 2,
/** 银币:固定发放,不分输赢 */
/** 银币:固定发放,不分输赢(通关时 × 胜利回合数累计) */
silver: 3,
/** 钻石:按输赢在 [min, max] 闭区间内随机(不参与 victory_multiplier) */
/** 钻石:按输赢在 [min, max] 闭区间内随机(通关时 × 胜利回合数累计,不参与 per_wave 加成) */
diamond: {
victory: { min: 5, max: 10 },
defeat: { min: 1, max: 3 },
},
/** 胜利加成系数(失败 1.0) */
victory_multiplier: 1.2,
/** 全员存活回合加成系数:每个全员存活回合额外 +20%(乘法叠乘于累计基础之上) */
per_wave_bonus: 0.2,
/**
* 失败奖励限制(不影响胜利):
* 坚持 < defeat_time_threshold 秒或本节一只怪未杀 → 无任何奖励;
* 达标后 = 坚持奖励(固定)+ 击杀比例奖励(基数 × 本节击杀比例)
* 坚持 < defeat_time_threshold 秒或本回合一只怪未杀 → 无任何奖励;
* 达标后 = 坚持奖励(固定)+ 击杀比例奖励(基数 × 本回合击杀比例)
*/
defeat: {
/** 坚持时间门槛(秒) */
time_threshold: 15,
/** 超门槛固定奖励("超 15 秒奖励") */
survive: { gold: 50, talent_exp: 10 },
/** 击杀比例奖励基数(本节怪物全灭时全额发放) */
/** 击杀比例奖励基数(本回合怪物全灭时全额发放) */
kill: { gold: 150, talent_exp: 30 },
},
};

View File

@@ -7,7 +7,7 @@ import { HeroAttrsComp } from "./HeroAttrsComp";
import { HeroViewComp } from "./HeroViewComp";
import { DamageQueueComp, DamageEvent } from "./DamageQueueComp";
import { smc } from "../common/SingletonModuleComp";
import { HeroInfo } from "../common/config/heroSet";
import { HeroInfo, MonType } from "../common/config/heroSet";
import { oops } from "db://oops-framework/core/Oops";
import { GameEvent } from "../common/config/GameEvent";
import { SkillTriggerType } from "../common/config/heroSet";
@@ -719,6 +719,8 @@ export class HeroAtkSystem extends ecs.ComblockSystem implements ecs.ISystemUpda
if (TAttrsComp.fac === FacSet.MON) {
// 怪物死亡处理
this.scheduleDrop(entity);
// 分类击杀统计:互斥优先级 Boss > 精英 > 近战/远程(供整局结算失败奖励判定)
this.countMonsterKill(TAttrsComp);
// oops.message 全局事件总线:怪物死亡(连杀计数等表现层消费)
oops.message.dispatchEvent(GameEvent.MonDead, {
isBoss: !!TAttrsComp.is_boss,
@@ -761,6 +763,38 @@ export class HeroAtkSystem extends ecs.ComblockSystem implements ecs.ISystemUpda
` ${TAttrsComp.hero_name} 死亡掉落金币 ${gold}(monType=${monType}, isBoss=${TAttrsComp.is_boss})`);
}
/**
* 怪物分类击杀统计(写入 smc.vmdata.scores,供整局结算的失败奖励判定)。
*
* 分类互斥,优先级 Boss > 精英 > 近战/远程:
* - Boss(is_boss) → boss_kill_count
* - 精英(is_elite) → elite_kill_count
* - 近战/重型/刺客 → melee_kill_count
* - 远程/辅助/召唤师(兜底) → remote_kill_count
*
* Why: 互斥计数保证 kills 之和不超过实际击杀数,与 MissionMonComp 的
* 整局已刷出总数(getTotalSpawnedCount)口径一致,避免击杀比例虚高。
*
* @param attrs 死亡怪物的属性组件
*/
private countMonsterKill(attrs: HeroAttrsComp): void {
const scores = smc.vmdata.scores;
if (attrs.is_boss) {
scores.boss_kill_count++;
return;
}
if (attrs.is_elite) {
scores.elite_kill_count++;
return;
}
const monType = HeroInfo[attrs.hero_uuid]?.monType ?? MonType.Melee;
if (monType === MonType.Melee || monType === MonType.Heavy || monType === MonType.Assassin) {
scores.melee_kill_count++;
} else {
scores.remote_kill_count++;
}
}
/**
* 延迟执行英雄死亡逻辑
*

View File

@@ -125,6 +125,8 @@ export class HeroAttrsComp extends ecs.Comp {
is_atking: boolean = false; // 是否正在攻击
is_stop: boolean = false; // 是否正在停止
is_boss: boolean = false;
/** 是否精英怪(刷怪时由 MissionMonComp 写入,驱动精英击杀统计) */
is_elite: boolean = false;
is_big_boss: boolean = false;
is_master: boolean = false;
is_friend: boolean = false;

View File

@@ -134,14 +134,14 @@ export class MoveSystem extends ecs.ComblockSystem implements ecs.ISystemUpdate
}
}
/** 准备阶段:不推进不索敌;英雄向编队槽位排队移动,怪物原地待命 */
/** 准备阶段:不推进不索敌;英雄向编队槽位排队移动,怪物向出生目标位归位 */
if (!smc.mission.in_fight) {
this.clearCombatTarget(model);
model.is_atking = false;
if (model.fac === FacSet.HERO) {
this.processHeroQueue(e, model, move, view);
} else {
view.status_change("idle");
this.processMonQueue(move, view, model);
}
return;
}
@@ -220,6 +220,42 @@ export class MoveSystem extends ecs.ComblockSystem implements ecs.ISystemUpdate
}
}
// ======================== 怪物入场归位(准备阶段) ========================
/**
* 怪物入场归位:从屏幕右边缘向 move.targetX(目标排锚点)移动,到位后 idle。
* 与英雄 processHeroQueue 对称,但怪物目标位在出生时由 MissionMonComp 一次性写入 targetX,无需动态重算槽位。
* 到位容差 2px,与英雄一致。
*/
private processMonQueue(move: MoveComp, view: HeroViewComp, model: HeroAttrsComp): void {
const targetX = move.targetX;
const selfX = view.node.position.x;
if (Math.abs(targetX - selfX) <= 2) {
if (Math.abs(selfX - targetX) > 0.01) {
view.node.setPosition(targetX, view.node.position.y, 0);
}
view.scale = -1;
view.status_change("idle");
return;
}
const dir = targetX > selfX ? 1 : -1;
move.direction = dir;
view.scale = dir;
const dist = Math.abs(targetX - selfX);
const speed = Math.max(model.speed / 3, dist / MoveSystem.QUEUE_ARRIVE_TIME);
const delta = speed * this.dt * dir;
let newX = selfX + delta;
if ((dir > 0 && newX > targetX) || (dir < 0 && newX < targetX)) {
newX = targetX;
}
if (Math.abs(newX - selfX) >= 0.01) {
view.node.setPosition(newX, view.node.position.y, 0);
view.status_change("move");
}
}
/** 查询当前英雄的编队槽位目标 X(无槽位返回 null) */
private resolveHeroQueueTargetX(entity: ecs.Entity): number | null {
const now = Date.now() / 1000;

View File

@@ -125,7 +125,7 @@ export class MissionComp extends CCComp {
/** 节间过场倒计时(秒):播放背景滚动 + 英雄归位,倒计时结束自动开下一节 */
private waveCountdown: number = 0;
/** 节间过场倒计时档位:统一时长(一关一局节间自动推进,节奏紧凑) */
private static readonly COUNTDOWN_TRANSITION = 2.5;
private static readonly COUNTDOWN_TRANSITION = 1;
/** 上一次显示的时间字符串(避免重复设置) */
private lastTimeStr: string = "";
/** 上一次显示的秒数(避免重复计算) */
@@ -629,11 +629,8 @@ export class MissionComp extends CCComp {
settle_title: this._settleTitle,
// 关卡难度度量(= 大关号,驱动胜利奖励随进度增长)
mon_lv: getMonsterLv(this.currentStage),
// 失败奖励判定数据:本关坚持时间 + 击杀统计(kills=已刷出-存活)
defeat_stats: {
clear_time: this.clearTime,
...this.querySectionKillStats(),
},
// 失败奖励判定数据:整局坚持时间 + 整局击杀统计(kills=跨回合累计击杀,total=整局已刷出)
defeat_stats: this.queryMissionKillStats(),
});
} else {
// play 已为 false(fight_end 强制结束)→ 直接清理
@@ -838,6 +835,8 @@ export class MissionComp extends CCComp {
// 重置所有的战局得分数据,防止上一局的数据污染
smc.resetScores();
// 重置整局刷怪统计(跨回合累计的已刷出总数),回合间不重置
ecs.query(ecs.allOf(MissionMonCompComp)).forEach(e => e.get(MissionMonCompComp).resetMissionStats());
smc.vmdata.mission_data.coin = Math.max(0, Math.floor(FightSet.INIT_COIN));
// 银币为局外持久货币,每局不重置,仅通过日常积攒,无需初始化
@@ -1023,14 +1022,24 @@ export class MissionComp extends CCComp {
}
/**
* 查询本节击杀统计(转交结算弹窗做失败奖励判定)
* @returns { kills, total },查询不到 MissionMonComp 时返回 0/0
* 查询整局(整关 5 回合)击杀统计与坚持时间,供结算弹窗做失败奖励判定。
* Why: 一关一局后,失败奖励按整局累计口径计算,不再只看当前回合——
* clear_time 用整关战斗总时长 fight_time(跨回合累计,整关重置一次);
* kills 用 scores 跨回合累计的分类击杀数之和(melee/remote/elite/boss);
* total 用整局已刷出怪物总数(MissionMonComp 跨回合累计)。
*/
private querySectionKillStats(): { kills: number; total: number } {
private queryMissionKillStats(): { clear_time: number; kills: number; total: number } {
const s = smc.vmdata.scores;
const kills = (s.melee_kill_count || 0) + (s.remote_kill_count || 0)
+ (s.elite_kill_count || 0) + (s.boss_kill_count || 0);
const entities = ecs.query(ecs.allOf(MissionMonCompComp));
const comp = entities[0]?.get(MissionMonCompComp);
if (!comp) return { kills: 0, total: 0 };
return comp.getSectionKillStats();
const total = comp ? comp.getTotalSpawnedCount() : kills;
return {
clear_time: smc.vmdata.mission_data.fight_time,
kills,
total: Math.max(total, kills),
};
}
/**

View File

@@ -85,6 +85,8 @@ export class MissionMonCompComp extends CCComp {
private sectionTargetCount: number = 0;
/** 当前小节已刷出怪物数量 */
private sectionSpawnedCount: number = 0;
/** 整局(整关 5 回合)累计刷出怪物总数:跨回合累计,仅在整关重置(由 MissionComp.data_init 触发) */
private missionSpawnedCount: number = 0;
/** 待刷出队列(batch=1 的怪物) */
private pendingQueue: GeneratedMonster[] = [];
/** 刷怪定时器已累积时间 */
@@ -170,14 +172,15 @@ export class MissionMonCompComp extends CCComp {
});
// 立即刷出 batch=0 的怪物(处理 delay 字段)
for (const mon of immediate) {
for (let i = 0; i < immediate.length; i++) {
const mon = immediate[i];
if (mon.delay && mon.delay > 0) {
// Boss 延迟刷出
this.scheduleOnce(() => {
this.spawnMonster(mon);
this.spawnMonster(mon, i);
}, mon.delay);
} else {
this.spawnMonster(mon);
this.spawnMonster(mon, i);
}
}
@@ -200,14 +203,15 @@ export class MissionMonCompComp extends CCComp {
/**
* 刷出单个怪物(统一入口)
* @param monData 怪物生成数据
* @param rank 编队优先级排序后的名次(0=前排,1=中排,≥2=后排);缺省回退到 spawnIndex
*/
private spawnMonster(monData: GeneratedMonster) {
private spawnMonster(monData: GeneratedMonster, rank?: number) {
const posIndex = monData.spawnIndex;
let mon = ecs.getEntity<Monster>(Monster);
let scale = -1;
const basePos = MissionMonCompComp.MON_POSITIONS[posIndex % MissionMonCompComp.MON_POSITIONS.length];
const landingX = this.resolveSpawnX(posIndex);
const landingX = this.resolveSpawnX(rank ?? posIndex);
const landingY = basePos.y;
// 出生点位于目标排右侧屏幕外(dropToY=landingY 无下落),落地即 moving=true 从右跑入
const spawnPos: Vec3 = v3(landingX + MissionMonCompComp.MON_SPAWN_OFFSET_X, landingY, 0);
@@ -238,9 +242,22 @@ export class MissionMonCompComp extends CCComp {
model.ap = monData.ap;
model.hp_max = monData.hp;
model.hp = model.hp_max;
// 精英标记:对象池复用实体需显式重置(Boss 与精英互斥,Boss 走 is_boss)
model.is_elite = !monData.isBoss && !!monData.isElite;
}
this.sectionSpawnedCount++;
this.missionSpawnedCount++;
}
/** 整局累计已刷出怪物总数(跨回合,整关重置):供 MissionComp 整局击杀统计 */
public getTotalSpawnedCount(): number {
return this.missionSpawnedCount;
}
/** 整关重置刷怪统计(由 MissionComp.data_init 在整关开始/重开时调用,回合间不重置) */
public resetMissionStats(): void {
this.missionSpawnedCount = 0;
}
/**
@@ -257,25 +274,6 @@ export class MissionMonCompComp extends CCComp {
return FightSet.MON_ROW_BACK_X;
}
/**
* 本节击杀统计(结算奖励消费):
* kills = 已刷出数 - 场上存活数(实时查询,不依赖 MissionComp 的 0.2s 降频同步)
* total = 本节目标怪物总数(含未刷出)
*/
public getSectionKillStats(): { kills: number; total: number } {
let alive = 0;
ecs.query(ecs.allOf(HeroAttrsComp)).forEach(entity => {
const attrs = entity.get(HeroAttrsComp);
if (attrs && !attrs.is_dead && attrs.fac === FacSet.MON) {
alive++;
}
});
return {
kills: Math.max(0, this.sectionSpawnedCount - alive),
total: this.sectionTargetCount,
};
}
/** ECS 组件移除时触发 */
reset() { }
}

View File

@@ -177,7 +177,7 @@ export class VictoryComp extends CCComp {
/** 是否胜利场(驱动主按钮文案与行为) */
private isVictory: boolean = false;
/** 失败奖励判定数据(MissionComp 传入:本节坚持时间 + 击杀统计) */
/** 失败奖励判定数据(MissionComp 传入:整局坚持时间 + 整局击杀统计) */
private defeatStats: { clear_time: number; kills: number; total: number } | null = null;
/** 本节怪物等级(= 大关号,驱动胜利奖励随关卡进度增长) */
@@ -585,12 +585,14 @@ export class VictoryComp extends CCComp {
/**
* 计算并发放结算奖励,结果存入 _rewardResult。
* 胜利:随关卡进度增长(base + monLv × per_lv,整体 ×1.2)。
* Why: 无回合概念后 wave_win_count 每节被 resetScores 清零恒为 1,
* 旧的 per_wave 增长失效,改挂怪物等级(= 大关号)随关卡推进线性增长。
* 通关:按 5 回合累计发放——
* 每回合基础奖励(随 monLv 增长)× 胜利回合数(wave_win_count),
* 再按全员存活回合数 × per_wave_bonus 加成。
* Why: 一关一局 5 回合后,通关奖励不再是单场,改按"5 回合累计"发放;
* scores 统计在整关间保留(data_init 仅在整关开始/重开重置),故可直接累计。
* 失败:受"坚持时间 + 击杀比例"限制——
* - 坚持 < 15s 或本节一只怪未杀 → 无任何奖励;
* - 达标 → 坚持奖励(固定)+ 击杀比例奖励(基数 × 本节击杀比例),
* - 坚持 < 15s 或本回合一只怪未杀 → 无任何奖励;
* - 达标 → 坚持奖励(固定)+ 击杀比例奖励(基数 × 本回合击杀比例),
* 银币/钻石/碎片同样受此门槛限制(不达标不发,达标正常发放)。
* @param isVictory 是否胜利
*/
@@ -609,13 +611,19 @@ export class VictoryComp extends CCComp {
killRatio = total > 0 ? Math.min(1, kills / total) : 0;
}
// 5 回合累计:胜利回合数与全员存活回合数(scores 跨回合累计,整关重置)
const winWaves = Math.max(1, Math.floor(smc.vmdata.scores.wave_win_count || 0));
const aliveWaves = Math.max(0, Math.floor(smc.vmdata.scores.wave_all_alive_count || 0));
// 全员存活回合加成:每个全员存活回合额外 +per_wave_bonus(线性叠加于累计基础)
const aliveBonus = 1 + aliveWaves * cfg.per_wave_bonus;
// 1. 金币 / 天赋经验
let gold: number;
let talentExp: number;
if (isVictory) {
const mult = cfg.victory_multiplier;
gold = Math.floor((cfg.gold.base + this.monLv * cfg.gold.per_lv) * mult);
talentExp = Math.floor((cfg.talent_exp.base + this.monLv * cfg.talent_exp.per_lv) * mult);
// 通关:每回合基础 × 胜利回合数 × 全员存活加成
gold = Math.floor((cfg.gold.base + this.monLv * cfg.gold.per_lv) * winWaves * aliveBonus);
talentExp = Math.floor((cfg.talent_exp.base + this.monLv * cfg.talent_exp.per_lv) * winWaves * aliveBonus);
} else if (defeatQualified) {
// 失败达标 = 坚持奖励(固定)+ 击杀比例奖励
gold = Math.floor(cfg.defeat.survive.gold + cfg.defeat.kill.gold * killRatio);
@@ -625,25 +633,31 @@ export class VictoryComp extends CCComp {
talentExp = 0;
}
// 1.5 银币固定发放;钻石按输赢在 [min, max] 内随机(失败不达标时同样清零)
const silver = !isVictory && !defeatQualified ? 0 : cfg.silver;
// 1.5 银币 / 钻石:通关按胜利回合数累计;失败不达标清零
const silverBase = !isVictory && !defeatQualified ? 0 : cfg.silver;
const silver = isVictory ? Math.floor(silverBase * winWaves) : silverBase;
let diamond = 0;
if (isVictory || defeatQualified) {
const dRange = isVictory ? cfg.diamond.victory : cfg.diamond.defeat;
if (isVictory) {
const dRange = cfg.diamond.victory;
const perWave = dRange.min + Math.floor(Math.random() * (dRange.max - dRange.min + 1));
diamond = perWave * winWaves;
} else if (defeatQualified) {
const dRange = cfg.diamond.defeat;
diamond = dRange.min + Math.floor(Math.random() * (dRange.max - dRange.min + 1));
}
// 2. 英雄碎片:出场英雄各保底 + 1 个随机英雄(失败不达标时不发)
// 2. 英雄碎片:出场英雄各保底 + 1 个随机英雄(通关 × 胜利回合数累计;失败不达标时不发)
const fragments: FragmentReward[] = [];
if (isVictory || defeatQualified) {
const playedUuids = this.collectPlayedHeroUuids();
const playedSet = new Set(playedUuids);
const fragMult = isVictory ? winWaves : 1;
for (const uuid of playedUuids) {
fragments.push({ uuid, count: cfg.played_fragment, isPlayed: true });
fragments.push({ uuid, count: cfg.played_fragment * fragMult, isPlayed: true });
}
const randomUuid = this.pickRandomHeroUuid(playedSet);
if (randomUuid > 0) {
fragments.push({ uuid: randomUuid, count: cfg.random_fragment, isPlayed: false });
fragments.push({ uuid: randomUuid, count: cfg.random_fragment * fragMult, isPlayed: false });
}
}
@@ -667,7 +681,8 @@ export class VictoryComp extends CCComp {
this._rewardResult = { gold, talentExp, silver, diamond, fragments };
mLogger.log(this.debugMode, 'VictoryComp', '[VictoryComp] 结算奖励发放', {
isVictory, monLv: this.monLv, gold, talentExp, silver, diamond, fragments,
isVictory, monLv: this.monLv, winWaves, aliveWaves, aliveBonus,
gold, talentExp, silver, diamond, fragments,
defeatStats: this.defeatStats, defeatQualified, killRatio,
});
}

View File

@@ -0,0 +1,132 @@
{
"triggerPresets": [
{
"id": "tp-atking-fenli",
"label": "每3次普攻·奋力一击",
"group": "atking",
"uuid": 6004,
"tags": ["伤害"],
"entry": { "lv": 4, "s_lv": 1, "name": "风暴打击", "t_num": 3, "overrides": { "ap": 300 }, "desc": "立刻打击{target},造成攻击力{ap}%伤害" }
},
{
"id": "tp-atking-poison",
"label": "每3次普攻·蚀骨毒箭",
"group": "atking",
"uuid": 6007,
"tags": ["伤害", "持续"],
"entry": { "lv": 4, "s_lv": 1, "name": "蚀骨毒箭", "t_num": 3, "overrides": { "ap": 80, "timed_debuff_id": 7005, "debuff_value": 40, "debuff_chance": 100 }, "desc": "追加射出蚀骨毒箭,对{target}造成攻击力{ap}%伤害,必定使目标中毒" }
},
{
"id": "tp-atking-heavy-arrow",
"label": "每3次普攻·追加重箭",
"group": "atking",
"uuid": 6008,
"tags": ["伤害", "击退"],
"entry": { "lv": 4, "s_lv": 1, "name": "", "t_num": 3, "overrides": { "ap": 120, "bck_chance": 50 }, "desc": "" }
},
{
"id": "tp-atking-shield",
"label": "每3次普攻·附加护盾",
"group": "atking",
"uuid": 6301,
"tags": ["防御", "团队"],
"entry": { "lv": 4, "s_lv": 1, "name": "圣愈之盾", "t_num": 3, "overrides": { "ap": 30 }, "desc": "{target}获得生命最大值{ap}%的护盾" }
},
{
"id": "tp-atked-shield-team",
"label": "受击3次·全队护盾",
"group": "atked",
"uuid": 6301,
"tags": ["防御", "团队"],
"entry": { "lv": 4, "s_lv": 1, "name": "全体圣盾", "t_num": 3, "overrides": { "ap": 10 }, "desc": "全队获得本英雄生命最大值{ap}%的护盾" }
},
{
"id": "tp-atked-heal-self",
"label": "受击3次·自我治疗",
"group": "atked",
"uuid": 6302,
"tags": ["治疗"],
"entry": { "lv": 4, "s_lv": 1, "name": "", "t_num": 3, "overrides": { "ap": 5 }, "desc": "为自己释放治疗,恢复生命值的{ap}%" }
},
{
"id": "tp-atked-thorns",
"label": "受击3次·荆棘反伤",
"group": "atked",
"uuid": 6516,
"tags": ["反伤", "防御"],
"entry": { "lv": 4, "s_lv": 1, "name": "荆棘回应", "t_num": 3, "overrides": { "timed_buff_id": 7028, "buff_value": 10 }, "desc": "凝聚荆棘护体,受击时反弹{buff_value}%所受伤害" }
},
{
"id": "tp-fstart-crit-burst",
"label": "开场·团队暴击",
"group": "fstart",
"uuid": 6504,
"tags": ["增益", "团队", "暴击"],
"entry": { "lv": 4, "s_lv": 1, "name": "猎杀时刻", "t_num": 1, "overrides": { "buff_value": 30 }, "desc": "战斗开始时全体友方暴击率提升{buff_value}%" }
},
{
"id": "tp-fstart-armor-break",
"label": "开场·先手破甲",
"group": "fstart",
"uuid": 6012,
"tags": ["减益", "破甲"],
"entry": { "lv": 4, "s_lv": 1, "name": "先手蚀甲", "t_num": 1, "overrides": { "ap": 150, "timed_debuff_id": 7008 }, "desc": "战斗开始掷出蚀甲酸瓶,必定使其破甲" }
},
{
"id": "tp-fstart-lightning",
"label": "开场·雷击先手",
"group": "fstart",
"uuid": 6203,
"tags": ["伤害", "麻痹"],
"entry": { "lv": 4, "s_lv": 1, "name": "雷击先手", "t_num": 1, "overrides": { "ap": 150, "para": 30 }, "desc": "战斗开始释放雷击,对{target}造成{ap}%伤害,{para}%概率麻痹" }
}
],
"fieldPresets": [
{
"id": "fp-crit-master",
"label": "暴击双光环(暴击率+暴伤)",
"tags": ["暴击"],
"lv": 4,
"uuids": [
{ "uuid": 7004, "value": 0.12, "name": "剑刃风暴" },
{ "uuid": 7005, "value": 0.2, "name": "斩魂利刃" }
]
},
{
"id": "fp-atk-aura",
"label": "攻击光环 15%",
"tags": ["增伤"],
"lv": 4,
"uuids": [
{ "uuid": 7002, "value": 0.15, "name": "" }
]
},
{
"id": "fp-atk-speed",
"label": "攻速光环 15%",
"tags": ["攻速"],
"lv": 4,
"uuids": [
{ "uuid": 7006, "value": 0.15, "name": "" }
]
},
{
"id": "fp-armor-pierce",
"label": "穿透光环 10%",
"tags": ["破甲"],
"lv": 4,
"uuids": [
{ "uuid": 7009, "value": 0.1, "name": "锐矛之赐" }
]
},
{
"id": "fp-thorns-aura",
"label": "荆棘领域 8",
"tags": ["反伤", "防御"],
"lv": 4,
"uuids": [
{ "uuid": 7022, "value": 8, "name": "荆棘领域" }
]
}
]
}

View File

@@ -103,6 +103,47 @@ app.post('/api/heroes', (req, res) => {
}
})
const TEMPLATES_PATH = path.join(DATA_DIR, 'templates.json')
/** 预设库持久化:GET 读取(无文件时返回空池) */
app.get('/api/templates', (_req, res) => {
try {
if (!fs.existsSync(TEMPLATES_PATH)) return res.json({ triggerPresets: [], fieldPresets: [] })
const data = JSON.parse(fs.readFileSync(TEMPLATES_PATH, 'utf8'))
// 兼容旧格式:若根上是 templates 数组,返回空池让前端走默认
if (Array.isArray(data.templates)) return res.json({ triggerPresets: [], fieldPresets: [] })
res.json({
triggerPresets: Array.isArray(data.triggerPresets) ? data.triggerPresets : [],
fieldPresets: Array.isArray(data.fieldPresets) ? data.fieldPresets : [],
})
} catch (e) {
res.status(500).json({ error: String(e?.message || e) })
}
})
/** 职业模板持久化:POST 全量覆盖保存(结构:{ triggerPresets: [], fieldPresets: [] }) */
app.post('/api/templates', (req, res) => {
const body = req.body || {}
const triggerPresets = body.triggerPresets
const fieldPresets = body.fieldPresets
if (!Array.isArray(triggerPresets) || !Array.isArray(fieldPresets)) {
return res.status(400).json({ error: 'triggerPresets 与 fieldPresets 必须为数组' })
}
// 触发预设必须绑定合法触发组
const groups = ['atking', 'atked', 'fstart', 'dead']
const bad = triggerPresets.find((p) => !groups.includes(p.group))
if (bad) {
return res.status(400).json({ error: `触发预设 ${bad.id || bad.label} 的 group 非法:${bad.group}` })
}
try {
fs.mkdirSync(DATA_DIR, { recursive: true })
fs.writeFileSync(TEMPLATES_PATH, JSON.stringify({ triggerPresets, fieldPresets }, null, 2), 'utf8')
res.json({ ok: true, path: TEMPLATES_PATH, triggerCount: triggerPresets.length, fieldCount: fieldPresets.length })
} catch (e) {
res.status(500).json({ error: String(e?.message || e) })
}
})
/** 序列化 overrides:TGroup 以字符串存储(如 "Team"),导出时还原为枚举表达式 TGroup.Team;
* buff_target 以数值存储(0-3),导出时还原为 BuffTarget.X 枚举表达式提升可读性 */
const BUFF_TARGET_NAMES = ['Hit', 'Self', 'Ally', 'Enemy']

View File

@@ -3,6 +3,10 @@
<header class="hero-header">
<h1>英雄配置编辑器</h1>
<div class="actions">
<el-radio-group v-model="view" size="small">
<el-radio-button value="heroes">英雄列表</el-radio-button>
<el-radio-button value="templates">预设库</el-radio-button>
</el-radio-group>
<el-button size="small" @click="refreshMeta" :loading="loadingMeta">刷新配置数据</el-button>
<el-button size="small" type="success" @click="doSave" :disabled="!heroes.length">保存到服务器</el-button>
<el-button size="small" type="primary" @click="doApply" :disabled="!heroes.length">写入 heroSet.ts</el-button>
@@ -10,6 +14,17 @@
</div>
</header>
<div class="hero-main">
<!-- 预设库视图 -->
<TemplateManager
v-if="view === 'templates'"
:trigger-presets="triggerPresets"
:field-presets="fieldPresets"
:skill-options="allSkills"
:skill-map="skillMap"
:field-options="fieldOptions"
:buff-options="buffOptions"
/>
<template v-else>
<!-- 首页:英雄卡片列表 -->
<div class="home-toolbar">
<span class="muted">共 {{ heroes.length }} 个英雄</span>
@@ -188,6 +203,17 @@
<el-button size="small" @click="editVisible = false">关闭</el-button>
</template>
</el-dialog>
<!-- 新建英雄向导 -->
<HeroWizard
v-model:visible="wizardVisible"
:heroes="heroes"
:skill-map="skillMap"
:trigger-presets="triggerPresets"
:field-presets="fieldPresets"
@confirm="onWizardCreate"
/>
</template>
</div>
</div>
</template>
@@ -199,17 +225,26 @@ import { api } from './api'
import {
HERO_ROLES, GROW_TYPES, H_TYPES,
ROLE_ATK_SKILL, ROLE_ULT_SKILL, SUPPORT_ULT,
defaultHero, defaultSkill,
defaultSkill,
} from './constants'
import SkillEditor from './components/SkillEditor.vue'
import TriggerGroupEditor from './components/TriggerGroupEditor.vue'
import FieldEditor from './components/FieldEditor.vue'
import BonusEditor from './components/BonusEditor.vue'
import HeroWizard from './components/HeroWizard.vue'
import TemplateManager from './components/TemplateManager.vue'
const heroes = ref([])
// 预设库(跨职业复用资产,与职业默认解耦)
const triggerPresets = ref([])
const fieldPresets = ref([])
const activeIdx = ref(-1)
const meta = ref(null)
const loadingMeta = ref(false)
// 视图切换:heroes 英雄列表 / templates 模板管理
const view = ref('heroes')
// 新建英雄向导可见性
const wizardVisible = ref(false)
// 编辑弹窗可见性:首页卡片点「修改/新建」打开,关闭仅收起不丢数据
const editVisible = ref(false)
@@ -389,13 +424,13 @@ function openEdit(i) {
activeIdx.value = i
editVisible.value = true
}
// 「+ 新建英雄」改为打开向导:选职业/技能底座/勾选模板预设,一键生成完整英雄
function addHero() {
const nh = defaultHero()
const used = new Set(heroes.value.map((h) => h.uuid))
let uuid = 5001
while (used.has(uuid)) uuid++
nh.uuid = uuid
heroes.value.push(nh)
wizardVisible.value = true
}
// 向导创建完成:追加到列表并打开编辑弹窗微调
function onWizardCreate(h) {
heroes.value.push(h)
openEdit(heroes.value.length - 1)
}
function removeHero(i) {
@@ -448,6 +483,16 @@ async function loadHeroes() {
}
}
async function loadTemplates() {
try {
const r = await api.fetchTemplates()
triggerPresets.value = Array.isArray(r.triggerPresets) ? r.triggerPresets : []
fieldPresets.value = Array.isArray(r.fieldPresets) ? r.fieldPresets : []
} catch (e) {
ElMessage.error('加载预设库失败: ' + (e?.response?.data?.error || e.message))
}
}
async function doSave() {
try {
const r = await api.saveHeroes(heroes.value)
@@ -492,5 +537,6 @@ async function doExportCsv() {
onMounted(() => {
loadMeta()
loadHeroes()
loadTemplates()
})
</script>

View File

@@ -7,4 +7,6 @@ export const api = {
saveHeroes: (heroes) => axios.post('/api/heroes', { heroes }).then((r) => r.data),
applyHeroes: (heroes) => axios.post('/api/apply', { heroes }).then((r) => r.data),
exportCsv: (heroes) => axios.post('/api/export-csv', { heroes }).then((r) => r.data),
fetchTemplates: () => axios.get('/api/templates').then((r) => r.data),
saveTemplates: (templates) => axios.post('/api/templates', { templates }).then((r) => r.data),
}

View File

@@ -0,0 +1,280 @@
<template>
<el-dialog
:model-value="visible"
title="新建英雄向导"
width="64%"
top="4vh"
:close-on-click-modal="false"
@update:model-value="emit('update:visible', $event)"
>
<div class="panel">
<h3>职业</h3>
<div class="role-cards">
<div
v-for="r in HERO_ROLES"
:key="r.key"
class="role-card"
:class="{ active: form.roleKey === r.key }"
@click="onRoleChange(r.key)"
>
{{ r.label }}
</div>
</div>
</div>
<div class="panel">
<h3>基础</h3>
<div class="form-grid">
<el-form-item label="名称">
<el-input v-model="form.name" placeholder="英雄名" />
</el-form-item>
<el-form-item label="稀有度">
<el-select v-model="form.rarity">
<el-option v-for="n in [1,2,3,4,5]" :key="n" :label="'★'.repeat(n)" :value="n" />
</el-select>
</el-form-item>
<el-form-item label="形态">
<el-select v-model="form.typeKey">
<el-option v-for="t in H_TYPES" :key="t.key" :label="`${t.key} ${t.label}`" :value="t.key" />
</el-select>
</el-form-item>
<el-form-item label="攻速档">
<el-select v-model="form.atkSpeed">
<el-option v-for="s in ATK_SPEEDS" :key="s" :label="s" :value="s" />
</el-select>
</el-form-item>
<el-form-item label="三维偏移">
<div class="stat-row">
<el-input-number v-model="form.statBase.hp" placeholder="hp" :controls="false" style="width: 90px" />
<el-input-number v-model="form.statBase.ap" placeholder="ap" :controls="false" style="width: 90px" />
<el-input-number v-model="form.statBase.def" placeholder="def" :controls="false" style="width: 90px" />
</div>
</el-form-item>
<el-form-item label="资源路径">
<el-input v-model="form.path" placeholder="ui/role/xxx(可后补)" />
</el-form-item>
</div>
</div>
<div class="panel">
<h3>技能底座</h3>
<div class="form-grid">
<el-form-item label="普攻底座">
<el-select v-model="form.atkUuid" filterable>
<el-option v-for="uuid in atkSkillOptions" :key="uuid" :label="`${uuid} ${skillName(uuid)}`" :value="uuid" />
</el-select>
</el-form-item>
<el-form-item label="大招底座">
<el-select v-model="form.ultUuid" filterable>
<el-option v-for="uuid in ultSkillOptions" :key="uuid" :label="`${uuid} ${skillName(uuid)}`" :value="uuid" />
</el-select>
</el-form-item>
</div>
<div class="muted" style="font-size: 11px">普攻自动按攻速档生成 cd/cdExpr + lv2 档(ap120);大招自动 cd:8 + lv8/lv16 档</div>
</div>
<div class="panel">
<h3>触发组预设 <span class="muted">来自预设库,勾选即注入,默认不勾</span></h3>
<div v-if="triggerPresets.length">
<div v-for="g in TRIGGER_GROUPS" :key="g.key" class="preset-group" v-show="presetsByGroup(g.key).length">
<div class="preset-group-title">{{ g.label }}</div>
<el-checkbox
v-for="p in presetsByGroup(g.key)"
:key="p.id"
v-model="p.checked"
:label="presetTriggerLabel(p)"
class="preset-item"
/>
</div>
</div>
<div v-else class="muted">暂无触发预设(可在"预设库"页添加)</div>
</div>
<div class="panel">
<h3>光环预设 <span class="muted">来自预设库,单选或不选</span></h3>
<div v-if="fieldPresets.length">
<el-radio-group v-model="checkedFieldId">
<el-radio :value="''">无光环</el-radio>
<el-radio v-for="p in fieldPresets" :key="p.id" :value="p.id" class="preset-item">
{{ p.label || '(未命名)' }} <span class="muted">lv{{ p.lv }} · {{ p.uuids.length }} 个词条</span>
</el-radio>
</el-radio-group>
</div>
<div v-else class="muted">暂无光环预设(可在"预设库"页添加)</div>
</div>
<template #footer>
<el-button size="small" @click="emit('update:visible', false)">取消</el-button>
<el-button size="small" type="primary" @click="confirm" :disabled="!form.name || !form.roleKey || !form.atkUuid || !form.ultUuid">
创建英雄
</el-button>
</template>
</el-dialog>
</template>
<script setup>
import { ref, computed } from 'vue'
import {
HERO_ROLES, H_TYPES, ATK_SPEEDS, ATK_SPEED_MAP,
ROLE_DEFAULTS, TRIGGER_GROUPS,
ROLE_ATK_SKILL, ROLE_ULT_SKILL, SUPPORT_ULT,
defaultHero,
} from '../constants'
const props = defineProps({
visible: { type: Boolean, default: false },
heroes: { type: Array, default: () => [] },
skillMap: { type: Object, default: () => ({}) },
// 预设库(跨职业复用资产)
triggerPresets: { type: Array, default: () => [] },
fieldPresets: { type: Array, default: () => [] },
})
const emit = defineEmits(['update:visible', 'confirm'])
const form = ref(emptyForm())
const checkedFieldId = ref('')
function emptyForm() {
return {
roleKey: '',
name: '',
rarity: 4,
typeKey: 'Melee',
atkSpeed: 'Normal',
statBase: { hp: 0, ap: 0, def: 0 },
path: '',
atkUuid: null,
ultUuid: null,
}
}
function skillName(uuid) { return props.skillMap[uuid]?.name || String(uuid ?? '') }
const atkSkillOptions = computed(() => (form.value.roleKey ? ROLE_ATK_SKILL[form.value.roleKey] : []))
const ultSkillOptions = computed(() => {
if (!form.value.roleKey) return []
return [...new Set([...(ROLE_ULT_SKILL[form.value.roleKey] || []), ...SUPPORT_ULT])]
})
function presetsByGroup(group) {
return props.triggerPresets.filter((p) => p.group === group)
}
function presetTriggerLabel(p) {
return p.label || `${p.uuid} ${skillName(p.uuid)} t${p.entry.t_num}`
}
function onRoleChange(roleKey) {
form.value.roleKey = roleKey
// 职业默认值:形态/攻速/稀有度/三维偏移(用户可微调)
const d = ROLE_DEFAULTS[roleKey]
if (d) {
form.value.typeKey = d.typeKey
form.value.atkSpeed = d.atkSpeed
form.value.rarity = d.rarity
form.value.statBase = { ...d.statBase }
}
// 切职业时重置已选技能(职业过滤后原选择可能不可用)
form.value.atkUuid = null
form.value.ultUuid = null
}
function confirm() {
const f = form.value
// uuid:现有最小空闲值,从 5001 起步
const used = new Set(props.heroes.map((h) => h.uuid))
let uuid = 5001
while (used.has(uuid)) uuid++
// 以 defaultHero() 为基础骨架,覆写向导字段
const hero = defaultHero()
hero.uuid = uuid
hero.name = f.name
hero.info = f.name
hero.roleKey = f.roleKey
hero.growKey = f.roleKey
hero.typeKey = f.typeKey
hero.rarity = f.rarity
hero.path = f.path
hero.statBase = { ...f.statBase }
// 普攻:cd/cdExpr 按攻速档生成;lv2 档 ap120
const atkSpeedConf = ATK_SPEED_MAP[f.atkSpeed] || ATK_SPEED_MAP.Normal
hero.skills[0] = {
uuid: f.atkUuid,
name: '',
cd: atkSpeedConf.cd,
cdExpr: atkSpeedConf.cdExpr,
overrides: { ap: 100 },
desc: '',
lv_entries: [{ lv: 2, s_lv: 2, overrides: { ap: 120 }, desc: '' }],
}
// 大招:cd:8 + lv8/lv16 档(对齐 addUltSkill 惯例)
hero.skills[1] = {
uuid: f.ultUuid,
name: '',
cd: 8,
cdExpr: null,
overrides: {},
desc: '',
lv_entries: [
{ lv: 8, s_lv: 2, overrides: {}, desc: '' },
{ lv: 16, s_lv: 3, overrides: {}, desc: '' },
],
}
// 触发组:勾选的预设深拷贝注入对应组(组内值为条目数组)
for (const p of props.triggerPresets) {
if (!p.checked) continue
const entry = JSON.parse(JSON.stringify(p.entry))
hero[p.group][String(p.uuid)] = [entry]
}
// 光环:单选预设,深拷贝 uuids
if (checkedFieldId.value) {
const fp = props.fieldPresets.find((x) => x.id === checkedFieldId.value)
if (fp) {
hero.field = [{
lv: fp.lv,
uuids: JSON.parse(JSON.stringify(fp.uuids)),
desc: '',
}]
}
}
emit('confirm', hero)
emit('update:visible', false)
// 重置
form.value = emptyForm()
checkedFieldId.value = ''
for (const p of props.triggerPresets) p.checked = false
}
</script>
<style scoped>
.role-cards { display: flex; flex-wrap: wrap; gap: 8px; }
.role-card {
padding: 8px 18px;
border: 1px solid var(--border);
border-radius: 6px;
cursor: pointer;
font-size: 13px;
color: var(--text);
background: var(--panel);
transition: all 0.15s;
}
.role-card:hover { border-color: var(--brand-light); }
.role-card.active { border-color: var(--brand); background: #eef4fc; color: var(--brand); font-weight: 600; }
.form-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 0 16px; }
.stat-row { display: flex; gap: 8px; }
.preset-group { margin-bottom: 6px; }
.preset-group-title { font-size: 12px; color: var(--text-light); margin-bottom: 2px; font-family: Menlo, Consolas, monospace; }
.preset-item { display: flex; margin-right: 0; }
@media (max-width: 600px) {
.form-grid { grid-template-columns: 1fr; }
}
</style>

View File

@@ -0,0 +1,411 @@
<template>
<div class="tpl-manager">
<div class="tpl-toolbar">
<div class="tpl-toolbar-left">
<span class="tpl-title">预设库</span>
<span class="muted">触发/光环预设是跨职业复用的资产,新英雄向导中勾选即可套用</span>
</div>
<div class="tpl-toolbar-actions">
<el-button size="small" type="success" @click="doSave">保存预设</el-button>
<el-button size="small" type="primary" @click="addPreset">+ 新建{{ activeTab === 'trigger' ? '触发' : '光环' }}预设</el-button>
</div>
</div>
<el-tabs v-model="activeTab" class="tpl-tabs">
<!-- 触发预设 -->
<el-tab-pane label="触发预设" name="trigger">
<div class="preset-sub-tabs">
<el-radio-group v-model="triggerGroup" size="small">
<el-radio-button v-for="g in TRIGGER_GROUPS" :key="g.key" :value="g.key">{{ g.label }}</el-radio-button>
</el-radio-group>
<span class="muted">共 {{ filteredTriggerPresets.length }} 个</span>
</div>
<div class="tpl-scroll">
<div class="tpl-cards">
<div v-for="p in filteredTriggerPresets" :key="p.id" class="tpl-card" @click="openEditTrigger(p)">
<div class="tpl-card-head">
<span class="tpl-name">{{ p.label || '(未命名)' }}</span>
<el-button type="danger" size="small" text @click.stop="removeTrigger(p)">删除</el-button>
</div>
<div class="tpl-meta">
<span class="tag">{{ p.group }}</span>
<span class="tpl-meta-item">{{ p.uuid }} {{ skillName(p.uuid) }}</span>
<span class="tpl-meta-sep">·</span>
<span class="tpl-meta-item">t_num {{ p.entry.t_num }}</span>
<span class="tpl-meta-sep">·</span>
<span class="tpl-meta-item">lv{{ p.entry.lv }}</span>
</div>
<div class="tpl-chips" v-if="overrideSummary(p.entry.overrides)">
<span class="chip">{{ overrideSummary(p.entry.overrides) }}</span>
</div>
<div class="tpl-chips" v-if="p.tags?.length">
<span v-for="t in p.tags" :key="t" class="chip tag-chip">{{ t }}</span>
</div>
</div>
<div v-if="!filteredTriggerPresets.length" class="tpl-empty-role">
<span class="muted">该组暂无预设</span>
<el-button size="small" text type="primary" @click="addTriggerFor(triggerGroup)">+ 补一个</el-button>
</div>
</div>
</div>
</el-tab-pane>
<!-- 光环预设 -->
<el-tab-pane label="光环预设" name="field">
<div class="preset-sub-tabs">
<span class="muted">共 {{ fieldPresets.length }} 个</span>
</div>
<div class="tpl-scroll">
<div class="tpl-cards">
<div v-for="p in fieldPresets" :key="p.id" class="tpl-card" @click="openEditField(p)">
<div class="tpl-card-head">
<span class="tpl-name">{{ p.label || '(未命名)' }}</span>
<el-button type="danger" size="small" text @click.stop="removeField(p)">删除</el-button>
</div>
<div class="tpl-meta">
<span class="tag">lv{{ p.lv }}</span>
<span class="tpl-meta-item">{{ p.uuids.length }} 个光环</span>
</div>
<div class="tpl-chips">
<span v-for="(u, i) in p.uuids" :key="i" class="chip aura">
{{ u.name || fieldName(u.uuid) }} {{ u.value }}
</span>
</div>
<div class="tpl-chips" v-if="p.tags?.length">
<span v-for="t in p.tags" :key="t" class="chip tag-chip">{{ t }}</span>
</div>
</div>
<div v-if="!fieldPresets.length" class="tpl-empty-role">
<span class="muted">暂无光环预设</span>
<el-button size="small" text type="primary" @click="addField">+ 补一个</el-button>
</div>
</div>
</div>
</el-tab-pane>
</el-tabs>
<!-- 触发预设编辑弹窗 -->
<el-dialog
v-model="editTriggerVisible"
:title="`编辑触发预设 ${currentTrigger?.label || ''}`"
width="70%"
top="4vh"
:close-on-click-modal="false"
>
<template v-if="currentTrigger">
<div class="panel">
<h3>基础</h3>
<div class="form-grid">
<el-form-item label="预设名">
<el-input v-model="currentTrigger.label" placeholder="如:受击3次·全队护盾" />
</el-form-item>
<el-form-item label="触发组">
<el-select v-model="currentTrigger.group">
<el-option v-for="g in TRIGGER_GROUPS" :key="g.key" :label="g.label" :value="g.key" />
</el-select>
</el-form-item>
<el-form-item label="技能底座">
<el-select v-model="currentTrigger.uuid" filterable placeholder="选择技能">
<el-option v-for="s in skillOptions" :key="s.uuid" :label="`${s.uuid} ${s.name}`" :value="s.uuid" />
</el-select>
</el-form-item>
<el-form-item label="标签(逗号分隔)">
<el-input v-model="triggerTagsInput" placeholder="伤害,团队" />
</el-form-item>
</div>
</div>
<div class="panel">
<h3>触发参数(entry)</h3>
<div class="form-grid">
<el-form-item label="解锁等级 lv">
<el-input-number v-model="currentTrigger.entry.lv" :min="1" :max="20" :controls="false" style="width: 100%" />
</el-form-item>
<el-form-item label="SkillLv">
<el-select v-model="currentTrigger.entry.s_lv">
<el-option v-for="n in 5" :key="n" :label="`Lv${n}`" :value="n" />
</el-select>
</el-form-item>
<el-form-item label="t_num">
<el-input-number v-model="currentTrigger.entry.t_num" :min="1" :controls="false" style="width: 100%" />
</el-form-item>
<el-form-item label="组名 name">
<el-input v-model="currentTrigger.entry.name" placeholder="留空回退底座名" />
</el-form-item>
</div>
<el-form-item label="overrides">
<OverrideEditor v-model="currentTrigger.entry.overrides" :skill-options="skillOptions" :buff-options="buffOptions" :base-skill-uuid="currentTrigger.uuid || 0" />
</el-form-item>
<el-form-item label="描述 desc">
<el-input v-model="currentTrigger.entry.desc" type="textarea" :rows="2" />
</el-form-item>
</div>
</template>
<template #footer>
<el-button size="small" @click="editTriggerVisible = false">关闭</el-button>
</template>
</el-dialog>
<!-- 光环预设编辑弹窗 -->
<el-dialog
v-model="editFieldVisible"
:title="`编辑光环预设 ${currentField?.label || ''}`"
width="60%"
top="6vh"
:close-on-click-modal="false"
>
<template v-if="currentField">
<div class="panel">
<h3>基础</h3>
<div class="form-grid">
<el-form-item label="预设名">
<el-input v-model="currentField.label" placeholder="如:暴击双光环" />
</el-form-item>
<el-form-item label="解锁等级 lv">
<el-input-number v-model="currentField.lv" :min="1" :max="20" :controls="false" style="width: 100%" />
</el-form-item>
<el-form-item label="标签(逗号分隔)" class="grid-span">
<el-input v-model="fieldTagsInput" placeholder="暴击,增伤" />
</el-form-item>
</div>
</div>
<div class="panel">
<h3>光环词条</h3>
<div v-for="(u, i) in currentField.uuids" :key="i" style="display: flex; gap: 8px; margin-bottom: 6px">
<el-select v-model="u.uuid" placeholder="光环" filterable style="width: 220px">
<el-option v-for="fs in fieldOptions" :key="fs.uuid" :label="`${fs.uuid} ${fs.name}`" :value="fs.uuid" />
</el-select>
<el-input-number v-model="u.value" :step="0.01" :controls="false" style="width: 120px" placeholder="value" />
<el-input v-model="u.name" placeholder="英雄语境名(可选)" style="width: 200px" />
<el-button type="danger" size="small" text @click="currentField.uuids.splice(i, 1)">删除</el-button>
</div>
<el-button size="small" @click="currentField.uuids.push({ uuid: null, value: 0, name: '' })">+ 添加词条</el-button>
</div>
</template>
<template #footer>
<el-button size="small" @click="editFieldVisible = false">关闭</el-button>
</template>
</el-dialog>
</div>
</template>
<script setup>
import { ref, computed } from 'vue'
import { ElMessage } from 'element-plus'
import { api } from '../api'
import { TRIGGER_GROUPS, defaultTriggerPreset, defaultFieldPreset } from '../constants'
import OverrideEditor from './OverrideEditor.vue'
const props = defineProps({
// 由 App 传入并持有,本组件直接原地修改,保存时整体提交
triggerPresets: { type: Array, required: true },
fieldPresets: { type: Array, required: true },
skillOptions: { type: Array, default: () => [] },
skillMap: { type: Object, default: () => ({}) },
fieldOptions: { type: Array, default: () => [] },
buffOptions: { type: Array, default: () => [] },
})
const activeTab = ref('trigger')
const triggerGroup = ref('atking')
const editTriggerVisible = ref(false)
const editFieldVisible = ref(false)
const activeTriggerId = ref('')
const activeFieldId = ref('')
const currentTrigger = computed(() => props.triggerPresets.find((p) => p.id === activeTriggerId.value) || null)
const currentField = computed(() => props.fieldPresets.find((p) => p.id === activeFieldId.value) || null)
const filteredTriggerPresets = computed(() => props.triggerPresets.filter((p) => p.group === triggerGroup.value))
// tags 数组 ↔ 输入框字符串双向桥接
const triggerTagsInput = computed({
get: () => (currentTrigger.value?.tags || []).join(','),
set: (v) => { if (currentTrigger.value) currentTrigger.value.tags = v.split(/[,,]/).map((s) => s.trim()).filter(Boolean) },
})
const fieldTagsInput = computed({
get: () => (currentField.value?.tags || []).join(','),
set: (v) => { if (currentField.value) currentField.value.tags = v.split(/[,,]/).map((s) => s.trim()).filter(Boolean) },
})
function skillName(uuid) { return props.skillMap[uuid]?.name || String(uuid ?? '') }
function fieldName(uuid) { return props.fieldOptions.find((f) => f.uuid === uuid)?.name || String(uuid ?? '') }
// overrides 摘要(卡片展示用):ap100 / buff 等关键键
function overrideSummary(ov) {
if (!ov) return ''
const keys = ['ap', 'crt', 'stun', 'frz', 'para', 'bck', 'timed_buff_id', 'timed_debuff_id', 'proc_skill']
const parts = []
for (const k of keys) {
const v = ov[k]
if (v == null) continue
if (k === 'proc_skill') parts.push(`proc:${v.s_uuid}×${v.chance ?? 100}%`)
else parts.push(`${k}${v}`)
}
return parts.join(' ')
}
function openEditTrigger(p) { activeTriggerId.value = p.id; editTriggerVisible.value = true }
function openEditField(p) { activeFieldId.value = p.id; editFieldVisible.value = true }
function addPreset() {
if (activeTab.value === 'trigger') addTriggerFor(triggerGroup.value)
else addField()
}
function addTriggerFor(group) {
const p = defaultTriggerPreset(group)
props.triggerPresets.push(p)
openEditTrigger(p)
}
function addField() {
const p = defaultFieldPreset()
props.fieldPresets.push(p)
openEditField(p)
}
function removeTrigger(p) {
const i = props.triggerPresets.findIndex((x) => x.id === p.id)
if (i >= 0) props.triggerPresets.splice(i, 1)
}
function removeField(p) {
const i = props.fieldPresets.findIndex((x) => x.id === p.id)
if (i >= 0) props.fieldPresets.splice(i, 1)
}
async function doSave() {
try {
const r = await api.saveTemplates({
triggerPresets: props.triggerPresets,
fieldPresets: props.fieldPresets,
})
ElMessage.success(`已保存:触发 ${r.triggerCount} / 光环 ${r.fieldCount} → ${r.path}`)
} catch (e) {
ElMessage.error('保存失败: ' + (e?.response?.data?.error || e.message))
}
}
</script>
<style scoped>
.tpl-manager {
flex: 1;
display: flex;
flex-direction: column;
overflow: hidden;
}
/* 顶部工具栏 */
.tpl-toolbar {
display: flex;
justify-content: space-between;
align-items: center;
padding: 10px 16px;
background: var(--panel);
border-bottom: 1px solid var(--border);
flex-shrink: 0;
gap: 12px;
}
.tpl-toolbar-left { display: flex; align-items: baseline; gap: 10px; min-width: 0; }
.tpl-title { font-size: 13px; font-weight: 600; color: var(--text); }
.tpl-toolbar-actions { display: flex; gap: 8px; flex-shrink: 0; }
/* tabs 撑满剩余高度 */
.tpl-tabs {
flex: 1;
display: flex;
flex-direction: column;
overflow: hidden;
padding: 0 16px;
}
.tpl-tabs :deep(.el-tabs__header) { margin-bottom: 4px; flex-shrink: 0; }
.tpl-tabs :deep(.el-tabs__content) { flex: 1; overflow: hidden; display: flex; flex-direction: column; }
.tpl-tabs :deep(.el-tab-pane) { flex: 1; display: flex; flex-direction: column; overflow: hidden; }
/* 触发组二级筛选 */
.preset-sub-tabs {
display: flex;
align-items: center;
gap: 12px;
padding: 4px 0 8px;
flex-shrink: 0;
}
/* 滚动区 */
.tpl-scroll {
flex: 1;
overflow-y: auto;
padding-bottom: 40px;
}
/* 预设卡片网格 */
.tpl-cards {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
gap: 10px;
}
.tpl-card {
background: var(--panel);
border: 1px solid var(--border);
border-radius: 8px;
padding: 10px 12px;
cursor: pointer;
transition: box-shadow 0.15s, border-color 0.15s;
display: flex;
flex-direction: column;
gap: 6px;
}
.tpl-card:hover {
box-shadow: 0 3px 12px rgba(46, 91, 160, 0.15);
border-color: var(--brand-light);
}
.tpl-card-head {
display: flex;
justify-content: space-between;
align-items: center;
}
.tpl-name { font-size: 13px; font-weight: 600; }
.tpl-meta { display: flex; align-items: center; gap: 6px; font-size: 11px; color: var(--text-light); flex-wrap: wrap; }
.tpl-meta-sep { color: var(--border); }
.tpl-chips { display: flex; flex-wrap: wrap; gap: 4px; }
.chip {
background: #eef4fc;
color: var(--brand);
border-radius: 4px;
padding: 0 6px;
font-size: 11px;
line-height: 18px;
}
.chip.aura { background: #f4f0fa; color: #7c5cbf; }
.chip.tag-chip { background: var(--el-fill-color); color: var(--text-light); }
.tag {
display: inline-block;
background: #eef4fc;
color: var(--brand);
border-radius: 4px;
padding: 0 5px;
font-size: 11px;
line-height: 18px;
font-family: Menlo, Consolas, monospace;
}
.tpl-empty-role {
border: 1px dashed var(--border);
border-radius: 8px;
padding: 14px;
display: flex;
align-items: center;
justify-content: center;
gap: 6px;
grid-column: 1 / -1;
}
.form-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 0 16px; }
.form-grid .grid-span { grid-column: 1 / -1; }
@media (max-width: 600px) {
.tpl-cards { grid-template-columns: 1fr; }
.form-grid { grid-template-columns: 1fr; }
}
</style>

View File

@@ -54,6 +54,34 @@ export const ROLE_ULT_SKILL = {
// 辅助类技能(任意职业可用作大招)
export const SUPPORT_ULT = [6301, 6302, 6407, 6412, 6413, 6414, 6415, 6416, 6501, 6514, 6515, 6516, 6517, 6518, 6519]
// 职业默认值(新建英雄向导用):形态/攻速/稀有度/三维偏移
// 数值取自现有英雄样本中位数,向导内可手动微调
export const ROLE_DEFAULTS = {
Tank: { typeKey: 'Melee', atkSpeed: 'Slow', rarity: 4, statBase: { hp: 20, ap: 0, def: 10 } },
Warrior: { typeKey: 'Melee', atkSpeed: 'Slow', rarity: 4, statBase: { hp: 5, ap: 20, def: 5 } },
Assassin: { typeKey: 'Mid', atkSpeed: 'Fast', rarity: 4, statBase: { hp: 0, ap: 30, def: 0 } },
Archer: { typeKey: 'Long', atkSpeed: 'Normal', rarity: 4, statBase: { hp: 0, ap: 30, def: 0 } },
Mage: { typeKey: 'Long', atkSpeed: 'Slow', rarity: 4, statBase: { hp: -5, ap: 10, def: 0 } },
Support: { typeKey: 'Long', atkSpeed: 'Normal', rarity: 4, statBase: { hp: 10, ap: 20, def: 0 } },
}
// 攻速档 → cd 数值 / cdExpr 表达式 映射(向导生成普攻用)
export const ATK_SPEED_MAP = {
VeryFast: { cd: 0.75, cdExpr: 'AtkSpeedSet[AtkSpeedLv.VeryFast].cd' },
Fast: { cd: 0.9, cdExpr: 'AtkSpeedSet[AtkSpeedLv.Fast].cd' },
Normal: { cd: 1.05, cdExpr: 'AtkSpeedSet[AtkSpeedLv.Normal].cd' },
Slow: { cd: 1.2, cdExpr: 'AtkSpeedSet[AtkSpeedLv.Slow].cd' },
VerySlow: { cd: 1.5, cdExpr: 'AtkSpeedSet[AtkSpeedLv.VerySlow].cd' },
}
// 触发组 key 列表(向导/模板管理共用)
export const TRIGGER_GROUPS = [
{ key: 'atking', label: 'atking 攻击后' },
{ key: 'atked', label: 'atked 受击后' },
{ key: 'fstart', label: 'fstart 战斗开始' },
{ key: 'dead', label: 'dead 死亡后' },
]
export function defaultHero() {
return {
uuid: 5001,
@@ -97,6 +125,29 @@ export function defaultTriggerEntry(lv = 4) {
return { lv, s_lv: 1, name: '', t_num: 3, overrides: {}, desc: '' }
}
// 触发预设工厂:预设是跨职业复用的触发组条目(group + 技能底座 uuid + 完整 entry)
export function defaultTriggerPreset(group = 'atking') {
return {
id: `tp-${group}-${Date.now()}`,
label: '',
group,
uuid: null,
tags: [],
entry: defaultTriggerEntry(4),
}
}
// 光环预设工厂:一个预设 = 解锁等级 + 多个光环词条
export function defaultFieldPreset() {
return {
id: `fp-${Date.now()}`,
label: '',
tags: [],
lv: 4,
uuids: [],
}
}
// overrides 可用键说明
export const OVERRIDE_KEYS = [
{ key: 'ap', label: 'ap 攻击百分比' },

View File

@@ -4,10 +4,11 @@ import vue from '@vitejs/plugin-vue'
export default defineConfig({
plugins: [vue()],
server: {
port: 5173,
port: 4004,
strictPort: true,
proxy: {
'/api': {
target: 'http://localhost:3001',
target: 'http://127.0.0.1:3001',
changeOrigin: true,
},
},