From 9420ecc00ee584c0cbe8195b4c7db3aaa93caaaa Mon Sep 17 00:00:00 2001 From: HashimTheArab Date: Wed, 22 Jul 2026 21:02:42 -0400 Subject: [PATCH 1/3] server/entity: match Bedrock arrow damage --- server/entity/arrow.go | 23 ++++++++++++++++++++++- server/entity/projectile.go | 15 ++++++++++++--- server/entity/register.go | 1 + server/item/bow.go | 9 +++++---- server/item/crossbow.go | 2 +- server/world/entity.go | 2 ++ 6 files changed, 43 insertions(+), 9 deletions(-) diff --git a/server/entity/arrow.go b/server/entity/arrow.go index 23714f9474..134797bd9f 100644 --- a/server/entity/arrow.go +++ b/server/entity/arrow.go @@ -1,6 +1,8 @@ package entity import ( + "math/rand/v2" + "github.com/df-mc/dragonfly/server/block/cube" "github.com/df-mc/dragonfly/server/internal/nbtconv" "github.com/df-mc/dragonfly/server/item" @@ -41,10 +43,26 @@ var arrowConf = ProjectileBehaviourConfig{ Gravity: 0.05, Drag: 0.01, Damage: 2.0, + damageCalculator: calculateArrowDamage, Sound: sound.ArrowHit{}, SurviveBlockCollision: true, } +// calculateArrowDamage calculates arrow damage using Bedrock Edition's formula. Arrows +// add 97% of their velocity to their base damage. Critical arrows deal 9-10 +// damage before Power is applied. +func calculateArrowDamage(baseDamage, velocity float64, critical bool, powerLevel int) float64 { + damage := baseDamage + velocity*0.97 + if critical { + damage += rand.Float64()*damage/2 + rand.Float64()*2 + damage = min(10, max(9, damage)) + } + if powerLevel > 0 { + damage *= 1 + float64(powerLevel+1)*0.25 + } + return damage +} + // boolByte returns 1 if the bool passed is true, or 0 if it is false. func boolByte(b bool) uint8 { if b { @@ -70,6 +88,8 @@ func (arrowType) BBox(world.Entity) cube.BBox { func (arrowType) DecodeNBT(m map[string]any, data *world.EntityData) { conf := arrowConf conf.Damage = float64(nbtconv.Float32(m, "Damage")) + conf.powerLevel = int(nbtconv.Uint8(m, "enchantPower")) + conf.Critical = nbtconv.Bool(m, "crit") conf.Potion = potion.From(nbtconv.Int32(m, "auxValue") - 1) conf.DisablePickup = !nbtconv.Bool(m, "player") if !nbtconv.Bool(m, "isCreative") { @@ -85,12 +105,13 @@ func (arrowType) EncodeNBT(data *world.EntityData) map[string]any { b := data.Data.(*ProjectileBehaviour) m := map[string]any{ "Damage": float32(b.conf.Damage), + "crit": boolByte(b.conf.Critical), "enchantPunch": byte(b.conf.KnockBackForceAddend / enchantment.Punch.KnockBackMultiplier()), "auxValue": int32(b.conf.Potion.Uint8() + 1), "player": boolByte(!b.conf.DisablePickup), "isCreative": boolByte(b.conf.PickupItem.Empty()), } - // TODO: Save critical flag if Minecraft ever saves it? + m["enchantPower"] = byte(b.conf.powerLevel) if b.collided { m["StuckToBlockPos"] = nbtconv.PosToInt32Slice(b.collisionPos) } diff --git a/server/entity/projectile.go b/server/entity/projectile.go index 250229957b..4f7243c8b7 100644 --- a/server/entity/projectile.go +++ b/server/entity/projectile.go @@ -32,6 +32,10 @@ type ProjectileBehaviourConfig struct { // back. The base damage is multiplied with the velocity of the projectile // to calculate the final damage of the projectile. Damage float64 + // damageCalculator calculates the final damage of the projectile. If nil, + // Damage is multiplied by the projectile's velocity. + damageCalculator func(baseDamage, velocity float64, critical bool, powerLevel int) float64 + powerLevel int // Potion is the potion effect that is applied to an entity when the // projectile hits it. Potion potion.Potion @@ -294,9 +298,14 @@ func (lt *ProjectileBehaviour) hitBlockSurviving(e *Ent, r trace.BlockResult, m func (lt *ProjectileBehaviour) hitEntity(l Living, e *Ent, vel mgl64.Vec3) { owner, _ := lt.conf.Owner.Entity(e.tx) src := ProjectileDamageSource{Projectile: e, Owner: owner} - dmg := math.Ceil(lt.conf.Damage * vel.Len()) - if lt.conf.Critical { - dmg += rand.Float64() * dmg / 2 + var dmg float64 + if lt.conf.damageCalculator == nil { + dmg = math.Ceil(lt.conf.Damage * vel.Len()) + if lt.conf.Critical { + dmg += rand.Float64() * dmg / 2 + } + } else { + dmg = lt.conf.damageCalculator(lt.conf.Damage, vel.Len(), lt.conf.Critical, lt.conf.powerLevel) } // TODO: Piercing arrows should bypass shield blocking when shields are implemented. if _, vulnerable := l.Hurt(dmg, src); vulnerable { diff --git a/server/entity/register.go b/server/entity/register.go index 7155ec9864..2599fde780 100644 --- a/server/entity/register.go +++ b/server/entity/register.go @@ -51,6 +51,7 @@ var conf = world.EntityRegistryConfig{ tip := arrow.Tip.(potion.Potion) conf := arrowConf conf.Damage, conf.Potion, conf.Owner = arrow.Damage, tip, arrow.Owner.H() + conf.powerLevel = arrow.PowerLevel conf.KnockBackForceAddend = float64(arrow.PunchLevel) * enchantment.Punch.KnockBackMultiplier() conf.DisablePickup = arrow.DisablePickup if arrow.ObtainArrowOnPickup { diff --git a/server/item/bow.go b/server/item/bow.go index 30ad644a62..41dcdf080a 100644 --- a/server/item/bow.go +++ b/server/item/bow.go @@ -61,7 +61,7 @@ func (Bow) Release(releaser Releaser, tx *world.Tx, ctx *UseContext, duration ti } held, _ := releaser.HeldItems() - damage, punchLevel, burnDuration, consume := 2.0, 0, time.Duration(0), !creative + powerLevel, punchLevel, burnDuration, consume := 0, 0, time.Duration(0), !creative for _, enchant := range held.Enchantments() { if f, ok := enchant.Type().(interface{ BurnDuration() time.Duration }); ok { burnDuration = f.BurnDuration() @@ -69,8 +69,8 @@ func (Bow) Release(releaser Releaser, tx *world.Tx, ctx *UseContext, duration ti if _, ok := enchant.Type().(interface{ KnockBackMultiplier() float64 }); ok { punchLevel = enchant.Level() } - if p, ok := enchant.Type().(interface{ PowerDamage(int) float64 }); ok { - damage += p.PowerDamage(enchant.Level()) + if _, ok := enchant.Type().(interface{ PowerDamage(int) float64 }); ok { + powerLevel = enchant.Level() } if i, ok := enchant.Type().(interface{ ConsumesArrows() bool }); ok && !i.ConsumesArrows() { consume = false @@ -84,7 +84,8 @@ func (Bow) Release(releaser Releaser, tx *world.Tx, ctx *UseContext, duration ti Rotation: releaser.Rotation().Neg(), } projectile := tx.AddEntity(create(opts, world.ArrowSpawnConfig{ - Damage: damage, + Damage: 1, + PowerLevel: powerLevel, Owner: releaser, Critical: force >= 1, ObtainArrowOnPickup: !creative && consume, diff --git a/server/item/crossbow.go b/server/item/crossbow.go index 1e173ab2c1..c8d00b8f37 100644 --- a/server/item/crossbow.go +++ b/server/item/crossbow.go @@ -133,7 +133,7 @@ func (c Crossbow) ReleaseCharge(releaser Releaser, tx *world.Tx, ctx *UseContext } arrowConf := world.ArrowSpawnConfig{ - Damage: 9, + Damage: 1, Owner: releaser, Critical: true, ObtainArrowOnPickup: !creative, diff --git a/server/world/entity.go b/server/world/entity.go index c150180baa..2ba09f8f65 100644 --- a/server/world/entity.go +++ b/server/world/entity.go @@ -508,6 +508,8 @@ type EntityRegistryConfig struct { type ArrowSpawnConfig struct { // Damage specifies the base damage dealt by the arrow. Damage float64 + // PowerLevel specifies the level of the Power enchantment applied to the arrow. + PowerLevel int // Owner is the entity that fired the arrow. Owner Entity // Critical specifies if the arrow should deal critical damage. From b661a57cc1c256c583407bc66884013daf14c70b Mon Sep 17 00:00:00 2001 From: HashimTheArab Date: Sat, 1 Aug 2026 22:10:35 -0400 Subject: [PATCH 2/3] server/item: fix crossbow arrow velocity --- server/item/crossbow.go | 2 +- server/item/crossbow_test.go | 82 ++++++++++++++++++++++++++++++++++++ 2 files changed, 83 insertions(+), 1 deletion(-) create mode 100644 server/item/crossbow_test.go diff --git a/server/item/crossbow.go b/server/item/crossbow.go index c8d00b8f37..6257d078ac 100644 --- a/server/item/crossbow.go +++ b/server/item/crossbow.go @@ -179,7 +179,7 @@ func (c Crossbow) shoot(releaser Releaser, tx *world.Tx, offsetAngle float64, ar arrowConf.Tip = c.Item.Item().(Arrow).Tip arrow := createArrow(world.EntitySpawnOpts{ Position: torsoPosition(releaser), - Velocity: dirVec.Mul(5.15), + Velocity: dirVec.Mul(5), Rotation: rot.Neg(), }, arrowConf) tx.AddEntity(arrow) diff --git a/server/item/crossbow_test.go b/server/item/crossbow_test.go new file mode 100644 index 0000000000..66729ce1c2 --- /dev/null +++ b/server/item/crossbow_test.go @@ -0,0 +1,82 @@ +package item_test + +import ( + "testing" + + "github.com/df-mc/dragonfly/server/block/cube" + _ "github.com/df-mc/dragonfly/server/internal/nbtconv" + "github.com/df-mc/dragonfly/server/item" + "github.com/df-mc/dragonfly/server/world" + "github.com/go-gl/mathgl/mgl64" +) + +func TestCrossbowArrowVelocityMatchesBedrock(t *testing.T) { + var velocity mgl64.Vec3 + registry := world.EntityRegistryConfig{ + Arrow: func(opts world.EntitySpawnOpts, _ world.ArrowSpawnConfig) *world.EntityHandle { + velocity = opts.Velocity + return opts.New(crossbowTestEntityType{}, crossbowTestEntityConfig{}) + }, + }.New([]world.EntityType{crossbowTestEntityType{}}) + w := world.Config{Synchronous: true, Entities: registry}.New() + defer w.Close() + + crossbow := item.Crossbow{Item: item.NewStack(item.Arrow{}, 1)} + releaser := crossbowTestReleaser{ + rotation: cube.Rotation{90, 0}, + held: item.NewStack(crossbow, 1), + } + w.Do(func(tx *world.Tx) { + if !crossbow.ReleaseCharge(&releaser, tx, &item.UseContext{}) { + t.Fatal("expected charged crossbow to fire") + } + }) + + want := releaser.Rotation().Vec3().Mul(5) + if !velocity.ApproxEqual(want) { + t.Fatalf("expected Bedrock crossbow arrow velocity %v, got %v", want, velocity) + } +} + +type crossbowTestReleaser struct { + rotation cube.Rotation + held item.Stack +} + +func (*crossbowTestReleaser) Close() error { return nil } +func (*crossbowTestReleaser) H() *world.EntityHandle { return nil } +func (*crossbowTestReleaser) Position() mgl64.Vec3 { return mgl64.Vec3{} } +func (r *crossbowTestReleaser) Rotation() cube.Rotation { return r.rotation } +func (r *crossbowTestReleaser) HeldItems() (item.Stack, item.Stack) { return r.held, item.Stack{} } +func (r *crossbowTestReleaser) SetHeldItems(main, _ item.Stack) { r.held = main } +func (*crossbowTestReleaser) UsingItem() bool { return false } +func (*crossbowTestReleaser) ReleaseItem() {} +func (*crossbowTestReleaser) UseItem() {} +func (*crossbowTestReleaser) GameMode() world.GameMode { return world.GameModeSurvival } +func (*crossbowTestReleaser) PlaySound(world.Sound) {} + +type crossbowTestEntityConfig struct{} + +func (crossbowTestEntityConfig) Apply(*world.EntityData) {} + +type crossbowTestEntityType struct{} + +func (crossbowTestEntityType) Open(_ *world.Tx, handle *world.EntityHandle, data *world.EntityData) world.Entity { + return crossbowTestEntity{handle: handle, data: data} +} +func (crossbowTestEntityType) EncodeEntity() string { return "test:crossbow_arrow" } +func (crossbowTestEntityType) BBox(world.Entity) cube.BBox { + return cube.Box(-0.125, -0.125, -0.125, 0.125, 0.125, 0.125) +} +func (crossbowTestEntityType) DecodeNBT(map[string]any, *world.EntityData) {} +func (crossbowTestEntityType) EncodeNBT(*world.EntityData) map[string]any { return nil } + +type crossbowTestEntity struct { + handle *world.EntityHandle + data *world.EntityData +} + +func (crossbowTestEntity) Close() error { return nil } +func (e crossbowTestEntity) H() *world.EntityHandle { return e.handle } +func (e crossbowTestEntity) Position() mgl64.Vec3 { return e.data.Pos } +func (e crossbowTestEntity) Rotation() cube.Rotation { return e.data.Rot } From 61abb74d3559f4e517af58fb39eb2923a7d89361 Mon Sep 17 00:00:00 2001 From: HashimTheArab Date: Sat, 1 Aug 2026 22:23:52 -0400 Subject: [PATCH 3/3] server/item: match Bedrock crossbow velocity --- server/item/crossbow.go | 2 +- server/item/crossbow_test.go | 82 ------------------------------------ 2 files changed, 1 insertion(+), 83 deletions(-) delete mode 100644 server/item/crossbow_test.go diff --git a/server/item/crossbow.go b/server/item/crossbow.go index 6257d078ac..fef3367e14 100644 --- a/server/item/crossbow.go +++ b/server/item/crossbow.go @@ -179,7 +179,7 @@ func (c Crossbow) shoot(releaser Releaser, tx *world.Tx, offsetAngle float64, ar arrowConf.Tip = c.Item.Item().(Arrow).Tip arrow := createArrow(world.EntitySpawnOpts{ Position: torsoPosition(releaser), - Velocity: dirVec.Mul(5), + Velocity: dirVec.Mul(3.15), Rotation: rot.Neg(), }, arrowConf) tx.AddEntity(arrow) diff --git a/server/item/crossbow_test.go b/server/item/crossbow_test.go deleted file mode 100644 index 66729ce1c2..0000000000 --- a/server/item/crossbow_test.go +++ /dev/null @@ -1,82 +0,0 @@ -package item_test - -import ( - "testing" - - "github.com/df-mc/dragonfly/server/block/cube" - _ "github.com/df-mc/dragonfly/server/internal/nbtconv" - "github.com/df-mc/dragonfly/server/item" - "github.com/df-mc/dragonfly/server/world" - "github.com/go-gl/mathgl/mgl64" -) - -func TestCrossbowArrowVelocityMatchesBedrock(t *testing.T) { - var velocity mgl64.Vec3 - registry := world.EntityRegistryConfig{ - Arrow: func(opts world.EntitySpawnOpts, _ world.ArrowSpawnConfig) *world.EntityHandle { - velocity = opts.Velocity - return opts.New(crossbowTestEntityType{}, crossbowTestEntityConfig{}) - }, - }.New([]world.EntityType{crossbowTestEntityType{}}) - w := world.Config{Synchronous: true, Entities: registry}.New() - defer w.Close() - - crossbow := item.Crossbow{Item: item.NewStack(item.Arrow{}, 1)} - releaser := crossbowTestReleaser{ - rotation: cube.Rotation{90, 0}, - held: item.NewStack(crossbow, 1), - } - w.Do(func(tx *world.Tx) { - if !crossbow.ReleaseCharge(&releaser, tx, &item.UseContext{}) { - t.Fatal("expected charged crossbow to fire") - } - }) - - want := releaser.Rotation().Vec3().Mul(5) - if !velocity.ApproxEqual(want) { - t.Fatalf("expected Bedrock crossbow arrow velocity %v, got %v", want, velocity) - } -} - -type crossbowTestReleaser struct { - rotation cube.Rotation - held item.Stack -} - -func (*crossbowTestReleaser) Close() error { return nil } -func (*crossbowTestReleaser) H() *world.EntityHandle { return nil } -func (*crossbowTestReleaser) Position() mgl64.Vec3 { return mgl64.Vec3{} } -func (r *crossbowTestReleaser) Rotation() cube.Rotation { return r.rotation } -func (r *crossbowTestReleaser) HeldItems() (item.Stack, item.Stack) { return r.held, item.Stack{} } -func (r *crossbowTestReleaser) SetHeldItems(main, _ item.Stack) { r.held = main } -func (*crossbowTestReleaser) UsingItem() bool { return false } -func (*crossbowTestReleaser) ReleaseItem() {} -func (*crossbowTestReleaser) UseItem() {} -func (*crossbowTestReleaser) GameMode() world.GameMode { return world.GameModeSurvival } -func (*crossbowTestReleaser) PlaySound(world.Sound) {} - -type crossbowTestEntityConfig struct{} - -func (crossbowTestEntityConfig) Apply(*world.EntityData) {} - -type crossbowTestEntityType struct{} - -func (crossbowTestEntityType) Open(_ *world.Tx, handle *world.EntityHandle, data *world.EntityData) world.Entity { - return crossbowTestEntity{handle: handle, data: data} -} -func (crossbowTestEntityType) EncodeEntity() string { return "test:crossbow_arrow" } -func (crossbowTestEntityType) BBox(world.Entity) cube.BBox { - return cube.Box(-0.125, -0.125, -0.125, 0.125, 0.125, 0.125) -} -func (crossbowTestEntityType) DecodeNBT(map[string]any, *world.EntityData) {} -func (crossbowTestEntityType) EncodeNBT(*world.EntityData) map[string]any { return nil } - -type crossbowTestEntity struct { - handle *world.EntityHandle - data *world.EntityData -} - -func (crossbowTestEntity) Close() error { return nil } -func (e crossbowTestEntity) H() *world.EntityHandle { return e.handle } -func (e crossbowTestEntity) Position() mgl64.Vec3 { return e.data.Pos } -func (e crossbowTestEntity) Rotation() cube.Rotation { return e.data.Rot }