2.4.13 COCOS的Mask实现工具刮刮乐的效果

892264892 发布于 7 天前 50 次阅读


AI 摘要

Cocos Creator 2.4.x 利用Mask组件Inverted模式与Graphics.clear实现高性能刮刮乐交互。方案通过分块检测算法精确计算擦除进度(blockSize可调),避免全像素遍历的性能瓶颈,支持自定义完成阈值与节点显隐回调。代码提供工具检测与擦除区域分离设计,可直接集成到彩票、涂色类游戏的涂层消除逻辑中。

展示代码

使用工具消除的mask区域

重要

onLoad【1】:获取对应组件信息。

start【3】:对mask组件效果取反,使用claerMask。

onComplete【4】:调用fullmask,后续处理自己需要的逻辑。

// Learn TypeScript:
//  - https://docs.cocos.com/creator/2.4/manual/en/scripting/typescript.html
// Learn Attribute:
//  - https://docs.cocos.com/creator/2.4/manual/en/scripting/reference/attributes.html
// Learn life-cycle callbacks:
//  - https://docs.cocos.com/creator/2.4/manual/en/scripting/life-cycle-callbacks.html

import { engine } from "../../../core/Engine";

const { ccclass, property } = cc._decorator;

/**
 * BaoGaiBanZhuRen_YWQ 擦除Mask组件
 * 功能:使用工具节点擦除Mask,支持进度检测和完成回调
 */
@ccclass
export default class BaoGaiBanZhuRen_YWQ_EraserMask extends cc.Component {

    /** ==================== 可配置属性 ==================== */

    @property({
        type: cc.Node,
        displayName: "工具检测节点",
        tooltip: "用于检测触摸/移动的工具节点(如橡皮擦)"
    })
    toolDetectNode: cc.Node = null;

    @property({
        type: cc.Node,
        displayName: "工具区域节点",
        tooltip: "定义擦除实际区域的工具节点,clearSize为0时自动使用此节点尺寸"
    })
    toolAreaNode: cc.Node = null;

    @property({
        type: cc.Node,
        displayName: "工具节点(旧)",
        tooltip: "【已废弃】请使用 toolDetectNode 和 toolAreaNode"
    })
    toolNode: cc.Node = null;

    @property({
        type: cc.Node,
        displayName: "Mask节点",
        tooltip: "包含cc.Mask组件的节点"
    })
    maskNode: cc.Node = null;

    @property({
        type: cc.Node,
        displayName: "被擦除区域节点",
        tooltip: "用于计算擦除范围的节点"
    })
    targetNode: cc.Node = null;

    @property({
        type: Number,
        displayName: "擦除范围",
        tooltip: "每次擦除的圆形半径,设为0时自动使用工具区域节点尺寸",
        min: 0,
        max: 100,
        step: 5
    })
    clearSize: number = 40;

    @property({
        type: Number,
        displayName: "分块大小",
        tooltip: "用于计算进度的分块大小,越小越精确但性能消耗越大",
        min: 10,
        max: 100,
        step: 5
    })
    blockSize: number = 40;

    @property({
        type: Number,
        displayName: "完成阈值",
        tooltip: "擦除进度达到此值时触发完成(0-1)",
        min: 0,
        max: 1,
        step: 0.05
    })
    completeThreshold: number = 0.8;

    @property({
        type: Boolean,
        displayName: "启用音效"
    })
    enableSound: boolean = true;

    @property({
        type: String,
        displayName: "擦除音效名称",
        visible: function () { return this.enableSound; }
    })
    scratchSoundName: string = "JJMPCS";

    @property({
        type: cc.Node,
        displayName: "完成时显示的节点",
        tooltip: "擦除完成后需要显示的节点列表"
    })
    showNodesOnComplete: cc.Node[] = [];

    @property({
        type: cc.Node,
        displayName: "完成时隐藏的节点",
        tooltip: "擦除完成后需要隐藏的节点列表"
    })
    hideNodesOnComplete: cc.Node[] = [];

    @property({
        type: Boolean,
        displayName: "完成后销毁组件"
    })
    destroyOnComplete: boolean = true;

    @property({
        type: Boolean,
        displayName: "调试模式"
    })
    isDebug: boolean = false;

    /** ==================== 私有成员 ==================== */

    // Mask组件
    private maskComponent: cc.Mask = null;
    // Graphics组件(用于绘制擦除)
    private maskGraphics: cc.Graphics = null;
    // 分块检测列表
    private blockList: { rect: cc.Rect; isHit: boolean }[] = [];
    // 临时绘制点列表
    private tempDrawPoints: cc.Vec2[] = [];
    // 是否已完成
    private isCompleted: boolean = false;
    // 当前进度(0-1)
    private currentProgress: number = 0;
    // 音效播放锁
    private canPlaySound: boolean = true;
    // 完成回调函数
    private completeCallback: Function = null;

    /** ==================== 生命周期函数 ==================== */

    onLoad() {
        this.handleBackwardCompatibility();
        this.initMask();
        this.initBlocks();
        this.registerEvents();
    }

    /**
     * 处理向后兼容:如果新属性未设置但旧属性已设置,使用旧属性值
     */
    private handleBackwardCompatibility(): void {
        if (!this.toolDetectNode && this.toolNode) {
            this.toolDetectNode = this.toolNode;
            cc.warn("[BaoGaiBanZhuRen_YWQ_EraserMask] toolNode 已废弃,请使用 toolDetectNode");
        }
        if (!this.toolAreaNode && this.toolNode) {
            this.toolAreaNode = this.toolNode;
        }
    }

    onDestroy() {
        this.unregisterEvents();
    }

    protected start(): void {
        this.maskGraphics.clear(); // 清除之前的绘制内容
        this.maskComponent.inverted = true; // 设置为非反转模式,即默认遮挡
    }

    /** ==================== 初始化函数 ==================== */

    /**
     * 初始化Mask组件
     */
    private initMask(): void {
        if (!this.maskNode) {
            cc.error("[BaoGaiBanZhuRen_YWQ_EraserMask] maskNode未设置!");
            return;
        }

        this.maskComponent = this.maskNode.getComponent(cc.Mask);
        if (!this.maskComponent) {
            cc.error("[BaoGaiBanZhuRen_YWQ_EraserMask] maskNode上未找到cc.Mask组件!");
            return;
        }


        // 获取Graphics组件
        this.maskGraphics = this.maskComponent["_graphics"];
        if (!this.maskGraphics) {
            cc.error("[BaoGaiBanZhuRen_YWQ_EraserMask] 无法获取Mask的Graphics组件!");
            return;
        }


        if (this.isDebug) {
            cc.log("[BaoGaiBanZhuRen_YWQ_EraserMask] Mask初始化成功");
        }
    }

    /**
     * 初始填充Mask(白色填充,表示全部遮挡)
     */
    private fillMask(): void {
        if (!this.maskGraphics || !this.targetNode) return;

        let stencil = this.maskGraphics;
        // 先clear确保干净
        this.maskGraphics.clear();

        // 绘制白色矩形填充整个区域
        const width = this.targetNode.width;
        const height = this.targetNode.height;

        this.maskGraphics.fillColor = cc.Color.BLACK;
        this.maskGraphics.rect(-width / 2, -height / 2, width, height);
        this.maskGraphics.fill();

        if (this.isDebug) {
            cc.log("[BaoGaiBanZhuRen_YWQ_EraserMask] Mask已填充");
        }
    }

    /**
     * 初始化分块检测列表
     */
    private initBlocks(): void {
        if (!this.targetNode) {
            cc.error("[BaoGaiBanZhuRen_YWQ_EraserMask] targetNode未设置!");
            return;
        }

        this.blockList = [];
        const width = this.targetNode.width;
        const height = this.targetNode.height;

        // 将目标区域划分为小块
        for (let x = 0; x < width; x += this.blockSize) {
            for (let y = 0; y < height; y += this.blockSize) {
                this.blockList.push({
                    rect: cc.rect(
                        x - width / 2,
                        y - height / 2,
                        this.blockSize,
                        this.blockSize
                    ),
                    isHit: false
                });
            }
        }

        if (this.isDebug) {
            cc.log(`[BaoGaiBanZhuRen_YWQ_EraserMask] 分块初始化完成,共${this.blockList.length}块`);
        }
    }

    /**
     * 注册触摸事件
     */
    private registerEvents(): void {
        if (!this.toolDetectNode) {
            cc.error("[BaoGaiBanZhuRen_YWQ_EraserMask] toolDetectNode未设置!");
            return;
        }

        this.toolDetectNode.on(cc.Node.EventType.TOUCH_START, this.onTouchStart, this);
        this.toolDetectNode.on(cc.Node.EventType.TOUCH_MOVE, this.onTouchMove, this);
        this.toolDetectNode.on(cc.Node.EventType.TOUCH_END, this.onTouchEnd, this);
        this.toolDetectNode.on(cc.Node.EventType.TOUCH_CANCEL, this.onTouchEnd, this);
    }

    /**
     * 注销触摸事件
     */
    private unregisterEvents(): void {
        if (!this.toolDetectNode) return;

        this.toolDetectNode.off(cc.Node.EventType.TOUCH_START, this.onTouchStart, this);
        this.toolDetectNode.off(cc.Node.EventType.TOUCH_MOVE, this.onTouchMove, this);
        this.toolDetectNode.off(cc.Node.EventType.TOUCH_END, this.onTouchEnd, this);
        this.toolDetectNode.off(cc.Node.EventType.TOUCH_CANCEL, this.onTouchEnd, this);
    }

    /** ==================== 触摸事件处理 ==================== */

    /**
     * 触摸开始
     */
    private onTouchStart(event: cc.Event.EventTouch): void {
        if (this.isCompleted) return;

        // 清空之前的点
        this.tempDrawPoints = [];
        const pos = this.getToolPosInMask();
        this.clearAtPos(pos);
    }

    /**
     * 触摸移动
     */
    private onTouchMove(event: cc.Event.EventTouch): void {
        if (this.isCompleted) return;

        const pos = this.getToolPosInMask();
        this.clearAtPos(pos);
    }

    /**
     * 触摸结束
     */
    private onTouchEnd(event: cc.Event.EventTouch): void {
        this.tempDrawPoints = [];
    }

    /** ==================== 擦除功能 ==================== */

    /**
     * 获取工具节点在Mask本地坐标系中的位置
     */
    private getToolPosInMask(): cc.Vec2 {
        if (!this.toolAreaNode || !this.maskNode) return cc.v2(0, 0);

        const worldPos = this.toolAreaNode.convertToWorldSpaceAR(cc.v2(0, 0));
        return this.maskNode.convertToNodeSpaceAR(worldPos);
    }

    /**
     * 获取实际擦除尺寸
     * @returns 擦除圆形半径
     */
    private getActualClearSize(): number {
        if (this.clearSize > 0) {
            return this.clearSize / 2;
        }
        // clearSize 为 0 时,使用 toolAreaNode 的尺寸
        if (this.toolAreaNode) {
            return Math.max(this.toolAreaNode.width, this.toolAreaNode.height) / 2;
        }
        return 20; // 默认回退值
    }

    /**
     * 在指定位置擦除
     * @param pos Mask本地坐标系中的位置
     */
    private clearAtPos(pos: cc.Vec2): void {
        if (!this.maskGraphics || !this.targetNode) return;

        // 检查是否在目标区域内
        if (!this.isInTargetArea(pos)) return;

        // 播放音效
        this.playScratchSound();

        // 绘制擦除
        const actualClearSize = this.getActualClearSize();
        const len = this.tempDrawPoints.length;
        let prevPos: cc.Vec2 = null;

        let stencil = this.maskGraphics;
        if (len <= 1) {
            // 第一个点,绘制圆形
            stencil.circle(pos.x, pos.y, actualClearSize);
            // this.maskGraphics.strokeColor = cc.Color.BLACK;
            stencil.fill();
            prevPos = pos;
        } else {
            // 连续移动,绘制线段
            prevPos = this.tempDrawPoints[len - 2];
            stencil.moveTo(prevPos.x, prevPos.y);
            stencil.lineTo(pos.x, pos.y);
            stencil.lineWidth = actualClearSize * 2; // lineWidth 是直径
            stencil.lineCap = cc.Graphics.LineCap.ROUND;
            stencil.lineJoin = cc.Graphics.LineJoin.ROUND;
            // this.maskGraphics.strokeColor = cc.Color.BLACK;
            stencil.stroke();
        }

        // 更新分块检测
        this.updateBlockHit(prevPos, pos);

        // 计算进度
        this.calculateProgress();
    }

    /**
     * 更新分块命中状态
     */
    private updateBlockHit(prevPos: cc.Vec2, curPos: cc.Vec2): void {
        // 单点检测
        if (prevPos.equals(curPos)) {
            this.blockList.forEach(block => {
                if (!block.isHit) {
                    const xFlag = curPos.x > block.rect.x && curPos.x < block.rect.x + block.rect.width;
                    const yFlag = curPos.y > block.rect.y && curPos.y < block.rect.y + block.rect.height;
                    if (xFlag && yFlag) {
                        block.isHit = true;
                    }
                }
            });
        } else {
            // 线段检测
            this.blockList.forEach(block => {
                if (!block.isHit) {
                    block.isHit = cc.Intersection.lineRect(prevPos, curPos, block.rect);
                }
            });
        }
    }

    /**
     * 计算当前进度
     */
    private calculateProgress(): void {
        let hitCount = 0;
        for (const block of this.blockList) {
            if (block.isHit) hitCount++;
        }

        this.currentProgress = hitCount / this.blockList.length;

        if (this.isDebug) {
            cc.log(`[BaoGaiBanZhuRen_YWQ_EraserMask] 当前进度: ${(this.currentProgress * 100).toFixed(1)}%`);
        }

        // 检查是否完成
        if (this.currentProgress >= this.completeThreshold && !this.isCompleted) {
            this.onComplete();
        }
    }

    /**
     * 检查位置是否在目标区域内
     */
    private isInTargetArea(pos: cc.Vec2): boolean {
        if (!this.targetNode) return false;

        const boundingBox = this.targetNode.getBoundingBox();
        return boundingBox.contains(pos);
    }

    /** ==================== 完成处理 ==================== */

    /**
     * 完成回调
     */
    private onComplete(): void {
        this.isCompleted = true;

        cc.log("[BaoGaiBanZhuRen_YWQ_EraserMask] 擦除完成!");

        // 显示节点
        for (const node of this.showNodesOnComplete) {
            if (node) node.active = true;
        }

        // 隐藏节点
        for (const node of this.hideNodesOnComplete) {
            if (node) node.active = false;
        }

        // 执行完成回调
        if (this.completeCallback) {
            this.completeCallback();
        }

        // 发送完成事件
        this.node.emit("eraserComplete", this);

        // 是否销毁组件
        if (this.destroyOnComplete) {
            this.scheduleOnce(() => {
                this.destroy();
            }, 0.1);
        }
    }

    /** ==================== 公共接口 ==================== */

    /**
     * 设置完成回调
     * @param callback 完成时的回调函数
     */
    public setCompleteCallback(callback: Function): void {
        this.completeCallback = callback;
    }

    /**
     * 获取当前进度(0-1)
     */
    public getProgress(): number {
        return this.currentProgress;
    }

    /**
     * 是否已完成
     */
    public getIsCompleted(): boolean {
        return this.isCompleted;
    }

    /**
     * 重置擦除状态
     */
    public reset(): void {
        if (!this.maskGraphics) return;

        this.tempDrawPoints = [];
        this.isCompleted = false;
        this.currentProgress = 0;

        // 重置分块
        for (const block of this.blockList) {
            block.isHit = false;
        }

        // 重新填充mask
        this.fillMask();

        if (this.isDebug) {
            cc.log("[BaoGaiBanZhuRen_YWQ_EraserMask] 已重置");
        }
    }

    /**
     * 全部清除(一次性清除整个mask)
     */
    public clearAll(): void {
        if (!this.maskGraphics || !this.targetNode) return;

        // 绘制透明填充整个区域(相当于全部擦除)
        const width = this.targetNode.width;
        const height = this.targetNode.height;

        // 使用透明色填充
        this.maskGraphics.fillColor = cc.color(0, 0, 0, 0);
        this.maskGraphics.rect(-width / 2, -height / 2, width, height);
        this.maskGraphics.fill();

        this.isCompleted = true;
        this.currentProgress = 1;

        // 标记所有分块为已擦除
        for (const block of this.blockList) {
            block.isHit = true;
        }

        this.onComplete();
    }

    public clearMask() {
        cc.log('清除遮罩');
        if (!this.maskGraphics || !this.targetNode) return;
        let stencil = this.maskGraphics;

        // 清空图形,让整个区域都不被遮罩
        stencil.clear();
    }

    /**
     * 启用/禁用擦除功能
     * @param enabled 是否启用
     */
    public setEnabled(enabled: boolean): void {
        if (enabled) {
            this.registerEvents();
        } else {
            this.unregisterEvents();
        }
    }

    /** ==================== 音效 ==================== */

    /**
     * 播放擦除音效
     */
    private playScratchSound(): void {
        if (!this.enableSound || !this.canPlaySound) return;

        this.canPlaySound = false;
        engine.audio.playEffect(this.scratchSoundName, false, (time) => {
            this.scheduleOnce(() => {
                this.canPlaySound = true;
            }, time);
        });
    }
}