feat: 提交资源
This commit is contained in:
9
extensions/max-studio/assets/max-studio.meta
Normal file
9
extensions/max-studio/assets/max-studio.meta
Normal file
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"ver": "1.2.0",
|
||||
"importer": "directory",
|
||||
"imported": true,
|
||||
"uuid": "2b36711d-b919-4ff7-8e47-c108085a99c9",
|
||||
"files": [],
|
||||
"subMetas": {},
|
||||
"userData": {}
|
||||
}
|
||||
13
extensions/max-studio/assets/max-studio/core.meta
Normal file
13
extensions/max-studio/assets/max-studio/core.meta
Normal file
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"ver": "1.2.0",
|
||||
"importer": "directory",
|
||||
"imported": true,
|
||||
"uuid": "e95a5a7d-79a7-4056-97d7-5c000bf3caa9",
|
||||
"files": [],
|
||||
"subMetas": {},
|
||||
"userData": {
|
||||
"isBundle": true,
|
||||
"bundleName": "max-core",
|
||||
"priority": 7
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import { Component, director, js, Node } from "cc";
|
||||
|
||||
export default class NodeSingleton extends Component {
|
||||
private static readonly _instances: Map<
|
||||
new () => NodeSingleton,
|
||||
NodeSingleton
|
||||
> = new Map();
|
||||
public static getInstance<T extends NodeSingleton>(this: new () => T): T {
|
||||
if (!NodeSingleton._instances.has(this)) {
|
||||
let instance: T | null = null;
|
||||
const scene = director.getScene();
|
||||
if (scene) {
|
||||
instance = scene.getComponentInChildren(this);
|
||||
}
|
||||
if (!instance) {
|
||||
const node = new Node(js.getClassName(this));
|
||||
instance = node.addComponent(this);
|
||||
scene.addChild(node);
|
||||
director.addPersistRootNode(node);
|
||||
}
|
||||
NodeSingleton._instances.set(this, instance);
|
||||
}
|
||||
return NodeSingleton._instances.get(this) as T;
|
||||
}
|
||||
|
||||
public static clearInstance<T extends NodeSingleton>(
|
||||
this: new () => T,
|
||||
): void {
|
||||
NodeSingleton._instances.delete(this);
|
||||
}
|
||||
|
||||
public static clearAll(): void {
|
||||
this._instances.clear();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"ver": "4.0.24",
|
||||
"importer": "typescript",
|
||||
"imported": true,
|
||||
"uuid": "c33f6c95-6164-4b52-9252-0881713f7372",
|
||||
"files": [],
|
||||
"subMetas": {},
|
||||
"userData": {}
|
||||
}
|
||||
212
extensions/max-studio/assets/max-studio/core/Singleton.ts
Normal file
212
extensions/max-studio/assets/max-studio/core/Singleton.ts
Normal file
@@ -0,0 +1,212 @@
|
||||
import { js } from "cc";
|
||||
import LogUtils from "./utils/LogUtils";
|
||||
import TimeUtils from "./utils/TimeUtils";
|
||||
import { EDITOR } from "cc/env";
|
||||
// 移除这行导入,避免循环引用
|
||||
// import { EventManager, GameEventMessage } from "./event/EventManager";
|
||||
|
||||
/**
|
||||
* 单例装饰器配置接口
|
||||
*/
|
||||
export interface SingletonDecoratorOptions {
|
||||
/** 是否自动实例化 */
|
||||
auto?: boolean;
|
||||
/** 分类标签 */
|
||||
category?: string;
|
||||
/** 描述信息 */
|
||||
description?: string;
|
||||
/** 初始化优先级(数字越小优先级越高) */
|
||||
priority?: number;
|
||||
}
|
||||
|
||||
export abstract class Singleton {
|
||||
public isInit: boolean = false;
|
||||
|
||||
private static readonly _instances: Map<new () => Singleton, Singleton> = new Map();
|
||||
|
||||
public constructor() {
|
||||
const constructor = this.constructor as new () => Singleton;
|
||||
if (Singleton._instances.has(constructor)) {
|
||||
throw new Error("请使用getInstance()获取实例,不要直接实例化!");
|
||||
}
|
||||
}
|
||||
|
||||
public static getInstance<T extends Singleton>(this: new () => T): T {
|
||||
if (!Singleton._instances.has(this)) {
|
||||
const instance = new this();
|
||||
Singleton._instances.set(this, instance);
|
||||
instance
|
||||
.onInit()
|
||||
.then(() => {
|
||||
instance.isInit = true;
|
||||
})
|
||||
.catch((err) => {
|
||||
LogUtils.error("Singleton", `初始化失败[${js.getClassName(this)}]:`, err);
|
||||
});
|
||||
}
|
||||
return Singleton._instances.get(this) as T;
|
||||
}
|
||||
|
||||
public static clearInstance<T extends Singleton>(this: new () => T): void {
|
||||
const instance = Singleton._instances.get(this);
|
||||
if (instance) {
|
||||
instance.onRelease();
|
||||
}
|
||||
Singleton._instances.delete(this);
|
||||
}
|
||||
|
||||
public static clearAll(): void {
|
||||
Singleton._instances.clear();
|
||||
}
|
||||
|
||||
protected async onInit() {}
|
||||
|
||||
protected onRelease() {}
|
||||
}
|
||||
|
||||
/**
|
||||
* 单例注册表,管理所有装饰的单例类
|
||||
*/
|
||||
export class SingletonRegistry {
|
||||
private static readonly _registeredClasses: Map<new () => Singleton, SingletonDecoratorOptions> = new Map();
|
||||
private static readonly _autoInstanceClasses: Array<{
|
||||
cls: new () => Singleton;
|
||||
priority: number;
|
||||
options: SingletonDecoratorOptions;
|
||||
}> = [];
|
||||
private static _isInitialized: boolean = false;
|
||||
|
||||
/**
|
||||
* 注册单例类
|
||||
*/
|
||||
public static register(cls: new () => Singleton, options: SingletonDecoratorOptions = {}): void {
|
||||
const finalOptions: SingletonDecoratorOptions = {
|
||||
auto: false,
|
||||
priority: 100,
|
||||
...options,
|
||||
};
|
||||
|
||||
this._registeredClasses.set(cls, finalOptions);
|
||||
|
||||
if (finalOptions.auto) {
|
||||
this._autoInstanceClasses.push({
|
||||
cls,
|
||||
priority: finalOptions.priority || 100,
|
||||
options: finalOptions,
|
||||
});
|
||||
|
||||
// 按优先级排序
|
||||
this._autoInstanceClasses.sort((a, b) => a.priority - b.priority);
|
||||
}
|
||||
|
||||
LogUtils.debug("SingletonRegistry", `注册单例类: ${js.getClassName(cls)}, 自动实例化: ${finalOptions.auto}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* 初始化所有需要自动实例化的单例
|
||||
*/
|
||||
public static async initializeAutoInstances(): Promise<void> {
|
||||
if (this._isInitialized) {
|
||||
LogUtils.warn("SingletonRegistry", "自动实例化已经执行过,跳过重复执行");
|
||||
return;
|
||||
}
|
||||
|
||||
LogUtils.info("SingletonRegistry", `开始自动实例化 ${this._autoInstanceClasses.length} 个单例类`);
|
||||
|
||||
const promises = this._autoInstanceClasses.map(async (item) => {
|
||||
try {
|
||||
const className = js.getClassName(item.cls);
|
||||
LogUtils.debug("SingletonRegistry", `正在实例化: ${className} (优先级: ${item.priority})`);
|
||||
|
||||
const instance: Singleton = (<any>item.cls).getInstance();
|
||||
|
||||
await TimeUtils.waitFor(() => instance.isInit, 100, 30000);
|
||||
|
||||
LogUtils.info("SingletonRegistry", `成功实例化: ${className}`);
|
||||
} catch (err) {
|
||||
const className = js.getClassName(item.cls);
|
||||
LogUtils.error("SingletonRegistry", `自动实例化失败: ${className}`, err);
|
||||
}
|
||||
});
|
||||
|
||||
await Promise.all(promises);
|
||||
|
||||
this._isInitialized = true;
|
||||
//抛出通用事件, 初始化已经结束
|
||||
LogUtils.info("SingletonRegistry", "所有自动实例化单例类初始化完成");
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取所有注册的类
|
||||
*/
|
||||
public static getAllRegisteredClasses(): Array<{
|
||||
cls: new () => Singleton;
|
||||
options: SingletonDecoratorOptions;
|
||||
className: string;
|
||||
isInstantiated: boolean;
|
||||
}> {
|
||||
const result: Array<any> = [];
|
||||
for (const [cls, options] of this._registeredClasses.entries()) {
|
||||
result.push({
|
||||
cls,
|
||||
options,
|
||||
className: js.getClassName(cls),
|
||||
isInstantiated: (cls as any)._instances?.has(cls) || false,
|
||||
});
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据分类获取类
|
||||
*/
|
||||
public static getClassesByCategory(category: string): Array<new () => Singleton> {
|
||||
const result: Array<new () => Singleton> = [];
|
||||
for (const [cls, options] of this._registeredClasses.entries()) {
|
||||
if (options.category === category) {
|
||||
result.push(cls);
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* 打印所有注册的类信息
|
||||
*/
|
||||
public static printAllClasses(): void {
|
||||
LogUtils.info("SingletonRegistry", "=== 所有注册的单例类 ===");
|
||||
for (const [cls, options] of this._registeredClasses.entries()) {
|
||||
const className = js.getClassName(cls);
|
||||
const isInstantiated = (cls as any)._instances?.has(cls) || false;
|
||||
LogUtils.info(
|
||||
"SingletonRegistry",
|
||||
`${className} | 分类: ${options.category || "无"} | ` +
|
||||
`自动实例化: ${options.auto ? "是" : "否"} | ` +
|
||||
`优先级: ${options.priority || 100} | ` +
|
||||
`状态: ${isInstantiated ? "已实例化" : "未实例化"} | ` +
|
||||
`描述: ${options.description || "无"}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 重置初始化状态(用于测试)
|
||||
*/
|
||||
public static resetInitializationState(): void {
|
||||
this._isInitialized = false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 单例装饰器
|
||||
* @param options 装饰器配置选项
|
||||
*/
|
||||
export function singleton(options: SingletonDecoratorOptions = {}) {
|
||||
return function <T extends new () => Singleton>(constructor: T): T {
|
||||
if (!EDITOR) {
|
||||
SingletonRegistry.register(constructor, options);
|
||||
}
|
||||
|
||||
return constructor;
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"ver": "4.0.24",
|
||||
"importer": "typescript",
|
||||
"imported": true,
|
||||
"uuid": "5136e9d4-0618-4f13-bc55-a759b2ea97fb",
|
||||
"files": [],
|
||||
"subMetas": {},
|
||||
"userData": {}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"ver": "1.2.0",
|
||||
"importer": "directory",
|
||||
"imported": true,
|
||||
"uuid": "31f646f2-7047-4e2b-b958-60dd501ec95f",
|
||||
"files": [],
|
||||
"subMetas": {},
|
||||
"userData": {}
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
import { EventManager, GameEventMessage } from "../event/EventManager";
|
||||
import { NetworkManager } from "../net/NetworkManager";
|
||||
import { singleton, Singleton } from "../Singleton";
|
||||
import StorageManager from "../storage/StorageManager";
|
||||
import { Popup } from "../ui/default/DefaultPopup";
|
||||
import LogUtils from "../utils/LogUtils";
|
||||
import { IAccountInfo } from "./Types";
|
||||
|
||||
@singleton()
|
||||
export default class AccountManager extends Singleton {
|
||||
/** 账号信息 */
|
||||
accountInfo: IAccountInfo | null = null;
|
||||
|
||||
/** 初始化 */
|
||||
public async onInit() {
|
||||
EventManager.getInstance().on(
|
||||
GameEventMessage.GAME_CORE_INITIALIZED,
|
||||
this.onGameCoreInitialized,
|
||||
this,
|
||||
);
|
||||
}
|
||||
|
||||
private onGameCoreInitialized() {
|
||||
void this.checkLogin();
|
||||
}
|
||||
|
||||
private async checkLogin(isDialog: boolean = false) {
|
||||
if (AccountManager.getInstance().accountInfo) {
|
||||
EventManager.getInstance().emit(GameEventMessage.GAME_LOGIN, true);
|
||||
return;
|
||||
}
|
||||
|
||||
if (StorageManager.getInstance().account == null) {
|
||||
Popup.show({
|
||||
content: "请先注册",
|
||||
confirm: "注册",
|
||||
onConfirm: async () => {
|
||||
await AccountManager.getInstance().register();
|
||||
void this.checkLogin(true);
|
||||
},
|
||||
cancel: "取消",
|
||||
onCancel: async () => {
|
||||
void this.checkLogin(true);
|
||||
},
|
||||
});
|
||||
} else {
|
||||
if (!isDialog) {
|
||||
await AccountManager.getInstance().login(
|
||||
StorageManager.getInstance().account,
|
||||
);
|
||||
void this.checkLogin(true);
|
||||
return;
|
||||
}
|
||||
|
||||
Popup.show({
|
||||
content: "请先登录",
|
||||
confirm: "登录",
|
||||
onConfirm: async () => {
|
||||
await AccountManager.getInstance().login(
|
||||
StorageManager.getInstance().account,
|
||||
);
|
||||
void this.checkLogin(true);
|
||||
},
|
||||
cancel: "取消",
|
||||
onCancel: async () => {
|
||||
void this.checkLogin(true);
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
public async register() {
|
||||
const rsp = await NetworkManager.getInstance()
|
||||
.getHttpClient()
|
||||
.send<IAccountInfo>("/user/register", "POST", {
|
||||
nickName: "未命名",
|
||||
avatar: "https://avatars.githubusercontent.com/u/59302977",
|
||||
});
|
||||
if (rsp.success) {
|
||||
this.accountInfo = rsp.data;
|
||||
StorageManager.getInstance().account = this.accountInfo;
|
||||
StorageManager.getInstance().loadData(
|
||||
this.accountInfo.uid,
|
||||
(<any>rsp.data).playerData,
|
||||
);
|
||||
LogUtils.log("注册成功", this.accountInfo);
|
||||
}
|
||||
}
|
||||
|
||||
public async login(accountInfo: IAccountInfo) {
|
||||
const rsp = await NetworkManager.getInstance()
|
||||
.getHttpClient()
|
||||
.send<IAccountInfo>("/user/login", "POST", {
|
||||
uid: accountInfo.uid,
|
||||
});
|
||||
if (rsp.success) {
|
||||
this.accountInfo = rsp.data;
|
||||
StorageManager.getInstance().account = this.accountInfo;
|
||||
StorageManager.getInstance().loadData(
|
||||
this.accountInfo.uid,
|
||||
(<any>rsp.data).playerData,
|
||||
);
|
||||
LogUtils.log("登录成功", this.accountInfo);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"ver": "4.0.24",
|
||||
"importer": "typescript",
|
||||
"imported": true,
|
||||
"uuid": "32bdb189-b596-4538-b022-cd9327f89b3e",
|
||||
"files": [],
|
||||
"subMetas": {},
|
||||
"userData": {}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
import { IResponseData } from "../net/HttpRequest";
|
||||
|
||||
export interface IAccountInfo extends IResponseData {
|
||||
uid: string;
|
||||
nickName: string;
|
||||
avatar: string;
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"ver": "4.0.24",
|
||||
"importer": "typescript",
|
||||
"imported": true,
|
||||
"uuid": "b377f38b-3dbc-49a1-b34e-704b02322e2f",
|
||||
"files": [],
|
||||
"subMetas": {},
|
||||
"userData": {}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"ver": "1.2.0",
|
||||
"importer": "directory",
|
||||
"imported": true,
|
||||
"uuid": "2a22b5fa-076a-4f5f-852d-c018e5b09586",
|
||||
"files": [],
|
||||
"subMetas": {},
|
||||
"userData": {}
|
||||
}
|
||||
323
extensions/max-studio/assets/max-studio/core/decorators/Event.ts
Normal file
323
extensions/max-studio/assets/max-studio/core/decorators/Event.ts
Normal file
@@ -0,0 +1,323 @@
|
||||
import { NodeEventType } from "cc";
|
||||
import { Node } from "cc";
|
||||
import { EventManager } from "../event/EventManager";
|
||||
|
||||
/**
|
||||
* 事件装饰器,用于自动绑定事件监听器
|
||||
* 支持两种事件类型:
|
||||
* 1. Node 节点内置事件(NodeEventType)- 使用 Node 的事件系统,自动选择事件目标
|
||||
* 2. 自定义事件(字符串)- 使用 EventManager 全局事件系统
|
||||
*
|
||||
* 适用场景:
|
||||
* - Cocos Creator 组件类:
|
||||
* Node 事件会绑定到组件的 node,自定义事件使用 EventManager
|
||||
* 会在 onLoad 之后注册相关事件、onDestroy 之前注销相关事件
|
||||
* - 普通类:
|
||||
* 只支持自定义事件,使用 EventManager
|
||||
* 会在构造函数中注册相关事件、不会主动销毁相关事件,需要手动添加 offEvent 装饰器注销事件
|
||||
*
|
||||
* @param key 事件类型,可以是字符串或 NodeEventType
|
||||
* @param once 是否只监听一次,默认 false
|
||||
*/
|
||||
// 用于标记是否已经重写过生命周期方法
|
||||
const LIFECYCLE_WRAPPED_SYMBOL = Symbol('lifecycleWrapped');
|
||||
|
||||
export function onEvent(
|
||||
key: string | NodeEventType,
|
||||
once: boolean = false,
|
||||
) {
|
||||
return function (target: any, propertyKey: string, descriptor: PropertyDescriptor) {
|
||||
const originalMethod = descriptor.value;
|
||||
|
||||
// 保存事件绑定信息到类原型上
|
||||
if (!target.__eventBindings) {
|
||||
target.__eventBindings = [];
|
||||
}
|
||||
|
||||
target.__eventBindings.push({
|
||||
key,
|
||||
once,
|
||||
method: propertyKey,
|
||||
originalMethod
|
||||
});
|
||||
|
||||
// 只在第一次使用装饰器时重写生命周期方法
|
||||
if (!target[LIFECYCLE_WRAPPED_SYMBOL]) {
|
||||
target[LIFECYCLE_WRAPPED_SYMBOL] = true;
|
||||
|
||||
// 检查是否为 Cocos Creator 组件类
|
||||
const isComponent = isCocosCreatoComponent(target);
|
||||
|
||||
if (isComponent) {
|
||||
// Cocos Creator 组件:重写 onLoad 和 onDestroy
|
||||
setupComponentLifecycle(target);
|
||||
} else {
|
||||
// 普通类:重写构造函数和添加销毁方法
|
||||
setupPlainClassLifecycle(target);
|
||||
}
|
||||
}
|
||||
|
||||
return descriptor;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断是否为 Cocos Creator 组件类
|
||||
*/
|
||||
function isCocosCreatoComponent(target: any): boolean {
|
||||
// 检查原型链中是否有 Component 相关的方法
|
||||
let proto = target;
|
||||
while (proto) {
|
||||
if (proto.onLoad || proto.onDestroy || proto.node !== undefined) {
|
||||
return true;
|
||||
}
|
||||
proto = Object.getPrototypeOf(proto);
|
||||
if (proto === Object.prototype) break;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* 为 Cocos Creator 组件设置生命周期
|
||||
*/
|
||||
function setupComponentLifecycle(target: any) {
|
||||
// 保存原始的生命周期方法
|
||||
const originalOnLoad = target.onLoad;
|
||||
const originalOnDestroy = target.onDestroy;
|
||||
|
||||
// 重写 onLoad 方法来自动绑定事件
|
||||
target.onLoad = function () {
|
||||
// 调用原始的 onLoad
|
||||
if (originalOnLoad) {
|
||||
originalOnLoad.call(this);
|
||||
}
|
||||
|
||||
// 绑定所有装饰器标记的事件
|
||||
bindEvents.call(this);
|
||||
};
|
||||
|
||||
// 重写 onDestroy 方法来自动解绑事件
|
||||
target.onDestroy = function () {
|
||||
// 解绑所有装饰器标记的事件
|
||||
unbindEvents.call(this);
|
||||
|
||||
// 调用原始的 onDestroy
|
||||
if (originalOnDestroy) {
|
||||
originalOnDestroy.call(this);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 为普通类设置生命周期
|
||||
*/
|
||||
function setupPlainClassLifecycle(target_: any) {
|
||||
// 保存原始构造函数
|
||||
const originalConstructor = target_.constructor;
|
||||
|
||||
// 重写构造函数来自动绑定事件
|
||||
target_.constructor = function (...args: any[]) {
|
||||
// 调用原始构造函数
|
||||
const result = originalConstructor.apply(this, args);
|
||||
|
||||
// 绑定事件(延迟到下一个事件循环,确保对象完全初始化)
|
||||
setTimeout(() => {
|
||||
bindEvents.call(this);
|
||||
}, 0);
|
||||
|
||||
return result;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 绑定事件
|
||||
*/
|
||||
function bindEvents(this: any) {
|
||||
if (this.__eventBindings) {
|
||||
this.__eventBindings.forEach((binding: any) => {
|
||||
const method = this[binding.method].bind(this);
|
||||
|
||||
if (isNodeEvent(binding.key)) {
|
||||
// Node 节点内置事件,使用 Node 的事件系统
|
||||
const eventTarget = getNodeEventTarget.call(this);
|
||||
if (eventTarget) {
|
||||
if (binding.once) {
|
||||
eventTarget.once(binding.key, method);
|
||||
} else {
|
||||
eventTarget.on(binding.key, method);
|
||||
}
|
||||
} else {
|
||||
console.warn(`无法为 Node 事件 "${binding.key}" 找到有效的事件目标`);
|
||||
}
|
||||
} else {
|
||||
// 自定义事件,使用 EventManager
|
||||
const eventManager = EventManager.getInstance();
|
||||
if (binding.once) {
|
||||
eventManager.once(binding.key, method, this);
|
||||
} else {
|
||||
eventManager.on(binding.key, method, this);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 解绑事件
|
||||
*/
|
||||
function unbindEvents(this: any) {
|
||||
if (this.__eventBindings) {
|
||||
this.__eventBindings.forEach((binding: any) => {
|
||||
const method = this[binding.method].bind(this);
|
||||
|
||||
if (isNodeEvent(binding.key)) {
|
||||
// Node 节点内置事件,使用 Node 的事件系统解绑
|
||||
const eventTarget = getNodeEventTarget.call(this);
|
||||
if (eventTarget) {
|
||||
eventTarget.off(binding.key, method);
|
||||
}
|
||||
} else {
|
||||
// 自定义事件,使用 EventManager 解绑
|
||||
const eventManager = EventManager.getInstance();
|
||||
eventManager.off(binding.key, method, this);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取 Node 事件的目标对象
|
||||
*/
|
||||
function getNodeEventTarget(this: any): any {
|
||||
// 优先使用组件的 node
|
||||
if (this.node) {
|
||||
return this.node;
|
||||
}
|
||||
|
||||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
* 主动删除事件监听装饰器
|
||||
* @param key 可选,指定要删除的事件类型。如果不传则删除所有事件监听
|
||||
*/
|
||||
export function offEvent(key?: string | NodeEventType) {
|
||||
return function (target: any, propertyKey: string, descriptor: PropertyDescriptor) {
|
||||
const originalMethod = descriptor.value;
|
||||
|
||||
// 重写方法,在调用时执行事件解绑逻辑
|
||||
descriptor.value = function (...args: any[]) {
|
||||
// 先调用原始方法
|
||||
const result = originalMethod.apply(this, args);
|
||||
|
||||
// 执行事件解绑
|
||||
if (key) {
|
||||
// 删除指定事件
|
||||
removeSpecificEvent.call(this, key);
|
||||
} else {
|
||||
// 删除所有事件
|
||||
removeAllEvents.call(this);
|
||||
}
|
||||
|
||||
return result;
|
||||
};
|
||||
|
||||
return descriptor;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除指定事件的监听
|
||||
*/
|
||||
function removeSpecificEvent(this: any, key: string | NodeEventType) {
|
||||
if (!this.__eventBindings) {
|
||||
return;
|
||||
}
|
||||
|
||||
// 找到匹配的事件绑定
|
||||
const bindingsToRemove = this.__eventBindings.filter((binding: any) => binding.key === key);
|
||||
|
||||
bindingsToRemove.forEach((binding: any) => {
|
||||
const method = this[binding.method].bind(this);
|
||||
|
||||
if (isNodeEvent(binding.key)) {
|
||||
// Node 节点内置事件
|
||||
const eventTarget = getNodeEventTarget.call(this);
|
||||
if (eventTarget) {
|
||||
eventTarget.off(binding.key, method);
|
||||
}
|
||||
} else {
|
||||
// 自定义事件
|
||||
const eventManager = EventManager.getInstance();
|
||||
eventManager.off(binding.key, method, this);
|
||||
}
|
||||
});
|
||||
|
||||
// 从绑定列表中移除
|
||||
this.__eventBindings = this.__eventBindings.filter((binding: any) => binding.key !== key);
|
||||
|
||||
console.log(`已删除事件监听: ${key}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除所有事件监听
|
||||
*/
|
||||
function removeAllEvents(this: any) {
|
||||
if (!this.__eventBindings) {
|
||||
return;
|
||||
}
|
||||
|
||||
const bindingsToRemove = [...this.__eventBindings];
|
||||
|
||||
bindingsToRemove.forEach((binding: any) => {
|
||||
const method = this[binding.method].bind(this);
|
||||
|
||||
if (isNodeEvent(binding.key)) {
|
||||
// Node 节点内置事件
|
||||
const eventTarget = getNodeEventTarget.call(this);
|
||||
if (eventTarget) {
|
||||
eventTarget.off(binding.key, method);
|
||||
}
|
||||
} else {
|
||||
// 自定义事件
|
||||
const eventManager = EventManager.getInstance();
|
||||
eventManager.off(binding.key, method, this);
|
||||
}
|
||||
});
|
||||
|
||||
// 清空绑定列表
|
||||
this.__eventBindings = [];
|
||||
|
||||
console.log('已删除所有事件监听');
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断是否为 Node 节点内置事件
|
||||
* @param key 事件键
|
||||
* @returns 是否为 Node 事件
|
||||
*/
|
||||
function isNodeEvent(key: string | NodeEventType): boolean {
|
||||
// 检查是否为 NodeEventType 枚举值
|
||||
if (typeof key === 'string') {
|
||||
// 检查是否为 Node 内置事件字符串
|
||||
const nodeEvents = [
|
||||
// 触摸事件
|
||||
'touch-start', 'touch-move', 'touch-end', 'touch-cancel',
|
||||
// 鼠标事件
|
||||
'mouse-down', 'mouse-move', 'mouse-up', 'mouse-enter', 'mouse-leave', 'mouse-wheel',
|
||||
// 键盘事件
|
||||
'key-down', 'key-up',
|
||||
// 节点事件
|
||||
'position-changed', 'rotation-changed', 'scale-changed', 'size-changed',
|
||||
'anchor-changed', 'color-changed', 'child-added', 'child-removed', 'child-reorder',
|
||||
// 组件事件
|
||||
'active-in-hierarchy-changed',
|
||||
// 其他常见 Node 事件
|
||||
'node-destroyed'
|
||||
];
|
||||
return nodeEvents.includes(key);
|
||||
}
|
||||
|
||||
// 如果是 NodeEventType 枚举,则为 Node 事件
|
||||
return Object.values(NodeEventType).includes(key as NodeEventType);
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"ver": "4.0.24",
|
||||
"importer": "typescript",
|
||||
"imported": true,
|
||||
"uuid": "c4bfe233-9d4c-4fb1-93f7-6cc890aa5f16",
|
||||
"files": [],
|
||||
"subMetas": {},
|
||||
"userData": {}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import { Constructor } from "cc";
|
||||
|
||||
const LAZY_PREFIX = "__lazy_";
|
||||
|
||||
export function lazy(type: Constructor, ...args: any[]): any {
|
||||
return function (
|
||||
target: any,
|
||||
propertyKey: any,
|
||||
descriptor: PropertyDescriptor,
|
||||
): PropertyDescriptor {
|
||||
const getter = function () {
|
||||
if (!this[LAZY_PREFIX + propertyKey]) {
|
||||
this[LAZY_PREFIX + propertyKey] = new type(...args);
|
||||
}
|
||||
return this[LAZY_PREFIX + propertyKey];
|
||||
};
|
||||
const setter = function (newValue: any) {
|
||||
this[LAZY_PREFIX + propertyKey] = newValue;
|
||||
};
|
||||
delete (descriptor as any).initializer;
|
||||
delete descriptor.writable;
|
||||
return Object.assign(descriptor, { get: getter, set: setter });
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"ver": "4.0.24",
|
||||
"importer": "typescript",
|
||||
"imported": true,
|
||||
"uuid": "efb635a3-0418-454c-bb4c-e11d56968aa2",
|
||||
"files": [],
|
||||
"subMetas": {},
|
||||
"userData": {}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import { EventManager } from "../event/EventManager";
|
||||
|
||||
export const NOTIFY_PREFIX = "__notify_";
|
||||
|
||||
export function notify(eventName?: string): any {
|
||||
return function (
|
||||
target: any,
|
||||
propertyKey: any,
|
||||
descriptor: PropertyDescriptor,
|
||||
): PropertyDescriptor {
|
||||
const getter = function () {
|
||||
return this[NOTIFY_PREFIX + propertyKey];
|
||||
};
|
||||
const setter = function (newValue: any) {
|
||||
if (newValue == this[NOTIFY_PREFIX + propertyKey]) {
|
||||
return;
|
||||
}
|
||||
if (eventName == undefined) {
|
||||
eventName = target.constructor.name + "." + propertyKey;
|
||||
}
|
||||
this[NOTIFY_PREFIX + propertyKey] = newValue;
|
||||
console.log(`Event ${eventName} emitted with value ${newValue}`);
|
||||
EventManager.getInstance().emit(eventName, newValue);
|
||||
};
|
||||
delete (descriptor as any).initializer;
|
||||
delete descriptor.writable;
|
||||
return Object.assign(descriptor, { get: getter, set: setter });
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"ver": "4.0.24",
|
||||
"importer": "typescript",
|
||||
"imported": true,
|
||||
"uuid": "02132acf-12b0-4fd5-a798-1d6b0ba097dd",
|
||||
"files": [],
|
||||
"subMetas": {},
|
||||
"userData": {}
|
||||
}
|
||||
@@ -0,0 +1,318 @@
|
||||
/**
|
||||
* 性能优化相关装饰器
|
||||
* 包含节流(throttle)和防抖(debounce)装饰器
|
||||
*/
|
||||
|
||||
/**
|
||||
* 节流装饰器 - 在指定时间间隔内最多执行一次
|
||||
* @param delay 节流间隔时间(毫秒)
|
||||
* @param options 配置选项
|
||||
*/
|
||||
export function throttle(delay: number, options: { leading?: boolean; trailing?: boolean } = {}) {
|
||||
const { leading = true, trailing = true } = options;
|
||||
|
||||
return function (target: any, propertyKey: string, descriptor: PropertyDescriptor) {
|
||||
const originalMethod = descriptor.value;
|
||||
let lastCallTime = 0;
|
||||
let timeoutId: any = null;
|
||||
let lastArgs: any[] = [];
|
||||
let lastThis: any = null;
|
||||
|
||||
descriptor.value = function (...args: any[]) {
|
||||
const now = Date.now();
|
||||
const timeSinceLastCall = now - lastCallTime;
|
||||
|
||||
lastArgs = args;
|
||||
lastThis = this;
|
||||
|
||||
const callFunction = () => {
|
||||
lastCallTime = Date.now();
|
||||
return originalMethod.apply(this, args);
|
||||
};
|
||||
|
||||
// 如果是第一次调用且允许立即执行
|
||||
if (lastCallTime === 0 && leading) {
|
||||
return callFunction();
|
||||
}
|
||||
|
||||
// 如果距离上次调用时间超过延迟时间
|
||||
if (timeSinceLastCall >= delay) {
|
||||
if (timeoutId) {
|
||||
clearTimeout(timeoutId);
|
||||
timeoutId = null;
|
||||
}
|
||||
return callFunction();
|
||||
}
|
||||
|
||||
// 如果允许尾随执行且没有待执行的定时器
|
||||
if (trailing && !timeoutId) {
|
||||
timeoutId = setTimeout(() => {
|
||||
timeoutId = null;
|
||||
if (trailing) {
|
||||
lastCallTime = Date.now();
|
||||
originalMethod.apply(lastThis, lastArgs);
|
||||
}
|
||||
}, delay - timeSinceLastCall);
|
||||
}
|
||||
};
|
||||
|
||||
return descriptor;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 防抖装饰器 - 延迟执行,如果在延迟期间再次调用则重新计时
|
||||
* @param delay 防抖延迟时间(毫秒)
|
||||
* @param options 配置选项
|
||||
*/
|
||||
export function debounce(delay: number, options: { immediate?: boolean } = {}) {
|
||||
const { immediate = false } = options;
|
||||
|
||||
return function (target: any, propertyKey: string, descriptor: PropertyDescriptor) {
|
||||
const originalMethod = descriptor.value;
|
||||
let timeoutId: any = null;
|
||||
let hasBeenCalled = false;
|
||||
|
||||
descriptor.value = function (...args: any[]) {
|
||||
const callNow = immediate && !hasBeenCalled;
|
||||
|
||||
// 清除之前的定时器
|
||||
if (timeoutId) {
|
||||
clearTimeout(timeoutId);
|
||||
}
|
||||
|
||||
// 如果是立即执行模式且是第一次调用
|
||||
if (callNow) {
|
||||
hasBeenCalled = true;
|
||||
const result = originalMethod.apply(this, args);
|
||||
|
||||
// 设置定时器重置状态
|
||||
timeoutId = setTimeout(() => {
|
||||
hasBeenCalled = false;
|
||||
timeoutId = null;
|
||||
}, delay);
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
// 设置延迟执行
|
||||
timeoutId = setTimeout(() => {
|
||||
timeoutId = null;
|
||||
if (!immediate) {
|
||||
originalMethod.apply(this, args);
|
||||
} else {
|
||||
hasBeenCalled = false;
|
||||
}
|
||||
}, delay);
|
||||
};
|
||||
|
||||
return descriptor;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 高级节流装饰器 - 支持异步方法和返回值处理
|
||||
* @param delay 节流间隔时间(毫秒)
|
||||
* @param options 配置选项
|
||||
*/
|
||||
export function advancedThrottle(delay: number, options: {
|
||||
leading?: boolean;
|
||||
trailing?: boolean;
|
||||
maxWait?: number;
|
||||
} = {}) {
|
||||
const { leading = true, trailing = true, maxWait } = options;
|
||||
|
||||
return function (target: any, propertyKey: string, descriptor: PropertyDescriptor) {
|
||||
const originalMethod = descriptor.value;
|
||||
let lastCallTime = 0;
|
||||
let lastInvokeTime = 0;
|
||||
let timeoutId: any = null;
|
||||
let maxTimeoutId: any = null;
|
||||
let lastArgs: any[] = [];
|
||||
let lastThis: any = null;
|
||||
let result: any;
|
||||
|
||||
const invokeFunc = (time: number) => {
|
||||
const args = lastArgs;
|
||||
const thisArg = lastThis;
|
||||
|
||||
lastArgs = [];
|
||||
lastThis = null;
|
||||
lastInvokeTime = time;
|
||||
result = originalMethod.apply(thisArg, args);
|
||||
return result;
|
||||
};
|
||||
|
||||
const leadingEdge = (time: number) => {
|
||||
lastInvokeTime = time;
|
||||
timeoutId = setTimeout(timerExpired, delay);
|
||||
return leading ? invokeFunc(time) : result;
|
||||
};
|
||||
|
||||
const remainingWait = (time: number) => {
|
||||
const timeSinceLastCall = time - lastCallTime;
|
||||
const timeSinceLastInvoke = time - lastInvokeTime;
|
||||
const timeWaiting = delay - timeSinceLastCall;
|
||||
|
||||
return maxWait !== undefined
|
||||
? Math.min(timeWaiting, maxWait - timeSinceLastInvoke)
|
||||
: timeWaiting;
|
||||
};
|
||||
|
||||
const shouldInvoke = (time: number) => {
|
||||
const timeSinceLastCall = time - lastCallTime;
|
||||
const timeSinceLastInvoke = time - lastInvokeTime;
|
||||
|
||||
return (lastCallTime === 0 ||
|
||||
timeSinceLastCall >= delay ||
|
||||
timeSinceLastCall < 0 ||
|
||||
(maxWait !== undefined && timeSinceLastInvoke >= maxWait));
|
||||
};
|
||||
|
||||
const timerExpired = () => {
|
||||
const time = Date.now();
|
||||
if (shouldInvoke(time)) {
|
||||
return trailingEdge(time);
|
||||
}
|
||||
timeoutId = setTimeout(timerExpired, remainingWait(time));
|
||||
};
|
||||
|
||||
const trailingEdge = (time: number) => {
|
||||
timeoutId = null;
|
||||
|
||||
if (trailing && lastArgs.length) {
|
||||
return invokeFunc(time);
|
||||
}
|
||||
lastArgs = [];
|
||||
lastThis = null;
|
||||
return result;
|
||||
};
|
||||
|
||||
descriptor.value = function (...args: any[]) {
|
||||
const time = Date.now();
|
||||
const isInvoking = shouldInvoke(time);
|
||||
|
||||
lastArgs = args;
|
||||
lastThis = this;
|
||||
lastCallTime = time;
|
||||
|
||||
if (isInvoking) {
|
||||
if (timeoutId === null) {
|
||||
return leadingEdge(lastCallTime);
|
||||
}
|
||||
if (maxWait !== undefined) {
|
||||
timeoutId = setTimeout(timerExpired, delay);
|
||||
return invokeFunc(lastCallTime);
|
||||
}
|
||||
}
|
||||
if (timeoutId === null) {
|
||||
timeoutId = setTimeout(timerExpired, delay);
|
||||
}
|
||||
return result;
|
||||
};
|
||||
|
||||
return descriptor;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 高级防抖装饰器 - 支持异步方法和取消功能
|
||||
* @param delay 防抖延迟时间(毫秒)
|
||||
* @param options 配置选项
|
||||
*/
|
||||
export function advancedDebounce(delay: number, options: {
|
||||
immediate?: boolean;
|
||||
maxWait?: number;
|
||||
} = {}) {
|
||||
const { immediate = false, maxWait } = options;
|
||||
|
||||
return function (target: any, propertyKey: string, descriptor: PropertyDescriptor) {
|
||||
const originalMethod = descriptor.value;
|
||||
let timeoutId: any = null;
|
||||
let maxTimeoutId: any = null;
|
||||
let lastCallTime = 0;
|
||||
let lastInvokeTime = 0;
|
||||
let lastArgs: any[] = [];
|
||||
let lastThis: any = null;
|
||||
let result: any;
|
||||
|
||||
const invokeFunc = (time: number) => {
|
||||
const args = lastArgs;
|
||||
const thisArg = lastThis;
|
||||
|
||||
lastArgs = [];
|
||||
lastThis = null;
|
||||
lastInvokeTime = time;
|
||||
result = originalMethod.apply(thisArg, args);
|
||||
return result;
|
||||
};
|
||||
|
||||
const shouldInvoke = (time: number) => {
|
||||
const timeSinceLastCall = time - lastCallTime;
|
||||
const timeSinceLastInvoke = time - lastInvokeTime;
|
||||
|
||||
return (lastCallTime === 0 ||
|
||||
timeSinceLastCall >= delay ||
|
||||
timeSinceLastCall < 0 ||
|
||||
(maxWait !== undefined && timeSinceLastInvoke >= maxWait));
|
||||
};
|
||||
|
||||
const timerExpired = () => {
|
||||
const time = Date.now();
|
||||
if (shouldInvoke(time)) {
|
||||
timeoutId = null;
|
||||
if (lastArgs.length) {
|
||||
return invokeFunc(time);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
timeoutId = setTimeout(timerExpired, delay);
|
||||
};
|
||||
|
||||
descriptor.value = function (...args: any[]) {
|
||||
const time = Date.now();
|
||||
const isInvoking = shouldInvoke(time);
|
||||
|
||||
lastArgs = args;
|
||||
lastThis = this;
|
||||
lastCallTime = time;
|
||||
|
||||
if (isInvoking && timeoutId === null) {
|
||||
if (immediate) {
|
||||
result = invokeFunc(time);
|
||||
timeoutId = setTimeout(() => {
|
||||
timeoutId = null;
|
||||
}, delay);
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
if (timeoutId) {
|
||||
clearTimeout(timeoutId);
|
||||
}
|
||||
|
||||
if (maxTimeoutId) {
|
||||
clearTimeout(maxTimeoutId);
|
||||
}
|
||||
|
||||
timeoutId = setTimeout(timerExpired, delay);
|
||||
|
||||
if (maxWait !== undefined && maxTimeoutId === null) {
|
||||
maxTimeoutId = setTimeout(() => {
|
||||
maxTimeoutId = null;
|
||||
if (timeoutId) {
|
||||
clearTimeout(timeoutId);
|
||||
timeoutId = null;
|
||||
}
|
||||
if (lastArgs.length) {
|
||||
invokeFunc(Date.now());
|
||||
}
|
||||
}, maxWait);
|
||||
}
|
||||
|
||||
return result;
|
||||
};
|
||||
|
||||
return descriptor;
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"ver": "4.0.24",
|
||||
"importer": "typescript",
|
||||
"imported": true,
|
||||
"uuid": "acacdae2-120a-4f5c-b81c-8a7feaec6926",
|
||||
"files": [],
|
||||
"subMetas": {},
|
||||
"userData": {}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
/**
|
||||
* 装饰器模块导出
|
||||
*/
|
||||
|
||||
// 事件相关装饰器
|
||||
export * from './Event';
|
||||
|
||||
// 性能优化装饰器
|
||||
export * from './Performance';
|
||||
|
||||
// 懒加载装饰器
|
||||
export * from './Lazy';
|
||||
|
||||
// 通知装饰器
|
||||
export * from './Notify';
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"ver": "4.0.24",
|
||||
"importer": "typescript",
|
||||
"imported": true,
|
||||
"uuid": "4702da4e-9d5f-4aec-8cad-bd6166bbad06",
|
||||
"files": [],
|
||||
"subMetas": {},
|
||||
"userData": {}
|
||||
}
|
||||
9
extensions/max-studio/assets/max-studio/core/event.meta
Normal file
9
extensions/max-studio/assets/max-studio/core/event.meta
Normal file
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"ver": "1.2.0",
|
||||
"importer": "directory",
|
||||
"imported": true,
|
||||
"uuid": "6ea0d4f4-fc08-4ee7-b8cb-ca0c576bf6ce",
|
||||
"files": [],
|
||||
"subMetas": {},
|
||||
"userData": {}
|
||||
}
|
||||
@@ -0,0 +1,141 @@
|
||||
import { Singleton, singleton } from "../Singleton";
|
||||
import LogUtils from "../utils/LogUtils";
|
||||
import { isValid } from "cc";
|
||||
|
||||
/**
|
||||
* 全局事件监听方法
|
||||
* @param event 事件名
|
||||
* @param args 事件参数
|
||||
*/
|
||||
export type ListenerFunc = (...args: unknown[]) => void;
|
||||
|
||||
/** 框架内部全局事件 */
|
||||
export enum GameEventMessage {
|
||||
/** 游戏从后台进入事件 */
|
||||
GAME_SHOW = "GAME_ENTER",
|
||||
/** 游戏切到后台事件 */
|
||||
GAME_HIDE = "GAME_EXIT",
|
||||
/** 游戏画笔尺寸变化事件 */
|
||||
GAME_RESIZE = "GAME_RESIZE",
|
||||
/** 游戏全屏事件 */
|
||||
GAME_FULL_SCREEN = "GAME_FULL_SCREEN",
|
||||
/** 游戏旋转屏幕事件 */
|
||||
GAME_ORIENTATION = "GAME_ORIENTATION",
|
||||
/** 引擎代码初始化完毕 */
|
||||
GAME_CORE_INITIALIZED = "GAME_CORE_INITIALIZED",
|
||||
/** 游戏初始化完成事件 */
|
||||
GAME_LOGIN = "GAME_LOGIN",
|
||||
}
|
||||
|
||||
export class EventData {
|
||||
public event!: string;
|
||||
public listener!: ListenerFunc;
|
||||
public obj: unknown;
|
||||
}
|
||||
|
||||
/**
|
||||
* 全局消息管理
|
||||
*/
|
||||
@singleton()
|
||||
export class EventManager extends Singleton {
|
||||
private events: Map<string, Array<EventData>> = new Map();
|
||||
|
||||
/**
|
||||
* 注册全局事件
|
||||
* @param event 事件名
|
||||
* @param listener 处理事件的侦听器函数
|
||||
* @param obj 侦听函数绑定的作用域对象
|
||||
*/
|
||||
on(event: string, listener: ListenerFunc, obj: unknown) {
|
||||
if (!event || !listener) {
|
||||
LogUtils.warn(`注册【${event}】事件的侦听器函数为空`);
|
||||
return;
|
||||
}
|
||||
|
||||
let eds = this.events.get(event);
|
||||
if (eds == null) {
|
||||
eds = [];
|
||||
this.events.set(event, eds);
|
||||
}
|
||||
|
||||
// 使用 some 提前终止循环,一旦发现重复就停止查找
|
||||
const isDuplicate = eds.some(
|
||||
(bin) => bin.listener === listener && bin.obj === obj,
|
||||
);
|
||||
if (isDuplicate) {
|
||||
LogUtils.warn(`名为【${event}】的事件重复注册侦听器`);
|
||||
return; // 避免重复添加
|
||||
}
|
||||
|
||||
const data: EventData = new EventData();
|
||||
data.event = event;
|
||||
data.listener = listener;
|
||||
data.obj = obj;
|
||||
eds.push(data);
|
||||
}
|
||||
|
||||
/**
|
||||
* 监听一次事件,事件响应后,该监听自动移除
|
||||
* @param event 事件名
|
||||
* @param listener 事件触发回调方法
|
||||
* @param obj 侦听函数绑定的作用域对象
|
||||
*/
|
||||
once(event: string, listener: ListenerFunc, obj: unknown) {
|
||||
const tempListener: ListenerFunc = (...args: unknown[]) => {
|
||||
this.off(event, tempListener, obj);
|
||||
listener.apply(obj, args);
|
||||
};
|
||||
this.on(event, tempListener, obj);
|
||||
}
|
||||
|
||||
/**
|
||||
* 移除全局事件
|
||||
* @param event 事件名
|
||||
* @param listener 处理事件的侦听器函数
|
||||
* @param obj 侦听函数绑定的作用域对象
|
||||
*/
|
||||
off(event: string, listener: ListenerFunc, obj: unknown) {
|
||||
const eds = this.events.get(event);
|
||||
|
||||
if (!eds) {
|
||||
LogUtils.log(`名为【${event}】的事件不存在`);
|
||||
return;
|
||||
}
|
||||
|
||||
const index = eds.findIndex(
|
||||
(bin) => bin.listener === listener && bin.obj === obj,
|
||||
);
|
||||
if (index !== -1) {
|
||||
eds.splice(index, 1);
|
||||
}
|
||||
|
||||
if (eds.length == 0) {
|
||||
this.events.delete(event);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 触发全局事件
|
||||
* @param event 事件名
|
||||
* @param args 事件参数
|
||||
*/
|
||||
emit(event: string, ...args: unknown[]) {
|
||||
const list = this.events.get(event);
|
||||
|
||||
if (list != null) {
|
||||
const eds: Array<EventData> = list.slice();
|
||||
for (let index = 0; index < eds.length; index++) {
|
||||
const eventBin = eds[index];
|
||||
if (isValid(eventBin.obj)) {
|
||||
eventBin.listener.call(eventBin.obj, ...args);
|
||||
} else {
|
||||
LogUtils.warn(
|
||||
`事件【${event}】的侦听器对象已被销毁,无法触发`,
|
||||
);
|
||||
// 注意:这里可能会导致数组长度变化,但因为是副本所以不影响循环
|
||||
this.off(event, eventBin.listener, eventBin.obj);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"ver": "4.0.24",
|
||||
"importer": "typescript",
|
||||
"imported": true,
|
||||
"uuid": "204f0b8f-bfab-4404-96c1-a17a5cc43086",
|
||||
"files": [],
|
||||
"subMetas": {},
|
||||
"userData": {}
|
||||
}
|
||||
9
extensions/max-studio/assets/max-studio/core/guide.meta
Normal file
9
extensions/max-studio/assets/max-studio/core/guide.meta
Normal file
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"ver": "1.2.0",
|
||||
"importer": "directory",
|
||||
"imported": true,
|
||||
"uuid": "77812b7d-eeb3-456e-a00e-f485194c345d",
|
||||
"files": [],
|
||||
"subMetas": {},
|
||||
"userData": {}
|
||||
}
|
||||
@@ -0,0 +1,301 @@
|
||||
import { IGuideConfig } from "./GuideData";
|
||||
import { ResManager } from "../res/ResManager";
|
||||
import { JsonAsset } from "cc";
|
||||
import { Singleton } from "../Singleton";
|
||||
import LogUtils from "../utils/LogUtils";
|
||||
|
||||
const TAG = "GuideConfigManager";
|
||||
|
||||
/**
|
||||
* 引导配置管理器
|
||||
*/
|
||||
export class GuideConfigManager extends Singleton {
|
||||
private _configs: Map<string, IGuideConfig> = new Map();
|
||||
private _configVersion: string = "1.0.0";
|
||||
|
||||
protected async onInit() {
|
||||
await this.loadDefaultConfigs();
|
||||
}
|
||||
|
||||
/**
|
||||
* 加载默认配置
|
||||
*/
|
||||
private async loadDefaultConfigs() {
|
||||
// 这里可以加载默认的引导配置
|
||||
|
||||
const asset = await ResManager.getInstance().loadAsset<JsonAsset>(
|
||||
"guide-config",
|
||||
JsonAsset,
|
||||
);
|
||||
if (asset?.json) {
|
||||
for (const key in asset.json) {
|
||||
if (Object.prototype.hasOwnProperty.call(asset.json, key)) {
|
||||
const element = asset.json[key];
|
||||
GuideConfigManager.getInstance().addGuideConfig(element);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
LogUtils.error(TAG, "加载默认引导配置失败");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 添加引导配置
|
||||
* @param config 引导配置
|
||||
* @returns 是否添加成功
|
||||
*/
|
||||
public addGuideConfig(config: IGuideConfig): boolean {
|
||||
if (!config?.id) {
|
||||
LogUtils.error(TAG, "引导配置无效");
|
||||
return false;
|
||||
}
|
||||
|
||||
if (this._configs.has(config.id)) {
|
||||
LogUtils.warn(TAG, `引导配置已存在: ${config.id}`);
|
||||
return false;
|
||||
}
|
||||
|
||||
// 验证配置
|
||||
if (!this.validateConfig(config)) {
|
||||
LogUtils.error(TAG, `引导配置验证失败: ${config.id}`);
|
||||
return false;
|
||||
}
|
||||
|
||||
this._configs.set(config.id, config);
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新引导配置
|
||||
* @param config 引导配置
|
||||
* @returns 是否更新成功
|
||||
*/
|
||||
public updateGuideConfig(config: IGuideConfig): boolean {
|
||||
if (!config?.id) {
|
||||
LogUtils.error(TAG, "引导配置无效");
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!this._configs.has(config.id)) {
|
||||
LogUtils.warn(TAG, `引导配置不存在: ${config.id}`);
|
||||
return false;
|
||||
}
|
||||
|
||||
// 验证配置
|
||||
if (!this.validateConfig(config)) {
|
||||
LogUtils.error(TAG, `引导配置验证失败: ${config.id}`);
|
||||
return false;
|
||||
}
|
||||
|
||||
this._configs.set(config.id, config);
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除引导配置
|
||||
* @param guideId 引导ID
|
||||
* @returns 是否删除成功
|
||||
*/
|
||||
public removeGuideConfig(guideId: string): boolean {
|
||||
if (!guideId) {
|
||||
LogUtils.error(TAG, "引导ID无效");
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!this._configs.has(guideId)) {
|
||||
LogUtils.warn(TAG, `引导配置不存在: ${guideId}`);
|
||||
return false;
|
||||
}
|
||||
|
||||
this._configs.delete(guideId);
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取引导配置
|
||||
* @param guideId 引导ID
|
||||
* @returns 引导配置
|
||||
*/
|
||||
public getGuideConfig(guideId: string): IGuideConfig | null {
|
||||
return this._configs.get(guideId) || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取所有引导配置
|
||||
* @returns 引导配置列表
|
||||
*/
|
||||
public getAllGuideConfigs(): IGuideConfig[] {
|
||||
return Array.from(this._configs.values());
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取启用的引导配置
|
||||
* @returns 启用的引导配置列表
|
||||
*/
|
||||
public getEnabledGuideConfigs(): IGuideConfig[] {
|
||||
return Array.from(this._configs.values()).filter(
|
||||
(config) => config.enabled !== false,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 批量添加引导配置
|
||||
* @param configs 引导配置列表
|
||||
* @returns 成功添加的数量
|
||||
*/
|
||||
public addGuideConfigs(configs: IGuideConfig[]): number {
|
||||
let successCount = 0;
|
||||
for (const config of configs) {
|
||||
if (this.addGuideConfig(config)) {
|
||||
successCount++;
|
||||
}
|
||||
}
|
||||
return successCount;
|
||||
}
|
||||
|
||||
/**
|
||||
* 清空所有配置
|
||||
*/
|
||||
public clearAllConfigs(): void {
|
||||
this._configs.clear();
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证引导配置
|
||||
* @param config 引导配置
|
||||
* @returns 是否有效
|
||||
*/
|
||||
private validateConfig(config: IGuideConfig): boolean {
|
||||
// 检查基本字段
|
||||
if (!config.id || !config.name) {
|
||||
LogUtils.error(TAG, "引导配置缺少必要字段: id 或 name");
|
||||
return false;
|
||||
}
|
||||
|
||||
// 检查步骤
|
||||
if (!config.steps || config.steps.length === 0) {
|
||||
LogUtils.error(TAG, "引导配置缺少步骤");
|
||||
return false;
|
||||
}
|
||||
|
||||
// 检查步骤ID唯一性
|
||||
const stepIds = new Set<string>();
|
||||
for (const step of config.steps) {
|
||||
if (!step.id) {
|
||||
LogUtils.error(TAG, "引导步骤缺少ID");
|
||||
return false;
|
||||
}
|
||||
if (stepIds.has(step.id)) {
|
||||
LogUtils.error(TAG, `引导步骤ID重复: ${step.id}`);
|
||||
return false;
|
||||
}
|
||||
stepIds.add(step.id);
|
||||
}
|
||||
|
||||
// 检查开始步骤
|
||||
if (config.startStepId && !stepIds.has(config.startStepId)) {
|
||||
LogUtils.error(TAG, `开始步骤ID不存在: ${config.startStepId}`);
|
||||
return false;
|
||||
}
|
||||
|
||||
// 检查步骤引用
|
||||
for (const step of config.steps) {
|
||||
if (step.nextStepId && !stepIds.has(step.nextStepId)) {
|
||||
LogUtils.error(
|
||||
TAG,
|
||||
`步骤 ${step.id} 的下一步骤ID不存在: ${step.nextStepId}`,
|
||||
);
|
||||
return false;
|
||||
}
|
||||
|
||||
if (step.branches) {
|
||||
for (const branch of step.branches) {
|
||||
if (!stepIds.has(branch.nextStepId)) {
|
||||
LogUtils.error(
|
||||
TAG,
|
||||
`步骤 ${step.id} 的分枝下一步骤ID不存在: ${branch.nextStepId}`,
|
||||
);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* 导出配置为JSON
|
||||
* @param guideId 引导ID,不传则导出所有
|
||||
* @returns JSON字符串
|
||||
*/
|
||||
public exportToJSON(guideId?: string): string {
|
||||
let data: Record<string, unknown>;
|
||||
|
||||
if (guideId) {
|
||||
const config = this.getGuideConfig(guideId);
|
||||
if (!config) {
|
||||
LogUtils.error(TAG, `引导配置不存在: ${guideId}`);
|
||||
return "";
|
||||
}
|
||||
data = { config: config };
|
||||
} else {
|
||||
data = {
|
||||
version: this._configVersion,
|
||||
configs: this.getAllGuideConfigs(),
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
return JSON.stringify(data, null, 2);
|
||||
} catch (err) {
|
||||
LogUtils.error(TAG, "导出配置失败", err);
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 从JSON导入配置
|
||||
* @param jsonStr JSON字符串
|
||||
* @returns 是否导入成功
|
||||
*/
|
||||
public importFromJSON(jsonStr: string): boolean {
|
||||
try {
|
||||
const data = JSON.parse(jsonStr);
|
||||
|
||||
if (data.config) {
|
||||
// 单个配置
|
||||
return this.addGuideConfig(data.config);
|
||||
} else if (data.configs) {
|
||||
// 多个配置
|
||||
const successCount = this.addGuideConfigs(data.configs);
|
||||
LogUtils.info(
|
||||
TAG,
|
||||
`导入配置成功: ${successCount}/${data.configs.length}`,
|
||||
);
|
||||
return successCount > 0;
|
||||
} else {
|
||||
LogUtils.error(TAG, "JSON格式不正确");
|
||||
return false;
|
||||
}
|
||||
} catch (err) {
|
||||
LogUtils.error(TAG, "导入配置失败", err);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取配置数量
|
||||
*/
|
||||
public getConfigCount(): number {
|
||||
return this._configs.size;
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查配置是否存在
|
||||
* @param guideId 引导ID
|
||||
*/
|
||||
public hasConfig(guideId: string): boolean {
|
||||
return this._configs.has(guideId);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"ver": "4.0.24",
|
||||
"importer": "typescript",
|
||||
"imported": true,
|
||||
"uuid": "90bfebfd-3ce7-4d44-a7da-c4f7597a9163",
|
||||
"files": [],
|
||||
"subMetas": {},
|
||||
"userData": {}
|
||||
}
|
||||
239
extensions/max-studio/assets/max-studio/core/guide/GuideData.ts
Normal file
239
extensions/max-studio/assets/max-studio/core/guide/GuideData.ts
Normal file
@@ -0,0 +1,239 @@
|
||||
import { Vec2, Size } from "cc";
|
||||
|
||||
/**
|
||||
* 引导步骤类型
|
||||
*/
|
||||
export enum GuideStepType {
|
||||
/** 高亮显示 */
|
||||
HIGHLIGHT = "highlight",
|
||||
/** 点击引导 */
|
||||
CLICK = "click",
|
||||
/** 文本提示 */
|
||||
TEXT = "text",
|
||||
/** 等待 */
|
||||
WAIT = "wait",
|
||||
/** 自定义 */
|
||||
CUSTOM = "custom",
|
||||
}
|
||||
|
||||
/**
|
||||
* 引导触发条件类型
|
||||
*/
|
||||
export enum GuideTriggerType {
|
||||
/** 立即触发 */
|
||||
IMMEDIATE = "immediate",
|
||||
/** 延迟触发 */
|
||||
DELAY = "delay",
|
||||
/** 事件触发 */
|
||||
EVENT = "event",
|
||||
/** 条件触发 */
|
||||
CONDITION = "condition",
|
||||
}
|
||||
|
||||
/**
|
||||
* 引导遮罩形状
|
||||
*/
|
||||
export enum GuideMaskShape {
|
||||
/** 矩形 */
|
||||
RECT = "rect",
|
||||
/** 圆形 */
|
||||
CIRCLE = "circle",
|
||||
/** 椭圆 */
|
||||
ELLIPSE = "ellipse",
|
||||
}
|
||||
|
||||
/**
|
||||
* 引导箭头方向
|
||||
*/
|
||||
export enum GuideArrowDirection {
|
||||
UP = "up",
|
||||
DOWN = "down",
|
||||
LEFT = "left",
|
||||
RIGHT = "right",
|
||||
UP_LEFT = "up_left",
|
||||
UP_RIGHT = "up_right",
|
||||
DOWN_LEFT = "down_left",
|
||||
DOWN_RIGHT = "down_right",
|
||||
}
|
||||
|
||||
/**
|
||||
* 引导状态
|
||||
*/
|
||||
export enum GuideState {
|
||||
/** 未开始 */
|
||||
NOT_STARTED = "not_started",
|
||||
/** 进行中 */
|
||||
RUNNING = "running",
|
||||
/** 已完成 */
|
||||
COMPLETED = "completed",
|
||||
/** 已跳过 */
|
||||
SKIPPED = "skipped",
|
||||
/** 已暂停 */
|
||||
PAUSED = "paused",
|
||||
}
|
||||
|
||||
/**
|
||||
* 引导分枝条件
|
||||
*/
|
||||
export interface IGuideCondition {
|
||||
/** 条件类型 */
|
||||
type: string;
|
||||
/** 条件参数 */
|
||||
params?: Record<string, unknown>;
|
||||
/** 条件表达式 */
|
||||
expression?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 引导分枝配置
|
||||
*/
|
||||
export interface IGuideBranch {
|
||||
/** 分枝ID */
|
||||
id: string;
|
||||
/** 分枝条件 */
|
||||
condition: IGuideCondition;
|
||||
/** 下一步骤ID */
|
||||
nextStepId: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 引导步骤配置
|
||||
*/
|
||||
export interface IGuideStepConfig {
|
||||
/** 步骤ID */
|
||||
id: string;
|
||||
/** 步骤类型 */
|
||||
type: GuideStepType;
|
||||
/** 步骤标题 */
|
||||
title?: string;
|
||||
/** 步骤描述 */
|
||||
description?: string;
|
||||
/** 目标节点路径 */
|
||||
targetPath?: string;
|
||||
/** 遮罩形状 */
|
||||
maskShape?: GuideMaskShape;
|
||||
/** 遮罩偏移 */
|
||||
maskOffset?: Vec2;
|
||||
/** 遮罩大小 */
|
||||
maskSize?: Size;
|
||||
/** 遮罩圆角 */
|
||||
maskRadius?: number;
|
||||
/** 提示文本位置 */
|
||||
textPosition?: Vec2;
|
||||
/** 箭头方向 */
|
||||
arrowDirection?: GuideArrowDirection;
|
||||
/** 箭头偏移 */
|
||||
arrowOffset?: Vec2;
|
||||
/** 等待时间(毫秒) */
|
||||
waitTime?: number;
|
||||
/** 触发条件 */
|
||||
trigger?: {
|
||||
type: GuideTriggerType;
|
||||
params?: Record<string, unknown>;
|
||||
};
|
||||
/** 分枝配置 */
|
||||
branches?: IGuideBranch[];
|
||||
/** 下一步骤ID */
|
||||
nextStepId?: string;
|
||||
/** 是否可跳过 */
|
||||
skippable?: boolean;
|
||||
/** 是否自动进入下一步(当目标按钮被点击时) */
|
||||
autoNextStep?: boolean;
|
||||
/** 自定义数据 */
|
||||
customData?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
/**
|
||||
* 引导配置
|
||||
*/
|
||||
export interface IGuideConfig {
|
||||
/** 引导ID */
|
||||
id: string;
|
||||
/** 引导名称 */
|
||||
name: string;
|
||||
/** 引导版本 */
|
||||
version?: string;
|
||||
/** 引导描述 */
|
||||
description?: string;
|
||||
/** 引导优先级 */
|
||||
priority?: number;
|
||||
/** 是否启用 */
|
||||
enabled?: boolean;
|
||||
/** 触发条件 */
|
||||
trigger?: {
|
||||
type: GuideTriggerType;
|
||||
params?: Record<string, unknown>;
|
||||
};
|
||||
/** 引导步骤列表 */
|
||||
steps: IGuideStepConfig[];
|
||||
/** 开始步骤ID */
|
||||
startStepId?: string;
|
||||
/** 是否可重复 */
|
||||
repeatable?: boolean;
|
||||
/** 自定义数据 */
|
||||
customData?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
/**
|
||||
* 引导运行时数据
|
||||
*/
|
||||
export interface IGuideRuntimeData {
|
||||
/** 引导配置 */
|
||||
config: IGuideConfig;
|
||||
/** 当前状态 */
|
||||
state: GuideState;
|
||||
/** 当前步骤ID */
|
||||
currentStepId?: string;
|
||||
/** 当前步骤索引 */
|
||||
currentStepIndex: number;
|
||||
/** 开始时间 */
|
||||
startTime?: number;
|
||||
/** 完成时间 */
|
||||
completeTime?: number;
|
||||
/** 暂停时间 */
|
||||
pauseTime?: number;
|
||||
/** 步骤历史 */
|
||||
stepHistory: string[];
|
||||
/** 自定义运行时数据 */
|
||||
runtimeData?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
/**
|
||||
* 引导事件类型
|
||||
*/
|
||||
export enum GuideEventType {
|
||||
/** 引导开始 */
|
||||
GUIDE_START = "guide_start",
|
||||
/** 引导完成 */
|
||||
GUIDE_COMPLETE = "guide_complete",
|
||||
/** 引导跳过 */
|
||||
GUIDE_SKIP = "guide_skip",
|
||||
/** 引导暂停 */
|
||||
GUIDE_PAUSE = "guide_pause",
|
||||
/** 引导恢复 */
|
||||
GUIDE_RESUME = "guide_resume",
|
||||
/** 步骤开始 */
|
||||
STEP_START = "step_start",
|
||||
/** 步骤完成 */
|
||||
STEP_COMPLETE = "step_complete",
|
||||
/** 步骤跳过 */
|
||||
STEP_SKIP = "step_skip",
|
||||
/** 分枝选择 */
|
||||
BRANCH_SELECT = "branch_select",
|
||||
}
|
||||
|
||||
/**
|
||||
* 引导事件数据
|
||||
*/
|
||||
export interface IGuideEvent {
|
||||
/** 事件类型 */
|
||||
type: GuideEventType;
|
||||
/** 引导ID */
|
||||
guideId: string;
|
||||
/** 步骤ID */
|
||||
stepId?: string;
|
||||
/** 事件数据 */
|
||||
data?: Record<string, unknown>;
|
||||
/** 时间戳 */
|
||||
timestamp: number;
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"ver": "4.0.24",
|
||||
"importer": "typescript",
|
||||
"imported": true,
|
||||
"uuid": "87d7cd52-ee8d-4b39-9fb9-b0f9c10d8bc3",
|
||||
"files": [],
|
||||
"subMetas": {},
|
||||
"userData": {}
|
||||
}
|
||||
@@ -0,0 +1,570 @@
|
||||
import {
|
||||
IGuideRuntimeData,
|
||||
IGuideStepConfig,
|
||||
GuideState,
|
||||
GuideEventType,
|
||||
IGuideEvent,
|
||||
IGuideBranch,
|
||||
} from "./GuideData";
|
||||
|
||||
import { Node, find } from "cc";
|
||||
import { GuideMaskComponent } from "./GuideMaskComponent";
|
||||
import { GuideStepComponent } from "./GuideStepComponent";
|
||||
import { GuideConfigManager } from "./GuideConfigManager";
|
||||
import TimeManager from "../timer/TimerManager";
|
||||
import UIManager from "../ui/UIManager";
|
||||
import { UIType } from "../ui/UIDecorator";
|
||||
import { GuideTargetComponent } from "./GuideTargetComponent";
|
||||
import StorageManager, { GUIDE_HISTORY_KEY } from "../storage/StorageManager";
|
||||
import { Singleton, singleton } from "../Singleton";
|
||||
import { EventManager } from "../event/EventManager";
|
||||
import LogUtils from "../utils/LogUtils";
|
||||
|
||||
const TAG = "GuideManager";
|
||||
|
||||
/**
|
||||
* 新手引导管理器
|
||||
*/
|
||||
export class GuideManager extends Singleton {
|
||||
private _configManager: GuideConfigManager;
|
||||
private _eventManager: EventManager;
|
||||
private _currentGuide: IGuideRuntimeData | null = null;
|
||||
private _guideHistory: Map<string, IGuideRuntimeData> = null;
|
||||
private _maskNode: Node | null = null;
|
||||
private _stepNode: Node | null = null;
|
||||
private _isRunning: boolean = false;
|
||||
private _isPaused: boolean = false;
|
||||
private _currentTargetNode: Node | null = null; // 当前目标节点
|
||||
|
||||
protected async onInit() {
|
||||
this._configManager = GuideConfigManager.getInstance();
|
||||
this._eventManager = EventManager.getInstance();
|
||||
this.createGuideNodes();
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建引导节点
|
||||
*/
|
||||
private createGuideNodes(): void {
|
||||
const guideRoot = UIManager.getInstance().getUITypeNode(UIType.GUIDE);
|
||||
if (!guideRoot) {
|
||||
LogUtils.error(TAG, "引导节点不存在,无法创建引导节点");
|
||||
return;
|
||||
}
|
||||
// 确保引导节点在最前面
|
||||
|
||||
// 优先查找遮罩节点,若无则创建
|
||||
this._maskNode = guideRoot.getChildByName("GuideMask");
|
||||
this._maskNode.active = false;
|
||||
|
||||
// 优先查找步骤节点,若无则创建
|
||||
this._stepNode = guideRoot.getChildByName("GuideStep");
|
||||
this._stepNode.active = false;
|
||||
}
|
||||
|
||||
private loadGuideHistory(): void {
|
||||
const parsedHistory =
|
||||
StorageManager.getInstance().getData(GUIDE_HISTORY_KEY);
|
||||
this._guideHistory = new Map();
|
||||
if (parsedHistory) {
|
||||
for (const [guideId, historyData] of parsedHistory) {
|
||||
const config = this._configManager.getGuideConfig(guideId);
|
||||
historyData.config = config;
|
||||
this._guideHistory.set(guideId, historyData);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 开始引导
|
||||
* @param guideId 引导ID
|
||||
* @returns 是否成功开始
|
||||
*/
|
||||
public startGuide(guideId: string): boolean {
|
||||
if (this._isRunning) {
|
||||
LogUtils.warn(TAG, `引导正在运行中,无法开始新的引导: ${guideId}`);
|
||||
return false;
|
||||
}
|
||||
|
||||
const config = this._configManager.getGuideConfig(guideId);
|
||||
if (!config) {
|
||||
LogUtils.error(TAG, `引导配置不存在: ${guideId}`);
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!config.enabled) {
|
||||
LogUtils.warn(TAG, `引导已禁用: ${guideId}`);
|
||||
return false;
|
||||
}
|
||||
// 检查是否已完成且不可重复
|
||||
if (this._guideHistory == null) {
|
||||
this.loadGuideHistory();
|
||||
}
|
||||
const history = this._guideHistory.get(guideId);
|
||||
if (
|
||||
history &&
|
||||
history.state === GuideState.COMPLETED &&
|
||||
!config.repeatable
|
||||
) {
|
||||
LogUtils.info(TAG, `引导已完成且不可重复: ${guideId}`);
|
||||
return false;
|
||||
}
|
||||
|
||||
// 创建运行时数据
|
||||
this._currentGuide = {
|
||||
config: config,
|
||||
state: GuideState.RUNNING,
|
||||
currentStepIndex: 0,
|
||||
startTime: TimeManager.getInstance().getCorrectedTime(),
|
||||
stepHistory: [],
|
||||
};
|
||||
|
||||
// 设置开始步骤
|
||||
const startStepId =
|
||||
config.startStepId ||
|
||||
(config.steps.length > 0 ? config.steps[0].id : null);
|
||||
if (startStepId) {
|
||||
this._currentGuide.currentStepId = startStepId;
|
||||
}
|
||||
|
||||
this._isRunning = true;
|
||||
this._isPaused = false;
|
||||
|
||||
// 触发引导开始事件
|
||||
this.emitGuideEvent(GuideEventType.GUIDE_START, guideId);
|
||||
|
||||
// 开始第一步
|
||||
this.executeCurrentStep();
|
||||
|
||||
LogUtils.info(TAG, `引导开始: ${guideId}`);
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* 停止引导
|
||||
* @param completed 是否完成
|
||||
*/
|
||||
public stopGuide(completed: boolean = false): void {
|
||||
if (!this._currentGuide) {
|
||||
return;
|
||||
}
|
||||
|
||||
const guideId = this._currentGuide.config.id;
|
||||
|
||||
// 更新状态
|
||||
this._currentGuide.state = completed
|
||||
? GuideState.COMPLETED
|
||||
: GuideState.SKIPPED;
|
||||
this._currentGuide.completeTime =
|
||||
TimeManager.getInstance().getCorrectedTime();
|
||||
|
||||
// 保存到历史
|
||||
this._guideHistory.set(guideId, this._currentGuide);
|
||||
|
||||
// 隐藏UI
|
||||
this.hideGuideUI();
|
||||
|
||||
// 触发事件
|
||||
const eventType = completed
|
||||
? GuideEventType.GUIDE_COMPLETE
|
||||
: GuideEventType.GUIDE_SKIP;
|
||||
this.emitGuideEvent(eventType, guideId);
|
||||
|
||||
// 重置状态
|
||||
this._currentGuide = null;
|
||||
this._isRunning = false;
|
||||
this._isPaused = false;
|
||||
const tempGuideHistory = {};
|
||||
for (const [guideId, historyData] of this._guideHistory) {
|
||||
const tempHistoryData = JSON.parse(JSON.stringify(historyData));
|
||||
tempHistoryData.config = undefined;
|
||||
tempGuideHistory[guideId] = tempHistoryData;
|
||||
}
|
||||
StorageManager.getInstance().setData(
|
||||
GUIDE_HISTORY_KEY,
|
||||
tempGuideHistory,
|
||||
);
|
||||
LogUtils.info(TAG, `引导${completed ? "完成" : "跳过"}: ${guideId}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* 暂停引导
|
||||
*/
|
||||
public pauseGuide(): void {
|
||||
if (!this._currentGuide || this._isPaused) {
|
||||
return;
|
||||
}
|
||||
|
||||
this._isPaused = true;
|
||||
this._currentGuide.pauseTime = Date.now();
|
||||
this.hideGuideUI();
|
||||
|
||||
this.emitGuideEvent(
|
||||
GuideEventType.GUIDE_PAUSE,
|
||||
this._currentGuide.config.id,
|
||||
);
|
||||
LogUtils.info(TAG, `引导暂停: ${this._currentGuide.config.id}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* 恢复引导
|
||||
*/
|
||||
public resumeGuide(): void {
|
||||
if (!this._currentGuide || !this._isPaused) {
|
||||
return;
|
||||
}
|
||||
|
||||
this._isPaused = false;
|
||||
this._currentGuide.pauseTime = undefined;
|
||||
this.executeCurrentStep();
|
||||
|
||||
this.emitGuideEvent(
|
||||
GuideEventType.GUIDE_RESUME,
|
||||
this._currentGuide.config.id,
|
||||
);
|
||||
LogUtils.info(TAG, `引导恢复: ${this._currentGuide.config.id}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* 执行当前步骤
|
||||
*/
|
||||
private executeCurrentStep(): void {
|
||||
if (!this._currentGuide || this._isPaused) {
|
||||
return;
|
||||
}
|
||||
|
||||
const currentStep = this.getCurrentStep();
|
||||
if (!currentStep) {
|
||||
LogUtils.warn(TAG, "当前步骤不存在,引导结束");
|
||||
this.stopGuide(true);
|
||||
return;
|
||||
}
|
||||
|
||||
// 记录步骤历史
|
||||
this._currentGuide.stepHistory.push(currentStep.id);
|
||||
|
||||
// 触发步骤开始事件
|
||||
this.emitGuideEvent(
|
||||
GuideEventType.STEP_START,
|
||||
this._currentGuide.config.id,
|
||||
currentStep.id,
|
||||
);
|
||||
|
||||
// 显示引导UI
|
||||
this.showGuideUI(currentStep);
|
||||
|
||||
LogUtils.info(TAG, `执行引导步骤: ${currentStep.id}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* 下一步
|
||||
*/
|
||||
public nextStep(): void {
|
||||
LogUtils.info(TAG, `开始下一步`);
|
||||
if (!this._currentGuide) {
|
||||
return;
|
||||
}
|
||||
|
||||
const currentStep = this.getCurrentStep();
|
||||
if (!currentStep) {
|
||||
return;
|
||||
}
|
||||
|
||||
// 触发步骤完成事件
|
||||
this.emitGuideEvent(
|
||||
GuideEventType.STEP_COMPLETE,
|
||||
this._currentGuide.config.id,
|
||||
currentStep.id,
|
||||
);
|
||||
|
||||
// 检查分枝
|
||||
const nextStepId = this.getNextStepId(currentStep);
|
||||
if (nextStepId) {
|
||||
this.goToStep(nextStepId);
|
||||
} else {
|
||||
// 没有下一步,引导完成
|
||||
this.stopGuide(true);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 跳转到指定步骤
|
||||
* @param stepId 步骤ID
|
||||
*/
|
||||
public goToStep(stepId: string): void {
|
||||
if (!this._currentGuide) {
|
||||
return;
|
||||
}
|
||||
|
||||
const stepIndex = this._currentGuide.config.steps.findIndex(
|
||||
(step) => step.id === stepId,
|
||||
);
|
||||
if (stepIndex === -1) {
|
||||
LogUtils.error(TAG, `步骤不存在: ${stepId}`);
|
||||
return;
|
||||
}
|
||||
|
||||
this._currentGuide.currentStepId = stepId;
|
||||
this._currentGuide.currentStepIndex = stepIndex;
|
||||
this.executeCurrentStep();
|
||||
}
|
||||
|
||||
public getCurrentGuideId(): string | null {
|
||||
if (!this._currentGuide) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return this._currentGuide.config.id;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取当前步骤
|
||||
*/
|
||||
public getCurrentStep(): IGuideStepConfig | null {
|
||||
if (!this._currentGuide) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (this._currentGuide.currentStepId) {
|
||||
return (
|
||||
this._currentGuide.config.steps.find(
|
||||
(step) => step.id === this._currentGuide.currentStepId,
|
||||
) || null
|
||||
);
|
||||
}
|
||||
|
||||
if (
|
||||
this._currentGuide.currentStepIndex <
|
||||
this._currentGuide.config.steps.length
|
||||
) {
|
||||
return this._currentGuide.config.steps[
|
||||
this._currentGuide.currentStepIndex
|
||||
];
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取下一步骤ID
|
||||
* @param currentStep 当前步骤
|
||||
*/
|
||||
private getNextStepId(currentStep: IGuideStepConfig): string | null {
|
||||
// 检查分枝条件
|
||||
if (currentStep.branches && currentStep.branches.length > 0) {
|
||||
for (const branch of currentStep.branches) {
|
||||
if (this.evaluateBranchCondition(branch)) {
|
||||
this.emitGuideEvent(
|
||||
GuideEventType.BRANCH_SELECT,
|
||||
this._currentGuide.config.id,
|
||||
currentStep.id,
|
||||
{
|
||||
branchId: branch.id,
|
||||
nextStepId: branch.nextStepId,
|
||||
},
|
||||
);
|
||||
return branch.nextStepId;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 使用默认下一步
|
||||
if (currentStep.nextStepId) {
|
||||
return currentStep.nextStepId;
|
||||
}
|
||||
|
||||
// 使用顺序下一步
|
||||
const currentIndex = this._currentGuide.currentStepIndex;
|
||||
if (currentIndex + 1 < this._currentGuide.config.steps.length) {
|
||||
return this._currentGuide.config.steps[currentIndex + 1].id;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 评估分枝条件
|
||||
* @param branch 分枝配置
|
||||
*/
|
||||
private evaluateBranchCondition(branch: IGuideBranch): boolean {
|
||||
// TODO: 实现条件评估逻辑
|
||||
// 这里可以根据条件类型和参数进行具体的条件判断
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* 显示引导UI
|
||||
* @param step 步骤配置
|
||||
*/
|
||||
private showGuideUI(step: IGuideStepConfig): void {
|
||||
// 显示遮罩
|
||||
if (this._maskNode) {
|
||||
this._maskNode.active = true;
|
||||
const maskComponent =
|
||||
this._maskNode.getComponent(GuideMaskComponent);
|
||||
if (maskComponent) {
|
||||
maskComponent.showMask(step);
|
||||
}
|
||||
}
|
||||
|
||||
// 显示步骤UI
|
||||
if (this._stepNode) {
|
||||
this._stepNode.active = true;
|
||||
const stepComponent =
|
||||
this._stepNode.getComponent(GuideStepComponent);
|
||||
if (stepComponent) {
|
||||
stepComponent.showStep(step);
|
||||
}
|
||||
}
|
||||
|
||||
// 在目标节点上添加 GuideTargetComponent
|
||||
this.addTargetComponent(step);
|
||||
}
|
||||
|
||||
/**
|
||||
* 隐藏引导UI
|
||||
*/
|
||||
private hideGuideUI(): void {
|
||||
if (this._maskNode) {
|
||||
this._maskNode.active = false;
|
||||
}
|
||||
if (this._stepNode) {
|
||||
this._stepNode.active = false;
|
||||
}
|
||||
|
||||
// 移除目标节点上的 GuideTargetComponent
|
||||
this.removeTargetComponent();
|
||||
}
|
||||
|
||||
/**
|
||||
* 在目标节点上添加 GuideTargetComponent
|
||||
* @param step 步骤配置
|
||||
*/
|
||||
private addTargetComponent(step: IGuideStepConfig): void {
|
||||
if (!this._currentGuide) {
|
||||
return;
|
||||
}
|
||||
|
||||
// 先移除之前的组件
|
||||
this.removeTargetComponent();
|
||||
|
||||
// 查找目标节点
|
||||
let targetNode: Node | null = null;
|
||||
|
||||
// 通过路径查找
|
||||
if (step.targetPath) {
|
||||
targetNode = find(step.targetPath);
|
||||
}
|
||||
|
||||
if (!targetNode) {
|
||||
LogUtils.warn(
|
||||
TAG,
|
||||
`未找到目标节点,无法添加 GuideTargetComponent: ${step.targetPath}`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// 检查是否已经有 GuideTargetComponent
|
||||
let targetComponent = targetNode.getComponent(GuideTargetComponent);
|
||||
if (!targetComponent) {
|
||||
targetComponent = targetNode.addComponent(GuideTargetComponent);
|
||||
}
|
||||
|
||||
// 设置引导信息
|
||||
targetComponent.setGuideInfo(
|
||||
this._currentGuide.config.id,
|
||||
step.id,
|
||||
step,
|
||||
);
|
||||
this._currentTargetNode = targetNode;
|
||||
|
||||
LogUtils.info(TAG, `已在目标节点添加 GuideTargetComponent: ${step.id}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* 移除目标节点上的 GuideTargetComponent
|
||||
*/
|
||||
private removeTargetComponent(): void {
|
||||
if (!this._currentTargetNode) {
|
||||
return;
|
||||
}
|
||||
|
||||
const targetComponent =
|
||||
this._currentTargetNode.getComponent(GuideTargetComponent);
|
||||
if (targetComponent) {
|
||||
const stepId = targetComponent.getStepId();
|
||||
targetComponent.destroy();
|
||||
LogUtils.info(
|
||||
TAG,
|
||||
`已移除目标节点的 GuideTargetComponent: ${stepId}`,
|
||||
);
|
||||
}
|
||||
|
||||
this._currentTargetNode = null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 触发引导事件
|
||||
* @param type 事件类型
|
||||
* @param guideId 引导ID
|
||||
* @param stepId 步骤ID
|
||||
* @param data 事件数据
|
||||
*/
|
||||
private emitGuideEvent(
|
||||
type: GuideEventType,
|
||||
guideId: string,
|
||||
stepId?: string,
|
||||
data?: Record<string, unknown>,
|
||||
): void {
|
||||
const event: IGuideEvent = {
|
||||
type: type,
|
||||
guideId: guideId,
|
||||
stepId: stepId,
|
||||
data: data,
|
||||
timestamp: Date.now(),
|
||||
};
|
||||
|
||||
this._eventManager.emit(type, event);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取当前引导
|
||||
*/
|
||||
public getCurrentGuide(): IGuideRuntimeData | null {
|
||||
return this._currentGuide;
|
||||
}
|
||||
|
||||
/**
|
||||
* 是否正在运行引导
|
||||
*/
|
||||
public isRunning(): boolean {
|
||||
return this._isRunning;
|
||||
}
|
||||
|
||||
/**
|
||||
* 是否暂停
|
||||
*/
|
||||
public isPaused(): boolean {
|
||||
return this._isPaused;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取引导历史
|
||||
* @param guideId 引导ID
|
||||
*/
|
||||
public getGuideHistory(guideId: string): IGuideRuntimeData | null {
|
||||
return this._guideHistory.get(guideId) || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 清除引导历史
|
||||
* @param guideId 引导ID,不传则清除所有
|
||||
*/
|
||||
public clearGuideHistory(guideId?: string): void {
|
||||
if (guideId) {
|
||||
this._guideHistory.delete(guideId);
|
||||
} else {
|
||||
this._guideHistory.clear();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"ver": "4.0.24",
|
||||
"importer": "typescript",
|
||||
"imported": true,
|
||||
"uuid": "c0d3d7a2-d0ce-4060-bcb9-0c83426243c0",
|
||||
"files": [],
|
||||
"subMetas": {},
|
||||
"userData": {}
|
||||
}
|
||||
@@ -0,0 +1,521 @@
|
||||
import {
|
||||
Component,
|
||||
_decorator,
|
||||
Graphics,
|
||||
UITransform,
|
||||
Vec2,
|
||||
Vec3,
|
||||
Size,
|
||||
view,
|
||||
Node,
|
||||
find,
|
||||
Widget,
|
||||
Color,
|
||||
EventTouch,
|
||||
Input,
|
||||
} from "cc";
|
||||
import { IGuideStepConfig, GuideMaskShape } from "./GuideData";
|
||||
import UIManager from "../ui/UIManager";
|
||||
import { StringUtils } from "../utils/StringUtils";
|
||||
import { GuideManager } from "./GuideManager";
|
||||
import LogUtils from "../utils/LogUtils";
|
||||
|
||||
const { ccclass, property } = _decorator;
|
||||
const TAG = "GuideMaskComponent";
|
||||
|
||||
/**
|
||||
* 引导遮罩组件
|
||||
* 实现挖洞效果和按钮屏蔽功能
|
||||
*/
|
||||
@ccclass("GuideMaskComponent")
|
||||
export class GuideMaskComponent extends Component {
|
||||
@property({
|
||||
displayName: "遮罩颜色",
|
||||
tooltip: "遮罩的颜色",
|
||||
})
|
||||
public maskColor: Color = new Color(0, 0, 0, 128);
|
||||
|
||||
@property({
|
||||
displayName: "高亮边框颜色",
|
||||
tooltip: "高亮区域边框的颜色",
|
||||
})
|
||||
public highlightBorderColor: Color = new Color(255, 255, 255, 255);
|
||||
|
||||
@property({
|
||||
displayName: "高亮边框宽度",
|
||||
tooltip: "高亮区域边框的宽度",
|
||||
})
|
||||
public highlightBorderWidth: number = 2;
|
||||
|
||||
private _graphics: Graphics | null = null;
|
||||
private _uiTransform: UITransform | null = null;
|
||||
private _currentStep: IGuideStepConfig | null = null;
|
||||
private _targetNode: Node | null = null;
|
||||
private _screenSize: Size = new Size();
|
||||
|
||||
protected onLoad(): void {
|
||||
this.initComponents();
|
||||
this.setupMask();
|
||||
this.setupTouchEvents();
|
||||
}
|
||||
|
||||
protected onEnable(): void {
|
||||
this.updateScreenSize();
|
||||
// onEnable 时重新启用触摸事件
|
||||
this.enableTouchEvents();
|
||||
}
|
||||
|
||||
protected onDisable(): void {
|
||||
this.disableTouchEvents();
|
||||
}
|
||||
|
||||
/**
|
||||
* 显示遮罩
|
||||
* @param step 引导步骤配置
|
||||
*/
|
||||
public showMask(step: IGuideStepConfig): void {
|
||||
this._currentStep = step;
|
||||
this.findTargetNode();
|
||||
this.drawMask();
|
||||
// 显示遮罩时确保触摸事件已启用
|
||||
this.enableTouchEvents();
|
||||
}
|
||||
|
||||
/**
|
||||
* 隐藏遮罩
|
||||
*/
|
||||
public hideMask(): void {
|
||||
if (this._graphics) {
|
||||
this._graphics.clear();
|
||||
}
|
||||
this._currentStep = null;
|
||||
this._targetNode = null;
|
||||
// 隐藏遮罩时禁用触摸事件
|
||||
this.disableTouchEvents();
|
||||
}
|
||||
|
||||
/**
|
||||
* 初始化组件
|
||||
*/
|
||||
private initComponents(): void {
|
||||
// 添加Graphics组件
|
||||
this._graphics = this.node.getComponent(Graphics);
|
||||
if (!this._graphics) {
|
||||
this._graphics = this.node.addComponent(Graphics);
|
||||
}
|
||||
|
||||
// 添加UITransform组件
|
||||
this._uiTransform = this.node.getComponent(UITransform);
|
||||
if (!this._uiTransform) {
|
||||
this._uiTransform = this.node.addComponent(UITransform);
|
||||
}
|
||||
|
||||
// 添加Widget组件,使其铺满屏幕
|
||||
let widget = this.node.getComponent(Widget);
|
||||
if (!widget) {
|
||||
widget = this.node.addComponent(Widget);
|
||||
}
|
||||
widget.isAlignTop = true;
|
||||
widget.isAlignBottom = true;
|
||||
widget.isAlignLeft = true;
|
||||
widget.isAlignRight = true;
|
||||
widget.top = 0;
|
||||
widget.bottom = 0;
|
||||
widget.left = 0;
|
||||
widget.right = 0;
|
||||
// 添加这行关键设置
|
||||
widget.alignMode = Widget.AlignMode.ON_WINDOW_RESIZE;
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置遮罩
|
||||
*/
|
||||
private setupMask(): void {
|
||||
this.updateScreenSize();
|
||||
if (this._uiTransform) {
|
||||
// 设置锚点为左下角
|
||||
this._uiTransform.setAnchorPoint(0, 0);
|
||||
// 设置内容尺寸为屏幕尺寸
|
||||
this._uiTransform.setContentSize(this._screenSize);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新屏幕尺寸
|
||||
*/
|
||||
private updateScreenSize(): void {
|
||||
const visibleSize = view.getVisibleSize();
|
||||
this._screenSize.width = visibleSize.width;
|
||||
this._screenSize.height = visibleSize.height;
|
||||
}
|
||||
|
||||
/**
|
||||
* 查找目标节点
|
||||
*/
|
||||
private findTargetNode(): void {
|
||||
if (!this._currentStep) {
|
||||
return;
|
||||
}
|
||||
|
||||
this._targetNode = null;
|
||||
|
||||
// 通过路径查找
|
||||
if (this._currentStep.targetPath) {
|
||||
this._targetNode = find(this._currentStep.targetPath);
|
||||
}
|
||||
|
||||
if (!this._targetNode) {
|
||||
LogUtils.warn(
|
||||
TAG,
|
||||
`未找到目标节点: ${this._currentStep.targetPath}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 绘制遮罩
|
||||
*/
|
||||
private drawMask(): void {
|
||||
if (!this._graphics || !this._currentStep) {
|
||||
return;
|
||||
}
|
||||
|
||||
this._graphics.clear();
|
||||
|
||||
// 绘制全屏遮罩 - 使用简单的 (0,0) 起点
|
||||
this._graphics.fillColor = this.maskColor;
|
||||
this._graphics.fillRect(
|
||||
0,
|
||||
0,
|
||||
this._uiTransform.contentSize.width,
|
||||
this._uiTransform.contentSize.height
|
||||
);
|
||||
|
||||
// 绘制挖洞
|
||||
this.drawHole();
|
||||
}
|
||||
|
||||
/**
|
||||
* 绘制挖洞效果
|
||||
*/
|
||||
private drawHole(): void {
|
||||
if (!this._graphics || !this._targetNode || !this._currentStep) {
|
||||
return;
|
||||
}
|
||||
|
||||
// 获取目标节点的世界坐标和尺寸
|
||||
const targetTransform = this._targetNode.getComponent(UITransform);
|
||||
if (!targetTransform) {
|
||||
LogUtils.warn(TAG, "目标节点没有UITransform组件");
|
||||
return;
|
||||
}
|
||||
|
||||
const worldPos = this._targetNode.getWorldPosition();
|
||||
const targetSize = targetTransform.contentSize;
|
||||
|
||||
// 转换为本地坐标 - 修复坐标转换问题
|
||||
const localPos = new Vec3();
|
||||
// 使用父节点(Canvas)进行坐标转换,而不是当前遮罩节点
|
||||
const canvas = find("Canvas");
|
||||
if (canvas) {
|
||||
const canvasTransform = canvas.getComponent(UITransform);
|
||||
canvasTransform.convertToNodeSpaceAR(worldPos, localPos);
|
||||
|
||||
// 由于遮罩节点锚点在左下角(0,0),需要调整坐标
|
||||
// Canvas 的坐标系原点在中心,需要转换到左下角坐标系
|
||||
localPos.x += canvasTransform.contentSize.width / 2;
|
||||
localPos.y += canvasTransform.contentSize.height / 2;
|
||||
|
||||
// 调整挖洞位置,使其以目标节点中心为基准
|
||||
localPos.x -= targetSize.width / 2;
|
||||
localPos.y -= targetSize.height / 2;
|
||||
} else {
|
||||
// 备用方案:直接使用世界坐标
|
||||
localPos.set(worldPos.x, worldPos.y, 0);
|
||||
}
|
||||
|
||||
// 应用偏移
|
||||
const offset = this._currentStep.maskOffset || Vec2.ZERO;
|
||||
localPos.x += offset.x;
|
||||
localPos.y += offset.y;
|
||||
|
||||
// 应用自定义尺寸
|
||||
const holeSize =
|
||||
this._currentStep.maskSize ||
|
||||
new Size(targetSize.width, targetSize.height);
|
||||
|
||||
// 根据形状绘制挖洞
|
||||
const shape = this._currentStep.maskShape || GuideMaskShape.RECT;
|
||||
this.drawHoleByShape(localPos, holeSize, shape);
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据形状绘制挖洞
|
||||
* @param pos 位置
|
||||
* @param size 尺寸
|
||||
* @param shape 形状
|
||||
*/
|
||||
private drawHoleByShape(
|
||||
pos: Vec3,
|
||||
size: Size,
|
||||
shape: GuideMaskShape
|
||||
): void {
|
||||
if (!this._graphics) {
|
||||
return;
|
||||
}
|
||||
|
||||
switch (shape) {
|
||||
case GuideMaskShape.RECT:
|
||||
this.drawRectHole(pos, size);
|
||||
break;
|
||||
case GuideMaskShape.CIRCLE:
|
||||
this.drawCircleHole(pos, Math.min(size.width, size.height) / 2);
|
||||
break;
|
||||
case GuideMaskShape.ELLIPSE:
|
||||
this.drawEllipseHole(pos, size.width / 2, size.height / 2);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 绘制矩形挖洞
|
||||
* @param pos 位置
|
||||
* @param size 尺寸
|
||||
*/
|
||||
private drawRectHole(pos: Vec3, size: Size): void {
|
||||
if (!this._graphics) {
|
||||
return;
|
||||
}
|
||||
|
||||
const graphics = this._graphics;
|
||||
const x = pos.x - size.width / 2;
|
||||
const y = pos.y - size.height / 2;
|
||||
const radius = this._currentStep?.maskRadius || 0;
|
||||
|
||||
// 清除矩形区域(挖洞)
|
||||
graphics.fillColor = Color.TRANSPARENT;
|
||||
if (radius > 0) {
|
||||
graphics.roundRect(x, y, size.width, size.height, radius);
|
||||
} else {
|
||||
graphics.rect(x, y, size.width, size.height);
|
||||
}
|
||||
graphics.fill();
|
||||
|
||||
// 绘制高亮边框
|
||||
if (this.highlightBorderWidth > 0) {
|
||||
graphics.strokeColor = this.highlightBorderColor;
|
||||
graphics.lineWidth = this.highlightBorderWidth;
|
||||
if (radius > 0) {
|
||||
graphics.roundRect(x, y, size.width, size.height, radius);
|
||||
} else {
|
||||
graphics.rect(x, y, size.width, size.height);
|
||||
}
|
||||
graphics.stroke();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 绘制圆形挖洞
|
||||
* @param pos 位置
|
||||
* @param radius 半径
|
||||
*/
|
||||
private drawCircleHole(pos: Vec3, radius: number): void {
|
||||
if (!this._graphics) {
|
||||
return;
|
||||
}
|
||||
|
||||
const graphics = this._graphics;
|
||||
|
||||
// 清除圆形区域(挖洞)
|
||||
graphics.fillColor = Color.TRANSPARENT;
|
||||
graphics.circle(pos.x, pos.y, radius);
|
||||
graphics.fill();
|
||||
|
||||
// 绘制高亮边框
|
||||
if (this.highlightBorderWidth > 0) {
|
||||
graphics.strokeColor = this.highlightBorderColor;
|
||||
graphics.lineWidth = this.highlightBorderWidth;
|
||||
graphics.circle(pos.x, pos.y, radius);
|
||||
graphics.stroke();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 绘制椭圆挖洞
|
||||
* @param pos 位置
|
||||
* @param radiusX X轴半径
|
||||
* @param radiusY Y轴半径
|
||||
*/
|
||||
private drawEllipseHole(pos: Vec3, radiusX: number, radiusY: number): void {
|
||||
if (!this._graphics) {
|
||||
return;
|
||||
}
|
||||
|
||||
const graphics = this._graphics;
|
||||
|
||||
// 清除椭圆区域(挖洞)
|
||||
graphics.fillColor = Color.TRANSPARENT;
|
||||
graphics.ellipse(pos.x, pos.y, radiusX, radiusY);
|
||||
graphics.fill();
|
||||
|
||||
// 绘制高亮边框
|
||||
if (this.highlightBorderWidth > 0) {
|
||||
graphics.strokeColor = this.highlightBorderColor;
|
||||
graphics.lineWidth = this.highlightBorderWidth;
|
||||
graphics.ellipse(pos.x, pos.y, radiusX, radiusY);
|
||||
graphics.stroke();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置触摸事件
|
||||
*/
|
||||
private setupTouchEvents(): void {
|
||||
this.enableTouchEvents();
|
||||
}
|
||||
|
||||
/**
|
||||
* 启用触摸事件
|
||||
*/
|
||||
private enableTouchEvents(): void {
|
||||
if (!this.node) {
|
||||
return;
|
||||
}
|
||||
|
||||
// 先移除旧的监听器
|
||||
this.disableTouchEvents();
|
||||
|
||||
// 注册新的监听器
|
||||
this.node.on(Input.EventType.TOUCH_START, this.handleTouchEvent, this);
|
||||
this.node.on(Input.EventType.TOUCH_END, this.handleTouchEvent, this);
|
||||
this.node.on(Input.EventType.TOUCH_MOVE, this.handleTouchEvent, this);
|
||||
this.node.on(Input.EventType.TOUCH_CANCEL, this.handleTouchEvent, this);
|
||||
}
|
||||
|
||||
/**
|
||||
* 禁用触摸事件
|
||||
*/
|
||||
private disableTouchEvents(): void {
|
||||
if (!this.node) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.node.off(Input.EventType.TOUCH_START, this.handleTouchEvent, this);
|
||||
this.node.off(Input.EventType.TOUCH_END, this.handleTouchEvent, this);
|
||||
this.node.off(Input.EventType.TOUCH_MOVE, this.handleTouchEvent, this);
|
||||
this.node.off(
|
||||
Input.EventType.TOUCH_CANCEL,
|
||||
this.handleTouchEvent,
|
||||
this
|
||||
);
|
||||
}
|
||||
|
||||
private handleTouchEvent(event: EventTouch): void {
|
||||
if (
|
||||
event.type == Input.EventType.TOUCH_END &&
|
||||
StringUtils.isEmpty(this._currentStep?.targetPath)
|
||||
) {
|
||||
GuideManager.getInstance().nextStep();
|
||||
return;
|
||||
}
|
||||
|
||||
const worldPos = event.getLocation();
|
||||
// 检查是否在挖洞区域内
|
||||
if (this.isPointInHole(new Vec3(worldPos.x, worldPos.y, 0))) {
|
||||
// 允许事件穿透
|
||||
event.propagationStopped = false;
|
||||
event.preventSwallow = true;
|
||||
return;
|
||||
}
|
||||
|
||||
// 阻止事件传播到下层节点
|
||||
event.propagationStopped = true;
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查点是否在挖洞区域内
|
||||
* @param screenPos 屏幕坐标
|
||||
* @returns 是否在挖洞区域内
|
||||
*/
|
||||
public isPointInHole(screenPos: Vec3): boolean {
|
||||
if (!this._targetNode || !this._currentStep) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const targetTransform = this._targetNode.getComponent(UITransform);
|
||||
if (!targetTransform) {
|
||||
LogUtils.warn(TAG, "目标节点没有UITransform组件");
|
||||
return false;
|
||||
}
|
||||
|
||||
// 获取目标节点的世界坐标和尺寸
|
||||
const targetWorldPos = this._targetNode.getWorldPosition();
|
||||
const targetScreenPos = UIManager.getInstance()
|
||||
.getCamera()
|
||||
.worldToScreen(targetWorldPos);
|
||||
const targetSize = targetTransform.contentSize;
|
||||
const holeSize =
|
||||
this._currentStep.maskSize ||
|
||||
new Size(targetSize.width, targetSize.height);
|
||||
const shape = this._currentStep.maskShape || GuideMaskShape.RECT;
|
||||
|
||||
// 应用偏移
|
||||
const offset = this._currentStep.maskOffset || Vec2.ZERO;
|
||||
const adjustedWorldPos = new Vec3(
|
||||
targetScreenPos.x + offset.x,
|
||||
targetScreenPos.y + offset.y,
|
||||
targetScreenPos.z
|
||||
);
|
||||
|
||||
// 计算相对于目标节点中心的偏移
|
||||
const deltaX = screenPos.x - adjustedWorldPos.x;
|
||||
const deltaY = screenPos.y - adjustedWorldPos.y;
|
||||
|
||||
let result = false;
|
||||
switch (shape) {
|
||||
case GuideMaskShape.RECT:
|
||||
result =
|
||||
Math.abs(deltaX) <= holeSize.width / 2 &&
|
||||
Math.abs(deltaY) <= holeSize.height / 2;
|
||||
break;
|
||||
|
||||
case GuideMaskShape.CIRCLE:
|
||||
const radius = Math.min(holeSize.width, holeSize.height) / 2;
|
||||
const distanceSquared = deltaX * deltaX + deltaY * deltaY;
|
||||
const radiusSquared = radius * radius;
|
||||
result = distanceSquared <= radiusSquared;
|
||||
break;
|
||||
|
||||
case GuideMaskShape.ELLIPSE:
|
||||
const radiusX = holeSize.width / 2;
|
||||
const radiusY = holeSize.height / 2;
|
||||
const ellipseValue =
|
||||
(deltaX * deltaX) / (radiusX * radiusX) +
|
||||
(deltaY * deltaY) / (radiusY * radiusY);
|
||||
result = ellipseValue <= 1;
|
||||
break;
|
||||
|
||||
default:
|
||||
LogUtils.warn(TAG, `未知的挖洞形状: ${shape}`);
|
||||
result = false;
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取当前目标节点
|
||||
*/
|
||||
public getTargetNode(): Node | null {
|
||||
return this._targetNode;
|
||||
}
|
||||
|
||||
/**
|
||||
* 刷新遮罩
|
||||
*/
|
||||
public refresh(): void {
|
||||
if (this._currentStep) {
|
||||
this.showMask(this._currentStep);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"ver": "4.0.24",
|
||||
"importer": "typescript",
|
||||
"imported": true,
|
||||
"uuid": "8924c51a-a141-4c6f-bfa1-82f5a20b3fbf",
|
||||
"files": [],
|
||||
"subMetas": {},
|
||||
"userData": {}
|
||||
}
|
||||
@@ -0,0 +1,339 @@
|
||||
import {
|
||||
Component,
|
||||
_decorator,
|
||||
Node,
|
||||
Label,
|
||||
UITransform,
|
||||
Vec3,
|
||||
Vec2,
|
||||
find,
|
||||
} from "cc";
|
||||
import { IGuideStepConfig, GuideArrowDirection } from "./GuideData";
|
||||
import { GuideManager } from "./GuideManager";
|
||||
import NodeUtils from "../utils/NodeUtils";
|
||||
import LogUtils from "../utils/LogUtils";
|
||||
|
||||
const { ccclass, property } = _decorator;
|
||||
const TAG = "GuideStepComponent";
|
||||
|
||||
/**
|
||||
* 引导步骤组件
|
||||
* 支持文本提示、箭头指向等UI元素
|
||||
*/
|
||||
@ccclass("GuideStepComponent")
|
||||
export class GuideStepComponent extends Component {
|
||||
@property({
|
||||
displayName: "默认提示位置偏移",
|
||||
tooltip: "提示框相对于目标的默认偏移",
|
||||
})
|
||||
public defaultTipOffset: Vec2 = new Vec2(0, 100);
|
||||
|
||||
@property({
|
||||
displayName: "默认箭头偏移",
|
||||
tooltip: "箭头相对于目标的默认偏移",
|
||||
})
|
||||
public defaultArrowOffset: Vec2 = new Vec2(0, 50);
|
||||
|
||||
@property({
|
||||
type: Node,
|
||||
displayName: "提示节点",
|
||||
tooltip: "提示框的节点",
|
||||
})
|
||||
private tipNode: Node = null;
|
||||
|
||||
@property({
|
||||
type: Node,
|
||||
displayName: "箭头节点",
|
||||
tooltip: "箭头的节点",
|
||||
})
|
||||
private arrowNode: Node = null;
|
||||
|
||||
private _currentStep: IGuideStepConfig | null = null;
|
||||
|
||||
private _targetNode: Node | null = null;
|
||||
private _guideManager: GuideManager | null = null;
|
||||
|
||||
protected onLoad(): void {
|
||||
this._guideManager = GuideManager.getInstance();
|
||||
this.initDefaultUI();
|
||||
this.node.addComponent(UITransform);
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建默认UI
|
||||
*/
|
||||
private initDefaultUI(): void {
|
||||
const nextButton = NodeUtils.findChildRecursive(this.node, "NextBtn");
|
||||
nextButton.onClick(() => {
|
||||
this._guideManager?.nextStep();
|
||||
}, this);
|
||||
nextButton.active = false;
|
||||
|
||||
const skipButton = NodeUtils.findChildRecursive(this.node, "SkipBtn");
|
||||
skipButton.onClick(() => {
|
||||
this._guideManager?.stopGuide(false);
|
||||
}, this);
|
||||
|
||||
this.tipNode.active = false;
|
||||
}
|
||||
|
||||
/**
|
||||
* 显示引导步骤
|
||||
* @param step 步骤配置
|
||||
*/
|
||||
public showStep(step: IGuideStepConfig): void {
|
||||
this.hideStep();
|
||||
this._currentStep = step;
|
||||
this.findTargetNode();
|
||||
this.showTip();
|
||||
this.showArrow();
|
||||
}
|
||||
|
||||
/**
|
||||
* 隐藏引导步骤
|
||||
*/
|
||||
public hideStep(): void {
|
||||
this.hideTip();
|
||||
this.hideArrow();
|
||||
this._currentStep = null;
|
||||
this._targetNode = null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 查找目标节点
|
||||
*/
|
||||
private findTargetNode(): void {
|
||||
if (!this._currentStep) {
|
||||
return;
|
||||
}
|
||||
|
||||
this._targetNode = null;
|
||||
|
||||
// 通过路径查找
|
||||
if (this._currentStep.targetPath) {
|
||||
this._targetNode = find(this._currentStep.targetPath);
|
||||
}
|
||||
if (!this._targetNode) {
|
||||
LogUtils.warn(
|
||||
TAG,
|
||||
`未找到目标节点: ${this._currentStep.targetPath}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 显示提示
|
||||
*/
|
||||
private showTip(): void {
|
||||
if (!this._currentStep) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!this.tipNode) {
|
||||
return;
|
||||
}
|
||||
|
||||
// 更新提示内容
|
||||
this.updateTipContent();
|
||||
|
||||
// 设置提示位置
|
||||
this.updateTipPosition();
|
||||
|
||||
// 显示提示
|
||||
this.tipNode.active = true;
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新提示内容
|
||||
*/
|
||||
private updateTipContent(): void {
|
||||
if (!this.tipNode || !this._currentStep) {
|
||||
return;
|
||||
}
|
||||
|
||||
// 更新标题
|
||||
const titleLabel = this.tipNode.getComponentInChildren(Label);
|
||||
if (titleLabel && this._currentStep.title) {
|
||||
titleLabel.string = this._currentStep.title;
|
||||
}
|
||||
|
||||
// 更新描述
|
||||
const descLabel = this.tipNode
|
||||
.getChildByName("Description")
|
||||
?.getComponent(Label);
|
||||
if (descLabel) {
|
||||
descLabel.string = this._currentStep.description;
|
||||
}
|
||||
|
||||
// 更新按钮状态
|
||||
const skipButton = this.tipNode.getChildByName("SkipButton");
|
||||
if (skipButton) {
|
||||
skipButton.active = this._currentStep.skippable !== false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新提示位置
|
||||
*/
|
||||
private updateTipPosition(): void {
|
||||
if (!this.tipNode) {
|
||||
return;
|
||||
}
|
||||
|
||||
const position = new Vec3(Vec3.ZERO);
|
||||
|
||||
if (this._currentStep?.textPosition) {
|
||||
position.x = this._currentStep.textPosition.x;
|
||||
position.y = this._currentStep.textPosition.y;
|
||||
} else if (this._targetNode) {
|
||||
// 相对于目标节点定位
|
||||
const targetWorldPos = this._targetNode.getWorldPosition();
|
||||
const localPos = new Vec3();
|
||||
this.node
|
||||
.getComponent(UITransform)
|
||||
.convertToNodeSpaceAR(targetWorldPos, localPos);
|
||||
|
||||
position.x = localPos.x + this.defaultTipOffset.x;
|
||||
position.y = localPos.y + this.defaultTipOffset.y;
|
||||
}
|
||||
|
||||
this.tipNode.setPosition(position);
|
||||
}
|
||||
|
||||
/**
|
||||
* 隐藏提示
|
||||
*/
|
||||
private hideTip(): void {
|
||||
if (this.tipNode) {
|
||||
this.tipNode.active = false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 显示箭头
|
||||
*/
|
||||
private showArrow(): void {
|
||||
if (!this._currentStep || !this._targetNode) {
|
||||
this.arrowNode.active = false;
|
||||
return;
|
||||
}
|
||||
|
||||
if (!this.arrowNode) {
|
||||
return;
|
||||
}
|
||||
|
||||
// 设置箭头位置和方向
|
||||
this.updateArrowPosition();
|
||||
this.updateArrowDirection();
|
||||
|
||||
// 显示箭头
|
||||
this.arrowNode.active = true;
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新箭头位置
|
||||
*/
|
||||
private updateArrowPosition(): void {
|
||||
if (!this.arrowNode || !this._targetNode) {
|
||||
return;
|
||||
}
|
||||
|
||||
const position = new Vec3(Vec3.ZERO);
|
||||
|
||||
if (this._currentStep?.arrowOffset) {
|
||||
const targetWorldPos = this._targetNode.getWorldPosition();
|
||||
const localPos = new Vec3();
|
||||
this.node
|
||||
.getComponent(UITransform)
|
||||
.convertToNodeSpaceAR(targetWorldPos, localPos);
|
||||
|
||||
position.x = localPos.x + this._currentStep.arrowOffset.x;
|
||||
position.y = localPos.y + this._currentStep.arrowOffset.y;
|
||||
} else {
|
||||
const targetWorldPos = this._targetNode.getWorldPosition();
|
||||
const localPos = new Vec3();
|
||||
this.node
|
||||
.getComponent(UITransform)
|
||||
.convertToNodeSpaceAR(targetWorldPos, localPos);
|
||||
|
||||
position.x = localPos.x + this.defaultArrowOffset.x;
|
||||
position.y = localPos.y + this.defaultArrowOffset.y;
|
||||
}
|
||||
|
||||
this.arrowNode.setPosition(position);
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新箭头方向
|
||||
*/
|
||||
private updateArrowDirection(): void {
|
||||
if (!this.arrowNode || !this._currentStep) {
|
||||
return;
|
||||
}
|
||||
|
||||
const direction =
|
||||
this._currentStep.arrowDirection || GuideArrowDirection.DOWN;
|
||||
let angle = 0;
|
||||
|
||||
switch (direction) {
|
||||
case GuideArrowDirection.UP:
|
||||
angle = 0;
|
||||
break;
|
||||
case GuideArrowDirection.DOWN:
|
||||
angle = 180;
|
||||
break;
|
||||
case GuideArrowDirection.LEFT:
|
||||
angle = 90;
|
||||
break;
|
||||
case GuideArrowDirection.RIGHT:
|
||||
angle = -90;
|
||||
break;
|
||||
case GuideArrowDirection.UP_LEFT:
|
||||
angle = 45;
|
||||
break;
|
||||
case GuideArrowDirection.UP_RIGHT:
|
||||
angle = -45;
|
||||
break;
|
||||
case GuideArrowDirection.DOWN_LEFT:
|
||||
angle = 135;
|
||||
break;
|
||||
case GuideArrowDirection.DOWN_RIGHT:
|
||||
angle = -135;
|
||||
break;
|
||||
}
|
||||
|
||||
this.arrowNode.setRotationFromEuler(0, 0, angle);
|
||||
}
|
||||
|
||||
/**
|
||||
* 隐藏箭头
|
||||
*/
|
||||
private hideArrow(): void {
|
||||
if (this.arrowNode) {
|
||||
this.arrowNode.active = false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取当前步骤
|
||||
*/
|
||||
public getCurrentStep(): IGuideStepConfig | null {
|
||||
return this._currentStep;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取目标节点
|
||||
*/
|
||||
public getTargetNode(): Node | null {
|
||||
return this._targetNode;
|
||||
}
|
||||
|
||||
/**
|
||||
* 刷新显示
|
||||
*/
|
||||
public refresh(): void {
|
||||
if (this._currentStep) {
|
||||
this.showStep(this._currentStep);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"ver": "4.0.24",
|
||||
"importer": "typescript",
|
||||
"imported": true,
|
||||
"uuid": "6881b25d-9574-41af-a9e8-3bee7b53349f",
|
||||
"files": [],
|
||||
"subMetas": {},
|
||||
"userData": {}
|
||||
}
|
||||
@@ -0,0 +1,147 @@
|
||||
import { _decorator, Component, Button } from "cc";
|
||||
|
||||
import { GuideManager } from "./GuideManager";
|
||||
import { IGuideStepConfig } from "./GuideData";
|
||||
import LogUtils from "../utils/LogUtils";
|
||||
|
||||
const { ccclass } = _decorator;
|
||||
const TAG = "GuideTargetComponent";
|
||||
|
||||
/**
|
||||
* 引导目标组件
|
||||
* 用于标识当前引导步骤的目标节点
|
||||
*/
|
||||
@ccclass("GuideTargetComponent")
|
||||
export class GuideTargetComponent extends Component {
|
||||
private _guideId: string = "";
|
||||
private _stepId: string = "";
|
||||
private _stepConfig: IGuideStepConfig | null = null;
|
||||
private _buttonComponent: Button | null = null;
|
||||
|
||||
onLoad() {
|
||||
this.setupButtonListener();
|
||||
}
|
||||
|
||||
onDestroy() {
|
||||
this.removeButtonListener();
|
||||
LogUtils.info(
|
||||
TAG,
|
||||
`引导目标组件已销毁: ${this._guideId}-${this._stepId}`
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置引导信息
|
||||
* @param guideId 引导ID
|
||||
* @param stepId 步骤ID
|
||||
* @param stepConfig 步骤配置
|
||||
*/
|
||||
public setGuideInfo(
|
||||
guideId: string,
|
||||
stepId: string,
|
||||
stepConfig: IGuideStepConfig
|
||||
): void {
|
||||
this._guideId = guideId;
|
||||
this._stepId = stepId;
|
||||
this._stepConfig = stepConfig;
|
||||
|
||||
// 重新设置按钮监听
|
||||
this.setupButtonListener();
|
||||
|
||||
LogUtils.info(
|
||||
TAG,
|
||||
`设置引导目标: ${guideId}-${stepId}, 自动下一步: ${stepConfig.autoNextStep || false}`
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取引导ID
|
||||
*/
|
||||
public getGuideId(): string {
|
||||
return this._guideId;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取步骤ID
|
||||
*/
|
||||
public getStepId(): string {
|
||||
return this._stepId;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取步骤配置
|
||||
*/
|
||||
public getStepConfig(): IGuideStepConfig | null {
|
||||
return this._stepConfig;
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置按钮监听
|
||||
*/
|
||||
private setupButtonListener(): void {
|
||||
// 移除之前的监听
|
||||
this.removeButtonListener();
|
||||
|
||||
// 检查是否有Button组件
|
||||
this._buttonComponent = this.node.getComponent(Button);
|
||||
if (!this._buttonComponent) {
|
||||
return;
|
||||
}
|
||||
|
||||
// 只有配置了自动进入下一步才添加监听
|
||||
if (!this._stepConfig?.autoNextStep) {
|
||||
return;
|
||||
}
|
||||
|
||||
// 添加点击监听
|
||||
this._buttonComponent.node.on(
|
||||
Button.EventType.CLICK,
|
||||
this.onButtonClick,
|
||||
this
|
||||
);
|
||||
LogUtils.info(
|
||||
TAG,
|
||||
`已添加按钮点击监听: ${this._guideId}-${this._stepId}`
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 移除按钮监听
|
||||
*/
|
||||
private removeButtonListener(): void {
|
||||
if (this._buttonComponent) {
|
||||
this._buttonComponent.node.off(
|
||||
Button.EventType.CLICK,
|
||||
this.onButtonClick,
|
||||
this
|
||||
);
|
||||
this._buttonComponent = null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 按钮点击处理
|
||||
*/
|
||||
private onButtonClick(): void {
|
||||
if (!this._stepConfig?.autoNextStep) {
|
||||
return;
|
||||
}
|
||||
|
||||
LogUtils.info(
|
||||
TAG,
|
||||
`按钮被点击,自动进入下一步: ${this._guideId}-${this._stepId}`
|
||||
);
|
||||
|
||||
// 延迟执行,确保按钮的原始点击事件先执行
|
||||
this.scheduleOnce(() => {
|
||||
const guideManager = GuideManager.getInstance();
|
||||
if (
|
||||
guideManager &&
|
||||
guideManager.getCurrentGuide()?.config.id === this._guideId &&
|
||||
this._stepId == guideManager.getCurrentStep()?.id
|
||||
) {
|
||||
guideManager.nextStep();
|
||||
}
|
||||
}, 0.1);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"ver": "4.0.24",
|
||||
"importer": "typescript",
|
||||
"imported": true,
|
||||
"uuid": "c85c1205-8f1f-4cf8-b6b5-0e2a8029d276",
|
||||
"files": [],
|
||||
"subMetas": {},
|
||||
"userData": {}
|
||||
}
|
||||
9
extensions/max-studio/assets/max-studio/core/net.meta
Normal file
9
extensions/max-studio/assets/max-studio/core/net.meta
Normal file
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"ver": "1.2.0",
|
||||
"importer": "directory",
|
||||
"imported": true,
|
||||
"uuid": "fa254158-c411-49a6-98e5-506cf9e1dca8",
|
||||
"files": [],
|
||||
"subMetas": {},
|
||||
"userData": {}
|
||||
}
|
||||
412
extensions/max-studio/assets/max-studio/core/net/HttpClient.ts
Normal file
412
extensions/max-studio/assets/max-studio/core/net/HttpClient.ts
Normal file
@@ -0,0 +1,412 @@
|
||||
import { HttpError, HttpResponse, IResponseData } from "./HttpRequest";
|
||||
import LogUtils from "../utils/LogUtils";
|
||||
import { HttpMethod, NetworkConfig } from "./Types";
|
||||
import { StringUtils } from "../utils/StringUtils";
|
||||
import { Toast } from "../ui/default/DefaultToast";
|
||||
|
||||
const TAG = "HttpClient";
|
||||
|
||||
interface RequestQueue<T = any> {
|
||||
abortController: AbortController;
|
||||
callbacks: Array<(response: HttpResponse<T>) => void>;
|
||||
responsePromise: Promise<HttpResponse<T>>;
|
||||
}
|
||||
|
||||
export default class HttpClient {
|
||||
private rootUrl: string = "";
|
||||
private headers: Map<string, string> = new Map();
|
||||
private requestQueues: Map<string, RequestQueue<any>> = new Map();
|
||||
private readonly timeout: number = 30000; // 30秒超时
|
||||
private readonly maxRetries: number = 3;
|
||||
private token: string = "";
|
||||
|
||||
constructor(cfg: NetworkConfig) {
|
||||
// 设置默认请求头
|
||||
this.headers.set("Content-Type", "application/json");
|
||||
this.rootUrl = cfg.httpRootUrl;
|
||||
this.timeout = cfg.defaultTimeout;
|
||||
this.maxRetries = cfg.defaultRetryCount;
|
||||
}
|
||||
|
||||
public setHeader(key: string, value: string) {
|
||||
this.headers.set(key, value);
|
||||
}
|
||||
|
||||
public async send<T extends IResponseData>(
|
||||
url: string,
|
||||
method: HttpMethod,
|
||||
data: unknown,
|
||||
callback: (response: HttpResponse<T>) => void = null,
|
||||
headers?: Map<string, string>
|
||||
): Promise<HttpResponse<T>> {
|
||||
// 生成请求签名,包含 URL、方法、数据和头部信息
|
||||
const requestSignature = this.generateRequestSignature(
|
||||
url,
|
||||
method,
|
||||
data,
|
||||
headers
|
||||
);
|
||||
|
||||
// 检查是否已有相同的请求在进行中
|
||||
const reqInfo = this.requestQueues.get(requestSignature);
|
||||
if (reqInfo) {
|
||||
// 复用现有请求,将回调添加到队列中
|
||||
reqInfo.callbacks.push(callback);
|
||||
return reqInfo.responsePromise;
|
||||
}
|
||||
|
||||
if (!url.toLowerCase().startsWith("http")) {
|
||||
url = this.rootUrl + url;
|
||||
}
|
||||
|
||||
// 创建新的请求
|
||||
const abortController = new AbortController();
|
||||
const callbacks = [callback];
|
||||
|
||||
const responsePromise = this.executeRequest<T>(
|
||||
url,
|
||||
method,
|
||||
data,
|
||||
headers,
|
||||
abortController
|
||||
);
|
||||
|
||||
// 将请求添加到队列
|
||||
this.requestQueues.set(requestSignature, {
|
||||
abortController,
|
||||
callbacks,
|
||||
responsePromise,
|
||||
});
|
||||
|
||||
// 处理响应并通知所有回调
|
||||
responsePromise
|
||||
.then((response) => {
|
||||
const queue = this.requestQueues.get(requestSignature);
|
||||
if (queue) {
|
||||
for (const tempCallback of queue.callbacks) {
|
||||
try {
|
||||
if (tempCallback) {
|
||||
tempCallback(response);
|
||||
}
|
||||
} catch (callError) {
|
||||
LogUtils.error(
|
||||
TAG,
|
||||
"回调函数执行失败",
|
||||
callError.message,
|
||||
callError.stack
|
||||
);
|
||||
}
|
||||
}
|
||||
this.requestQueues.delete(requestSignature);
|
||||
}
|
||||
})
|
||||
.catch((err) => {
|
||||
LogUtils.error(TAG, "请求失败", err.message, err.stack);
|
||||
const queue = this.requestQueues.get(requestSignature);
|
||||
if (queue) {
|
||||
const errorResponse: HttpResponse<T> = {
|
||||
success: false,
|
||||
message: HttpError.UNKNOWN_ERROR,
|
||||
};
|
||||
for (const tempCallback of queue.callbacks) {
|
||||
try {
|
||||
if (tempCallback) {
|
||||
tempCallback(errorResponse);
|
||||
}
|
||||
} catch (callError) {
|
||||
LogUtils.error(
|
||||
TAG,
|
||||
"回调函数执行失败",
|
||||
callError.message,
|
||||
callError.stack
|
||||
);
|
||||
}
|
||||
}
|
||||
this.requestQueues.delete(requestSignature);
|
||||
}
|
||||
});
|
||||
|
||||
return responsePromise;
|
||||
}
|
||||
|
||||
private async executeRequest<T extends IResponseData>(
|
||||
url: string,
|
||||
method: HttpMethod,
|
||||
data: unknown,
|
||||
headers?: Map<string, string>,
|
||||
abortController?: AbortController,
|
||||
retryCount: number = 0
|
||||
): Promise<HttpResponse<T>> {
|
||||
try {
|
||||
// 合并请求头
|
||||
const requestHeaders = new Headers();
|
||||
|
||||
// 添加全局头部
|
||||
for (const [key, value] of this.headers.entries()) {
|
||||
requestHeaders.set(key, value);
|
||||
}
|
||||
|
||||
// 添加请求特定头部
|
||||
if (headers) {
|
||||
for (const [key, value] of headers.entries()) {
|
||||
requestHeaders.set(key, value);
|
||||
}
|
||||
}
|
||||
|
||||
if (!StringUtils.isEmpty(this.token)) {
|
||||
let auth = this.token;
|
||||
if (!this.token.startsWith("Bearer ")) {
|
||||
auth = `Bearer ${this.token}`;
|
||||
}
|
||||
requestHeaders.set("Authorization", auth);
|
||||
}
|
||||
|
||||
// 构建请求配置
|
||||
const requestConfig: RequestInit = {
|
||||
method,
|
||||
headers: requestHeaders,
|
||||
signal: abortController?.signal,
|
||||
};
|
||||
|
||||
// 处理请求数据
|
||||
if (method === "POST" && data) {
|
||||
requestConfig.body = JSON.stringify(data);
|
||||
} else if (method === "GET" && data) {
|
||||
const paramString = this.getParamString(data);
|
||||
url += "?" + paramString;
|
||||
}
|
||||
|
||||
// 创建超时Promise
|
||||
const timeoutPromise = new Promise<never>((resolve, reject) => {
|
||||
setTimeout(() => {
|
||||
reject(new Error("Request timeout"));
|
||||
}, this.timeout);
|
||||
});
|
||||
|
||||
// 执行请求
|
||||
const response = await Promise.race([
|
||||
fetch(url, requestConfig),
|
||||
timeoutPromise,
|
||||
]);
|
||||
|
||||
// 检查响应状态
|
||||
if (!response.ok) {
|
||||
const error =
|
||||
response.status >= 500
|
||||
? new Error("Server error")
|
||||
: new Error(`HTTP ${response.status}`);
|
||||
throw error;
|
||||
}
|
||||
|
||||
// 解析响应数据
|
||||
|
||||
let responseData: HttpResponse<T>;
|
||||
|
||||
try {
|
||||
responseData = await response.json();
|
||||
} catch (err) {
|
||||
LogUtils.warn("HttpClient", "响应数据解析失败", err);
|
||||
return {
|
||||
success: false,
|
||||
message: err.message,
|
||||
};
|
||||
}
|
||||
if (
|
||||
!responseData.success &&
|
||||
!StringUtils.isEmpty(responseData.message)
|
||||
) {
|
||||
Toast.show(responseData.message);
|
||||
}
|
||||
|
||||
if (response.headers.get("Authorization")?.startsWith("Bearer ")) {
|
||||
LogUtils.log(
|
||||
"HttpClient",
|
||||
"获取到新的token",
|
||||
response.headers.get("Authorization")
|
||||
);
|
||||
this.token = response.headers.get("Authorization");
|
||||
}
|
||||
|
||||
return responseData;
|
||||
} catch (err: any) {
|
||||
// 处理取消请求
|
||||
if (err.name === "AbortError") {
|
||||
LogUtils.info("HttpClient", "请求已取消", url);
|
||||
return {
|
||||
success: false,
|
||||
message: HttpError.CANCEL,
|
||||
};
|
||||
}
|
||||
|
||||
if (retryCount < this.maxRetries) {
|
||||
LogUtils.info(
|
||||
"HttpClient",
|
||||
`重试请求 ${retryCount + 1}/${this.maxRetries}`,
|
||||
url
|
||||
);
|
||||
return this.executeRequest(
|
||||
url,
|
||||
method,
|
||||
data,
|
||||
headers,
|
||||
abortController,
|
||||
retryCount + 1
|
||||
);
|
||||
}
|
||||
|
||||
let errMessage = HttpError.UNKNOWN_ERROR;
|
||||
|
||||
// 处理超时
|
||||
if (err.message === "Request timeout") {
|
||||
errMessage = HttpError.TIMEOUT;
|
||||
} else if (err.message === "Server error") {
|
||||
errMessage = HttpError.NO_NETWORK;
|
||||
}
|
||||
|
||||
return {
|
||||
success: false,
|
||||
message: errMessage,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 取消指定签名的请求
|
||||
*/
|
||||
public cancelRequest(
|
||||
url: string,
|
||||
method: "GET" | "POST",
|
||||
data: any,
|
||||
headers?: Map<string, string>
|
||||
): boolean {
|
||||
const requestSignature = this.generateRequestSignature(
|
||||
url,
|
||||
method,
|
||||
data,
|
||||
headers
|
||||
);
|
||||
const queue = this.requestQueues.get(requestSignature);
|
||||
|
||||
if (queue) {
|
||||
// 中止 fetch 请求
|
||||
queue.abortController.abort();
|
||||
|
||||
// 通知所有回调请求被取消
|
||||
const cancelResponse: HttpResponse<any> = {
|
||||
success: false,
|
||||
message: HttpError.CANCEL,
|
||||
};
|
||||
|
||||
for (const callback of queue.callbacks) {
|
||||
callback(cancelResponse);
|
||||
}
|
||||
|
||||
// 清理队列
|
||||
this.requestQueues.delete(requestSignature);
|
||||
LogUtils.info("HttpClient", "请求已取消", requestSignature);
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* 取消所有进行中的请求
|
||||
*/
|
||||
public cancelAllRequests(): void {
|
||||
const cancelResponse: HttpResponse<any> = {
|
||||
success: false,
|
||||
message: HttpError.CANCEL,
|
||||
};
|
||||
|
||||
for (const [, queue] of this.requestQueues.entries()) {
|
||||
queue.abortController.abort();
|
||||
for (const callback of queue.callbacks) {
|
||||
callback(cancelResponse);
|
||||
}
|
||||
}
|
||||
|
||||
const count = this.requestQueues.size;
|
||||
this.requestQueues.clear();
|
||||
LogUtils.info("HttpClient", `已取消所有请求,共 ${count} 个`);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取当前进行中的请求数量
|
||||
*/
|
||||
public getPendingRequestCount(): number {
|
||||
return this.requestQueues.size;
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查指定请求是否正在进行中
|
||||
*/
|
||||
public isRequestPending(
|
||||
url: string,
|
||||
method: "GET" | "POST",
|
||||
data: any,
|
||||
headers?: Map<string, string>
|
||||
): boolean {
|
||||
const requestSignature = this.generateRequestSignature(
|
||||
url,
|
||||
method,
|
||||
data,
|
||||
headers
|
||||
);
|
||||
return this.requestQueues.has(requestSignature);
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成请求签名,用于识别相同的请求
|
||||
*/
|
||||
private generateRequestSignature(
|
||||
url: string,
|
||||
method: string,
|
||||
data: any,
|
||||
headers?: Map<string, string>
|
||||
): string {
|
||||
// 确保 URL 是完整的
|
||||
if (!url.toLowerCase().startsWith("http")) {
|
||||
url = this.rootUrl + url;
|
||||
}
|
||||
|
||||
// 序列化数据
|
||||
const dataStr = data ? JSON.stringify(data) : "";
|
||||
|
||||
// 序列化头部信息(合并全局头部和请求特定头部)
|
||||
const allHeaders = new Map(this.headers);
|
||||
if (headers) {
|
||||
for (const [key, value] of headers.entries()) {
|
||||
allHeaders.set(key, value);
|
||||
}
|
||||
}
|
||||
|
||||
const headersArray = Array.from(allHeaders.entries()).sort();
|
||||
const headersStr = JSON.stringify(headersArray);
|
||||
|
||||
// 生成签名
|
||||
return `${method}:${url}:${dataStr}:${headersStr}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* 将参数对象转换为查询字符串
|
||||
*/
|
||||
private getParamString(parameters: any): string {
|
||||
if (!parameters) return "";
|
||||
|
||||
const params = new URLSearchParams();
|
||||
|
||||
for (const name in parameters) {
|
||||
const data = parameters[name];
|
||||
if (data instanceof Object && !(data instanceof Array)) {
|
||||
for (const key in data) {
|
||||
params.append(key, data[key]);
|
||||
}
|
||||
} else {
|
||||
params.append(name, data);
|
||||
}
|
||||
}
|
||||
|
||||
return params.toString();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"ver": "4.0.24",
|
||||
"importer": "typescript",
|
||||
"imported": true,
|
||||
"uuid": "730e6326-afb0-46ca-be45-32e4eccc822a",
|
||||
"files": [],
|
||||
"subMetas": {},
|
||||
"userData": {}
|
||||
}
|
||||
156
extensions/max-studio/assets/max-studio/core/net/HttpRequest.ts
Normal file
156
extensions/max-studio/assets/max-studio/core/net/HttpRequest.ts
Normal file
@@ -0,0 +1,156 @@
|
||||
/** 请求事件 */
|
||||
export enum HttpError {
|
||||
/** 断网 */
|
||||
NO_NETWORK = "http_request_no_network",
|
||||
/** 未知错误 */
|
||||
UNKNOWN_ERROR = "http_request_unknown_error",
|
||||
/** 请求超时 */
|
||||
TIMEOUT = "http_request_timout",
|
||||
|
||||
/** 重复请求 */
|
||||
REAPEAT = "http_request_repeat",
|
||||
|
||||
/** 取消请求 */
|
||||
CANCEL = "http_request_cancel",
|
||||
}
|
||||
|
||||
export interface IResponseData {
|
||||
/** 库存数据 */
|
||||
inventoryDataList?: {
|
||||
id: string;
|
||||
count: number;
|
||||
}[];
|
||||
}
|
||||
|
||||
export interface HttpResponse<T extends IResponseData> {
|
||||
/** 是否请求成功 */
|
||||
success: boolean;
|
||||
/** 请求返回数据 */
|
||||
data?: T;
|
||||
/** 请求错误数据 */
|
||||
message?: HttpError;
|
||||
}
|
||||
|
||||
export default class HttpRequest<T = any> {
|
||||
private url: string;
|
||||
private method: "POST" | "GET";
|
||||
private postData: any;
|
||||
private request: XMLHttpRequest;
|
||||
private callback: (response: HttpResponse<T>) => void;
|
||||
private maxRetries: number = 3;
|
||||
private retryCount: number = 0;
|
||||
private headers: Map<string, string> = new Map();
|
||||
|
||||
constructor(
|
||||
url: string,
|
||||
parameters: any,
|
||||
method: "POST" | "GET",
|
||||
callback: (response: HttpResponse<T>) => void,
|
||||
isOpenTimeout: boolean = true,
|
||||
) {
|
||||
this.callback = callback;
|
||||
if (parameters && method == "GET") {
|
||||
const parametersStr = this.getParamString(parameters);
|
||||
url += url.indexOf("?") > -1 ? "&" + parametersStr : "?" + parametersStr;
|
||||
}
|
||||
this.url = url;
|
||||
this.postData = parameters;
|
||||
this.createHttpRequest();
|
||||
}
|
||||
|
||||
public setHeader(key: string, value: string) {
|
||||
this.headers.set(key, value);
|
||||
this.request.setRequestHeader(key, value);
|
||||
}
|
||||
|
||||
public send() {
|
||||
this.request.send(this.postData);
|
||||
}
|
||||
|
||||
/**
|
||||
* 中止请求
|
||||
*/
|
||||
public abort() {
|
||||
if (this.request) {
|
||||
this.request.abort();
|
||||
}
|
||||
}
|
||||
|
||||
private onTimeout() {
|
||||
this.handleError(HttpError.TIMEOUT);
|
||||
}
|
||||
|
||||
private onLoadend() {
|
||||
if (this.request.status == 500) {
|
||||
this.handleError(HttpError.NO_NETWORK);
|
||||
}
|
||||
}
|
||||
|
||||
private onError() {
|
||||
this.handleError(HttpError.UNKNOWN_ERROR);
|
||||
}
|
||||
|
||||
private onReadyStateChange() {
|
||||
if (this.request.readyState != 4) {
|
||||
return;
|
||||
}
|
||||
if (this.request.status == 200) {
|
||||
try {
|
||||
const res = JSON.parse(this.request.responseText);
|
||||
this.callback({
|
||||
success: true,
|
||||
data: res,
|
||||
});
|
||||
} catch (error) {
|
||||
this.callback({
|
||||
success: true,
|
||||
message: this.request.response,
|
||||
});
|
||||
}
|
||||
} else {
|
||||
this.handleError(HttpError.UNKNOWN_ERROR);
|
||||
}
|
||||
}
|
||||
|
||||
private handleError(error: HttpError) {
|
||||
if (this.retryCount < this.maxRetries) {
|
||||
this.retryCount++;
|
||||
this.request.abort();
|
||||
this.createHttpRequest();
|
||||
this.send();
|
||||
} else {
|
||||
this.callback({
|
||||
success: false,
|
||||
message: error,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private createHttpRequest() {
|
||||
this.request = new XMLHttpRequest();
|
||||
this.request.open(this.method, this.url);
|
||||
this.request.timeout = 30000;
|
||||
this.request.ontimeout = this.onTimeout.bind(this);
|
||||
this.request.onloadend = this.onLoadend.bind(this);
|
||||
this.request.onerror = this.onError.bind(this);
|
||||
this.request.onreadystatechange = this.onReadyStateChange.bind(this);
|
||||
if (this.headers != null) {
|
||||
for (const [key, value] of this.headers.entries()) {
|
||||
this.request.setRequestHeader(key, value);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private getParamString(parameters: any) {
|
||||
let result = "";
|
||||
for (const name in parameters) {
|
||||
const data = parameters[name];
|
||||
if (data instanceof Object) {
|
||||
for (const key in data) result += `${key}=${data[key]}&`;
|
||||
} else {
|
||||
result += `${name}=${data}&`;
|
||||
}
|
||||
}
|
||||
return result.substring(0, result.length - 1);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"ver": "4.0.24",
|
||||
"importer": "typescript",
|
||||
"imported": true,
|
||||
"uuid": "76617246-abfb-4bfe-8178-9fddff43a135",
|
||||
"files": [],
|
||||
"subMetas": {},
|
||||
"userData": {}
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
import LogUtils from "../utils/LogUtils";
|
||||
import { WebSocketClient } from "./WebSocketClient";
|
||||
import { SerializerManager } from "./Serializer";
|
||||
import { WebSocketConfig } from "./Types";
|
||||
import { singleton, Singleton } from "../Singleton";
|
||||
import { ResManager } from "../res/ResManager";
|
||||
import { JsonAsset } from "cc";
|
||||
import { StringUtils } from "../utils/StringUtils";
|
||||
import HttpClient from "./HttpClient";
|
||||
|
||||
const TAG = "Network";
|
||||
|
||||
/**
|
||||
* 网络管理器
|
||||
*/
|
||||
@singleton({auto: true})
|
||||
export class NetworkManager extends Singleton {
|
||||
private httpClient: HttpClient;
|
||||
private webSocketClients = new Map<string, WebSocketClient>();
|
||||
private serializerManager: SerializerManager;
|
||||
|
||||
protected async onInit() {
|
||||
LogUtils.info(TAG, "初始化网络管理器");
|
||||
this.serializerManager = new SerializerManager();
|
||||
const asset = await ResManager.getInstance().loadAsset<JsonAsset>(
|
||||
"net-config",
|
||||
JsonAsset,
|
||||
);
|
||||
if (StringUtils.isEmpty(asset?.json)) {
|
||||
LogUtils.error(TAG, "加载网络配置失败");
|
||||
return;
|
||||
}
|
||||
|
||||
this.httpClient = new HttpClient(asset.json);
|
||||
|
||||
LogUtils.info(TAG, "网络管理器初始化完成");
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取HTTP客户端
|
||||
*/
|
||||
public getHttpClient(): HttpClient {
|
||||
return this.httpClient;
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建WebSocket客户端
|
||||
*/
|
||||
public createWebSocketClient(
|
||||
name: string,
|
||||
config: WebSocketConfig,
|
||||
): WebSocketClient {
|
||||
if (this.webSocketClients.has(name)) {
|
||||
LogUtils.warn(TAG, `WebSocket客户端已存在: ${name}`);
|
||||
return this.webSocketClients.get(name)!;
|
||||
}
|
||||
|
||||
const client = new WebSocketClient(config);
|
||||
this.webSocketClients.set(name, client);
|
||||
LogUtils.info(TAG, `创建WebSocket客户端: ${name}`);
|
||||
|
||||
return client;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取WebSocket客户端
|
||||
*/
|
||||
public getWebSocketClient(name: string): WebSocketClient | null {
|
||||
return this.webSocketClients.get(name) || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取序列化管理器
|
||||
*/
|
||||
public getSerializerManager(): SerializerManager {
|
||||
return this.serializerManager;
|
||||
}
|
||||
|
||||
/**
|
||||
* 销毁网络管理器
|
||||
*/
|
||||
public destroy(): void {
|
||||
LogUtils.info(TAG, "销毁网络管理器");
|
||||
|
||||
for (const [, client] of this.webSocketClients)
|
||||
void client.disconnect();
|
||||
this.webSocketClients.clear();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"ver": "4.0.24",
|
||||
"importer": "typescript",
|
||||
"imported": true,
|
||||
"uuid": "07ed05f0-db2f-4006-b25d-5a9e271c5826",
|
||||
"files": [],
|
||||
"subMetas": {},
|
||||
"userData": {}
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
import LogUtils from "../utils/LogUtils";
|
||||
import { SerializationType } from "./Types";
|
||||
|
||||
/**
|
||||
* 数据序列化器接口
|
||||
*/
|
||||
export interface ISerializer {
|
||||
serialize(data: any): ArrayBuffer | string;
|
||||
deserialize(data: ArrayBuffer | string): any;
|
||||
}
|
||||
|
||||
/**
|
||||
* JSON序列化器
|
||||
*/
|
||||
export class JsonSerializer implements ISerializer {
|
||||
serialize(data: any): string {
|
||||
return JSON.stringify(data);
|
||||
}
|
||||
|
||||
deserialize(data: string): any {
|
||||
return JSON.parse(data);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Protobuf序列化器
|
||||
*/
|
||||
export class ProtobufSerializer implements ISerializer {
|
||||
private protoRoot: any;
|
||||
|
||||
constructor(protoRoot: any) {
|
||||
this.protoRoot = protoRoot;
|
||||
}
|
||||
|
||||
serialize(data: any): ArrayBuffer {
|
||||
// TODO: 实现Protobuf序列化逻辑
|
||||
throw new Error("Protobuf序列化未实现");
|
||||
}
|
||||
|
||||
deserialize(data: ArrayBuffer): any {
|
||||
// TODO: 实现Protobuf反序列化逻辑
|
||||
throw new Error("Protobuf反序列化未实现");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 序列化器管理器
|
||||
*/
|
||||
export class SerializerManager {
|
||||
private static readonly TAG = "SerializerManager";
|
||||
private serializers = new Map<SerializationType, ISerializer>();
|
||||
|
||||
constructor() {
|
||||
this.registerSerializer("json", new JsonSerializer());
|
||||
}
|
||||
|
||||
/**
|
||||
* 注册序列化器
|
||||
*/
|
||||
public registerSerializer(
|
||||
type: SerializationType,
|
||||
serializer: ISerializer
|
||||
): void {
|
||||
this.serializers.set(type, serializer);
|
||||
LogUtils.debug(SerializerManager.TAG, `注册序列化器: ${type}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* 序列化数据
|
||||
*/
|
||||
public serialize(data: any, type: SerializationType): ArrayBuffer | string {
|
||||
const serializer = this.serializers.get(type);
|
||||
if (!serializer) {
|
||||
throw new Error(`未找到序列化器: ${type}`);
|
||||
}
|
||||
return serializer.serialize(data);
|
||||
}
|
||||
|
||||
/**
|
||||
* 反序列化数据
|
||||
*/
|
||||
public deserialize(
|
||||
data: ArrayBuffer | string,
|
||||
type: SerializationType
|
||||
): any {
|
||||
const serializer = this.serializers.get(type);
|
||||
if (!serializer) {
|
||||
throw new Error(`未找到序列化器: ${type}`);
|
||||
}
|
||||
return serializer.deserialize(data);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"ver": "4.0.24",
|
||||
"importer": "typescript",
|
||||
"imported": true,
|
||||
"uuid": "b2799e8e-596a-49b9-a71b-898e604601aa",
|
||||
"files": [],
|
||||
"subMetas": {},
|
||||
"userData": {}
|
||||
}
|
||||
101
extensions/max-studio/assets/max-studio/core/net/Types.ts
Normal file
101
extensions/max-studio/assets/max-studio/core/net/Types.ts
Normal file
@@ -0,0 +1,101 @@
|
||||
/**
|
||||
* 网络模块核心类型定义
|
||||
*/
|
||||
|
||||
/** HTTP请求方法 */
|
||||
|
||||
export enum HttpMethod {
|
||||
GET = "GET",
|
||||
POST = "POST",
|
||||
PUT = "PUT",
|
||||
DELETE = "DELETE",
|
||||
PATCH = "PATCH",
|
||||
HEAD = "HEAD",
|
||||
OPTIONS = "OPTIONS",
|
||||
}
|
||||
|
||||
/** 数据序列化类型 */
|
||||
export type SerializationType = "json" | "protobuf" | "binary" | "text";
|
||||
|
||||
/** 网络请求状态 */
|
||||
export enum NetworkStatus {
|
||||
IDLE = "idle",
|
||||
CONNECTING = "connecting",
|
||||
CONNECTED = "connected",
|
||||
DISCONNECTED = "disconnected",
|
||||
ERROR = "error",
|
||||
RECONNECTING = "reconnecting",
|
||||
}
|
||||
|
||||
/** HTTP请求配置 */
|
||||
export interface HttpRequestConfig {
|
||||
url: string;
|
||||
method: HttpMethod;
|
||||
headers?: Record<string, string>;
|
||||
data?: any;
|
||||
timeout?: number;
|
||||
retryCount?: number;
|
||||
retryDelay?: number;
|
||||
responseType?: SerializationType;
|
||||
withCredentials?: boolean;
|
||||
}
|
||||
|
||||
/** HTTP响应数据 */
|
||||
export interface HttpResponse<T = any> {
|
||||
data: T;
|
||||
status: number;
|
||||
statusText: string;
|
||||
headers: Record<string, string>;
|
||||
config: HttpRequestConfig;
|
||||
}
|
||||
|
||||
/** WebSocket配置 */
|
||||
export interface WebSocketConfig {
|
||||
url: string;
|
||||
protocols?: string[];
|
||||
reconnectInterval?: number;
|
||||
maxReconnectAttempts?: number;
|
||||
heartbeatInterval?: number;
|
||||
heartbeatTimeout?: number;
|
||||
binaryType?: "blob" | "arraybuffer";
|
||||
autoReconnect?: boolean;
|
||||
}
|
||||
|
||||
/** WebSocket消息 */
|
||||
export interface WebSocketMessage {
|
||||
id: string;
|
||||
type: SerializationType;
|
||||
data: any;
|
||||
timestamp: number;
|
||||
}
|
||||
|
||||
/** 网络错误 */
|
||||
export interface NetworkError {
|
||||
code: string;
|
||||
message: string;
|
||||
details?: any;
|
||||
timestamp: number;
|
||||
}
|
||||
|
||||
/** Worker消息类型 */
|
||||
export enum WorkerMessageType {
|
||||
HTTP_REQUEST = "http_request",
|
||||
HTTP_RESPONSE = "http_response",
|
||||
WS_CONNECT = "ws_connect",
|
||||
WS_DISCONNECT = "ws_disconnect",
|
||||
WS_SEND = "ws_send",
|
||||
WS_MESSAGE = "ws_message",
|
||||
WS_STATUS = "ws_status",
|
||||
ERROR = "error",
|
||||
HEARTBEAT = "heartbeat",
|
||||
}
|
||||
|
||||
/** 网络配置 */
|
||||
export interface NetworkConfig {
|
||||
httpRootUrl?: string;
|
||||
wsRootUrl?: string;
|
||||
workerPath?: string;
|
||||
defaultTimeout?: number;
|
||||
defaultRetryCount?: number;
|
||||
defaultRetryDelay?: number;
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"ver": "4.0.24",
|
||||
"importer": "typescript",
|
||||
"imported": true,
|
||||
"uuid": "2c350f80-6720-4da3-8d80-ed9485b75eab",
|
||||
"files": [],
|
||||
"subMetas": {},
|
||||
"userData": {}
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
import LogUtils from "../utils/LogUtils";
|
||||
import {
|
||||
WebSocketConfig,
|
||||
WebSocketMessage,
|
||||
NetworkStatus,
|
||||
SerializationType,
|
||||
} from "./Types";
|
||||
|
||||
const TAG = "Network";
|
||||
|
||||
/**
|
||||
* WebSocket客户端
|
||||
*/
|
||||
export class WebSocketClient {
|
||||
private config: WebSocketConfig;
|
||||
private status: NetworkStatus = NetworkStatus.IDLE;
|
||||
private messageHandlers = new Map<
|
||||
string,
|
||||
(message: WebSocketMessage) => void
|
||||
>();
|
||||
private statusHandlers: Array<(status: NetworkStatus) => void> = [];
|
||||
|
||||
constructor(config: WebSocketConfig) {
|
||||
this.config = {
|
||||
reconnectInterval: 5000,
|
||||
maxReconnectAttempts: 5,
|
||||
heartbeatInterval: 30000,
|
||||
heartbeatTimeout: 10000,
|
||||
binaryType: "arraybuffer",
|
||||
autoReconnect: true,
|
||||
...config,
|
||||
};
|
||||
this.setupWorkerHandlers();
|
||||
}
|
||||
|
||||
/**
|
||||
* 连接WebSocket
|
||||
*/
|
||||
public async connect(): Promise<void> {
|
||||
// TODO: 实现WebSocket连接逻辑
|
||||
LogUtils.info(TAG, `连接WebSocket: ${this.config.url}`);
|
||||
this.setStatus(NetworkStatus.CONNECTING);
|
||||
}
|
||||
|
||||
/**
|
||||
* 断开连接
|
||||
*/
|
||||
public async disconnect(): Promise<void> {
|
||||
// TODO: 实现WebSocket断开逻辑
|
||||
LogUtils.info(TAG, "断开WebSocket连接");
|
||||
}
|
||||
|
||||
/**
|
||||
* 发送消息
|
||||
*/
|
||||
public async send(
|
||||
data: any,
|
||||
type: SerializationType = "json",
|
||||
): Promise<void> {
|
||||
// TODO: 实现消息发送逻辑
|
||||
LogUtils.debug(TAG, `发送WebSocket消息: ${type}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* 注册消息处理器
|
||||
*/
|
||||
public onMessage(
|
||||
type: string,
|
||||
handler: (message: WebSocketMessage) => void,
|
||||
): void {
|
||||
this.messageHandlers.set(type, handler);
|
||||
}
|
||||
|
||||
/**
|
||||
* 注册状态变化处理器
|
||||
*/
|
||||
public onStatusChange(handler: (status: NetworkStatus) => void): void {
|
||||
this.statusHandlers.push(handler);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取当前状态
|
||||
*/
|
||||
public getStatus(): NetworkStatus {
|
||||
return this.status;
|
||||
}
|
||||
|
||||
private setupWorkerHandlers(): void {
|
||||
// TODO: 设置Worker消息处理器
|
||||
}
|
||||
|
||||
private setStatus(status: NetworkStatus): void {
|
||||
if (this.status !== status) {
|
||||
this.status = status;
|
||||
for (const handler of this.statusHandlers) handler(status);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"ver": "4.0.24",
|
||||
"importer": "typescript",
|
||||
"imported": true,
|
||||
"uuid": "5dc01552-598f-40da-8382-3f2dd4799ece",
|
||||
"files": [],
|
||||
"subMetas": {},
|
||||
"userData": {}
|
||||
}
|
||||
9
extensions/max-studio/assets/max-studio/core/pool.meta
Normal file
9
extensions/max-studio/assets/max-studio/core/pool.meta
Normal file
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"ver": "1.2.0",
|
||||
"importer": "directory",
|
||||
"imported": true,
|
||||
"uuid": "fffeb1ff-b3ae-42be-a006-629da68909b3",
|
||||
"files": [],
|
||||
"subMetas": {},
|
||||
"userData": {}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"ver": "1.2.0",
|
||||
"importer": "directory",
|
||||
"imported": true,
|
||||
"uuid": "9ed356c0-4f7d-4699-a4d9-4d649726e57b",
|
||||
"files": [],
|
||||
"subMetas": {},
|
||||
"userData": {}
|
||||
}
|
||||
@@ -0,0 +1,269 @@
|
||||
import { _decorator, Component, Node, Label } from "cc";
|
||||
import { RedPointManager } from "./RedPointManager";
|
||||
import { IRedPointData, RedPointState, IRedPointEvent } from "./RedPointData";
|
||||
|
||||
const { ccclass, property, executeInEditMode } = _decorator;
|
||||
|
||||
/**
|
||||
* 红点组件
|
||||
* 用于在Cocos Creator节点上显示红点
|
||||
*/
|
||||
@ccclass("RedPointComponent")
|
||||
@executeInEditMode
|
||||
export class RedPointComponent extends Component {
|
||||
@property({
|
||||
displayName: "红点ID",
|
||||
tooltip: "红点的唯一标识符",
|
||||
})
|
||||
public redPointId: string = "";
|
||||
|
||||
@property({
|
||||
displayName: "最大显示数字",
|
||||
tooltip: "超过此数字显示为99+",
|
||||
})
|
||||
public maxNumber: number = 99;
|
||||
|
||||
@property({
|
||||
displayName: "红点节点",
|
||||
type: Node,
|
||||
tooltip: "显示红点圆点的节点",
|
||||
})
|
||||
public dotNode: Node | null = null;
|
||||
|
||||
@property({
|
||||
displayName: "数字标签",
|
||||
type: Label,
|
||||
tooltip: "显示数字的Label组件",
|
||||
})
|
||||
public numberLabel: Label | null = null;
|
||||
|
||||
private _currentData: IRedPointData | null = null;
|
||||
private _isListening: boolean = false;
|
||||
|
||||
protected onLoad(): void {
|
||||
this.findChildNodes();
|
||||
}
|
||||
|
||||
protected onEnable(): void {
|
||||
if (this.redPointId && !this._isListening) {
|
||||
this.startListening();
|
||||
}
|
||||
this.updateDisplay();
|
||||
}
|
||||
|
||||
protected onDisable(): void {
|
||||
this.stopListening();
|
||||
}
|
||||
|
||||
protected onDestroy(): void {
|
||||
this.stopListening();
|
||||
}
|
||||
|
||||
/**
|
||||
* 自动查找子节点
|
||||
*/
|
||||
private findChildNodes(): void {
|
||||
if (!this.dotNode) {
|
||||
this.dotNode = this.node.getChildByName("RedDot");
|
||||
}
|
||||
|
||||
if (!this.numberLabel) {
|
||||
const numberNode = this.node.getChildByName("RedNumber");
|
||||
if (numberNode) {
|
||||
this.numberLabel = numberNode.getComponent(Label);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 开始监听红点变化
|
||||
*/
|
||||
private startListening(): void {
|
||||
if (this._isListening) return;
|
||||
|
||||
RedPointManager.getInstance().addListener(
|
||||
this.redPointId,
|
||||
this.onRedPointChanged.bind(this),
|
||||
);
|
||||
this._isListening = true;
|
||||
|
||||
// 获取当前数据
|
||||
this._currentData = RedPointManager.getInstance().getRedPoint(
|
||||
this.redPointId,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 停止监听红点变化
|
||||
*/
|
||||
private stopListening(): void {
|
||||
if (!this._isListening) return;
|
||||
|
||||
RedPointManager.getInstance().removeListener(
|
||||
this.redPointId,
|
||||
this.onRedPointChanged.bind(this),
|
||||
);
|
||||
this._isListening = false;
|
||||
}
|
||||
|
||||
/**
|
||||
* 红点变化回调
|
||||
* @param event 红点事件
|
||||
*/
|
||||
private onRedPointChanged(event: IRedPointEvent): void {
|
||||
if (event.redPointId === this.redPointId) {
|
||||
this._currentData =
|
||||
RedPointManager.getInstance()?.getRedPoint(this.redPointId) ||
|
||||
null;
|
||||
this.updateDisplay();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新显示
|
||||
*/
|
||||
private updateDisplay(): void {
|
||||
if (!this._currentData?.enabled) {
|
||||
this.hideAll();
|
||||
return;
|
||||
}
|
||||
|
||||
const state = this._currentData.state;
|
||||
const count = this._currentData.count;
|
||||
|
||||
if (state === RedPointState.NONE) {
|
||||
this.hideAll();
|
||||
return;
|
||||
}
|
||||
|
||||
if (state === RedPointState.SHOW) {
|
||||
this.showDot(true);
|
||||
this.showNumber(false);
|
||||
return;
|
||||
}
|
||||
|
||||
if (state === RedPointState.NUMBER) {
|
||||
this.showDot(false);
|
||||
this.showNumber(true, count);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 显示/隐藏红点
|
||||
* @param show 是否显示
|
||||
*/
|
||||
private showDot(show: boolean): void {
|
||||
if (this.dotNode) {
|
||||
this.dotNode.active = show;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 显示/隐藏数字
|
||||
* @param show 是否显示
|
||||
* @param count 数字
|
||||
*/
|
||||
private showNumber(show: boolean, count: number = 0): void {
|
||||
if (this.numberLabel) {
|
||||
this.numberLabel.node.active = show;
|
||||
}
|
||||
|
||||
if (show && this.numberLabel) {
|
||||
let displayText = count.toString();
|
||||
if (count > this.maxNumber) {
|
||||
displayText = this.maxNumber + "+";
|
||||
}
|
||||
this.numberLabel.string = displayText;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 隐藏所有
|
||||
*/
|
||||
private hideAll(): void {
|
||||
this.showDot(false);
|
||||
this.showNumber(false);
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置红点ID
|
||||
* @param redPointId 红点ID
|
||||
*/
|
||||
public setRedPointId(redPointId: string): void {
|
||||
if (this.redPointId === redPointId) return;
|
||||
|
||||
this.stopListening();
|
||||
this.redPointId = redPointId;
|
||||
|
||||
if (this.enabled && this.redPointId) {
|
||||
this.startListening();
|
||||
this.updateDisplay();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 手动刷新显示
|
||||
*/
|
||||
public refresh(): void {
|
||||
this._currentData = RedPointManager.getInstance().getRedPoint(
|
||||
this.redPointId,
|
||||
);
|
||||
this.updateDisplay();
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取当前红点数据
|
||||
*/
|
||||
public getCurrentData(): IRedPointData | null {
|
||||
return this._currentData;
|
||||
}
|
||||
|
||||
/**
|
||||
* 是否有红点
|
||||
*/
|
||||
public hasRedPoint(): boolean {
|
||||
return this._currentData
|
||||
? this._currentData.enabled &&
|
||||
this._currentData.state !== RedPointState.NONE
|
||||
: false;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取红点数量
|
||||
*/
|
||||
public getCount(): number {
|
||||
return this._currentData ? this._currentData.count : 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建红点节点
|
||||
* @param parent 父节点
|
||||
* @param redPointId 红点ID
|
||||
* @returns 红点组件
|
||||
*/
|
||||
public static create(parent: Node, redPointId: string): RedPointComponent {
|
||||
const redPointNode = new Node("RedPoint");
|
||||
parent.addChild(redPointNode);
|
||||
|
||||
const component = redPointNode.addComponent(RedPointComponent);
|
||||
component.redPointId = redPointId;
|
||||
|
||||
return component;
|
||||
}
|
||||
|
||||
/**
|
||||
* 快速设置红点
|
||||
* @param node 节点
|
||||
* @param redPointId 红点ID
|
||||
* @returns 红点组件
|
||||
*/
|
||||
public static setup(node: Node, redPointId: string): RedPointComponent {
|
||||
let component = node.getComponent(RedPointComponent);
|
||||
if (!component) {
|
||||
component = node.addComponent(RedPointComponent);
|
||||
}
|
||||
component.setRedPointId(redPointId);
|
||||
return component;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"ver": "4.0.24",
|
||||
"importer": "typescript",
|
||||
"imported": true,
|
||||
"uuid": "8fbf52b3-a3d6-43a9-a663-1e63c87201d5",
|
||||
"files": [],
|
||||
"subMetas": {},
|
||||
"userData": {}
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
/**
|
||||
* 红点数据接口
|
||||
* 简单易用的红点系统核心数据结构
|
||||
*/
|
||||
|
||||
/** 红点状态枚举 */
|
||||
export enum RedPointState {
|
||||
/** 无红点 */
|
||||
NONE = 0,
|
||||
/** 显示红点 */
|
||||
SHOW = 1,
|
||||
/** 显示数字 */
|
||||
NUMBER = 2,
|
||||
}
|
||||
|
||||
/** 红点数据接口 */
|
||||
export interface IRedPointData {
|
||||
/** 红点唯一标识 */
|
||||
id: string;
|
||||
/** 红点状态 */
|
||||
state: RedPointState;
|
||||
/** 红点数量(当state为NUMBER时显示) */
|
||||
count: number;
|
||||
/** 是否启用 */
|
||||
enabled: boolean;
|
||||
/** 父节点ID(用于层级关系) */
|
||||
parentId?: string;
|
||||
/** 子节点ID列表 */
|
||||
children?: string[];
|
||||
/** 最后更新时间 */
|
||||
updateTime: number;
|
||||
/** 自定义数据 */
|
||||
customData?: unknown;
|
||||
}
|
||||
|
||||
/** 红点配置接口 */
|
||||
export interface IRedPointConfig {
|
||||
/** 红点ID */
|
||||
id: string;
|
||||
/** 显示名称 */
|
||||
name?: string;
|
||||
/** 是否自动计算(基于子节点) */
|
||||
autoCalculate?: boolean;
|
||||
/** 最大显示数量(超过显示99+) */
|
||||
maxCount?: number;
|
||||
/** 是否持久化 */
|
||||
persistent?: boolean;
|
||||
/** 自定义计算函数 */
|
||||
calculator?: () => { state: RedPointState; count: number };
|
||||
}
|
||||
|
||||
/** 红点事件类型 */
|
||||
export enum RedPointEventType {
|
||||
/** 红点状态改变 */
|
||||
STATE_CHANGED = "redpoint_state_changed",
|
||||
/** 红点数量改变 */
|
||||
COUNT_CHANGED = "redpoint_count_changed",
|
||||
/** 红点添加 */
|
||||
ADDED = "redpoint_added",
|
||||
/** 红点移除 */
|
||||
REMOVED = "redpoint_removed",
|
||||
}
|
||||
|
||||
/** 红点事件数据 */
|
||||
export interface IRedPointEvent {
|
||||
/** 事件类型 */
|
||||
type: RedPointEventType;
|
||||
/** 红点ID */
|
||||
redPointId: string;
|
||||
/** 旧值 */
|
||||
oldValue?: unknown;
|
||||
/** 新值 */
|
||||
newValue?: unknown;
|
||||
/** 时间戳 */
|
||||
timestamp: number;
|
||||
}
|
||||
|
||||
/** 红点监听器 */
|
||||
export type RedPointListener = (event: IRedPointEvent) => void;
|
||||
|
||||
/** 红点过滤器 */
|
||||
export type RedPointFilter = (data: IRedPointData) => boolean;
|
||||
|
||||
/** 红点排序器 */
|
||||
export type RedPointSorter = (a: IRedPointData, b: IRedPointData) => number;
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"ver": "4.0.24",
|
||||
"importer": "typescript",
|
||||
"imported": true,
|
||||
"uuid": "a5ded0f5-f34f-4d00-a1b5-d42ad177edd9",
|
||||
"files": [],
|
||||
"subMetas": {},
|
||||
"userData": {}
|
||||
}
|
||||
@@ -0,0 +1,432 @@
|
||||
import { EventManager } from "../event/EventManager";
|
||||
import NodeSingleton from "../NodeSingleton";
|
||||
import TimeManager from "../timer/TimerManager";
|
||||
import LogUtils from "../utils/LogUtils";
|
||||
import {
|
||||
IRedPointData,
|
||||
IRedPointConfig,
|
||||
RedPointState,
|
||||
RedPointEventType,
|
||||
IRedPointEvent,
|
||||
RedPointListener,
|
||||
RedPointFilter,
|
||||
RedPointSorter,
|
||||
} from "./RedPointData";
|
||||
|
||||
/**
|
||||
* 红点管理器
|
||||
* 简单易用的红点系统核心管理类
|
||||
*/
|
||||
export class RedPointManager extends NodeSingleton {
|
||||
private _redPoints: Map<string, IRedPointData> = new Map();
|
||||
private _configs: Map<string, IRedPointConfig> = new Map();
|
||||
private _listeners: Map<string, RedPointListener[]> = new Map();
|
||||
private _storageKey = "redpoint_data";
|
||||
|
||||
protected onLoad(): void {
|
||||
this.loadFromStorage();
|
||||
}
|
||||
|
||||
/**
|
||||
* 注册红点配置
|
||||
* @param config 红点配置
|
||||
*/
|
||||
public registerConfig(config: IRedPointConfig): void {
|
||||
this._configs.set(config.id, config);
|
||||
|
||||
// 如果红点不存在,创建默认红点
|
||||
if (!this._redPoints.has(config.id)) {
|
||||
this.createRedPoint(config.id);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建红点
|
||||
* @param id 红点ID
|
||||
* @param parentId 父节点ID
|
||||
*/
|
||||
public createRedPoint(id: string, parentId?: string): IRedPointData {
|
||||
if (this._redPoints.has(id)) {
|
||||
return this._redPoints.get(id);
|
||||
}
|
||||
|
||||
const redPoint: IRedPointData = {
|
||||
id,
|
||||
state: RedPointState.NONE,
|
||||
count: 0,
|
||||
enabled: true,
|
||||
parentId,
|
||||
children: [],
|
||||
updateTime: TimeManager.getInstance().getCorrectedTime(),
|
||||
};
|
||||
|
||||
this._redPoints.set(id, redPoint);
|
||||
|
||||
// 建立父子关系
|
||||
if (parentId && this._redPoints.has(parentId)) {
|
||||
const parent = this._redPoints.get(parentId);
|
||||
if (!parent.children) parent.children = [];
|
||||
if (parent.children.indexOf(id) === -1) {
|
||||
parent.children.push(id);
|
||||
}
|
||||
}
|
||||
|
||||
this.emitEvent({
|
||||
type: RedPointEventType.ADDED,
|
||||
redPointId: id,
|
||||
newValue: redPoint,
|
||||
timestamp: TimeManager.getInstance().getCorrectedTime(),
|
||||
});
|
||||
|
||||
return redPoint;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取红点数据
|
||||
* @param id 红点ID
|
||||
*/
|
||||
public getRedPoint(id: string): IRedPointData | null {
|
||||
return this._redPoints.get(id) || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置红点状态
|
||||
* @param id 红点ID
|
||||
* @param state 红点状态
|
||||
* @param count 红点数量
|
||||
*/
|
||||
public setRedPoint(
|
||||
id: string,
|
||||
state: RedPointState,
|
||||
count: number = 0,
|
||||
): void {
|
||||
let redPoint = this._redPoints.get(id);
|
||||
if (!redPoint) {
|
||||
redPoint = this.createRedPoint(id);
|
||||
}
|
||||
|
||||
const oldState = redPoint.state;
|
||||
const oldCount = redPoint.count;
|
||||
|
||||
redPoint.state = state;
|
||||
redPoint.count = Math.max(0, count);
|
||||
redPoint.updateTime = Date.now();
|
||||
|
||||
// 触发事件
|
||||
if (oldState !== state) {
|
||||
this.emitEvent({
|
||||
type: RedPointEventType.STATE_CHANGED,
|
||||
redPointId: id,
|
||||
oldValue: oldState,
|
||||
newValue: state,
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
}
|
||||
|
||||
if (oldCount !== count) {
|
||||
this.emitEvent({
|
||||
type: RedPointEventType.COUNT_CHANGED,
|
||||
redPointId: id,
|
||||
oldValue: oldCount,
|
||||
newValue: count,
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
}
|
||||
|
||||
// 更新父节点
|
||||
this.updateParentRedPoint(id);
|
||||
this.saveToStorage();
|
||||
}
|
||||
|
||||
/**
|
||||
* 显示红点
|
||||
* @param id 红点ID
|
||||
* @param count 数量(可选)
|
||||
*/
|
||||
public showRedPoint(id: string, count?: number): void {
|
||||
if (count !== undefined && count > 0) {
|
||||
this.setRedPoint(id, RedPointState.NUMBER, count);
|
||||
} else {
|
||||
this.setRedPoint(id, RedPointState.SHOW, 0);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 隐藏红点
|
||||
* @param id 红点ID
|
||||
*/
|
||||
public hideRedPoint(id: string): void {
|
||||
this.setRedPoint(id, RedPointState.NONE, 0);
|
||||
}
|
||||
|
||||
/**
|
||||
* 增加红点数量
|
||||
* @param id 红点ID
|
||||
* @param count 增加的数量
|
||||
*/
|
||||
public addRedPointCount(id: string, count: number): void {
|
||||
const redPoint = this.getRedPoint(id);
|
||||
const currentCount = redPoint ? redPoint.count : 0;
|
||||
this.setRedPoint(id, RedPointState.NUMBER, currentCount + count);
|
||||
}
|
||||
|
||||
/**
|
||||
* 减少红点数量
|
||||
* @param id 红点ID
|
||||
* @param count 减少的数量
|
||||
*/
|
||||
public reduceRedPointCount(id: string, count: number): void {
|
||||
const redPoint = this.getRedPoint(id);
|
||||
if (!redPoint) return;
|
||||
|
||||
const newCount = Math.max(0, redPoint.count - count);
|
||||
const newState =
|
||||
newCount > 0 ? RedPointState.NUMBER : RedPointState.NONE;
|
||||
this.setRedPoint(id, newState, newCount);
|
||||
}
|
||||
|
||||
/**
|
||||
* 移除红点
|
||||
* @param id 红点ID
|
||||
*/
|
||||
public removeRedPoint(id: string): void {
|
||||
const redPoint = this._redPoints.get(id);
|
||||
if (!redPoint) return;
|
||||
// 移除父子关系
|
||||
if (redPoint.parentId) {
|
||||
const parent = this._redPoints.get(redPoint.parentId);
|
||||
if (parent?.children) {
|
||||
const index = parent.children.indexOf(id);
|
||||
if (index !== -1) {
|
||||
parent.children.splice(index, 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 移除子节点
|
||||
if (redPoint.children) {
|
||||
for (const childId of redPoint.children) {
|
||||
this.removeRedPoint(childId);
|
||||
}
|
||||
}
|
||||
|
||||
this._redPoints.delete(id);
|
||||
this._configs.delete(id);
|
||||
|
||||
this.emitEvent({
|
||||
type: RedPointEventType.REMOVED,
|
||||
redPointId: id,
|
||||
oldValue: redPoint,
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
|
||||
this.saveToStorage();
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新父节点红点状态
|
||||
* @param childId 子节点ID
|
||||
*/
|
||||
private updateParentRedPoint(childId: string): void {
|
||||
const child = this._redPoints.get(childId);
|
||||
if (!child?.parentId) return;
|
||||
|
||||
const parent = this._redPoints.get(child.parentId);
|
||||
if (!parent) return;
|
||||
|
||||
const config = this._configs.get(child.parentId);
|
||||
if (config && config.autoCalculate === false) return;
|
||||
|
||||
// 计算子节点状态
|
||||
let hasShow = false;
|
||||
let totalCount = 0;
|
||||
|
||||
if (parent.children) {
|
||||
for (const childId of parent.children) {
|
||||
const child = this._redPoints.get(childId);
|
||||
if (
|
||||
child &&
|
||||
child.enabled &&
|
||||
child.state !== RedPointState.NONE
|
||||
) {
|
||||
hasShow = true;
|
||||
if (child.state === RedPointState.NUMBER) {
|
||||
totalCount += child.count;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 更新父节点状态
|
||||
const newState = hasShow
|
||||
? totalCount > 0
|
||||
? RedPointState.NUMBER
|
||||
: RedPointState.SHOW
|
||||
: RedPointState.NONE;
|
||||
const newCount = totalCount;
|
||||
|
||||
if (parent.state !== newState || parent.count !== newCount) {
|
||||
const oldState = parent.state;
|
||||
const oldCount = parent.count;
|
||||
|
||||
parent.state = newState;
|
||||
parent.count = newCount;
|
||||
parent.updateTime = Date.now();
|
||||
|
||||
// 触发事件
|
||||
if (oldState !== newState) {
|
||||
this.emitEvent({
|
||||
type: RedPointEventType.STATE_CHANGED,
|
||||
redPointId: parent.id,
|
||||
oldValue: oldState,
|
||||
newValue: newState,
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
}
|
||||
|
||||
if (oldCount !== newCount) {
|
||||
this.emitEvent({
|
||||
type: RedPointEventType.COUNT_CHANGED,
|
||||
redPointId: parent.id,
|
||||
oldValue: oldCount,
|
||||
newValue: newCount,
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
}
|
||||
|
||||
// 递归更新父节点的父节点
|
||||
this.updateParentRedPoint(parent.id);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 添加事件监听器
|
||||
* @param redPointId 红点ID,为空则监听所有红点
|
||||
* @param listener 监听器
|
||||
*/
|
||||
public addListener(
|
||||
redPointId: string | null,
|
||||
listener: RedPointListener,
|
||||
): void {
|
||||
const key = redPointId || "*";
|
||||
if (!this._listeners.has(key)) {
|
||||
this._listeners.set(key, []);
|
||||
}
|
||||
this._listeners.get(key).push(listener);
|
||||
}
|
||||
|
||||
/**
|
||||
* 移除事件监听器
|
||||
* @param redPointId 红点ID
|
||||
* @param listener 监听器
|
||||
*/
|
||||
public removeListener(
|
||||
redPointId: string | null,
|
||||
listener: RedPointListener,
|
||||
): void {
|
||||
const key = redPointId || "*";
|
||||
const listeners = this._listeners.get(key);
|
||||
if (listeners) {
|
||||
const index = listeners.indexOf(listener);
|
||||
if (index !== -1) {
|
||||
listeners.splice(index, 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 触发事件
|
||||
* @param event 事件数据
|
||||
*/
|
||||
private emitEvent(event: IRedPointEvent): void {
|
||||
// 触发特定红点监听器
|
||||
const specificListeners = this._listeners.get(event.redPointId);
|
||||
if (specificListeners) {
|
||||
for (const listener of specificListeners) listener(event);
|
||||
}
|
||||
|
||||
// 触发全局监听器
|
||||
const globalListeners = this._listeners.get("*");
|
||||
if (globalListeners) {
|
||||
for (const listener of globalListeners) listener(event);
|
||||
}
|
||||
|
||||
// 通过事件管理器分发
|
||||
EventManager.getInstance().emit(event.type, event);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取所有红点数据
|
||||
* @param filter 过滤器
|
||||
* @param sorter 排序器
|
||||
*/
|
||||
public getAllRedPoints(
|
||||
filter?: RedPointFilter,
|
||||
sorter?: RedPointSorter,
|
||||
): IRedPointData[] {
|
||||
let result = Array.from(this._redPoints.values());
|
||||
|
||||
if (filter) {
|
||||
result = result.filter(filter);
|
||||
}
|
||||
|
||||
if (sorter) {
|
||||
result.sort(sorter);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取活跃的红点(有显示状态的)
|
||||
*/
|
||||
public getActiveRedPoints(): IRedPointData[] {
|
||||
return this.getAllRedPoints(
|
||||
(data) => data.enabled && data.state !== RedPointState.NONE,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 保存到本地存储
|
||||
*/
|
||||
private saveToStorage(): void {
|
||||
try {
|
||||
const data = {
|
||||
redPoints: Array.from(this._redPoints.entries()),
|
||||
configs: Array.from(this._configs.entries()),
|
||||
timestamp: Date.now(),
|
||||
};
|
||||
if (typeof localStorage !== "undefined") {
|
||||
localStorage.setItem(this._storageKey, JSON.stringify(data));
|
||||
}
|
||||
} catch (err) {
|
||||
LogUtils.warn("RedPointManager: 保存数据失败", err);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 从本地存储加载
|
||||
*/
|
||||
private loadFromStorage(): void {
|
||||
try {
|
||||
if (typeof localStorage !== "undefined") {
|
||||
const dataString = localStorage.getItem(this._storageKey);
|
||||
if (dataString) {
|
||||
const data = JSON.parse(dataString);
|
||||
if (data.redPoints) {
|
||||
this._redPoints = new Map(data.redPoints);
|
||||
}
|
||||
if (data.configs) {
|
||||
this._configs = new Map(data.configs);
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
LogUtils.warn("RedPointManager: 加载数据失败", err);
|
||||
}
|
||||
}
|
||||
|
||||
protected onDestroy(): void {
|
||||
this.saveToStorage();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"ver": "4.0.24",
|
||||
"importer": "typescript",
|
||||
"imported": true,
|
||||
"uuid": "16f67373-1f2f-4945-95ad-d6962b1a5215",
|
||||
"files": [],
|
||||
"subMetas": {},
|
||||
"userData": {}
|
||||
}
|
||||
9
extensions/max-studio/assets/max-studio/core/res.meta
Normal file
9
extensions/max-studio/assets/max-studio/core/res.meta
Normal file
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"ver": "1.2.0",
|
||||
"importer": "directory",
|
||||
"imported": true,
|
||||
"uuid": "0768a376-ca6e-4114-abf7-91b3579c8040",
|
||||
"files": [],
|
||||
"subMetas": {},
|
||||
"userData": {}
|
||||
}
|
||||
156
extensions/max-studio/assets/max-studio/core/res/LocalSprite.ts
Normal file
156
extensions/max-studio/assets/max-studio/core/res/LocalSprite.ts
Normal file
@@ -0,0 +1,156 @@
|
||||
import { _decorator, Sprite, SpriteFrame } from "cc";
|
||||
import LogUtils from "../utils/LogUtils";
|
||||
import { ResManager } from "./ResManager";
|
||||
import { EDITOR } from "cc/env";
|
||||
const { ccclass, property } = _decorator;
|
||||
|
||||
const TAG = "LocalSprite";
|
||||
|
||||
@ccclass("LocalSprite")
|
||||
export default class LocalSprite extends Sprite {
|
||||
@property({
|
||||
displayName: "Bundle名称",
|
||||
tooltip: "资源包名称,默认为resources",
|
||||
visible: true,
|
||||
})
|
||||
private _bundleName: string = "resources";
|
||||
|
||||
@property({
|
||||
displayName: "资源路径",
|
||||
tooltip: "SpriteFrame资源路径",
|
||||
visible: true,
|
||||
})
|
||||
private _assetPath: string = "";
|
||||
|
||||
private _isLoading: boolean = false;
|
||||
private _loadingKey: string = "";
|
||||
|
||||
/**
|
||||
* 获取Bundle名称
|
||||
*/
|
||||
get bundleName(): string {
|
||||
return this._bundleName;
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置Bundle名称
|
||||
*/
|
||||
set bundleName(value: string) {
|
||||
const newValue = value || "resources";
|
||||
if (this._bundleName !== newValue) {
|
||||
this._bundleName = newValue;
|
||||
void this.loadSpriteFrame();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取资源路径
|
||||
*/
|
||||
get assetPath(): string {
|
||||
return this._assetPath;
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置资源路径
|
||||
*/
|
||||
set assetPath(value: string) {
|
||||
if (this._assetPath !== value) {
|
||||
this._assetPath = value;
|
||||
void this.loadSpriteFrame();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取是否正在加载
|
||||
*/
|
||||
get isLoading(): boolean {
|
||||
return this._isLoading;
|
||||
}
|
||||
|
||||
public onLoad(): void {
|
||||
super.onLoad();
|
||||
if (EDITOR) {
|
||||
return;
|
||||
}
|
||||
void this.loadSpriteFrame();
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置Bundle和资源路径
|
||||
*/
|
||||
public setAsset(assetPath: string, bundleName: string = "resources"): void {
|
||||
// 先释放旧资源
|
||||
if (this._bundleName && this._assetPath) {
|
||||
ResManager.getInstance().releaseAsset(
|
||||
this._bundleName,
|
||||
this._assetPath,
|
||||
);
|
||||
}
|
||||
|
||||
this._bundleName = bundleName || "resources";
|
||||
this._assetPath = assetPath;
|
||||
void this.loadSpriteFrame();
|
||||
}
|
||||
|
||||
/**
|
||||
* 加载SpriteFrame
|
||||
*/
|
||||
private async loadSpriteFrame(): Promise<void> {
|
||||
// 检查路径是否有效
|
||||
if (!this._assetPath) {
|
||||
LogUtils.warn(TAG, "资源路径为空");
|
||||
this.spriteFrame = null;
|
||||
return;
|
||||
}
|
||||
|
||||
const loadKey = `${this._bundleName}/${this._assetPath}`;
|
||||
// 防止重复加载
|
||||
if (this._isLoading && loadKey == this._loadingKey) {
|
||||
LogUtils.warn(
|
||||
TAG,
|
||||
`正在加载中,跳过重复请求: ${this._bundleName}/${this._assetPath}`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
this._isLoading = true;
|
||||
this._loadingKey = loadKey;
|
||||
try {
|
||||
LogUtils.log(
|
||||
TAG,
|
||||
`开始加载SpriteFrame: ${this._bundleName}/${this._assetPath}`,
|
||||
);
|
||||
|
||||
const spriteFrame =
|
||||
await ResManager.getInstance().loadAsset<SpriteFrame>(
|
||||
this._assetPath,
|
||||
SpriteFrame,
|
||||
this._bundleName,
|
||||
);
|
||||
|
||||
if (spriteFrame && this.isValid && loadKey == this._loadingKey) {
|
||||
this.spriteFrame = spriteFrame;
|
||||
LogUtils.log(
|
||||
TAG,
|
||||
`加载SpriteFrame成功: ${this._bundleName}/${this._assetPath}`,
|
||||
);
|
||||
} else {
|
||||
LogUtils.log(
|
||||
TAG,
|
||||
`加载SpriteFrame失败: ${this._bundleName}/${this._assetPath}`,
|
||||
);
|
||||
}
|
||||
} catch (err) {
|
||||
LogUtils.error(
|
||||
TAG,
|
||||
`加载SpriteFrame失败: ${this._bundleName}/${this._assetPath}`,
|
||||
err,
|
||||
);
|
||||
if (this.isValid) {
|
||||
this.spriteFrame = null;
|
||||
}
|
||||
} finally {
|
||||
this._isLoading = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"ver": "4.0.24",
|
||||
"importer": "typescript",
|
||||
"imported": true,
|
||||
"uuid": "21febc68-719c-4eab-90d3-193f9f15abba",
|
||||
"files": [],
|
||||
"subMetas": {},
|
||||
"userData": {}
|
||||
}
|
||||
112
extensions/max-studio/assets/max-studio/core/res/RemoteSprite.ts
Normal file
112
extensions/max-studio/assets/max-studio/core/res/RemoteSprite.ts
Normal file
@@ -0,0 +1,112 @@
|
||||
import { _decorator, Sprite } from "cc";
|
||||
import { EDITOR } from "cc/env";
|
||||
import { UITransform } from "cc";
|
||||
import { StringUtils } from "../utils/StringUtils";
|
||||
import { RemoteSpriteCache } from "./RemoteSpriteCache";
|
||||
import LogUtils from "../utils/LogUtils";
|
||||
const { ccclass, property } = _decorator;
|
||||
|
||||
@ccclass("RemoteSprite")
|
||||
export default class RemoteSprite extends Sprite {
|
||||
/**
|
||||
* 远程图片URL
|
||||
*/
|
||||
@property({
|
||||
displayName: "远程图片URL",
|
||||
tooltip: "远程图片的URL地址",
|
||||
visible: true,
|
||||
})
|
||||
private _remoteUrl: string = "";
|
||||
|
||||
public get remoteUrl(): string {
|
||||
return this._remoteUrl;
|
||||
}
|
||||
|
||||
public set remoteUrl(value: string) {
|
||||
const newValue = value || "";
|
||||
if (this._remoteUrl !== newValue) {
|
||||
this._remoteUrl = newValue;
|
||||
if (this._remoteUrl !== this._currentUrl) {
|
||||
this.loadRemoteSprite(this._remoteUrl).catch((err) => {
|
||||
LogUtils.error(
|
||||
"RemoteSprite",
|
||||
`加载远程图片失败: ${this._remoteUrl}`,
|
||||
err,
|
||||
);
|
||||
// 可以添加默认图片或错误状态处理
|
||||
if (this.isValid) {
|
||||
this.spriteFrame = null;
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 私有属性
|
||||
private _currentUrl: string = "";
|
||||
|
||||
public onLoad(): void {
|
||||
super.onLoad();
|
||||
if (EDITOR) {
|
||||
return;
|
||||
}
|
||||
RemoteSpriteCache.getInstance().checkAndRegisterSprite(
|
||||
this._currentUrl,
|
||||
this.spriteFrame,
|
||||
);
|
||||
if (
|
||||
!StringUtils.isEmpty(this._remoteUrl) &&
|
||||
StringUtils.isEmpty(this._currentUrl)
|
||||
) {
|
||||
this.loadRemoteSprite(this._remoteUrl).catch((err) => {
|
||||
LogUtils.error(
|
||||
"RemoteSprite",
|
||||
`onLoad加载远程图片失败: ${this._remoteUrl}`,
|
||||
err,
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private async loadRemoteSprite(url: string): Promise<void> {
|
||||
if (StringUtils.isEmpty(url)) {
|
||||
return;
|
||||
}
|
||||
|
||||
// URL 相同,且已经加载完成,直接返回
|
||||
if (this._currentUrl === url && this.spriteFrame) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.release();
|
||||
const sp = await RemoteSpriteCache.getInstance().loadSpriteFrame(url);
|
||||
|
||||
// 检查是否在加载过程中URL发生了变化
|
||||
if (this._remoteUrl !== url || this._currentUrl === url) {
|
||||
RemoteSpriteCache.getInstance().releaseResource(url);
|
||||
return;
|
||||
}
|
||||
|
||||
this.spriteFrame = sp;
|
||||
this._currentUrl = url;
|
||||
}
|
||||
|
||||
public setSize(widthOrHeight: number, height?: number): void {
|
||||
height ??= widthOrHeight;
|
||||
this.sizeMode = Sprite.SizeMode.CUSTOM;
|
||||
this.getComponent(UITransform).setContentSize(widthOrHeight, height);
|
||||
}
|
||||
|
||||
public release(): void {
|
||||
if (!StringUtils.isEmpty(this._currentUrl)) {
|
||||
RemoteSpriteCache.getInstance()?.releaseResource(this._currentUrl);
|
||||
}
|
||||
this._currentUrl = "";
|
||||
this.spriteFrame = null;
|
||||
}
|
||||
|
||||
public onDestroy(): void {
|
||||
this.release();
|
||||
super.onDestroy();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"ver": "4.0.24",
|
||||
"importer": "typescript",
|
||||
"imported": true,
|
||||
"uuid": "2dcda316-1f1c-4372-a8df-2405ccf618f3",
|
||||
"files": [],
|
||||
"subMetas": {},
|
||||
"userData": {}
|
||||
}
|
||||
@@ -0,0 +1,422 @@
|
||||
import {
|
||||
_decorator,
|
||||
SpriteFrame,
|
||||
ImageAsset,
|
||||
Texture2D,
|
||||
assetManager,
|
||||
} from "cc";
|
||||
import NodeSingleton from "../NodeSingleton";
|
||||
import LogUtils from "../utils/LogUtils";
|
||||
import { StringUtils } from "../utils/StringUtils";
|
||||
const { ccclass } = _decorator;
|
||||
|
||||
enum LoadState {
|
||||
NONE,
|
||||
LOADING,
|
||||
LOADED,
|
||||
FAILED,
|
||||
}
|
||||
|
||||
// 添加加载超时和重试机制
|
||||
const LOAD_TIMEOUT = 30000; // 30秒超时
|
||||
const MAX_RETRY_COUNT = 3;
|
||||
|
||||
// 资源缓存项接口
|
||||
class CacheItem {
|
||||
spriteFrame: SpriteFrame | null;
|
||||
loadState: LoadState;
|
||||
loadPromise: Promise<SpriteFrame> | null;
|
||||
|
||||
private _refCount: number;
|
||||
private _retryCount: number = 0;
|
||||
private _loadStartTime: number = 0;
|
||||
|
||||
get refCount(): number {
|
||||
return this._refCount;
|
||||
}
|
||||
set refCount(value: number) {
|
||||
this._lastAccessTime = Date.now();
|
||||
this._refCount = value;
|
||||
}
|
||||
private _lastAccessTime: number;
|
||||
get lastAccessTime(): number {
|
||||
return this._lastAccessTime;
|
||||
}
|
||||
|
||||
constructor() {
|
||||
this.loadState = LoadState.NONE;
|
||||
this.refCount = 0;
|
||||
}
|
||||
|
||||
get retryCount(): number {
|
||||
return this._retryCount;
|
||||
}
|
||||
|
||||
incrementRetry(): void {
|
||||
this._retryCount++;
|
||||
}
|
||||
|
||||
resetRetry(): void {
|
||||
this._retryCount = 0;
|
||||
}
|
||||
|
||||
setLoadStartTime(): void {
|
||||
this._loadStartTime = Date.now();
|
||||
}
|
||||
|
||||
isLoadTimeout(): boolean {
|
||||
return Date.now() - this._loadStartTime > LOAD_TIMEOUT;
|
||||
}
|
||||
}
|
||||
|
||||
const TAG = "RemoteSpriteCache";
|
||||
const CLEANUP_CHECK_INTERVAL = 10; // 10秒检查一次
|
||||
const CACHE_EXPIRE_TIME = 1 * 60 * 1000; // 1分钟
|
||||
|
||||
// 添加最大缓存数量限制
|
||||
const MAX_CACHE_SIZE = 100; // 最大缓存数量
|
||||
|
||||
@ccclass("RemoteSpriteCache")
|
||||
export class RemoteSpriteCache extends NodeSingleton {
|
||||
private readonly _cache = new Map<string, CacheItem>();
|
||||
private _lastCleanupTime = 0;
|
||||
private _maxCacheSize = MAX_CACHE_SIZE;
|
||||
|
||||
/**
|
||||
* 设置最大缓存数量
|
||||
*/
|
||||
public setMaxCacheSize(size: number): void {
|
||||
this._maxCacheSize = Math.max(10, size);
|
||||
LogUtils.info(TAG, `设置最大缓存数量: ${this._maxCacheSize}`);
|
||||
}
|
||||
|
||||
protected init(): void {
|
||||
this._lastCleanupTime = Date.now();
|
||||
LogUtils.log(TAG, "RemoteSpriteCache initialized");
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查并注册精灵
|
||||
*/
|
||||
public checkAndRegisterSprite(
|
||||
url: string,
|
||||
spriteFrame: SpriteFrame,
|
||||
): boolean {
|
||||
if (spriteFrame && !StringUtils.isEmpty(url)) {
|
||||
const cacheItem = this._cache.get(url);
|
||||
if (cacheItem && cacheItem.spriteFrame === spriteFrame) {
|
||||
cacheItem.refCount++;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
protected update(dt: number): void {
|
||||
const currentTime = Date.now();
|
||||
// 每隔指定时间检查一次过期资源
|
||||
if (
|
||||
currentTime - this._lastCleanupTime >=
|
||||
CLEANUP_CHECK_INTERVAL * 1000
|
||||
) {
|
||||
this.cleanupUnusedResources();
|
||||
this._lastCleanupTime = currentTime;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 加载SpriteFrame(带重试机制)
|
||||
*/
|
||||
public async loadSpriteFrame(url: string): Promise<SpriteFrame> {
|
||||
let cacheItem = this._cache.get(url);
|
||||
if (cacheItem) {
|
||||
if (
|
||||
cacheItem.loadState === LoadState.LOADED &&
|
||||
cacheItem.spriteFrame
|
||||
) {
|
||||
cacheItem.refCount++;
|
||||
return cacheItem.spriteFrame;
|
||||
} else if (
|
||||
cacheItem.loadState === LoadState.LOADING &&
|
||||
cacheItem.loadPromise
|
||||
) {
|
||||
try {
|
||||
const spriteFrame = await cacheItem.loadPromise;
|
||||
if (spriteFrame) {
|
||||
cacheItem.refCount++;
|
||||
}
|
||||
return spriteFrame;
|
||||
} catch (err) {
|
||||
LogUtils.error(TAG, `等待加载失败: ${url}`, err);
|
||||
// 如果等待失败,重新尝试加载
|
||||
cacheItem.loadState = LoadState.FAILED;
|
||||
cacheItem.loadPromise = null;
|
||||
}
|
||||
}
|
||||
|
||||
// 如果加载失败且重试次数未达到上限,重新尝试
|
||||
if (
|
||||
cacheItem.loadState === LoadState.FAILED &&
|
||||
cacheItem.retryCount < MAX_RETRY_COUNT
|
||||
) {
|
||||
LogUtils.warn(
|
||||
TAG,
|
||||
`重试加载: ${url}, 第${cacheItem.retryCount + 1}次`,
|
||||
);
|
||||
cacheItem.incrementRetry();
|
||||
return this.doLoadSpriteFrame(url, cacheItem);
|
||||
}
|
||||
|
||||
if (cacheItem.loadState === LoadState.FAILED) {
|
||||
throw new Error(`加载失败且已达到最大重试次数: ${url}`);
|
||||
}
|
||||
}
|
||||
|
||||
cacheItem = new CacheItem();
|
||||
this._cache.set(url, cacheItem);
|
||||
|
||||
return this.doLoadSpriteFrame(url, cacheItem);
|
||||
}
|
||||
|
||||
private async doLoadSpriteFrame(
|
||||
url: string,
|
||||
cacheItem: CacheItem,
|
||||
): Promise<SpriteFrame> {
|
||||
cacheItem.loadState = LoadState.LOADING;
|
||||
cacheItem.setLoadStartTime();
|
||||
|
||||
cacheItem.loadPromise = new Promise<SpriteFrame>((resolve, reject) => {
|
||||
const timeoutId = setTimeout(() => {
|
||||
LogUtils.error(TAG, `加载超时: ${url}`);
|
||||
cacheItem.loadState = LoadState.FAILED;
|
||||
cacheItem.loadPromise = null;
|
||||
reject(new Error(`加载超时: ${url}`));
|
||||
}, LOAD_TIMEOUT);
|
||||
|
||||
assetManager.loadRemote(url, (err, asset: ImageAsset) => {
|
||||
clearTimeout(timeoutId);
|
||||
|
||||
if (err) {
|
||||
LogUtils.error(TAG, `loadRemote error: ${url}`, err);
|
||||
cacheItem.loadState = LoadState.FAILED;
|
||||
cacheItem.loadPromise = null;
|
||||
reject(err);
|
||||
} else {
|
||||
try {
|
||||
asset.addRef();
|
||||
const texture = new Texture2D();
|
||||
texture.image = asset;
|
||||
const spriteFrame = new SpriteFrame();
|
||||
spriteFrame.texture = texture;
|
||||
spriteFrame.addRef();
|
||||
|
||||
cacheItem.refCount++;
|
||||
cacheItem.spriteFrame = spriteFrame;
|
||||
cacheItem.loadState = LoadState.LOADED;
|
||||
cacheItem.loadPromise = null;
|
||||
cacheItem.resetRetry();
|
||||
|
||||
resolve(spriteFrame);
|
||||
} catch (createErr) {
|
||||
LogUtils.error(
|
||||
TAG,
|
||||
`创建SpriteFrame失败: ${url}`,
|
||||
createErr,
|
||||
);
|
||||
cacheItem.loadState = LoadState.FAILED;
|
||||
cacheItem.loadPromise = null;
|
||||
reject(createErr);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
return await cacheItem.loadPromise;
|
||||
}
|
||||
|
||||
/**
|
||||
* 释放资源引用
|
||||
*/
|
||||
public releaseResource(url: string): void {
|
||||
if (url) {
|
||||
const cacheItem = this._cache.get(url);
|
||||
if (cacheItem) {
|
||||
cacheItem.refCount--;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 清理未使用的资源
|
||||
*/
|
||||
public cleanupUnusedResources(): void {
|
||||
const currentCacheSize = this._cache.size;
|
||||
const targetSize = Math.floor(this._maxCacheSize * 0.8); // 目标大小为阈值的80%
|
||||
|
||||
// 如果未达到阈值,仅清理引用计数为0且缓存超时的资源
|
||||
if (currentCacheSize < this._maxCacheSize) {
|
||||
this.cleanupExpiredResources();
|
||||
return;
|
||||
}
|
||||
|
||||
// 达到阈值时,按使用时间排序清理引用计数为0的资源
|
||||
this.cleanupByUsageTime(targetSize);
|
||||
}
|
||||
|
||||
/**
|
||||
* 清理过期的资源(引用计数为0且超时)
|
||||
*/
|
||||
private cleanupExpiredResources(): void {
|
||||
const urlsToRemove: string[] = [];
|
||||
let cleanedCount = 0;
|
||||
const currentTime = Date.now();
|
||||
|
||||
for (const [url, cacheItem] of this._cache.entries()) {
|
||||
if (
|
||||
cacheItem.spriteFrame &&
|
||||
cacheItem.loadState === LoadState.LOADED &&
|
||||
cacheItem.refCount <= 0 &&
|
||||
cacheItem.lastAccessTime < currentTime - CACHE_EXPIRE_TIME
|
||||
) {
|
||||
this.destroyCacheItem(cacheItem);
|
||||
urlsToRemove.push(url);
|
||||
cleanedCount++;
|
||||
}
|
||||
}
|
||||
|
||||
// 从缓存中移除已清理的项
|
||||
for (const url of urlsToRemove) {
|
||||
this._cache.delete(url);
|
||||
}
|
||||
|
||||
if (cleanedCount > 0) {
|
||||
LogUtils.log(TAG, `清理过期资源: ${cleanedCount} 个`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 按使用时间排序清理资源(只清理引用计数为0的资源)
|
||||
*/
|
||||
private cleanupByUsageTime(targetSize: number): void {
|
||||
// 直接筛选并排序需要清理的资源
|
||||
const entriesToClean = Array.from(this._cache.entries())
|
||||
.filter(
|
||||
([_, cacheItem]) =>
|
||||
cacheItem.refCount <= 0 &&
|
||||
cacheItem.loadState === LoadState.LOADED,
|
||||
)
|
||||
.sort((a, b) => a[1].lastAccessTime - b[1].lastAccessTime);
|
||||
|
||||
let cleanedCount = 0;
|
||||
|
||||
// 清理资源直到达到目标大小
|
||||
for (const [url, cacheItem] of entriesToClean) {
|
||||
if (this._cache.size <= targetSize) {
|
||||
break;
|
||||
}
|
||||
|
||||
this.destroyCacheItem(cacheItem);
|
||||
this._cache.delete(url);
|
||||
cleanedCount++;
|
||||
}
|
||||
|
||||
if (cleanedCount > 0) {
|
||||
const finalSize = this._cache.size;
|
||||
LogUtils.log(
|
||||
TAG,
|
||||
`缓存清理完成: 清理了 ${cleanedCount} 个资源,当前缓存大小: ${finalSize}/${this._maxCacheSize}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 销毁缓存项中的资源
|
||||
*/
|
||||
private destroyCacheItem(cacheItem: CacheItem): void {
|
||||
if (!cacheItem.spriteFrame) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// 先获取texture引用
|
||||
let texture = cacheItem.spriteFrame.texture as Texture2D;
|
||||
let imageAsset: ImageAsset = null;
|
||||
|
||||
// 如果已加入动态合图,必须取原始的Texture2D
|
||||
if (
|
||||
cacheItem.spriteFrame.packable &&
|
||||
cacheItem.spriteFrame.original
|
||||
) {
|
||||
texture = cacheItem.spriteFrame.original._texture as Texture2D;
|
||||
}
|
||||
|
||||
// 获取ImageAsset引用
|
||||
if (texture?.image) {
|
||||
imageAsset = texture.image;
|
||||
}
|
||||
|
||||
// 先销毁spriteFrame(这会自动处理对texture的引用)
|
||||
if (cacheItem.spriteFrame.isValid) {
|
||||
cacheItem.spriteFrame.destroy();
|
||||
}
|
||||
|
||||
// 再销毁texture
|
||||
if (texture?.isValid) {
|
||||
texture.destroy();
|
||||
}
|
||||
|
||||
// 最后减少ImageAsset的引用计数
|
||||
if (imageAsset?.isValid) {
|
||||
imageAsset.decRef();
|
||||
}
|
||||
} catch (err) {
|
||||
LogUtils.error(TAG, `销毁缓存项时发生错误`, err);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取缓存统计信息
|
||||
*/
|
||||
public getCacheStats(): {
|
||||
total: number;
|
||||
loaded: number;
|
||||
loading: number;
|
||||
failed: number;
|
||||
} {
|
||||
const stats = { total: 0, loaded: 0, loading: 0, failed: 0 };
|
||||
for (const [, cacheItem] of this._cache.entries()) {
|
||||
stats.total++;
|
||||
switch (cacheItem.loadState) {
|
||||
case LoadState.LOADED:
|
||||
stats.loaded++;
|
||||
break;
|
||||
case LoadState.LOADING:
|
||||
stats.loading++;
|
||||
break;
|
||||
case LoadState.FAILED:
|
||||
stats.failed++;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return stats;
|
||||
}
|
||||
|
||||
/**
|
||||
* 清空所有缓存
|
||||
*/
|
||||
public clearAllCache(): void {
|
||||
for (const [, cacheItem] of this._cache.entries()) {
|
||||
if (cacheItem.spriteFrame?.isValid) {
|
||||
cacheItem.spriteFrame.destroy();
|
||||
}
|
||||
}
|
||||
this._cache.clear();
|
||||
LogUtils.log(TAG, "All cache cleared");
|
||||
}
|
||||
|
||||
protected onDestroy(): void {
|
||||
this.clearAllCache();
|
||||
super.onDestroy();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"ver": "4.0.24",
|
||||
"importer": "typescript",
|
||||
"imported": true,
|
||||
"uuid": "72082034-0f4c-43b7-b108-0019caeec10a",
|
||||
"files": [],
|
||||
"subMetas": {},
|
||||
"userData": {}
|
||||
}
|
||||
683
extensions/max-studio/assets/max-studio/core/res/ResManager.ts
Normal file
683
extensions/max-studio/assets/max-studio/core/res/ResManager.ts
Normal file
@@ -0,0 +1,683 @@
|
||||
import { Asset, assetManager, AssetManager, resources } from "cc";
|
||||
import LogUtils from "../utils/LogUtils";
|
||||
import { singleton, Singleton } from "../Singleton";
|
||||
|
||||
/**
|
||||
* 加载状态枚举
|
||||
*/
|
||||
export enum LoadState {
|
||||
NONE = 0,
|
||||
LOADING = 1,
|
||||
LOADED = 2,
|
||||
FAILED = 3,
|
||||
}
|
||||
|
||||
/**
|
||||
* 加载任务接口
|
||||
*/
|
||||
interface LoadTask {
|
||||
url: string;
|
||||
type: "bundle" | "asset";
|
||||
bundleName?: string;
|
||||
assetPath?: string;
|
||||
assetType?: typeof Asset;
|
||||
resolve: (result: any) => void;
|
||||
reject: (error: Error) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Bundle信息接口
|
||||
*/
|
||||
interface BundleInfo {
|
||||
bundle: AssetManager.Bundle;
|
||||
state: LoadState;
|
||||
refCount: number;
|
||||
isRemote: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* 资源信息接口
|
||||
*/
|
||||
interface AssetInfo {
|
||||
asset: Asset;
|
||||
state: LoadState;
|
||||
refCount: number;
|
||||
bundleName: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 资源管理器
|
||||
*/
|
||||
@singleton()
|
||||
export class ResManager extends Singleton {
|
||||
private static readonly TAG = "ResManager";
|
||||
private static readonly DEFAULT_BUNDLE = "resources";
|
||||
|
||||
// Bundle缓存
|
||||
private bundleCache = new Map<string, BundleInfo>();
|
||||
|
||||
// 资源缓存
|
||||
private assetCache = new Map<string, AssetInfo>();
|
||||
|
||||
// 加载队列
|
||||
private loadQueue: LoadTask[] = [];
|
||||
|
||||
// 当前正在加载的任务数量
|
||||
private loadingCount = 0;
|
||||
|
||||
// 最大并发加载数量
|
||||
private maxConcurrentLoads = 30;
|
||||
|
||||
// 是否正在处理队列
|
||||
private isProcessingQueue = false;
|
||||
|
||||
// Bundle加载等待队列
|
||||
private bundleWaitQueue = new Map<string, Array<{ resolve: Function; reject: Function }>>();
|
||||
|
||||
// 资源加载等待队列
|
||||
private assetWaitQueue = new Map<string, Array<{ resolve: Function; reject: Function }>>();
|
||||
|
||||
protected async onInit() {
|
||||
this.initDefaultBundle();
|
||||
}
|
||||
|
||||
/**
|
||||
* 初始化默认Bundle(resources)
|
||||
*/
|
||||
private initDefaultBundle(): void {
|
||||
this.bundleCache.set(ResManager.DEFAULT_BUNDLE, {
|
||||
bundle: resources,
|
||||
state: LoadState.LOADED,
|
||||
refCount: 1,
|
||||
isRemote: false,
|
||||
});
|
||||
LogUtils.info(ResManager.TAG, "初始化默认Bundle: resources");
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置最大并发加载数量
|
||||
* @param count 最大并发数
|
||||
*/
|
||||
public setMaxConcurrentLoads(count: number): void {
|
||||
this.maxConcurrentLoads = Math.max(1, count);
|
||||
LogUtils.info(ResManager.TAG, `设置最大并发加载数量: ${this.maxConcurrentLoads}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* 加载远端Bundle
|
||||
* @param bundleName Bundle名称
|
||||
* @param url Bundle的远端URL
|
||||
* @returns Promise<Bundle>
|
||||
*/
|
||||
public async loadRemoteBundle(bundleName: string, url: string): Promise<AssetManager.Bundle> {
|
||||
const cachedBundle = this.bundleCache.get(bundleName);
|
||||
|
||||
// 检查缓存
|
||||
if (cachedBundle) {
|
||||
if (cachedBundle.state === LoadState.LOADED) {
|
||||
cachedBundle.refCount++;
|
||||
LogUtils.debug(ResManager.TAG, `使用缓存Bundle: ${bundleName}`);
|
||||
return cachedBundle.bundle;
|
||||
} else if (cachedBundle.state === LoadState.LOADING) {
|
||||
// 正在加载中,等待加载完成
|
||||
return this.waitForBundleLoad(bundleName);
|
||||
}
|
||||
}
|
||||
|
||||
// 标记为加载中
|
||||
this.bundleCache.set(bundleName, {
|
||||
bundle: null,
|
||||
state: LoadState.LOADING,
|
||||
refCount: 1,
|
||||
isRemote: true,
|
||||
});
|
||||
|
||||
return new Promise<AssetManager.Bundle>((resolve, reject) => {
|
||||
const task: LoadTask = {
|
||||
url,
|
||||
type: "bundle",
|
||||
bundleName,
|
||||
resolve,
|
||||
reject,
|
||||
};
|
||||
|
||||
this.addToQueue(task);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 加载本地Bundle
|
||||
* @param bundleName Bundle名称
|
||||
* @returns Promise<Bundle>
|
||||
*/
|
||||
public async loadLocalBundle(bundleName: string): Promise<AssetManager.Bundle> {
|
||||
const cachedBundle = this.bundleCache.get(bundleName);
|
||||
|
||||
// 检查缓存
|
||||
if (cachedBundle) {
|
||||
if (cachedBundle.state === LoadState.LOADED) {
|
||||
cachedBundle.refCount++;
|
||||
LogUtils.debug(ResManager.TAG, `使用缓存Bundle: ${bundleName}`);
|
||||
return cachedBundle.bundle;
|
||||
} else if (cachedBundle.state === LoadState.LOADING) {
|
||||
// 正在加载中,等待加载完成
|
||||
return this.waitForBundleLoad(bundleName);
|
||||
}
|
||||
}
|
||||
|
||||
// 标记为加载中
|
||||
this.bundleCache.set(bundleName, {
|
||||
bundle: null as any,
|
||||
state: LoadState.LOADING,
|
||||
refCount: 1,
|
||||
isRemote: false,
|
||||
});
|
||||
|
||||
return new Promise<AssetManager.Bundle>((resolve, reject) => {
|
||||
const task: LoadTask = {
|
||||
url: bundleName, // 本地Bundle使用bundleName作为url
|
||||
type: "bundle",
|
||||
bundleName,
|
||||
resolve,
|
||||
reject,
|
||||
};
|
||||
|
||||
this.addToQueue(task);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 加载资源
|
||||
* @param assetPath 资源路径
|
||||
* @param assetType 资源类型
|
||||
* @param bundleName Bundle名称,不传则默认为resources
|
||||
* @returns Promise<T>
|
||||
*/
|
||||
public async loadAsset<T extends Asset>(
|
||||
assetPath: string,
|
||||
assetType: typeof Asset,
|
||||
bundleName?: string,
|
||||
): Promise<T> {
|
||||
const targetBundle = bundleName || ResManager.DEFAULT_BUNDLE;
|
||||
const cacheKey = `${targetBundle}/${assetPath}`;
|
||||
const cachedAsset = this.assetCache.get(cacheKey);
|
||||
|
||||
// 检查缓存
|
||||
if (cachedAsset) {
|
||||
if (cachedAsset.state === LoadState.LOADED) {
|
||||
cachedAsset.refCount++;
|
||||
LogUtils.debug(ResManager.TAG, `使用缓存资源: ${cacheKey}`);
|
||||
return cachedAsset.asset as T;
|
||||
} else if (cachedAsset.state === LoadState.LOADING) {
|
||||
// 正在加载中,等待加载完成
|
||||
return this.waitForAssetLoad<T>(cacheKey);
|
||||
}
|
||||
}
|
||||
|
||||
// 确保Bundle已加载
|
||||
await this.ensureBundleLoaded(targetBundle);
|
||||
|
||||
// 标记为加载中
|
||||
this.assetCache.set(cacheKey, {
|
||||
asset: null,
|
||||
state: LoadState.LOADING,
|
||||
refCount: 1,
|
||||
bundleName: targetBundle,
|
||||
});
|
||||
|
||||
return new Promise<T>((resolve, reject) => {
|
||||
const task: LoadTask = {
|
||||
url: cacheKey,
|
||||
type: "asset",
|
||||
bundleName: targetBundle,
|
||||
assetPath,
|
||||
assetType,
|
||||
resolve,
|
||||
reject,
|
||||
};
|
||||
|
||||
this.addToQueue(task);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 确保Bundle已加载
|
||||
* @param bundleName Bundle名称
|
||||
*/
|
||||
private async ensureBundleLoaded(bundleName: string): Promise<void> {
|
||||
const bundleInfo = this.bundleCache.get(bundleName);
|
||||
|
||||
if (!bundleInfo || bundleInfo.state === LoadState.NONE) {
|
||||
// Bundle不存在或状态为NONE,尝试加载本地Bundle
|
||||
LogUtils.info(ResManager.TAG, `Bundle ${bundleName} 不存在或未加载,尝试加载本地Bundle`);
|
||||
try {
|
||||
await this.loadLocalBundle(bundleName);
|
||||
} catch (err) {
|
||||
LogUtils.error(
|
||||
ResManager.TAG,
|
||||
`Bundle ${bundleName} 本地加载失败,请检查Bundle是否存在或调用 loadRemoteBundle 加载远程Bundle`,
|
||||
err,
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (bundleInfo.state === LoadState.FAILED) {
|
||||
throw new Error(`Bundle ${bundleName} 加载失败`);
|
||||
}
|
||||
|
||||
if (bundleInfo.state === LoadState.LOADING) {
|
||||
// Bundle正在加载中,等待加载完成
|
||||
LogUtils.info(ResManager.TAG, `Bundle ${bundleName} 正在加载中,等待加载完成`);
|
||||
await this.waitForBundleLoad(bundleName);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 释放Bundle
|
||||
* @param bundleName Bundle名称
|
||||
*/
|
||||
public releaseBundle(bundleName: string): void {
|
||||
// 不允许释放默认Bundle
|
||||
if (bundleName === ResManager.DEFAULT_BUNDLE) {
|
||||
LogUtils.warn(ResManager.TAG, "不允许释放默认Bundle: resources");
|
||||
return;
|
||||
}
|
||||
|
||||
const bundleInfo = this.bundleCache.get(bundleName);
|
||||
if (!bundleInfo) {
|
||||
LogUtils.warn(ResManager.TAG, `Bundle ${bundleName} 不存在`);
|
||||
return;
|
||||
}
|
||||
|
||||
bundleInfo.refCount--;
|
||||
LogUtils.debug(ResManager.TAG, `Bundle ${bundleName} 引用计数: ${bundleInfo.refCount}`);
|
||||
|
||||
if (bundleInfo.refCount <= 0) {
|
||||
// 释放Bundle中的所有资源
|
||||
this.releaseAssetsInBundle(bundleName);
|
||||
|
||||
// 释放Bundle
|
||||
if (bundleInfo.bundle && bundleInfo.isRemote) {
|
||||
assetManager.removeBundle(bundleInfo.bundle);
|
||||
}
|
||||
|
||||
this.bundleCache.delete(bundleName);
|
||||
LogUtils.info(ResManager.TAG, `释放Bundle: ${bundleName}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 释放资源
|
||||
* @param assetPath 资源路径
|
||||
* @param bundleName Bundle名称,不传则默认为resources
|
||||
*/
|
||||
public releaseAsset(assetPath: string, bundleName?: string): void {
|
||||
const targetBundle = bundleName || ResManager.DEFAULT_BUNDLE;
|
||||
const cacheKey = `${targetBundle}/${assetPath}`;
|
||||
const assetInfo = this.assetCache.get(cacheKey);
|
||||
|
||||
if (!assetInfo) {
|
||||
LogUtils.warn(ResManager.TAG, `资源 ${cacheKey} 不存在`);
|
||||
return;
|
||||
}
|
||||
|
||||
assetInfo.refCount--;
|
||||
LogUtils.debug(ResManager.TAG, `资源 ${cacheKey} 引用计数: ${assetInfo.refCount}`);
|
||||
|
||||
if (assetInfo.refCount <= 0) {
|
||||
if (assetInfo.asset) {
|
||||
assetInfo.asset.decRef();
|
||||
}
|
||||
|
||||
this.assetCache.delete(cacheKey);
|
||||
LogUtils.info(ResManager.TAG, `释放资源: ${cacheKey}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 清理所有缓存(保留默认Bundle)
|
||||
*/
|
||||
public clearAll(): void {
|
||||
// 释放所有资源
|
||||
for (const [, assetInfo] of this.assetCache) {
|
||||
if (assetInfo.asset) {
|
||||
assetInfo.asset.decRef();
|
||||
}
|
||||
}
|
||||
this.assetCache.clear();
|
||||
|
||||
// 释放所有远端Bundle
|
||||
for (const [key, bundleInfo] of this.bundleCache) {
|
||||
if (key !== ResManager.DEFAULT_BUNDLE && bundleInfo.bundle && bundleInfo.isRemote) {
|
||||
assetManager.removeBundle(bundleInfo.bundle);
|
||||
}
|
||||
}
|
||||
|
||||
// 清空Bundle缓存,但保留默认Bundle
|
||||
const defaultBundle = this.bundleCache.get(ResManager.DEFAULT_BUNDLE);
|
||||
this.bundleCache.clear();
|
||||
if (defaultBundle) {
|
||||
this.bundleCache.set(ResManager.DEFAULT_BUNDLE, defaultBundle);
|
||||
}
|
||||
|
||||
// 清空加载队列
|
||||
this.loadQueue = [];
|
||||
this.loadingCount = 0;
|
||||
this.bundleWaitQueue.clear();
|
||||
this.assetWaitQueue.clear();
|
||||
|
||||
LogUtils.info(ResManager.TAG, "清理所有缓存完成");
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取缓存状态信息
|
||||
*/
|
||||
public getCacheInfo(): {
|
||||
bundleCount: number;
|
||||
assetCount: number;
|
||||
queueLength: number;
|
||||
loadingCount: number;
|
||||
} {
|
||||
return {
|
||||
bundleCount: this.bundleCache.size,
|
||||
assetCount: this.assetCache.size,
|
||||
queueLength: this.loadQueue.length,
|
||||
loadingCount: this.loadingCount,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 添加任务到队列
|
||||
* @param task 加载任务
|
||||
*/
|
||||
private addToQueue(task: LoadTask): void {
|
||||
this.loadQueue.push(task);
|
||||
void this.processQueue();
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理加载队列
|
||||
*/
|
||||
private async processQueue(): Promise<void> {
|
||||
if (this.isProcessingQueue || this.loadingCount >= this.maxConcurrentLoads) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.isProcessingQueue = true;
|
||||
|
||||
while (this.loadQueue.length > 0 && this.loadingCount < this.maxConcurrentLoads) {
|
||||
const task = this.loadQueue.shift();
|
||||
if (!task) break;
|
||||
|
||||
this.loadingCount++;
|
||||
this.executeTask(task)
|
||||
.then(() => {
|
||||
this.loadingCount--;
|
||||
void this.processQueue();
|
||||
})
|
||||
.catch((err) => {
|
||||
LogUtils.error(ResManager.TAG, `队列任务执行失败: ${task.url}`, err);
|
||||
this.loadingCount--;
|
||||
void this.processQueue();
|
||||
});
|
||||
}
|
||||
|
||||
this.isProcessingQueue = false;
|
||||
}
|
||||
|
||||
/**
|
||||
* 执行加载任务
|
||||
* @param task 加载任务
|
||||
*/
|
||||
private async executeTask(task: LoadTask): Promise<void> {
|
||||
try {
|
||||
await (task.type === "bundle" ? this.loadBundleTask(task) : this.loadAssetTask(task));
|
||||
} catch (err) {
|
||||
LogUtils.error(ResManager.TAG, `加载任务失败: ${task.url}`, err);
|
||||
task.reject(err as Error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 执行Bundle加载任务
|
||||
* @param task 加载任务
|
||||
*/
|
||||
private async loadBundleTask(task: LoadTask): Promise<void> {
|
||||
const { url, bundleName, resolve, reject } = task;
|
||||
if (!bundleName) {
|
||||
reject(new Error("Bundle名称不能为空"));
|
||||
return;
|
||||
}
|
||||
|
||||
const bundleInfo = this.bundleCache.get(bundleName);
|
||||
const isRemote = bundleInfo?.isRemote ?? false;
|
||||
|
||||
// 根据是否为远端Bundle选择不同的加载方式
|
||||
if (isRemote) {
|
||||
// 远端Bundle加载
|
||||
assetManager.loadBundle(url, (err, bundle) => {
|
||||
this.handleBundleLoadResult(bundleName, err, bundle, resolve, reject);
|
||||
});
|
||||
} else {
|
||||
// 本地Bundle加载
|
||||
assetManager.loadBundle(bundleName, (err, bundle) => {
|
||||
this.handleBundleLoadResult(bundleName, err, bundle, resolve, reject);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理Bundle加载结果
|
||||
* @param bundleName Bundle名称
|
||||
* @param err 错误信息
|
||||
* @param bundle Bundle对象
|
||||
* @param resolve Promise resolve函数
|
||||
* @param reject Promise reject函数
|
||||
*/
|
||||
private handleBundleLoadResult(
|
||||
bundleName: string,
|
||||
err: Error | null,
|
||||
bundle: AssetManager.Bundle,
|
||||
resolve: (result: AssetManager.Bundle) => void,
|
||||
reject: (error: Error) => void,
|
||||
): void {
|
||||
const bundleInfo = this.bundleCache.get(bundleName);
|
||||
|
||||
if (err) {
|
||||
LogUtils.error(ResManager.TAG, `Bundle加载失败: ${bundleName}`, err);
|
||||
if (bundleInfo) {
|
||||
bundleInfo.state = LoadState.FAILED;
|
||||
}
|
||||
|
||||
// 通知所有等待的任务
|
||||
this.notifyBundleWaiters(bundleName, false, new Error(`Bundle加载失败: ${err.message}`));
|
||||
reject(new Error(`Bundle加载失败: ${err.message}`));
|
||||
return;
|
||||
}
|
||||
|
||||
if (bundleInfo) {
|
||||
bundleInfo.bundle = bundle;
|
||||
bundleInfo.state = LoadState.LOADED;
|
||||
}
|
||||
|
||||
LogUtils.info(ResManager.TAG, `Bundle加载成功: ${bundleName}`);
|
||||
|
||||
// 通知所有等待的任务
|
||||
this.notifyBundleWaiters(bundleName, true, bundle);
|
||||
resolve(bundle);
|
||||
}
|
||||
|
||||
/**
|
||||
* 执行资源加载任务
|
||||
* @param task 加载任务
|
||||
*/
|
||||
private async loadAssetTask(task: LoadTask): Promise<void> {
|
||||
const { url, bundleName, assetPath, assetType, resolve, reject } = task;
|
||||
if (!bundleName || !assetPath || !assetType) {
|
||||
reject(new Error("资源加载参数不完整"));
|
||||
return;
|
||||
}
|
||||
|
||||
const bundleInfo = this.bundleCache.get(bundleName);
|
||||
|
||||
if (!bundleInfo?.bundle) {
|
||||
reject(new Error(`Bundle ${bundleName} 不存在`));
|
||||
return;
|
||||
}
|
||||
|
||||
bundleInfo.bundle.load(assetPath, assetType, (err, asset) => {
|
||||
const assetInfo = this.assetCache.get(url);
|
||||
|
||||
if (err) {
|
||||
LogUtils.error(ResManager.TAG, `资源加载失败: ${url}`, err);
|
||||
if (assetInfo) {
|
||||
assetInfo.state = LoadState.FAILED;
|
||||
}
|
||||
|
||||
// 通知所有等待的任务
|
||||
this.notifyAssetWaiters(url, false, new Error(`资源加载失败: ${err.message}`));
|
||||
reject(new Error(`资源加载失败: ${err.message}`));
|
||||
return;
|
||||
}
|
||||
|
||||
if (assetInfo) {
|
||||
assetInfo.asset = asset;
|
||||
assetInfo.state = LoadState.LOADED;
|
||||
}
|
||||
|
||||
LogUtils.info(ResManager.TAG, `资源加载成功: ${url}`);
|
||||
|
||||
// 通知所有等待的任务
|
||||
this.notifyAssetWaiters(url, true, asset);
|
||||
resolve(asset);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 等待Bundle加载完成
|
||||
* @param bundleName Bundle名称
|
||||
*/
|
||||
private async waitForBundleLoad(bundleName: string): Promise<AssetManager.Bundle> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const bundleInfo = this.bundleCache.get(bundleName);
|
||||
|
||||
if (bundleInfo?.state === LoadState.LOADED) {
|
||||
bundleInfo.refCount++;
|
||||
resolve(bundleInfo.bundle);
|
||||
return;
|
||||
}
|
||||
|
||||
if (bundleInfo?.state === LoadState.FAILED) {
|
||||
reject(new Error(`Bundle ${bundleName} 加载失败`));
|
||||
return;
|
||||
}
|
||||
|
||||
// 添加到等待队列
|
||||
if (!this.bundleWaitQueue.has(bundleName)) {
|
||||
this.bundleWaitQueue.set(bundleName, []);
|
||||
}
|
||||
this.bundleWaitQueue.get(bundleName).push({ resolve, reject });
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 等待资源加载完成
|
||||
* @param cacheKey 缓存键
|
||||
*/
|
||||
private async waitForAssetLoad<T extends Asset>(cacheKey: string): Promise<T> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const assetInfo = this.assetCache.get(cacheKey);
|
||||
|
||||
if (assetInfo?.state === LoadState.LOADED) {
|
||||
assetInfo.refCount++;
|
||||
resolve(assetInfo.asset as T);
|
||||
return;
|
||||
}
|
||||
|
||||
if (assetInfo?.state === LoadState.FAILED) {
|
||||
reject(new Error(`资源 ${cacheKey} 加载失败`));
|
||||
return;
|
||||
}
|
||||
|
||||
// 添加到等待队列
|
||||
if (!this.assetWaitQueue.has(cacheKey)) {
|
||||
this.assetWaitQueue.set(cacheKey, []);
|
||||
}
|
||||
this.assetWaitQueue.get(cacheKey).push({ resolve, reject });
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 通知Bundle等待者
|
||||
* @param bundleName Bundle名称
|
||||
* @param success 是否成功
|
||||
* @param result 结果或错误
|
||||
*/
|
||||
private notifyBundleWaiters(bundleName: string, success: boolean, result: any): void {
|
||||
const waiters = this.bundleWaitQueue.get(bundleName);
|
||||
if (!waiters) return;
|
||||
|
||||
for (const waiter of waiters) {
|
||||
if (success) {
|
||||
const bundleInfo = this.bundleCache.get(bundleName);
|
||||
if (bundleInfo) {
|
||||
bundleInfo.refCount++;
|
||||
}
|
||||
waiter.resolve(result);
|
||||
} else {
|
||||
waiter.reject(result);
|
||||
}
|
||||
}
|
||||
|
||||
this.bundleWaitQueue.delete(bundleName);
|
||||
}
|
||||
|
||||
/**
|
||||
* 通知资源等待者
|
||||
* @param cacheKey 缓存键
|
||||
* @param success 是否成功
|
||||
* @param result 结果或错误
|
||||
*/
|
||||
private notifyAssetWaiters(cacheKey: string, success: boolean, result: any): void {
|
||||
const waiters = this.assetWaitQueue.get(cacheKey);
|
||||
if (!waiters) return;
|
||||
|
||||
for (const waiter of waiters) {
|
||||
if (success) {
|
||||
const assetInfo = this.assetCache.get(cacheKey);
|
||||
if (assetInfo) {
|
||||
assetInfo.refCount++;
|
||||
}
|
||||
waiter.resolve(result);
|
||||
} else {
|
||||
waiter.reject(result);
|
||||
}
|
||||
}
|
||||
|
||||
this.assetWaitQueue.delete(cacheKey);
|
||||
}
|
||||
|
||||
/**
|
||||
* 释放Bundle中的所有资源
|
||||
* @param bundleName Bundle名称
|
||||
*/
|
||||
private releaseAssetsInBundle(bundleName: string): void {
|
||||
const assetsToRemove: string[] = [];
|
||||
|
||||
for (const [key, assetInfo] of this.assetCache) {
|
||||
if (assetInfo.bundleName === bundleName) {
|
||||
if (assetInfo.asset) {
|
||||
assetInfo.asset.decRef();
|
||||
}
|
||||
assetsToRemove.push(key);
|
||||
}
|
||||
}
|
||||
|
||||
for (const key of assetsToRemove) {
|
||||
this.assetCache.delete(key);
|
||||
}
|
||||
|
||||
LogUtils.debug(ResManager.TAG, `释放Bundle ${bundleName} 中的 ${assetsToRemove.length} 个资源`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"ver": "4.0.24",
|
||||
"importer": "typescript",
|
||||
"imported": true,
|
||||
"uuid": "16315e98-964a-4632-beb3-44842a65a045",
|
||||
"files": [],
|
||||
"subMetas": {},
|
||||
"userData": {}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"ver": "1.2.0",
|
||||
"importer": "directory",
|
||||
"imported": true,
|
||||
"uuid": "e05b4383-3170-4dd4-af7c-cc04bad7b4c2",
|
||||
"files": [],
|
||||
"subMetas": {},
|
||||
"userData": {}
|
||||
}
|
||||
@@ -0,0 +1,148 @@
|
||||
import AccountManager from "../account/AccountManager";
|
||||
import { IAccountInfo } from "../account/Types";
|
||||
import { NetworkManager } from "../net/NetworkManager";
|
||||
import { Singleton } from "../Singleton";
|
||||
import LogUtils from "../utils/LogUtils";
|
||||
import { StringUtils } from "../utils/StringUtils";
|
||||
|
||||
const ACCOUNT_SAVE_KEY: string = "account_save_key";
|
||||
const ACCOUNT_DATA_SAVE_KEY: string = "account_data_save_key";
|
||||
export const GUIDE_HISTORY_KEY: string = "guide_history_key";
|
||||
const VERSION_KEY: string = "version";
|
||||
|
||||
export default class StorageManager extends Singleton {
|
||||
private storageData: Record<string, any> = {};
|
||||
private needSaveRemote: boolean = false;
|
||||
private isSaving: boolean = false;
|
||||
private ignoreSaveKeys: Set<string> = new Set([GUIDE_HISTORY_KEY]);
|
||||
|
||||
private saveInterval: number | NodeJS.Timeout = 0;
|
||||
private saveKeys: Set<string> = new Set();
|
||||
|
||||
protected async onInit(): Promise<void> {
|
||||
this.saveInterval = setInterval(this.saveRemoteData.bind(this), 30000);
|
||||
}
|
||||
|
||||
public get account(): IAccountInfo {
|
||||
const accountInfo = localStorage.getItem(ACCOUNT_SAVE_KEY);
|
||||
if (accountInfo) {
|
||||
return JSON.parse(accountInfo) as IAccountInfo;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
public set account(value: IAccountInfo) {
|
||||
if (!value) {
|
||||
return;
|
||||
}
|
||||
localStorage.setItem(ACCOUNT_SAVE_KEY, JSON.stringify(value));
|
||||
}
|
||||
|
||||
public setData(key: string, value: any) {
|
||||
// 如果是基础类型, 需要优先判断值是否有修改
|
||||
if (
|
||||
typeof value === "number" ||
|
||||
typeof value === "string" ||
|
||||
typeof value === "boolean"
|
||||
) {
|
||||
if (this.storageData[key] === value) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
this.storageData[key] = value;
|
||||
if (this.ignoreSaveKeys.has(key)) {
|
||||
this.saveData();
|
||||
return;
|
||||
}
|
||||
this.saveKeys.add(key);
|
||||
this.storageData[VERSION_KEY] += 1;
|
||||
this.saveData();
|
||||
this.needSaveRemote = true;
|
||||
}
|
||||
|
||||
public getData<T = any>(key: string) {
|
||||
return this.storageData[key] as T;
|
||||
}
|
||||
|
||||
public loadData(uid: string, remoteData: Record<string, any>) {
|
||||
const localDataValue = localStorage.getItem(
|
||||
`${ACCOUNT_DATA_SAVE_KEY}_${uid}`,
|
||||
);
|
||||
let localData: Record<string, any> = {};
|
||||
if (!StringUtils.isEmpty(localDataValue)) {
|
||||
localData = JSON.parse(localDataValue);
|
||||
}
|
||||
|
||||
const compareResult = this.compareData(localData, remoteData);
|
||||
if (compareResult > 0) {
|
||||
this.storageData = localData;
|
||||
this.needSaveRemote = true;
|
||||
} else if (compareResult < 0) {
|
||||
this.storageData = remoteData;
|
||||
}
|
||||
}
|
||||
|
||||
private saveData() {
|
||||
const uid = AccountManager.getInstance().accountInfo.uid;
|
||||
if (uid) {
|
||||
localStorage.setItem(
|
||||
`${ACCOUNT_DATA_SAVE_KEY}_${uid}`,
|
||||
JSON.stringify(this.storageData),
|
||||
);
|
||||
LogUtils.info("StorageManager", "saveData", uid);
|
||||
}
|
||||
}
|
||||
|
||||
private async saveRemoteData() {
|
||||
if (!this.needSaveRemote || this.isSaving || this.saveKeys.size <= 0) {
|
||||
return;
|
||||
}
|
||||
this.isSaving = true;
|
||||
const httpClient = NetworkManager.getInstance().getHttpClient();
|
||||
const uploadData: Record<string, any> = {};
|
||||
for (const key of this.saveKeys) {
|
||||
uploadData[key] = this.storageData[key];
|
||||
}
|
||||
uploadData[VERSION_KEY] = this.storageData[VERSION_KEY];
|
||||
const rsp = await httpClient.send("/user/update", "POST", uploadData);
|
||||
if (rsp.success) {
|
||||
this.saveKeys.clear();
|
||||
}
|
||||
|
||||
this.isSaving = false;
|
||||
}
|
||||
|
||||
private compareData(
|
||||
localData: Record<string, any>,
|
||||
remoteData: Record<string, any>,
|
||||
): number {
|
||||
if (
|
||||
localData[VERSION_KEY] === undefined ||
|
||||
isNaN(localData[VERSION_KEY])
|
||||
) {
|
||||
localData[VERSION_KEY] = 1;
|
||||
}
|
||||
if (
|
||||
remoteData[VERSION_KEY] === undefined ||
|
||||
isNaN(remoteData[VERSION_KEY])
|
||||
) {
|
||||
remoteData[VERSION_KEY] = 1;
|
||||
}
|
||||
|
||||
if (localData[VERSION_KEY] > remoteData[VERSION_KEY]) {
|
||||
return 1;
|
||||
} else if (localData[VERSION_KEY] < remoteData[VERSION_KEY]) {
|
||||
return -1;
|
||||
} else {
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
protected onRelease(): void {
|
||||
if (this.saveInterval) {
|
||||
clearInterval(this.saveInterval);
|
||||
this.saveInterval = 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"ver": "4.0.24",
|
||||
"importer": "typescript",
|
||||
"imported": true,
|
||||
"uuid": "3ab1f6fe-c52e-48ac-9fc0-7cfbeb17a654",
|
||||
"files": [],
|
||||
"subMetas": {},
|
||||
"userData": {}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
/**
|
||||
* 存储配置接口
|
||||
*/
|
||||
export interface StorageConfig {
|
||||
enableRemoteSync?: boolean;
|
||||
remoteBaseUrl?: string;
|
||||
/** 同步时间间隔(毫秒),默认1分钟 */
|
||||
syncInterval?: number;
|
||||
remoteIgnoreList?: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* 存储项接口
|
||||
*/
|
||||
export interface StorageItem<T = any> {
|
||||
value: T;
|
||||
timestamp: number;
|
||||
version: number;
|
||||
needSync?: boolean;
|
||||
lastSyncTime?: number;
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"ver": "4.0.24",
|
||||
"importer": "typescript",
|
||||
"imported": true,
|
||||
"uuid": "30d7b260-c453-42fa-a47a-9c2295081772",
|
||||
"files": [],
|
||||
"subMetas": {},
|
||||
"userData": {}
|
||||
}
|
||||
9
extensions/max-studio/assets/max-studio/core/task.meta
Normal file
9
extensions/max-studio/assets/max-studio/core/task.meta
Normal file
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"ver": "1.2.0",
|
||||
"importer": "directory",
|
||||
"imported": true,
|
||||
"uuid": "0efdbac2-799b-4086-a224-398e3481d588",
|
||||
"files": [],
|
||||
"subMetas": {},
|
||||
"userData": {}
|
||||
}
|
||||
241
extensions/max-studio/assets/max-studio/core/task/TaskData.ts
Normal file
241
extensions/max-studio/assets/max-studio/core/task/TaskData.ts
Normal file
@@ -0,0 +1,241 @@
|
||||
/**
|
||||
* 任务类型枚举
|
||||
*/
|
||||
export enum GlobalTaskType {
|
||||
/** 每日任务 */
|
||||
DAILY = "daily",
|
||||
/** 每周任务 */
|
||||
WEEKLY = "weekly",
|
||||
/** 每月任务 */
|
||||
MONTHLY = "monthly",
|
||||
/** 赛季任务 */
|
||||
SEASONAL = "seasonal",
|
||||
/** 活动任务 */
|
||||
EVENT = "event",
|
||||
/** 成就任务 */
|
||||
ACHIEVEMENT = "achievement",
|
||||
/** 主线任务 */
|
||||
MAIN = "main",
|
||||
/** 支线任务 */
|
||||
SIDE = "side",
|
||||
}
|
||||
|
||||
export type TaskType = GlobalTaskType | string;
|
||||
|
||||
/**
|
||||
* 任务状态枚举
|
||||
*/
|
||||
export enum TaskStatus {
|
||||
/** 未激活 */
|
||||
INACTIVE = "inactive",
|
||||
/** 进行中 */
|
||||
ACTIVE = "active",
|
||||
/** 已完成 */
|
||||
COMPLETED = "completed",
|
||||
/** 已领取奖励 */
|
||||
CLAIMED = "claimed",
|
||||
/** 已过期 */
|
||||
EXPIRED = "expired",
|
||||
/** 已锁定 */
|
||||
LOCKED = "locked",
|
||||
}
|
||||
|
||||
/**
|
||||
* 任务目标类型
|
||||
*/
|
||||
export enum TaskTargetType {
|
||||
/** 数量目标 */
|
||||
COUNT = "count",
|
||||
/** 收集目标 */
|
||||
COLLECT = "collect",
|
||||
/** 击败目标 */
|
||||
DEFEAT = "defeat",
|
||||
/** 达到等级 */
|
||||
REACH_LEVEL = "reach_level",
|
||||
/** 消费目标 */
|
||||
SPEND = "spend",
|
||||
/** 获得目标 */
|
||||
GAIN = "gain",
|
||||
/** 使用目标 */
|
||||
USE = "use",
|
||||
/** 完成目标 */
|
||||
COMPLETE = "complete",
|
||||
}
|
||||
|
||||
/**
|
||||
* 任务重置周期
|
||||
*/
|
||||
export enum TaskResetCycle {
|
||||
/** 不重置 */
|
||||
NEVER = "never",
|
||||
/** 每日重置 */
|
||||
DAILY = "daily",
|
||||
/** 每周重置 */
|
||||
WEEKLY = "weekly",
|
||||
/** 每月重置 */
|
||||
MONTHLY = "monthly",
|
||||
/** 赛季重置 */
|
||||
SEASONAL = "seasonal",
|
||||
}
|
||||
|
||||
/**
|
||||
* 奖励类型
|
||||
*/
|
||||
export enum RewardType {
|
||||
/** 金币 */
|
||||
GOLD = "gold",
|
||||
/** 经验 */
|
||||
EXP = "exp",
|
||||
/** 道具 */
|
||||
ITEM = "item",
|
||||
/** 装备 */
|
||||
EQUIPMENT = "equipment",
|
||||
/** 货币 */
|
||||
CURRENCY = "currency",
|
||||
/** 称号 */
|
||||
TITLE = "title",
|
||||
}
|
||||
|
||||
/**
|
||||
* 任务奖励配置
|
||||
*/
|
||||
export interface ITaskReward {
|
||||
/** 奖励类型 */
|
||||
type: RewardType;
|
||||
/** 奖励ID */
|
||||
id: string;
|
||||
/** 奖励数量 */
|
||||
amount: number;
|
||||
/** 奖励名称 */
|
||||
name?: string;
|
||||
/** 奖励描述 */
|
||||
description?: string;
|
||||
/** 奖励图标 */
|
||||
icon?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 任务目标配置
|
||||
*/
|
||||
export interface ITaskTarget {
|
||||
/** 目标ID */
|
||||
id: string;
|
||||
/** 目标类型 */
|
||||
type: TaskTargetType;
|
||||
/** 目标描述 */
|
||||
description: string;
|
||||
/** 目标数量 */
|
||||
targetCount: number;
|
||||
/** 当前进度 */
|
||||
currentCount: number;
|
||||
/** 目标参数 */
|
||||
params?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
/**
|
||||
* 任务条件配置
|
||||
*/
|
||||
export interface ITaskCondition {
|
||||
/** 条件类型 */
|
||||
type: string;
|
||||
/** 条件参数 */
|
||||
params?: Record<string, unknown>;
|
||||
/** 条件表达式 */
|
||||
expression?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 任务配置接口
|
||||
*/
|
||||
export interface ITaskConfig {
|
||||
/** 任务ID */
|
||||
id: string;
|
||||
/** 任务名称 */
|
||||
name: string;
|
||||
/** 任务描述 */
|
||||
description: string;
|
||||
/** 任务类型 */
|
||||
type: TaskType;
|
||||
/** 任务优先级 */
|
||||
priority: number;
|
||||
/** 任务图标 */
|
||||
icon?: string;
|
||||
/** 任务目标列表 */
|
||||
targets: ITaskTarget[];
|
||||
/** 任务奖励列表 */
|
||||
rewards: ITaskReward[];
|
||||
/** 解锁条件 */
|
||||
unlockConditions?: ITaskCondition[];
|
||||
/** 完成条件 */
|
||||
completeConditions?: ITaskCondition[];
|
||||
/** 重置周期 */
|
||||
resetCycle: TaskResetCycle;
|
||||
/** 开始时间 */
|
||||
startTime?: number;
|
||||
/** 结束时间 */
|
||||
endTime?: number;
|
||||
/** 是否启用 */
|
||||
enabled: boolean;
|
||||
/** 是否可重复 */
|
||||
repeatable: boolean;
|
||||
/** 前置任务ID列表 */
|
||||
prerequisites?: string[];
|
||||
/** 自定义数据 */
|
||||
customData?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
/**
|
||||
* 任务运行时数据
|
||||
*/
|
||||
export interface ITaskRuntimeData {
|
||||
/** 任务配置 */
|
||||
config: ITaskConfig;
|
||||
/** 任务状态 */
|
||||
status: TaskStatus;
|
||||
/** 任务目标进度 */
|
||||
targets: ITaskTarget[];
|
||||
/** 激活时间 */
|
||||
activatedTime?: number;
|
||||
/** 完成时间 */
|
||||
completedTime?: number;
|
||||
/** 领取时间 */
|
||||
claimedTime?: number;
|
||||
/** 过期时间 */
|
||||
expiredTime?: number;
|
||||
/** 重置时间 */
|
||||
resetTime?: number;
|
||||
/** 自定义运行时数据 */
|
||||
runtimeData?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
/**
|
||||
* 任务事件类型
|
||||
*/
|
||||
export enum TaskEventType {
|
||||
/** 任务激活 */
|
||||
TASK_ACTIVATED = "task_activated",
|
||||
/** 任务进度更新 */
|
||||
TASK_PROGRESS_UPDATED = "task_progress_updated",
|
||||
/** 任务完成 */
|
||||
TASK_COMPLETED = "task_completed",
|
||||
/** 任务奖励领取 */
|
||||
TASK_REWARD_CLAIMED = "task_reward_claimed",
|
||||
/** 任务过期 */
|
||||
TASK_EXPIRED = "task_expired",
|
||||
/** 任务重置 */
|
||||
TASK_RESET = "task_reset",
|
||||
}
|
||||
|
||||
/**
|
||||
* 任务事件数据
|
||||
*/
|
||||
export interface ITaskEventData {
|
||||
/** 事件类型 */
|
||||
type: TaskEventType;
|
||||
/** 任务ID */
|
||||
taskId: string;
|
||||
/** 事件时间 */
|
||||
timestamp: number;
|
||||
/** 事件数据 */
|
||||
data?: Record<string, unknown>;
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"ver": "4.0.24",
|
||||
"importer": "typescript",
|
||||
"imported": true,
|
||||
"uuid": "208a38dd-f8fb-4792-b872-751c0f081679",
|
||||
"files": [],
|
||||
"subMetas": {},
|
||||
"userData": {}
|
||||
}
|
||||
553
extensions/max-studio/assets/max-studio/core/task/TaskManager.ts
Normal file
553
extensions/max-studio/assets/max-studio/core/task/TaskManager.ts
Normal file
@@ -0,0 +1,553 @@
|
||||
import LogUtils from "../utils/LogUtils";
|
||||
import { singleton, Singleton } from "../Singleton";
|
||||
import {
|
||||
ITaskConfig,
|
||||
ITaskRuntimeData,
|
||||
ITaskReward,
|
||||
TaskStatus,
|
||||
TaskType,
|
||||
TaskEventType,
|
||||
} from "./TaskData";
|
||||
import { TaskTypeManager } from "./TaskTypeManager";
|
||||
import { EventManager } from "../event/EventManager";
|
||||
import TimeManager from "../timer/TimerManager";
|
||||
import { ResManager } from "../res/ResManager";
|
||||
import { JsonAsset } from "cc";
|
||||
|
||||
const TAG = "TaskManager";
|
||||
|
||||
/**
|
||||
* 任务管理器
|
||||
*/
|
||||
@singleton()
|
||||
export class TaskManager extends Singleton {
|
||||
/** 任务配置映射 */
|
||||
private taskConfigs: Map<string, ITaskConfig> = new Map();
|
||||
|
||||
/** 任务运行时数据映射 */
|
||||
private taskRuntimeData: Map<string, ITaskRuntimeData> = new Map();
|
||||
|
||||
/** 任务类型管理器 */
|
||||
private typeManager: TaskTypeManager;
|
||||
|
||||
/** 是否已初始化 */
|
||||
private initialized = false;
|
||||
|
||||
/**
|
||||
* 初始化方法(由 Singleton 基类调用)
|
||||
*/
|
||||
protected async onInit() {
|
||||
LogUtils.info(TAG, "TaskManager 单例初始化");
|
||||
|
||||
if (this.initialized) {
|
||||
LogUtils.warn(TAG, "任务管理器已经初始化");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
LogUtils.info(TAG, "开始初始化任务管理器");
|
||||
this.typeManager = TaskTypeManager.getInstance();
|
||||
// 加载任务配置
|
||||
await this.loadTaskConfigs();
|
||||
|
||||
// 加载任务运行时数据
|
||||
await this.loadTaskRuntimeData();
|
||||
|
||||
// 检查任务状态
|
||||
this.checkTaskStatus();
|
||||
|
||||
this.initialized = true;
|
||||
LogUtils.info(TAG, "任务管理器初始化完成");
|
||||
} catch (err) {
|
||||
LogUtils.error(TAG, "任务管理器初始化失败:", err);
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 加载任务配置
|
||||
*/
|
||||
private async loadTaskConfigs(): Promise<void> {
|
||||
// TODO: 从配置文件或服务器加载任务配置
|
||||
LogUtils.info(TAG, "加载任务配置");
|
||||
const jsonAsset = await ResManager.getInstance().loadAsset<JsonAsset>(
|
||||
"task-configs",
|
||||
JsonAsset,
|
||||
);
|
||||
const taskConfigs = jsonAsset.json as Record<string, ITaskConfig[]>;
|
||||
|
||||
for (const taskType of Object.keys(taskConfigs)) {
|
||||
const configs = taskConfigs[taskType];
|
||||
for (const config of configs) {
|
||||
this.addTaskConfig(config);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 加载任务运行时数据
|
||||
*/
|
||||
private async loadTaskRuntimeData(): Promise<void> {
|
||||
// TODO: 从本地存储加载任务运行时数据
|
||||
LogUtils.info(TAG, "加载任务运行时数据");
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查任务状态
|
||||
*/
|
||||
private checkTaskStatus(): void {
|
||||
for (const [taskId, runtimeData] of this.taskRuntimeData) {
|
||||
const config = runtimeData.config;
|
||||
|
||||
// 检查任务是否过期
|
||||
if (
|
||||
runtimeData.status === TaskStatus.ACTIVE &&
|
||||
runtimeData.activatedTime &&
|
||||
this.typeManager.isTaskExpired(
|
||||
config,
|
||||
runtimeData.activatedTime,
|
||||
)
|
||||
) {
|
||||
this.expireTask(taskId);
|
||||
continue;
|
||||
}
|
||||
|
||||
// 检查任务是否需要重置
|
||||
if (
|
||||
runtimeData.resetTime &&
|
||||
this.typeManager.shouldResetTask(config, runtimeData.resetTime)
|
||||
) {
|
||||
this.resetTask(taskId);
|
||||
continue;
|
||||
}
|
||||
|
||||
// 检查任务是否可以激活
|
||||
if (
|
||||
runtimeData.status === TaskStatus.INACTIVE &&
|
||||
this.canActivateTask(config)
|
||||
) {
|
||||
this.activateTask(taskId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 添加任务配置
|
||||
*/
|
||||
public addTaskConfig(config: ITaskConfig): boolean {
|
||||
try {
|
||||
// 验证任务配置
|
||||
if (!this.typeManager.validateTaskConfig(config)) {
|
||||
LogUtils.warn(TAG, `任务配置验证失败: ${config.id}`);
|
||||
return false;
|
||||
}
|
||||
|
||||
this.taskConfigs.set(config.id, config);
|
||||
|
||||
// 创建运行时数据
|
||||
const runtimeData: ITaskRuntimeData = {
|
||||
config: config,
|
||||
status: TaskStatus.INACTIVE,
|
||||
targets: config.targets.map((target) => ({ ...target })),
|
||||
resetTime: Date.now(),
|
||||
};
|
||||
|
||||
this.taskRuntimeData.set(config.id, runtimeData);
|
||||
|
||||
LogUtils.info(TAG, `添加任务配置: ${config.id}`);
|
||||
return true;
|
||||
} catch (err) {
|
||||
LogUtils.error(TAG, `添加任务配置失败: ${config.id}`, err);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 移除任务配置
|
||||
*/
|
||||
public removeTaskConfig(taskId: string): boolean {
|
||||
try {
|
||||
this.taskConfigs.delete(taskId);
|
||||
this.taskRuntimeData.delete(taskId);
|
||||
|
||||
LogUtils.info(TAG, `移除任务配置: ${taskId}`);
|
||||
return true;
|
||||
} catch (err) {
|
||||
LogUtils.error(TAG, `移除任务配置失败: ${taskId}`, err);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取任务配置
|
||||
*/
|
||||
public getTaskConfig(taskId: string): ITaskConfig | undefined {
|
||||
return this.taskConfigs.get(taskId);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取任务运行时数据
|
||||
*/
|
||||
public getTaskRuntimeData(taskId: string): ITaskRuntimeData | undefined {
|
||||
return this.taskRuntimeData.get(taskId);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取指定类型的任务列表
|
||||
*/
|
||||
public getTasksByType(type: TaskType): ITaskRuntimeData[] {
|
||||
const tasks: ITaskRuntimeData[] = [];
|
||||
|
||||
for (const runtimeData of this.taskRuntimeData.values()) {
|
||||
if (runtimeData.config.type === type) {
|
||||
tasks.push(runtimeData);
|
||||
}
|
||||
}
|
||||
|
||||
return tasks.sort((a, b) => a.config.priority - b.config.priority);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取指定状态的任务列表
|
||||
*/
|
||||
public getTasksByStatus(status: TaskStatus): ITaskRuntimeData[] {
|
||||
const tasks: ITaskRuntimeData[] = [];
|
||||
|
||||
for (const runtimeData of this.taskRuntimeData.values()) {
|
||||
if (runtimeData.status === status) {
|
||||
tasks.push(runtimeData);
|
||||
}
|
||||
}
|
||||
|
||||
return tasks.sort((a, b) => a.config.priority - b.config.priority);
|
||||
}
|
||||
|
||||
/**
|
||||
* 激活任务
|
||||
*/
|
||||
public activateTask(taskId: string): boolean {
|
||||
try {
|
||||
const runtimeData = this.taskRuntimeData.get(taskId);
|
||||
if (!runtimeData) {
|
||||
LogUtils.warn(TAG, `任务不存在: ${taskId}`);
|
||||
return false;
|
||||
}
|
||||
|
||||
if (runtimeData.status !== TaskStatus.INACTIVE) {
|
||||
LogUtils.warn(
|
||||
TAG,
|
||||
`任务状态不正确,无法激活: ${taskId}, 当前状态: ${runtimeData.status}`,
|
||||
);
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!this.canActivateTask(runtimeData.config)) {
|
||||
LogUtils.warn(TAG, `任务激活条件不满足: ${taskId}`);
|
||||
return false;
|
||||
}
|
||||
|
||||
runtimeData.status = TaskStatus.ACTIVE;
|
||||
runtimeData.activatedTime = Date.now();
|
||||
|
||||
EventManager.getInstance().emit(
|
||||
TaskEventType.TASK_ACTIVATED,
|
||||
taskId,
|
||||
TimeManager.getInstance().getCorrectedTime(),
|
||||
);
|
||||
|
||||
LogUtils.info(TAG, `任务已激活: ${taskId}`);
|
||||
return true;
|
||||
} catch (err) {
|
||||
LogUtils.error(TAG, `激活任务失败: ${taskId}`, err);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新任务进度
|
||||
*/
|
||||
public updateTaskProgress(
|
||||
taskId: string,
|
||||
targetId: string,
|
||||
progress: number,
|
||||
): boolean {
|
||||
try {
|
||||
const runtimeData = this.taskRuntimeData.get(taskId);
|
||||
if (!runtimeData) {
|
||||
LogUtils.warn(TAG, `任务不存在: ${taskId}`);
|
||||
return false;
|
||||
}
|
||||
|
||||
if (runtimeData.status !== TaskStatus.ACTIVE) {
|
||||
LogUtils.debug(
|
||||
TAG,
|
||||
`任务状态不正确,无法更新进度: ${taskId}, 当前状态: ${runtimeData.status}`,
|
||||
);
|
||||
return false;
|
||||
}
|
||||
|
||||
const target = runtimeData.targets.find((t) => t.id === targetId);
|
||||
if (!target) {
|
||||
LogUtils.warn(TAG, `任务目标不存在: ${taskId}, ${targetId}`);
|
||||
return false;
|
||||
}
|
||||
|
||||
const oldProgress = target.currentCount;
|
||||
target.currentCount = Math.min(
|
||||
target.currentCount + progress,
|
||||
target.targetCount,
|
||||
);
|
||||
EventManager.getInstance().emit(
|
||||
TaskEventType.TASK_PROGRESS_UPDATED,
|
||||
taskId,
|
||||
TimeManager.getInstance().getCorrectedTime(),
|
||||
{
|
||||
targetId: targetId,
|
||||
oldProgress: oldProgress,
|
||||
newProgress: target.currentCount,
|
||||
targetCount: target.targetCount,
|
||||
},
|
||||
);
|
||||
|
||||
// 检查任务是否完成
|
||||
if (this.isTaskCompleted(runtimeData)) {
|
||||
this.completeTask(taskId);
|
||||
}
|
||||
|
||||
LogUtils.debug(
|
||||
TAG,
|
||||
`任务进度已更新: ${taskId}, ${targetId}, ${target.currentCount}/${target.targetCount}`,
|
||||
);
|
||||
return true;
|
||||
} catch (err) {
|
||||
LogUtils.error(TAG, `更新任务进度失败: ${taskId}`, err);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 完成任务
|
||||
*/
|
||||
public completeTask(taskId: string): boolean {
|
||||
try {
|
||||
const runtimeData = this.taskRuntimeData.get(taskId);
|
||||
if (!runtimeData) {
|
||||
LogUtils.warn(TAG, `任务不存在: ${taskId}`);
|
||||
return false;
|
||||
}
|
||||
|
||||
if (runtimeData.status !== TaskStatus.ACTIVE) {
|
||||
LogUtils.warn(
|
||||
TAG,
|
||||
`任务状态不正确,无法完成: ${taskId}, 当前状态: ${runtimeData.status}`,
|
||||
);
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!this.isTaskCompleted(runtimeData)) {
|
||||
LogUtils.warn(TAG, `任务完成条件不满足: ${taskId}`);
|
||||
return false;
|
||||
}
|
||||
|
||||
runtimeData.status = TaskStatus.COMPLETED;
|
||||
runtimeData.completedTime = Date.now();
|
||||
|
||||
EventManager.getInstance().emit(
|
||||
TaskEventType.TASK_COMPLETED,
|
||||
taskId,
|
||||
TimeManager.getInstance().getCorrectedTime(),
|
||||
);
|
||||
LogUtils.info(TAG, `任务已完成: ${taskId}`);
|
||||
return true;
|
||||
} catch (err) {
|
||||
LogUtils.error(TAG, `完成任务失败: ${taskId}`, err);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 领取任务奖励
|
||||
*/
|
||||
public claimTaskReward(taskId: string): ITaskReward[] | null {
|
||||
try {
|
||||
const runtimeData = this.taskRuntimeData.get(taskId);
|
||||
if (!runtimeData) {
|
||||
LogUtils.warn(TAG, `任务不存在: ${taskId}`);
|
||||
return null;
|
||||
}
|
||||
|
||||
if (runtimeData.status !== TaskStatus.COMPLETED) {
|
||||
LogUtils.warn(
|
||||
TAG,
|
||||
`任务状态不正确,无法领取奖励: ${taskId}, 当前状态: ${runtimeData.status}`,
|
||||
);
|
||||
return null;
|
||||
}
|
||||
|
||||
runtimeData.status = TaskStatus.CLAIMED;
|
||||
runtimeData.claimedTime = Date.now();
|
||||
|
||||
const rewards = runtimeData.config.rewards;
|
||||
|
||||
EventManager.getInstance().emit(
|
||||
TaskEventType.TASK_REWARD_CLAIMED,
|
||||
taskId,
|
||||
TimeManager.getInstance().getCorrectedTime(),
|
||||
{
|
||||
rewards: rewards,
|
||||
},
|
||||
);
|
||||
|
||||
LogUtils.info(
|
||||
TAG,
|
||||
`任务奖励已领取: ${taskId}, 奖励数量: ${rewards.length}`,
|
||||
);
|
||||
return rewards;
|
||||
} catch (err) {
|
||||
LogUtils.error(TAG, `领取任务奖励失败: ${taskId}`, err);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 重置任务
|
||||
*/
|
||||
public resetTask(taskId: string): boolean {
|
||||
try {
|
||||
const runtimeData = this.taskRuntimeData.get(taskId);
|
||||
if (!runtimeData) {
|
||||
LogUtils.warn(TAG, `任务不存在: ${taskId}`);
|
||||
return false;
|
||||
}
|
||||
|
||||
// 重置任务状态和进度
|
||||
runtimeData.status = TaskStatus.INACTIVE;
|
||||
runtimeData.activatedTime = undefined;
|
||||
runtimeData.completedTime = undefined;
|
||||
runtimeData.claimedTime = undefined;
|
||||
runtimeData.resetTime = Date.now();
|
||||
|
||||
// 重置目标进度
|
||||
for (const target of runtimeData.targets) {
|
||||
target.currentCount = 0;
|
||||
}
|
||||
|
||||
EventManager.getInstance().emit(
|
||||
TaskEventType.TASK_RESET,
|
||||
taskId,
|
||||
TimeManager.getInstance().getCorrectedTime(),
|
||||
);
|
||||
|
||||
LogUtils.info(TAG, `任务已重置: ${taskId}`);
|
||||
return true;
|
||||
} catch (err) {
|
||||
LogUtils.error(TAG, `重置任务失败: ${taskId}`, err);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 使任务过期
|
||||
*/
|
||||
public expireTask(taskId: string): boolean {
|
||||
try {
|
||||
const runtimeData = this.taskRuntimeData.get(taskId);
|
||||
if (!runtimeData) {
|
||||
LogUtils.warn(TAG, `任务不存在: ${taskId}`);
|
||||
return false;
|
||||
}
|
||||
|
||||
runtimeData.status = TaskStatus.EXPIRED;
|
||||
runtimeData.expiredTime = Date.now();
|
||||
|
||||
EventManager.getInstance().emit(
|
||||
TaskEventType.TASK_EXPIRED,
|
||||
taskId,
|
||||
TimeManager.getInstance().getCorrectedTime(),
|
||||
);
|
||||
|
||||
LogUtils.info(TAG, `任务已过期: ${taskId}`);
|
||||
return true;
|
||||
} catch (err) {
|
||||
LogUtils.error(TAG, `任务过期失败: ${taskId}`, err);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查任务是否可以激活
|
||||
*/
|
||||
private canActivateTask(config: ITaskConfig): boolean {
|
||||
// 检查任务是否启用
|
||||
if (!config.enabled) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// 检查时间范围
|
||||
const now = Date.now();
|
||||
if (config.startTime && now < config.startTime) {
|
||||
return false;
|
||||
}
|
||||
if (config.endTime && now > config.endTime) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// 检查前置任务
|
||||
if (config.prerequisites) {
|
||||
for (const prerequisiteId of config.prerequisites) {
|
||||
const prerequisiteData =
|
||||
this.taskRuntimeData.get(prerequisiteId);
|
||||
if (
|
||||
!prerequisiteData ||
|
||||
prerequisiteData.status !== TaskStatus.CLAIMED
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TODO: 检查解锁条件
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查任务是否完成
|
||||
*/
|
||||
private isTaskCompleted(runtimeData: ITaskRuntimeData): boolean {
|
||||
// 检查所有目标是否完成
|
||||
for (const target of runtimeData.targets) {
|
||||
if (target.currentCount < target.targetCount) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// TODO: 检查完成条件
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* 保存任务数据
|
||||
*/
|
||||
public async saveTaskData(): Promise<void> {
|
||||
try {
|
||||
// TODO: 保存任务运行时数据到本地存储
|
||||
LogUtils.info(TAG, "保存任务数据");
|
||||
} catch (err) {
|
||||
LogUtils.error(TAG, "保存任务数据失败:", err);
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 销毁任务管理器
|
||||
*/
|
||||
public destroy(): void {
|
||||
this.taskConfigs.clear();
|
||||
this.taskRuntimeData.clear();
|
||||
this.initialized = false;
|
||||
|
||||
LogUtils.info(TAG, "任务管理器已销毁");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"ver": "4.0.24",
|
||||
"importer": "typescript",
|
||||
"imported": true,
|
||||
"uuid": "02df2ceb-9103-4a43-9c77-3e8c88c6a1d6",
|
||||
"files": [],
|
||||
"subMetas": {},
|
||||
"userData": {}
|
||||
}
|
||||
@@ -0,0 +1,290 @@
|
||||
import { Singleton } from "../Singleton";
|
||||
import LogUtils from "../utils/LogUtils";
|
||||
import { TaskTypeRegistry } from "./decorators/TaskTypeDecorator";
|
||||
import {
|
||||
TaskType,
|
||||
TaskResetCycle,
|
||||
ITaskConfig,
|
||||
GlobalTaskType,
|
||||
} from "./TaskData";
|
||||
|
||||
const TAG = "TaskTypeManager";
|
||||
|
||||
/**
|
||||
* 任务类型处理器接口
|
||||
*/
|
||||
export interface ITaskTypeHandler {
|
||||
/** 验证任务配置 */
|
||||
validateConfig(config: ITaskConfig): boolean;
|
||||
|
||||
/** 检查任务是否应该重置 */
|
||||
shouldReset(config: ITaskConfig, lastResetTime: number): boolean;
|
||||
|
||||
/** 检查任务是否过期 */
|
||||
isExpired(config: ITaskConfig, activatedTime: number): boolean;
|
||||
|
||||
/** 获取下次重置时间 */
|
||||
getNextResetTime(config: ITaskConfig): number;
|
||||
|
||||
/** 获取任务过期时间 */
|
||||
getExpireTime(config: ITaskConfig, activatedTime: number): number;
|
||||
}
|
||||
|
||||
/**
|
||||
* 每周任务处理器
|
||||
*/
|
||||
export class WeeklyTaskHandler implements ITaskTypeHandler {
|
||||
getType(): string {
|
||||
return GlobalTaskType.WEEKLY;
|
||||
}
|
||||
|
||||
public validateConfig(config: ITaskConfig): boolean {
|
||||
if (config.resetCycle !== TaskResetCycle.WEEKLY) {
|
||||
LogUtils.warn(TAG, `每周任务重置周期应为 WEEKLY: ${config.id}`);
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
public shouldReset(config: ITaskConfig, lastResetTime: number): boolean {
|
||||
const now = Date.now();
|
||||
const lastResetDate = new Date(lastResetTime);
|
||||
const currentDate = new Date(now);
|
||||
|
||||
// 获取周一的日期
|
||||
const getMonday = (date: Date): Date => {
|
||||
const day = date.getDay();
|
||||
const diff = date.getDate() - day + (day === 0 ? -6 : 1);
|
||||
const monday = new Date(date);
|
||||
monday.setDate(diff);
|
||||
monday.setHours(0, 0, 0, 0);
|
||||
return monday;
|
||||
};
|
||||
|
||||
const lastMonday = getMonday(lastResetDate);
|
||||
const currentMonday = getMonday(currentDate);
|
||||
|
||||
return lastMonday.getTime() !== currentMonday.getTime();
|
||||
}
|
||||
|
||||
public isExpired(config: ITaskConfig, activatedTime: number): boolean {
|
||||
const expireTime = this.getExpireTime(config, activatedTime);
|
||||
return Date.now() > expireTime;
|
||||
}
|
||||
|
||||
public getNextResetTime(config: ITaskConfig): number {
|
||||
const now = new Date();
|
||||
const nextMonday = new Date(now);
|
||||
const day = now.getDay();
|
||||
const diff = 7 - day + 1;
|
||||
nextMonday.setDate(now.getDate() + diff);
|
||||
nextMonday.setHours(0, 0, 0, 0);
|
||||
return nextMonday.getTime();
|
||||
}
|
||||
|
||||
public getExpireTime(config: ITaskConfig, activatedTime: number): number {
|
||||
const activatedDate = new Date(activatedTime);
|
||||
const day = activatedDate.getDay();
|
||||
const diff = 7 - day + 1;
|
||||
const expireDate = new Date(activatedDate);
|
||||
expireDate.setDate(expireDate.getDate() + diff);
|
||||
expireDate.setHours(0, 0, 0, 0);
|
||||
return expireDate.getTime();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 每月任务处理器
|
||||
*/
|
||||
export class MonthlyTaskHandler implements ITaskTypeHandler {
|
||||
getType(): string {
|
||||
return GlobalTaskType.MONTHLY;
|
||||
}
|
||||
|
||||
public validateConfig(config: ITaskConfig): boolean {
|
||||
if (config.resetCycle !== TaskResetCycle.MONTHLY) {
|
||||
LogUtils.warn(TAG, `每月任务重置周期应为 MONTHLY: ${config.id}`);
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
public shouldReset(config: ITaskConfig, lastResetTime: number): boolean {
|
||||
const lastResetDate = new Date(lastResetTime);
|
||||
const currentDate = new Date();
|
||||
|
||||
return (
|
||||
lastResetDate.getMonth() !== currentDate.getMonth() ||
|
||||
lastResetDate.getFullYear() !== currentDate.getFullYear()
|
||||
);
|
||||
}
|
||||
|
||||
public isExpired(config: ITaskConfig, activatedTime: number): boolean {
|
||||
const expireTime = this.getExpireTime(config, activatedTime);
|
||||
return Date.now() > expireTime;
|
||||
}
|
||||
|
||||
public getNextResetTime(config: ITaskConfig): number {
|
||||
const now = new Date();
|
||||
const nextMonth = new Date(now.getFullYear(), now.getMonth() + 1, 1);
|
||||
nextMonth.setHours(0, 0, 0, 0);
|
||||
return nextMonth.getTime();
|
||||
}
|
||||
|
||||
public getExpireTime(config: ITaskConfig, activatedTime: number): number {
|
||||
const activatedDate = new Date(activatedTime);
|
||||
const expireDate = new Date(
|
||||
activatedDate.getFullYear(),
|
||||
activatedDate.getMonth() + 1,
|
||||
1,
|
||||
);
|
||||
expireDate.setHours(0, 0, 0, 0);
|
||||
return expireDate.getTime();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 赛季任务处理器
|
||||
*/
|
||||
export class SeasonalTaskHandler implements ITaskTypeHandler {
|
||||
getType(): string {
|
||||
return GlobalTaskType.SEASONAL;
|
||||
}
|
||||
|
||||
public validateConfig(config: ITaskConfig): boolean {
|
||||
if (config.resetCycle !== TaskResetCycle.SEASONAL) {
|
||||
LogUtils.warn(TAG, `赛季任务重置周期应为 SEASONAL: ${config.id}`);
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!config.startTime || !config.endTime) {
|
||||
LogUtils.warn(TAG, `赛季任务必须设置开始和结束时间: ${config.id}`);
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
public shouldReset(config: ITaskConfig, lastResetTime: number): boolean {
|
||||
// 赛季任务通常不自动重置,由配置的开始时间决定
|
||||
return false;
|
||||
}
|
||||
|
||||
public isExpired(config: ITaskConfig, activatedTime: number): boolean {
|
||||
return config.endTime ? Date.now() > config.endTime : false;
|
||||
}
|
||||
|
||||
public getNextResetTime(config: ITaskConfig): number {
|
||||
// 赛季任务的下次重置时间通常是下个赛季的开始时间
|
||||
return config.endTime || 0;
|
||||
}
|
||||
|
||||
public getExpireTime(config: ITaskConfig, activatedTime: number): number {
|
||||
return config.endTime || Number.MAX_SAFE_INTEGER;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 任务类型管理器
|
||||
*/
|
||||
export class TaskTypeManager extends Singleton {
|
||||
private handlers: Map<TaskType, ITaskTypeHandler> = new Map();
|
||||
|
||||
protected async onInit() {
|
||||
this.registerDefaultHandlers();
|
||||
}
|
||||
|
||||
/**
|
||||
* 注册默认的任务类型处理器
|
||||
*/
|
||||
private registerDefaultHandlers(): void {
|
||||
const handlers = TaskTypeRegistry.createAllHandlers();
|
||||
for (const handler of handlers) {
|
||||
this.registerHandler(handler);
|
||||
}
|
||||
LogUtils.info(TAG, `自动注册了 ${handlers.length} 个任务类型处理器`);
|
||||
}
|
||||
|
||||
/**
|
||||
* 注册任务类型处理器
|
||||
*/
|
||||
public registerHandler(handler: ITaskTypeHandler): void {
|
||||
const type = TaskTypeRegistry.getTypeByHandler(handler);
|
||||
this.handlers.set(type, handler);
|
||||
LogUtils.info(TAG, `注册任务类型处理器: ${type}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取任务类型处理器
|
||||
*/
|
||||
public getHandler(type: TaskType): ITaskTypeHandler | undefined {
|
||||
return this.handlers.get(type);
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证任务配置
|
||||
*/
|
||||
public validateTaskConfig(config: ITaskConfig): boolean {
|
||||
const handler = this.getHandler(config.type);
|
||||
if (!handler) {
|
||||
LogUtils.warn(TAG, `未找到任务类型处理器: ${config.type}`);
|
||||
return false;
|
||||
}
|
||||
|
||||
return handler.validateConfig(config);
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查任务是否应该重置
|
||||
*/
|
||||
public shouldResetTask(
|
||||
config: ITaskConfig,
|
||||
lastResetTime: number,
|
||||
): boolean {
|
||||
const handler = this.getHandler(config.type);
|
||||
if (!handler) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return handler.shouldReset(config, lastResetTime);
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查任务是否过期
|
||||
*/
|
||||
public isTaskExpired(config: ITaskConfig, activatedTime: number): boolean {
|
||||
const handler = this.getHandler(config.type);
|
||||
if (!handler) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return handler.isExpired(config, activatedTime);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取任务下次重置时间
|
||||
*/
|
||||
public getTaskNextResetTime(config: ITaskConfig): number {
|
||||
const handler = this.getHandler(config.type);
|
||||
if (!handler) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
return handler.getNextResetTime(config);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取任务过期时间
|
||||
*/
|
||||
public getTaskExpireTime(
|
||||
config: ITaskConfig,
|
||||
activatedTime: number,
|
||||
): number {
|
||||
const handler = this.getHandler(config.type);
|
||||
if (!handler) {
|
||||
return Number.MAX_SAFE_INTEGER;
|
||||
}
|
||||
|
||||
return handler.getExpireTime(config, activatedTime);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"ver": "4.0.24",
|
||||
"importer": "typescript",
|
||||
"imported": true,
|
||||
"uuid": "7591243c-cfc5-44dc-a93c-ddba56c9422b",
|
||||
"files": [],
|
||||
"subMetas": {},
|
||||
"userData": {}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"ver": "1.2.0",
|
||||
"importer": "directory",
|
||||
"imported": true,
|
||||
"uuid": "bc0e3ad3-a16b-48eb-878f-4c2a0e9fa318",
|
||||
"files": [],
|
||||
"subMetas": {},
|
||||
"userData": {}
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
import { TaskType } from "../TaskData";
|
||||
import { ITaskTypeHandler } from "../TaskTypeManager";
|
||||
import LogUtils from "../../utils/LogUtils";
|
||||
import { EDITOR } from "cc/env";
|
||||
|
||||
const TAG = "TaskTypeDecorator";
|
||||
|
||||
/**
|
||||
* 任务类型处理器注册表
|
||||
*/
|
||||
class TaskTypeRegistry {
|
||||
private static handlers: Map<TaskType, new () => ITaskTypeHandler> =
|
||||
new Map();
|
||||
private static handlerInstances: Map<ITaskTypeHandler, TaskType> =
|
||||
new Map();
|
||||
|
||||
/**
|
||||
* 注册任务类型处理器
|
||||
*/
|
||||
public static register(
|
||||
type: TaskType,
|
||||
handlerClass: new () => ITaskTypeHandler,
|
||||
): void {
|
||||
if (EDITOR) {
|
||||
return;
|
||||
}
|
||||
this.handlers.set(type, handlerClass);
|
||||
LogUtils.info(TAG, `注册任务类型处理器类: ${type}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取所有注册的处理器类
|
||||
*/
|
||||
public static getAllHandlerClasses(): Map<
|
||||
TaskType,
|
||||
new () => ITaskTypeHandler
|
||||
> {
|
||||
return new Map(this.handlers);
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建所有处理器实例
|
||||
*/
|
||||
public static createAllHandlers(): ITaskTypeHandler[] {
|
||||
const handlers: ITaskTypeHandler[] = [];
|
||||
for (const [type, HandlerClass] of this.handlers) {
|
||||
try {
|
||||
const handler = new HandlerClass();
|
||||
handlers.push(handler);
|
||||
// 记录实例与类型的映射关系
|
||||
this.handlerInstances.set(handler, type);
|
||||
LogUtils.info(TAG, `创建任务类型处理器实例: ${type}`);
|
||||
} catch (err) {
|
||||
LogUtils.error(TAG, `创建任务类型处理器失败: ${type}`, err);
|
||||
}
|
||||
}
|
||||
return handlers;
|
||||
}
|
||||
|
||||
/**
|
||||
* 通过处理器实例获取对应的任务类型
|
||||
* @param handler 处理器实例
|
||||
* @returns 对应的任务类型,如果未找到则返回 undefined
|
||||
*/
|
||||
public static getTypeByHandler(
|
||||
handler: ITaskTypeHandler,
|
||||
): TaskType | undefined {
|
||||
// 首先从实例映射中查找
|
||||
const typeFromMap = this.handlerInstances.get(handler);
|
||||
if (typeFromMap !== undefined) {
|
||||
return typeFromMap;
|
||||
}
|
||||
|
||||
// 如果映射中没有,尝试通过构造函数查找
|
||||
const handlerConstructor =
|
||||
handler.constructor as new () => ITaskTypeHandler;
|
||||
for (const [type, HandlerClass] of this.handlers) {
|
||||
if (HandlerClass === handlerConstructor) {
|
||||
// 更新实例映射
|
||||
this.handlerInstances.set(handler, type);
|
||||
LogUtils.debug(TAG, `通过构造函数找到处理器类型: ${type}`);
|
||||
return type;
|
||||
}
|
||||
}
|
||||
|
||||
LogUtils.warn(TAG, "无法找到处理器对应的任务类型");
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* 清除实例映射缓存
|
||||
*/
|
||||
public static clearInstanceCache(): void {
|
||||
this.handlerInstances.clear();
|
||||
LogUtils.info(TAG, "已清除处理器实例映射缓存");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 任务类型装饰器
|
||||
* @param type 任务类型
|
||||
*/
|
||||
export function TaskTypeHandler(type: TaskType) {
|
||||
return function <T extends new () => ITaskTypeHandler>(target: T): T {
|
||||
// 注册处理器类
|
||||
TaskTypeRegistry.register(type, target);
|
||||
return target;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 导出注册表供 TaskTypeManager 使用
|
||||
*/
|
||||
export { TaskTypeRegistry };
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"ver": "4.0.24",
|
||||
"importer": "typescript",
|
||||
"imported": true,
|
||||
"uuid": "b12123d8-76c4-4593-8d60-bc155c58a14f",
|
||||
"files": [],
|
||||
"subMetas": {},
|
||||
"userData": {}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"ver": "1.2.0",
|
||||
"importer": "directory",
|
||||
"imported": true,
|
||||
"uuid": "01a41960-3ea0-4285-b02c-fa4dd616bba1",
|
||||
"files": [],
|
||||
"subMetas": {},
|
||||
"userData": {}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
import LogUtils from "../../utils/LogUtils";
|
||||
import { GlobalTaskType, ITaskConfig, TaskResetCycle } from "../TaskData";
|
||||
import { ITaskTypeHandler } from "../TaskTypeManager";
|
||||
import { TaskTypeHandler } from "../decorators/TaskTypeDecorator";
|
||||
|
||||
const TAG = "DailyTaskHandler";
|
||||
|
||||
/**
|
||||
* 每日任务处理器
|
||||
*/
|
||||
@TaskTypeHandler(GlobalTaskType.DAILY)
|
||||
export class DailyTaskHandler implements ITaskTypeHandler {
|
||||
public validateConfig(config: ITaskConfig): boolean {
|
||||
if (config.resetCycle !== TaskResetCycle.DAILY) {
|
||||
LogUtils.warn(TAG, `每日任务重置周期应为 DAILY: ${config.id}`);
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
public shouldReset(config: ITaskConfig, lastResetTime: number): boolean {
|
||||
const now = Date.now();
|
||||
const lastResetDate = new Date(lastResetTime);
|
||||
const currentDate = new Date(now);
|
||||
|
||||
// 检查是否跨天
|
||||
return (
|
||||
lastResetDate.getDate() !== currentDate.getDate() ||
|
||||
lastResetDate.getMonth() !== currentDate.getMonth() ||
|
||||
lastResetDate.getFullYear() !== currentDate.getFullYear()
|
||||
);
|
||||
}
|
||||
|
||||
public isExpired(config: ITaskConfig, activatedTime: number): boolean {
|
||||
const expireTime = this.getExpireTime(config, activatedTime);
|
||||
return Date.now() > expireTime;
|
||||
}
|
||||
|
||||
public getNextResetTime(config: ITaskConfig): number {
|
||||
const tomorrow = new Date();
|
||||
tomorrow.setDate(tomorrow.getDate() + 1);
|
||||
tomorrow.setHours(0, 0, 0, 0);
|
||||
return tomorrow.getTime();
|
||||
}
|
||||
|
||||
public getExpireTime(config: ITaskConfig, activatedTime: number): number {
|
||||
const activatedDate = new Date(activatedTime);
|
||||
const expireDate = new Date(activatedDate);
|
||||
expireDate.setDate(expireDate.getDate() + 1);
|
||||
expireDate.setHours(0, 0, 0, 0);
|
||||
return expireDate.getTime();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"ver": "4.0.24",
|
||||
"importer": "typescript",
|
||||
"imported": true,
|
||||
"uuid": "68a07db2-c379-4565-aa54-63671afd0bfc",
|
||||
"files": [],
|
||||
"subMetas": {},
|
||||
"userData": {}
|
||||
}
|
||||
9
extensions/max-studio/assets/max-studio/core/timer.meta
Normal file
9
extensions/max-studio/assets/max-studio/core/timer.meta
Normal file
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"ver": "1.2.0",
|
||||
"importer": "directory",
|
||||
"imported": true,
|
||||
"uuid": "9f931516-2af1-4d73-894f-576a5f792662",
|
||||
"files": [],
|
||||
"subMetas": {},
|
||||
"userData": {}
|
||||
}
|
||||
@@ -0,0 +1,319 @@
|
||||
import { _decorator, Director, director } from "cc";
|
||||
import NodeSingleton from "../NodeSingleton";
|
||||
import LogUtils from "../utils/LogUtils";
|
||||
|
||||
const { ccclass } = _decorator;
|
||||
|
||||
/**
|
||||
* 计时器接口
|
||||
*/
|
||||
export interface ITimer {
|
||||
/** 计时器ID */
|
||||
id: number;
|
||||
/** 延迟时间(秒) */
|
||||
delay: number;
|
||||
/** 重复次数,-1表示无限重复 */
|
||||
repeat: number;
|
||||
/** 回调函数 */
|
||||
callback: () => void;
|
||||
/** 已执行次数 */
|
||||
executeCount: number;
|
||||
/** 累计时间 */
|
||||
elapsedTime: number;
|
||||
/** 是否暂停 */
|
||||
isPaused: boolean;
|
||||
/** 是否已完成 */
|
||||
isCompleted: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* 时间管理器
|
||||
* 提供计时器功能和时间矫正功能
|
||||
*/
|
||||
@ccclass("TimeManager")
|
||||
export default class TimeManager extends NodeSingleton {
|
||||
private static readonly TAG = "TimeManager";
|
||||
|
||||
/** 计时器映射表 */
|
||||
private _timers: Map<number, ITimer> = new Map();
|
||||
|
||||
/** 计时器ID生成器 */
|
||||
private _timerIdGenerator = 0;
|
||||
|
||||
/** 服务器时间偏移量(毫秒) */
|
||||
private _serverTimeOffset = 0;
|
||||
|
||||
/** 是否已进行时间矫正 */
|
||||
private _isTimeCorrected = false;
|
||||
|
||||
/** 上次更新时间 */
|
||||
private _lastUpdateTime = 0;
|
||||
|
||||
protected onLoad(): void {
|
||||
this._lastUpdateTime = Date.now();
|
||||
LogUtils.log(TimeManager.TAG, "TimeManager 初始化完成");
|
||||
}
|
||||
|
||||
protected onEnable(): void {
|
||||
director.on(Director.EVENT_BEFORE_UPDATE, this.update, this);
|
||||
}
|
||||
|
||||
protected onDisable(): void {
|
||||
director.off(Director.EVENT_BEFORE_UPDATE, this.update, this);
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新计时器
|
||||
*/
|
||||
public update(): void {
|
||||
const currentTime = Date.now();
|
||||
const deltaTime = (currentTime - this._lastUpdateTime) / 1000; // 转换为秒
|
||||
this._lastUpdateTime = currentTime;
|
||||
|
||||
// 更新所有计时器
|
||||
const completedTimers: number[] = [];
|
||||
|
||||
for (const [id, timer] of this._timers.entries()) {
|
||||
if (timer.isPaused || timer.isCompleted) {
|
||||
continue;
|
||||
}
|
||||
|
||||
timer.elapsedTime += deltaTime;
|
||||
|
||||
if (timer.elapsedTime >= timer.delay) {
|
||||
// 执行回调
|
||||
try {
|
||||
timer.callback();
|
||||
} catch (err) {
|
||||
LogUtils.error(
|
||||
TimeManager.TAG,
|
||||
`计时器回调执行出错: ${err}`,
|
||||
);
|
||||
} finally {
|
||||
timer.executeCount++;
|
||||
}
|
||||
|
||||
// 检查是否需要重复
|
||||
if (timer.repeat === -1 || timer.executeCount < timer.repeat) {
|
||||
timer.elapsedTime = 0; // 重置计时
|
||||
} else {
|
||||
timer.isCompleted = true;
|
||||
completedTimers.push(id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 清理已完成的计时器
|
||||
for (const id of completedTimers) {
|
||||
this._timers.delete(id);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建计时器
|
||||
* @param delay 延迟时间(秒)
|
||||
* @param callback 回调函数
|
||||
* @param repeat 重复次数,默认1次,-1表示无限重复
|
||||
* @returns 计时器ID
|
||||
*/
|
||||
public createTimer(
|
||||
delay: number,
|
||||
callback: () => void,
|
||||
repeat: number = 1,
|
||||
): number {
|
||||
if (delay <= 0) {
|
||||
LogUtils.warn(TimeManager.TAG, "计时器延迟时间必须大于0");
|
||||
return -1;
|
||||
}
|
||||
|
||||
if (!callback) {
|
||||
LogUtils.warn(TimeManager.TAG, "计时器回调函数不能为空");
|
||||
return -1;
|
||||
}
|
||||
|
||||
const timerId = ++this._timerIdGenerator;
|
||||
const timer: ITimer = {
|
||||
id: timerId,
|
||||
delay: delay,
|
||||
repeat: repeat,
|
||||
callback: callback,
|
||||
executeCount: 0,
|
||||
elapsedTime: 0,
|
||||
isPaused: false,
|
||||
isCompleted: false,
|
||||
};
|
||||
|
||||
this._timers.set(timerId, timer);
|
||||
LogUtils.log(
|
||||
TimeManager.TAG,
|
||||
`创建计时器: ID=${timerId}, delay=${delay}s, repeat=${repeat}`,
|
||||
);
|
||||
|
||||
return timerId;
|
||||
}
|
||||
|
||||
/**
|
||||
* 暂停计时器
|
||||
* @param timerId 计时器ID
|
||||
*/
|
||||
public pauseTimer(timerId: number): void {
|
||||
const timer = this._timers.get(timerId);
|
||||
if (timer) {
|
||||
timer.isPaused = true;
|
||||
LogUtils.log(TimeManager.TAG, `暂停计时器: ID=${timerId}`);
|
||||
} else {
|
||||
LogUtils.warn(TimeManager.TAG, `计时器不存在: ID=${timerId}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 恢复计时器
|
||||
* @param timerId 计时器ID
|
||||
*/
|
||||
public resumeTimer(timerId: number): void {
|
||||
const timer = this._timers.get(timerId);
|
||||
if (timer) {
|
||||
timer.isPaused = false;
|
||||
LogUtils.log(TimeManager.TAG, `恢复计时器: ID=${timerId}`);
|
||||
} else {
|
||||
LogUtils.warn(TimeManager.TAG, `计时器不存在: ID=${timerId}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 停止并移除计时器
|
||||
* @param timerId 计时器ID
|
||||
*/
|
||||
public removeTimer(timerId: number): void {
|
||||
if (this._timers.delete(timerId)) {
|
||||
LogUtils.log(TimeManager.TAG, `移除计时器: ID=${timerId}`);
|
||||
} else {
|
||||
LogUtils.warn(TimeManager.TAG, `计时器不存在: ID=${timerId}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 清除所有计时器
|
||||
*/
|
||||
public clearAllTimers(): void {
|
||||
const count = this._timers.size;
|
||||
this._timers.clear();
|
||||
LogUtils.log(TimeManager.TAG, `清除所有计时器,共${count}个`);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取计时器信息
|
||||
* @param timerId 计时器ID
|
||||
* @returns 计时器信息,如果不存在返回null
|
||||
*/
|
||||
public getTimerInfo(timerId: number): ITimer | null {
|
||||
return this._timers.get(timerId) || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取活跃计时器数量
|
||||
* @returns 活跃计时器数量
|
||||
*/
|
||||
public getActiveTimerCount(): number {
|
||||
let count = 0;
|
||||
for (const [, timer] of this._timers) {
|
||||
if (!timer.isCompleted && !timer.isPaused) {
|
||||
count++;
|
||||
}
|
||||
}
|
||||
return count;
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置服务器时间矫正
|
||||
* @param serverTimestamp 服务器时间戳(毫秒)
|
||||
*/
|
||||
public correctTime(serverTimestamp: number): void {
|
||||
const localTime = Date.now();
|
||||
this._serverTimeOffset = serverTimestamp - localTime;
|
||||
this._isTimeCorrected = true;
|
||||
|
||||
LogUtils.log(
|
||||
TimeManager.TAG,
|
||||
`时间矫正完成,偏移量: ${this._serverTimeOffset}ms`,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取矫正后的当前时间戳(毫秒)
|
||||
* @returns 矫正后的时间戳
|
||||
*/
|
||||
public getCorrectedTime(): number {
|
||||
return Date.now() + this._serverTimeOffset;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取矫正后的当前时间戳(秒)
|
||||
* @returns 矫正后的时间戳(秒)
|
||||
*/
|
||||
public getCorrectedTimeSeconds(): number {
|
||||
return Math.floor(this.getCorrectedTime() / 1000);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取服务器时间偏移量
|
||||
* @returns 偏移量(毫秒)
|
||||
*/
|
||||
public getTimeOffset(): number {
|
||||
return this._serverTimeOffset;
|
||||
}
|
||||
|
||||
/**
|
||||
* 是否已进行时间矫正
|
||||
* @returns 是否已矫正
|
||||
*/
|
||||
public isTimeCorrected(): boolean {
|
||||
return this._isTimeCorrected;
|
||||
}
|
||||
|
||||
/**
|
||||
* 重置时间矫正
|
||||
*/
|
||||
public resetTimeCorrection(): void {
|
||||
this._serverTimeOffset = 0;
|
||||
this._isTimeCorrected = false;
|
||||
LogUtils.log(TimeManager.TAG, "时间矫正已重置");
|
||||
}
|
||||
|
||||
// 便捷方法
|
||||
|
||||
/**
|
||||
* 延迟执行(一次性)
|
||||
* @param delay 延迟时间(秒)
|
||||
* @param callback 回调函数
|
||||
* @returns 计时器ID
|
||||
*/
|
||||
public delay(delay: number, callback: () => void): number {
|
||||
return this.createTimer(delay, callback, 1);
|
||||
}
|
||||
|
||||
/**
|
||||
* 循环执行
|
||||
* @param interval 间隔时间(秒)
|
||||
* @param callback 回调函数
|
||||
* @returns 计时器ID
|
||||
*/
|
||||
public loop(interval: number, callback: () => void): number {
|
||||
return this.createTimer(interval, callback, -1);
|
||||
}
|
||||
|
||||
/**
|
||||
* 重复执行指定次数
|
||||
* @param interval 间隔时间(秒)
|
||||
* @param callback 回调函数
|
||||
* @param count 重复次数
|
||||
* @returns 计时器ID
|
||||
*/
|
||||
public repeat(
|
||||
interval: number,
|
||||
callback: () => void,
|
||||
count: number,
|
||||
): number {
|
||||
return this.createTimer(interval, callback, count);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"ver": "4.0.24",
|
||||
"importer": "typescript",
|
||||
"imported": true,
|
||||
"uuid": "d6880bf2-460f-401a-aa43-bc264265a46c",
|
||||
"files": [],
|
||||
"subMetas": {},
|
||||
"userData": {}
|
||||
}
|
||||
9
extensions/max-studio/assets/max-studio/core/ui.meta
Normal file
9
extensions/max-studio/assets/max-studio/core/ui.meta
Normal file
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"ver": "1.2.0",
|
||||
"importer": "directory",
|
||||
"imported": true,
|
||||
"uuid": "f26384d6-ae5e-4196-b413-d675dc2de641",
|
||||
"files": [],
|
||||
"subMetas": {},
|
||||
"userData": {}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
import BaseUI from "./BaseUI";
|
||||
|
||||
export default abstract class BaseLayer extends BaseUI {}
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"ver": "4.0.24",
|
||||
"importer": "typescript",
|
||||
"imported": true,
|
||||
"uuid": "788266c3-b792-4c79-ba00-7617f8313f23",
|
||||
"files": [],
|
||||
"subMetas": {},
|
||||
"userData": {}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import BaseUI from "./BaseUI";
|
||||
import { _decorator } from "cc";
|
||||
const { ccclass, property, menu } = _decorator;
|
||||
|
||||
export default class BaseLoading extends BaseUI {}
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"ver": "4.0.24",
|
||||
"importer": "typescript",
|
||||
"imported": true,
|
||||
"uuid": "d2ffb883-0085-4a45-ab40-616a8ae74b01",
|
||||
"files": [],
|
||||
"subMetas": {},
|
||||
"userData": {}
|
||||
}
|
||||
12
extensions/max-studio/assets/max-studio/core/ui/BasePopup.ts
Normal file
12
extensions/max-studio/assets/max-studio/core/ui/BasePopup.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
import BaseUI from "./BaseUI";
|
||||
|
||||
export interface DefaultPopupParameters {
|
||||
title?: string;
|
||||
content?: string;
|
||||
cancel?: string;
|
||||
confirm?: string;
|
||||
onCancel?: () => void;
|
||||
onConfirm?: () => void;
|
||||
}
|
||||
|
||||
export default abstract class BasePopup extends BaseUI {}
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"ver": "4.0.24",
|
||||
"importer": "typescript",
|
||||
"imported": true,
|
||||
"uuid": "5de5613c-b911-463b-9727-361aa8c53434",
|
||||
"files": [],
|
||||
"subMetas": {},
|
||||
"userData": {}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
import BaseUI from "./BaseUI";
|
||||
|
||||
export default class BaseToast extends BaseUI {}
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"ver": "4.0.24",
|
||||
"importer": "typescript",
|
||||
"imported": true,
|
||||
"uuid": "20926bc6-5134-4717-bf09-5d9c7e78f867",
|
||||
"files": [],
|
||||
"subMetas": {},
|
||||
"userData": {}
|
||||
}
|
||||
17
extensions/max-studio/assets/max-studio/core/ui/BaseUI.ts
Normal file
17
extensions/max-studio/assets/max-studio/core/ui/BaseUI.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
import { Component } from "cc";
|
||||
|
||||
export default abstract class BaseUI extends Component {
|
||||
async onShow(...args: any[]) {}
|
||||
|
||||
async onHide() {}
|
||||
|
||||
/**
|
||||
* UI进入后台时调用
|
||||
*/
|
||||
onEnterBackground() {}
|
||||
|
||||
/**
|
||||
* UI回到前台时调用
|
||||
*/
|
||||
onEnterForeground() {}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"ver": "4.0.24",
|
||||
"importer": "typescript",
|
||||
"imported": true,
|
||||
"uuid": "61c7b7e7-0cd1-469c-9fc1-7ccbe20fa809",
|
||||
"files": [],
|
||||
"subMetas": {},
|
||||
"userData": {}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user