diff --git a/server/block/campfire.go b/server/block/campfire.go new file mode 100644 index 000000000..93e37c7ca --- /dev/null +++ b/server/block/campfire.go @@ -0,0 +1,283 @@ +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/internal/nbtconv" + "github.com/df-mc/dragonfly/server/item" + "github.com/df-mc/dragonfly/server/world" + "github.com/df-mc/dragonfly/server/world/sound" + "github.com/go-gl/mathgl/mgl64" + "math/rand" + "strconv" + "time" +) + +// Campfire is a block that can be used to cook food, pacify bees, act as a spread-proof light source, smoke signal or +// damaging trap block. +type Campfire struct { + transparent + bass + sourceWaterDisplacer + + // Items represents the items in the campfire that are being cooked. + Items [4]CampfireItem + // Facing represents the direction that the campfire is facing. + Facing cube.Direction + // Extinguished is true if the campfire was extinguished by a water source. + Extinguished bool + // Type represents the type of Campfire, currently there are Normal and Soul campfires. + Type FireType +} + +// CampfireItem holds data about the items in the campfire. +type CampfireItem struct { + // Item is a specific item being cooked on top of the campfire. + Item item.Stack + // Time is the countdown of ticks until the food item is cooked (when 0). + Time time.Duration +} + +// Model ... +func (Campfire) Model() world.BlockModel { + return model.Campfire{} +} + +// SideClosed ... +func (Campfire) SideClosed(cube.Pos, cube.Pos, *world.World) bool { + return false +} + +// BreakInfo ... +func (c Campfire) BreakInfo() BreakInfo { + return newBreakInfo(2, alwaysHarvestable, axeEffective, func(t item.Tool, enchantments []item.Enchantment) []item.Stack { + var drops []item.Stack + if hasSilkTouch(enchantments) { + drops = append(drops, item.NewStack(c, 1)) + } else { + switch c.Type { + case NormalFire(): + drops = append(drops, item.NewStack(item.Charcoal{}, 2)) + case SoulFire(): + drops = append(drops, item.NewStack(SoulSoil{}, 1)) + } + } + for _, v := range c.Items { + if !v.Item.Empty() { + drops = append(drops, v.Item) + } + } + return drops + }) +} + +// LightEmissionLevel ... +func (c Campfire) LightEmissionLevel() uint8 { + if c.Extinguished { + return 0 + } + return c.Type.LightLevel() +} + +// Ignite ... +func (c Campfire) Ignite(pos cube.Pos, w *world.World, _ world.Entity) bool { + w.PlaySound(pos.Vec3(), sound.Ignite{}) + if !c.Extinguished { + return false + } + if _, ok := w.Liquid(pos); ok { + return false + } + + c.Extinguished = false + w.SetBlock(pos, c, nil) + return true +} + +// Splash ... +func (c Campfire) Splash(w *world.World, pos cube.Pos) { + if c.Extinguished { + return + } + + c.extinguish(pos, w) +} + +// extinguish extinguishes the campfire. +func (c Campfire) extinguish(pos cube.Pos, w *world.World) { + w.PlaySound(pos.Vec3Centre(), sound.FireExtinguish{}) + c.Extinguished = true + + for i := range c.Items { + c.Items[i].Time = time.Second * 30 + } + + w.SetBlock(pos, c, nil) +} + +// Activate ... +func (c Campfire) Activate(pos cube.Pos, _ cube.Face, w *world.World, u item.User, ctx *item.UseContext) bool { + held, _ := u.HeldItems() + if held.Empty() { + return false + } + + if _, ok := held.Item().(item.Shovel); ok && !c.Extinguished { + c.extinguish(pos, w) + ctx.DamageItem(1) + return true + } + + rawFood, ok := held.Item().(item.Smeltable) + if !ok || !rawFood.SmeltInfo().Food { + return false + } + + for i, it := range c.Items { + if it.Item.Empty() { + c.Items[i] = CampfireItem{ + Item: held.Grow(-held.Count() + 1), + Time: time.Second * 30, + } + + ctx.SubtractFromCount(1) + + w.PlaySound(pos.Vec3Centre(), sound.ItemAdd{}) + w.SetBlock(pos, c, nil) + return true + } + } + return false +} + +// UseOnBlock ... +func (c Campfire) UseOnBlock(pos cube.Pos, face cube.Face, _ mgl64.Vec3, w *world.World, user item.User, ctx *item.UseContext) (used bool) { + pos, _, used = firstReplaceable(w, pos, face, c) + if !used { + return + } + if _, ok := w.Block(pos.Side(cube.FaceDown)).(Campfire); ok { + return false + } + c.Facing = user.Rotation().Direction().Opposite() + place(w, pos, c, user, ctx) + return placed(ctx) +} + +// Tick is called to cook the items within the campfire. +func (c Campfire) Tick(_ int64, pos cube.Pos, w *world.World) { + if c.Extinguished { + // Extinguished, do nothing. + return + } + if rand.Float64() <= 0.016 { // Every three or so seconds. + w.PlaySound(pos.Vec3Centre(), sound.CampfireCrackle{}) + } + + updated := false + for i, it := range c.Items { + if it.Item.Empty() { + continue + } + + updated = true + if it.Time > 0 { + c.Items[i].Time = it.Time - time.Millisecond*50 + continue + } + + if food, ok := it.Item.Item().(item.Smeltable); ok { + dropItem(w, food.SmeltInfo().Product, pos.Vec3Middle()) + } + c.Items[i].Item = item.Stack{} + } + if updated { + w.SetBlock(pos, c, nil) + } +} + +// NeighbourUpdateTick ... +func (c Campfire) NeighbourUpdateTick(pos, _ cube.Pos, w *world.World) { + _, ok := w.Liquid(pos) + liquid, okTwo := w.Liquid(pos.Side(cube.FaceUp)) + if (ok || (okTwo && liquid.LiquidType() == "water")) && !c.Extinguished { + c.extinguish(pos, w) + } +} + +// EntityInside ... +func (c Campfire) EntityInside(pos cube.Pos, w *world.World, e world.Entity) { + if flammable, ok := e.(flammableEntity); ok { + if flammable.OnFireDuration() > 0 && c.Extinguished { + c.Extinguished = false + w.PlaySound(pos.Vec3(), sound.Ignite{}) + w.SetBlock(pos, c, nil) + } + if !c.Extinguished { + if l, ok := e.(livingEntity); ok && !l.AttackImmune() { + l.Hurt(c.Type.Damage(), FireDamageSource{}) + } + } + } +} + +// EncodeNBT ... +func (c Campfire) EncodeNBT() map[string]any { + m := map[string]any{"id": "Campfire"} + for i, v := range c.Items { + id := strconv.Itoa(i + 1) + if !v.Item.Empty() { + m["Item"+id] = nbtconv.WriteItem(v.Item, true) + m["ItemTime"+id] = uint8(v.Time.Milliseconds() / 50) + } + } + return m +} + +// DecodeNBT ... +func (c Campfire) DecodeNBT(data map[string]any) any { + for i := 0; i < 4; i++ { + id := strconv.Itoa(i + 1) + c.Items[i] = CampfireItem{ + Item: nbtconv.MapItem(data, "Item"+id), + Time: time.Duration(nbtconv.Int16(data, "ItemTime"+id)) * time.Millisecond * 50, + } + } + return c +} + +// EncodeItem ... +func (c Campfire) EncodeItem() (name string, meta int16) { + switch c.Type { + case NormalFire(): + return "minecraft:campfire", 0 + case SoulFire(): + return "minecraft:soul_campfire", 0 + } + panic("invalid fire type") +} + +// EncodeBlock ... +func (c Campfire) EncodeBlock() (name string, properties map[string]any) { + switch c.Type { + case NormalFire(): + name = "minecraft:campfire" + case SoulFire(): + name = "minecraft:soul_campfire" + } + return name, map[string]any{ + "minecraft:cardinal_direction": c.Facing.String(), + "extinguished": c.Extinguished, + } +} + +// allCampfires ... +func allCampfires() (campfires []world.Block) { + for _, d := range cube.Directions() { + for _, f := range FireTypes() { + campfires = append(campfires, Campfire{Facing: d, Type: f, Extinguished: true}) + campfires = append(campfires, Campfire{Facing: d, Type: f}) + } + } + return campfires +} diff --git a/server/block/hash.go b/server/block/hash.go index 00b8a5e8a..983e243ff 100644 --- a/server/block/hash.go +++ b/server/block/hash.go @@ -24,6 +24,7 @@ const ( hashCactus hashCake hashCalcite + hashCampfire hashCarpet hashCarrot hashChain @@ -289,6 +290,11 @@ func (Calcite) Hash() uint64 { return hashCalcite } +// Hash ... +func (c Campfire) Hash() uint64 { + return hashCampfire | uint64(c.Facing)<<8 | uint64(boolByte(c.Extinguished))<<10 | uint64(c.Type.Uint8())<<11 +} + // Hash ... func (c Carpet) Hash() uint64 { return hashCarpet | uint64(c.Colour.Uint8())<<8 diff --git a/server/block/item_frame.go b/server/block/item_frame.go index 4cbf49b35..aef2a71d5 100644 --- a/server/block/item_frame.go +++ b/server/block/item_frame.go @@ -41,7 +41,7 @@ func (i ItemFrame) Activate(pos cube.Pos, _ cube.Face, w *world.World, u item.Us i.Item = held.Grow(-held.Count() + 1) // TODO: When maps are implemented, check the item is a map, and if so, display the large version of the frame. ctx.SubtractFromCount(1) - w.PlaySound(pos.Vec3Centre(), sound.ItemFrameAdd{}) + w.PlaySound(pos.Vec3Centre(), sound.ItemAdd{}) } else { return true } diff --git a/server/block/model/campfire.go b/server/block/model/campfire.go new file mode 100644 index 000000000..8c4f816e3 --- /dev/null +++ b/server/block/model/campfire.go @@ -0,0 +1,19 @@ +package model + +import ( + "github.com/df-mc/dragonfly/server/block/cube" + "github.com/df-mc/dragonfly/server/world" +) + +// Campfire is the model used by campfires. +type Campfire struct{} + +// BBox returns a flat BBox with a height of 0.4375. +func (Campfire) BBox(cube.Pos, *world.World) []cube.BBox { + return []cube.BBox{cube.Box(0, 0, 0, 1, 0.4375, 1)} +} + +// FaceSolid returns true if the face is down. +func (Campfire) FaceSolid(_ cube.Pos, face cube.Face, _ *world.World) bool { + return face == cube.FaceDown +} diff --git a/server/block/register.go b/server/block/register.go index 5a1db6c0a..2ce11330e 100644 --- a/server/block/register.go +++ b/server/block/register.go @@ -124,6 +124,7 @@ func init() { registerAll(allBoneBlock()) registerAll(allCactus()) registerAll(allCake()) + registerAll(allCampfires()) registerAll(allCarpet()) registerAll(allCarrots()) registerAll(allChains()) @@ -394,6 +395,7 @@ func init() { for _, f := range FireTypes() { world.RegisterItem(Lantern{Type: f}) world.RegisterItem(Torch{Type: f}) + world.RegisterItem(Campfire{Type: f}) } for _, f := range FlowerTypes() { world.RegisterItem(Flower{Type: f}) diff --git a/server/entity/splashable.go b/server/entity/splashable.go index a843ee1a2..9e5ae8840 100644 --- a/server/entity/splashable.go +++ b/server/entity/splashable.go @@ -10,6 +10,20 @@ import ( "time" ) +// SplashableBlock is a block that can be splashed with a splash bottle. +type SplashableBlock interface { + world.Block + // Splash is called when a water bottle splashes onto a block. + Splash(w *world.World, pos cube.Pos) +} + +// SplashableEntity is an entity that can be splashed with a splash bottle. +type SplashableEntity interface { + world.Entity + // Splash is called when a water bottle splashes onto an entity. + Splash(w *world.World, pos mgl64.Vec3) +} + // potionSplash returns a function that creates a potion splash with a specific // duration multiplier and potion type. func potionSplash(durMul float64, pot potion.Potion, linger bool) func(e *Ent, res trace.Result) { @@ -66,10 +80,25 @@ func potionSplash(durMul float64, pot potion.Potion, linger bool) func(e *Ent, r if h := blockPos.Side(f); w.Block(h) == fire() { w.SetBlock(h, nil, nil) } + + if b, ok := w.Block(blockPos.Side(f)).(SplashableBlock); ok { + b.Splash(w, blockPos.Side(f)) + } + } + + resultPos := result.BlockPosition() + if b, ok := w.Block(resultPos).(SplashableBlock); ok { + b.Splash(w, resultPos) } case trace.EntityResult: // TODO: Damage endermen, blazes, striders and snow golems when implemented and rehydrate axolotls. } + + for _, otherE := range w.EntitiesWithin(box.GrowVec3(mgl64.Vec3{8.25, 4.25, 8.25}), ignores) { + if splashE, ok := otherE.(SplashableEntity); ok { + splashE.Splash(w, otherE.Position()) + } + } } if linger { w.AddEntity(NewAreaEffectCloud(pos, pot)) diff --git a/server/item/recipe/furnace_data.nbt b/server/item/recipe/furnace_data.nbt new file mode 100644 index 000000000..01703c41f Binary files /dev/null and b/server/item/recipe/furnace_data.nbt differ diff --git a/server/item/recipe/item.go b/server/item/recipe/item.go index 623d76dd0..acc974af8 100644 --- a/server/item/recipe/item.go +++ b/server/item/recipe/item.go @@ -16,8 +16,8 @@ type Item interface { Empty() bool } -// inputItems is a type representing a list of input items, with helper functions to convert them to -type inputItems []struct { +// inputItem is a type representing an input item, with a helper function to convert it to an Item. +type inputItem struct { // Name is the name of the item being inputted. Name string `nbt:"name"` // Meta is the meta of the item. This can change the item almost completely, or act as durability. @@ -34,34 +34,47 @@ type inputItems []struct { Tag string `nbt:"tag"` } +// Item converts an input item to a recipe item. +func (i inputItem) Item() (Item, bool) { + if i.Tag != "" { + return NewItemTag(i.Tag, int(i.Count)), true + } + + it, ok := world.ItemByName(i.Name, int16(i.Meta)) + if !ok { + return nil, false + } + if b, ok := world.BlockByName(i.State.Name, i.State.Properties); ok { + if it, ok = b.(world.Item); !ok { + return nil, false + } + } + st := item.NewStack(it, int(i.Count)) + if i.Meta == math.MaxInt16 { + st = st.WithValue("variants", true) + } + + return st, true +} + +// inputItems is a type representing a list of input items, with a helper function to convert it to an Item. +type inputItems []inputItem + // Items converts input items to recipe items. func (d inputItems) Items() ([]Item, bool) { s := make([]Item, 0, len(d)) for _, i := range d { - if i.Tag != "" { - s = append(s, NewItemTag(i.Tag, int(i.Count))) - } else { - it, ok := world.ItemByName(i.Name, int16(i.Meta)) - if !ok { - return nil, false - } - if b, ok := world.BlockByName(i.State.Name, i.State.Properties); ok { - if it, ok = b.(world.Item); !ok { - return nil, false - } - } - st := item.NewStack(it, int(i.Count)) - if i.Meta == math.MaxInt16 { - st = st.WithValue("variants", true) - } - s = append(s, st) + itemInput, ok := i.Item() + if !ok { + return nil, false } + s = append(s, itemInput) } return s, true } -// outputItems is an array of output items. -type outputItems []struct { +// outputItem is an output item. +type outputItem struct { // Name is the name of the item being output. Name string `nbt:"name"` // Meta is the meta of the item. This can change the item almost completely, or act as durability. @@ -78,23 +91,36 @@ type outputItems []struct { NBTData map[string]interface{} `nbt:"data"` } +// Stack converts an output item to an item stack. +func (o outputItem) Stack() (item.Stack, bool) { + it, ok := world.ItemByName(o.Name, int16(o.Meta)) + if !ok { + return item.Stack{}, false + } + if b, ok := world.BlockByName(o.State.Name, o.State.Properties); ok { + if it, ok = b.(world.Item); !ok { + return item.Stack{}, false + } + } + if n, ok := it.(world.NBTer); ok { + it = n.DecodeNBT(o.NBTData).(world.Item) + } + + return item.NewStack(it, int(o.Count)), true +} + +// outputItems is an array of output items. +type outputItems []outputItem + // Stacks converts output items to item stacks. func (d outputItems) Stacks() ([]item.Stack, bool) { s := make([]item.Stack, 0, len(d)) for _, o := range d { - it, ok := world.ItemByName(o.Name, int16(o.Meta)) + itemOutput, ok := o.Stack() if !ok { return nil, false } - if b, ok := world.BlockByName(o.State.Name, o.State.Properties); ok { - if it, ok = b.(world.Item); !ok { - return nil, false - } - } - if n, ok := it.(world.NBTer); ok { - it = n.DecodeNBT(o.NBTData).(world.Item) - } - s = append(s, item.NewStack(it, int(o.Count))) + s = append(s, itemOutput) } return s, true } diff --git a/server/item/recipe/recipe.go b/server/item/recipe/recipe.go index 5f4ff42f4..7dce90a04 100644 --- a/server/item/recipe/recipe.go +++ b/server/item/recipe/recipe.go @@ -59,6 +59,20 @@ func NewSmithingTrim(base, addition, template Item, block string) SmithingTrim { }} } +// Furnace represents a recipe only craftable in a furnace. +type Furnace struct { + recipe +} + +// NewFurnace creates a new furnace recipe and returns it. +func NewFurnace(input Item, output item.Stack, block string) Furnace { + return Furnace{recipe: recipe{ + input: []Item{input}, + output: []item.Stack{output}, + block: block, + }} +} + // Shaped is a recipe that has a specific shape that must be used to craft the output of the recipe. type Shaped struct { recipe diff --git a/server/item/recipe/vanilla.go b/server/item/recipe/vanilla.go index e4c17b634..aa1f079bb 100644 --- a/server/item/recipe/vanilla.go +++ b/server/item/recipe/vanilla.go @@ -2,9 +2,10 @@ package recipe import ( _ "embed" + // Ensure all blocks and items are registered before trying to load vanilla recipes. _ "github.com/df-mc/dragonfly/server/block" - _ "github.com/df-mc/dragonfly/server/item" + "github.com/df-mc/dragonfly/server/item" "github.com/sandertv/gophertunnel/minecraft/nbt" ) @@ -15,6 +16,8 @@ var ( vanillaSmithingData []byte //go:embed smithing_trim_data.nbt vanillaSmithingTrimData []byte + //go:embed furnace_data.nbt + furnaceData []byte ) // shapedRecipe is a recipe that must be crafted in a specific shape. @@ -35,6 +38,13 @@ type shapelessRecipe struct { Priority int32 `nbt:"priority"` } +// furnaceRecipe is a recipe that may be crafted in a furnace. +type furnaceRecipe struct { + Input inputItem `nbt:"input"` + Output outputItem `nbt:"output"` + Block string `nbt:"block"` +} + func init() { var craftingRecipes struct { Shaped []shapedRecipe `nbt:"shaped"` @@ -114,4 +124,24 @@ func init() { priority: uint32(s.Priority), }}) } + + var furnaceRecipes []furnaceRecipe + if err := nbt.Unmarshal(furnaceData, &furnaceRecipes); err != nil { + panic(err) + } + + for _, s := range furnaceRecipes { + input, ok := s.Input.Item() + output, okTwo := s.Output.Stack() + if !ok || !okTwo { + // This can be expected to happen - refer to the comment above. + continue + } + + Register(Furnace{recipe{ + input: []Item{input}, + output: []item.Stack{output}, + block: s.Block, + }}) + } } diff --git a/server/session/player.go b/server/session/player.go index c1e98b1d2..c2b953240 100644 --- a/server/session/player.go +++ b/server/session/player.go @@ -152,6 +152,12 @@ func (s *Session) sendRecipes() { Block: i.Block(), RecipeNetworkID: networkID, }) + case recipe.Furnace: + recipes = append(recipes, &protocol.FurnaceRecipe{ + InputType: stackFromItem(i.Input()[0].(item.Stack)).ItemType, + Output: stackFromItem(i.Output()[0]), + Block: i.Block(), + }) } } s.writePacket(&packet.CraftingData{Recipes: recipes, ClearRecipes: true}) diff --git a/server/session/world.go b/server/session/world.go index 1018c45b7..fc2f287db 100644 --- a/server/session/world.go +++ b/server/session/world.go @@ -543,7 +543,7 @@ func (s *Session) playSound(pos mgl64.Vec3, t world.Sound, disableRelative bool) return case sound.Teleport: pk.SoundType = packet.SoundEventTeleport - case sound.ItemFrameAdd: + case sound.ItemAdd: s.writePacket(&packet.LevelEvent{ EventType: packet.LevelEventSoundAddItem, Position: vec64To32(pos), @@ -589,6 +589,8 @@ func (s *Session) playSound(pos mgl64.Vec3, t world.Sound, disableRelative bool) pk.SoundType = packet.SoundEventTwinkle case sound.FurnaceCrackle: pk.SoundType = packet.SoundEventFurnaceUse + case sound.CampfireCrackle: + pk.SoundType = packet.SoundEventCampfireCrackle case sound.BlastFurnaceCrackle: pk.SoundType = packet.SoundEventBlastFurnaceUse case sound.SmokerCrackle: diff --git a/server/world/sound/block.go b/server/world/sound/block.go index 4fcc55544..1518bd2ac 100644 --- a/server/world/sound/block.go +++ b/server/world/sound/block.go @@ -149,8 +149,8 @@ type MusicDiscPlay struct { // MusicDiscEnd is a sound played when a music disc has stopped playing in a jukebox. type MusicDiscEnd struct{ sound } -// ItemFrameAdd is a sound played when an item is added to an item frame. -type ItemFrameAdd struct{ sound } +// ItemAdd is a sound played when an item is added to an item frame or campfire. +type ItemAdd struct{ sound } // ItemFrameRemove is a sound played when an item is removed from an item frame. type ItemFrameRemove struct{ sound } @@ -161,6 +161,9 @@ type ItemFrameRotate struct{ sound } // FurnaceCrackle is a sound played every one to five seconds from a furnace. type FurnaceCrackle struct{ sound } +// CampfireCrackle is a sound played every one to five seconds from a campfire. +type CampfireCrackle struct{ sound } + // BlastFurnaceCrackle is a sound played every one to five seconds from a blast furnace. type BlastFurnaceCrackle struct{ sound }