Skip to content
攻击反馈

攻击反馈

目前我们的角色发起攻击后给玩家带来的反馈较少,本节课将通过动画、音效、特效、倒计时、飘字这5个方面来提升攻击反馈。

1.攻击动画

首先需要找到一个自己喜欢的攻击动画

我们可以在本地资源库中寻找自己喜欢的动画,在这里我使用的动画 AssetID:84912

点击动画资源右上角的放大镜可以进行预览

将需要使用的动画拖入到优先加载(如果不拖会导致资源加载失败,使用不生效)

image-20230922162540501

为了让动画能够同步播放,所以我们需要在服务端播放动画。

由于我们使用了模块管理,所以可以在PlayerModuleS中添加一个播放动画的网络方法

PlayerModuleS脚本:

此次添加逻辑的要点:

添加了 net_playAnim 这个函数来播放动画

net_playAnim是一个网络方法

网络方法

在模块管理中,以"net_"开头命名的函数,会在模块注册的时候,注册为“网络方法”。

所谓网络方法就是可以提供让服务端模块调用客户端模块函数、客户端模块调用服务端模块函数。

通过这种方式,将原本需要来回添加事件监听逻辑的写法,简化为只需要加上"net_"

使用loadAnimation给character加载了一个动画,并立即播放

ts
import { PlayerModuleC } from "./PlayerModuleC";

export class PlayerModuleS extends ModuleS<PlayerModuleC, null>{

    protected onStart(): void {
        console.log("角色服务端模块启动")
    }

    public net_playAnim() {  
        // 播放攻击动作  
        this.currentPlayer.character.loadAnimation("84912").play()  
    }  
}
import { PlayerModuleC } from "./PlayerModuleC";

export class PlayerModuleS extends ModuleS<PlayerModuleC, null>{

    protected onStart(): void {
        console.log("角色服务端模块启动")
    }

    public net_playAnim() {  
        // 播放攻击动作  
        this.currentPlayer.character.loadAnimation("84912").play()  
    }  
}

服务端模块有了播放动画的函数后,客户端模块只需要在对应的位置调用即可。

PlayerModuleC脚本:

通过this.server来调用服务端模块的网络方法

ts
import MonsterScirpt from "../MonsterScirpt";
import { PlayerModuleS } from "./PlayerModuleS";

export class PlayerModuleC extends ModuleC<PlayerModuleS, PlayerModuleData>{

    private _nowAtk: number = 50

    protected async onStart(): Promise<void> {
        console.log("角色客户端模块启动")
    }

    public atk() {
        // 范围检测
        let result = QueryUtil.sphereOverlap(this.localPlayer.character.worldTransform.position, 100, false)
        // 筛选出怪物
        for (let obj of result) {
            if (obj instanceof Character) {
                continue
            }

            if (obj.tag == "Monster") {
                // 让怪物受伤
                let scripts = obj.getScripts()
                for (let script of scripts) {
                    if (script instanceof MonsterScirpt) {
                        let damage = script.hurt(this._nowAtk)
                    }
                }
            }
        }

        this.server.net_playAnim()  

    }
}
import MonsterScirpt from "../MonsterScirpt";
import { PlayerModuleS } from "./PlayerModuleS";

export class PlayerModuleC extends ModuleC<PlayerModuleS, PlayerModuleData>{

    private _nowAtk: number = 50

    protected async onStart(): Promise<void> {
        console.log("角色客户端模块启动")
    }

    public atk() {
        // 范围检测
        let result = QueryUtil.sphereOverlap(this.localPlayer.character.worldTransform.position, 100, false)
        // 筛选出怪物
        for (let obj of result) {
            if (obj instanceof Character) {
                continue
            }

            if (obj.tag == "Monster") {
                // 让怪物受伤
                let scripts = obj.getScripts()
                for (let script of scripts) {
                    if (script instanceof MonsterScirpt) {
                        let damage = script.hurt(this._nowAtk)
                    }
                }
            }
        }

        this.server.net_playAnim()  

    }
}

由于我们在PlayerModuleC中已经实现了角色播放攻击动画的逻辑,所以需要将DefaultUI中播放动画的逻辑删除,避免动画冲突。

DefaultUI脚本:

修改过后的完整脚本

ts
import { PlayerModuleC } from "./Module/PlayerModuleC";

@UIBind('')
export default class DefaultUI extends UIScript {
	private character: Character;
	private anim1 = null;

	/** 仅在游戏时间对非模板实例调用一次 */
	protected onStart() {
		//设置能否每帧触发onUpdate
		this.canUpdate = false;

		//找到对应的跳跃按钮
		const jumpBtn = this.uiWidgetBase.findChildByPath('RootCanvas/Button_Jump') as Button
		const attackBtn = this.uiWidgetBase.findChildByPath('RootCanvas/Button_Attack') as Button

		//点击跳跃按钮,异步获取人物后执行跳跃
		jumpBtn.onPressed.add(() => {
			if (this.character) {
				this.character.jump();
			} else {
				Player.asyncGetLocalPlayer().then((player) => {
					this.character = player.character;
					//角色执行跳跃功能
					this.character.jump();
				});
			}
		})

		//点击攻击按钮,异步获取人物后执行攻击动作
		attackBtn.onPressed.add(() => {
			ModuleService.getModule(PlayerModuleC).atk()  
		})
	}
}
import { PlayerModuleC } from "./Module/PlayerModuleC";

@UIBind('')
export default class DefaultUI extends UIScript {
	private character: Character;
	private anim1 = null;

	/** 仅在游戏时间对非模板实例调用一次 */
	protected onStart() {
		//设置能否每帧触发onUpdate
		this.canUpdate = false;

		//找到对应的跳跃按钮
		const jumpBtn = this.uiWidgetBase.findChildByPath('RootCanvas/Button_Jump') as Button
		const attackBtn = this.uiWidgetBase.findChildByPath('RootCanvas/Button_Attack') as Button

		//点击跳跃按钮,异步获取人物后执行跳跃
		jumpBtn.onPressed.add(() => {
			if (this.character) {
				this.character.jump();
			} else {
				Player.asyncGetLocalPlayer().then((player) => {
					this.character = player.character;
					//角色执行跳跃功能
					this.character.jump();
				});
			}
		})

		//点击攻击按钮,异步获取人物后执行攻击动作
		attackBtn.onPressed.add(() => {
			ModuleService.getModule(PlayerModuleC).atk()  
		})
	}
}

2.攻击音效

首先需要找到一个自己喜欢的攻击音效

我们可以在本地资源库中寻找自己喜欢的音效,在这里我使用的动画 AssetID:209818

点击音效资源右上角的放大镜可以打开预览面板s

点击播放按钮即可预览音效

将需要使用的音效拖入到优先加载(如果不拖会导致资源加载失败,使用不生效)

image-20230922164001213

播放音效只需要在客户端进行播放,所以在客户端模块添加对应代码即可

PlayerModuleC脚本:

使用SoundService.playSound播放音效209818

ts
export class PlayerModuleC extends ModuleC<PlayerModuleS, PlayerModuleData>{

    public atk() {
    	// 省略代码
        ......

        this.server.net_playAnim()

        // 播放音效  
        SoundService.playSound("209818", 1)  
    }
}
export class PlayerModuleC extends ModuleC<PlayerModuleS, PlayerModuleData>{

    public atk() {
    	// 省略代码
        ......

        this.server.net_playAnim()

        // 播放音效  
        SoundService.playSound("209818", 1)  
    }
}

除了角色攻击时需要攻击音效,怪物受到攻击时也需要播放音效。将135757这个音效资源拖入优先加载后,来到MonsterScript脚本添加如下逻辑:

使用SoundService.playSound播放音效135757

ts
@Component
export default class MonsterScirpt extends Script {
	// 省略代码
    ......
    
    public hurt(damage: number) {
        if (this.nowHP <= 0) { return 0 }
        this.hurtOnServer(damage)
        
        SoundService.playSound("135757", 1)  

        return damage
    }
}
@Component
export default class MonsterScirpt extends Script {
	// 省略代码
    ......
    
    public hurt(damage: number) {
        if (this.nowHP <= 0) { return 0 }
        this.hurtOnServer(damage)
        
        SoundService.playSound("135757", 1)  

        return damage
    }
}

3.怪物死亡特效

首先需要在本地资源库中找到一个自己喜欢的怪物死亡特效,预览逻辑和动画、音效一致,在这里我使用的特效 AssetID 为142750,搜索到对应特效,拖入到优先加载即可。

怪物死亡特效需要在怪物死亡时进行播放,所以我们在怪物脚本中添加逻辑:

MonsterScript脚本:

使用EffectService.playAtPosition播放特效142750

ts
@Component
export default class MonsterScirpt extends Script {
	
    // 省略代码
    ......
    
    @mw.RemoteFunction(mw.Server)
    private hurtOnServer(damage: number) {
        // 扣血
        this.nowHP = this.nowHP - damage < 0 ? 0 : this.nowHP - damage
        // 死亡逻辑
        if (this.nowHP <= 0) {
            this.gameObject.setVisibility(false)

            EffectService.playAtPosition("142750", this.gameObject.worldTransform.position)  

            // 怪物复活
            setTimeout(() => {
                this.gameObject.setVisibility(true)
                this.nowHP = this.maxHP
            }, (this.time + 1) * 1000);
        }

    }
}
@Component
export default class MonsterScirpt extends Script {
	
    // 省略代码
    ......
    
    @mw.RemoteFunction(mw.Server)
    private hurtOnServer(damage: number) {
        // 扣血
        this.nowHP = this.nowHP - damage < 0 ? 0 : this.nowHP - damage
        // 死亡逻辑
        if (this.nowHP <= 0) {
            this.gameObject.setVisibility(false)

            EffectService.playAtPosition("142750", this.gameObject.worldTransform.position)  

            // 怪物复活
            setTimeout(() => {
                this.gameObject.setVisibility(true)
                this.nowHP = this.maxHP
            }, (this.time + 1) * 1000);
        }

    }
}

4.怪物复活倒计时

虽然怪物有复活时间这个属性,但目前并没有在游戏中展示出来给玩家,所以我们可以通过怪物UI来将死亡倒计时进行展示。

首先在MonsterUI脚本中添加倒计时函数

使用一个间隔函数来进行复活倒计时,每一秒改变血条文本所显示的内容

ts
export default class MonsterUI extends MonsterUI_Generate {
    // 省略代码
    ......

    /**复活倒计时 */  
    public openClock(time: number) {  
        this.mHP_txt.text = "复活倒计时:" + time  
        let inter = setInterval(() => {  
            time -= 1  
            this.mHP_txt.text = "复活倒计时:" + time  
            if (time <= 0) {  
                clearInterval(inter)  
            }  
        }, 1000)  
    }  
}
export default class MonsterUI extends MonsterUI_Generate {
    // 省略代码
    ......

    /**复活倒计时 */  
    public openClock(time: number) {  
        this.mHP_txt.text = "复活倒计时:" + time  
        let inter = setInterval(() => {  
            time -= 1  
            this.mHP_txt.text = "复活倒计时:" + time  
            if (time <= 0) {  
                clearInterval(inter)  
            }  
        }, 1000)  
    }  
}

有了倒计时UI函数,也就意味着MonsterUI具备了倒计时功能,所以我们只需要在判断到怪物死亡的时候,开启这个倒计时即可

MonsterScript脚本中判断怪物死亡,并开启倒计时

在血量发生变化时,判断到血量小于或者等于0,那么就开启复活倒计时

ts
@Component
export default class MonsterScirpt extends Script {
    // 省略代码
    ......

    private onHPChanged() {
        // 调用血条刷新的逻辑
        if (this.monsterUI) {
            this.monsterUI.freshHP(this.nowHP)

            if (this.nowHP <= 0) {  
                this.monsterUI.openClock(this.time)  
            }  
        }

    }

}
@Component
export default class MonsterScirpt extends Script {
    // 省略代码
    ......

    private onHPChanged() {
        // 调用血条刷新的逻辑
        if (this.monsterUI) {
            this.monsterUI.freshHP(this.nowHP)

            if (this.nowHP <= 0) {  
                this.monsterUI.openClock(this.time)  
            }  
        }

    }

}

5.伤害飘字

伤害飘字是最常见的攻击反馈,在这里我提供了编写好的飘字脚本,大家只需要复制粘贴到工程里就可以使用啦~

步骤一:创建飘字脚本

这个脚本不需要拖动到场景中,大家只需要新建一个脚本,然后将内容粘贴进去即可。

飘字代码:

ts
export class FlyText {
    // 单例模式
    private static _instance: FlyText
    public static get instance() {
        if (FlyText._instance == null) {
            FlyText._instance = new FlyText()
        }
        return FlyText._instance
    }

    private _uiWidget: UserWidget
    private _rootCanvas: Canvas
    private _textPools: TextBlock[] = []


    /**默认文本框大小(由于开启了自适应,所以文本框有多大,文本就有多大) */
    private _defaultTextSize: Vector2 = new Vector2(150, 80)
    /**默认文本颜色 */
    private _defaultFontColor: LinearColor = LinearColor.yellow


    /**
     * 展示飘字
     * @param content 内容
     * @param worldLocation 世界坐标
     * @param color 颜色(可选)
     */
    public showFlyText(content: string, worldLocation: Vector, color?: LinearColor) {
        // 将世界坐标转换为屏幕坐标
        let vec2 = InputUtil.projectWorldPositionToWidgetPosition(worldLocation, true).screenPosition;
        // 对象池处理
        let textBlock: TextBlock;
        if (this._textPools.length == 0) {
            textBlock = this.createText()
        } else {
            textBlock = this._textPools.pop();
        }
        // 给一点初始偏移,方便做动画
        vec2.x -= 120
        vec2.y -= 160;
        let toX = this.getRandomIntInclusive(100, 300);
        Math.random() < 0.5 ? toX = -toX : toX = toX;
        let toY = this.getRandomIntInclusive(-300, 100);
        // 用Tween,并结合PI来做曲线动画
        let animator = new Tween({ a: 0 }).to({ a: Math.PI }, 1000).onUpdate((object) => {
            textBlock.position = vec2.clone().add(new Vector2(toX * object.a / Math.PI, toY * Math.sin(object.a)));
            textBlock.renderScale = new Vector2(Math.sin(object.a));
        }).onStart(() => {
            textBlock.fontColor = color ? color : this._defaultFontColor
            textBlock.text = content;
            textBlock.visibility = SlateVisibility.SelfHitTestInvisible
        }).onComplete(() => {
            textBlock.visibility = SlateVisibility.Hidden
            this._textPools.push(textBlock);
        })
        animator.start();
    }


    /**创建一个文本框 */
    private createText() {
        // 首次创建,如果没有UI对象,就创建一个
        if (!this._uiWidget) {
            // 创建一个UI对象
            this._uiWidget = UserWidget.newObject();
            this._uiWidget.addToViewport(1)
            // 首次创建,如果没有rootCanvas,就创建一个
            if (!this._rootCanvas) {
                this._rootCanvas = Canvas.newObject()
                this._rootCanvas.size = new Vector2(1920, 1080);
                this._rootCanvas.position = Vector2.zero
                this._uiWidget.rootContent = this._rootCanvas
            }
        }
        // 创建一个文本框,并添加到画布上
        let textBlock = TextBlock.newObject(this._rootCanvas)
        textBlock.size = this._defaultTextSize
        // 开启文本自适应
        textBlock.autoAdjust = true
        return textBlock
    }

    /**得到一个两数之间的随机整数,包括两个数在内 */
    private getRandomIntInclusive(min: number, max: number) {
        min = Math.ceil(min);
        max = Math.floor(max);
        return Math.floor(Math.random() * (max - min + 1)) + min; //含最大值,含最小值 
    }

}
export class FlyText {
    // 单例模式
    private static _instance: FlyText
    public static get instance() {
        if (FlyText._instance == null) {
            FlyText._instance = new FlyText()
        }
        return FlyText._instance
    }

    private _uiWidget: UserWidget
    private _rootCanvas: Canvas
    private _textPools: TextBlock[] = []


    /**默认文本框大小(由于开启了自适应,所以文本框有多大,文本就有多大) */
    private _defaultTextSize: Vector2 = new Vector2(150, 80)
    /**默认文本颜色 */
    private _defaultFontColor: LinearColor = LinearColor.yellow


    /**
     * 展示飘字
     * @param content 内容
     * @param worldLocation 世界坐标
     * @param color 颜色(可选)
     */
    public showFlyText(content: string, worldLocation: Vector, color?: LinearColor) {
        // 将世界坐标转换为屏幕坐标
        let vec2 = InputUtil.projectWorldPositionToWidgetPosition(worldLocation, true).screenPosition;
        // 对象池处理
        let textBlock: TextBlock;
        if (this._textPools.length == 0) {
            textBlock = this.createText()
        } else {
            textBlock = this._textPools.pop();
        }
        // 给一点初始偏移,方便做动画
        vec2.x -= 120
        vec2.y -= 160;
        let toX = this.getRandomIntInclusive(100, 300);
        Math.random() < 0.5 ? toX = -toX : toX = toX;
        let toY = this.getRandomIntInclusive(-300, 100);
        // 用Tween,并结合PI来做曲线动画
        let animator = new Tween({ a: 0 }).to({ a: Math.PI }, 1000).onUpdate((object) => {
            textBlock.position = vec2.clone().add(new Vector2(toX * object.a / Math.PI, toY * Math.sin(object.a)));
            textBlock.renderScale = new Vector2(Math.sin(object.a));
        }).onStart(() => {
            textBlock.fontColor = color ? color : this._defaultFontColor
            textBlock.text = content;
            textBlock.visibility = SlateVisibility.SelfHitTestInvisible
        }).onComplete(() => {
            textBlock.visibility = SlateVisibility.Hidden
            this._textPools.push(textBlock);
        })
        animator.start();
    }


    /**创建一个文本框 */
    private createText() {
        // 首次创建,如果没有UI对象,就创建一个
        if (!this._uiWidget) {
            // 创建一个UI对象
            this._uiWidget = UserWidget.newObject();
            this._uiWidget.addToViewport(1)
            // 首次创建,如果没有rootCanvas,就创建一个
            if (!this._rootCanvas) {
                this._rootCanvas = Canvas.newObject()
                this._rootCanvas.size = new Vector2(1920, 1080);
                this._rootCanvas.position = Vector2.zero
                this._uiWidget.rootContent = this._rootCanvas
            }
        }
        // 创建一个文本框,并添加到画布上
        let textBlock = TextBlock.newObject(this._rootCanvas)
        textBlock.size = this._defaultTextSize
        // 开启文本自适应
        textBlock.autoAdjust = true
        return textBlock
    }

    /**得到一个两数之间的随机整数,包括两个数在内 */
    private getRandomIntInclusive(min: number, max: number) {
        min = Math.ceil(min);
        max = Math.floor(max);
        return Math.floor(Math.random() * (max - min + 1)) + min; //含最大值,含最小值 
    }

}

步骤二:调用飘字函数

在怪物受伤时调用飘字脚本提供的函数来展示飘字。

MonsterScript脚本:

将伤害值作为飘字内容,从怪物模型的位置产生飘字

ts
import { FlyText } from "./FlyText"
import MonsterUI from "./UI/MonsterUI"

@Component
export default class MonsterScirpt extends Script {

    public hurt(damage: number) {
        if (this.nowHP <= 0) { return 0 }
        this.hurtOnServer(damage)
        SoundService.playSound("135757", 1)

        FlyText.instance.showFlyText(damage.toFixed(0), this.gameObject.worldTransform.position)  
        
        return damage
    }

}
import { FlyText } from "./FlyText"
import MonsterUI from "./UI/MonsterUI"

@Component
export default class MonsterScirpt extends Script {

    public hurt(damage: number) {
        if (this.nowHP <= 0) { return 0 }
        this.hurtOnServer(damage)
        SoundService.playSound("135757", 1)

        FlyText.instance.showFlyText(damage.toFixed(0), this.gameObject.worldTransform.position)  
        
        return damage
    }

}

步骤三:在主循环中对Tween进行驱动

因为飘字使用了Tween这个函数库,它需要被驱动后才能够执行。

在GameStart脚本中对Tween进行驱动

ts
import { PlayerModuleC } from "./Module/PlayerModuleC";
import { PlayerModuleData } from "./Module/PlayerModuleData";
import { PlayerModuleS } from "./Module/PlayerModuleS";

@Component
export default class GameStart extends Script {

    /** 当脚本被实例后,会在第一帧更新前调用此函数 */
    protected onStart(): void {
        this.useUpdate = true  

        ModuleService.registerModule(PlayerModuleS, PlayerModuleC, PlayerModuleData)
    }

    /**
     * 周期函数 每帧执行
     * 此函数执行需要将this.useUpdate赋值为true
     * @param dt 当前帧与上一帧的延迟 / 秒
     */
    protected onUpdate(dt: number): void {
        // 对Tween进行了驱动  
        TweenUtil.TWEEN.update()  
    }

    /** 脚本被销毁时最后一帧执行完调用此函数 */
    protected onDestroy(): void {

    }
}
import { PlayerModuleC } from "./Module/PlayerModuleC";
import { PlayerModuleData } from "./Module/PlayerModuleData";
import { PlayerModuleS } from "./Module/PlayerModuleS";

@Component
export default class GameStart extends Script {

    /** 当脚本被实例后,会在第一帧更新前调用此函数 */
    protected onStart(): void {
        this.useUpdate = true  

        ModuleService.registerModule(PlayerModuleS, PlayerModuleC, PlayerModuleData)
    }

    /**
     * 周期函数 每帧执行
     * 此函数执行需要将this.useUpdate赋值为true
     * @param dt 当前帧与上一帧的延迟 / 秒
     */
    protected onUpdate(dt: number): void {
        // 对Tween进行了驱动  
        TweenUtil.TWEEN.update()  
    }

    /** 脚本被销毁时最后一帧执行完调用此函数 */
    protected onDestroy(): void {

    }
}

Tween

有关Tween的相关知识点可以阅读:Tween - 补间动画 | 教程 (ark.online)

6.避免角色出生位置重合

由于目前场景上只有一个初生点,这会导致玩家在同一时间进入游戏的时候,角色创建在同一个位置,然后出现角色被弹开的情况。

因此我们需要通过在场景中新增初生点的方式来避免这个问题:

① 在资源库中选择“游戏功能对象”

② 将“初生点”放置在场景中。(一般对于10人以下的游戏,3~5个就足够了)

image-20231109182959339