diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml index b9f1cf130a..2dae4541e5 100644 --- a/.github/workflows/pr.yml +++ b/.github/workflows/pr.yml @@ -17,3 +17,6 @@ jobs: - name: Lint run: make lint + + - name: Test + run: go test ./... diff --git a/.github/workflows/push.yml b/.github/workflows/push.yml index 2107ea1ff6..c0955b2503 100644 --- a/.github/workflows/push.yml +++ b/.github/workflows/push.yml @@ -18,6 +18,9 @@ jobs: - name: Lint run: make lint + - name: Test + run: go test ./... + deploy: name: Deploy needs: build diff --git a/go.mod b/go.mod index 9afcbf709c..381b9dfcd5 100644 --- a/go.mod +++ b/go.mod @@ -6,11 +6,11 @@ require ( github.com/brentp/intintmap v0.0.0-20251106190759-56907b1f8479 github.com/cespare/xxhash/v2 v2.3.0 github.com/df-mc/goleveldb v1.1.9 - github.com/df-mc/worldupgrader v1.0.20 + github.com/df-mc/worldupgrader v1.0.21 github.com/go-gl/mathgl v1.2.0 github.com/google/uuid v1.6.0 github.com/pelletier/go-toml v1.9.5 - github.com/sandertv/gophertunnel v1.56.2 + github.com/sandertv/gophertunnel v1.57.0 github.com/segmentio/fasthash v1.0.3 golang.org/x/exp v0.0.0-20250103183323-7d7fa50e5329 golang.org/x/mod v0.22.0 diff --git a/go.sum b/go.sum index 17f22d8693..4bc90dae7e 100644 --- a/go.sum +++ b/go.sum @@ -12,8 +12,8 @@ github.com/df-mc/goleveldb v1.1.9 h1:ihdosZyy5jkQKrxucTQmN90jq/2lUwQnJZjIYIC/9YU github.com/df-mc/goleveldb v1.1.9/go.mod h1:+NHCup03Sci5q84APIA21z3iPZCuk6m6ABtg4nANCSk= github.com/df-mc/jsonc v1.0.5 h1:O7oh07kbS5AYY+l2Fji6l4h0iHcdjKbxCtK5VlZlLMU= github.com/df-mc/jsonc v1.0.5/go.mod h1:+Q++JuCE9IKiP8v7sWImdf/RjQX0nfXyfX6PdfTTmc4= -github.com/df-mc/worldupgrader v1.0.20 h1:wfJyG3bFeaM/HXy7TCiO4HKVw3Mf3N4gPFmgxMHsKnc= -github.com/df-mc/worldupgrader v1.0.20/go.mod h1:tsSOLTRm9mpG7VHvYpAjjZrkRHWmSbKZAm9bOLNnlDk= +github.com/df-mc/worldupgrader v1.0.21 h1:Qr4/QB8ek7En0vkTuRXYq4FrZM0HHSOXsJOL7Ko4Cjg= +github.com/df-mc/worldupgrader v1.0.21/go.mod h1:tsSOLTRm9mpG7VHvYpAjjZrkRHWmSbKZAm9bOLNnlDk= github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo= github.com/go-gl/mathgl v1.2.0 h1:v2eOj/y1B2afDxF6URV1qCYmo1KW08lAMtTbOn3KXCY= github.com/go-gl/mathgl v1.2.0/go.mod h1:pf9+b5J3LFP7iZ4XXaVzZrCle0Q/vNpB/vDe5+3ulRE= @@ -41,8 +41,8 @@ github.com/pelletier/go-toml v1.9.5 h1:4yBQzkHv+7BHq2PQUZF3Mx0IYxG7LsP222s7Agd3v github.com/pelletier/go-toml v1.9.5/go.mod h1:u1nR/EPcESfeI/szUZKdtJ0xRNbUoANCkoOuaOx1Y+c= github.com/sandertv/go-raknet v1.15.1-0.20260112202637-beca0b10c217 h1:UZQq2253Q+7co/C9Et62RYPBggzz+L+2yqGlvQhSNM8= github.com/sandertv/go-raknet v1.15.1-0.20260112202637-beca0b10c217/go.mod h1:/yysjwfCXm2+2OY8mBazLzcxJ3irnylKCyG3FLgUPVU= -github.com/sandertv/gophertunnel v1.56.2 h1:eFc58AkMQo43ntR0Wmvz8GRFSdOgABKVDn52GMbIYag= -github.com/sandertv/gophertunnel v1.56.2/go.mod h1:F8+ZPbzxJ0LqunXEaDjqeyUgHVB0rI5ZU+PHnptXGfI= +github.com/sandertv/gophertunnel v1.57.0 h1:UkgVg1xLCsOSm79rP09WmodGSHgA8M7+l4quL01cIL8= +github.com/sandertv/gophertunnel v1.57.0/go.mod h1:W4VnrX9AIPIVXNDMEIKMIRj1T80EdOgdqXpGbQpyAbE= github.com/segmentio/fasthash v1.0.3 h1:EI9+KE1EwvMLBWwjpRDc+fEM+prwxDYbslddQGtrmhM= github.com/segmentio/fasthash v1.0.3/go.mod h1:waKX8l2N8yckOgmSsXJi7x1ZfdKZ4x7KRMzBtS3oedY= golang.org/x/exp v0.0.0-20250103183323-7d7fa50e5329 h1:9kj3STMvgqy3YA4VQXBrN7925ICMxD5wzMRcgA30588= diff --git a/server/block/air.go b/server/block/air.go index 895946b6d5..e1840a2942 100644 --- a/server/block/air.go +++ b/server/block/air.go @@ -1,5 +1,7 @@ package block +import "github.com/df-mc/dragonfly/server/world" + // Air is the block present in otherwise empty space. type Air struct { empty @@ -12,6 +14,11 @@ func (Air) HasLiquidDrops() bool { return false } +// PortalInterior returns true if air may occupy the inside of a portal frame before activation for the target dimension. +func (Air) PortalInterior(target world.Dimension) bool { + return target == world.Nether +} + // EncodeItem ... func (Air) EncodeItem() (name string, meta int16) { return "minecraft:air", 0 diff --git a/server/block/bamboo_block.go b/server/block/bamboo_block.go new file mode 100644 index 0000000000..c03a1a03ff --- /dev/null +++ b/server/block/bamboo_block.go @@ -0,0 +1,78 @@ +package block + +import ( + "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" + "time" +) + +// BambooBlock is a rotatable flammable block made from bamboo. +type BambooBlock struct { + solid + bass + + // Axis is the axis which the bamboo block faces. + Axis cube.Axis + // Stripped specifies if the bamboo block is stripped. + Stripped bool +} + +// FlammabilityInfo ... +func (BambooBlock) FlammabilityInfo() FlammabilityInfo { + return newFlammabilityInfo(5, 5, true) +} + +// BreakInfo ... +func (b BambooBlock) BreakInfo() BreakInfo { + return newBreakInfo(2.0, alwaysHarvestable, axeEffective, oneOf(b)) +} + +// FuelInfo ... +func (BambooBlock) FuelInfo() item.FuelInfo { + return newFuelInfo(time.Second * 15) +} + +// UseOnBlock ... +func (b BambooBlock) UseOnBlock(pos cube.Pos, face cube.Face, _ mgl64.Vec3, tx *world.Tx, user item.User, ctx *item.UseContext) (used bool) { + pos, face, used = firstReplaceable(tx, pos, face, b) + if !used { + return + } + b.Axis = face.Axis() + + place(tx, pos, b, user, ctx) + return placed(ctx) +} + +// Strip ... +func (b BambooBlock) Strip() (world.Block, world.Sound, bool) { + return BambooBlock{Axis: b.Axis, Stripped: true}, nil, !b.Stripped +} + +// EncodeItem ... +func (b BambooBlock) EncodeItem() (name string, meta int16) { + if b.Stripped { + return "minecraft:stripped_bamboo_block", 0 + } + return "minecraft:bamboo_block", 0 +} + +// EncodeBlock ... +func (b BambooBlock) EncodeBlock() (name string, properties map[string]any) { + meta := map[string]any{"pillar_axis": b.Axis.String()} + if b.Stripped { + return "minecraft:stripped_bamboo_block", meta + } + return "minecraft:bamboo_block", meta +} + +// allBambooBlocks ... +func allBambooBlocks() (blocks []world.Block) { + for _, axis := range cube.Axes() { + blocks = append(blocks, BambooBlock{Axis: axis}) + blocks = append(blocks, BambooBlock{Axis: axis, Stripped: true}) + } + return +} diff --git a/server/block/bamboo_mosaic.go b/server/block/bamboo_mosaic.go new file mode 100644 index 0000000000..5bd89fda28 --- /dev/null +++ b/server/block/bamboo_mosaic.go @@ -0,0 +1,42 @@ +package block + +import ( + "github.com/df-mc/dragonfly/server/item" + "time" +) + +// BambooMosaic is a decorative bamboo plank variant. +type BambooMosaic struct { + solid + bass +} + +// FlammabilityInfo ... +func (BambooMosaic) FlammabilityInfo() FlammabilityInfo { + return newFlammabilityInfo(5, 20, true) +} + +// BreakInfo ... +func (b BambooMosaic) BreakInfo() BreakInfo { + return newBreakInfo(2, alwaysHarvestable, axeEffective, oneOf(b)).withBlastResistance(15) +} + +// RepairsWoodTools ... +func (BambooMosaic) RepairsWoodTools() bool { + return true +} + +// FuelInfo ... +func (BambooMosaic) FuelInfo() item.FuelInfo { + return newFuelInfo(time.Second * 15) +} + +// EncodeItem ... +func (BambooMosaic) EncodeItem() (name string, meta int16) { + return "minecraft:bamboo_mosaic", 0 +} + +// EncodeBlock ... +func (BambooMosaic) EncodeBlock() (string, map[string]any) { + return "minecraft:bamboo_mosaic", nil +} diff --git a/server/block/beetroot_seeds.go b/server/block/beetroot_seeds.go index b0f6516df7..f6f13ddf89 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 c7d559e4aa..834fa35aa8 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 bc2a4048b9..bf19b07166 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/axis.go b/server/block/cube/axis.go index dd94d46ded..fa39ca40d8 100644 --- a/server/block/cube/axis.go +++ b/server/block/cube/axis.go @@ -44,6 +44,20 @@ func (a Axis) RotateRight() Axis { return a.RotateLeft() } +// Faces returns the negative and positive Face along the Axis. For X it +// returns FaceWest, FaceEast; for Y, FaceDown, FaceUp; for Z, FaceNorth, +// FaceSouth. +func (a Axis) Faces() (negative, positive Face) { + switch a { + case X: + return FaceWest, FaceEast + case Y: + return FaceDown, FaceUp + default: + return FaceNorth, FaceSouth + } +} + // Vec3 returns a unit Vec3 of either (1, 0, 0), (0, 1, 0) or (0, 0, 1), // depending on the Axis. func (a Axis) Vec3() mgl64.Vec3 { diff --git a/server/block/cube/face.go b/server/block/cube/face.go index d08286b64c..8076733f43 100644 --- a/server/block/cube/face.go +++ b/server/block/cube/face.go @@ -1,5 +1,7 @@ package cube +import "github.com/go-gl/mathgl/mgl64" + const ( // FaceDown represents the bottom face of a block. FaceDown Face = iota @@ -89,6 +91,25 @@ func (f Face) RotateLeft() Face { return f } +// Offset returns the position offset of the Face. +func (f Face) Offset() mgl64.Vec3 { + switch f { + case FaceUp: + return mgl64.Vec3{0, 1, 0} + case FaceDown: + return mgl64.Vec3{0, -1, 0} + case FaceNorth: + return mgl64.Vec3{0, 0, -1} + case FaceSouth: + return mgl64.Vec3{0, 0, 1} + case FaceWest: + return mgl64.Vec3{-1, 0, 0} + case FaceEast: + return mgl64.Vec3{1, 0, 0} + } + panic("invalid face") +} + // String returns the Face as a string. func (f Face) String() string { switch f { diff --git a/server/block/cube/pos.go b/server/block/cube/pos.go index 2c87d8659a..99bb72000d 100644 --- a/server/block/cube/pos.go +++ b/server/block/cube/pos.go @@ -96,21 +96,29 @@ func (p Pos) Side(face Face) Pos { // Face returns the face that the other Pos was on compared to the current Pos. // The other Pos is assumed to be a direct neighbour of the current Pos. func (p Pos) Face(other Pos) Face { - switch other { - case p.Add(Pos{0, 1}): - return FaceUp - case p.Add(Pos{0, -1}): - return FaceDown - case p.Add(Pos{0, 0, -1}): - return FaceNorth - case p.Add(Pos{0, 0, 1}): - return FaceSouth - case p.Add(Pos{-1, 0, 0}): - return FaceWest - case p.Add(Pos{1, 0, 0}): - return FaceEast + face, _ := p.NeighbourFace(other) + return face +} + +// NeighbourFace returns the face that the other Pos was on compared to the +// current Pos, if the other Pos is a direct neighbour of the current Pos. +// Example: Pos{0, 0, 0}.NeighbourFace(Pos{0, 1, 0}) returns FaceUp, true. +func (p Pos) NeighbourFace(other Pos) (Face, bool) { + switch other.Sub(p) { + case Pos{0, 1, 0}: + return FaceUp, true + case Pos{0, -1, 0}: + return FaceDown, true + case Pos{0, 0, -1}: + return FaceNorth, true + case Pos{0, 0, 1}: + return FaceSouth, true + case Pos{-1, 0, 0}: + return FaceWest, true + case Pos{1, 0, 0}: + return FaceEast, true } - return FaceUp + return FaceUp, false } // Neighbours calls the function passed for each of the block position's diff --git a/server/block/cube/trace/bbox.go b/server/block/cube/trace/bbox.go index 249bef5bc0..aef3bbf272 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 e9a0f2ce3f..3139151ebd 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 c957484931..00dbc7d28b 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 4317feb934..88479a50d2 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/fire.go b/server/block/fire.go index 59a6052bed..380b32af36 100644 --- a/server/block/fire.go +++ b/server/block/fire.go @@ -3,13 +3,15 @@ package block //lint:file-ignore ST1022 Exported variables in this package have compiler directives. These variables are not otherwise exposed to users. import ( + "math/rand/v2" + "time" + "github.com/df-mc/dragonfly/server/block/cube" "github.com/df-mc/dragonfly/server/event" "github.com/df-mc/dragonfly/server/item" "github.com/df-mc/dragonfly/server/item/enchantment" "github.com/df-mc/dragonfly/server/world" - "math/rand/v2" - "time" + "github.com/df-mc/dragonfly/server/world/portal" ) // Fire is a non-solid block that can spread to nearby flammable blocks. @@ -185,6 +187,9 @@ func (f Fire) spread(from, to cube.Pos, tx *world.Tx, r *rand.Rand) { return } spread := Fire{Type: f.Type, Age: min(15, f.Age+r.IntN(5)/4)} + if spread.Type == NormalFire() && portal.ActivateNetherPortal(tx, to) { + return + } tx.SetBlock(to, spread, nil) tx.ScheduleBlockUpdate(to, spread, time.Duration(30+r.IntN(10))*time.Second/20) } @@ -239,6 +244,11 @@ func (f Fire) HasLiquidDrops() bool { return false } +// PortalInterior returns true if fire may occupy the inside of a portal frame before activation for the target dimension. +func (f Fire) PortalInterior(target world.Dimension) bool { + return target == world.Nether && f.Type == NormalFire() +} + // LightEmissionLevel ... func (f Fire) LightEmissionLevel() uint8 { return f.Type.LightLevel() @@ -265,6 +275,9 @@ func (f Fire) Start(tx *world.Tx, pos cube.Pos) { if air || shortGrass || fern { below := tx.Block(pos.Side(cube.FaceDown)) if below.Model().FaceSolid(pos, cube.FaceUp, tx) || neighboursFlammable(pos, tx) { + if portal.ActivateNetherPortal(tx, pos) { + return + } f := Fire{} tx.SetBlock(pos, f, nil) tx.ScheduleBlockUpdate(pos, f, time.Duration(30+rand.IntN(10))*time.Second/20) diff --git a/server/block/flower.go b/server/block/flower.go index a294444e60..3cccb320d4 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 10dd0e1aa5..dade87ce78 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/hash.go b/server/block/hash.go index 684d9f010b..89a861f3f0 100644 --- a/server/block/hash.go +++ b/server/block/hash.go @@ -10,6 +10,8 @@ const ( hashAncientDebris hashAndesite hashAnvil + hashBambooBlock + hashBambooMosaic hashBanner hashBarrel hashBarrier @@ -147,6 +149,7 @@ const ( hashPodzol hashPolishedBlackstoneBrick hashPolishedTuff + hashPortal hashPotato hashPrismarine hashPumpkin @@ -240,6 +243,14 @@ func (a Anvil) Hash() (uint64, uint64) { return hashAnvil, uint64(a.Type.Uint8()) | uint64(a.Facing)<<2 } +func (b BambooBlock) Hash() (uint64, uint64) { + return hashBambooBlock, uint64(b.Axis) | uint64(boolByte(b.Stripped))<<2 +} + +func (BambooMosaic) Hash() (uint64, uint64) { + return hashBambooMosaic, 0 +} + func (b Banner) Hash() (uint64, uint64) { return hashBanner, uint64(b.Attach.Uint8()) } @@ -788,6 +799,10 @@ func (PolishedTuff) Hash() (uint64, uint64) { return hashPolishedTuff, 0 } +func (p Portal) Hash() (uint64, uint64) { + return hashPortal, uint64(p.Axis) +} + func (p Potato) Hash() (uint64, uint64) { return hashPotato, uint64(p.Growth) } diff --git a/server/block/kelp.go b/server/block/kelp.go index 27c03af134..7140bbfb5c 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/log.go b/server/block/log.go index 9303dd9f9e..61cd441fac 100644 --- a/server/block/log.go +++ b/server/block/log.go @@ -106,6 +106,9 @@ func (l Log) EncodeBlock() (name string, properties map[string]any) { // allLogs returns a list of all possible log states. func allLogs() (logs []world.Block) { for _, w := range WoodTypes() { + if w == BambooWood() { + continue + } for axis := cube.Axis(0); axis < 3; axis++ { logs = append(logs, Log{Axis: axis, Stripped: true, Wood: w}) logs = append(logs, Log{Axis: axis, Stripped: false, Wood: w}) diff --git a/server/block/melon_seeds.go b/server/block/melon_seeds.go index 68bbaa2d75..2fcfeece46 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/model/portal.go b/server/block/model/portal.go new file mode 100644 index 0000000000..a0007aba43 --- /dev/null +++ b/server/block/model/portal.go @@ -0,0 +1,22 @@ +package model + +import ( + "github.com/df-mc/dragonfly/server/block/cube" + "github.com/df-mc/dragonfly/server/world" +) + +// Portal is a model used by portal blocks. +type Portal struct { + // Axis is the axis that the portal faces. + Axis cube.Axis +} + +// BBox ... +func (Portal) BBox(cube.Pos, world.BlockSource) []cube.BBox { + return nil +} + +// FaceSolid ... +func (Portal) FaceSolid(cube.Pos, cube.Face, world.BlockSource) bool { + return false +} diff --git a/server/block/obsidian.go b/server/block/obsidian.go index 5d99bd4a0d..0b8abe51a2 100644 --- a/server/block/obsidian.go +++ b/server/block/obsidian.go @@ -2,6 +2,7 @@ package block import ( "github.com/df-mc/dragonfly/server/item" + "github.com/df-mc/dragonfly/server/world" ) // Obsidian is a dark purple block known for its high blast resistance and strength, most commonly found when @@ -37,6 +38,11 @@ func (o Obsidian) EncodeBlock() (string, map[string]any) { return "minecraft:obsidian", nil } +// Frame ... +func (o Obsidian) Frame(dimension world.Dimension) bool { + return dimension == world.Nether && !o.Crying +} + // BreakInfo ... func (o Obsidian) BreakInfo() BreakInfo { return newBreakInfo(35, func(t item.Tool) bool { diff --git a/server/block/pink_petals.go b/server/block/pink_petals.go index 1df913167f..3093836c08 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/portal.go b/server/block/portal.go new file mode 100644 index 0000000000..94aabbf899 --- /dev/null +++ b/server/block/portal.go @@ -0,0 +1,69 @@ +package block + +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/df-mc/dragonfly/server/world/portal" +) + +// Portal is the translucent part of the nether portal that teleports the player to and from the Nether. +type Portal struct { + transparent + + // Axis is the axis which the portal faces. + Axis cube.Axis +} + +// portalTraveller represents an entity that can handle touching a portal block. +type portalTraveller interface { + TravelThroughPortal(tx *world.Tx, target world.Dimension) +} + +// Model ... +func (p Portal) Model() world.BlockModel { + return model.Portal{Axis: p.Axis} +} + +// Portal ... +func (Portal) Portal() world.Dimension { + return world.Nether +} + +// LightEmissionLevel returns 11. +func (Portal) LightEmissionLevel() uint8 { + return 11 +} + +// HasLiquidDrops ... +func (p Portal) HasLiquidDrops() bool { + return false +} + +// EncodeBlock ... +func (p Portal) EncodeBlock() (string, map[string]any) { + return "minecraft:portal", map[string]any{"portal_axis": p.Axis.String()} +} + +// NeighbourUpdateTick ... +func (p Portal) NeighbourUpdateTick(pos, neighbour cube.Pos, tx *world.Tx) { + face, ok := pos.NeighbourFace(neighbour) + if !ok { + return + } + axis := face.Axis() + if axis != cube.Y && axis != p.Axis { + return + } + if n, ok := portal.NetherPortalFromPos(tx, pos); ok && n.Framed() && n.Activated() { + return + } + portal.DeactivateNetherPortal(tx, pos) +} + +// EntityInside ... +func (p Portal) EntityInside(_ cube.Pos, tx *world.Tx, e world.Entity) { + if t, ok := e.(portalTraveller); ok { + t.TravelThroughPortal(tx, p.Portal()) + } +} diff --git a/server/block/potato.go b/server/block/potato.go index ecb9e3b5fe..266c36a1db 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 5a62c65072..e14be3a13f 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/register.go b/server/block/register.go index 116c454e68..1f393b323b 100644 --- a/server/block/register.go +++ b/server/block/register.go @@ -1,6 +1,7 @@ package block import ( + "github.com/df-mc/dragonfly/server/block/cube" "github.com/df-mc/dragonfly/server/item" "github.com/df-mc/dragonfly/server/world" ) @@ -14,6 +15,7 @@ func init() { world.RegisterBlock(AncientDebris{}) world.RegisterBlock(Andesite{Polished: true}) world.RegisterBlock(Andesite{}) + world.RegisterBlock(BambooMosaic{}) world.RegisterBlock(Barrier{}) world.RegisterBlock(Beacon{}) world.RegisterBlock(Bedrock{InfiniteBurning: true}) @@ -89,6 +91,8 @@ func init() { world.RegisterBlock(PackedIce{}) world.RegisterBlock(PackedMud{}) world.RegisterBlock(Podzol{}) + world.RegisterBlock(Portal{Axis: cube.X}) + world.RegisterBlock(Portal{Axis: cube.Z}) world.RegisterBlock(PolishedBlackstoneBrick{Cracked: true}) world.RegisterBlock(PolishedBlackstoneBrick{}) world.RegisterBlock(QuartzBricks{}) @@ -138,6 +142,7 @@ func init() { } registerAll(allAnvils()) + registerAll(allBambooBlocks()) registerAll(allBanners()) registerAll(allBarrels()) registerAll(allBasalt()) @@ -243,6 +248,9 @@ func init() { world.RegisterItem(AncientDebris{}) world.RegisterItem(Andesite{Polished: true}) world.RegisterItem(Andesite{}) + world.RegisterItem(BambooBlock{}) + world.RegisterItem(BambooBlock{Stripped: true}) + world.RegisterItem(BambooMosaic{}) world.RegisterItem(Barrel{}) world.RegisterItem(Barrier{}) world.RegisterItem(Basalt{Polished: true}) @@ -440,20 +448,21 @@ func init() { world.RegisterItem(Wool{Colour: c}) } for _, w := range WoodTypes() { - if w != WarpedWood() && w != CrimsonWood() { - t, _ := w.Leaves() + if t, ok := w.Leaves(); ok { world.RegisterItem(Leaves{Type: t, Persistent: true}) } - world.RegisterItem(Log{Wood: w, Stripped: true}) - world.RegisterItem(Log{Wood: w}) + if w != BambooWood() { + world.RegisterItem(Log{Wood: w, Stripped: true}) + world.RegisterItem(Log{Wood: w}) + world.RegisterItem(Wood{Wood: w, Stripped: true}) + world.RegisterItem(Wood{Wood: w}) + } world.RegisterItem(Planks{Wood: w}) world.RegisterItem(Sign{Wood: w}) world.RegisterItem(WoodDoor{Wood: w}) world.RegisterItem(WoodFenceGate{Wood: w}) world.RegisterItem(WoodFence{Wood: w}) world.RegisterItem(WoodTrapdoor{Wood: w}) - world.RegisterItem(Wood{Wood: w, Stripped: true}) - world.RegisterItem(Wood{Wood: w}) } world.RegisterItem(Leaves{Type: AzaleaLeaves(), Persistent: true}) world.RegisterItem(Leaves{Type: FloweringAzaleaLeaves(), Persistent: true}) diff --git a/server/block/sea_pickle.go b/server/block/sea_pickle.go index 3b6acbc87a..96df4d893b 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 6f74cb8326..edf6f3f706 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/slab.go b/server/block/slab.go index 13cb1d90f5..90c578f953 100644 --- a/server/block/slab.go +++ b/server/block/slab.go @@ -63,6 +63,9 @@ func (s Slab) Instrument() sound.Instrument { if _, ok := s.Block.(Planks); ok { return sound.Bass() } + if _, ok := s.Block.(BambooMosaic); ok { + return sound.Bass() + } return sound.BassDrum() } diff --git a/server/block/slab_type.go b/server/block/slab_type.go index f5e4185bfa..476bd64461 100644 --- a/server/block/slab_type.go +++ b/server/block/slab_type.go @@ -25,6 +25,8 @@ func encodeSlabBlock(block world.Block, double bool) (id string, suffix string) } else if block.Type == PolishedBlackstone() { return "polished_blackstone", suffix } + case BambooMosaic: + return "bamboo_mosaic", suffix case Bricks: return "brick", suffix case Cobblestone: @@ -153,6 +155,7 @@ func SlabBlocks() []world.Block { b := []world.Block{ Andesite{Polished: true}, Andesite{}, + BambooMosaic{}, Blackstone{Type: PolishedBlackstone()}, Blackstone{}, Bricks{}, diff --git a/server/block/stairs.go b/server/block/stairs.go index 665199100e..ff0e4827d9 100644 --- a/server/block/stairs.go +++ b/server/block/stairs.go @@ -55,6 +55,9 @@ func (s Stairs) Instrument() sound.Instrument { if _, ok := s.Block.(Planks); ok { return sound.Bass() } + if _, ok := s.Block.(BambooMosaic); ok { + return sound.Bass() + } return sound.BassDrum() } diff --git a/server/block/stairs_type.go b/server/block/stairs_type.go index e3b2d9f94b..a2b1e8dcc8 100644 --- a/server/block/stairs_type.go +++ b/server/block/stairs_type.go @@ -18,6 +18,8 @@ func encodeStairsBlock(block world.Block) string { } else if block.Type == PolishedBlackstone() { return "polished_blackstone" } + case BambooMosaic: + return "bamboo_mosaic" case Bricks: return "brick" case Cobblestone: @@ -136,6 +138,7 @@ func StairsBlocks() []world.Block { b := []world.Block{ Andesite{Polished: true}, Andesite{}, + BambooMosaic{}, Blackstone{Type: PolishedBlackstone()}, Blackstone{}, Bricks{}, diff --git a/server/block/sugar_cane.go b/server/block/sugar_cane.go index 1c6e21c2a8..0f7b467f73 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 4103a306bf..549f4ceb75 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/block/wood.go b/server/block/wood.go index 787c8573cd..c58a07091c 100644 --- a/server/block/wood.go +++ b/server/block/wood.go @@ -98,9 +98,13 @@ func (w Wood) EncodeBlock() (name string, properties map[string]any) { } } -// allWood returns a list of all possible wood states. +// allWood returns all possible Wood block states, excluding bamboo blocks, +// which are registered separately through allBambooBlocks. func allWood() (wood []world.Block) { for _, w := range WoodTypes() { + if w == BambooWood() { + continue + } for axis := cube.Axis(0); axis < 3; axis++ { wood = append(wood, Wood{Axis: axis, Stripped: true, Wood: w}) wood = append(wood, Wood{Axis: axis, Stripped: false, Wood: w}) diff --git a/server/block/wood_type.go b/server/block/wood_type.go index ded6df1999..cafd9b0648 100644 --- a/server/block/wood_type.go +++ b/server/block/wood_type.go @@ -61,9 +61,14 @@ func PaleOakWood() WoodType { return WoodType{10} } +// BambooWood returns bamboo wood material. +func BambooWood() WoodType { + return WoodType{11} +} + // WoodTypes returns a list of all wood types func WoodTypes() []WoodType { - return []WoodType{OakWood(), SpruceWood(), BirchWood(), JungleWood(), AcaciaWood(), DarkOakWood(), CrimsonWood(), WarpedWood(), MangroveWood(), CherryWood(), PaleOakWood()} + return []WoodType{OakWood(), SpruceWood(), BirchWood(), JungleWood(), AcaciaWood(), DarkOakWood(), CrimsonWood(), WarpedWood(), MangroveWood(), CherryWood(), PaleOakWood(), BambooWood()} } type wood uint8 @@ -98,6 +103,8 @@ func (w wood) Name() string { return "Cherry Wood" case 10: return "Pale Oak Wood" + case 11: + return "Bamboo Wood" } panic("unknown wood type") } @@ -127,6 +134,8 @@ func (w wood) String() string { return "cherry" case 10: return "pale_oak" + case 11: + return "bamboo" } panic("unknown wood type") } diff --git a/server/entity/area_effect_cloud_behaviour.go b/server/entity/area_effect_cloud_behaviour.go index 056b944439..b7cfa604d1 100644 --- a/server/entity/area_effect_cloud_behaviour.go +++ b/server/entity/area_effect_cloud_behaviour.go @@ -1,12 +1,13 @@ package entity import ( + "iter" + "time" + "github.com/df-mc/dragonfly/server/entity/effect" "github.com/df-mc/dragonfly/server/item/potion" "github.com/df-mc/dragonfly/server/world" "github.com/go-gl/mathgl/mgl64" - "iter" - "time" ) // AreaEffectCloudBehaviourConfig contains optional parameters for an area @@ -66,6 +67,11 @@ type AreaEffectCloudBehaviour struct { targets map[*world.EntityHandle]time.Duration } +// PortalTravelComputer returns the interdimensional travel state for the behaviour. +func (a *AreaEffectCloudBehaviour) PortalTravelComputer() *PortalTravelComputer { + return a.stationary.PortalTravelComputer() +} + // Radius returns the current radius of the area effect cloud. func (a *AreaEffectCloudBehaviour) Radius() float64 { return a.radius diff --git a/server/entity/base_behaviour.go b/server/entity/base_behaviour.go new file mode 100644 index 0000000000..0f36fe0ab2 --- /dev/null +++ b/server/entity/base_behaviour.go @@ -0,0 +1,20 @@ +package entity + +// BaseBehaviour provides shared runtime state for Ent behaviours. Embed it +// to inherit common functionality, or forward methods to another instance. +type BaseBehaviour struct { + portalTravel *PortalTravelComputer +} + +// NewBaseBehaviour returns a BaseBehaviour initialised with the default Ent runtime behaviour. +func NewBaseBehaviour() BaseBehaviour { + return BaseBehaviour{portalTravel: NewPortalTravelComputer()} +} + +// PortalTravelComputer returns the portal travel state for a behaviour. +func (b *BaseBehaviour) PortalTravelComputer() *PortalTravelComputer { + if b.portalTravel == nil { + b.portalTravel = NewPortalTravelComputer() + } + return b.portalTravel +} diff --git a/server/entity/ender_pearl.go b/server/entity/ender_pearl.go index 5fd44e3749..42b905af1a 100644 --- a/server/entity/ender_pearl.go +++ b/server/entity/ender_pearl.go @@ -34,7 +34,11 @@ type teleporter interface { // teleport teleports the owner of an Ent to a trace.Result's position. func teleport(e *Ent, tx *world.Tx, target trace.Result) { - owner, _ := e.Behaviour().(*ProjectileBehaviour).Owner().Entity(tx) + behaviour := e.Behaviour().(*ProjectileBehaviour) + if behaviour.PortalTravel() { + return + } + owner, _ := behaviour.Owner().Entity(tx) if user, ok := owner.(teleporter); ok { tx.PlaySound(user.Position(), sound.Teleport{}) user.Teleport(target.Position()) diff --git a/server/entity/ent.go b/server/entity/ent.go index e3bb889fbe..b74e8f8dfb 100644 --- a/server/entity/ent.go +++ b/server/entity/ent.go @@ -1,12 +1,13 @@ package entity import ( + "sync" + "time" + "github.com/df-mc/dragonfly/server/block" "github.com/df-mc/dragonfly/server/block/cube" "github.com/df-mc/dragonfly/server/world" "github.com/go-gl/mathgl/mgl64" - "sync" - "time" ) // Behaviour implements the behaviour of an Ent. @@ -21,10 +22,11 @@ type Behaviour interface { // share a lot of code. It is currently under development and is prone to // (breaking) changes. type Ent struct { - tx *world.Tx - handle *world.EntityHandle - data *world.EntityData - once sync.Once + tx *world.Tx + handle *world.EntityHandle + data *world.EntityData + deferPortalTravel bool + once sync.Once } // Open converts a world.EntityHandle to an Ent in a world.Tx. @@ -66,6 +68,15 @@ func (e *Ent) SetVelocity(v mgl64.Vec3) { e.data.Vel = v } +// Teleport teleports the entity to the position given. +func (e *Ent) Teleport(pos mgl64.Vec3) { + viewers := e.tx.Viewers(e.data.Pos) + e.data.Pos = pos + for _, v := range viewers { + v.ViewEntityTeleport(e, pos) + } +} + // Rotation returns the rotation of the entity. func (e *Ent) Rotation() cube.Rotation { return e.data.Rot @@ -118,6 +129,11 @@ func (e *Ent) SetNameTag(s string) { // Tick ticks Ent, progressing its lifetime and closing the entity if it is // in the void. func (e *Ent) Tick(tx *world.Tx, current int64) { + e.deferPortalTravel = true + defer func() { + e.deferPortalTravel = false + }() + y := e.data.Pos[1] if y < float64(tx.Range()[0]) && current%10 == 0 { _ = e.Close() @@ -125,9 +141,17 @@ func (e *Ent) Tick(tx *world.Tx, current int64) { } e.SetOnFire(e.OnFireDuration() - time.Second/20) - if m := e.Behaviour().Tick(e, tx); m != nil { + m := e.Behaviour().Tick(e, tx) + if e.finishPendingPortalTravel(tx) { + return + } + if m != nil { m.Send() } + if e.checkPortalInsiders() && e.finishPendingPortalTravel(tx) { + return + } + e.stopPortalContact() e.data.Age += time.Second / 20 } diff --git a/server/entity/ent_portal.go b/server/entity/ent_portal.go new file mode 100644 index 0000000000..ab296813a4 --- /dev/null +++ b/server/entity/ent_portal.go @@ -0,0 +1,69 @@ +package entity + +import ( + "github.com/df-mc/dragonfly/server/block/cube" + "github.com/df-mc/dragonfly/server/world" +) + +// TravelThroughPortal handles the entity touching a portal block. +func (e *Ent) TravelThroughPortal(tx *world.Tx, target world.Dimension) { + if tc := e.portalTravelComputer(); tc != nil { + if e.deferPortalTravel { + tc.queuePortalTravel(tx, target) + return + } + tc.EnterPortal(e, tx, target) + } +} + +// portalTravelComputer returns the behaviour's portal travel state, if any. +func (e *Ent) portalTravelComputer() *PortalTravelComputer { + if b, ok := e.Behaviour().(portalTravelComputerProvider); ok { + return b.PortalTravelComputer() + } + return nil +} + +// stopPortalContact resets portal contact state when no portal was touched. +func (e *Ent) stopPortalContact() { + if tc := e.portalTravelComputer(); tc != nil { + tc.StopPortalContact() + } +} + +// pendingPortalTravel reports whether this tick queued terminal portal travel. +func (e *Ent) pendingPortalTravel() bool { + if tc := e.portalTravelComputer(); tc != nil { + return tc.hasPendingPortalTravel() + } + return false +} + +// finishPendingPortalTravel starts queued terminal portal travel, if present. +func (e *Ent) finishPendingPortalTravel(tx *world.Tx) bool { + if tc := e.portalTravelComputer(); tc != nil { + return tc.finishPendingPortalTravel(e, tx) + } + return false +} + +type portalBlock interface { + Portal() world.Dimension +} + +// checkPortalInsiders checks whether the entity is inside portal blocks. +// Other EntityInsider blocks are intentionally left to entity physics. +func (e *Ent) checkPortalInsiders() bool { + box := e.H().Type().BBox(e).Translate(e.Position()).Grow(-0.0001) + low, high := cube.PosFromVec3(box.Min()), cube.PosFromVec3(box.Max()) + + for blockPos := range cube.Range3D(low, high) { + if p, ok := e.tx.Block(blockPos).(portalBlock); ok { + e.TravelThroughPortal(e.tx, p.Portal()) + if e.pendingPortalTravel() { + return true + } + } + } + return false +} diff --git a/server/entity/experience_orb_behaviour.go b/server/entity/experience_orb_behaviour.go index f90453ede9..eea158ddc0 100644 --- a/server/entity/experience_orb_behaviour.go +++ b/server/entity/experience_orb_behaviour.go @@ -1,11 +1,12 @@ package entity import ( + "math" + "time" + "github.com/df-mc/dragonfly/server/block/cube" "github.com/df-mc/dragonfly/server/world" "github.com/go-gl/mathgl/mgl64" - "math" - "time" ) // ExperienceOrbBehaviourConfig holds optional parameters for the creation of @@ -47,14 +48,18 @@ func (conf ExperienceOrbBehaviourConfig) New() *ExperienceOrbBehaviour { // ExperienceOrbBehaviour implements Behaviour for an experience orb entity. type ExperienceOrbBehaviour struct { - conf ExperienceOrbBehaviourConfig - + conf ExperienceOrbBehaviourConfig passive *PassiveBehaviour lastSearch time.Time target *world.EntityHandle } +// PortalTravelComputer returns the interdimensional travel state for the behaviour. +func (exp *ExperienceOrbBehaviour) PortalTravelComputer() *PortalTravelComputer { + return exp.passive.PortalTravelComputer() +} + // Experience returns the amount of experience the orb carries. func (exp *ExperienceOrbBehaviour) Experience() int { return exp.conf.Experience diff --git a/server/entity/falling_block_behaviour.go b/server/entity/falling_block_behaviour.go index 7ea9ea84f2..1b346550af 100644 --- a/server/entity/falling_block_behaviour.go +++ b/server/entity/falling_block_behaviour.go @@ -48,6 +48,11 @@ type FallingBlockBehaviour struct { block world.Block } +// PortalTravelComputer returns the interdimensional travel state for the behaviour. +func (f *FallingBlockBehaviour) PortalTravelComputer() *PortalTravelComputer { + return f.passive.PortalTravelComputer() +} + // Block returns the world.Block of the entity. func (f *FallingBlockBehaviour) Block() world.Block { return f.block diff --git a/server/entity/firework_behaviour.go b/server/entity/firework_behaviour.go index a09a09f554..ae7f68c426 100644 --- a/server/entity/firework_behaviour.go +++ b/server/entity/firework_behaviour.go @@ -51,6 +51,11 @@ type FireworkBehaviour struct { passive *PassiveBehaviour } +// PortalTravelComputer returns the interdimensional travel state for the behaviour. +func (f *FireworkBehaviour) PortalTravelComputer() *PortalTravelComputer { + return f.passive.PortalTravelComputer() +} + // Firework returns the underlying item.Firework of the FireworkBehaviour. func (f *FireworkBehaviour) Firework() item.Firework { return f.conf.Firework diff --git a/server/entity/item_behaviour.go b/server/entity/item_behaviour.go index 807506459e..01e2d0f083 100644 --- a/server/entity/item_behaviour.go +++ b/server/entity/item_behaviour.go @@ -66,6 +66,11 @@ type ItemBehaviour struct { pickupDelay time.Duration } +// PortalTravelComputer returns the interdimensional travel state for the behaviour. +func (i *ItemBehaviour) PortalTravelComputer() *PortalTravelComputer { + return i.passive.PortalTravelComputer() +} + // Item returns the item.Stack held by the entity. func (i *ItemBehaviour) Item() item.Stack { return i.i diff --git a/server/entity/passive.go b/server/entity/passive.go index d1d8b500ab..3a0f16befa 100644 --- a/server/entity/passive.go +++ b/server/entity/passive.go @@ -36,17 +36,24 @@ func (conf PassiveBehaviourConfig) New() *PassiveBehaviour { if conf.ExistenceDuration == 0 { conf.ExistenceDuration = math.MaxInt64 } - return &PassiveBehaviour{conf: conf, fuse: conf.ExistenceDuration, mc: &MovementComputer{ - Gravity: conf.Gravity, - Drag: conf.Drag, - DragBeforeGravity: true, - }} + return &PassiveBehaviour{ + BaseBehaviour: NewBaseBehaviour(), + conf: conf, + fuse: conf.ExistenceDuration, + mc: &MovementComputer{ + Gravity: conf.Gravity, + Drag: conf.Drag, + DragBeforeGravity: true, + }, + } } // PassiveBehaviour implements Behaviour for entities that act passively. This // means that they can move, but only under influence of the environment, which // includes, for example, falling, and flowing water. type PassiveBehaviour struct { + BaseBehaviour + conf PassiveBehaviourConfig mc *MovementComputer diff --git a/server/entity/projectile.go b/server/entity/projectile.go index 8c8a247071..029ffce223 100644 --- a/server/entity/projectile.go +++ b/server/entity/projectile.go @@ -98,16 +98,24 @@ 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, - }} + return &ProjectileBehaviour{ + BaseBehaviour: NewBaseBehaviour(), + 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 { + BaseBehaviour + conf ProjectileBehaviourConfig mc *MovementComputer ageCollided int @@ -117,6 +125,7 @@ type ProjectileBehaviour struct { collided bool collidedEntities []*world.EntityHandle + portalTravel bool } // Owner returns the owner of the projectile. @@ -142,6 +151,16 @@ func (lt *ProjectileBehaviour) Critical() bool { return lt.conf.Critical && !lt.collided } +// HandlePortalTravel records that this projectile has travelled between dimensions through a portal. +func (lt *ProjectileBehaviour) HandlePortalTravel(world.Dimension, world.Dimension) { + lt.portalTravel = true +} + +// PortalTravel reports whether this projectile has travelled between dimensions through a portal. +func (lt *ProjectileBehaviour) PortalTravel() bool { + return lt.portalTravel +} + // 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. diff --git a/server/entity/stationary.go b/server/entity/stationary.go index 3aaecd18c5..e399f3184a 100644 --- a/server/entity/stationary.go +++ b/server/entity/stationary.go @@ -31,13 +31,15 @@ func (conf StationaryBehaviourConfig) New() *StationaryBehaviour { if conf.ExistenceDuration == 0 { conf.ExistenceDuration = math.MaxInt64 } - return &StationaryBehaviour{conf: conf} + return &StationaryBehaviour{BaseBehaviour: NewBaseBehaviour(), conf: conf} } // StationaryBehaviour implements the behaviour of an entity that is unable to // move, such as a text entity or an area effect cloud. Applying velocity to // such entities will not move them. type StationaryBehaviour struct { + BaseBehaviour + conf StationaryBehaviourConfig close bool } diff --git a/server/entity/travel.go b/server/entity/travel.go new file mode 100644 index 0000000000..d57ede60a0 --- /dev/null +++ b/server/entity/travel.go @@ -0,0 +1,254 @@ +package entity + +import ( + "sync" + "time" + + "github.com/df-mc/dragonfly/server/block/cube" + "github.com/df-mc/dragonfly/server/world" + "github.com/df-mc/dragonfly/server/world/portal" + "github.com/go-gl/mathgl/mgl64" +) + +// PortalTravelComputer handles portal-triggered interdimensional travel for an entity. +type PortalTravelComputer struct { + // Instantaneous returns true if the entity should skip the portal wait timer. Players use this for Creative mode. + Instantaneous func() bool + // Teleport teleports the entity to the final portal position. If nil, Traveller.Teleport is used. + Teleport func(e Traveller, pos mgl64.Vec3) + + mu sync.Mutex + start time.Time + inside bool + awaitingTravel bool + travelling bool + timedOut bool + pending *world.World +} + +// NewPortalTravelComputer creates a PortalTravelComputer for instant portal travel. +func NewPortalTravelComputer() *PortalTravelComputer { + return &PortalTravelComputer{Instantaneous: func() bool { return true }} +} + +type portalTravelComputerProvider interface { + PortalTravelComputer() *PortalTravelComputer +} + +// Traveller represents a world.Entity that can travel between dimensions. +type Traveller interface { + world.Entity + // Teleport teleports the entity to the position given. + Teleport(pos mgl64.Vec3) +} + +type portalTravelHandler interface { + HandlePortalTravel(source, destination world.Dimension) +} + +// EnterPortal handles an entity touching a portal block. It teleports the entity to the other dimension after four +// seconds or instantly if instantaneous is true. +func (t *PortalTravelComputer) EnterPortal(e Traveller, tx *world.Tx, target world.Dimension) { + if destination := t.enterPortal(tx, target); destination != nil { + t.travelQueued(e, tx, destination) + } +} + +// queuePortalTravel records portal travel to be completed by a terminal Ent tick step. +func (t *PortalTravelComputer) queuePortalTravel(tx *world.Tx, target world.Dimension) { + if destination := t.enterPortal(tx, target); destination != nil { + t.mu.Lock() + t.pending = destination + t.mu.Unlock() + } +} + +// enterPortal updates portal contact state and returns the destination world if travel should start. +func (t *PortalTravelComputer) enterPortal(tx *world.Tx, target world.Dimension) *world.World { + source := tx.World() + destination := source.PortalDestination(target) + if destination == source { + return nil + } + + t.mu.Lock() + t.inside = true + if t.timedOut { + // Timed out, we can't travel through portals. + t.mu.Unlock() + return nil + } + travelNow := t.instantaneous() || (t.awaitingTravel && time.Since(t.start) >= time.Second*4) + if !travelNow && !t.awaitingTravel { + t.start, t.awaitingTravel = time.Now(), true + } + t.mu.Unlock() + + if travelNow { + return destination + } + return nil +} + +func (t *PortalTravelComputer) instantaneous() bool { + return t.Instantaneous != nil && t.Instantaneous() +} + +// hasPendingPortalTravel reports whether portal travel was queued during this tick. +func (t *PortalTravelComputer) hasPendingPortalTravel() bool { + t.mu.Lock() + defer t.mu.Unlock() + return t.pending != nil +} + +// finishPendingPortalTravel consumes queued portal travel and starts the terminal transfer. +func (t *PortalTravelComputer) finishPendingPortalTravel(e Traveller, tx *world.Tx) bool { + t.mu.Lock() + destination := t.pending + t.pending = nil + t.mu.Unlock() + + if destination == nil { + return false + } + t.travel(e, tx, destination) + return true +} + +// StopPortalContact resets the portal timer if the entity was not inside a portal this tick. +func (t *PortalTravelComputer) StopPortalContact() { + t.mu.Lock() + defer t.mu.Unlock() + if t.inside { + t.inside = false + return + } + if t.travelling || t.pending != nil { + return + } + t.timedOut, t.awaitingTravel = false, false +} + +// travel removes the entity from the current world and queues it for the given Nether or Overworld world. +func (t *PortalTravelComputer) travel(e Traveller, tx *world.Tx, destination *world.World) { + source := tx.World() + if destination == nil || destination == source { + return + } + + sourceDim, destinationDim := source.Dimension(), destination.Dimension() + pos := translatePortalPosition(cube.PosFromVec3(e.Position()), sourceDim, destinationDim) + + t.mu.Lock() + t.travelling, t.timedOut, t.awaitingTravel = true, true, false + t.mu.Unlock() + + handle := tx.RemoveEntity(e) + if handle == nil { + t.mu.Lock() + t.travelling = false + t.mu.Unlock() + return + } + + go func() { + <-destination.Exec(func(tx *world.Tx) { + spawn := pos.Vec3Middle() + if netherPortal, ok := portal.FindOrCreateNetherPortal(tx, pos, 128); ok { + spawn = netherPortal.Spawn().Vec3Middle() + } + + if e, ok := tx.AddEntityAt(handle, spawn).(Traveller); ok { + t.finishTravel(e, spawn, sourceDim, destinationDim) + } + }) + + t.mu.Lock() + t.travelling = false + t.mu.Unlock() + }() +} + +// travelQueued moves the entity after the current transaction finishes. This is used by callers such as players that +// may touch a portal from the middle of a tick and continue running afterwards. +func (t *PortalTravelComputer) travelQueued(e Traveller, tx *world.Tx, destination *world.World) { + source := tx.World() + if destination == nil || destination == source { + return + } + + sourceDim, destinationDim := source.Dimension(), destination.Dimension() + pos := translatePortalPosition(cube.PosFromVec3(e.Position()), sourceDim, destinationDim) + + t.mu.Lock() + t.travelling, t.timedOut, t.awaitingTravel = true, true, false + t.mu.Unlock() + + go func() { + var handle *world.EntityHandle + <-source.Exec(func(tx *world.Tx) { + handle = tx.RemoveEntity(e) + }) + if handle == nil { + t.mu.Lock() + t.travelling = false + t.mu.Unlock() + return + } + + <-destination.Exec(func(tx *world.Tx) { + spawn := pos.Vec3Middle() + if netherPortal, ok := portal.FindOrCreateNetherPortal(tx, pos, 128); ok { + spawn = netherPortal.Spawn().Vec3Middle() + } + + if e, ok := tx.AddEntityAt(handle, spawn).(Traveller); ok { + t.finishTravel(e, spawn, sourceDim, destinationDim) + } + }) + + t.mu.Lock() + t.travelling = false + t.mu.Unlock() + }() +} + +// finishTravel runs the post-transfer portal hook and places the traveller at +// the destination spawn position. +func (t *PortalTravelComputer) finishTravel(e Traveller, pos mgl64.Vec3, source, destination world.Dimension) { + handlePortalTravel(e, source, destination) + if t.Teleport != nil { + t.Teleport(e, pos) + return + } + e.Teleport(pos) +} + +// handlePortalTravel dispatches portal travel hooks to Ent behaviours and +// non-Ent travellers that implement portalTravelHandler. +func handlePortalTravel(e Traveller, source, destination world.Dimension) { + if ent, ok := e.(*Ent); ok { + if h, ok := ent.Behaviour().(portalTravelHandler); ok { + h.HandlePortalTravel(source, destination) + } + return + } + if h, ok := e.(portalTravelHandler); ok { + h.HandlePortalTravel(source, destination) + } +} + +// translatePortalPosition maps a position in the source dimension to the equivalent position in the target dimension. +// Overworld coordinates are divided by 8 when crossing to the Nether and Nether coordinates are multiplied by 8 when +// crossing to the Overworld; the Y coordinate is clamped to the target dimension's vertical range. +func translatePortalPosition(pos cube.Pos, source, target world.Dimension) cube.Pos { + switch source { + case world.Overworld: + pos[0], pos[2] = pos[0]>>3, pos[2]>>3 + case world.Nether: + pos[0], pos[2] = pos[0]*8, pos[2]*8 + } + r := target.Range() + pos[1] = min(max(pos[1], r.Min()), r.Max()) + return pos +} diff --git a/server/entity/travel_test.go b/server/entity/travel_test.go new file mode 100644 index 0000000000..3968bcf19d --- /dev/null +++ b/server/entity/travel_test.go @@ -0,0 +1,247 @@ +package entity + +import ( + "testing" + "time" + + "github.com/df-mc/dragonfly/server/block" + "github.com/df-mc/dragonfly/server/block/cube" + "github.com/df-mc/dragonfly/server/world" + "github.com/go-gl/mathgl/mgl64" +) + +func TestPortalTravelComputerStopPortalContact(t *testing.T) { + t.Run("keeps timer after portal contact", func(t *testing.T) { + tc := &PortalTravelComputer{inside: true, awaitingTravel: true, start: time.Now()} + tc.StopPortalContact() + if !tc.awaitingTravel { + t.Fatal("StopPortalContact() reset travel timer after portal contact") + } + if tc.inside { + t.Fatal("StopPortalContact() did not clear portal contact for the next tick") + } + }) + + t.Run("resets timer without portal contact", func(t *testing.T) { + tc := &PortalTravelComputer{awaitingTravel: true, start: time.Now()} + tc.StopPortalContact() + if tc.awaitingTravel { + t.Fatal("StopPortalContact() kept travel timer without portal contact") + } + }) +} + +func TestEntProjectileTravelsThroughPortal(t *testing.T) { + var overworld, nether *world.World + overworld = world.Config{PortalDestination: func(dim world.Dimension) *world.World { + if dim == world.Nether { + return nether + } + return nil + }}.New() + nether = world.Config{Dim: world.Nether, PortalDestination: func(dim world.Dimension) *world.World { + if dim == world.Nether { + return overworld + } + return nil + }}.New() + t.Cleanup(func() { + _ = overworld.Close() + _ = nether.Close() + }) + + spawnRecorder := &entitySpawnRecorder{} + nether.Handle(spawnRecorder) + + sourcePos := mgl64.Vec3{80.5, 64, 80.5} + targetPortal := cube.Pos{10, 64, 10} + <-nether.Exec(func(tx *world.Tx) { + buildActivePortal(tx, targetPortal) + }) + + handle := world.EntitySpawnOpts{Position: sourcePos}.New(EnderPearlType, enderPearlConf) + <-overworld.Exec(func(tx *world.Tx) { + e := tx.AddEntity(handle) + (block.Portal{Axis: cube.Z}).EntityInside(cube.PosFromVec3(sourcePos), tx, e) + if _, ok := handle.Entity(tx); !ok { + t.Fatal("non-terminal portal contact removed entity before the source transaction finished") + } + }) + + waitForEntityWorld(t, handle, nether) + if entityInWorld(handle, overworld) { + t.Fatal("entity remained in the source world after portal travel") + } + if !spawnRecorder.called { + t.Fatal("destination world did not fire an entity spawn event") + } + if got, want := spawnRecorder.pos, targetPortal.Vec3Middle(); !got.ApproxEqual(want) { + t.Fatalf("destination spawn event position = %v, want %v", got, want) + } + + <-nether.Exec(func(tx *world.Tx) { + e, ok := handle.Entity(tx) + if !ok { + t.Fatal("entity was not added to the Nether") + } + if got, want := cube.PosFromVec3(e.Position()), targetPortal; got != want { + t.Fatalf("entity position after portal travel = %v, want %v", got, want) + } + ent, ok := e.(*Ent) + if !ok { + t.Fatalf("entity after portal travel has type %T, want *Ent", e) + } + projectile, ok := ent.Behaviour().(*ProjectileBehaviour) + if !ok { + t.Fatalf("entity behaviour after portal travel has type %T, want *ProjectileBehaviour", ent.Behaviour()) + } + if !projectile.PortalTravel() { + t.Fatal("projectile portal travel state was not preserved") + } + }) +} + +func TestEntTravelsThroughPortalOnTick(t *testing.T) { + var overworld, nether *world.World + overworld = world.Config{PortalDestination: func(dim world.Dimension) *world.World { + if dim == world.Nether { + return nether + } + return nil + }}.New() + nether = world.Config{Dim: world.Nether, PortalDestination: func(dim world.Dimension) *world.World { + if dim == world.Nether { + return overworld + } + return nil + }}.New() + t.Cleanup(func() { + _ = overworld.Close() + _ = nether.Close() + }) + + sourcePortal, targetPortal := cube.Pos{80, 64, 80}, cube.Pos{10, 64, 10} + <-overworld.Exec(func(tx *world.Tx) { + buildActivePortal(tx, sourcePortal) + }) + <-nether.Exec(func(tx *world.Tx) { + buildActivePortal(tx, targetPortal) + }) + + handle := world.EntitySpawnOpts{Position: sourcePortal.Vec3Middle().Sub(mgl64.Vec3{1})}.New(testMovingEntType{}, testMoveConfig{delta: mgl64.Vec3{1}}) + <-overworld.Exec(func(tx *world.Tx) { + e := tx.AddEntity(handle) + ticker, ok := e.(world.TickerEntity) + if !ok { + t.Fatalf("entity has type %T, want world.TickerEntity", e) + } + ticker.Tick(tx, 1) + }) + + waitForEntityWorld(t, handle, nether) + if entityInWorld(handle, overworld) { + t.Fatal("entity remained in the source world after tick-driven portal travel") + } + <-nether.Exec(func(tx *world.Tx) { + e, ok := handle.Entity(tx) + if !ok { + t.Fatal("entity was not added to the Nether") + } + if got, want := cube.PosFromVec3(e.Position()), targetPortal; got != want { + t.Fatalf("entity position after tick-driven portal travel = %v, want %v", got, want) + } + if got := e.(*Ent).Age(); got != 0 { + t.Fatalf("entity age after terminal portal travel tick = %v, want 0", got) + } + }) +} + +func waitForEntityWorld(t *testing.T, handle *world.EntityHandle, w *world.World) { + t.Helper() + deadline := time.Now().Add(2 * time.Second) + for time.Now().Before(deadline) { + if entityInWorld(handle, w) { + return + } + time.Sleep(10 * time.Millisecond) + } + t.Fatal("timed out waiting for entity to change worlds") +} + +func entityInWorld(handle *world.EntityHandle, w *world.World) bool { + result := make(chan bool, 1) + go func() { + var ok bool + running := handle.ExecWorld(func(tx *world.Tx, _ world.Entity) { + ok = tx.World() == w + }) + result <- running && ok + }() + + select { + case ok := <-result: + return ok + case <-time.After(50 * time.Millisecond): + return false + } +} + +func buildActivePortal(tx *world.Tx, origin cube.Pos) { + for x := range 2 { + p := origin.Add(cube.Pos{0, 0, x}) + tx.SetBlock(p.Side(cube.FaceDown), block.Obsidian{}, nil) + tx.SetBlock(p.Add(cube.Pos{0, 3}), block.Obsidian{}, nil) + } + for y := range 3 { + p := origin.Add(cube.Pos{0, y}) + tx.SetBlock(p.Side(cube.FaceNorth), block.Obsidian{}, nil) + tx.SetBlock(p.Add(cube.Pos{0, 0, 2}), block.Obsidian{}, nil) + for x := range 2 { + tx.SetBlock(p.Add(cube.Pos{0, 0, x}), block.Portal{Axis: cube.Z}, nil) + } + } +} + +type entitySpawnRecorder struct { + world.NopHandler + + called bool + pos mgl64.Vec3 +} + +func (r *entitySpawnRecorder) HandleEntitySpawn(_ *world.Tx, e world.Entity) { + r.called = true + r.pos = e.Position() +} + +type testMoveConfig struct { + delta mgl64.Vec3 +} + +func (c testMoveConfig) Apply(data *world.EntityData) { + data.Data = &testMoveBehaviour{BaseBehaviour: NewBaseBehaviour(), delta: c.delta} +} + +type testMoveBehaviour struct { + BaseBehaviour + + delta mgl64.Vec3 +} + +func (b *testMoveBehaviour) Tick(e *Ent, _ *world.Tx) *Movement { + e.data.Pos = e.data.Pos.Add(b.delta) + return nil +} + +type testMovingEntType struct{} + +func (testMovingEntType) Open(tx *world.Tx, handle *world.EntityHandle, data *world.EntityData) world.Entity { + return &Ent{tx: tx, handle: handle, data: data} +} + +func (testMovingEntType) EncodeEntity() string { return "minecraft:test_moving_ent" } +func (testMovingEntType) BBox(world.Entity) cube.BBox { + return cube.Box(-0.125, 0, -0.125, 0.125, 0.25, 0.125) +} +func (testMovingEntType) DecodeNBT(map[string]any, *world.EntityData) {} +func (testMovingEntType) EncodeNBT(*world.EntityData) map[string]any { return nil } diff --git a/server/item/bone_meal.go b/server/item/bone_meal.go index ec1151da0f..9e7deea185 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/creative/creative_items.nbt b/server/item/creative/creative_items.nbt index 8ebb5d9539..ebb532f65b 100644 Binary files a/server/item/creative/creative_items.nbt and b/server/item/creative/creative_items.nbt differ diff --git a/server/item/crossbow.go b/server/item/crossbow.go index 915b3e274f..ed90384f43 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/fire_charge.go b/server/item/fire_charge.go index b74653b019..7199650c40 100644 --- a/server/item/fire_charge.go +++ b/server/item/fire_charge.go @@ -3,6 +3,7 @@ package item import ( "github.com/df-mc/dragonfly/server/block/cube" "github.com/df-mc/dragonfly/server/world" + "github.com/df-mc/dragonfly/server/world/portal" "github.com/df-mc/dragonfly/server/world/sound" "github.com/go-gl/mathgl/mgl64" "math/rand/v2" @@ -27,6 +28,9 @@ func (f FireCharge) UseOnBlock(pos cube.Pos, face cube.Face, _ mgl64.Vec3, tx *w } else if s := pos.Side(face); tx.Block(s) == air() { ctx.SubtractFromCount(1) tx.PlaySound(s.Vec3Centre(), sound.FireCharge{}) + if portal.ActivateNetherPortal(tx, s) { + return true + } flame := fire() tx.SetBlock(s, flame, nil) diff --git a/server/item/flint_and_steel.go b/server/item/flint_and_steel.go index 1e139dcbe0..3be2004e44 100644 --- a/server/item/flint_and_steel.go +++ b/server/item/flint_and_steel.go @@ -3,6 +3,7 @@ package item import ( "github.com/df-mc/dragonfly/server/block/cube" "github.com/df-mc/dragonfly/server/world" + "github.com/df-mc/dragonfly/server/world/portal" "github.com/df-mc/dragonfly/server/world/sound" "github.com/go-gl/mathgl/mgl64" "math/rand/v2" @@ -38,6 +39,9 @@ func (f FlintAndSteel) UseOnBlock(pos cube.Pos, face cube.Face, _ mgl64.Vec3, tx return true } else if s := pos.Side(face); tx.Block(s) == air() { tx.PlaySound(s.Vec3Centre(), sound.Ignite{}) + if portal.ActivateNetherPortal(tx, s) { + return true + } flame := fire() tx.SetBlock(s, flame, nil) diff --git a/server/item/item.go b/server/item/item.go index 1562cd3731..33a503cd2a 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/item/recipe/crafting_data.nbt b/server/item/recipe/crafting_data.nbt index 800aab6e3a..350fc0c124 100644 Binary files a/server/item/recipe/crafting_data.nbt and b/server/item/recipe/crafting_data.nbt differ diff --git a/server/item/recipe/potion_data.nbt b/server/item/recipe/potion_data.nbt index 980737cf36..3b9f707eb7 100644 Binary files a/server/item/recipe/potion_data.nbt and b/server/item/recipe/potion_data.nbt differ diff --git a/server/player/bossbar/colour.go b/server/player/bossbar/colour.go index 62af540600..56ef6746ed 100644 --- a/server/player/bossbar/colour.go +++ b/server/player/bossbar/colour.go @@ -33,8 +33,8 @@ func Purple() Colour { return Colour{colour(5)} } -// DarkPurple is the colour for a dark purple boss bar. -func DarkPurple() Colour { +// RebeccaPurple is the colour for a rebecca purple boss bar. +func RebeccaPurple() Colour { return Colour{colour(6)} } diff --git a/server/player/conf.go b/server/player/conf.go index 194156c0d9..d2524f4aad 100644 --- a/server/player/conf.go +++ b/server/player/conf.go @@ -1,6 +1,9 @@ package player import ( + "math/rand/v2" + "time" + "github.com/df-mc/dragonfly/server/block/cube" "github.com/df-mc/dragonfly/server/entity" "github.com/df-mc/dragonfly/server/entity/effect" @@ -11,8 +14,6 @@ import ( "github.com/go-gl/mathgl/mgl64" "github.com/google/uuid" "golang.org/x/text/language" - "math/rand/v2" - "time" ) // Config holds options that a Player can be created with. @@ -85,6 +86,14 @@ func (cfg Config) Apply(data *world.EntityData) { fireTicks: conf.FireTicks, fallDistance: conf.FallDistance, } + pdata.portalTravel = &entity.PortalTravelComputer{ + Instantaneous: func() bool { + return pdata.gameMode == world.GameModeCreative + }, + Teleport: func(e entity.Traveller, pos mgl64.Vec3) { + e.(*Player).forceTeleport(pos) + }, + } pdata.hunger.foodLevel, pdata.hunger.foodTick, pdata.hunger.exhaustionLevel, pdata.hunger.saturationLevel = conf.Food, conf.FoodTick, conf.Exhaustion, conf.Saturation pdata.experience.Add(conf.Experience) data.Data = pdata diff --git a/server/player/debug/shape.go b/server/player/debug/shape.go index 75438c2e97..66bcc9b1ed 100644 --- a/server/player/debug/shape.go +++ b/server/player/debug/shape.go @@ -161,3 +161,95 @@ type Text struct { // Entity is an optional entity handle to attach the shape to. Entity *world.EntityHandle } + +// Cylinder represents a hollow cylinder, or frustum, that can be drawn at any point in the world, with a +// height running up the Y axis. The base and top each have their own radius on the X and Z axes, allowing +// for tapered and elliptical cylinders. A Cone is the special case of a Cylinder with a zero top radius. +type Cylinder struct { + shape + + // Colour is the colour that will be used for the outline. If empty, it will default to white. + Colour color.RGBA + // Position is the origin position of the shape in the world. + Position mgl64.Vec3 + // Scale is the rate to scale the shape from its origin point. If zero, it will default to 1.0. + Scale float64 + // BaseRadius is the radius of the cylinder's base along the X and Z axes. If empty, it will default to a + // radius of 1.0 on each axis. Differing X and Z radii produce an elliptical cylinder. + BaseRadius mgl64.Vec2 + // TopRadius is the radius of the cylinder's top along the X and Z axes. If empty, it will default to + // BaseRadius, producing a straight cylinder. A smaller TopRadius tapers the cylinder into a frustum. + TopRadius mgl64.Vec2 + // Height is the height of the cylinder. If zero, it will default to 1.0. + Height float64 + // Segments is the number of segments that the cylinder will be drawn with. The more segments, the + // smoother the cylinder will look. If zero, it will default to 20. + Segments int + // Entity is an optional entity handle to attach the shape to. + Entity *world.EntityHandle +} + +// Pyramid represents a pyramid that can be drawn at any point in the world, with a base on the X and Z axes +// and a height running up the Y axis to a single apex. +type Pyramid struct { + shape + + // Colour is the colour that will be used for the outline. If empty, it will default to white. + Colour color.RGBA + // Position is the origin position of the shape in the world. + Position mgl64.Vec3 + // Scale is the rate to scale the shape from its origin point. If zero, it will default to 1.0. + Scale float64 + // Width is the width along the X axis of the pyramid base. If zero, it will default to 1.0. + Width float64 + // Depth is the depth along the Z axis of the pyramid base. If zero, it will default to Width. + Depth float64 + // Height is the height of the pyramid. If zero, it will default to 1.0. + Height float64 + // Entity is an optional entity handle to attach the shape to. + Entity *world.EntityHandle +} + +// Ellipsoid represents a hollow ellipsoid that can be drawn at any point in the world, with a radius along +// each of the X, Y and Z axes. +type Ellipsoid struct { + shape + + // Colour is the colour that will be used for the outline. If empty, it will default to white. + Colour color.RGBA + // Position is the origin position of the shape in the world. + Position mgl64.Vec3 + // Scale is the rate to scale the shape from its origin point. If zero, it will default to 1.0. + Scale float64 + // Radii are the radii of the ellipsoid along the X, Y and Z axes. If empty, it will default to a radius + // of 1.0 on each axis. + Radii mgl64.Vec3 + // SegmentsPerAxis is the number of segments that the ellipsoid will be drawn with per axis. The more + // segments, the smoother the ellipsoid will look. If zero, it will default to 20. + SegmentsPerAxis int + // Entity is an optional entity handle to attach the shape to. + Entity *world.EntityHandle +} + +// Cone represents a cone that can be drawn at any point in the world, with a base on the X and Z axes and a +// height running up the Y axis to a single apex. +type Cone struct { + shape + + // Colour is the colour that will be used for the outline. If empty, it will default to white. + Colour color.RGBA + // Position is the origin position of the shape in the world. + Position mgl64.Vec3 + // Scale is the rate to scale the shape from its origin point. If zero, it will default to 1.0. + Scale float64 + // Radii are the radii along the X and Z axes of the cone base. If empty, it will default to a radius of + // 1.0 on each axis. + Radii mgl64.Vec2 + // Height is the height of the cone. If zero, it will default to 1.0. + Height float64 + // Segments is the number of segments that the cone will be drawn with. The more segments, the smoother + // the cone will look. If zero, it will default to 20. + Segments int + // Entity is an optional entity handle to attach the shape to. + Entity *world.EntityHandle +} diff --git a/server/player/player.go b/server/player/player.go index bbccbd06e1..01149a57e3 100644 --- a/server/player/player.go +++ b/server/player/player.go @@ -91,7 +91,8 @@ type playerData struct { enchantSeed int64 - mc *entity.MovementComputer + mc *entity.MovementComputer + portalTravel *entity.PortalTravelComputer collidedVertically, collidedHorizontally bool @@ -1512,7 +1513,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 } @@ -2173,12 +2174,17 @@ func (p *Player) Teleport(pos mgl64.Vec3) { if p.Handler().HandleTeleport(ctx, pos); ctx.Cancelled() { return } + p.forceTeleport(pos) +} + +// forceTeleport teleports the player without calling the Handler. +// It also wakes up the player from sleep. +func (p *Player) forceTeleport(pos mgl64.Vec3) { p.Wake() p.teleport(pos) } -// teleport teleports the player to a target position in the world. It does not call the Handler of the -// player. +// teleport teleports the player to a target position in the world without updating non-positional state. func (p *Player) teleport(pos mgl64.Vec3) { for _, v := range p.viewers() { v.ViewEntityTeleport(p, pos) @@ -2584,6 +2590,13 @@ func (p *Player) Tick(tx *world.Tx, current int64) { } else { p.data.Vel = mgl64.Vec3{} } + + p.portalTravel.StopPortalContact() +} + +// TravelThroughPortal handles the player touching a portal block. +func (p *Player) TravelThroughPortal(tx *world.Tx, target world.Dimension) { + p.portalTravel.EnterPortal(p, tx, target) } // ViewLayer returns the ViewLayer attached to the player's session. diff --git a/server/session/enchantment_texts.go b/server/session/enchantment_texts.go index abe44b56be..853823736b 100644 --- a/server/session/enchantment_texts.go +++ b/server/session/enchantment_texts.go @@ -4,4 +4,4 @@ package session // enchantNames are names translated to the 'Standard Galactic Alphabet' client-side. The names generally have no meaning // on the vanilla server implementation, so we can sneak some easter eggs in here without anyone noticing. -var enchantNames = []string{"aabstractt", "abimek", "aericio", "aimjel", "akmal fairuz", "alvin0319", "andreashgk", "assassin ghost yt", "atm85", "azvyl", "blackjack200", "cetfu", "cjmustard", "cooldogedev", "cqdetdev", "da pig guy", "daft0175", "dasciam", "deniel world", "didntpot", "driftlgtm", "eminarican", "endermanbugzjfc", "erkam246", "ethaniccc", "fdutch", "flonja", "game parrot", "gewinum", "hashim the arab", "hochbaum", "hydzilla", "im da real ani", "inotflying", "ipad54", "its zodia x", "ivan craft623", "javier leon9966", "just tal develops", "k4ties", "krivey", "manab-pr", "mmm545", "mohamed587100", "myma qc", "natuyasai natuo", "neutronic mc", "nonono697", "nope not dark", "provsalt", "restart fu", "riccskn", "robertdudaa", "royal mcpe", "sallypemdas", "sandertv", "schphe", "sculas", "sergittos", "sqmatheus", "ssaini123456", "studgi", "superomarking", "t14 raptor", "tadhunt", "theaddonn", "thicksunny", "thunder33345", "tristanmorgan", "twisted asylum mc", "unickorn", "unknown ore", "uramnoil", "wqrro", "x natsuri", "x toast-dev", "x4caa", "xd-pro"} +var enchantNames = []string{"aabstractt", "abimek", "aericio", "aimjel", "akmal fairuz", "alvin0319", "andreashgk", "assassin ghost yt", "atm85", "azvyl", "blackjack200", "cetfu", "cjmustard", "cooldogedev", "cqdetdev", "da pig guy", "daft0175", "dasciam", "deniel world", "didntpot", "driftlgtm", "eminarican", "endermanbugzjfc", "erkam246", "ethaniccc", "fdutch", "flonja", "game parrot", "gewinum", "hashim the arab", "hochbaum", "hydzilla", "im da real ani", "inotflying", "ipad54", "its zodia x", "ivan craft623", "javier leon9966", "just tal develops", "k4ties", "krivey", "manab-pr", "memoxiiii", "mmm545", "mohamed587100", "myma qc", "natuyasai natuo", "neutronic mc", "nonono697", "nope not dark", "provsalt", "restart fu", "riccskn", "robertdudaa", "royal mcpe", "sallypemdas", "sandertv", "schphe", "sculas", "sergittos", "smell-of-curry", "sqmatheus", "ssaini123456", "studgi", "superomarking", "t14 raptor", "tadhunt", "theaddonn", "thicksunny", "thunder33345", "tripple awap", "tristanmorgan", "twisted asylum mc", "unickorn", "unknown ore", "uramnoil", "wqrro", "x natsuri", "x toast-dev", "x4caa", "xd-pro"} diff --git a/server/session/entity_metadata.go b/server/session/entity_metadata.go index 427266aacd..2cdfb2cfe0 100644 --- a/server/session/entity_metadata.go +++ b/server/session/entity_metadata.go @@ -148,7 +148,7 @@ func (s *Session) addSpecificMetadata(e any, m protocol.EntityMetadata) { } } - if l, ok := e.(living); ok && s.ent.UUID() == l.UUID() { + if l, ok := e.(living); ok && s.ent != nil && s.ent.UUID() == l.UUID() { deathPos, deathDimension, died := l.DeathPosition() if died { dim, _ := world.DimensionID(deathDimension) diff --git a/server/session/player.go b/server/session/player.go index a8642a7019..e039bb4406 100644 --- a/server/session/player.go +++ b/server/session/player.go @@ -1117,6 +1117,14 @@ func (s *Session) shapeAttachedEntityRuntimeID(shape debug.Shape) int64 { handle = shape.Entity case *debug.Text: handle = shape.Entity + case *debug.Cylinder: + handle = shape.Entity + case *debug.Pyramid: + handle = shape.Entity + case *debug.Ellipsoid: + handle = shape.Entity + case *debug.Cone: + handle = shape.Entity } if handle == nil { return 0 @@ -1138,7 +1146,7 @@ func debugShapeToProtocol(shape debug.Shape, dim world.Dimension, attachedEntity white := color.RGBA{R: 255, G: 255, B: 255, A: 255} switch shape := shape.(type) { case *debug.Arrow: - ps.Type = protocol.Option(uint8(protocol.PrimitiveShapeArrow)) + ps.Type = protocol.Option(protocol.PrimitiveShapeArrow) ps.Colour = protocol.Option(valueOrDefault(shape.Colour, white)) ps.Location = protocol.Option(vec64To32(shape.Position)) ps.ExtraShapeData = &protocol.ArrowShape{ @@ -1148,30 +1156,30 @@ func debugShapeToProtocol(shape debug.Shape, dim world.Dimension, attachedEntity Segments: protocol.Option(valueOrDefault(uint8(shape.HeadSegments), 4)), } case *debug.Box: - ps.Type = protocol.Option(uint8(protocol.PrimitiveShapeBox)) + ps.Type = protocol.Option(protocol.PrimitiveShapeBox) ps.Colour = protocol.Option(valueOrDefault(shape.Colour, white)) ps.Location = protocol.Option(vec64To32(shape.Position)) ps.Scale = protocol.Option(valueOrDefault(float32(shape.Scale), 1)) ps.ExtraShapeData = &protocol.BoxShape{BoxBound: valueOrDefault(vec64To32(shape.Bounds), mgl32.Vec3{1, 1, 1})} case *debug.Circle: - ps.Type = protocol.Option(uint8(protocol.PrimitiveShapeCircle)) + ps.Type = protocol.Option(protocol.PrimitiveShapeCircle) ps.Colour = protocol.Option(valueOrDefault(shape.Colour, white)) ps.Location = protocol.Option(vec64To32(shape.Position)) ps.Scale = protocol.Option(valueOrDefault(float32(shape.Scale), 1)) ps.ExtraShapeData = &protocol.SphereShape{Segments: valueOrDefault(uint8(shape.Segments), 20)} case *debug.Line: - ps.Type = protocol.Option(uint8(protocol.PrimitiveShapeLine)) + ps.Type = protocol.Option(protocol.PrimitiveShapeLine) ps.Colour = protocol.Option(valueOrDefault(shape.Colour, white)) ps.Location = protocol.Option(vec64To32(shape.Position)) ps.ExtraShapeData = &protocol.LineShape{LineEndLocation: vec64To32(shape.EndPosition)} case *debug.Sphere: - ps.Type = protocol.Option(uint8(protocol.PrimitiveShapeSphere)) + ps.Type = protocol.Option(protocol.PrimitiveShapeSphere) ps.Colour = protocol.Option(valueOrDefault(shape.Colour, white)) ps.Location = protocol.Option(vec64To32(shape.Position)) ps.Scale = protocol.Option(valueOrDefault(float32(shape.Scale), 1)) ps.ExtraShapeData = &protocol.SphereShape{Segments: valueOrDefault(uint8(shape.Segments), 20)} case *debug.Text: - ps.Type = protocol.Option(uint8(protocol.PrimitiveShapeText)) + ps.Type = protocol.Option(protocol.PrimitiveShapeText) ps.Colour = protocol.Option(valueOrDefault(shape.Colour, white)) ps.Location = protocol.Option(vec64To32(shape.Position)) ps.Scale = protocol.Option(valueOrDefault(float32(shape.Scale), 1)) @@ -1192,6 +1200,51 @@ func debugShapeToProtocol(shape debug.Shape, dim world.Dimension, attachedEntity textData.BackgroundColour = protocol.Option(shape.BackgroundColour) } ps.ExtraShapeData = textData + case *debug.Cylinder: + ps.Type = protocol.Option(protocol.PrimitiveShapeCylinder) + ps.Colour = protocol.Option(valueOrDefault(shape.Colour, white)) + ps.Location = protocol.Option(vec64To32(shape.Position)) + ps.Scale = protocol.Option(valueOrDefault(float32(shape.Scale), 1)) + base := valueOrDefault(shape.BaseRadius, mgl64.Vec2{1, 1}) + top := valueOrDefault(shape.TopRadius, base) + ps.ExtraShapeData = &protocol.CylinderShape{ + RadiusX: mgl32.Vec2{float32(base[0]), float32(top[0])}, + RadiusZ: mgl32.Vec2{float32(base[1]), float32(top[1])}, + Height: valueOrDefault(float32(shape.Height), 1), + NumSegments: valueOrDefault(uint8(shape.Segments), 20), + } + case *debug.Pyramid: + ps.Type = protocol.Option(protocol.PrimitiveShapePyramid) + ps.Colour = protocol.Option(valueOrDefault(shape.Colour, white)) + ps.Location = protocol.Option(vec64To32(shape.Position)) + ps.Scale = protocol.Option(valueOrDefault(float32(shape.Scale), 1)) + pyramid := &protocol.PyramidShape{ + Width: valueOrDefault(float32(shape.Width), 1), + Height: valueOrDefault(float32(shape.Height), 1), + } + if shape.Depth != 0 { + pyramid.Depth = protocol.Option(float32(shape.Depth)) + } + ps.ExtraShapeData = pyramid + case *debug.Ellipsoid: + ps.Type = protocol.Option(protocol.PrimitiveShapeEllipsoid) + ps.Colour = protocol.Option(valueOrDefault(shape.Colour, white)) + ps.Location = protocol.Option(vec64To32(shape.Position)) + ps.Scale = protocol.Option(valueOrDefault(float32(shape.Scale), 1)) + ps.ExtraShapeData = &protocol.EllipsoidShape{ + Radii: valueOrDefault(vec64To32(shape.Radii), mgl32.Vec3{1, 1, 1}), + SegmentsPerAxis: valueOrDefault(uint8(shape.SegmentsPerAxis), 20), + } + case *debug.Cone: + ps.Type = protocol.Option(protocol.PrimitiveShapeCone) + ps.Colour = protocol.Option(valueOrDefault(shape.Colour, white)) + ps.Location = protocol.Option(vec64To32(shape.Position)) + ps.Scale = protocol.Option(valueOrDefault(float32(shape.Scale), 1)) + ps.ExtraShapeData = &protocol.ConeShape{ + Radii: valueOrDefault(vec2To32(shape.Radii), mgl32.Vec2{1, 1}), + Height: valueOrDefault(float32(shape.Height), 1), + NumSegments: valueOrDefault(uint8(shape.Segments), 20), + } default: panic(fmt.Sprintf("unknown debug shape type %T", shape)) } diff --git a/server/session/session.go b/server/session/session.go index 0bfcc7934e..cdd1a69c19 100644 --- a/server/session/session.go +++ b/server/session/session.go @@ -144,7 +144,7 @@ type Conn interface { } // Nop represents a no-operation session. It does not do anything when sending a packet to it. -var Nop = &Session{} +var Nop = &Session{conf: Config{Log: slog.New(slog.DiscardHandler)}} // selfEntityRuntimeID is the entity runtime (or unique) ID of the controllable that the session holds. const selfEntityRuntimeID = 1 diff --git a/server/session/text.go b/server/session/text.go index 4c447b7f5e..d8418df4a8 100644 --- a/server/session/text.go +++ b/server/session/text.go @@ -1,12 +1,13 @@ package session import ( + "time" + "github.com/df-mc/dragonfly/server/player/chat" "github.com/df-mc/dragonfly/server/player/scoreboard" "github.com/sandertv/gophertunnel/minecraft/protocol" "github.com/sandertv/gophertunnel/minecraft/protocol/packet" "golang.org/x/text/language" - "time" ) // SendMessage ... @@ -145,7 +146,7 @@ func (s *Session) SendBossBar(text string, colour uint8, healthPercentage float6 EventType: packet.BossEventShow, BossBarTitle: text, HealthPercentage: float32(healthPercentage), - Colour: uint32(colour), + Colour: colour, }) } diff --git a/server/session/world.go b/server/session/world.go index 1ecdfb728e..d624576ded 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{ @@ -487,7 +488,7 @@ func (s *Session) ViewParticle(pos mgl64.Vec3, p world.Particle) { } // tierToSoundEvent converts an item.ArmourTier to a sound event associated with equipping it. -func tierToSoundEvent(tier item.ArmourTier) uint32 { +func tierToSoundEvent(tier item.ArmourTier) string { switch tier.(type) { case item.ArmourTierLeather: return packet.SoundEventEquipLeather @@ -1412,6 +1413,11 @@ func vec64To32(vec3 mgl64.Vec3) mgl32.Vec3 { return mgl32.Vec3{float32(vec3[0]), float32(vec3[1]), float32(vec3[2])} } +// vec2To32 converts a mgl64.Vec2 to a mgl32.Vec2. +func vec2To32(vec2 mgl64.Vec2) mgl32.Vec2 { + return mgl32.Vec2{float32(vec2[0]), float32(vec2[1])} +} + // blockPosFromProtocol ... func blockPosFromProtocol(pos protocol.BlockPos) cube.Pos { return cube.Pos{int(pos.X()), int(pos.Y()), int(pos.Z())} diff --git a/server/world/biome/register.go b/server/world/biome/register.go index 4db6de5115..d5b5aec45b 100644 --- a/server/world/biome/register.go +++ b/server/world/biome/register.go @@ -80,6 +80,7 @@ func init() { world.RegisterBiome(SoulSandValley{}) world.RegisterBiome(StonyPeaks{}) world.RegisterBiome(StonyShore{}) + world.RegisterBiome(SulfurCaves{}) world.RegisterBiome(SunflowerPlains{}) world.RegisterBiome(SwampHills{}) world.RegisterBiome(Swamp{}) diff --git a/server/world/biome/sulfur_caves.go b/server/world/biome/sulfur_caves.go new file mode 100644 index 0000000000..7c32f06aa0 --- /dev/null +++ b/server/world/biome/sulfur_caves.go @@ -0,0 +1,46 @@ +package biome + +import "image/color" + +// SulfurCaves ... +type SulfurCaves struct{} + +// Temperature ... +func (SulfurCaves) Temperature() float64 { + return 0.8 +} + +// Rainfall ... +func (SulfurCaves) Rainfall() float64 { + return 0.4 +} + +// Depth ... +func (SulfurCaves) Depth() float64 { + return 0.1 +} + +// Scale ... +func (SulfurCaves) Scale() float64 { + return 0.2 +} + +// WaterColour ... +func (SulfurCaves) WaterColour() color.RGBA { + return color.RGBA{R: 0x60, G: 0xb7, B: 0xff, A: 0xa6} +} + +// Tags ... +func (SulfurCaves) Tags() []string { + return []string{"caves", "sulfur_caves", "overworld", "monster"} +} + +// String ... +func (SulfurCaves) String() string { + return "sulfur_caves" +} + +// EncodeBiome ... +func (SulfurCaves) EncodeBiome() int { + return 194 +} diff --git a/server/world/block_states.nbt b/server/world/block_states.nbt index 73ccf6fe61..1e4d94632c 100644 Binary files a/server/world/block_states.nbt and b/server/world/block_states.nbt differ diff --git a/server/world/chunk/block_registry.go b/server/world/chunk/block_registry.go index d5a749dde4..24888d2bae 100644 --- a/server/world/chunk/block_registry.go +++ b/server/world/chunk/block_registry.go @@ -27,4 +27,6 @@ type BlockRegistry interface { LiquidBlock(rid uint32) bool // HashToRuntimeID resolves a "network block hash" to a runtime ID. HashToRuntimeID(hash uint32) (rid uint32, ok bool) + // RuntimeIDToHash resolves a runtime ID to its "network block hash". + RuntimeIDToHash(runtimeID uint32) (hash uint32, ok bool) } diff --git a/server/world/chunk/chunk.go b/server/world/chunk/chunk.go index 17ef1f10f5..ddee338b51 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) { @@ -212,14 +232,14 @@ func (chunk *Chunk) SubY(index int16) int16 { return (index << 4) + int16(chunk.r[0]) } -// HighestFilledSubChunk returns the index of the highest sub chunk in the chunk -// that has any blocks in it. 0 is returned if no subchunks have any blocks. +// HighestFilledSubChunk returns the number of sub chunks up to and including the +// highest sub chunk in the chunk that has any blocks in it. 0 is returned if no +// subchunks have any blocks. func (chunk *Chunk) HighestFilledSubChunk() uint16 { - highest := uint16(0) - for highest = uint16(len(chunk.sub) - 1); highest > 0; highest-- { - if !chunk.sub[highest].Empty() { - break + for i, sub := range slices.Backward(chunk.sub) { + if !sub.Empty() { + return uint16(i + 1) } } - return highest + return 0 } diff --git a/server/world/chunk/palette.go b/server/world/chunk/palette.go index 3b3d32b179..f3121f4f5b 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 92153e7fb2..ac49c67c44 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 21436cecb4..7a06680deb 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/entity.go b/server/world/entity.go index 01c6c0ba6a..3bcdc249d8 100644 --- a/server/world/entity.go +++ b/server/world/entity.go @@ -181,9 +181,10 @@ func (e *EntityHandle) execWorld(f func(tx *Tx, e Entity), weak bool) bool { } // We now arrive at the more complicated part. When we call e.w.Exec(), our // transaction must await earlier transactions in the world. If one of those - // earlier transactions tries to change e.w (through e.unsetAndLockWorld() - // or e.setAndUnlockWorld()), it must lock e.cond.L. This would lead to a - // deadlock, because we already have e.cond.L locked here. + // earlier transactions tries to change e.w (through e.unsetAndLockWorld(), + // e.setAndUnlockWorld(), or e.setAndUnlockWorldAt()), it must lock + // e.cond.L. This would lead to a deadlock, because we already have e.cond.L + // locked here. // We work around this with so-called "weak transactions". This is a // transaction that may be invalidated before it is executed. In this case, // this invalidation happens by setting e.worldless to true. If the @@ -262,6 +263,20 @@ func (e *EntityHandle) setAndUnlockWorld(w *World) { e.cond.Broadcast() } +// setAndUnlockWorldAt sets e's position before publishing e to the World +// passed, then broadcasts e.cond so waiters can open the entity. +func (e *EntityHandle) setAndUnlockWorldAt(w *World, pos mgl64.Vec3) { + e.cond.L.Lock() + defer e.cond.L.Unlock() + + if e.w != nil { + panic("cannot add entity to new world before removing from old world") + } + e.data.Pos = pos + e.w = w + e.cond.Broadcast() +} + // decodeNBT decodes the position, velocity, rotation, age, on-fire duration and // name tag of an entity. func (e *EntityHandle) decodeNBT(m map[string]any) { diff --git a/server/world/handler.go b/server/world/handler.go index bdd248ae16..1867711f3a 100644 --- a/server/world/handler.go +++ b/server/world/handler.go @@ -53,7 +53,7 @@ type Handler interface { // ctx.Cancel() may be called to prevent leaves from decaying. HandleLeavesDecay(ctx *Context, pos cube.Pos) // HandleEntitySpawn handles an Entity being spawned into a World through a - // call to Tx.AddEntity. + // call to Tx.AddEntity or Tx.AddEntityAt. HandleEntitySpawn(tx *Tx, e Entity) // HandleEntityDespawn handles an Entity being despawned from a World // through a call to Tx.RemoveEntity. diff --git a/server/world/mcdb/leveldat/data.go b/server/world/mcdb/leveldat/data.go index 05fd507449..68f45038c8 100644 --- a/server/world/mcdb/leveldat/data.go +++ b/server/world/mcdb/leveldat/data.go @@ -74,83 +74,86 @@ type Data struct { WalkSpeed float32 `nbt:"walkSpeed"` VerticalFlySpeed float32 `nbt:"verticalFlySpeed"` } `nbt:"abilities"` - BonusChestEnabled bool `nbt:"bonusChestEnabled"` - BonusChestSpawned bool `nbt:"bonusChestSpawned"` - CommandBlockOutput bool `nbt:"commandblockoutput"` - CommandBlocksEnabled bool `nbt:"commandblocksenabled"` - CommandsEnabled bool `nbt:"commandsEnabled"` - CurrentTick int64 `nbt:"currentTick"` - DoDayLightCycle bool `nbt:"dodaylightcycle"` - DoEntityDrops bool `nbt:"doentitydrops"` - DoFireTick bool `nbt:"dofiretick"` - DoImmediateRespawn bool `nbt:"doimmediaterespawn"` - DoInsomnia bool `nbt:"doinsomnia"` - DoMobLoot bool `nbt:"domobloot"` - DoMobSpawning bool `nbt:"domobspawning"` - DoTileDrops bool `nbt:"dotiledrops"` - DoWeatherCycle bool `nbt:"doweathercycle"` - DrowningDamage bool `nbt:"drowningdamage"` - EduLevel bool `nbt:"eduLevel"` - EducationFeaturesEnabled bool `nbt:"educationFeaturesEnabled"` - ExperimentalGamePlay bool `nbt:"experimentalgameplay"` - FallDamage bool `nbt:"falldamage"` - FireDamage bool `nbt:"firedamage"` - FunctionCommandLimit int32 `nbt:"functioncommandlimit"` - HasBeenLoadedInCreative bool `nbt:"hasBeenLoadedInCreative"` - HasLockedBehaviourPack bool `nbt:"hasLockedBehaviorPack"` - HasLockedResourcePack bool `nbt:"hasLockedResourcePack"` - ImmutableWorld bool `nbt:"immutableWorld"` - IsCreatedInEditor bool `nbt:"isCreatedInEditor"` - IsExportedFromEditor bool `nbt:"isExportedFromEditor"` - IsFromLockedTemplate bool `nbt:"isFromLockedTemplate"` - IsFromWorldTemplate bool `nbt:"isFromWorldTemplate"` - IsWorldTemplateOptionLocked bool `nbt:"isWorldTemplateOptionLocked"` - KeepInventory bool `nbt:"keepinventory"` - LastOpenedWithVersion []int32 `nbt:"lastOpenedWithVersion"` - LightningLevel float32 `nbt:"lightningLevel"` - LightningTime int32 `nbt:"lightningTime"` - MaxCommandChainLength int32 `nbt:"maxcommandchainlength"` - MobGriefing bool `nbt:"mobgriefing"` - NaturalRegeneration bool `nbt:"naturalregeneration"` - PRID string `nbt:"prid"` - PVP bool `nbt:"pvp"` - RainLevel float32 `nbt:"rainLevel"` - RainTime int32 `nbt:"rainTime"` - RandomTickSpeed int32 `nbt:"randomtickspeed"` - RequiresCopiedPackRemovalCheck bool `nbt:"requiresCopiedPackRemovalCheck"` - SendCommandFeedback bool `nbt:"sendcommandfeedback"` - ServerChunkTickRange int32 `nbt:"serverChunkTickRange"` - ShowCoordinates bool `nbt:"showcoordinates"` - ShowDeathMessages bool `nbt:"showdeathmessages"` - SpawnMobs bool `nbt:"spawnMobs"` - SpawnRadius int32 `nbt:"spawnradius"` - StartWithMapEnabled bool `nbt:"startWithMapEnabled"` - TexturePacksRequired bool `nbt:"texturePacksRequired"` - TNTExplodes bool `nbt:"tntexplodes"` - UseMSAGamerTagsOnly bool `nbt:"useMsaGamertagsOnly"` - WorldStartCount int64 `nbt:"worldStartCount"` - Experiments map[string]any `nbt:"experiments"` - FreezeDamage bool `nbt:"freezedamage"` - WorldPolicies map[string]any `nbt:"world_policies"` - WorldVersion int32 `nbt:"WorldVersion"` - RespawnBlocksExplode bool `nbt:"respawnblocksexplode"` - ShowBorderEffect bool `nbt:"showbordereffect"` - PermissionsLevel int32 `nbt:"permissionsLevel"` - PlayerPermissionsLevel int32 `nbt:"playerPermissionsLevel"` - IsRandomSeedAllowed bool `nbt:"isRandomSeedAllowed"` - DoLimitedCrafting bool `nbt:"dolimitedcrafting"` - EditorWorldType int32 `nbt:"editorWorldType"` - PlayersSleepingPercentage int32 `nbt:"playerssleepingpercentage"` - RecipesUnlock bool `nbt:"recipesunlock"` - NaturalGeneration bool `nbt:"naturalgeneration"` - ProjectilesCanBreakBlocks bool `nbt:"projectilescanbreakblocks"` - ShowRecipeMessages bool `nbt:"showrecipemessages"` - IsHardcore bool `nbt:"IsHardcore"` - ShowDaysPlayed bool `nbt:"showdaysplayed"` - TNTExplosionDropDecay bool `nbt:"tntexplosiondropdecay"` - HasUncompleteWorldFileOnDisk bool `nbt:"HasUncompleteWorldFileOnDisk"` - PlayerHasDied bool `nbt:"PlayerHasDied"` - UseAllowList bool `nbt:"UseAllowList"` + BonusChestEnabled bool `nbt:"bonusChestEnabled"` + BonusChestSpawned bool `nbt:"bonusChestSpawned"` + CommandBlockOutput bool `nbt:"commandblockoutput"` + CommandBlocksEnabled bool `nbt:"commandblocksenabled"` + CommandsEnabled bool `nbt:"commandsEnabled"` + CurrentTick int64 `nbt:"currentTick"` + DoDayLightCycle bool `nbt:"dodaylightcycle"` + DoEntityDrops bool `nbt:"doentitydrops"` + DoFireTick bool `nbt:"dofiretick"` + DoImmediateRespawn bool `nbt:"doimmediaterespawn"` + DoInsomnia bool `nbt:"doinsomnia"` + DoMobLoot bool `nbt:"domobloot"` + DoMobSpawning bool `nbt:"domobspawning"` + DoTileDrops bool `nbt:"dotiledrops"` + DoWeatherCycle bool `nbt:"doweathercycle"` + DrowningDamage bool `nbt:"drowningdamage"` + EduLevel bool `nbt:"eduLevel"` + EducationFeaturesEnabled bool `nbt:"educationFeaturesEnabled"` + ExperimentalGamePlay bool `nbt:"experimentalgameplay"` + FallDamage bool `nbt:"falldamage"` + FireDamage bool `nbt:"firedamage"` + FunctionCommandLimit int32 `nbt:"functioncommandlimit"` + HasBeenLoadedInCreative bool `nbt:"hasBeenLoadedInCreative"` + HasLockedBehaviourPack bool `nbt:"hasLockedBehaviorPack"` + HasLockedResourcePack bool `nbt:"hasLockedResourcePack"` + ImmutableWorld bool `nbt:"immutableWorld"` + IsCreatedInEditor bool `nbt:"isCreatedInEditor"` + IsExportedFromEditor bool `nbt:"isExportedFromEditor"` + IsFromLockedTemplate bool `nbt:"isFromLockedTemplate"` + IsFromWorldTemplate bool `nbt:"isFromWorldTemplate"` + IsWorldTemplateOptionLocked bool `nbt:"isWorldTemplateOptionLocked"` + KeepInventory bool `nbt:"keepinventory"` + LastOpenedWithVersion []int32 `nbt:"lastOpenedWithVersion"` + LightningLevel float32 `nbt:"lightningLevel"` + LightningTime int32 `nbt:"lightningTime"` + MaxCommandChainLength int32 `nbt:"maxcommandchainlength"` + MobGriefing bool `nbt:"mobgriefing"` + NaturalRegeneration bool `nbt:"naturalregeneration"` + PRID string `nbt:"prid"` + PVP bool `nbt:"pvp"` + RainLevel float32 `nbt:"rainLevel"` + RainTime int32 `nbt:"rainTime"` + RandomTickSpeed int32 `nbt:"randomtickspeed"` + RequiresCopiedPackRemovalCheck bool `nbt:"requiresCopiedPackRemovalCheck"` + SendCommandFeedback bool `nbt:"sendcommandfeedback"` + ServerChunkTickRange int32 `nbt:"serverChunkTickRange"` + ShowCoordinates bool `nbt:"showcoordinates"` + ShowDeathMessages bool `nbt:"showdeathmessages"` + SpawnMobs bool `nbt:"spawnMobs"` + SpawnRadius int32 `nbt:"spawnradius"` + StartWithMapEnabled bool `nbt:"startWithMapEnabled"` + TexturePacksRequired bool `nbt:"texturePacksRequired"` + TNTExplodes bool `nbt:"tntexplodes"` + UseMSAGamerTagsOnly bool `nbt:"useMsaGamertagsOnly"` + WorldStartCount int64 `nbt:"worldStartCount"` + Experiments map[string]any `nbt:"experiments"` + FreezeDamage bool `nbt:"freezedamage"` + WorldPolicies map[string]any `nbt:"world_policies"` + WorldVersion int32 `nbt:"WorldVersion"` + RespawnBlocksExplode bool `nbt:"respawnblocksexplode"` + ShowBorderEffect bool `nbt:"showbordereffect"` + PermissionsLevel int32 `nbt:"permissionsLevel"` + PlayerPermissionsLevel int32 `nbt:"playerPermissionsLevel"` + IsRandomSeedAllowed bool `nbt:"isRandomSeedAllowed"` + DoLimitedCrafting bool `nbt:"dolimitedcrafting"` + EditorWorldType int32 `nbt:"editorWorldType"` + PlayersSleepingPercentage int32 `nbt:"playerssleepingpercentage"` + RecipesUnlock bool `nbt:"recipesunlock"` + NaturalGeneration bool `nbt:"naturalgeneration"` + ProjectilesCanBreakBlocks bool `nbt:"projectilescanbreakblocks"` + ShowRecipeMessages bool `nbt:"showrecipemessages"` + IsHardcore bool `nbt:"IsHardcore"` + ShowDaysPlayed bool `nbt:"showdaysplayed"` + TNTExplosionDropDecay bool `nbt:"tntexplosiondropdecay"` + HasUncompleteWorldFileOnDisk bool `nbt:"HasUncompleteWorldFileOnDisk"` + PlayerHasDied bool `nbt:"PlayerHasDied"` + UseAllowList bool `nbt:"UseAllowList"` + AllowAnonymousBlockDropsInEditorWorlds bool `nbt:"allowAnonymousBlockDropsInEditorWorlds"` + PlayerWaypoints int32 `nbt:"playerwaypoints"` + ServerEditorConnectionPolicy int32 `nbt:"serverEditorConnectionPolicy"` } // FillDefault fills out d with all the default level.dat values. diff --git a/server/world/mcdb/leveldat/level_dat.go b/server/world/mcdb/leveldat/level_dat.go index eabdd1910c..4e4d63ccba 100644 --- a/server/world/mcdb/leveldat/level_dat.go +++ b/server/world/mcdb/leveldat/level_dat.go @@ -4,9 +4,10 @@ import ( "bufio" "encoding/binary" "fmt" - "github.com/sandertv/gophertunnel/minecraft/nbt" "io" "os" + + "github.com/sandertv/gophertunnel/minecraft/nbt" ) // LevelDat implements the encoding and decoding of level.dat files. An empty @@ -40,7 +41,7 @@ func Read(r io.Reader) (*LevelDat, error) { return nil, fmt.Errorf("level.dat: read header: %w", err) } ldat.data = make([]byte, ldat.hdr.FileLength) - if n, err := r.Read(ldat.data); err != nil || int32(n) != ldat.hdr.FileLength { + if n, err := io.ReadFull(r, ldat.data); err != nil || int32(n) != ldat.hdr.FileLength { return nil, fmt.Errorf("level.dat: read data: %w", err) } return &ldat, nil diff --git a/server/world/particle/block.go b/server/world/particle/block.go index 43130e2b3d..71133aaa44 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/portal/nether.go b/server/world/portal/nether.go new file mode 100644 index 0000000000..61bdd6b240 --- /dev/null +++ b/server/world/portal/nether.go @@ -0,0 +1,322 @@ +package portal + +import ( + "math" + "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/world" +) + +// Nether contains information about a nether portal structure. Values returned from this package are tied to the +// transaction that created them and must not be retained after that transaction finishes. +type Nether struct { + w, h int + framed bool + axis cube.Axis + tx *world.Tx + spawnPos cube.Pos + positions []cube.Pos +} + +const ( + minimumNetherPortalWidth, maximumNetherPortalWidth = 2, 21 + minimumNetherPortalHeight, maximumNetherPortalHeight = 3, 21 + + minimumNetherPortalArea = minimumNetherPortalWidth * minimumNetherPortalHeight +) + +// NetherPortalFromPos returns Nether portal information from a given position in the frame. +func NetherPortalFromPos(tx *world.Tx, pos cube.Pos) (Nether, bool) { + if tx.World().Dimension() == world.End { + return Nether{}, false + } + + axis, positions, width, height, completed, ok := multiAxisScan(pos, tx, matchesNetherPortalInterior) + if !ok { + axis, positions, width, height, completed, ok = multiAxisScan(pos, tx, matchesNetherPortal) + } + if !ok { + return Nether{}, false + } + return Nether{ + w: width, h: height, + spawnPos: pos, + positions: positions, + framed: completed, + axis: axis, + tx: tx, + }, ok +} + +// ActivateNetherPortal activates an inactive framed Nether portal at the position passed. +func ActivateNetherPortal(tx *world.Tx, pos cube.Pos) bool { + p, ok := NetherPortalFromPos(tx, pos) + if !ok || !p.Framed() || p.Activated() { + return false + } + p.Activate() + return true +} + +// DeactivateNetherPortal deactivates the connected Nether portal at the position passed. +func DeactivateNetherPortal(tx *world.Tx, pos cube.Pos) bool { + _, positions, ok := connectedNetherPortal(tx, pos) + if !ok { + return false + } + deactivate(tx, positions) + return true +} + +// FindOrCreateNetherPortal finds or creates a Nether portal at the given position. +func FindOrCreateNetherPortal(tx *world.Tx, pos cube.Pos, radius int) (Nether, bool) { + n, ok := FindNetherPortal(tx, pos, radius) + if ok { + return n, true + } + return CreateNetherPortal(tx, pos) +} + +// portalBlock represents a block that can be used as a portal to travel between dimensions. +type portalBlock interface { + // Portal returns the dimension that the portal leads to. + Portal() world.Dimension +} + +// frameBlock represents a block that can be used as a frame for a Nether portal. +type frameBlock interface { + // Frame returns true if the block is used as a frame for the given dimension. + Frame(dimension world.Dimension) bool +} + +// FindNetherPortal searches a provided radius for a Nether portal. +func FindNetherPortal(tx *world.Tx, pos cube.Pos, radius int) (Nether, bool) { + if tx.World().Dimension() == world.End { + return Nether{}, false + } + + closest, closestDist, found := Nether{}, math.MaxFloat64, false + for x := pos.X() - radius; x < pos.X()+radius; x++ { + for z := pos.Z() - radius; z < pos.Z()+radius; z++ { + r := tx.World().Dimension().Range() + for y := r.Max(); y >= r.Min(); y-- { + selectedPos := cube.Pos{x, y, z} + if p, ok := tx.Block(selectedPos).(portalBlock); ok && p.Portal() == world.Nether { + if n, ok := NetherPortalFromPos(tx, selectedPos); ok && n.Framed() && n.Activated() { + dist := selectedPos.Vec3().Sub(pos.Vec3()).Len() + if dist < closestDist { + closestDist, closest, found = dist, n, true + } + } + } + } + } + } + if !found { + return Nether{}, false + } + return closest, true +} + +// CreateNetherPortal creates a Nether portal at the given position. +func CreateNetherPortal(tx *world.Tx, pos cube.Pos) (Nether, bool) { + if tx.World().Dimension() == world.End { + return Nether{}, false + } + + resultPos, random, distance, a, r := pos, rand.IntN(4), -1.0, 0, tx.Range() + searchValidArea := func(directions int, valid func(pos cube.Pos, riv int, coEff1, coEff2 int) bool) { + for tempX := pos.X() - 16; tempX <= pos.X()+16; tempX++ { + offsetX := float64(tempX-pos.X()) + 0.5 + for tempZ := pos.Z() - 16; tempZ <= pos.Z()+16; tempZ++ { + offsetZ := float64(tempZ-pos.Z()) + 0.5 + for tempY := r.Max() - 1; tempY >= r.Min(); tempY-- { + entryPos := cube.Pos{tempX, tempY, tempZ} + if tx.Block(entryPos) != air() { + continue + } + + for tempY > r.Min() && tx.Block(entryPos.Side(cube.FaceDown)) == air() { + tempY-- + entryPos[1]-- + } + + for riv := random; riv < random+directions; riv++ { + coEff1 := riv % 2 + coEff2 := 1 - coEff1 + + if !valid(entryPos, riv, coEff1, coEff2) { + break + } + + offsetY := float64(tempY-pos.Y()) + 0.5 + newDist := offsetX*offsetX + offsetY*offsetY + offsetZ*offsetZ + if distance < 0.0 || newDist < distance { + distance = newDist + a = riv % directions + resultPos = cube.Pos{tempX, tempY, tempZ} + } + } + } + } + } + } + + // Search for a valid area in all four directions, adding some extra space for comfort. + searchValidArea(4, func(pos cube.Pos, riv int, coEff1, coEff2 int) bool { + if riv%4 >= 2 { + coEff1 = -coEff1 + coEff2 = -coEff2 + } + + for safeSpace1 := range 3 { + for safeSpace2 := -1; safeSpace2 < 3; safeSpace2++ { + for height := -1; height < 4; height++ { + b := tx.Block(cube.Pos{ + pos.X() + safeSpace2*coEff1 + safeSpace1*coEff2, + pos.Y() + height, + pos.Z() + safeSpace2*coEff2 - safeSpace1*coEff1, + }) + _, solid := b.Model().(model.Solid) + if height < 0 && !solid || height >= 0 && b != air() { + return false + } + } + } + } + return true + }) + + if distance < 0.0 { + // If we couldn't find a valid area under those specifications, we can search the two main directions instead, + // reducing comfort but at least allowing us to have a portal in the area. + searchValidArea(2, func(pos cube.Pos, riv int, coEff1, coEff2 int) bool { + for safeSpace := range 3 { + for height := -1; height < 4; height++ { + b := tx.Block(cube.Pos{ + pos.X() + safeSpace*coEff1, + pos.Y() + height, + pos.Z() + safeSpace*coEff2, + }) + _, solid := b.Model().(model.Solid) + if height < 0 && !solid || height >= 0 && b != air() { + return false + } + } + } + return true + }) + } + + coEff1 := a % 2 + coEff2 := 1 - coEff1 + if a%4 >= 2 { + coEff1 = -coEff1 + coEff2 = -coEff2 + } + + axis := cube.X + if coEff1 == 0 { + axis = cube.Z + } + + if distance < 0.0 { + // If all else fails, we can simply create a floating platform in the void with the portal on it. + resultPos[1] = min(max(resultPos[1], 70), r.Max()-10) + for safeBeforeAfter := -1; safeBeforeAfter <= 1; safeBeforeAfter++ { + for safeWidth := range 2 { + for height := -1; height < 3; height++ { + entryPos := cube.Pos{ + resultPos.X() + safeWidth*coEff1 + safeBeforeAfter*coEff2, + resultPos.Y() + height, + resultPos.Z() + safeWidth*coEff2 - safeBeforeAfter*coEff1, + } + + tx.SetBlock(entryPos, nil, nil) + if height < 0 { + tx.SetBlock(entryPos, obsidian(), nil) + } + } + } + } + } + + // Build the portal frame and activate it. + var positions []cube.Pos + for width := -1; width < 3; width++ { + for height := -1; height < 4; height++ { + entryPos := cube.Pos{ + resultPos.X() + width*coEff1, + resultPos.Y() + height, + resultPos.Z() + width*coEff2, + } + + if width == -1 || width == 2 || height == -1 || height == 3 { + tx.SetBlock(entryPos, obsidian(), nil) + continue + } + positions = append(positions, entryPos) + tx.SetBlock(entryPos, portal(axis), nil) + } + } + + return Nether{ + w: minimumNetherPortalWidth, + h: minimumNetherPortalHeight, + framed: true, + spawnPos: resultPos, + positions: positions, + axis: axis, + tx: tx, + }, true +} + +// Bounds ... +func (n Nether) Bounds() (int, int) { + return n.w, n.h +} + +// Activate ... +func (n Nether) Activate() { + for _, pos := range n.Positions() { + n.tx.SetBlock(pos, portal(n.axis), nil) + } +} + +// Deactivate ... +func (n Nether) Deactivate() { + deactivate(n.tx, n.Positions()) +} + +func deactivate(tx *world.Tx, positions []cube.Pos) { + for _, pos := range positions { + tx.SetBlock(pos, nil, nil) + } +} + +// Framed ... +func (n Nether) Framed() bool { + return n.framed +} + +// Activated ... +func (n Nether) Activated() bool { + for _, pos := range n.Positions() { + if n.tx.Block(pos) != portal(n.axis) { + return false + } + } + return true +} + +// Spawn ... +func (n Nether) Spawn() cube.Pos { + return n.spawnPos +} + +// Positions ... +func (n Nether) Positions() []cube.Pos { + return n.positions +} diff --git a/server/world/portal/nether_test.go b/server/world/portal/nether_test.go new file mode 100644 index 0000000000..25993643c1 --- /dev/null +++ b/server/world/portal/nether_test.go @@ -0,0 +1,221 @@ +package portal_test + +import ( + "testing" + + "github.com/df-mc/dragonfly/server/block" + "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/df-mc/dragonfly/server/world/portal" +) + +func TestNetherPortalFromPos(t *testing.T) { + tests := []struct { + name string + build func(tx *world.Tx, origin cube.Pos) + pos cube.Pos + ok bool + }{ + { + name: "valid vertical frame", + build: func(tx *world.Tx, origin cube.Pos) { + buildVerticalFrame(tx, origin, cube.Z, 2, 3) + }, + pos: cube.Pos{}, + ok: true, + }, + { + name: "maximum size frame", + build: func(tx *world.Tx, origin cube.Pos) { + buildVerticalFrame(tx, origin, cube.Z, 21, 21) + }, + pos: cube.Pos{}, + ok: true, + }, + { + name: "too wide frame", + build: func(tx *world.Tx, origin cube.Pos) { + buildVerticalFrame(tx, origin, cube.Z, 22, 3) + }, + pos: cube.Pos{}, + }, + { + name: "too tall frame", + build: func(tx *world.Tx, origin cube.Pos) { + buildVerticalFrame(tx, origin, cube.Z, 2, 22) + }, + pos: cube.Pos{}, + }, + { + name: "horizontal frame", + build: func(tx *world.Tx, origin cube.Pos) { + buildHorizontalFrame(tx, origin) + }, + pos: cube.Pos{1, 0, 1}, + }, + { + name: "crying obsidian does not complete frame", + build: func(tx *world.Tx, origin cube.Pos) { + buildVerticalFrame(tx, origin, cube.Z, 2, 3) + tx.SetBlock(origin.Side(cube.FaceNorth), block.Obsidian{Crying: true}, nil) + }, + pos: cube.Pos{}, + }, + { + name: "missing side frame does not complete frame", + build: func(tx *world.Tx, origin cube.Pos) { + buildVerticalFrame(tx, origin, cube.Z, 2, 3) + tx.SetBlock(origin.Side(cube.FaceNorth), nil, nil) + }, + pos: cube.Pos{}, + }, + { + name: "soul fire does not activate frame", + build: func(tx *world.Tx, origin cube.Pos) { + buildVerticalFrame(tx, origin, cube.Z, 2, 3) + tx.SetBlock(origin, block.Fire{Type: block.SoulFire()}, nil) + }, + pos: cube.Pos{}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + w := world.New() + t.Cleanup(func() { _ = w.Close() }) + + origin := cube.Pos{8, 10, 8} + <-w.Exec(func(tx *world.Tx) { + tt.build(tx, origin) + p, ok := portal.NetherPortalFromPos(tx, origin.Add(tt.pos)) + if ok != tt.ok { + t.Fatalf("NetherPortalFromPos() ok = %v, want %v, portal = %#v", ok, tt.ok, p) + } + if ok && !p.Framed() { + t.Fatal("NetherPortalFromPos() returned an unframed portal") + } + }) + }) + } +} + +func TestPortalModelHasNoCollisionBBox(t *testing.T) { + for _, axis := range []cube.Axis{cube.X, cube.Z} { + if boxes := (model.Portal{Axis: axis}).BBox(cube.Pos{}, nil); len(boxes) != 0 { + t.Fatalf("BBox() returned %d boxes, want 0", len(boxes)) + } + } +} + +func TestActivateNetherPortal(t *testing.T) { + w := world.New() + t.Cleanup(func() { _ = w.Close() }) + + origin := cube.Pos{8, 10, 8} + <-w.Exec(func(tx *world.Tx) { + buildVerticalFrame(tx, origin, cube.Z, 2, 3) + if !portal.ActivateNetherPortal(tx, origin) { + t.Fatal("ActivateNetherPortal() = false, want true") + } + for x := range 2 { + for y := range 3 { + if _, ok := tx.Block(origin.Add(widthOffset(cube.Z, x)).Add(cube.Pos{0, y})).(block.Portal); !ok { + t.Fatalf("portal block not placed at interior offset %d,%d", x, y) + } + } + } + }) +} + +func TestFireChargeActivatesNetherPortal(t *testing.T) { + w := world.New() + t.Cleanup(func() { _ = w.Close() }) + + origin := cube.Pos{8, 10, 8} + <-w.Exec(func(tx *world.Tx) { + buildVerticalFrame(tx, origin, cube.Z, 2, 3) + ctx := &item.UseContext{} + if ok := (item.FireCharge{}).UseOnBlock(origin.Side(cube.FaceDown), cube.FaceUp, cube.Pos{}.Vec3(), tx, nil, ctx); !ok { + t.Fatal("FireCharge.UseOnBlock() = false, want true") + } + if ctx.CountSub != 1 { + t.Fatalf("FireCharge.UseOnBlock() subtracted %d items, want 1", ctx.CountSub) + } + if _, ok := tx.Block(origin).(block.Portal); !ok { + t.Fatal("FireCharge.UseOnBlock() did not activate portal") + } + }) +} + +func TestActivatedPortalCleanupOnBrokenFrame(t *testing.T) { + w := world.New() + t.Cleanup(func() { _ = w.Close() }) + + origin := cube.Pos{8, 10, 8} + <-w.Exec(func(tx *world.Tx) { + buildVerticalFrame(tx, origin, cube.Z, 2, 3) + if !portal.ActivateNetherPortal(tx, origin) { + t.Fatal("ActivateNetherPortal() = false, want true") + } + + broken := origin.Add(widthOffset(cube.Z, 2)).Add(cube.Pos{0, 1}) + tx.SetBlock(broken, nil, nil) + + updated := origin.Add(widthOffset(cube.Z, 1)).Add(cube.Pos{0, 1}) + pb, ok := tx.Block(updated).(block.Portal) + if !ok { + t.Fatalf("block at updated position = %T, want block.Portal", tx.Block(updated)) + } + pb.NeighbourUpdateTick(updated, broken, tx) + + var remaining []cube.Pos + for x := range 2 { + for y := range 3 { + p := origin.Add(widthOffset(cube.Z, x)).Add(cube.Pos{0, y}) + if _, ok := tx.Block(p).(block.Portal); ok { + remaining = append(remaining, p) + } + } + } + if len(remaining) != 0 { + t.Fatalf("after frame break: %d orphan portal blocks remain at %v", len(remaining), remaining) + } + }) +} + +func buildVerticalFrame(tx *world.Tx, origin cube.Pos, axis cube.Axis, width, height int) { + for x := 0; x < width; x++ { + p := origin.Add(widthOffset(axis, x)) + tx.SetBlock(p.Side(cube.FaceDown), block.Obsidian{}, nil) + tx.SetBlock(p.Add(cube.Pos{0, height}), block.Obsidian{}, nil) + } + negative := cube.FaceNorth + if axis == cube.X { + negative = cube.FaceWest + } + for y := 0; y < height; y++ { + p := origin.Add(cube.Pos{0, y}) + tx.SetBlock(p.Side(negative), block.Obsidian{}, nil) + tx.SetBlock(p.Add(widthOffset(axis, width)), block.Obsidian{}, nil) + } +} + +func buildHorizontalFrame(tx *world.Tx, origin cube.Pos) { + for x := 0; x < 3; x++ { + for z := 0; z < 3; z++ { + if x == 1 && z == 1 { + continue + } + tx.SetBlock(origin.Add(cube.Pos{x, 0, z}), block.Obsidian{}, nil) + } + } +} + +func widthOffset(axis cube.Axis, width int) cube.Pos { + if axis == cube.X { + return cube.Pos{width, 0, 0} + } + return cube.Pos{0, 0, width} +} diff --git a/server/world/portal/scan.go b/server/world/portal/scan.go new file mode 100644 index 0000000000..722fad2f11 --- /dev/null +++ b/server/world/portal/scan.go @@ -0,0 +1,191 @@ +package portal + +import ( + "github.com/df-mc/dragonfly/server/block/cube" + "github.com/df-mc/dragonfly/server/block/model" + "github.com/df-mc/dragonfly/server/world" +) + +// blockMatcher reports whether a block belongs to a portal interior on the given axis. +type blockMatcher func(world.Block, cube.Axis) bool + +// multiAxisScan performs a scan on the Z and X axis, favouring the Z axis unless only the X axis reaches the minimum +// area. The last return value reports whether a portal-like interior was found; use Framed to check completion. +func multiAxisScan(framePos cube.Pos, tx *world.Tx, matches blockMatcher) (cube.Axis, []cube.Pos, int, int, bool, bool) { + zPositions, zWidth, zHeight, zCompleted := scan(cube.Z, framePos, tx, matches) + xPositions, xWidth, xHeight, xCompleted := scan(cube.X, framePos, tx, matches) + if len(zPositions) < minimumNetherPortalArea && len(xPositions) >= minimumNetherPortalArea { + return cube.X, xPositions, xWidth, xHeight, xCompleted, len(xPositions) > 0 + } + return cube.Z, zPositions, zWidth, zHeight, zCompleted, len(zPositions) > 0 +} + +// scan validates a vertical rectangular portal interior on the given horizontal axis. +func scan(axis cube.Axis, pos cube.Pos, tx *world.Tx, matches blockMatcher) ([]cube.Pos, int, int, bool) { + // Return if the starting block isn't part of a portal interior. + if !matches(tx.Block(pos), axis) { + return nil, 0, 0, false + } + negative, positive := axis.Faces() + + // Walk down then towards the negative face to land on the bottom-left interior corner. + origin := pos + for down, next := 0, origin.Side(cube.FaceDown); matches(tx.Block(next), axis); down, next = down+1, origin.Side(cube.FaceDown) { + if down >= maximumNetherPortalHeight { + return nil, 0, 0, false + } + origin = next + } + for left, next := 0, origin.Side(negative); matches(tx.Block(next), axis); left, next = left+1, origin.Side(negative) { + if left >= maximumNetherPortalWidth { + return nil, 0, 0, false + } + origin = next + } + + // Measure the bottom row and the leftmost column from the origin. + width := 0 + for p := origin; matches(tx.Block(p), axis); p = p.Side(positive) { + width++ + if width > maximumNetherPortalWidth { + return nil, 0, 0, false + } + } + height := 0 + for p := origin; matches(tx.Block(p), axis); p = p.Side(cube.FaceUp) { + height++ + if height > maximumNetherPortalHeight { + return nil, 0, 0, false + } + } + // Reject anything smaller than the minimum frame size. + if width < minimumNetherPortalWidth || height < minimumNetherPortalHeight { + return nil, width, height, false + } + + // Validate each row: side frames intact and every interior block matches. + positions := make([]cube.Pos, 0, width*height) + for y := 0; y < height; y++ { + row := origin.Add(cube.Pos{0, y}) + if !isFrame(tx.Block(row.Side(negative))) || !isFrame(tx.Block(row.Add(widthOffset(axis, width)))) { + return nil, width, height, false + } + for x := 0; x < width; x++ { + p := row.Add(widthOffset(axis, x)) + if !matches(tx.Block(p), axis) { + return nil, width, height, false + } + positions = append(positions, p) + } + } + // Validate the top and bottom frames over each column. + for x := 0; x < width; x++ { + p := origin.Add(widthOffset(axis, x)) + if !isFrame(tx.Block(p.Side(cube.FaceDown))) || !isFrame(tx.Block(p.Add(cube.Pos{0, height}))) { + return nil, width, height, false + } + } + return positions, width, height, true +} + +// connectedNetherPortal flood-fills the region of portal blocks reachable from pos and returns its axis and positions. +// Used to clean up an entire portal when its frame breaks, where scan would only return a partial rectangle. +func connectedNetherPortal(tx *world.Tx, pos cube.Pos) (cube.Axis, []cube.Pos, bool) { + for _, axis := range []cube.Axis{cube.Z, cube.X} { + if !matchesNetherPortal(tx.Block(pos), axis) { + continue + } + positions := connectedPortalBlocks(tx, pos, axis) + return axis, positions, len(positions) > 0 + } + return 0, nil, false +} + +// connectedPortalBlocks returns every portal block of the given axis reachable from pos via face neighbours. +func connectedPortalBlocks(tx *world.Tx, pos cube.Pos, axis cube.Axis) []cube.Pos { + var positions []cube.Pos + queue := []cube.Pos{pos} + seen := map[cube.Pos]struct{}{pos: {}} + for len(queue) > 0 { + p := queue[0] + queue = queue[1:] + if !matchesNetherPortal(tx.Block(p), axis) { + continue + } + positions = append(positions, p) + for _, face := range portalFaces(axis) { + next := p.Side(face) + if _, ok := seen[next]; ok { + continue + } + seen[next] = struct{}{} + queue = append(queue, next) + } + } + return positions +} + +// portalFaces returns the four neighbouring faces used to flood-fill a portal of the given horizontal axis. +func portalFaces(axis cube.Axis) []cube.Face { + negative, positive := axis.Faces() + return []cube.Face{cube.FaceDown, cube.FaceUp, negative, positive} +} + +// widthOffset returns the position offset for moving by the given number of blocks along the portal's width axis. +func widthOffset(axis cube.Axis, offset int) cube.Pos { + if axis == cube.X { + return cube.Pos{offset, 0, 0} + } + return cube.Pos{0, 0, offset} +} + +// isFrame reports whether the block can act as a Nether portal frame block. +func isFrame(b world.Block) bool { + f, ok := b.(frameBlock) + return ok && f.Frame(world.Nether) +} + +// matchesNetherPortalInterior reports whether the block may sit inside an unactivated Nether portal frame. +func matchesNetherPortalInterior(b world.Block, _ cube.Axis) bool { + i, ok := b.(interface { + PortalInterior(target world.Dimension) bool + }) + return ok && i.PortalInterior(world.Nether) +} + +// matchesNetherPortal reports whether the block is an active Nether portal block aligned with the given axis. +func matchesNetherPortal(b world.Block, axis cube.Axis) bool { + p, ok := b.(portalBlock) + if !ok || p.Portal() != world.Nether { + return false + } + m, ok := b.Model().(model.Portal) + return ok && m.Axis == axis +} + +// air returns an air block. +func air() world.Block { + a, ok := world.BlockByName("minecraft:air", nil) + if !ok { + panic("could not find air block") + } + return a +} + +// portal returns a portal block. +func portal(axis cube.Axis) world.Block { + p, ok := world.BlockByName("minecraft:portal", map[string]any{"portal_axis": axis.String()}) + if !ok { + panic("could not find portal block") + } + return p +} + +// obsidian returns an obsidian block. +func obsidian() world.Block { + o, ok := world.BlockByName("minecraft:obsidian", nil) + if !ok { + panic("could not find obsidian block") + } + return o +} diff --git a/server/world/tx.go b/server/world/tx.go index 61655893a6..d823d66960 100644 --- a/server/world/tx.go +++ b/server/world/tx.go @@ -199,6 +199,13 @@ func (tx *Tx) AddEntity(e *EntityHandle) Entity { return tx.World().addEntity(tx, e) } +// AddEntityAt adds an EntityHandle to a World at the position passed. The Entity will be visible to all viewers of +// the World that have the chunk at the position passed. AddEntityAt panics if the EntityHandle is already in a world. +// AddEntityAt returns the Entity created by the EntityHandle. +func (tx *Tx) AddEntityAt(e *EntityHandle, pos mgl64.Vec3) Entity { + return tx.World().addEntityAt(tx, e, pos) +} + // RemoveEntity removes an Entity from the World that is currently present in // it. Any viewers of the Entity will no longer be able to see it. // RemoveEntity returns the EntityHandle of the Entity. After removing an Entity diff --git a/server/world/vanilla_items.nbt b/server/world/vanilla_items.nbt index c5d6a1b2b0..52af4d9e1d 100644 Binary files a/server/world/vanilla_items.nbt and b/server/world/vanilla_items.nbt differ diff --git a/server/world/world.go b/server/world/world.go index 82290e434d..b620b6156f 100644 --- a/server/world/world.go +++ b/server/world/world.go @@ -694,11 +694,16 @@ func (w *World) playSound(tx *Tx, pos mgl64.Vec3, s Sound) { // loaded. addEntity panics if the EntityHandle is already in a world. // addEntity returns the Entity created by the EntityHandle. func (w *World) addEntity(tx *Tx, handle *EntityHandle) Entity { - handle.setAndUnlockWorld(w) - pos := chunkPosFromVec3(handle.data.Pos) - w.entities[handle] = pos + return w.addEntityAt(tx, handle, handle.data.Pos) +} - c := w.chunk(pos) +// addEntityAt adds an EntityHandle to a World at the position passed. +func (w *World) addEntityAt(tx *Tx, handle *EntityHandle, pos mgl64.Vec3) Entity { + handle.setAndUnlockWorldAt(w, pos) + chunkPos := chunkPosFromVec3(handle.data.Pos) + w.entities[handle] = chunkPos + + c := w.chunk(chunkPos) c.Entities, c.modified = append(c.Entities, handle), true e := handle.mustEntity(tx)