# 消耗类武器

温馨提示:开始阅读这篇指南之前,我们希望你对《我的世界》基岩版附加包有一定了解,有能力撰写 JSON 数据格式,并能够独立阅读《我的世界》开发者官网-开发指南或其他技术引用文档。

本文将帮助你添加一个可以投掷出去的燃烧瓶 3D 武器。(强烈建议阅读之前先阅读第一节课的内容,因为思路一样)

本文假定你熟悉 Molang、渲染控制器、动画和实体定义有基本的了解。本文不涉及美术资源的相关教程,如果对此感兴趣的同学可以自行学习和了解。

在本教程中,您将学习以下内容。

  • ✅制作一个燃烧瓶,可投掷并造成伤害。

# 成果展示

还是一个动画完整,附带一个简单交互界面的燃烧瓶,可以扔出去造成伤害:

燃烧瓶演示

# 燃烧瓶制作

所有的 3D 武器都是有两种制作思路 ,由于燃烧瓶几乎是只提供给玩家使用的道具,所以我们这里直接采取第一种额外骨骼的方式。

# 模型制作

先来一个骨骼对应好的模型:

除此之外,我们还需要一个用于投掷出去的抛射物模型:

制作好之后导出到响应文件下就可以。

# 动画制作

我们先来准备一个用于制作动画的模型,复制一份原版的玩家模型,然后导入我们的自定义燃烧瓶模型,删除贴图:

# 第三人称动画

由于骨骼是与玩家严格对齐,所以第三人称手持动画就不需要制作了。直接制作一个抬手仍出去的攻击动画:

燃烧瓶第三人称攻击动画

由于第三人称的情况下手臂默认会有一个抬起的动画,所以第三人称的动画需要勾选上动画的「覆盖」模式。

# 第一人称动画

还是按照之前的方法,先模拟出游戏中的第一人称视角。加入我们的模拟动画:

{
	"format_version": "1.8.0",
	"animations": {
		"animation.first_person_guide.right_arm.method_one": {
			"loop": true,
			"bones": {
				"rightArm": {
					"rotation": [95, -45, 115],
					"position": [13.5, -10, 12]
				},
				"rightItem": {
					"position": [0, 0, -1]
				}
			}
		}
	}
}

再来一个模拟第一人称相机的视角:

再把除了右手之外的其他骨骼给隐藏掉,然后修正第一人称视角下的位置:

然后我们可以直接考虑复制第三人称攻击动画的帧,加入到第一人称攻击动画中,这样做主要是为了对齐关键动作的时间,然后再做一些修改,就可以得到我们第一人称的攻击动画了 : 燃烧瓶第一人称攻击动画

# 抛射物的飞行动画

为了稍微「精致」一点儿,我们也要为投掷出去的抛射物制作一个旋转动画:

燃烧瓶的旋转动画

# 动画控制器

动画控制器很简单,一个第一人称使用,一个第三人称使用,除了播放的动画不一样之外,没有区别:

{
    "format_version": "1.10.0",
    "animation_controllers": {
        "controller.animation.custom_fire_bottle_first_person": {
            "initial_state": "default",
            "states": {
                "default": {
                    "transitions": [
                        {
                            "first_person_attack1": "query.mod.custom_fire_bottle_attack == 1.0"
                        }
                    ]
                },
                "first_person_attack1": {
                    "animations": [
                        "custom_fire_bottle_attack_first_person"
                    ],
                    "transitions": [
                        {
                            "default": "query.any_animation_finished && query.mod.custom_fire_bottle_attack == 0.0"
                        }
                    ]
                }
            }
        },
        "controller.animation.custom_fire_bottle_third_person": {
            "initial_state": "default",
            "states": {
                "default": {
                    "transitions": [
                        {
                            "third_person_attack1": "query.mod.custom_fire_bottle_attack == 1.0"
                        }
                    ]
                },
                "third_person_attack1": {
                    "animations": [
                        "custom_fire_bottle_attack_third_person"
                    ],
                    "transitions": [
                        {
                            "default": "query.any_animation_finished && query.mod.custom_fire_bottle_attack == 0.0"
                        }
                    ]
                }
            }
        }
    }
}

# 渲染器

为了在第一人称下显示手臂,我们也需要对应修改原版的 player.render_controllers.json 文件:

{
  "format_version": "1.8.0",
  "render_controllers": {
    "controller.render.player.first_person": {
      "geometry": "Geometry.default",
      "materials": [ { "*": "Material.default" } ],
      "textures": [ "Texture.default" ],
      "part_visibility": [
        { "*": false },
        // 修改原版渲染器,让它支持在手持自定义枪械和燃烧瓶时,显示右手臂
        { "rightArm": "query.get_equipped_item_name(0, 1) == '' || query.get_equipped_item_name(0, 1) == 'map' || query.get_equipped_item_name(0, 1) == 'custom_gun' || query.get_equipped_item_name(0, 1) == 'custom_fire_bottle'" },
        { "rightSleeve": "query.get_equipped_item_name(0, 1) == '' || query.get_equipped_item_name(0, 1) == 'map' || query.get_equipped_item_name(0, 1) == 'custom_gun' || query.get_equipped_item_name(0, 1) == 'custom_fire_bottle'" },
        { "leftArm": "(query.get_equipped_item_name(0, 1) == 'map' && query.get_equipped_item_name('off_hand') != 'shield') || (query.get_equipped_item_name('off_hand') == 'map' && !query.item_is_charged) || (!query.item_is_charged && (variable.item_use_normalized > 0 && variable.item_use_normalized < 1.0))" },
        { "leftSleeve": "(query.get_equipped_item_name(0, 1) == 'map' && query.get_equipped_item_name('off_hand') != 'shield') || (query.get_equipped_item_name('off_hand') == 'map' && !query.item_is_charged) || (!query.item_is_charged && (variable.item_use_normalized > 0 && variable.item_use_normalized < 1.0))" }
      ]
    },

然后还有燃烧瓶的渲染控制器(tutorial_custom_fire_bottle.render_controllers.json):

{
    "format_version": "1.8.0",
    "render_controllers": {
        "controller.render.tutorial_custom_fire_bottle": {
            "geometry": "Geometry.custom_fire_bottle",
            "materials": [{"*": "Material.default"}],
            "textures": ["Texture.custom_fire_bottle"]
        }
    }
}

# 抛射物的定义

我们还需要新建一个实体用来当做燃烧瓶的抛射物:

{
    "format_version": "1.13.0",
    "minecraft:entity": {
        "description": {
            "is_experimental": false,
            "identifier": "tutorial:custom_fire_bottle_projectile",
            "is_spawnable": false,
            "is_summonable": false
        },
        "component_groups": {
        },
        "components": {
            "minecraft:despawn": {
                "despawn_from_distance": {}
            },
            "minecraft:physics": {},
            "minecraft:projectile": {
                "on_hit": {
                    "remove_on_hit": {},
                    "impact_damage": {
                        "catch_fire": true,
                        "knockback": false,
                        "damage": 1,
                        "destroy_on_hit": true
                    }
                },
                "gravity": 0.0,
                "power": 1.0,
                "offset": [
                    0,
                    0,
                    0
                ]
            },
            "minecraft:collision_box": {
                "width": 0.31,
                "height": 0.31
            },
            "netease:custom_entity_type": {
                "value": "projectile_entity"
            },
            "minecraft:pushable": {
                "is_pushable_by_piston": true,
                "is_pushable": true
            }
        },
        "events": {
        }
    }
}

资源包下的实体定义:

{
    "format_version": "1.10.0",
    "minecraft:client_entity": {
        "description": {
            "identifier": "tutorial:custom_fire_bottle_projectile",
            "materials": {
                "default": "entity_alphatest"
            },
            "textures": {
                "default": "textures/models/tutorial_custom_fire_bottle"
            },
            "geometry": {
                "default": "geometry.tutorial_custom_fire_bottle_projectile"
            },
            "animations": {
                "move": "animation.tutorial_custom_fire_bottle_projectile.move"
            },
            "scripts": {
                "animate": [
                    "move"
                ]
            },
            "render_controllers": [
                "controller.render.default"
            ]
        }
    }
}

# 注入相关资源

把我们上面制作好的资源通过代码注入到玩家的渲染器下,并且监听了服务端传回来的状态同步事件:

# -*- coding: utf-8 -*-
import mod.client.extraClientApi as clientApi
import time

import config

CompFactory = clientApi.GetEngineCompFactory()
gameComp = CompFactory.CreateGame(clientApi.GetLevelId())


class TutorialClientSystem(clientApi.GetClientSystemCls()):

    def __init__(self, namespace, name):
        super(TutorialClientSystem, self).__init__(namespace, name)
        self.ListenEvent()

    def ListenEvent(self):
        # 自定义事件
        self.ListenForEvent('tutorialMod', 'tutorialServerSystem', 'SyncCustomFireBottleStateEvent', self,
                            self.OnSyncCustomFireBottleStateEvent)

    def OnSyncCustomFireBottleStateEvent(self, args):
        playerId = args['playerId']
        value = float(args['value'])
        CompFactory.CreateQueryVariable(playerId).Set(config.FireBottleAttackVarName, value)

    def OnAddPlayerCreatedClientEvent(self, args):
        playerId = args['playerId']
        self.InitRender(playerId)  # 包括其他玩家也需要被初始化

    # 初始化绑定
    def InitRender(self, playerId):
        # 燃烧瓶
        self._InitToFireBottle(playerId)

        actorRenderComp = CompFactory.CreateActorRender(playerId)
        actorRenderComp.RebuildPlayerRender()

    # 燃烧瓶渲染器
    def _InitToFireBottle(self, playerId):
        queryVariableComp = CompFactory.CreateQueryVariable(playerId)
        queryVariableComp.Register(config.FireBottleAttackVarName, 0)
        queryVariableComp.Set(config.FireBottleAttackVarName, 0)

        actorRenderComp = CompFactory.CreateActorRender(playerId)
        # 控制器
        actorRenderComp.AddPlayerGeometry('custom_fire_bottle', 'geometry.tutorial_custom_fire_bottle')
        actorRenderComp.AddPlayerTexture('custom_fire_bottle', 'textures/models/tutorial_custom_fire_bottle')
        actorRenderComp.AddPlayerRenderController("controller.render.tutorial_custom_fire_bottle",
                                                  "query.get_equipped_item_name('main_hand') == 'custom_fire_bottle'")
        # 定义动画和控制器名称
        animations = ['hold_first_person', 'attack_first_person', 'attack_third_person']
        controllers = ['custom_fire_bottle_first_person', 'custom_fire_bottle_third_person']

        for anim in animations:
            animationKey = 'custom_fire_bottle_' + anim
            animationName = 'animation.tutorial_custom_fire_bottle.' + anim
            actorRenderComp.AddPlayerAnimation(animationKey, animationName)

        for controller in controllers:
            controllerKey = controller + "_controller"
            controllerName = 'controller.animation.' + controller
            actorRenderComp.AddPlayerAnimationController(controllerKey, controllerName)

        # 添加动画的触发条件
        actorRenderComp.AddPlayerScriptAnimate(
            'custom_fire_bottle_hold_first_person',
            "variable.is_first_person && query.get_equipped_item_name('main_hand') == 'custom_fire_bottle'"
        )
        actorRenderComp.AddPlayerScriptAnimate(
            'custom_fire_bottle_first_person_controller',
            "variable.is_first_person && query.get_equipped_item_name('main_hand') == 'custom_fire_bottle'"
        )
        actorRenderComp.AddPlayerScriptAnimate(
            'custom_fire_bottle_third_person_controller',
            "!variable.is_first_person && query.get_equipped_item_name('main_hand') == 'custom_fire_bottle'"
        )

# 编写 UI 文件

我们只需要一个拥有绝对定位的按钮的简单 UI:

界面也很简单:

# -*- coding: utf-8 -*-
import mod.client.extraClientApi as clientApi

import config

ViewBinder = clientApi.GetViewBinderCls()
ViewRequest = clientApi.GetViewViewRequestCls()
ScreenNode = clientApi.GetScreenNodeCls()

Namespace = clientApi.GetEngineNamespace()
SystemName = clientApi.GetEngineSystemName()
CompFactory = clientApi.GetEngineCompFactory()
gameComp = CompFactory.CreateGame(clientApi.GetLevelId())


#ui布局绑定
class FireBottleUIScripts(ScreenNode):
    def __init__(self, namespace, name, param):
        ScreenNode.__init__(self, namespace, name, param)
        self.mPlayerId = clientApi.GetLocalPlayerId()
        self.mClientSystem = clientApi.GetSystem('tutorialMod', 'tutorialClientSystem')
        self.mItemComp = CompFactory.CreateItem(self.mPlayerId)
        self.mQueryVariableComp = CompFactory.CreateQueryVariable(self.mPlayerId)

        # 组件地址
        self.mBtnPath = "/button"

        # 界面所需的变量
        self.mCarriedItem = None

    def Create(self):
        print("===== Tutorial Custom Gun UI Create Finished =====")
        # 注册按钮的事件
        control = self.GetBaseUIControl(self.mBtnPath).asButton()
        control.AddTouchEventParams({"isSwallow": True})
        control.SetButtonTouchUpCallback(self.OnButtonUp)

        # 关注事件
        namespace, systemName = clientApi.GetEngineNamespace(), clientApi.GetEngineSystemName()
        self.mClientSystem.ListenForEvent(namespace, systemName, "OnCarriedNewItemChangedClientEvent", self, self.OnCarriedNewItem)

        # 刚创建时也自动触发一次
        self.OnCarriedNewItem({'itemDict': self.mItemComp.GetCarriedItem()})

    # region 按钮事件
    # --------------------------------------------------------------------------------------------
    def OnButtonUp(self, args):
        self._HandleThrow()
        # 按了一次之后就直接隐藏界面,避免误操作
        self._SetUIVisible(False)

    # endregion

    # region 事件监听
    # --------------------------------------------------------------------------------------------
    def OnCarriedNewItem(self, args):
        self.mCarriedItem = args['itemDict']
        if self._IsCarriedCustomGun():
            self._SetUIVisible(True)
        else:
            self._SetUIVisible(False)

    # endregion

    # region 类函数
    # --------------------------------------------------------------------------------------------
    def _IsCarriedCustomGun(self):
        if self.mCarriedItem and self.mCarriedItem['itemName'] == 'tutorial:custom_fire_bottle':
            return True
        return False

    def _SetUIVisible(self, flag):
        self.SetScreenVisible(flag)

    def _HandleThrow(self):
        throwTime = 0.25
        animTotalTime = 0.5
        gameComp.AddTimer(0, self._SetAttackStateAndSyncToOtherClients, 'start')
        gameComp.AddTimer(throwTime, self._SetAttackStateAndSyncToOtherClients, 'throw')
        gameComp.AddTimer(animTotalTime, self._SetAttackStateAndSyncToOtherClients, 'end')

    def _SetAttackStateAndSyncToOtherClients(self, state):
        # 设置本地自定义变量
        self.mQueryVariableComp.Set(config.FireBottleAttackVarName, 1.0 if state in ['start', 'throw'] else 0.0)
        # 通知其他客户端
        self.mClientSystem.NotifyToServer('SyncCustomFireBottleStateEvent', {'state': state, 'playerId': self.mPlayerId})

    # endregion
  • 我们监听了 OnCarriedNewItemChangedClientEvent 事件,会在切换到该物品时显示按钮,也会在切换到其他物品时隐藏按钮;
  • 另外我们在响应点击之后,立马就隐藏了界面,防止多次点击;

# 处理投掷事件

我们已经在界面文件中,在响应点击之后发送了事件给服务端,所以服务端只需要监听事件做出响应就可以:

# -*- coding: utf-8 -*-
import mod.server.extraServerApi as serverApi

CompFactory = serverApi.GetEngineCompFactory()
gameComp = CompFactory.CreateGame(serverApi.GetLevelId())


class TutorialServerSystem(serverApi.GetServerSystemCls()):
    def __init__(self, namespace, name):
        super(TutorialServerSystem, self).__init__(namespace, name)
        self.ListenEvent()

    def ListenEvent(self):
        # 自定义事件
        self.ListenForEvent('tutorialMod', 'tutorialClientSystem', "SyncCustomFireBottleStateEvent", self,
                            self.OnSyncCustomFireBottleStateEvent)

    def OnSyncCustomFireBottleStateEvent(self, args):
        playerId = args['playerId']
        state = args['state']
        if state == 'throw':
            self._ThrowFireBottle(playerId)
        else:
            relevantPlayers = CompFactory.CreatePlayer(playerId).GetRelevantPlayer([playerId])
            self.NotifyToMultiClients(relevantPlayers, 'SyncCustomFireBottleStateEvent', {
                'playerId': playerId,
                'value'   : 1.0 if state == 'start' else 0.0
            })
	
    # 向前投掷燃烧瓶
    def _ThrowFireBottle(self, playerId):
        rot = CompFactory.CreateRot(playerId).GetRot()
        # 默认是向上抬一点
        rot = (rot[0] - 30, rot[1])
        param = {
            'power'    : 1.2,
            'gravity'  : 0.125,
            'direction': serverApi.GetDirFromRot(rot)
        }

        projectileComp = CompFactory.CreateProjectile(playerId)
        projectileComp.CreateProjectileEntity(playerId, 'tutorial:custom_fire_bottle_projectile', param)

        self._ReduceCarriedItemNum(playerId, 1)

    def _ReduceCarriedItemNum(self, playerId, reduceNum):
        itemComp = CompFactory.CreateItem(playerId)
        selectSlotId = itemComp.GetSelectSlotId()
        itemDict = itemComp.GetPlayerItem(serverApi.GetMinecraftEnum().ItemPosType.INVENTORY, selectSlotId)
        return itemComp.SetInvItemNum(selectSlotId, itemDict['count'] - reduceNum)

这里默认的投掷方向是玩家当前朝向向上偏移 30° 作为初始的燃烧瓶的速度方向。

# 进入游戏测试

完成上面的步骤,我们就可以进入游戏中愉快的测试了。

# 课后作业

本次课后作业,内容如下:

  • 制作一个可投掷出去的 3D 道具,需要有一个简单可交互的界面、完整的第一、第三人称动画。

成果展示

燃烧瓶制作

模型制作

动画制作

动画控制器

渲染器

抛射物的定义

注入相关资源

编写 UI 文件

处理投掷事件

进入游戏测试

课后作业