新增配置实现

master
humingxing 3 days ago
parent 4e2a133ff5
commit e7221e5483
  1. 41
      assets/mx/module/battle/combat/DamagePipeline.ts
  2. 120
      assets/script/battle/combat/BattleBuffDamageHook.ts
  3. 9
      assets/script/battle/combat/BattleBuffDamageHook.ts.meta
  4. 377
      assets/script/battle/combat/BattleBuffDigitStatHost.ts
  5. 83
      assets/script/battle/combat/ExecuteRules.ts
  6. 409
      assets/script/battle/combat/PassiveSkillRuntime.ts
  7. 8
      assets/script/battle/heros/Gong.ts
  8. 3
      assets/script/battle/runtime/BattleRuntime.ts
  9. 9
      assets/script/battle/skill/Skill.ts
  10. 11
      assets/script/interface/AutoTable.ts
  11. 123
      assets/script/manager/battleBuff/BattleBuffParamParse.ts
  12. 22
      assets/script/manager/battleBuff/BattleBuffTypes.ts
  13. 52
      docs/方案-Battlebuff扩展105-112.md
  14. 4
      docs/方案-Battlebuff新表实现.md
  15. 1
      preview-template/index.ejs
  16. 364
      preview-template/workbench/css/workbench.css
  17. 404
      preview-template/workbench/js/agent-assets.js
  18. 1
      preview-template/workbench/js/agent-core.js
  19. 161
      preview-template/workbench/js/agent-pick.js
  20. 124
      preview-template/workbench/js/host.js
  21. 264
      preview-template/workbench/js/panels/assets.js

@ -54,6 +54,31 @@ export function setDamageCalcDebug(fn: ((info: DamageCalcDebugInfo) => void) | n
_damageCalcDebug = fn;
}
/** 业务层伤害预调(必暴/技能乘区等);在暴击判定前调用 */
export interface DamagePreCalcState {
/** 当前攻击力(已含 scale/dmg_mul) */
atk: number;
/** 是否强制暴击 */
forceCrit: boolean;
/** 暴击伤害倍率(可追加) */
critDamage: number;
/** 是否把当前暴击率并入爆伤并强制满暴 */
critRateToCritDmg: boolean;
}
export type DamagePreCalcHook = (
ctx: AbilityContext,
params: Record<string, unknown>,
state: DamagePreCalcState,
) => void;
let _damagePreCalc: DamagePreCalcHook | null = null;
/** 注册/清除业务伤害预调 */
export function setDamagePreCalcHook(fn: DamagePreCalcHook | null): void {
_damagePreCalc = fn;
}
interface StrikeHitAfterMeta {
isCrit: boolean;
damageType?: string;
@ -267,9 +292,23 @@ export class DamagePipeline {
let isCrit = false;
let critDamage = this._attributes.getAttr(ctx.caster, AttrId.CritDamage);
const pre: DamagePreCalcState = {
atk,
forceCrit: false,
critDamage,
critRateToCritDmg: false,
};
if (_damagePreCalc) _damagePreCalc(ctx, params, pre);
atk = pre.atk;
critDamage = pre.critDamage;
if (pre.critRateToCritDmg) {
const cr = Math.min(1, this._attributes.getAttr(ctx.caster, AttrId.CritRate));
critDamage += cr * 2;
pre.forceCrit = true;
}
if (this._canCrit(ctx, params)) {
const critRate = Math.min(1, this._attributes.getAttr(ctx.caster, AttrId.CritRate));
if (ctx.rng.next() < critRate) {
if (pre.forceCrit || ctx.rng.next() < critRate) {
isCrit = true;
atk *= critDamage;
}

@ -0,0 +1,120 @@
import type { AbilityContext } from 'db://assets/mx/module/battle/combat/AbilityContext';
import {
setDamagePreCalcHook,
type DamagePreCalcState,
} from 'db://assets/mx/module/battle/combat/DamagePipeline';
import { AttributeComp } from 'db://assets/mx/module/battle/ecs/Components';
import type { EntityId } from 'db://assets/mx/module/ecs';
import { getBattleBuffDigitStatHost } from './BattleBuffDigitStatHost';
import { getPassiveSkillHost } from './PassiveSkillRuntime';
/**
* 从 Ability.id 解析 Skill.id。
* @param abilityId Ability id
*/
export function parseSkillIdFromAbilityId(abilityId: string): number {
const raw = String(abilityId ?? '');
const m = /(?:ability_(?:norm|hero|weapon|monster|buffskill)_|passive_)(\d+)/.exec(raw);
if (!m) return 0;
return Math.floor(Number(m[1]) || 0);
}
/**
* 列表是否含 skillId。
* @param ids id 列表
* @param skillId Skill.id
*/
function listHasSkill(ids: readonly number[], skillId: number): boolean {
for (let i = 0; i < ids.length; i++) {
if (ids[i] === skillId) return true;
}
return false;
}
/**
* 目标是否处于减速(move_speed_mul&lt;1 或 debuff_3 / slow 标签)。
* @param ctx 伤害上下文
* @param target 目标
*/
function targetIsSlowed(ctx: AbilityContext, target: EntityId): boolean {
const world = ctx.services.world;
const attr = world.getComponent(target, AttributeComp);
if (attr) {
for (let i = 0; i < attr.modifiers.length; i++) {
const m = attr.modifiers[i];
if (m.attr === 'move_speed_mul' && m.op === 'Mul' && m.value < 1) return true;
}
}
if (ctx.services.buffs.hasTag(target, 'debuff_3')) return true;
if (ctx.services.buffs.hasTag(target, 'slow')) return true;
return false;
}
/**
* 局内数字 buff 伤害预调:107/110/111/112。
*/
export class BattleBuffDamageHook {
/** 注册到 DamagePipeline */
static install(): void {
setDamagePreCalcHook((ctx, params, state) => {
BattleBuffDamageHook.apply(ctx, params, state);
});
}
/** 清除注册 */
static uninstall(): void {
setDamagePreCalcHook(null);
}
/**
* @param ctx 伤害上下文
* @param params deal 参数
* @param state 可调状态
*/
static apply(ctx: AbilityContext, params: Record<string, unknown>, state: DamagePreCalcState): void {
const flags = getBattleBuffDigitStatHost().getFlags(ctx.caster);
if (!flags) return;
const skillId = parseSkillIdFromAbilityId(ctx.abilityId);
const tableSkillId = Math.floor(Number(params.skillId) || 0);
const sid = skillId > 0 ? skillId : tableSkillId;
for (let i = 0; i < flags.skillMuls.length; i++) {
const row = flags.skillMuls[i];
if (sid > 0 && listHasSkill(row.skillIds, sid)) {
state.atk *= row.mul;
}
}
for (let i = 0; i < flags.forceCritSkills.length; i++) {
const row = flags.forceCritSkills[i];
if (sid > 0 && listHasSkill(row.skillIds, sid)) {
state.forceCrit = true;
if (row.convert) state.critRateToCritDmg = true;
}
}
if (flags.chanceMul && flags.chanceMul.wan > 0 && flags.chanceMul.mul > 1) {
const roll = ctx.rng.next() * 10000;
if (roll < flags.chanceMul.wan) {
state.atk *= flags.chanceMul.mul;
}
}
const target = ctx.targets[0];
if (!(target > 0)) return;
const mode = flags.condCrit;
if (!mode) return;
if (mode === 'always_convert') {
state.forceCrit = true;
state.critRateToCritDmg = true;
return;
}
if (mode === 'poison') {
if (getPassiveSkillHost().hasPoison(target)) state.forceCrit = true;
return;
}
if (mode === 'slow') {
if (targetIsSlowed(ctx, target)) state.forceCrit = true;
}
}
}

@ -0,0 +1,9 @@
{
"ver": "4.0.24",
"importer": "typescript",
"imported": true,
"uuid": "2430878d-c135-416c-9b1f-28d3f2fa2c29",
"files": [],
"subMetas": {},
"userData": {}
}

@ -5,17 +5,39 @@ import { GEvent } from 'db://assets/mx/module/event/GEvent';
import { TableNames } from '../../game/ConfigTableData';
import { BattleBuffCarryStore } from '../../manager/battleBuff/BattleBuffCarryStore';
import { BattleBuffFunctionType } from '../../manager/battleBuff/BattleBuffTypes';
import { BattleBuffTargetParse, parseFunctionParNums } from '../../manager/battleBuff/BattleBuffParamParse';
import {
BattleBuffTargetParse,
parseBattleBuffSkillListMul,
parseFunctionParNums,
} from '../../manager/battleBuff/BattleBuffParamParse';
import { AI_PLAYER_ID } from '../ai/runtime/AiCampArea';
import { isElementHero } from '../config/BattleHeroTable';
import { WeaponComposeConfigProvider } from '../bag/WeaponComposeConfigProvider';
import { BattleMapSpec } from '../map/BattleMapSpec';
import { getTowerLoadoutService } from './TowerLoadoutService';
import type { TowerCombatStats } from './TowerQueryTypes';
import { getPassiveSkillHost } from './PassiveSkillRuntime';
/** modifier source 前缀 */
const DIGIT_STAT_SOURCE = 'battle_buff_digit_';
/** 绑定所需最小战斗句柄(避免 import BattleRuntime 造成循环) */
/** 单塔战斗侧额外效果(105+) */
export interface DigitCombatFlags {
/** 107:技能伤害倍数 */
skillMuls: { skillIds: number[]; mul: number }[];
/** 110:技能必暴(convert=暴击率转爆伤) */
forceCritSkills: { skillIds: number[]; convert: boolean }[];
/** 111:概率多倍伤 */
chanceMul: { wan: number; mul: number } | null;
/** 112:条件必暴 */
condCrit: 'poison' | 'slow' | 'always_convert' | null;
/** 108:穿透加值 */
pierceAdd: number;
/** 109:额外箭 */
extraArrow: number;
}
/** 绑定所需最小战斗句柄 */
interface DigitStatBattleRef {
/** 本机 playerId */
localPlayerId: number;
@ -25,11 +47,24 @@ interface DigitStatBattleRef {
session: BattleSession;
}
/** 场上单位匹配快照 */
interface DigitUnitSnap {
/** 实体 */
entityId: EntityId;
/** 五行 */
heroType: number;
/** 匹配等级 */
level: number;
/** 同五行排名(1=最高) */
rank: number;
}
/**
* 局内 functiontype 101~104:按 target/targetpar 给数字塔加伤/暴击/爆伤/攻速。
* 局内 functiontype 101~112:按 target/targetpar 给数字塔属性/技能/战斗标记。
*/
export class BattleBuffDigitStatHost {
private _battle: DigitStatBattleRef | null = null;
private readonly _flags: { entityId: EntityId; flags: DigitCombatFlags }[] = [];
/** 绑定本局并刷新已部署单位 */
bind(battle: DigitStatBattleRef): void {
@ -41,6 +76,18 @@ export class BattleBuffDigitStatHost {
/** 解除绑定 */
unbind(): void {
this._battle = null;
this._flags.length = 0;
}
/**
* 取塔战斗标记(伤害管线 / 弓箭用)。
* @param entityId 塔
*/
getFlags(entityId: EntityId): DigitCombatFlags | null {
for (let i = 0; i < this._flags.length; i++) {
if (this._flags[i].entityId === entityId) return this._flags[i].flags;
}
return null;
}
/**
@ -64,7 +111,6 @@ export class BattleBuffDigitStatHost {
seen.push(eid);
this.refreshEntity(session, eid, pid);
}
// deployedUnits 与 ECS 偶发不同步时,按 TeamComp+CombatComp 补刷
session.world.view2(CombatComp, TeamComp).forEach((entityId, _combat, team) => {
if (team.ownerId !== pid) return;
if (this._hasSeen(seen, entityId)) return;
@ -72,7 +118,7 @@ export class BattleBuffDigitStatHost {
this.refreshEntity(session, entityId, pid);
});
if (this._hasDigitPassives(pid) && seen.length <= 0) {
console.warn('[BattleBuffDigitStat] 有 101~104 但场上无塔可刷', pid);
console.warn('[BattleBuffDigitStat] 有数字 buff 但场上无塔可刷', pid);
}
GEvent.Ins.emit(GEvent.UpdateBestPlace);
}
@ -94,22 +140,34 @@ export class BattleBuffDigitStatHost {
let aspdAdd = 0;
let critRateAdd = 0;
let critDmgAdd = 0;
let rangeAdd = 0;
const passives = BattleBuffCarryStore.getPassives(pid);
for (let i = 0; i < passives.length; i++) {
const row = passives[i].row as ITableBattlebuff | undefined;
if (!row) continue;
const ft = Math.floor(Number(row.functiontype) || 0);
if (!this._isDigitFt(ft)) continue;
if (!BattleBuffTargetParse.matches(row.target, row.targetpar, heroType, lv)) continue;
if (!this._isStatFt(ft) && ft !== BattleBuffFunctionType.DigitRange) continue;
if (!BattleBuffTargetParse.matches(row.target, row.targetpar, heroType, lv, 1)) continue;
const nums = parseFunctionParNums(row.functionpar);
const wan = nums.length > 0 ? Math.floor(nums[0]) : 0;
if (wan === 0) continue;
if (ft === BattleBuffFunctionType.DigitDamage) attackMul *= 1 + wan / 10000;
else if (ft === BattleBuffFunctionType.DigitCritRate) critRateAdd += wan / 10000;
else if (ft === BattleBuffFunctionType.DigitCritDamage) critDmgAdd += wan / 10000;
else if (ft === BattleBuffFunctionType.DigitAttackSpeed) aspdAdd += wan;
if (ft === BattleBuffFunctionType.DigitDamage) {
if (wan === 0) continue;
attackMul *= 1 + wan / 10000;
} else if (ft === BattleBuffFunctionType.DigitCritRate) {
if (wan === 0) continue;
critRateAdd += wan / 10000;
} else if (ft === BattleBuffFunctionType.DigitCritDamage) {
if (wan === 0) continue;
critDmgAdd += wan / 10000;
} else if (ft === BattleBuffFunctionType.DigitAttackSpeed) {
if (wan === 0) continue;
aspdAdd += wan;
} else if (ft === BattleBuffFunctionType.DigitRange) {
if (wan === 0) continue;
rangeAdd += Math.abs(wan) <= 16 ? wan * BattleMapSpec.CELL_SIZE : wan;
}
}
if (attackMul === 1 && aspdAdd === 0 && critRateAdd === 0 && critDmgAdd === 0) return stats;
if (attackMul === 1 && aspdAdd === 0 && critRateAdd === 0 && critDmgAdd === 0 && rangeAdd === 0) return stats;
const attackSpeed = stats.attackSpeed * (1 + aspdAdd / 10000);
return {
...stats,
@ -118,24 +176,29 @@ export class BattleBuffDigitStatHost {
attackInterval: attackSpeed > 0 ? 1 / attackSpeed : 0,
critRate: Math.min(1, stats.critRate + critRateAdd),
critDamage: stats.critDamage + critDmgAdd,
range: stats.range + rangeAdd,
};
}
/**
* 单塔刷新 101~104 修正。
* 单塔刷新 101~112。
* @param session 会话
* @param entityId 实体
* @param ownerPlayerId 归属
*/
refreshEntity(session: BattleSession, entityId: EntityId, ownerPlayerId: number): void {
this._clearDigitMods(session, entityId);
this._clearFlags(entityId);
const pid = Math.max(0, Math.floor(Number(ownerPlayerId) || 0));
if (!this._hasDigitPassives(pid)) return;
const loadout = getTowerLoadoutService().getLoadout(entityId);
if (!loadout) return;
if (!loadout) {
getPassiveSkillHost().syncBuffSkills(session, entityId, []);
return;
}
const hero = gg.data.table.getTableData<ITableHero>(TableNames.Hero, loadout.weapon1Id);
if (!hero) {
console.warn('[BattleBuffDigitStat] Hero 缺失', loadout.weapon1Id, entityId);
getPassiveSkillHost().syncBuffSkills(session, entityId, []);
return;
}
const heroType = this._resolveElementType(loadout.weapon1Id, hero);
@ -144,50 +207,208 @@ export class BattleBuffDigitStatHost {
if (!world.isAlive(entityId)) return;
if (!world.getComponent(entityId, CombatComp)) {
console.warn('[BattleBuffDigitStat] 无 CombatComp', entityId, loadout.weapon1Id);
getPassiveSkillHost().syncBuffSkills(session, entityId, []);
return;
}
this._applyPassivesToEntity(session, entityId, pid, heroType, level);
}
/**
* 匹配用等级:五行以 Hero.level 为准(weapon1Id 已定级)。
* @param hero Hero 行
* @param loadoutLevel 装载等级
*/
private _resolveMatchLevel(hero: ITableHero, loadoutLevel: number): number {
if (isElementHero(hero)) {
const hlv = Math.floor(Number(hero.level) || 0);
if (hlv > 0) return hlv;
const snaps = this._buildSnaps(session, pid);
let rank = 0;
for (let i = 0; i < snaps.length; i++) {
if (snaps[i].entityId === entityId) {
rank = snaps[i].rank;
break;
}
}
return Math.max(1, Math.floor(Number(loadoutLevel) || 0));
}
/**
* @param session 会话
* @param entityId 实体
* @param playerId 携带方
* @param heroType 五行
* @param level 等级
*/
private _applyPassivesToEntity(session: BattleSession, entityId: EntityId, playerId: number, heroType: number, level: number): void {
const passives = BattleBuffCarryStore.getPassives(playerId);
const grantSkills: number[] = [];
const flags = this._emptyFlags();
const passives = BattleBuffCarryStore.getPassives(pid);
for (let i = 0; i < passives.length; i++) {
const row = passives[i].row as ITableBattlebuff | undefined;
if (!row) continue;
if (!BattleBuffTargetParse.matches(row.target, row.targetpar, heroType, level, rank)) continue;
const ft = Math.floor(Number(row.functiontype) || 0);
if (!this._isDigitFt(ft)) continue;
if (!BattleBuffTargetParse.matches(row.target, row.targetpar, heroType, level)) continue;
const nums = parseFunctionParNums(row.functionpar);
const ft1 = Math.floor(Number(row.functiontype1) || 0);
this._applyPrimary(session, entityId, row, ft, i, grantSkills, flags);
this._applySecondary(row, ft1, flags);
}
this._setFlags(entityId, flags);
getPassiveSkillHost().syncBuffSkills(session, entityId, grantSkills);
}
private _applyPrimary(
session: BattleSession,
entityId: EntityId,
row: ITableBattlebuff,
ft: number,
index: number,
grantSkills: number[],
flags: DigitCombatFlags,
): void {
const source = `${DIGIT_STAT_SOURCE}${row.id}_${index}`;
const nums = parseFunctionParNums(row.functionpar);
if (ft === BattleBuffFunctionType.DigitDamage
|| ft === BattleBuffFunctionType.DigitCritRate
|| ft === BattleBuffFunctionType.DigitCritDamage
|| ft === BattleBuffFunctionType.DigitAttackSpeed) {
const wan = nums.length > 0 ? Math.floor(nums[0]) : 0;
if (wan === 0) {
console.warn('[BattleBuffDigitStat] functionpar 无效', row.id, row.functionpar);
return;
}
this._applyStat(session, entityId, ft, wan, source);
return;
}
if (ft === BattleBuffFunctionType.DigitGrantSkill) {
const skillId = nums.length > 0 ? Math.floor(nums[0]) : 0;
if (!(skillId > 0)) {
console.warn('[BattleBuffDigitStat] ft105 缺 Skill.id', row.id, row.functionpar);
return;
}
grantSkills.push(skillId);
return;
}
if (ft === BattleBuffFunctionType.DigitRange) {
const raw = nums.length > 0 ? Math.floor(nums[0]) : 0;
if (raw === 0) {
console.warn('[BattleBuffDigitStat] ft106 缺距离', row.id, row.functionpar);
return;
}
const addPx = Math.abs(raw) <= 16 ? raw * BattleMapSpec.CELL_SIZE : raw;
this._ensureAttr(session, entityId);
session.attributes.addModifier(entityId, {
attr: AttrId.Range, op: 'Add', value: addPx, source,
});
return;
}
if (ft === BattleBuffFunctionType.DigitPierce) {
const n = nums.length > 0 ? Math.floor(nums[0]) : 0;
if (!(n > 0)) {
console.warn('[BattleBuffDigitStat] ft108 缺穿透', row.id, row.functionpar);
return;
}
flags.pierceAdd += n;
return;
}
if (ft === BattleBuffFunctionType.DigitExtraArrow) {
const n = nums.length > 0 ? Math.floor(nums[0]) : 0;
if (!(n > 0)) {
console.warn('[BattleBuffDigitStat] ft109 缺箭数', row.id, row.functionpar);
return;
}
flags.extraArrow += n;
return;
}
if (ft === BattleBuffFunctionType.DigitChanceMulDmg) {
if (nums.length < 2) {
console.warn('[BattleBuffDigitStat] ft111 需 概率,倍数', row.id, row.functionpar);
return;
}
flags.chanceMul = { wan: Math.floor(nums[0]), mul: Math.floor(nums[1]) };
return;
}
if (ft === BattleBuffFunctionType.DigitCondForceCrit) {
const t = Math.floor(Number(row.target) || 0);
if (t === 2) flags.condCrit = 'poison';
else if (t === 3) flags.condCrit = 'slow';
else if (t === 4) flags.condCrit = 'always_convert';
else console.warn('[BattleBuffDigitStat] ft112 无法从 target 推断模式', row.id, t);
return;
}
if (ft === BattleBuffFunctionType.DigitSkillDmgMul) {
const parsed = parseBattleBuffSkillListMul(row.functionpar);
if (!parsed) {
console.warn('[BattleBuffDigitStat] ft107 functionpar 无效', row.id, row.functionpar);
return;
}
flags.skillMuls.push({ skillIds: this._expandWeaponSkillIds(parsed.skillIds), mul: parsed.mul });
}
}
private _applySecondary(row: ITableBattlebuff, ft1: number, flags: DigitCombatFlags): void {
if (!(ft1 > 0)) return;
const parsed = parseBattleBuffSkillListMul(row.functionpar1);
if (!parsed) {
console.warn('[BattleBuffDigitStat] functiontype1 参数无效', row.id, ft1, row.functionpar1);
return;
}
const skillIds = this._expandWeaponSkillIds(parsed.skillIds);
if (ft1 === BattleBuffFunctionType.DigitSkillDmgMul) {
flags.skillMuls.push({ skillIds, mul: parsed.mul });
return;
}
if (ft1 === BattleBuffFunctionType.DigitSkillForceCrit) {
flags.forceCritSkills.push({ skillIds, convert: true });
return;
}
console.warn('[BattleBuffDigitStat] 未知 functiontype1', row.id, ft1);
}
/**
* Weapon.id 列表展开为 act_skill(Skill.id),便于伤害管线按 Ability 匹配。
* @param weaponIds Weapon.id
*/
private _expandWeaponSkillIds(weaponIds: readonly number[]): number[] {
const out: number[] = [];
for (let i = 0; i < weaponIds.length; i++) {
const wid = weaponIds[i];
const w = gg.data.table.getTableData<ITableWeapon>(TableNames.Weapon, wid);
if (!w) {
console.warn('[BattleBuffDigitStat] 技能列表缺 Weapon', wid);
continue;
}
if (wan > 0 && wan < 100) {
console.warn('[BattleBuffDigitStat] functionpar 过小(疑非万分比)', row.id, wan, row.desc);
const sid = Math.floor(Number(w.act_skill) || 0);
if (!(sid > 0)) {
console.warn('[BattleBuffDigitStat] Weapon 无 act_skill', wid);
continue;
}
if (out.indexOf(sid) < 0) out.push(sid);
}
return out;
}
private _buildSnaps(session: BattleSession, playerId: number): DigitUnitSnap[] {
const battle = this._battle;
const out: DigitUnitSnap[] = [];
const seen: EntityId[] = [];
const push = (entityId: EntityId): void => {
if (this._hasSeen(seen, entityId)) return;
const loadout = getTowerLoadoutService().getLoadout(entityId);
if (!loadout) return;
const hero = gg.data.table.getTableData<ITableHero>(TableNames.Hero, loadout.weapon1Id);
if (!hero) return;
if (!session.world.isAlive(entityId)) return;
if (!session.world.getComponent(entityId, CombatComp)) return;
seen.push(entityId);
out.push({
entityId,
heroType: this._resolveElementType(loadout.weapon1Id, hero),
level: this._resolveMatchLevel(hero, loadout.level),
rank: 0,
});
};
const player = battle?.getPlayer(playerId);
const deployed = player?.deployedUnits ?? [];
for (let i = 0; i < deployed.length; i++) push(deployed[i]);
session.world.view2(CombatComp, TeamComp).forEach((entityId, _c, team) => {
if (team.ownerId !== playerId) return;
push(entityId);
});
for (let elem = 1; elem <= 5; elem++) {
const group: DigitUnitSnap[] = [];
for (let i = 0; i < out.length; i++) {
if (out[i].heroType === elem) group.push(out[i]);
}
this._applyOne(session, entityId, ft, wan, `${DIGIT_STAT_SOURCE}${row.id}_${i}`);
group.sort((a, b) => b.level - a.level || a.entityId - b.entityId);
for (let i = 0; i < group.length; i++) group[i].rank = i + 1;
}
return out;
}
private _resolveMatchLevel(hero: ITableHero, loadoutLevel: number): number {
if (isElementHero(hero)) {
const hlv = Math.floor(Number(hero.level) || 0);
if (hlv > 0) return hlv;
}
return Math.max(1, Math.floor(Number(loadoutLevel) || 0));
}
private _hasSeen(seen: readonly EntityId[], eid: EntityId): boolean {
@ -202,23 +423,32 @@ export class BattleBuffDigitStatHost {
for (let i = 0; i < passives.length; i++) {
const row = passives[i].row as ITableBattlebuff | undefined;
if (!row) continue;
if (this._isDigitFt(Math.floor(Number(row.functiontype) || 0))) return true;
const ft = Math.floor(Number(row.functiontype) || 0);
const ft1 = Math.floor(Number(row.functiontype1) || 0);
if (this._isDigitFt(ft) || ft1 > 0) return true;
}
return false;
}
private _isDigitFt(ft: number): boolean {
private _isStatFt(ft: number): boolean {
return ft === BattleBuffFunctionType.DigitDamage
|| ft === BattleBuffFunctionType.DigitCritRate
|| ft === BattleBuffFunctionType.DigitCritDamage
|| ft === BattleBuffFunctionType.DigitAttackSpeed;
}
/**
* 五行 type:优先 Text.type,否则 Hero.herotype。
* @param weapon1Id Hero.id
* @param hero Hero 行
*/
private _isDigitFt(ft: number): boolean {
return this._isStatFt(ft)
|| ft === BattleBuffFunctionType.DigitGrantSkill
|| ft === BattleBuffFunctionType.DigitRange
|| ft === BattleBuffFunctionType.DigitPierce
|| ft === BattleBuffFunctionType.DigitExtraArrow
|| ft === BattleBuffFunctionType.DigitChanceMulDmg
|| ft === BattleBuffFunctionType.DigitCondForceCrit
|| ft === BattleBuffFunctionType.DigitSkillDmgMul
|| ft === BattleBuffFunctionType.DigitSkillForceCrit;
}
private _resolveElementType(weapon1Id: number, hero: ITableHero): number {
const provider = WeaponComposeConfigProvider.instance;
provider.load();
@ -244,13 +474,17 @@ export class BattleBuffDigitStatHost {
attr.modifiers = next;
}
private _applyOne(session: BattleSession, entityId: EntityId, ft: number, wan: number, source: string): void {
private _ensureAttr(session: BattleSession, entityId: EntityId): void {
if (!session.world.getComponent(entityId, AttributeComp)) {
session.world.addComponent(entityId, new AttributeComp());
}
}
private _applyStat(session: BattleSession, entityId: EntityId, ft: number, wan: number, source: string): void {
const world = session.world;
if (!world.isAlive(entityId)) return;
if (!world.getComponent(entityId, CombatComp)) return;
if (!world.getComponent(entityId, AttributeComp)) {
world.addComponent(entityId, new AttributeComp());
}
this._ensureAttr(session, entityId);
const attrSvc = session.attributes;
if (ft === BattleBuffFunctionType.DigitDamage) {
attrSvc.addModifier(entityId, {
@ -276,6 +510,35 @@ export class BattleBuffDigitStatHost {
});
}
}
private _emptyFlags(): DigitCombatFlags {
return {
skillMuls: [],
forceCritSkills: [],
chanceMul: null,
condCrit: null,
pierceAdd: 0,
extraArrow: 0,
};
}
private _clearFlags(entityId: EntityId): void {
for (let i = this._flags.length - 1; i >= 0; i--) {
if (this._flags[i].entityId === entityId) this._flags.splice(i, 1);
}
}
private _setFlags(entityId: EntityId, flags: DigitCombatFlags): void {
this._clearFlags(entityId);
const empty = flags.skillMuls.length <= 0
&& flags.forceCritSkills.length <= 0
&& !flags.chanceMul
&& !flags.condCrit
&& flags.pierceAdd <= 0
&& flags.extraArrow <= 0;
if (empty) return;
this._flags.push({ entityId, flags });
}
}
let _host: BattleBuffDigitStatHost | null = null;

@ -1,3 +1,4 @@
import type { BattleSession } from 'db://assets/mx/module/battle';
import type { EntityId } from 'db://assets/mx/module/ecs';
import { createAbilityContext } from 'db://assets/mx/module/battle/combat/AbilityContext';
import { EntityTag, HealthComp, TagComp } from 'db://assets/mx/module/battle';
@ -5,10 +6,10 @@ import type { SkillPresentationContext } from '../skill/SkillPresentationTypes';
import { SkillPresentationEnv } from '../skill/SkillPresentationEnv';
/** Boss 不可被秒杀/斩杀 */
export function isBossEntity(entityId: EntityId): boolean {
const session = SkillPresentationEnv.getBattleSession();
if (!session?.world.isAlive(entityId)) return false;
return !!session.world.getComponent(entityId, TagComp)?.tags.includes(EntityTag.Boss);
export function isBossEntity(entityId: EntityId, session?: BattleSession | null): boolean {
const s = session ?? SkillPresentationEnv.getBattleSession();
if (!s?.world.isAlive(entityId)) return false;
return !!s.world.getComponent(entityId, TagComp)?.tags.includes(EntityTag.Boss);
}
/** 聚合已选词条 skill_special(含普攻 up_type=1) */
@ -49,6 +50,38 @@ function dealExecuteDamage(
return amount > 0;
}
/**
* 会话侧斩杀/秒杀扣血。
* @param session 战斗会话
* @param caster 来源
* @param target 目标
* @param damageType 伤害类型
* @param fromPassiveResult 是否被动 result
*/
function dealExecuteDamageSession(
session: BattleSession,
caster: EntityId,
target: EntityId,
damageType: string,
fromPassiveResult: boolean,
): boolean {
if (!session.world.isAlive(target)) return false;
const floatPos = SkillPresentationEnv.resolveKillFloaterWorldPos(target);
const abilityCtx = createAbilityContext(session, caster, `passive_execute_${damageType}`, [target]);
const amount = session.damage.dealToTarget(caster, target, {
percentMaxHpWan: 10000,
scale: 1,
damageType,
fromPassiveResult,
skillAtkBuff1: '',
skillAtkBuff2: '',
}, abilityCtx);
if (amount > 0 && floatPos) {
SkillPresentationEnv.playKillFloater(floatPos, damageType);
}
return amount > 0;
}
/** skill_special 类型 10:概率秒杀(Boss 无效) */
export function tryInstantKill(
ctx: SkillPresentationContext,
@ -78,3 +111,45 @@ export function tryExecuteByThreshold(
if (hp.hp / hp.maxHp > thresholdWan / 10000) return false;
return dealExecuteDamage(ctx, targetId, damageType);
}
/**
* 被动 result 16:概率秒杀。
* @param session 会话
* @param caster 来源
* @param target 目标
* @param killWan 概率万分比
*/
export function tryInstantKillSession(
session: BattleSession,
caster: EntityId,
target: EntityId,
killWan: number,
): boolean {
if (killWan <= 0 || isBossEntity(target, session)) return false;
if (session.rng.next() * 10000 >= killWan) return false;
return dealExecuteDamageSession(session, caster, target, 'instant_kill', true);
}
/**
* 被动 result 15:低血量斩杀(可再掷概率)。
* @param session 会话
* @param caster 来源
* @param target 目标
* @param thresholdWan 血量比例万分比
* @param chanceWan 斩杀概率万分比
*/
export function tryExecuteByThresholdSession(
session: BattleSession,
caster: EntityId,
target: EntityId,
thresholdWan: number,
chanceWan = 10000,
): boolean {
if (thresholdWan <= 0 || isBossEntity(target, session)) return false;
if (!session.world.isAlive(target)) return false;
const hp = session.world.getComponent(target, HealthComp);
if (!hp || hp.maxHp <= 0) return false;
if (hp.hp / hp.maxHp > thresholdWan / 10000) return false;
if (chanceWan < 10000 && session.rng.next() * 10000 >= chanceWan) return false;
return dealExecuteDamageSession(session, caster, target, 'execute', true);
}

@ -1,8 +1,9 @@
import type { BattleSession } from 'db://assets/mx/module/battle';
import type { AbilityDef, BattleSession } from 'db://assets/mx/module/battle';
import { BattleEventFactory, BattleTeamId } from 'db://assets/mx/module/battle';
import { createAbilityContext } from 'db://assets/mx/module/battle/combat/AbilityContext';
import { VAR_LAST_DAMAGE_AMOUNT } from 'db://assets/mx/module/battle/combat/DamagePipeline';
import {
AbilitySetComp,
AttrId,
AttributeComp,
HealthComp,
@ -14,7 +15,9 @@ import {
} from 'db://assets/mx/module/battle/ecs/Components';
import type { EntityId } from 'db://assets/mx/module/ecs';
import { TableNames } from '../../game/ConfigTableData';
import { SkillAbilityBuilder } from '../config/baseAtk/SkillAbilityBuilder';
import { applySkillTableKnockback } from './AtkHookEffects';
import { tryExecuteByThresholdSession, tryInstantKillSession } from './ExecuteRules';
import { applySkillAtkBuffBoardEffect } from './SkillAtkBuffBoardBridge';
import { SkillAtkBuffExecutor } from './SkillAtkBuffExecutor';
@ -85,12 +88,28 @@ export enum PassiveResultKind {
IceConeSplit = 13,
/** 爆炸:圆形半径像素,伤害系数万分比 */
Explosion = 14,
/** 低血量斩杀:剩余血量比例万分比,斩杀概率万分比 */
ExecuteLowHp = 15,
/** 秒杀(Boss 无效):秒杀概率万分比 */
InstantKill = 16,
/** 上毒(同 8):间隔秒,伤害系数万分比,持续秒 */
Poison17 = 17,
/** 寒潭减速:半径像素,减速比例万分比,持续秒 */
GroundSlow = 18,
/** 目标身后扇形:角度,半径像素,伤害系数万分比 */
BehindFan = 19,
/** 击杀叠伤害:加成万分比,最大叠加次数 */
KillDmgStack = 20,
/** 空 result:按行 atk_type 施放一次 */
CastAtkRow = 0,
}
/** 攻速 modifier group 前缀 */
const ASPD_GROUP = 'passive_aspd_';
/** 射程 modifier group 前缀 */
const RANGE_GROUP = 'passive_range_';
/** 击杀叠伤 modifier group 前缀 */
const KILL_DMG_GROUP = 'passive_kill_dmg_';
/** 毒跳伤害类型(不回触发 condition) */
const POISON_DMG = 'passive_poison';
/** 剑阵跳伤类型(不回触发 condition) */
@ -107,6 +126,10 @@ const LIGHTNING_CHAIN_DMG = 'passive_lightning_chain';
const EXPLOSION_DMG = 'passive_explosion';
/** 分裂冰锥伤害类型(不回触发 condition) */
const ICE_CONE_DMG = 'passive_ice_cone';
/** 扇形额外伤类型 */
const BEHIND_FAN_DMG = 'passive_behind_fan';
/** 寒潭减速 modifier source */
const GROUND_SLOW_SRC = 'passive_ground_slow';
/** 分裂冰锥飞行像素/秒(与水弹一致) */
const ICE_CONE_SPEED = 1280;
@ -273,7 +296,7 @@ export interface PassiveSkillBind {
hitTargets: EntityId[];
/** 已累计释放次数(condition 6) */
castCount: number;
/** 攻速当前层数(result 2) */
/** 攻速当前层数(result 2)/击杀叠伤层数(result 20) */
stackCount: number;
/** 攻速剩余持续(秒) */
stackLeft: number;
@ -281,6 +304,30 @@ export interface PassiveSkillBind {
atkBuff1: string;
/** Skill.atk_buff2 原文(空=无) */
atkBuff2: string;
/** 是否来自 Battlebuff ft=105 */
fromBuff: boolean;
/** 空 result 时施放用 atk_type */
atkType: string;
/** 空 result 时施放用 atk_para */
atkPara: string;
}
/** 寒潭地面减速区 */
interface GroundSlowZone {
/** 施法者 */
caster: EntityId;
/** Skill.id */
skillId: number;
/** 圆心 X */
x: number;
/** 圆心 Y */
y: number;
/** 半径像素 */
radius: number;
/** 减速万分比(正=减速) */
slowWan: number;
/** 剩余秒 */
left: number;
}
/** 毒 DoT */
@ -459,6 +506,7 @@ export class PassiveSkillHost {
private readonly _binds: { entityId: EntityId; list: PassiveSkillBind[] }[] = [];
private readonly _dots: PoisonDot[] = [];
private readonly _zones: SwordArrayZone[] = [];
private readonly _groundSlows: GroundSlowZone[] = [];
private readonly _lineDragons: LineDragonFlight[] = [];
private readonly _wavePending: SwordWavePending[] = [];
private readonly _waves: SwordWaveFlight[] = [];
@ -483,6 +531,7 @@ export class PassiveSkillHost {
this._binds.length = 0;
this._dots.length = 0;
this._zones.length = 0;
this._groundSlows.length = 0;
this._lineDragons.length = 0;
this._wavePending.length = 0;
this._waves.length = 0;
@ -500,6 +549,7 @@ export class PassiveSkillHost {
this._stripMods(entityId);
this._removeDotsByCaster(entityId);
this._removeZonesByCaster(entityId);
this._removeGroundSlowsByCaster(entityId);
this._removeWavesByCaster(entityId);
this._removeChainsByCaster(entityId);
this._removeIceConesByCaster(entityId);
@ -515,17 +565,87 @@ export class PassiveSkillHost {
*/
bindFromLoadout(session: BattleSession, entityId: EntityId, loadout: PassiveLoadoutBind): void {
this._session = session;
const keptBuff: PassiveSkillBind[] = [];
const prev = this._indexOf(entityId);
if (prev >= 0) {
const old = this._binds[prev].list;
for (let i = 0; i < old.length; i++) {
if (old[i].fromBuff) keptBuff.push(old[i]);
}
}
this.unbind(entityId);
const list: PassiveSkillBind[] = [];
if (loadout.weaponSkillId > 0 && !loadout.weaponSkillActive) {
const bind = this._parseSkill(loadout.weaponSkillId);
const bind = this._parseSkill(loadout.weaponSkillId, false);
if (bind) list.push(bind);
}
for (let i = 0; i < keptBuff.length; i++) list.push(keptBuff[i]);
if (list.length === 0) return;
this._binds.push({ entityId, list });
this._fireAlways(session, entityId, list);
}
/**
* 同步 Battlebuff ft=105 挂载的被动 Skill(替换旧 buff 源)。
* @param session 会话
* @param entityId 塔
* @param skillIds Skill.id 列表
*/
syncBuffSkills(session: BattleSession, entityId: EntityId, skillIds: readonly number[]): void {
this._session = session;
const idx = this._indexOf(entityId);
const weaponList: PassiveSkillBind[] = [];
const oldBuffById: { skillId: number; bind: PassiveSkillBind }[] = [];
if (idx >= 0) {
const old = this._binds[idx].list;
for (let i = 0; i < old.length; i++) {
if (!old[i].fromBuff) weaponList.push(old[i]);
else oldBuffById.push({ skillId: old[i].skillId, bind: old[i] });
}
}
const buffList: PassiveSkillBind[] = [];
const newly: PassiveSkillBind[] = [];
const seen: number[] = [];
for (let i = 0; i < skillIds.length; i++) {
const sid = Math.floor(Number(skillIds[i]) || 0);
if (!(sid > 0)) continue;
if (seen.indexOf(sid) >= 0) continue;
seen.push(sid);
let kept: PassiveSkillBind | null = null;
for (let j = 0; j < oldBuffById.length; j++) {
if (oldBuffById[j].skillId === sid) {
kept = oldBuffById[j].bind;
break;
}
}
if (kept) {
buffList.push(kept);
continue;
}
const bind = this._parseSkill(sid, true);
if (!bind) continue;
buffList.push(bind);
newly.push(bind);
}
const list = weaponList.concat(buffList);
if (idx >= 0) this._binds.splice(idx, 1);
if (list.length <= 0) return;
this._binds.push({ entityId, list });
if (newly.length > 0) this._fireAlways(session, entityId, newly);
}
/**
* 目标是否处于本宿主施加的毒 DoT。
* @param target 目标
*/
hasPoison(target: EntityId): boolean {
if (!(target > 0)) return false;
for (let i = 0; i < this._dots.length; i++) {
if (this._dots[i].target === target && this._dots[i].left > 0) return true;
}
return false;
}
/**
* 查询实体已绑定被动。
* @param entityId 塔实体
@ -540,6 +660,7 @@ export class PassiveSkillHost {
* @param dt 逻辑秒
*/
tick(dt: number): void {
const session = this._session;
for (let i = 0; i < this._binds.length; i++) {
const entityId = this._binds[i].entityId;
const list = this._binds[i].list;
@ -553,10 +674,17 @@ export class PassiveSkillHost {
this._session?.attributes.removeModifiersByGroup(entityId, ASPD_GROUP + b.skillId);
}
}
if (session
&& b.conditionKind === PassiveConditionKind.Always
&& b.cd > 0
&& b.cdLeft <= 0) {
this._fire(session, entityId, 0, b, 0);
}
}
}
this._tickDots(dt);
this._tickZones(dt);
this._tickGroundSlows(dt);
this._tickLineDragons(dt);
this._tickSwordWavePending(dt);
this._tickSwordWaves(dt);
@ -632,6 +760,7 @@ export class PassiveSkillHost {
* @param target 主目标(可 0)
*/
onAbilityCast(session: BattleSession, caster: EntityId, target: EntityId): boolean {
if (this._resultGate > 0) return false;
const idx = this._indexOf(caster);
if (idx < 0) return false;
const list = this._binds[idx].list;
@ -831,6 +960,27 @@ export class PassiveSkillHost {
case PassiveResultKind.Explosion:
this._applyExplosion(session, caster, target, bind, p);
return;
case PassiveResultKind.ExecuteLowHp:
tryExecuteByThresholdSession(session, caster, target, p[0] || 0, p[1] ?? 10000);
return;
case PassiveResultKind.InstantKill:
tryInstantKillSession(session, caster, target, p[0] || 0);
return;
case PassiveResultKind.Poison17:
this._applyPoison(caster, target, bind.skillId, p);
return;
case PassiveResultKind.GroundSlow:
this._applyGroundSlow(session, caster, target, bind.skillId, p);
return;
case PassiveResultKind.BehindFan:
this._applyBehindFan(session, caster, target, bind, p);
return;
case PassiveResultKind.KillDmgStack:
this._applyKillDmgStack(session, caster, bind, p);
return;
case PassiveResultKind.CastAtkRow:
this._applyCastAtkRow(session, caster, target, bind);
return;
default:
console.log(`[PassiveSkill] 未知 result skill=${bind.skillId} kind=${kind}`);
}
@ -1980,6 +2130,7 @@ export class PassiveSkillHost {
const id = list[i].skillId;
session.attributes.removeModifiersByGroup(entityId, ASPD_GROUP + id);
session.attributes.removeModifiersByGroup(entityId, RANGE_GROUP + id);
session.attributes.removeModifiersByGroup(entityId, KILL_DMG_GROUP + id);
}
}
@ -2042,8 +2193,9 @@ export class PassiveSkillHost {
/**
* 解析 Skill.condition / result。
* @param skillId Skill.id
* @param fromBuff 是否 Battlebuff 挂载
*/
private _parseSkill(skillId: number): PassiveSkillBind | null {
private _parseSkill(skillId: number, fromBuff: boolean): PassiveSkillBind | null {
const row = gg.data.table.getTableData<ITableSkill>(TableNames.Skill, skillId);
if (!row) {
console.log(`[PassiveSkill] 缺表 Skill.id=${skillId}`);
@ -2052,12 +2204,17 @@ export class PassiveSkillHost {
if (Number(row.type) !== 2) return null;
const cond = String(row.condition ?? '').trim();
const result = String(row.result ?? '').trim();
if (!cond || !result) {
console.log(`[PassiveSkill] condition/result 为空 Skill.id=${skillId}`);
const atkType = String(row.atk_type ?? '').trim();
if (!cond) {
console.log(`[PassiveSkill] condition 为空 Skill.id=${skillId}`);
return null;
}
if (!result && !atkType) {
console.log(`[PassiveSkill] result/atk_type 皆空 Skill.id=${skillId}`);
return null;
}
const condParts = cond.split(',').map(Number);
const resultParts = result.split(',').map(Number);
const resultParts = result ? result.split(',').map(Number) : [PassiveResultKind.CastAtkRow];
const conditionKind = condParts[0] || 0;
if (conditionKind < 1 || conditionKind > 6) {
console.log(`[PassiveSkill] 未知 condition skill=${skillId} kind=${conditionKind}`);
@ -2077,9 +2234,247 @@ export class PassiveSkillHost {
stackLeft: 0,
atkBuff1: String(row.atk_buff1 ?? ''),
atkBuff2: String(row.atk_buff2 ?? ''),
fromBuff,
atkType,
atkPara: String(row.atk_para ?? ''),
};
}
/**
* 空 result:注册一次性 Ability 并 tryCast。
* @param session 会话
* @param caster 施法者
* @param target 主目标
* @param bind 绑定
*/
private _applyCastAtkRow(session: BattleSession, caster: EntityId, target: EntityId, bind: PassiveSkillBind): void {
const row = gg.data.table.getTableData<ITableSkill>(TableNames.Skill, bind.skillId);
if (!row) {
console.log(`[PassiveSkill] CastAtkRow 缺表 skill=${bind.skillId}`);
return;
}
if (!String(row.atk_type ?? '').trim()) {
console.log(`[PassiveSkill] CastAtkRow 无 atk_type skill=${bind.skillId}`);
return;
}
const abilityId = `ability_buffskill_${bind.skillId}_e${caster}`;
const def = SkillAbilityBuilder.buildNormal(row, abilityId) as AbilityDef;
def.triggers = [];
// 被动主动施放:已有主目标时不再挡 HasTargetInRange
def.conditions = [];
gg.battle.config.registerAbility(abilityId, def);
const set = session.world.getComponent(caster, AbilitySetComp);
if (!set) {
console.log(`[PassiveSkill] CastAtkRow 无 AbilitySet skill=${bind.skillId}`);
return;
}
let found = false;
for (let i = 0; i < set.abilityIds.length; i++) {
if (set.abilityIds[i] === abilityId) {
found = true;
break;
}
}
if (!found) set.abilityIds.push(abilityId);
session.abilities.bindEntity(caster, { preserveRuntime: true });
const ok = session.abilities.tryCast(caster, abilityId, target > 0 ? target : undefined);
if (!ok) {
console.log(`[PassiveSkill] CastAtkRow tryCast 失败 skill=${bind.skillId} target=${target}`);
}
}
/**
* 寒潭:落点圆形持续减速。
* @param session 会话
* @param caster 施法者
* @param target 落点
* @param skillId Skill.id
* @param p 半径,减速万分比,持续秒
*/
private _applyGroundSlow(session: BattleSession, caster: EntityId, target: EntityId, skillId: number, p: number[]): void {
const radius = p[0];
const slowWan = p[1];
const dur = p[2];
if (!(radius > 0) || !(slowWan > 0) || !(dur > 0)) {
console.log(`[PassiveSkill] result 18 参数无效 skill=${skillId}`, p.join(','));
return;
}
const pos = this._logicPos(session, target > 0 ? target : caster);
if (!pos) {
console.log(`[PassiveSkill] result 18 无落点 skill=${skillId}`);
return;
}
for (let i = 0; i < this._groundSlows.length; i++) {
const z = this._groundSlows[i];
if (z.caster === caster && z.skillId === skillId) {
z.x = pos.x;
z.y = pos.y;
z.radius = radius;
z.slowWan = slowWan;
z.left = dur;
return;
}
}
this._groundSlows.push({
caster, skillId, x: pos.x, y: pos.y, radius, slowWan, left: dur,
});
}
/** 推进寒潭并刷新圈内减速 */
private _tickGroundSlows(dt: number): void {
const session = this._session;
if (!session) return;
for (let i = this._groundSlows.length - 1; i >= 0; i--) {
const z = this._groundSlows[i];
z.left -= dt;
if (z.left <= 0) {
this._groundSlows.splice(i, 1);
continue;
}
this._applyGroundSlowPulse(session, z);
}
}
/**
* 对寒潭圈内敌对单位施加减速。
* @param session 会话
* @param z 区域
*/
private _applyGroundSlowPulse(session: BattleSession, z: GroundSlowZone): void {
const casterTeam = session.world.getComponent(z.caster, TeamComp);
const cell = session.grid.worldToCell(z.x, z.y);
const radiusCells = z.radius / Math.max(1, session.grid.cellSize);
const ids = session.spatial.queryRadius(cell, radiusCells + 1);
const r2 = z.radius * z.radius;
const mul = Math.max(0.05, 1 - z.slowWan / 10000);
for (let i = 0; i < ids.length; i++) {
const id = ids[i];
if (!session.world.isAlive(id)) continue;
if (!BattleTeamId.isHostile(casterTeam, session.world.getComponent(id, TeamComp))) continue;
const pos = this._logicPos(session, id);
if (!pos) continue;
const dx = pos.x - z.x;
const dy = pos.y - z.y;
if (dx * dx + dy * dy > r2) continue;
if (!session.world.getComponent(id, AttributeComp)) {
session.world.addComponent(id, new AttributeComp());
}
session.attributes.removeModifiersByGroup(id, GROUND_SLOW_SRC + z.skillId);
session.attributes.addModifier(id, {
attr: 'move_speed_mul',
op: 'Mul',
value: mul,
source: GROUND_SLOW_SRC + z.skillId,
group: GROUND_SLOW_SRC + z.skillId,
remain: 0.2,
});
const move = session.world.getComponent(id, MovementComp);
if (move) {
const base = move.baseSpeed > 0 ? move.baseSpeed : move.speed;
move.speed = Math.max(0, base * mul);
}
}
}
private _removeGroundSlowsByCaster(caster: EntityId): void {
for (let i = this._groundSlows.length - 1; i >= 0; i--) {
if (this._groundSlows[i].caster === caster) this._groundSlows.splice(i, 1);
}
}
/**
* 目标身后扇形伤害。
* @param session 会话
* @param caster 施法者
* @param target 命中目标
* @param bind 绑定
* @param p 角度,半径,伤害万分比
*/
private _applyBehindFan(session: BattleSession, caster: EntityId, target: EntityId, bind: PassiveSkillBind, p: number[]): void {
const angle = p[0];
const radius = p[1];
const coeffWan = p[2];
if (!(angle > 0) || !(radius > 0) || !(coeffWan > 0)) {
console.log(`[PassiveSkill] result 19 参数无效 skill=${bind.skillId}`, p.join(','));
return;
}
const origin = this._logicPos(session, target > 0 ? target : caster);
const from = this._logicPos(session, caster);
if (!origin || !from) {
console.log(`[PassiveSkill] result 19 无坐标 skill=${bind.skillId}`);
return;
}
let ax = origin.x - from.x;
let ay = origin.y - from.y;
const len = Math.sqrt(ax * ax + ay * ay);
if (!(len > 0.001)) {
ax = 0;
ay = 1;
} else {
ax /= len;
ay /= len;
}
const halfRad = (angle * 0.5 * Math.PI) / 180;
const cosMin = Math.cos(halfRad);
const scale = coeffWan / 10000;
const casterTeam = session.world.getComponent(caster, TeamComp);
const cell = session.grid.worldToCell(origin.x, origin.y);
const radiusCells = radius / Math.max(1, session.grid.cellSize);
const ids = session.spatial.queryRadius(cell, radiusCells + 1);
const r2 = radius * radius;
this._beginResultGate();
try {
for (let i = 0; i < ids.length; i++) {
const id = ids[i];
if (id === caster || id === target) continue;
if (!session.world.isAlive(id)) continue;
const hp = session.world.getComponent(id, HealthComp);
if (!hp || !(hp.hp > 0)) continue;
if (!BattleTeamId.isHostile(casterTeam, session.world.getComponent(id, TeamComp))) continue;
const pos = this._logicPos(session, id);
if (!pos) continue;
const dx = pos.x - origin.x;
const dy = pos.y - origin.y;
const d2 = dx * dx + dy * dy;
if (d2 > r2 || d2 <= 0.0001) continue;
const d = Math.sqrt(d2);
const dot = (dx / d) * ax + (dy / d) * ay;
if (dot < cosMin) continue;
this._dealResultDamage(session, caster, id, bind.skillId, scale, BEHIND_FAN_DMG);
}
session.events.flush();
} finally {
this._endResultGate();
}
}
/**
* 击杀叠伤害(永久叠至上限)。
* @param session 会话
* @param caster 施法者
* @param bind 绑定
* @param p 每层万分比,最大层数
*/
private _applyKillDmgStack(session: BattleSession, caster: EntityId, bind: PassiveSkillBind, p: number[]): void {
const bonusWan = p[0];
const maxStack = p[1];
if (!(bonusWan > 0) || !(maxStack > 0)) {
console.log(`[PassiveSkill] result 20 参数无效 skill=${bind.skillId}`, p.join(','));
return;
}
this._ensureAttr(session, caster);
bind.stackCount = Math.min(Math.floor(maxStack), bind.stackCount + 1);
const group = KILL_DMG_GROUP + bind.skillId;
session.attributes.removeModifiersByGroup(caster, group);
session.attributes.addModifier(caster, {
attr: 'dmg_mul',
op: 'Add',
value: (bonusWan / 10000) * bind.stackCount,
source: group,
group,
});
}
/**
* 查找实体绑定下标。
* @param entityId 塔实体

@ -13,6 +13,7 @@ import { HeroSkin } from '../view/HeroSkin';
import { BattleSpinePlayback } from '../presentation/BattleSpinePlayback';
import { AttrId } from 'db://assets/mx/module/battle/ecs/Components';
import { BOW_ATK_ARROW_SPEED, SCATTER_RANGE_MUL, getBowAtkHost } from '../combat/BowAtkRuntime';
import { getBattleBuffDigitStatHost } from '../combat/BattleBuffDigitStatHost';
import { HeroActor } from './HeroActor';
const { ccclass } = _decorator;
@ -746,7 +747,8 @@ export class Gong extends HeroActor {
*/
private _launchScatterVolley(ctx: SkillPresentationContext, destId: EntityId, shape: number): void {
const parts = String(ctx.params.atkPara ?? '').split(',').map(Number);
const count = Math.floor(parts[0] || 0);
const flags = getBattleBuffDigitStatHost().getFlags(ctx.caster);
const count = Math.floor(parts[0] || 0) + (flags?.extraArrow ?? 0);
const stepDeg = parts[1];
const wan = parts[2];
const rangePx = SkillPresentationEnv.getCasterAttr(ctx.caster, AttrId.Range);
@ -806,8 +808,8 @@ export class Gong extends HeroActor {
buff1: String(ctx.params.buff1 ?? ''),
buff2: String(ctx.params.buff2 ?? ''),
hitIds: [],
pierce: shape !== 17,
onConsume: shape === 17 ? this._consumeScatterClone.bind(this, clone, flyLayer, spawnAtMs) : undefined,
pierce: shape !== 17 || (flags?.pierceAdd ?? 0) > 0,
onConsume: shape === 17 && !(flags?.pierceAdd) ? this._consumeScatterClone.bind(this, clone, flyLayer, spawnAtMs) : undefined,
});
}
}

@ -26,6 +26,7 @@ import { getBattleBuffMoveSpeedHost } from '../combat/BattleBuffMoveSpeedHost';
import { getBattleBuffUnitAttackSpeedHost } from '../combat/BattleBuffUnitAttackSpeedHost';
import { getBattleBuffBaseThunderHost } from '../combat/BattleBuffBaseThunderHost';
import { getBattleBuffDigitStatHost } from '../combat/BattleBuffDigitStatHost';
import { BattleBuffDamageHook } from '../combat/BattleBuffDamageHook';
import { WeaponDropService } from '../combat/WeaponDropService';
import { BattleBuffCarryStore } from '../../manager/battleBuff/BattleBuffCarryStore';
import {
@ -511,6 +512,7 @@ export class BattleRuntime implements IBattleRuntimeHandle {
this.session.holdAbilityTickPredicate = this._holdAbilityTick;
this._bindSoldEvents();
this._bindPassiveSkillEvents();
BattleBuffDamageHook.install();
this.session.events.on(BattleEventType.WaveStart, this._onWaveStart);
getTowerLevelDownHost().bindCombatRefresh(this._refreshLevelDownCombat);
SkillAtkBuffBoardViewBind.bindActors(HeroActorHost.instance);
@ -1224,6 +1226,7 @@ export class BattleRuntime implements IBattleRuntimeHandle {
getBattleBuffUnitAttackSpeedHost().unbind();
getBattleBuffBaseThunderHost().unbind();
getBattleBuffDigitStatHost().unbind();
BattleBuffDamageHook.uninstall();
this._appliedBaseHp.length = 0;
getNezhaChargeHost().abortAll();
getEarthChargeHost().abortAll();

@ -33,6 +33,7 @@ import { getStaffSmashHost } from '../presentation/StaffSmashPresenter';
import { SkillPresentationEnv } from './SkillPresentationEnv';
import { SkillShowEffectPlayer } from './SkillShowEffect';
import { AttrId } from 'db://assets/mx/module/battle/ecs/Components';
import { getBattleBuffDigitStatHost } from '../combat/BattleBuffDigitStatHost';
export type { SkillPresentationContext, SkillAttackFlowSnapshot } from './SkillPresentationTypes';
const { ccclass } = _decorator;
@ -689,7 +690,8 @@ export class Skill extends Component {
private _setupVolley(ctx: SkillPresentationContext): boolean {
const raw = String(ctx.params.atkPara ?? '');
const parts = raw.split(',').map((s) => Number(String(s).trim()));
const count = Math.floor(parts[0] || 0);
const flags = getBattleBuffDigitStatHost().getFlags(ctx.caster);
const count = Math.floor(parts[0] || 0) + (flags?.extraArrow ?? 0);
const interval = Number(parts[1]);
const wan = Number(parts[2]);
const target = ctx.targets[0] ?? 0;
@ -717,12 +719,13 @@ export class Skill extends Component {
*/
private _doScatter(ctx: SkillPresentationContext): void {
const parts = String(ctx.params.atkPara ?? '').split(',').map(Number);
const count = Math.floor(parts[0] || 0);
const flags = getBattleBuffDigitStatHost().getFlags(ctx.caster);
const count = Math.floor(parts[0] || 0) + (flags?.extraArrow ?? 0);
const stepDeg = parts[1];
const wan = parts[2];
const target = ctx.targets[0] ?? 0;
const head = Math.floor(Number(ctx.params.atkTypeHead ?? ctx.params.atkType) || 0);
const pierce = head !== 17;
const pierce = head !== 17 || (flags?.pierceAdd ?? 0) > 0;
const rangePx = SkillPresentationEnv.getCasterAttr(ctx.caster, AttrId.Range);
if (!(count > 0) || !(stepDeg > 0) || !(wan > 0) || !(rangePx > 0) || !(target > 0)) {
console.log('[Skill] atk_type=12/17 参数无效', ctx.params.atkPara, rangePx, target);

@ -377,7 +377,7 @@ declare global {
12、损毁基础字,参数:概率
13、冰冻,参数:概率,冰冻时间
14、点燃,参数:伤害间隔,伤害系数,持续时间
15、毒,参数:伤害间隔,伤害系数,持续时间 */
*/
readonly atk_buff1: string;
/** 命中后效果:buff类型,参数
1、眩晕,参数:概率,眩晕时长
@ -386,10 +386,15 @@ declare global {
4、血量上限,参数:概率,上限系数
5、添加桃子,参数:数量
6、提升攻速,参数:概率,攻速加成,持续时间
7、降低等级,参数:概率,降低等级幅度
7、降低等级,参数:概率,降低等级幅度,持续时间
8、混乱,参数:概率,混乱时长
9、改变地块状态,参数:无
10、恢复血量,参数:回血比例(最大满血) */
10、恢复血量,参数:回血比例(最大满血)
11、攻击距离增加,参数:距离加值,持续时间(-1为本场永久生效)
12、损毁基础字,参数:概率
13、冰冻,参数:概率,冰冻时间
14、点燃,参数:伤害间隔,伤害系数,持续时间
*/
readonly atk_buff2: string;
/** 额外效果
1、哪吒效果:吒字飞出去,反向穿透冲锋,对沿途怪物造成伤害,参数:冲锋速度,持续时间,伤害系数

@ -238,40 +238,107 @@ export interface BattleBuffTargetLevelRange {
maxLv: number;
}
/** targetpar 解析结果 */
export type BattleBuffTargetPar =
| { kind: 1; minLv: number; maxLv: number }
| { kind: 2; topN: number };
/** functiontype1=107/110:技能 id 列表 + 倍率 */
export interface BattleBuffSkillListMul {
/** Weapon/Skill id 列表 */
skillIds: number[];
/** 伤害倍数(如 3) */
mul: number;
}
/**
* 解析 `id|id|…,倍数`。
* @param raw functionpar1
*/
export function parseBattleBuffSkillListMul(raw: string | number | null | undefined): BattleBuffSkillListMul | null {
const text = String(raw ?? '').trim();
if (!text) return null;
const comma = text.lastIndexOf(',');
if (comma < 0) {
console.warn('[BattleBuff] functionpar1 缺倍数', raw);
return null;
}
const mul = Math.floor(Number(text.slice(comma + 1).trim()) || 0);
if (!(mul > 0)) {
console.warn('[BattleBuff] functionpar1 倍数非法', raw);
return null;
}
const idPart = text.slice(0, comma).trim();
if (!idPart) {
console.warn('[BattleBuff] functionpar1 缺技能 id', raw);
return null;
}
const segs = idPart.split('|');
const skillIds: number[] = [];
for (let i = 0; i < segs.length; i++) {
const id = Math.floor(Number(segs[i].trim()) || 0);
if (!(id > 0)) {
console.warn('[BattleBuff] functionpar1 含非法 id', raw, segs[i]);
continue;
}
skillIds.push(id);
}
if (skillIds.length <= 0) return null;
return { skillIds, mul };
}
/**
* 解析 Battlebuff.targetpar。
* 格式:类型,参数…;类型1=等级区间(min,max);类型2 预留。
* 格式:类型,参数…;类型1=等级区间(min,max);类型2=场上最大 N 个。
*/
export class BattleBuffTargetParse {
/**
* 解析等级区间;非类型1 或非法时返回 null。
* 解析 targetpar。
* @param raw targetpar
*/
static parseLevelRange(raw: string | null | undefined): BattleBuffTargetLevelRange | null {
static parse(raw: string | null | undefined): BattleBuffTargetPar | null {
const text = String(raw ?? '').trim();
if (!text) return null;
const nums = parseFunctionParNums(text);
if (nums.length < 1) return null;
const kind = Math.floor(nums[0]);
if (kind === 2) {
console.warn('[BattleBuff] targetpar 类型2尚未实现', raw);
return null;
}
if (kind !== 1) {
console.warn('[BattleBuff] targetpar 未知类型', raw);
return null;
if (kind === 1) {
if (nums.length < 3) {
console.warn('[BattleBuff] targetpar 类型1缺少等级区间', raw);
return null;
}
const minLv = Math.floor(nums[1]);
const maxLv = Math.floor(nums[2]);
if (minLv <= 0 || maxLv < minLv) {
console.warn('[BattleBuff] targetpar 等级区间非法', raw);
return null;
}
return { kind: 1, minLv, maxLv };
}
if (nums.length < 3) {
console.warn('[BattleBuff] targetpar 类型1缺少等级区间', raw);
return null;
}
const minLv = Math.floor(nums[1]);
const maxLv = Math.floor(nums[2]);
if (minLv <= 0 || maxLv < minLv) {
console.warn('[BattleBuff] targetpar 等级区间非法', raw);
return null;
if (kind === 2) {
if (nums.length < 2) {
console.warn('[BattleBuff] targetpar 类型2缺少数量', raw);
return null;
}
const topN = Math.floor(nums[1]);
if (!(topN > 0)) {
console.warn('[BattleBuff] targetpar 类型2数量非法', raw);
return null;
}
return { kind: 2, topN };
}
return { minLv, maxLv };
console.warn('[BattleBuff] targetpar 未知类型', raw);
return null;
}
/**
* 解析等级区间;非类型1 时返回 null。
* @param raw targetpar
*/
static parseLevelRange(raw: string | null | undefined): BattleBuffTargetLevelRange | null {
const p = this.parse(raw);
if (!p || p.kind !== 1) return null;
return { minLv: p.minLv, maxLv: p.maxLv };
}
/**
@ -280,8 +347,9 @@ export class BattleBuffTargetParse {
* @param targetpar 生效参数
* @param heroType 单位 herotype(1~5)
* @param level 单位等级
* @param rankAmongElement 同五行场上等级排名(1=最高);类型2 必填
*/
static matches(target: number, targetpar: string | null | undefined, heroType: number, level: number): boolean {
static matches(target: number, targetpar: string | null | undefined, heroType: number, level: number, rankAmongElement = 0): boolean {
const t = Math.floor(Number(target) || 0);
const ht = Math.floor(Number(heroType) || 0);
const lv = Math.floor(Number(level) || 0);
@ -294,8 +362,15 @@ export class BattleBuffTargetParse {
console.warn('[BattleBuff] target 非法', t);
return false;
}
const range = this.parseLevelRange(targetpar);
if (!range) return !String(targetpar ?? '').trim();
return lv >= range.minLv && lv <= range.maxLv;
const text = String(targetpar ?? '').trim();
if (!text) return true;
const par = this.parse(text);
if (!par) return false;
if (par.kind === 1) return lv >= par.minLv && lv <= par.maxLv;
if (!(rankAmongElement > 0)) {
console.warn('[BattleBuff] targetpar 类型2缺排名', targetpar);
return false;
}
return rankAmongElement <= par.topN;
}
}

@ -52,6 +52,22 @@ export enum BattleBuffFunctionType {
DigitCritDamage = 103,
/** 数字攻速万分比加成 */
DigitAttackSpeed = 104,
/** 给匹配数字挂 Skill.type=2(functionpar=Skill.id) */
DigitGrantSkill = 105,
/** 攻击距离加值(像素;|≤16| 按格×83) */
DigitRange = 106,
/** 指定武器技能伤害倍数(多用 functiontype1) */
DigitSkillDmgMul = 107,
/** 普攻穿透 +N */
DigitPierce = 108,
/** 每次攻击额外箭矢 +N */
DigitExtraArrow = 109,
/** 指定技能必暴+暴击率转爆伤(多用 functiontype1) */
DigitSkillForceCrit = 110,
/** 概率多倍伤害:概率万分比,倍数 */
DigitChanceMulDmg = 111,
/** 条件必暴(毒/减速/霸王气,见 target 五行) */
DigitCondForceCrit = 112,
}
/** 携带即生效、不走局内 CD 遮罩的 functiontype */
@ -74,6 +90,12 @@ const PASSIVE_NO_BATTLE_CD_FUNCTION_TYPES: readonly BattleBuffFunctionType[] = [
BattleBuffFunctionType.DigitCritRate,
BattleBuffFunctionType.DigitCritDamage,
BattleBuffFunctionType.DigitAttackSpeed,
BattleBuffFunctionType.DigitGrantSkill,
BattleBuffFunctionType.DigitRange,
BattleBuffFunctionType.DigitPierce,
BattleBuffFunctionType.DigitExtraArrow,
BattleBuffFunctionType.DigitChanceMulDmg,
BattleBuffFunctionType.DigitCondForceCrit,
];
/**

@ -0,0 +1,52 @@
# 方案:Battlebuff 105–112 与 Skill.result 15–20
> 日期:2026-10-15
> 配表:`Battlebuff.json` / `Skill.json` / `AutoTable.ts`
## 1. Battlebuff 新 functiontype
| ft | 含义 | functionpar |
|---:|------|-------------|
| 105 | 给匹配数字挂 `Skill.type=2` | Skill.id |
| 106 | 攻击距离加值 | 像素;\|≤16\| 按格×83 |
| 107 | 指定武器技能伤害×N(多用 `functiontype1`) | `WeaponId\|…,倍数` |
| 108 | 普攻穿透 +N | 整数 |
| 109 | 每次攻击额外箭 +N | 整数 |
| 110 | 指定技能必暴+暴击转爆伤(多用 `functiontype1`) | `WeaponId\|…,倍数(占位)` |
| 111 | 概率多倍伤害 | `概率万分比,倍数` |
| 112 | 条件必暴 | 空;按 `target` 五行:木=毒/水=减速/火=霸王气 |
双槽:`functiontype` + `functiontype1` / `functionpar1`。橙品质增幅多为 `101` + `107|110`。
`targetpar` 类型2:场上同五行等级最高的 N 个(`2,N`)。
## 2. Skill.result 15–20
| result | 含义 | 实现 |
|-------:|------|------|
| 15 | 低血斩杀 | `tryExecuteByThresholdSession` |
| 16 | 秒杀 | `tryInstantKillSession` |
| 17 | 上毒 | 复用 result 8 DoT |
| 18 | 寒潭减速 | `PassiveSkillHost` 地面区 |
| 19 | 目标身后扇形 | 向量扇形圈伤 |
| 20 | 击杀叠伤 | `dmg_mul` 叠层 |
空 `result` + 有 `atk_type`:`CastAtkRow` 注册 `ability_buffskill_*` 后 `tryCast`(冰球术/穿云击/群体加速)。
`condition=2` 且 `cd>0`:部署触发后按 CD 循环(远攻剑气)。
## 3. 代码入口
- `BattleBuffDigitStatHost`:101–112 刷新、挂 Skill、战斗标记
- `BattleBuffDamageHook`:107/110/111/112 伤害预调
- `PassiveSkillHost.syncBuffSkills`:ft=105
- `Skill` / `Gong`:108 穿透、109 额外箭
## 4. 验收要点
- [ ] 远攻(1052):仅场上最高金系挂 105201,CD 13 放剑气
- [ ] 斩杀/秒杀/毒素/寒潭/隔山打牛/击杀增幅按 result 生效
- [ ] 冰球术/穿云击每 10 次攻击施放
- [ ] 飞剑增幅等:对应武器技能伤害 ×3;火龙/箭雨必暴+转爆伤
- [ ] 毒素爆发/弱点打击/霸王之气条件必暴
- [ ] 极限输出 50%×10 倍;穿透/额外飞箭/攻击距离

@ -28,8 +28,10 @@
| 102 | DigitCritRate | 数字暴击 | 局内三选一 | `bonusWan` | CritRate Add |
| 103 | DigitCritDamage | 数字爆伤 | 局内三选一 | `bonusWan` | CritDamage Add |
| 104 | DigitAttackSpeed | 数字攻速 | 局内三选一 | `bonusWan` | aspd_add |
| 105~112 | 见扩展方案 | 五行专属 | 局内三选一 | 见配表 | [方案-Battlebuff扩展105-112.md](./方案-Battlebuff扩展105-112.md) |
> 获取方式已改为局内三选一(单局有效),见 [方案-局内Battlebuff三选一.md](./方案-局内Battlebuff三选一.md)。局外 DailyShop 已下线。
> 获取方式已改为局内三选一(单局有效),见 [方案-局内Battlebuff三选一.md](./方案-局内Battlebuff三选一.md)。局外 DailyShop 已下线。
> 双功能槽 `functiontype1` / `functionpar1`、Skill.result 15–20 见扩展方案。
## 2. 表现复用

@ -206,6 +206,7 @@
'/workbench/js/agent-serialize.js',
'/workbench/js/agent-pick.js',
'/workbench/js/agent-console.js',
'/workbench/js/agent-assets.js',
'/workbench/js/agent.js',
];
var fi = 0;

@ -908,21 +908,375 @@ body {
.pw-summary span { color: var(--pw-muted); }
.pw-summary b { color: var(--pw-text); font-weight: 600; }
/* —— 资源面板:Bundle 优先 —— */
.pw-assets {
--pw-assets-sidebar: 148px;
min-height: 0;
}
.pw-assets-summary {
display: flex;
flex-wrap: wrap;
gap: var(--sp-2) var(--sp-3);
align-items: baseline;
padding: var(--sp-2) var(--sp-2) 0;
border-bottom: 1px solid var(--pw-border);
padding-bottom: var(--sp-2);
flex-shrink: 0;
}
.pw-assets-kpi {
display: inline-flex;
align-items: baseline;
gap: 4px;
}
.pw-assets-kpi b {
font-family: var(--pw-font-mono);
font-size: var(--pw-fs-md);
font-weight: 600;
color: var(--pw-text);
}
.pw-assets-kpi.accent b { color: var(--pw-ok); }
.pw-assets-kpi span {
font-size: var(--pw-fs-xs);
color: var(--pw-muted);
}
.pw-assets-note {
flex-basis: 100%;
font-size: 10px;
color: var(--pw-muted);
opacity: 0.85;
}
.pw-assets-body {
display: flex;
flex: 1;
min-height: 0;
overflow: hidden;
}
.pw-assets-bundles {
width: var(--pw-assets-sidebar);
flex-shrink: 0;
overflow: auto;
border-right: 1px solid var(--pw-border);
padding: var(--sp-1);
}
.pw-assets-bundle-head {
font-size: var(--pw-fs-xs);
color: var(--pw-muted);
padding: 4px 6px;
text-transform: uppercase;
letter-spacing: 0.04em;
}
.pw-bundle-item {
display: block;
width: 100%;
text-align: left;
border: 1px solid transparent;
background: transparent;
color: var(--pw-text-2);
border-radius: 4px;
padding: 6px 8px;
margin-bottom: 2px;
cursor: pointer;
font: var(--pw-fs-sm)/1.3 var(--pw-font-ui);
}
.pw-bundle-item:hover {
background: rgba(255, 255, 255, 0.04);
color: var(--pw-text);
}
.pw-bundle-item.active {
background: var(--pw-accent-soft);
border-color: var(--pw-accent);
color: var(--pw-text);
}
.pw-bundle-name {
font-weight: 600;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
display: flex;
align-items: center;
gap: 4px;
}
.pw-bundle-tag {
flex-shrink: 0;
font-size: 9px;
font-weight: 500;
padding: 0 4px;
border-radius: 3px;
line-height: 14px;
background: rgba(255, 255, 255, 0.06);
color: var(--pw-muted);
}
.pw-bundle-item.st-loaded .pw-bundle-tag {
background: rgba(61, 186, 124, 0.18);
color: var(--pw-ok);
}
.pw-bundle-item.st-empty .pw-bundle-tag {
background: rgba(212, 162, 76, 0.18);
color: var(--pw-warn);
}
.pw-bundle-item.st-unloaded {
opacity: 0.55;
}
.pw-bundle-item.st-unloaded .pw-bundle-tag {
background: rgba(255, 255, 255, 0.05);
color: var(--pw-muted);
}
.pw-bundle-item.st-unloaded .pw-bundle-bar {
opacity: 0.35;
}
.pw-bundle-sub {
font-size: 10px;
color: var(--pw-muted);
margin-top: 2px;
}
.pw-bundle-bar {
height: 3px;
background: rgba(255, 255, 255, 0.06);
border-radius: 2px;
margin-top: 4px;
overflow: hidden;
}
.pw-bundle-bar i {
display: block;
height: 100%;
background: var(--pw-accent);
border-radius: 2px;
}
.pw-assets-main {
flex: 1;
min-width: 0;
display: flex;
min-height: 0;
}
.pw-assets-list {
flex: 1;
min-width: 0;
overflow: auto;
}
.pw-assets-row {
display: grid;
grid-template-columns: 1fr 110px 72px;
gap: 6px;
align-items: center;
padding: 4px 10px;
cursor: pointer;
border-bottom: 1px solid transparent;
font-size: var(--pw-fs-sm);
}
.pw-assets-row:hover { background: rgba(255, 255, 255, 0.03); }
.pw-assets-row.active {
background: var(--pw-accent-soft);
box-shadow: inset 2px 0 0 var(--pw-accent);
}
.pw-assets-row-head {
position: sticky;
top: 0;
background: var(--pw-bg-1);
color: var(--pw-muted);
font-size: var(--pw-fs-xs);
cursor: default;
border-bottom: 1px solid var(--pw-border);
}
.pw-assets-name {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.pw-assets-type {
color: var(--pw-muted);
font-size: 11px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.pw-assets-mem {
font-family: var(--pw-font-mono);
font-size: 11px;
text-align: right;
color: var(--pw-text-2);
font-variant-numeric: tabular-nums;
}
.pw-assets-side {
width: 168px;
flex-shrink: 0;
border-left: 1px solid var(--pw-border);
display: flex;
flex-direction: column;
min-height: 0;
}
.pw-assets-preview {
height: 140px;
background: var(--pw-bg-1);
border-bottom: 1px solid var(--pw-border);
display: flex;
align-items: center;
justify-content: center;
overflow: hidden;
padding: 6px;
}
.pw-assets-preview img {
max-width: 100%;
max-height: 100%;
object-fit: contain;
image-rendering: auto;
border-radius: 2px;
}
.pw-assets-meta {
padding: var(--sp-2);
font-size: 11px;
line-height: 1.5;
overflow: auto;
}
.pw-pick-overlay {
position: absolute;
position: fixed;
pointer-events: none;
z-index: 99999;
z-index: 2147483646;
box-sizing: border-box;
background: rgba(46, 204, 113, 0.42);
border: 2px solid rgba(39, 174, 96, 1);
background: rgba(46, 204, 113, 0.35);
border: 2px solid #27ae60;
border-radius: 2px;
box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.2), 0 0 14px rgba(46, 204, 113, 0.35);
box-shadow: 0 0 0 1px rgba(255, 255, 255, 0.25);
}
.pw-pick-overlay[hidden] {
display: none !important;
}
/* 壳层选中 Gizmo:可拖半透明框 + X/Y 轴箭头 */
.pw-pick-gizmo {
position: absolute;
z-index: 18;
pointer-events: none;
box-sizing: border-box;
}
.pw-pick-gizmo[hidden] {
display: none !important;
}
.pw-gizmo-box {
position: absolute;
inset: 0;
pointer-events: auto;
cursor: move;
background: rgba(46, 204, 113, 0.28);
border: 2px solid #27ae60;
border-radius: 2px;
box-shadow: 0 0 0 1px rgba(255, 255, 255, 0.2);
}
.pw-gizmo-axis {
position: absolute;
pointer-events: auto;
}
.pw-gizmo-x {
right: -4px;
top: 50%;
width: 36px;
height: 18px;
margin-top: -9px;
cursor: ew-resize;
}
.pw-gizmo-x i {
position: absolute;
left: 0;
right: 12px;
top: 50%;
height: 3px;
margin-top: -1.5px;
background: #e74c3c;
border-radius: 2px;
}
.pw-gizmo-x b {
position: absolute;
right: 0;
top: 50%;
margin-top: -6px;
width: 0;
height: 0;
border: 6px solid transparent;
border-left: 10px solid #e74c3c;
}
.pw-gizmo-y {
left: 50%;
top: -36px;
width: 18px;
height: 36px;
margin-left: -9px;
cursor: ns-resize;
}
.pw-gizmo-y i {
position: absolute;
top: 0;
bottom: 12px;
left: 50%;
width: 3px;
margin-left: -1.5px;
background: #2ecc71;
border-radius: 2px;
}
.pw-gizmo-y b {
position: absolute;
top: 0;
left: 50%;
margin-left: -6px;
width: 0;
height: 0;
border: 6px solid transparent;
border-bottom: 10px solid #2ecc71;
}
.pw-pick-gizmo.dragging .pw-gizmo-box {
background: rgba(46, 204, 113, 0.4);
border-color: #2ecc71;
}
.pw-pick-gizmo.dragging .pw-gizmo-x,
.pw-pick-gizmo.dragging .pw-gizmo-y {
opacity: 0.85;
}
/* 壳层选中高亮:绿色半透明遮罩 */
.pw-pick-highlight {
position: absolute;

@ -0,0 +1,404 @@
(function (global) {
'use strict';
/**
* 资源面板数据源(仅引擎接口):
* assetManager.assets — 全部已加载资源
* assetManager.bundles — 已加载 Bundle(归属用 cache/_map 尽力匹配)
*/
function fmtType(asset) {
var t = (asset && asset.constructor && asset.constructor.name) || 'Other';
return String(t).replace(/^cc\./, '');
}
function memMB(bytes) {
return (bytes || 0) / (1024 * 1024);
}
/** GPU 估算:纹理去重只计一次 */
function texMemMB(tex) {
if (!tex) return 0;
var w = tex.width || 0;
var h = tex.height || 0;
if (!w || !h) {
try {
if (tex.image && tex.image.width) {
w = tex.image.width;
h = tex.image.height;
}
} catch (e) { /* */ }
}
return w && h ? memMB(w * h * 4 * 1.33) : 0;
}
function assetMem(asset, type, seenTex) {
if (type === 'Texture2D' || type === 'Texture') {
var key = String(asset._uuid || asset.uuid || '');
if (key && seenTex[key]) return 0;
if (key) seenTex[key] = 1;
return texMemMB(asset);
}
// SpriteFrame / Image 挂在纹理上,不进总量
if (type === 'SpriteFrame' || type === 'ImageAsset') return 0;
if (type === 'AudioClip' || /Audio/.test(type)) {
try {
var d = asset.duration || (asset._nativeAsset && asset._nativeAsset.duration) || 0;
return d ? memMB(d * 44100 * 2 * 2) : 0.2;
} catch (e) { return 0.2; }
}
try {
if (asset._nativeAsset && asset._nativeAsset.byteLength) {
return memMB(asset._nativeAsset.byteLength);
}
if (asset.data && asset.data.byteLength) {
return memMB(asset.data.byteLength);
}
} catch (e) { /* */ }
return 0;
}
function frameSize(asset) {
var rect = asset.rect || asset._rect;
if (rect && rect.width) return { w: rect.width | 0, h: rect.height | 0 };
return { w: asset.width | 0, h: asset.height | 0 };
}
function gfxTotals() {
var cc = global.cc;
var tex = 0;
var buf = 0;
var heap = 0;
try {
var st = cc && cc.profiler && cc.profiler.stats;
if (st) {
if (st.textureMemory && st.textureMemory.counter) tex = st.textureMemory.counter.value || 0;
if (st.bufferMemory && st.bufferMemory.counter) buf = st.bufferMemory.counter.value || 0;
}
} catch (e) { /* */ }
try {
if (performance.memory && performance.memory.usedJSHeapSize) {
heap = performance.memory.usedJSHeapSize / (1024 * 1024);
}
} catch (e2) { /* */ }
return { gfxTextureMB: tex, gfxBufferMB: buf, jsHeapMB: heap };
}
/**
* 解析资源所属 Bundle。
* 顺序:asset 上的 bundle 字段 → nativeUrl 路径 → bundle.getAssetInfo → cache.has
*/
function resolveBundleName(uuid, asset, bundleObjs, uuidMap) {
try {
if (asset) {
if (asset.bundle && asset.bundle.name) return asset.bundle.name;
if (asset._bundle && asset._bundle.name) return asset._bundle.name;
if (asset.__bundleName) return asset.__bundleName;
}
} catch (e) { /* */ }
if (uuidMap && uuid && uuidMap[uuid]) return uuidMap[uuid];
var url = (asset && (asset.nativeUrl || asset._nativeUrl)) || '';
var m = String(url).match(/\/([A-Za-z0-9_\-]+)\/(?:import|native|js|ohm|chunks)\//i);
if (m && m[1]) return m[1];
if (bundleObjs && bundleObjs.length) {
for (var i = 0; i < bundleObjs.length; i++) {
var b = bundleObjs[i];
if (!b) continue;
var bname = b.__pwName || b.name || '';
try {
if (typeof b.getAssetInfo === 'function' && uuid) {
var info = b.getAssetInfo(uuid);
if (info && (info.uuid || info.redirect || info.path != null || info.ext)) {
return bname;
}
}
} catch (e2) { /* */ }
try {
if (b.cache && typeof b.cache.has === 'function' && uuid && b.cache.has(uuid)) {
return bname;
}
} catch (e3) { /* */ }
try {
if (b.cache && b.cache.get && uuid && b.cache.get(uuid)) {
return bname;
}
} catch (e4) { /* */ }
}
}
return '';
}
/** 遍历已加载 bundles,收集对象与 cache uuid 索引 */
function indexBundles(cc) {
var list = [];
var uuidMap = {};
var bundleObjs = [];
try {
cc.assetManager.bundles.forEach(function (bundle, name) {
var n = name || (bundle && bundle.name) || '';
if (!n) return;
if (bundle) {
bundle.__pwName = n;
bundleObjs.push(bundle);
}
list.push({
name: n,
assetCount: 0,
memMB: 0,
texMB: 0,
audioMB: 0,
otherMB: 0,
status: 'loaded',
});
var add = function (u) {
if (u) uuidMap[String(u)] = n;
};
try {
var cache = bundle.cache || bundle._cache;
if (cache && typeof cache.forEach === 'function') {
cache.forEach(function (asset, uuid) {
add(uuid);
if (asset) add(asset._uuid);
});
}
if (cache && cache._map) {
for (var k in cache._map) {
if (Object.prototype.hasOwnProperty.call(cache._map, k)) add(k);
}
}
} catch (e) { /* */ }
// config.uuids / paths
try {
var cfg = bundle._config;
if (cfg) {
var lists = [cfg.uuids, cfg.uuidList, cfg._uuidList];
for (var li = 0; li < lists.length; li++) {
var arr = lists[li];
if (!arr) continue;
if (Array.isArray(arr)) {
for (var i = 0; i < arr.length; i++) add(arr[i]);
} else if (typeof arr === 'object') {
for (var kk in arr) {
if (Object.prototype.hasOwnProperty.call(arr, kk)) add(kk);
}
}
}
}
} catch (e2) { /* */ }
});
} catch (e3) { /* */ }
return { list: list, uuidMap: uuidMap, bundleObjs: bundleObjs };
}
/**
* @returns {{bundles:Array, rows:Array, totals:Object}}
*/
function collectAssets() {
var cc = global.cc;
if (!cc || !cc.assetManager || !cc.assetManager.assets) {
return { bundles: [], rows: [], totals: gfxTotals() };
}
var bi = indexBundles(cc);
var bundleByName = {};
for (var i = 0; i < bi.list.length; i++) {
bundleByName[bi.list[i].name] = bi.list[i];
}
var seenTex = {};
var rows = [];
// —— 全部已加载资源:assetManager.assets ——
try {
cc.assetManager.assets.forEach(function (asset, uuid) {
if (!asset) return;
var id = String(uuid || asset._uuid || '');
var type = fmtType(asset);
var mem = assetMem(asset, type, seenTex);
var bname = resolveBundleName(id, asset, bi.bundleObjs, bi.uuidMap);
if (!bname) {
var isDefault = /default_|splash|empty|builtin/i.test(String(asset.name || ''));
bname = isDefault ? 'internal' : 'internal';
}
var agg = bundleByName[bname];
if (!agg) {
agg = {
name: bname,
assetCount: 0,
memMB: 0,
texMB: 0,
audioMB: 0,
otherMB: 0,
status: 'loaded',
};
bundleByName[bname] = agg;
bi.list.push(agg);
}
var kind = 'other';
if (type === 'Texture2D' || type === 'Texture') kind = 'tex';
else if (type === 'AudioClip' || /Audio/.test(type)) kind = 'audio';
var w = 0;
var h = 0;
if (type === 'SpriteFrame') {
var sz = frameSize(asset);
w = sz.w;
h = sz.h;
} else {
w = asset.width | 0;
h = asset.height | 0;
}
var display = asset.name || '';
if (!display || /^[0-9a-f-]{8,}$/i.test(display)) {
// 可读名:优先 getAssetInfo.path 最后一段
try {
for (var bi2 = 0; bi2 < bi.bundleObjs.length; bi2++) {
var bb = bi2.bundleObjs[bi2];
if (bb && typeof bb.getAssetInfo === 'function') {
var inf = bb.getAssetInfo(id);
if (inf && inf.path) {
var segs = String(inf.path).split('/');
display = segs[segs.length - 1] || inf.path;
break;
}
}
}
} catch (eName) { /* */ }
if (!display) display = id.slice(0, 8) + '…';
}
rows.push({
uuid: id,
name: display,
type: type,
bundle: bname,
mem: mem,
ref: asset.refCount != null ? asset.refCount : 0,
w: w,
h: h,
kind: kind,
});
agg.assetCount += 1;
agg.memMB += mem;
if (kind === 'tex') agg.texMB += mem;
else if (kind === 'audio') agg.audioMB += mem;
else agg.otherMB += mem;
});
} catch (e) {
console.warn('[Agent] assets.scan', e);
}
var bundles = bi.list;
for (var j = 0; j < bundles.length; j++) {
if (bundles[j].assetCount === 0 && bundles[j].status === 'loaded') {
// cache 里没建到索引,但 assets 已扫到就保持 loaded;全 0 才标空
if (bundles[j].memMB === 0 && bundles[j].assetCount === 0) {
bundles[j].status = 'loaded-empty';
}
}
}
bundles.sort(function (a, b) {
return (b.memMB - a.memMB) || (b.assetCount - a.assetCount) || String(a.name).localeCompare(String(b.name));
});
var totals = gfxTotals();
totals.estimateMB = 0;
totals.texMB = 0;
totals.audioMB = 0;
totals.otherMB = 0;
for (var r = 0; r < rows.length; r++) {
totals.estimateMB += rows[r].mem;
if (rows[r].kind === 'tex') totals.texMB += rows[r].mem;
else if (rows[r].kind === 'audio') totals.audioMB += rows[r].mem;
else totals.otherMB += rows[r].mem;
}
totals.assetCount = rows.length;
totals.bundleCount = bundles.length;
return { bundles: bundles, rows: rows, totals: totals };
}
function buildPreview(asset, type) {
try {
if (type === 'SpriteFrame') {
var tex = asset.texture || (asset.getTexture && asset.getTexture());
var img = tex && tex.image;
var data = img && (img._data || img.data);
var rect = asset.rect || asset._rect || { x: 0, y: 0, width: asset.width, height: asset.height };
if (data && typeof HTMLImageElement !== 'undefined' && data instanceof HTMLImageElement) {
var ih = data.height || img.height || 0;
var sw = Math.max(1, rect.width | 0);
var sh = Math.max(1, rect.height | 0);
var sx = rect.x | 0;
var sy = Math.max(0, ih - (rect.y | 0) - sh) | 0;
var cv = document.createElement('canvas');
cv.width = sw;
cv.height = sh;
cv.getContext('2d').drawImage(data, sx, sy, sw, sh, 0, 0, sw, sh);
return { kind: 'image', w: sw, h: sh, url: cv.toDataURL('image/png') };
}
if (img && img.nativeUrl) {
return { kind: 'url', url: img.nativeUrl, w: (rect.width | 0), h: (rect.height | 0) };
}
return { kind: 'none' };
}
var im = type === 'ImageAsset' ? asset : (asset.image || asset._image);
var d2 = im && (im._data || im.data);
if (d2 && typeof HTMLImageElement !== 'undefined' && d2 instanceof HTMLImageElement) {
return { kind: 'image', w: d2.width, h: d2.height, url: d2.src || '' };
}
if (d2 && d2.data && d2.width && typeof ImageData !== 'undefined') {
var c2 = document.createElement('canvas');
c2.width = d2.width;
c2.height = d2.height;
c2.getContext('2d').putImageData(new ImageData(d2.data, d2.width, d2.height), 0, 0);
return { kind: 'image', w: d2.width, h: d2.height, url: c2.toDataURL('image/png') };
}
if (im && im.nativeUrl) return { kind: 'url', url: im.nativeUrl, w: im.width | 0, h: im.height | 0 };
if (asset.nativeUrl) return { kind: 'url', url: asset.nativeUrl, w: asset.width | 0, h: asset.height | 0 };
} catch (e) { /* */ }
return { kind: 'none' };
}
function previewAsset(uuid) {
var cc = global.cc;
var asset = null;
try {
asset = cc.assetManager.assets.get(uuid);
} catch (e) {
asset = null;
}
if (!asset) return { kind: 'none' };
return buildPreview(asset, fmtType(asset));
}
if (global.PWAgent) {
global.PWAgent.prototype._listAssets = function () {
return collectAssets();
};
global.PWAgent.prototype._listAssetsAsync = function (done) {
try {
done(collectAssets());
} catch (e) {
done({ bundles: [], rows: [], totals: gfxTotals() });
}
};
global.PWAgent.prototype._previewAsset = function (uuid) {
return previewAsset(uuid);
};
}
global.PWAssetsUtil = {
collectAssets: collectAssets,
previewAsset: previewAsset,
buildPreview: buildPreview,
};
})(window);

@ -152,6 +152,7 @@
case 'node.get': return this._getNodeProps(params.uuid);
case 'node.set': return this._setNodeProp(params.uuid, params.fid, params.value);
case 'assets.list': return this._listAssets();
case 'assets.preview': return this._previewAsset(params.uuid);
case 'pick.setMode': return this._setPickMode(params.mode);
case 'pick.select': return this._selectNode(params.uuid);
case 'pick.at': return this._pickAt(params);

@ -438,10 +438,8 @@
el.id = 'pw-pick-overlay';
el.className = 'pw-pick-overlay';
el.hidden = true;
// 挂到画布容器内,避免被设备壳/全局样式盖住
var canvas = this._getCanvasEl();
var host = (canvas && canvas.parentElement) || document.body || document.documentElement;
host.appendChild(el);
// fixed 相对游戏视口,挂 body 避免父级 transform 干扰
(document.body || document.documentElement).appendChild(el);
}
this._overlay = el;
};
@ -460,29 +458,22 @@
overlay.hidden = true;
return;
}
// 相对画布父容器定位(absolute),比 fixed 更稳
var canvas = this._getCanvasEl();
var parent = (canvas && canvas.parentElement) || overlay.parentNode;
var originLeft = 0;
var originTop = 0;
if (parent && parent.getBoundingClientRect) {
var pr = parent.getBoundingClientRect();
originLeft = pr.left;
originTop = pr.top;
if (getComputedStyle(parent).position === 'static') {
parent.style.position = 'relative';
}
}
// 挂在 documentElement 上用 viewport 坐标(fixed),与 _nodeClientRect 同一套
overlay.hidden = false;
overlay.style.position = 'absolute';
overlay.style.left = (box.left - originLeft) + 'px';
overlay.style.top = (box.top - originTop) + 'px';
overlay.style.position = 'fixed';
overlay.style.left = box.left + 'px';
overlay.style.top = box.top + 'px';
overlay.style.width = box.width + 'px';
overlay.style.height = box.height + 'px';
overlay.style.zIndex = '99999';
overlay.style.display = 'block';
overlay.style.pointerEvents = 'none';
};
/**
* 节点世界 AABB → 浏览器视口矩形(游戏 iframe 内)。
* 主路径:相机 worldToScreen;回退:Canvas 盒 UV 映射。
*/
WorkbenchAgent.prototype._nodeClientRect = function (node) {
var cc = this.cc;
var canvas = this._getCanvasEl();
@ -502,38 +493,75 @@
if (bh < 0) { by += bh; bh = -bh; }
var rect = canvas.getBoundingClientRect();
var cbox = this._canvasWorldBox();
var vw, vh, ox, oy;
if (cbox) {
vw = Math.max(1, cbox.width);
vh = Math.max(1, cbox.height);
ox = cbox.x;
oy = cbox.y;
} else {
var design = cc.view && cc.view.getDesignResolutionSize ? cc.view.getDesignResolutionSize() : null;
vw = Math.max(1, (design && design.width) || rect.width);
vh = Math.max(1, (design && design.height) || rect.height);
ox = 0;
oy = 0;
}
if (!rect.width || !rect.height) return null;
var corners = [
{ x: bx, y: by },
{ x: bx + bw, y: by },
{ x: bx + bw, y: by + bh },
{ x: bx, y: by + bh },
];
var xs = [];
var ys = [];
for (var i = 0; i < 4; i++) {
var u = (corners[i].x - ox) / vw;
var v = 1 - (corners[i].y - oy) / vh;
xs.push(rect.left + u * rect.width);
ys.push(rect.top + v * rect.height);
// 1) 相机投影(最准,自动处理缩放/适配)
var cam = this._uiCamera();
if (cam && typeof cam.worldToScreen === 'function') {
var xs = [];
var ys = [];
var ok = true;
try {
for (var i = 0; i < 4; i++) {
var sp = cc.Vec3 ? new cc.Vec3() : { x: 0, y: 0, z: 0 };
var wp = cc.Vec3
? new cc.Vec3(corners[i].x, corners[i].y, 0)
: { x: corners[i].x, y: corners[i].y, z: 0 };
cam.worldToScreen(sp, wp);
// worldToScreen:原点多在画布左下,单位为画布像素
var sx = Number(sp.x);
var sy = Number(sp.y);
if (!isFinite(sx) || !isFinite(sy)) { ok = false; break; }
// 画布缓冲像素 → CSS 视口
var cx = rect.left + (sx / Math.max(1, canvas.width)) * rect.width;
var cy = rect.top + rect.height - (sy / Math.max(1, canvas.height)) * rect.height;
xs.push(cx);
ys.push(cy);
}
} catch (e2) { ok = false; }
if (ok && xs.length === 4) {
var L = Math.min.apply(null, xs);
var R = Math.max.apply(null, xs);
var T = Math.min.apply(null, ys);
var B = Math.max.apply(null, ys);
if (isFinite(L) && isFinite(T) && R > L && B > T) {
return {
left: L,
top: T,
right: R,
bottom: B,
width: Math.max(2, R - L),
height: Math.max(2, B - T),
};
}
}
}
// 2) Canvas 世界盒 UV 映射
var cbox = this._canvasWorldBox();
var vw = Math.max(1, (cbox && cbox.width) || rect.width);
var vh = Math.max(1, (cbox && cbox.height) || rect.height);
var ox = cbox ? cbox.x : 0;
var oy = cbox ? cbox.y : 0;
var xs2 = [];
var ys2 = [];
for (var j = 0; j < 4; j++) {
var u = (corners[j].x - ox) / vw;
var v = 1 - (corners[j].y - oy) / vh;
xs2.push(rect.left + u * rect.width);
ys2.push(rect.top + v * rect.height);
}
var left = Math.min.apply(null, xs);
var right = Math.max.apply(null, xs);
var top = Math.min.apply(null, ys);
var bottom = Math.max.apply(null, ys);
var left = Math.min.apply(null, xs2);
var right = Math.max.apply(null, xs2);
var top = Math.min.apply(null, ys2);
var bottom = Math.max.apply(null, ys2);
return {
left: left,
top: top,
@ -545,11 +573,21 @@
};
WorkbenchAgent.prototype._getOverlayRect = function () {
// 优先读最新计算框,避免 style 尚未写入
var node = this._findNode(this._selectedUuid);
if (node) {
var box = this._nodeClientRect(node);
if (box) return box;
var rect = node ? this._nodeClientRect(node) : null;
var extra = this._getDragState(node);
if (rect) {
return {
left: rect.left,
top: rect.top,
width: rect.width,
height: rect.height,
right: rect.right,
bottom: rect.bottom,
uuid: this._selectedUuid || '',
pos: extra.pos,
scale: extra.scale,
};
}
var overlay = this._overlay;
if (!overlay || overlay.hidden) return null;
@ -558,7 +596,32 @@
top: parseFloat(overlay.style.top) || 0,
width: parseFloat(overlay.style.width) || 0,
height: parseFloat(overlay.style.height) || 0,
uuid: this._selectedUuid || '',
pos: extra.pos,
scale: extra.scale,
};
};
/** 拖拽所需的节点位置与「屏幕像素→本地坐标」缩放 */
WorkbenchAgent.prototype._getDragState = function (node) {
var pos = { x: 0, y: 0, z: 0 };
if (node) {
try {
var p = node.getPosition ? node.getPosition() : node.position;
if (p) pos = { x: p.x || 0, y: p.y || 0, z: p.z || 0 };
} catch (e) { /* */ }
}
var canvas = this._getCanvasEl();
var cbox = this._canvasWorldBox();
var design = this.cc && this.cc.view && this.cc.view.getDesignResolutionSize
? this.cc.view.getDesignResolutionSize()
: null;
var w = (cbox && cbox.width) || (design && design.width) || 750;
var h = (cbox && cbox.height) || (design && design.height) || 1600;
var rect = canvas && canvas.getBoundingClientRect ? canvas.getBoundingClientRect() : null;
var sx = rect && rect.width ? w / rect.width : 1;
var sy = rect && rect.height ? h / rect.height : 1;
return { pos: pos, scale: { x: sx, y: sy } };
};
})(window);

@ -139,7 +139,18 @@
cap.title = '检视:点击选取节点';
host.appendChild(cap);
// 选中高亮由游戏内 #pw-pick-overlay 绘制(壳层不再重复,避免坐标原点不一致)
// 选中 Gizmo(拖动改坐标 + X/Y 轴箭头)
var gizmo = document.createElement('div');
gizmo.id = 'pw-pick-gizmo';
gizmo.className = 'pw-pick-gizmo pw-shell-only';
gizmo.hidden = true;
gizmo.innerHTML =
'<div class="pw-gizmo-box" data-drag="xy" title="拖动移动节点"></div>' +
'<div class="pw-gizmo-axis pw-gizmo-x" data-drag="x" title="沿 X 轴拖动"><i></i><b></b></div>' +
'<div class="pw-gizmo-axis pw-gizmo-y" data-drag="y" title="沿 Y 轴拖动"><i></i><b></b></div>';
host.appendChild(gizmo);
this._bindGizmoDrag(gizmo);
if (this.layout) this.layout.gameHost = host;
};
@ -636,11 +647,116 @@
};
/**
* 选中高亮由游戏 iframe 内绘制;壳层仅清理遗留节点,避免双框/错位。
* 选中 Gizmo:半透明框可拖;红/绿箭头沿 X/Y 轴拖。
* 坐标来自 pick.getRect(含 pos / scale)。
*/
PreviewWorkbenchHost.prototype._setPickHighlight = function (rect) {
var el = document.getElementById('pw-pick-highlight');
if (el && el.parentNode) el.parentNode.removeChild(el);
var gizmo = document.getElementById('pw-pick-gizmo');
if (!gizmo) return;
if (!rect || !(rect.width > 0) || !(rect.height > 0)) {
gizmo.hidden = true;
this._gizmoState = null;
return;
}
var host = document.getElementById('pw-game-host');
var ox = 0;
var oy = 0;
if (host && this._iframe) {
var hr = host.getBoundingClientRect();
var ir = this._iframe.getBoundingClientRect();
ox = ir.left - hr.left;
oy = ir.top - hr.top;
}
gizmo.hidden = false;
gizmo.style.left = (ox + (rect.left || 0)) + 'px';
gizmo.style.top = (oy + (rect.top || 0)) + 'px';
gizmo.style.width = Math.max(4, rect.width || 0) + 'px';
gizmo.style.height = Math.max(4, rect.height || 0) + 'px';
this._gizmoState = {
uuid: rect.uuid || '',
pos: rect.pos || { x: 0, y: 0, z: 0 },
scale: rect.scale || { x: 1, y: 1 },
ox: ox,
oy: oy,
};
};
/** Gizmo 拖拽:xy / 仅 x / 仅 y */
PreviewWorkbenchHost.prototype._bindGizmoDrag = function (gizmo) {
var self = this;
var drag = null;
var onDown = function (ev) {
if (ev.button != null && ev.button !== 0) return;
var axis = ev.currentTarget.getAttribute('data-drag') || 'xy';
var st = self._gizmoState;
if (!st || !st.uuid) return;
ev.preventDefault();
ev.stopPropagation();
drag = {
axis: axis,
x0: ev.clientX,
y0: ev.clientY,
px: st.pos.x || 0,
py: st.pos.y || 0,
sx: st.scale.x || 1,
sy: st.scale.y || 1,
uuid: st.uuid,
left0: parseFloat(gizmo.style.left) || 0,
top0: parseFloat(gizmo.style.top) || 0,
};
document.addEventListener('pointermove', onMove);
document.addEventListener('pointerup', onUp);
gizmo.classList.add('dragging');
};
var onMove = function (ev) {
if (!drag) return;
ev.preventDefault();
var dx = ev.clientX - drag.x0;
var dy = ev.clientY - drag.y0;
var nx = drag.px;
var ny = drag.py;
if (drag.axis !== 'y') nx = drag.px + dx * drag.sx;
if (drag.axis !== 'x') ny = drag.py - dy * drag.sy;
nx = Math.round(nx * 100) / 100;
ny = Math.round(ny * 100) / 100;
if (self.bridge) {
if (drag.axis !== 'y') {
self.bridge.call('node.set', { uuid: drag.uuid, fid: 'px', value: nx });
}
if (drag.axis !== 'x') {
self.bridge.call('node.set', { uuid: drag.uuid, fid: 'py', value: ny });
}
}
// Gizmo 跟手
if (drag.axis !== 'y') gizmo.style.left = (drag.left0 + dx) + 'px';
if (drag.axis !== 'x') gizmo.style.top = (drag.top0 + dy) + 'px';
if (self._gizmoState) {
self._gizmoState.pos = { x: nx, y: ny, z: (self._gizmoState.pos && self._gizmoState.pos.z) || 0 };
}
};
var onUp = function () {
if (!drag) return;
var uuid = drag.uuid;
drag = null;
gizmo.classList.remove('dragging');
document.removeEventListener('pointermove', onMove);
document.removeEventListener('pointerup', onUp);
if (self.bridge) {
self.bridge.call('pick.getRect').then(function (r) {
self._setPickHighlight(r);
}).catch(function () { /* */ });
if (self.panels.inspector) self.panels.inspector.loadUuid(uuid);
}
};
var handles = gizmo.querySelectorAll('[data-drag]');
for (var i = 0; i < handles.length; i++) {
handles[i].addEventListener('pointerdown', onDown);
handles[i].addEventListener('mousedown', onDown);
}
};
PreviewWorkbenchHost.prototype._setStatus = function (text, ok) {

@ -2,7 +2,7 @@
'use strict';
/**
* 资源面板(壳侧):枚举在 Agent,仅订阅可见时刷新。
* 资源面板(壳侧):Bundle 优先 + 去重内存 + 可预览。
* @constructor
*/
function AssetsPanel() {
@ -10,11 +10,12 @@
this._bridge = null;
this._rows = [];
this._bundles = [];
this._totals = null;
this._bundleFilter = '';
this._typeFilter = '';
this._query = '';
this._sort = 'mem';
this._selected = null;
this._selectedUuid = '';
}
AssetsPanel.prototype.setBridge = function (bridge) {
@ -24,56 +25,39 @@
AssetsPanel.prototype.mount = function () {
var self = this;
var root = document.createElement('div');
root.className = 'pw-panel-inner';
root.className = 'pw-panel-inner pw-assets';
root.style.height = '100%';
root.style.display = 'flex';
root.style.flexDirection = 'column';
root.innerHTML =
'<div class="pw-panel-head">' +
'<span class="pw-panel-title">资源</span>' +
'<div class="pw-panel-tools">' +
'<select id="pw-asset-bundle" class="pw-select"><option value="">分包:全部</option></select>' +
'<select id="pw-asset-type" class="pw-select">' +
'<option value="">类型:全部</option>' +
'<option value="Texture2D">纹理 Texture2D</option>' +
'<option value="SpriteFrame">图集帧 SpriteFrame</option>' +
'<option value="Prefab">预制体 Prefab</option>' +
'<option value="sp.SkeletonData">Spine</option>' +
'<option value="AudioClip">音频</option>' +
'<option value="JsonAsset">JSON</option>' +
'<option value="Other">其他</option>' +
'</select>' +
'<input id="pw-asset-q" class="pw-input" placeholder="搜索" style="width:90px" />' +
'<div class="pw-panel-toolbar">' +
'<input id="pw-asset-q" class="pw-input" placeholder="搜索资源 / Bundle" style="flex:1;min-width:80px" />' +
'<select id="pw-asset-type" class="pw-select"><option value="">类型</option></select>' +
'<select id="pw-asset-sort" class="pw-select">' +
'<option value="mem">按内存↓</option>' +
'<option value="name">按名称</option>' +
'<option value="ref">按引用↓</option>' +
'<option value="type">按类型</option>' +
'<option value="mem">按内存</option><option value="name">按名称</option>' +
'<option value="type">按类型</option><option value="ref">按引用</option>' +
'</select>' +
'<button type="button" id="pw-asset-refresh" class="pw-btn">刷新</button>' +
'</div></div>' +
'<div class="pw-panel-body">' +
'<div id="pw-asset-summary" class="pw-summary"></div>' +
'<div id="pw-asset-bundles" class="pw-muted" style="margin-bottom:6px"></div>' +
'<div style="display:flex;gap:8px;min-height:0;flex:1">' +
'<div style="flex:1;overflow:auto;min-width:0"><table class="pw-table" id="pw-asset-table"><thead><tr>' +
'<th>名称</th><th>类型</th><th>分包</th><th>内存</th><th>引用</th>' +
'</tr></thead><tbody></tbody></table></div>' +
'<div style="width:180px;flex-shrink:0"><div class="pw-muted">预览</div><div class="pw-preview-box" id="pw-asset-preview"></div><div id="pw-asset-meta" class="pw-muted" style="margin-top:4px"></div></div>' +
'</div></div>';
'<button type="button" id="pw-asset-refresh" class="pw-btn pw-btn-icon" title="刷新" aria-label="刷新">⟳</button>' +
'</div>' +
'<div id="pw-asset-summary" class="pw-assets-summary"></div>' +
'<div class="pw-assets-body">' +
'<div class="pw-assets-bundles" id="pw-asset-bundles"></div>' +
'<div class="pw-assets-main">' +
'<div class="pw-assets-list" id="pw-asset-list"></div>' +
'<div class="pw-assets-side">' +
'<div class="pw-assets-preview" id="pw-asset-preview"><span class="pw-muted">选择资源预览</span></div>' +
'<div id="pw-asset-meta" class="pw-assets-meta"></div>' +
'</div></div></div>';
this.el = root;
root.querySelector('#pw-asset-bundle').addEventListener('change', function (ev) {
self._bundleFilter = ev.target.value;
root.querySelector('#pw-asset-q').addEventListener('input', function (ev) {
self._query = (ev.target.value || '').toLowerCase();
self.renderList();
});
root.querySelector('#pw-asset-type').addEventListener('change', function (ev) {
self._typeFilter = ev.target.value;
self.renderList();
});
root.querySelector('#pw-asset-q').addEventListener('input', function (ev) {
self._query = (ev.target.value || '').toLowerCase();
self.renderList();
});
root.querySelector('#pw-asset-sort').addEventListener('change', function (ev) {
self._sort = ev.target.value;
self.renderList();
@ -88,20 +72,82 @@
AssetsPanel.prototype.applyData = function (data) {
this._rows = (data && data.rows) || [];
this._bundles = (data && data.bundles) || [];
var sel = this.el && this.el.querySelector('#pw-asset-bundle');
if (sel) {
var cur = this._bundleFilter;
var html = '<option value="">分包:全部</option>';
for (var i = 0; i < this._bundles.length; i++) {
var b = this._bundles[i];
if (!b) continue;
html += '<option value="' + escapeAttr(b) + '"' + (b === cur ? ' selected' : '') + '>' + escapeHtml(b) + '</option>';
}
sel.innerHTML = html;
}
this._totals = (data && data.totals) || null;
this.renderSummary();
this.renderBundles();
this.renderList();
};
AssetsPanel.prototype.renderSummary = function () {
var el = this.el && this.el.querySelector('#pw-asset-summary');
if (!el) return;
var t = this._totals || {};
var f = function (n) {
n = Number(n) || 0;
return n >= 100 ? Math.round(n) : Math.round(n * 10) / 10;
};
el.innerHTML =
'<div class="pw-assets-kpi"><b>' + (t.bundleCount != null ? t.bundleCount : this._bundles.length) + '</b><span>Bundle</span></div>' +
'<div class="pw-assets-kpi"><b>' + (t.assetCount != null ? t.assetCount : this._rows.length) + '</b><span>资源</span></div>' +
'<div class="pw-assets-kpi accent"><b>' + f(t.estimateMB) + '</b><span>估算 MB</span></div>' +
'<div class="pw-assets-kpi"><b>' + f(t.texMB) + '</b><span>纹理</span></div>' +
'<div class="pw-assets-kpi"><b>' + f(t.gfxTextureMB) + '</b><span>GFX纹理</span></div>' +
'<div class="pw-assets-kpi"><b>' + f(t.jsHeapMB) + '</b><span>JS堆</span></div>' +
'<div class="pw-assets-note">估算=去重纹理+音频+其它;GFX/JS堆为实测</div>';
};
AssetsPanel.prototype.renderBundles = function () {
var el = this.el && this.el.querySelector('#pw-asset-bundles');
if (!el) return;
var q = this._query;
var list = this._bundles.slice();
if (q) {
list = list.filter(function (b) {
return String(b.name).toLowerCase().indexOf(q) >= 0;
});
}
var maxMem = 1;
for (var i = 0; i < this._bundles.length; i++) {
maxMem = Math.max(maxMem, this._bundles[i].memMB || 0);
}
var html = ['<div class="pw-assets-bundle-head">Bundle · ' + this._bundles.length + '</div>'];
var allActive = !this._bundleFilter;
html.push(
'<button type="button" class="pw-bundle-item' + (allActive ? ' active' : '') + '" data-bundle="">' +
'<div class="pw-bundle-name">全部</div>' +
'<div class="pw-bundle-sub">' + this._rows.length + ' 项</div>' +
'</button>'
);
for (var j = 0; j < list.length; j++) {
var b = list[j];
var mem = Number(b.memMB) || 0;
var pct = maxMem > 0 ? Math.min(100, (mem / maxMem) * 100) : 0;
var act = this._bundleFilter === b.name ? ' active' : '';
var st = b.status || (b.assetCount > 0 ? 'loaded' : 'unloaded');
var stCls = st === 'loaded' ? 'st-loaded' : (st === 'loaded-empty' ? 'st-empty' : 'st-unloaded');
var stText = st === 'loaded' ? '已加载' : (st === 'loaded-empty' ? '空缓存' : '未加载');
html.push(
'<button type="button" class="pw-bundle-item' + act + ' ' + stCls + '" data-bundle="' + escapeAttr(b.name) + '">' +
'<div class="pw-bundle-name">' + escapeHtml(b.name) +
'<span class="pw-bundle-tag">' + stText + '</span></div>' +
'<div class="pw-bundle-sub">' + (b.assetCount || 0) + ' 项 · ' + fmtMB(mem) + '</div>' +
'<div class="pw-bundle-bar"><i style="width:' + pct + '%"></i></div>' +
'</button>'
);
}
el.innerHTML = html.join('');
var self = this;
var btns = el.querySelectorAll('.pw-bundle-item');
for (var k = 0; k < btns.length; k++) {
btns[k].addEventListener('click', function (ev) {
var name = ev.currentTarget.getAttribute('data-bundle') || '';
self._bundleFilter = name;
self.renderBundles();
self.renderList();
});
}
};
AssetsPanel.prototype.renderList = function () {
if (!this.el) return;
var rows = this._rows.slice();
@ -110,43 +156,121 @@
var tf = this._typeFilter;
rows = rows.filter(function (r) {
if (bf && r.bundle !== bf) return false;
if (tf && tf !== 'Other' && r.type !== tf) return false;
if (tf === 'Other' && /Texture2D|SpriteFrame|Prefab|SkeletonData|AudioClip|JsonAsset/.test(r.type)) return false;
if (tf && r.type !== tf && r.kind !== tf) return false;
if (q && String(r.name).toLowerCase().indexOf(q) < 0 && String(r.type).toLowerCase().indexOf(q) < 0) return false;
return true;
});
var sort = this._sort;
rows.sort(function (a, b) {
if (sort === 'name') return String(a.name).localeCompare(String(b.name));
if (sort === 'type') return String(a.type).localeCompare(String(b.type));
if (sort === 'type') return String(a.type).localeCompare(String(b.type)) || String(a.name).localeCompare(String(b.name));
if (sort === 'ref') return (b.ref || 0) - (a.ref || 0);
return (b.mem || 0) - (a.mem || 0);
});
var memTotal = 0;
for (var i = 0; i < this._rows.length; i++) memTotal += this._rows[i].mem || 0;
var sum = this.el.querySelector('#pw-asset-summary');
if (sum) {
sum.innerHTML = '<span>共 <b>' + this._rows.length + '</b> 资源</span><span>筛选 <b>' + rows.length +
'</b></span><span>估算纹理 <b>' + (Math.round(memTotal * 100) / 100) + ' MB</b></span>';
// 类型选项
var typeSel = this.el.querySelector('#pw-asset-type');
if (typeSel) {
var types = {};
for (var i = 0; i < this._rows.length; i++) types[this._rows[i].type] = 1;
var cur = this._typeFilter;
var html = '<option value="">类型</option>';
var keys = Object.keys(types).sort();
for (var t = 0; t < keys.length; t++) {
html += '<option value="' + escapeAttr(keys[t]) + '"' + (keys[t] === cur ? ' selected' : '') + '>' + escapeHtml(keys[t]) + '</option>';
}
typeSel.innerHTML = html;
}
var bundlesEl = this.el.querySelector('#pw-asset-bundles');
if (bundlesEl) bundlesEl.textContent = this._bundles.length ? ('分包:' + this._bundles.join(', ')) : '';
var tbody = this.el.querySelector('#pw-asset-table tbody');
if (!tbody) return;
var html = '';
for (var j = 0; j < Math.min(rows.length, 500); j++) {
var listEl = this.el.querySelector('#pw-asset-list');
if (!listEl) return;
if (!rows.length) {
listEl.innerHTML = '<div class="pw-muted" style="padding:16px">无匹配资源</div>';
return;
}
var out = [];
out.push('<div class="pw-assets-row pw-assets-row-head"><span>名称</span><span>类型</span><span>内存</span></div>');
var maxShow = Math.min(rows.length, 400);
for (var j = 0; j < maxShow; j++) {
var r = rows[j];
html += '<tr data-i="' + j + '"><td>' + escapeHtml(r.name) + '</td><td>' + escapeHtml(r.type) +
'</td><td>' + escapeHtml(r.bundle || '-') + '</td><td>' + (r.mem ? r.mem.toFixed(2) + 'MB' : 'n/a') +
'</td><td>' + (r.ref || 0) + '</td></tr>';
var sel = r.uuid === this._selectedUuid ? ' active' : '';
var memText = fmtMB(r.mem);
if (r.type === 'SpriteFrame') {
memText = (r.w && r.h) ? (r.w + '×' + r.h) : '共享纹理';
} else if (r.type === 'ImageAsset') {
memText = (r.w && r.h) ? (r.w + '×' + r.h) : '源像素';
}
out.push(
'<div class="pw-assets-row' + sel + '" data-uuid="' + escapeAttr(r.uuid) + '" title="' + escapeAttr(r.type + ' · ' + r.bundle) + '">' +
'<span class="pw-assets-name">' + escapeHtml(r.name) + '</span>' +
'<span class="pw-assets-type">' + escapeHtml(r.type) + '</span>' +
'<span class="pw-assets-mem">' + escapeHtml(memText) + '</span>' +
'</div>'
);
}
if (rows.length > maxShow) {
out.push('<div class="pw-muted" style="padding:8px">… 共 ' + rows.length + ' 项,已显示前 ' + maxShow + '</div>');
}
listEl.innerHTML = out.join('');
var self = this;
var items = listEl.querySelectorAll('.pw-assets-row[data-uuid]');
for (var k = 0; k < items.length; k++) {
items[k].addEventListener('click', function (ev) {
var uuid = ev.currentTarget.getAttribute('data-uuid');
self._selectedUuid = uuid;
self.renderList();
self.loadPreview(uuid);
});
}
tbody.innerHTML = html || '<tr><td colspan="5" class="pw-muted">无</td></tr>';
};
AssetsPanel.prototype.loadPreview = function (uuid) {
var self = this;
var meta = this.el && this.el.querySelector('#pw-asset-meta');
var box = this.el && this.el.querySelector('#pw-asset-preview');
var row = null;
for (var i = 0; i < this._rows.length; i++) {
if (this._rows[i].uuid === uuid) { row = this._rows[i]; break; }
}
if (meta && row) {
meta.innerHTML =
'<div><b>' + escapeHtml(row.name) + '</b></div>' +
'<div class="pw-muted">' + escapeHtml(row.type) + ' · ' + escapeHtml(row.bundle) + '</div>' +
'<div class="pw-muted">' +
(row.type === 'SpriteFrame' || row.type === 'ImageAsset'
? ((row.w && row.h) ? (row.w + '×' + row.h) : '')
: fmtMB(row.mem)) +
' · 引用 ' + (row.ref || 0) + '</div>';
}
if (!box || !this._bridge) return;
box.innerHTML = '<span class="pw-muted">加载预览…</span>';
this._bridge.call('assets.preview', { uuid: uuid }).then(function (p) {
if (!box) return;
if (p && p.kind === 'image' && p.url) {
box.innerHTML = '<img alt="" src="' + p.url + '" />';
} else if (p && p.kind === 'bitmap') {
box.innerHTML = '<span class="pw-muted">Bitmap ' + (p.w || 0) + '×' + (p.h || 0) + '</span>';
} else if (p && p.kind === 'url' && p.url) {
box.innerHTML = '<img alt="" src="' + p.url + '" />';
} else {
box.innerHTML = '<span class="pw-muted">无预览</span>';
}
}).catch(function () {
if (box) box.innerHTML = '<span class="pw-muted">预览失败</span>';
});
};
function fmtMB(mb) {
var n = Number(mb) || 0;
if (n <= 0) return '0';
if (n < 0.01) return '<0.01MB';
if (n < 10) return (Math.round(n * 100) / 100) + 'MB';
return (Math.round(n * 10) / 10) + 'MB';
}
function escapeHtml(s) {
return String(s).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
return String(s == null ? '' : s)
.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
}
function escapeAttr(s) {
return escapeHtml(s).replace(/"/g, '&quot;');

Loading…
Cancel
Save