From e7221e54832033095fc8940f3eb1b710e09e160f Mon Sep 17 00:00:00 2001 From: humingxing <930621236@qq.com> Date: Thu, 15 Oct 2026 11:15:51 +0800 Subject: [PATCH] =?UTF-8?q?=E6=96=B0=E5=A2=9E=E9=85=8D=E7=BD=AE=E5=AE=9E?= =?UTF-8?q?=E7=8E=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../mx/module/battle/combat/DamagePipeline.ts | 41 +- .../battle/combat/BattleBuffDamageHook.ts | 120 +++++ .../combat/BattleBuffDamageHook.ts.meta | 9 + .../battle/combat/BattleBuffDigitStatHost.ts | 377 +++++++++++++--- assets/script/battle/combat/ExecuteRules.ts | 83 +++- .../battle/combat/PassiveSkillRuntime.ts | 409 +++++++++++++++++- assets/script/battle/heros/Gong.ts | 8 +- assets/script/battle/runtime/BattleRuntime.ts | 3 + assets/script/battle/skill/Skill.ts | 9 +- assets/script/interface/AutoTable.ts | 11 +- .../battleBuff/BattleBuffParamParse.ts | 123 +++++- .../manager/battleBuff/BattleBuffTypes.ts | 22 + docs/方案-Battlebuff扩展105-112.md | 52 +++ docs/方案-Battlebuff新表实现.md | 4 +- preview-template/index.ejs | 1 + preview-template/workbench/css/workbench.css | 364 +++++++++++++++- preview-template/workbench/js/agent-assets.js | 404 +++++++++++++++++ preview-template/workbench/js/agent-core.js | 1 + preview-template/workbench/js/agent-pick.js | 161 ++++--- preview-template/workbench/js/host.js | 124 +++++- .../workbench/js/panels/assets.js | 264 ++++++++--- 21 files changed, 2359 insertions(+), 231 deletions(-) create mode 100644 assets/script/battle/combat/BattleBuffDamageHook.ts create mode 100644 assets/script/battle/combat/BattleBuffDamageHook.ts.meta create mode 100644 docs/方案-Battlebuff扩展105-112.md create mode 100644 preview-template/workbench/js/agent-assets.js diff --git a/assets/mx/module/battle/combat/DamagePipeline.ts b/assets/mx/module/battle/combat/DamagePipeline.ts index 677a592..bfa0e3c 100644 --- a/assets/mx/module/battle/combat/DamagePipeline.ts +++ b/assets/mx/module/battle/combat/DamagePipeline.ts @@ -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, + 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; } diff --git a/assets/script/battle/combat/BattleBuffDamageHook.ts b/assets/script/battle/combat/BattleBuffDamageHook.ts new file mode 100644 index 0000000..e831841 --- /dev/null +++ b/assets/script/battle/combat/BattleBuffDamageHook.ts @@ -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<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, 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; + } + } +} diff --git a/assets/script/battle/combat/BattleBuffDamageHook.ts.meta b/assets/script/battle/combat/BattleBuffDamageHook.ts.meta new file mode 100644 index 0000000..f7380be --- /dev/null +++ b/assets/script/battle/combat/BattleBuffDamageHook.ts.meta @@ -0,0 +1,9 @@ +{ + "ver": "4.0.24", + "importer": "typescript", + "imported": true, + "uuid": "2430878d-c135-416c-9b1f-28d3f2fa2c29", + "files": [], + "subMetas": {}, + "userData": {} +} diff --git a/assets/script/battle/combat/BattleBuffDigitStatHost.ts b/assets/script/battle/combat/BattleBuffDigitStatHost.ts index e5b2c25..4c6b45a 100644 --- a/assets/script/battle/combat/BattleBuffDigitStatHost.ts +++ b/assets/script/battle/combat/BattleBuffDigitStatHost.ts @@ -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(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(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(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; diff --git a/assets/script/battle/combat/ExecuteRules.ts b/assets/script/battle/combat/ExecuteRules.ts index cf86ca5..05bc383 100644 --- a/assets/script/battle/combat/ExecuteRules.ts +++ b/assets/script/battle/combat/ExecuteRules.ts @@ -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); +} diff --git a/assets/script/battle/combat/PassiveSkillRuntime.ts b/assets/script/battle/combat/PassiveSkillRuntime.ts index 0869d03..ea54fc8 100644 --- a/assets/script/battle/combat/PassiveSkillRuntime.ts +++ b/assets/script/battle/combat/PassiveSkillRuntime.ts @@ -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(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(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 塔实体 diff --git a/assets/script/battle/heros/Gong.ts b/assets/script/battle/heros/Gong.ts index 94a99b8..bfb4468 100644 --- a/assets/script/battle/heros/Gong.ts +++ b/assets/script/battle/heros/Gong.ts @@ -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, }); } } diff --git a/assets/script/battle/runtime/BattleRuntime.ts b/assets/script/battle/runtime/BattleRuntime.ts index 56d3628..a649f9e 100644 --- a/assets/script/battle/runtime/BattleRuntime.ts +++ b/assets/script/battle/runtime/BattleRuntime.ts @@ -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(); diff --git a/assets/script/battle/skill/Skill.ts b/assets/script/battle/skill/Skill.ts index dff005d..2d21aeb 100644 --- a/assets/script/battle/skill/Skill.ts +++ b/assets/script/battle/skill/Skill.ts @@ -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); diff --git a/assets/script/interface/AutoTable.ts b/assets/script/interface/AutoTable.ts index ff6bbe7..297d8f4 100644 --- a/assets/script/interface/AutoTable.ts +++ b/assets/script/interface/AutoTable.ts @@ -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、哪吒效果:吒字飞出去,反向穿透冲锋,对沿途怪物造成伤害,参数:冲锋速度,持续时间,伤害系数 diff --git a/assets/script/manager/battleBuff/BattleBuffParamParse.ts b/assets/script/manager/battleBuff/BattleBuffParamParse.ts index 62b49a9..5a05bb9 100644 --- a/assets/script/manager/battleBuff/BattleBuffParamParse.ts +++ b/assets/script/manager/battleBuff/BattleBuffParamParse.ts @@ -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; } } diff --git a/assets/script/manager/battleBuff/BattleBuffTypes.ts b/assets/script/manager/battleBuff/BattleBuffTypes.ts index 1d3d552..fce3e07 100644 --- a/assets/script/manager/battleBuff/BattleBuffTypes.ts +++ b/assets/script/manager/battleBuff/BattleBuffTypes.ts @@ -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, ]; /** diff --git a/docs/方案-Battlebuff扩展105-112.md b/docs/方案-Battlebuff扩展105-112.md new file mode 100644 index 0000000..beddf93 --- /dev/null +++ b/docs/方案-Battlebuff扩展105-112.md @@ -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 倍;穿透/额外飞箭/攻击距离 diff --git a/docs/方案-Battlebuff新表实现.md b/docs/方案-Battlebuff新表实现.md index a449215..fbb4a89 100644 --- a/docs/方案-Battlebuff新表实现.md +++ b/docs/方案-Battlebuff新表实现.md @@ -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. 表现复用 diff --git a/preview-template/index.ejs b/preview-template/index.ejs index 1301385..4bf1b72 100644 --- a/preview-template/index.ejs +++ b/preview-template/index.ejs @@ -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; diff --git a/preview-template/workbench/css/workbench.css b/preview-template/workbench/css/workbench.css index 0e28041..d89f859 100644 --- a/preview-template/workbench/css/workbench.css +++ b/preview-template/workbench/css/workbench.css @@ -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; diff --git a/preview-template/workbench/js/agent-assets.js b/preview-template/workbench/js/agent-assets.js new file mode 100644 index 0000000..0c9e8a0 --- /dev/null +++ b/preview-template/workbench/js/agent-assets.js @@ -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); diff --git a/preview-template/workbench/js/agent-core.js b/preview-template/workbench/js/agent-core.js index 3cece88..1fe9642 100644 --- a/preview-template/workbench/js/agent-core.js +++ b/preview-template/workbench/js/agent-core.js @@ -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); diff --git a/preview-template/workbench/js/agent-pick.js b/preview-template/workbench/js/agent-pick.js index 5474908..bf6ce3c 100644 --- a/preview-template/workbench/js/agent-pick.js +++ b/preview-template/workbench/js/agent-pick.js @@ -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); diff --git a/preview-template/workbench/js/host.js b/preview-template/workbench/js/host.js index 8b750c0..b54c28d 100644 --- a/preview-template/workbench/js/host.js +++ b/preview-template/workbench/js/host.js @@ -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 = + '
' + + '
' + + '
'; + 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) { diff --git a/preview-template/workbench/js/panels/assets.js b/preview-template/workbench/js/panels/assets.js index 0c39c53..cac7896 100644 --- a/preview-template/workbench/js/panels/assets.js +++ b/preview-template/workbench/js/panels/assets.js @@ -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 = - '
' + - '资源' + - '
' + - '' + - '' + - '' + + '
' + + '' + + '' + '' + - '' + - '
' + - '
' + - '
' + - '
' + - '
' + - '
' + - '' + - '
名称类型分包内存引用
' + - '
预览
' + - '
'; + '' + + '
' + + '
' + + '
' + + '
' + + '
' + + '
' + + '
' + + '
选择资源预览
' + + '
' + + '
'; 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 = ''; - for (var i = 0; i < this._bundles.length; i++) { - var b = this._bundles[i]; - if (!b) continue; - html += ''; - } - 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 = + '
' + (t.bundleCount != null ? t.bundleCount : this._bundles.length) + 'Bundle
' + + '
' + (t.assetCount != null ? t.assetCount : this._rows.length) + '资源
' + + '
' + f(t.estimateMB) + '估算 MB
' + + '
' + f(t.texMB) + '纹理
' + + '
' + f(t.gfxTextureMB) + 'GFX纹理
' + + '
' + f(t.jsHeapMB) + 'JS堆
' + + '
估算=去重纹理+音频+其它;GFX/JS堆为实测
'; + }; + + 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 = ['
Bundle · ' + this._bundles.length + '
']; + var allActive = !this._bundleFilter; + html.push( + '' + ); + 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( + '' + ); + } + 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 = '共 ' + this._rows.length + ' 资源筛选 ' + rows.length + - '估算纹理 ' + (Math.round(memTotal * 100) / 100) + ' MB'; + // 类型选项 + 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 = ''; + var keys = Object.keys(types).sort(); + for (var t = 0; t < keys.length; t++) { + html += ''; + } + 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 = '
无匹配资源
'; + return; + } + var out = []; + out.push('
名称类型内存
'); + var maxShow = Math.min(rows.length, 400); + for (var j = 0; j < maxShow; j++) { var r = rows[j]; - html += '' + escapeHtml(r.name) + '' + escapeHtml(r.type) + - '' + escapeHtml(r.bundle || '-') + '' + (r.mem ? r.mem.toFixed(2) + 'MB' : 'n/a') + - '' + (r.ref || 0) + ''; + 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( + '
' + + '' + escapeHtml(r.name) + '' + + '' + escapeHtml(r.type) + '' + + '' + escapeHtml(memText) + '' + + '
' + ); + } + if (rows.length > maxShow) { + out.push('
… 共 ' + rows.length + ' 项,已显示前 ' + maxShow + '
'); + } + 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 || '无'; }; + 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 = + '
' + escapeHtml(row.name) + '
' + + '
' + escapeHtml(row.type) + ' · ' + escapeHtml(row.bundle) + '
' + + '
' + + (row.type === 'SpriteFrame' || row.type === 'ImageAsset' + ? ((row.w && row.h) ? (row.w + '×' + row.h) : '') + : fmtMB(row.mem)) + + ' · 引用 ' + (row.ref || 0) + '
'; + } + if (!box || !this._bridge) return; + box.innerHTML = '加载预览…'; + this._bridge.call('assets.preview', { uuid: uuid }).then(function (p) { + if (!box) return; + if (p && p.kind === 'image' && p.url) { + box.innerHTML = ''; + } else if (p && p.kind === 'bitmap') { + box.innerHTML = 'Bitmap ' + (p.w || 0) + '×' + (p.h || 0) + ''; + } else if (p && p.kind === 'url' && p.url) { + box.innerHTML = ''; + } else { + box.innerHTML = '无预览'; + } + }).catch(function () { + if (box) box.innerHTML = '预览失败'; + }); + }; + + 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, '&').replace(//g, '>'); + return String(s == null ? '' : s) + .replace(/&/g, '&').replace(//g, '>'); } function escapeAttr(s) { return escapeHtml(s).replace(/"/g, '"');