Files

362 lines
13 KiB
Go
Raw Permalink Normal View History

2026-07-09 08:33:57 +08:00
package entity
import (
"iter"
"math"
"math/rand/v2"
"slices"
"time"
"github.com/df-mc/dragonfly/server/block"
"github.com/df-mc/dragonfly/server/block/cube"
"github.com/df-mc/dragonfly/server/block/cube/trace"
"github.com/df-mc/dragonfly/server/entity/effect"
"github.com/df-mc/dragonfly/server/item"
"github.com/df-mc/dragonfly/server/item/potion"
"github.com/df-mc/dragonfly/server/world"
"github.com/go-gl/mathgl/mgl64"
)
// ProjectileBehaviourConfig allows the configuration of projectiles. Calling
// ProjectileBehaviourConfig.New() creates a ProjectileBehaviour using these
// settings.
type ProjectileBehaviourConfig struct {
Owner *world.EntityHandle
// Gravity is the amount of Y velocity subtracted every tick.
Gravity float64
// Drag is used to reduce all axes of the velocity every tick. Velocity is
// multiplied with (1-Drag) every tick.
Drag float64
// Damage specifies the base damage dealt by the Projectile. If set to a
// negative number, entities hit are not hurt at all and are not knocked
// back. The base damage is multiplied with the velocity of the projectile
// to calculate the final damage of the projectile.
Damage float64
// Potion is the potion effect that is applied to an entity when the
// projectile hits it.
Potion potion.Potion
// KnockBackForceAddend is the additional horizontal velocity that is
// applied to an entity when it is hit by the projectile.
KnockBackForceAddend float64
// KnockBackHeightAddend is the additional vertical velocity that is applied
// to an entity when it is hit by the projectile.
KnockBackHeightAddend float64
// Particle is a particle that is spawned when the projectile hits a
// target, either a block or an entity. No particle is spawned if left nil.
Particle world.Particle
// ParticleCount is the amount of particles that should be spawned if
// Particle is not nil. ParticleCount will be set to 1 if Particle is not
// nil and ParticleCount is 0.
ParticleCount int
// Sound is a sound that is played when the projectile hits a target, either
// a block or an entity. No sound is played if left nil.
Sound world.Sound
// Critical specifies if the projectile is critical. This spawns critical
// hit particles behind the projectile and causes it to deal up to 50% more
// damage.
Critical bool
// Hit is a function that is called when the projectile Ent hits a target
// (the trace.Result). The target is either of the type trace.EntityResult
// or trace.BlockResult. Hit may be set to run additional behaviour when a
// projectile hits a target.
Hit func(e *Ent, tx *world.Tx, target trace.Result)
// SurviveBlockCollision specifies if a projectile with this
// ProjectileBehaviour should survive collision with a block. If set to
// false, the projectile will break when hitting a block (like a snowball).
// If set to true, the projectile will survive like an arrow does.
SurviveBlockCollision bool
// BlockCollisionVelocityMultiplier is the multiplier used to modify the
// velocity of a projectile that has SurviveBlockCollision set to true. The
// default, 0, will cause the projectile to lose its velocity completely. A
// multiplier such as 0.5 will reduce the projectile's velocity, but retain
// half of it after inverting the axis on which the projectile collided.
BlockCollisionVelocityMultiplier float64
// DisablePickup specifies if picking up the projectile should be disabled,
// which is relevant in the case SurviveBlockCollision is set to true. Some
// projectiles, such as arrows, cannot be picked up if they are shot by
// monsters like skeletons.
DisablePickup bool
// PickupItem is the item that is given to a player when it picks up this
// projectile. If left as an empty item.Stack, no item is given upon pickup.
PickupItem item.Stack
// CollisionPosition specifies the position that the projectile is stuck
// in. If non-empty, the entity will not move.
CollisionPosition cube.Pos
// PiercingLevel is the crossbow Piercing enchantment level. The projectile
// passes through PiercingLevel entities and damages PiercingLevel+1 in
// total. A value of 0 means no piercing.
PiercingLevel int
}
func (conf ProjectileBehaviourConfig) Apply(data *world.EntityData) {
data.Data = conf.New()
}
// New creates a new ProjectileBehaviour using conf. The owner passed may be nil
// if the projectile does not have one.
func (conf ProjectileBehaviourConfig) New() *ProjectileBehaviour {
if conf.ParticleCount == 0 && conf.Particle != nil {
conf.ParticleCount = 1
}
return &ProjectileBehaviour{conf: conf, collided: conf.CollisionPosition != cube.Pos{}, collisionPos: conf.CollisionPosition, mc: &MovementComputer{
Gravity: conf.Gravity,
Drag: conf.Drag,
DragBeforeGravity: true,
}}
}
// ProjectileBehaviour implements the behaviour of projectiles. Its specifics
// may be configured using ProjectileBehaviourConfig.
type ProjectileBehaviour struct {
conf ProjectileBehaviourConfig
mc *MovementComputer
ageCollided int
close bool
collisionPos cube.Pos
collided bool
collidedEntities []*world.EntityHandle
}
// Owner returns the owner of the projectile.
func (lt *ProjectileBehaviour) Owner() *world.EntityHandle {
return lt.conf.Owner
}
// Explode adds velocity to a projectile to blast it away from the explosion's
// source.
func (lt *ProjectileBehaviour) Explode(e *Ent, src mgl64.Vec3, impact float64, _ block.ExplosionConfig) {
e.data.Vel = e.Velocity().Add(e.Position().Sub(src).Normalize().Mul(impact))
}
// Potion returns the potion.Potion that is applied to an entity if hit by the
// projectile.
func (lt *ProjectileBehaviour) Potion() potion.Potion {
return lt.conf.Potion
}
// Critical returns true if ProjectileBehaviourConfig.Critical was set to true
// and if the projectile has not collided.
func (lt *ProjectileBehaviour) Critical() bool {
return lt.conf.Critical && !lt.collided
}
// Tick runs the tick-based behaviour of a ProjectileBehaviour and returns the
// Movement within the tick. Tick handles the movement, collision and hitting
// of a projectile.
func (lt *ProjectileBehaviour) Tick(e *Ent, tx *world.Tx) *Movement {
if lt.close {
_ = e.Close()
return nil
}
if lt.collided && lt.tickAttached(e, tx) {
if lt.ageCollided > 1200 {
lt.close = true
}
return nil
}
vel := e.Velocity()
m, result := lt.tickMovement(e, tx)
e.data.Pos, e.data.Vel = m.pos, m.vel
lt.collisionPos, lt.collided, lt.ageCollided = cube.Pos{}, false, 0
if result == nil {
return m
}
for i := 0; i < lt.conf.ParticleCount; i++ {
tx.AddParticle(result.Position(), lt.conf.Particle)
}
if lt.conf.Sound != nil {
tx.PlaySound(result.Position(), lt.conf.Sound)
}
switch r := result.(type) {
case trace.EntityResult:
if l, ok := r.Entity().(Living); ok {
if lt.conf.Damage >= 0 {
lt.hitEntity(l, e, vel)
}
lt.collidedEntities = append(lt.collidedEntities, l.H())
}
case trace.BlockResult:
bpos := r.BlockPosition()
if h, ok := tx.Block(bpos).(block.ProjectileHitter); ok {
h.ProjectileHit(bpos, tx, e, r.Face())
}
if lt.conf.SurviveBlockCollision {
lt.hitBlockSurviving(e, r, m, tx)
return m
}
lt.close = true
}
if lt.conf.Hit != nil {
lt.conf.Hit(e, tx, result)
}
if len(lt.collidedEntities) > lt.conf.PiercingLevel {
lt.close = true
}
return m
}
// tickAttached performs the attached logic for a projectile. It checks if the
// projectile is still attached to a block and if it can be picked up.
func (lt *ProjectileBehaviour) tickAttached(e *Ent, tx *world.Tx) bool {
boxes := tx.Block(lt.collisionPos).Model().BBox(lt.collisionPos, tx)
box := e.H().Type().BBox(e).Translate(e.Position())
for _, bb := range boxes {
if box.IntersectsWith(bb.Translate(lt.collisionPos.Vec3()).Grow(0.05)) {
if lt.ageCollided > 5 && !lt.conf.DisablePickup {
lt.tryPickup(e, tx)
}
lt.ageCollided++
return true
}
}
return false
}
// tryPickup checks for nearby projectile collectors and closes the entity if
// one was found.
func (lt *ProjectileBehaviour) tryPickup(e *Ent, tx *world.Tx) {
translated := e.H().Type().BBox(e).Translate(e.Position())
grown := translated.GrowVec3(mgl64.Vec3{1, 0.5, 1})
for other := range tx.EntitiesWithin(translated.Grow(2)) {
if !other.H().Type().BBox(other).Translate(other.Position()).IntersectsWith(grown) {
continue
}
collector, ok := other.(Collector)
if !ok {
continue
}
if _, ok := collector.Collect(lt.conf.PickupItem); !ok {
continue
}
// A collector was within range and able to pick up the entity.
lt.close = true
for _, viewer := range tx.Viewers(e.Position()) {
viewer.ViewEntityAction(e, PickedUpAction{Collector: collector})
}
}
}
// hitBlockSurviving is called if
// ProjectileBehaviourConfig.SurviveBlockCollision is set to true and the
// projectile collides with a block. If the resulting velocity is roughly 0,
// it sets the projectile as having collided with the block.
func (lt *ProjectileBehaviour) hitBlockSurviving(e *Ent, r trace.BlockResult, m *Movement, tx *world.Tx) {
// Create an epsilon for deciding if the projectile has slowed down enough
// for us to consider it as having collided for the final time. We take the
// square root because FloatEqualThreshold squares it, which is not what we
// want.
eps := math.Sqrt(0.1 * (1 - lt.conf.BlockCollisionVelocityMultiplier))
if mgl64.FloatEqualThreshold(e.Velocity().Len(), 0, eps) {
e.SetVelocity(mgl64.Vec3{})
lt.collisionPos, lt.collided = r.BlockPosition(), true
for _, v := range tx.Viewers(m.pos) {
v.ViewEntityAction(e, ArrowShakeAction{Duration: time.Millisecond * 350})
v.ViewEntityState(e)
}
return
}
}
// hitEntity is called when a projectile hits a Living. It deals damage to the
// entity and knocks it back. Additionally, it applies any potion effects and
// fire if applicable.
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
}
// TODO: Piercing arrows should bypass shield blocking when shields are implemented.
if _, vulnerable := l.Hurt(dmg, src); vulnerable {
l.KnockBack(l.Position().Sub(vel), 0.45+lt.conf.KnockBackForceAddend, 0.3608+lt.conf.KnockBackHeightAddend)
for _, eff := range lt.conf.Potion.Effects() {
if lasting, ok := eff.Type().(effect.LastingType); ok {
l.AddEffect(effect.New(lasting, eff.Level(), time.Duration(float64(eff.Duration())/8)))
continue
}
l.AddEffect(eff)
}
if flammable, ok := l.(Flammable); ok && e.OnFireDuration() > 0 {
flammable.SetOnFire(time.Second * 5)
}
}
}
// tickMovement ticks the movement of a projectile. It updates the position and
// rotation of the projectile based on its velocity and updates the velocity
// based on gravity and drag.
func (lt *ProjectileBehaviour) tickMovement(e *Ent, tx *world.Tx) (*Movement, trace.Result) {
pos, vel := e.Position(), e.Velocity()
viewers := tx.Viewers(pos)
velBefore := vel
vel = lt.mc.applyHorizontalForces(tx, pos, lt.mc.applyVerticalForces(vel))
rot := cube.Rotation{
mgl64.RadToDeg(math.Atan2(vel[0], vel[2])),
mgl64.RadToDeg(math.Atan2(vel[1], math.Hypot(vel[0], vel[2]))),
}
var (
end = pos.Add(vel)
hit trace.Result
ok bool
)
if !mgl64.FloatEqual(end.Sub(pos).LenSqr(), 0) {
if hit, ok = trace.Perform(pos, end, tx, e.H().Type().BBox(e).Grow(1.0), lt.ignores(e)); ok {
if _, ok := hit.(trace.BlockResult); ok {
// Undo the gravity because the velocity as a result of gravity
// at the point of collision should be 0.
vel[1] = (vel[1] + lt.mc.Gravity) / (1 - lt.mc.Drag)
x, y, z := vel.Mul(lt.conf.BlockCollisionVelocityMultiplier).Elem()
// Calculate multipliers for all coordinates: 1 for the ones that
// weren't on the same axis as the one collided with, -1 for the one
// that was on that axis to deflect the projectile.
mx, my, mz := hit.Face().Axis().Vec3().Mul(-2).Add(mgl64.Vec3{1, 1, 1}).Elem()
vel = mgl64.Vec3{x * mx, y * my, z * mz}
} else if lt.conf.PiercingLevel == 0 {
vel = zeroVec3
}
end = hit.Position()
}
}
return &Movement{v: viewers, e: e, pos: end, vel: vel, dpos: end.Sub(pos), dvel: vel.Sub(velBefore), rot: rot}, hit
}
// ignores returns a function to ignore entities in trace.Perform that are
// either a spectator, not living, the entity itself, its owner in the first
// 5 ticks, or an entity it already collided with.
func (lt *ProjectileBehaviour) ignores(e *Ent) trace.EntityFilter {
return func(seq iter.Seq[world.Entity]) iter.Seq[world.Entity] {
return func(yield func(world.Entity) bool) {
for other := range seq {
g, ok := other.(interface{ GameMode() world.GameMode })
spectator := ok && !g.GameMode().HasCollision()
itself := e.H() == other.H()
_, living := other.(Living)
owner := e.data.Age < time.Second/4 && lt.conf.Owner == other.H()
collidedEntity := slices.Contains(lt.collidedEntities, other.H())
if spectator || itself || !living || owner || collidedEntity {
continue
}
if !yield(other) {
return
}
}
}
}
}