diff --git a/assets/bundles/battleGameMonster/prefab/monster/Boss土.prefab b/assets/bundles/battleGameMonster/prefab/monster/Boss土.prefab index a2f579b..88d32ec 100644 --- a/assets/bundles/battleGameMonster/prefab/monster/Boss土.prefab +++ b/assets/bundles/battleGameMonster/prefab/monster/Boss土.prefab @@ -1,7 +1,7 @@ [ { "__type__": "cc.Prefab", - "_name": "Boss土", + "_name": "BOSS土", "_objFlags": 0, "__editorExtras__": {}, "_native": "", @@ -13,7 +13,7 @@ }, { "__type__": "cc.Node", - "_name": "Boss土", + "_name": "BOSS土", "_objFlags": 0, "__editorExtras__": {}, "_parent": null, diff --git a/assets/bundles/battleGameMonster/prefab/monster/Boss土.prefab.meta b/assets/bundles/battleGameMonster/prefab/monster/Boss土.prefab.meta index c66e900..e7390b2 100644 --- a/assets/bundles/battleGameMonster/prefab/monster/Boss土.prefab.meta +++ b/assets/bundles/battleGameMonster/prefab/monster/Boss土.prefab.meta @@ -8,6 +8,6 @@ ], "subMetas": {}, "userData": { - "syncNodeName": "Boss土" + "syncNodeName": "BOSS土" } } diff --git a/assets/bundles/battleGameMonster/prefab/monster/Boss木.prefab b/assets/bundles/battleGameMonster/prefab/monster/Boss木.prefab index 250beab..a5c810e 100644 --- a/assets/bundles/battleGameMonster/prefab/monster/Boss木.prefab +++ b/assets/bundles/battleGameMonster/prefab/monster/Boss木.prefab @@ -1,7 +1,7 @@ [ { "__type__": "cc.Prefab", - "_name": "Boss木", + "_name": "BOSS木", "_objFlags": 0, "__editorExtras__": {}, "_native": "", @@ -13,7 +13,7 @@ }, { "__type__": "cc.Node", - "_name": "Boss木", + "_name": "BOSS木", "_objFlags": 0, "__editorExtras__": {}, "_parent": null, diff --git a/assets/bundles/battleGameMonster/prefab/monster/Boss木.prefab.meta b/assets/bundles/battleGameMonster/prefab/monster/Boss木.prefab.meta index 7cebeb9..745a297 100644 --- a/assets/bundles/battleGameMonster/prefab/monster/Boss木.prefab.meta +++ b/assets/bundles/battleGameMonster/prefab/monster/Boss木.prefab.meta @@ -8,6 +8,6 @@ ], "subMetas": {}, "userData": { - "syncNodeName": "Boss木" + "syncNodeName": "BOSS木" } } diff --git a/assets/bundles/battleGameMonster/prefab/monster/Boss水.prefab b/assets/bundles/battleGameMonster/prefab/monster/Boss水.prefab index a9101c1..5b7c7ed 100644 --- a/assets/bundles/battleGameMonster/prefab/monster/Boss水.prefab +++ b/assets/bundles/battleGameMonster/prefab/monster/Boss水.prefab @@ -1,7 +1,7 @@ [ { "__type__": "cc.Prefab", - "_name": "Boss水", + "_name": "BOSS水", "_objFlags": 0, "__editorExtras__": {}, "_native": "", @@ -13,7 +13,7 @@ }, { "__type__": "cc.Node", - "_name": "Boss水", + "_name": "BOSS水", "_objFlags": 0, "__editorExtras__": {}, "_parent": null, diff --git a/assets/bundles/battleGameMonster/prefab/monster/Boss水.prefab.meta b/assets/bundles/battleGameMonster/prefab/monster/Boss水.prefab.meta index 89cb549..f68ab9d 100644 --- a/assets/bundles/battleGameMonster/prefab/monster/Boss水.prefab.meta +++ b/assets/bundles/battleGameMonster/prefab/monster/Boss水.prefab.meta @@ -8,6 +8,6 @@ ], "subMetas": {}, "userData": { - "syncNodeName": "Boss水" + "syncNodeName": "BOSS水" } } diff --git a/assets/bundles/battleGameMonster/prefab/monster/Boss火.prefab b/assets/bundles/battleGameMonster/prefab/monster/Boss火.prefab index 576ce5f..48f9134 100644 --- a/assets/bundles/battleGameMonster/prefab/monster/Boss火.prefab +++ b/assets/bundles/battleGameMonster/prefab/monster/Boss火.prefab @@ -1,7 +1,7 @@ [ { "__type__": "cc.Prefab", - "_name": "Boss火", + "_name": "BOSS火", "_objFlags": 0, "__editorExtras__": {}, "_native": "", @@ -13,7 +13,7 @@ }, { "__type__": "cc.Node", - "_name": "Boss火", + "_name": "BOSS火", "_objFlags": 0, "__editorExtras__": {}, "_parent": null, diff --git a/assets/bundles/battleGameMonster/prefab/monster/Boss火.prefab.meta b/assets/bundles/battleGameMonster/prefab/monster/Boss火.prefab.meta index 1da9d8d..d090eb2 100644 --- a/assets/bundles/battleGameMonster/prefab/monster/Boss火.prefab.meta +++ b/assets/bundles/battleGameMonster/prefab/monster/Boss火.prefab.meta @@ -8,6 +8,6 @@ ], "subMetas": {}, "userData": { - "syncNodeName": "Boss火" + "syncNodeName": "BOSS火" } } diff --git a/assets/bundles/battleGameMonster/prefab/monster/Boss金.prefab b/assets/bundles/battleGameMonster/prefab/monster/Boss金.prefab index 530373b..b9fd17c 100644 --- a/assets/bundles/battleGameMonster/prefab/monster/Boss金.prefab +++ b/assets/bundles/battleGameMonster/prefab/monster/Boss金.prefab @@ -1,7 +1,7 @@ [ { "__type__": "cc.Prefab", - "_name": "Boss金", + "_name": "BOSS金", "_objFlags": 0, "__editorExtras__": {}, "_native": "", @@ -13,7 +13,7 @@ }, { "__type__": "cc.Node", - "_name": "Boss金", + "_name": "BOSS金", "_objFlags": 0, "__editorExtras__": {}, "_parent": null, diff --git a/assets/bundles/battleGameMonster/prefab/monster/Boss金.prefab.meta b/assets/bundles/battleGameMonster/prefab/monster/Boss金.prefab.meta index f06837a..813d656 100644 --- a/assets/bundles/battleGameMonster/prefab/monster/Boss金.prefab.meta +++ b/assets/bundles/battleGameMonster/prefab/monster/Boss金.prefab.meta @@ -8,6 +8,6 @@ ], "subMetas": {}, "userData": { - "syncNodeName": "Boss金" + "syncNodeName": "BOSS金" } } diff --git a/assets/bundles/first/config/Skill.json b/assets/bundles/first/config/Skill.json index 2b6eba7..e30f1ae 100644 --- a/assets/bundles/first/config/Skill.json +++ b/assets/bundles/first/config/Skill.json @@ -558,7 +558,7 @@ "level": 0, "cd": 16, "priority": 0, - "animation": "攻击1", + "animation": "攻击", "showeffect": "", "target_stage": 1, "range": 0, @@ -580,7 +580,7 @@ "level": 0, "cd": 16, "priority": 0, - "animation": "攻击1", + "animation": "攻击", "showeffect": "", "target_stage": 3, "range": 0, @@ -602,7 +602,7 @@ "level": 0, "cd": 16, "priority": 0, - "animation": "攻击1", + "animation": "攻击", "showeffect": "", "target_stage": 2, "range": 0, @@ -624,7 +624,7 @@ "level": 0, "cd": 16, "priority": 0, - "animation": "攻击1", + "animation": "攻击", "showeffect": "", "target_stage": 1, "range": 0, @@ -646,7 +646,7 @@ "level": 0, "cd": 16, "priority": 0, - "animation": "攻击1", + "animation": "攻击", "showeffect": "", "target_stage": 1, "range": 0, diff --git a/assets/mx/module/battle/combat/AbilitySystem.ts b/assets/mx/module/battle/combat/AbilitySystem.ts index 1958e51..ff490b0 100644 --- a/assets/mx/module/battle/combat/AbilitySystem.ts +++ b/assets/mx/module/battle/combat/AbilitySystem.ts @@ -223,7 +223,12 @@ export class AbilitySystem implements ISystem { if (this._services.castBlockPredicate?.(entityId)) { return false; } - if (this._services.buffs.hasTag(entityId, 'debuff_8') || this._services.buffs.hasTag(entityId, 'debuff_13')) { + // 眩晕 / 混乱 / 冰冻:塔与怪均禁攻 + if ( + this._services.buffs.hasTag(entityId, 'debuff_4') + || this._services.buffs.hasTag(entityId, 'debuff_8') + || this._services.buffs.hasTag(entityId, 'debuff_13') + ) { return false; } const abilities = this._world.getComponent(entityId, AbilitySetComp); diff --git a/assets/mx/module/battle/combat/TargetResolver.ts b/assets/mx/module/battle/combat/TargetResolver.ts index f6d12c8..9fd7e93 100644 --- a/assets/mx/module/battle/combat/TargetResolver.ts +++ b/assets/mx/module/battle/combat/TargetResolver.ts @@ -15,7 +15,8 @@ import type { Cell } from '../battlefield/Cell'; import { applyTargetStageStrategy, type TargetStageStrategyOptions } from './TargetStageStrategy'; import { BattleMapSpec } from 'db://assets/script/battle/map/BattleMapSpec'; import { parseLegacyMonsterId, parseMonsterUnitDefId } from 'db://assets/script/battle/config/MonsterHpCalculator'; -import { isHeroWeaponTarget, isMonsterBasicHeroTarget, isMonsterDefaultEnemyTarget, isPeachTreeTower } from 'db://assets/script/battle/config/BattleHeroTable'; +import { isHeroWeaponTarget, isMonsterBasicHeroTarget, isMonsterDefaultEnemyTarget, isPeachTreeTower, resolveTowerComposeLevel } from 'db://assets/script/battle/config/BattleHeroTable'; +import { getTowerRuinHost } from 'db://assets/script/battle/combat/TowerRuinHost'; import { MonsterDataManager } from 'db://assets/script/manager/MonsterDataManager'; /** @@ -131,6 +132,7 @@ export class TargetResolver { filtered.push(id); } this._addSameSideCaster(ctx, spec, filtered); + this._excludeBoardMaxLevel(filtered, spec); if (filtered.length === 0) return []; @@ -236,6 +238,7 @@ export class TargetResolver { filtered.push(id); } this._addSameSideCaster(ctx, spec, filtered); + this._excludeBoardMaxLevel(filtered, spec); if (filtered.length === 0) return []; @@ -256,6 +259,30 @@ export class TargetResolver { ); } + /** + * params.monsterSkipMaxLevel:从候选中剔除当前等级最高的数字(火 Boss 41101)。 + * 候选全为同级最高时清空,本次不施法。 + * @param filtered 已筛列表(原地改) + * @param spec 索敌 + */ + private _excludeBoardMaxLevel(filtered: EntityId[], spec: TargetSpec): void { + if (!spec.params?.monsterSkipMaxLevel || filtered.length <= 0) return; + let maxLv = 0; + for (let i = 0; i < filtered.length; i++) { + const lv = resolveTowerComposeLevel(this._world, filtered[i]); + if (lv > maxLv) maxLv = lv; + } + if (!(maxLv > 0)) return; + let w = 0; + for (let i = 0; i < filtered.length; i++) { + const id = filtered[i]; + if (resolveTowerComposeLevel(this._world, id) >= maxLv) continue; + filtered[w] = id; + w += 1; + } + filtered.length = w; + } + private _stagePickOptions(ctx: AbilityContext, spec: TargetSpec): TargetStageStrategyOptions { const stageRangePx = Math.max(0, (spec.params?.stageRangePx as number) ?? 0); const posScratch = { x: 0, y: 0 }; @@ -602,7 +629,10 @@ export class TargetResolver { : BattleTeamId.isFriendly(casterTeam, team); if (!match) return false; if (spec.params?.monsterEnemyHero && !isMonsterDefaultEnemyTarget(world, id)) return false; - if (spec.params?.monsterBasicHero && !isMonsterBasicHeroTarget(world, id)) return false; + if (spec.params?.monsterBasicHero) { + if (!isMonsterBasicHeroTarget(world, id)) return false; + if (getTowerRuinHost().isEntityRuined(id)) return false; + } const weaponType = Math.floor(Number(spec.params?.weaponType) || 0); if (weaponType > 0 && !isHeroWeaponTarget(world, id, weaponType)) return false; return true; diff --git a/assets/script/battle/combat/MonsterSkillHost.ts b/assets/script/battle/combat/MonsterSkillHost.ts index 96fee58..1300ee5 100644 --- a/assets/script/battle/combat/MonsterSkillHost.ts +++ b/assets/script/battle/combat/MonsterSkillHost.ts @@ -29,6 +29,7 @@ import type { SkillPresentationContext } from '../skill/SkillPresentationTypes'; import { BATTLE_MONSTER_CCCLASS, type IBattleMonsterView } from '../view/IBattleMonsterView'; import { getMonsterStatusText } from '../view/MonsterStatusText'; import { pickSealablePutTiles, sealPlaceableTiles } from './SkillAtkBuffBoardBridge'; +import { getPassiveSkillHost, PassiveResultKind } from './PassiveSkillRuntime'; import { getTowerRuinHost } from './TowerRuinHost'; import { SkillAtkBuffExecutor } from './SkillAtkBuffExecutor'; @@ -82,6 +83,8 @@ const GOURD_TURN_SEC = 0.12; const RAIN_ABOVE_PX = BattleMapSpec.CELL_SIZE * 0.5; /** Skill.skill 类型 5:吞噬本方小兵 */ const EXTRA_DEVOUR = 5; +/** Skill.atk_buff1 类型 1:眩晕 */ +const BUFF_STUN = 1; /** Skill.atk_buff1 类型 3:移速 */ const BUFF_SPEED = 3; @@ -204,11 +207,13 @@ export function getMonsterSkillHost(): MonsterSkillHost { /** * 出怪绑定 Monster.skill → Ability,承接 monsterSkill cue 播释放动画。 * extra 9:起播时挂干扰迷雾并渐显(无 atk_type=半屏;atk_type=15=主目标格);同时降攻速。 - * atk_buff1 类型 12:技能动画结束后在锁定基础字格播损毁文字并使其失效。 + * atk_buff1 类型 12:技能动画结束后在锁定基础字格播损毁文字并使其失效(火 Boss 41101 索敌跳过场上最高等级)。 + * atk_buff1 类型 1 + atk_type=3:动画结束按**当前位置**重扫圈内数字并眩晕(土 Boss 51101;禁攻+格上怪_晕眩)。 + * result=11 且 atk_buff1=12:动画结束后放剑气,命中路径上基础字击倒(金 Boss 11101)。 * atk_buff1 类型 6:技能动画结束后降攻速;无 skill 类型 9 时再挂降雨(敖广)。 * 奔波儿灞:播「技能」同时出池 `葫芦` 挂怪物(预制体默认偏移),播「技能葫芦」并朝向主目标格;同时渐显干扰迷雾,播完回 45° 后回池。 * extra 5:释放同时吸入本方周围小兵,动画结束吞噬并加血上限。 - * target_stage=2:技能动画结束后给射程内同侧友军怪上 atk_buff(太白移速+回血,含自身)。 + * target_stage=2:技能动画结束后给射程内同侧友军怪上 atk_buff(太白移速+回血、水 Boss 31101 移速,含自身;依赖 Skill.range>0)。 * atk_buff1 类型 9:施法起播时出千里眼技能瞄向目标格并封为可开挖。 */ export class MonsterSkillHost { @@ -425,12 +430,6 @@ export class MonsterSkillHost { } const animName = String(event.params?.animation ?? '').trim(); - if (!animName) { - console.log('[MonsterSkillHost] Skill.animation 为空', event.abilityId); - ctx.finish(); - return; - } - const node = SkillPresentationEnv.getMonsterNode(event.caster); const view = node?.isValid ? node.getComponent(BATTLE_MONSTER_CCCLASS) as IBattleMonsterView | null @@ -438,31 +437,46 @@ export class MonsterSkillHost { const sk = view?.anim; if (!node?.isValid || !view || !sk?.isValid) { console.log('[MonsterSkillHost] 怪物节点未就绪', event.caster, event.abilityId); - ctx.finish(); + this._finishPresent({ entity: event.caster, ctx, wait: 0, moveLocked: false }); return; } - const deathAnim = MonsterDataManager.resolveDeathAnim(view.Data ?? null); + // 无 Data 时不查死亡轨(避免 resolveDeathAnim 无配表刷屏);仅已绑定配表时拦截死亡态 + const deathAnim = view.Data ? MonsterDataManager.resolveDeathAnim(view.Data) : ''; if (sk.animation === '死亡' || (deathAnim && sk.animation === deathAnim)) { ctx.finish(); return; } - if (BattleSpinePlayback.getAnimDuration(sk, animName) == null) { - console.log('[MonsterSkillHost] 骨骼无技能动画', animName, node.name, event.abilityId); - ctx.finish(); - return; + + let playAnim = animName; + if (!playAnim) { + console.log('[MonsterSkillHost] Skill.animation 为空', event.abilityId); + } else if (BattleSpinePlayback.getAnimDuration(sk, playAnim) == null) { + // 五行 Boss 资源轨多为「攻击」,表若写「攻击1」则告警后改播「攻击」 + if (playAnim === '攻击1' && BattleSpinePlayback.getAnimDuration(sk, '攻击') != null) { + console.log('[MonsterSkillHost] 骨骼无', playAnim, '改播攻击', node.name, event.abilityId); + playAnim = '攻击'; + } else { + console.log('[MonsterSkillHost] 骨骼无技能动画', playAnim, node.name, event.abilityId); + playAnim = ''; + } } - const delay = BattleSpinePlayback.resolveLogicalCompleteDelay(sk, animName, 1); - // Boss/怪物主动技能:动画倍速固定 1(不跟攻速) - view.playSpineAnim?.(animName, false); + let wait = 0; + if (playAnim) { + const delay = BattleSpinePlayback.resolveLogicalCompleteDelay(sk, playAnim, 1); + // Boss/怪物主动技能:动画倍速固定 1(不跟攻速) + view.playSpineAnim?.(playAnim, false); + wait = delay != null && delay > 0 ? delay : 0; + } this._playGourdIfNeeded(event.caster, ctx); - const wait = delay != null && delay > 0 ? delay : 0; this._spawnFogIfNeeded(ctx, wait > 0 ? wait : FOG_FADE_SEC); // 迷雾起播即降攻速,避免动画结束前点面板看不到 if (Math.floor(Number(ctx.params.skillType ?? 0) || 0) === EXTRA_FOG) { this._spawnRainIfNeeded(ctx); } getMonsterStatusText().play(event.caster, '释放技能'); + // result 11 剑气与攻击同帧起飞(勿等动画结束,否则观感像没放) + this._fireSkillResultIfNeeded(ctx); this._beginDevour(ctx, wait); this._beginEyeIfNeeded(ctx, wait); if (!(wait > 0)) { @@ -490,22 +504,88 @@ export class MonsterSkillHost { ? node.getComponent(BATTLE_MONSTER_CCCLASS) as IBattleMonsterView | null : null; if (view?.playWalk && view.anim?.isValid) { - const deathAnim = MonsterDataManager.resolveDeathAnim(view.Data ?? null); + const deathAnim = view.Data ? MonsterDataManager.resolveDeathAnim(view.Data) : ''; const cur = String(view.anim.animation ?? ''); if (cur !== '死亡' && !(deathAnim && cur === deathAnim)) { view.playWalk(); } } this._consumeDevour(item.entity); - this._spawnDestroyIfNeeded(ctx); + if (!this._hasSwordWaveRuin(ctx)) { + this._spawnDestroyIfNeeded(ctx); + } if (Math.floor(Number(ctx.params.skillType ?? 0) || 0) !== EXTRA_FOG) { this._spawnRainIfNeeded(ctx); } this._buffAlliesIfNeeded(ctx); + this._stunAroundIfNeeded(ctx); while (ctx.isAlive && ctx.hit()) { /* 召唤 / atk_buff */ } ctx.finish(); } + /** + * atk_type=3 + atk_buff1 类型1:按施法者**当前**位置为圆心重扫范围内敌方数字并眩晕。 + * @param ctx 本次施法 + */ + private _stunAroundIfNeeded(ctx: SkillPresentationContext): void { + const buff1 = String(ctx.params.buff1 ?? '').trim(); + const buff2 = String(ctx.params.buff2 ?? '').trim(); + const head = Math.floor(Number(buff1.split(',')[0]) || 0); + if (head !== BUFF_STUN) return; + const atkHead = Math.floor(Number(ctx.params.atkTypeHead ?? 0) || 0); + if (atkHead !== 3) return; + const battle = this._battle; + if (!battle) { + console.log('[MonsterSkillHost] 未绑定,无法圈晕', ctx.abilityId); + return; + } + const def = battle.session.config.getAbility(ctx.abilityId); + if (!def?.targeting) { + console.log('[MonsterSkillHost] 圈晕缺少 targeting', ctx.abilityId); + return; + } + const abilityCtx = createAbilityContext(battle.session, ctx.caster, ctx.abilityId); + abilityCtx.level = ctx.level; + const ids = battle.session.targets.resolve(abilityCtx, def.targeting); + if (ids.length <= 0) { + console.log('[MonsterSkillHost] 当前位置圈内无可晕数字', ctx.abilityId); + return; + } + for (let i = 0; i < ids.length; i++) { + SkillAtkBuffExecutor.applyPair(battle.session, abilityCtx, ids[i], buff1, buff2); + } + } + + /** + * 施法起播按 Skill.result 结算(金 Boss 等:result 11 剑气)。 + * @param ctx 本次施法 + */ + private _fireSkillResultIfNeeded(ctx: SkillPresentationContext): void { + const skillId = Math.floor(Number(ctx.params.skillId) || 0); + const session = this._battle?.session; + if (!session || !(skillId > 0)) return; + const row = gg.data.table.getTableData(TableNames.Skill, skillId); + if (!String(row?.result ?? '').trim()) return; + const target = ctx.targets[0] ?? 0; + getPassiveSkillHost().applySkillResult(session, ctx.caster, target, skillId); + } + + /** + * result=11 且 atk_buff1=12:损毁改由剑气命中路径结算。 + * @param ctx 本次施法 + */ + private _hasSwordWaveRuin(ctx: SkillPresentationContext): boolean { + const skillId = Math.floor(Number(ctx.params.skillId) || 0); + if (!(skillId > 0)) return false; + const row = gg.data.table.getTableData(TableNames.Skill, skillId); + const result = String(row?.result ?? '').trim(); + if (!result) return false; + const kind = Math.floor(Number(result.split(',')[0]) || 0); + if (kind !== PassiveResultKind.SwordWave) return false; + const buffHead = Math.floor(Number(String(ctx.params.buff1 ?? '').split(',')[0]) || 0); + return buffHead === BUFF_DESTROY; + } + /** * 取消指定实体未结束的表现。 * @param entityId 施法者 diff --git a/assets/script/battle/combat/PassiveSkillRuntime.ts b/assets/script/battle/combat/PassiveSkillRuntime.ts index e44ee10..926f620 100644 --- a/assets/script/battle/combat/PassiveSkillRuntime.ts +++ b/assets/script/battle/combat/PassiveSkillRuntime.ts @@ -16,10 +16,12 @@ import { import type { EntityId } from 'db://assets/mx/module/ecs'; import { TableNames } from '../../game/ConfigTableData'; import { SkillAbilityBuilder } from '../config/baseAtk/SkillAbilityBuilder'; +import { isMonsterBasicHeroTarget } from '../config/BattleHeroTable'; import { applySkillTableKnockback } from './AtkHookEffects'; import { tryExecuteByThresholdSession, tryInstantKillSession } from './ExecuteRules'; import { applySkillAtkBuffBoardEffect } from './SkillAtkBuffBoardBridge'; import { SkillAtkBuffExecutor } from './SkillAtkBuffExecutor'; +import { getTowerRuinHost } from './TowerRuinHost'; /** 被动 result 10 覆盖普攻(由装载服务实现,避免本文件再引用 Loadout) */ export interface IPassiveAtkOverrideHost { @@ -452,6 +454,8 @@ interface SwordWavePending { uy: number; /** 伤害倍率 */ scale: number; + /** atk_buff1=12 时命中基础字损毁概率万分比;<0 不损毁 */ + ruinWan: number; /** 距放出(秒) */ wait: number; } @@ -474,6 +478,8 @@ interface SwordWaveFlight { speed: number; /** 伤害倍率 */ scale: number; + /** atk_buff1=12 时命中基础字损毁概率万分比;<0 不损毁 */ + ruinWan: number; /** 已命中实体(每目标一次) */ hitIds: EntityId[]; } @@ -716,6 +722,34 @@ export class PassiveSkillHost { return idx >= 0 ? this._binds[idx].list : []; } + /** + * 按 Skill.result 执行(怪物主动技等外部触发)。当前支持 result 11 剑气。 + * @param session 战斗会话 + * @param caster 施法者 + * @param target 主目标(朝向优先) + * @param skillId Skill.id + */ + applySkillResult(session: BattleSession, caster: EntityId, target: EntityId, skillId: number): void { + this._session = session; + if (!(skillId > 0) || !session.world.isAlive(caster)) return; + const row = gg.data.table.getTableData(TableNames.Skill, skillId); + if (!row) { + console.log(`[PassiveSkill] applySkillResult 缺表 skill=${skillId}`); + return; + } + const result = String(row.result ?? '').trim(); + if (!result) return; + const parts = result.split(','); + const kind = Math.floor(Number(parts[0]) || 0); + const p: number[] = []; + for (let i = 1; i < parts.length; i++) p.push(Number(parts[i])); + if (kind === PassiveResultKind.SwordWave) { + this._applySwordWave(session, caster, target, skillId, p); + return; + } + console.log(`[PassiveSkill] applySkillResult 未支持 kind=${kind} skill=${skillId}`); + } + /** * 推进冷却、攻速层数、毒跳、剑阵与火球。 * @param dt 逻辑秒 @@ -1409,20 +1443,38 @@ export class PassiveSkillHost { } /** - * 玉帝刀光动画:轩辕剑蓝、吴钩双剑红。 + * 玉帝刀光动画:轩辕剑蓝、吴钩双剑红;无名默认金。 * @param skillId 被动 Skill.id */ private _swordWaveAnim(skillId: number): string { const row = gg.data.table.getTableData(TableNames.Skill, skillId); const name = String(row?.name ?? '').trim(); + if (!name) return SWORD_WAVE_ANIM_BLUE; if (name === '吴钩双剑') return SWORD_WAVE_ANIM_RED; if (name === '轩辕剑') return SWORD_WAVE_ANIM_BLUE; console.log(`[PassiveSkill] result 11 未配置剑气色 skill=${skillId} name=${name}`); return SWORD_WAVE_ANIM_BLUE; } + /** + * Skill.atk_buff1 类型 12 的损毁概率万分比;非 12 返回 -1。 + * @param skillId Skill.id + */ + private _swordWaveRuinWan(skillId: number): number { + const row = gg.data.table.getTableData(TableNames.Skill, skillId); + const parts = String(row?.atk_buff1 ?? '').split(','); + if (Math.floor(Number(parts[0]) || 0) !== 12) return -1; + const wan = Number(parts[1]); + if (!Number.isFinite(wan) || wan < 0) { + console.log(`[PassiveSkill] result 11 损毁概率无效 skill=${skillId}`, row?.atk_buff1); + return -1; + } + return wan; + } + /** * 直线剑气:按间隔连发 count 道,优先各朝不同敌人;敌数不足则循环复用。 + * 系数可为 0(仅表现/损毁);atk_buff1=12 时命中基础字击倒。 * @param session 战斗会话 * @param caster 施法者 * @param target 触发主目标(优先作为第一道朝向) @@ -1433,10 +1485,11 @@ export class PassiveSkillHost { const count = Math.floor(p[0]); const interval = p[1]; const coeffWan = p[2]; - if (!(count > 0) || !(interval >= 0) || !(coeffWan > 0)) { + if (!(count > 0) || !(interval >= 0) || !Number.isFinite(coeffWan) || coeffWan < 0) { console.log(`[PassiveSkill] result 11 参数无效 skill=${skillId}`, p.join(',')); return; } + // 系数 0:仅表现/航迹击倒(如金 Boss 11101),合法,不刷日志 const origin = this._logicPos(session, caster); if (!origin) { console.log(`[PassiveSkill] result 11 无起点 skill=${skillId}`); @@ -1444,25 +1497,33 @@ export class PassiveSkillHost { } const picks: EntityId[] = []; this._collectHostileIds(session, caster, picks); - if (picks.length === 0) { + if (picks.length === 0 && !(target > 0)) { console.log(`[PassiveSkill] result 11 无敌可朝向 skill=${skillId}`); return; } // 触发主目标优先,其余打乱;敌数不足时按序循环复用 const ordered: EntityId[] = []; if (target > 0) { + let inPicks = false; for (let i = 0; i < picks.length; i++) { if (picks[i] === target) { ordered.push(target); picks[i] = picks[picks.length - 1]; picks.length -= 1; + inPicks = true; break; } } + if (!inPicks && session.world.isAlive(target)) ordered.push(target); } this._shuffleIds(session, picks); for (let i = 0; i < picks.length; i++) ordered.push(picks[i]); + if (ordered.length === 0) { + console.log(`[PassiveSkill] result 11 无敌可朝向 skill=${skillId}`); + return; + } const scale = coeffWan / 10000; + const ruinWan = this._swordWaveRuinWan(skillId); let spawned = 0; for (let i = 0; i < count; i++) { let ux = 0; @@ -1492,6 +1553,7 @@ export class PassiveSkillHost { ux, uy, scale, + ruinWan, wait: spawned * interval, }); spawned += 1; @@ -1580,6 +1642,7 @@ export class PassiveSkillHost { uy: p.uy, speed: SWORD_WAVE_SPEED, scale: p.scale, + ruinWan: p.ruinWan, hitIds: [], }; this._waves.push(flight); @@ -1657,7 +1720,10 @@ export class PassiveSkillHost { const ly = rx * w.uy - ry * w.ux; if (Math.abs(lx) > SWORD_WAVE_HALF_LEN || Math.abs(ly) > SWORD_WAVE_HALF_WID) continue; w.hitIds.push(id); - this._dealResultDamage(session, w.caster, id, w.skillId, w.scale, SWORD_WAVE_DMG); + if (w.scale > 0) { + this._dealResultDamage(session, w.caster, id, w.skillId, w.scale, SWORD_WAVE_DMG); + } + this._ruinSwordWaveHit(session, w, id); } session.events.flush(); } finally { @@ -1665,6 +1731,25 @@ export class PassiveSkillHost { } } + /** + * 剑气命中:atk_buff1=12 时击倒基础字。 + * @param session 战斗会话 + * @param w 在飞剑气 + * @param id 命中实体 + */ + private _ruinSwordWaveHit(session: BattleSession, w: SwordWaveFlight, id: EntityId): void { + if (!(w.ruinWan >= 0)) return; + if (!isMonsterBasicHeroTarget(session.world, id)) return; + if (getTowerRuinHost().isEntityRuined(id)) return; + if (w.ruinWan < 10000 && session.rng.next() * 10000 >= w.ruinWan) return; + const team = session.world.getComponent(w.caster, TeamComp); + applySkillAtkBuffBoardEffect(session, { + kind: 12, + ownerPlayerId: team?.ownerId ?? team?.team ?? 0, + target: id, + }); + } + /** * 该剑气是否已命中过该实体。 * @param w 在飞剑气 diff --git a/assets/script/battle/config/BattleHeroTable.ts b/assets/script/battle/config/BattleHeroTable.ts index 881baf8..2cc67a2 100644 --- a/assets/script/battle/config/BattleHeroTable.ts +++ b/assets/script/battle/config/BattleHeroTable.ts @@ -129,13 +129,27 @@ function resolveTowerHero(world: World, entityId: EntityId): ITableHero | null { } /** - * 怪物技能 target_stage=1 的默认敌人:敌方阵营、非组合武将(herotype≠2)、非多格组合字。 + * 塔合成/显示等级(优先 UnitMetaComp.level,否则 Hero.level)。 + * @param world 战斗世界 + * @param entityId 实体 + */ +export function resolveTowerComposeLevel(world: World, entityId: EntityId): number { + const fromMeta = Math.floor(Number(world.getComponent(entityId, UnitMetaComp)?.level) || 0); + if (fromMeta > 0) return fromMeta; + const hero = resolveTowerHero(world, entityId); + return Math.max(0, Math.floor(Number(hero?.level) || 0)); +} + +/** + * 怪物技能 target_stage=1 的默认敌人:敌方阵营五行单字(herotype 1–5 金木水火土),排除多格组合。 + * 新表 herotype 表示五行,不再用旧口径「herotype≠2=非组合神将」。 * @param world 战斗世界 * @param entityId 候选目标 */ export function isMonsterDefaultEnemyTarget(world: World, entityId: EntityId): boolean { + if (isPeachTreeTower(world, entityId)) return false; const hero = resolveTowerHero(world, entityId); - if (hero == null || Number(hero.herotype) === 2) return false; + if (!isElementHero(hero)) return false; const cells = world.getComponent(entityId, GridOccupancyComp)?.cells; return !(cells && cells.length > 1); } @@ -150,15 +164,12 @@ export function isPeachTreeTower(world: World, entityId: EntityId): boolean { } /** - * atk_buff1 类型 12:只锁基础字(herotype=1),不含组合字。 - * 损毁态由调用方用 TowerRuinHost 判定,本表不反向依赖战斗宿主。 + * atk_buff1 类型 12:锁五行单字(非多格)。损毁态由调用方判定,本表不依赖 TowerRuinHost。 * @param world 战斗世界 * @param entityId 候选目标 */ export function isMonsterBasicHeroTarget(world: World, entityId: EntityId): boolean { - if (!isMonsterDefaultEnemyTarget(world, entityId)) return false; - const hero = resolveTowerHero(world, entityId); - return hero != null && isBasicHero(hero); + return isMonsterDefaultEnemyTarget(world, entityId); } /** diff --git a/assets/script/battle/config/TableBattleConfigProvider.ts b/assets/script/battle/config/TableBattleConfigProvider.ts index d6f7435..f3522d2 100644 --- a/assets/script/battle/config/TableBattleConfigProvider.ts +++ b/assets/script/battle/config/TableBattleConfigProvider.ts @@ -223,6 +223,10 @@ export class TableBattleConfigProvider implements IConfigProvider { else if (row.type === 3) tags.push(EntityTag.Elite); const prefabName = ChapterLevelBuilder.resolveMonsterPrefabFromRow(row); + const atkRange = Math.max(0, Math.floor(Number(row.atkdistance) || 0)); + if (!(atkRange > 0)) { + console.log('[TableBattleConfigProvider] Monster.atkdistance 无效', m, row.atkdistance); + } const unit: UnitDef = { id: unitDefId, name: prefabName ?? `monster_${m}`, @@ -232,7 +236,7 @@ export class TableBattleConfigProvider implements IConfigProvider { combat: { attack: (row as ITableMonster & { attack?: number }).attack ?? 1, attackSpeed: (row as ITableMonster & { attack_speed?: number }).attack_speed ?? 1, - range: 1, + range: atkRange, critRate: 0, critDamage: 1, }, diff --git a/assets/script/battle/config/baseAtk/SkillAbilityBuilder.ts b/assets/script/battle/config/baseAtk/SkillAbilityBuilder.ts index d82e86f..96b095a 100644 --- a/assets/script/battle/config/baseAtk/SkillAbilityBuilder.ts +++ b/assets/script/battle/config/baseAtk/SkillAbilityBuilder.ts @@ -139,6 +139,14 @@ export class SkillAbilityBuilder { targeting.params = { ...(targeting.params ?? {}), monsterEnemyHero: atkHead !== 9 }; if (buffHead === 12) targeting.params.monsterBasicHero = true; if (buffHead === 6) targeting.params.monsterRandomPick = true; + // 火 Boss 41101:写死不锁当前场上等级最高的数字 + if (row.id === 41101) targeting.params.monsterSkipMaxLevel = true; + // Skill.range<=0 的单体锁敌:全图索敌(金/火 Boss 等),避免仅靠 Monster.atkdistance 近战圈永远放不出 + if (this._rangePx(row) <= 0 && (atkHead === 0 || atkHead === 1)) { + targeting.selector = 'AllEnemies'; + delete targeting.rangeRef; + if (targeting.params) delete targeting.params.range; + } } if (kind === 'monster' && (row.target_stage || 1) === 2) { targeting.params = { ...(targeting.params ?? {}), monsterSameSide: true }; @@ -423,7 +431,7 @@ export class SkillAbilityBuilder { } /** - * 怪物 atk_buff1/2(类型 6/9/12、target_stage=2 由 Host 处理全部目标,不走此阶段)。 + * 怪物 atk_buff1/2(类型 1+atk_type3 圈晕、6/9/12、target_stage=2 由 Host 处理,不走此阶段)。 * @param row Skill 行 */ private static _monsterBuffStage(row: ITableSkill): EffectSpec[] | null { @@ -432,6 +440,8 @@ export class SkillAbilityBuilder { if (!buff1 && !buff2) return null; const head = this._splitNums(buff1)[0] ?? 0; if (head === 6 || head === 9 || head === 12) return null; + // 圈晕:动画结束按当前位置重扫,不锁开战目标 + if (head === 1 && this._atkHead(row) === 3) return null; if ((row.target_stage || 1) === 2) return null; return [{ type: 'ApplySkillAtkBuff', params: { buff1, buff2 } }]; } diff --git a/assets/script/battle/skill/SkillController.ts b/assets/script/battle/skill/SkillController.ts index 3834cb0..2c2daaf 100644 --- a/assets/script/battle/skill/SkillController.ts +++ b/assets/script/battle/skill/SkillController.ts @@ -34,6 +34,8 @@ const { ccclass } = _decorator; const PRESENTATION_SKILL_BIND_MAX_RETRIES = 60; const CONFUSION_BUFF_ID = 'debuff_8'; +/** 眩晕(土 Boss 51101 等) */ +const STUN_BUFF_ID = 'debuff_4'; /** 单个玩家的技能表现控制器(监听战斗事件 → 驱动 HeroActor Skill) */ @ccclass('SkillController') @@ -203,19 +205,25 @@ export class SkillController extends Component { case BattleEventType.BuffApplied: if (event.buffId === CONFUSION_BUFF_ID) { this._setTowerConfusionFx(event.target, true); + } else if (event.buffId === STUN_BUFF_ID) { + this._setTowerStunFx(event.target, true); } break; case BattleEventType.BuffRemoved: if (event.buffId === CONFUSION_BUFF_ID) { this._setTowerConfusionFx(event.target, false); + } else if (event.buffId === STUN_BUFF_ID) { + this._setTowerStunFx(event.target, false); } break; case BattleEventType.EntityKilled: case BattleEventType.UnitDespawned: if (event.type === BattleEventType.EntityKilled) { this._setTowerConfusionFx(event.victim, false); + this._setTowerStunFx(event.victim, false); } else { this._setTowerConfusionFx(event.entity, false); + this._setTowerStunFx(event.entity, false); } break; } @@ -229,6 +237,14 @@ export class SkillController extends Component { this._textGridAt(gridIndex)?.setConfusionFx(on); } + /** 眩晕:塔格挂「怪_晕眩」,禁攻由 AbilitySystem debuff_4 判定 */ + private _setTowerStunFx(entityId: EntityId, on: boolean): void { + if (!this._ownsEntity(entityId)) return; + const gridIndex = this._resolveGridIndex(entityId); + if (gridIndex == null) return; + this._textGridAt(gridIndex)?.setStunFx(on); + } + private _playPresentationCue(event: Extract): void { const session = this._match?.session; if (!session) return; diff --git a/assets/script/battle/view/CocosBattleCombatPresenter.ts b/assets/script/battle/view/CocosBattleCombatPresenter.ts index a1cd2f4..7d9b231 100644 --- a/assets/script/battle/view/CocosBattleCombatPresenter.ts +++ b/assets/script/battle/view/CocosBattleCombatPresenter.ts @@ -1,7 +1,7 @@ import { Node, sp, UITransform, Vec3, view } from 'cc'; import type { BattleEvent, EntityId } from 'db://assets/mx/module/battle'; import { BATTLE_EVENT_ALL, BattleEventType } from 'db://assets/mx/module/battle'; -import { AttributeComp, HealthComp } from 'db://assets/mx/module/battle/ecs/Components'; +import { AttributeComp, HealthComp, TransformComp } from 'db://assets/mx/module/battle/ecs/Components'; import { ensureSpineSkeletonDataRuntime } from 'db://assets/mx/module/lang/spineRuntimePatch'; import type { ProjectBattleHandle } from '../runtime/BattleRuntime'; import type { CocosBattleMonsterPresenter } from './CocosBattleMonsterPresenter'; @@ -32,6 +32,7 @@ import { type SwordWaveCue, type LightningChainCue, } from '../combat/PassiveSkillRuntime'; +import { logicalWorldToCocos } from '../bridge/CocosBattleMovementBridge'; import { BattleMapSpec } from '../map/BattleMapSpec'; import { traceCombatDamage } from 'db://assets/mx/tools/CombatDamageTrace'; @@ -724,17 +725,11 @@ export class CocosBattleCombatPresenter { } /** - * 起点:优先塔格心,否则逻辑坐标。 + * 起点:实体世界坐标 → 塔格心 → 逻辑坐标换算。 * @param cue 逻辑层 cue */ private _lineDragonOrigin(cue: LineDragonCue): Vec3 | null { - const grid = SkillPresentationEnv.getCasterGridCenter(cue.caster); - if (grid) return grid; - if (Number.isFinite(cue.originX) && Number.isFinite(cue.originY)) { - this._dragonFrom.set(cue.originX, cue.originY, 0); - return this._dragonFrom; - } - return null; + return this._passiveProjectileOrigin(cue.caster, cue.originX, cue.originY); } /** @@ -863,17 +858,39 @@ export class CocosBattleCombatPresenter { } /** - * 剑气起点:优先塔格心,否则逻辑坐标。 + * 剑气起点:实体世界坐标 → 塔格心 → 逻辑坐标换算。 * @param cue 逻辑层 cue */ private _swordWaveOrigin(cue: SwordWaveCue): Vec3 | null { - const grid = SkillPresentationEnv.getCasterGridCenter(cue.caster); + return this._passiveProjectileOrigin(cue.caster, cue.originX, cue.originY); + } + + /** + * 被动弹道起点(怪/塔)。禁止把逻辑坐标当 Cocos 世界坐标用。 + * @param caster 施法者 + * @param logicX 逻辑 X + * @param logicY 逻辑 Y + */ + private _passiveProjectileOrigin(caster: EntityId, logicX: number, logicY: number): Vec3 | null { + if (this._entityWorld(caster, this._dragonFrom)) return this._dragonFrom; + const grid = SkillPresentationEnv.getCasterGridCenter(caster); if (grid) return grid; - if (Number.isFinite(cue.originX) && Number.isFinite(cue.originY)) { - this._dragonFrom.set(cue.originX, cue.originY, 0); - return this._dragonFrom; + if (!(Number.isFinite(logicX) && Number.isFinite(logicY))) return null; + const session = this._battle?.session; + const map = SkillPresentationEnv.getMap(); + if (!session || !map) { + console.log('[CombatPresenter] 被动弹道无法换算逻辑坐标', caster); + return null; } - return null; + const tf = session.world.getComponent(caster, TransformComp); + if (!tf) { + console.log('[CombatPresenter] 被动弹道施法者无 Transform', caster); + return null; + } + const cocos = logicalWorldToCocos(session, map, tf.row, tf.col, logicX, logicY); + if (!cocos) return null; + this._dragonFrom.set(cocos.x, cocos.y, cocos.z); + return this._dragonFrom; } /** diff --git a/assets/script/battle/view/CocosBattleMonsterPresenter.ts b/assets/script/battle/view/CocosBattleMonsterPresenter.ts index ec0a7af..f9bf4d9 100644 --- a/assets/script/battle/view/CocosBattleMonsterPresenter.ts +++ b/assets/script/battle/view/CocosBattleMonsterPresenter.ts @@ -116,6 +116,12 @@ const MONSTER_SHADOW_SCALE: { [prefabName: string]: number } = { Boss水: 1, Boss火: 1, Boss土: 1, + /** 表 icon 写 BOSS金 时与 Boss金 同缩放 */ + BOSS金: 1, + BOSS木: 1, + BOSS水: 1, + BOSS火: 1, + BOSS土: 1, 兵: 0.5, 将: 0.5, 帅: 0.55, @@ -1636,32 +1642,19 @@ export class CocosBattleMonsterPresenter { * @param entityId 实体 * @param spawnPort 出怪口 */ + /** + * 本项目怪物不设皮肤,仅挂行军 Spine 缓存。 + * @param monster 怪视图 + * @param _monsterData 配表(保留签名) + * @param _entityId 实体 + * @param _spawnPort 出怪口 + */ private _applyCampSkinToMonster( monster: IBattleMonsterView, - monsterData: ITableMonster, - entityId?: EntityId, - spawnPort?: number, + _monsterData: ITableMonster, + _entityId?: EntityId, + _spawnPort?: number, ): void { - // 五行新怪 / 新 Boss:仅 default 皮,不 setSkin、不区分阵营 - if (MonsterDataManager.skipsBattleSpineSkin(monsterData)) { - monster.applyMarchSpineCache?.(); - return; - } - const anim = monster.anim; - if (!anim?.isValid) return; - const override = this._resolveSpineSkinOverride(entityId); - if (override) { - this._setSkeletonCampSkin(anim, override, monsterData.id, spawnPort, this._isEnemyCampMonster(entityId, spawnPort)); - monster.applyMarchSpineCache?.(); - return; - } - const enemyCamp = this._isEnemyCampMonster(entityId, spawnPort); - const preferred = MonsterDataManager.resolveBattleSpineSkin(monsterData, enemyCamp); - if (!preferred) { - console.error('[CocosBattleMonsterPresenter] 阵营皮肤名为空', monsterData.id, 'port=', spawnPort); - return; - } - this._setSkeletonCampSkin(anim, preferred, monsterData.id, spawnPort, enemyCamp); monster.applyMarchSpineCache?.(); } @@ -1753,26 +1746,14 @@ export class CocosBattleMonsterPresenter { ); } - /** @deprecated 保留给挂皮流程;正式套皮见 _applyCampSkinToMonster */ + /** @deprecated 本项目怪物不设皮肤 */ private _applyMonsterSpineSkin( - anim: sp.Skeleton, - monsterData: ITableMonster, - entityId?: EntityId, - spawnPort?: number, + _anim: sp.Skeleton, + _monsterData: ITableMonster, + _entityId?: EntityId, + _spawnPort?: number, ): void { - if (MonsterDataManager.skipsBattleSpineSkin(monsterData)) return; - const override = this._resolveSpineSkinOverride(entityId); - if (override) { - this._setSkeletonCampSkin(anim, override, monsterData.id, spawnPort, this._isEnemyCampMonster(entityId, spawnPort)); - return; - } - const enemyCamp = this._isEnemyCampMonster(entityId, spawnPort); - const preferred = MonsterDataManager.resolveBattleSpineSkin(monsterData, enemyCamp); - if (!preferred) { - console.error('[CocosBattleMonsterPresenter] 阵营皮肤名为空', monsterData.id, 'port=', spawnPort); - return; - } - this._setSkeletonCampSkin(anim, preferred, monsterData.id, spawnPort, enemyCamp); + /* no-op */ } /** 配表 icon → 预制体文件名 */ diff --git a/assets/script/battle/view/TextGrid.ts b/assets/script/battle/view/TextGrid.ts index 587bc16..d43cce8 100644 --- a/assets/script/battle/view/TextGrid.ts +++ b/assets/script/battle/view/TextGrid.ts @@ -181,6 +181,8 @@ export class TextGrid extends Component { private _sleepFx: Node | null = null; /** 混乱(debuff_8)特效 */ private _confusionFx: Node | null = null; + /** 眩晕(debuff_4)特效 */ + private _stunFx: Node | null = null; /** attachBattleBuffFx 挂载的节点(施法幻魂镜等) */ private readonly _attachedBattleBuffFx: Node[] = []; /** @@ -454,6 +456,65 @@ export class TextGrid extends Component { gg.res.putNode(node); } + /** + * 土 Boss 等:格子上挂「怪_晕眩」。 + * @param visible 是否显示 + */ + setStunFx(visible: boolean): void { + if (!visible) { + this.clearStunFx(); + return; + } + if (!this.node?.isValid) return; + if (this._stunFx?.isValid) { + this._stunFx.active = true; + this._stunFx.setSiblingIndex(this.node.children.length - 1); + this._ensureDragSurface(); + return; + } + let node: Node | null = null; + try { + node = gg.res.getNode('prefab/', '怪_晕眩', GBundle.BattleGameMonster); + } catch (err) { + console.log('[TextGrid] 怪_晕眩 getNode 失败', String(err)); + return; + } + if (!node?.isValid) { + console.log('[TextGrid] 怪_晕眩预制体未加载'); + return; + } + node.parent = this.node; + node.setPosition(0, BattleMapSpec.CELL_SIZE * 0.25, 0); + node.active = true; + node.layer = this.node.layer; + node.setSiblingIndex(this.node.children.length - 1); + this._stunFx = node; + const sk = node.getComponent(sp.Skeleton) ?? node.getComponentInChildren(sp.Skeleton); + if (!sk?.isValid) { + console.log('[TextGrid] 怪_晕眩缺少 Skeleton'); + this.clearStunFx(); + return; + } + sk.setAnimationCacheMode(sp.SpineAnimationCacheMode.REALTIME); + if (!BattleSpinePlayback.play(sk, 'animation', { loop: true })) { + console.log('[TextGrid] 怪_晕眩无动画 animation'); + this.clearStunFx(); + return; + } + this._ensureDragSurface(); + } + + /** 回收眩晕特效 */ + clearStunFx(): void { + const node = this._stunFx; + this._stunFx = null; + if (!node?.isValid) return; + const sk = node.getComponent(sp.Skeleton) ?? node.getComponentInChildren(sp.Skeleton); + BattleSpinePlayback.clearPendingComplete(sk); + BattleSpinePlayback.resetTracks(sk); + gg.res.putNode(node); + } + /** 多格组合:特效锚点偏到占格几何中心(水平偏 X,竖直偏 Y) */ private _resolveComposeSpineOffset(): { x: number; y: number } { const composition = this._getComposition(); @@ -861,6 +922,7 @@ export class TextGrid extends Component { this.clearPositiveBuffFx(); this.clearSleepFx(); this.clearConfusionFx(); + this.clearStunFx(); this.clearAttachedBattleBuffFx(); this.resetNodeTransform(); this.resetSkinPresentation(); @@ -918,6 +980,7 @@ export class TextGrid extends Component { this.clearPositiveBuffFx(); this.clearSleepFx(); this.clearConfusionFx(); + this.clearStunFx(); this.clearAttachedBattleBuffFx(); this._releaseCanUpEff(); this._releaseTrails(); diff --git a/assets/script/manager/MonsterDataManager.ts b/assets/script/manager/MonsterDataManager.ts index e00a38b..5120bb0 100644 --- a/assets/script/manager/MonsterDataManager.ts +++ b/assets/script/manager/MonsterDataManager.ts @@ -35,13 +35,18 @@ const ELEMENT_MARCH_BY_ICON: Readonly> = { 小怪土: '土小怪', }; -/** 新 Boss 预制体 icon(仅 default 皮,行进=走路) */ +/** 新 Boss 预制体 icon(仅 default 皮,行进=走路;兼容表写 BOSS金 / 资源 Boss金) */ const NEW_BOSS_ICONS: Readonly> = { Boss金: true, Boss木: true, Boss水: true, Boss火: true, Boss土: true, + BOSS金: true, + BOSS木: true, + BOSS水: true, + BOSS火: true, + BOSS土: true, }; /** 怪物表数据管理 */ @@ -61,11 +66,14 @@ export class MonsterDataManager { } /** - * 是否五行新小怪(不设皮肤、不区分阵营;行进/死亡用系小怪轨)。 + * 是否五行新小怪/召唤(icon 为小怪金…土;不设皮肤;行进/死亡用系小怪轨)。 + * 含 type=3 召唤(如木 Boss 招出的 10004),勿按旧多皮走「走路/小怪木2」。 * @param row Monster 行 */ static isElementSmallMonster(row: ITableMonster | null | undefined): boolean { - if (!row || row.type !== MonsterType.SmallMonster) return false; + if (!row) return false; + const t = Math.floor(Number(row.type)); + if (t !== MonsterType.SmallMonster && t !== MonsterType.SummonMonster) return false; const icon = String(row.icon ?? '').trim(); return !!ELEMENT_MARCH_BY_ICON[icon]; } @@ -81,11 +89,11 @@ export class MonsterDataManager { } /** - * 新怪表现:不 setSkin。 - * @param row Monster 行 + * 本项目全部怪物仅 default 皮,永不 setSkin、不区分阵营。 + * @param _row Monster 行(保留签名供调用方) */ - static skipsBattleSpineSkin(row: ITableMonster | null | undefined): boolean { - return this.isElementSmallMonster(row) || this.isNewElementBoss(row); + static skipsBattleSpineSkin(_row: ITableMonster | null | undefined): boolean { + return true; } /** @@ -125,32 +133,12 @@ export class MonsterDataManager { } /** - * @deprecated 新五行怪不设皮肤;仅旧多皮资源(猴/哮天犬等)仍用。 - * - type1 小怪 / type3 召唤:己方口=`icon`,敌方口=`icon`+`2` - * - type2 Boss:己方口=`皮肤字1_1`,敌方口=`皮肤字2_1` - * @param row Monster 行 - * @param enemyCamp 是否敌方出怪口放出的怪 + * @deprecated 本项目怪物不设皮肤,恒返回空。 + * @param _row Monster 行 + * @param _enemyCamp 是否敌方出怪口 */ - public static resolveBattleSpineSkin(row: ITableMonster, enemyCamp: boolean): string { - if (this.skipsBattleSpineSkin(row)) { - return ''; - } - const icon = String(row.icon ?? '').trim(); - if (row.type === MonsterType.SmallMonster || row.type === MonsterType.SummonMonster) { - if (!icon) { - console.error('[MonsterDataManager] 怪物 icon 为空,无法定皮肤', row.id, row.type); - return ''; - } - return enemyCamp ? `${icon}2` : icon; - } - if (row.type === MonsterType.Boss) { - return enemyCamp ? '皮肤字2_1' : '皮肤字1_1'; - } - if (!icon) { - console.error('[MonsterDataManager] 怪物 icon 为空', row.id, row.type); - return ''; - } - return icon; + public static resolveBattleSpineSkin(_row: ITableMonster, _enemyCamp: boolean): string { + return ''; } /** diff --git a/assets/script/ui/BattleBoss/UIBattleBoss.ts b/assets/script/ui/BattleBoss/UIBattleBoss.ts index bd2d87d..f2a43e9 100644 --- a/assets/script/ui/BattleBoss/UIBattleBoss.ts +++ b/assets/script/ui/BattleBoss/UIBattleBoss.ts @@ -173,14 +173,7 @@ export class UIBattleBoss extends Auto_BattleBoss { if (!anim) { console.error('[UIBattleBoss] 怪物 Spine 缺失', prefabName, row.id); } else { - if (!MonsterDataManager.skipsBattleSpineSkin(row)) { - const skinName = MonsterDataManager.resolveBattleSpineSkin(row, true); - if (!skinName) { - console.error('[UIBattleBoss] Boss 皮肤名为空', row.id); - } else { - anim.setSkin(skinName); - } - } + // 本项目怪物仅 default 皮,不 setSkin const march = MonsterDataManager.resolveMarchAnim(row) || '走路'; if (!BattleSpinePlayback.play(anim, march, { loop: true })) { console.error('[UIBattleBoss] Boss 行进动画起播失败', row.id, march); diff --git a/assets/script/ui/BattleGame/BattleGameExtraBossSkill.ts b/assets/script/ui/BattleGame/BattleGameExtraBossSkill.ts index f3bb580..1b14955 100644 --- a/assets/script/ui/BattleGame/BattleGameExtraBossSkill.ts +++ b/assets/script/ui/BattleGame/BattleGameExtraBossSkill.ts @@ -55,12 +55,11 @@ export class BattleGameExtraBossSkill { const config = gg.battle.config as TableBattleConfigProvider; const unitDefId = config.ensureSpawnMonsterDef(monsterId, route.spawnPort, difficultyRow); - const skin = MonsterDataManager.resolveBattleSpineSkin(row, route.enemyCamp); + // 本项目怪物不设皮肤,不传 spineSkinOverride const entity = battle.session.spawner.spawnExtraEnemy( unitDefId, route.spawnPort, route.targetPlayerId, - skin || undefined, ); if (entity == null) return false; diff --git a/docs/方案-出怪逻辑实现.md b/docs/方案-出怪逻辑实现.md index 0955ddc..2a7e986 100644 --- a/docs/方案-出怪逻辑实现.md +++ b/docs/方案-出怪逻辑实现.md @@ -49,18 +49,18 @@ waveIndex|monsterId,... - **Monster1**:行 = `waveIndex`;列 = `Rank.diffcult` / Level 难度组。 - **血量计算时机**:**出怪当下**套公式 `floor(Monster.hp × hp_multiple × port_hp × Monster1[波次] × 战力修正 × weapon_para)`,不以开局缓存为准。波次怪、营召唤、`atk_type=8`(含哮天犬)、万神幡 Boss 同一套;Monster1 行取**当前波** `waveIndex`。 -#### 表现(五行新怪:不设皮肤、不区分阵营) +#### 表现(全部怪物:不设皮肤、不区分阵营) -Spine 资源仅 `default` 皮;双口同一种表现。 +Spine 资源仅 `default` 皮;双口同一种表现,**禁止 setSkin / 阵营皮后缀**。 | type | 预制体 | 皮肤 | 行进 | 死亡 | |------|--------|------|------|------| -| 1 五行小怪 | `prefab/monster/{icon}`(如 `小怪金`) | **不 setSkin** | `{系}小怪`(如 `金小怪`) | `{系}小怪2`(如 `金小怪2`) | -| 2 新 Boss | `prefab/monster/{icon}`(如 `Boss金`) | **不 setSkin** | `走路` | 按 Boss 资源轨(缺则 error) | +| 1 五行小怪 / 3 同 icon 召唤 | `prefab/monster/{icon}`(如 `小怪金`) | **不 setSkin** | `{系}小怪`(如 `金小怪`) | `{系}小怪2`(如 `金小怪2`) | +| 2 Boss | `prefab/monster/{icon}`(如 `BOSS金`) | **不 setSkin** | `走路`(或资源轨) | 按 Boss 资源轨(缺则 error) | `Monster.icon` → 行进 / 死亡:`小怪金`→`金小怪`/`金小怪2`,木/水/火/土同理。映射失败或动画不存在 → `console.error`,禁止静默兜底。 -实现:`MonsterDataManager.resolveElementMarchAnim` / `resolveElementDeathAnim` + `Monster.playWalk` / 死亡播控;Presenter 对新怪跳过换皮。 +实现:`MonsterDataManager.skipsBattleSpineSkin` 恒 true;`resolveMarchAnim` / `resolveDeathAnim` + `Monster.playWalk`;Presenter / Boss 预览 / 额外 Boss 召唤均不换皮。 #### 营字强化出怪(Hero.id=5) diff --git a/docs/方案-怪物技能.md b/docs/方案-怪物技能.md index 64bbd8b..3db2db7 100644 --- a/docs/方案-怪物技能.md +++ b/docs/方案-怪物技能.md @@ -1,13 +1,13 @@ # 方案:怪物技能 -> 状态:框架已落地;奔波儿灞 30105、牛魔王 30106、六耳 30107、蟹将 30205、龟丞相 30206、敖广 30207、黑无常 30305、白无常 30306、谛听 30307、千里眼 30405、顺风耳 30406、太白 30407 已落地 +> 状态:框架已落地;金 Boss 11101、水 Boss 31101、火 Boss 41101、土 Boss 51101、奔波儿灞 30105、牛魔王 30106、六耳 30107、蟹将 30205、龟丞相 30206、敖广 30207、黑无常 30305、白无常 30306、谛听 30307、千里眼 30405、顺风耳 30406、太白 30407 已落地 > 日期:2026-09-04 > 关联:[方案-战斗技能逻辑层.md](./方案-战斗技能逻辑层.md)、[方案-出怪逻辑实现.md](./方案-出怪逻辑实现.md) ## 1. 目标(本切片) 1. `Monster.skill` 关联 `Skill.id`,出怪后按表进入 OnTick 施法。 -2. 怪物技能 `target_stage=1`(敌人)默认只打 **敌方阵营、Hero.herotype ≠ 2**(非组合英雄 / Weapon2.type≠2 组合字)。无特殊需求时不选 type2。 +2. 怪物技能 `target_stage=1`(敌人)默认只打 **敌方阵营五行单字**(`Hero.herotype` 1–5 金木水火土,且非多格组合)。新表 `herotype` 表示五行,不再用旧「≠2=非组合神将」。 3. 做到播 `Skill.animation`(多为「技能」)并 `finish` 解锁;伤害 / 召唤 / debuff / 冲锋等 **后续逐个核对**。 ## 2. 管线 @@ -39,7 +39,7 @@ UnitSpawned(波次 / 营召唤 / atk_type=8 召唤) | `target_stage` | team | 本切片 | |---|---|---| -| 1 敌人 | enemy | `params.monsterEnemyHero`:只保留 herotype≠2 且非多格组合的塔(`atk_type=9` 除外,要打组合弓将);`atk_buff1` 类型 12 再加 `monsterBasicHero`(仅 herotype=1、**跳过已损毁**);类型 6 再加 `monsterRandomPick`(全屏洗牌后再取 `atk_para` 个数);`atk_type=9` 再加 `weaponType`=将 id(Hero.weapon,4=弓,含组合武将) | +| 1 敌人 | enemy | `params.monsterEnemyHero`:只保留五行单字且非多格(`atk_type=9` 除外);`atk_buff1` 类型 12 再加 `monsterBasicHero`(同口径、**跳过已损毁**);类型 6 再加 `monsterRandomPick`(全屏洗牌后再取 `atk_para` 个数);`atk_type=9` 再加 `weaponType`=将 id(Hero.weapon,4=弓,含组合武将) | | 2 友军 | ally | `params.monsterSameSide`:同一 `ownerId` 且同为敌/己的怪物;`skill` 类型 5 再加 `monsterDevourMinion`(仅小怪/召唤物),`stageStrategy=1` 去重 | | 3 自身 | self | 无 `HasTargetInRange`,到点即放 | @@ -49,6 +49,10 @@ UnitSpawned(波次 / 营召唤 / atk_type=8 召唤) | Skill.id | 怪 | 表现 | |---|---|---| +| 11101 | 金系 Boss | `result=11,1,0,0` + `atk_buff1=12,10000`:播「攻击」后放直线剑气(玉帝刀光),伤害系数 0 仅表现;剑气穿透路径上命中的基础字按类型 12 击倒(禁攻倒下);`Skill.range=0` 单体全图索敌;动画结束不再立刻损毁主目标 | +| 31101 | 水系 Boss | `target_stage=2` 射程 208 `atk_type=3` `atk_buff1=3,10000,3000,3`:播「攻击」后给射程内**同侧友军怪(含自身)**移速 ×1.3 持续 3 秒(身上 `怪_加速`);走太白同源 Host 友军 buff 通路,`Skill.range` 须 >0 | +| 41101 | 火系 Boss | `atk_buff1=12,10000`:播「攻击」后损毁锁定的五行单字(同牛魔王类型12);`Skill.range=0` 全图索敌;**写死不锁当前场上等级最高的数字**(`monsterSkipMaxLevel`;场上仅最高级时不施法) | +| 51101 | 土系 Boss | `atk_type=3` 圈径跟 `Monster.atkdistance`(表 `range=0`)`atk_buff1=1,10000,3`:播「攻击」后按**当时位置**为圆心重扫周围五行单字眩晕 3 秒(`debuff_4` 禁攻 + 格上 `怪_晕眩`;不锁开战目标) | | 30105 | 奔波儿灞 | `atk_type=15` `atk_para=半径,伤害`:射程内锁主目标(**跳过蟠桃树**;`atk_buff1` 类型6 时在非桃候选中随机),再对主目标圆心半径内敌方非组合字结算(半径按配表 83px/格 + 占地扫描);`skill=9,秒` 与葫芦同时在**主目标格**渐显 `干扰迷雾`(**隐藏全屏紫雾槽**,节点缩放 `0.2×0.4`;持续后渐隐);`atk_buff1=6,…` 迷雾起播即圆内降攻速(**不挂降雨**,降雨仅敖广);播「技能」同时出池 `葫芦` 挂怪物(预制体默认偏移),播 `技能葫芦` 并朝向同一目标格,播完回 45° 后回池 | | 30106 | 牛魔王 | `atk_buff1=12,概率`:播「技能」后在锁定基础字格播 `损毁文字`,该字**失效禁攻**(可拖可换位,不激活),字形倒下转 -90°;**不选已损毁、不选组合字**;营损毁后 Host 停出怪,合成升级解除后恢复;**损毁跟字块走**(进背包仍倒下,交换不传染别的字;倒下只拧本格 HeroActor/字形,合成升级一把不得扶正另一把);**己方与敌方字均倒下**;ebg/营底框保持世界角与世界坐标;**合成升级**后回正 | | 30107 | 六耳 | `atk_type=8` `atk_para=10002,4,0.5,2`:播「技能」后在**自身周围**每隔 0.5 秒招 1 只猴(共 4 只),阵营与六耳一致 | @@ -63,7 +67,7 @@ UnitSpawned(波次 / 营召唤 / atk_type=8 召唤) | 30407 | 太白 | `atk_type=3` 射程 291 `atk_buff1=3,10000,2000,3` `atk_buff2=10,3000`:播「技能」后给射程内**同侧友军怪(含自身)**移速 ×1.2 持续 3 秒(身上 `怪_加速`),并回复 30% 最大生命(`怪物加血`) | | 40001 | 哮天犬 | `type=0` 仍按主动绑定;播「技能」后给射程 208 内同侧友军移速 ×1.2 持续 3 秒(`怪_加速`);己方皮 `哮天犬`、敌方皮 `哮天犬2` | -类型 9(封格 atk_buff)不走 `HasTargetInRange`(封格不锁英雄)。`skill` 类型 9 且**无** `atk_type` 时仍半屏迷雾、不锁英雄;`atk_type=15` + `skill` 9 则锁主目标并扩圆。`atk_type=9` 指定将走 `HasTargetInRange` + `weaponType`。类型 12 只锁 `herotype=1` 基础字。类型 6 对每个锁定目标播降雨并加攻速(不走 `ApplySkillAtkBuff`);攻速写 `aspd_add` 万分比,负值降攻速。类型 5 范围内没有可吞小兵则不施法。`target_stage=2` 的 30407 对每个同侧友军上 buff(含自身)。`target_stage=3` 的 30107 / 30305 不锁敌。`ApplySkillAtkBuff` 对 `ctx.targets` 全部目标生效(龟丞相 / 白无常 / 顺风耳)。 +类型 9(封格 atk_buff)不走 `HasTargetInRange`(封格不锁英雄)。`skill` 类型 9 且**无** `atk_type` 时仍半屏迷雾、不锁英雄;`atk_type=15` + `skill` 9 则锁主目标并扩圆。`atk_type=9` 指定将走 `HasTargetInRange` + `weaponType`。类型 12 锁五行单字(非多格、跳过已损毁/蟠桃树);41101 再跳过当前场上最高等级。类型 6 对每个锁定目标播降雨并加攻速(不走 `ApplySkillAtkBuff`);攻速写 `aspd_add` 万分比,负值降攻速。类型 5 范围内没有可吞小兵则不施法。`target_stage=2` 的 30407 对每个同侧友军上 buff(含自身)。`target_stage=3` 的 30107 / 30305 不锁敌。`ApplySkillAtkBuff` 对 `ctx.targets` 全部目标生效(龟丞相 / 白无常 / 顺风耳)。 ## 6. 本切片不做 @@ -79,7 +83,7 @@ UnitSpawned(波次 / 营召唤 / atk_type=8 召唤) | Ability 构建 | `SkillAbilityBuilder.buildMonster` | | Ability id | `Weapon1BattleIds.entityMonsterAbilityId` | | 默认敌人过滤 | `BattleHeroTable.isMonsterDefaultEnemyTarget` → `TargetResolver` | -| 类型12基础字 | `isMonsterBasicHeroTarget` → `TargetResolver`;`TowerRuinHost` 失效至合成升级 | +| 类型12基础字 | `isMonsterBasicHeroTarget` → `TargetResolver`;`TowerRuinHost` 失效至合成升级;火 Boss 41101 另加 `monsterSkipMaxLevel` 剔除场上最高等级 | | atk_type=9 指定将 | `params.weaponType` → `isHeroWeaponTarget`;`monsterEnemyHero` 关闭 | | 类型6随机字 | `params.monsterRandomPick` → `TargetResolver` 洗牌;Host `applyPair` 降攻速;无 skill 类型9 时再播 `降雨`(敖广);奔波儿灞出池 `葫芦` 挂怪物(预制体默认偏移)、与迷雾同瞄主目标格 | | 类型5吞噬 | `params.monsterDevourMinion` → `TargetResolver`;Host 吸入后 `lifecycle.despawn` + `applyMaxHpCoeff` | @@ -87,4 +91,5 @@ UnitSpawned(波次 / 营召唤 / atk_type=8 召唤) | 类型7降级 | `TowerLevelDownHost`:钳制最低 1 级,持续秒内禁合成、可移动,到期恢复;等级字变色 | | 类型12损毁 | `TowerRuinHost`:禁攻、可移动;不选已损毁 / type2 组合字;损毁写在字块上(进背包仍倒下,交换不传染;倒下只拧本格字形,升级一把不得扶正另一把);**己方与敌方 AI 地图字均倒下**;ebg/营底框保持世界角与世界坐标;合成升级解除。营出怪走 `YingCampSummonHost`,须跟 `castBlockPredicate`(损毁/魅惑)停出怪,解除后恢复 | | 类型2友军 buff | `params.monsterSameSide` → `TargetResolver`;Host 扫描射程内同侧怪 `applyPair` + `怪_加速` | -| 表现分发 | `SkillController` 跳过;Host 听 `PresentationCue` | +| 类型1圈晕 | Host 动画结束按当前位置 `targets.resolve` → `applyPair` → `debuff_4`;`AbilitySystem` 禁攻;塔格 `SkillController` → `TextGrid.setStunFx`(`怪_晕眩`) | +| 表现分发 | `SkillController` 跳过怪物 cue;Host 听 `PresentationCue`;塔 debuff 特效仍由 SkillController 接 | diff --git a/docs/方案-战斗技能逻辑层.md b/docs/方案-战斗技能逻辑层.md index 8b676b2..5c16fae 100644 --- a/docs/方案-战斗技能逻辑层.md +++ b/docs/方案-战斗技能逻辑层.md @@ -60,7 +60,7 @@ Hero(合成等级 level) **result 10(改普攻)**:触发后覆盖**该宿主**普攻 Skill 的 `atk_type` / `atk_para`,按实体重建 `ability_norm_{id}_e{entity}`。不改共享配表,避免同技能其他塔串台。例:`10,12,5,12,10000` → `atk_type=12`,`atk_para=5,12,10000`。 -**result 11(直线剑气)**:参数 `数量,间隔秒,伤害系数万分比`。按间隔连发;优先各朝不同敌人(触发主目标优先,其余随机),敌数不足则循环复用同一批目标仍发满数量。穿透且每敌只伤一次;飞出地图边界结束。表现用 `battleHero/prefab/skill/玉帝刀光`,飞出可视区后回池。飞行速度代码常量(表无速度段)。动画:轩辕剑 `剑气蓝`,吴钩双剑 `剑气红`。例:轩辕剑 `11,3,0.3,15000`。 +**result 11(直线剑气)**:参数 `数量,间隔秒,伤害系数万分比`。按间隔连发;优先各朝不同敌人(触发主目标优先,其余随机),敌数不足则循环复用同一批目标仍发满数量。穿透且每敌只伤一次;飞出地图边界结束。伤害系数可为 `0`(仅表现/航迹效果,不结算 HP)。若同行 `atk_buff1` 头为 `12`,命中基础字时按该 buff 概率击倒(损毁)。怪物主动技也可在施法结束经 `PassiveSkillHost.applySkillResult` 触发(金 Boss `11101`)。表现用 `battleHero/prefab/skill/玉帝刀光`,飞出可视区后回池。飞行速度代码常量(表无速度段)。动画:轩辕剑 / 无名默认 `剑气金`,吴钩双剑同资源。例:轩辕剑 `11,3,0.3,15000`;金 Boss `11,1,0,0`。 **result 12(闪电链)**:参数 `蔓延范围像素,蔓延数量,伤害系数万分比`。从触发目标起跳,向范围内**未打过**的最近敌蔓延(触发目标算第 1 跳,不重复)。每跳先出电弧/受击,间隔后再结算该落点伤害。表现用老项目 `电母-大招`(`普通雷电`)与 `电母-大招受击`(`普通受击`),传导间隔对齐资源时长/2。例:三尖两面刀 `12,200,5,120000`。 diff --git a/preview-template/index.ejs b/preview-template/index.ejs index 4bf1b72..3386845 100644 --- a/preview-template/index.ejs +++ b/preview-template/index.ejs @@ -50,6 +50,17 @@ + + @@ -207,6 +218,7 @@ '/workbench/js/agent-pick.js', '/workbench/js/agent-console.js', '/workbench/js/agent-assets.js', + '/workbench/js/agent-profiler.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 b2abd85..abb63f8 100644 --- a/preview-template/workbench/css/workbench.css +++ b/preview-template/workbench/css/workbench.css @@ -1139,6 +1139,11 @@ body { vertical-align: middle; } +.pw-assets-tag-dep { + background: rgba(212, 162, 76, 0.16); + color: var(--pw-warn); +} + .pw-assets-side { width: 168px; flex-shrink: 0; @@ -1174,6 +1179,160 @@ body { overflow: auto; } +/* —— 性能面板 —— */ +.pw-perf-body { + flex: 1; + min-height: 0; + overflow: auto; + padding: var(--sp-2); + display: flex; + flex-direction: column; + gap: var(--sp-2); +} + +.pw-perf-charts { + display: grid; + grid-template-columns: 1fr 1fr; + gap: var(--sp-2); +} + +.pw-perf-charts .pw-chart-card { + background: var(--pw-bg-1); + border: 1px solid var(--pw-border); + border-radius: 6px; + padding: var(--sp-1) var(--sp-2) var(--sp-2); + min-height: 120px; +} + +.pw-perf-charts h4 { + margin: 4px 0 6px; + font-size: var(--pw-fs-xs); + color: var(--pw-muted); + font-weight: 600; +} + +.pw-perf-charts canvas { + width: 100%; + height: 96px; + display: block; +} + +.pw-perf-row { + display: grid; + grid-template-columns: 1fr 1fr; + gap: var(--sp-2); +} + +.pw-perf-block { + background: var(--pw-bg-1); + border: 1px solid var(--pw-border); + border-radius: 6px; + min-height: 0; + display: flex; + flex-direction: column; +} + +.pw-perf-block h4 { + margin: 0; + padding: 8px 10px 4px; + font-size: var(--pw-fs-xs); + color: var(--pw-text-2); + font-weight: 600; +} + +.pw-perf-list { + overflow: auto; + max-height: 220px; + font-size: 11px; + padding: 0 6px 6px; +} + +.pw-perf-rowh { + display: grid; + grid-template-columns: 1fr 56px 56px; + gap: 6px; + padding: 4px 6px; + color: var(--pw-muted); + border-bottom: 1px solid var(--pw-border); + position: sticky; + top: 0; + background: var(--pw-bg-1); +} + +.pw-perf-item { + display: grid; + grid-template-columns: 1fr 56px 56px; + gap: 6px; + padding: 3px 6px; + border-bottom: 1px solid transparent; + align-items: start; +} + +.pw-perf-item:hover { background: rgba(255, 255, 255, 0.03); } + +.pw-perf-name { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + font-family: var(--pw-font-mono); +} + +.pw-perf-name i { + font-style: normal; + font-size: 10px; +} + +.pw-perf-num { + font-family: var(--pw-font-mono); + font-variant-numeric: tabular-nums; + text-align: right; + color: var(--pw-text-2); +} + +.pw-perf-stack { + grid-column: 2 / -1; + color: var(--pw-muted); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + font-family: var(--pw-font-mono); + font-size: 10px; +} + +.pw-slow-item { + grid-template-columns: 56px 1fr; +} + +.pw-bad { color: var(--pw-danger); } + +.pw-res-list .pw-perf-item { + grid-template-columns: 1fr 72px 64px auto; + align-items: center; +} + +.pw-res-list .pw-perf-rowh { + grid-template-columns: 1fr 72px 64px auto; +} + +.pw-res-ops { + display: flex; + gap: 2px; + flex-shrink: 0; +} + +.pw-btn-mini { + padding: 1px 6px; + font-size: 10px; + border-radius: 3px; +} + +.pw-perf-type { + color: var(--pw-muted); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + .pw-pick-overlay { position: fixed; pointer-events: none; @@ -1215,21 +1374,23 @@ body { .pw-gizmo-axis { position: absolute; pointer-events: auto; + z-index: 2; } +/* 轴原点 = 节点中心 */ .pw-gizmo-x { - right: -4px; + left: 50%; top: 50%; - width: 36px; - height: 18px; - margin-top: -9px; + width: 42px; + height: 16px; + margin-top: -8px; cursor: ew-resize; } .pw-gizmo-x i { position: absolute; left: 0; - right: 12px; + right: 10px; top: 50%; height: 3px; margin-top: -1.5px; @@ -1250,17 +1411,18 @@ body { .pw-gizmo-y { left: 50%; - top: -36px; - width: 18px; - height: 36px; - margin-left: -9px; + top: 50%; + width: 16px; + height: 42px; + margin-left: -8px; + margin-top: -42px; cursor: ns-resize; } .pw-gizmo-y i { position: absolute; top: 0; - bottom: 12px; + bottom: 10px; left: 50%; width: 3px; margin-left: -1.5px; @@ -2360,24 +2522,26 @@ html.pw-inspect #GameCanvas { .pw-shell-classic .pw-device-ear { display: block; } /* 刘海 / 灵动岛 / 打孔 */ +/* 挖孔 / 刘海 / 胶囊:相对「屏幕」而非中框 */ .pw-device-cutout { display: none; position: absolute; z-index: 6; pointer-events: none; background: #050505; - box-shadow: inset 0 0 0 1px #1a1a1a; } .pw-device-bezel.has-cutout .pw-device-cutout { display: block; } +/* 刘海:贴屏幕上沿居中 */ .pw-device-bezel.has-notch .pw-device-cutout { - top: 10px; + top: 0; left: 50%; transform: translateX(-50%); width: 36%; height: 22px; border-radius: 0 0 14px 14px; + background: #050505; } .pw-device-bezel.has-notch .pw-device-cutout::after { @@ -2390,17 +2554,18 @@ html.pw-inspect #GameCanvas { margin-top: -4px; border-radius: 50%; background: radial-gradient(circle at 35% 35%, #1e3a5f, #0a1520 70%); - box-shadow: 0 0 0 1px #222; } +/* 灵动岛 */ .pw-device-bezel.has-island .pw-device-cutout { - top: 12px; + top: 10px; left: 50%; transform: translateX(-50%); - width: 28%; - max-width: 120px; + width: 26%; + max-width: 118px; height: 24px; border-radius: 999px; + background: #050505; } .pw-device-bezel.has-island .pw-device-cutout::before { @@ -2415,14 +2580,293 @@ html.pw-inspect #GameCanvas { background: radial-gradient(circle at 30% 30%, #243b55, #0a1018 70%); } +/* 挖孔:贴屏幕上沿 */ .pw-device-bezel.has-punch .pw-device-cutout { - top: 10px; + top: 8px; left: 50%; transform: translateX(-50%); - width: 10px; - height: 10px; + width: 11px; + height: 11px; border-radius: 50%; background: radial-gradient(circle at 35% 35%, #1a3350, #05080c 75%); + box-shadow: 0 0 0 1px rgba(255, 255, 255, 0.06); +} + +.pw-device-bezel.has-punch-l .pw-device-cutout { + left: 10%; + transform: none; +} + +.pw-device-bezel.has-teardrop .pw-device-cutout { + top: 0; + left: 50%; + transform: translateX(-50%); + width: 14px; + height: 18px; + border-radius: 0 0 10px 10px; + background: radial-gradient(circle at 50% 20%, #1a3350, #05080c 70%); +} + +.pw-device-bezel.has-tablet-cam .pw-device-cutout { + display: block; + top: 50%; + left: 6px; + transform: translateY(-50%); + width: 8px; + height: 8px; + border-radius: 50%; + background: radial-gradient(circle at 35% 35%, #243b55, #0a1018 70%); +} + +/* 微信小程序胶囊:屏幕内右上,由 JS 按安全区调 top */ +.pw-device-capsule { + position: absolute; + z-index: 20; + top: 8px; + right: 10px; + display: none; + align-items: center; + gap: 0; + height: 32px; + padding: 0 12px; + border-radius: 999px; + pointer-events: none; + box-sizing: border-box; +} + +.pw-device-capsule.show { display: inline-flex; } + +/* —— 微信官方小程序菜单(浅色,对齐设计指南「小程序菜单」) —— */ +.pw-device-capsule.cap-wechat { + background: rgba(255, 255, 255, 0.96); + border: 0.5px solid rgba(0, 0, 0, 0.08); + box-shadow: 0 1px 4px rgba(0, 0, 0, 0.1); + height: 32px; + border-radius: 16px; + padding: 0 12px; +} + +.pw-device-capsule.cap-wechat .pw-capsule-home { + width: 16px; + height: 16px; + border-radius: 50%; + border: 1.6px solid #07c160; + background: + radial-gradient(circle at 50% 42%, #07c160 0 2.2px, transparent 2.4px), + radial-gradient(circle at 50% 50%, transparent 0 4px, #07c160 4.2px 5.8px, transparent 6px); +} + +.pw-device-capsule.cap-wechat .pw-capsule-line { + width: 1px; + height: 16px; + margin: 0 12px; + background: rgba(0, 0, 0, 0.1); +} + +.pw-device-capsule.cap-wechat .pw-capsule-menu { + width: 18px; + height: 5px; + background: + radial-gradient(circle at 8% 50%, #111 0 1.8px, transparent 2px), + radial-gradient(circle at 50% 50%, #111 0 1.8px, transparent 2px), + radial-gradient(circle at 92% 50%, #111 0 1.8px, transparent 2px); +} + +/* —— 微信官方菜单(深色) —— */ +.pw-device-capsule.cap-wechat-dark { + background: rgba(0, 0, 0, 0.75); + height: 32px; + border-radius: 16px; + padding: 0 12px; +} + +.pw-device-capsule.cap-wechat-dark .pw-capsule-home { + width: 16px; + height: 16px; + border-radius: 50%; + border: 1.6px solid #fff; + background: + radial-gradient(circle at 50% 42%, #fff 0 2.2px, transparent 2.4px), + radial-gradient(circle at 50% 50%, transparent 0 4px, #fff 4.2px 5.8px, transparent 6px); +} + +.pw-device-capsule.cap-wechat-dark .pw-capsule-line { + width: 1px; + height: 16px; + margin: 0 12px; + background: rgba(255, 255, 255, 0.2); +} + +.pw-device-capsule.cap-wechat-dark .pw-capsule-menu { + width: 18px; + height: 5px; + background: + radial-gradient(circle at 8% 50%, #fff 0 1.8px, transparent 2px), + radial-gradient(circle at 50% 50%, #fff 0 1.8px, transparent 2px), + radial-gradient(circle at 92% 50%, #fff 0 1.8px, transparent 2px); +} + +/* —— 抖音:深色毛玻璃(默认) —— */ +.pw-device-capsule.cap-douyin { + background: rgba(0, 0, 0, 0.58); + backdrop-filter: blur(12px); + -webkit-backdrop-filter: blur(12px); + height: 32px; + border-radius: 16px; + padding: 0 12px; +} + +.pw-device-capsule.cap-douyin .pw-capsule-home { + width: 12px; + height: 12px; + border-radius: 50%; + border: 1.5px solid #fff; + background: radial-gradient(circle at 50% 50%, #fff 0 22%, transparent 23%); +} + +.pw-device-capsule.cap-douyin .pw-capsule-line { + width: 1px; + height: 14px; + margin: 0 10px; + background: rgba(255, 255, 255, 0.28); +} + +.pw-device-capsule.cap-douyin .pw-capsule-menu { + width: 16px; + height: 4px; + background: + radial-gradient(circle at 10% 50%, #fff 0 1.5px, transparent 1.7px), + radial-gradient(circle at 50% 50%, #fff 0 1.5px, transparent 1.7px), + radial-gradient(circle at 90% 50%, #fff 0 1.5px, transparent 1.7px); +} + +/* —— 支付宝:浅灰胶囊 —— */ +.pw-device-capsule.cap-alipay { + background: rgba(245, 246, 248, 0.96); + height: 30px; +} + +.pw-device-capsule.cap-alipay .pw-capsule-home { + width: 13px; + height: 13px; + border-radius: 3px; + background: #1677ff; + box-shadow: inset 0 0 0 2px rgba(255, 255, 255, 0.25); +} + +.pw-device-capsule.cap-alipay .pw-capsule-line { + width: 1px; + height: 14px; + margin: 0 10px; + background: rgba(0, 0, 0, 0.1); +} + +.pw-device-capsule.cap-alipay .pw-capsule-menu { + width: 14px; + height: 14px; + border: 1.5px solid #333; + border-radius: 50%; + background: transparent; +} + +/* —— QQ:浅色 + 企鹅感圆点 —— */ +.pw-device-capsule.cap-qq { + background: rgba(255, 255, 255, 0.95); + height: 30px; +} + +.pw-device-capsule.cap-qq .pw-capsule-home { + width: 14px; + height: 14px; + border-radius: 50%; + background: radial-gradient(circle at 35% 35%, #5ec7f5, #12b7f5 70%); +} + +.pw-device-capsule.cap-qq .pw-capsule-line { + width: 1px; + height: 14px; + margin: 0 10px; + background: rgba(0, 0, 0, 0.1); +} + +.pw-device-capsule.cap-qq .pw-capsule-menu { + width: 16px; + height: 4px; + background: + radial-gradient(circle at 10% 50%, #222 0 1.5px, transparent 1.7px), + radial-gradient(circle at 50% 50%, #222 0 1.5px, transparent 1.7px), + radial-gradient(circle at 90% 50%, #222 0 1.5px, transparent 1.7px); +} + +/* —— 快手:橙色点缀 —— */ +.pw-device-capsule.cap-kuaishou { + background: rgba(255, 255, 255, 0.94); + height: 30px; +} + +.pw-device-capsule.cap-kuaishou .pw-capsule-home { + width: 13px; + height: 13px; + border-radius: 50%; + background: #ff5000; +} + +.pw-device-capsule.cap-kuaishou .pw-capsule-line { + width: 1px; + height: 14px; + margin: 0 10px; + background: rgba(0, 0, 0, 0.1); +} + +.pw-device-capsule.cap-kuaishou .pw-capsule-menu { + width: 16px; + height: 4px; + background: + radial-gradient(circle at 10% 50%, #222 0 1.5px, transparent 1.7px), + radial-gradient(circle at 50% 50%, #222 0 1.5px, transparent 1.7px), + radial-gradient(circle at 90% 50%, #222 0 1.5px, transparent 1.7px); +} + +/* —— B站:粉点缀 —— */ +.pw-device-capsule.cap-bilibili { + background: rgba(255, 255, 255, 0.95); + height: 30px; +} + +.pw-device-capsule.cap-bilibili .pw-capsule-home { + width: 14px; + height: 12px; + border-radius: 3px; + border: 1.6px solid #fb7299; + background: transparent; + position: relative; +} + +.pw-device-capsule.cap-bilibili .pw-capsule-home::after { + content: ''; + position: absolute; + left: -3px; + top: -3px; + width: 4px; + height: 4px; + border: 1.5px solid #fb7299; + border-radius: 1px; +} + +.pw-device-capsule.cap-bilibili .pw-capsule-line { + width: 1px; + height: 14px; + margin: 0 10px; + background: rgba(0, 0, 0, 0.1); +} + +.pw-device-capsule.cap-bilibili .pw-capsule-menu { + width: 16px; + height: 4px; + background: + radial-gradient(circle at 10% 50%, #222 0 1.5px, transparent 1.7px), + radial-gradient(circle at 50% 50%, #222 0 1.5px, transparent 1.7px), + radial-gradient(circle at 90% 50%, #222 0 1.5px, transparent 1.7px); } /* Home 指示条 / 实体 Home 键 */ diff --git a/preview-template/workbench/js/agent-assets.js b/preview-template/workbench/js/agent-assets.js index 8103db7..467a1eb 100644 --- a/preview-template/workbench/js/agent-assets.js +++ b/preview-template/workbench/js/agent-assets.js @@ -2,9 +2,11 @@ 'use strict'; /** - * 资源面板数据源(仅引擎接口): - * assetManager.assets — 全部已加载资源 - * assetManager.bundles — 已加载 Bundle(归属用 cache/_map 尽力匹配) + * 资源进包(对齐 Cocos 构建规则): + * 1) 在 Bundle 目录/config 内 → 该 Bundle + * 2) 不在任何 Bundle 内,被若干 Bundle 引用 → 优先级数字最大的 Bundle + * 3) 无人引用 → internal + * 优先级:config.priority,数字越大越高;无配置则 0。 */ function fmtType(asset) { @@ -16,7 +18,6 @@ return (bytes || 0) / (1024 * 1024); } - /** GPU 估算:纹理去重只计一次 */ function texMemMB(tex) { if (!tex) return 0; var w = tex.width || 0; @@ -39,7 +40,6 @@ if (key) seenTex[key] = 1; return texMemMB(asset); } - // SpriteFrame / Image 挂在纹理上,不进总量 if (type === 'SpriteFrame' || type === 'ImageAsset') return 0; if (type === 'AudioClip' || /Audio/.test(type)) { try { @@ -48,12 +48,8 @@ } 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); - } + 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; } @@ -84,204 +80,117 @@ return { gfxTextureMB: tex, gfxBufferMB: buf, jsHeapMB: heap }; } - /** - * 按构建「依赖打入分包」扩展归属: - * 包内资源引用到的、且尚未被其它包显式声明的资源,打入同一包。 - * 使用 assetManager.dependUtil.getDependsRecursively。 - */ - function expandOwnershipByDeps(cc, bi) { - var depUtil = cc.assetManager && cc.assetManager.dependUtil; - if (!depUtil || typeof depUtil.getDependsRecursively !== 'function') return; - - var uuidMap = bi.uuidMap || {}; - var depPacked = bi.depPacked || (bi.depPacked = {}); - var roots = []; - for (var u in uuidMap) { - if (Object.prototype.hasOwnProperty.call(uuidMap, u)) roots.push(u); - } - - for (var i = 0; i < roots.length; i++) { - var root = roots[i]; - var bname = uuidMap[root]; - if (!bname) continue; - var deps = null; - try { - deps = depUtil.getDependsRecursively(root); - } catch (e) { - try { - deps = depUtil.getDeps(root); - } catch (e2) { - deps = null; - } - } - if (!deps || !deps.length) continue; - for (var j = 0; j < deps.length; j++) { - var d = String(deps[j]); - if (!d || d === root) continue; - // 已被其它包显式占有则不抢(跨包引用) - if (uuidMap[d] && uuidMap[d] !== bname) continue; - if (!uuidMap[d]) { - uuidMap[d] = bname; - depPacked[d] = true; - } - } - } - } - function resolveBundleName(uuid, asset, bi) { + /** 优先级:数字越大越高 */ + function readPriority(bundle, name) { 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; - } + if (bundle && bundle.priority != null && isFinite(bundle.priority)) return Number(bundle.priority); } catch (e) { /* */ } - if (bi && bi.uuidMap && uuid && bi.uuidMap[uuid]) return bi.uuidMap[uuid]; + try { + var cfg = bundle && bundle._config; + if (cfg && cfg.priority != null && isFinite(cfg.priority)) return Number(cfg.priority); + } catch (e2) { /* */ } + try { + if (bundle && bundle._priority != null && isFinite(bundle._priority)) return Number(bundle._priority); + } catch (e3) { /* */ } + return 0; + } - var url = (asset && (asset.nativeUrl || asset._nativeUrl)) || ''; + /** nativeUrl 里的包名:…/home/import/… → home */ + function bundleFromUrl(url) { + if (!url) return ''; var m = String(url).match(/\/([A-Za-z0-9_\-]+)\/(?:import|native|js|ohm|chunks)\//i); - if (m && m[1]) return m[1]; - - var objs = (bi && bi.bundleObjs) || []; - for (var i = 0; i < objs.length; i++) { - var b = objs[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 && b.cache.has && uuid && b.cache.has(uuid)) return bname; - } catch (e3) { /* */ } - } - return ''; + return m && m[1] ? m[1] : ''; } /** - * 构建索引:uuid→bundle、uuid→path、pack(自动图集)成员。 - * 数据来自 bundle._config(paths / packs)与 cache。 + * 建 uuid → bundle 显式索引(构建归属)。 + * 顺序:nativeUrl → getAssetInfo → cache → config.paths/packs */ - function indexBundles(cc) { - var list = []; + function buildIndex(cc) { var uuidMap = {}; var pathMap = {}; - var packOf = {}; // uuid -> packId - var packMembers = {}; // packId -> [uuid] + var packOf = {}; + var packMembers = {}; var bundleObjs = []; + var list = []; - var addU = function (u, bname) { - if (u != null && bname) uuidMap[String(u)] = bname; - }; - - try { - cc.assetManager.bundles.forEach(function (bundle, name) { - var n = name || (bundle && bundle.name) || ''; - if (!n || !bundle) return; - bundle.__pwName = n; - bundleObjs.push(bundle); - list.push({ - name: n, - assetCount: 0, - memMB: 0, - texMB: 0, - audioMB: 0, - otherMB: 0, - status: 'loaded', - packCount: 0, - }); + cc.assetManager.bundles.forEach(function (bundle, name) { + var n = name || (bundle && bundle.name) || ''; + if (!n || !bundle) return; + bundle.__pwName = n; + bundleObjs.push(bundle); + list.push({ + name: n, + assetCount: 0, + memMB: 0, + texMB: 0, + audioMB: 0, + otherMB: 0, + status: 'loaded', + priority: readPriority(bundle, n), + }); - var cfg = bundle._config; + var put = function (u) { + if (u != null && u !== '') uuidMap[String(u)] = n; + }; - // 1) paths: 路径 → [uuid, type, ...] 或 {uuid} - try { - var paths = cfg && (cfg.paths || cfg._paths); - if (paths && typeof paths === 'object') { - for (var p in paths) { - if (!Object.prototype.hasOwnProperty.call(paths, p)) continue; - var val = paths[p]; - if (Array.isArray(val)) { - for (var i = 0; i < val.length; i++) { - var u1 = val[i]; - if (typeof u1 === 'string' && /^[0-9a-fA-F-]{8,}/.test(u1)) { - addU(u1, n); - if (!pathMap[u1]) pathMap[u1] = p; - } else if (u1 && u1.uuid) { - addU(u1.uuid, n); - if (!pathMap[u1.uuid]) pathMap[u1.uuid] = p; - } - } - } else if (val && val.uuid) { - addU(val.uuid, n); - if (!pathMap[val.uuid]) pathMap[val.uuid] = p; - } - } + // cache + try { + var cache = bundle.cache || bundle._cache; + if (cache && cache._map) { + var keys = Object.keys(cache._map); + for (var i = 0; i < keys.length; i++) { + if (cache._map[keys[i]]) put(keys[i]); } - } catch (e) { /* */ } + } + if (cache && typeof cache.forEach === 'function') { + cache.forEach(function (asset, uuid) { + put(uuid); + if (asset) put(asset._uuid); + }); + } + } catch (e) { /* */ } - // 2) packs: 自动图集 / 合包 - try { - var packs = cfg && (cfg.packs || cfg._packs); - if (packs && typeof packs === 'object') { - var packCount = 0; - for (var packId in packs) { - if (!Object.prototype.hasOwnProperty.call(packs, packId)) continue; - var members = packs[packId]; - if (!members) continue; - packCount += 1; - packMembers[packId] = packMembers[packId] || []; - var arr = Array.isArray(members) ? members : [members]; - for (var j = 0; j < arr.length; j++) { - var um = arr[j]; - if (um && typeof um === 'object') um = um.uuid; - if (!um) continue; - addU(um, n); - packOf[String(um)] = packId; - packMembers[packId].push(String(um)); - if (!pathMap[String(um)]) pathMap[String(um)] = packId; + // config.paths + try { + var cfg = bundle._config; + var paths = cfg && cfg.paths; + if (paths) { + for (var p in paths) { + if (!Object.prototype.hasOwnProperty.call(paths, p)) continue; + var val = paths[p]; + var listU = Array.isArray(val) ? val : (val ? [val] : []); + for (var j = 0; j < listU.length; j++) { + var uu = listU[j]; + if (uu && typeof uu === 'object') uu = uu.uuid; + if (typeof uu === 'string' && uu) { + put(uu); + if (!pathMap[uu]) pathMap[uu] = p; } } - for (var q = 0; q < list.length; q++) { - if (list[q].name === n) list[q].packCount = packCount; - } } - } catch (e2) { /* */ } - - // 3) cache - try { - var cache = bundle.cache || bundle._cache; - if (cache && typeof cache.forEach === 'function') { - cache.forEach(function (asset, uuid) { - addU(uuid, n); - if (asset) addU(asset._uuid, n); - }); - } - if (cache && cache._map) { - for (var k in cache._map) { - if (Object.prototype.hasOwnProperty.call(cache._map, k)) addU(k, n); - } - } - } catch (e3) { /* */ } - - // 4) getAssetInfo 扫一遍 config 里的 uuid - try { - if (typeof bundle.getAssetInfo === 'function') { - var keys = Object.keys(uuidMap); - for (var s = 0; s < keys.length; s++) { - if (uuidMap[keys[s]] === n) continue; - try { - var inf = bundle.getAssetInfo(keys[s]); - if (inf && (inf.path != null || inf.redirect)) { - addU(keys[s], n); - if (!pathMap[keys[s]] && inf.path) pathMap[keys[s]] = inf.path; - } - } catch (e4) { /* */ } + } + // config.packs(自动图集) + var packs = cfg && cfg.packs; + if (packs) { + for (var packId in packs) { + if (!Object.prototype.hasOwnProperty.call(packs, packId)) continue; + var members = packs[packId]; + if (!members) continue; + packMembers[packId] = packMembers[packId] || []; + var arr = Array.isArray(members) ? members : [members]; + for (var m = 0; m < arr.length; m++) { + var um = arr[m]; + if (um && typeof um === 'object') um = um.uuid; + if (!um) continue; + put(um); + packOf[String(um)] = packId; + packMembers[packId].push(String(um)); } } - } catch (e5) { /* */ } - }); - } catch (e6) { /* */ } + } + } catch (e2) { /* */ } + }); return { list: list, @@ -293,136 +202,235 @@ }; } + /** 对每个资源:getAssetInfo / nativeUrl 再补一轮归属 */ + function refineOwnership(cc, bi, rows) { + var objs = bi.bundleObjs || []; + for (var i = 0; i < rows.length; i++) { + var row = rows[i]; + var id = row.uuid; + if (bi.uuidMap[id]) continue; + + // nativeUrl + var asset = null; + try { + asset = cc.assetManager.assets.get(id); + } catch (e) { asset = null; } + var fromUrl = bundleFromUrl(asset && (asset.nativeUrl || asset._nativeUrl)); + if (fromUrl) { + bi.uuidMap[id] = fromUrl; + row.bundle = fromUrl; + continue; + } + + // getAssetInfo + for (var b = 0; b < objs.length; b++) { + var bundle = objs[b]; + if (!bundle || typeof bundle.getAssetInfo !== 'function') continue; + try { + var info = bundle.getAssetInfo(id); + if (info && (info.path != null || info.uuid || info.redirect)) { + bi.uuidMap[id] = bundle.__pwName || bundle.name; + row.bundle = bi.uuidMap[id]; + if (info.path && !bi.pathMap[id]) bi.pathMap[id] = info.path; + break; + } + } catch (e2) { /* */ } + } + } + } + /** - * @returns {{bundles:Array, rows:Array, totals:Object}} + * 依赖进包:不在任何 Bundle 的资源 → 优先级数字最大的引用方。 */ + function applyRefPacking(cc, bi, rows) { + var depUtil = cc.assetManager && cc.assetManager.dependUtil; + var byUuid = {}; + for (var i = 0; i < rows.length; i++) byUuid[rows[i].uuid] = rows[i]; + + var explicit = {}; + for (var k in bi.uuidMap) { + if (Object.prototype.hasOwnProperty.call(bi.uuidMap, k)) explicit[k] = bi.uuidMap[k]; + } + + var prio = {}; + for (var p = 0; p < bi.list.length; p++) { + prio[bi.list[p].name] = bi.list[p].priority || 0; + } + + // 引用根:各包内显式归属的资源 + var rootsByBundle = {}; + for (var r = 0; r < rows.length; r++) { + var row = rows[r]; + var bb = explicit[row.uuid]; + if (!bb) continue; + if (!rootsByBundle[bb]) rootsByBundle[bb] = []; + rootsByBundle[bb].push(row.uuid); + } + + var getDeps = function (uuid) { + if (!depUtil) return []; + try { + if (depUtil.getDependsRecursively) return depUtil.getDependsRecursively(uuid) || []; + } catch (e) { /* */ } + try { + if (depUtil.getDeps) return depUtil.getDeps(uuid) || []; + } catch (e2) { /* */ } + return []; + }; + + var refBy = {}; + var addRef = function (uuid, bname) { + if (!uuid || !bname) return; + if (!refBy[uuid]) refBy[uuid] = []; + if (refBy[uuid].indexOf(bname) < 0) refBy[uuid].push(bname); + }; + + for (var bn in rootsByBundle) { + if (!Object.prototype.hasOwnProperty.call(rootsByBundle, bn)) continue; + var roots = rootsByBundle[bn]; + for (var i2 = 0; i2 < roots.length; i2++) { + var deps = getDeps(roots[i2]); + addRef(roots[i2], bn); + for (var d = 0; d < deps.length; d++) addRef(String(deps[d]), bn); + } + } + + for (var j = 0; j < rows.length; j++) { + var row2 = rows[j]; + var u2 = row2.uuid; + + // 已显式归属 + if (explicit[u2]) { + row2.bundle = explicit[u2]; + row2.via = 'direct'; + row2.usedBy = refBy[u2] ? refBy[u2].slice() : [explicit[u2]]; + continue; + } + + var refs = refBy[u2] || []; + if (!refs.length) { + row2.bundle = 'internal'; + row2.via = 'direct'; + row2.usedBy = ['internal']; + continue; + } + + // 优先级数字最大的引用方 + var best = refs[0]; + for (var n = 1; n < refs.length; n++) { + var prBest = prio[best] || 0; + var prCur = prio[refs[n]] || 0; + if (prCur > prBest) best = refs[n]; + } + row2.bundle = best; + row2.via = 'dep'; + row2.usedBy = refs.slice(); + } + } + 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 bi = buildIndex(cc); var rows = []; + var seenTex = {}; - // —— 全部已加载资源: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); - if (!bname) bname = 'internal'; - var agg = bundleByName[bname]; - if (!agg) { - agg = { - name: bname, - assetCount: 0, - memMB: 0, - texMB: 0, - audioMB: 0, - otherMB: 0, - status: 'loaded', - packCount: 0, - }; - bundleByName[bname] = agg; - bi.list.push(agg); - } + 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 = bi.uuidMap[id] || bundleFromUrl(asset.nativeUrl || asset._nativeUrl) || ''; - 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 kind = 'other'; + if (type === 'Texture2D' || type === 'Texture') kind = 'tex'; + else if (type === 'AudioClip' || /Audio/.test(type)) kind = 'audio'; - // 自动图集 / 合包:packId;SpriteFrame 挂到纹理 uuid - var packId = bi.packOf[id] || ''; - var texUuid = ''; - if (type === 'SpriteFrame') { - var tex = asset.texture || (asset.getTexture && asset.getTexture()); - texUuid = String((tex && (tex._uuid || tex.uuid)) || ''); - } else if (type === 'Texture2D' || type === 'Texture') { - texUuid = id; - } + 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 || ''; - var pathStr = bi.pathMap[id] || ''; - if (!display || /^[0-9a-f-]{8,}$/i.test(display)) { - if (pathStr) { - var segs = String(pathStr).split('/'); - display = segs[segs.length - 1] || pathStr; - } - if (!display) { - try { - for (var bi2 = 0; bi2 < bi.bundleObjs.length; bi2++) { - var bb = bi.bundleObjs[bi2]; - if (bb && typeof bb.getAssetInfo === 'function') { - var inf = bb.getAssetInfo(id); - if (inf && inf.path) { - var ps = String(inf.path).split('/'); - display = ps[ps.length - 1] || inf.path; - break; - } - } - } - } catch (eName) { /* */ } - } - if (!display) display = id.slice(0, 8) + '…'; + var display = asset.name || ''; + var pathStr = bi.pathMap[id] || ''; + if (!display || /^[0-9a-f-]{8,}$/i.test(display)) { + if (pathStr) { + var segs = String(pathStr).split('/'); + display = segs[segs.length - 1] || pathStr; } + 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, - pack: packId, - texUuid: texUuid, - path: pathStr, - }); - - agg.assetCount += 1; - agg.memMB += mem; - if (kind === 'tex') agg.texMB += mem; - else if (kind === 'audio') agg.audioMB += mem; - else agg.otherMB += mem; + 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, + pack: bi.packOf[id] || '', + texUuid: type === 'SpriteFrame' ? '' : id, + path: pathStr, + via: bname ? 'direct' : 'direct', }); - } 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'; - } + // 补归属 + 依赖进包 + refineOwnership(cc, bi, rows); + applyRefPacking(cc, bi, rows); + + // 汇总 bundle + var byName = {}; + for (var i = 0; i < bi.list.length; i++) { + byName[bi.list[i].name] = bi.list[i]; + } + for (var z = 0; z < bi.list.length; z++) { + bi.list[z].assetCount = 0; + bi.list[z].memMB = 0; + bi.list[z].texMB = 0; + bi.list[z].audioMB = 0; + bi.list[z].otherMB = 0; + } + for (var r = 0; r < rows.length; r++) { + var row = rows[r]; + var agg = byName[row.bundle]; + if (!agg) { + agg = { + name: row.bundle || 'internal', + assetCount: 0, + memMB: 0, + texMB: 0, + audioMB: 0, + otherMB: 0, + status: 'loaded', + priority: 0, + }; + byName[agg.name] = agg; + bi.list.push(agg); } + agg.assetCount += 1; + agg.memMB += row.mem; + if (row.kind === 'tex') agg.texMB += row.mem; + else if (row.kind === 'audio') agg.audioMB += row.mem; + else agg.otherMB += row.mem; + } + for (var y = 0; y < bi.list.length; y++) { + if (bi.list[y].assetCount === 0) bi.list[y].status = 'loaded-empty'; } - bundles.sort(function (a, b) { - return (b.memMB - a.memMB) || (b.assetCount - a.assetCount) || String(a.name).localeCompare(String(b.name)); + bi.list.sort(function (a, b) { + return (b.memMB - a.memMB) || (b.assetCount - a.assetCount); }); var totals = gfxTotals(); @@ -430,21 +438,20 @@ 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; + for (var t = 0; t < rows.length; t++) { + totals.estimateMB += rows[t].mem; + if (rows[t].kind === 'tex') totals.texMB += rows[t].mem; + else if (rows[t].kind === 'audio') totals.audioMB += rows[t].mem; + else totals.otherMB += rows[t].mem; } totals.assetCount = rows.length; - totals.bundleCount = bundles.length; - totals.packCount = Object.keys(bi.packMembers || {}).length; + totals.bundleCount = bi.list.length; return { - bundles: bundles, + bundles: bi.list, rows: rows, totals: totals, - packs: bi.packMembers || {}, - packOf: bi.packOf || {}, + packs: bi.packMembers, + packOf: bi.packOf, }; } @@ -459,32 +466,19 @@ 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); + cv.getContext('2d').drawImage(data, rect.x | 0, Math.max(0, ih - (rect.y | 0) - sh) | 0, 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) { /* */ } @@ -496,9 +490,7 @@ var asset = null; try { asset = cc.assetManager.assets.get(uuid); - } catch (e) { - asset = null; - } + } catch (e) { /* */ } if (!asset) return { kind: 'none' }; return buildPreview(asset, fmtType(asset)); } @@ -511,6 +503,7 @@ try { done(collectAssets()); } catch (e) { + console.warn('[assets]', e); done({ bundles: [], rows: [], totals: gfxTotals() }); } }; @@ -522,6 +515,5 @@ 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 1fe9642..8180eec 100644 --- a/preview-template/workbench/js/agent-core.js +++ b/preview-template/workbench/js/agent-core.js @@ -142,6 +142,10 @@ case 'unsubscribe': return this.unsubscribe(params.channel); case 'perf.get': return this._perfPayload(); case 'stats.get': return this._statsPayload(); + case 'prof.start': return this._profStart ? this._profStart() : false; + case 'prof.stop': return this._profStop ? this._profStop() : false; + case 'prof.clear': return this._profClear ? this._profClear() : false; + case 'prof.report': return this._profReport ? this._profReport() : { functions: [], slowFrames: [], live: null }; case 'perf.clear': if (this._sampler) this._sampler.clear(); return true; case 'perf.mark': if (this._sampler) this._sampler.userMark(params.text || ''); return true; case 'perf.setInterval': if (this._sampler) this._sampler.setIntervalMs(params.ms || PERF_DEFAULT_MS); return true; @@ -168,7 +172,15 @@ case 'device.getState': return this._device ? { id: this._device.deviceId, rotated: this._device.rotated, + capsule: this._device.isCapsule ? this._device.isCapsule() : false, } : null; + case 'device.setCapsule': return this._device && this._device.setCapsule ? this._device.setCapsule(!!params.on) : false; + case 'device.setCapsuleStyle': return this._device && this._device.setCapsuleStyle + ? this._device.setCapsuleStyle(params.style) + : 'wechat'; + case 'device.getCapsuleStyle': return this._device && this._device.getCapsuleStyle + ? this._device.getCapsuleStyle() + : 'wechat'; case 'console.inspect': return this._inspectConsoleObj(params.oid, params.path || ''); default: throw new Error('unknown method: ' + method); diff --git a/preview-template/workbench/js/agent-profiler.js b/preview-template/workbench/js/agent-profiler.js new file mode 100644 index 0000000..418e2ca --- /dev/null +++ b/preview-template/workbench/js/agent-profiler.js @@ -0,0 +1,227 @@ +(function (global) { + 'use strict'; + + /** + * 游戏侧性能剖析(零侵入,按需采样): + * - 函数耗时 Top(Error.stack 采样聚合) + * - 慢帧堆栈 Top(帧耗时超阈值时抓栈) + * - 真实 FPS / 内存 + */ + function StackProfiler() { + this.enabled = false; + this.intervalMs = 8; + this._timer = 0; + this._fnStats = {}; // name -> {count, ms} + this._slowFrames = []; + this._maxSlow = 20; + this._lastTick = 0; + this._tickHooked = false; + this._origTick = null; + this._cc = null; + } + + StackProfiler.prototype.bindEngine = function (cc) { + this._cc = cc; + this._hookTick(); + }; + + StackProfiler.prototype._hookTick = function () { + var self = this; + var cc = this._cc; + if (!cc || !cc.director || this._tickHooked) return; + var dir = cc.director; + var proto = Object.getPrototypeOf(dir) || dir; + if (typeof proto.tick !== 'function') return; + this._origTick = proto.tick; + this._tickHooked = true; + proto.tick = function () { + if (!self.enabled) { + return self._origTick.apply(this, arguments); + } + var t0 = performance.now(); + var ret = self._origTick.apply(this, arguments); + var dt = performance.now() - t0; + self._noteFrame(dt); + return ret; + }; + }; + + StackProfiler.prototype._noteFrame = function (ms) { + if (ms < 20) return; + var stack = ''; + try { + stack = new Error('pf').stack || ''; + } catch (e) { /* */ } + this._slowFrames.push({ + t: Date.now(), + ms: Math.round(ms * 100) / 100, + stack: this._cleanStack(stack), + }); + if (this._slowFrames.length > this._maxSlow * 3) { + this._slowFrames.sort(function (a, b) { return b.ms - a.ms; }); + this._slowFrames.length = this._maxSlow; + } + }; + + StackProfiler.prototype._cleanStack = function (stack) { + if (!stack) return ''; + var lines = String(stack).split('\n'); + var out = []; + for (var i = 0; i < lines.length && out.length < 12; i++) { + var s = lines[i].trim(); + if (!s || /^error/i.test(s) || s.indexOf('Error') === 0 && s.indexOf('at ') < 0) continue; + if (/StackProfiler|_cleanStack|_noteFrame|agent-profiler|workbench/.test(s)) continue; + out.push(s); + } + return out.join('\n'); + }; + + StackProfiler.prototype.start = function () { + if (this.enabled) return; + this.enabled = true; + this._fnStats = {}; + this._slowFrames = []; + var self = this; + this._timer = setInterval(function () { + self._sampleStack(); + }, this.intervalMs); + }; + + StackProfiler.prototype.stop = function () { + this.enabled = false; + if (this._timer) { + clearInterval(this._timer); + this._timer = 0; + } + }; + + StackProfiler.prototype.clear = function () { + this._fnStats = {}; + this._slowFrames = []; + }; + + /** 调用栈采样:每 tick 记一帧到最热函数 */ + StackProfiler.prototype._sampleStack = function () { + if (!this.enabled) return; + var stack = ''; + try { + stack = new Error('s').stack || ''; + } catch (e) { + return; + } + var lines = String(stack).split('\n'); + var name = ''; + for (var i = 1; i < lines.length; i++) { + var s = lines[i].trim(); + if (!s) continue; + if (/StackProfiler|_sampleStack|agent-profiler|workbench|setInterval/.test(s)) continue; + // at fnName (url:line:col) 或 at url:line:col + var m = s.match(/at\s+([^\s(]+)/); + name = (m && m[1]) || s; + break; + } + if (!name) return; + if (!this._fnStats[name]) this._fnStats[name] = { count: 0, ms: 0 }; + this._fnStats[name].count += 1; + this._fnStats[name].ms += this.intervalMs; + }; + + StackProfiler.prototype.topFunctions = function (limit) { + var arr = []; + for (var k in this._fnStats) { + if (!Object.prototype.hasOwnProperty.call(this._fnStats, k)) continue; + arr.push({ + name: k, + count: this._fnStats[k].count, + ms: Math.round(this._fnStats[k].ms * 10) / 10, + }); + } + arr.sort(function (a, b) { return b.ms - a.ms; }); + return arr.slice(0, limit || 20); + }; + + StackProfiler.prototype.topSlowFrames = function (limit) { + var arr = this._slowFrames.slice(); + arr.sort(function (a, b) { return b.ms - a.ms; }); + return arr.slice(0, limit || 10); + }; + + /** 当前真实帧率 / 内存 */ + StackProfiler.prototype.liveStats = function () { + var cc = this._cc; + var fps = 0; + var tex = 0; + var buf = 0; + try { + var st = cc && cc.profiler && cc.profiler.stats; + if (st) { + if (st.fps && st.fps.counter) fps = st.fps.counter.value || 0; + 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) { /* */ } + if (!fps && cc && cc.director && typeof cc.director.getTotalFrames === 'function') { + var now = performance.now(); + var frames = cc.director.getTotalFrames(); + if (this._fpsLastFrames != null && this._fpsLastTime) { + var dt = now - this._fpsLastTime; + if (dt > 0) fps = ((frames - this._fpsLastFrames) * 1000) / dt; + } + this._fpsLastFrames = frames; + this._fpsLastTime = now; + } + var heap = 0; + try { + if (performance.memory && performance.memory.usedJSHeapSize) { + heap = performance.memory.usedJSHeapSize / (1024 * 1024); + } + } catch (e2) { /* */ } + return { + fps: fps, + textureMemory: tex, + bufferMemory: buf, + jsHeap: heap, + totalMemory: tex + buf + heap, + }; + }; + + global.PWStackProfiler = StackProfiler; + + // —— 挂到 Agent —— + if (global.PWAgent) { + var Agent = global.PWAgent; + if (!Agent.prototype._profiler) { + Agent.prototype._ensureProfiler = function () { + if (!this.__stackProfiler) { + this.__stackProfiler = new StackProfiler(); + if (this.cc) this.__stackProfiler.bindEngine(this.cc); + } else if (this.cc && !this.__stackProfiler._cc) { + this.__stackProfiler.bindEngine(this.cc); + } + return this.__stackProfiler; + }; + Agent.prototype._profStart = function () { + var p = this._ensureProfiler(); + p.start(); + return true; + }; + Agent.prototype._profStop = function () { + if (this.__stackProfiler) this.__stackProfiler.stop(); + return true; + }; + Agent.prototype._profClear = function () { + if (this.__stackProfiler) this.__stackProfiler.clear(); + return true; + }; + Agent.prototype._profReport = function () { + var p = this._ensureProfiler(); + return { + enabled: p.enabled, + functions: p.topFunctions(20), + slowFrames: p.topSlowFrames(10), + live: p.liveStats(), + }; + }; + } + } +})(window); diff --git a/preview-template/workbench/js/device.js b/preview-template/workbench/js/device.js index 858683b..e6482b0 100644 --- a/preview-template/workbench/js/device.js +++ b/preview-template/workbench/js/device.js @@ -2,37 +2,90 @@ 'use strict'; /** - * shell: - * - classic 圆角矩形 + Home 键(iPhone SE/8) - * - notch 刘海(iPhone X / 14) - * - island 灵动岛(iPhone 14 Pro+) - * - android 窄边框 + 打孔/小刘海感 - * - tablet 平板宽边框 + * shell(按真机形态做 CSS 边框): + * - classic Home 键(iPhone 8 / SE) + * - notch 刘海(iPhone X~14 标准) + * - island 灵动岛(iPhone 14 Pro 起) + * - punch 居中挖孔(多数安卓) + * - punchL 左上挖孔(部分三星/小米) + * - teardrop 水滴(中端安卓) + * - tablet 平板 + * - tabletCam 平板+前摄 + * - flat 无装饰框 */ var SHELL = { - classic: { padX: 0.048, padY: 0.11, minPadX: 12, minPadY: 28, radius: 0.12 }, - notch: { padX: 0.03, padY: 0.03, minPadX: 10, minPadY: 10, radius: 0.14 }, - island: { padX: 0.028, padY: 0.028, minPadX: 10, minPadY: 10, radius: 0.145 }, - android: { padX: 0.02, padY: 0.02, minPadX: 8, minPadY: 8, radius: 0.1 }, - tablet: { padX: 0.032, padY: 0.032, minPadX: 14, minPadY: 14, radius: 0.05 }, + classic: { padX: 0.055, padY: 0.12, minPadX: 14, minPadY: 32, radius: 0.1 }, + notch: { padX: 0.032, padY: 0.032, minPadX: 10, minPadY: 10, radius: 0.13 }, + island: { padX: 0.028, padY: 0.028, minPadX: 10, minPadY: 10, radius: 0.14 }, + punch: { padX: 0.022, padY: 0.022, minPadX: 8, minPadY: 8, radius: 0.11 }, + punchL: { padX: 0.022, padY: 0.022, minPadX: 8, minPadY: 8, radius: 0.11 }, + teardrop: { padX: 0.024, padY: 0.024, minPadX: 8, minPadY: 8, radius: 0.11 }, + android: { padX: 0.022, padY: 0.022, minPadX: 8, minPadY: 8, radius: 0.11 }, + tablet: { padX: 0.036, padY: 0.036, minPadX: 16, minPadY: 16, radius: 0.05 }, + tabletCam: { padX: 0.036, padY: 0.036, minPadX: 16, minPadY: 16, radius: 0.05 }, + flat: { padX: 0.018, padY: 0.018, minPadX: 6, minPadY: 6, radius: 0.08 }, }; - /** 常见预览设备 */ + /** 主流厂商代表性 / 较新机型(逻辑分辨率) */ var DEVICES = [ - { id: 'fit', name: '适应窗口', w: 0, h: 0, frame: false, shell: 'island' }, - { id: 'iphone-se', name: 'iPhone SE', w: 375, h: 667, frame: true, shell: 'classic' }, - { id: 'iphone-8', name: 'iPhone 8', w: 375, h: 667, frame: true, shell: 'classic' }, - { id: 'iphone-x', name: 'iPhone X / XS', w: 375, h: 812, frame: true, shell: 'notch' }, - { id: 'iphone-14', name: 'iPhone 14', w: 390, h: 844, frame: true, shell: 'notch' }, - { id: 'iphone-14-pro', name: 'iPhone 14 Pro', w: 393, h: 852, frame: true, shell: 'island' }, - { id: 'iphone-14-promax', name: 'iPhone 14 Pro Max', w: 430, h: 932, frame: true, shell: 'island' }, - { id: 'pixel-5', name: 'Pixel 5', w: 393, h: 851, frame: true, shell: 'android' }, - { id: 'galaxy-s20', name: 'Galaxy S20', w: 360, h: 800, frame: true, shell: 'android' }, - { id: 'ipad', name: 'iPad', w: 768, h: 1024, frame: true, shell: 'tablet' }, - { id: 'ipad-pro', name: 'iPad Pro 11', w: 834, h: 1194, frame: true, shell: 'tablet' }, - { id: 'custom-720', name: '720×1280', w: 720, h: 1280, frame: true, shell: 'android' }, - { id: 'custom-1080', name: '1080×1920', w: 1080, h: 1920, frame: true, shell: 'android' }, - { id: 'design', name: '设计分辨率', w: 750, h: 1600, frame: true, shell: 'island' }, + { id: 'fit', name: '适应窗口', w: 0, h: 0, frame: false, shell: 'flat', brand: '—' }, + // —— Apple iPhone —— + { id: 'iphone-se', name: 'iPhone SE 3', w: 375, h: 667, frame: true, shell: 'classic', brand: 'Apple' }, + { id: 'iphone-8', name: 'iPhone 8', w: 375, h: 667, frame: true, shell: 'classic', brand: 'Apple' }, + { id: 'iphone-x', name: 'iPhone X / XS', w: 375, h: 812, frame: true, shell: 'notch', brand: 'Apple' }, + { id: 'iphone-xr', name: 'iPhone XR / 11', w: 414, h: 896, frame: true, shell: 'notch', brand: 'Apple' }, + { id: 'iphone-12', name: 'iPhone 12 / 13', w: 390, h: 844, frame: true, shell: 'notch', brand: 'Apple' }, + { id: 'iphone-12p', name: 'iPhone 12 / 13 Pro', w: 390, h: 844, frame: true, shell: 'notch', brand: 'Apple' }, + { id: 'iphone-12pm', name: 'iPhone 12 / 13 Pro Max', w: 428, h: 926, frame: true, shell: 'notch', brand: 'Apple' }, + { id: 'iphone-14', name: 'iPhone 14', w: 390, h: 844, frame: true, shell: 'notch', brand: 'Apple' }, + { id: 'iphone-14-plus', name: 'iPhone 14 Plus', w: 428, h: 926, frame: true, shell: 'notch', brand: 'Apple' }, + { id: 'iphone-14-pro', name: 'iPhone 14 Pro', w: 393, h: 852, frame: true, shell: 'island', brand: 'Apple' }, + { id: 'iphone-14-promax', name: 'iPhone 14 Pro Max', w: 430, h: 932, frame: true, shell: 'island', brand: 'Apple' }, + { id: 'iphone-15', name: 'iPhone 15 / 15 Plus', w: 393, h: 852, frame: true, shell: 'island', brand: 'Apple' }, + { id: 'iphone-15-pro', name: 'iPhone 15 Pro', w: 393, h: 852, frame: true, shell: 'island', brand: 'Apple' }, + { id: 'iphone-15-promax', name: 'iPhone 15 Pro Max', w: 430, h: 932, frame: true, shell: 'island', brand: 'Apple' }, + { id: 'iphone-16', name: 'iPhone 16', w: 393, h: 852, frame: true, shell: 'island', brand: 'Apple' }, + { id: 'iphone-16-pro', name: 'iPhone 16 Pro', w: 402, y: 0, h: 874, frame: true, shell: 'island', brand: 'Apple' }, + { id: 'iphone-16-promax', name: 'iPhone 16 Pro Max', w: 440, h: 956, frame: true, shell: 'island', brand: 'Apple' }, + // —— Apple iPad —— + { id: 'ipad', name: 'iPad 10', w: 820, h: 1180, frame: true, shell: 'tabletCam', brand: 'Apple' }, + { id: 'ipad-9', name: 'iPad 9', w: 768, h: 1024, frame: true, shell: 'classic', brand: 'Apple' }, + { id: 'ipad-air', name: 'iPad Air', w: 820, h: 1180, frame: true, shell: 'tabletCam', brand: 'Apple' }, + { id: 'ipad-pro', name: 'iPad Pro 11', w: 834, h: 1194, frame: true, shell: 'tabletCam', brand: 'Apple' }, + { id: 'ipad-pro13', name: 'iPad Pro 12.9', w: 1024, h: 1366, frame: true, shell: 'tabletCam', brand: 'Apple' }, + // —— Samsung —— + { id: 'galaxy-s20', name: 'Galaxy S20', w: 360, h: 800, frame: true, shell: 'punch', brand: 'Samsung' }, + { id: 'galaxy-s21', name: 'Galaxy S21 / S22', w: 360, h: 800, frame: true, shell: 'punch', brand: 'Samsung' }, + { id: 'galaxy-s23', name: 'Galaxy S23 / S24', w: 360, h: 800, frame: true, shell: 'punch', brand: 'Samsung' }, + { id: 'galaxy-s24u', name: 'Galaxy S24 Ultra', w: 412, h: 915, frame: true, shell: 'punch', brand: 'Samsung' }, + { id: 'galaxy-note20', name: 'Galaxy Note 20', w: 412, h: 915, frame: true, shell: 'punch', brand: 'Samsung' }, + { id: 'galaxy-a54', name: 'Galaxy A54', w: 360, h: 800, frame: true, shell: 'punch', brand: 'Samsung' }, + // —— Google —— + { id: 'pixel-5', name: 'Pixel 5', w: 393, h: 851, frame: true, shell: 'punch', brand: 'Google' }, + { id: 'pixel-6', name: 'Pixel 6 / 7', w: 412, h: 915, frame: true, shell: 'punch', brand: 'Google' }, + { id: 'pixel-8', name: 'Pixel 8 / 8 Pro', w: 412, h: 915, frame: true, shell: 'punch', brand: 'Google' }, + // —— 小米 / Redmi —— + { id: 'mi-13', name: '小米 13 / 14', w: 393, h: 873, frame: true, shell: 'punch', brand: 'Xiaomi' }, + { id: 'mi-14-pro', name: '小米 14 Pro', w: 400, h: 882, frame: true, shell: 'punch', brand: 'Xiaomi' }, + { id: 'redmi-note12', name: 'Redmi Note 12 / 13', w: 360, h: 800, frame: true, shell: 'teardrop', brand: 'Xiaomi' }, + { id: 'redmi-k60', name: 'Redmi K60 / K70', w: 393, h: 873, frame: true, shell: 'punch', brand: 'Xiaomi' }, + // —— 华为 / 荣耀 —— + { id: 'huawei-mate60', name: 'Mate 60 Pro', w: 400, h: 880, frame: true, shell: 'punch', brand: 'Huawei' }, + { id: 'huawei-p60', name: 'P60 / nova', w: 360, h: 800, frame: true, shell: 'punch', brand: 'Huawei' }, + { id: 'honor-magic6', name: '荣耀 Magic6', w: 360, h: 800, frame: true, shell: 'punch', brand: 'Honor' }, + // —— OPPO / 一加 / realme —— + { id: 'oppo-find', name: 'Find X6 / X7', w: 360, h: 800, frame: true, shell: 'punch', brand: 'OPPO' }, + { id: 'oppo-reno', name: 'Reno 11', w: 360, h: 800, frame: true, shell: 'punch', brand: 'OPPO' }, + { id: 'oneplus-12', name: '一加 12', w: 450, h: 1000, frame: true, shell: 'punch', brand: 'OnePlus' }, + // —— vivo / iQOO —— + { id: 'vivo-x100', name: 'vivo X100', w: 412, h: 915, frame: true, shell: 'punch', brand: 'vivo' }, + { id: 'iqoo-12', name: 'iQOO 12', w: 393, h: 873, frame: true, shell: 'punch', brand: 'iQOO' }, + // —— 其它常见分辨率 —— + { id: 'android-360', name: 'Android 360×800', w: 360, h: 800, frame: true, shell: 'punch', brand: '通用' }, + { id: 'android-412', name: 'Android 412×915', w: 412, h: 915, frame: true, shell: 'punch', brand: '通用' }, + { id: 'android-720', name: '720×1280', w: 720, h: 1280, frame: true, shell: 'flat', brand: '通用' }, + { id: 'android-1080', name: '1080×1920', w: 1080, h: 1920, frame: true, shell: 'flat', brand: '通用' }, + { id: 'design', name: '设计分辨率', w: 750, h: 1600, frame: true, shell: 'island', brand: '通用' }, ]; /** @@ -42,6 +95,8 @@ function DevicePreview() { this.deviceId = 'iphone-x'; this.rotated = false; + this._capsuleOn = true; + this._capsuleStyle = 'douyin'; this._stage = null; this._bezel = null; this._screen = null; @@ -73,8 +128,13 @@ '' + '' + '' + + '
' + '' + - '
' + + '' + + '
' + '' + ''; @@ -86,8 +146,9 @@ stage.appendChild(scaleLabel); var screen = bezel.querySelector('#pw-device-screen'); + // 内容与挖孔/胶囊同在 screen 内,保证位置相对屏幕 if (contentEl.parentNode) contentEl.parentNode.insertBefore(stage, contentEl); - screen.appendChild(contentEl); + screen.insertBefore(contentEl, screen.firstChild); this._stage = stage; this._bezel = bezel; @@ -95,6 +156,7 @@ this._cutout = bezel.querySelector('#pw-device-cutout'); this._homeBar = bezel.querySelector('#pw-device-home-bar'); this._homeBtn = bezel.querySelector('#pw-device-home-btn'); + this._capsule = bezel.querySelector('#pw-device-capsule'); this._scaleLabel = scaleLabel; this._center = centerEl; @@ -125,6 +187,65 @@ try { localStorage.setItem('preview-workbench-device', this.deviceId); } catch (e) { /* */ } }; + /** 微信/抖音等小程序右上角胶囊按钮开关 */ + DevicePreview.prototype.setCapsule = function (on) { + this._capsuleOn = !!on; + if (this._capsule) { + this._capsule.hidden = !this._capsuleOn; + this._capsule.className = 'pw-device-capsule' + + (this._capsuleOn ? ' show' : '') + + ' cap-' + (this._capsuleStyle || 'wechat'); + this._capsule.setAttribute('data-style', this._capsuleStyle || 'wechat'); + } + try { localStorage.setItem('preview-workbench-capsule', this._capsuleOn ? '1' : '0'); } catch (e) { /* */ } + return this._capsuleOn; + }; + + /** 胶囊样式:none=不显示;douyin 默认。wechat / wechat-dark 对齐微信官方菜单 */ + DevicePreview.prototype.setCapsuleStyle = function (style) { + var allow = { + none: 1, douyin: 1, wechat: 1, 'wechat-dark': 1, + alipay: 1, qq: 1, kuaishou: 1, bilibili: 1, + }; + this._capsuleStyle = allow[style] ? style : 'douyin'; + // 「无」= 不显示;其它样式默认显示 + if (this._capsuleStyle === 'none') { + this._capsuleOn = false; + } else { + this._capsuleOn = true; + } + this.setCapsule(this._capsuleOn); + if (this._capsule) { + this._capsule.setAttribute('data-style', this._capsuleStyle); + this._capsule.className = 'pw-device-capsule' + + ((this._capsuleOn && this._capsuleStyle !== 'none') ? ' show' : '') + + ' cap-' + this._capsuleStyle; + } + try { localStorage.setItem('preview-workbench-capsule-style', this._capsuleStyle); } catch (e) { /* */ } + return this._capsuleStyle; + }; + + DevicePreview.prototype.getCapsuleStyle = function () { + return this._capsuleStyle || 'douyin'; + }; + + DevicePreview.prototype.isCapsule = function () { + return !!this._capsuleOn && this._capsuleStyle !== 'none'; + }; + + DevicePreview.capsuleStyles = function () { + return [ + { id: 'none', name: '无' }, + { id: 'douyin', name: '抖音' }, + { id: 'wechat', name: '微信·浅' }, + { id: 'wechat-dark', name: '微信·深' }, + { id: 'alipay', name: '支付宝' }, + { id: 'qq', name: 'QQ' }, + { id: 'kuaishou', name: '快手' }, + { id: 'bilibili', name: 'B站' }, + ]; + }; + DevicePreview.prototype.toggleRotate = function () { this.rotated = !this.rotated; this.apply(false); @@ -143,6 +264,108 @@ return SHELL[key] || SHELL.island; }; + /** + * 安全区(逻辑分辨率单位,与 w/h 同一坐标系)。 + * 供 Cocos SafeArea / getSafeAreaEdge 使用。 + */ + DevicePreview.prototype._safeOf = function (d) { + if (d && d.safe) { + return { + top: d.safe.top || 0, + bottom: d.safe.bottom || 0, + left: d.safe.left || 0, + right: d.safe.right || 0, + }; + } + var shell = (d && d.shell) || 'island'; + var h = (d && d.h) || 812; + var w = (d && d.w) || 375; + // 以 iPhone 点值为基准按高度比例换算 + var s = h / 852; + if (shell === 'island') { + return { top: Math.round(59 * s), bottom: Math.round(34 * s), left: 0, right: 0 }; + } + if (shell === 'notch') { + return { top: Math.round(47 * s), bottom: Math.round(34 * s), left: 0, right: 0 }; + } + if (shell === 'classic') { + return { top: 20, bottom: 0, left: 0, right: 0 }; + } + if (shell === 'punch' || shell === 'punchL' || shell === 'android') { + return { top: Math.round(28 * s), bottom: Math.round(16 * s), left: 0, right: 0 }; + } + if (shell === 'teardrop') { + return { top: Math.round(24 * s), bottom: Math.round(12 * s), left: 0, right: 0 }; + } + if (shell === 'tablet' || shell === 'tabletCam') { + return { top: 24, bottom: 20, left: 0, right: 0 }; + } + return { top: 0, bottom: 0, left: 0, right: 0 }; + }; + + /** 把安全区注入引擎,供 cc.SafeArea / view.getSafeAreaEdge 使用 */ + DevicePreview.prototype._applySafeArea = function () { + var cc = global.cc; + if (!cc) return; + var d = this._find(); + var safe = this._safeOf(d); + if (this.rotated) { + // 横屏:上下安全区对应侧边 + safe = { + top: 0, + bottom: 0, + left: this._safeOf(d).bottom, + right: 0, + }; + // 保持 notch/island 在横屏左侧时的上/左边距 + var base = this._safeOf(d); + safe = { top: 0, bottom: 0, left: base.bottom || 0, right: base.top || 0 }; + } + this._safeEdge = safe; + + var applyEdge = function () { + return { + top: safe.top, + bottom: safe.bottom, + left: safe.left, + right: safe.right, + }; + }; + + try { + if (cc.view) { + cc.view.getSafeAreaEdge = applyEdge; + if (typeof cc.view.getSafeAreaRect === 'function') { + var origRect = cc.view.getSafeAreaRect; + cc.view.getSafeAreaRect = function () { + var vs = cc.view.getVisibleSize ? cc.view.getVisibleSize() : { width: 0, height: 0 }; + var origin = cc.view.getVisibleOrigin ? cc.view.getVisibleOrigin() : { x: 0, y: 0 }; + var e = applyEdge(); + // 逻辑点:与设计分辨率同一尺度(view 一般用设计/可见单位) + return { + x: origin.x + e.left, + y: origin.y + e.bottom, + width: Math.max(1, vs.width - e.left - e.right), + height: Math.max(1, vs.height - e.top - e.bottom), + contains: origRect ? undefined : undefined, + }; + }; + } + } + // 部分机型走 screen.safeAreaEdge + if (global.screen && global.screen.safeAreaEdge !== undefined) { + global.screen.safeAreaEdge = applyEdge(); + } + } catch (e) { /* */ } + + // 胶囊:贴齐安全区右上 + if (this._capsule) { + var capTop = Math.max(6, safe.top + 6); + this._capsule.style.top = capTop + 'px'; + this._capsule.style.right = '10px'; + } + }; + /** * @param {string} shell * @param {boolean} useFrame @@ -159,10 +382,16 @@ if (landscape) bezel.classList.add('is-landscape'); if (shell === 'notch' && !landscape) bezel.classList.add('has-cutout', 'has-notch'); if (shell === 'island' && !landscape) bezel.classList.add('has-cutout', 'has-island'); - if (shell === 'android' && !landscape) bezel.classList.add('has-cutout', 'has-punch'); + if ((shell === 'punch' || shell === 'android') && !landscape) { + bezel.classList.add('has-cutout', 'has-punch'); + } + if (shell === 'punchL' && !landscape) bezel.classList.add('has-cutout', 'has-punch', 'has-punch-l'); + if (shell === 'teardrop' && !landscape) bezel.classList.add('has-cutout', 'has-teardrop'); if (shell === 'classic') bezel.classList.add('has-home-btn'); - if (shell === 'notch' || shell === 'island') bezel.classList.add('has-home-bar'); - if (shell === 'tablet') bezel.classList.add('has-home-bar'); + if (shell === 'notch' || shell === 'island' || shell === 'tablet' || shell === 'tabletCam') { + bezel.classList.add('has-home-bar'); + } + if (shell === 'tabletCam' && !landscape) bezel.classList.add('has-tablet-cam'); }; /** @@ -272,6 +501,7 @@ this._bezel.style.transform = 'none'; this._fillScreen(screenW, screenH); + this._applySafeArea(); var key = this.deviceId + '|' + (this.rotated ? 1 : 0) + '|' + screenW + 'x' + screenH + '|' + stageW + 'x' + stageH; var changed = key !== this._lastKey; @@ -302,6 +532,7 @@ if (!w || !h) return; this._frameW = w; this._frameH = h; + this._applySafeArea(); var cc = global.cc; try { @@ -343,6 +574,17 @@ try { var id = localStorage.getItem('preview-workbench-device'); if (id) this.deviceId = id; + var capStyle = localStorage.getItem('preview-workbench-capsule-style'); + this._capsuleStyle = capStyle || 'douyin'; + // 默认显示抖音;仅样式为 none 时隐藏 + this._capsuleOn = this._capsuleStyle !== 'none'; + if (this._capsule) { + this._capsule.hidden = !this._capsuleOn; + this._capsule.setAttribute('data-style', this._capsuleStyle); + this._capsule.className = 'pw-device-capsule' + + (this._capsuleOn ? ' show' : '') + + ' cap-' + this._capsuleStyle; + } } catch (e) { /* */ } }; diff --git a/preview-template/workbench/js/host.js b/preview-template/workbench/js/host.js index b54c28d..f4ccc52 100644 --- a/preview-template/workbench/js/host.js +++ b/preview-template/workbench/js/host.js @@ -254,10 +254,11 @@ self._setPickHighlight(rect); }); b.on('perf.sample', function (data) { - if (self.panels.perf) self.panels.perf.applySample(data); + if (self.panels.perf && self.panels.perf.applySample) self.panels.perf.applySample(data); }); b.on('assets.update', function (data) { if (self.panels.assets) self.panels.assets.applyData(data); + if (self.panels.perf && self.panels.perf.applyAssets) self.panels.perf.applyAssets(data); }); b.on('console.batch', function (data) { if (self.panels.console) self.panels.console.applyBatch(data && data.items); @@ -300,6 +301,12 @@ this.bridge.unsubscribe(ch); } } + // 性能面板可见时启动函数采样剖析 + if (map.perf && this.bridge) { + this.bridge.call('prof.start').catch(function () { /* */ }); + } else if (this.bridge) { + this.bridge.call('prof.stop').catch(function () { /* */ }); + } var self = this; if (map.hierarchy) { this.bridge.call('hier.getTree').then(function (tree) { @@ -373,6 +380,36 @@ }); }); } + var capBtn = document.getElementById('pw-device-capsule-btn'); + if (capBtn) { + var syncCap = function (on) { + capBtn.classList.toggle('active', !!on); + capBtn.setAttribute('aria-pressed', on ? 'true' : 'false'); + }; + capBtn.addEventListener('click', function () { + if (!self.bridge) return; + self.bridge.call('device.getState').then(function (st) { + var next = !(st && st.capsule); + return self.bridge.call('device.setCapsule', { on: next }).then(function (on) { + syncCap(on); + }); + }).catch(function () { /* */ }); + }); + if (self.bridge) { + self.bridge.call('device.getState').then(function (st) { + syncCap(st && st.capsule); + }).catch(function () { /* */ }); + } + } + var capStyle = document.getElementById('pw-capsule-style'); + if (capStyle && self.bridge) { + capStyle.addEventListener('change', function () { + self.bridge.call('device.setCapsuleStyle', { style: capStyle.value }).catch(function () { /* */ }); + }); + self.bridge.call('device.getCapsuleStyle').then(function (s) { + if (s) capStyle.value = s; + }).catch(function () { /* */ }); + } var refreshBtn = document.getElementById('pw-game-refresh'); if (refreshBtn) { @@ -647,12 +684,20 @@ }; /** - * 选中 Gizmo:半透明框可拖;红/绿箭头沿 X/Y 轴拖。 - * 坐标来自 pick.getRect(含 pos / scale)。 + * 选中高亮: + * - 游戏模式:只保留游戏内绿框,不显示坐标轴、禁止拖动 + * - 检视模式:绿框 + 中心坐标轴;点击循环选中,拖动才移动 */ PreviewWorkbenchHost.prototype._setPickHighlight = function (rect) { var gizmo = document.getElementById('pw-pick-gizmo'); if (!gizmo) return; + + // 非检视:隐藏 Gizmo(绿框由游戏内 overlay 绘制) + if (!this._inspectOn) { + gizmo.hidden = true; + this._gizmoState = null; + return; + } if (!rect || !(rect.width > 0) || !(rect.height > 0)) { gizmo.hidden = true; this._gizmoState = null; @@ -681,19 +726,23 @@ }; }; - /** Gizmo 拖拽:xy / 仅 x / 仅 y */ + /** + * Gizmo:位移阈值内视为点击(循环选中),超过阈值才拖动改坐标。 + * 轴从节点中心画出。 + */ PreviewWorkbenchHost.prototype._bindGizmoDrag = function (gizmo) { var self = this; - var drag = null; + var pending = null; + var THRESHOLD = 4; var onDown = function (ev) { if (ev.button != null && ev.button !== 0) return; + if (!self._inspectOn) return; var axis = ev.currentTarget.getAttribute('data-drag') || 'xy'; var st = self._gizmoState; if (!st || !st.uuid) return; - ev.preventDefault(); - ev.stopPropagation(); - drag = { + // 先不拦截,留给点击;真拖动再 prevent + pending = { axis: axis, x0: ev.clientX, y0: ev.clientY, @@ -704,51 +753,90 @@ uuid: st.uuid, left0: parseFloat(gizmo.style.left) || 0, top0: parseFloat(gizmo.style.top) || 0, + dragging: false, }; document.addEventListener('pointermove', onMove); document.addEventListener('pointerup', onUp); - gizmo.classList.add('dragging'); }; var onMove = function (ev) { - if (!drag) return; + if (!pending) return; + var dx = ev.clientX - pending.x0; + var dy = ev.clientY - pending.y0; + if (!pending.dragging) { + if (Math.abs(dx) < THRESHOLD && Math.abs(dy) < THRESHOLD) return; + pending.dragging = true; + ev.preventDefault(); + ev.stopPropagation(); + gizmo.classList.add('dragging'); + } 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; + var nx = pending.px; + var ny = pending.py; + if (pending.axis !== 'y') nx = pending.px + dx * pending.sx; + if (pending.axis !== 'x') ny = pending.py - dy * pending.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 (pending.axis !== 'y') { + self.bridge.call('node.set', { uuid: pending.uuid, fid: 'px', value: nx }); } - if (drag.axis !== 'x') { - self.bridge.call('node.set', { uuid: drag.uuid, fid: 'py', value: ny }); + if (pending.axis !== 'x') { + self.bridge.call('node.set', { uuid: pending.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 (pending.axis !== 'y') gizmo.style.left = (pending.left0 + dx) + 'px'; + if (pending.axis !== 'x') gizmo.style.top = (pending.top0 + dy) + 'px'; if (self._gizmoState) { - self._gizmoState.pos = { x: nx, y: ny, z: (self._gizmoState.pos && self._gizmoState.pos.z) || 0 }; + 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'); + var onUp = function (ev) { document.removeEventListener('pointermove', onMove); document.removeEventListener('pointerup', onUp); + if (!pending) return; + var st = pending; + pending = null; + gizmo.classList.remove('dragging'); + + if (!st.dragging) { + // 视为点击 → 循环选中重叠节点 + var iframe = self._iframe; + var gameX = ev.clientX; + var gameY = ev.clientY; + if (iframe) { + var r = iframe.getBoundingClientRect(); + gameX = ev.clientX - r.left; + gameY = ev.clientY - r.top; + } + if (self.bridge) { + self.bridge.call('pick.at', { clientX: gameX, clientY: gameY, space: 'viewport' }) + .then(function (res) { + if (res && res.uuid) { + if (self.panels.inspector) self.panels.inspector.loadUuid(res.uuid); + if (self.panels.hierarchy) self.panels.hierarchy.setSelectedUuid(res.uuid); + } + return self.bridge.call('pick.getRect'); + }) + .then(function (rect) { + self._setPickHighlight(rect); + }) + .catch(function () { /* */ }); + } + return; + } + + // 真正拖动结束 → 刷新框与属性 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); + if (self.panels.inspector) self.panels.inspector.loadUuid(st.uuid); } }; diff --git a/preview-template/workbench/js/panels/assets.js b/preview-template/workbench/js/panels/assets.js index a1f304d..59ada5f 100644 --- a/preview-template/workbench/js/panels/assets.js +++ b/preview-template/workbench/js/panels/assets.js @@ -130,7 +130,8 @@ '' ); @@ -155,7 +156,11 @@ var bf = this._bundleFilter; var tf = this._typeFilter; rows = rows.filter(function (r) { - if (bf && r.bundle !== bf) return false; + if (bf) { + var inBundle = r.bundle === bf; + var used = r.usedBy && r.usedBy.indexOf(bf) >= 0; + if (!inBundle && !used) 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; @@ -203,6 +208,7 @@ var tag = ''; if (r.pack) tag += ' 图集'; if (r.type === 'Texture2D' && r.pack) tag += ' 合包'; + if (r.via === 'dep') tag += ' 依赖'; out.push( '
' + '' + escapeHtml(r.name) + tag + '' + diff --git a/preview-template/workbench/js/panels/perf.js b/preview-template/workbench/js/panels/perf.js index 19b2905..444a89f 100644 --- a/preview-template/workbench/js/panels/perf.js +++ b/preview-template/workbench/js/panels/perf.js @@ -2,17 +2,23 @@ 'use strict'; /** - * 性能面板(壳侧):采样在游戏 Agent,曲线只在壳 document 绘制。 + * 性能面板(壳侧): + * 1) 帧率 / 内存曲线 + * 2) 函数耗时 Top20 + * 3) 慢帧堆栈 Top10 + * 4) 内存资源 Top20(可复制名称 / UUID,可预览) * @constructor */ function PerfPanel() { this.el = null; - this.charts = {}; this._bridge = null; + this._chartFps = null; + this._chartMem = null; this._data = null; - this._renderTimer = 0; - this._dirty = false; this._paused = false; + this._profiling = true; + this._pollTimer = 0; + this._assetRows = []; } PerfPanel.prototype.setBridge = function (bridge) { @@ -22,189 +28,273 @@ PerfPanel.prototype.mount = function () { var self = this; var root = document.createElement('div'); - root.className = 'pw-panel-inner'; + root.className = 'pw-panel-inner pw-perf'; root.style.height = '100%'; root.style.display = 'flex'; root.style.flexDirection = 'column'; root.innerHTML = - '
' + - '性能' + - '
' + + '
' + + '' + '' + '' + '' + - '' + + '' + '' + - '' + - '' + - '
' + - '
' + - '
' + - '
' + - '
' + + '
' + + '
' + + '
' + + '

帧率 FPS

' + + '

总内存 (MB)

' + + '
' + + '
' + + '
' + + '

函数耗时 Top20

' + + '
' + + '
' + + '
' + + '

慢帧堆栈 Top10

' + + '
' + + '
' + + '
' + + '
' + + '

内存资源 Top20

' + + '
' + + '
' + '
'; this.el = root; - var defs = [ - { key: 'fps', title: '帧率 FPS', color: '#89d185' }, - { key: 'frame', title: '帧耗时 (ms)', color: '#3794ff' }, - { key: 'logic', title: '逻辑 (ms)', color: '#4ec9b0' }, - { key: 'render', title: '渲染 (ms)', color: '#f0a020' }, - { key: 'present', title: '呈现 (ms)', color: '#c586c0' }, - { key: 'draws', title: 'DrawCall', color: '#dcdcaa' }, - { key: 'tricount', title: '三角面', color: '#9cdcfe' }, - { key: 'textureMemory', title: '纹理显存 (MB)', color: '#f14c4c' }, - { key: 'bufferMemory', title: '缓冲显存 (MB)', color: '#ce9178' }, - { key: 'jsHeap', title: 'JS 堆 (MB)', color: '#b5cea8' }, - { key: 'assetCount', title: '资源数量', color: '#569cd6' }, - ]; - var chartsEl = root.querySelector('#pw-perf-charts'); - for (var i = 0; i < defs.length; i++) { - var d = defs[i]; - var card = document.createElement('div'); - card.className = 'pw-chart-card'; - card.innerHTML = '

' + d.title + '

'; - chartsEl.appendChild(card); - var canvas = card.querySelector('canvas'); - var chart = new global.PWRingChart(canvas); - chart.color = d.color; - chart.fill = hexToRgba(d.color, 0.15); - this.charts[d.key] = chart; - } + this._chartFps = new global.PWRingChart(root.querySelector('#pw-ch-fps')); + this._chartFps.color = '#3dd68c'; + this._chartFps.fill = 'rgba(61,218,140,0.12)'; + this._chartMem = new global.PWRingChart(root.querySelector('#pw-ch-mem')); + this._chartMem.color = '#5b9fd4'; + this._chartMem.fill = 'rgba(91,159,212,0.12)'; - root.querySelector('#pw-perf-pause').addEventListener('click', function (ev) { + root.querySelector('#pw-perf-pause').addEventListener('click', function () { self._paused = !self._paused; - if (self._bridge) self._bridge.call('perf.setPaused', { paused: self._paused }); - ev.currentTarget.textContent = self._paused ? '继续' : '暂停'; - ev.currentTarget.classList.toggle('active', self._paused); + this.textContent = self._paused ? '继续' : '暂停'; + this.classList.toggle('active', self._paused); }); root.querySelector('#pw-perf-clear').addEventListener('click', function () { - if (self._bridge) self._bridge.call('perf.clear'); - self._data = null; - self.render(); + if (!self._bridge) return; + self._bridge.call('perf.clear'); + self._bridge.call('prof.clear'); + self._fnCache = []; + self._slowCache = []; + self._renderReports({ functions: [], slowFrames: [] }); }); root.querySelector('#pw-perf-mark').addEventListener('click', function () { - var text = root.querySelector('#pw-perf-mark-text').value; - if (self._bridge) self._bridge.call('perf.mark', { text: text }); + if (!self._bridge) return; + var t = root.querySelector('#pw-perf-mark-text'); + self._bridge.call('perf.mark', { text: (t && t.value) || '' }); + if (t) t.value = ''; }); root.querySelector('#pw-perf-csv').addEventListener('click', function () { - var csv = self.toCsv(); - var blob = new Blob([csv], { type: 'text/csv;charset=utf-8' }); - var a = document.createElement('a'); - a.href = URL.createObjectURL(blob); - a.download = 'workbench-perf.csv'; - a.click(); - URL.revokeObjectURL(a.href); - }); - root.querySelector('#pw-perf-window').addEventListener('change', function (ev) { - if (self._bridge) self._bridge.call('perf.setCapacity', { n: parseInt(ev.target.value, 10) || 300 }); + if (!self._bridge) return; + self._bridge.call('perf.get').then(function (d) { + var csv = 't,fps,frame,jsHeap,totalMemory\n'; + if (d && d.times) { + for (var i = 0; i < d.times.length; i++) { + var s = d.series || {}; + csv += d.times[i] + ',' + + ((s.fps && s.fps[i]) || 0) + ',' + + ((s.frame && s.frame[i]) || 0) + ',' + + ((s.jsHeap && s.jsHeap[i]) || 0) + ',' + + ((s.textureMemory && s.textureMemory[i] || 0) + (s.bufferMemory && s.bufferMemory[i] || 0) + (s.jsHeap && s.jsHeap[i] || 0)) + '\n'; + } + } + var blob = new Blob([csv], { type: 'text/csv' }); + var a = document.createElement('a'); + a.href = URL.createObjectURL(blob); + a.download = 'perf.csv'; + a.click(); + }); }); - root.querySelector('#pw-perf-interval').addEventListener('change', function (ev) { - if (self._bridge) self._bridge.call('perf.setInterval', { ms: parseInt(ev.target.value, 10) || 500 }); + root.querySelector('#pw-perf-prof').addEventListener('click', function () { + self._profiling = !self._profiling; + this.classList.toggle('active', self._profiling); + this.textContent = self._profiling ? '剖析中' : '已停止'; + if (!self._bridge) return; + self._bridge.call(self._profiling ? 'prof.start' : 'prof.stop'); }); - document.addEventListener('keydown', function (ev) { - if (ev.altKey && (ev.key === 'm' || ev.key === 'M')) { - if (self._bridge) self._bridge.call('perf.mark', { text: '' }); - } - }); - - // 绘图与采样解耦:最多约 5fps 重绘 - this._renderTimer = setInterval(function () { - if (self._dirty) { - self._dirty = false; - self.render(); - } - }, 200); - + this._startPoll(); return root; }; - PerfPanel.prototype.applySample = function (data) { - this._data = data; - this._dirty = true; + PerfPanel.prototype._startPoll = function () { + var self = this; + if (this._pollTimer) clearInterval(this._pollTimer); + this._pollTimer = setInterval(function () { + if (self._paused || !self._bridge) return; + self._bridge.call('perf.get').then(function (d) { + self._data = d; + self._drawCurves(d); + }).catch(function () { /* */ }); + self._bridge.call('prof.report').then(function (r) { + self._renderReports(r); + }).catch(function () { /* */ }); + }, 500); }; - PerfPanel.prototype.render = function () { - if (!this.el) return; - var s = this._data; - var latest = (s && s.latest) || {}; - var sum = this.el.querySelector('#pw-perf-summary'); - if (sum) { - var fpsArr = (s && s.series && s.series.fps) || []; - var avg = average(fpsArr); - var min = minOf(fpsArr); - var max = maxOf(fpsArr); - var low = 0; - for (var i = 0; i < fpsArr.length; i++) if (fpsArr[i] < 30) low++; - var lowPct = fpsArr.length ? Math.round((low / fpsArr.length) * 100) : 0; - sum.innerHTML = - '帧率 ' + Math.round(latest.fps || 0) + ' 均 ' + Math.round(avg) + ' 最低 ' + Math.round(min) + ' 最高 ' + Math.round(max) + '' + - '<30帧占比 ' + lowPct + '%' + - 'DrawCall ' + Math.round(latest.draws || 0) + '' + - '帧耗时 ' + (Math.round((latest.frame || 0) * 10) / 10) + 'ms' + - '纹理 ' + (Math.round((latest.textureMemory || 0) * 100) / 100) + 'MB' + - '缓冲 ' + (Math.round((latest.bufferMemory || 0) * 100) / 100) + 'MB' + - '堆内存 ' + (Math.round((latest.jsHeap || 0) * 100) / 100) + 'MB'; + PerfPanel.prototype._drawCurves = function (d) { + if (!d) return; + var fpsArr = (d.series && d.series.fps) || []; + var memArr = []; + var ts = d.times || []; + var s = d.series || {}; + for (var i = 0; i < ts.length; i++) { + var t = (s.textureMemory && s.textureMemory[i]) || 0; + var b = (s.bufferMemory && s.bufferMemory[i]) || 0; + var h = (s.jsHeap && s.jsHeap[i]) || 0; + memArr.push(Math.round((t + b + h) * 10) / 10); } - var markEl = this.el.querySelector('#pw-perf-markers'); - if (markEl) { - var marks = (s && s.markers) ? s.markers.slice(-8) : []; - markEl.textContent = marks.length - ? ('标记:' + marks.map(function (m) { return m.label; }).join(' | ')) - : '标记:(自动 scene/bundle + Alt+M 手动)'; + if (this._chartFps) this._chartFps.draw(fpsArr, [], ts); + if (this._chartMem) this._chartMem.draw(memArr, [], ts); + }; + + PerfPanel.prototype._renderReports = function (r) { + if (!this.el || !r) return; + var fnEl = this.el.querySelector('#pw-fn-list'); + var slowEl = this.el.querySelector('#pw-slow-list'); + var fnC = this.el.querySelector('#pw-fn-count'); + var slowC = this.el.querySelector('#pw-slow-count'); + + var fns = r.functions || []; + if (fnC) fnC.textContent = fns.length ? (fns.length + ' 项 · 单位 ms') : ''; + if (fnEl) { + if (!fns.length) { + fnEl.innerHTML = '
点击「剖析中」开始采样(约 8ms 一帧栈)
'; + } else { + var html = ['
函数次数估算ms
']; + for (var i = 0; i < fns.length; i++) { + html.push( + '
' + + '' + escapeHtml(shorten(fns[i].name, 42)) + '' + + '' + fns[i].count + '' + + '' + fns[i].ms + '' + + '
' + ); + } + fnEl.innerHTML = html.join(''); + } } - if (!s || !s.series) return; - for (var key in this.charts) { - if (!Object.prototype.hasOwnProperty.call(this.charts, key)) continue; - this.charts[key].draw(s.series[key] || [], s.markers || [], s.times || []); + + var slows = r.slowFrames || []; + if (slowC) slowC.textContent = slows.length ? (slows.length + ' 次 >20ms') : ''; + if (slowEl) { + if (!slows.length) { + slowEl.innerHTML = '
暂无慢帧(阈值 20ms)
'; + } else { + var sh = ['
耗时堆栈
']; + for (var j = 0; j < slows.length; j++) { + var st = escapeHtml(String(slows[j].stack || '').split('\n').slice(0, 3).join(' | ')); + sh.push( + '
' + + '' + slows[j].ms + 'ms' + + '' + st + '' + + '
' + ); + } + slowEl.innerHTML = sh.join(''); + } } }; - PerfPanel.prototype.toCsv = function () { - var s = this._data; - if (!s || !s.series) return ''; - var keys = Object.keys(s.series); - var lines = ['t,' + keys.join(',')]; - for (var i = 0; i < (s.times || []).length; i++) { - var row = [s.times[i]]; - for (var k = 0; k < keys.length; k++) row.push(s.series[keys[k]][i]); - lines.push(row.join(',')); + /** 内存资源 Top20 */ + PerfPanel.prototype.applyAssets = function (data) { + this._assetRows = (data && data.rows) || []; + this._renderAssets(); + }; + + PerfPanel.prototype._renderAssets = function () { + var el = this.el && this.el.querySelector('#pw-res-list'); + var cnt = this.el && this.el.querySelector('#pw-res-count'); + if (!el) return; + var rows = this._assetRows.slice(); + rows.sort(function (a, b) { return (b.mem || 0) - (a.mem || 0); }); + rows = rows.slice(0, 20); + if (cnt) cnt.textContent = rows.length + ' 项'; + if (!rows.length) { + el.innerHTML = '
打开「资源」面板或稍候自动加载
'; + return; + } + var html = ['
名称 / UUID类型内存操作
']; + for (var i = 0; i < rows.length; i++) { + var r = rows[i]; + html.push( + '
' + + '' + escapeHtml(r.name) + '
' + escapeHtml(r.uuid) + '
' + + '' + escapeHtml(r.type) + '' + + '' + fmtMB(r.mem) + '' + + '' + + '' + + '' + + '' + + '
' + ); } - return lines.join('\n'); + el.innerHTML = html.join(''); + + var self = this; + el.addEventListener('click', function (ev) { + var btn = ev.target.closest ? ev.target.closest('button') : null; + if (!btn) return; + var item = btn.closest('.pw-res-item'); + if (!item) return; + var uuid = item.getAttribute('data-uuid') || ''; + var name = (item.querySelector('b') && item.querySelector('b').textContent) || ''; + if (btn.getAttribute('data-copy') === 'name') copyText(name); + else if (btn.getAttribute('data-copy') === 'uuid') copyText(uuid); + else if (btn.getAttribute('data-preview') && self._bridge) { + self._bridge.call('assets.preview', { uuid: uuid }).then(function (p) { + var box = document.getElementById('pw-asset-preview'); + if (!box) return; + if (p && p.kind === 'image' && p.url) { + box.innerHTML = ''; + } else if (p && p.kind === 'url' && p.url) { + box.innerHTML = ''; + } else { + box.innerHTML = '无预览'; + } + }).catch(function () { /* */ }); + } + }); }; - function average(arr) { - if (!arr.length) return 0; - var t = 0; - for (var i = 0; i < arr.length; i++) t += arr[i]; - return t / arr.length; + function fmtMB(mb) { + var n = Number(mb) || 0; + if (n <= 0) return '0'; + if (n < 10) return (Math.round(n * 100) / 100) + 'MB'; + return (Math.round(n * 10) / 10) + 'MB'; + } + + function shorten(s, n) { + s = String(s || ''); + return s.length > n ? s.slice(0, n - 1) + '…' : s; } - function minOf(arr) { - if (!arr.length) return 0; - var m = arr[0]; - for (var i = 1; i < arr.length; i++) if (arr[i] < m) m = arr[i]; - return m; + + function escapeHtml(s) { + return String(s == null ? '' : s) + .replace(/&/g, '&').replace(//g, '>'); } - function maxOf(arr) { - if (!arr.length) return 0; - var m = arr[0]; - for (var i = 1; i < arr.length; i++) if (arr[i] > m) m = arr[i]; - return m; + function escapeAttr(s) { + return escapeHtml(s).replace(/"/g, '"'); } - function hexToRgba(hex, a) { - var h = hex.replace('#', ''); - var r = parseInt(h.slice(0, 2), 16); - var g = parseInt(h.slice(2, 4), 16); - var b = parseInt(h.slice(4, 6), 16); - return 'rgba(' + r + ',' + g + ',' + b + ',' + a + ')'; + + function copyText(text) { + try { + if (navigator.clipboard && navigator.clipboard.writeText) { + navigator.clipboard.writeText(String(text)); + return; + } + } catch (e) { /* */ } + var ta = document.createElement('textarea'); + ta.value = String(text); + document.body.appendChild(ta); + ta.select(); + try { document.execCommand('copy'); } catch (e2) { /* */ } + document.body.removeChild(ta); } global.PWPerfPanel = PerfPanel; diff --git a/配置表/excelList/技能表.xlsx b/配置表/excelList/技能表.xlsx index 15c55e7..109bcfe 100644 Binary files a/配置表/excelList/技能表.xlsx and b/配置表/excelList/技能表.xlsx differ