diff --git a/server/entity/firework.go b/server/entity/firework.go index c3dbb5b29..1b598f5a6 100644 --- a/server/entity/firework.go +++ b/server/entity/firework.go @@ -11,13 +11,15 @@ import ( // for creating decorative explosions, boosting when flying with elytra, and // loading into a crossbow as ammunition. func NewFirework(opts world.EntitySpawnOpts, firework item.Firework) *world.EntityHandle { - return NewFireworkAttached(opts, firework, nil, false) + return NewFireworkAttached(opts, firework, nil, 1.15, 0.04, false) } // NewFireworkAttached creates a firework entity with an owner that the firework // may be attached to. -func NewFireworkAttached(opts world.EntitySpawnOpts, firework item.Firework, owner world.Entity, attached bool) *world.EntityHandle { +func NewFireworkAttached(opts world.EntitySpawnOpts, firework item.Firework, owner world.Entity, sidewaysVelocityMultiplier, upwardsAcceleration float64, attached bool) *world.EntityHandle { conf := fireworkConf + conf.SidewaysVelocityMultiplier = sidewaysVelocityMultiplier + conf.UpwardsAcceleration = upwardsAcceleration conf.Firework = firework conf.ExistenceDuration = firework.RandomisedDuration() conf.Attached = attached @@ -27,10 +29,7 @@ func NewFireworkAttached(opts world.EntitySpawnOpts, firework item.Firework, own return opts.New(FireworkType, conf) } -var fireworkConf = FireworkBehaviourConfig{ - SidewaysVelocityMultiplier: 1.15, - UpwardsAcceleration: 0.04, -} +var fireworkConf = FireworkBehaviourConfig{} // FireworkType is a world.EntityType implementation for Firework. var FireworkType fireworkType diff --git a/server/entity/register.go b/server/entity/register.go index 0e7b92653..5085c415b 100644 --- a/server/entity/register.go +++ b/server/entity/register.go @@ -35,8 +35,8 @@ var conf = world.EntityRegistryConfig{ EnderPearl: NewEnderPearl, FallingBlock: NewFallingBlock, Lightning: NewLightning, - Firework: func(opts world.EntitySpawnOpts, firework world.Item, owner world.Entity, attached bool) *world.EntityHandle { - return NewFireworkAttached(opts, firework.(item.Firework), owner, attached) + Firework: func(opts world.EntitySpawnOpts, firework world.Item, owner world.Entity, sidewaysVelocityMultiplier, upwardsAcceleration float64, attached bool) *world.EntityHandle { + return NewFireworkAttached(opts, firework.(item.Firework), owner, sidewaysVelocityMultiplier, upwardsAcceleration, attached) }, Item: func(opts world.EntitySpawnOpts, it any) *world.EntityHandle { return NewItem(opts, it.(item.Stack)) diff --git a/server/item/crossbow.go b/server/item/crossbow.go new file mode 100644 index 000000000..7ceb508d0 --- /dev/null +++ b/server/item/crossbow.go @@ -0,0 +1,230 @@ +package item + +import ( + "time" + _ "unsafe" + + "github.com/df-mc/dragonfly/server/world" + "github.com/df-mc/dragonfly/server/world/sound" +) + +// Crossbow is a ranged weapon similar to a bow that uses arrows or fireworks as ammunition. +type Crossbow struct { + // Item is the item the crossbow is charged with. + Item Stack +} + +// Charge starts the charging process and checks if the charge duration meets the required duration. +func (c Crossbow) Charge(releaser Releaser, tx *world.Tx, ctx *UseContext, duration time.Duration) bool { + if !c.Item.Empty() { + return false + } + + creative := releaser.GameMode().CreativeInventory() + held, left := releaser.HeldItems() + + chargeDuration := time.Duration(1.25 * float64(time.Second)) + for _, enchant := range held.Enchantments() { + if q, ok := enchant.Type().(interface{ ChargeDuration(int) time.Duration }); ok { + chargeDuration = min(chargeDuration, q.ChargeDuration(enchant.Level())) + } + } + + if duration < chargeDuration { + return false + } + + var projectileItem Stack + if !left.Empty() { + _, isFirework := left.Item().(Firework) + _, isArrow := left.Item().(Arrow) + if isFirework || isArrow { + projectileItem = left + } + } + + if projectileItem.Empty() { + var ok bool + projectileItem, ok = ctx.FirstFunc(func(stack Stack) bool { + _, isArrow := stack.Item().(Arrow) + return isArrow + }) + + if !ok && !creative { + return false + } + + if projectileItem.Empty() { + projectileItem = NewStack(Arrow{}, 1) + } + } + + c.Item = projectileItem.Grow(-projectileItem.Count() + 1) + if !creative { + ctx.Consume(c.Item) + } + + crossbow := held.WithItem(c) + releaser.SetHeldItems(crossbow, left) + return true +} + +// ContinueCharge ... +func (c Crossbow) ContinueCharge(releaser Releaser, tx *world.Tx, ctx *UseContext, duration time.Duration) { + if !c.Item.Empty() { + return + } + + creative := releaser.GameMode().CreativeInventory() + held, left := releaser.HeldItems() + + chargeDuration, quickChargeLevel := time.Duration(1.25*float64(time.Second)), 0 + for _, enchant := range held.Enchantments() { + if q, ok := enchant.Type().(interface{ ChargeDuration(int) time.Duration }); ok { + chargeDuration = min(chargeDuration, q.ChargeDuration(enchant.Level())) + quickChargeLevel = enchant.Level() + } + } + + var projectileItem Stack + if !left.Empty() { + _, isFirework := left.Item().(Firework) + _, isArrow := left.Item().(Arrow) + if isFirework || isArrow { + projectileItem = left + } + } + + if projectileItem.Empty() { + var ok bool + projectileItem, ok = ctx.FirstFunc(func(stack Stack) bool { + _, isArrow := stack.Item().(Arrow) + return isArrow + }) + + if !ok && !creative { + return + } + + if projectileItem.Empty() { + projectileItem = NewStack(Arrow{}, 1) + } + } + + if projectileItem.Empty() { + return + } + + hasQuickCharge := quickChargeLevel > 0 + progress := float64(duration) / float64(chargeDuration) + if duration.Seconds() <= 0.1 { + tx.PlaySound(releaser.Position(), sound.Crossbow{Stage: sound.CrossbowStageLoadStart, QuickCharge: hasQuickCharge}) + } + + // Base reload time is 25 ticks; each Quick Charge level reduces by 5 ticks + multiplier := 25.0 / float64(25-(5*quickChargeLevel)) + + // Adjust ticks based on the multiplier + adjustedTicks := int(float64(duration.Milliseconds()) / (50 / multiplier)) + + // Play sound after every 16 ticks (adjusted by Quick Charge) + if adjustedTicks%16 == 0 { + tx.PlaySound(releaser.Position(), sound.Crossbow{Stage: sound.CrossbowStageMiddle, QuickCharge: hasQuickCharge}) + } + + if progress >= 1 && quickChargeLevel > 0 { + tx.PlaySound(releaser.Position(), sound.Crossbow{Stage: sound.CrossbowStageLoadEnd, QuickCharge: hasQuickCharge}) + } +} + +// ReleaseCharge checks if the item is fully charged and, if so, releases it. +func (c Crossbow) ReleaseCharge(releaser Releaser, tx *world.Tx, ctx *UseContext) bool { + if c.Item.Empty() { + return false + } + + creative := releaser.GameMode().CreativeInventory() + rot := releaser.Rotation().Neg() + dirVec := releaser.Rotation().Vec3().Normalize() + + if firework, isFirework := c.Item.Item().(Firework); isFirework { + createFirework := tx.World().EntityRegistry().Config().Firework + fireworkEntity := createFirework(world.EntitySpawnOpts{ + Position: torsoPosition(releaser), + Velocity: dirVec.Mul(0.1), + Rotation: rot, + }, firework, releaser, 1.15, 0, false) + tx.AddEntity(fireworkEntity) + ctx.DamageItem(3) + } else { + createArrow := tx.World().EntityRegistry().Config().Arrow + arrow := createArrow(world.EntitySpawnOpts{ + Position: torsoPosition(releaser), + Velocity: dirVec.Mul(5.15), + Rotation: rot, + }, 9, releaser, false, false, !creative, 0, c.Item.Item().(Arrow).Tip) + tx.AddEntity(arrow) + ctx.DamageItem(1) + } + + c.Item = Stack{} + held, left := releaser.HeldItems() + crossbow := held.WithItem(c) + releaser.SetHeldItems(crossbow, left) + tx.PlaySound(releaser.Position(), sound.Crossbow{Stage: sound.CrossbowStageShoot}) + return true +} + +// MaxCount always returns 1. +func (Crossbow) MaxCount() int { + return 1 +} + +// DurabilityInfo ... +func (Crossbow) DurabilityInfo() DurabilityInfo { + return DurabilityInfo{ + MaxDurability: 464, + BrokenItem: simpleItem(Stack{}), + } +} + +// FuelInfo ... +func (Crossbow) FuelInfo() FuelInfo { + return newFuelInfo(time.Second * 15) +} + +// EnchantmentValue ... +func (Crossbow) EnchantmentValue() int { + return 1 +} + +// EncodeItem ... +func (Crossbow) EncodeItem() (name string, meta int16) { + return "minecraft:crossbow", 0 +} + +// DecodeNBT ... +func (c Crossbow) DecodeNBT(data map[string]any) any { + c.Item = mapItem(data, "chargedItem") + return c +} + +// EncodeNBT ... +func (c Crossbow) EncodeNBT() map[string]any { + if !c.Item.Empty() { + return map[string]any{ + "chargedItem": writeItem(c.Item, true), + } + } + return nil +} + +// noinspection ALL +// +//go:linkname writeItem github.com/df-mc/dragonfly/server/internal/nbtconv.WriteItem +func writeItem(s Stack, disk bool) map[string]any + +// noinspection ALL +// +//go:linkname mapItem github.com/df-mc/dragonfly/server/internal/nbtconv.MapItem +func mapItem(x map[string]any, k string) Stack diff --git a/server/item/enchantment/quick_charge.go b/server/item/enchantment/quick_charge.go new file mode 100644 index 000000000..e9f305b63 --- /dev/null +++ b/server/item/enchantment/quick_charge.go @@ -0,0 +1,49 @@ +package enchantment + +import ( + "github.com/df-mc/dragonfly/server/item" + "github.com/df-mc/dragonfly/server/world" + "time" +) + +// QuickCharge is an enchantment for quickly reloading a crossbow. +var QuickCharge quickCharge + +type quickCharge struct{} + +// Name ... +func (quickCharge) Name() string { + return "Quick Charge" +} + +// MaxLevel ... +func (quickCharge) MaxLevel() int { + return 3 +} + +// Cost ... +func (quickCharge) Cost(level int) (int, int) { + minCost := 12 + (level-1)*20 + return minCost, 50 +} + +// Rarity ... +func (quickCharge) Rarity() item.EnchantmentRarity { + return item.EnchantmentRarityUncommon +} + +// ChargeDuration returns the charge duration. +func (quickCharge) ChargeDuration(level int) time.Duration { + return time.Duration((1.25 - 0.25*float64(level)) * float64(time.Second)) +} + +// CompatibleWithEnchantment ... +func (quickCharge) CompatibleWithEnchantment(item.EnchantmentType) bool { + return true +} + +// CompatibleWithItem ... +func (quickCharge) CompatibleWithItem(i world.Item) bool { + _, ok := i.(item.Crossbow) + return ok +} diff --git a/server/item/enchantment/register.go b/server/item/enchantment/register.go index 462bf7caf..71ceda3e9 100644 --- a/server/item/enchantment/register.go +++ b/server/item/enchantment/register.go @@ -38,7 +38,7 @@ func init() { // TODO: (32) Channeling. // TODO: (33) Multishot. // TODO: (34) Piercing. - // TODO: (35) Quick Charge. + item.RegisterEnchantment(35, QuickCharge) item.RegisterEnchantment(36, SoulSpeed) item.RegisterEnchantment(37, SwiftSneak) } diff --git a/server/item/firework.go b/server/item/firework.go index c8c9e5f94..0cd5f55db 100644 --- a/server/item/firework.go +++ b/server/item/firework.go @@ -31,7 +31,7 @@ func (f Firework) Use(tx *world.Tx, user User, ctx *UseContext) bool { tx.PlaySound(pos, sound.FireworkLaunch{}) create := tx.World().EntityRegistry().Config().Firework opts := world.EntitySpawnOpts{Position: pos, Rotation: user.Rotation()} - tx.AddEntity(create(opts, f, user, true)) + tx.AddEntity(create(opts, f, user, 1.15, 0.04, true)) ctx.SubtractFromCount(1) return true @@ -42,7 +42,7 @@ func (f Firework) UseOnBlock(pos cube.Pos, _ cube.Face, clickPos mgl64.Vec3, tx fpos := pos.Vec3().Add(clickPos) create := tx.World().EntityRegistry().Config().Firework opts := world.EntitySpawnOpts{Position: fpos, Rotation: cube.Rotation{rand.Float64() * 360, 90}} - tx.AddEntity(create(opts, f, user, false)) + tx.AddEntity(create(opts, f, user, 1.15, 0.04, false)) tx.PlaySound(fpos, sound.FireworkLaunch{}) ctx.SubtractFromCount(1) diff --git a/server/item/item.go b/server/item/item.go index bf3bb6248..8a2cb4587 100644 --- a/server/item/item.go +++ b/server/item/item.go @@ -155,6 +155,16 @@ type Releasable interface { Requirements() []Stack } +// Chargeable represents an item that can be charged. +type Chargeable interface { + // Charge is called when an item is being used. + Charge(releaser Releaser, tx *world.Tx, ctx *UseContext, duration time.Duration) bool + // ContinueCharge continues the charge. + ContinueCharge(releaser Releaser, tx *world.Tx, ctx *UseContext, duration time.Duration) + // ReleaseCharge is called when an item is being released. + ReleaseCharge(releaser Releaser, tx *world.Tx, ctx *UseContext) bool +} + // User represents an entity that is able to use an item in the world, typically entities such as players, // which interact with the world using an item. type User interface { @@ -217,6 +227,16 @@ func eyePosition(e world.Entity) mgl64.Vec3 { return pos } +// torsoPosition returns the position of the torso of the entity if the entity implements entity.Torsoed, or the +// actual position if it doesn't. +func torsoPosition(e world.Entity) mgl64.Vec3 { + pos := e.Position() + if torso, ok := e.(interface{ TorsoHeight() float64 }); ok { + pos = pos.Add(mgl64.Vec3{0, torso.TorsoHeight()}) + } + return pos +} + // Int32FromRGBA converts a color.RGBA into an int32. These int32s are present in things such as signs and dyed leather armour. func int32FromRGBA(x color.RGBA) int32 { if x.R == 0 && x.G == 0 && x.B == 0 { diff --git a/server/item/register.go b/server/item/register.go index 2a0742cc7..966a7a56a 100644 --- a/server/item/register.go +++ b/server/item/register.go @@ -40,6 +40,7 @@ func init() { world.RegisterItem(Compass{}) world.RegisterItem(Cookie{}) world.RegisterItem(CopperIngot{}) + world.RegisterItem(Crossbow{}) world.RegisterItem(Diamond{}) world.RegisterItem(DiscFragment{}) world.RegisterItem(DragonBreath{}) diff --git a/server/player/player.go b/server/player/player.go index 6ee407700..bd013eefb 100644 --- a/server/player/player.go +++ b/server/player/player.go @@ -1376,6 +1376,28 @@ func (p *Player) UseItem() { p.updateState() } + if chargeable, ok := it.(item.Chargeable); ok { + useCtx := p.useContext() + if !p.usingItem { + if !chargeable.ReleaseCharge(p, p.tx, useCtx) { + // If the item was not charged yet, start charging. + p.usingSince, p.usingItem = time.Now(), true + } + p.handleUseContext(useCtx) + p.updateState() + return + } + + // Stop charging and determine if the item is ready. + p.usingItem = false + dur := p.useDuration() + if chargeable.Charge(p, p.tx, useCtx, dur) { + p.session().SendChargeItemComplete() + } + p.handleUseContext(useCtx) + p.updateState() + } + switch usable := it.(type) { case item.Usable: useCtx := p.useContext() @@ -2346,8 +2368,8 @@ func (p *Player) Tick(tx *world.Tx, current int64) { } } + held, _ := p.HeldItems() if current%4 == 0 && p.usingItem { - held, _ := p.HeldItems() if _, ok := held.Item().(item.Consumable); ok { // Eating particles seem to happen roughly every 4 ticks. for _, v := range p.viewers() { @@ -2355,6 +2377,13 @@ func (p *Player) Tick(tx *world.Tx, current int64) { } } } + + if p.usingItem { + if c, ok := held.Item().(item.Chargeable); ok { + c.ContinueCharge(p, tx, p.useContext(), p.useDuration()) + } + } + for it, ti := range p.cooldowns { if time.Now().After(ti) { delete(p.cooldowns, it) @@ -2656,6 +2685,19 @@ func (p *Player) EyeHeight() float64 { } } +// TorsoHeight returns the torso height of the player: 1.52, 1.16 if the player is sneaking, or 0.42 if the player is +// swimming, gliding, or crawling. +func (p *Player) TorsoHeight() float64 { + switch { + case p.swimming || p.crawling || p.gliding: + return 0.42 + case p.sneaking: + return 1.16 + default: + return 1.52 + } +} + // PlaySound plays a world.Sound that only this Player can hear. Unlike World.PlaySound, it is not broadcast // to players around it. func (p *Player) PlaySound(sound world.Sound) { diff --git a/server/session/player.go b/server/session/player.go index 3f8e2d99b..18d8fc684 100644 --- a/server/session/player.go +++ b/server/session/player.go @@ -725,6 +725,14 @@ func (s *Session) SendExperience(level int, progress float64) { }) } +// SendChargeItemComplete sends a packet to indicate that the item charging process has been completed. +func (s *Session) SendChargeItemComplete() { + s.writePacket(&packet.ActorEvent{ + EntityRuntimeID: selfEntityRuntimeID, + EventType: packet.ActorEventFinishedChargingItem, + }) +} + // stackFromItem converts an item.Stack to its network ItemStack representation. func stackFromItem(it item.Stack) protocol.ItemStack { if it.Empty() { diff --git a/server/session/world.go b/server/session/world.go index b9f38a7eb..fecb5fa25 100644 --- a/server/session/world.go +++ b/server/session/world.go @@ -712,6 +712,26 @@ func (s *Session) playSound(pos mgl64.Vec3, t world.Sound, disableRelative bool) pk.SoundType = packet.SoundEventBucketEmptyLava case sound.BowShoot: pk.SoundType = packet.SoundEventBow + case sound.Crossbow: + switch so.Stage { + case sound.CrossbowStageLoadStart: + pk.SoundType = packet.SoundEventCrossbowLoadingStart + if so.QuickCharge { + pk.SoundType = packet.SoundEventCrossbowQuickChargeStart + } + case sound.CrossbowStageMiddle: + pk.SoundType = packet.SoundEventCrossbowLoadingMiddle + if so.QuickCharge { + pk.SoundType = packet.SoundEventCrossbowQuickChargeMiddle + } + case sound.CrossbowStageLoadEnd: + pk.SoundType = packet.SoundEventCrossbowLoadingEnd + if so.QuickCharge { + pk.SoundType = packet.SoundEventCrossbowQuickChargeEnd + } + case sound.CrossbowStageShoot: + pk.SoundType = packet.SoundEventCrossbowShoot + } case sound.ArrowHit: pk.SoundType = packet.SoundEventBowHit case sound.ItemThrow: diff --git a/server/world/entity.go b/server/world/entity.go index b75879b7e..7beaf125e 100644 --- a/server/world/entity.go +++ b/server/world/entity.go @@ -366,7 +366,7 @@ type EntityRegistryConfig struct { Arrow func(opts EntitySpawnOpts, damage float64, owner Entity, critical, disallowPickup, obtainArrowOnPickup bool, punchLevel int, tip any) *EntityHandle Egg func(opts EntitySpawnOpts, owner Entity) *EntityHandle EnderPearl func(opts EntitySpawnOpts, owner Entity) *EntityHandle - Firework func(opts EntitySpawnOpts, firework Item, owner Entity, attached bool) *EntityHandle + Firework func(opts EntitySpawnOpts, firework Item, owner Entity, sidewaysVelocityMultiplier, upwardsAcceleration float64, attached bool) *EntityHandle LingeringPotion func(opts EntitySpawnOpts, t any, owner Entity) *EntityHandle Snowball func(opts EntitySpawnOpts, owner Entity) *EntityHandle SplashPotion func(opts EntitySpawnOpts, t any, owner Entity) *EntityHandle diff --git a/server/world/sound/item.go b/server/world/sound/item.go index 72c6fe6a7..55d190b08 100644 --- a/server/world/sound/item.go +++ b/server/world/sound/item.go @@ -47,6 +47,27 @@ type BucketEmpty struct { // BowShoot is a sound played when a bow is shot. type BowShoot struct{ sound } +// Crossbow is a sound when a crossbow is being used. +type Crossbow struct { + // Stage is the stage of the crossbow. + Stage int + // QuickCharge returns if the item being used has quick charge enchantment. + QuickCharge bool + + sound +} + +const ( + // CrossbowStageLoadStart is the stage where crossbows start to load. + CrossbowStageLoadStart = iota + // CrossbowStageMiddle is the stage where crossbow is loading and stops loading. + CrossbowStageMiddle + // CrossbowStageLoadEnd is the stage where crossbow is finished loading. + CrossbowStageLoadEnd + // CrossbowStageShoot is the stage where a crossbow is shot. + CrossbowStageShoot +) + // ArrowHit is a sound played when an arrow hits ground. type ArrowHit struct{ sound }