diff --git a/.gitignore b/.gitignore index 2502e81c2..fecf38a66 100644 --- a/.gitignore +++ b/.gitignore @@ -7,3 +7,6 @@ /world/ /players/ /resources/ + +# AI +.omx diff --git a/server/block/beetroot_seeds.go b/server/block/beetroot_seeds.go index b0f6516df..f6f13ddf8 100644 --- a/server/block/beetroot_seeds.go +++ b/server/block/beetroot_seeds.go @@ -21,16 +21,16 @@ func (BeetrootSeeds) SameCrop(c Crop) bool { } // BoneMeal ... -func (b BeetrootSeeds) BoneMeal(pos cube.Pos, tx *world.Tx) bool { +func (b BeetrootSeeds) BoneMeal(pos cube.Pos, tx *world.Tx) item.BoneMealResult { if b.Growth == 7 { - return false + return item.BoneMealResultNone } if rand.Float64() < 0.75 { b.Growth++ tx.SetBlock(pos, b, nil) - return true + return item.BoneMealResultSmall } - return false + return item.BoneMealResultNone } // UseOnBlock ... diff --git a/server/block/carrot.go b/server/block/carrot.go index c7d559e4a..834fa35aa 100644 --- a/server/block/carrot.go +++ b/server/block/carrot.go @@ -38,13 +38,13 @@ func (c Carrot) Consume(_ *world.Tx, co item.Consumer) item.Stack { } // BoneMeal ... -func (c Carrot) BoneMeal(pos cube.Pos, tx *world.Tx) bool { +func (c Carrot) BoneMeal(pos cube.Pos, tx *world.Tx) item.BoneMealResult { if c.Growth == 7 { - return false + return item.BoneMealResultNone } c.Growth = min(c.Growth+rand.IntN(4)+2, 7) tx.SetBlock(pos, c, nil) - return true + return item.BoneMealResultSmall } // UseOnBlock ... diff --git a/server/block/cocoa_bean.go b/server/block/cocoa_bean.go index bc2a4048b..bf19b0716 100644 --- a/server/block/cocoa_bean.go +++ b/server/block/cocoa_bean.go @@ -1,12 +1,13 @@ package block import ( + "math/rand/v2" + "github.com/df-mc/dragonfly/server/block/cube" "github.com/df-mc/dragonfly/server/block/model" "github.com/df-mc/dragonfly/server/item" "github.com/df-mc/dragonfly/server/world" "github.com/go-gl/mathgl/mgl64" - "math/rand/v2" ) // CocoaBean is a crop block found in jungle biomes. @@ -20,13 +21,13 @@ type CocoaBean struct { } // BoneMeal ... -func (c CocoaBean) BoneMeal(pos cube.Pos, tx *world.Tx) bool { +func (c CocoaBean) BoneMeal(pos cube.Pos, tx *world.Tx) item.BoneMealResult { if c.Age == 2 { - return false + return item.BoneMealResultNone } c.Age++ tx.SetBlock(pos, c, nil) - return true + return item.BoneMealResultSmall } // HasLiquidDrops ... diff --git a/server/block/cube/trace/bbox.go b/server/block/cube/trace/bbox.go index 249bef5bc..aef3bbf27 100644 --- a/server/block/cube/trace/bbox.go +++ b/server/block/cube/trace/bbox.go @@ -99,6 +99,41 @@ func BBoxIntercept(bb cube.BBox, start, end mgl64.Vec3) (result BBoxResult, ok b return BBoxResult{bb: bb, pos: *vec, face: f}, true } +// BBoxIntersects checks if the line segment from start to end intersects the BBox. +// Unlike BBoxIntercept, it only reports whether an intersection exists and does not +// calculate the closest hit position or face. +func BBoxIntersects(bb cube.BBox, start, end mgl64.Vec3) bool { + min, max := bb.Min(), bb.Max() + dir := end.Sub(start) + tMin, tMax := 0.0, 1.0 + + for axis := range 3 { + if mgl64.FloatEqual(dir[axis], 0) { + if start[axis] < min[axis] || start[axis] > max[axis] { + return false + } + continue + } + + inv := 1 / dir[axis] + t1 := (min[axis] - start[axis]) * inv + t2 := (max[axis] - start[axis]) * inv + if t1 > t2 { + t1, t2 = t2, t1 + } + if t1 > tMin { + tMin = t1 + } + if t2 < tMax { + tMax = t2 + } + if tMin > tMax { + return false + } + } + return true +} + // vec3OnLineWithX returns an mgl64.Vec3 on the line between mgl64.Vec3 a and b with an X value passed. If no such vec3 // could be found, the bool returned is false. func vec3OnLineWithX(a, b mgl64.Vec3, x float64) *mgl64.Vec3 { diff --git a/server/block/cube/trace/block.go b/server/block/cube/trace/block.go index e9a0f2ce3..3139151eb 100644 --- a/server/block/cube/trace/block.go +++ b/server/block/cube/trace/block.go @@ -2,6 +2,7 @@ package trace import ( "github.com/df-mc/dragonfly/server/block/cube" + "github.com/df-mc/dragonfly/server/block/model" "github.com/df-mc/dragonfly/server/world" "github.com/go-gl/mathgl/mgl64" "math" @@ -70,3 +71,23 @@ func BlockIntercept(pos cube.Pos, src world.BlockSource, b world.Block, start, e return BlockResult{bb: hit.BBox(), pos: hit.Position(), face: hit.Face(), blockPos: pos}, true } + +// BlockIntersects checks if the line segment from start to end intersects the block model of b at pos. Unlike +// BlockIntercept, it only reports whether an intersection exists and does not calculate the closest hit position, face, +// or bounding box. +func BlockIntersects(pos cube.Pos, src world.BlockSource, b world.Block, start, end mgl64.Vec3) bool { + m := b.Model() + switch m.(type) { + case model.Empty: + return false + case model.Solid: + return BBoxIntersects(cube.Box(0, 0, 0, 1, 1, 1).Translate(pos.Vec3()), start, end) + } + + for _, bb := range m.BBox(pos, src) { + if BBoxIntersects(bb.Translate(pos.Vec3()), start, end) { + return true + } + } + return false +} diff --git a/server/block/double_flower.go b/server/block/double_flower.go index c95748493..00dbc7d28 100644 --- a/server/block/double_flower.go +++ b/server/block/double_flower.go @@ -24,9 +24,9 @@ func (d DoubleFlower) FlammabilityInfo() FlammabilityInfo { } // BoneMeal ... -func (d DoubleFlower) BoneMeal(pos cube.Pos, tx *world.Tx) bool { +func (d DoubleFlower) BoneMeal(pos cube.Pos, tx *world.Tx) item.BoneMealResult { dropItem(tx, item.NewStack(d, 1), pos.Vec3Centre()) - return true + return item.BoneMealResultSmall } // NeighbourUpdateTick ... diff --git a/server/block/fern.go b/server/block/fern.go index 4317feb93..88479a50d 100644 --- a/server/block/fern.go +++ b/server/block/fern.go @@ -25,14 +25,14 @@ func (g Fern) BreakInfo() BreakInfo { } // BoneMeal attempts to affect the block using a bone meal item. -func (g Fern) BoneMeal(pos cube.Pos, tx *world.Tx) bool { +func (g Fern) BoneMeal(pos cube.Pos, tx *world.Tx) item.BoneMealResult { upper := DoubleTallGrass{Type: FernDoubleTallGrass(), UpperPart: true} if replaceableWith(tx, pos.Side(cube.FaceUp), upper) { tx.SetBlock(pos, DoubleTallGrass{Type: FernDoubleTallGrass()}, nil) tx.SetBlock(pos.Side(cube.FaceUp), upper, nil) - return true + return item.BoneMealResultSmall } - return false + return item.BoneMealResultNone } // CompostChance ... diff --git a/server/block/flower.go b/server/block/flower.go index a294444e6..3cccb320d 100644 --- a/server/block/flower.go +++ b/server/block/flower.go @@ -1,13 +1,14 @@ package block import ( + "math/rand/v2" + "time" + "github.com/df-mc/dragonfly/server/block/cube" "github.com/df-mc/dragonfly/server/entity/effect" "github.com/df-mc/dragonfly/server/item" "github.com/df-mc/dragonfly/server/world" "github.com/go-gl/mathgl/mgl64" - "math/rand/v2" - "time" ) // Flower is a non-solid plant that occur in a variety of shapes and colours. They are primarily used for decoration @@ -32,7 +33,8 @@ func (f Flower) EntityInside(_ cube.Pos, _ *world.Tx, e world.Entity) { } // BoneMeal ... -func (f Flower) BoneMeal(pos cube.Pos, tx *world.Tx) (success bool) { +func (f Flower) BoneMeal(pos cube.Pos, tx *world.Tx) (result item.BoneMealResult) { + result = item.BoneMealResultNone if f.Type == WitherRose() { return } @@ -54,7 +56,7 @@ func (f Flower) BoneMeal(pos cube.Pos, tx *world.Tx) (success bool) { } } tx.SetBlock(p, Flower{Type: flowerType}, nil) - success = true + result = item.BoneMealResultArea } return } diff --git a/server/block/grass.go b/server/block/grass.go index 10dd0e1aa..dade87ce7 100644 --- a/server/block/grass.go +++ b/server/block/grass.go @@ -1,9 +1,11 @@ package block import ( + "math/rand/v2" + "github.com/df-mc/dragonfly/server/block/cube" + "github.com/df-mc/dragonfly/server/item" "github.com/df-mc/dragonfly/server/world" - "math/rand/v2" ) // Grass blocks generate abundantly across the surface of the world. @@ -78,18 +80,19 @@ func (g Grass) RandomTick(pos cube.Pos, tx *world.Tx, r *rand.Rand) { } // BoneMeal ... -func (g Grass) BoneMeal(pos cube.Pos, tx *world.Tx) bool { - for i := 0; i < 14; i++ { +func (g Grass) BoneMeal(pos cube.Pos, tx *world.Tx) (result item.BoneMealResult) { + result = item.BoneMealResultNone + for range 14 { c := pos.Add(cube.Pos{rand.IntN(6) - 3, 0, rand.IntN(6) - 3}) above := c.Side(cube.FaceUp) _, air := tx.Block(above).(Air) _, grass := tx.Block(c).(Grass) if air && grass { tx.SetBlock(above, plantSelection[rand.IntN(len(plantSelection))], nil) + result = item.BoneMealResultArea } } - - return false + return } // BreakInfo ... diff --git a/server/block/kelp.go b/server/block/kelp.go index 27c03af13..7140bbfb5 100644 --- a/server/block/kelp.go +++ b/server/block/kelp.go @@ -1,11 +1,12 @@ package block import ( + "math/rand/v2" + "github.com/df-mc/dragonfly/server/block/cube" "github.com/df-mc/dragonfly/server/item" "github.com/df-mc/dragonfly/server/world" "github.com/go-gl/mathgl/mgl64" - "math/rand/v2" ) // Kelp is an underwater block which can grow on top of solids underwater. @@ -24,7 +25,7 @@ func (k Kelp) SmeltInfo() item.SmeltInfo { } // BoneMeal ... -func (k Kelp) BoneMeal(pos cube.Pos, tx *world.Tx) bool { +func (k Kelp) BoneMeal(pos cube.Pos, tx *world.Tx) item.BoneMealResult { for y := pos.Y(); y <= tx.Range()[1]; y++ { currentPos := cube.Pos{pos.X(), y, pos.Z()} block := tx.Block(currentPos) @@ -36,11 +37,11 @@ func (k Kelp) BoneMeal(pos cube.Pos, tx *world.Tx) bool { } if water, ok := block.(Water); ok && water.Depth == 8 { tx.SetBlock(currentPos, Kelp{Age: k.Age + 1}, nil) - return true + return item.BoneMealResultSmall } break } - return false + return item.BoneMealResultNone } // BreakInfo ... diff --git a/server/block/melon_seeds.go b/server/block/melon_seeds.go index 68bbaa2d7..2fcfeece4 100644 --- a/server/block/melon_seeds.go +++ b/server/block/melon_seeds.go @@ -1,11 +1,12 @@ package block import ( + "math/rand/v2" + "github.com/df-mc/dragonfly/server/block/cube" "github.com/df-mc/dragonfly/server/item" "github.com/df-mc/dragonfly/server/world" "github.com/go-gl/mathgl/mgl64" - "math/rand/v2" ) // MelonSeeds grow melon blocks. @@ -62,13 +63,13 @@ func (m MelonSeeds) RandomTick(pos cube.Pos, tx *world.Tx, r *rand.Rand) { } // BoneMeal ... -func (m MelonSeeds) BoneMeal(pos cube.Pos, tx *world.Tx) bool { +func (m MelonSeeds) BoneMeal(pos cube.Pos, tx *world.Tx) item.BoneMealResult { if m.Growth == 7 { - return false + return item.BoneMealResultNone } m.Growth = min(m.Growth+rand.IntN(4)+2, 7) tx.SetBlock(pos, m, nil) - return true + return item.BoneMealResultSmall } // UseOnBlock ... diff --git a/server/block/pink_petals.go b/server/block/pink_petals.go index 1df913167..3093836c0 100644 --- a/server/block/pink_petals.go +++ b/server/block/pink_petals.go @@ -21,14 +21,14 @@ type PinkPetals struct { } // BoneMeal ... -func (p PinkPetals) BoneMeal(pos cube.Pos, tx *world.Tx) bool { +func (p PinkPetals) BoneMeal(pos cube.Pos, tx *world.Tx) item.BoneMealResult { if p.AdditionalCount < 3 { p.AdditionalCount++ tx.SetBlock(pos, p, nil) - return true + return item.BoneMealResultSmall } dropItem(tx, item.NewStack(p, 1), pos.Vec3Centre()) - return true + return item.BoneMealResultSmall } // UseOnBlock ... diff --git a/server/block/potato.go b/server/block/potato.go index ecb9e3b5f..266c36a1d 100644 --- a/server/block/potato.go +++ b/server/block/potato.go @@ -43,13 +43,13 @@ func (p Potato) Consume(_ *world.Tx, c item.Consumer) item.Stack { } // BoneMeal ... -func (p Potato) BoneMeal(pos cube.Pos, tx *world.Tx) bool { +func (p Potato) BoneMeal(pos cube.Pos, tx *world.Tx) item.BoneMealResult { if p.Growth == 7 { - return false + return item.BoneMealResultNone } p.Growth = min(p.Growth+rand.IntN(4)+2, 7) tx.SetBlock(pos, p, nil) - return true + return item.BoneMealResultSmall } // UseOnBlock ... diff --git a/server/block/pumpkin_seeds.go b/server/block/pumpkin_seeds.go index 5a62c6507..e14be3a13 100644 --- a/server/block/pumpkin_seeds.go +++ b/server/block/pumpkin_seeds.go @@ -1,11 +1,12 @@ package block import ( + "math/rand/v2" + "github.com/df-mc/dragonfly/server/block/cube" "github.com/df-mc/dragonfly/server/item" "github.com/df-mc/dragonfly/server/world" "github.com/go-gl/mathgl/mgl64" - "math/rand/v2" ) // PumpkinSeeds grow pumpkin blocks. @@ -62,13 +63,13 @@ func (p PumpkinSeeds) RandomTick(pos cube.Pos, tx *world.Tx, r *rand.Rand) { } // BoneMeal ... -func (p PumpkinSeeds) BoneMeal(pos cube.Pos, tx *world.Tx) bool { +func (p PumpkinSeeds) BoneMeal(pos cube.Pos, tx *world.Tx) item.BoneMealResult { if p.Growth == 7 { - return false + return item.BoneMealResultNone } p.Growth = min(p.Growth+rand.IntN(4)+2, 7) tx.SetBlock(pos, p, nil) - return true + return item.BoneMealResultSmall } // UseOnBlock ... diff --git a/server/block/sea_pickle.go b/server/block/sea_pickle.go index 3b6acbc87..96df4d893 100644 --- a/server/block/sea_pickle.go +++ b/server/block/sea_pickle.go @@ -1,12 +1,13 @@ package block import ( + "math" + "math/rand/v2" + "github.com/df-mc/dragonfly/server/block/cube" "github.com/df-mc/dragonfly/server/item" "github.com/df-mc/dragonfly/server/world" "github.com/go-gl/mathgl/mgl64" - "math" - "math/rand/v2" ) // SeaPickle is a small stationary underwater block that emits light, and is typically found in colonies of up to @@ -41,12 +42,12 @@ func (SeaPickle) canSurvive(pos cube.Pos, tx *world.Tx) bool { } // BoneMeal ... -func (s SeaPickle) BoneMeal(pos cube.Pos, tx *world.Tx) bool { +func (s SeaPickle) BoneMeal(pos cube.Pos, tx *world.Tx) item.BoneMealResult { if s.Dead { - return false + return item.BoneMealResultNone } if coral, ok := tx.Block(pos.Side(cube.FaceDown)).(CoralBlock); !ok || coral.Dead { - return false + return item.BoneMealResultNone } if s.AdditionalCount != 3 { @@ -74,7 +75,7 @@ func (s SeaPickle) BoneMeal(pos cube.Pos, tx *world.Tx) bool { } } - return true + return item.BoneMealResultSmall } // UseOnBlock ... diff --git a/server/block/short_grass.go b/server/block/short_grass.go index 6f74cb832..edf6f3f70 100644 --- a/server/block/short_grass.go +++ b/server/block/short_grass.go @@ -27,14 +27,14 @@ func (g ShortGrass) BreakInfo() BreakInfo { } // BoneMeal attempts to affect the block using a bone meal item. -func (g ShortGrass) BoneMeal(pos cube.Pos, tx *world.Tx) bool { +func (g ShortGrass) BoneMeal(pos cube.Pos, tx *world.Tx) item.BoneMealResult { upper := DoubleTallGrass{Type: NormalDoubleTallGrass(), UpperPart: true} if replaceableWith(tx, pos.Side(cube.FaceUp), upper) { tx.SetBlock(pos, DoubleTallGrass{Type: NormalDoubleTallGrass()}, nil) tx.SetBlock(pos.Side(cube.FaceUp), upper, nil) - return true + return item.BoneMealResultSmall } - return false + return item.BoneMealResultNone } // CompostChance ... diff --git a/server/block/sugar_cane.go b/server/block/sugar_cane.go index 1c6e21c2a..0f7b467f7 100644 --- a/server/block/sugar_cane.go +++ b/server/block/sugar_cane.go @@ -1,11 +1,12 @@ package block import ( + "math/rand/v2" + "github.com/df-mc/dragonfly/server/block/cube" "github.com/df-mc/dragonfly/server/item" "github.com/df-mc/dragonfly/server/world" "github.com/go-gl/mathgl/mgl64" - "math/rand/v2" ) // SugarCane is a plant block that generates naturally near water. @@ -63,7 +64,7 @@ func (c SugarCane) RandomTick(pos cube.Pos, tx *world.Tx, _ *rand.Rand) { } // BoneMeal ... -func (c SugarCane) BoneMeal(pos cube.Pos, tx *world.Tx) bool { +func (c SugarCane) BoneMeal(pos cube.Pos, tx *world.Tx) item.BoneMealResult { for _, ok := tx.Block(pos.Side(cube.FaceDown)).(SugarCane); ok; _, ok = tx.Block(pos.Side(cube.FaceDown)).(SugarCane) { pos = pos.Side(cube.FaceDown) } @@ -73,9 +74,9 @@ func (c SugarCane) BoneMeal(pos cube.Pos, tx *world.Tx) bool { tx.SetBlock(pos.Add(cube.Pos{0, y}), SugarCane{}, nil) } } - return true + return item.BoneMealResultSmall } - return false + return item.BoneMealResultNone } // canGrowHere implements logic to check if sugar cane can live/grow here. diff --git a/server/block/wheat_seeds.go b/server/block/wheat_seeds.go index 4103a306b..549f4ceb7 100644 --- a/server/block/wheat_seeds.go +++ b/server/block/wheat_seeds.go @@ -21,13 +21,13 @@ func (WheatSeeds) SameCrop(c Crop) bool { } // BoneMeal ... -func (s WheatSeeds) BoneMeal(pos cube.Pos, tx *world.Tx) bool { +func (s WheatSeeds) BoneMeal(pos cube.Pos, tx *world.Tx) item.BoneMealResult { if s.Growth == 7 { - return false + return item.BoneMealResultNone } s.Growth = min(s.Growth+rand.IntN(4)+2, 7) tx.SetBlock(pos, s, nil) - return true + return item.BoneMealResultSmall } // UseOnBlock ... diff --git a/server/item/bone_meal.go b/server/item/bone_meal.go index ec1151da0..9e7deea18 100644 --- a/server/item/bone_meal.go +++ b/server/item/bone_meal.go @@ -7,20 +7,40 @@ import ( "github.com/go-gl/mathgl/mgl64" ) +// BoneMealResult represents the outcome of a bone meal interaction with a block, +// determining the intensity of the particle effect displayed. +type BoneMealResult int + +const ( + // BoneMealResultNone indicates that the bone meal had no effect on the block. + BoneMealResultNone BoneMealResult = iota + // BoneMealResultSmall indicates a minor growth effect, produces a small particle burst. + BoneMealResultSmall + // BoneMealResultArea indicates a significant growth effect over an area, produces a large particle burst. + BoneMealResultArea +) + // BoneMeal is an item used to force growth in plants & crops. type BoneMeal struct{} // BoneMealAffected represents a block that is affected when bone meal is used on it. type BoneMealAffected interface { // BoneMeal attempts to affect the block using a bone meal item. - BoneMeal(pos cube.Pos, tx *world.Tx) bool + BoneMeal(pos cube.Pos, tx *world.Tx) BoneMealResult } // UseOnBlock ... func (b BoneMeal) UseOnBlock(pos cube.Pos, _ cube.Face, _ mgl64.Vec3, tx *world.Tx, _ User, ctx *UseContext) bool { - if bm, ok := tx.Block(pos).(BoneMealAffected); ok && bm.BoneMeal(pos, tx) { + if bm, ok := tx.Block(pos).(BoneMealAffected); ok { + result := bm.BoneMeal(pos, tx) + if result == BoneMealResultNone { + return false + } + ctx.SubtractFromCount(1) - tx.AddParticle(pos.Vec3(), particle.BoneMeal{}) + tx.AddParticle(pos.Vec3(), particle.BoneMeal{ + Area: result == BoneMealResultArea, + }) return true } return false diff --git a/server/item/crossbow.go b/server/item/crossbow.go index 915b3e274..ed90384f4 100644 --- a/server/item/crossbow.go +++ b/server/item/crossbow.go @@ -135,6 +135,7 @@ func (c Crossbow) ReleaseCharge(releaser Releaser, tx *world.Tx, ctx *UseContext arrowConf := world.ArrowSpawnConfig{ Damage: 9, Owner: releaser, + Critical: true, ObtainArrowOnPickup: !creative, PiercingLevel: pierceLevel, } @@ -154,6 +155,12 @@ func (c Crossbow) ReleaseCharge(releaser Releaser, tx *world.Tx, ctx *UseContext return true } +// CanCharge ... +func (c Crossbow) CanCharge(releaser Releaser, _ *world.Tx, ctx *UseContext) bool { + _, found := c.findProjectile(releaser, ctx) + return found && !c.Item.Empty() +} + // shoot fires the crossbow's loaded projectiles. func (c Crossbow) shoot(releaser Releaser, tx *world.Tx, offsetAngle float64, arrowConf world.ArrowSpawnConfig) { rot := releaser.Rotation() diff --git a/server/item/item.go b/server/item/item.go index 1562cd373..33a503cd2 100644 --- a/server/item/item.go +++ b/server/item/item.go @@ -2,12 +2,13 @@ package item import ( "encoding/binary" + "image/color" + "time" + "github.com/df-mc/dragonfly/server/block/cube" "github.com/df-mc/dragonfly/server/entity/effect" "github.com/df-mc/dragonfly/server/world" "github.com/go-gl/mathgl/mgl64" - "image/color" - "time" ) // MaxCounter represents an item that has a specific max count. By default, each item will be expected to have @@ -168,6 +169,8 @@ type Chargeable interface { 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 + // CanCharge returns whether the item can currently be charged. + CanCharge(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, diff --git a/server/player/handler.go b/server/player/handler.go index ada7a6546..6951b200c 100644 --- a/server/player/handler.go +++ b/server/player/handler.go @@ -71,8 +71,10 @@ type Handler interface { // be called to stop the player from breaking the block completely. HandleStartBreak(ctx *Context, pos cube.Pos) // HandleBlockBreak handles a block that is being broken by a player. ctx.Cancel() may be called to cancel - // the block being broken. A pointer to a slice of the block's drops is passed, and may be altered - // to change what items will actually be dropped. + // the block being broken. A pointer to a slice of the block's drops is passed, and may be altered to change + // what items will actually be dropped. If the block being broken is a private view-layer block, drops and xp + // start empty and modifications to them are ignored: Private breaks only remove the viewer's override. Use + // ctx.Val().ViewLayer().Block(pos) to check if the block being broken is private. HandleBlockBreak(ctx *Context, pos cube.Pos, drops *[]item.Stack, xp *int) // HandleBlockPlace handles the player placing a specific block at a position in its world. ctx.Cancel() // may be called to cancel the block being placed. diff --git a/server/player/player.go b/server/player/player.go index bbccbd06e..12624624c 100644 --- a/server/player/player.go +++ b/server/player/player.go @@ -98,6 +98,8 @@ type playerData struct { breaking bool breakingPos cube.Pos breakingFace cube.Face + breakingPrivate bool + breakingPosValid bool lastBreakDuration time.Duration breakCounter uint32 @@ -1512,7 +1514,7 @@ func (p *Player) UseItem() { case item.Chargeable: useCtx := p.useContext() if !p.usingItem { - if !usable.ReleaseCharge(p, p.tx, useCtx) { + if !usable.ReleaseCharge(p, p.tx, useCtx) && usable.CanCharge(p, p.tx, useCtx) { // If the item was not charged yet, start charging. p.usingSince, p.usingItem = time.Now(), true } @@ -1666,9 +1668,10 @@ func (p *Player) UsingItem() bool { // returns immediately. // UseItemOnBlock does nothing if the block at the cube.Pos passed is of the type block.Air. func (p *Player) UseItemOnBlock(pos cube.Pos, face cube.Face, clickPos mgl64.Vec3) { - if _, ok := p.tx.Block(pos).(block.Air); ok || !p.canReach(pos.Vec3Centre()) { - // The client used its item on a block that does not exist server-side or one it couldn't reach. Stop trying - // to use the item immediately. + b, _ := p.viewedBlock(pos) + var private bool + if _, ok := b.(block.Air); ok || !p.canReach(pos.Vec3Centre()) { + // The client used its item on a block that cannot be interacted with. Stop trying to use the item immediately. p.resendNearbyBlocks(pos, face) return } @@ -1677,8 +1680,12 @@ func (p *Player) UseItemOnBlock(pos cube.Pos, face cube.Face, clickPos mgl64.Vec p.resendNearbyBlocks(pos, face) return } + b, private = p.viewedBlock(pos) + if _, ok := b.(block.Air); ok || private { + p.resendNearbyBlocks(pos, face) + return + } i, left := p.HeldItems() - b := p.tx.Block(pos) if act, ok := b.(block.Activatable); ok { // If a player is sneaking, it will not activate the block clicked, unless it is not holding any // items, in which case the block will be activated as usual. @@ -1714,7 +1721,12 @@ func (p *Player) UseItemOnBlock(pos cube.Pos, face cube.Face, clickPos mgl64.Vec // The block clicked was either not replaceable, or not replaceable using the block passed. replacedPos = pos.Side(face) } - if replaceable, ok := p.tx.Block(replacedPos).(block.Replaceable); !ok || !replaceable.ReplaceableBy(ib) || replacedPos.OutOfBounds(p.tx.Range()) { + replacedBlock, replacedPrivate := p.viewedBlock(replacedPos) + if replacedPrivate { + p.resendNearbyBlocks(replacedPos) + return + } + if replaceable, ok := replacedBlock.(block.Replaceable); !ok || !replaceable.ReplaceableBy(ib) || replacedPos.OutOfBounds(p.tx.Range()) { return } if !p.placeBlock(replacedPos, ib, false) || p.GameMode().CreativeInventory() { @@ -1842,19 +1854,27 @@ func (p *Player) AttackEntity(e world.Entity) bool { // player might be breaking before this method is called. func (p *Player) StartBreaking(pos cube.Pos, face cube.Face) { p.AbortBreaking() - if _, air := p.tx.Block(pos).(block.Air); air || !p.canReach(pos.Vec3Centre()) { + b, private := p.viewedBlock(pos) + if _, air := b.(block.Air); air || !p.canReach(pos.Vec3Centre()) { // The block was either out of range or air, so it can't be broken by the player. return } - if _, ok := p.tx.Block(pos.Side(face)).(block.Fire); ok { + firePos := pos.Side(face) + fireBlock, firePrivate := p.viewedBlock(firePos) + if _, ok := fireBlock.(block.Fire); ok { ctx := event.C(p) if p.Handler().HandleFireExtinguish(ctx, pos); ctx.Cancelled() { // Resend the block because on client side that was extinguished p.resendNearbyBlocks(pos, face) return } + if firePrivate { + p.ViewPublicBlock(firePos) + p.session().ViewSound(pos.Vec3(), sound.FireExtinguish{}) + return + } - p.tx.SetBlock(pos.Side(face), nil, nil) + p.tx.SetBlock(firePos, nil, nil) p.tx.PlaySound(pos.Vec3(), sound.FireExtinguish{}) return } @@ -1866,13 +1886,13 @@ func (p *Player) StartBreaking(pos cube.Pos, face cube.Face) { } // Note: We intentionally store this regardless of whether the breaking proceeds, so that we // can resend the block to the client when it tries to break the block regardless. - p.breakingPos = pos + p.breakingPos, p.breakingPrivate, p.breakingPosValid = pos, private, true ctx := event.C(p) if p.Handler().HandleStartBreak(ctx, pos); ctx.Cancelled() { return } - if punchable, ok := p.tx.Block(pos).(block.Punchable); ok { + if punchable, ok := b.(block.Punchable); ok && !private { punchable.Punch(pos, face, p.tx, p) } @@ -1882,17 +1902,15 @@ func (p *Player) StartBreaking(pos cube.Pos, face cube.Face) { if p.GameMode().CreativeInventory() { return } - p.lastBreakDuration = p.breakTime(pos) - for _, viewer := range p.viewers() { - viewer.ViewBlockAction(pos, block.StartCrackAction{BreakTime: p.lastBreakDuration}) - } + p.lastBreakDuration = p.breakTime(b) + p.viewBreakingBlockAction(pos, private, block.StartCrackAction{BreakTime: p.lastBreakDuration}) } // breakTime returns the time needed to break a block at the position passed, taking into account the item // held, if the player is on the ground/underwater and if the player has any effects. -func (p *Player) breakTime(pos cube.Pos) time.Duration { +func (p *Player) breakTime(b world.Block) time.Duration { held, _ := p.HeldItems() - breakTime := block.BreakDuration(p.tx.Block(pos), held) + breakTime := block.BreakDuration(b, held) if !p.OnGround() { breakTime *= 5 } @@ -1918,11 +1936,22 @@ func (p *Player) breakTime(pos cube.Pos) time.Duration { // FinishBreaking will stop the animation and break the block. func (p *Player) FinishBreaking() { if !p.breaking { - p.resendNearbyBlock(p.breakingPos) + if p.breakingPosValid { + p.resendBreakingBlock(p.breakingPos, p.breakingPrivate) + p.breakingPosValid = false + } return } + pos := p.breakingPos + private := p.breakingPrivate p.AbortBreaking() - p.BreakBlock(p.breakingPos) + if private { + if _, ok := p.privateBlock(pos); !ok { + p.resendBreakingBlock(pos, false) + return + } + } + p.BreakViewedBlock(pos) } // AbortBreaking makes the player stop breaking the block it is currently breaking, or returns immediately @@ -1930,12 +1959,12 @@ func (p *Player) FinishBreaking() { // Unlike FinishBreaking, AbortBreaking does not stop the animation. func (p *Player) AbortBreaking() { if !p.breaking { + p.breakingPosValid = false return } - p.breaking, p.breakCounter = false, 0 - for _, viewer := range p.viewers() { - viewer.ViewBlockAction(p.breakingPos, block.StopCrackAction{}) - } + private := p.breakingPrivate + p.breaking, p.breakingPrivate, p.breakingPosValid, p.breakCounter = false, false, false, 0 + p.viewBreakingBlockAction(p.breakingPos, private, block.StopCrackAction{}) } // ContinueBreaking makes the player continue breaking the block it started breaking after a call to @@ -1946,24 +1975,59 @@ func (p *Player) ContinueBreaking(face cube.Face) { return } pos := p.breakingPos - b := p.tx.Block(pos) - p.tx.AddParticle(pos.Vec3(), particle.PunchBlock{Block: b, Face: face}) + private := p.breakingPrivate + var b world.Block + if private { + var ok bool + b, ok = p.privateBlock(pos) + if !ok { + p.AbortBreaking() + p.resendBreakingBlock(pos, false) + return + } + p.ShowParticle(pos.Vec3(), particle.PunchBlock{Block: b, Face: face}) + } else { + b = p.tx.Block(pos) + p.tx.AddParticle(pos.Vec3(), particle.PunchBlock{Block: b, Face: face}) + } if p.breakCounter++; p.breakCounter%5 == 0 { p.SwingArm() // We send this sound only every so often. Vanilla doesn't send it every tick while breaking // either. Every 5 ticks seems accurate. - p.tx.PlaySound(pos.Vec3(), sound.BlockBreaking{Block: b}) - } - if breakTime := p.breakTime(pos); breakTime != p.lastBreakDuration { - for _, viewer := range p.viewers() { - viewer.ViewBlockAction(pos, block.ContinueCrackAction{BreakTime: breakTime}) + if private { + p.session().ViewSound(pos.Vec3(), sound.BlockBreaking{Block: b}) + } else { + p.tx.PlaySound(pos.Vec3(), sound.BlockBreaking{Block: b}) } + } + if breakTime := p.breakTime(b); breakTime != p.lastBreakDuration { + p.viewBreakingBlockAction(pos, private, block.ContinueCrackAction{BreakTime: breakTime}) p.lastBreakDuration = breakTime } } +// viewBreakingBlockAction shows a breaking action to the player only if the block is a private view-layer +// block, or to all viewers if it is a public world block. +func (p *Player) viewBreakingBlockAction(pos cube.Pos, private bool, a world.BlockAction) { + if private { + p.session().ViewPrivateBlockAction(pos, a) + return + } + for _, viewer := range p.viewers() { + viewer.ViewBlockAction(pos, a) + } +} + +// viewedBlock returns the block currently shown to the player at pos. +func (p *Player) viewedBlock(pos cube.Pos) (world.Block, bool) { + if b, ok := p.privateBlock(pos); ok { + return b, true + } + return p.tx.Block(pos), false +} + // PlaceBlock makes the player place the block passed at the position passed, granted it is within the range // of the player. // An item.UseContext may be passed to obtain information on if the block placement was successful. (SubCount will @@ -2036,44 +2100,69 @@ func (p *Player) obstructedPos(pos cube.Pos, b world.Block) (obstructed, selfOnl return obstructed, true } -// BreakBlock makes the player break a block in the world at a position passed. If the player is unable to -// reach the block passed, the method returns immediately. +// BreakBlock makes the player break the public world block at the position passed. Private view-layer +// overrides are ignored by this method: Call BreakViewedBlock to break what the player currently sees instead. +// If the player is unable to reach the block passed, the method returns immediately. func (p *Player) BreakBlock(pos cube.Pos) { - b := p.tx.Block(pos) + p.breakBlock(pos, p.tx.Block(pos), false) +} + +// BreakViewedBlock makes the player break the block currently shown to them at the position passed. If the +// player has a private block override at that position, it is removed instead of breaking the public world block. +// If the player is unable to reach the block passed, the method returns immediately. +func (p *Player) BreakViewedBlock(pos cube.Pos) { + b, private := p.viewedBlock(pos) + p.breakBlock(pos, b, private) +} + +// breakBlock makes the player break the block passed at the position passed. Private blocks are removed +// from the player's view layer instead of the world. +func (p *Player) breakBlock(pos cube.Pos, b world.Block, private bool) { if _, air := b.(block.Air); air { // Don't do anything if the position broken is already air. return } + resendBrokenBlock := func() { + p.resendBreakingBlock(pos, private) + } if !p.canReach(pos.Vec3Centre()) || !p.GameMode().AllowsEditing() { - p.resendNearbyBlocks(pos) + resendBrokenBlock() return } - if _, breakable := b.(block.Breakable); !breakable && !p.GameMode().CreativeInventory() { - p.resendNearbyBlocks(pos) + breakable, ok := b.(block.Breakable) + if !ok && !p.GameMode().CreativeInventory() { + resendBrokenBlock() return } held, _ := p.HeldItems() - drops := p.drops(held, b) + var drops []item.Stack xp := 0 - if breakable, ok := b.(block.Breakable); ok && !p.GameMode().CreativeInventory() { - if _, hasSilkTouch := held.Enchantment(enchantment.SilkTouch); !hasSilkTouch { - xp = breakable.BreakInfo().XPDrops.RandomValue() + if !private { + drops = p.drops(held, b) + if ok && !p.GameMode().CreativeInventory() { + if _, hasSilkTouch := held.Enchantment(enchantment.SilkTouch); !hasSilkTouch { + xp = breakable.BreakInfo().XPDrops.RandomValue() + } } } ctx := event.C(p) if p.Handler().HandleBlockBreak(ctx, pos, &drops, &xp); ctx.Cancelled() { - p.resendNearbyBlocks(pos) + resendBrokenBlock() return } held, left := p.HeldItems() p.SwingArm() + if private { + p.ViewPublicBlock(pos) + p.ShowParticle(pos.Vec3Centre(), particle.BlockBreak{Block: b}) + return + } p.tx.SetBlock(pos, nil, nil) p.tx.AddParticle(pos.Vec3Centre(), particle.BlockBreak{Block: b}) - - if breakable, ok := b.(block.Breakable); ok { + if ok { info := breakable.BreakInfo() if info.BreakHandler != nil { info.BreakHandler(pos, p.tx, p) @@ -2113,6 +2202,23 @@ func (p *Player) drops(held item.Stack, b world.Block) []item.Stack { return drops } +// privateBlock returns this player's view-layer block override at pos, if present. +func (p *Player) privateBlock(pos cube.Pos) (world.Block, bool) { + if p.session() == session.Nop { + return nil, false + } + return p.ViewLayer().Block(pos) +} + +// resendBreakingBlock resends the block being broken without overwriting private view-layer overrides. +func (p *Player) resendBreakingBlock(pos cube.Pos, private bool) { + if private { + p.session().ViewLayerBlockChanged(pos) + return + } + p.resendNearbyBlocks(pos) +} + // PickBlock makes the player pick a block in the world at a position passed. If the player is unable to // pick the block, the method returns immediately. func (p *Player) PickBlock(pos cube.Pos) { @@ -2120,7 +2226,7 @@ func (p *Player) PickBlock(pos cube.Pos) { return } - b := p.tx.Block(pos) + b, _ := p.viewedBlock(pos) var pickedItem item.Stack if pi, ok := b.(block.Pickable); ok { @@ -2616,6 +2722,16 @@ func (p *Player) ViewVisibility(entity world.Entity, level world.VisibilityLevel p.session().ViewVisibility(entity, level) } +// ViewBlock overrides the public block at the position passed for this player. +func (p *Player) ViewBlock(pos cube.Pos, b world.Block) { + p.session().ViewBlock(pos, b) +} + +// ViewPublicBlock removes the block override at the position passed for this player. +func (p *Player) ViewPublicBlock(pos cube.Pos) { + p.session().ViewPublicBlock(pos) +} + // RemoveViewLayer removes all view-layer overrides of the entity for this player. func (p *Player) RemoveViewLayer(entity world.Entity) { p.session().RemoveViewLayer(entity) diff --git a/server/session/chunk.go b/server/session/chunk.go index 150142ae3..eabe2af42 100644 --- a/server/session/chunk.go +++ b/server/session/chunk.go @@ -2,6 +2,7 @@ package session import ( "bytes" + "maps" "github.com/cespare/xxhash/v2" "github.com/df-mc/dragonfly/server/block/cube" @@ -18,6 +19,7 @@ const subChunkRequests = true // ViewChunk ... func (s *Session) ViewChunk(pos world.ChunkPos, dim world.Dimension, blockEntities map[cube.Pos]world.Block, c *chunk.Chunk) { + c, blockEntities = s.applyViewLayerToChunk(pos, c, blockEntities) if !s.conn.ClientCacheEnabled() { s.sendNetworkChunk(pos, dim, c, blockEntities) return @@ -31,21 +33,31 @@ func (s *Session) ViewSubChunks(centre world.SubChunkPos, offsets []protocol.Sub entries := make([]protocol.SubChunkEntry, 0, len(offsets)) transaction := make(map[uint64]struct{}) + viewedChunks := make(map[world.ChunkPos]struct { + chunk *chunk.Chunk + blockEntities map[cube.Pos]world.Block + }) for _, offset := range offsets { ind := int16(centre.Y()) + int16(offset[1]) - int16(r[0])>>4 if ind < 0 || ind > int16(r.Height()>>4) { entries = append(entries, protocol.SubChunkEntry{Result: protocol.SubChunkResultIndexOutOfBounds, Offset: offset}) continue } - col, ok := s.chunkLoader.Chunk(world.ChunkPos{ + chunkPos := world.ChunkPos{ centre.X() + int32(offset[0]), centre.Z() + int32(offset[2]), - }) + } + col, ok := s.chunkLoader.Chunk(chunkPos) if !ok { entries = append(entries, protocol.SubChunkEntry{Result: protocol.SubChunkResultChunkNotFound, Offset: offset}) continue } - entries = append(entries, s.subChunkEntry(offset, ind, col, transaction)) + viewed, ok := viewedChunks[chunkPos] + if !ok { + viewed.chunk, viewed.blockEntities = s.applyViewLayerToChunk(chunkPos, col.Chunk, col.BlockEntities) + viewedChunks[chunkPos] = viewed + } + entries = append(entries, s.subChunkEntry(offset, ind, viewed.chunk, viewed.blockEntities, transaction)) } if s.conn.ClientCacheEnabled() && len(transaction) > 0 { s.blobMu.Lock() @@ -61,21 +73,21 @@ func (s *Session) ViewSubChunks(centre world.SubChunkPos, offsets []protocol.Sub }) } -func (s *Session) subChunkEntry(offset protocol.SubChunkOffset, ind int16, col *world.Column, transaction map[uint64]struct{}) protocol.SubChunkEntry { - chunkMap := col.HeightMap() +func (s *Session) subChunkEntry(offset protocol.SubChunkOffset, ind int16, c *chunk.Chunk, blockEntities map[cube.Pos]world.Block, transaction map[uint64]struct{}) protocol.SubChunkEntry { + chunkMap := c.HeightMap() subMapType, subMap := byte(protocol.HeightMapDataHasData), make([]int8, 256) higher, lower := true, true for x := uint8(0); x < 16; x++ { for z := uint8(0); z < 16; z++ { y, i := chunkMap.At(x, z), (uint16(z)<<4)|uint16(x) - otherInd := col.SubIndex(y) + otherInd := c.SubIndex(y) switch { case otherInd > ind: subMap[i], lower = 16, false case otherInd < ind: subMap[i], higher = -1, false default: - subMap[i], lower, higher = int8(y-col.SubY(otherInd)), false, false + subMap[i], lower, higher = int8(y-c.SubY(otherInd)), false, false } } } @@ -85,7 +97,7 @@ func (s *Session) subChunkEntry(offset protocol.SubChunkOffset, ind int16, col * subMapType, subMap = protocol.HeightMapDataTooLow, nil } - sub := col.Sub()[ind] + sub := c.Sub()[ind] if sub.Empty() { return protocol.SubChunkEntry{ Result: protocol.SubChunkResultSuccessAllAir, @@ -97,12 +109,12 @@ func (s *Session) subChunkEntry(offset protocol.SubChunkOffset, ind int16, col * } } - serialisedSubChunk := chunk.EncodeSubChunk(col.Chunk, chunk.NetworkEncoding, int(ind)) + serialisedSubChunk := chunk.EncodeSubChunk(c, chunk.NetworkEncoding, int(ind)) blockEntityBuf := bytes.NewBuffer(nil) enc := nbt.NewEncoderWithEncoding(blockEntityBuf, nbt.NetworkLittleEndian) - for pos, b := range col.BlockEntities { - if n, ok := b.(world.NBTer); ok && col.SubIndex(int16(pos.Y())) == ind { + for pos, b := range blockEntities { + if n, ok := b.(world.NBTer); ok && c.SubIndex(int16(pos.Y())) == ind { d := n.EncodeNBT() d["x"], d["y"], d["z"] = int32(pos[0]), int32(pos[1]), int32(pos[2]) _ = enc.Encode(d) @@ -129,6 +141,38 @@ func (s *Session) subChunkEntry(offset protocol.SubChunkOffset, ind int16, col * return entry } +// applyViewLayerToChunk returns a chunk and block entity map with this session's view-layer block overrides applied. +func (s *Session) applyViewLayerToChunk(pos world.ChunkPos, c *chunk.Chunk, blockEntities map[cube.Pos]world.Block) (*chunk.Chunk, map[cube.Pos]world.Block) { + if s.viewLayer == nil { + return c, blockEntities + } + overrides := s.viewLayer.ChunkBlocks(pos) + if len(overrides) == 0 { + return c, blockEntities + } + + var cloned bool + for blockPos, b := range overrides { + if blockPos.OutOfBounds(c.Range()) { + continue + } + if !cloned { + c = c.Clone() + blockEntities = maps.Clone(blockEntities) + cloned = true + } + x, y, z := uint8(blockPos[0]), int16(blockPos[1]), uint8(blockPos[2]) + c.SetBlock(x, y, z, 0, s.br.BlockRuntimeID(b)) + c.SetBlock(x, y, z, 1, s.br.AirRuntimeID()) + if _, ok := b.(world.NBTer); ok { + blockEntities[blockPos] = b + } else { + delete(blockEntities, blockPos) + } + } + return c, blockEntities +} + // dimensionID returns the dimension ID of the world that the session is in. func (s *Session) dimensionID(dim world.Dimension) int32 { d, _ := world.DimensionID(dim) diff --git a/server/session/controllable.go b/server/session/controllable.go index 8a924bf60..cffa63070 100644 --- a/server/session/controllable.go +++ b/server/session/controllable.go @@ -57,6 +57,7 @@ type Controllable interface { UseItemOnBlock(pos cube.Pos, face cube.Face, clickPos mgl64.Vec3) UseItemOnEntity(e world.Entity) bool BreakBlock(pos cube.Pos) + BreakViewedBlock(pos cube.Pos) PickBlock(pos cube.Pos) AttackEntity(e world.Entity) bool Drop(s item.Stack) (n int) diff --git a/server/session/handler_inventory_transaction.go b/server/session/handler_inventory_transaction.go index 9cb4c3820..f69d7c277 100644 --- a/server/session/handler_inventory_transaction.go +++ b/server/session/handler_inventory_transaction.go @@ -187,7 +187,7 @@ func (h *InventoryTransactionHandler) handleUseItemTransaction(data *protocol.Us switch data.ActionType { case protocol.UseItemActionBreakBlock: - c.BreakBlock(pos) + c.BreakViewedBlock(pos) case protocol.UseItemActionClickBlock: c.UseItemOnBlock(pos, cube.Face(data.BlockFace), vec32To64(data.ClickedPosition)) case protocol.UseItemActionClickAir: diff --git a/server/session/handler_player_auth_input.go b/server/session/handler_player_auth_input.go index f75fcba2f..23c9fd907 100644 --- a/server/session/handler_player_auth_input.go +++ b/server/session/handler_player_auth_input.go @@ -164,7 +164,7 @@ func (h PlayerAuthInputHandler) handleUseItemData(data protocol.UseItemTransacti // Seems like this is only used for breaking blocks at the moment. switch data.ActionType { case protocol.UseItemActionBreakBlock: - c.BreakBlock(pos) + c.BreakViewedBlock(pos) default: return fmt.Errorf("unhandled UseItem ActionType for PlayerAuthInput packet %v", data.ActionType) } diff --git a/server/session/view_layer.go b/server/session/view_layer.go index 632b58012..a444eb368 100644 --- a/server/session/view_layer.go +++ b/server/session/view_layer.go @@ -1,6 +1,9 @@ package session -import "github.com/df-mc/dragonfly/server/world" +import ( + "github.com/df-mc/dragonfly/server/block/cube" + "github.com/df-mc/dragonfly/server/world" +) // ViewLayer returns the session's ViewLayer. The layer may be used to override how entities are viewed // by this session, such as with a different name tag or visibility state. @@ -48,6 +51,22 @@ func (s *Session) ViewVisibility(entity world.Entity, level world.VisibilityLeve s.viewLayer.ViewVisibility(entity, level) } +// ViewBlock overwrites the public block at the position passed and immediately refreshes it for this session. +func (s *Session) ViewBlock(pos cube.Pos, b world.Block) { + if s.viewLayer == nil { + return + } + s.viewLayer.ViewBlock(pos, b) +} + +// ViewPublicBlock removes the block override at the position passed and immediately refreshes it for this session. +func (s *Session) ViewPublicBlock(pos cube.Pos) { + if s.viewLayer == nil { + return + } + s.viewLayer.ViewPublicBlock(pos) +} + // RemoveViewLayer removes all overrides for the entity and immediately refreshes it for this session. func (s *Session) RemoveViewLayer(entity world.Entity) { if s.viewLayer == nil { @@ -64,6 +83,57 @@ func (s *Session) ViewLayerEntityChanged(e world.Entity) { s.ViewEntityState(e) } +// ViewLayerBlockChanged refreshes a block override for this session if its chunk is currently visible. +func (s *Session) ViewLayerBlockChanged(pos cube.Pos) { + if s.viewLayer == nil || !s.viewingBlock(pos) { + return + } + if b, ok := s.viewLayer.Block(pos); ok { + s.broadcastPrivateBlockSubChunk(pos) + s.viewBlockUpdate(pos, b, 0) + s.viewBlockUpdate(pos, s.br.Air(), 1) + return + } + if b, ok := s.publicBlock(pos); ok { + s.viewBlockUpdate(pos, b, 0) + s.viewBlockUpdate(pos, s.publicLiquid(pos), 1) + } +} + +// broadcastPrivateBlockSubChunk resends the chunk height advert if a private block override occupies a +// sub-chunk the client may not have loaded from the public chunk state. +func (s *Session) broadcastPrivateBlockSubChunk(pos cube.Pos) { + if !subChunkRequests || s.chunkLoader == nil { + return + } + chunkPos := world.ChunkPos{int32(pos[0] >> 4), int32(pos[2] >> 4)} + col, ok := s.chunkLoader.Chunk(chunkPos) + if !ok || pos.OutOfBounds(col.Range()) { + return + } + if uint16(col.SubIndex(int16(pos[1]))) <= col.HighestFilledSubChunk() { + return + } + w := s.chunkLoader.World() + if w == nil { + return + } + c, blockEntities := s.applyViewLayerToChunk(chunkPos, col.Chunk, col.BlockEntities) + s.sendNetworkChunk(chunkPos, w.Dimension(), c, blockEntities) +} + +// publicLiquid returns the public liquid layer loaded for this session at pos, or air if no liquid is present. +func (s *Session) publicLiquid(pos cube.Pos) world.Block { + if s.chunkLoader == nil { + return s.br.Air() + } + col, ok := s.chunkLoader.Chunk(world.ChunkPos{int32(pos[0] >> 4), int32(pos[2] >> 4)}) + if !ok || pos.OutOfBounds(col.Range()) { + return s.br.Air() + } + return s.br.BlockByRuntimeIDOrAir(col.Block(uint8(pos[0]), int16(pos[1]), uint8(pos[2]), 1)) +} + // viewingEntity checks if this session currently has a runtime ID assigned to the entity handle. func (s *Session) viewingEntity(handle *world.EntityHandle) bool { s.entityMutex.RLock() @@ -71,3 +141,30 @@ func (s *Session) viewingEntity(handle *world.EntityHandle) bool { s.entityMutex.RUnlock() return ok } + +// viewingBlock returns true if the block position is loaded for this session. +func (s *Session) viewingBlock(pos cube.Pos) bool { + if s.chunkLoader == nil { + return false + } + col, ok := s.chunkLoader.Chunk(world.ChunkPos{int32(pos[0] >> 4), int32(pos[2] >> 4)}) + if !ok { + return false + } + return !pos.OutOfBounds(col.Range()) +} + +// publicBlock returns the public block loaded for this session at pos. +func (s *Session) publicBlock(pos cube.Pos) (world.Block, bool) { + if s.chunkLoader == nil { + return nil, false + } + col, ok := s.chunkLoader.Chunk(world.ChunkPos{int32(pos[0] >> 4), int32(pos[2] >> 4)}) + if !ok || pos.OutOfBounds(col.Range()) { + return nil, false + } + if b, ok := col.BlockEntities[pos]; ok { + return b, true + } + return s.br.BlockByRuntimeID(col.Block(uint8(pos[0]), int16(pos[1]), uint8(pos[2]), 0)) +} diff --git a/server/session/world.go b/server/session/world.go index 1ecdfb728..89a874ee1 100644 --- a/server/session/world.go +++ b/server/session/world.go @@ -384,6 +384,7 @@ func (s *Session) ViewParticle(pos mgl64.Vec3, p world.Particle) { s.writePacket(&packet.LevelEvent{ EventType: packet.LevelEventParticleCropGrowth, Position: vec64To32(pos), + EventData: int32(boolByte(pa.Area)), }) case particle.BlockForceField: s.writePacket(&packet.LevelEvent{ @@ -959,6 +960,20 @@ func (s *Session) ViewBrewingUpdate(prevBrewTime, brewTime time.Duration, prevFu // ViewBlockUpdate ... func (s *Session) ViewBlockUpdate(pos cube.Pos, b world.Block, layer int) { + if s.viewLayer != nil { + if viewed, ok := s.viewLayer.Block(pos); ok { + if layer == 0 { + b = viewed + } else { + b = s.br.Air() + } + } + } + s.viewBlockUpdate(pos, b, layer) +} + +// viewBlockUpdate sends a block update to the session without applying view-layer overrides. +func (s *Session) viewBlockUpdate(pos cube.Pos, b world.Block, layer int) { blockPos := protocol.BlockPos{int32(pos[0]), int32(pos[1]), int32(pos[2])} s.writePacket(&packet.UpdateBlock{ Position: blockPos, @@ -1244,6 +1259,21 @@ func (s *Session) ViewSlotChange(slot int, newItem item.Stack) { // ViewBlockAction ... func (s *Session) ViewBlockAction(pos cube.Pos, a world.BlockAction) { + if s.viewLayer != nil { + if _, ok := s.viewLayer.Block(pos); ok { + return + } + } + s.viewBlockAction(pos, a) +} + +// ViewPrivateBlockAction views an action performed by a private view-layer block. +func (s *Session) ViewPrivateBlockAction(pos cube.Pos, a world.BlockAction) { + s.viewBlockAction(pos, a) +} + +// viewBlockAction sends a block action without applying view-layer filters. +func (s *Session) viewBlockAction(pos cube.Pos, a world.BlockAction) { blockPos := protocol.BlockPos{int32(pos[0]), int32(pos[1]), int32(pos[2])} switch t := a.(type) { case block.OpenAction: diff --git a/server/world/chunk/chunk.go b/server/world/chunk/chunk.go index 17ef1f10f..2d09ccd51 100644 --- a/server/world/chunk/chunk.go +++ b/server/world/chunk/chunk.go @@ -49,6 +49,26 @@ func New(br BlockRegistry, r cube.Range) *Chunk { } } +// Clone returns an independent copy of the Chunk. +func (chunk *Chunk) Clone() *Chunk { + clone := &Chunk{ + r: chunk.r, + br: chunk.br, + air: chunk.air, + recalculateHeightMap: chunk.recalculateHeightMap, + heightMap: slices.Clone(chunk.heightMap), + sub: make([]*SubChunk, len(chunk.sub)), + biomes: make([]*PalettedStorage, len(chunk.biomes)), + } + for i, sub := range chunk.sub { + clone.sub[i] = sub.Clone() + } + for i, biomes := range chunk.biomes { + clone.biomes[i] = biomes.Clone() + } + return clone +} + // Equals returns if the chunk passed is equal to the current one func (chunk *Chunk) Equals(c *Chunk) bool { if !chunk.recalculateHeightMap && !c.recalculateHeightMap && !slices.Equal(c.heightMap, chunk.heightMap) { diff --git a/server/world/chunk/palette.go b/server/world/chunk/palette.go index 3b3d32b17..f3121f4f5 100644 --- a/server/world/chunk/palette.go +++ b/server/world/chunk/palette.go @@ -2,6 +2,7 @@ package chunk import ( "math" + "slices" ) // paletteSize is the size of a palette. It indicates the amount of bits occupied per value stored. @@ -23,6 +24,16 @@ func newPalette(size paletteSize, values []uint32) *Palette { return &Palette{size: size, values: values, last: math.MaxUint32} } +// Clone returns an independent copy of the Palette. +func (palette *Palette) Clone() *Palette { + return &Palette{ + last: palette.last, + lastIndex: palette.lastIndex, + size: palette.size, + values: slices.Clone(palette.values), + } +} + // Len returns the amount of unique values in the Palette. func (palette *Palette) Len() int { return len(palette.values) diff --git a/server/world/chunk/paletted_storage.go b/server/world/chunk/paletted_storage.go index 92153e7fb..ac49c67c4 100644 --- a/server/world/chunk/paletted_storage.go +++ b/server/world/chunk/paletted_storage.go @@ -2,6 +2,7 @@ package chunk import ( "bytes" + "slices" "unsafe" ) @@ -59,6 +60,11 @@ func emptyStorage(v uint32) *PalettedStorage { return newPalettedStorage([]uint32{}, newPalette(0, []uint32{v})) } +// Clone returns an independent copy of the PalettedStorage. +func (storage *PalettedStorage) Clone() *PalettedStorage { + return newPalettedStorage(slices.Clone(storage.indices), storage.palette.Clone()) +} + // Palette returns the Palette of the PalettedStorage. func (storage *PalettedStorage) Palette() *Palette { return storage.palette @@ -164,7 +170,7 @@ func (storage *PalettedStorage) resize(newPaletteSize paletteSize) { // relatively heavy task which should only happen right before the sub chunk holding this PalettedStorage is // saved to disk. compact also shrinks the palette size if possible. func (storage *PalettedStorage) compact() { - if storage.palette == nil || storage.palette.Len() == 0 { + if storage.palette.Len() == 0 { return } if storage.palette.Len() == 1 { diff --git a/server/world/chunk/sub_chunk.go b/server/world/chunk/sub_chunk.go index 21436cecb..7a06680de 100644 --- a/server/world/chunk/sub_chunk.go +++ b/server/world/chunk/sub_chunk.go @@ -1,5 +1,7 @@ package chunk +import "slices" + // SubChunk is a cube of blocks located in a chunk. It has a size of 16x16x16 blocks and forms part of a stack // that forms a Chunk. type SubChunk struct { @@ -29,6 +31,34 @@ func NewSubChunk(air uint32) *SubChunk { return &SubChunk{air: air} } +// Clone returns an independent copy of the SubChunk. +func (sub *SubChunk) Clone() *SubChunk { + clone := &SubChunk{ + air: sub.air, + storages: make([]*PalettedStorage, len(sub.storages)), + blockLight: cloneLight(sub.blockLight), + skyLight: cloneLight(sub.skyLight), + } + for i, storage := range sub.storages { + clone.storages[i] = storage.Clone() + } + return clone +} + +func cloneLight(light []uint8) []uint8 { + if len(light) == 0 { + return slices.Clone(light) + } + switch &light[0] { + case noLightPtr: + return noLight + case fullLightPtr: + return fullLight + default: + return slices.Clone(light) + } +} + // Empty checks if the SubChunk is considered empty. This is the case if the SubChunk has 0 block storages or if it has // a single one that is completely filled with air. func (sub *SubChunk) Empty() bool { diff --git a/server/world/particle/block.go b/server/world/particle/block.go index 43130e2b3..71133aaa4 100644 --- a/server/world/particle/block.go +++ b/server/world/particle/block.go @@ -1,11 +1,12 @@ package particle import ( + "image/color" + "github.com/df-mc/dragonfly/server/block/cube" "github.com/df-mc/dragonfly/server/world" "github.com/df-mc/dragonfly/server/world/sound" "github.com/go-gl/mathgl/mgl64" - "image/color" ) // Flame is a particle shown around torches. It can have any colour specified with the Colour field. @@ -49,7 +50,14 @@ type PunchBlock struct { type BlockForceField struct{ particle } // BoneMeal is a particle that shows up on bone meal usage. -type BoneMeal struct{ particle } +type BoneMeal struct { + particle + + // Area specifies whether the particle effect should be for area. If false, + // a small burst is used for minor growth. If true, a large burst is used + // for significant growth. + Area bool +} // Note is a particle that shows up on note block interactions. type Note struct { diff --git a/server/world/view_layer.go b/server/world/view_layer.go index cb9098f1b..d2b160fa3 100644 --- a/server/world/view_layer.go +++ b/server/world/view_layer.go @@ -4,6 +4,8 @@ import ( "maps" "slices" "sync" + + "github.com/df-mc/dragonfly/server/block/cube" ) // layer stores the appearance overrides that a ViewLayer applies to an entity. @@ -19,6 +21,11 @@ type ViewLayerUpdater interface { ViewLayerEntityChanged(entity Entity) } +type viewLayerBlockUpdater interface { + // ViewLayerBlockChanged handles a block whose view-layer override changed. + ViewLayerBlockChanged(pos cube.Pos) +} + type viewLayerViewer interface { ViewLayer() *ViewLayer } @@ -26,16 +33,18 @@ type viewLayerViewer interface { // ViewLayer holds overrides for how entities are viewed by a single viewer. It allows entities to be // viewed differently by different players, such as with a different name tag or visibility state. type ViewLayer struct { - mu sync.RWMutex - entities map[*EntityHandle]layer - updater ViewLayerUpdater + mu sync.RWMutex + entities map[*EntityHandle]layer + blocksByChunk map[ChunkPos]map[cube.Pos]Block + updater ViewLayerUpdater } // NewViewLayer returns a new ViewLayer. func NewViewLayer(updater ViewLayerUpdater) *ViewLayer { return &ViewLayer{ - entities: map[*EntityHandle]layer{}, - updater: updater, + entities: map[*EntityHandle]layer{}, + blocksByChunk: map[ChunkPos]map[cube.Pos]Block{}, + updater: updater, } } @@ -119,6 +128,66 @@ func (v *ViewLayer) Visibility(entity Entity) VisibilityLevel { return v.entities[entity.H()].visibility } +// ViewBlock overwrites the public block at the position passed for this ViewLayer. Liquid or waterlogged +// state at layer 1 is not represented for overrides. Passing nil removes the block override, causing the +// public block to be viewed again. +func (v *ViewLayer) ViewBlock(pos cube.Pos, b Block) { + v.mu.Lock() + chunkPos := ChunkPos{int32(pos[0] >> 4), int32(pos[2] >> 4)} + if b == nil { + delete(v.blocksByChunk[chunkPos], pos) + if len(v.blocksByChunk[chunkPos]) == 0 { + delete(v.blocksByChunk, chunkPos) + } + } else { + if v.blocksByChunk[chunkPos] == nil { + v.blocksByChunk[chunkPos] = map[cube.Pos]Block{} + } + v.blocksByChunk[chunkPos][pos] = b + } + v.mu.Unlock() + + v.refreshBlock(pos) +} + +// ViewPublicBlock removes the block override at the position passed, causing the public block to be viewed again. +func (v *ViewLayer) ViewPublicBlock(pos cube.Pos) { + v.ViewBlock(pos, nil) +} + +// Block returns the overwritten block at the position passed and whether an override was set. +func (v *ViewLayer) Block(pos cube.Pos) (Block, bool) { + v.mu.RLock() + defer v.mu.RUnlock() + + b, ok := v.blocksByChunk[ChunkPos{int32(pos[0] >> 4), int32(pos[2] >> 4)}][pos] + return b, ok +} + +// Blocks returns all block overrides in the view layer. +func (v *ViewLayer) Blocks() map[cube.Pos]Block { + v.mu.RLock() + defer v.mu.RUnlock() + + blocks := make(map[cube.Pos]Block) + for _, chunkBlocks := range v.blocksByChunk { + maps.Copy(blocks, chunkBlocks) + } + return blocks +} + +// ChunkBlocks returns all block overrides in a chunk. +func (v *ViewLayer) ChunkBlocks(pos ChunkPos) map[cube.Pos]Block { + v.mu.RLock() + defer v.mu.RUnlock() + + blocks := v.blocksByChunk[pos] + if len(blocks) == 0 { + return nil + } + return maps.Clone(blocks) +} + // Remove removes all overrides for the entity from the ViewLayer. func (v *ViewLayer) Remove(entity Entity) { if v.remove(entity) { @@ -162,6 +231,7 @@ func (v *ViewLayer) Close() error { defer v.mu.Unlock() clear(v.entities) + clear(v.blocksByChunk) return nil } @@ -175,3 +245,9 @@ func (v *ViewLayer) refresh(entity Entity) { v.updater.ViewLayerEntityChanged(entity) } } + +func (v *ViewLayer) refreshBlock(pos cube.Pos) { + if updater, ok := v.updater.(viewLayerBlockUpdater); ok { + updater.ViewLayerBlockChanged(pos) + } +}