手把手使用Minestom带你解剖Minecraft的击退机制
——实现精准可控的PVP击退效果
第一章 关于一个简单的击退原理介绍以及本篇文章的概要
读前须知
- 该教程相对于其他的击退,或许(此处表示也许)更加通俗易懂(仅仅对于我而言)
- 初中生来了也能懂,但是你如果初中没有学习向量的话可以稍微了解一点,稍微一点就会
- 我代码写的很烂,别喷,能看懂就行,这里主要是计算
- 该教程也是看着MinestomPVP总结出来的
- 此处不包含任何MoJang代码,依赖与Minestom
- 教程很长,大多数都是问题的解答,有实力的可以只看前两章,后两章只是解答问题
- 本文章使用了AI帮我把我的话我的代码总结成md文档,本人表达能力有限。不得不说我觉得AI最好用的功能就是写wiki了,其他真的一般般
- 测试环境:
- 测试核心:Minestom
- 核心版本:9803f2bfe3
- 游戏版本:1.21.3
核心概念
在Minecraft中,击退(Knockback,简称KB)的本质是 通过方向向量和速度合成实现的物理效果 要实现优秀的PVP击退,需掌握以下核心要素:
- 击退方向: 由攻击者的面朝角度(Yaw)决定
- 击退力度: 由武器属性和状态效果调节
- 速度合成: 结合目标当前速度和击退力
零、代码展示
0.1 takeKnockback(对目标执行一个击退)
@JvmStatic
fun executeKnockback(
target: LivingEntity,
//source也许是一个空的
source: Entity?,
strength: Float,
dx: Double,
dz: Double,
type: Type,
attacker: Entity,
verticalLimit: Double = 0.4
): Boolean {
val event = EntityKnockbackEvent(target, source ?: attacker, type ,strength, verticalLimit).apply {
EventDispatcher.call(this)
}
if (event.isCancelled) return false
takeKnockback(target, event.strength, dx, dz, event.verticalLimit)
return true
}
/*
*空中连击为什么难?因为limit参数卡死了上升速度!
*在20tick服务器,Y轴速度最大=0.4×20=8格/秒——
*这就是你无法把对手打成卫星的根本原因!
*
* */
@JvmStatic
fun takeKnockback(entity: LivingEntity, str: Float, x: Double, z: Double, limit: Double = 0.4) {
var strength = str
if (strength > 0.0f) {
strength *= ServerFlag.SERVER_TICKS_PER_SECOND.toFloat()
//这是做了个归一化,如果不做归一化的话,可能会使击退数据出现异常,增加或降低
val velocityModifier = Vec(x, z).normalize().mul(strength.toDouble())
//在20tick服务器,Y轴速度最大=limit×20=8格/秒——
val verticalLimit = limit * ServerFlag.SERVER_TICKS_PER_SECOND.toDouble()
entity.velocity = Vec(
entity.velocity.x() / 2.0 - velocityModifier.x(), if
(entity.isOnGround) min(verticalLimit, entity.velocity.y() / 2.0 + strength.toDouble())
else entity.velocity.y(), entity.velocity.z() / 2.0 - velocityModifier.z()
)
}
}
外部参数名称 |
类型 |
默认值 |
作用域 |
功能描述 |
entity: LivingEntity |
一只富有活力实体 |
- |
函数入参 |
被施加击退效果的目标实体(玩家/生物) |
str: Float |
浮点数 |
- |
函数入参 |
基础击退强度(受武器、附魔、药水等影响) |
x: Double |
双精度浮点 |
- |
函数入参 |
击退方向的 X 轴分量(东西方向) |
z: Double |
双精度浮点 |
- |
函数入参 |
击退方向的 Z 轴分量(南北方向) |
limit: Double |
双精度浮点 |
0.4 |
函数入参 |
垂直速度上限(格/秒) |
内部变量名称 |
类型 |
默认值 |
作用域 |
功能描述 |
strength |
浮点数 |
- |
函数内部变量 |
经过 Tick 率换算后的实际击退强度 |
velocityModifier |
Vec 向量 |
- |
函数内部变量 |
归一化后的击退方向向量 × 强度 |
verticalLimit |
双精度浮点 |
- |
函数内部变量 |
根据服务器 Tick 率换算的垂直速度上限 |
0.2 runKnockback(执行一个简单的击退)
private const val DEFAULT_KNOCKBACK_FACTOR = 0.5f
private const val DEFAULT_VERTICAL_LIMIT = 0.4
//这是Attack的击退!具体什么是attack击退你看第二章就知道了
override fun processKnockback(
attacker: Entity,
target: LivingEntity,
knockbackStrength: Int
): Boolean {
if (knockbackStrength <= 0) return false
return with(attacker.position) {
val horizontalStrength = knockbackStrength * DEFAULT_KNOCKBACK_FACTOR
val (dx, dz) = calculateAttackDirectionComponents(yaw)
executeKnockback(
target = target,
source = attacker,
strength = horizontalStrength, dx = dx, dz = dz, type = Type.ATTACK, attacker = attacker, DEFAULT_VERTICAL_LIMIT
)
.also {
success -> if (success && attacker is GamePlayer) attacker.afterMoveAttack()
}
}
}
private fun calculateAttackDirectionComponents(yaw: Float): Pair<Double, Double> {
val radianYaw = Math.toRadians(yaw.toDouble())
return sin(radianYaw) to -cos(radianYaw)
}
0.2.1
这是Attack的击退的代码!具体什么是attack击退你看第二章就知道了,最好别跳着看,你可以先不知道,先往下看,这代码只是助于你理解的,所以叫attack击退
一、坐标系与基础参数
1.1 世界坐标系
- X轴:东(+) ↔ 西(-)
- Z轴:南(+) ↔ 北(-)
- Y轴:垂直方向(上+,下-)
1.2 面朝角度(Yaw)
- 0°:正南(-Z方向)
- 90°:西(-X方向)
- 180°:正北(+Z方向)
- 270°:东(+X方向)
二、击退方向计算
2.1 基础公式
击退方向由攻击者的Yaw角度通过三角函数计算:
//将角度转为弧度制
val radianYaw = Math.toRadians(yaw.toDouble())
//计算生成一个Pair<Double,Doblue>的一对数值
return sin(radianYaw) to -cos(radianYaw)
关于2.1
你或许会有的疑问:
为什么dx不带负号而dz带了呢?:
我们前面说过面朝的角度(Yaw)与方向的关系我们可以知道Yaw角度与方向的关系:
Z轴和南北有关
X轴与东西有关
第一步:理解 Minecraft 的角度系统
这与数学中的标准角度系统(0° 为东,逆时针旋转)不同,Minecraft 的 yaw 是 以正南为起点顺时针旋转
可见我们需要调整三角函数的计算方式
第二步:击退方向的反向逻辑
当攻击者挥剑时,击退方向与攻击者的面朝方向相反(类似“向后推”)例如:
攻击者面朝 正南(+Z) → 击退方向是 正北(-Z)
攻击者面朝 东(+X) → 击退方向是 西(-X)
可见为了反向,代码中需要将面朝方向的分量取反
第三步:X 和 Z 分量的推导
1. X 轴(东西方向)的分量:x = sin(yaw)
在MC中,sin(θ) 对应 东西方向的分量
当玩家面朝 东(yaw=270°) 时:sin(270°) = -1 → 击退方向为 西(-X)
当玩家面朝 西(yaw=90°) 时:sin(90°) = 1 → 击退方向为 东(+X)
2. Z轴(南北方向)的分量:z = -cos(yaw)
cos(yaw) 原本对应 南北方向的分量,但需要反向
当玩家面朝 南(yaw=0°) 时:cos(0°) = 1 → -cos(0°) = -1 → 击退方向为 北(-Z)
当玩家面朝 北(yaw=180°) 时:cos(180°) = -1 → -cos(180°) = 1 → 击退方向为 南(+Z)
第四步:举例子
例1:攻击者面朝正南(yaw=0)
x = sin(0°) = 0 → 无东西方向分量
z = -cos(0°) = -1 → 击退方向为 北(-Z)
✅ 符合预期(面朝南,击退向北)
例2:攻击者面朝东(yaw=270°)
x = sin(270°) = -1 → 击退方向为 西(-X)
z = -cos(270°) = 0 → 无南北方向分量
✅ 符合预期(面朝东,击退向西)
例3:攻击者面朝西北(yaw=135°)
x = sin(135°) ≈ 0.707 → 击退方向为 东南(+X)
z = -cos(135°) ≈ 0.707 → 击退方向为 东南(+Z)
✅ 合成为 东南方向,与面朝西北相反
你还可以这么想,通过诱导公式可以得出以下公式
sin(-α) = -sinα
cos(-α) = cosα
这组诱导公式得到的sin都是相反的,但是cos不是,想要得到相反的直接加负号不就好了
2.2 方向验证表
面朝方向 |
Yaw |
dx |
dz |
击退方向 |
正南 |
0° |
0.0 |
-1.0 |
正北 |
正东 |
270° |
-1.0 |
0.0 |
正西 |
东北 |
45° |
0.707 |
0.707 |
西南 |
三、归一化处理
3.1 问题描述
原始方向向量的长度不固定(如东北方向长度为√2≈1.414),直接使用会导致:
3.2 解决方案
将方向向量转换为单位向量(长度=1):
原代码(Kotlin):
val velocityModifier = Vec(dx, dz).normalize().mul(strength.toDouble())
实际表达的意思(Java):
// 计算向量长度
double length = Math.sqrt(dx * dx + dz * dz);
// 归一化
if (length > 0) {
dx /= length;
dz /= length;
}
3.3 效果对比
原始向量 |
归一化结果 |
(3, 4) |
(0.6, 0.8) |
(1, 1) |
(0.707, 0.707) |
四、速度合成算法
4.1 计算公式
原代码:
// 水平速度
val newVelX = entity.velocity.x() / 2.0 - velocityModifier.x()
val newVelZ = entity.velocity.z() / 2.0 - velocityModifier.z()
// 垂直速度
val newVelY = if (entity.isOnGround) min(verticalLimit, entity.velocity.y() / 2.0 + strength.toDouble()) else entity.velocity.y()
entity.velocity = Vec(newVelX, newVelY, newVelZ)
4.2 参数说明
- entity.velocity:目标当前速度矢量
- velocityModifier:处理后的速度矢量,实际上就是原速度矢量*strength
- /2.0:模拟惯性衰减
五、实现优秀PVP击退的配置指南
5.1 参数推荐值
战斗风格 |
strength |
适用场景 |
竞技精准 |
0.6~0.8 |
1v1决斗 |
快速连击 |
0.4~0.6 |
连招Combo |
爆发控制 |
1.0~1.2 |
群体PVP |
5.2 进阶优化技巧
-
地面限制增强
// 增强地面单位的垂直击退
val newVelY = if (entity.isOnGround) min(verticalLimit + 0.2, entity.velocity.y() / 2.0 + strength.toDouble() * 1.2) else entity.velocity.y()
-
空中击退补偿
// 对空中单位施加20%额外水平击退
if (!entity.isOnGround) entity.velocity = Vec(newVelX * 1.2, newVelY, newVelZ * 1.2)
-
方向随机扰动(防预判,同时这也会为将要讲的DamageKnockback埋下伏笔)
// 添加±5°随机偏移
val randomOffset = ThreadLocalRandom.current().nextDouble(-5.0, 5.0)
//这里的yaw是processKnockback里的那个calculateAttackDirectionComponents(yaw)里面的这个yaw
val adjustedYaw = yaw + randomOffset
//在processKnockback里的calculateAttackDirectionComponents(yaw)改成calculateAttackDirectionComponents(adjustedYaw)
六、调试与验证方法
6.1 调试工具
- F3调试界面:实时查看实体坐标和速度
- Replay Mod:录制并回放击退轨迹
- 自定义ActionBar:显示方向向量和力度数值
6.2 验证流程
- 面朝正南攻击,确认目标向北移动(Z坐标递减)
- 使用45°方向攻击,验证击退距离 = strength × √2
- 连续攻击空中目标,检查垂直速度是否符合预期
七、常见问题解决方案
问题现象 |
排查重点 |
解决方案 |
击退方向相反 |
检查dz是否使用-cos(yaw) |
修正符号 |
斜向击退距离异常 |
确认是否执行归一化 |
添加归一化处理 |
空中单位无击退效果 |
检查onGround判断逻辑 |
移除不必要的条件限制 |
连击时速度指数增长 |
验证速度衰减是否使用/2.0 |
检查速度合成公式 |
第二章 深度解构Damage击退与Attack击退的协同机制——精确控制双重击退的叠加效应
提前预判你的疑问
啥是Damage击退啊啥是Attack击退啊
Damage击退就是每次攻击触发的
Attack击退就是蓄力接近满格+击退触发的额外击退,这额外击退是在damage击退的基础上追加的一个加速度向量罢了
哪Damage击退的代码跟Attack代码有什么区别?
实际上我上一章讲到了一点attack击退,就是
核心概念重塑
Damage击退与Attack击退的本质差异
特性 |
Damage击退 |
Attack击退(暴击击退) |
触发条件 |
所有攻击必定触发 |
疾跑+蓄力满格时触发 |
力度基数 |
基础值0.4f |
额外追加0.5f~1.0f |
方向基准 |
攻击者与目标位置关系 |
攻击者面朝方向 |
优先级 |
底层基础 |
上层叠加 |
代码全景
DamageKnockback.kt
//CONFIG
private const val MIN_DIRECTION_THRESHOLD = 1.0E-4
private const val RANDOM_DIRECTION_FACTOR = 0.01
private const val STRENGTH = 0.4f
object DamageKnockback :
IDamageKnockback {
// [DAMAGE击退核心逻辑]
override fun processKnockback(damage: Damage, target: LivingEntity, knockbackStrength: Int): Boolean {
val attacker = damage.attacker ?: return false
val (dx, dz) = attacker.calculateKnockbackDirectionTo(target.position)
target.sendHurtAnimation(dx, dz)
return executeKnockback(target, attacker = attacker, source = damage.source, type = Type.DAMAGE, strength = STRENGTH, dx = dx, dz = dz)
}
private fun Entity.calculateKnockbackDirectionTo(target: Pos): Pair<Double, Double> {
var dx = this.position.x - target.x
var dz = this.position.z - target.z
/*
* 当方向向量过小时生成随机方向
* 所以你在攻击的时候,与defender贴的很近,可能就会出现一种奇妙的现象,就是这个defender飞到你了你后面,或者左边或者右边
*
* */
if (dx * dx + dz * dz < MIN_DIRECTION_THRESHOLD) {
val random = ThreadLocalRandom.current()
dx = random.randomDirectionComponent()
dz = random.randomDirectionComponent()
}
return dx to dz
}
private fun ThreadLocalRandom.randomDirectionComponent() =
nextDouble(-1.0, 1.0) * RANDOM_DIRECTION_FACTOR
private fun LivingEntity.sendHurtAnimation(dx: Double, dz: Double) {
//这个东西完全自愿选择!删了的话也没问题,客户端自行处理的动画没有太大差异
if (this is Player) {
val hurtDirection = calculateHurtDirection(dx, dz)
sendPacket(HitAnimationPacket(entityId, hurtDirection))
}
}
private fun Player.calculateHurtDirection(dx: Double, dz: Double): Float {
val attackAngle = Math.toDegrees(atan2(dz, dx))
return (attackAngle - position.yaw).toFloat().normalizeAngle()
}
}
AttackKnockback.kt
object AttackKnockback : IAttackKnockback {
// [ATTACK击退应用层]
private const val DEFAULT_KNOCKBACK_FACTOR = 0.5f
private const val DEFAULT_VERTICAL_LIMIT = 0.4
override fun processKnockback(
attacker: Entity,
target: LivingEntity,
knockbackStrength: Int
): Boolean {
if (knockbackStrength <= 0) return false
return with(attacker.position) {
val horizontalStrength = knockbackStrength * DEFAULT_KNOCKBACK_FACTOR
val (dx, dz) = calculateAttackDirectionComponents(yaw)
executeKnockback(
target = target,
source = attacker,
strength = horizontalStrength, dx = dx, dz = dz, type = Type.ATTACK, attacker = attacker, DEFAULT_VERTICAL_LIMIT
)
.also {
//这afterMoveAttack()实际上是为了限制玩家的速度
success -> if (success && attacker is GamePlayer) attacker.
afterMoveAttack()
}
}
}
private fun calculateAttackDirectionComponents(yaw: Float): Pair<Double, Double> {
val radianYaw = Math.toRadians(yaw.toDouble())
return sin(radianYaw) to -cos(radianYaw)
}
}
一、方向计算机制
1.1 基础向量
从攻击者到目标的向量:
var dx = attacker.x - target.x
var dz = attacker.z - target.z
坐标差类型 |
物理意义 |
dx > 0 |
攻击者在目标东侧 |
dz < 0 |
攻击者在目标北侧 |
1.2 随机扰动触发条件
当向量长度平方小于阈值时触发随机方向:
if (dx*dx + dz*dz < MIN_DIRECTION_THRESHOLD) { // 默认1.0E-4
// 生成随机方向分量
}
典型场景:
- 攻击者与目标坐标完全重合
- 两者距离小于0.01格(√(1E-4) = 0.01)
举例子:
玩家A和玩家B在游戏中决斗,两人同时发动攻击,结果坐标几乎完全重合,距离小于0.01格。此时,游戏的随机扰动机制触发:
原本玩家A的剑应该击中玩家B,但因扰动,剑锋偏转,擦肩而过。玩家B抓住机会,反手一击,反败为胜。玩家A无奈道:“就差0.01格,代码不让我赢!”
二、随机方向生成
2.1 随机分量算法
fun ThreadLocalRandom.randomDirectionComponent() =
nextDouble(-1.0, 1.0) * RANDOM_DIRECTION_FACTOR // 默认0.01
生成范围:-0.01 ~ +0.01
2.2 随机方向意义
随机值 |
方向偏移 |
视觉效果 |
dx=0.01 |
向东轻微偏移 |
目标微微右弹 |
dz=-0.008 |
向南轻微偏移 |
目标微微前弹 |
三、受伤动画同步
3.1 动画包发送
target.sendPacket(HitAnimationPacket(entityId, hurtDirection))
关键参数:
- entityId: 受击实体的唯一标识
- hurtDirection: 伤害方向角度(单位:度)
3.2 角度计算流程
- 计算攻击向量与东轴的夹角:
val attackAngle = Math.toDegrees(atan2(dz, dx)) // 范围(-180°~180°)
- 转换为相对于玩家视角的角度:
(attackAngle - playerYaw).normalizeAngle()
3.3 我猜你还会有疑问
为啥你都对它造成伤害了,哪不有受伤动画了吗,为啥还要发一次呀!
我在一开始查资料的时候没有注意,我看了NMS源码,它也没有再发包,但是自己复刻出来发现缺点味,打起来不舒服,这不舒服来自窗口的抖动
哪么到底是为什么呢!?
欸欸欸这要解释的话太长了,我放第三章,额外章好好给你讲讲,感兴趣的可以直接看那里
四、配置参数详解
参数名称 |
默认值 |
作用域 |
调整建议 |
MIN_DIRECTION_THRESHOLD |
1.0E-4 |
方向计算 |
保持默认 |
RANDOM_DIRECTION_FACTOR |
0.01 |
随机扰动幅度 |
根据战斗风格调整(最好保持默认就行,你玩家挤在一起的机会很少) |
Type.DAMAGE.strength |
0.4f |
基础击退力度 |
参考PVP平衡需求(其他需求可以看第一章第五节) |
五、调试技巧
5.1 方向可视化
- 在攻击者与目标之间绘制粒子线
- 当触发随机方向时显示特殊标记
5.2 数据监控
// 调试输出示例
println("原始方向: dx=$dx, dz=$dz")
if (isRandomized) println("随机方向: $randDx, $randDz")
六、与Attack击退的对比
特性 |
Damage击退 |
Attack击退 |
方向基准 |
攻击者位置 |
攻击者面朝角度 |
垂直控制 |
无特殊处理 |
地面限制 |
适用场景 |
弹射物、环境伤害 |
近战攻击 |
方向随机性 |
近距离强制随机 |
可配置扰动 |
6.1 我猜你有疑问
-
为什么attack击退的计算与damage击退的计算有这么大的差异!?
一 Damage击退:位置差决定方向
1 空想
想象攻击者(A)和目标(B)在平面上:
A坐标:(x₁, z₁)
B坐标:(x₂, z₂)
击退方向 = B到A的反方向
2 计算
dx = A的x坐标 - B的x坐标
dz = A的z坐标 - B的z坐标
比如A在(5,5),B在(0,0),dx=5-0=5,dz=5-0=5
反向处理的话直接(-dx, -dz)即可
3. 为什么这样设计?
符合直觉:就像你推别人,对方会朝你相反方向后退
自动适应:无论从哪个方向攻击,方向自动计算
二、Attack击退:面朝角度决定方向(第一章有详细的计算过程和详解)
1. 角度系统速成(虽然已经说过无数遍了)
0°:面朝正南
90°:面朝西
180°:面朝正北
270°:面朝东
2. 计算步骤
把角度转为弧度:
弧度 = 角度 × (π/180)
比如90° → 1.5708弧度
用三角函数计算方向:
dx = sin(弧度)
dz = -cos(弧度)
三、为什么两种方式不同?
1. Damage击退的特点
适合远程攻击:箭、火球等
无需玩家控制:方向自动计算(方向其实就是那碰撞点决定的)
2. Attack击退的特点
适合近战操作:玩家可以主动控制方向
战术性强:可以通过调整面朝角度精确击退
四、实战验证方法
1. Damage击退测试
让朋友站在(0,0)
你站在(5,0)攻击
观察朋友被击退到(-5,0)左右的方向(一定不是-5,0,因为会有衰减的)
2. Attack击退测试
面朝正南(0°)攻击
朋友会被击退到正北方向
转身90°再攻击,观察方向变化
你现在应该能理解:
Damage击退像被球砸中,方向由碰撞点决定
Attack击退像用棍子推人,方向由你拿棍子姿势控制
第三章 精准控制客户端表现的关键设计——sendHurtAnimation
前情概要
这章开始往后都是问题篇章了,这章的问题在第二章3.3中提到
一、动画触发的本质区别
1.1 服务端与客户端的职责分离
触发源 |
动画类型 |
控制权归属 |
必要性 |
伤害计算 |
实体抖动+红屏 |
客户端 |
自动触发(不可靠) |
HitAnimationPacket |
击退方向动画 |
服务端 |
必须手动控制 |
- 客户端自动动画:当客户端收到伤害事件时自动播放基础受伤效果,但:
- ❌ 不包含精确的击退方向信息
- ❌ 不同客户端版本表现不一致
- ❌ 无法与自定义击退逻辑同步
二、HitAnimationPacket 的核心作用
2.1 数据包结构解析()
HitAnimationPacket(
int entityId, // 受击实体ID
float yaw // 伤害方向角度(单位:度)
)
2.2 关键功能实现
参数 |
客户端行为 |
entityId |
确定要播放动画的实体 |
direction |
控制以下行为:<br>1. 受伤实体头部转向<br>2. 客户端预计算击退 |
三、两组动画实验得出真相
3.1 实际表现对比
调用情况 |
客户端视觉效果 |
网络流量分析 |
不发送数据包 |
仅有轻微抖动,无方向性动画 |
减少1个数据包(约20字节) |
发送HitAnimationPacket |
完整击退方向动画+实体头部转向 |
增加关键方向信息同步 |
3.2 代码层面验证
在 LivingEntity#damage()
源码中:
// Minestom核心类
public void damage(@NotNull Damage damage) {
// 自动触发的客户端效果仅限于:
// 1. 生命值变化
// 2. 基础受伤音效
// 3. 无方向性的实体抖动
// 没有击退方向动画!
}
四、伟大的科学!
4.1 角度计算公式
fun calculateHurtDirection(dx: Double, dz: Double): Float {
val attackAngle = Math.toDegrees(atan2(dz, dx)) // [-180°, 180°]
val relativeAngle = attackAngle - position.yaw // 转换为玩家视角坐标系
return normalizeAngle(relativeAngle) // 标准化到[-180°, 180°]
}
4.2 同步必要性演示
假设场景:
- 攻击者坐标:(5, 0, 5)
- 受击者坐标:(0, 0, 0)
- 攻击者Yaw:45°
不发送数据包时:
客户端计算方向角度 = atan2(0,0) = 0° // 无效值
实际表现:随机方向抖动
发送数据包时:
服务端计算:
attackAngle = atan2(5,5) = 45°
relativeAngle = 45° - 45° = 0°
最终动画:正对攻击者的完美方向同步,抖动更加舒服了,使我达到高潮
5.2 流量消耗测试(理论)
战斗强度 |
每秒数据包增量 |
带宽消耗 |
单人PVP |
2-5 packets/s |
<1 KB/s |
50人团战 |
100 packets/s |
≈20 KB/s |
通过以上步骤的系统化实施,你将能够精确控制Minecraft的击退机制,打造出既符合物理直觉又具备竞技深度的PVP体验
如果还有问题请加入我们