实现boss技能

master
humingxing 3 days ago
parent f0f9cf7499
commit 4087d18989
  1. 4
      assets/bundles/battleGameMonster/prefab/monster/Boss土.prefab
  2. 2
      assets/bundles/battleGameMonster/prefab/monster/Boss土.prefab.meta
  3. 4
      assets/bundles/battleGameMonster/prefab/monster/Boss木.prefab
  4. 2
      assets/bundles/battleGameMonster/prefab/monster/Boss木.prefab.meta
  5. 4
      assets/bundles/battleGameMonster/prefab/monster/Boss水.prefab
  6. 2
      assets/bundles/battleGameMonster/prefab/monster/Boss水.prefab.meta
  7. 4
      assets/bundles/battleGameMonster/prefab/monster/Boss火.prefab
  8. 2
      assets/bundles/battleGameMonster/prefab/monster/Boss火.prefab.meta
  9. 4
      assets/bundles/battleGameMonster/prefab/monster/Boss金.prefab
  10. 2
      assets/bundles/battleGameMonster/prefab/monster/Boss金.prefab.meta
  11. 10
      assets/bundles/first/config/Skill.json
  12. 7
      assets/mx/module/battle/combat/AbilitySystem.ts
  13. 34
      assets/mx/module/battle/combat/TargetResolver.ts
  14. 120
      assets/script/battle/combat/MonsterSkillHost.ts
  15. 93
      assets/script/battle/combat/PassiveSkillRuntime.ts
  16. 25
      assets/script/battle/config/BattleHeroTable.ts
  17. 6
      assets/script/battle/config/TableBattleConfigProvider.ts
  18. 12
      assets/script/battle/config/baseAtk/SkillAbilityBuilder.ts
  19. 16
      assets/script/battle/skill/SkillController.ts
  20. 47
      assets/script/battle/view/CocosBattleCombatPresenter.ts
  21. 63
      assets/script/battle/view/CocosBattleMonsterPresenter.ts
  22. 63
      assets/script/battle/view/TextGrid.ts
  23. 52
      assets/script/manager/MonsterDataManager.ts
  24. 9
      assets/script/ui/BattleBoss/UIBattleBoss.ts
  25. 3
      assets/script/ui/BattleGame/BattleGameExtraBossSkill.ts
  26. 10
      docs/方案-出怪逻辑实现.md
  27. 17
      docs/方案-怪物技能.md
  28. 2
      docs/方案-战斗技能逻辑层.md
  29. 12
      preview-template/index.ejs
  30. 482
      preview-template/workbench/css/workbench.css
  31. 646
      preview-template/workbench/js/agent-assets.js
  32. 12
      preview-template/workbench/js/agent-core.js
  33. 227
      preview-template/workbench/js/agent-profiler.js
  34. 304
      preview-template/workbench/js/device.js
  35. 148
      preview-template/workbench/js/host.js
  36. 10
      preview-template/workbench/js/panels/assets.js
  37. 388
      preview-template/workbench/js/panels/perf.js
  38. BIN
      配置表/excelList/技能表.xlsx

@ -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,

@ -8,6 +8,6 @@
],
"subMetas": {},
"userData": {
"syncNodeName": "Boss土"
"syncNodeName": "BOSS土"
}
}

@ -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,

@ -8,6 +8,6 @@
],
"subMetas": {},
"userData": {
"syncNodeName": "Boss木"
"syncNodeName": "BOSS木"
}
}

@ -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,

@ -8,6 +8,6 @@
],
"subMetas": {},
"userData": {
"syncNodeName": "Boss水"
"syncNodeName": "BOSS水"
}
}

@ -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,

@ -8,6 +8,6 @@
],
"subMetas": {},
"userData": {
"syncNodeName": "Boss火"
"syncNodeName": "BOSS火"
}
}

@ -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,

@ -8,6 +8,6 @@
],
"subMetas": {},
"userData": {
"syncNodeName": "Boss金"
"syncNodeName": "BOSS金"
}
}

@ -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,

@ -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);

@ -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;

@ -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<ITableSkill>(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<ITableSkill>(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 施法者

@ -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<ITableSkill>(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<ITableSkill>(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<ITableSkill>(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 在飞剑气

@ -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);
}
/**

@ -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,
},

@ -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 } }];
}

@ -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<BattleEvent, { type: BattleEventType.PresentationCue }>): void {
const session = this._match?.session;
if (!session) return;

@ -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;
}
/**

@ -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 → 预制体文件名 */

@ -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();

@ -35,13 +35,18 @@ const ELEMENT_MARCH_BY_ICON: Readonly<Record<string, string>> = {
小怪土: '土小怪',
};
/** 新 Boss 预制体 icon(仅 default 皮,行进=走路) */
/** 新 Boss 预制体 icon(仅 default 皮,行进=走路;兼容表写 BOSS金 / 资源 Boss金) */
const NEW_BOSS_ICONS: Readonly<Record<string, boolean>> = {
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 '';
}
/**

@ -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);

@ -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;

@ -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)

@ -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 接 |

@ -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`。

@ -50,6 +50,17 @@
<button type="button" id="pw-device-rotate" class="pw-btn pw-btn-icon" title="旋转" aria-label="旋转">
<svg class="pw-icon" viewBox="0 0 16 16" aria-hidden="true"><path d="M8 2a6 6 0 1 0 5.7 4.2" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/><path d="M13 2v4h-4" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/></svg>
</button>
<button type="button" id="pw-device-capsule-btn" class="pw-btn" title="开启/关闭右上角小程序胶囊按钮" aria-pressed="false">胶囊</button>
<select id="pw-capsule-style" class="pw-select" title="小程序胶囊样式(含官方菜单)">
<option value="none">无</option>
<option value="douyin" selected>抖音</option>
<option value="wechat">微信·浅</option>
<option value="wechat-dark">微信·深</option>
<option value="alipay">支付宝</option>
<option value="qq">QQ</option>
<option value="kuaishou">快手</option>
<option value="bilibili">B站</option>
</select>
<button type="button" id="pw-game-refresh" class="pw-btn pw-btn-icon" title="刷新并重启游戏" aria-label="刷新">
<svg class="pw-icon" viewBox="0 0 16 16" aria-hidden="true"><path d="M13.5 8a5.5 5.5 0 1 1-1.6-3.9" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/><path d="M13.5 2.5v3.2h-3.2" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/></svg>
</button>
@ -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;

@ -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 键 */

@ -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);

@ -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);

@ -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);

@ -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 @@
'<div class="pw-device-btn pw-device-btn-voldown" aria-hidden="true"></div>' +
'<div class="pw-device-btn pw-device-btn-power" aria-hidden="true"></div>' +
'<div class="pw-device-ear" aria-hidden="true"></div>' +
'<div class="pw-device-screen" id="pw-device-screen">' +
'<div class="pw-device-cutout" id="pw-device-cutout" aria-hidden="true"></div>' +
'<div class="pw-device-screen" id="pw-device-screen"></div>' +
'<div class="pw-device-capsule" id="pw-device-capsule" data-style="wechat" hidden>' +
'<span class="pw-capsule-home"></span><span class="pw-capsule-line"></span>' +
'<span class="pw-capsule-menu"></span>' +
'</div>' +
'</div>' +
'<div class="pw-device-home-bar" id="pw-device-home-bar" aria-hidden="true"></div>' +
'<div class="pw-device-home-btn" id="pw-device-home-btn" aria-hidden="true"></div>';
@ -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) { /* */ }
};

@ -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);
}
};

@ -130,7 +130,8 @@
'<button type="button" class="pw-bundle-item' + act + ' ' + stCls + '" data-bundle="' + escapeAttr(b.name) + '">' +
'<div class="pw-bundle-name">' + escapeHtml(b.name) +
'<span class="pw-bundle-tag">' + stText + '</span></div>' +
'<div class="pw-bundle-sub">' + (b.assetCount || 0) + ' 项 · ' + fmtMB(mem) + '</div>' +
'<div class="pw-bundle-sub">' + (b.assetCount || 0) + ' 项 · ' + fmtMB(mem) +
(b.priority ? ' · P' + b.priority : '') + '</div>' +
'<div class="pw-bundle-bar"><i style="width:' + pct + '%"></i></div>' +
'</button>'
);
@ -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 += ' <span class="pw-assets-tag">图集</span>';
if (r.type === 'Texture2D' && r.pack) tag += ' <span class="pw-assets-tag">合包</span>';
if (r.via === 'dep') tag += ' <span class="pw-assets-tag pw-assets-tag-dep">依赖</span>';
out.push(
'<div class="pw-assets-row' + sel + '" data-uuid="' + escapeAttr(r.uuid) + '" title="' + escapeAttr((r.path || r.type) + ' · ' + r.bundle) + '">' +
'<span class="pw-assets-name">' + escapeHtml(r.name) + tag + '</span>' +

@ -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 =
'<div class="pw-panel-head">' +
'<span class="pw-panel-title">性能</span>' +
'<div class="pw-panel-tools">' +
'<div class="pw-panel-toolbar">' +
'<button type="button" id="pw-perf-prof" class="pw-btn active">剖析中</button>' +
'<button type="button" id="pw-perf-pause" class="pw-btn">暂停</button>' +
'<button type="button" id="pw-perf-clear" class="pw-btn">清空</button>' +
'<button type="button" id="pw-perf-mark" class="pw-btn">打标记</button>' +
'<input id="pw-perf-mark-text" class="pw-input" placeholder="标记" style="width:80px" />' +
'<input id="pw-perf-mark-text" class="pw-input" placeholder="标记" style="width:72px" />' +
'<button type="button" id="pw-perf-csv" class="pw-btn">导出CSV</button>' +
'<select id="pw-perf-window" class="pw-select">' +
'<option value="300">窗口 60秒</option>' +
'<option value="1500">窗口 5分钟</option>' +
'</select>' +
'<select id="pw-perf-interval" class="pw-select">' +
'<option value="200">采样 200ms</option>' +
'<option value="500" selected>采样 500ms</option>' +
'<option value="1000">采样 1s</option>' +
'</select>' +
'</div></div>' +
'<div class="pw-panel-body">' +
'<div id="pw-perf-summary" class="pw-summary"></div>' +
'<div id="pw-perf-markers" class="pw-muted" style="margin-bottom:6px;max-height:36px;overflow:auto"></div>' +
'<div class="pw-charts" id="pw-perf-charts"></div>' +
'</div>' +
'<div class="pw-perf-body">' +
'<div class="pw-perf-row pw-perf-charts">' +
'<div class="pw-chart-card"><h4>帧率 FPS</h4><canvas id="pw-ch-fps"></canvas></div>' +
'<div class="pw-chart-card"><h4>总内存 (MB)</h4><canvas id="pw-ch-mem"></canvas></div>' +
'</div>' +
'<div class="pw-perf-row">' +
'<div class="pw-perf-block">' +
'<h4>函数耗时 Top20 <span class="pw-muted" id="pw-fn-count"></span></h4>' +
'<div class="pw-perf-list" id="pw-fn-list"></div>' +
'</div>' +
'<div class="pw-perf-block">' +
'<h4>慢帧堆栈 Top10 <span class="pw-muted" id="pw-slow-count"></span></h4>' +
'<div class="pw-perf-list" id="pw-slow-list"></div>' +
'</div>' +
'</div>' +
'<div class="pw-perf-block">' +
'<h4>内存资源 Top20 <span class="pw-muted" id="pw-res-count"></span></h4>' +
'<div class="pw-perf-list pw-res-list" id="pw-res-list"></div>' +
'</div>' +
'</div>';
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 = '<h4>' + d.title + '</h4><canvas data-key="' + d.key + '"></canvas>';
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 =
'<span>帧率 <b>' + Math.round(latest.fps || 0) + '</b> 均 ' + Math.round(avg) + ' 最低 ' + Math.round(min) + ' 最高 ' + Math.round(max) + '</span>' +
'<span>&lt;30帧占比 <b>' + lowPct + '%</b></span>' +
'<span>DrawCall <b>' + Math.round(latest.draws || 0) + '</b></span>' +
'<span>帧耗时 <b>' + (Math.round((latest.frame || 0) * 10) / 10) + 'ms</b></span>' +
'<span>纹理 <b>' + (Math.round((latest.textureMemory || 0) * 100) / 100) + 'MB</b></span>' +
'<span>缓冲 <b>' + (Math.round((latest.bufferMemory || 0) * 100) / 100) + 'MB</b></span>' +
'<span>堆内存 <b>' + (Math.round((latest.jsHeap || 0) * 100) / 100) + 'MB</b></span>';
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 = '<div class="pw-muted" style="padding:12px">点击「剖析中」开始采样(约 8ms 一帧栈)</div>';
} else {
var html = ['<div class="pw-perf-rowh"><span>函数</span><span>次数</span><span>估算ms</span></div>'];
for (var i = 0; i < fns.length; i++) {
html.push(
'<div class="pw-perf-item">' +
'<span class="pw-perf-name" title="' + escapeAttr(fns[i].name) + '">' + escapeHtml(shorten(fns[i].name, 42)) + '</span>' +
'<span class="pw-perf-num">' + fns[i].count + '</span>' +
'<span class="pw-perf-num">' + fns[i].ms + '</span>' +
'</div>'
);
}
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 = '<div class="pw-muted" style="padding:12px">暂无慢帧(阈值 20ms)</div>';
} else {
var sh = ['<div class="pw-perf-rowh"><span>耗时</span><span>堆栈</span></div>'];
for (var j = 0; j < slows.length; j++) {
var st = escapeHtml(String(slows[j].stack || '').split('\n').slice(0, 3).join(' | '));
sh.push(
'<div class="pw-perf-item pw-slow-item">' +
'<span class="pw-perf-num pw-bad">' + slows[j].ms + 'ms</span>' +
'<span class="pw-perf-stack" title="' + escapeAttr(slows[j].stack || '') + '">' + st + '</span>' +
'</div>'
);
}
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 = '<div class="pw-muted" style="padding:12px">打开「资源」面板或稍候自动加载</div>';
return;
}
var html = ['<div class="pw-perf-rowh"><span>名称 / UUID</span><span>类型</span><span>内存</span><span>操作</span></div>'];
for (var i = 0; i < rows.length; i++) {
var r = rows[i];
html.push(
'<div class="pw-perf-item pw-res-item" data-uuid="' + escapeAttr(r.uuid) + '">' +
'<span class="pw-perf-name"><b>' + escapeHtml(r.name) + '</b><br><i class="pw-muted">' + escapeHtml(r.uuid) + '</i></span>' +
'<span class="pw-perf-type">' + escapeHtml(r.type) + '</span>' +
'<span class="pw-perf-num">' + fmtMB(r.mem) + '</span>' +
'<span class="pw-res-ops">' +
'<button type="button" class="pw-btn pw-btn-mini" data-copy="name">复制名</button>' +
'<button type="button" class="pw-btn pw-btn-mini" data-copy="uuid">复制UUID</button>' +
'<button type="button" class="pw-btn pw-btn-mini" data-preview="1">预览</button>' +
'</span></div>'
);
}
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 = '<img alt="" src="' + p.url + '" />';
} else if (p && p.kind === 'url' && p.url) {
box.innerHTML = '<img alt="" src="' + p.url + '" />';
} else {
box.innerHTML = '<span class="pw-muted">无预览</span>';
}
}).catch(function () { /* */ });
}
});
};
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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
}
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, '&quot;');
}
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;

Loading…
Cancel
Save